vexy-stax-js 3.0.0

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/src/stage.js ADDED
@@ -0,0 +1,349 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: src/stage.js
3
+ //
4
+ // three.js stage: builds plates (PlaneGeometry + texture, bottom-aligned,
5
+ // centered, MeshBasicMaterial), a floor plane, and a perspective camera.
6
+ // Reduced to the two views (expanded/compact) per SPEC.md §3, consuming
7
+ // geometry.js for camera/spacing/opacity. Adapted from
8
+ // vexy-stax-old/vexy-stax-js (SceneComposition + FloorManager + camera),
9
+ // stripped of the editor UI.
10
+
11
+ import * as THREE from "three";
12
+
13
+ import {
14
+ compactCamera,
15
+ expandedCamera,
16
+ plateGaps,
17
+ stackDepth,
18
+ MIN_GAP,
19
+ } from "./geometry.js";
20
+ import { resolvedOpacity } from "./scene.js";
21
+
22
+ /** Caption fade factor for a slide+view, given the morph factor t (0=compact,1=expanded). */
23
+ function captionFade(caption, t) {
24
+ if (!caption) return 0;
25
+ switch (caption.show_in) {
26
+ case "both":
27
+ return 1;
28
+ case "none":
29
+ return 0;
30
+ case "compact":
31
+ return 1 - t; // visible in compact, fades out as deck expands
32
+ case "expanded":
33
+ default:
34
+ return t; // visible in expanded, fades in as deck expands
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Build a canvas-texture sprite for caption text. Rendered upright (a Sprite
40
+ * always faces the camera) and placed beneath its plate. Text is drawn left→right
41
+ * so it reads correctly (never mirrored).
42
+ */
43
+ function makeCaptionSprite(text, style) {
44
+ const size = style?.size ?? 28;
45
+ const color = style?.color ?? "#222222";
46
+ const font = style?.font ?? "sans-serif";
47
+ const dpr = 2; // crisper text
48
+ const pxFont = `${size * dpr}px ${font}`;
49
+ const pad = size * dpr * 0.4;
50
+
51
+ const measureCanvas = document.createElement("canvas");
52
+ const mctx = measureCanvas.getContext("2d");
53
+ mctx.font = pxFont;
54
+ const textW = Math.ceil(mctx.measureText(text).width);
55
+ const canvasW = Math.max(1, textW + pad * 2);
56
+ const canvasH = Math.ceil(size * dpr * 1.6);
57
+
58
+ const canvas = document.createElement("canvas");
59
+ canvas.width = canvasW;
60
+ canvas.height = canvasH;
61
+ const ctx = canvas.getContext("2d");
62
+ ctx.font = pxFont;
63
+ ctx.textAlign = "center";
64
+ ctx.textBaseline = "middle";
65
+ ctx.fillStyle = color;
66
+ ctx.fillText(text, canvasW / 2, canvasH / 2);
67
+
68
+ const texture = new THREE.CanvasTexture(canvas);
69
+ texture.colorSpace = THREE.SRGBColorSpace;
70
+ texture.needsUpdate = true;
71
+ const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
72
+ const sprite = new THREE.Sprite(material);
73
+ // World size in scene points: scale to the caption pixel size (1px ≈ 1pt) so it
74
+ // is legible relative to the plates. Preserve the canvas aspect ratio.
75
+ const worldH = size * 2.2;
76
+ const worldW = worldH * (canvasW / canvasH);
77
+ sprite.scale.set(worldW, worldH, 1);
78
+ return { sprite, material };
79
+ }
80
+
81
+ /** Load a texture from a URL/data-URI, resolving once decoded. */
82
+ function loadTexture(loader, src) {
83
+ return new Promise((resolve, reject) => {
84
+ loader.load(
85
+ src,
86
+ (texture) => {
87
+ texture.colorSpace = THREE.SRGBColorSpace;
88
+ resolve(texture);
89
+ },
90
+ undefined,
91
+ (err) => reject(new Error(`Failed to load texture: ${src} (${err?.message ?? err})`))
92
+ );
93
+ });
94
+ }
95
+
96
+ export class Stage {
97
+ /**
98
+ * @param {HTMLElement} container
99
+ * @param {object} scene normalized scene (from loadScene)
100
+ */
101
+ constructor(container, scene) {
102
+ this.container = container;
103
+ this.scene = scene;
104
+ this.plates = []; // { mesh, width, height, slide, caption? }
105
+ this.captions = []; // { sprite, material, plateIndex }
106
+ this.view = scene.view;
107
+ this._disposed = false;
108
+ }
109
+
110
+ /** Build renderer, scene graph, camera, floor, and load all plate textures. */
111
+ async init() {
112
+ const width = this.container.clientWidth || this.scene.size.width;
113
+ const height = this.container.clientHeight || this.scene.size.height;
114
+
115
+ this.threeScene = new THREE.Scene();
116
+ this.threeScene.background = new THREE.Color(this.scene.background);
117
+
118
+ this.camera = new THREE.PerspectiveCamera(
119
+ this.scene.camera.fov,
120
+ width / height,
121
+ 1,
122
+ 1_000_000
123
+ );
124
+
125
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
126
+ this.renderer.setPixelRatio(typeof window !== "undefined" ? window.devicePixelRatio : 1);
127
+ this.renderer.setSize(width, height, false);
128
+ this.container.appendChild(this.renderer.domElement);
129
+
130
+ await this._buildPlates();
131
+ this._buildFloor();
132
+ this._buildCaptions();
133
+ this.setView(this.view);
134
+
135
+ return this;
136
+ }
137
+
138
+ async _buildPlates() {
139
+ const loader = new THREE.TextureLoader();
140
+ const textures = await Promise.all(this.scene.slides.map((s) => loadTexture(loader, s.src)));
141
+ const reflectivity = this.scene.floor.reflectivity;
142
+
143
+ textures.forEach((texture, i) => {
144
+ const w = texture.image.width;
145
+ const h = texture.image.height;
146
+ const geometry = new THREE.PlaneGeometry(w, h);
147
+ // DoubleSide so the front face reads un-mirrored from +Z and the plate stays
148
+ // visible when the expanded camera orbits behind it.
149
+ const material = new THREE.MeshBasicMaterial({
150
+ map: texture,
151
+ side: THREE.DoubleSide,
152
+ transparent: true,
153
+ opacity: 1,
154
+ });
155
+ const mesh = new THREE.Mesh(geometry, material);
156
+ this.threeScene.add(mesh);
157
+
158
+ // Mirror copy below the floor line for the floor reflection (matches the
159
+ // pygfx engine): scaled (1,-1,1), opacity = plate opacity * reflectivity,
160
+ // rendered behind the floor (renderOrder -1).
161
+ let reflection = null;
162
+ if (reflectivity > 0) {
163
+ const reflMat = new THREE.MeshBasicMaterial({
164
+ map: texture,
165
+ side: THREE.DoubleSide,
166
+ transparent: true,
167
+ opacity: reflectivity,
168
+ });
169
+ reflection = new THREE.Mesh(geometry.clone(), reflMat);
170
+ reflection.scale.y = -1;
171
+ reflection.renderOrder = -1;
172
+ this.threeScene.add(reflection);
173
+ }
174
+
175
+ this.plates.push({ mesh, reflection, width: w, height: h, slide: this.scene.slides[i] });
176
+ });
177
+ }
178
+
179
+ /** Build a caption sprite under each plate that declares one (best-effort). */
180
+ _buildCaptions() {
181
+ if (typeof document === "undefined") return; // no canvas → skip captions
182
+ const defaults = this.scene.caption_defaults ?? null;
183
+ this.plates.forEach((plate, i) => {
184
+ const caption = plate.slide.caption;
185
+ if (!caption) return;
186
+ const style = { ...(defaults ?? {}), ...(caption.style ?? {}) };
187
+ const { sprite, material } = makeCaptionSprite(caption.text, style);
188
+ this.threeScene.add(sprite);
189
+ this.captions.push({ sprite, material, plateIndex: i, caption });
190
+ });
191
+ }
192
+
193
+ _tallestHeight() {
194
+ return this.plates.reduce((m, p) => Math.max(m, p.height), 0);
195
+ }
196
+
197
+ _buildFloor() {
198
+ // Match the pygfx engine's floor extents/placement so JS and Python frame the
199
+ // deck identically: width = widest*4, depth = stackDepth + widest*2, centered
200
+ // under the deck at (0, floorY, -depth/2).
201
+ const widest = this.plates.reduce((m, p) => Math.max(m, p.width), 0) || this.scene.size.width;
202
+ const depth = stackDepth(this.scene, "expanded");
203
+ const extentW = widest * 4.0;
204
+ const extentZ = depth > 0 ? depth + widest * 2.0 : widest * 2.0;
205
+ const geometry = new THREE.PlaneGeometry(extentW, extentZ);
206
+ const material = new THREE.MeshBasicMaterial({
207
+ color: new THREE.Color(this.scene.floor.color),
208
+ transparent: true,
209
+ opacity: this.scene.floor.opacity,
210
+ side: THREE.DoubleSide,
211
+ });
212
+ const floor = new THREE.Mesh(geometry, material);
213
+ // Lay flat on the XZ plane at the floor line (bottom of the tallest plate).
214
+ floor.rotation.x = -Math.PI / 2;
215
+ floor.position.set(0, -this._tallestHeight() / 2, -depth / 2.0);
216
+ this.threeScene.add(floor);
217
+ this.floor = floor;
218
+ }
219
+
220
+ /**
221
+ * Position plates (Z spacing + Y bottom-align), opacity, and camera for a view.
222
+ * @param {"expanded"|"compact"} view
223
+ */
224
+ setView(view) {
225
+ this.view = view;
226
+ const gaps = view === "compact" ? this.plates.map(() => MIN_GAP) : plateGaps(this.scene);
227
+ const opacities = this.plates.map((p) => resolvedOpacity(p.slide, view));
228
+ const pose = view === "compact" ? compactCamera(this.scene) : expandedCamera(this.scene);
229
+ const t = view === "compact" ? 0 : 1;
230
+ this._placePlates(gaps, opacities);
231
+ this._placeCaptions(t);
232
+ this._applyPose(pose);
233
+ }
234
+
235
+ /**
236
+ * Apply a geometry.js FrameState (camera pose + per-plate gaps + per-slide
237
+ * opacities) for the playable transition / scrollspy. `t` is the eased morph
238
+ * factor (0=compact,1=expanded) used for caption fade.
239
+ * @param {{camera:object, gaps:number[], opacities:number[]}} state
240
+ * @param {number} t
241
+ */
242
+ applyFrameState(state, t) {
243
+ this._placePlates(state.gaps, state.opacities);
244
+ this._placeCaptions(t);
245
+ this._applyPose(state.camera);
246
+ }
247
+
248
+ /** Lay out plates along Z (index 0 farthest), bottom-aligned, with opacities. */
249
+ _placePlates(gaps, opacities) {
250
+ const tallest = this._tallestHeight();
251
+ const bottomY = -tallest / 2;
252
+ const floorY = bottomY;
253
+ const reflectivity = this.scene.floor.reflectivity;
254
+
255
+ // Cumulative Z: index 0 farthest (most negative), last at 0.
256
+ // stack_depth = sum(gaps[1:]); place slide i at z = -(stack_depth - cumGapTo(i)).
257
+ let totalDepth = 0;
258
+ for (let i = 1; i < gaps.length; i++) totalDepth += gaps[i];
259
+
260
+ let cum = 0;
261
+ this.plates.forEach((plate, i) => {
262
+ if (i > 0) cum += gaps[i];
263
+ const z = -(totalDepth - cum);
264
+ const y = bottomY + plate.height / 2;
265
+ plate.mesh.position.set(0, y, z);
266
+ plate.mesh.material.opacity = opacities[i];
267
+ if (plate.reflection) {
268
+ // Mirror the plate (centered at y) across the floor line: a point at y
269
+ // maps to 2*floorY - y; the plate center is y, so the reflection center
270
+ // is 2*floorY - y, with scale.y=-1 flipping the image. Opacity fades with
271
+ // the plate (matches the pygfx engine).
272
+ plate.reflection.position.set(0, 2.0 * floorY - y, z);
273
+ plate.reflection.material.opacity = opacities[i] * reflectivity;
274
+ plate.reflection.visible = opacities[i] * reflectivity > 0.001;
275
+ }
276
+ });
277
+ }
278
+
279
+ /** Position each caption beneath its plate and fade it per the morph factor t. */
280
+ _placeCaptions(t) {
281
+ if (this.captions.length === 0) return;
282
+ const tallest = this._tallestHeight();
283
+ const floorY = -tallest / 2;
284
+ this.captions.forEach(({ sprite, material, plateIndex, caption }) => {
285
+ const plate = this.plates[plateIndex];
286
+ sprite.position.set(plate.mesh.position.x, floorY - sprite.scale.y, plate.mesh.position.z);
287
+ const fade = captionFade(caption, t);
288
+ material.opacity = fade;
289
+ sprite.visible = fade > 0.001;
290
+ });
291
+ }
292
+
293
+ /** Apply a geometry.js pose (position/target/fov/near) to the three camera. */
294
+ _applyPose(pose) {
295
+ this.camera.position.set(pose.position[0], pose.position[1], pose.position[2]);
296
+ this.camera.up.set(0, 1, 0);
297
+ this.camera.lookAt(pose.target[0], pose.target[1], pose.target[2]);
298
+ // three.js PerspectiveCamera.fov is the VERTICAL fov. The shared geometry
299
+ // treats scene.camera.fov as a reference fov consumed identically by the
300
+ // Python pygfx engine, whose projection sets the vertical half-height to
301
+ // tan(vfov/2) = 2*tan(fov/2) / (1 + sceneAspect)
302
+ // (it splits the reference size across width+height by the scene aspect).
303
+ // Convert here so the JS stage frames the deck identically to the Python
304
+ // engines (cross-engine parity, SPEC.md §8) without touching geometry.js.
305
+ const sceneAspect = this.scene.size.width / this.scene.size.height;
306
+ const halfFov = (pose.fov * Math.PI) / 360; // (fov/2) in radians
307
+ const vfov = 2 * Math.atan((2 * Math.tan(halfFov)) / (1 + sceneAspect));
308
+ this.camera.fov = (vfov * 180) / Math.PI;
309
+ this.camera.near = pose.near;
310
+ this.camera.updateProjectionMatrix();
311
+ }
312
+
313
+ render() {
314
+ if (this._disposed) return;
315
+ this.renderer.render(this.threeScene, this.camera);
316
+ }
317
+
318
+ resize(width, height) {
319
+ if (!width || !height) return;
320
+ this.camera.aspect = width / height;
321
+ this.camera.updateProjectionMatrix();
322
+ this.renderer.setSize(width, height, false);
323
+ }
324
+
325
+ dispose() {
326
+ this._disposed = true;
327
+ this.plates.forEach((p) => {
328
+ p.mesh.geometry?.dispose();
329
+ p.mesh.material?.map?.dispose();
330
+ p.mesh.material?.dispose();
331
+ if (p.reflection) {
332
+ p.reflection.geometry?.dispose();
333
+ p.reflection.material?.dispose();
334
+ }
335
+ });
336
+ this.captions.forEach((c) => {
337
+ c.material?.map?.dispose();
338
+ c.material?.dispose();
339
+ });
340
+ this.floor?.geometry?.dispose();
341
+ this.floor?.material?.dispose();
342
+ this.renderer?.dispose();
343
+ if (this.renderer?.domElement?.parentNode) {
344
+ this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
345
+ }
346
+ this.plates = [];
347
+ this.captions = [];
348
+ }
349
+ }
@@ -0,0 +1,169 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: src/transition.js
3
+ //
4
+ // Morph driver (SPEC.md §3, §6.1). Drives the camera pose + per-plate spacing +
5
+ // per-slide opacity (and caption fade) from compact↔expanded endpoints, using
6
+ // the shared easing from geometry.js so the playable animation matches the
7
+ // frame_plan math the engines render.
8
+ //
9
+ // The geometry is provided by frameStateAt() (pure, in geometry.js); this module
10
+ // only sequences the legs over wall-clock time with requestAnimationFrame and
11
+ // applies each FrameState through an `apply` callback. No three.js here, so the
12
+ // timeline logic is testable under node --test.
13
+
14
+ import { frameStateAt, ease } from "./geometry.js";
15
+
16
+ // Each transition is a sequence of legs expressed as morph endpoints
17
+ // (0=compact, 1=expanded). Mirrors _LEGS in geometry.py / framePlan.
18
+ const LEGS = {
19
+ expand: [[0.0, 1.0]],
20
+ collapse: [[1.0, 0.0]],
21
+ expand_collapse: [
22
+ [0.0, 1.0],
23
+ [1.0, 0.0],
24
+ ],
25
+ collapse_expand: [
26
+ [1.0, 0.0],
27
+ [0.0, 1.0],
28
+ ],
29
+ };
30
+
31
+ /** Endpoint morph factors for a transition kind: [start, end] of its first/last leg. */
32
+ export function transitionEndpoints(kind) {
33
+ const legs = LEGS[kind];
34
+ if (!legs) throw new Error(`Unknown transition kind: ${JSON.stringify(kind)}`);
35
+ return { startMorph: legs[0][0], endMorph: legs[legs.length - 1][1] };
36
+ }
37
+
38
+ /**
39
+ * Build a normalized timeline of segments for a transition, in [0,1] global
40
+ * progress. Each leg contributes a move segment (duration seconds) and a hold
41
+ * segment (wait seconds). Returns segments with their global-progress span and
42
+ * the morph endpoints they interpolate between (move) or hold at (hold).
43
+ *
44
+ * Pure + deterministic so it can be unit-tested without a clock.
45
+ * @param {string} kind transition kind
46
+ * @param {{duration:number, wait:number}} timing
47
+ */
48
+ export function buildTimeline(kind, { duration, wait }) {
49
+ const legs = LEGS[kind];
50
+ if (!legs) throw new Error(`Unknown transition kind: ${JSON.stringify(kind)}`);
51
+ const segments = [];
52
+ let total = 0;
53
+ for (const [start, end] of legs) {
54
+ if (duration > 0) segments.push({ type: "move", start, end, seconds: duration });
55
+ if (wait > 0) segments.push({ type: "hold", start: end, end, seconds: wait });
56
+ total += duration + wait;
57
+ }
58
+ // Guard: a zero-length timeline (duration=wait=0) still needs one instantaneous
59
+ // segment so we can settle at the final endpoint.
60
+ if (total <= 0) {
61
+ const last = legs[legs.length - 1];
62
+ segments.push({ type: "hold", start: last[1], end: last[1], seconds: 0 });
63
+ total = 0;
64
+ }
65
+ // Assign global-progress spans [from, to] across the whole timeline.
66
+ let acc = 0;
67
+ for (const seg of segments) {
68
+ const from = total > 0 ? acc / total : 0;
69
+ acc += seg.seconds;
70
+ const to = total > 0 ? acc / total : 1;
71
+ seg.from = from;
72
+ seg.to = to;
73
+ }
74
+ return { segments, totalSeconds: total };
75
+ }
76
+
77
+ /**
78
+ * Resolve a global progress p in [0,1] to a morph factor (already eased) using a
79
+ * timeline from buildTimeline(). The move segments ease their local progress; the
80
+ * hold segments stay at their endpoint morph. Pure — used by both the rAF player
81
+ * and the scrollspy mapper.
82
+ * @param {{segments:Array}} timeline
83
+ * @param {number} p global progress [0,1]
84
+ * @param {(t:number)=>number} easeFn easing applied within move segments
85
+ */
86
+ export function morphAtProgress(timeline, p, easeFn) {
87
+ const { segments } = timeline;
88
+ const clamped = Math.max(0, Math.min(1, p));
89
+ for (const seg of segments) {
90
+ if (clamped <= seg.to || seg === segments[segments.length - 1]) {
91
+ if (seg.type === "hold" || seg.to === seg.from) return seg.end;
92
+ const local = (clamped - seg.from) / (seg.to - seg.from);
93
+ const eased = easeFn(Math.max(0, Math.min(1, local)));
94
+ return seg.start + (seg.end - seg.start) * eased;
95
+ }
96
+ }
97
+ return segments[segments.length - 1].end;
98
+ }
99
+
100
+ /**
101
+ * Play a transition over wall-clock time, calling `apply(frameState)` each frame
102
+ * and `onProgress(p)` with global progress [0,1]. Resolves when complete.
103
+ *
104
+ * @param {object} scene parsed scene
105
+ * @param {(state:object)=>void} apply applies a FrameState to the stage
106
+ * @param {object} [opts]
107
+ * @param {string} [opts.kind] override scene.transition.kind
108
+ * @param {(p:number)=>void} [opts.onProgress]
109
+ * @param {(ms:number)=>void} [opts.render] called after each apply (renders a frame)
110
+ * @param {{now:()=>number, raf:(cb)=>any, caf:(h)=>void}} [opts.clock] injectable for tests
111
+ * @returns {{promise:Promise<void>, cancel:()=>void}}
112
+ */
113
+ export function playTransition(scene, apply, opts = {}) {
114
+ const tr = scene.transition;
115
+ const kind = opts.kind ?? tr?.kind;
116
+ if (!kind) throw new Error("playTransition: no transition kind (scene.transition is null and no kind given)");
117
+ const duration = tr?.duration ?? 3.0;
118
+ const wait = tr?.wait ?? 0.0;
119
+ const easing = tr?.easing ?? "easeInOutCubic";
120
+ const timeline = buildTimeline(kind, { duration, wait });
121
+
122
+ const now = opts.clock?.now ?? (() => performance.now());
123
+ const raf =
124
+ opts.clock?.raf ??
125
+ (typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : (cb) => setTimeout(() => cb(now()), 16));
126
+ const caf =
127
+ opts.clock?.caf ??
128
+ (typeof cancelAnimationFrame !== "undefined" ? cancelAnimationFrame : (h) => clearTimeout(h));
129
+
130
+ let handle = null;
131
+ let cancelled = false;
132
+ let resolveFn;
133
+ let rejectFn;
134
+ const promise = new Promise((resolve, reject) => {
135
+ resolveFn = resolve;
136
+ rejectFn = reject;
137
+ });
138
+
139
+ const totalMs = timeline.totalSeconds * 1000;
140
+ const start = now();
141
+
142
+ const step = () => {
143
+ if (cancelled) {
144
+ rejectFn(new Error("transition cancelled"));
145
+ return;
146
+ }
147
+ const elapsed = now() - start;
148
+ const p = totalMs > 0 ? Math.min(1, elapsed / totalMs) : 1;
149
+ const t = morphAtProgress(timeline, p, (x) => ease(easing, x));
150
+ apply(frameStateAt(scene, t));
151
+ opts.onProgress?.(p);
152
+ if (p >= 1) {
153
+ resolveFn();
154
+ return;
155
+ }
156
+ handle = raf(step);
157
+ };
158
+
159
+ // Kick off on the next frame so listeners can attach.
160
+ handle = raf(step);
161
+
162
+ return {
163
+ promise,
164
+ cancel() {
165
+ cancelled = true;
166
+ if (handle != null) caf(handle);
167
+ },
168
+ };
169
+ }