@es3d/es3d 1.1.0 → 1.1.2
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/browser/es3d-stage.min.js +258 -244
- package/docs/SPEC.md +1 -1
- package/package.json +1 -1
- package/src/io/threejs.js +19 -9
- package/src/io/usd.js +30 -6
- package/src/io/x3d.js +6 -4
- package/src/render/frame-builder.js +38 -3
package/docs/SPEC.md
CHANGED
|
@@ -195,7 +195,7 @@ The reference reader keeps up to four levels. At quality 1 the segment count is
|
|
|
195
195
|
|
|
196
196
|
A `segments` attribute (or both torus segment counts) requests one fixed tessellation, which is part of the shape. A `segments="6"` cylinder is a hexagonal prism at every distance. Exact shapes (box, plane, polygon, hairline, authored mesh) have one level and a bound of 0.
|
|
197
197
|
|
|
198
|
-
Each level stores its bound. A frame may draw a level only while `bound × scale × pixelsPerUnit` is at most 1. `scale` is the longest column of the instance matrix. `pixelsPerUnit` is the projected size of
|
|
198
|
+
Each level stores its bound. A frame may draw a level only while `bound × scale × pixelsPerUnit` is at most 1. `scale` is the longest column of the instance matrix. `pixelsPerUnit` is the largest projected size, in CSS pixels, of a unit length at the centre of the instance's bounding sphere. For a perspective view it is F·d/z², where F = viewportHeight / (2·tan(verticalFov/2)), d is the distance from the eye to the centre and z is its depth along the view axis; d²/z² is limited to its value at the corners of the view, and that limit is used when z ≤ 0. For an orthographic view it is viewportHeight divided by the view's height. The reference reader picks the coarsest level that satisfies that test. A renderer `lodBias` above 1 (default 1) divides the one-pixel budget; it is not a document attribute. While a level is still being generated, a renderer may draw a coarser one in its place for a few frames (progressive refinement), as long as it keeps drawing until every level passes the test. For a unit sphere on the view axis the switches fall near 59, 15 and 7 pixels of the sphere's own radius.
|
|
199
199
|
|
|
200
200
|
Closed shapes must produce closed, outward-facing surfaces. Open or flat shapes (planes, polygons, paths, open revolves, `caps="false"`) are rendered double-sided.
|
|
201
201
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@es3d/es3d",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "ES3D (Extensible Scalable 3D): a declarative, SVG-inspired 3D scene format with a WebGPU/WebGL2 reference renderer, editor and converter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/src/io/threejs.js
CHANGED
|
@@ -44,7 +44,11 @@ export function exportThree(model) {
|
|
|
44
44
|
const tally = new Map();
|
|
45
45
|
const add = (key, k = 1) => tally.set(key, (tally.get(key) || 0) + k);
|
|
46
46
|
const lines = [];
|
|
47
|
+
// Geometries and materials are declared once, at the top of createScene(), so a
|
|
48
|
+
// repeat factory and the code after it can both use them.
|
|
49
|
+
const defs = [];
|
|
47
50
|
const geos = new Map();
|
|
51
|
+
const bakedGeos = new Map();
|
|
48
52
|
const mats = new Map();
|
|
49
53
|
let seq = 0;
|
|
50
54
|
const fresh = (p) => `${p}${seq++}`;
|
|
@@ -59,10 +63,10 @@ export function exportThree(model) {
|
|
|
59
63
|
const c = m.baseColorSrgb;
|
|
60
64
|
const side = (m.doubleSided || forceDouble) ? ', side: THREE.DoubleSide' : '';
|
|
61
65
|
if (m.unlit) {
|
|
62
|
-
|
|
66
|
+
defs.push(` const ${id} = new THREE.MeshBasicMaterial({ color: new THREE.Color(${n(c[0])}, ${n(c[1])}, ${n(c[2])}), transparent: ${m.opacity < 0.999}, opacity: ${n(m.opacity)}${side} });`);
|
|
63
67
|
return id;
|
|
64
68
|
}
|
|
65
|
-
|
|
69
|
+
defs.push(` const ${id} = new THREE.MeshStandardMaterial({ color: new THREE.Color(${n(c[0])}, ${n(c[1])}, ${n(c[2])}), metalness: ${n(m.metallic)}, roughness: ${n(m.roughness)}, transparent: ${m.opacity < 0.999}, opacity: ${n(m.opacity)}${side} });`);
|
|
66
70
|
return id;
|
|
67
71
|
};
|
|
68
72
|
|
|
@@ -71,7 +75,7 @@ export function exportThree(model) {
|
|
|
71
75
|
if (geos.has(key)) return geos.get(key);
|
|
72
76
|
const id = fresh('g');
|
|
73
77
|
geos.set(key, id);
|
|
74
|
-
|
|
78
|
+
defs.push(` const ${id} = ${geometryExpr(solid)};`);
|
|
75
79
|
return id;
|
|
76
80
|
};
|
|
77
81
|
|
|
@@ -116,12 +120,17 @@ export function exportThree(model) {
|
|
|
116
120
|
const baked = (node) => {
|
|
117
121
|
const mesh = node.solid.kind === 'mesh' || node.solid.kind === 'lines' ? node.solid : model.bakedMesh(node.entry);
|
|
118
122
|
add(node.solid.kind === 'mesh' || node.solid.kind === 'lines' ? 'mesh' : node.solid.kind);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
const geoKey = mesh.key || mesh;
|
|
124
|
+
let g = bakedGeos.get(geoKey);
|
|
125
|
+
if (!g) {
|
|
126
|
+
g = fresh('g');
|
|
127
|
+
bakedGeos.set(geoKey, g);
|
|
128
|
+
const positions = `new Float32Array([${nums(mesh.positions)}])`;
|
|
129
|
+
defs.push(` const ${g} = new THREE.BufferGeometry();`);
|
|
130
|
+
defs.push(` ${g}.setAttribute('position', new THREE.BufferAttribute(${positions}, 3));`);
|
|
131
|
+
if (mesh.normals) defs.push(` ${g}.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([${nums(mesh.normals)}]), 3));`);
|
|
132
|
+
defs.push(` ${g}.setIndex([${Array.from(mesh.indices).join(', ')}]);`);
|
|
133
|
+
}
|
|
125
134
|
const m = material(node.material, node.doubleSided);
|
|
126
135
|
const id = fresh('o');
|
|
127
136
|
const kind = mesh.kind === 'lines' ? 'LineSegments' : 'Mesh';
|
|
@@ -289,6 +298,7 @@ export function exportThree(model) {
|
|
|
289
298
|
${helper}
|
|
290
299
|
export function createScene() {
|
|
291
300
|
const root = new THREE.Group();
|
|
301
|
+
${defs.join('\n')}
|
|
292
302
|
${lines.join('\n')}
|
|
293
303
|
return root;
|
|
294
304
|
}
|
package/src/io/usd.js
CHANGED
|
@@ -220,7 +220,7 @@ export function exportUSD(model) {
|
|
|
220
220
|
const inst = g.matrix || matrix;
|
|
221
221
|
if ((uses.get(key) || 0) > 1) {
|
|
222
222
|
const id = ensureProto(shape);
|
|
223
|
-
return ` def
|
|
223
|
+
return ` def "${name}" (\n prepend references = </World/Prototypes/${id}>\n ) {\n token visibility = "inherited"\n${indent(xformBlock(inst, ''), 12)}${indent(bind, 12)} }`;
|
|
224
224
|
}
|
|
225
225
|
const combined = g.matrix ? inst : combine(matrix, g.local);
|
|
226
226
|
return ` def ${g.type} "${name}" {\n${indent(g.body, 12)}${indent(xformBlock(combined, ''), 12)}${indent(bind, 12)} }`;
|
|
@@ -439,6 +439,27 @@ function blockNamed(text, type, name) {
|
|
|
439
439
|
return sliceBlock(text, open);
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
/** The text of a prim's body without the bodies of the prims defined inside it. */
|
|
443
|
+
function ownText(body) {
|
|
444
|
+
let out = '';
|
|
445
|
+
let i = 0;
|
|
446
|
+
while (i < body.length) {
|
|
447
|
+
const at = body.indexOf('def ', i);
|
|
448
|
+
if (at < 0) { out += body.slice(i); break; }
|
|
449
|
+
out += body.slice(i, at);
|
|
450
|
+
const open = body.indexOf('{', at);
|
|
451
|
+
if (open < 0) break;
|
|
452
|
+
let depth = 0;
|
|
453
|
+
let j = open;
|
|
454
|
+
for (; j < body.length; j++) {
|
|
455
|
+
if (body[j] === '{') depth++;
|
|
456
|
+
else if (body[j] === '}' && --depth === 0) break;
|
|
457
|
+
}
|
|
458
|
+
i = j + 1;
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
442
463
|
function listPrims(text) {
|
|
443
464
|
const out = [];
|
|
444
465
|
let i = 0;
|
|
@@ -447,22 +468,25 @@ function listPrims(text) {
|
|
|
447
468
|
if (at < 0) break;
|
|
448
469
|
const before = text.slice(i, at);
|
|
449
470
|
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));
|
|
471
|
+
const head = /^def\s+(?:([A-Za-z0-9_]+)\s+)?"([^"]+)"\s*(\([^)]*\))?/.exec(text.slice(at));
|
|
451
472
|
if (!head) { i = at + 4; continue; }
|
|
452
473
|
const open = text.indexOf('{', at + head[0].length - 1);
|
|
453
474
|
if (open < 0) break;
|
|
454
475
|
const body = sliceBlock(text, open);
|
|
455
|
-
out.push({ type: head[1], name: head[2], meta: head[3] || '', body });
|
|
476
|
+
out.push({ type: head[1] || '', name: head[2], meta: head[3] || '', body });
|
|
456
477
|
i = open + body.length + 2;
|
|
457
478
|
}
|
|
458
479
|
return out;
|
|
459
480
|
}
|
|
460
481
|
|
|
461
482
|
function emitPrim(prim, lib, materials, paint, notes, parent, inherited) {
|
|
462
|
-
|
|
483
|
+
// A prim's own transform and binding, not those of the prims nested in it.
|
|
484
|
+
const own = ownText(prim.body);
|
|
485
|
+
const local = readXform(own);
|
|
463
486
|
const matrix = combine(parent, local);
|
|
464
|
-
const style = paint(
|
|
465
|
-
|
|
487
|
+
const style = paint(own, materials) || inherited || '';
|
|
488
|
+
// A typeless def takes its type from the prototype it references.
|
|
489
|
+
if (prim.type === 'Xform' || prim.type === '') {
|
|
466
490
|
const ref = /references\s*=\s*<([^>]+)>/.exec(prim.meta);
|
|
467
491
|
const target = ref ? lib.get(ref[1].split('/').pop()) : null;
|
|
468
492
|
if (target) return emitPrim(target, lib, materials, paint, notes, matrix, style);
|
package/src/io/x3d.js
CHANGED
|
@@ -592,7 +592,7 @@ export function importX3D(text) {
|
|
|
592
592
|
i = step.i;
|
|
593
593
|
}
|
|
594
594
|
materialAttrs.materials = [];
|
|
595
|
-
const parts = nodes.map((node) => emitEs3d(node, notes)).filter(Boolean);
|
|
595
|
+
const parts = nodes.map((node) => emitEs3d(node, notes, defs)).filter(Boolean);
|
|
596
596
|
const mats = materialAttrs.materials;
|
|
597
597
|
materialAttrs.materials = [];
|
|
598
598
|
const markup = `<es3d version="1">\n${mats.join('\n')}${mats.length ? '\n' : ''}${parts.join('\n')}\n</es3d>\n`;
|
|
@@ -734,7 +734,7 @@ function numAttr(list, count) {
|
|
|
734
734
|
return nums.slice(0, count);
|
|
735
735
|
}
|
|
736
736
|
|
|
737
|
-
function emitEs3d(node, notes) {
|
|
737
|
+
function emitEs3d(node, notes, defs) {
|
|
738
738
|
if (!node) return '';
|
|
739
739
|
if (node.use) return `<use ref="${esc(node.use)}"/>`;
|
|
740
740
|
const f = node.fields || {};
|
|
@@ -743,11 +743,13 @@ function emitEs3d(node, notes) {
|
|
|
743
743
|
if (node.name === 'Transform' || node.name === 'Group' || node.name === 'Anchor' || node.name === 'Collision' || node.name === 'Switch' || node.name === 'Billboard') {
|
|
744
744
|
if (node.name !== 'Transform' && node.name !== 'Group') notes.push(`<${node.name}> was written as a group.`);
|
|
745
745
|
const tr = x3dTransform(f);
|
|
746
|
-
const body = kids.map((c) => emitEs3d(c, notes)).filter(Boolean).join('\n');
|
|
746
|
+
const body = kids.map((c) => emitEs3d(c, notes, defs)).filter(Boolean).join('\n');
|
|
747
747
|
return `<group${id}${tr}>\n${body}\n</group>`;
|
|
748
748
|
}
|
|
749
749
|
if (node.name === 'Shape') {
|
|
750
|
-
|
|
750
|
+
// geometry USE G names a node defined earlier; draw that geometry here too.
|
|
751
|
+
const used = f.geometry && f.geometry.use && defs ? defs.get(f.geometry.use) : null;
|
|
752
|
+
const geom = f.geometry && f.geometry.name ? f.geometry : (used && used.name ? used : null);
|
|
751
753
|
const style = materialAttrs(f.appearance, notes);
|
|
752
754
|
if (!geom) return '';
|
|
753
755
|
return emitGeom(geom, id, style, notes);
|
|
@@ -21,6 +21,41 @@ function length3(x, y, z) {
|
|
|
21
21
|
return Math.sqrt(x * x + y * y + z * z);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Per-frame constants for `instancePixelsPerUnit`.
|
|
26
|
+
* `ppuFactor` is F = viewportHeight / (2 tan(verticalFov / 2)) for a perspective
|
|
27
|
+
* camera, or the constant scale for an orthographic one. `cornerStretch` is
|
|
28
|
+
* 1/cos^2 of the angle to the frustum's corner.
|
|
29
|
+
*/
|
|
30
|
+
export function lodFrameConstants(camera, viewportHeight) {
|
|
31
|
+
const aspect = camera.aspect || 1;
|
|
32
|
+
const tanV = Math.tan(camera.verticalFov(aspect) / 2);
|
|
33
|
+
return {
|
|
34
|
+
orthographic: camera.type === 'orthographic',
|
|
35
|
+
ppuFactor: camera.pixelsPerUnit(1, viewportHeight),
|
|
36
|
+
view: camera.view,
|
|
37
|
+
cornerStretch: 1 + tanV * tanV * (1 + aspect * aspect),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Largest projected size, in CSS pixels, of a unit length at the centre
|
|
43
|
+
* (cx, cy, cz) of an instance's bounding sphere; `d` is the distance from the
|
|
44
|
+
* eye to that centre. A flat image plane stretches a displacement at angle a
|
|
45
|
+
* from the view axis by up to 1/cos^2(a) = d^2/z^2 (z = depth along the axis)
|
|
46
|
+
* radially. Inside the view that never exceeds its value at the frustum's
|
|
47
|
+
* corners, which is also used when the centre is at or behind the eye plane
|
|
48
|
+
* while the sphere is still visible. Orthographic views do not stretch.
|
|
49
|
+
* Allocation-free: it runs once per instance.
|
|
50
|
+
*/
|
|
51
|
+
export function instancePixelsPerUnit(k, cx, cy, cz, d) {
|
|
52
|
+
if (k.orthographic) return k.ppuFactor;
|
|
53
|
+
const V = k.view;
|
|
54
|
+
const z = -(V[2] * cx + V[6] * cy + V[10] * cz + V[14]);
|
|
55
|
+
const stretch = z > 0 ? Math.min((d * d) / (z * z), k.cornerStretch) : k.cornerStretch;
|
|
56
|
+
return (k.ppuFactor * stretch) / Math.max(d, 1e-6);
|
|
57
|
+
}
|
|
58
|
+
|
|
24
59
|
/**
|
|
25
60
|
* The level to draw while `lod` is not generated yet: the nearest coarser
|
|
26
61
|
* level that exists, else the nearest finer one, else the coarsest level
|
|
@@ -159,8 +194,8 @@ export class FrameBuilder {
|
|
|
159
194
|
const vh = p.viewportHeight;
|
|
160
195
|
const bias = options.lodBias || 1;
|
|
161
196
|
const fixedLod = options.autoLod === false ? options.fixedLod || 0 : -1;
|
|
162
|
-
// pixels per world unit
|
|
163
|
-
const
|
|
197
|
+
// pixels per world unit: depth based for perspective, constant for orthographic
|
|
198
|
+
const lodK = lodFrameConstants(camera, vh);
|
|
164
199
|
|
|
165
200
|
for (let i = 0; i < n; i++) {
|
|
166
201
|
keys[i] = -1;
|
|
@@ -189,7 +224,7 @@ export class FrameBuilder {
|
|
|
189
224
|
else {
|
|
190
225
|
const dx = cx - camPos[0], dy = cy - camPos[1], dz = cz - camPos[2];
|
|
191
226
|
const d = length3(dx, dy, dz);
|
|
192
|
-
const ppu =
|
|
227
|
+
const ppu = instancePixelsPerUnit(lodK, cx, cy, cz, d);
|
|
193
228
|
lod = selectLod(entry.lodDeviation, entry.lods, axisScale(scene.matrices, i), ppu, bias);
|
|
194
229
|
}
|
|
195
230
|
}
|