@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/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 one canonical unit. 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 the switches fall near 59, 15 and 7 pixels of the sphere's own radius.
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.0",
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
- lines.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} });`);
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
- lines.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} });`);
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
- lines.push(` const ${id} = ${geometryExpr(solid)};`);
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 g = fresh('g');
120
- const positions = `new Float32Array([${nums(mesh.positions)}])`;
121
- lines.push(` const ${g} = new THREE.BufferGeometry();`);
122
- lines.push(` ${g}.setAttribute('position', new THREE.BufferAttribute(${positions}, 3));`);
123
- if (mesh.normals) lines.push(` ${g}.setAttribute('normal', new THREE.BufferAttribute(new Float32Array([${nums(mesh.normals)}]), 3));`);
124
- lines.push(` ${g}.setIndex([${Array.from(mesh.indices).join(', ')}]);`);
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 Xform "${name}" (\n prepend references = </World/Prototypes/${id}>\n ) {\n token visibility = "inherited"\n${indent(xformBlock(inst, ''), 12)}${indent(bind, 12)} }`;
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
- const local = readXform(prim.body);
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(prim.body, materials) || inherited || '';
465
- if (prim.type === 'Xform') {
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
- const geom = f.geometry && f.geometry.name ? f.geometry : null;
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 at distance 1 (perspective) or everywhere (orthographic)
163
- const ppuFactor = camera.pixelsPerUnit(1, vh);
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 = camera.type === 'orthographic' ? ppuFactor : ppuFactor / Math.max(d, 1e-6);
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
  }