@es3d/es3d 0.0.0-stage → 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 -0
- package/README.md +85 -2
- package/docs/REFERENCE.md +581 -0
- package/docs/SPEC.md +417 -0
- package/package.json +50 -4
- package/scripts/es3d-cli.mjs +377 -0
- package/src/cache/geometry-store.js +392 -0
- package/src/cache/mesh-bundle.js +123 -0
- package/src/cache/sha256.js +73 -0
- package/src/format/ast.js +238 -0
- package/src/format/colors.js +93 -0
- package/src/format/compact-parser.js +171 -0
- package/src/format/compact-tokenizer.js +165 -0
- package/src/format/errors.js +247 -0
- package/src/format/json.js +55 -0
- package/src/format/limits.js +14 -0
- package/src/format/parse.js +92 -0
- package/src/format/schema.js +617 -0
- package/src/format/serializer.js +333 -0
- package/src/format/validator.js +330 -0
- package/src/format/value-error.js +12 -0
- package/src/format/value-format.js +314 -0
- package/src/format/values.js +459 -0
- package/src/format/xml-parser.js +136 -0
- package/src/format/xml-tokenizer.js +237 -0
- package/src/geometry/contour.js +86 -0
- package/src/geometry/extrude.js +175 -0
- package/src/geometry/gear.js +85 -0
- package/src/geometry/geometry-cache.js +159 -0
- package/src/geometry/lod-error.js +178 -0
- package/src/geometry/mesh.js +458 -0
- package/src/geometry/primitives.js +299 -0
- package/src/geometry/registry.js +645 -0
- package/src/geometry/revolve.js +136 -0
- package/src/geometry/sweep.js +441 -0
- package/src/index.js +47 -0
- package/src/io/compression.js +67 -0
- package/src/io/convert.js +236 -0
- package/src/io/download.js +13 -0
- package/src/io/external-scene.js +127 -0
- package/src/io/file-loader.js +28 -0
- package/src/io/gltf.js +630 -0
- package/src/io/mesh-import.js +104 -0
- package/src/io/obj.js +352 -0
- package/src/io/step-brep.js +439 -0
- package/src/io/step-exact.js +1149 -0
- package/src/io/step.js +1418 -0
- package/src/io/stl.js +193 -0
- package/src/io/svg-import.js +787 -0
- package/src/material/materials.js +154 -0
- package/src/math/mat4.js +475 -0
- package/src/math/random.js +30 -0
- package/src/math/trig.js +65 -0
- package/src/math/vec3.js +81 -0
- package/src/optimize/optimizer.js +579 -0
- package/src/path/path-flatten.js +322 -0
- package/src/path/path-parser.js +332 -0
- package/src/path/shapes.js +129 -0
- package/src/path/triangulate.js +517 -0
- package/src/pipeline.js +74 -0
- package/src/render/camera.js +168 -0
- package/src/render/frame-builder.js +362 -0
- package/src/render/geometry-pool.js +149 -0
- package/src/render/lighting.js +149 -0
- package/src/render/orbit-controls.js +237 -0
- package/src/render/renderer.js +258 -0
- package/src/render/scroll-timeline.js +104 -0
- package/src/render/shaders/glsl.js +285 -0
- package/src/render/shaders/wgsl.js +252 -0
- package/src/render/stage.js +364 -0
- package/src/render/texture-cache.js +40 -0
- package/src/render/textures.js +223 -0
- package/src/render/webgl2/webgl2-backend.js +586 -0
- package/src/render/webgpu/webgpu-backend.js +482 -0
- package/src/scene/animation.js +171 -0
- package/src/scene/camera-constraints.js +117 -0
- package/src/scene/flatten.js +754 -0
- package/src/scene/picking.js +161 -0
- package/src/scene/scene-graph.js +320 -0
- package/src/transform/transform.js +211 -0
- package/src/wasm/kernels-binary.js +3 -0
- package/src/wasm/kernels.js +385 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// CPU ray picking against the instance table.
|
|
2
|
+
//
|
|
3
|
+
// Each instance is tested with a world-space bounding-sphere check, then the
|
|
4
|
+
// ray is transformed into the instance's canonical mesh space (inverse world
|
|
5
|
+
// matrix) and tested against the canonical bounds and finally the triangles
|
|
6
|
+
// (Möller–Trumbore). Because instancing keeps one canonical mesh per
|
|
7
|
+
// geometry, picking cost scales with visible instances, not with triangles.
|
|
8
|
+
|
|
9
|
+
import * as mat4 from '../math/mat4.js';
|
|
10
|
+
|
|
11
|
+
function raySphere(ox, oy, oz, dx, dy, dz, cx, cy, cz, r) {
|
|
12
|
+
const lx = cx - ox, ly = cy - oy, lz = cz - oz;
|
|
13
|
+
const tca = lx * dx + ly * dy + lz * dz;
|
|
14
|
+
const d2 = lx * lx + ly * ly + lz * lz - tca * tca;
|
|
15
|
+
if (d2 > r * r) return Infinity;
|
|
16
|
+
const thc = Math.sqrt(r * r - d2);
|
|
17
|
+
const t0 = tca - thc;
|
|
18
|
+
const t1 = tca + thc;
|
|
19
|
+
if (t1 < 0) return Infinity;
|
|
20
|
+
return t0 < 0 ? 0 : t0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rayBox(o, d, b) {
|
|
24
|
+
let tmin = -Infinity, tmax = Infinity;
|
|
25
|
+
for (let k = 0; k < 3; k++) {
|
|
26
|
+
const pad = 1e-6 + (b.max[k] - b.min[k]) * 1e-4;
|
|
27
|
+
const lo = b.min[k] - pad, hi = b.max[k] + pad;
|
|
28
|
+
if (Math.abs(d[k]) < 1e-20) {
|
|
29
|
+
if (o[k] < lo || o[k] > hi) return false;
|
|
30
|
+
} else {
|
|
31
|
+
let t1 = (lo - o[k]) / d[k];
|
|
32
|
+
let t2 = (hi - o[k]) / d[k];
|
|
33
|
+
if (t1 > t2) [t1, t2] = [t2, t1];
|
|
34
|
+
tmin = Math.max(tmin, t1);
|
|
35
|
+
tmax = Math.min(tmax, t2);
|
|
36
|
+
if (tmin > tmax) return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return tmax >= 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let hitTriangle = -1; // first index of the triangle found by the last rayTriangles call
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Nearest triangle hit closer than `best`. With `facing` (+1 or -1) only
|
|
46
|
+
* triangles whose front side faces the ray origin count: det > 0 means the
|
|
47
|
+
* ray meets the counter-clockwise side, and `facing` is -1 for mirrored instances.
|
|
48
|
+
*/
|
|
49
|
+
function rayTriangles(o, d, mesh, best, facing = 0) {
|
|
50
|
+
const p = mesh.positions;
|
|
51
|
+
const idx = mesh.indices;
|
|
52
|
+
let tBest = best;
|
|
53
|
+
hitTriangle = -1;
|
|
54
|
+
for (let i = 0; i < idx.length; i += 3) {
|
|
55
|
+
const a = idx[i] * 3, b = idx[i + 1] * 3, c = idx[i + 2] * 3;
|
|
56
|
+
const e1x = p[b] - p[a], e1y = p[b + 1] - p[a + 1], e1z = p[b + 2] - p[a + 2];
|
|
57
|
+
const e2x = p[c] - p[a], e2y = p[c + 1] - p[a + 1], e2z = p[c + 2] - p[a + 2];
|
|
58
|
+
const px = d[1] * e2z - d[2] * e2y, py = d[2] * e2x - d[0] * e2z, pz = d[0] * e2y - d[1] * e2x;
|
|
59
|
+
const det = e1x * px + e1y * py + e1z * pz;
|
|
60
|
+
if (Math.abs(det) < 1e-18) continue;
|
|
61
|
+
if (facing && det * facing < 0) continue;
|
|
62
|
+
const inv = 1 / det;
|
|
63
|
+
const tx = o[0] - p[a], ty = o[1] - p[a + 1], tz = o[2] - p[a + 2];
|
|
64
|
+
const u = (tx * px + ty * py + tz * pz) * inv;
|
|
65
|
+
if (u < 0 || u > 1) continue;
|
|
66
|
+
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x;
|
|
67
|
+
const v = (d[0] * qx + d[1] * qy + d[2] * qz) * inv;
|
|
68
|
+
if (v < 0 || u + v > 1) continue;
|
|
69
|
+
const t = (e2x * qx + e2y * qy + e2z * qz) * inv;
|
|
70
|
+
if (t > 1e-9 && t < tBest) {
|
|
71
|
+
tBest = t;
|
|
72
|
+
hitTriangle = i;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return tBest;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Nearest surface along a ray.
|
|
80
|
+
* @param {import('./flatten.js').RenderScene} scene
|
|
81
|
+
* @param {(entry:object, lod:number)=>import('../geometry/mesh.js').Mesh} meshOf
|
|
82
|
+
* @param {number[]} origin world ray origin
|
|
83
|
+
* @param {number[]} dir world ray direction (normalized)
|
|
84
|
+
* @param {{isVisible?: (i:number)=>boolean, maxDistance?: number, skipFlags?: number, facing?: boolean}} [opts]
|
|
85
|
+
* skipFlags: instances with any of these flag bits are ignored. facing: only
|
|
86
|
+
* surfaces whose front side faces the origin (double-sided geometry always counts).
|
|
87
|
+
* @returns {{index:number, uid:number, distance:number, point:number[], normal:number[]}|null}
|
|
88
|
+
* normal is the world-space surface normal turned against the ray.
|
|
89
|
+
*/
|
|
90
|
+
export function raycast(scene, meshOf, origin, dir, opts = {}) {
|
|
91
|
+
const { isVisible = null, maxDistance = Infinity, skipFlags = 0, facing = false } = opts;
|
|
92
|
+
const cands = [];
|
|
93
|
+
const S = scene.spheres;
|
|
94
|
+
for (let i = 0; i < scene.count; i++) {
|
|
95
|
+
if (skipFlags && scene.flags[i] & skipFlags) continue;
|
|
96
|
+
if (isVisible && !isVisible(i)) continue;
|
|
97
|
+
const entry = scene.geometries[scene.geom[i]];
|
|
98
|
+
if (entry.kind !== 'triangles') continue;
|
|
99
|
+
const t = raySphere(origin[0], origin[1], origin[2], dir[0], dir[1], dir[2], S[i * 4], S[i * 4 + 1], S[i * 4 + 2], S[i * 4 + 3]);
|
|
100
|
+
if (t < maxDistance) cands.push([t, i]);
|
|
101
|
+
}
|
|
102
|
+
cands.sort((a, b) => a[0] - b[0]);
|
|
103
|
+
let best = maxDistance;
|
|
104
|
+
let bestIndex = -1;
|
|
105
|
+
let bestTriangle = -1;
|
|
106
|
+
const normal = [0, 0, 0];
|
|
107
|
+
const m = new Float64Array(16);
|
|
108
|
+
const inv = new Float64Array(16);
|
|
109
|
+
const lo = [0, 0, 0];
|
|
110
|
+
const ld = [0, 0, 0];
|
|
111
|
+
for (const [tSphere, i] of cands) {
|
|
112
|
+
if (tSphere > best) break;
|
|
113
|
+
for (let k = 0; k < 16; k++) m[k] = scene.matrices[i * 16 + k];
|
|
114
|
+
if (!mat4.invert(inv, m)) continue;
|
|
115
|
+
mat4.transformPoint(lo, inv, origin[0], origin[1], origin[2]);
|
|
116
|
+
mat4.transformDir(ld, inv, dir[0], dir[1], dir[2]);
|
|
117
|
+
const entry = scene.geometries[scene.geom[i]];
|
|
118
|
+
if (entry.bounds && !rayBox(lo, ld, entry.bounds)) continue;
|
|
119
|
+
const mesh = meshOf(entry, 0);
|
|
120
|
+
const material = scene.materials[scene.mat[i]];
|
|
121
|
+
const twoSided = entry.doubleSided || (material && material.doubleSided);
|
|
122
|
+
const front = facing && !twoSided ? (scene.flags[i] & 1 ? -1 : 1) : 0;
|
|
123
|
+
// t is in world units because the direction was transformed linearly (not renormalized)
|
|
124
|
+
const t = rayTriangles(lo, ld, mesh, best, front);
|
|
125
|
+
if (t < best) {
|
|
126
|
+
best = t;
|
|
127
|
+
bestIndex = i;
|
|
128
|
+
bestTriangle = hitTriangle;
|
|
129
|
+
// triangle normal to world space with the inverse transpose
|
|
130
|
+
const p = mesh.positions, idx = mesh.indices;
|
|
131
|
+
const a = idx[bestTriangle] * 3, b = idx[bestTriangle + 1] * 3, c = idx[bestTriangle + 2] * 3;
|
|
132
|
+
const e1 = [p[b] - p[a], p[b + 1] - p[a + 1], p[b + 2] - p[a + 2]];
|
|
133
|
+
const e2 = [p[c] - p[a], p[c + 1] - p[a + 1], p[c + 2] - p[a + 2]];
|
|
134
|
+
const n = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
|
|
135
|
+
for (let k = 0; k < 3; k++) normal[k] = inv[k * 4] * n[0] + inv[k * 4 + 1] * n[1] + inv[k * 4 + 2] * n[2];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (bestIndex < 0) return null;
|
|
139
|
+
const nl = Math.hypot(normal[0], normal[1], normal[2]) || 1;
|
|
140
|
+
const sign = normal[0] * dir[0] + normal[1] * dir[1] + normal[2] * dir[2] > 0 ? -1 : 1;
|
|
141
|
+
return {
|
|
142
|
+
index: bestIndex,
|
|
143
|
+
uid: scene.uid[bestIndex],
|
|
144
|
+
distance: best,
|
|
145
|
+
point: [origin[0] + dir[0] * best, origin[1] + dir[1] * best, origin[2] + dir[2] * best],
|
|
146
|
+
normal: [(normal[0] / nl) * sign, (normal[1] / nl) * sign, (normal[2] / nl) * sign],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Element under a ray (for selection).
|
|
152
|
+
* @param {import('./flatten.js').RenderScene} scene
|
|
153
|
+
* @param {(entry:object, lod:number)=>import('../geometry/mesh.js').Mesh} meshOf
|
|
154
|
+
* @param {number[]} origin world ray origin
|
|
155
|
+
* @param {number[]} dir world ray direction (normalized)
|
|
156
|
+
* @param {(i:number)=>boolean} [isVisible]
|
|
157
|
+
* @returns {{index:number, uid:number, distance:number, point:number[], normal:number[]}|null}
|
|
158
|
+
*/
|
|
159
|
+
export function pick(scene, meshOf, origin, dir, isVisible = null) {
|
|
160
|
+
return raycast(scene, meshOf, origin, dir, { isVisible });
|
|
161
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The ES3D transform system.
|
|
2
|
+
//
|
|
3
|
+
// transform="translate(0,2,0) rotate(0,45,0) scale(2)" is read exactly like
|
|
4
|
+
// SVG: functions are applied left to right as nested coordinate systems, so the
|
|
5
|
+
// resulting matrix is T1 * T2 * T3 (the rightmost function acts on the object
|
|
6
|
+
// first). Supported functions:
|
|
7
|
+
//
|
|
8
|
+
// translate(x[,y[,z]]) rotate(x,y,z) Euler degrees (X, then Y, then Z)
|
|
9
|
+
// rotateX(a) rotateY(a) rotateZ(a) rotate(a, ax,ay,az) axis-angle
|
|
10
|
+
// scale(s) | scale(x,y,z) quat(x,y,z,w)
|
|
11
|
+
// matrix(m0..m15) 16 numbers, column-major (like CSS matrix3d)
|
|
12
|
+
//
|
|
13
|
+
// Elements additionally accept position / rotation / scale attributes. The
|
|
14
|
+
// local matrix of an element is transform * T(position) * R(rotation) * S(scale)
|
|
15
|
+
// mirroring SVG <use>, where x/y are applied after (inside) the transform.
|
|
16
|
+
|
|
17
|
+
import * as mat4 from '../math/mat4.js';
|
|
18
|
+
import { ValueError } from '../format/value-error.js';
|
|
19
|
+
import { suggest } from '../format/errors.js';
|
|
20
|
+
import { readNumber, numberValue } from '../path/path-parser.js';
|
|
21
|
+
|
|
22
|
+
const FUNCS = {
|
|
23
|
+
translate: [1, 3],
|
|
24
|
+
rotate: [3, 4],
|
|
25
|
+
rotateX: [1, 1],
|
|
26
|
+
rotateY: [1, 1],
|
|
27
|
+
rotateZ: [1, 1],
|
|
28
|
+
scale: [1, 3],
|
|
29
|
+
matrix: [16, 16],
|
|
30
|
+
quat: [4, 4],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function isWs(c) {
|
|
34
|
+
return c === 32 || c === 9 || c === 10 || c === 13 || c === 44; // comma acts as separator between functions
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse a transform list into operations (validated, with offsets). */
|
|
38
|
+
export function parseTransformOps(str) {
|
|
39
|
+
const ops = [];
|
|
40
|
+
const n = str.length;
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (true) {
|
|
43
|
+
while (i < n && isWs(str.charCodeAt(i))) i++;
|
|
44
|
+
if (i >= n) break;
|
|
45
|
+
const nameStart = i;
|
|
46
|
+
while (i < n && /[A-Za-z0-9]/.test(str[i])) i++;
|
|
47
|
+
const fn = str.slice(nameStart, i);
|
|
48
|
+
if (!fn) throw new ValueError(`Unexpected character '${str[i]}' in transform.`, i, 1);
|
|
49
|
+
if (!(fn in FUNCS)) {
|
|
50
|
+
throw new ValueError(`Unknown transform function '${fn}'.`, nameStart, fn.length, {
|
|
51
|
+
suggestion: suggest(fn, Object.keys(FUNCS)),
|
|
52
|
+
hint: 'Valid functions: translate, rotate, rotateX, rotateY, rotateZ, scale, matrix, quat.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
while (i < n && (str[i] === ' ' || str[i] === '\t')) i++;
|
|
56
|
+
if (str[i] !== '(') throw new ValueError(`Expected '(' after '${fn}'.`, i, 1);
|
|
57
|
+
i++;
|
|
58
|
+
const args = [];
|
|
59
|
+
for (;;) {
|
|
60
|
+
while (i < n && /[\s,]/.test(str[i])) i++;
|
|
61
|
+
if (str[i] === ')') {
|
|
62
|
+
i++;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (i >= n) throw new ValueError(`Missing ')' to close '${fn}('.`, nameStart, fn.length + 1);
|
|
66
|
+
const end = readNumber(str, i);
|
|
67
|
+
if (end < 0) throw new ValueError(`Expected a number in '${fn}()' but found '${str[i]}'.`, i, 1);
|
|
68
|
+
args.push(numberValue);
|
|
69
|
+
i = end;
|
|
70
|
+
}
|
|
71
|
+
const [min, max] = FUNCS[fn];
|
|
72
|
+
if (args.length < min || args.length > max) {
|
|
73
|
+
let hint;
|
|
74
|
+
if (fn === 'rotate' && args.length === 1) hint = 'rotate() takes Euler angles rotate(x,y,z) or an axis-angle rotate(a,ax,ay,az); use rotateY(a) for a single angle.';
|
|
75
|
+
if (fn === 'rotate' && args.length === 2) hint = 'rotate() takes 3 Euler angles in degrees, e.g. rotate(0,45,0).';
|
|
76
|
+
throw new ValueError(
|
|
77
|
+
`${fn}() takes ${min === max ? min : `${min} to ${max}`} numbers but ${args.length} ${args.length === 1 ? 'was' : 'were'} given.`,
|
|
78
|
+
nameStart,
|
|
79
|
+
i - nameStart,
|
|
80
|
+
{ hint },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (fn === 'rotate' && args.length === 4 && Math.hypot(args[1], args[2], args[3]) < 1e-12) {
|
|
84
|
+
throw new ValueError('rotate(angle, ax, ay, az): the rotation axis must not be zero.', nameStart, i - nameStart);
|
|
85
|
+
}
|
|
86
|
+
if (fn === 'scale' && args.length === 2) {
|
|
87
|
+
throw new ValueError('scale() takes 1 (uniform) or 3 numbers.', nameStart, i - nameStart);
|
|
88
|
+
}
|
|
89
|
+
ops.push({ fn, args, offset: nameStart });
|
|
90
|
+
}
|
|
91
|
+
return ops;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Matrix of a single transform operation. */
|
|
95
|
+
export function opMatrix(op, out = mat4.createF64()) {
|
|
96
|
+
const a = op.args;
|
|
97
|
+
switch (op.fn) {
|
|
98
|
+
case 'translate':
|
|
99
|
+
return mat4.fromTranslation(out, a[0], a[1] || 0, a[2] || 0);
|
|
100
|
+
case 'rotate':
|
|
101
|
+
if (a.length === 4) return mat4.fromAxisAngle(out, a[1], a[2], a[3], a[0] * mat4.DEG);
|
|
102
|
+
return mat4.fromEulerDeg(out, a[0], a[1], a[2]);
|
|
103
|
+
case 'rotateX':
|
|
104
|
+
return mat4.fromRotationX(out, a[0] * mat4.DEG);
|
|
105
|
+
case 'rotateY':
|
|
106
|
+
return mat4.fromRotationY(out, a[0] * mat4.DEG);
|
|
107
|
+
case 'rotateZ':
|
|
108
|
+
return mat4.fromRotationZ(out, a[0] * mat4.DEG);
|
|
109
|
+
case 'scale':
|
|
110
|
+
return a.length === 1 ? mat4.fromScaling(out, a[0], a[0], a[0]) : mat4.fromScaling(out, a[0], a[1], a[2]);
|
|
111
|
+
case 'matrix':
|
|
112
|
+
for (let k = 0; k < 16; k++) out[k] = a[k];
|
|
113
|
+
return out;
|
|
114
|
+
case 'quat':
|
|
115
|
+
return mat4.fromQuat(out, a[0], a[1], a[2], a[3]);
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`unknown transform ${op.fn}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function opsToMatrix(ops) {
|
|
122
|
+
const m = mat4.createF64();
|
|
123
|
+
const t = mat4.createF64();
|
|
124
|
+
for (const op of ops) {
|
|
125
|
+
opMatrix(op, t);
|
|
126
|
+
mat4.multiply(m, m, t);
|
|
127
|
+
}
|
|
128
|
+
return m;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* {ops, matrix} for already separated operations (binary decoding), applying
|
|
133
|
+
* the same rules as the text parser; null when an operation is invalid.
|
|
134
|
+
*/
|
|
135
|
+
export function transformFromOps(ops) {
|
|
136
|
+
for (const op of ops) {
|
|
137
|
+
const range = FUNCS[op.fn];
|
|
138
|
+
if (!range) return null;
|
|
139
|
+
const n = op.args.length;
|
|
140
|
+
if (n < range[0] || n > range[1] || !op.args.every(Number.isFinite)) return null;
|
|
141
|
+
if (op.fn === 'rotate' && n === 4 && Math.hypot(op.args[1], op.args[2], op.args[3]) < 1e-12) return null;
|
|
142
|
+
if (op.fn === 'scale' && n === 2) return null;
|
|
143
|
+
}
|
|
144
|
+
return { ops, matrix: opsToMatrix(ops) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parse a transform attribute value into {ops, matrix}. */
|
|
148
|
+
export function parseTransform(str) {
|
|
149
|
+
const ops = parseTransformOps(str);
|
|
150
|
+
return { ops, matrix: opsToMatrix(ops) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ZERO = [0, 0, 0];
|
|
154
|
+
const ONE = [1, 1, 1];
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Local matrix of an element from its typed values:
|
|
158
|
+
* transform * T(position) * R(rotation) * S(scale)
|
|
159
|
+
*/
|
|
160
|
+
export function localMatrix(values, out = mat4.createF64(), overrides = null) {
|
|
161
|
+
const position = (overrides && overrides.position) || values.position || ZERO;
|
|
162
|
+
const rotation = (overrides && overrides.rotation) || values.rotation || ZERO;
|
|
163
|
+
const scale = (overrides && overrides.scale) || values.scale || ONE;
|
|
164
|
+
mat4.compose(out, position, rotation, scale);
|
|
165
|
+
const tr = values.transform;
|
|
166
|
+
if (tr && tr.ops.length) mat4.multiply(out, tr.matrix, out);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Format a matrix as a transform string (used by serializers / expansion). */
|
|
171
|
+
export function matrixToTransformString(m, fmt) {
|
|
172
|
+
const d = mat4.decompose(m);
|
|
173
|
+
if (d) {
|
|
174
|
+
const parts = [];
|
|
175
|
+
const [tx, ty, tz] = d.translation;
|
|
176
|
+
if (Math.abs(tx) > 1e-9 || Math.abs(ty) > 1e-9 || Math.abs(tz) > 1e-9) parts.push(`translate(${fmt(tx)},${fmt(ty)},${fmt(tz)})`);
|
|
177
|
+
const [qx, qy, qz, qw] = d.rotation;
|
|
178
|
+
if (Math.abs(qx) > 1e-9 || Math.abs(qy) > 1e-9 || Math.abs(qz) > 1e-9) parts.push(`quat(${fmt(qx)},${fmt(qy)},${fmt(qz)},${fmt(qw)})`);
|
|
179
|
+
const [sx, sy, sz] = d.scale;
|
|
180
|
+
if (Math.abs(sx - 1) > 1e-9 || Math.abs(sy - 1) > 1e-9 || Math.abs(sz - 1) > 1e-9) {
|
|
181
|
+
if (Math.abs(sx - sy) < 1e-9 && Math.abs(sy - sz) < 1e-9) parts.push(`scale(${fmt(sx)})`);
|
|
182
|
+
else parts.push(`scale(${fmt(sx)},${fmt(sy)},${fmt(sz)})`);
|
|
183
|
+
}
|
|
184
|
+
return parts.join(' ');
|
|
185
|
+
}
|
|
186
|
+
return `matrix(${Array.from(m, fmt).join(',')})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Decompose a shear-free affine matrix into translation, ES3D Euler angles
|
|
191
|
+
* (degrees, R = Rz * Ry * Rx) and scale. Returns null when shear is present.
|
|
192
|
+
*/
|
|
193
|
+
export function matrixToEuler(m) {
|
|
194
|
+
const d = mat4.decompose(m);
|
|
195
|
+
if (!d) return null;
|
|
196
|
+
const [sx, sy, sz] = d.scale;
|
|
197
|
+
const r = [m[0] / sx, m[1] / sx, m[2] / sx, m[4] / sy, m[5] / sy, m[6] / sy, m[8] / sz, m[9] / sz, m[10] / sz];
|
|
198
|
+
const R2 = Math.max(-1, Math.min(1, -r[2]));
|
|
199
|
+
const y = Math.asin(R2);
|
|
200
|
+
let x, z;
|
|
201
|
+
if (Math.abs(R2) < 0.9999999) {
|
|
202
|
+
x = Math.atan2(r[5], r[8]);
|
|
203
|
+
z = Math.atan2(r[1], r[0]);
|
|
204
|
+
} else {
|
|
205
|
+
// gimbal lock: rotation about X and Z are coupled; put everything into Z
|
|
206
|
+
x = 0;
|
|
207
|
+
z = Math.atan2(-r[3], r[4]);
|
|
208
|
+
}
|
|
209
|
+
const deg = (v) => (v * 180) / Math.PI;
|
|
210
|
+
return { translation: d.translation, rotation: [deg(x), deg(y), deg(z)], scale: d.scale };
|
|
211
|
+
}
|