@needle-tools/engine 5.1.10 → 5.1.11

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/SKILL.md +2 -0
  3. package/components.needle.json +1 -1
  4. package/dist/{needle-engine.bundle-DZD-GG7l.min.js → needle-engine.bundle-d-dLyN3s.min.js} +127 -127
  5. package/dist/{needle-engine.bundle-Dv5TidOk.umd.cjs → needle-engine.bundle-n5YALNtc.umd.cjs} +113 -113
  6. package/dist/{needle-engine.bundle-DP--RGAU.js → needle-engine.bundle-ue2Lw4bF.js} +3798 -3755
  7. package/dist/needle-engine.d.ts +52 -13
  8. package/dist/needle-engine.js +534 -534
  9. package/dist/needle-engine.min.js +1 -1
  10. package/dist/needle-engine.umd.cjs +1 -1
  11. package/lib/engine/api.d.ts +1 -1
  12. package/lib/engine/api.js +1 -1
  13. package/lib/engine/api.js.map +1 -1
  14. package/lib/engine/engine_init.js +2 -2
  15. package/lib/engine/engine_license.d.ts +7 -7
  16. package/lib/engine/engine_license.js +71 -71
  17. package/lib/engine/engine_license.js.map +1 -1
  18. package/lib/engine/engine_math.d.ts +13 -0
  19. package/lib/engine/engine_math.js +17 -0
  20. package/lib/engine/engine_math.js.map +1 -1
  21. package/lib/engine/engine_networking_blob.js +3 -3
  22. package/lib/engine/engine_utils_qrcode.js +2 -2
  23. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  24. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  25. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  26. package/lib/engine/webcomponents/needle-engine.js +2 -2
  27. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  28. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  29. package/lib/engine/xr/TempXRContext.js +2 -2
  30. package/lib/engine-components/DragControls.d.ts +0 -5
  31. package/lib/engine-components/DragControls.js +0 -8
  32. package/lib/engine-components/DragControls.js.map +1 -1
  33. package/lib/engine-components/RendererInstancing.d.ts +34 -3
  34. package/lib/engine-components/RendererInstancing.js +92 -11
  35. package/lib/engine-components/RendererInstancing.js.map +1 -1
  36. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  37. package/lib/engine-components/splines/SplineWalker.js +9 -3
  38. package/lib/engine-components/splines/SplineWalker.js.map +1 -1
  39. package/package.json +1 -1
  40. package/plugins/common/license.js +4 -4
  41. package/plugins/vite/license.js +4 -4
  42. package/src/engine/api.ts +1 -1
  43. package/src/engine/engine_init.ts +2 -2
  44. package/src/engine/engine_license.ts +68 -68
  45. package/src/engine/engine_math.ts +17 -0
  46. package/src/engine/engine_networking_blob.ts +3 -3
  47. package/src/engine/engine_utils_qrcode.ts +2 -2
  48. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  49. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  50. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  51. package/src/engine/webcomponents/needle-engine.ts +2 -2
  52. package/src/engine/xr/TempXRContext.ts +2 -2
  53. package/src/engine-components/DragControls.ts +0 -7
  54. package/src/engine-components/RendererInstancing.ts +92 -11
  55. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  56. package/src/engine-components/splines/SplineWalker.ts +9 -3
@@ -1,5 +1,5 @@
1
1
  import { calculateMeshLODLevel, getLODColor } from "@needle-tools/gltf-progressive";
2
- import { BatchedMesh, BufferGeometry, Color, Material, Matrix4, Mesh, MeshStandardMaterial, Object3D, RawShaderMaterial, Vector3 } from "three";
2
+ import { BatchedMesh, BufferGeometry, Color, Material, Matrix4, Mesh, MeshStandardMaterial, Object3D, RawShaderMaterial, Sphere, Vector3 } from "three";
3
3
 
4
4
  import { isDevEnvironment, showBalloonError } from "../engine/debug/index.js";
5
5
  import { Gizmos } from "../engine/engine_gizmos.js";
@@ -25,6 +25,11 @@ function trackProgressiveLOD<T>(context: Context, type: "mesh" | "texture", obje
25
25
  return promise;
26
26
  }
27
27
 
28
+ /** Mirrored = negative determinant, which reverses triangle winding. */
29
+ function isMirrored(matrix: Matrix4): boolean {
30
+ return matrix.determinant() < 0;
31
+ }
32
+
28
33
  /**
29
34
  * Handles instancing for Needle Engine.
30
35
  */
@@ -108,8 +113,12 @@ export class InstancingHandler {
108
113
  const mat = mesh.material as Material | Material[];
109
114
  if (Array.isArray(mat)) return null;
110
115
 
116
+ // mirrored objects need their own batch, see InstancedMeshRenderer
117
+ mesh.updateWorldMatrix(true, false);
118
+ const mirrored = isMirrored(mesh.matrixWorld);
119
+
111
120
  for (const i of this.objs) {
112
- if (!i.canAdd(geometry, mat)) continue;
121
+ if (!i.canAdd(geometry, mat, mirrored)) continue;
113
122
  const handle = i.addInstance(mesh, geometry);
114
123
  return handle;
115
124
  }
@@ -119,7 +128,7 @@ export class InstancingHandler {
119
128
  }
120
129
  let name = obj.name;
121
130
  if (!name?.length) name = makeIdFromRandomWords();
122
- const i = new InstancedMeshRenderer(name, geometry, mat, maxInstances, context);
131
+ const i = new InstancedMeshRenderer(name, geometry, mat, maxInstances, context, mirrored);
123
132
  this.objs.push(i);
124
133
  const handle = i.addInstance(mesh, geometry);
125
134
  return handle;
@@ -240,8 +249,18 @@ export class InstanceHandle {
240
249
  updateInstanceMatrix(updateChildren: boolean = false, updateMatrix: boolean = true) {
241
250
  if (this.__instanceIndex < 0) return;
242
251
  if (updateMatrix) this.object.updateWorldMatrix(true, updateChildren);
252
+ this.assertMirroringUnchanged();
243
253
  this.renderer.updateInstance(this.object.matrixWorld, this.__instanceIndex);
244
254
  }
255
+
256
+ private _didWarnAboutMirroringChange = false;
257
+ /** Batches are bucketed by mirroring at setup time and an instance can not move between them. */
258
+ private assertMirroringUnchanged() {
259
+ if (this._didWarnAboutMirroringChange) return;
260
+ if (isMirrored(this.object.matrixWorld) === this.renderer.mirrored) return;
261
+ this._didWarnAboutMirroringChange = true;
262
+ console.warn(`[Instancing] "${this.name}" changed its mirroring at runtime (world matrix determinant flipped sign). Instanced objects can not change mirroring - this object will render its backfaces. Disable instancing for it via Renderer.setInstanced(obj, false).`);
263
+ }
245
264
  /** Updates the matrix of the instance */
246
265
  setMatrix(matrix: Matrix4) {
247
266
  if (this.__instanceIndex < 0) return;
@@ -320,6 +339,23 @@ export class InstanceHandle {
320
339
  }
321
340
  }
322
341
 
342
+ /**
343
+ * Renders one geometry+material combination for many objects using a
344
+ * {@link https://threejs.org/docs/#api/en/objects/BatchedMesh | BatchedMesh}.
345
+ *
346
+ * The BatchedMesh belongs to this class: it is created here, parented directly to the untransformed
347
+ * `context.scene` and never moved, so its transform is whatever {@link applyBatchTransform} puts
348
+ * there and nothing else.
349
+ *
350
+ * **Mirroring:** three flips the winding for a mirrored transform per *draw call*, from
351
+ * `object.matrixWorld.determinant()` - and for a batch that object is the BatchedMesh, not the
352
+ * instance. So mirrored instances get their own batch, which carries the mirror on its own
353
+ * transform (`scale.z = -1`); otherwise their front faces would be culled.
354
+ *
355
+ * That mirror is the one transform the shader's `modelMatrix * batchingMatrix` would otherwise apply
356
+ * twice, so a mirrored batch stores its instance matrices with it undone. Every other batch is
357
+ * untransformed and stores world matrices as they are.
358
+ */
323
359
  class InstancedMeshRenderer {
324
360
  /** The three instanced mesh
325
361
  * @link https://threejs.org/docs/#api/en/objects/InstancedMesh
@@ -327,6 +363,11 @@ class InstancedMeshRenderer {
327
363
  get batchedMesh() {
328
364
  return this._batchedMesh;
329
365
  }
366
+
367
+ /** True if this batch renders mirrored objects. A batch only holds one or the other. */
368
+ get mirrored() {
369
+ return this._mirrored;
370
+ }
330
371
  get visible(): boolean {
331
372
  return this._batchedMesh.visible;
332
373
  }
@@ -369,15 +410,17 @@ class InstancedMeshRenderer {
369
410
  if (sphere)
370
411
  this._batchedMesh.computeBoundingSphere();
371
412
  if (debugInstancing && this._batchedMesh.boundingSphere) {
372
- const sphere = this._batchedMesh.boundingSphere;
373
- // const worldPos = this._batchedMesh.worldPosition.add(sphere.center);
374
- // const worldRadius = sphere!.radius;
413
+ // the bounding sphere is in the batch's local space, the gizmo expects world space
414
+ const sphere = InstancedMeshRenderer.debugBoundsSphere
415
+ .copy(this._batchedMesh.boundingSphere)
416
+ .applyMatrix4(this._batchedMesh.matrixWorld);
375
417
  Gizmos.DrawWireSphere(sphere.center, sphere.radius, 0x00ff00);
376
418
  }
377
419
  }
378
420
 
379
421
  private _context: Context;
380
422
  private _batchedMesh: BatchedMesh;
423
+ private readonly _mirrored: boolean;
381
424
  private _handles: (InstanceHandle | null)[] = [];
382
425
  private _geometryIds = new WeakMap<BufferGeometry, number>();
383
426
  private _maxInstanceCount: number;
@@ -389,8 +432,9 @@ class InstancedMeshRenderer {
389
432
  private _maxVertexCount: number;
390
433
  private _maxIndexCount: number;
391
434
 
392
- private static nullMatrix: Matrix4 = new Matrix4();
435
+ private static instanceMatrix: Matrix4 = new Matrix4();
393
436
  private static lodProjectionScreenMatrix: Matrix4 = new Matrix4();
437
+ private static debugBoundsSphere: Sphere = new Sphere();
394
438
  private static debugLODColor = new Color();
395
439
  private static lodSelectionResult = {
396
440
  level: -1,
@@ -403,12 +447,15 @@ class InstancedMeshRenderer {
403
447
  /** Check if the geometry can be added to this instancer
404
448
  * @param geometry The geometry to check
405
449
  * @param material The material of the geometry
450
+ * @param mirrored Whether the object's world matrix has a negative determinant
406
451
  * @returns true if the geometry can be added
407
452
  */
408
- canAdd(geometry: BufferGeometry, material: Material): boolean {
453
+ canAdd(geometry: BufferGeometry, material: Material, mirrored: boolean): boolean {
409
454
 
410
455
  if (this._maxVertexCount > 10_000_000) return false;
411
456
 
457
+ if (this._mirrored !== mirrored) return false;
458
+
412
459
  // The material instance must match
413
460
  // perhaps at some point later we *could* check if it's the same shader and properties but this would be risky
414
461
  if (material !== this.material) {
@@ -482,11 +529,12 @@ class InstancedMeshRenderer {
482
529
  return this.name ? `${this.name} (BatchedMesh)` : "BatchedMesh";
483
530
  }
484
531
 
485
- constructor(name: string, geo: BufferGeometry, material: Material, initialMaxCount: number, context: Context) {
532
+ constructor(name: string, geo: BufferGeometry, material: Material, initialMaxCount: number, context: Context, mirrored: boolean = false) {
486
533
  this.name = name;
487
534
  this.geometry = geo;
488
535
  this.material = material;
489
536
  this._context = context;
537
+ this._mirrored = mirrored;
490
538
  this._maxInstanceCount = Math.max(2, initialMaxCount);
491
539
  if (debugInstancing) {
492
540
  this._debugMaterial = createDebugMaterial(debugLODColors);
@@ -496,6 +544,7 @@ class InstancedMeshRenderer {
496
544
  this._maxIndexCount = estimate.indexCount;
497
545
  this._batchedMesh = new BatchedMesh(this._maxInstanceCount, this._maxVertexCount, this._maxIndexCount, this._debugMaterial ?? this.material);
498
546
  this._batchedMesh.name = this.getBatchedMeshName();
547
+ this.applyBatchTransform(this._batchedMesh);
499
548
  // this.inst = new InstancedMesh(geo, material, count);
500
549
  this._batchedMesh[$instancingAutoUpdateBounds] = true;
501
550
  // this.inst.count = 0;
@@ -614,10 +663,39 @@ class InstancedMeshRenderer {
614
663
  }
615
664
 
616
665
  updateInstance(mat: Matrix4, index: number) {
617
- this._batchedMesh.setMatrixAt(index, mat);
666
+ this.setInstanceMatrix(index, mat);
618
667
  this.markNeedsUpdate();
619
668
  }
620
669
 
670
+ /** The batch's own transform. This class owns it: the mirror is the only thing that ever goes
671
+ * here, and the batch is never reparented away from the untransformed scene root. */
672
+ private applyBatchTransform(batch: BatchedMesh) {
673
+ // every other batch keeps the identity a fresh BatchedMesh is constructed with
674
+ if (!this._mirrored) return;
675
+ if (batch.scale.z === -1) return;
676
+ // negative determinant on the batch itself -> three flips the winding for the whole draw call
677
+ batch.scale.z = -1;
678
+ // three composes the TRS into matrix/matrixWorld no earlier than the render, which would leave
679
+ // those identity for a frame - and updateBounds() reads matrixWorld from a pre-render callback
680
+ batch.updateMatrixWorld();
681
+ }
682
+
683
+ /** Writes a *world* matrix to an instance slot, converted into the batch's local space.
684
+ * All instance matrix writes go through here. */
685
+ private setInstanceMatrix(index: number, worldMatrix: Matrix4) {
686
+ if (!this._mirrored) {
687
+ // an untransformed batch: local space *is* world space
688
+ this._batchedMesh.setMatrixAt(index, worldMatrix);
689
+ return;
690
+ }
691
+ // undo the batch's mirror, so the shader's modelMatrix * batchingMatrix composes back to the world matrix.
692
+ // a pure z flip only negates row 2 (column-major indices 2/6/10/14), so it's cheaper than a full multiply
693
+ const local = InstancedMeshRenderer.instanceMatrix.copy(worldMatrix);
694
+ const e = local.elements;
695
+ e[2] = -e[2]; e[6] = -e[6]; e[10] = -e[10]; e[14] = -e[14];
696
+ this._batchedMesh.setMatrixAt(index, local);
697
+ }
698
+
621
699
  updateGeometry(geo: BufferGeometry, geometryIndex: number): boolean {
622
700
  if (!this.validateGeometry(geo)) {
623
701
  return false;
@@ -815,11 +893,14 @@ class InstancedMeshRenderer {
815
893
  newInst.receiveShadow = this._batchedMesh.receiveShadow;
816
894
  newInst.visible = this._batchedMesh.visible;
817
895
  newInst[$instancingAutoUpdateBounds] = this._batchedMesh[$instancingAutoUpdateBounds];
896
+
818
897
  newInst.matrixAutoUpdate = this._batchedMesh.matrixAutoUpdate;
819
898
  newInst.matrixWorldNeedsUpdate = this._batchedMesh.matrixWorldNeedsUpdate;
820
899
  newInst.matrixAutoUpdate = this._batchedMesh.matrixAutoUpdate;
821
900
  newInst.matrixWorld.copy(this._batchedMesh.matrixWorld);
822
901
  newInst.matrix.copy(this._batchedMesh.matrix);
902
+ // the replacement must carry the mirror too, otherwise every instance in it ends up culled
903
+ this.applyBatchTransform(newInst);
823
904
 
824
905
  // dispose the old batched mesh
825
906
  this._batchedMesh.dispose();
@@ -942,7 +1023,7 @@ class InstancedMeshRenderer {
942
1023
  handle.__instanceIndex = i;
943
1024
  handle.__reservedVertexRange = handle.maxVertexCount;
944
1025
  handle.__reservedIndexRange = handle.maxIndexCount;
945
- this._batchedMesh.setMatrixAt(i, handle.object.matrixWorld);
1026
+ this.setInstanceMatrix(i, handle.object.matrixWorld);
946
1027
  this.updateDebugLODColor(handle);
947
1028
  if (debugInstancing)
948
1029
  console.debug(`[Instancing] > ADDED INSTANCE \"${handle.name}\"\nGEOMETRY_ID=${geometryId}\n${this._currentInstanceCount} instances\nIndex: ${handle.__instanceIndex}\nVertices: ${this._currentVertexCount.toLocaleString()}/${this._maxVertexCount.toLocaleString()},\nIndices: ${this._currentIndexCount.toLocaleString()}/${this._maxIndexCount.toLocaleString()}`);
@@ -3,7 +3,7 @@ import { Euler, Material, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "t
3
3
 
4
4
  import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
5
5
  import { findObjectOfType } from "../../../engine/engine_components.js";
6
- import { $Ljyi } from "../../../engine/engine_license.js";
6
+ import { oMxKl } from "../../../engine/engine_license.js";
7
7
  import { serializable } from "../../../engine/engine_serialization.js";
8
8
  import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js";
9
9
  import { DeviceUtilities, getParam } from "../../../engine/engine_utils.js";
@@ -277,7 +277,7 @@ export class USDZExporter extends Behaviour {
277
277
  let name = this.exportFileName ?? this.objectToExport?.name ?? this.name;
278
278
  name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
279
279
 
280
- if (!$Ljyi()) {
280
+ if (!oMxKl()) {
281
281
  if (name !== "") name += "-";
282
282
  name += "MadeWithNeedle";
283
283
  }
@@ -682,7 +682,7 @@ export class USDZExporter extends Behaviour {
682
682
  if (debug)
683
683
  showBalloonMessage("Quicklook url: " + callToActionURL);
684
684
  if (callToActionURL) {
685
- if (!$Ljyi()) {
685
+ if (!oMxKl()) {
686
686
  console.warn("Quicklook closed: custom redirects require a Needle Engine Pro license: https://needle.tools/pricing", callToActionURL)
687
687
  }
688
688
  else {
@@ -697,7 +697,7 @@ export class USDZExporter extends Behaviour {
697
697
  private buildQuicklookOverlay(): CustomBranding {
698
698
  const obj: CustomBranding = {};
699
699
  if (this.customBranding) Object.assign(obj, this.customBranding);
700
- if (!$Ljyi()) {
700
+ if (!oMxKl()) {
701
701
  console.log("Custom Quicklook banner text requires pro license: https://needle.tools/pricing");
702
702
  obj.callToAction = "Close";
703
703
  obj.checkoutTitle = "🌵 Made with Needle";
@@ -74,7 +74,11 @@ export class SplineWalker extends Behaviour {
74
74
  return this._position01;
75
75
  }
76
76
  set position01(v: number) {
77
- this._position01 = v;
77
+ // Normalize on assignment so reading the value back is predictable and never out of range.
78
+ // Doing this only in updateFromPosition() made it depend on a spline + curve + object being
79
+ // ready and a frame having run — until then this property reported e.g. 1.5.
80
+ // Note this is destructive (as clamping always is): setting 1.5 while clamped stores 1.
81
+ this._position01 = this.clamp ? Mathf.clamp01(v) : Mathf.repeat(v);
78
82
  this._needsUpdate = true;
79
83
  }
80
84
 
@@ -156,10 +160,12 @@ export class SplineWalker extends Behaviour {
156
160
  if (!this.spline || !this.spline.curve) return;
157
161
  if (!this.object) return;
158
162
 
163
+ // Still normalized here as well: autoRun accumulates into _position01 directly (bypassing the
164
+ // setter), and `clamp` may change after a value was assigned.
159
165
  if (this.clamp) this._position01 = Mathf.clamp01(this._position01);
160
- else this._position01 = this._position01 % 1;
166
+ else this._position01 = Mathf.repeat(this._position01);
161
167
 
162
- const t = this._position01 >= 1 ? 1 : this._position01 % 1;
168
+ const t = this._position01 >= 1 ? 1 : Mathf.repeat(this._position01);
163
169
  const pt = this.spline.getPointAt(t);
164
170
 
165
171
  if (this.pullStrength >= 1) {