@es3d/es3d 1.0.1 → 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 +27 -7
- package/browser/es3d-stage.min.js +10349 -0
- package/docs/SPEC.md +9 -0
- package/package.json +4 -2
- package/scripts/es3d-cli.mjs +56 -20
- package/src/format/parse.js +43 -3
- package/src/index.js +12 -5
- 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/gltf-pack.js +1461 -0
- 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/optimize/optimizer.js +34 -9
- package/src/render/stage.js +87 -11
- package/src/scene/flatten.js +208 -4
- package/src/scene/scene-graph.js +13 -2
- package/src/stage.js +8 -0
package/src/io/usd.js
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// USD ASCII, as read by OpenUSD's USDA parser.
|
|
2
|
+
//
|
|
3
|
+
// Specification: Universal Scene Description, schema version used by OpenUSD
|
|
4
|
+
// 24.x (the crate magic and the composition rules are stable back to the 1.0
|
|
5
|
+
// documents). This module writes `#usda 1.0`. USDC is smaller and is accepted
|
|
6
|
+
// by stock USD readers, but a correct crate cannot be produced without the
|
|
7
|
+
// OpenUSD library, and that library cannot run in the browser. USDA is the
|
|
8
|
+
// encoding a stock reader accepts without it. The size comparison should say
|
|
9
|
+
// so: a USDC file of the same scene would be smaller.
|
|
10
|
+
//
|
|
11
|
+
// Stage: upAxis = "Y" (ES3D is Y-up). metersPerUnit is always written;
|
|
12
|
+
// the fallback is 0.01, which would silently turn metres into centimetres.
|
|
13
|
+
// metres → 1, centimetres → 0.01, millimetres → 0.001, kilometres → 1000,
|
|
14
|
+
// inches → 0.0254, feet → 0.3048.
|
|
15
|
+
//
|
|
16
|
+
// Prims (UsdGeom):
|
|
17
|
+
// Cube.size is one edge length, default 2, so the cube extends ±size/2.
|
|
18
|
+
// A box that is not a cube is a Cube of size 1 (extent ±0.5, the ES3D unit
|
|
19
|
+
// cube) plus a scale.
|
|
20
|
+
// Sphere.radius defaults to 1.
|
|
21
|
+
// Cylinder, Cone, Capsule and Plane default to axis "Z"; ES3D axes are Y,
|
|
22
|
+
// so axis is always written. Cylinder has one radius (a frustum is a mesh).
|
|
23
|
+
// Cone's apex is along +axis and its base is at the negative end.
|
|
24
|
+
// Capsule.height is the cylindrical portion only. ES3D height includes the
|
|
25
|
+
// hemispheres, so the written height is h − 2r.
|
|
26
|
+
// Plane axis "Y": width runs along X and length along Z (UsdGeomPlane).
|
|
27
|
+
// The plane is double-sided by default.
|
|
28
|
+
// Extent is the local bound, excluding the prim's own transform, and is
|
|
29
|
+
// written whenever a size is written.
|
|
30
|
+
// Mesh subdivisionScheme = "none", orientation = "rightHanded" (counter-clockwise).
|
|
31
|
+
//
|
|
32
|
+
// Transforms (UsdGeomXformable): xformOpOrder lists the least-local op first.
|
|
33
|
+
// The documented TRS order is translate, rotate, scale, so a column vector
|
|
34
|
+
// sees scale, then rotation, then translation. A matrix4d is stored for row
|
|
35
|
+
// vectors (p' = p · M): the translation is the last row.
|
|
36
|
+
//
|
|
37
|
+
// Materials: UsdPreviewSurface. diffuseColor is linear. There is no unlit
|
|
38
|
+
// preview shader; an unlit material is written as a diffuse colour and noted.
|
|
39
|
+
// Lights and cameras are counted in the notes and left out. This module does
|
|
40
|
+
// not write UsdLux lights or UsdGeomCamera.
|
|
41
|
+
//
|
|
42
|
+
// Reuse: a geometry used more than once is a prototype under an invisible
|
|
43
|
+
// Xform, referenced by the instances. Repeat and scatter become a
|
|
44
|
+
// PointInstancer (prototypes, protoIndices, positions, orientations as
|
|
45
|
+
// quatf real-first, scales). A copy whose transform shears is an ordinary
|
|
46
|
+
// Xform with a matrix4d instead.
|
|
47
|
+
|
|
48
|
+
import * as mat4 from '../math/mat4.js';
|
|
49
|
+
import { matrixToTransformString } from '../transform/transform.js';
|
|
50
|
+
import { colorToShortHex } from '../format/colors.js';
|
|
51
|
+
import { n, trsOf, usdRows, metresPerUnit } from './text-encode.js';
|
|
52
|
+
import { repeatCopies, scatterCopies, paint } from './export-model.js';
|
|
53
|
+
|
|
54
|
+
function vec3(v) {
|
|
55
|
+
return `(${n(v[0])}, ${n(v[1])}, ${n(v[2])})`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extent(min, max) {
|
|
59
|
+
return `[${vec3(min)}, ${vec3(max)}]`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pointsOf(flat) {
|
|
63
|
+
const parts = [];
|
|
64
|
+
for (let i = 0; i < flat.length; i += 3) parts.push(vec3([flat[i], flat[i + 1], flat[i + 2]]));
|
|
65
|
+
return parts.join(', ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function matrixText(m) {
|
|
69
|
+
const rows = usdRows(m).map((r) => `(${r.map(n).join(', ')})`);
|
|
70
|
+
return `(${rows.join(', ')})`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function xformBlock(matrix, pad) {
|
|
74
|
+
if (!matrix) return '';
|
|
75
|
+
const t = trsOf(matrix);
|
|
76
|
+
if (!t) {
|
|
77
|
+
return `${pad}matrix4d xformOp:transform = ${matrixText(matrix)}\n${pad}uniform token[] xformOpOrder = ["xformOp:transform"]\n`;
|
|
78
|
+
}
|
|
79
|
+
if (t.identity) return '';
|
|
80
|
+
const order = [];
|
|
81
|
+
let body = '';
|
|
82
|
+
if (!t.noTrans) {
|
|
83
|
+
order.push('"xformOp:translate"');
|
|
84
|
+
body += `${pad}double3 xformOp:translate = ${vec3(t.translation)}\n`;
|
|
85
|
+
}
|
|
86
|
+
if (!t.noRot) {
|
|
87
|
+
return `${pad}matrix4d xformOp:transform = ${matrixText(matrix)}\n${pad}uniform token[] xformOpOrder = ["xformOp:transform"]\n`;
|
|
88
|
+
}
|
|
89
|
+
if (!t.noScale) {
|
|
90
|
+
order.push('"xformOp:scale"');
|
|
91
|
+
body += `${pad}float3 xformOp:scale = ${vec3(t.scale)}\n`;
|
|
92
|
+
}
|
|
93
|
+
if (!order.length) return '';
|
|
94
|
+
return `${body}${pad}uniform token[] xformOpOrder = [${order.join(', ')}]\n`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function boundsOf(positions) {
|
|
98
|
+
const min = [Infinity, Infinity, Infinity];
|
|
99
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
100
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
101
|
+
for (let k = 0; k < 3; k++) {
|
|
102
|
+
const v = positions[i + k];
|
|
103
|
+
if (v < min[k]) min[k] = v;
|
|
104
|
+
if (v > max[k]) max[k] = v;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return extent(min, max);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @returns {{text: string, notes: string[]}}
|
|
112
|
+
*/
|
|
113
|
+
export function exportUSD(model) {
|
|
114
|
+
const notes = model.notes.slice();
|
|
115
|
+
const tally = new Map();
|
|
116
|
+
const add = (key, k = 1) => tally.set(key, (tally.get(key) || 0) + k);
|
|
117
|
+
const uses = new Map();
|
|
118
|
+
const countUses = (node) => {
|
|
119
|
+
if (!node) return;
|
|
120
|
+
if (node.kind === 'shape') uses.set(node.solid.key, (uses.get(node.solid.key) || 0) + 1);
|
|
121
|
+
for (const c of node.children || []) countUses(c);
|
|
122
|
+
};
|
|
123
|
+
countUses(model.root);
|
|
124
|
+
|
|
125
|
+
const protos = [];
|
|
126
|
+
const protoName = new Map();
|
|
127
|
+
let seq = 0;
|
|
128
|
+
const fresh = (prefix) => `${prefix}${seq++}`;
|
|
129
|
+
|
|
130
|
+
const materialName = new Map();
|
|
131
|
+
const materials = [];
|
|
132
|
+
let unlit = 0;
|
|
133
|
+
const materialPath = (index) => {
|
|
134
|
+
if (materialName.has(index)) return materialName.get(index);
|
|
135
|
+
const id = fresh('M');
|
|
136
|
+
materialName.set(index, id);
|
|
137
|
+
const m = model.materials[index];
|
|
138
|
+
if (m.unlit) unlit++;
|
|
139
|
+
const c = m.baseColor;
|
|
140
|
+
const e = m.emissive;
|
|
141
|
+
const extra = e.some((v) => v > 1e-6) ? `\n color3f inputs:emissiveColor = ${vec3(e)}` : '';
|
|
142
|
+
materials.push(` def Material "${id}" {
|
|
143
|
+
token outputs:surface.connect = </World/Materials/${id}/Surface.outputs:surface>
|
|
144
|
+
def Shader "Surface" {
|
|
145
|
+
uniform token info:id = "UsdPreviewSurface"
|
|
146
|
+
color3f inputs:diffuseColor = ${vec3(c)}
|
|
147
|
+
float inputs:metallic = ${n(m.metallic)}
|
|
148
|
+
float inputs:roughness = ${n(m.roughness)}
|
|
149
|
+
float inputs:opacity = ${n(m.opacity)}${extra}
|
|
150
|
+
token outputs:surface
|
|
151
|
+
}
|
|
152
|
+
}`);
|
|
153
|
+
return id;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const gprim = (shape) => {
|
|
157
|
+
const s = shape.solid;
|
|
158
|
+
if (s.kind === 'box') {
|
|
159
|
+
const size = s.size;
|
|
160
|
+
if (Math.abs(size[0] - size[1]) < 1e-9 && Math.abs(size[1] - size[2]) < 1e-9) {
|
|
161
|
+
const h = size[0] / 2;
|
|
162
|
+
return { type: 'Cube', body: `double size = ${n(size[0])}\nfloat3[] extent = ${extent([-h, -h, -h], [h, h, h])}\n`, local: null };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
type: 'Cube',
|
|
166
|
+
body: `double size = 1\nfloat3[] extent = ${extent([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5])}\n`,
|
|
167
|
+
local: scaleMatrix(size),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (s.kind === 'sphere') {
|
|
171
|
+
const r = s.radius;
|
|
172
|
+
return { type: 'Sphere', body: `double radius = ${n(r)}\nfloat3[] extent = ${extent([-r, -r, -r], [r, r, r])}\n`, local: null };
|
|
173
|
+
}
|
|
174
|
+
if (s.kind === 'cylinder') {
|
|
175
|
+
const r = s.radius, h = s.height;
|
|
176
|
+
return { type: 'Cylinder', body: `uniform token axis = "Y"\ndouble radius = ${n(r)}\ndouble height = ${n(h)}\nfloat3[] extent = ${extent([-r, -h / 2, -r], [r, h / 2, r])}\n`, local: null };
|
|
177
|
+
}
|
|
178
|
+
if (s.kind === 'cone') {
|
|
179
|
+
const r = s.radius, h = s.height;
|
|
180
|
+
return { type: 'Cone', body: `uniform token axis = "Y"\ndouble radius = ${n(r)}\ndouble height = ${n(h)}\nfloat3[] extent = ${extent([-r, -h / 2, -r], [r, h / 2, r])}\n`, local: null };
|
|
181
|
+
}
|
|
182
|
+
if (s.kind === 'capsule') {
|
|
183
|
+
const r = s.radius, h = s.height;
|
|
184
|
+
const mid = h - 2 * r;
|
|
185
|
+
if (mid <= 1e-6) {
|
|
186
|
+
return { type: 'Sphere', body: `double radius = ${n(r)}\nfloat3[] extent = ${extent([-r, -r, -r], [r, r, r])}\n`, local: null };
|
|
187
|
+
}
|
|
188
|
+
return { type: 'Capsule', body: `uniform token axis = "Y"\ndouble radius = ${n(r)}\ndouble height = ${n(mid)}\nfloat3[] extent = ${extent([-r, -h / 2, -r], [r, h / 2, r])}\n`, local: null, note: 'capsule' };
|
|
189
|
+
}
|
|
190
|
+
if (s.kind === 'plane') {
|
|
191
|
+
const w = s.width, d = s.depth;
|
|
192
|
+
return { type: 'Plane', body: `uniform token axis = "Y"\ndouble width = ${n(w)}\ndouble length = ${n(d)}\nfloat3[] extent = ${extent([-w / 2, 0, -d / 2], [w / 2, 0, d / 2])}\n`, local: null };
|
|
193
|
+
}
|
|
194
|
+
if (s.kind === 'frustum' || s.kind === 'torus' || s.kind === 'extrude' || s.kind === 'revolve' || s.kind === 'surface') {
|
|
195
|
+
const mesh = model.bakedMesh(shape.entry);
|
|
196
|
+
return { type: 'Mesh', body: meshBody(mesh, shape.doubleSided), local: null, matrix: shape.meshMatrix, note: s.kind };
|
|
197
|
+
}
|
|
198
|
+
if (s.kind === 'mesh' || s.kind === 'lines') {
|
|
199
|
+
return { type: s.kind === 'lines' ? 'BasisCurves' : 'Mesh', body: meshBody(s, shape.doubleSided), local: null, matrix: null, note: null };
|
|
200
|
+
}
|
|
201
|
+
return { type: 'Mesh', body: meshBody(model.bakedMesh(shape.entry), shape.doubleSided), local: null, matrix: shape.meshMatrix, note: s.kind };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const ensureProto = (shape) => {
|
|
205
|
+
const key = shape.solid.key;
|
|
206
|
+
if (protoName.has(key)) return protoName.get(key);
|
|
207
|
+
const id = fresh('G');
|
|
208
|
+
protoName.set(key, id);
|
|
209
|
+
const g = gprim(shape);
|
|
210
|
+
const local = g.local;
|
|
211
|
+
protos.push(` def ${g.type} "${id}" {\n${indent(g.body, 12)}${local ? indent(xformBlock(local, ''), 12) : ''} }`);
|
|
212
|
+
return id;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const direct = (name, shape, matrix) => {
|
|
216
|
+
const key = shape.solid.key;
|
|
217
|
+
const g = gprim(shape);
|
|
218
|
+
if (g.note) add(g.note);
|
|
219
|
+
const bind = `rel material:binding = </World/Materials/${materialPath(shape.material)}>\n`;
|
|
220
|
+
const inst = g.matrix || matrix;
|
|
221
|
+
if ((uses.get(key) || 0) > 1) {
|
|
222
|
+
const id = ensureProto(shape);
|
|
223
|
+
return ` def Xform "${name}" (\n prepend references = </World/Prototypes/${id}>\n ) {\n token visibility = "inherited"\n${indent(xformBlock(inst, ''), 12)}${indent(bind, 12)} }`;
|
|
224
|
+
}
|
|
225
|
+
const combined = g.matrix ? inst : combine(matrix, g.local);
|
|
226
|
+
return ` def ${g.type} "${name}" {\n${indent(g.body, 12)}${indent(xformBlock(combined, ''), 12)}${indent(bind, 12)} }`;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const chunks = [];
|
|
230
|
+
const emit = (node) => {
|
|
231
|
+
if (!node) return;
|
|
232
|
+
if (node.kind === 'group') {
|
|
233
|
+
const id = fresh('X');
|
|
234
|
+
const saved = chunks.length;
|
|
235
|
+
for (const c of node.children) emit(c);
|
|
236
|
+
const body = chunks.splice(saved);
|
|
237
|
+
if (!body.length && !node.matrix) return;
|
|
238
|
+
chunks.push(` def Xform "${id}" {\n${indent(xformBlock(node.matrix, ''), 12)}${body.map((b) => indent(b, 4)).join('\n')}\n }`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (node.kind === 'repeat' || node.kind === 'scatter') {
|
|
242
|
+
chunks.push(instancer(node));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
chunks.push(direct(fresh('S'), node, node.matrix));
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const instancer = (node) => {
|
|
249
|
+
const copies = node.kind === 'repeat'
|
|
250
|
+
? repeatCopies(node).map((matrix) => ({ matrix, material: null }))
|
|
251
|
+
: scatterCopies(node);
|
|
252
|
+
add(node.kind, copies.length);
|
|
253
|
+
const positions = [];
|
|
254
|
+
const orients = [];
|
|
255
|
+
const scales = [];
|
|
256
|
+
const indices = [];
|
|
257
|
+
const kept = [];
|
|
258
|
+
let baked = 0;
|
|
259
|
+
const bakedPrims = [];
|
|
260
|
+
copies.forEach((copy) => {
|
|
261
|
+
if (!trsOf(copy.matrix)) {
|
|
262
|
+
baked++;
|
|
263
|
+
const shape = firstShape(node);
|
|
264
|
+
if (shape) bakedPrims.push(direct(fresh('S'), shape, copy.matrix));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
kept.push(copy);
|
|
268
|
+
});
|
|
269
|
+
const groups = new Map();
|
|
270
|
+
kept.forEach((copy, index) => {
|
|
271
|
+
const id = copy.material || '';
|
|
272
|
+
if (!groups.has(id)) groups.set(id, []);
|
|
273
|
+
groups.get(id).push(index);
|
|
274
|
+
});
|
|
275
|
+
const protoIds = [];
|
|
276
|
+
for (const [matId, groupIndices] of groups) {
|
|
277
|
+
const kids = matId ? node.children.map((c) => paint(c, matId, null, model.resolve)) : node.children;
|
|
278
|
+
const pid = fresh('P');
|
|
279
|
+
const saved = chunks.length;
|
|
280
|
+
for (const c of kids) emit(c);
|
|
281
|
+
const body = chunks.splice(saved);
|
|
282
|
+
protos.push(` def Xform "${pid}" {\n${body.map((b) => indent(b, 4)).join('\n')}\n }`);
|
|
283
|
+
protoIds.push({ pid, groupIndices });
|
|
284
|
+
}
|
|
285
|
+
const indexOf = new Array(kept.length);
|
|
286
|
+
let pi = 0;
|
|
287
|
+
for (const group of protoIds) {
|
|
288
|
+
for (const index of group.groupIndices) indexOf[index] = pi;
|
|
289
|
+
pi++;
|
|
290
|
+
}
|
|
291
|
+
kept.forEach((copy, index) => {
|
|
292
|
+
const t = trsOf(copy.matrix);
|
|
293
|
+
positions.push(vec3(t.translation));
|
|
294
|
+
const q = t.rotation;
|
|
295
|
+
orients.push(`(${n(q[3])}, ${n(q[0])}, ${n(q[1])}, ${n(q[2])})`);
|
|
296
|
+
scales.push(vec3(t.scale));
|
|
297
|
+
indices.push(indexOf[index]);
|
|
298
|
+
});
|
|
299
|
+
if (baked) notes.push(`${baked.toLocaleString('en-US')} instanced cop${baked === 1 ? 'y was' : 'ies were'} written as its own prim because the transform shears.`);
|
|
300
|
+
const id = fresh('I');
|
|
301
|
+
const rel = protoIds.map((p) => `</World/Prototypes/${p.pid}>`).join(', ');
|
|
302
|
+
return ` def PointInstancer "${id}" {\n rel prototypes = [${rel}]\n int[] protoIndices = [${indices.join(', ')}]\n point3f[] positions = [${positions.join(', ')}]\n quatf[] orientations = [${orients.join(', ')}]\n float3[] scales = [${scales.join(', ')}]\n${xformBlock(node.matrix, ' ')} }${bakedPrims.length ? '\n' + bakedPrims.join('\n') : ''}`;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
emit(model.root);
|
|
306
|
+
|
|
307
|
+
if (unlit) notes.push(`${unlit.toLocaleString('en-US')} unlit material${unlit === 1 ? '' : 's'} written as a diffuse colour (UsdPreviewSurface has no unlit shader).`);
|
|
308
|
+
const say = (key, text) => { if (tally.get(key)) notes.push(text(tally.get(key))); };
|
|
309
|
+
say('frustum', (k) => `${k.toLocaleString('en-US')} frustum${k === 1 ? '' : 's'} written as a mesh (USD Cylinder has one radius).`);
|
|
310
|
+
say('torus', (k) => `${k.toLocaleString('en-US')} torus${k === 1 ? '' : 'es'} written as a mesh (USD has no torus).`);
|
|
311
|
+
say('extrude', (k) => `${k.toLocaleString('en-US')} extrusion${k === 1 ? '' : 's'} written as a mesh (USD has no extrusion).`);
|
|
312
|
+
say('revolve', (k) => `${k.toLocaleString('en-US')} revolve${k === 1 ? '' : 's'} written as a mesh (USD has no lathe).`);
|
|
313
|
+
say('surface', (k) => `${k.toLocaleString('en-US')} flat surface${k === 1 ? '' : 's'} written as a mesh.`);
|
|
314
|
+
say('capsule', (k) => `${k.toLocaleString('en-US')} capsule${k === 1 ? '' : 's'} written with height excluding the hemispheres, which is the USD convention.`);
|
|
315
|
+
say('repeat', (k) => `${k.toLocaleString('en-US')} repeated copies written as a PointInstancer.`);
|
|
316
|
+
say('scatter', (k) => `${k.toLocaleString('en-US')} scattered copies written as a PointInstancer.`);
|
|
317
|
+
if (model.lights.length) notes.push(`${model.lights.length.toLocaleString('en-US')} light${model.lights.length === 1 ? '' : 's'} omitted (this USDA file does not write UsdLux lights).`);
|
|
318
|
+
if (model.cameras.length) notes.push(`${model.cameras.length.toLocaleString('en-US')} camera${model.cameras.length === 1 ? '' : 's'} omitted.`);
|
|
319
|
+
|
|
320
|
+
const protoBlock = protos.length ? ` def Xform "Prototypes" {\n token visibility = "invisible"\n${protos.join('\n')}\n }\n` : '';
|
|
321
|
+
const matBlock = materials.length ? ` def Scope "Materials" {\n${materials.join('\n')}\n }\n` : '';
|
|
322
|
+
const text = `#usda 1.0
|
|
323
|
+
(
|
|
324
|
+
defaultPrim = "World"
|
|
325
|
+
upAxis = "Y"
|
|
326
|
+
metersPerUnit = ${n(metresPerUnit(model.unit))}
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def Xform "World" {
|
|
330
|
+
${protoBlock}${matBlock}${chunks.join('\n')}
|
|
331
|
+
}
|
|
332
|
+
`;
|
|
333
|
+
return { text, notes };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function scaleMatrix(size) {
|
|
337
|
+
const m = mat4.createF64();
|
|
338
|
+
m[0] = size[0];
|
|
339
|
+
m[5] = size[1];
|
|
340
|
+
m[10] = size[2];
|
|
341
|
+
return m;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function combine(a, b) {
|
|
345
|
+
if (!a) return b || null;
|
|
346
|
+
if (!b) return a;
|
|
347
|
+
const o = mat4.createF64();
|
|
348
|
+
mat4.multiply(o, a, b);
|
|
349
|
+
return o;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function indent(text, spaces) {
|
|
353
|
+
const pad = ' '.repeat(spaces);
|
|
354
|
+
return text.split('\n').map((line) => (line ? pad + line : line)).join('\n');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function meshBody(mesh, doubleSided) {
|
|
358
|
+
if (!mesh) return `uniform token subdivisionScheme = "none"\n`;
|
|
359
|
+
if (mesh.kind === 'lines') {
|
|
360
|
+
const counts = [];
|
|
361
|
+
const idx = [];
|
|
362
|
+
for (let i = 0; i < mesh.indices.length; i += 2) {
|
|
363
|
+
counts.push(2);
|
|
364
|
+
idx.push(mesh.indices[i], mesh.indices[i + 1]);
|
|
365
|
+
}
|
|
366
|
+
return `int[] curveVertexCounts = [${counts.join(', ')}]\npoint3f[] points = [${pointsOf(mesh.positions)}]\nuniform token type = "linear"\nfloat3[] extent = ${boundsOf(mesh.positions)}\n`;
|
|
367
|
+
}
|
|
368
|
+
const counts = new Array(mesh.indices.length / 3).fill(3);
|
|
369
|
+
const normals = mesh.normals ? `normal3f[] normals = [${pointsOf(mesh.normals)}]\nuniform token normals:interpolation = "vertex"\n` : '';
|
|
370
|
+
const side = doubleSided ? 'uniform bool doubleSided = 1\n' : '';
|
|
371
|
+
return `int[] faceVertexCounts = [${counts.join(', ')}]\nint[] faceVertexIndices = [${Array.from(mesh.indices).join(', ')}]\npoint3f[] points = [${pointsOf(mesh.positions)}]\n${normals}${side}uniform token subdivisionScheme = "none"\nuniform token orientation = "rightHanded"\nfloat3[] extent = ${boundsOf(mesh.positions)}\n`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function firstShape(node) {
|
|
375
|
+
if (!node) return null;
|
|
376
|
+
if (node.kind === 'shape') return node;
|
|
377
|
+
for (const c of node.children || []) {
|
|
378
|
+
const hit = firstShape(c);
|
|
379
|
+
if (hit) return hit;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Read the USDA this module writes: Cube, Sphere, Cylinder, Cone, Capsule,
|
|
386
|
+
* Plane, Mesh, references to prototypes, and PointInstancer copies.
|
|
387
|
+
* Transforms are translate, scale and matrix4d (row-vector, translation in the
|
|
388
|
+
* last row, which is how usdRows lays a column-major matrix out).
|
|
389
|
+
* Crate files are rejected by the caller.
|
|
390
|
+
* @returns {{text: string, notes: string[]}}
|
|
391
|
+
*/
|
|
392
|
+
export function importUSD(text) {
|
|
393
|
+
const notes = [];
|
|
394
|
+
const src = String(text).replace(/^\uFEFF/, '');
|
|
395
|
+
if (!/^\s*#usda\b/.test(src)) notes.push('The file does not start with #usda; it was read as USDA anyway.');
|
|
396
|
+
const meters = /metersPerUnit\s*=\s*([0-9.eE+-]+)/.exec(src);
|
|
397
|
+
const unit = unitFromMetres(meters ? Number(meters[1]) : 1);
|
|
398
|
+
if (!meters) notes.push('metersPerUnit was missing; USD defaults to centimetres, and this file was read as metres.');
|
|
399
|
+
const world = blockNamed(src, 'Xform', 'World') || src;
|
|
400
|
+
const lib = new Map();
|
|
401
|
+
const materials = new Map();
|
|
402
|
+
const lines = [];
|
|
403
|
+
const matLines = [];
|
|
404
|
+
const paint = makePaint(matLines);
|
|
405
|
+
for (const prim of listPrims(world)) {
|
|
406
|
+
if (prim.type === 'Xform' && prim.name === 'Prototypes') {
|
|
407
|
+
for (const child of listPrims(prim.body)) lib.set(child.name, child);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (prim.type === 'Scope' && prim.name === 'Materials') {
|
|
411
|
+
for (const mat of listPrims(prim.body)) if (mat.type === 'Material') materials.set(mat.name, readPreview(mat.body));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
lines.push(...emitPrim(prim, lib, materials, paint, notes, null));
|
|
415
|
+
}
|
|
416
|
+
const body = [...matLines, ...lines].filter(Boolean);
|
|
417
|
+
return { text: `<es3d version="1" unit="${unit}">\n${body.join('\n')}\n</es3d>\n`, notes };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function sliceBlock(text, open) {
|
|
421
|
+
let depth = 0;
|
|
422
|
+
for (let i = open; i < text.length; i++) {
|
|
423
|
+
if (text[i] === '{') depth++;
|
|
424
|
+
else if (text[i] === '}') {
|
|
425
|
+
depth--;
|
|
426
|
+
if (depth === 0) return text.slice(open + 1, i);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return '';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const GPRIMS = new Set(['Cube', 'Sphere', 'Cylinder', 'Cone', 'Capsule', 'Plane', 'Mesh']);
|
|
433
|
+
|
|
434
|
+
function blockNamed(text, type, name) {
|
|
435
|
+
const re = new RegExp(`def\\s+${type}\\s+"${name}"\\s*(?:\\([^)]*\\))?\\s*\\{`);
|
|
436
|
+
const hit = re.exec(text);
|
|
437
|
+
if (!hit) return null;
|
|
438
|
+
const open = text.indexOf('{', hit.index);
|
|
439
|
+
return sliceBlock(text, open);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function listPrims(text) {
|
|
443
|
+
const out = [];
|
|
444
|
+
let i = 0;
|
|
445
|
+
while (i < text.length) {
|
|
446
|
+
const at = text.indexOf('def ', i);
|
|
447
|
+
if (at < 0) break;
|
|
448
|
+
const before = text.slice(i, at);
|
|
449
|
+
if ((before.match(/\{/g) || []).length !== (before.match(/\}/g) || []).length) { i = at + 4; continue; }
|
|
450
|
+
const head = /^def\s+([A-Za-z0-9_]+)\s+"([^"]+)"\s*(\([^)]*\))?/.exec(text.slice(at));
|
|
451
|
+
if (!head) { i = at + 4; continue; }
|
|
452
|
+
const open = text.indexOf('{', at + head[0].length - 1);
|
|
453
|
+
if (open < 0) break;
|
|
454
|
+
const body = sliceBlock(text, open);
|
|
455
|
+
out.push({ type: head[1], name: head[2], meta: head[3] || '', body });
|
|
456
|
+
i = open + body.length + 2;
|
|
457
|
+
}
|
|
458
|
+
return out;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function emitPrim(prim, lib, materials, paint, notes, parent, inherited) {
|
|
462
|
+
const local = readXform(prim.body);
|
|
463
|
+
const matrix = combine(parent, local);
|
|
464
|
+
const style = paint(prim.body, materials) || inherited || '';
|
|
465
|
+
if (prim.type === 'Xform') {
|
|
466
|
+
const ref = /references\s*=\s*<([^>]+)>/.exec(prim.meta);
|
|
467
|
+
const target = ref ? lib.get(ref[1].split('/').pop()) : null;
|
|
468
|
+
if (target) return emitPrim(target, lib, materials, paint, notes, matrix, style);
|
|
469
|
+
const out = [];
|
|
470
|
+
for (const child of listPrims(prim.body)) out.push(...emitPrim(child, lib, materials, paint, notes, matrix, style));
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
if (prim.type === 'PointInstancer') return emitInstancer(prim, lib, materials, paint, notes, matrix);
|
|
474
|
+
if (!GPRIMS.has(prim.type)) {
|
|
475
|
+
if (prim.type !== 'Shader' && prim.type !== 'Material' && prim.type !== 'Scope') notes.push(`${prim.type} ${prim.name} was omitted.`);
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
const tag = gprimTag(prim, notes, matrix, style);
|
|
479
|
+
return tag ? [tag] : [];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function emitInstancer(prim, lib, materials, paint, notes, parent) {
|
|
483
|
+
const names = [...prim.body.matchAll(/<([^>]+)>/g)].map((m) => m[1].split('/').pop());
|
|
484
|
+
const rel = /prototypes\s*=\s*\[([^\]]*)\]/.exec(prim.body);
|
|
485
|
+
const protoNames = rel ? [...rel[1].matchAll(/<([^>]+)>/g)].map((m) => m[1].split('/').pop()) : names;
|
|
486
|
+
const indices = intList(prim.body, 'protoIndices');
|
|
487
|
+
const positions = tupleList(prim.body, 'positions');
|
|
488
|
+
const orients = tupleList(prim.body, 'orientations');
|
|
489
|
+
const scales = tupleList(prim.body, 'scales');
|
|
490
|
+
const count = Math.max(indices.length, positions.length);
|
|
491
|
+
if (!count || !protoNames.length) { notes.push(`PointInstancer ${prim.name} had no instances.`); return []; }
|
|
492
|
+
const out = [];
|
|
493
|
+
for (let i = 0; i < count; i++) {
|
|
494
|
+
const proto = lib.get(protoNames[indices[i] || 0]);
|
|
495
|
+
if (!proto) continue;
|
|
496
|
+
const trs = trsMatrix(positions[i] || [0, 0, 0], orients[i] || [1, 0, 0, 0], scales[i] || [1, 1, 1]);
|
|
497
|
+
out.push(...emitPrim(proto, lib, materials, paint, notes, combine(parent, trs)));
|
|
498
|
+
}
|
|
499
|
+
if (!out.length) notes.push(`PointInstancer ${prim.name} did not resolve its prototypes.`);
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function gprimTag(prim, notes, matrix, style) {
|
|
504
|
+
const num = (name, fallback) => {
|
|
505
|
+
const hit = new RegExp(`(?:double|float)\\s+${name}\\s*=\\s*([0-9.eE+-]+)`).exec(prim.body);
|
|
506
|
+
return hit ? Number(hit[1]) : fallback;
|
|
507
|
+
};
|
|
508
|
+
const axis = ((/axis\s*=\s*"([XYZ])"/.exec(prim.body) || [])[1]) || (prim.type === 'Cylinder' || prim.type === 'Cone' || prim.type === 'Capsule' || prim.type === 'Plane' ? 'Z' : 'Y');
|
|
509
|
+
const posed = prim.type === 'Cube' ? matrix : combine(matrix, axisFix(axis));
|
|
510
|
+
if (prim.type === 'Cube') {
|
|
511
|
+
const edge = num('size', 2);
|
|
512
|
+
const info = posed ? trsOf(posed) : null;
|
|
513
|
+
if (info && info.scale.every((v) => v > 0)) {
|
|
514
|
+
return `<box${xformAttr(rigid(info))}${style} size="${n(edge * info.scale[0])} ${n(edge * info.scale[1])} ${n(edge * info.scale[2])}"/>`;
|
|
515
|
+
}
|
|
516
|
+
return `<box${xformAttr(posed)}${style} size="${n(edge)} ${n(edge)} ${n(edge)}"/>`;
|
|
517
|
+
}
|
|
518
|
+
if (prim.type === 'Sphere') return `<sphere${xformAttr(posed)}${style} radius="${n(num('radius', 1))}"/>`;
|
|
519
|
+
if (prim.type === 'Cylinder') return `<cylinder${xformAttr(posed)}${style} radius="${n(num('radius', 1))}" height="${n(num('height', 2))}"/>`;
|
|
520
|
+
if (prim.type === 'Cone') return `<cone${xformAttr(posed)}${style} radius="${n(num('radius', 1))}" height="${n(num('height', 2))}"/>`;
|
|
521
|
+
if (prim.type === 'Capsule') {
|
|
522
|
+
const r = num('radius', 0.5);
|
|
523
|
+
const h = num('height', 1);
|
|
524
|
+
return `<capsule${xformAttr(posed)}${style} radius="${n(r)}" height="${n(h + 2 * r)}"/>`;
|
|
525
|
+
}
|
|
526
|
+
if (prim.type === 'Plane') return `<plane${xformAttr(posed)}${style} size="${n(num('width', 2))} ${n(num('length', 2))}"/>`;
|
|
527
|
+
if (prim.type === 'Mesh') return meshTag(prim, notes, posed, style);
|
|
528
|
+
return '';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function meshTag(prim, notes, matrix, style) {
|
|
532
|
+
const points = tupleList(prim.body, 'points');
|
|
533
|
+
const counts = intList(prim.body, 'faceVertexCounts');
|
|
534
|
+
const indices = intList(prim.body, 'faceVertexIndices');
|
|
535
|
+
if (!points.length || !indices.length) { notes.push(`Mesh ${prim.name} had no points.`); return ''; }
|
|
536
|
+
const left = /orientation\s*=\s*"leftHanded"/.test(prim.body);
|
|
537
|
+
const tris = [];
|
|
538
|
+
let cursor = 0;
|
|
539
|
+
const faces = counts.length ? counts : new Array(indices.length / 3).fill(3);
|
|
540
|
+
for (const count of faces) {
|
|
541
|
+
const face = indices.slice(cursor, cursor + count);
|
|
542
|
+
cursor += count;
|
|
543
|
+
if (left) face.reverse();
|
|
544
|
+
for (let k = 1; k < face.length - 1; k++) tris.push(face[0], face[k], face[k + 1]);
|
|
545
|
+
}
|
|
546
|
+
if (faces.some((c) => c !== 3)) notes.push(`Mesh ${prim.name} had a face that was fanned into triangles.`);
|
|
547
|
+
const pos = points.flatMap((p) => p.slice(0, 3).map((v) => n(v))).join(' ');
|
|
548
|
+
return `<mesh${xformAttr(matrix)}${style} positions="${pos}" indices="${tris.join(' ')}"/>`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function readXform(body) {
|
|
552
|
+
const at = body.indexOf('xformOp:transform');
|
|
553
|
+
if (at >= 0) {
|
|
554
|
+
const nums = body.slice(at).match(/[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g);
|
|
555
|
+
if (nums && nums.length >= 16) {
|
|
556
|
+
const m = mat4.createF64();
|
|
557
|
+
for (let i = 0; i < 16; i++) m[i] = Number(nums[i]);
|
|
558
|
+
return m;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const t = vecField(body, 'xformOp:translate');
|
|
562
|
+
const s = vecField(body, 'xformOp:scale');
|
|
563
|
+
if (!t && !s) return null;
|
|
564
|
+
const tm = mat4.createF64();
|
|
565
|
+
if (t) { tm[12] = t[0]; tm[13] = t[1]; tm[14] = t[2]; }
|
|
566
|
+
const sm = mat4.createF64();
|
|
567
|
+
if (s) { sm[0] = s[0]; sm[5] = s[1]; sm[10] = s[2]; }
|
|
568
|
+
return combine(t ? tm : null, s ? sm : null);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function axisFix(axis) {
|
|
572
|
+
if (axis === 'Y') return null;
|
|
573
|
+
const m = mat4.createF64();
|
|
574
|
+
if (axis === 'X') mat4.fromEulerDeg(m, 0, 0, -90);
|
|
575
|
+
else mat4.fromEulerDeg(m, 90, 0, 0);
|
|
576
|
+
return m;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function rigid(info) {
|
|
580
|
+
const r = mat4.createF64();
|
|
581
|
+
mat4.fromQuat(r, info.rotation[0], info.rotation[1], info.rotation[2], info.rotation[3]);
|
|
582
|
+
const t = mat4.createF64();
|
|
583
|
+
t[12] = info.translation[0];
|
|
584
|
+
t[13] = info.translation[1];
|
|
585
|
+
t[14] = info.translation[2];
|
|
586
|
+
return combine(t, r);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function trsMatrix(t, q, s) {
|
|
590
|
+
const tm = mat4.createF64();
|
|
591
|
+
tm[12] = t[0]; tm[13] = t[1]; tm[14] = t[2];
|
|
592
|
+
const rm = mat4.createF64();
|
|
593
|
+
mat4.fromQuat(rm, q[1], q[2], q[3], q[0]);
|
|
594
|
+
const sm = mat4.createF64();
|
|
595
|
+
sm[0] = s[0]; sm[5] = s[1]; sm[10] = s[2];
|
|
596
|
+
return combine(tm, combine(rm, sm));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function xformAttr(m) {
|
|
600
|
+
if (!m) return '';
|
|
601
|
+
const text = matrixToTransformString(m, (v) => n(v));
|
|
602
|
+
return text ? ` transform="${text}"` : '';
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function vecField(body, name) {
|
|
606
|
+
const hit = new RegExp(`${name}\\s*=\\s*\\(([^)]*)\\)`).exec(body);
|
|
607
|
+
if (!hit) return null;
|
|
608
|
+
const v = hit[1].split(',').map((s) => Number(s.trim()));
|
|
609
|
+
if (v.length < 3 || v.some((x) => !Number.isFinite(x))) return null;
|
|
610
|
+
return v;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function tupleList(body, name) {
|
|
614
|
+
const hit = new RegExp(`${name}\\s*=\\s*\\[([\\s\\S]*?)\\]`).exec(body);
|
|
615
|
+
if (!hit) return [];
|
|
616
|
+
const out = [];
|
|
617
|
+
const re = /\(([^)]*)\)/g;
|
|
618
|
+
let m;
|
|
619
|
+
while ((m = re.exec(hit[1]))) {
|
|
620
|
+
const v = m[1].split(',').map((s) => Number(s.trim()));
|
|
621
|
+
if (v.length && v.every((x) => Number.isFinite(x))) out.push(v);
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function intList(body, name) {
|
|
627
|
+
const hit = new RegExp(`${name}\\s*=\\s*\\[([^\\]]*)\\]`).exec(body);
|
|
628
|
+
if (!hit) return [];
|
|
629
|
+
return hit[1].split(/[\s,]+/).filter(Boolean).map(Number).filter((v) => Number.isFinite(v));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function readPreview(body) {
|
|
633
|
+
const color = vecField(body, 'inputs:diffuseColor') || [0.8, 0.8, 0.8];
|
|
634
|
+
const num = (name, fallback) => {
|
|
635
|
+
const hit = new RegExp(`inputs:${name}\\s*=\\s*([0-9.eE+-]+)`).exec(body);
|
|
636
|
+
return hit ? Number(hit[1]) : fallback;
|
|
637
|
+
};
|
|
638
|
+
return { color, metallic: num('metallic', 0), roughness: num('roughness', 0.5), opacity: num('opacity', 1) };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function makePaint(lines) {
|
|
642
|
+
let seq = 0;
|
|
643
|
+
return (body, materials) => {
|
|
644
|
+
const bind = /material:binding\s*=\s*<([^>]+)>/.exec(body);
|
|
645
|
+
if (!bind) return '';
|
|
646
|
+
const mat = materials.get(bind[1].split('/').pop());
|
|
647
|
+
if (!mat) return '';
|
|
648
|
+
const hex = colorToShortHex(mat.color.map(linearToSrgb));
|
|
649
|
+
if (mat.metallic < 1e-6 && Math.abs(mat.roughness - 0.5) < 1e-6 && mat.opacity > 0.999) return ` color="${hex}"`;
|
|
650
|
+
const id = `m${seq++}`;
|
|
651
|
+
const bits = [`color="${hex}"`];
|
|
652
|
+
if (mat.metallic >= 1e-6) bits.push(`metallic="${n(mat.metallic)}"`);
|
|
653
|
+
if (Math.abs(mat.roughness - 0.5) >= 1e-6) bits.push(`roughness="${n(mat.roughness)}"`);
|
|
654
|
+
if (mat.opacity <= 0.999) bits.push(`opacity="${n(mat.opacity)}"`);
|
|
655
|
+
lines.push(`<material id="${id}" ${bits.join(' ')}/>`);
|
|
656
|
+
return ` material="${id}"`;
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function linearToSrgb(c) {
|
|
661
|
+
const x = Math.min(1, Math.max(0, c));
|
|
662
|
+
return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function unitFromMetres(m) {
|
|
666
|
+
const table = [['m', 1], ['cm', 0.01], ['mm', 0.001], ['km', 1000], ['in', 0.0254], ['ft', 0.3048]];
|
|
667
|
+
for (const [name, v] of table) if (Math.abs(m - v) < 1e-8) return name;
|
|
668
|
+
return 'm';
|
|
669
|
+
}
|