@es3d/es3d 1.0.0 → 1.0.1
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/LICENSE +201 -201
- package/README.md +86 -86
- package/docs/SPEC.md +417 -417
- package/package.json +1 -1
- package/scripts/es3d-cli.mjs +377 -377
- package/src/cache/geometry-store.js +392 -392
- package/src/cache/mesh-bundle.js +123 -123
- package/src/cache/sha256.js +73 -73
- package/src/format/ast.js +238 -238
- package/src/format/colors.js +93 -93
- package/src/format/compact-parser.js +171 -171
- package/src/format/compact-tokenizer.js +165 -165
- package/src/format/errors.js +247 -247
- package/src/format/json.js +55 -55
- package/src/format/limits.js +14 -14
- package/src/format/parse.js +92 -92
- package/src/format/schema.js +617 -617
- package/src/format/serializer.js +333 -333
- package/src/format/validator.js +330 -330
- package/src/format/value-error.js +12 -12
- package/src/format/value-format.js +314 -314
- package/src/format/values.js +459 -459
- package/src/format/xml-parser.js +136 -136
- package/src/format/xml-tokenizer.js +237 -237
- package/src/geometry/contour.js +86 -86
- package/src/geometry/extrude.js +175 -175
- package/src/geometry/gear.js +85 -85
- package/src/geometry/geometry-cache.js +159 -159
- package/src/geometry/lod-error.js +178 -178
- package/src/geometry/mesh.js +458 -458
- package/src/geometry/primitives.js +299 -299
- package/src/geometry/registry.js +645 -645
- package/src/geometry/revolve.js +136 -136
- package/src/geometry/sweep.js +441 -441
- package/src/index.js +47 -47
- package/src/io/compression.js +67 -67
- package/src/io/convert.js +236 -236
- package/src/io/download.js +13 -13
- package/src/io/external-scene.js +127 -127
- package/src/io/file-loader.js +28 -28
- package/src/io/gltf.js +630 -630
- package/src/io/mesh-import.js +104 -104
- package/src/io/obj.js +352 -352
- package/src/io/step-brep.js +439 -439
- package/src/io/step-exact.js +1149 -1149
- package/src/io/step.js +1418 -1418
- package/src/io/stl.js +193 -193
- package/src/io/svg-import.js +787 -787
- package/src/material/materials.js +154 -154
- package/src/math/mat4.js +475 -475
- package/src/math/random.js +30 -30
- package/src/math/trig.js +65 -65
- package/src/math/vec3.js +81 -81
- package/src/optimize/optimizer.js +579 -579
- package/src/path/path-flatten.js +322 -322
- package/src/path/path-parser.js +332 -332
- package/src/path/shapes.js +129 -129
- package/src/path/triangulate.js +517 -517
- package/src/pipeline.js +74 -74
- package/src/render/camera.js +168 -168
- package/src/render/frame-builder.js +362 -362
- package/src/render/geometry-pool.js +149 -149
- package/src/render/lighting.js +149 -149
- package/src/render/orbit-controls.js +237 -237
- package/src/render/renderer.js +258 -249
- package/src/render/scroll-timeline.js +104 -104
- package/src/render/shaders/glsl.js +285 -285
- package/src/render/shaders/wgsl.js +252 -252
- package/src/render/stage.js +364 -352
- package/src/render/texture-cache.js +40 -40
- package/src/render/textures.js +223 -223
- package/src/render/webgl2/webgl2-backend.js +586 -532
- package/src/render/webgpu/webgpu-backend.js +482 -480
- package/src/scene/flatten.js +754 -754
- package/src/scene/picking.js +161 -161
- package/src/scene/scene-graph.js +320 -320
- package/src/transform/transform.js +211 -211
- package/src/wasm/kernels-binary.js +3 -3
- package/src/wasm/kernels.js +385 -385
package/src/scene/scene-graph.js
CHANGED
|
@@ -1,320 +1,320 @@
|
|
|
1
|
-
// Scene graph construction: validated AST -> SceneNode tree.
|
|
2
|
-
//
|
|
3
|
-
// Every renderable element becomes one SceneNode with its static local matrix
|
|
4
|
-
// pre-computed. Nodes are memoised per element, so a definition referenced by
|
|
5
|
-
// a thousand <use> elements exists once in memory: instancing is resolved by
|
|
6
|
-
// traversal, not by copying subtrees. Materials are collected into a table,
|
|
7
|
-
// geometry descriptors are interned in the geometry cache (lazy tessellation).
|
|
8
|
-
|
|
9
|
-
import * as mat4 from '../math/mat4.js';
|
|
10
|
-
import { valueOf, elementSpec } from '../format/schema.js';
|
|
11
|
-
import { localMatrix } from '../transform/transform.js';
|
|
12
|
-
import { describeShape, hasGeometry } from '../geometry/registry.js';
|
|
13
|
-
import { MaterialTable, paramsFromElement } from '../material/materials.js';
|
|
14
|
-
import { animationFromElement } from './animation.js';
|
|
15
|
-
import { MAX_INSTANCES, MAX_DEPTH } from '../format/limits.js';
|
|
16
|
-
|
|
17
|
-
export { MAX_INSTANCES, MAX_DEPTH };
|
|
18
|
-
|
|
19
|
-
export class SceneNode {
|
|
20
|
-
constructor(el, kind) {
|
|
21
|
-
this.el = el;
|
|
22
|
-
this.uid = el.uid;
|
|
23
|
-
this.kind = kind;
|
|
24
|
-
this.local = null; // static local matrix (Float64Array) or null for identity
|
|
25
|
-
this.visible = true;
|
|
26
|
-
this.material = null;
|
|
27
|
-
this.color = null;
|
|
28
|
-
this.anims = [];
|
|
29
|
-
this.children = [];
|
|
30
|
-
// shape
|
|
31
|
-
this.geometry = null;
|
|
32
|
-
this.unit = null;
|
|
33
|
-
// use
|
|
34
|
-
this.target = null;
|
|
35
|
-
// repeat
|
|
36
|
-
this.count = 0;
|
|
37
|
-
this.step = null;
|
|
38
|
-
// scatter / lod / light / camera parameters
|
|
39
|
-
this.params = null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
get animated() {
|
|
43
|
-
return this.anims.length > 0;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const ENV_DEFAULTS = {
|
|
48
|
-
background: [0.09, 0.1, 0.12, 1],
|
|
49
|
-
sky: [0.8, 0.86, 0.95, 1],
|
|
50
|
-
ground: [0.25, 0.22, 0.2, 1],
|
|
51
|
-
intensity: 0.5,
|
|
52
|
-
exposure: 1,
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
function isIdentity(m) {
|
|
56
|
-
return mat4.isIdentity(m, 1e-15);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @param {import('../format/ast.js').Document} doc validated document
|
|
61
|
-
* @param {import('../geometry/geometry-cache.js').GeometryCache} cache
|
|
62
|
-
*/
|
|
63
|
-
export function buildSceneGraph(doc, cache, opts = {}) {
|
|
64
|
-
const maxInstances = opts.maxInstances || MAX_INSTANCES;
|
|
65
|
-
const diagnostics = [];
|
|
66
|
-
const materials = new MaterialTable();
|
|
67
|
-
const memo = new Map();
|
|
68
|
-
let environment = { ...ENV_DEFAULTS, explicit: false };
|
|
69
|
-
let envFound = false;
|
|
70
|
-
let controls = null;
|
|
71
|
-
// which clocks drive the document: viewers skip per-frame work for the ones it does not use
|
|
72
|
-
const animation = { time: false, scroll: false };
|
|
73
|
-
const stats = { nodes: 0, shapes: 0, uses: 0, defs: 0, lights: 0, cameras: 0, materials: 0 };
|
|
74
|
-
|
|
75
|
-
const report = (el, severity, code, message, phase = 'reference') => {
|
|
76
|
-
diagnostics.push({ severity, code, message, element: el, phase });
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
// materials and environment are document-wide
|
|
80
|
-
const collect = (el) => {
|
|
81
|
-
if (el.unknown) return;
|
|
82
|
-
if (el.name === 'material' && el.values && el.values.id !== undefined) {
|
|
83
|
-
materials.define(el.values.id, paramsFromElement(el));
|
|
84
|
-
stats.materials++;
|
|
85
|
-
}
|
|
86
|
-
if (el.name === 'environment' && !envFound) {
|
|
87
|
-
envFound = true;
|
|
88
|
-
environment = {
|
|
89
|
-
background: valueOf(el, 'background'),
|
|
90
|
-
sky: valueOf(el, 'sky'),
|
|
91
|
-
ground: valueOf(el, 'ground'),
|
|
92
|
-
intensity: valueOf(el, 'intensity'),
|
|
93
|
-
exposure: valueOf(el, 'exposure'),
|
|
94
|
-
explicit: true,
|
|
95
|
-
uid: el.uid,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
if (el.name === 'controls' && !controls) controls = controlsFromElement(el);
|
|
99
|
-
if (el.name === 'defs') stats.defs += el.children.filter((c) => c.type === 'element').length;
|
|
100
|
-
for (const c of el.children) if (c.type === 'element') collect(c);
|
|
101
|
-
};
|
|
102
|
-
collect(doc.root);
|
|
103
|
-
|
|
104
|
-
cache.beginBuild();
|
|
105
|
-
|
|
106
|
-
const nodeFor = (el, depth = 0) => {
|
|
107
|
-
if (memo.has(el)) return memo.get(el);
|
|
108
|
-
if (depth > MAX_DEPTH) {
|
|
109
|
-
report(el, 'error', 'E_TOO_DEEP', `Nesting is deeper than ${MAX_DEPTH}. Flatten the tree or shorten the use chain.`);
|
|
110
|
-
memo.set(el, null);
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
if (el.unknown || !elementSpec(el.name)) {
|
|
114
|
-
memo.set(el, null);
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
const name = el.name;
|
|
118
|
-
let kind;
|
|
119
|
-
if (name === 'es3d' || name === 'v3d' || name === 'scene' || name === 'group') kind = 'group';
|
|
120
|
-
else if (name === 'use') kind = 'use';
|
|
121
|
-
else if (name === 'repeat') kind = 'repeat';
|
|
122
|
-
else if (name === 'scatter') kind = 'scatter';
|
|
123
|
-
else if (name === 'lod') kind = 'lod';
|
|
124
|
-
else if (name === 'light') kind = 'light';
|
|
125
|
-
else if (name === 'camera') kind = 'camera';
|
|
126
|
-
else if (hasGeometry(name)) kind = 'shape';
|
|
127
|
-
else {
|
|
128
|
-
memo.set(el, null);
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
const node = new SceneNode(el, kind);
|
|
132
|
-
memo.set(el, node);
|
|
133
|
-
stats.nodes++;
|
|
134
|
-
const v = el.values || {};
|
|
135
|
-
if (kind !== 'camera' && kind !== 'light') {
|
|
136
|
-
const m = localMatrix(v);
|
|
137
|
-
node.local = isIdentity(m) ? null : m;
|
|
138
|
-
}
|
|
139
|
-
node.visible = valueOf(el, 'visible') !== false;
|
|
140
|
-
node.collide = v.collide !== false;
|
|
141
|
-
node.material = v.material !== undefined ? v.material : null;
|
|
142
|
-
node.color = v.color !== undefined ? v.color : null;
|
|
143
|
-
const position = valueOf(el, 'position') || [0, 0, 0];
|
|
144
|
-
node.base = {
|
|
145
|
-
position,
|
|
146
|
-
rotation: valueOf(el, 'rotation') || [0, 0, 0],
|
|
147
|
-
scale: valueOf(el, 'scale') || [1, 1, 1],
|
|
148
|
-
};
|
|
149
|
-
if (kind === 'camera') {
|
|
150
|
-
// a camera without a target looks down -Z: start a target animation from there
|
|
151
|
-
node.base.target = v.target || [position[0], position[1], position[2] - 1];
|
|
152
|
-
node.base.fov = [valueOf(el, 'fov')];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
for (const c of el.children) {
|
|
156
|
-
if (c.type !== 'element') continue;
|
|
157
|
-
if (c.name === 'animate') {
|
|
158
|
-
if (!c.unknown && c.values && c.values.attr) {
|
|
159
|
-
const a = animationFromElement(c, node.base);
|
|
160
|
-
node.anims.push(a);
|
|
161
|
-
animation[a.timeline === 'scroll' ? 'scroll' : 'time'] = true;
|
|
162
|
-
}
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (name === 'es3d' || name === 'v3d') {
|
|
166
|
-
if (c.name === 'defs' || c.name === 'title' || c.name === 'desc' || c.name === 'environment' || c.name === 'material') continue;
|
|
167
|
-
}
|
|
168
|
-
if (c.name === 'material' || c.name === 'environment' || c.name === 'controls') continue;
|
|
169
|
-
const child = nodeFor(c, depth + 1);
|
|
170
|
-
if (child) node.children.push(child);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
switch (kind) {
|
|
174
|
-
case 'shape': {
|
|
175
|
-
stats.shapes++;
|
|
176
|
-
let desc = null;
|
|
177
|
-
try {
|
|
178
|
-
desc = describeShape(el, {});
|
|
179
|
-
} catch (err) {
|
|
180
|
-
report(el, 'error', 'E_GEOMETRY', `Cannot describe <${name}>: ${err.message}`, 'geometry');
|
|
181
|
-
}
|
|
182
|
-
if (desc) {
|
|
183
|
-
node.geometry = cache.acquire(desc, el);
|
|
184
|
-
node.unit = desc.unit && !isIdentity(desc.unit) ? desc.unit : null;
|
|
185
|
-
} else if (!diagnostics.some((d) => d.element === el)) {
|
|
186
|
-
report(el, 'error', 'E_GEOMETRY', `<${name}> has no usable geometry source.`, 'geometry');
|
|
187
|
-
}
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
case 'use':
|
|
191
|
-
stats.uses++;
|
|
192
|
-
if (el.target && !el.circular) node.target = nodeFor(el.target, depth + 1);
|
|
193
|
-
break;
|
|
194
|
-
case 'repeat':
|
|
195
|
-
node.count = v.count || 0;
|
|
196
|
-
node.step = v.step ? v.step.matrix : mat4.createF64();
|
|
197
|
-
break;
|
|
198
|
-
case 'scatter':
|
|
199
|
-
node.params = {
|
|
200
|
-
grid: v.grid || null,
|
|
201
|
-
count: v.grid ? v.grid[0] * v.grid[1] : v.count || 0,
|
|
202
|
-
spacing: valueOf(el, 'spacing'),
|
|
203
|
-
area: v.area || null,
|
|
204
|
-
radius: v.radius || null,
|
|
205
|
-
seed: valueOf(el, 'seed'),
|
|
206
|
-
jitter: valueOf(el, 'jitter'),
|
|
207
|
-
randomScale: v.randomScale || null,
|
|
208
|
-
randomRotation: v.randomRotation || null,
|
|
209
|
-
randomMaterial: v.randomMaterial || null,
|
|
210
|
-
};
|
|
211
|
-
break;
|
|
212
|
-
case 'lod':
|
|
213
|
-
node.params = { distances: v.distances || [] };
|
|
214
|
-
break;
|
|
215
|
-
case 'light': {
|
|
216
|
-
stats.lights++;
|
|
217
|
-
const m = mat4.createF64();
|
|
218
|
-
if (v.transform) m.set(v.transform.matrix);
|
|
219
|
-
node.params = {
|
|
220
|
-
type: v.type,
|
|
221
|
-
color: valueOf(el, 'color'),
|
|
222
|
-
intensity: valueOf(el, 'intensity'),
|
|
223
|
-
position: valueOf(el, 'position'),
|
|
224
|
-
rotation: v.rotation || null,
|
|
225
|
-
direction: v.direction || null,
|
|
226
|
-
target: v.target || null,
|
|
227
|
-
range: valueOf(el, 'range'),
|
|
228
|
-
angle: valueOf(el, 'angle'),
|
|
229
|
-
penumbra: valueOf(el, 'penumbra'),
|
|
230
|
-
groundColor: valueOf(el, 'groundColor'),
|
|
231
|
-
transform: v.transform ? m : null,
|
|
232
|
-
};
|
|
233
|
-
break;
|
|
234
|
-
}
|
|
235
|
-
case 'camera': {
|
|
236
|
-
stats.cameras++;
|
|
237
|
-
node.params = {
|
|
238
|
-
type: valueOf(el, 'type'),
|
|
239
|
-
position: valueOf(el, 'position'),
|
|
240
|
-
target: v.target || null,
|
|
241
|
-
rotation: v.rotation || null,
|
|
242
|
-
up: valueOf(el, 'up'),
|
|
243
|
-
fov: valueOf(el, 'fov'),
|
|
244
|
-
height: valueOf(el, 'height'),
|
|
245
|
-
near: v.near || null,
|
|
246
|
-
far: v.far || null,
|
|
247
|
-
aspect: valueOf(el, 'aspect'),
|
|
248
|
-
fit: valueOf(el, 'fit'),
|
|
249
|
-
transform: v.transform ? v.transform.matrix : null,
|
|
250
|
-
name: v.name || v.id || `camera ${stats.cameras}`,
|
|
251
|
-
};
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
default:
|
|
255
|
-
break;
|
|
256
|
-
}
|
|
257
|
-
return node;
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
const root = doc.root ? nodeFor(doc.root) : null;
|
|
261
|
-
cache.endBuild();
|
|
262
|
-
|
|
263
|
-
// expanded instance count (memoised per node) guards against accidental explosions
|
|
264
|
-
const counts = new Map();
|
|
265
|
-
const countOf = (node) => {
|
|
266
|
-
if (!node) return 0;
|
|
267
|
-
if (counts.has(node)) return counts.get(node);
|
|
268
|
-
counts.set(node, 0); // cycle guard
|
|
269
|
-
let n = 0;
|
|
270
|
-
const kids = () => node.children.reduce((s, c) => s + countOf(c), 0);
|
|
271
|
-
switch (node.kind) {
|
|
272
|
-
case 'shape':
|
|
273
|
-
n = node.geometry ? 1 : 0;
|
|
274
|
-
break;
|
|
275
|
-
case 'use':
|
|
276
|
-
n = countOf(node.target);
|
|
277
|
-
break;
|
|
278
|
-
case 'repeat':
|
|
279
|
-
n = node.count * kids();
|
|
280
|
-
break;
|
|
281
|
-
case 'scatter':
|
|
282
|
-
n = node.params.count * kids();
|
|
283
|
-
break;
|
|
284
|
-
case 'light':
|
|
285
|
-
case 'camera':
|
|
286
|
-
n = 0;
|
|
287
|
-
break;
|
|
288
|
-
default:
|
|
289
|
-
n = kids();
|
|
290
|
-
}
|
|
291
|
-
if (!node.visible) n = 0;
|
|
292
|
-
counts.set(node, n);
|
|
293
|
-
return n;
|
|
294
|
-
};
|
|
295
|
-
const expanded = countOf(root);
|
|
296
|
-
stats.expandedInstances = expanded;
|
|
297
|
-
if (expanded > maxInstances) {
|
|
298
|
-
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
|
-
}
|
|
300
|
-
|
|
301
|
-
return { doc, root, materials, environment, controls, animation, cache, diagnostics, stats, tooLarge: expanded > maxInstances, expanded };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Viewer settings from a <controls> element (angles in degrees, as written). */
|
|
305
|
-
function controlsFromElement(el) {
|
|
306
|
-
const v = el.values || {};
|
|
307
|
-
return {
|
|
308
|
-
mode: valueOf(el, 'mode'),
|
|
309
|
-
zoom: valueOf(el, 'zoom'),
|
|
310
|
-
pan: valueOf(el, 'pan'),
|
|
311
|
-
minDistance: v.minDistance ?? null,
|
|
312
|
-
maxDistance: v.maxDistance ?? null,
|
|
313
|
-
minPolar: valueOf(el, 'minPolar'),
|
|
314
|
-
maxPolar: valueOf(el, 'maxPolar'),
|
|
315
|
-
targetMin: v.targetMin && v.targetMax ? v.targetMin : null,
|
|
316
|
-
targetMax: v.targetMin && v.targetMax ? v.targetMax : null,
|
|
317
|
-
collide: valueOf(el, 'collide'),
|
|
318
|
-
uid: el.uid,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
1
|
+
// Scene graph construction: validated AST -> SceneNode tree.
|
|
2
|
+
//
|
|
3
|
+
// Every renderable element becomes one SceneNode with its static local matrix
|
|
4
|
+
// pre-computed. Nodes are memoised per element, so a definition referenced by
|
|
5
|
+
// a thousand <use> elements exists once in memory: instancing is resolved by
|
|
6
|
+
// traversal, not by copying subtrees. Materials are collected into a table,
|
|
7
|
+
// geometry descriptors are interned in the geometry cache (lazy tessellation).
|
|
8
|
+
|
|
9
|
+
import * as mat4 from '../math/mat4.js';
|
|
10
|
+
import { valueOf, elementSpec } from '../format/schema.js';
|
|
11
|
+
import { localMatrix } from '../transform/transform.js';
|
|
12
|
+
import { describeShape, hasGeometry } from '../geometry/registry.js';
|
|
13
|
+
import { MaterialTable, paramsFromElement } from '../material/materials.js';
|
|
14
|
+
import { animationFromElement } from './animation.js';
|
|
15
|
+
import { MAX_INSTANCES, MAX_DEPTH } from '../format/limits.js';
|
|
16
|
+
|
|
17
|
+
export { MAX_INSTANCES, MAX_DEPTH };
|
|
18
|
+
|
|
19
|
+
export class SceneNode {
|
|
20
|
+
constructor(el, kind) {
|
|
21
|
+
this.el = el;
|
|
22
|
+
this.uid = el.uid;
|
|
23
|
+
this.kind = kind;
|
|
24
|
+
this.local = null; // static local matrix (Float64Array) or null for identity
|
|
25
|
+
this.visible = true;
|
|
26
|
+
this.material = null;
|
|
27
|
+
this.color = null;
|
|
28
|
+
this.anims = [];
|
|
29
|
+
this.children = [];
|
|
30
|
+
// shape
|
|
31
|
+
this.geometry = null;
|
|
32
|
+
this.unit = null;
|
|
33
|
+
// use
|
|
34
|
+
this.target = null;
|
|
35
|
+
// repeat
|
|
36
|
+
this.count = 0;
|
|
37
|
+
this.step = null;
|
|
38
|
+
// scatter / lod / light / camera parameters
|
|
39
|
+
this.params = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get animated() {
|
|
43
|
+
return this.anims.length > 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ENV_DEFAULTS = {
|
|
48
|
+
background: [0.09, 0.1, 0.12, 1],
|
|
49
|
+
sky: [0.8, 0.86, 0.95, 1],
|
|
50
|
+
ground: [0.25, 0.22, 0.2, 1],
|
|
51
|
+
intensity: 0.5,
|
|
52
|
+
exposure: 1,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function isIdentity(m) {
|
|
56
|
+
return mat4.isIdentity(m, 1e-15);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('../format/ast.js').Document} doc validated document
|
|
61
|
+
* @param {import('../geometry/geometry-cache.js').GeometryCache} cache
|
|
62
|
+
*/
|
|
63
|
+
export function buildSceneGraph(doc, cache, opts = {}) {
|
|
64
|
+
const maxInstances = opts.maxInstances || MAX_INSTANCES;
|
|
65
|
+
const diagnostics = [];
|
|
66
|
+
const materials = new MaterialTable();
|
|
67
|
+
const memo = new Map();
|
|
68
|
+
let environment = { ...ENV_DEFAULTS, explicit: false };
|
|
69
|
+
let envFound = false;
|
|
70
|
+
let controls = null;
|
|
71
|
+
// which clocks drive the document: viewers skip per-frame work for the ones it does not use
|
|
72
|
+
const animation = { time: false, scroll: false };
|
|
73
|
+
const stats = { nodes: 0, shapes: 0, uses: 0, defs: 0, lights: 0, cameras: 0, materials: 0 };
|
|
74
|
+
|
|
75
|
+
const report = (el, severity, code, message, phase = 'reference') => {
|
|
76
|
+
diagnostics.push({ severity, code, message, element: el, phase });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// materials and environment are document-wide
|
|
80
|
+
const collect = (el) => {
|
|
81
|
+
if (el.unknown) return;
|
|
82
|
+
if (el.name === 'material' && el.values && el.values.id !== undefined) {
|
|
83
|
+
materials.define(el.values.id, paramsFromElement(el));
|
|
84
|
+
stats.materials++;
|
|
85
|
+
}
|
|
86
|
+
if (el.name === 'environment' && !envFound) {
|
|
87
|
+
envFound = true;
|
|
88
|
+
environment = {
|
|
89
|
+
background: valueOf(el, 'background'),
|
|
90
|
+
sky: valueOf(el, 'sky'),
|
|
91
|
+
ground: valueOf(el, 'ground'),
|
|
92
|
+
intensity: valueOf(el, 'intensity'),
|
|
93
|
+
exposure: valueOf(el, 'exposure'),
|
|
94
|
+
explicit: true,
|
|
95
|
+
uid: el.uid,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (el.name === 'controls' && !controls) controls = controlsFromElement(el);
|
|
99
|
+
if (el.name === 'defs') stats.defs += el.children.filter((c) => c.type === 'element').length;
|
|
100
|
+
for (const c of el.children) if (c.type === 'element') collect(c);
|
|
101
|
+
};
|
|
102
|
+
collect(doc.root);
|
|
103
|
+
|
|
104
|
+
cache.beginBuild();
|
|
105
|
+
|
|
106
|
+
const nodeFor = (el, depth = 0) => {
|
|
107
|
+
if (memo.has(el)) return memo.get(el);
|
|
108
|
+
if (depth > MAX_DEPTH) {
|
|
109
|
+
report(el, 'error', 'E_TOO_DEEP', `Nesting is deeper than ${MAX_DEPTH}. Flatten the tree or shorten the use chain.`);
|
|
110
|
+
memo.set(el, null);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (el.unknown || !elementSpec(el.name)) {
|
|
114
|
+
memo.set(el, null);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const name = el.name;
|
|
118
|
+
let kind;
|
|
119
|
+
if (name === 'es3d' || name === 'v3d' || name === 'scene' || name === 'group') kind = 'group';
|
|
120
|
+
else if (name === 'use') kind = 'use';
|
|
121
|
+
else if (name === 'repeat') kind = 'repeat';
|
|
122
|
+
else if (name === 'scatter') kind = 'scatter';
|
|
123
|
+
else if (name === 'lod') kind = 'lod';
|
|
124
|
+
else if (name === 'light') kind = 'light';
|
|
125
|
+
else if (name === 'camera') kind = 'camera';
|
|
126
|
+
else if (hasGeometry(name)) kind = 'shape';
|
|
127
|
+
else {
|
|
128
|
+
memo.set(el, null);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const node = new SceneNode(el, kind);
|
|
132
|
+
memo.set(el, node);
|
|
133
|
+
stats.nodes++;
|
|
134
|
+
const v = el.values || {};
|
|
135
|
+
if (kind !== 'camera' && kind !== 'light') {
|
|
136
|
+
const m = localMatrix(v);
|
|
137
|
+
node.local = isIdentity(m) ? null : m;
|
|
138
|
+
}
|
|
139
|
+
node.visible = valueOf(el, 'visible') !== false;
|
|
140
|
+
node.collide = v.collide !== false;
|
|
141
|
+
node.material = v.material !== undefined ? v.material : null;
|
|
142
|
+
node.color = v.color !== undefined ? v.color : null;
|
|
143
|
+
const position = valueOf(el, 'position') || [0, 0, 0];
|
|
144
|
+
node.base = {
|
|
145
|
+
position,
|
|
146
|
+
rotation: valueOf(el, 'rotation') || [0, 0, 0],
|
|
147
|
+
scale: valueOf(el, 'scale') || [1, 1, 1],
|
|
148
|
+
};
|
|
149
|
+
if (kind === 'camera') {
|
|
150
|
+
// a camera without a target looks down -Z: start a target animation from there
|
|
151
|
+
node.base.target = v.target || [position[0], position[1], position[2] - 1];
|
|
152
|
+
node.base.fov = [valueOf(el, 'fov')];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const c of el.children) {
|
|
156
|
+
if (c.type !== 'element') continue;
|
|
157
|
+
if (c.name === 'animate') {
|
|
158
|
+
if (!c.unknown && c.values && c.values.attr) {
|
|
159
|
+
const a = animationFromElement(c, node.base);
|
|
160
|
+
node.anims.push(a);
|
|
161
|
+
animation[a.timeline === 'scroll' ? 'scroll' : 'time'] = true;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (name === 'es3d' || name === 'v3d') {
|
|
166
|
+
if (c.name === 'defs' || c.name === 'title' || c.name === 'desc' || c.name === 'environment' || c.name === 'material') continue;
|
|
167
|
+
}
|
|
168
|
+
if (c.name === 'material' || c.name === 'environment' || c.name === 'controls') continue;
|
|
169
|
+
const child = nodeFor(c, depth + 1);
|
|
170
|
+
if (child) node.children.push(child);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
switch (kind) {
|
|
174
|
+
case 'shape': {
|
|
175
|
+
stats.shapes++;
|
|
176
|
+
let desc = null;
|
|
177
|
+
try {
|
|
178
|
+
desc = describeShape(el, {});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
report(el, 'error', 'E_GEOMETRY', `Cannot describe <${name}>: ${err.message}`, 'geometry');
|
|
181
|
+
}
|
|
182
|
+
if (desc) {
|
|
183
|
+
node.geometry = cache.acquire(desc, el);
|
|
184
|
+
node.unit = desc.unit && !isIdentity(desc.unit) ? desc.unit : null;
|
|
185
|
+
} else if (!diagnostics.some((d) => d.element === el)) {
|
|
186
|
+
report(el, 'error', 'E_GEOMETRY', `<${name}> has no usable geometry source.`, 'geometry');
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case 'use':
|
|
191
|
+
stats.uses++;
|
|
192
|
+
if (el.target && !el.circular) node.target = nodeFor(el.target, depth + 1);
|
|
193
|
+
break;
|
|
194
|
+
case 'repeat':
|
|
195
|
+
node.count = v.count || 0;
|
|
196
|
+
node.step = v.step ? v.step.matrix : mat4.createF64();
|
|
197
|
+
break;
|
|
198
|
+
case 'scatter':
|
|
199
|
+
node.params = {
|
|
200
|
+
grid: v.grid || null,
|
|
201
|
+
count: v.grid ? v.grid[0] * v.grid[1] : v.count || 0,
|
|
202
|
+
spacing: valueOf(el, 'spacing'),
|
|
203
|
+
area: v.area || null,
|
|
204
|
+
radius: v.radius || null,
|
|
205
|
+
seed: valueOf(el, 'seed'),
|
|
206
|
+
jitter: valueOf(el, 'jitter'),
|
|
207
|
+
randomScale: v.randomScale || null,
|
|
208
|
+
randomRotation: v.randomRotation || null,
|
|
209
|
+
randomMaterial: v.randomMaterial || null,
|
|
210
|
+
};
|
|
211
|
+
break;
|
|
212
|
+
case 'lod':
|
|
213
|
+
node.params = { distances: v.distances || [] };
|
|
214
|
+
break;
|
|
215
|
+
case 'light': {
|
|
216
|
+
stats.lights++;
|
|
217
|
+
const m = mat4.createF64();
|
|
218
|
+
if (v.transform) m.set(v.transform.matrix);
|
|
219
|
+
node.params = {
|
|
220
|
+
type: v.type,
|
|
221
|
+
color: valueOf(el, 'color'),
|
|
222
|
+
intensity: valueOf(el, 'intensity'),
|
|
223
|
+
position: valueOf(el, 'position'),
|
|
224
|
+
rotation: v.rotation || null,
|
|
225
|
+
direction: v.direction || null,
|
|
226
|
+
target: v.target || null,
|
|
227
|
+
range: valueOf(el, 'range'),
|
|
228
|
+
angle: valueOf(el, 'angle'),
|
|
229
|
+
penumbra: valueOf(el, 'penumbra'),
|
|
230
|
+
groundColor: valueOf(el, 'groundColor'),
|
|
231
|
+
transform: v.transform ? m : null,
|
|
232
|
+
};
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'camera': {
|
|
236
|
+
stats.cameras++;
|
|
237
|
+
node.params = {
|
|
238
|
+
type: valueOf(el, 'type'),
|
|
239
|
+
position: valueOf(el, 'position'),
|
|
240
|
+
target: v.target || null,
|
|
241
|
+
rotation: v.rotation || null,
|
|
242
|
+
up: valueOf(el, 'up'),
|
|
243
|
+
fov: valueOf(el, 'fov'),
|
|
244
|
+
height: valueOf(el, 'height'),
|
|
245
|
+
near: v.near || null,
|
|
246
|
+
far: v.far || null,
|
|
247
|
+
aspect: valueOf(el, 'aspect'),
|
|
248
|
+
fit: valueOf(el, 'fit'),
|
|
249
|
+
transform: v.transform ? v.transform.matrix : null,
|
|
250
|
+
name: v.name || v.id || `camera ${stats.cameras}`,
|
|
251
|
+
};
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
return node;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const root = doc.root ? nodeFor(doc.root) : null;
|
|
261
|
+
cache.endBuild();
|
|
262
|
+
|
|
263
|
+
// expanded instance count (memoised per node) guards against accidental explosions
|
|
264
|
+
const counts = new Map();
|
|
265
|
+
const countOf = (node) => {
|
|
266
|
+
if (!node) return 0;
|
|
267
|
+
if (counts.has(node)) return counts.get(node);
|
|
268
|
+
counts.set(node, 0); // cycle guard
|
|
269
|
+
let n = 0;
|
|
270
|
+
const kids = () => node.children.reduce((s, c) => s + countOf(c), 0);
|
|
271
|
+
switch (node.kind) {
|
|
272
|
+
case 'shape':
|
|
273
|
+
n = node.geometry ? 1 : 0;
|
|
274
|
+
break;
|
|
275
|
+
case 'use':
|
|
276
|
+
n = countOf(node.target);
|
|
277
|
+
break;
|
|
278
|
+
case 'repeat':
|
|
279
|
+
n = node.count * kids();
|
|
280
|
+
break;
|
|
281
|
+
case 'scatter':
|
|
282
|
+
n = node.params.count * kids();
|
|
283
|
+
break;
|
|
284
|
+
case 'light':
|
|
285
|
+
case 'camera':
|
|
286
|
+
n = 0;
|
|
287
|
+
break;
|
|
288
|
+
default:
|
|
289
|
+
n = kids();
|
|
290
|
+
}
|
|
291
|
+
if (!node.visible) n = 0;
|
|
292
|
+
counts.set(node, n);
|
|
293
|
+
return n;
|
|
294
|
+
};
|
|
295
|
+
const expanded = countOf(root);
|
|
296
|
+
stats.expandedInstances = expanded;
|
|
297
|
+
if (expanded > maxInstances) {
|
|
298
|
+
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
|
+
}
|
|
300
|
+
|
|
301
|
+
return { doc, root, materials, environment, controls, animation, cache, diagnostics, stats, tooLarge: expanded > maxInstances, expanded };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Viewer settings from a <controls> element (angles in degrees, as written). */
|
|
305
|
+
function controlsFromElement(el) {
|
|
306
|
+
const v = el.values || {};
|
|
307
|
+
return {
|
|
308
|
+
mode: valueOf(el, 'mode'),
|
|
309
|
+
zoom: valueOf(el, 'zoom'),
|
|
310
|
+
pan: valueOf(el, 'pan'),
|
|
311
|
+
minDistance: v.minDistance ?? null,
|
|
312
|
+
maxDistance: v.maxDistance ?? null,
|
|
313
|
+
minPolar: valueOf(el, 'minPolar'),
|
|
314
|
+
maxPolar: valueOf(el, 'maxPolar'),
|
|
315
|
+
targetMin: v.targetMin && v.targetMax ? v.targetMin : null,
|
|
316
|
+
targetMax: v.targetMin && v.targetMax ? v.targetMax : null,
|
|
317
|
+
collide: valueOf(el, 'collide'),
|
|
318
|
+
uid: el.uid,
|
|
319
|
+
};
|
|
320
|
+
}
|