@directivegames/genesys.js 3.1.27 → 3.1.29

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * SplineMeshComponent generates a flat ribbon mesh along a Catmull-Rom spline.
3
+ *
4
+ * Use cases include roads, paths, rivers, and any geometry that follows a curved path.
5
+ * The component samples points along the spline and creates a ribbon with proper
6
+ * UV mapping for texture tiling based on world distance.
7
+ *
8
+ * Recommended usage (for humans and AI assistants):
9
+ * - Provide an array of control points defining the path
10
+ * - Adjust width, segments, and tension for desired appearance
11
+ * - Use uvScale to control texture tiling density
12
+ * - Enable showDebug to visualize control points and spline curve
13
+ */
14
+ import * as THREE from 'three';
15
+
16
+ import { MaterialUtils } from '../../materials.js';
17
+ import { EngineClass } from '../../systems/ClassRegistry.js';
18
+ import { resourceManager } from '../../utils/ResourceManager.js';
19
+ import { isNode } from '../../utils/Utils.js';
20
+ import { PrimitiveComponent } from '../PrimitiveComponent.js';
21
+
22
+ import type { GenericMaterialType } from '../../materials.js';
23
+ import type { EditorClassMeta, EditorPropertyChangedResult } from '../../utils/EditorClassMeta.js';
24
+ import type { PrimitiveComponentOptions } from '../PrimitiveComponent.js';
25
+
26
+ export interface SplineMeshComponentOptions extends PrimitiveComponentOptions {
27
+ /** Control points defining the spline path */
28
+ points?: THREE.Vector3[];
29
+ /** Width of the ribbon mesh (default: 1) */
30
+ width?: number;
31
+ /** Number of segments along the spline (default: 50) */
32
+ segments?: number;
33
+ /** Whether the spline forms a closed loop (default: false) */
34
+ closed?: boolean;
35
+ /** Catmull-Rom tension parameter (default: 0.5) */
36
+ tension?: number;
37
+ /** UV tiles per world unit along the spline (default: 1) */
38
+ uvScale?: number;
39
+ /** Material for the mesh (path to material JSON or THREE.Material instance) */
40
+ material?: GenericMaterialType;
41
+ /** Show debug visualization of control points and spline (default: false) */
42
+ showDebug?: boolean;
43
+ }
44
+
45
+ @EngineClass('SplineMeshComponent')
46
+ export class SplineMeshComponent extends PrimitiveComponent<SplineMeshComponentOptions> {
47
+ private mesh: THREE.Mesh | null = null;
48
+ private curve: THREE.CatmullRomCurve3 | null = null;
49
+ private debugGroup: THREE.Group | null = null;
50
+
51
+ static override readonly EDITOR_CLASS_META: EditorClassMeta = {
52
+ ...PrimitiveComponent.EDITOR_CLASS_META,
53
+ points: { array: { child: { type: THREE.Vector3 } } },
54
+ width: { number: { min: 0.1, max: 100, step: 0.1, decimals: 2 } },
55
+ segments: { integer: { min: 2, max: 500, step: 1 } },
56
+ tension: { number: { min: 0, max: 1, step: 0.05, decimals: 2 } },
57
+ uvScale: { number: { min: 0.01, max: 10, step: 0.1, decimals: 2 } },
58
+ closed: { boolean: {} },
59
+ showDebug: { boolean: {} },
60
+ material: { type: ['material'] },
61
+ } as const;
62
+
63
+ static override get DEFAULT_OPTIONS(): SplineMeshComponentOptions {
64
+ return {
65
+ ...PrimitiveComponent.DEFAULT_OPTIONS,
66
+ // Provide default starter points so the spline is visible when created
67
+ points: [
68
+ new THREE.Vector3(-2, 0, 0),
69
+ new THREE.Vector3(0, 0, 1),
70
+ new THREE.Vector3(2, 0, 0),
71
+ ],
72
+ width: 1,
73
+ segments: 50,
74
+ closed: false,
75
+ tension: 0.5,
76
+ uvScale: 1,
77
+ material: '', // Asset path to material JSON file
78
+ showDebug: false,
79
+ physicsOptions: {
80
+ ...PrimitiveComponent.DEFAULT_PHYSICS_OPTIONS,
81
+ enabled: false,
82
+ },
83
+ };
84
+ }
85
+
86
+ constructor(options: SplineMeshComponentOptions = {}) {
87
+ super(options);
88
+ this.rebuildMesh();
89
+ }
90
+
91
+ /**
92
+ * Returns the THREE.Mesh object associated with this component
93
+ */
94
+ public getMesh(): THREE.Mesh | null {
95
+ return this.mesh;
96
+ }
97
+
98
+ /**
99
+ * Returns the underlying Catmull-Rom curve
100
+ */
101
+ public getCurve(): THREE.CatmullRomCurve3 | null {
102
+ return this.curve;
103
+ }
104
+
105
+ /**
106
+ * Sets new control points and rebuilds the mesh
107
+ */
108
+ public setPoints(points: THREE.Vector3[]): void {
109
+ this.options.points = points;
110
+ this.rebuildMesh();
111
+ }
112
+
113
+ /**
114
+ * Sets the ribbon width and rebuilds the mesh
115
+ */
116
+ public setWidth(width: number): void {
117
+ this.options.width = width;
118
+ this.rebuildMesh();
119
+ }
120
+
121
+ /**
122
+ * Sets the segment count and rebuilds the mesh
123
+ */
124
+ public setSegments(segments: number): void {
125
+ this.options.segments = segments;
126
+ this.rebuildMesh();
127
+ }
128
+
129
+ /**
130
+ * Sets the Catmull-Rom tension and rebuilds the mesh
131
+ */
132
+ public setTension(tension: number): void {
133
+ this.options.tension = tension;
134
+ this.rebuildMesh();
135
+ }
136
+
137
+ /**
138
+ * Sets the UV scale and rebuilds the mesh
139
+ */
140
+ public setUvScale(uvScale: number): void {
141
+ this.options.uvScale = uvScale;
142
+ this.rebuildMesh();
143
+ }
144
+
145
+ /**
146
+ * Sets whether the spline is closed and rebuilds the mesh
147
+ */
148
+ public setClosed(closed: boolean): void {
149
+ this.options.closed = closed;
150
+ this.rebuildMesh();
151
+ }
152
+
153
+ /**
154
+ * Sets the material for the mesh
155
+ */
156
+ public setMaterial(material: GenericMaterialType): void {
157
+ this.options.material = material;
158
+ if (this.mesh) {
159
+ resourceManager.loadGenericMaterial(material).then((loadedMaterial) => {
160
+ if (loadedMaterial && this.mesh) {
161
+ this.mesh.material = loadedMaterial;
162
+ }
163
+ });
164
+ } else {
165
+ // Mesh doesn't exist yet - rebuild to create it with the new material
166
+ this.rebuildMesh();
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Toggles debug visualization
172
+ */
173
+ public setShowDebug(show: boolean): void {
174
+ this.options.showDebug = show;
175
+ this.updateDebugVisualization();
176
+ }
177
+
178
+ /**
179
+ * Rebuilds the ribbon mesh based on current options
180
+ */
181
+ public rebuildMesh(): void {
182
+ // Filter out null/undefined points and ensure they are Vector3 instances
183
+ // (points may be plain {x,y,z} objects after deserialization)
184
+ const rawPoints = this.options.points ?? [];
185
+ const points = rawPoints
186
+ .filter((p): p is THREE.Vector3 => p != null)
187
+ .map(p => p instanceof THREE.Vector3 ? p : new THREE.Vector3((p as any).x, (p as any).y, (p as any).z));
188
+ if (points.length < 2) {
189
+ this.clearMesh();
190
+ return;
191
+ }
192
+
193
+ // Create the Catmull-Rom curve
194
+ this.curve = new THREE.CatmullRomCurve3(
195
+ points,
196
+ this.options.closed ?? false,
197
+ 'catmullrom',
198
+ this.options.tension ?? 0.5
199
+ );
200
+
201
+ const segments = this.options.segments ?? 50;
202
+ const width = this.options.width ?? 1;
203
+ const uvScale = this.options.uvScale ?? 1;
204
+ const halfWidth = width / 2;
205
+
206
+ // Generate geometry
207
+ const geometry = this.generateRibbonGeometry(this.curve, segments, halfWidth, uvScale);
208
+
209
+ // Remove old mesh if exists
210
+ if (this.mesh) {
211
+ this.mesh.removeFromParent();
212
+ this.mesh.geometry.dispose();
213
+ }
214
+
215
+ // Create new mesh with default material initially
216
+ this.mesh = new THREE.Mesh(geometry, MaterialUtils.DefaultMaterial);
217
+ this.mesh.setTransient(true);
218
+ this.bindObject3DProperties(this.mesh, ['castShadow', 'receiveShadow']);
219
+ this.add(this.mesh);
220
+
221
+ // Load material if specified
222
+ if (!isNode() && this.options.material) {
223
+ resourceManager.loadGenericMaterial(this.options.material).then((loadedMaterial) => {
224
+ if (loadedMaterial && this.mesh) {
225
+ this.mesh.material = loadedMaterial;
226
+ }
227
+ });
228
+ }
229
+
230
+ // Update debug visualization
231
+ this.updateDebugVisualization();
232
+ }
233
+
234
+ /**
235
+ * Generates a ribbon geometry along the curve
236
+ */
237
+ private generateRibbonGeometry(
238
+ curve: THREE.CatmullRomCurve3,
239
+ segments: number,
240
+ halfWidth: number,
241
+ uvScale: number
242
+ ): THREE.BufferGeometry {
243
+ const vertices: number[] = [];
244
+ const normals: number[] = [];
245
+ const uvs: number[] = [];
246
+ const indices: number[] = [];
247
+
248
+ const totalLength = curve.getLength();
249
+ const up = new THREE.Vector3(0, 1, 0);
250
+ const tempTangent = new THREE.Vector3();
251
+ const tempNormal = new THREE.Vector3();
252
+ const tempBinormal = new THREE.Vector3();
253
+
254
+ // Sample points along the curve
255
+ for (let i = 0; i <= segments; i++) {
256
+ const t = i / segments;
257
+ const point = curve.getPointAt(t);
258
+ const tangent = curve.getTangentAt(t).normalize();
259
+
260
+ // Calculate binormal (perpendicular to tangent and up)
261
+ tempBinormal.crossVectors(up, tangent).normalize();
262
+
263
+ // Handle degenerate case when tangent is parallel to up
264
+ if (tempBinormal.lengthSq() < 0.001) {
265
+ tempBinormal.set(1, 0, 0);
266
+ }
267
+
268
+ // Calculate normal (perpendicular to tangent and binormal)
269
+ tempNormal.crossVectors(tangent, tempBinormal).normalize();
270
+
271
+ // Create two vertices at this point (left and right edges)
272
+ const leftPoint = point.clone().addScaledVector(tempBinormal, -halfWidth);
273
+ const rightPoint = point.clone().addScaledVector(tempBinormal, halfWidth);
274
+
275
+ vertices.push(leftPoint.x, leftPoint.y, leftPoint.z);
276
+ vertices.push(rightPoint.x, rightPoint.y, rightPoint.z);
277
+
278
+ // Normals point up
279
+ normals.push(tempNormal.x, tempNormal.y, tempNormal.z);
280
+ normals.push(tempNormal.x, tempNormal.y, tempNormal.z);
281
+
282
+ // UVs: U tiles along length based on world distance, V is 0-1 across width
283
+ const distanceAlongCurve = t * totalLength;
284
+ const u = distanceAlongCurve * uvScale;
285
+ uvs.push(u, 0); // Left edge
286
+ uvs.push(u, 1); // Right edge
287
+ }
288
+
289
+ // Generate indices for triangle strip (CCW winding for upward-facing normals)
290
+ for (let i = 0; i < segments; i++) {
291
+ const baseIndex = i * 2;
292
+
293
+ // First triangle: left[i] -> left[i+1] -> right[i]
294
+ indices.push(baseIndex, baseIndex + 2, baseIndex + 1);
295
+ // Second triangle: right[i] -> left[i+1] -> right[i+1]
296
+ indices.push(baseIndex + 1, baseIndex + 2, baseIndex + 3);
297
+ }
298
+
299
+ const geometry = new THREE.BufferGeometry();
300
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
301
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
302
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
303
+ geometry.setIndex(indices);
304
+
305
+ return geometry;
306
+ }
307
+
308
+ /**
309
+ * Clears the current mesh
310
+ */
311
+ private clearMesh(): void {
312
+ if (this.mesh) {
313
+ this.mesh.removeFromParent();
314
+ this.mesh.geometry.dispose();
315
+ this.mesh = null;
316
+ }
317
+ this.curve = null;
318
+ this.clearDebugVisualization();
319
+ }
320
+
321
+ /**
322
+ * Updates debug visualization showing control points and spline
323
+ */
324
+ private updateDebugVisualization(): void {
325
+ this.clearDebugVisualization();
326
+
327
+ if (!this.options.showDebug || !this.curve) {
328
+ return;
329
+ }
330
+
331
+ this.debugGroup = new THREE.Group();
332
+ this.debugGroup.setTransient(true);
333
+
334
+ // Filter out null/undefined points and ensure they are Vector3 instances
335
+ const rawPoints = this.options.points ?? [];
336
+ const points = rawPoints
337
+ .filter((p): p is THREE.Vector3 => p != null)
338
+ .map(p => p instanceof THREE.Vector3 ? p : new THREE.Vector3((p as any).x, (p as any).y, (p as any).z));
339
+
340
+ // Draw control point spheres
341
+ const sphereGeometry = new THREE.SphereGeometry(0.15);
342
+ const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });
343
+
344
+ for (let i = 0; i < points.length; i++) {
345
+ const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
346
+ sphere.position.copy(points[i]);
347
+ this.debugGroup.add(sphere);
348
+ }
349
+
350
+ // Draw spline curve line
351
+ const curvePoints = this.curve.getPoints(100);
352
+ const lineGeometry = new THREE.BufferGeometry().setFromPoints(curvePoints);
353
+ const lineMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00 });
354
+ const line = new THREE.Line(lineGeometry, lineMaterial);
355
+ this.debugGroup.add(line);
356
+
357
+ // Draw tangent indicators at control points
358
+ const arrowMaterial = new THREE.LineBasicMaterial({ color: 0x0000ff });
359
+ for (let i = 0; i < points.length; i++) {
360
+ const t = this.options.closed ? i / points.length : i / (points.length - 1);
361
+ const tangent = this.curve.getTangentAt(Math.min(t, 1)).normalize();
362
+ const arrowPoints = [
363
+ points[i].clone(),
364
+ points[i].clone().addScaledVector(tangent, 0.5)
365
+ ];
366
+ const arrowGeometry = new THREE.BufferGeometry().setFromPoints(arrowPoints);
367
+ const arrow = new THREE.Line(arrowGeometry, arrowMaterial);
368
+ this.debugGroup.add(arrow);
369
+ }
370
+
371
+ this.add(this.debugGroup);
372
+ }
373
+
374
+ /**
375
+ * Clears debug visualization
376
+ */
377
+ private clearDebugVisualization(): void {
378
+ if (this.debugGroup) {
379
+ this.debugGroup.removeFromParent();
380
+ this.debugGroup.traverse((child) => {
381
+ if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
382
+ child.geometry.dispose();
383
+ if (Array.isArray(child.material)) {
384
+ child.material.forEach(m => m.dispose());
385
+ } else {
386
+ child.material.dispose();
387
+ }
388
+ }
389
+ });
390
+ this.debugGroup = null;
391
+ }
392
+ }
393
+
394
+ public override calcBoundingBox(): void {
395
+ if (this.mesh) {
396
+ this.mesh.geometry.computeBoundingBox();
397
+ this._boundingBox.copy(this.mesh.geometry.boundingBox!).applyMatrix4(this.mesh.matrixWorld);
398
+ }
399
+ }
400
+
401
+ public override getEditorClassIcon(): string | null {
402
+ return 'Icon_Path';
403
+ }
404
+
405
+ public override onEditorPropertyChanged(path: string, value: any, result: EditorPropertyChangedResult): void {
406
+ super.onEditorPropertyChanged(path, value, result);
407
+
408
+ // Rebuild mesh when any spline-related property changes
409
+ if (result.isOptions) {
410
+ if (path === 'material') {
411
+ this.setMaterial(value);
412
+ } else if (path === 'points' || path.startsWith('points[') || path.startsWith('points.')) {
413
+ // Handle array changes: full replacement, element changes, or property changes
414
+ this.rebuildMesh();
415
+ } else if (['width', 'segments', 'tension', 'uvScale', 'closed', 'showDebug'].includes(path)) {
416
+ this.rebuildMesh();
417
+ }
418
+ }
419
+ }
420
+ }
421
+
@@ -21,3 +21,4 @@ export * from './TerrainMeshComponent.js';
21
21
  export * from './SkyboxComponent.js';
22
22
  export * from './FogComponent.js';
23
23
  export * from './UI3DComponent.js';
24
+ export * from './SplineMeshComponent.js';
@@ -62,6 +62,53 @@ type CtorArgProperty = {
62
62
  metadata?: PropertyMetadata;
63
63
  }
64
64
 
65
+ function deepishEqual(a: any, b: any, maxDepth = 3): boolean {
66
+ function eq(a: any, b: any, depth: number, maxDepth: number): boolean {
67
+ if (depth > maxDepth) {
68
+ // If we *reach* depth limit, treat as different
69
+ return false;
70
+ }
71
+
72
+ // Fast path: identity or strict equality
73
+ if (a === b) return true;
74
+
75
+ // Handle case where types differ
76
+ if (typeof a !== typeof b) return false;
77
+
78
+ // if both functions, consider them equal
79
+ if (typeof a === 'function' && typeof b === 'function') {
80
+ return true;
81
+ }
82
+
83
+ // Handle primitives
84
+ if (a === null || b === null || typeof a !== 'object') return false;
85
+
86
+ // Arrays
87
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
88
+ if (Array.isArray(a)) {
89
+ if (a.length !== b.length) return false;
90
+ for (let i = 0; i < a.length; i++) {
91
+ if (!eq(a[i], b[i], depth + 1, maxDepth)) return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ // Objects
97
+ const aKeys = Object.keys(a);
98
+ const bKeys = Object.keys(b);
99
+ if (aKeys.length !== bKeys.length) return false;
100
+
101
+ for (const key of aKeys) {
102
+ if (!(key in b)) return false;
103
+ if (!eq(a[key], b[key], depth + 1, maxDepth)) return false;
104
+ }
105
+
106
+ return true;
107
+ }
108
+
109
+ return eq(a, b, 0, maxDepth);
110
+ }
111
+
65
112
 
66
113
  /**
67
114
  * The serialized data will either be $root + $objects, or $prefab + $diff
@@ -206,6 +253,8 @@ export class Dumper extends Serializer {
206
253
  throw new Error('Dumper instances cannot be reused.');
207
254
  }
208
255
 
256
+ const startTime = performance.now();
257
+
209
258
  let realPrefabPath: any;
210
259
  if (this.hasFlags(DumperFlags.Inline) && obj.$prefab) {
211
260
  // modify existing prefab to its parent
@@ -245,6 +294,8 @@ export class Dumper extends Serializer {
245
294
  if (realPrefabPath) {
246
295
  obj.$prefab = realPrefabPath;
247
296
  }
297
+ const endTime = performance.now();
298
+ console.log(`📦Dumper: Dumping took time ${(endTime - startTime).toFixed(2)}ms`);
248
299
  }
249
300
  }
250
301
 
@@ -410,11 +461,11 @@ export class Dumper extends Serializer {
410
461
  return DumpValueResult.SkippedEqualToDefaultValue;
411
462
  }
412
463
  } else {
413
- // object type, do a deep comparison
464
+ // object type, do a deep-ish comparison
414
465
  if (
415
466
  defaultValue
416
467
  && Object.getPrototypeOf(value) === Object.getPrototypeOf(defaultValue)
417
- && JSON.stringify(value) === JSON.stringify(defaultValue)
468
+ && deepishEqual(value, defaultValue, 3)
418
469
  ) {
419
470
  return DumpValueResult.SkippedEqualToDefaultValue;
420
471
  }
@@ -670,7 +721,7 @@ export class Loader extends Serializer {
670
721
  this._prepareLoader(data);
671
722
  const result = this._load(data.$root, '$root', target);
672
723
  const endTime = performance.now();
673
- console.log(`Loader.loadToInstance execution time: ${endTime - startTime}ms`);
724
+ console.log(`📦Loader: Loading took time ${(endTime - startTime).toFixed(2)}ms`);
674
725
  return result;
675
726
  }
676
727
 
@@ -697,7 +748,7 @@ export class Loader extends Serializer {
697
748
 
698
749
  const result = this._load(data.$root, '$root', target);
699
750
  const endTime = performance.now();
700
- console.log(`Loader.loadToInstanceAsync execution time: ${endTime - startTime}ms`);
751
+ console.log(`📦Loader: Async loading took time ${(endTime - startTime).toFixed(2)}ms`);
701
752
  return result;
702
753
  }
703
754