@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.
- package/README.md +16 -5
- package/browser/es3d-stage.min.js +10349 -0
- package/package.json +4 -2
- package/scripts/es3d-cli.mjs +28 -5
- package/src/index.js +10 -3
- package/src/io/convert.js +53 -5
- package/src/io/export-model.js +679 -0
- package/src/io/file-loader.js +3 -2
- package/src/io/openscad-import.js +1295 -0
- package/src/io/openscad.js +252 -0
- package/src/io/text-encode.js +87 -0
- package/src/io/threejs.js +397 -0
- package/src/io/usd.js +669 -0
- package/src/io/x3d.js +1183 -0
- package/src/render/stage.js +85 -10
- package/src/scene/flatten.js +208 -4
- package/src/scene/scene-graph.js +13 -2
- package/src/stage.js +8 -0
package/src/io/x3d.js
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
// X3D 4, Classic VRML encoding.
|
|
2
|
+
//
|
|
3
|
+
// Specification: ISO/IEC 19775-1:2023 (X3D Architecture, version 4) and the
|
|
4
|
+
// Classic VRML encoding of ISO/IEC 19776-2. The header is `#X3D V4.0 utf8`,
|
|
5
|
+
// the form X_ITE and the other current browsers accept. XML (19776-1) is a
|
|
6
|
+
// larger encoding of the same graph. Binary (19776-3) is not read by the
|
|
7
|
+
// usual web viewers, so it is not a fair size baseline. JSON (19776-5) is
|
|
8
|
+
// not yet an International Standard.
|
|
9
|
+
//
|
|
10
|
+
// Geometry (clause 13):
|
|
11
|
+
// Box size is the full edge lengths, default 2 2 2, centred (§13.3.1).
|
|
12
|
+
// Sphere radius defaults to 1 (§13.3.7).
|
|
13
|
+
// Cylinder radius 1, height 2, along Y, caps at ±height/2 (§13.3.3).
|
|
14
|
+
// Cone bottomRadius 1, height 2, apex at +height/2, bottom at −height/2 (§13.3.2).
|
|
15
|
+
// Extrusion (§13.3.5): crossSection is in the Y=0 plane as (x, z). A straight
|
|
16
|
+
// spine along +Y is collinear, so the spine-aligned plane is the rotation of
|
|
17
|
+
// +Y onto that spine, which is the identity (§13.3.5.4.3). A closed spine
|
|
18
|
+
// repeats its first point; the first and last planes match (§13.3.5.3 b, e).
|
|
19
|
+
// IndexedFaceSet is counter-clockwise from the outside when ccw is TRUE
|
|
20
|
+
// (§13.3.6, §11.2.3). ccw FALSE reverses each face. A face list that does
|
|
21
|
+
// not end in −1 still keeps its last face.
|
|
22
|
+
// Import tessellates an Extrusion in the node's coordinates and writes a
|
|
23
|
+
// mesh, so a parent rotation is applied once. A spine along Y uses the
|
|
24
|
+
// identity frame; a closed spine uses the frame above. IndexedFaceSet and
|
|
25
|
+
// IndexedLineSet are read from Classic and from XML. A line set becomes
|
|
26
|
+
// polylines. XML lights and viewpoints use the same mapping as Classic.
|
|
27
|
+
// Fields that equal those defaults (size 2 2 2, radius 1, height 2, solid TRUE,
|
|
28
|
+
// ccw TRUE, caps TRUE) are left out. creaseAngle 0.8 is written because the
|
|
29
|
+
// default is 0, which would facet every edge.
|
|
30
|
+
// There is no capsule, torus, plane or repeat. A capsule is an open cylinder
|
|
31
|
+
// plus two spheres. A full torus and a revolve are Extrusion nodes. A plane is
|
|
32
|
+
// two triangles. Repeat and scatter are expanded; geometry is still DEF/USE.
|
|
33
|
+
//
|
|
34
|
+
// Materials (clause 12): PhysicalMaterial is the glTF model. Its defaults are
|
|
35
|
+
// baseColor 1 1 1, metallic 1, roughness 1 and transparency 0, so baseColor,
|
|
36
|
+
// metallic and roughness are written (an ES3D material does not use those
|
|
37
|
+
// defaults). transparency is written only when it is not 0. baseColor is
|
|
38
|
+
// linear. transparency is 1 − opacity.
|
|
39
|
+
// UnlitMaterial carries the colour as emissiveColor. emissiveColor is limited
|
|
40
|
+
// to 0..1; a brighter colour is clamped and counted in the notes.
|
|
41
|
+
|
|
42
|
+
import * as mat4 from '../math/mat4.js';
|
|
43
|
+
import { linearToSrgb, colorToShortHex } from '../format/colors.js';
|
|
44
|
+
import { matrixToTransformString } from '../transform/transform.js';
|
|
45
|
+
import { n, numList, trsOf, axisAngle, metresPerUnit } from './text-encode.js';
|
|
46
|
+
import { repeatCopies, scatterCopies, paint } from './export-model.js';
|
|
47
|
+
|
|
48
|
+
const NO_OWN = { material: null, color: null };
|
|
49
|
+
const UNIT_NAME = { cm: 'Centimeters', mm: 'Millimeters', km: 'Kilometers', in: 'Inches', ft: 'Feet' };
|
|
50
|
+
|
|
51
|
+
function bool(v) {
|
|
52
|
+
return v ? 'TRUE' : 'FALSE';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An X3D field, or nothing when the value is the specification default. */
|
|
56
|
+
function keep(name, value, fallback) {
|
|
57
|
+
return value === fallback ? '' : ` ${name} ${value}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rgb(c) {
|
|
61
|
+
return `${n(c[0])} ${n(c[1])} ${n(c[2])}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Closed-spine point of an Extrusion, ISO/IEC 19775-1:2023 §13.3.5.3. `spine` repeats its first point. */
|
|
65
|
+
export function extrusionVertex(spine, sx, sz, index) {
|
|
66
|
+
const nPts = spine.length / 3;
|
|
67
|
+
const at = (i) => [spine[i * 3], spine[i * 3 + 1], spine[i * 3 + 2]];
|
|
68
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
69
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
70
|
+
const norm = (a) => {
|
|
71
|
+
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
72
|
+
return [a[0] / l, a[1] / l, a[2] / l];
|
|
73
|
+
};
|
|
74
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
75
|
+
const last = nPts - 1;
|
|
76
|
+
const i = index === last ? 0 : index;
|
|
77
|
+
const yAxis = norm(i === 0 ? sub(at(1), at(last - 1)) : sub(at(i + 1), at(i - 1)));
|
|
78
|
+
let zAxis;
|
|
79
|
+
if (i === 0) zAxis = cross(sub(at(1), at(0)), sub(at(last - 1), at(0)));
|
|
80
|
+
else zAxis = cross(sub(at(i + 1), at(i)), sub(at(i - 1), at(i)));
|
|
81
|
+
zAxis = norm(zAxis);
|
|
82
|
+
if (i > 0) {
|
|
83
|
+
const prev = extrusionVertex._z(spine, i - 1);
|
|
84
|
+
if (dot(zAxis, prev) < 0) zAxis = [-zAxis[0], -zAxis[1], -zAxis[2]];
|
|
85
|
+
}
|
|
86
|
+
extrusionVertex._cache = extrusionVertex._cache || new Map();
|
|
87
|
+
const xAxis = norm(cross(yAxis, zAxis));
|
|
88
|
+
const p = at(i);
|
|
89
|
+
return [p[0] + sx * xAxis[0] + sz * zAxis[0], p[1] + sx * xAxis[1] + sz * zAxis[1], p[2] + sx * xAxis[2] + sz * zAxis[2]];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
extrusionVertex._z = function zOnly(spine, index) {
|
|
93
|
+
const nPts = spine.length / 3;
|
|
94
|
+
const at = (i) => [spine[i * 3], spine[i * 3 + 1], spine[i * 3 + 2]];
|
|
95
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
96
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
97
|
+
const norm = (a) => {
|
|
98
|
+
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
99
|
+
return [a[0] / l, a[1] / l, a[2] / l];
|
|
100
|
+
};
|
|
101
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
102
|
+
const last = nPts - 1;
|
|
103
|
+
const i = index === last ? 0 : index;
|
|
104
|
+
let z = i === 0 ? cross(sub(at(1), at(0)), sub(at(last - 1), at(0))) : cross(sub(at(i + 1), at(i)), sub(at(i - 1), at(i)));
|
|
105
|
+
z = norm(z);
|
|
106
|
+
if (i > 0 && dot(z, zOnly(spine, i - 1)) < 0) z = [-z[0], -z[1], -z[2]];
|
|
107
|
+
return z;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Circular spine of a torus or revolve. θ = 0 is +X and the sweep goes toward −Z, matching revolve.js. */
|
|
111
|
+
export function circularSpine(radius, segments) {
|
|
112
|
+
const m = Math.max(3, segments | 0);
|
|
113
|
+
const spine = [];
|
|
114
|
+
for (let i = 0; i <= m; i++) {
|
|
115
|
+
const a = ((i % m) / m) * Math.PI * 2;
|
|
116
|
+
spine.push(radius * Math.cos(a), 0, -radius * Math.sin(a));
|
|
117
|
+
}
|
|
118
|
+
return spine;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function circle2(radius, segments) {
|
|
122
|
+
const m = Math.max(3, segments | 0);
|
|
123
|
+
const out = [];
|
|
124
|
+
for (let i = 0; i <= m; i++) {
|
|
125
|
+
const a = ((i % m) / m) * Math.PI * 2;
|
|
126
|
+
out.push(radius * Math.cos(a), radius * Math.sin(a));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function close2(points) {
|
|
132
|
+
const out = points.slice();
|
|
133
|
+
if (out.length >= 2 && (out[0] !== out[out.length - 2] || out[1] !== out[out.length - 1])) out.push(out[0], out[1]);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function bakeMesh(mesh, matrix) {
|
|
138
|
+
const m = matrix;
|
|
139
|
+
const inv = mat4.createF64();
|
|
140
|
+
if (!mat4.invert(inv, m)) return mesh;
|
|
141
|
+
const p = mesh.positions;
|
|
142
|
+
const srcN = mesh.normals;
|
|
143
|
+
const pos = new Float32Array(p.length);
|
|
144
|
+
const norms = srcN ? new Float32Array(srcN.length) : null;
|
|
145
|
+
for (let i = 0; i < p.length; i += 3) {
|
|
146
|
+
const x = p[i], y = p[i + 1], z = p[i + 2];
|
|
147
|
+
pos[i] = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
148
|
+
pos[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
149
|
+
pos[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
150
|
+
if (norms) {
|
|
151
|
+
const nx = srcN[i], ny = srcN[i + 1], nz = srcN[i + 2];
|
|
152
|
+
let ox = inv[0] * nx + inv[1] * ny + inv[2] * nz;
|
|
153
|
+
let oy = inv[4] * nx + inv[5] * ny + inv[6] * nz;
|
|
154
|
+
let oz = inv[8] * nx + inv[9] * ny + inv[10] * nz;
|
|
155
|
+
const l = Math.hypot(ox, oy, oz) || 1;
|
|
156
|
+
norms[i] = ox / l;
|
|
157
|
+
norms[i + 1] = oy / l;
|
|
158
|
+
norms[i + 2] = oz / l;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { kind: mesh.kind, key: `${mesh.key}:baked`, positions: pos, normals: norms, indices: mesh.indices };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {import('./export-model.js').buildExportModel extends Function ? object : object} model
|
|
166
|
+
* @returns {{text: string, notes: string[]}}
|
|
167
|
+
*/
|
|
168
|
+
export function exportX3D(model) {
|
|
169
|
+
const notes = model.notes.slice();
|
|
170
|
+
const tally = new Map();
|
|
171
|
+
const add = (key, n = 1) => tally.set(key, (tally.get(key) || 0) + n);
|
|
172
|
+
const geos = new Map();
|
|
173
|
+
const apps = new Map();
|
|
174
|
+
let seq = 0;
|
|
175
|
+
const name = (prefix) => `${prefix}${seq++}`;
|
|
176
|
+
let emissiveClamped = 0;
|
|
177
|
+
|
|
178
|
+
const appearance = (index) => {
|
|
179
|
+
const hit = apps.get(index);
|
|
180
|
+
if (hit) return `USE ${hit}`;
|
|
181
|
+
const id = name('A');
|
|
182
|
+
apps.set(index, id);
|
|
183
|
+
const m = model.materials[index];
|
|
184
|
+
const c = m.baseColor;
|
|
185
|
+
if (m.unlit) {
|
|
186
|
+
const e = c.map((v) => Math.min(1, Math.max(0, v)));
|
|
187
|
+
return `DEF ${id} Appearance { material UnlitMaterial { emissiveColor ${rgb(e)} } }`;
|
|
188
|
+
}
|
|
189
|
+
const e = m.emissive.map((v) => {
|
|
190
|
+
if (v > 1 || v < 0) emissiveClamped++;
|
|
191
|
+
return Math.min(1, Math.max(0, v));
|
|
192
|
+
});
|
|
193
|
+
const fields = [`baseColor ${rgb(c)}`, `metallic ${n(m.metallic)}`, `roughness ${n(m.roughness)}`];
|
|
194
|
+
if (m.opacity < 0.999) fields.push(`transparency ${n(1 - m.opacity)}`);
|
|
195
|
+
if (e.some((v) => v > 1e-6)) fields.push(`emissiveColor ${rgb(e)}`);
|
|
196
|
+
return `DEF ${id} Appearance { material PhysicalMaterial { ${fields.join(' ')} } }`;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const define = (key, body) => {
|
|
200
|
+
const hit = geos.get(key);
|
|
201
|
+
if (hit) return `USE ${hit}`;
|
|
202
|
+
const id = name('G');
|
|
203
|
+
geos.set(key, id);
|
|
204
|
+
return `DEF ${id} ${body}`;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const indexed = (mesh, doubleSided) => {
|
|
208
|
+
const solid = !doubleSided;
|
|
209
|
+
if (mesh.kind === 'lines') {
|
|
210
|
+
const idx = [];
|
|
211
|
+
for (let i = 0; i < mesh.indices.length; i += 2) idx.push(mesh.indices[i], mesh.indices[i + 1], -1);
|
|
212
|
+
return `IndexedLineSet { coord Coordinate { point [ ${numList(mesh.positions)} ] } coordIndex [ ${numList(idx, 16)} ] }`;
|
|
213
|
+
}
|
|
214
|
+
const idx = [];
|
|
215
|
+
for (let i = 0; i < mesh.indices.length; i += 3) idx.push(mesh.indices[i], mesh.indices[i + 1], mesh.indices[i + 2], -1);
|
|
216
|
+
const normal = mesh.normals ? ` normal Normal { vector [ ${numList(mesh.normals)} ] }` : '';
|
|
217
|
+
return `IndexedFaceSet {${keep('solid', bool(solid), 'TRUE')} coord Coordinate { point [ ${numList(mesh.positions)} ] }${normal} coordIndex [ ${numList(idx, 16)} ] }`;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const geometryOf = (solid, doubleSided) => {
|
|
221
|
+
const key = `${solid.key}:${doubleSided ? 1 : 0}`;
|
|
222
|
+
if (solid.kind === 'box') {
|
|
223
|
+
const s = solid.size;
|
|
224
|
+
return define(key, `Box {${keep('size', `${n(s[0])} ${n(s[1])} ${n(s[2])}`, '2 2 2')}${keep('solid', bool(!doubleSided), 'TRUE')} }`);
|
|
225
|
+
}
|
|
226
|
+
if (solid.kind === 'sphere') return define(key, `Sphere {${keep('radius', n(solid.radius), '1')}${keep('solid', bool(!doubleSided), 'TRUE')} }`);
|
|
227
|
+
if (solid.kind === 'cylinder') {
|
|
228
|
+
const caps = solid.caps !== false;
|
|
229
|
+
return define(key, `Cylinder {${keep('radius', n(solid.radius), '1')}${keep('height', n(solid.height), '2')}${keep('top', bool(caps), 'TRUE')}${keep('bottom', bool(caps), 'TRUE')}${keep('solid', bool(caps && !doubleSided), 'TRUE')} }`);
|
|
230
|
+
}
|
|
231
|
+
if (solid.kind === 'cone') {
|
|
232
|
+
const caps = solid.caps !== false;
|
|
233
|
+
return define(key, `Cone {${keep('bottomRadius', n(solid.radius), '1')}${keep('height', n(solid.height), '2')}${keep('bottom', bool(caps), 'TRUE')}${keep('solid', bool(caps && !doubleSided), 'TRUE')} }`);
|
|
234
|
+
}
|
|
235
|
+
const extrusion = (o) => {
|
|
236
|
+
const scale = o.scale ? ` scale [ ${o.scale} ]` : '';
|
|
237
|
+
return `Extrusion {${keep('solid', bool(o.solid), 'TRUE')} creaseAngle 0.8${keep('beginCap', bool(o.begin), 'TRUE')}${keep('endCap', bool(o.end), 'TRUE')} crossSection [ ${numList(o.cross)} ] spine [ ${numList(o.spine, 9)} ]${scale} }`;
|
|
238
|
+
};
|
|
239
|
+
if (solid.kind === 'frustum') {
|
|
240
|
+
const cross = circle2(1, solid.segments);
|
|
241
|
+
const h = solid.height;
|
|
242
|
+
const caps = solid.caps !== false;
|
|
243
|
+
return define(key, extrusion({
|
|
244
|
+
solid: caps && !doubleSided,
|
|
245
|
+
begin: caps,
|
|
246
|
+
end: caps,
|
|
247
|
+
cross,
|
|
248
|
+
spine: [0, -h / 2, 0, 0, h / 2, 0],
|
|
249
|
+
scale: `${n(solid.radius)} ${n(solid.radius)} ${n(solid.radiusTop)} ${n(solid.radiusTop)}`,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
if (solid.kind === 'torus') {
|
|
253
|
+
add('torus');
|
|
254
|
+
return define(key, extrusion({ solid: true, begin: false, end: false, cross: circle2(solid.tube, solid.tubeSegments), spine: circularSpine(solid.radius, solid.segments) }));
|
|
255
|
+
}
|
|
256
|
+
if (solid.kind === 'revolve') {
|
|
257
|
+
add('revolve');
|
|
258
|
+
const cs = [];
|
|
259
|
+
for (let i = 0; i < solid.points.length; i += 2) cs.push(solid.points[i] - 1, solid.points[i + 1]);
|
|
260
|
+
const cross = solid.closed ? close2(cs) : cs;
|
|
261
|
+
const full = solid.angle >= 360 - 1e-6;
|
|
262
|
+
const steps = Math.max(3, solid.segments | 0);
|
|
263
|
+
const spine = [];
|
|
264
|
+
for (let i = 0; i <= steps; i++) {
|
|
265
|
+
const a = (Math.min(solid.angle, 360) * Math.PI / 180) * (full ? (i % steps) / steps : i / steps);
|
|
266
|
+
spine.push(Math.cos(a), 0, -Math.sin(a));
|
|
267
|
+
}
|
|
268
|
+
const cap = !full && solid.closed;
|
|
269
|
+
return define(key, extrusion({ solid: !doubleSided, begin: cap, end: cap, cross, spine }));
|
|
270
|
+
}
|
|
271
|
+
if (solid.kind === 'extrude') {
|
|
272
|
+
const holed = solid.regions.length !== 1 || solid.regions[0].holes.length > 0;
|
|
273
|
+
if (holed) return null;
|
|
274
|
+
add('extrude');
|
|
275
|
+
const loop = solid.regions[0].outer;
|
|
276
|
+
const cs = [];
|
|
277
|
+
for (let i = 0; i < loop.length; i += 2) cs.push(loop[i], -loop[i + 1]);
|
|
278
|
+
return define(key, extrusion({ solid: true, begin: true, end: true, cross: close2(cs), spine: [0, 0, 0, 0, solid.depth, 0] }));
|
|
279
|
+
}
|
|
280
|
+
if (solid.kind === 'plane') {
|
|
281
|
+
add('plane');
|
|
282
|
+
const w = solid.width, d = solid.depth;
|
|
283
|
+
const pos = [-w / 2, 0, d / 2, w / 2, 0, d / 2, w / 2, 0, -d / 2, -w / 2, 0, -d / 2];
|
|
284
|
+
const mesh = { kind: 'mesh', positions: pos, normals: [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], indices: [0, 1, 2, 0, 2, 3] };
|
|
285
|
+
return define(key, indexed(mesh, true));
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const meshGeometry = (mesh, doubleSided) => define(`${mesh.key}:${doubleSided ? 1 : 0}`, indexed(mesh, doubleSided));
|
|
291
|
+
|
|
292
|
+
const transformOf = (matrix) => {
|
|
293
|
+
if (!matrix) return '';
|
|
294
|
+
const t = trsOf(matrix);
|
|
295
|
+
if (!t || t.identity) return t ? '' : null;
|
|
296
|
+
const parts = [];
|
|
297
|
+
if (!t.noTrans) parts.push(`translation ${n(t.translation[0])} ${n(t.translation[1])} ${n(t.translation[2])}`);
|
|
298
|
+
if (!t.noRot) {
|
|
299
|
+
const a = axisAngle(t.rotation);
|
|
300
|
+
parts.push(`rotation ${n(a.axis[0])} ${n(a.axis[1])} ${n(a.axis[2])} ${n(a.angle)}`);
|
|
301
|
+
}
|
|
302
|
+
if (!t.noScale) parts.push(`scale ${n(t.scale[0])} ${n(t.scale[1])} ${n(t.scale[2])}`);
|
|
303
|
+
return parts.join(' ');
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const lines = [];
|
|
307
|
+
const emit = (node, depth) => {
|
|
308
|
+
if (!node) return;
|
|
309
|
+
const pad = ' '.repeat(depth);
|
|
310
|
+
if (node.kind === 'repeat') {
|
|
311
|
+
add('repeat', node.count);
|
|
312
|
+
for (const acc of repeatCopies(node)) {
|
|
313
|
+
emit({ kind: 'group', matrix: acc, children: node.children, own: node.own }, depth);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (node.kind === 'scatter') {
|
|
318
|
+
add('scatter', node.params.count);
|
|
319
|
+
for (const copy of scatterCopies(node)) {
|
|
320
|
+
const children = copy.material ? node.children.map((c) => paint(c, copy.material, null, model.resolve)) : node.children;
|
|
321
|
+
emit({ kind: 'group', matrix: copy.matrix, children, own: node.own }, depth);
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (node.kind === 'group') {
|
|
326
|
+
const fields = transformOf(node.matrix);
|
|
327
|
+
if (fields === null) {
|
|
328
|
+
add('shear');
|
|
329
|
+
for (const c of node.children) emit(bakedUnder(c, node.matrix), depth);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const saved = lines.length;
|
|
333
|
+
for (const c of node.children) emit(c, depth + 1);
|
|
334
|
+
const body = lines.splice(saved);
|
|
335
|
+
if (!body.length) return;
|
|
336
|
+
if (!fields) {
|
|
337
|
+
lines.push(...body.map((l) => (l.startsWith(' ') ? l.slice(2) : l)));
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
lines.push(`${pad}Transform { ${fields} children [`);
|
|
341
|
+
lines.push(...body);
|
|
342
|
+
lines.push(`${pad}] }`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
emitShape(node, depth);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const bakedUnder = (node, parent) => {
|
|
349
|
+
const local = node.matrix || null;
|
|
350
|
+
const world = mat4.createF64();
|
|
351
|
+
if (local) mat4.multiply(world, parent, local);
|
|
352
|
+
else world.set(parent);
|
|
353
|
+
if (node.kind === 'shape') {
|
|
354
|
+
const space = mat4.createF64();
|
|
355
|
+
const basis = node.solid.kind === 'mesh' || node.solid.kind === 'lines' ? local : (node.meshMatrix || local);
|
|
356
|
+
if (basis) mat4.multiply(space, parent, basis);
|
|
357
|
+
else space.set(parent);
|
|
358
|
+
const mesh = node.solid.kind === 'mesh' || node.solid.kind === 'lines' ? node.solid : model.bakedMesh(node.entry);
|
|
359
|
+
return { ...node, matrix: null, meshMatrix: null, solid: bakeMesh(mesh, space) };
|
|
360
|
+
}
|
|
361
|
+
if (node.kind === 'repeat') {
|
|
362
|
+
const copies = repeatCopies(node).map((acc) => {
|
|
363
|
+
const m = mat4.createF64();
|
|
364
|
+
mat4.multiply(m, world, acc);
|
|
365
|
+
return { kind: 'group', matrix: null, children: node.children.map((c) => bakedUnder(c, m)), own: NO_OWN };
|
|
366
|
+
});
|
|
367
|
+
return { kind: 'group', matrix: null, children: copies, own: node.own };
|
|
368
|
+
}
|
|
369
|
+
if (node.kind === 'scatter') {
|
|
370
|
+
const copies = scatterCopies(node).map((copy) => {
|
|
371
|
+
const m = mat4.createF64();
|
|
372
|
+
mat4.multiply(m, world, copy.matrix);
|
|
373
|
+
const kids = copy.material ? node.children.map((c) => paint(c, copy.material, null, model.resolve)) : node.children;
|
|
374
|
+
return { kind: 'group', matrix: null, children: kids.map((c) => bakedUnder(c, m)), own: NO_OWN };
|
|
375
|
+
});
|
|
376
|
+
return { kind: 'group', matrix: null, children: copies, own: node.own };
|
|
377
|
+
}
|
|
378
|
+
const children = (node.children || []).map((c) => bakedUnder(c, world));
|
|
379
|
+
return { kind: 'group', matrix: null, children, own: node.own };
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const emitShape = (node, depth) => {
|
|
383
|
+
const pad = ' '.repeat(depth);
|
|
384
|
+
let shape = node;
|
|
385
|
+
let fields = transformOf(shape.matrix);
|
|
386
|
+
if (fields === null) {
|
|
387
|
+
add('shear');
|
|
388
|
+
const mesh = shape.solid.kind === 'mesh' || shape.solid.kind === 'lines' ? shape.solid : model.bakedMesh(shape.entry);
|
|
389
|
+
const baked = bakeMesh(mesh, shape.solid.kind === 'mesh' || shape.solid.kind === 'lines' ? shape.matrix : shape.meshMatrix);
|
|
390
|
+
shape = { ...shape, matrix: null, solid: baked };
|
|
391
|
+
fields = '';
|
|
392
|
+
}
|
|
393
|
+
const app = appearance(shape.material);
|
|
394
|
+
let geom = geometryOf(shape.solid, shape.doubleSided);
|
|
395
|
+
let matrix = shape.matrix;
|
|
396
|
+
if (shape.solid.kind === 'capsule') {
|
|
397
|
+
add('capsule');
|
|
398
|
+
geom = capsule(shape, app);
|
|
399
|
+
} else if (shape.solid.kind === 'extrude' && geom) {
|
|
400
|
+
matrix = composeExtrude(matrix, shape.solid);
|
|
401
|
+
fields = transformOf(matrix);
|
|
402
|
+
if (fields === null && matrix) {
|
|
403
|
+
add('shear');
|
|
404
|
+
const mesh = model.bakedMesh(shape.entry);
|
|
405
|
+
const baked = bakeMesh(mesh, shape.meshMatrix || matrix);
|
|
406
|
+
geom = meshGeometry(baked, shape.doubleSided);
|
|
407
|
+
fields = '';
|
|
408
|
+
}
|
|
409
|
+
} else if (!geom) {
|
|
410
|
+
const mesh = shape.solid.kind === 'mesh' || shape.solid.kind === 'lines' ? shape.solid : model.bakedMesh(shape.entry);
|
|
411
|
+
geom = meshGeometry(mesh, shape.doubleSided);
|
|
412
|
+
if (shape.solid.kind !== 'mesh' && shape.solid.kind !== 'lines') {
|
|
413
|
+
matrix = shape.meshMatrix;
|
|
414
|
+
}
|
|
415
|
+
fields = transformOf(matrix);
|
|
416
|
+
if (fields === null && matrix) {
|
|
417
|
+
const baked = bakeMesh(mesh, matrix);
|
|
418
|
+
geom = meshGeometry(baked, shape.doubleSided);
|
|
419
|
+
fields = '';
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
fields = transformOf(matrix);
|
|
423
|
+
if (fields === null && matrix) {
|
|
424
|
+
add('shear');
|
|
425
|
+
const mesh = model.bakedMesh(shape.entry);
|
|
426
|
+
const baked = bakeMesh(mesh, shape.meshMatrix || matrix);
|
|
427
|
+
geom = meshGeometry(baked, shape.doubleSided);
|
|
428
|
+
fields = '';
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const inner = shape.solid.kind === 'capsule'
|
|
432
|
+
? geom
|
|
433
|
+
: `Shape { geometry ${geom} appearance ${app} }`;
|
|
434
|
+
if (!fields) lines.push(`${pad}${inner}`);
|
|
435
|
+
else lines.push(`${pad}Transform { ${fields} children [ ${inner} ] }`);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
function composeExtrude(matrix, solid) {
|
|
439
|
+
const r = mat4.createF64();
|
|
440
|
+
mat4.fromEulerDeg(r, 90, 0, 0);
|
|
441
|
+
if (solid.center) {
|
|
442
|
+
const t = mat4.createF64();
|
|
443
|
+
t[14] = -solid.depth / 2;
|
|
444
|
+
const tr = mat4.createF64();
|
|
445
|
+
mat4.multiply(tr, t, r);
|
|
446
|
+
r.set(tr);
|
|
447
|
+
}
|
|
448
|
+
if (!matrix) return r;
|
|
449
|
+
const out = mat4.createF64();
|
|
450
|
+
mat4.multiply(out, matrix, r);
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const capsule = (shape, app) => {
|
|
455
|
+
const r = shape.solid.radius;
|
|
456
|
+
const h = shape.solid.height;
|
|
457
|
+
const mid = h - 2 * r;
|
|
458
|
+
if (mid <= 1e-6) {
|
|
459
|
+
const geom = define(`sphere:${r}`, `Sphere { radius ${n(r)} }`);
|
|
460
|
+
return `Shape { geometry ${geom} appearance ${app} }`;
|
|
461
|
+
}
|
|
462
|
+
const y = h / 2 - r;
|
|
463
|
+
const cyl = define(`capcyl:${r}:${mid}`, `Cylinder { radius ${n(r)} height ${n(mid)} top FALSE bottom FALSE solid FALSE }`);
|
|
464
|
+
const sph = define(`sphere:${r}`, `Sphere { radius ${n(r)} }`);
|
|
465
|
+
return `Group { children [
|
|
466
|
+
Shape { geometry ${cyl} appearance ${app} }
|
|
467
|
+
Transform { translation 0 ${n(y)} 0 children [ Shape { geometry ${sph} appearance ${app} } ] }
|
|
468
|
+
Transform { translation 0 ${n(-y)} 0 children [ Shape { geometry ${sph} appearance ${app} } ] }
|
|
469
|
+
] }`;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
emit(model.root, 1);
|
|
473
|
+
for (const light of model.lights) {
|
|
474
|
+
const node = lightNode(light, add);
|
|
475
|
+
if (node) lines.push(` ${node}`);
|
|
476
|
+
}
|
|
477
|
+
for (const camera of model.cameras) lines.push(' ' + cameraNode(camera, add));
|
|
478
|
+
if (model.environment && model.environment.explicit) {
|
|
479
|
+
const s = model.environment.sky || model.environment.background;
|
|
480
|
+
lines.push(` Background { skyColor [ ${rgb(s)} ] }`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (emissiveClamped) notes.push(`${emissiveClamped.toLocaleString('en-US')} emissive colour${emissiveClamped === 1 ? '' : 's'} clamped to 0..1 (X3D emissiveColor).`);
|
|
484
|
+
const say = (key, text) => { if (tally.get(key)) notes.push(text(tally.get(key))); };
|
|
485
|
+
say('capsule', (k) => `${k.toLocaleString('en-US')} capsule${k === 1 ? '' : 's'} written as an open cylinder and two spheres (X3D has no capsule).`);
|
|
486
|
+
say('torus', (k) => `${k.toLocaleString('en-US')} torus${k === 1 ? '' : 'es'} written as Extrusion (X3D has no torus).`);
|
|
487
|
+
say('revolve', (k) => `${k.toLocaleString('en-US')} revolve${k === 1 ? '' : 's'} written as Extrusion along a circular spine.`);
|
|
488
|
+
say('extrude', (k) => `${k.toLocaleString('en-US')} extrusion${k === 1 ? '' : 's'} written as Extrusion; a +90° rotation about X turns the spine from Y onto Z.`);
|
|
489
|
+
say('plane', (k) => `${k.toLocaleString('en-US')} plane${k === 1 ? '' : 's'} written as two triangles (X3D has no plane primitive).`);
|
|
490
|
+
say('repeat', (k) => `${k.toLocaleString('en-US')} repeated copies expanded into transforms (X3D has no repeat node); the geometry is shared.`);
|
|
491
|
+
say('scatter', (k) => `${k.toLocaleString('en-US')} scattered copies expanded into transforms (X3D has no scatter node); the geometry is shared.`);
|
|
492
|
+
say('shear', (k) => `${k.toLocaleString('en-US')} sheared transform${k === 1 ? '' : 's'} baked into vertices (an X3D Transform is translation, rotation and scale).`);
|
|
493
|
+
say('ambient', (k) => `${k.toLocaleString('en-US')} ambient or hemisphere light${k === 1 ? '' : 's'} omitted (X3D has no such light).`);
|
|
494
|
+
say('range', (k) => `${k.toLocaleString('en-US')} unlimited point or spot light${k === 1 ? '' : 's'} given a radius of 1000000 (X3D lights cut off at their radius).`);
|
|
495
|
+
|
|
496
|
+
const head = ['#X3D V4.0 utf8', 'PROFILE Immersive', 'COMPONENT Shape:2'];
|
|
497
|
+
if (model.unit !== 'm') head.push(`UNIT length ${UNIT_NAME[model.unit] || 'Unit'} ${n(metresPerUnit(model.unit))}`);
|
|
498
|
+
const body = lines.join('\n');
|
|
499
|
+
const text = `${head.join('\n')}\nGroup {\n children [\n${body}\n ]\n}\n`;
|
|
500
|
+
return { text, notes };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function lightNode(light, add) {
|
|
504
|
+
const max = Math.max(light.color[0], light.color[1], light.color[2], 1e-8);
|
|
505
|
+
const col = light.color.map((v) => v / max);
|
|
506
|
+
const intensity = n(max);
|
|
507
|
+
const colour = rgb(col);
|
|
508
|
+
if (light.type === 'ambient' || light.type === 'hemisphere') {
|
|
509
|
+
add('ambient');
|
|
510
|
+
return '';
|
|
511
|
+
}
|
|
512
|
+
if (light.type === 'directional') {
|
|
513
|
+
const d = light.direction;
|
|
514
|
+
return `DirectionalLight { direction ${n(d[0])} ${n(d[1])} ${n(d[2])} color ${colour} intensity ${intensity} global TRUE }`;
|
|
515
|
+
}
|
|
516
|
+
const p = light.position;
|
|
517
|
+
let radius = light.range;
|
|
518
|
+
if (!(radius > 0)) {
|
|
519
|
+
radius = 1e6;
|
|
520
|
+
add('range');
|
|
521
|
+
}
|
|
522
|
+
if (light.type === 'spot') {
|
|
523
|
+
const d = light.direction;
|
|
524
|
+
const cut = Math.acos(Math.min(1, Math.max(-1, light.outer)));
|
|
525
|
+
const beam = Math.acos(Math.min(1, Math.max(-1, light.inner)));
|
|
526
|
+
return `SpotLight { location ${n(p[0])} ${n(p[1])} ${n(p[2])} direction ${n(d[0])} ${n(d[1])} ${n(d[2])} cutOffAngle ${n(cut)} beamWidth ${n(beam)} radius ${n(radius)} color ${colour} intensity ${intensity} }`;
|
|
527
|
+
}
|
|
528
|
+
return `PointLight { location ${n(p[0])} ${n(p[1])} ${n(p[2])} radius ${n(radius)} color ${colour} intensity ${intensity} }`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function cameraNode(camera, add) {
|
|
532
|
+
const eye = camera.position;
|
|
533
|
+
const q = viewQuat(eye, camera.target, camera.up);
|
|
534
|
+
const a = axisAngle(q);
|
|
535
|
+
const ori = `${n(a.axis[0])} ${n(a.axis[1])} ${n(a.axis[2])} ${n(a.angle)}`;
|
|
536
|
+
const pos = `${n(eye[0])} ${n(eye[1])} ${n(eye[2])}`;
|
|
537
|
+
if (camera.type === 'orthographic') {
|
|
538
|
+
const hh = camera.height / 2;
|
|
539
|
+
const hw = hh * (camera.aspect || 1);
|
|
540
|
+
return `OrthoViewpoint { position ${pos} orientation ${ori} fieldOfView [ ${n(-hw)} ${n(-hh)} ${n(hw)} ${n(hh)} ] }`;
|
|
541
|
+
}
|
|
542
|
+
if (camera.aspect && Math.abs(camera.aspect - 1) > 1e-6) add('aspect');
|
|
543
|
+
const fov = ((camera.fov || 45) * Math.PI) / 180;
|
|
544
|
+
return `Viewpoint { position ${pos} orientation ${ori} fieldOfView ${n(fov)} }`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Quaternion [x, y, z, w] rotating the default camera (−Z forward, +Y up) onto this view. */
|
|
548
|
+
function viewQuat(eye, target, up) {
|
|
549
|
+
const f = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
|
|
550
|
+
const fl = Math.hypot(...f) || 1;
|
|
551
|
+
f[0] /= fl; f[1] /= fl; f[2] /= fl;
|
|
552
|
+
const z = [-f[0], -f[1], -f[2]];
|
|
553
|
+
let x = [up[1] * z[2] - up[2] * z[1], up[2] * z[0] - up[0] * z[2], up[0] * z[1] - up[1] * z[0]];
|
|
554
|
+
const xl = Math.hypot(...x);
|
|
555
|
+
if (xl < 1e-8) x = [1, 0, 0];
|
|
556
|
+
else { x[0] /= xl; x[1] /= xl; x[2] /= xl; }
|
|
557
|
+
const y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]];
|
|
558
|
+
const m = mat4.createF64();
|
|
559
|
+
m.set([x[0], x[1], x[2], 0, y[0], y[1], y[2], 0, z[0], z[1], z[2], 0, 0, 0, 0, 1]);
|
|
560
|
+
return mat4.quatFromRotationMatrix(m);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const HEADER = /^\s*#X3D[^\n]*\n?/;
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Read a Classic VRML X3D file into ES3D markup. XML X3D is accepted for the
|
|
567
|
+
* same node subset. USDC-style binary is rejected by the caller.
|
|
568
|
+
* @returns {{text: string, notes: string[]}}
|
|
569
|
+
*/
|
|
570
|
+
export function importX3D(text) {
|
|
571
|
+
const notes = [];
|
|
572
|
+
const trimmed = String(text).replace(/^\uFEFF/, '');
|
|
573
|
+
if (/^\s*<\?xml|^\s*<X3D[\s>]/i.test(trimmed)) return importX3DXml(trimmed, notes);
|
|
574
|
+
const body = trimmed.replace(HEADER, '');
|
|
575
|
+
const tokens = tokenize(body);
|
|
576
|
+
const defs = new Map();
|
|
577
|
+
let i = 0;
|
|
578
|
+
const nodes = [];
|
|
579
|
+
while (i < tokens.length) {
|
|
580
|
+
if (tokens[i].t === 'word' && (tokens[i].v === 'PROFILE' || tokens[i].v === 'COMPONENT' || tokens[i].v === 'UNIT' || tokens[i].v === 'META')) {
|
|
581
|
+
i = skipStatement(tokens, i);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (tokens[i].t === 'word' && tokens[i].v === 'ROUTE') {
|
|
585
|
+
notes.push('A ROUTE was ignored.');
|
|
586
|
+
i = skipStatement(tokens, i);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const step = parseNode(tokens, i, defs);
|
|
590
|
+
if (!step) break;
|
|
591
|
+
nodes.push(step.node);
|
|
592
|
+
i = step.i;
|
|
593
|
+
}
|
|
594
|
+
materialAttrs.materials = [];
|
|
595
|
+
const parts = nodes.map((node) => emitEs3d(node, notes)).filter(Boolean);
|
|
596
|
+
const mats = materialAttrs.materials;
|
|
597
|
+
materialAttrs.materials = [];
|
|
598
|
+
const markup = `<es3d version="1">\n${mats.join('\n')}${mats.length ? '\n' : ''}${parts.join('\n')}\n</es3d>\n`;
|
|
599
|
+
return { text: markup, notes };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function tokenize(text) {
|
|
603
|
+
const out = [];
|
|
604
|
+
let i = 0;
|
|
605
|
+
while (i < text.length) {
|
|
606
|
+
const c = text[i];
|
|
607
|
+
if (c === '#') { while (i < text.length && text[i] !== '\n') i++; continue; }
|
|
608
|
+
if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === ',') { i++; continue; }
|
|
609
|
+
if (c === '{' || c === '}' || c === '[' || c === ']') { out.push({ t: c, v: c }); i++; continue; }
|
|
610
|
+
if (c === '"') {
|
|
611
|
+
let j = i + 1, s = '';
|
|
612
|
+
while (j < text.length && text[j] !== '"') {
|
|
613
|
+
if (text[j] === '\\') { s += text[j + 1] || ''; j += 2; continue; }
|
|
614
|
+
s += text[j++];
|
|
615
|
+
}
|
|
616
|
+
out.push({ t: 'string', v: s });
|
|
617
|
+
i = j + 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
let j = i;
|
|
621
|
+
while (j < text.length && !/[\s,{}[\]"#]/.test(text[j])) j++;
|
|
622
|
+
const word = text.slice(i, j);
|
|
623
|
+
if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(word)) out.push({ t: 'num', v: Number(word) });
|
|
624
|
+
else out.push({ t: 'word', v: word });
|
|
625
|
+
i = j;
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function skipStatement(tokens, i) {
|
|
631
|
+
const word = tokens[i] && tokens[i].v;
|
|
632
|
+
i++;
|
|
633
|
+
if (word === 'PROFILE' || word === 'COMPONENT' || word === 'META' || word === 'UNIT' || word === 'ROUTE') {
|
|
634
|
+
while (i < tokens.length) {
|
|
635
|
+
const t = tokens[i];
|
|
636
|
+
const next = tokens[i + 1];
|
|
637
|
+
if (t.t === 'word' && (t.v === 'PROFILE' || t.v === 'COMPONENT' || t.v === 'UNIT' || t.v === 'META' || t.v === 'DEF' || t.v === 'ROUTE' || (next && next.t === '{'))) break;
|
|
638
|
+
i++;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return i;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function parseNode(tokens, i, defs) {
|
|
645
|
+
if (i >= tokens.length || tokens[i].t !== 'word') return null;
|
|
646
|
+
let def = null;
|
|
647
|
+
if (tokens[i].v === 'DEF') {
|
|
648
|
+
def = tokens[i + 1] && tokens[i + 1].v;
|
|
649
|
+
i += 2;
|
|
650
|
+
} else if (tokens[i].v === 'USE') {
|
|
651
|
+
const id = tokens[i + 1] && tokens[i + 1].v;
|
|
652
|
+
return { node: { use: id }, i: i + 2 };
|
|
653
|
+
}
|
|
654
|
+
if (!tokens[i] || tokens[i].t !== 'word') return null;
|
|
655
|
+
const name = tokens[i].v;
|
|
656
|
+
i++;
|
|
657
|
+
if (!tokens[i] || tokens[i].t !== '{') return { node: { name, fields: {}, def }, i };
|
|
658
|
+
i++;
|
|
659
|
+
const fields = {};
|
|
660
|
+
while (i < tokens.length && tokens[i].t !== '}') {
|
|
661
|
+
if (tokens[i].t !== 'word') { i++; continue; }
|
|
662
|
+
const key = tokens[i++].v;
|
|
663
|
+
if (VEC[key] && tokens[i] && tokens[i].t === 'num') {
|
|
664
|
+
const got = takeNumbers(tokens, i, VEC[key] || undefined);
|
|
665
|
+
fields[key] = VEC[key] === 1 ? got.list[0] : got.list;
|
|
666
|
+
i = got.i;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const value = parseValue(tokens, i, defs);
|
|
670
|
+
fields[key] = value.v;
|
|
671
|
+
i = value.i;
|
|
672
|
+
}
|
|
673
|
+
if (tokens[i] && tokens[i].t === '}') i++;
|
|
674
|
+
const node = { name, fields, def };
|
|
675
|
+
if (def) defs.set(def, node);
|
|
676
|
+
return { node, i };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function parseValue(tokens, i, defs) {
|
|
680
|
+
const tok = tokens[i];
|
|
681
|
+
if (!tok) return { v: null, i };
|
|
682
|
+
if (tok.t === '{' ) return { v: null, i };
|
|
683
|
+
if (tok.t === '[') {
|
|
684
|
+
i++;
|
|
685
|
+
const list = [];
|
|
686
|
+
while (i < tokens.length && tokens[i].t !== ']') {
|
|
687
|
+
if (tokens[i].t === 'word' && tokens[i + 1] && tokens[i + 1].t === '{') {
|
|
688
|
+
const step = parseNode(tokens, i, defs);
|
|
689
|
+
if (!step) break;
|
|
690
|
+
list.push(step.node);
|
|
691
|
+
i = step.i;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
if (tokens[i].t === 'word' && (tokens[i].v === 'DEF' || tokens[i].v === 'USE')) {
|
|
695
|
+
const step = parseNode(tokens, i, defs);
|
|
696
|
+
if (!step) break;
|
|
697
|
+
list.push(step.node);
|
|
698
|
+
i = step.i;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (tokens[i].t === 'num') list.push(tokens[i++].v);
|
|
702
|
+
else if (tokens[i].t === 'word') list.push(tokens[i++].v);
|
|
703
|
+
else if (tokens[i].t === 'string') list.push(tokens[i++].v);
|
|
704
|
+
else i++;
|
|
705
|
+
}
|
|
706
|
+
if (tokens[i] && tokens[i].t === ']') i++;
|
|
707
|
+
return { v: list, i };
|
|
708
|
+
}
|
|
709
|
+
if (tok.t === 'word' && (tok.v === 'DEF' || tok.v === 'USE' || (tokens[i + 1] && tokens[i + 1].t === '{'))) {
|
|
710
|
+
const step = parseNode(tokens, i, defs);
|
|
711
|
+
return { v: step ? step.node : null, i: step ? step.i : i + 1 };
|
|
712
|
+
}
|
|
713
|
+
if (tok.t === 'num' || tok.t === 'word' || tok.t === 'string') return { v: tok.v, i: i + 1 };
|
|
714
|
+
return { v: null, i: i + 1 };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const VEC = {
|
|
718
|
+
translation: 3, rotation: 4, scale: 3, size: 3, center: 3, point: 0,
|
|
719
|
+
baseColor: 3, emissiveColor: 3, diffuseColor: 3, color: 3,
|
|
720
|
+
location: 3, direction: 3, position: 3,
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
function takeNumbers(tokens, i, count) {
|
|
724
|
+
const list = [];
|
|
725
|
+
const limit = count || 1e9;
|
|
726
|
+
while (list.length < limit && tokens[i] && tokens[i].t === 'num') list.push(tokens[i++].v);
|
|
727
|
+
return { list, i };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function numAttr(list, count) {
|
|
731
|
+
if (typeof list === 'number') return [list];
|
|
732
|
+
if (!Array.isArray(list)) return null;
|
|
733
|
+
const nums = list.filter((v) => typeof v === 'number');
|
|
734
|
+
return nums.slice(0, count);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function emitEs3d(node, notes) {
|
|
738
|
+
if (!node) return '';
|
|
739
|
+
if (node.use) return `<use ref="${esc(node.use)}"/>`;
|
|
740
|
+
const f = node.fields || {};
|
|
741
|
+
const id = node.def ? ` id="${esc(node.def)}"` : '';
|
|
742
|
+
const kids = listNodes(f.children);
|
|
743
|
+
if (node.name === 'Transform' || node.name === 'Group' || node.name === 'Anchor' || node.name === 'Collision' || node.name === 'Switch' || node.name === 'Billboard') {
|
|
744
|
+
if (node.name !== 'Transform' && node.name !== 'Group') notes.push(`<${node.name}> was written as a group.`);
|
|
745
|
+
const tr = x3dTransform(f);
|
|
746
|
+
const body = kids.map((c) => emitEs3d(c, notes)).filter(Boolean).join('\n');
|
|
747
|
+
return `<group${id}${tr}>\n${body}\n</group>`;
|
|
748
|
+
}
|
|
749
|
+
if (node.name === 'Shape') {
|
|
750
|
+
const geom = f.geometry && f.geometry.name ? f.geometry : null;
|
|
751
|
+
const style = materialAttrs(f.appearance, notes);
|
|
752
|
+
if (!geom) return '';
|
|
753
|
+
return emitGeom(geom, id, style, notes);
|
|
754
|
+
}
|
|
755
|
+
if (isGeom(node.name)) return emitGeom(node, id, '', notes);
|
|
756
|
+
if (node.name === 'DirectionalLight' || node.name === 'PointLight' || node.name === 'SpotLight') return emitLight(node, id);
|
|
757
|
+
if (node.name === 'Viewpoint' || node.name === 'OrthoViewpoint') return emitCamera(node, id, notes);
|
|
758
|
+
if (node.name === 'WorldInfo' || node.name === 'Background' || node.name === 'NavigationInfo') return '';
|
|
759
|
+
notes.push(`${node.name} was omitted.`);
|
|
760
|
+
return '';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function listNodes(value) {
|
|
764
|
+
if (!value) return [];
|
|
765
|
+
if (Array.isArray(value)) return value.filter((v) => v && v.name);
|
|
766
|
+
if (value.name) return [value];
|
|
767
|
+
return [];
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function isGeom(name) {
|
|
771
|
+
return name === 'Box' || name === 'Sphere' || name === 'Cylinder' || name === 'Cone' || name === 'Extrusion' || name === 'IndexedFaceSet' || name === 'IndexedLineSet';
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function x3dMatrix(f) {
|
|
775
|
+
const full = (list, count) => {
|
|
776
|
+
const nums = numAttr(Array.isArray(list) ? list : null, count);
|
|
777
|
+
return nums && nums.length >= count ? nums : null;
|
|
778
|
+
};
|
|
779
|
+
const t = full(f.translation, 3);
|
|
780
|
+
const s = full(f.scale, 3);
|
|
781
|
+
const r = full(f.rotation, 4);
|
|
782
|
+
if (!t && !s && !r) return null;
|
|
783
|
+
const sm = mat4.createF64();
|
|
784
|
+
const scale = s || [1, 1, 1];
|
|
785
|
+
sm[0] = scale[0];
|
|
786
|
+
sm[5] = scale[1];
|
|
787
|
+
sm[10] = scale[2];
|
|
788
|
+
const rm = mat4.createF64();
|
|
789
|
+
if (r) {
|
|
790
|
+
const q = quatFromAxis(r[0], r[1], r[2], r[3]);
|
|
791
|
+
mat4.fromQuat(rm, q[0], q[1], q[2], q[3]);
|
|
792
|
+
}
|
|
793
|
+
const tm = mat4.createF64();
|
|
794
|
+
const pos = t || [0, 0, 0];
|
|
795
|
+
tm[12] = pos[0];
|
|
796
|
+
tm[13] = pos[1];
|
|
797
|
+
tm[14] = pos[2];
|
|
798
|
+
const rs = mat4.createF64();
|
|
799
|
+
mat4.multiply(rs, rm, sm);
|
|
800
|
+
const out = mat4.createF64();
|
|
801
|
+
mat4.multiply(out, tm, rs);
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function x3dTransform(f) {
|
|
806
|
+
const m = x3dMatrix(f);
|
|
807
|
+
if (!m) return '';
|
|
808
|
+
const text = matrixToTransformString(m, (v) => n(v));
|
|
809
|
+
return text ? ` transform="${text}"` : '';
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function quatFromAxis(x, y, z, angle) {
|
|
813
|
+
const l = Math.hypot(x, y, z) || 1;
|
|
814
|
+
const h = angle / 2;
|
|
815
|
+
const s = Math.sin(h);
|
|
816
|
+
return [(x / l) * s, (y / l) * s, (z / l) * s, Math.cos(h)];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function materialAttrs(appearance, notes) {
|
|
820
|
+
if (!appearance || !appearance.fields) return '';
|
|
821
|
+
const mat = appearance.fields.material;
|
|
822
|
+
if (!mat || !mat.fields) return '';
|
|
823
|
+
const f = mat.fields;
|
|
824
|
+
if (mat.name === 'Material') notes.push('A Phong Material was read as a colour (X3D Material is not the glTF model).');
|
|
825
|
+
const physical = mat.name === 'PhysicalMaterial';
|
|
826
|
+
let base = numAttr(f.baseColor || f.diffuseColor || f.emissiveColor, 3);
|
|
827
|
+
if (!base) {
|
|
828
|
+
if (!physical && mat.name !== 'UnlitMaterial') return '';
|
|
829
|
+
base = [1, 1, 1];
|
|
830
|
+
}
|
|
831
|
+
const srgb = base.slice(0, 3).map((v) => linearToSrgb(v));
|
|
832
|
+
const color = `color="${colorToShortHex(srgb)}"`;
|
|
833
|
+
const metallic = typeof f.metallic === 'number' ? f.metallic : (physical ? 1 : 0);
|
|
834
|
+
const roughness = typeof f.roughness === 'number' ? f.roughness : (physical ? 1 : 0.5);
|
|
835
|
+
const opacity = typeof f.transparency === 'number' ? 1 - f.transparency : 1;
|
|
836
|
+
if (Math.abs(metallic) < 1e-6 && Math.abs(roughness - 0.5) < 1e-6 && opacity > 0.999) return ` ${color}`;
|
|
837
|
+
const id = `m${materialAttrs.materials.length}`;
|
|
838
|
+
const bits = [color];
|
|
839
|
+
if (Math.abs(metallic) >= 1e-6) bits.push(`metallic="${n(metallic)}"`);
|
|
840
|
+
if (Math.abs(roughness - 0.5) >= 1e-6) bits.push(`roughness="${n(roughness)}"`);
|
|
841
|
+
if (opacity <= 0.999) bits.push(`opacity="${n(opacity)}"`);
|
|
842
|
+
materialAttrs.materials.push(`<material id="${id}" ${bits.join(' ')}/>`);
|
|
843
|
+
return ` material="${id}"`;
|
|
844
|
+
}
|
|
845
|
+
materialAttrs.materials = [];
|
|
846
|
+
|
|
847
|
+
function emitGeom(node, id, style, notes) {
|
|
848
|
+
const f = node.fields || {};
|
|
849
|
+
if (node.name === 'Box') {
|
|
850
|
+
const s = numAttr(f.size, 3) || [2, 2, 2];
|
|
851
|
+
return `<box${id}${style} size="${n(s[0])} ${n(s[1])} ${n(s[2])}"/>`;
|
|
852
|
+
}
|
|
853
|
+
if (node.name === 'Sphere') {
|
|
854
|
+
const r = typeof f.radius === 'number' ? f.radius : 1;
|
|
855
|
+
return `<sphere${id}${style} radius="${n(r)}"/>`;
|
|
856
|
+
}
|
|
857
|
+
if (node.name === 'Cylinder') {
|
|
858
|
+
const r = typeof f.radius === 'number' ? f.radius : 1;
|
|
859
|
+
const h = typeof f.height === 'number' ? f.height : 2;
|
|
860
|
+
const caps = f.top !== 'FALSE' && f.bottom !== 'FALSE';
|
|
861
|
+
return `<cylinder${id}${style} radius="${n(r)}" height="${n(h)}"${caps ? '' : ' caps="false"'}/>`;
|
|
862
|
+
}
|
|
863
|
+
if (node.name === 'Cone') {
|
|
864
|
+
const r = typeof f.bottomRadius === 'number' ? f.bottomRadius : 1;
|
|
865
|
+
const h = typeof f.height === 'number' ? f.height : 2;
|
|
866
|
+
const caps = f.bottom !== 'FALSE';
|
|
867
|
+
return `<cone${id}${style} radius="${n(r)}" height="${n(h)}"${caps ? '' : ' caps="false"'}/>`;
|
|
868
|
+
}
|
|
869
|
+
if (node.name === 'Extrusion') return emitExtrusion(node, id, style, notes);
|
|
870
|
+
if (node.name === 'IndexedFaceSet' || node.name === 'IndexedLineSet') return emitIndexed(node, id, style, notes);
|
|
871
|
+
notes.push(`${node.name} was omitted.`);
|
|
872
|
+
return '';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function emitExtrusion(node, id, style, notes) {
|
|
876
|
+
const f = node.fields || {};
|
|
877
|
+
const cross = numAttr(f.crossSection, 1e9) || [];
|
|
878
|
+
const spine = numAttr(f.spine, 1e9) || [];
|
|
879
|
+
if (cross.length < 4 || spine.length < 6) {
|
|
880
|
+
notes.push('An Extrusion had no cross section to import.');
|
|
881
|
+
return '';
|
|
882
|
+
}
|
|
883
|
+
const mesh = tessellateExtrusion(cross, spine, numAttr(f.scale, 1e9), f.beginCap !== 'FALSE', f.endCap !== 'FALSE');
|
|
884
|
+
notes.push('An Extrusion was imported as a mesh.');
|
|
885
|
+
return `<mesh${id}${style} positions="${mesh.positions.map((v) => n(v)).join(' ')}" indices="${mesh.indices.join(' ')}"/>`;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Spine cross-section product. A spine along Y uses the identity frame from §13.3.5; a closed spine uses extrusionVertex. */
|
|
889
|
+
function tessellateExtrusion(cross, spine, scale, beginCap, endCap) {
|
|
890
|
+
const loop = [];
|
|
891
|
+
for (let i = 0; i + 1 < cross.length; i += 2) loop.push([cross[i], cross[i + 1]]);
|
|
892
|
+
if (loop.length > 1 && Math.hypot(loop[0][0] - loop[loop.length - 1][0], loop[0][1] - loop[loop.length - 1][1]) < 1e-8) loop.pop();
|
|
893
|
+
const pts = [];
|
|
894
|
+
for (let i = 0; i + 2 < spine.length; i += 3) pts.push([spine[i], spine[i + 1], spine[i + 2]]);
|
|
895
|
+
const closed = pts.length > 2 && Math.hypot(pts[0][0] - pts[pts.length - 1][0], pts[0][1] - pts[pts.length - 1][1], pts[0][2] - pts[pts.length - 1][2]) < 1e-6;
|
|
896
|
+
const alongY = pts.every((p) => Math.abs(p[0]) < 1e-6 && Math.abs(p[2]) < 1e-6);
|
|
897
|
+
const ringsN = closed ? pts.length - 1 : pts.length;
|
|
898
|
+
const atScale = (i) => {
|
|
899
|
+
if (!scale || scale.length < 2) return [1, 1];
|
|
900
|
+
return scale.length >= pts.length * 2 ? [scale[i * 2], scale[i * 2 + 1]] : [scale[0], scale[1]];
|
|
901
|
+
};
|
|
902
|
+
const place = (i, sx, sz) => {
|
|
903
|
+
const sc = atScale(i);
|
|
904
|
+
const x = sx * sc[0];
|
|
905
|
+
const z = sz * sc[1];
|
|
906
|
+
const p = pts[i];
|
|
907
|
+
if (alongY) return [p[0] + x, p[1], p[2] + z];
|
|
908
|
+
if (closed) return extrusionVertex(spine, x, z, i);
|
|
909
|
+
return openExtrusionPoint(pts, x, z, i);
|
|
910
|
+
};
|
|
911
|
+
const positions = [];
|
|
912
|
+
const rings = [];
|
|
913
|
+
for (let i = 0; i < ringsN; i++) {
|
|
914
|
+
const ring = [];
|
|
915
|
+
for (const [sx, sz] of loop) {
|
|
916
|
+
const p = place(i, sx, sz);
|
|
917
|
+
ring.push(positions.length / 3);
|
|
918
|
+
positions.push(p[0], p[1], p[2]);
|
|
919
|
+
}
|
|
920
|
+
rings.push(ring);
|
|
921
|
+
}
|
|
922
|
+
// Signed area of the cross section in its (x, z) plane. The wall order
|
|
923
|
+
// follows it, so a clockwise outline and a counter-clockwise one both
|
|
924
|
+
// face outward.
|
|
925
|
+
let area = 0;
|
|
926
|
+
for (let i = 0; i < loop.length; i++) {
|
|
927
|
+
const p = loop[i];
|
|
928
|
+
const q = loop[(i + 1) % loop.length];
|
|
929
|
+
area += p[0] * q[1] - q[0] * p[1];
|
|
930
|
+
}
|
|
931
|
+
const ccw = area > 0;
|
|
932
|
+
const indices = [];
|
|
933
|
+
const segs = closed ? rings.length : rings.length - 1;
|
|
934
|
+
for (let i = 0; i < segs; i++) {
|
|
935
|
+
const a = rings[i];
|
|
936
|
+
const b = rings[closed ? (i + 1) % rings.length : i + 1];
|
|
937
|
+
for (let j = 0; j < a.length; j++) {
|
|
938
|
+
const j2 = (j + 1) % a.length;
|
|
939
|
+
if (ccw) indices.push(a[j], b[j], b[j2], a[j], b[j2], a[j2]);
|
|
940
|
+
else indices.push(a[j], a[j2], b[j2], a[j], b[j2], b[j]);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const fan = (ring, flip) => {
|
|
944
|
+
for (let j = 1; j < ring.length - 1; j++) indices.push(ring[0], ...(flip ? [ring[j + 1], ring[j]] : [ring[j], ring[j + 1]]));
|
|
945
|
+
};
|
|
946
|
+
if (!closed && beginCap) fan(rings[0], !ccw);
|
|
947
|
+
if (!closed && endCap) fan(rings[rings.length - 1], ccw);
|
|
948
|
+
return { positions, indices };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function openExtrusionPoint(pts, sx, sz, index) {
|
|
952
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
953
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
954
|
+
const norm = (a) => {
|
|
955
|
+
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
956
|
+
return [a[0] / l, a[1] / l, a[2] / l];
|
|
957
|
+
};
|
|
958
|
+
const y = index <= 0 ? norm(sub(pts[1], pts[0])) : index >= pts.length - 1 ? norm(sub(pts[pts.length - 1], pts[pts.length - 2])) : norm(sub(pts[index + 1], pts[index - 1]));
|
|
959
|
+
const ref = Math.abs(y[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
|
|
960
|
+
const xAxis = norm(cross(y, ref));
|
|
961
|
+
const zAxis = norm(cross(xAxis, y));
|
|
962
|
+
const p = pts[index];
|
|
963
|
+
return [p[0] + sx * xAxis[0] + sz * zAxis[0], p[1] + sx * xAxis[1] + sz * zAxis[1], p[2] + sx * xAxis[2] + sz * zAxis[2]];
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function faceIndices(index, ccw) {
|
|
967
|
+
const faces = [];
|
|
968
|
+
let cur = [];
|
|
969
|
+
const flush = () => {
|
|
970
|
+
if (cur.length >= 3) {
|
|
971
|
+
for (let k = 1; k < cur.length - 1; k++) {
|
|
972
|
+
if (ccw) faces.push(cur[0], cur[k], cur[k + 1]);
|
|
973
|
+
else faces.push(cur[0], cur[k + 1], cur[k]);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
cur = [];
|
|
977
|
+
};
|
|
978
|
+
for (const v of index) {
|
|
979
|
+
if (v < 0) flush();
|
|
980
|
+
else cur.push(v);
|
|
981
|
+
}
|
|
982
|
+
if (cur.length) flush();
|
|
983
|
+
return faces;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function lineStrips(coord, index) {
|
|
987
|
+
const strips = [];
|
|
988
|
+
let cur = [];
|
|
989
|
+
const flush = () => {
|
|
990
|
+
if (cur.length >= 2) {
|
|
991
|
+
const pts = [];
|
|
992
|
+
for (const i of cur) {
|
|
993
|
+
const o = i * 3;
|
|
994
|
+
if (o + 2 < coord.length) pts.push(coord[o], coord[o + 1], coord[o + 2]);
|
|
995
|
+
}
|
|
996
|
+
if (pts.length >= 6) strips.push(pts);
|
|
997
|
+
}
|
|
998
|
+
cur = [];
|
|
999
|
+
};
|
|
1000
|
+
for (const v of index) {
|
|
1001
|
+
if (v < 0) flush();
|
|
1002
|
+
else cur.push(v);
|
|
1003
|
+
}
|
|
1004
|
+
if (cur.length) flush();
|
|
1005
|
+
return strips;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function emitIndexed(node, id, style, notes) {
|
|
1009
|
+
const f = node.fields || {};
|
|
1010
|
+
const coord = f.coord && f.coord.fields ? numAttr(f.coord.fields.point, 1e9) : numAttr(f.point, 1e9);
|
|
1011
|
+
const index = numAttr(f.coordIndex, 1e9) || [];
|
|
1012
|
+
return indexedMarkup(node.name, coord, index, f.ccw !== 'FALSE', id, style, notes);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function indexedMarkup(name, coord, index, ccw, id, style, notes) {
|
|
1016
|
+
if (!coord || coord.length < 3) {
|
|
1017
|
+
notes.push(`An ${name} had no coordinates.`);
|
|
1018
|
+
return '';
|
|
1019
|
+
}
|
|
1020
|
+
if (name === 'IndexedLineSet') {
|
|
1021
|
+
const strips = lineStrips(coord, index);
|
|
1022
|
+
if (!strips.length) {
|
|
1023
|
+
notes.push('An IndexedLineSet had no lines.');
|
|
1024
|
+
return '';
|
|
1025
|
+
}
|
|
1026
|
+
notes.push('An IndexedLineSet was imported as polylines.');
|
|
1027
|
+
const one = (pts, withId) => `<polyline${withId ? id : ''}${style} points="${pts.map((v) => n(v)).join(' ')}"/>`;
|
|
1028
|
+
if (strips.length === 1) return one(strips[0], true);
|
|
1029
|
+
return `<group${id}>\n${strips.map((pts) => one(pts, false)).join('\n')}\n</group>`;
|
|
1030
|
+
}
|
|
1031
|
+
const faces = faceIndices(index, ccw);
|
|
1032
|
+
notes.push('An IndexedFaceSet was imported as a mesh.');
|
|
1033
|
+
return `<mesh${id}${style} positions="${coord.map((v) => n(v)).join(' ')}" indices="${faces.join(' ')}"/>`;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function emitLight(node, id) {
|
|
1037
|
+
const f = node.fields || {};
|
|
1038
|
+
const type = node.name === 'DirectionalLight' ? 'directional' : node.name === 'SpotLight' ? 'spot' : 'point';
|
|
1039
|
+
const color = numAttr(f.color, 3);
|
|
1040
|
+
const pos = numAttr(f.location, 3);
|
|
1041
|
+
const dir = numAttr(f.direction, 3);
|
|
1042
|
+
const parts = [`type="${type}"`];
|
|
1043
|
+
if (color) parts.push(`color="${colorToShortHex(color.slice(0, 3).map((v) => linearToSrgb(v)))}"`);
|
|
1044
|
+
if (typeof f.intensity === 'number' && f.intensity !== 1) parts.push(`intensity="${n(f.intensity)}"`);
|
|
1045
|
+
if (pos) parts.push(`position="${pos.map(n).join(' ')}"`);
|
|
1046
|
+
if (dir) parts.push(`direction="${dir.map(n).join(' ')}"`);
|
|
1047
|
+
return `<light${id} ${parts.join(' ')}/>`;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function emitCamera(node, id, notes) {
|
|
1051
|
+
const f = node.fields || {};
|
|
1052
|
+
const pos = numAttr(f.position, 3) || [0, 0, 10];
|
|
1053
|
+
notes.push('A viewpoint was imported as a camera looking along its orientation.');
|
|
1054
|
+
return `<camera${id} position="${pos.map(n).join(' ')}"/>`;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function esc(s) {
|
|
1058
|
+
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function importX3DXml(text, notes) {
|
|
1062
|
+
notes.push('XML X3D was read with the same node subset as Classic VRML.');
|
|
1063
|
+
const tags = parseXml(text);
|
|
1064
|
+
const root = tags.find((t) => t.name.toLowerCase() === 'x3d') || { name: 'X3D', children: tags };
|
|
1065
|
+
const scene = (root.children || []).find((t) => t.name.toLowerCase() === 'scene') || root;
|
|
1066
|
+
const parts = (scene.children || []).map((t) => emitXml(t, notes)).filter(Boolean);
|
|
1067
|
+
return { text: `<es3d version="1">\n${parts.join('\n')}\n</es3d>\n`, notes };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function parseXml(text) {
|
|
1071
|
+
const out = [];
|
|
1072
|
+
const stack = [{ children: out }];
|
|
1073
|
+
const re = /<!--[\s\S]*?-->|<(\/?)([A-Za-z0-9:_-]+)([^>]*?)(\/?)>/g;
|
|
1074
|
+
let m;
|
|
1075
|
+
while ((m = re.exec(text))) {
|
|
1076
|
+
if (m[0].startsWith('<!--')) continue;
|
|
1077
|
+
const close = m[1] === '/';
|
|
1078
|
+
const name = m[2];
|
|
1079
|
+
const self = m[4] === '/';
|
|
1080
|
+
if (close) {
|
|
1081
|
+
if (stack.length > 1) stack.pop();
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
const node = { name, attrs: attrsOf(m[3]), children: [] };
|
|
1085
|
+
stack[stack.length - 1].children.push(node);
|
|
1086
|
+
if (!self) stack.push(node);
|
|
1087
|
+
}
|
|
1088
|
+
return out;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function attrsOf(raw) {
|
|
1092
|
+
const attrs = {};
|
|
1093
|
+
const re = /([A-Za-z0-9:_-]+)\s*=\s*"([^"]*)"|([A-Za-z0-9:_-]+)\s*=\s*'([^']*)'/g;
|
|
1094
|
+
let m;
|
|
1095
|
+
while ((m = re.exec(raw))) attrs[m[1] || m[3]] = m[2] !== undefined ? m[2] : m[4];
|
|
1096
|
+
return attrs;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function nums(s) {
|
|
1100
|
+
if (!s) return [];
|
|
1101
|
+
return s.trim().split(/[\s,]+/).filter(Boolean).map(Number).filter((v) => Number.isFinite(v));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function emitXml(tag, notes) {
|
|
1105
|
+
const name = tag.name;
|
|
1106
|
+
const a = tag.attrs || {};
|
|
1107
|
+
const id = a.DEF ? ` id="${esc(a.DEF)}"` : '';
|
|
1108
|
+
if (a.USE) return `<use ref="${esc(a.USE)}"/>`;
|
|
1109
|
+
if (name === 'Transform' || name === 'Group') {
|
|
1110
|
+
const fake = { translation: nums(a.translation), rotation: nums(a.rotation), scale: nums(a.scale) };
|
|
1111
|
+
const tr = x3dTransform(fake);
|
|
1112
|
+
const body = (tag.children || []).map((c) => emitXml(c, notes)).filter(Boolean).join('\n');
|
|
1113
|
+
return `<group${id}${tr}>\n${body}\n</group>`;
|
|
1114
|
+
}
|
|
1115
|
+
if (name === 'Shape') {
|
|
1116
|
+
const geom = (tag.children || []).find((c) => c.name === 'Box' || c.name === 'Sphere' || c.name === 'Cylinder' || c.name === 'Cone' || (c.name === 'Appearance' ? false : isGeom(c.name)));
|
|
1117
|
+
const app = (tag.children || []).find((c) => c.name === 'Appearance');
|
|
1118
|
+
const style = app ? xmlMaterial(app, notes) : '';
|
|
1119
|
+
if (!geom) return (tag.children || []).map((c) => emitXml(c, notes)).filter(Boolean).join('\n');
|
|
1120
|
+
return emitXml({ ...geom, attrs: { ...geom.attrs, __style: style, __id: id } }, notes);
|
|
1121
|
+
}
|
|
1122
|
+
const style = a.__style || '';
|
|
1123
|
+
const ident = a.__id || id;
|
|
1124
|
+
if (name === 'Box') {
|
|
1125
|
+
const s = nums(a.size);
|
|
1126
|
+
const size = s.length >= 3 ? s : [2, 2, 2];
|
|
1127
|
+
return `<box${ident}${style} size="${size.slice(0, 3).map(n).join(' ')}"/>`;
|
|
1128
|
+
}
|
|
1129
|
+
if (name === 'Sphere') return `<sphere${ident}${style} radius="${n(a.radius !== undefined ? Number(a.radius) : 1)}"/>`;
|
|
1130
|
+
if (name === 'Cylinder') {
|
|
1131
|
+
const caps = a.top !== 'false' && a.bottom !== 'false';
|
|
1132
|
+
return `<cylinder${ident}${style} radius="${n(a.radius !== undefined ? Number(a.radius) : 1)}" height="${n(a.height !== undefined ? Number(a.height) : 2)}"${caps ? '' : ' caps="false"'}/>`;
|
|
1133
|
+
}
|
|
1134
|
+
if (name === 'Cone') {
|
|
1135
|
+
const caps = a.bottom !== 'false';
|
|
1136
|
+
return `<cone${ident}${style} radius="${n(a.bottomRadius !== undefined ? Number(a.bottomRadius) : 1)}" height="${n(a.height !== undefined ? Number(a.height) : 2)}"${caps ? '' : ' caps="false"'}/>`;
|
|
1137
|
+
}
|
|
1138
|
+
if (name === 'Extrusion') {
|
|
1139
|
+
const cross = nums(a.crossSection);
|
|
1140
|
+
const spine = nums(a.spine);
|
|
1141
|
+
if (cross.length < 4 || spine.length < 6) {
|
|
1142
|
+
notes.push('An Extrusion had no cross section to import.');
|
|
1143
|
+
return '';
|
|
1144
|
+
}
|
|
1145
|
+
const on = (v) => v !== 'false' && v !== 'FALSE';
|
|
1146
|
+
const mesh = tessellateExtrusion(cross, spine, nums(a.scale), on(a.beginCap), on(a.endCap));
|
|
1147
|
+
notes.push('An Extrusion was imported as a mesh.');
|
|
1148
|
+
return `<mesh${ident}${style} positions="${mesh.positions.map((v) => n(v)).join(' ')}" indices="${mesh.indices.join(' ')}"/>`;
|
|
1149
|
+
}
|
|
1150
|
+
if (name === 'IndexedFaceSet' || name === 'IndexedLineSet') {
|
|
1151
|
+
const coordTag = (tag.children || []).find((c) => c.name === 'Coordinate');
|
|
1152
|
+
const point = nums((coordTag && coordTag.attrs && coordTag.attrs.point) || a.point);
|
|
1153
|
+
const ccw = a.ccw !== 'false' && a.ccw !== 'FALSE';
|
|
1154
|
+
return indexedMarkup(name, point, nums(a.coordIndex), ccw, ident, style, notes);
|
|
1155
|
+
}
|
|
1156
|
+
if (name === 'DirectionalLight' || name === 'PointLight' || name === 'SpotLight') {
|
|
1157
|
+
const fields = {};
|
|
1158
|
+
const color = nums(a.color);
|
|
1159
|
+
const location = nums(a.location);
|
|
1160
|
+
const direction = nums(a.direction);
|
|
1161
|
+
if (color.length >= 3) fields.color = color;
|
|
1162
|
+
if (location.length >= 3) fields.location = location;
|
|
1163
|
+
if (direction.length >= 3) fields.direction = direction;
|
|
1164
|
+
if (a.intensity !== undefined && a.intensity !== '') fields.intensity = Number(a.intensity);
|
|
1165
|
+
return emitLight({ name, fields }, ident);
|
|
1166
|
+
}
|
|
1167
|
+
if (name === 'Viewpoint' || name === 'OrthoViewpoint') {
|
|
1168
|
+
const position = nums(a.position);
|
|
1169
|
+
return emitCamera({ fields: { position: position.length >= 3 ? position : undefined } }, ident, notes);
|
|
1170
|
+
}
|
|
1171
|
+
if (name === 'Scene' || name === 'X3D') return (tag.children || []).map((c) => emitXml(c, notes)).filter(Boolean).join('\n');
|
|
1172
|
+
return '';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function xmlMaterial(app, notes) {
|
|
1176
|
+
const mat = (app.children || []).find((c) => c.name === 'PhysicalMaterial' || c.name === 'Material' || c.name === 'UnlitMaterial');
|
|
1177
|
+
if (!mat) return '';
|
|
1178
|
+
if (mat.name === 'Material') notes.push('A Phong Material was read as a colour (X3D Material is not the glTF model).');
|
|
1179
|
+
const a = mat.attrs || {};
|
|
1180
|
+
const base = nums(a.baseColor || a.diffuseColor || a.emissiveColor);
|
|
1181
|
+
if (base.length < 3) return '';
|
|
1182
|
+
return ` color="${colorToShortHex(base.slice(0, 3).map((v) => linearToSrgb(v)))}"`;
|
|
1183
|
+
}
|