@es3d/es3d 1.0.2 → 1.1.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.
@@ -20,11 +20,16 @@
20
20
  // the page is idle, and read back instead of tessellated on the next visit.
21
21
  //
22
22
  // Reduced motion (prefers-reduced-motion) stops clock animations and the
23
- // automatic orbit; scroll-driven cameras jump between stops.
23
+ // automatic orbit; scroll-driven cameras jump between stops. stage.reducedMotion
24
+ // reads that preference on each access, and the clock checks it every frame.
25
+ //
26
+ // clock: 'always' advances time even when the document has no <animate>.
27
+ // background: true selects that clock and leaves the camera still, for a
28
+ // page that moves shapes itself with stage.setTransform(id, matrix).
24
29
 
25
30
  import { compile } from '../pipeline.js';
26
31
  import { formatDiagnostics } from '../format/parse.js';
27
- import { updateDynamic } from '../scene/flatten.js';
32
+ import { applyLocalMatrices, updateDynamic } from '../scene/flatten.js';
28
33
  import { pick } from '../scene/picking.js';
29
34
  import { createRenderer, PROGRESSIVE_BUDGET_MS } from './renderer.js';
30
35
  import { openGeometryStore, persistGeometry } from '../cache/geometry-store.js';
@@ -32,9 +37,36 @@ import { Camera } from './camera.js';
32
37
  import { OrbitControls } from './orbit-controls.js';
33
38
  import { ScrollTimeline } from './scroll-timeline.js';
34
39
 
35
- const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
40
+ const prefersReducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
36
41
  const idle = (fn) => (typeof requestIdleCallback === 'function' ? requestIdleCallback(fn, { timeout: 500 }) : setTimeout(fn, 16));
37
42
 
43
+ /**
44
+ * Clock and orbit flags for createStage. background: true runs the clock
45
+ * and does not orbit, unless clock or autoOrbit is set explicitly.
46
+ * @param {object} [opts]
47
+ * @param {boolean} [reduced]
48
+ */
49
+ export function stageOptions(opts = {}, reduced = false) {
50
+ const background = opts.background === true;
51
+ const always = opts.clock === 'always' || (opts.clock == null && background);
52
+ const autoOrbit = opts.autoOrbit !== undefined ? opts.autoOrbit !== false : !background;
53
+ return {
54
+ clock: always ? 'always' : 'document',
55
+ playing: opts.playing !== false && !reduced,
56
+ auto: autoOrbit && !reduced,
57
+ };
58
+ }
59
+
60
+ /** True when this frame should add dt to the stage clock. */
61
+ export function clockAdvances(state, scene, reduced) {
62
+ return !!(state.playing && !reduced && (state.clock === 'always' || (scene.animation && scene.animation.time)));
63
+ }
64
+
65
+ /** True when this frame should turn the automatic orbit. */
66
+ export function orbitAdvances(state, reduced) {
67
+ return !!(state.auto && !reduced);
68
+ }
69
+
38
70
  /** Names (name or id) on an element and its ancestors, nearest first. */
39
71
  export function ancestorNames(doc, uid) {
40
72
  const names = [];
@@ -57,7 +89,12 @@ const RESTORE_WAIT_MS = 50;
57
89
  * @param {HTMLCanvasElement} canvas sized by its parent element
58
90
  * @param {object} [opts]
59
91
  * @param {boolean} [opts.playing=true] play clock animations
60
- * @param {boolean} [opts.autoOrbit=true] turn slowly around the target until someone drags
92
+ * @param {boolean} [opts.autoOrbit=true] turn slowly around the target until someone drags.
93
+ * background: true starts this off
94
+ * @param {'always'|'document'} [opts.clock] 'always' advances time with no <animate>.
95
+ * background: true selects 'always'
96
+ * @param {boolean} [opts.background=false] a still camera and a running clock, for a script
97
+ * that moves shapes with setTransform. <controls mode="none"/> turns dragging off
61
98
  * @param {number} [opts.orbitSpeed=0.22] radians per second
62
99
  * @param {boolean} [opts.interactive=true] false: no dragging, whatever the document says
63
100
  * @param {HTMLElement} [opts.scroll] section whose scroll position drives timeline="scroll"
@@ -80,15 +117,16 @@ export async function createStage(canvas, opts = {}) {
80
117
  target.style.display = 'block';
81
118
  const camera = new Camera();
82
119
  const controls = new OrbitControls(target, camera);
83
- const motion = !reducedMotion();
120
+ const motion = stageOptions(opts, prefersReducedMotion());
84
121
  const state = {
85
122
  result: null,
86
123
  time: 0,
87
124
  progress: 0,
88
125
  last: 0,
89
126
  disposed: false,
90
- playing: opts.playing !== false && motion,
91
- auto: opts.autoOrbit !== false && motion,
127
+ clock: motion.clock,
128
+ playing: motion.playing,
129
+ auto: motion.auto,
92
130
  orbitSpeed: opts.orbitSpeed ?? 0.22,
93
131
  onScreen: true,
94
132
  // A frame is drawn only when something visible changed: the camera, the
@@ -250,6 +288,43 @@ export async function createStage(canvas, opts = {}) {
250
288
  set playing(v) {
251
289
  state.playing = !!v;
252
290
  },
291
+ /** True while the visitor prefers reduced motion. The clock and the automatic orbit stay still. */
292
+ get reducedMotion() {
293
+ return prefersReducedMotion();
294
+ },
295
+ /**
296
+ * Replace the local matrix of each element. An entry is `[id, matrix]`
297
+ * or `{id, matrix}`. The matrix is column-major, 16 numbers, the same
298
+ * layout as matrix(). Every matrix is stored first, then each affected
299
+ * part of the scene is walked once. The returned array has false for an
300
+ * id that is missing or drawn in more than one place (a shared
301
+ * definition, or a copy made by repeat or scatter).
302
+ * @param {Array<[string, ArrayLike<number>]|{id: string, matrix: ArrayLike<number>}>} entries
303
+ */
304
+ setTransforms(entries) {
305
+ const list = Array.from(entries || []);
306
+ const res = state.result;
307
+ const graph = res && res.graph;
308
+ const byId = graph && graph.byId;
309
+ const nodes = list.map((entry) => byId && byId.get(Array.isArray(entry) ? entry[0] : entry && entry.id));
310
+ const matrices = list.map((entry) => Array.isArray(entry) ? entry[1] : entry && entry.matrix);
311
+ const applied = graph && res.scene
312
+ ? applyLocalMatrices(res.scene, graph, nodes, matrices, state.time, state.progress)
313
+ : list.map(() => false);
314
+ if (applied.some(Boolean)) state.dirty = true;
315
+ return applied;
316
+ },
317
+ /**
318
+ * Replace the local matrix of the element with this id. The matrix is
319
+ * column-major, 16 numbers, the same layout as matrix(). Returns false
320
+ * when the id is missing or that element is drawn in more than one place
321
+ * (a shared definition, or a copy made by repeat or scatter).
322
+ * @param {string} id
323
+ * @param {ArrayLike<number>} matrix
324
+ */
325
+ setTransform(id, matrix) {
326
+ return this.setTransforms([[id, matrix]])[0] === true;
327
+ },
253
328
  /** Draw the next frame even if nothing the stage tracks has changed. */
254
329
  invalidate,
255
330
  get result() {
@@ -320,11 +395,11 @@ export async function createStage(canvas, opts = {}) {
320
395
  if (!state.width || !state.height) return;
321
396
  const res = state.result;
322
397
  const scene = res.scene;
323
- // a scene without clock animations looks the same at every time
324
- if (state.playing && scene.animation.time) state.time += dt;
398
+ const reduced = prefersReducedMotion();
399
+ if (clockAdvances(state, scene, reduced)) state.time += dt;
325
400
  if (state.timeline && state.timeline.update(dt)) state.progress = state.timeline.value;
326
401
  if (opts.onFrame) opts.onFrame(dt, api);
327
- if (state.auto) {
402
+ if (orbitAdvances(state, reduced)) {
328
403
  const prev = controls.snapshot();
329
404
  const dx = camera.position[0] - camera.target[0];
330
405
  const dz = camera.position[2] - camera.target[2];
@@ -15,6 +15,13 @@
15
15
  //
16
16
  // The RenderScene is renderer-agnostic; the OBJ and glTF importers produce the
17
17
  // same structure, which lets the benchmark render all formats identically.
18
+ //
19
+ // A node the walker visits once remembers that placement: the parent matrix
20
+ // and the cursors at its start. applyLocalMatrices() stores every script
21
+ // matrix first, then rewrites each affected subtree once. A later animation
22
+ // walk reads the script matrix, so the document's own animation does not put
23
+ // the old matrix back. A node visited again, or written by the instance
24
+ // kernel, is shared and left alone.
18
25
 
19
26
  import * as mat4 from '../math/mat4.js';
20
27
  import { mulberry32 } from '../math/random.js';
@@ -160,6 +167,33 @@ class Walker {
160
167
  return this.stack[depth];
161
168
  }
162
169
 
170
+ /**
171
+ * Remember the one placement of a node. A second visit drops it: scatter,
172
+ * repeat and a definition used twice do not have one matrix to replace.
173
+ */
174
+ notePlace(node, parent, material, color, sel, lodNode, lodChild) {
175
+ if (node.place === 'many') return;
176
+ if (node.place) {
177
+ node.place = 'many';
178
+ return;
179
+ }
180
+ const copy = new Float64Array(16);
181
+ copy.set(parent);
182
+ node.place = {
183
+ parent: copy,
184
+ material,
185
+ color,
186
+ sel,
187
+ lodNode,
188
+ lodChild,
189
+ noCollide: this.noCollide,
190
+ rowStart: this.cursor,
191
+ lightStart: this.lightCursor,
192
+ cameraStart: this.cameraCursor,
193
+ switchStart: this.switchCursor,
194
+ };
195
+ }
196
+
163
197
  /** Local matrix of a node at the current time. */
164
198
  localOf(node, out) {
165
199
  if (!node.anims.length) {
@@ -177,7 +211,7 @@ class Walker {
177
211
 
178
212
  walk(node, parent, material, color, sel, lodNode, lodChild, depth) {
179
213
  if (!node || !node.visible) return;
180
- const g = this.graph;
214
+ if (!this.overwrite) this.notePlace(node, parent, material, color, sel, lodNode, lodChild);
181
215
  const isSegmentRoot = node.anims.length > 0 && this.segmentDepth === 0 && !this.overwrite;
182
216
  let seg = null;
183
217
  if (isSegmentRoot) {
@@ -213,13 +247,14 @@ class Walker {
213
247
  else if (node.kind === 'camera') this.emitCamera(node, parent);
214
248
  else {
215
249
  let world;
216
- if (!node.local && !node.anims.length) {
250
+ const scripted = node.scriptLocal;
251
+ if (!scripted && !node.local && !node.anims.length) {
217
252
  // identity local transform: children write only to deeper stack slots, so the parent can be shared
218
253
  world = parent;
219
254
  } else {
220
255
  world = this.mat(depth);
221
- // static nodes multiply by their stored matrix directly (no copy)
222
- const local = node.anims.length ? this.localOf(node, this.tmp) : node.local;
256
+ // a script matrix replaces the element's own transform, including its <animate>
257
+ const local = scripted || (node.anims.length ? this.localOf(node, this.tmp) : node.local);
223
258
  mat4.multiply(world, parent, local);
224
259
  }
225
260
  switch (node.kind) {
@@ -256,6 +291,12 @@ class Walker {
256
291
  seg.switchEnd = this.switchCursor;
257
292
  this.scene.dynamic.push(seg);
258
293
  }
294
+ if (!this.overwrite && node.place && node.place !== 'many') {
295
+ node.place.rowEnd = this.cursor;
296
+ node.place.lightEnd = this.lightCursor;
297
+ node.place.cameraEnd = this.cameraCursor;
298
+ node.place.switchEnd = this.switchCursor;
299
+ }
259
300
  }
260
301
 
261
302
  emitShape(node, world, material, color, uid, lodNode, lodChild, depth) {
@@ -438,6 +479,7 @@ class Walker {
438
479
  }
439
480
  this.cursor = cursor;
440
481
  this.switchCursor = sw;
482
+ markPlacedMany(node);
441
483
  return true;
442
484
  }
443
485
 
@@ -728,6 +770,168 @@ export function flattenScene(graph, opts = {}) {
728
770
  return scene;
729
771
  }
730
772
 
773
+ /**
774
+ * The instance kernel wrote every copy of this subtree, so none of those
775
+ * nodes has a single placement a script can replace.
776
+ */
777
+ function markPlacedMany(node) {
778
+ const visit = (n) => {
779
+ if (!n || n.place === 'many') return;
780
+ n.place = 'many';
781
+ if (n.target) visit(n.target);
782
+ if (n.children) for (const c of n.children) visit(c);
783
+ };
784
+ if (node.target) visit(node.target);
785
+ if (node.children) for (const c of node.children) visit(c);
786
+ }
787
+
788
+ /**
789
+ * The animated subtree that contains this node, if its parent matrix depends
790
+ * on the clock. The segment's own parent is above that animation, so rewalking
791
+ * the segment at the current time keeps a script matrix in the right place.
792
+ */
793
+ function segmentFor(scene, node) {
794
+ for (const seg of scene.dynamic) {
795
+ for (let n = node; n; n = n.parent) if (n === seg.node) return seg;
796
+ }
797
+ return null;
798
+ }
799
+
800
+ function finiteMatrix(matrix) {
801
+ if (!matrix || matrix.length < 16) return false;
802
+ for (let i = 0; i < 16; i++) if (!Number.isFinite(+matrix[i])) return false;
803
+ return true;
804
+ }
805
+
806
+ function writeScriptLocal(node, matrix) {
807
+ let local = node.scriptLocal;
808
+ if (!local) local = node.scriptLocal = new Float64Array(16);
809
+ for (let i = 0; i < 16; i++) local[i] = +matrix[i];
810
+ }
811
+
812
+ function under(node, ancestor) {
813
+ for (let n = node; n; n = n.parent) if (n === ancestor) return true;
814
+ return false;
815
+ }
816
+
817
+ /** The deepest node that contains every entry, itself included. */
818
+ function deepestCovering(nodes) {
819
+ for (let p = nodes[0]; p; p = p.parent) {
820
+ if (nodes.every((n) => n === p || under(n, p))) return p;
821
+ }
822
+ return null;
823
+ }
824
+
825
+ /**
826
+ * One walk root per cluster. A shared parent of several moves is walked
827
+ * once. The document root is not that parent: two unrelated shapes keep
828
+ * two small walks.
829
+ */
830
+ function clusterRoots(nodes, root) {
831
+ if (nodes.length <= 1) return nodes.slice();
832
+ const common = deepestCovering(nodes);
833
+ if (common && common !== root && common.place && common.place !== 'many') return [common];
834
+ const groups = new Map();
835
+ for (const node of nodes) {
836
+ let top = node;
837
+ for (let p = node.parent; p && p !== root && p.place && p.place !== 'many'; p = p.parent) top = p;
838
+ let list = groups.get(top);
839
+ if (!list) groups.set(top, list = []);
840
+ list.push(node);
841
+ }
842
+ const out = [];
843
+ for (const [top, members] of groups) {
844
+ if (members.length > 1 && top.place && top.place !== 'many' && top !== root) out.push(top);
845
+ else if (members.length === nodes.length) for (const member of members) out.push(member);
846
+ else out.push(...clusterRoots(members, root));
847
+ }
848
+ return out;
849
+ }
850
+
851
+ function rewalkAt(w, rowStart, lightStart, cameraStart, switchStart, noCollide, node, parent, material, color, sel, lodNode, lodChild) {
852
+ w.cursor = rowStart;
853
+ w.lightCursor = lightStart;
854
+ w.cameraCursor = cameraStart;
855
+ w.switchCursor = switchStart;
856
+ w.segmentDepth = 0;
857
+ w.noCollide = noCollide;
858
+ w.walk(node, parent, material, color, sel, lodNode, lodChild, 0);
859
+ }
860
+
861
+ /**
862
+ * Replace the local matrix of each node and rewrite the rows once per
863
+ * affected subtree. Each entry of the returned array is false when that
864
+ * node is missing, the matrix is not 16 finite numbers, or the element is
865
+ * shared (a definition used twice, a repeat or scatter copy, or a subtree
866
+ * the instance kernel expanded). Column-major, the same layout as matrix().
867
+ * @param {RenderScene} scene
868
+ * @param {object} graph
869
+ * @param {import('./scene-graph.js').SceneNode[]} nodes
870
+ * @param {ArrayLike<number>[]} matrices
871
+ */
872
+ export function applyLocalMatrices(scene, graph, nodes, matrices, time = 0, progress = 0) {
873
+ const list = nodes || [];
874
+ const ok = new Array(list.length);
875
+ const accepted = [];
876
+ const seen = new Set();
877
+ for (let i = 0; i < list.length; i++) {
878
+ const node = list[i];
879
+ const matrix = matrices && matrices[i];
880
+ if (!scene || !graph || !node || !node.place || node.place === 'many' || !finiteMatrix(matrix)) {
881
+ ok[i] = false;
882
+ continue;
883
+ }
884
+ writeScriptLocal(node, matrix);
885
+ ok[i] = true;
886
+ if (!seen.has(node)) {
887
+ seen.add(node);
888
+ accepted.push(node);
889
+ }
890
+ }
891
+ if (!accepted.length) return ok;
892
+
893
+ const segments = [];
894
+ const staticNodes = [];
895
+ const seenSeg = new Set();
896
+ for (const node of accepted) {
897
+ const seg = segmentFor(scene, node);
898
+ if (seg) {
899
+ if (!seenSeg.has(seg)) {
900
+ seenSeg.add(seg);
901
+ segments.push(seg);
902
+ }
903
+ } else staticNodes.push(node);
904
+ }
905
+ const roots = clusterRoots(staticNodes, graph.root);
906
+ const w = new Walker(scene, graph, time, progress);
907
+ w.overwrite = true;
908
+ for (const node of roots) {
909
+ const p = node.place;
910
+ rewalkAt(w, p.rowStart, p.lightStart, p.cameraStart, p.switchStart, p.noCollide, node, p.parent, p.material, p.color, p.sel, p.lodNode, p.lodChild);
911
+ }
912
+ for (const seg of segments) {
913
+ if (roots.some((node) => under(seg.node, node))) continue;
914
+ rewalkAt(w, seg.start, seg.lightStart, seg.cameraStart, seg.switchStart, seg.noCollide, seg.node, seg.parent, seg.material, seg.color, seg.sel, seg.lodNode, seg.lodChild);
915
+ }
916
+ scene.version++;
917
+ return ok;
918
+ }
919
+
920
+ /**
921
+ * Replace one element's local matrix and rewrite the rows of its single
922
+ * placement. The matrix is column-major, the same layout as matrix().
923
+ * Returns false when the id is missing, the matrix is not 16 finite numbers,
924
+ * or the element is shared (a definition used twice, a repeat or scatter
925
+ * copy, or a subtree the instance kernel expanded).
926
+ * @param {RenderScene} scene
927
+ * @param {object} graph
928
+ * @param {import('./scene-graph.js').SceneNode} node
929
+ * @param {ArrayLike<number>} matrix
930
+ */
931
+ export function applyLocalMatrix(scene, graph, node, matrix, time = 0, progress = 0) {
932
+ return applyLocalMatrices(scene, graph, [node], [matrix], time, progress)[0] === true;
933
+ }
934
+
731
935
  /** Re-evaluate animated subtrees at time t and scroll progress, overwriting their rows. */
732
936
  export function updateDynamic(scene, graph, time, progress = 0) {
733
937
  if (!scene.dynamic.length) return false;
@@ -37,6 +37,12 @@ export class SceneNode {
37
37
  this.step = null;
38
38
  // scatter / lod / light / camera parameters
39
39
  this.params = null;
40
+ /** Column-major local matrix written by a script, or null. It wins over this node's own transform. */
41
+ this.scriptLocal = null;
42
+ /** One recorded placement, or 'many' when the node is drawn in more than one place. */
43
+ this.place = null;
44
+ /** Scene-graph parent. A shared definition has none: it is not a child of the uses that draw it. */
45
+ this.parent = null;
40
46
  }
41
47
 
42
48
  get animated() {
@@ -65,6 +71,7 @@ export function buildSceneGraph(doc, cache, opts = {}) {
65
71
  const diagnostics = [];
66
72
  const materials = new MaterialTable();
67
73
  const memo = new Map();
74
+ const byId = new Map();
68
75
  let environment = { ...ENV_DEFAULTS, explicit: false };
69
76
  let envFound = false;
70
77
  let controls = null;
@@ -132,6 +139,7 @@ export function buildSceneGraph(doc, cache, opts = {}) {
132
139
  memo.set(el, node);
133
140
  stats.nodes++;
134
141
  const v = el.values || {};
142
+ if (v.id !== undefined) byId.set(v.id, node);
135
143
  if (kind !== 'camera' && kind !== 'light') {
136
144
  const m = localMatrix(v);
137
145
  node.local = isIdentity(m) ? null : m;
@@ -167,7 +175,10 @@ export function buildSceneGraph(doc, cache, opts = {}) {
167
175
  }
168
176
  if (c.name === 'material' || c.name === 'environment' || c.name === 'controls') continue;
169
177
  const child = nodeFor(c, depth + 1);
170
- if (child) node.children.push(child);
178
+ if (child) {
179
+ child.parent = node;
180
+ node.children.push(child);
181
+ }
171
182
  }
172
183
 
173
184
  switch (kind) {
@@ -298,7 +309,7 @@ export function buildSceneGraph(doc, cache, opts = {}) {
298
309
  report(doc.root, 'error', 'E_TOO_MANY_INSTANCES', `The scene expands to ${expanded.toLocaleString('en-US')} instances, more than the limit of ${maxInstances.toLocaleString('en-US')}. Reduce repeat or scatter counts.`, 'render');
299
310
  }
300
311
 
301
- return { doc, root, materials, environment, controls, animation, cache, diagnostics, stats, tooLarge: expanded > maxInstances, expanded };
312
+ return { doc, root, materials, environment, controls, animation, cache, diagnostics, stats, tooLarge: expanded > maxInstances, expanded, byId };
302
313
  }
303
314
 
304
315
  /** Viewer settings from a <controls> element (angles in degrees, as written). */
package/src/stage.js ADDED
@@ -0,0 +1,8 @@
1
+ // The page entry. The published file is one minified build of this module
2
+ // (browser/es3d-stage.min.js, `npm run build:stage`), so a page loads the
3
+ // stage in a single request. createStage, ancestorNames and the placement
4
+ // functions a script shares with the reader are the exports.
5
+
6
+ export { createStage, ancestorNames } from './render/stage.js';
7
+ export { mulberry32 } from './math/random.js';
8
+ export { es3dSin, es3dCos } from './math/trig.js';