@naniteninja/trait-visual 1.0.3 → 1.0.4

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.
@@ -1,21 +1,186 @@
1
+ import * as i1 from '@angular/common';
1
2
  import { CommonModule } from '@angular/common';
2
3
  import * as i0 from '@angular/core';
3
- import { Input, ViewChild, Component } from '@angular/core';
4
+ import { EventEmitter, Output, Input, ViewChild, Component } from '@angular/core';
5
+ import * as i2 from '@angular/material/icon';
6
+ import { MatIconModule } from '@angular/material/icon';
7
+ import * as i3 from '@angular/material/button';
8
+ import { MatButtonModule } from '@angular/material/button';
9
+ import * as i4 from '@angular/material/tooltip';
10
+ import { MatTooltipModule } from '@angular/material/tooltip';
4
11
  import * as THREE from 'three';
5
12
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
6
13
  import { PCA } from 'ml-pca';
14
+ import * as i5 from '@angular/material/select';
15
+ import { MatSelectModule } from '@angular/material/select';
16
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
17
 
8
- class Node extends THREE.Object3D {
18
+ /**
19
+ * Safe access to window.location.search (e.g. for ?debug=1).
20
+ * Use instead of (window as any)?.location?.search to satisfy strict typing.
21
+ */
22
+ function getWindowLocationSearch() {
23
+ if (typeof window === 'undefined')
24
+ return '';
25
+ return window.location?.search ?? '';
26
+ }
27
+ /** True when URL has debug=1 (enables optional ingest logging). */
28
+ function isDebugIngestEnabled() {
29
+ const search = getWindowLocationSearch();
30
+ try {
31
+ return new URLSearchParams(search).get('debug') === '1';
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // Dynamically resolve the PCA constructor for different build types
39
+ const PCAClass = PCA.default ||
40
+ PCA.PCA ||
41
+ PCA;
42
+ let dynamicCounts = {
43
+ attributes: 8,
44
+ preferences: 8
45
+ };
46
+ function updateCounts(attrCount, prefCount) {
47
+ dynamicCounts.attributes = attrCount;
48
+ dynamicCounts.preferences = prefCount;
49
+ }
50
+ /**
51
+ * Performs PCA on a list of attribute arrays to map them into 3D space.
52
+ * Returns:
53
+ * - positions: normalized [x, y, z] tuples
54
+ * - weights: per-attribute magnitude across PCA components
55
+ * - pca: fitted model and maxAbs for projecting any vector to the same 3D space
56
+ */
57
+ function computePCA3D(attributesList) {
58
+ const pca = new PCAClass(attributesList, { center: true, scale: true });
59
+ const reduced = pca.predict(attributesList, { nComponents: 3 }).to2DArray();
60
+ // --- Compute per-attribute weights ---
61
+ const loadings = pca.getLoadings().to2DArray();
62
+ const weights = loadings.map((arr) => Math.sqrt(arr[0] ** 2 + arr[1] ** 2 + arr[2] ** 2));
63
+ const maxAbs = Math.max(1e-6, ...reduced.flatMap((arr) => arr.map((v) => Math.abs(v))));
64
+ const positions = reduced.map((arr) => [
65
+ arr[0] / maxAbs,
66
+ arr[1] / maxAbs,
67
+ arr[2] / maxAbs
68
+ ]);
69
+ return { positions, weights, pca, maxAbs };
70
+ }
71
+ function generateAttributes(count, base = 0) {
72
+ const attrs = {};
73
+ for (let i = 0; i < count; i++)
74
+ attrs[`attr${i + 1}`] = (base + i * 20) % 101;
75
+ return attrs;
76
+ }
77
+ // Default node colors: SMBH black, BHs dark blue (#00001E)
78
+ const defaultSMBHColor = '#000000';
79
+ const defaultBHColor = '#00001E';
80
+ const baseNodes = [
81
+ {
82
+ id: 1,
83
+ name: 'James',
84
+ initialPosition: [0, 0, 0],
85
+ isSupermassiveBlackhole: true,
86
+ color: defaultSMBHColor,
87
+ attributes: generateAttributes(dynamicCounts.attributes, 0),
88
+ preferences: generateAttributes(dynamicCounts.preferences, 100),
89
+ },
90
+ ...[...Array(10)].map((_, i) => {
91
+ const id = i + 2;
92
+ const names = ['John', 'Alice', 'Robert', 'Emma', 'Michael', 'Sarah', 'David', 'Lisa', 'Chris', 'Maria'];
93
+ return {
94
+ id,
95
+ name: names[i],
96
+ initialPosition: [0, 0, 0],
97
+ isSupermassiveBlackhole: false,
98
+ color: defaultBHColor,
99
+ attributes: generateAttributes(dynamicCounts.attributes, i * 10),
100
+ preferences: generateAttributes(dynamicCounts.preferences, 0),
101
+ };
102
+ }),
103
+ ];
104
+ const attributesList = baseNodes.map((n) => Object.values(n.attributes));
105
+ const { positions, pca, maxAbs } = computePCA3D(attributesList);
106
+ const attrCount = attributesList[0]?.length ?? 8;
107
+ /**
108
+ * Projects an attribute or preference vector into the same 3D PCA space used for layout.
109
+ * Use this to compare preference vs attribute in one dimension per trait, then in 3D.
110
+ */
111
+ const pcaDim = attributesList[0]?.length ?? 8;
112
+ function projectTo3D(vec) {
113
+ if (!vec || vec.length === 0)
114
+ return [0, 0, 0];
115
+ const row = [];
116
+ for (let i = 0; i < pcaDim; i++)
117
+ row.push(typeof vec[i] === 'number' ? vec[i] : 0);
118
+ const reduced = pca.predict([row], { nComponents: 3 }).to2DArray();
119
+ const arr = reduced[0];
120
+ return [
121
+ arr[0] / maxAbs,
122
+ arr[1] / maxAbs,
123
+ arr[2] / maxAbs
124
+ ];
125
+ }
126
+ const nodeData = baseNodes.map((node, i) => {
127
+ if (node.isSupermassiveBlackhole)
128
+ return { ...node, initialPosition: [0, 0, 0] };
129
+ const [x, y, z] = positions[i];
130
+ return {
131
+ ...node,
132
+ initialPosition: [x * 5, y * 5, z * 5],
133
+ };
134
+ });
135
+ const attributeWeights = Array.from({ length: attrCount }, () => 1);
136
+
137
+ /**
138
+ * Debug frame counters for physics/snapshot logging. Kept in a module instead of
139
+ * (Blackhole as any) to satisfy strict typing. Only used when ?debug=1 ingest is enabled.
140
+ */
141
+ let _physicsCallsThisFrame = 0;
142
+ let _debugFrameCount = 0;
143
+ function getPhysicsCallsThisFrame() {
144
+ return _physicsCallsThisFrame;
145
+ }
146
+ function setPhysicsCallsThisFrame(value) {
147
+ _physicsCallsThisFrame = value;
148
+ }
149
+ function incrementPhysicsCalls() {
150
+ _physicsCallsThisFrame += 1;
151
+ return _physicsCallsThisFrame;
152
+ }
153
+ function getDebugFrameCount() {
154
+ return _debugFrameCount;
155
+ }
156
+ function incrementDebugFrameCount() {
157
+ _debugFrameCount += 1;
158
+ return _debugFrameCount;
159
+ }
160
+ function resetPhysicsCalls() {
161
+ _physicsCallsThisFrame = 0;
162
+ }
163
+
164
+ class Blackhole extends THREE.Object3D {
9
165
  options;
10
166
  velocity;
11
- isSun;
167
+ isSupermassiveBlackhole;
12
168
  attributes;
13
169
  preferences;
14
170
  preference = 0;
15
171
  mesh;
16
172
  swap = null;
173
+ nodeMaterial = null;
174
+ fixedY = 0;
17
175
  static attributeWeights = [];
18
176
  static preferenceWeights = [];
177
+ static toNumberArray(v) {
178
+ if (!v)
179
+ return [];
180
+ if (Array.isArray(v))
181
+ return v.slice();
182
+ return Object.values(v);
183
+ }
19
184
  static normalizeWeights(weights, len) {
20
185
  const list = Array.isArray(weights) ? weights.slice(0, len) : [];
21
186
  const extended = list.slice();
@@ -23,258 +188,502 @@ class Node extends THREE.Object3D {
23
188
  extended.push(1);
24
189
  return extended.map((w) => (Number.isFinite(w) && w >= 0 ? w : 0));
25
190
  }
26
- sunBaseScale = 3;
27
- halo;
28
- coreSprite;
29
- haloSprite;
30
- baseSphereRadius = 0.05; // geometry radius
191
+ supermassiveBlackholeBaseScale = 1.4;
192
+ baseSphereRadius = 8; // geometry radius
31
193
  clock = new THREE.Clock();
194
+ /** Used by Cluster for deltaTime. */
195
+ getClockDelta() {
196
+ return this.clock.getDelta();
197
+ }
198
+ /** Reused force vectors per blackhole index (avoids N² allocations per frame). */
199
+ _forces = [];
200
+ _scratchD = new THREE.Vector3();
201
+ _scratchDir = new THREE.Vector3();
202
+ _scratchF = new THREE.Vector3();
32
203
  constructor(data, options) {
33
204
  super();
34
205
  this.options = options;
35
206
  this.position.fromArray(data.initialPosition);
207
+ this.position.y = 0;
36
208
  this.velocity = new THREE.Vector3(0, 0, 0);
37
209
  this.userData = { ...data };
38
- this.isSun = data.isSun;
210
+ this.isSupermassiveBlackhole = data.isSupermassiveBlackhole;
39
211
  this.attributes = data.attributes
40
212
  ? [...Object.values(data.attributes)]
41
213
  : Array.from({ length: 10 }, () => Math.floor(Math.random() * 99));
42
214
  this.preferences = data.preferences
43
215
  ? [...Object.values(data.preferences)]
44
216
  : Array.from({ length: 10 }, () => Math.floor(Math.random() * 99));
45
- // Create glowing core using additive Fresnel-like shader (center-bright)
46
217
  const geom = new THREE.SphereGeometry(this.baseSphereRadius, 48, 48);
47
- const coreMat = makeCoreGlowMaterial(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.9 : 0.7, 3.5, this.isSun ? 0.6 : 0.35 // base floor so planets stay red from all angles
48
- );
49
- this.mesh = new THREE.Mesh(geom, coreMat);
218
+ this.nodeMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
219
+ this.mesh = new THREE.Mesh(geom, this.nodeMaterial);
50
220
  this.add(this.mesh);
51
- // Soft additive rim glow (BackSide Fresnel)
52
- const haloMat = makeRimGlowMaterial(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.22 : 0.16, 0.2, 2.2);
53
- this.halo = new THREE.Mesh(geom.clone(), haloMat);
54
- this.halo.scale.setScalar(this.isSun ? 1.8 : 1.35);
55
- this.add(this.halo);
56
- // Camera-facing luminous core billboard to ensure visibility from all angles
57
- this.coreSprite = this.createBillboardGlow(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.35 : 0.25, // reduced to avoid washing out particles
58
- this.isSun ? 1.4 : 1.05);
59
- this.coreSprite.visible = false; // keep glow sprites hidden for clearer particles
60
- this.add(this.coreSprite);
61
- // Softer, larger halo billboard (always-on rim feel)
62
- this.halo.visible = false; // keep mesh halo hidden
63
- this.haloSprite = this.createBillboardGlow(this.isSun ? new THREE.Color('#C300FF') : new THREE.Color('#FF3366'), this.isSun ? 0.08 : 0.06, // much softer halo
64
- this.isSun ? 2.2 : 1.6);
65
- this.haloSprite.renderOrder = 18;
66
- this.haloSprite.visible = false; // hide outer halo to ensure particles remain readable
67
- this.add(this.haloSprite);
68
- }
69
- updatePhysics(nodes, central, deltaTime) {
70
- if (!nodes || nodes.length === 0)
221
+ }
222
+ enforceFixedY() {
223
+ this.position.y = this.fixedY;
224
+ }
225
+ updatePhysics(blackholes, central, deltaTime) {
226
+ if (!blackholes || blackholes.length === 0)
71
227
  return;
72
- const forces = nodes.map(() => new THREE.Vector3());
73
- // --- Attraction: from each non-sun node to central (sun) ---
228
+ // #region agent log
229
+ incrementPhysicsCalls();
230
+ const blackholeIndices = central ? blackholes.map((b, idx) => idx).filter((idx) => blackholes[idx] !== central) : [];
231
+ const logFrame = getDebugFrameCount() % 60 === 0;
232
+ if (logFrame && isDebugIngestEnabled()) {
233
+ const aw = Blackhole.attributeWeights;
234
+ const sample = (aw?.length && aw.length > 0) ? [aw[0], aw[1], aw[2]] : [];
235
+ const weightSum = (aw?.length && aw.length > 0) ? aw.slice(0, 8).reduce((s, w) => s + (Number(w) || 0), 0) : 0;
236
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'H5', location: 'Blackhole.ts:updatePhysics', message: 'attributeWeights static', data: { attributeWeightsLength: aw?.length ?? 0, sample, weightSum }, timestamp: Date.now() }) }).catch(() => { });
237
+ }
238
+ // #endregion
239
+ const forces = this._forces;
240
+ while (forces.length < blackholes.length) {
241
+ forces.push(new THREE.Vector3());
242
+ }
243
+ for (let i = 0; i < blackholes.length; i++) {
244
+ forces[i].set(0, 0, 0);
245
+ }
246
+ // Central: desired distance from PCA compatibility; min/max in SMBHW from options.
247
+ const supermassiveWidth = 2 * 8 * 1.4;
248
+ const minW = this.options.supermassiveBlackHole.minDistanceWidths ?? 0.25;
249
+ const maxW = this.options.supermassiveBlackHole.maxDistanceWidths ?? 7;
250
+ const viewableRadius = maxW * supermassiveWidth;
251
+ const minDistFromCentral = minW * supermassiveWidth;
252
+ // Per-BH preferred compatibility with central (BH attributes vs SMBH preferences). Used to reduce repulsion on same-trait BHs.
253
+ const compatWithCentral = [];
254
+ for (let k = 0; k < blackholes.length; k++) {
255
+ compatWithCentral[k] = 0;
256
+ if (central && blackholes[k] !== central) {
257
+ compatWithCentral[k] = blackholes[k].calculatePreferredCompatibility(central);
258
+ }
259
+ }
260
+ // Per-BH "neighbor repulsion pressure" (PCA: dissimilar neighbors repel); attenuates drift toward SMBH.
261
+ const neighborPressure = [];
262
+ for (let i = 0; i < blackholes.length; i++) {
263
+ neighborPressure[i] = 0;
264
+ const a = blackholes[i];
265
+ if (a === central)
266
+ continue;
267
+ for (let j = 0; j < blackholes.length; j++) {
268
+ if (j === i || blackholes[j] === central)
269
+ continue;
270
+ const b = blackholes[j];
271
+ const dist = Math.max(1e-4, a.position.distanceTo(b.position));
272
+ const compat = a.calculateAttributeCompatibility(b);
273
+ neighborPressure[i] += (1 - compat) / (dist * 0.01 + 1);
274
+ }
275
+ }
74
276
  if (central) {
75
- for (let i = 0; i < nodes.length; i++) {
76
- const node = nodes[i];
77
- if (node === central)
277
+ const d = this._scratchD;
278
+ for (let i = 0; i < blackholes.length; i++) {
279
+ const bh = blackholes[i];
280
+ if (bh === central)
281
+ continue;
282
+ d.copy(central.position).sub(bh.position);
283
+ const distance = Math.sqrt(d.lengthSq() + 1e-4);
284
+ // Collision: prevent blackhole from passing through SMBH (position correction when overlapping).
285
+ const centralScale = central.mesh?.scale?.x ?? 1;
286
+ const centralRadius = central.baseSphereRadius * centralScale * 1;
287
+ const bhRadius = bh.baseSphereRadius * bh.mesh.scale.x * 1;
288
+ const minDistCentral = centralRadius + bhRadius;
289
+ if (distance < minDistCentral && distance > 1e-8) {
290
+ const overlap = minDistCentral - distance;
291
+ d.normalize();
292
+ // Push blackhole away from central: d points bh→central, so use -d to move bh outward
293
+ this._scratchF.copy(d).multiplyScalar(-overlap * 1.2);
294
+ this._scratchF.z = 0;
295
+ bh.position.add(this._scratchF);
296
+ bh.position.y = 0;
297
+ // Resolve velocity so blackhole is not moving toward central (stops bounce/jitter).
298
+ const towardCentral = bh.velocity.dot(d);
299
+ if (towardCentral > 0)
300
+ bh.velocity.addScaledVector(d, -towardCentral);
301
+ bh.velocity.y = 0;
302
+ d.copy(central.position).sub(bh.position);
303
+ d.y = 0;
304
+ }
305
+ const distanceAfterCollision = Math.sqrt(d.lengthSq() + 1e-4);
306
+ // PCA: single-trait-to-xyz via projectTo3D in calculatePreferredCompatibility; settle by attr vs SMBH prefs.
307
+ const compatibility = bh.calculatePreferredCompatibility(central);
308
+ const desiredWidths = minW + (1 - compatibility) * (maxW - minW);
309
+ const desiredDist = desiredWidths * supermassiveWidth;
310
+ const error = distanceAfterCollision - desiredDist;
311
+ const settlingStiffness = compatibility > 0.9 ? 0.85 : 0.14;
312
+ let settlingMag = settlingStiffness * error;
313
+ // Attenuate drift by neighbor repulsion only when BH is not very similar to SMBH; same-trait BHs (e.g. Alice) must settle close.
314
+ const pressure = neighborPressure[i] ?? 0;
315
+ const attenuation = compatibility > 0.9 ? 1 : (1 / (1 + 0.25 * pressure));
316
+ settlingMag *= attenuation;
317
+ const settlingCap = compatibility > 0.9 ? 9 : 2.5;
318
+ settlingMag = Math.max(-settlingCap, Math.min(settlingCap, settlingMag));
319
+ d.normalize();
320
+ this._scratchF.copy(d).multiplyScalar(settlingMag);
321
+ forces[i].add(this._scratchF);
322
+ const falloffDenom = Math.sqrt(distanceAfterCollision + 1) * 2.5 + 2;
323
+ let centralMag = (this.options.supermassiveBlackHole.attraction * compatibility * 3.0) / falloffDenom;
324
+ if (compatibility > 0.8)
325
+ centralMag *= Math.min(1 + (compatibility - 0.8) * 1.5, 1.5);
326
+ if (compatibility > 0.9 && distanceAfterCollision > desiredDist * 1.2)
327
+ centralMag *= 2.2;
328
+ d.copy(central.position).sub(bh.position).normalize().multiplyScalar(centralMag);
329
+ d.clampLength(0, compatibility > 0.9 ? 3.2 : 1.4);
330
+ forces[i].add(d);
331
+ if (distanceAfterCollision > viewableRadius) {
332
+ const excess = distanceAfterCollision - viewableRadius;
333
+ const boundaryMag = Math.min(excess * 0.45 * compatibility, 22);
334
+ this._scratchF.copy(central.position).sub(bh.position).normalize().multiplyScalar(boundaryMag);
335
+ forces[i].add(this._scratchF);
336
+ }
337
+ }
338
+ }
339
+ // #region agent log — per-BH preferredCompat and distance (to verify same-trait nodes e.g. Alice settle close)
340
+ if (logFrame && central && isDebugIngestEnabled()) {
341
+ const perBh = [];
342
+ for (let k = 0; k < blackholes.length; k++) {
343
+ const b = blackholes[k];
344
+ if (b === central)
78
345
  continue;
79
- const d = central.position.clone().sub(node.position);
80
- const distanceSq = d.lengthSq() + 1e-4;
81
- const compatibility = node.calculatePreferredCompatibility(central);
82
- let force = d
83
- .normalize()
84
- .multiplyScalar((this.options.sun.attraction * compatibility * 2.0) /
85
- (distanceSq + 0.5));
86
- // Clamp runaway attraction
87
- force.clampLength(0, 0.08);
88
- forces[i].add(force);
89
- }
90
- }
91
- // ---Outer Outer interactions (blend of attraction and repulsion) ---
92
- for (let i = 0; i < nodes.length; i++) {
93
- const a = nodes[i];
346
+ const compat = b.calculatePreferredCompatibility(central);
347
+ const dw = minW + (1 - compat) * (maxW - minW);
348
+ const dist = central.position.distanceTo(b.position);
349
+ perBh.push({
350
+ name: String(b.userData?.['name'] ?? ''),
351
+ preferredCompat: compat,
352
+ desiredWidths: dw,
353
+ distFromCentral: dist,
354
+ distInWidths: dist / supermassiveWidth,
355
+ });
356
+ }
357
+ if (perBh.length > 0) {
358
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'compat', location: 'Blackhole.ts:perBhCompat', message: 'per-BH preferredCompat and distance', data: { frame: getDebugFrameCount(), viewableRadius, minW, maxW, perBh }, timestamp: Date.now() }) }).catch(() => { });
359
+ }
360
+ }
361
+ // #endregion
362
+ const dir = this._scratchDir;
363
+ const scratchF = this._scratchF;
364
+ for (let i = 0; i < blackholes.length; i++) {
365
+ const a = blackholes[i];
94
366
  if (a === central)
95
367
  continue;
96
- for (let j = i + 1; j < nodes.length; j++) {
97
- const b = nodes[j];
368
+ for (let j = i + 1; j < blackholes.length; j++) {
369
+ const b = blackholes[j];
98
370
  if (b === central)
99
371
  continue;
100
- const dir = b.position.clone().sub(a.position);
372
+ dir.copy(b.position).sub(a.position);
373
+ dir.y = 0;
101
374
  const distSq = dir.lengthSq() + 1e-6;
102
375
  const dist = Math.sqrt(distSq);
103
376
  if (dist < 1e-8)
104
377
  continue;
105
- const similarity = a.calculateAttributeCompatibility(b);
106
- const dissimilarity = 1 - similarity;
107
- const radiusA = a.baseSphereRadius * a.mesh.scale.x;
108
- const radiusB = b.baseSphereRadius * b.mesh.scale.x;
109
- const minDist = (radiusA + radiusB) * 1;
378
+ const visualRadiusA = a.baseSphereRadius * a.mesh.scale.x * 1;
379
+ const visualRadiusB = b.baseSphereRadius * b.mesh.scale.x * 1;
380
+ const minDist = visualRadiusA + visualRadiusB;
381
+ const compatibility = a.calculateAttributeCompatibility(b);
382
+ // #region agent log (first blackhole-blackhole pair; index 0 is often central so (0,3) never ran)
383
+ if (logFrame && blackholeIndices.length >= 2 && i === blackholeIndices[0] && j === blackholeIndices[1] && isDebugIngestEnabled()) {
384
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'H4', location: 'Blackhole.ts:pairwise_first_pair', message: 'first blackhole-blackhole pair compat', data: { i, j, compatibility, dist, minDist, overlap: minDist - dist, similarity: compatibility, dissimilarity: 1 - compatibility, attrsA: a.attributes?.slice(0, 4), attrsB: b.attributes?.slice(0, 4) }, timestamp: Date.now() }) }).catch(() => { });
385
+ }
386
+ // #endregion
387
+ if (compatibility > 0.95) {
388
+ const overlap = minDist - dist;
389
+ if (overlap > 0) {
390
+ dir.normalize();
391
+ scratchF.copy(dir).multiplyScalar(overlap * 1.2);
392
+ scratchF.z = 0;
393
+ a.position.sub(scratchF);
394
+ b.position.add(scratchF);
395
+ a.position.y = 0;
396
+ b.position.y = 0;
397
+ // Resolve approach velocity to prevent bounce/jitter.
398
+ const relVel = b.velocity.dot(dir) - a.velocity.dot(dir);
399
+ if (relVel < 0) {
400
+ const k = relVel / 2;
401
+ a.velocity.addScaledVector(dir, k);
402
+ b.velocity.addScaledVector(dir, -k);
403
+ a.velocity.y = 0;
404
+ b.velocity.y = 0;
405
+ }
406
+ }
407
+ // No extra attraction when already very close — let separation dominate so they don’t pile up
408
+ if (overlap > 0)
409
+ continue; // already handled; skip dist < minDist to avoid double position/velocity correction
410
+ }
110
411
  if (dist < minDist) {
111
- const repel = dir
112
- .normalize()
113
- .multiplyScalar(this.options.planet.repulsion / (distSq + 0.01));
114
- repel.clampLength(0, 0.25);
115
- forces[i].sub(repel);
116
- forces[j].add(repel);
412
+ dir.normalize();
413
+ const overlap = minDist - dist;
414
+ // Position correction for all overlapping pairs so BHs cannot pass through each other (collision on for similar and dissimilar).
415
+ const separatePush = Math.max(0.15, overlap * 1.2);
416
+ scratchF.copy(dir).multiplyScalar(separatePush);
417
+ scratchF.z = 0;
418
+ a.position.sub(scratchF);
419
+ b.position.add(scratchF);
420
+ a.position.y = 0;
421
+ b.position.y = 0;
422
+ // Resolve approach velocity so they separate instead of bouncing every frame (stops jitter).
423
+ const relVel = b.velocity.dot(dir) - a.velocity.dot(dir);
424
+ if (relVel < 0) {
425
+ const k = relVel / 2;
426
+ a.velocity.addScaledVector(dir, k);
427
+ b.velocity.addScaledVector(dir, -k);
428
+ a.velocity.y = 0;
429
+ b.velocity.y = 0;
430
+ }
431
+ // Also add force for extra separation
432
+ const push = Math.max(0.15, overlap * 0.3);
433
+ scratchF.copy(dir).multiplyScalar(-push);
434
+ forces[i].add(scratchF);
435
+ scratchF.copy(dir).multiplyScalar(push);
436
+ forces[j].add(scratchF);
117
437
  continue;
118
438
  }
119
- const attractMag = (30 * similarity * 3.0) / (distSq + 0.5);
120
- const repelMag = 90 * dissimilarity / (distSq + 0.5);
439
+ const similarity = compatibility;
440
+ const dissimilarity = 1 - compatibility;
441
+ const exp = Math.min(4, Math.max(1, a.options.blackhole.dissimilarityRepulsionExponent ?? 2));
442
+ const dNorm = Math.pow(dissimilarity, exp);
443
+ // Attraction: reduced so similar nodes cluster but don’t collapse on top of each other
444
+ let attractMag = similarity >= 0.5
445
+ ? (45 * similarity) / (dist + 8.0)
446
+ : 0;
447
+ // Repulsion: stronger and longer range so dissimilar groups actually spread apart (gentler falloff)
448
+ const mult = a.options.blackhole.pairwiseRepulsionMultiplier ?? 1;
449
+ const repulsionBase = (a.options.blackhole.pairwiseRepulsionMain ?? 52) * mult;
450
+ let repelMag = repulsionBase * 2.5 * dNorm / (dist * 0.08 + 3);
451
+ if (similarity > 0.85) {
452
+ attractMag *= 1.3;
453
+ repelMag *= 0.25;
454
+ }
455
+ // dir = B - A: positive netMag = pull A toward B (attract), negative = push A away from B (repel)
121
456
  const netMag = attractMag - repelMag;
122
457
  if (Math.abs(netMag) < 1e-4)
123
458
  continue;
124
- const f = dir.normalize().multiplyScalar(netMag).clampLength(-0.9, 0.9);
125
- forces[i].add(f);
126
- forces[j].add(f.clone().multiplyScalar(-1));
459
+ // Strong repulsion (up to 10); mild attraction (cap 2) so similar stay near but not on top
460
+ dir.normalize().multiplyScalar(netMag).clampLength(-10, 2);
461
+ // Same-trait BHs (high compat with SMBH) receive much less pairwise repulsion so they can settle close to central
462
+ const scaleI = (compatWithCentral[i] > 0.9 && netMag < 0) ? 0.05 : 1;
463
+ const scaleJ = (compatWithCentral[j] > 0.9 && netMag < 0) ? 0.05 : 1;
464
+ // #region agent log
465
+ if (logFrame && blackholeIndices.length >= 2 && i === blackholeIndices[0] && j === blackholeIndices[1]) {
466
+ const ax = dir.x;
467
+ const ay = dir.y;
468
+ const az = dir.z;
469
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'B', location: 'Blackhole.ts:pairwise', message: 'pair 0-1 force', data: { similarity, dissimilarity, attractMag, repelMag, netMag, forceOnI: { x: ax, y: ay, z: az }, dist }, timestamp: Date.now() }) }).catch(() => { });
470
+ }
471
+ // #endregion
472
+ scratchF.copy(dir).multiplyScalar(scaleI);
473
+ forces[i].add(scratchF);
474
+ scratchF.copy(dir).multiplyScalar(-scaleJ);
475
+ forces[j].add(scratchF);
127
476
  }
128
477
  }
129
- // ---Extra Repulsion (outer nodes only) ---
130
- for (let i = 0; i < nodes.length; i++) {
131
- const a = nodes[i];
478
+ for (let i = 0; i < blackholes.length; i++) {
479
+ const a = blackholes[i];
132
480
  if (a === central)
133
481
  continue;
134
- for (let j = i + 1; j < nodes.length; j++) {
135
- const b = nodes[j];
482
+ for (let j = i + 1; j < blackholes.length; j++) {
483
+ const b = blackholes[j];
136
484
  if (b === central)
137
485
  continue;
138
- const dir = a.position.clone().sub(b.position);
486
+ dir.copy(a.position).sub(b.position);
139
487
  const distSq = dir.lengthSq() + 1e-4;
488
+ const distSec = Math.sqrt(distSq);
140
489
  const similarity = a.calculateAttributeCompatibility(b);
141
- const mag = 20 * (1 - similarity) / (distSq + 0.5);
142
- const f = dir.normalize().multiplyScalar(mag).clampLength(0, 0.9);
143
- forces[i].add(f);
144
- forces[j].add(f.clone().multiplyScalar(-1));
490
+ const d = 1 - similarity;
491
+ const exp = Math.min(4, Math.max(1, a.options.blackhole.dissimilarityRepulsionExponent ?? 2));
492
+ const dNorm = Math.pow(d, exp);
493
+ const multSec = a.options.blackhole.pairwiseRepulsionMultiplier ?? 1;
494
+ const secBase = (a.options.blackhole.pairwiseRepulsionSecondary ?? 42) * multSec;
495
+ const mag = secBase * 2 * dNorm / (distSec * 0.08 + 3);
496
+ // Cap per-pair repulsion at 6 so dissimilar groups spread out
497
+ dir.normalize().multiplyScalar(mag).clampLength(0, 6);
498
+ const secScaleI = compatWithCentral[i] > 0.9 ? 0.05 : 1;
499
+ const secScaleJ = compatWithCentral[j] > 0.9 ? 0.05 : 1;
500
+ scratchF.copy(dir).multiplyScalar(secScaleI);
501
+ forces[i].add(scratchF);
502
+ scratchF.copy(dir).multiplyScalar(-secScaleJ);
503
+ forces[j].add(scratchF);
145
504
  }
146
505
  }
147
- // --- Apply forces → velocities → positions ---
148
- for (let i = 0; i < nodes.length; i++) {
149
- const node = nodes[i];
150
- if (node == central)
506
+ // #region agent log
507
+ if (logFrame && blackholeIndices[0] !== undefined) {
508
+ const totalAccel = forces[blackholeIndices[0]]?.length?.() ?? 0;
509
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'C', location: 'Blackhole.ts:beforeApply', message: 'total acceleration mag first blackhole', data: { totalAccelMag: totalAccel }, timestamp: Date.now() }) }).catch(() => { });
510
+ }
511
+ // #endregion
512
+ let didLogLocThisFrame = false;
513
+ for (let i = 0; i < blackholes.length; i++) {
514
+ const bh = blackholes[i];
515
+ if (bh == central)
151
516
  continue;
152
- if (!(node.velocity instanceof THREE.Vector3)) {
153
- node.velocity = new THREE.Vector3(0, 0, 0);
517
+ if (!(bh.velocity instanceof THREE.Vector3)) {
518
+ bh.velocity = new THREE.Vector3(0, 0, 0);
154
519
  }
155
520
  const effectiveDt = Math.min(deltaTime, 0.95);
156
521
  const acceleration = forces[i];
157
- // apply acceleration
158
- node.velocity.addScaledVector(acceleration, effectiveDt);
159
- // --- jitter suppression ---
160
- // add a small deadzone to prevent micro-oscillation
161
- if (node.velocity.lengthSq() < 1e-10) {
162
- node.velocity.set(0, 0, 0);
522
+ bh.velocity.addScaledVector(acceleration, effectiveDt);
523
+ bh.velocity.y = 0;
524
+ // Light similarity boost so similar nodes still cluster, but don’t over-amplify (was 2.0, caused pile-up)
525
+ if (!bh.isSupermassiveBlackhole) {
526
+ let boost = 1.0;
527
+ for (let j = 0; j < blackholes.length; j++) {
528
+ const other = blackholes[j];
529
+ if (other === bh || other.isSupermassiveBlackhole)
530
+ continue;
531
+ const similarity = bh.calculateAttributeCompatibility(other);
532
+ if (similarity > 0.85)
533
+ boost += (similarity - 0.85) * 0.4;
534
+ }
535
+ boost = Math.min(boost, 1.25);
536
+ forces[i].multiplyScalar(boost);
537
+ }
538
+ if (bh.velocity.lengthSq() < 1e-10) {
539
+ bh.velocity.set(0, 0, 0);
163
540
  continue;
164
541
  }
165
- // soft low-pass filter to smooth small fluctuations
166
- node.velocity.lerp(node.velocity.clone(), 0.95);
167
- // damping
168
- // const dampingFactor = 1 - 0.8 * 0.9;
169
- const dampingFactor = 0.181;
170
- node.velocity.multiplyScalar(dampingFactor);
171
- // clamp to prevent small high-frequency shake
172
- node.velocity.clampLength(0, 0.15);
173
- // integrate motion
174
- node.position.addScaledVector(node.velocity, effectiveDt);
175
- // --- Multi-node jitter suppression (group stabilizer) ---
176
- if (nodes.length > 2) {
542
+ bh.velocity.multiplyScalar(0.96);
543
+ bh.position.addScaledVector(bh.velocity, effectiveDt);
544
+ bh.position.y = 0;
545
+ // Clamp BH to viewport: no closer than minW SMBHW, no further than maxW SMBHW (e.g. 14 SMBHW diameter when max=7).
546
+ if (central && bh !== central) {
547
+ const distFromCentral = central.position.distanceTo(bh.position) + 1e-6;
548
+ const toBh = bh.position.clone().sub(central.position);
549
+ toBh.y = 0;
550
+ if (distFromCentral > 1e-6) {
551
+ if (distFromCentral > viewableRadius) {
552
+ toBh.normalize().multiplyScalar(viewableRadius);
553
+ bh.position.copy(central.position).add(toBh);
554
+ bh.position.y = 0;
555
+ bh.velocity.multiplyScalar(0.82);
556
+ // #region agent log
557
+ if (logFrame && isDebugIngestEnabled()) {
558
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'viewport', location: 'Blackhole.ts:clampMax', message: 'BH clamped to max viewport radius', data: { bhIndex: i, distFromCentralBefore: distFromCentral, viewableRadius, distInWidths: distFromCentral / supermassiveWidth }, timestamp: Date.now() }) }).catch(() => { });
559
+ }
560
+ // #endregion
561
+ }
562
+ else if (distFromCentral < minDistFromCentral) {
563
+ this._scratchD.copy(bh.position).sub(central.position).normalize();
564
+ this._scratchF.copy(this._scratchD).multiplyScalar(minDistFromCentral);
565
+ bh.position.copy(central.position).add(this._scratchF);
566
+ bh.position.y = 0;
567
+ const dot = bh.velocity.dot(this._scratchD);
568
+ if (dot < 0)
569
+ bh.velocity.addScaledVector(this._scratchD, -dot);
570
+ // #region agent log
571
+ if (logFrame && isDebugIngestEnabled()) {
572
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'viewport', location: 'Blackhole.ts:clampMin', message: 'BH clamped to min distance', data: { bhIndex: i, distFromCentralBefore: distFromCentral, minDistFromCentral, distInWidths: distFromCentral / supermassiveWidth }, timestamp: Date.now() }) }).catch(() => { });
573
+ }
574
+ // #endregion
575
+ }
576
+ }
577
+ }
578
+ // Jitter reduction: when very slow and near equilibrium, zero velocity so nodes/particles don't jitter.
579
+ const speedSq = bh.velocity.lengthSq();
580
+ if (speedSq < 4e-4) {
581
+ bh.velocity.set(0, 0, 0);
582
+ }
583
+ else if (speedSq < 0.0025) {
584
+ bh.velocity.multiplyScalar(0.92);
585
+ }
586
+ // #region agent log — location over time: BH vs SMBH (first non-central BH every 60 frames)
587
+ if (logFrame && central && !didLogLocThisFrame && isDebugIngestEnabled()) {
588
+ didLogLocThisFrame = true;
589
+ const dFromCentral = central.position.distanceTo(bh.position);
590
+ const inView = dFromCentral <= viewableRadius && dFromCentral >= minDistFromCentral;
591
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'loc', location: 'Blackhole.ts:locationOverTime', message: 'BH vs SMBH distance and viewport', data: { frame: getDebugFrameCount(), bhIndex: i, distFromCentral: dFromCentral, viewableRadius, minDistFromCentral, distInWidths: dFromCentral / supermassiveWidth, inViewableZone: inView, velocityMag: bh.velocity.length() }, timestamp: Date.now() }) }).catch(() => { });
592
+ }
593
+ // #endregion
594
+ if (blackholes.length > 2) {
177
595
  let nearCount = 0;
178
- for (let j = 0; j < nodes.length; j++) {
179
- if (j === i || nodes[j] === central)
596
+ for (let j = 0; j < blackholes.length; j++) {
597
+ if (j === i || blackholes[j] === central)
180
598
  continue;
181
- const d = node.position.distanceTo(nodes[j].position);
599
+ const d = bh.position.distanceTo(blackholes[j].position);
182
600
  if (d < 0.35)
183
- nearCount++; // consider nodes within a small neighborhood
601
+ nearCount++;
184
602
  }
185
- // if surrounded by 2+ near neighbors and moving very slowly, freeze micro motion
186
- if (nearCount >= 2 && node.velocity.lengthSq() < 1e-5) {
187
- node.velocity.multiplyScalar(0.1); // heavy dampening
188
- if (node.velocity.lengthSq() < 1e-10)
189
- node.velocity.set(0, 0, 0);
603
+ if (nearCount >= 2 && bh.velocity.lengthSq() < 1e-5) {
604
+ bh.velocity.multiplyScalar(0.1);
605
+ if (bh.velocity.lengthSq() < 1e-10)
606
+ bh.velocity.set(0, 0, 0);
190
607
  }
191
608
  }
192
609
  }
193
- // --- Final stabilization: prevent overlap among similar clusters ---
194
- const separationRadius = 0.12;
195
- const stiffness = 0.1;
196
- for (let i = 0; i < nodes.length; i++) {
197
- const a = nodes[i];
610
+ // Enforce minimum separation so nodes stay visible and don’t stack
611
+ const separationRadius = 0.4;
612
+ const stiffness = 0.6;
613
+ for (let i = 0; i < blackholes.length; i++) {
614
+ const a = blackholes[i];
198
615
  if (a === central)
199
616
  continue;
200
- for (let j = i + 1; j < nodes.length; j++) {
201
- const b = nodes[j];
617
+ for (let j = i + 1; j < blackholes.length; j++) {
618
+ const b = blackholes[j];
202
619
  if (b === central)
203
620
  continue;
204
- const dir = a.position.clone().sub(b.position);
621
+ dir.copy(a.position).sub(b.position);
205
622
  const dist = dir.length();
206
623
  if (dist < separationRadius && dist > 0.01) {
207
- // very small corrective displacement only, not a new force
208
624
  const overlap = separationRadius - dist;
209
- const correction = dir.normalize().multiplyScalar(overlap * stiffness);
210
- a.position.add(correction);
211
- b.position.sub(correction);
625
+ scratchF.copy(dir).normalize().multiplyScalar(overlap * stiffness);
626
+ a.position.add(scratchF);
627
+ b.position.sub(scratchF);
212
628
  }
213
629
  }
214
630
  }
215
- }
216
- setSun(state = !this.isSun, preference) {
217
- this.isSun = state;
218
- if (state) {
219
- // Switch to neon purple core; keep sprites hidden for clarity
220
- const core = this.mesh.material;
221
- core.uniforms['glowColor'].value = new THREE.Color('#C300FF');
222
- core.uniforms['opacity'].value = 0.9;
223
- core.uniforms['base'].value = 0.6;
224
- const halo = this.halo.material;
225
- halo.uniforms['glowColor'].value = new THREE.Color('#C300FF');
226
- halo.uniforms['opacity'].value = 0.22;
227
- this.halo.scale.setScalar(1.8);
228
- // Billboard core (hidden)
229
- this.coreSprite.material.color =
230
- new THREE.Color('#C300FF');
231
- this.coreSprite.material.opacity = 0.35;
232
- const dSun = this.baseSphereRadius * 2 * 1.8;
233
- this.coreSprite.scale.set(dSun, dSun, 1);
234
- // Billboard halo (hidden)
235
- this.haloSprite.material.color =
236
- new THREE.Color('#C300FF');
237
- this.haloSprite.material.opacity = 0.08;
238
- const dSunHalo = this.baseSphereRadius * 2 * 3.0;
239
- this.haloSprite.scale.set(dSunHalo, dSunHalo, 1);
240
- }
241
- else {
242
- // Switch to pink-red core; keep sprites hidden for clarity
243
- const core = this.mesh.material;
244
- core.uniforms['glowColor'].value = new THREE.Color('#FF3366');
245
- core.uniforms['opacity'].value = 0.7;
246
- core.uniforms['base'].value = 0.35;
247
- const halo = this.halo.material;
248
- halo.uniforms['glowColor'].value = new THREE.Color('#FF3366');
249
- halo.uniforms['opacity'].value = 0.16;
250
- this.halo.scale.setScalar(1.35);
251
- // Billboard core (hidden)
252
- this.coreSprite.material.color =
253
- new THREE.Color('#FF3366');
254
- this.coreSprite.material.opacity = 0.25;
255
- const d = this.baseSphereRadius * 2 * 1.3;
256
- this.coreSprite.scale.set(d, d, 1);
257
- // Billboard halo (hidden)
258
- this.haloSprite.material.color =
259
- new THREE.Color('#FF3366');
260
- this.haloSprite.material.opacity = 0.06;
261
- const dHalo = this.baseSphereRadius * 2 * 2.0;
262
- this.haloSprite.scale.set(dHalo, dHalo, 1);
631
+ // Same-trait BHs (compat with SMBH > 0.95): snap to min distance AFTER separation so they stay at 0.25 SMBHW
632
+ if (central) {
633
+ for (let i = 0; i < blackholes.length; i++) {
634
+ const bh = blackholes[i];
635
+ if (bh === central)
636
+ continue;
637
+ if (compatWithCentral[i] <= 0.95)
638
+ continue;
639
+ const distFromCentral = central.position.distanceTo(bh.position) + 1e-6;
640
+ if (distFromCentral <= minDistFromCentral * 1.05)
641
+ continue;
642
+ this._scratchD.copy(bh.position).sub(central.position).normalize();
643
+ this._scratchF.copy(this._scratchD).multiplyScalar(minDistFromCentral);
644
+ bh.position.copy(central.position).add(this._scratchF);
645
+ bh.position.y = 0;
646
+ const radialVel = bh.velocity.dot(this._scratchD);
647
+ bh.velocity.addScaledVector(this._scratchD, -radialVel);
648
+ bh.velocity.y = 0;
649
+ if (logFrame && isDebugIngestEnabled()) {
650
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'snap', location: 'Blackhole.ts:snapAfterSep', data: { bhIndex: i, name: bh.userData?.['name'], distBefore: distFromCentral, distAfter: minDistFromCentral }, timestamp: Date.now() }) }).catch(() => { });
651
+ }
652
+ }
263
653
  }
654
+ }
655
+ setSupermassiveBlackhole(state = !this.isSupermassiveBlackhole, preference) {
656
+ this.isSupermassiveBlackhole = state;
264
657
  if (preference !== undefined) {
265
658
  this.preference = preference;
266
659
  }
267
660
  }
268
- calculatePreferredCompatibility(sun) {
269
- const sunPreferences = sun.preferences;
270
- const planetAttributes = this.attributes;
271
- const len = Math.min(sunPreferences.length, planetAttributes.length);
272
- const weights = Node.normalizeWeights(Node.preferenceWeights, len);
661
+ /** Preferred compatibility via PCA: map preferences and attributes to 3D (each trait its own dimension, then PCA to 3D) and use distance. */
662
+ calculatePreferredCompatibility(supermassiveBlackhole) {
663
+ const prefVec = Blackhole.toNumberArray(supermassiveBlackhole.preferences);
664
+ const attrVec = Blackhole.toNumberArray(this.attributes);
665
+ if (prefVec.length === 0 || attrVec.length === 0)
666
+ return 0;
667
+ try {
668
+ const p = projectTo3D(prefVec);
669
+ const a = projectTo3D(attrVec);
670
+ const d = Math.sqrt((p[0] - a[0]) ** 2 + (p[1] - a[1]) ** 2 + (p[2] - a[2]) ** 2);
671
+ return 1 / (1 + d);
672
+ }
673
+ catch {
674
+ return this.fallbackPreferredCompatibility(supermassiveBlackhole);
675
+ }
676
+ }
677
+ fallbackPreferredCompatibility(supermassiveBlackhole) {
678
+ const centralPreferences = Blackhole.toNumberArray(supermassiveBlackhole.preferences);
679
+ const blackholeAttributes = Blackhole.toNumberArray(this.attributes);
680
+ const len = Math.min(centralPreferences.length, blackholeAttributes.length);
681
+ const weights = Blackhole.normalizeWeights(Blackhole.preferenceWeights, len);
273
682
  let diffSum = 0;
274
683
  let weightSum = 0;
275
684
  for (let i = 0; i < len; i++) {
276
685
  const w = weights[i];
277
- diffSum += w * Math.abs(sunPreferences[i] - planetAttributes[i]);
686
+ diffSum += w * Math.abs(centralPreferences[i] - blackholeAttributes[i]);
278
687
  weightSum += w;
279
688
  }
280
689
  const maxDiff = Math.max(1e-6, weightSum * 100);
@@ -284,7 +693,7 @@ class Node extends THREE.Object3D {
284
693
  const attributesA = this.attributes;
285
694
  const attributesB = other.attributes;
286
695
  const len = Math.min(attributesA.length, attributesB.length);
287
- const weights = Node.normalizeWeights(Node.attributeWeights, len);
696
+ const weights = Blackhole.normalizeWeights(Blackhole.attributeWeights, len);
288
697
  let diffSum = 0;
289
698
  let weightSum = 0;
290
699
  for (let i = 0; i < len; i++) {
@@ -295,62 +704,42 @@ class Node extends THREE.Object3D {
295
704
  const maxDiff = Math.max(1e-6, weightSum * 100);
296
705
  return 1 - diffSum / maxDiff;
297
706
  }
298
- update(nodes, cursorPosition, scene, camera) {
707
+ update(blackholes, cursorPosition, scene, camera) {
299
708
  if (this.swap) {
300
709
  this.handleSwapAnimation();
301
710
  return;
302
711
  }
303
- // Sun: keep steady size and glow (disable pulsation)
304
- if (this.isSun) {
305
- const targetScale = this.sunBaseScale;
712
+ // Apply group color from userData when set (e.g. Diverse Groups preset or context menu). Applies to all BHs (planets and SMBH) so opacity knob fades the sphere and reveals particles behind it.
713
+ if (this.userData?.['color'] && this.nodeMaterial && 'color' in this.nodeMaterial) {
714
+ const colorStr = String(this.userData['color']).trim();
715
+ const mat = this.nodeMaterial;
716
+ if (colorStr.length === 9 && colorStr.startsWith('#') && /^#[0-9a-fA-F]{8}$/.test(colorStr)) {
717
+ const rgbHex = colorStr.slice(1, 7);
718
+ const alphaHex = colorStr.slice(7, 9);
719
+ mat.color.set('#' + rgbHex);
720
+ mat.opacity = parseInt(alphaHex, 16) / 255;
721
+ mat.transparent = mat.opacity < 1;
722
+ mat.depthWrite = mat.opacity >= 1; // transparent sphere must not write depth so it doesn't occlude particles/background
723
+ }
724
+ else {
725
+ mat.color.set(colorStr);
726
+ mat.opacity = 1;
727
+ mat.transparent = false;
728
+ mat.depthWrite = true;
729
+ }
730
+ // Draw sphere after particles (renderOrder 0) so the BH is the visible layer that fades; opacity then shows particles behind it
731
+ this.mesh.renderOrder = 1;
732
+ }
733
+ if (this.isSupermassiveBlackhole) {
734
+ const targetScale = this.supermassiveBlackholeBaseScale;
306
735
  this.mesh.scale.lerp(new THREE.Vector3(targetScale, targetScale, targetScale), 0.2);
307
- const core = this.mesh.material;
308
- core.uniforms['opacity'].value = 0.75;
309
- const haloMat = this.halo.material;
310
- haloMat.uniforms['opacity'].value = 0.14;
311
- const haloScale = 1.8;
312
- this.halo.scale.lerp(new THREE.Vector3(haloScale, haloScale, haloScale), 0.2);
313
- // Sprites remain hidden/steady
314
736
  return;
315
737
  }
316
- let totalForce = new THREE.Vector3();
317
- const sun = nodes.find((n) => n.isSun) ?? null;
318
- if (sun) {
319
- totalForce.add(this.calculateSunForce(sun));
320
- // Update planet glow based on compatibility with sun
321
- const compat = this.calculatePreferredCompatibility(sun); // 0..1
322
- const core = this.mesh.material;
323
- core.uniforms['opacity'].value = 0.55 + compat * 0.4; // brighter core with compatibility
324
- // Planet additive halo tightly around mesh
325
- const haloMat = this.halo.material;
326
- const baseOpacity = 0.12 + compat * 0.25; // 0.12..0.37
327
- let proximityBoost = 0;
328
- if (cursorPosition) {
329
- const d = this.position.distanceTo(cursorPosition);
330
- proximityBoost = Math.max(0, 1 - d / 1.5) * 0.04; // very subtle
331
- }
332
- haloMat.uniforms['opacity'].value = Math.min(0.5, baseOpacity + proximityBoost);
333
- this.mesh.scale.set(1.9, 1.9, 1.9);
334
- const s = 1.35 * (1 + compat * 0.15);
335
- this.halo.scale.lerp(new THREE.Vector3(s, s, s), 0.25);
336
- // Billboard tuning for planets
337
- const coreMat = this.coreSprite.material;
338
- coreMat.opacity = 0.45 + compat * 0.35 + proximityBoost * 0.2;
339
- const d2 = this.baseSphereRadius * 2 * (1.3 + compat * 0.2) * this.mesh.scale.x;
340
- this.coreSprite.scale.lerp(new THREE.Vector3(d2, d2, 1), 0.2);
341
- const haloMatS = this.haloSprite.material;
342
- haloMatS.opacity = 0.16 + compat * 0.18 + proximityBoost * 0.1;
343
- const d2h = this.baseSphereRadius * 2 * (2.0 + compat * 0.5) * this.mesh.scale.x;
344
- this.haloSprite.scale.lerp(new THREE.Vector3(d2h, d2h, 1), 0.2);
345
- }
346
- totalForce.add(this.calculatePlanetRepulsion(nodes));
347
- totalForce.add(this.calculatePlanetAttraction(nodes));
348
- totalForce.multiplyScalar(this.options.velocityDamping);
349
- const deltaTime = this.clock.getDelta();
350
- this.updatePhysics(nodes, sun, deltaTime);
351
- this.applyForces(totalForce);
352
- }
353
- // (Sprite-based glow removed in favor of additive core + halo meshes)
738
+ // Blackholes: motion is integrated only in updatePhysics() (central + pairwise attract/repel).
739
+ // Skipping applyForces here so the central "spring" in calculateSupermassiveBlackHoleForce
740
+ // doesn't run again and pull everyone back to center, which was overpowering dissimilar repulsion.
741
+ return;
742
+ }
354
743
  createBillboardGlow(color, opacity, diameterMultiplier) {
355
744
  const size = 128;
356
745
  const canvas = document.createElement('canvas');
@@ -381,32 +770,32 @@ class Node extends THREE.Object3D {
381
770
  sprite.renderOrder = 20;
382
771
  return sprite;
383
772
  }
384
- calculateSunForce(sun) {
773
+ calculateSupermassiveBlackHoleForce(supermassiveBlackhole) {
385
774
  let force = new THREE.Vector3();
386
- const compatibility = this.calculatePreferredCompatibility(sun);
387
- const desiredDistance = 0.25 + (1 - compatibility) * 4.0;
388
- const currentDistance = sun.position.distanceTo(this.position);
775
+ const compatibility = this.calculatePreferredCompatibility(supermassiveBlackhole);
776
+ const desiredDistance = 0.15 + (1 - compatibility) * 200.0;
777
+ const currentDistance = supermassiveBlackhole.position.distanceTo(this.position);
389
778
  const error = currentDistance - desiredDistance;
390
- const directionToSun = new THREE.Vector3()
391
- .subVectors(sun.position, this.position)
779
+ const directionToCentral = new THREE.Vector3()
780
+ .subVectors(supermassiveBlackhole.position, this.position)
392
781
  .normalize();
393
782
  if (currentDistance < desiredDistance) {
394
- const repulsionForce = -this.options.sun.repulsion *
783
+ const repulsionForce = -this.options.supermassiveBlackHole.repulsion *
395
784
  (desiredDistance - currentDistance) *
396
785
  compatibility;
397
- force.add(directionToSun.multiplyScalar(repulsionForce));
786
+ force.add(directionToCentral.multiplyScalar(repulsionForce));
398
787
  }
399
788
  else {
400
- const attractionForce = 2 * this.options.sun.attraction * error * compatibility + 0.001;
401
- force.add(directionToSun.multiplyScalar(attractionForce));
789
+ const attractionForce = 2 * this.options.supermassiveBlackHole.attraction * error * compatibility + 0.001;
790
+ force.add(directionToCentral.multiplyScalar(attractionForce));
402
791
  }
403
792
  return force;
404
793
  }
405
- calculatePlanetAttraction(nodes) {
794
+ calculateBlackholeAttraction(blackholes) {
406
795
  let attractionForce = new THREE.Vector3();
407
796
  const attractionConstant = 0.001;
408
- nodes.forEach((other) => {
409
- if (other !== this && !other.isSun) {
797
+ blackholes.forEach((other) => {
798
+ if (other !== this && !other.isSupermassiveBlackhole) {
410
799
  const compatibility = this.calculateAttributeCompatibility(other);
411
800
  const forceMagnitude = attractionConstant * compatibility - 0.001;
412
801
  const attractionDirection = new THREE.Vector3()
@@ -417,17 +806,20 @@ class Node extends THREE.Object3D {
417
806
  });
418
807
  return attractionForce;
419
808
  }
420
- calculatePlanetRepulsion(nodes) {
809
+ calculateBlackholeRepulsion(blackholes) {
421
810
  let repulsionForce = new THREE.Vector3();
422
- nodes.forEach((other) => {
423
- if (other !== this && !other.isSun) {
811
+ blackholes.forEach((other) => {
812
+ if (other !== this && !other.isSupermassiveBlackhole) {
424
813
  const distance = this.position.distanceTo(other.position);
425
- if (distance < this.options.planet.repulsionInitializationThreshold) {
814
+ if (distance < this.options.blackhole.repulsionInitializationThreshold) {
426
815
  const compatibility = this.calculateAttributeCompatibility(other);
427
- const repulsion = this.options.planet.repulsion *
428
- (this.options.planet.repulsionInitializationThreshold -
816
+ const d = 1 - compatibility;
817
+ const exp = Math.min(4, Math.max(1, this.options.blackhole.dissimilarityRepulsionExponent ?? 2));
818
+ const dNorm = Math.pow(d, exp);
819
+ const repulsion = this.options.blackhole.repulsion *
820
+ (this.options.blackhole.repulsionInitializationThreshold -
429
821
  distance) *
430
- (1 - compatibility) +
822
+ dNorm +
431
823
  0.001;
432
824
  const repulsionDirection = new THREE.Vector3()
433
825
  .subVectors(this.position, other.position)
@@ -486,7 +878,6 @@ function makeCoreGlowMaterial(color, opacity = 0.8, power = 3.5, base = 0.1) {
486
878
  varying vec3 vWorldPosition;
487
879
  void main() {
488
880
  vec3 viewDir = normalize(cameraPosition - vWorldPosition);
489
- // Center-bright with a base floor so sides never go black
490
881
  float ndv = clamp(dot(vNormal, viewDir), 0.0, 1.0);
491
882
  float intensity = base + (1.0 - base) * pow(ndv, p);
492
883
  gl_FragColor = vec4(glowColor * intensity, intensity * opacity);
@@ -542,46 +933,145 @@ function makeRimGlowMaterial(color, opacity = 0.2, c = 0.2, power = 2.2) {
542
933
  side: THREE.BackSide,
543
934
  });
544
935
  }
545
- Node.prototype.makeCoreGlowMaterial = makeCoreGlowMaterial;
546
- Node.prototype.makeRimGlowMaterial = makeRimGlowMaterial;
936
+ Blackhole.prototype.makeCoreGlowMaterial = makeCoreGlowMaterial;
937
+ Blackhole.prototype.makeRimGlowMaterial = makeRimGlowMaterial;
547
938
 
548
939
  class Cluster extends THREE.Object3D {
549
940
  options;
550
- nodes;
941
+ blackholes;
942
+ _lastLogDist = [];
551
943
  constructor(nodeData, options) {
552
944
  super();
553
945
  this.options = {
554
- sun: {
555
- attraction: 1,
946
+ supermassiveBlackHole: {
947
+ attraction: 1.4,
556
948
  repulsion: 1,
557
949
  repulsionInitializationThreshold: 0.4,
950
+ minDistanceWidths: 0.25,
951
+ maxDistanceWidths: 7,
558
952
  },
559
- planet: {
953
+ blackhole: {
560
954
  attraction: 1,
561
955
  repulsion: 1,
562
- repulsionInitializationThreshold: 0.2,
956
+ repulsionInitializationThreshold: 30.2,
957
+ pairwiseRepulsionMain: 52,
958
+ pairwiseRepulsionSecondary: 42,
959
+ pairwiseRepulsionMultiplier: 1,
960
+ dissimilarityRepulsionExponent: 2,
563
961
  },
564
- maxVelocity: 0.02,
565
- velocityDamping: 0.8,
962
+ maxVelocity: 1.0,
963
+ velocityDamping: 0.85,
566
964
  minAttributeValue: 0,
567
965
  minPreferenceValue: 0,
568
966
  maxAttributeValue: 100,
569
967
  maxPreferenceValue: 100,
968
+ simulationSpeed: 3.25,
570
969
  ...options,
571
970
  };
572
- this.nodes = [];
971
+ this.blackholes = [];
573
972
  this.setUp(nodeData);
574
973
  }
575
974
  setUp(nodeData) {
576
975
  nodeData.forEach((data) => {
577
- const node = new Node(data, this.options);
578
- this.nodes.push(node);
579
- this.add(node);
976
+ const blackhole = new Blackhole(data, this.options);
977
+ this.blackholes.push(blackhole);
978
+ this.add(blackhole);
580
979
  });
581
980
  }
582
981
  update(cursorPosition, scene, camera) {
583
- this.nodes.forEach((node) => node.update(this.nodes, cursorPosition, scene, camera));
584
- // Bound the position within (-8, -8, -8) and (8, 8, 8)
982
+ // #region agent log
983
+ setPhysicsCallsThisFrame(0);
984
+ const frame = incrementDebugFrameCount();
985
+ const central = this.blackholes.find((b) => b.isSupermassiveBlackhole);
986
+ const firstBlackhole = this.blackholes.find((b) => !b.isSupermassiveBlackhole);
987
+ if (frame % 60 === 0 && isDebugIngestEnabled()) {
988
+ const opts = this.options;
989
+ const sbh = opts.supermassiveBlackHole;
990
+ const bhOpts = opts.blackhole;
991
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'H1_H2', location: 'Cluster.ts:update', message: 'options and updatePhysics gate', data: { frame, hasCentral: !!central, hasFirstBlackhole: !!firstBlackhole, sbhAttraction: sbh?.attraction, sbhRepulsion: sbh?.repulsion, blackholeRepulsion: bhOpts?.repulsion, pairwiseRepulsionMain: bhOpts?.pairwiseRepulsionMain, pairwiseRepulsionSecondary: bhOpts?.pairwiseRepulsionSecondary, optionsRefSameAsFirstBlackhole: firstBlackhole ? firstBlackhole.options === opts : null }, timestamp: Date.now() }) }).catch(() => { });
992
+ }
993
+ // #endregion
994
+ if (firstBlackhole && central) {
995
+ const rawDt = firstBlackhole.getClockDelta();
996
+ const speed = this.options.simulationSpeed ?? 1;
997
+ const deltaTime = rawDt * Math.max(0.1, Math.min(10, speed));
998
+ firstBlackhole.updatePhysics(this.blackholes, central, deltaTime);
999
+ }
1000
+ this.blackholes.forEach((bh) => bh.update(this.blackholes, cursorPosition, scene, camera));
1001
+ // #region agent log
1002
+ const nonCentralBlackholes = this.blackholes.filter((b) => !b.isSupermassiveBlackhole);
1003
+ if (central && nonCentralBlackholes.length > 0 && getDebugFrameCount() % 60 === 0 && isDebugIngestEnabled()) {
1004
+ const supermassiveWidth = 2 * 8 * 1.4;
1005
+ const minW = this.options.supermassiveBlackHole.minDistanceWidths ?? 0.25;
1006
+ const maxW = this.options.supermassiveBlackHole.maxDistanceWidths ?? 7;
1007
+ const viewableRadius = maxW * supermassiveWidth;
1008
+ const centralPrefs = Array.isArray(central.preferences) ? [...central.preferences] : [];
1009
+ while (this._lastLogDist.length < nonCentralBlackholes.length)
1010
+ this._lastLogDist.push(0);
1011
+ const positionsRel = nonCentralBlackholes.map((p, idx) => {
1012
+ const relX = p.position.x - central.position.x;
1013
+ const relY = p.position.y - central.position.y;
1014
+ const relZ = p.position.z - central.position.z;
1015
+ const dist = Math.sqrt(relX * relX + relY * relY + relZ * relZ);
1016
+ const distPrev = this._lastLogDist[idx] ?? dist;
1017
+ const distDelta = dist - distPrev;
1018
+ this._lastLogDist[idx] = dist;
1019
+ const inViewableZone = dist <= viewableRadius;
1020
+ const preferredCompat = p.calculatePreferredCompatibility(central);
1021
+ const desiredWidths = minW + (1 - preferredCompat) * (maxW - minW);
1022
+ const desiredDist = desiredWidths * supermassiveWidth;
1023
+ const velocityMag = p.velocity?.length?.() ?? 0;
1024
+ const attrs = Array.isArray(p.attributes) ? [...p.attributes] : [];
1025
+ return { idx, relX, relY, relZ, dist, distPrev, distDelta, distInWidths: dist / supermassiveWidth, velocityMag, preferredCompat, desiredDist, desiredWidths, inViewableZone, attributes: attrs };
1026
+ });
1027
+ const outsideCount = positionsRel.filter((r) => !r.inViewableZone).length;
1028
+ const maxDist = Math.max(...positionsRel.map((r) => r.dist));
1029
+ const avgAbsDistDelta = positionsRel.reduce((s, r) => s + Math.abs(r.distDelta), 0) / positionsRel.length;
1030
+ let minDistDissimilar = Infinity;
1031
+ for (let i = 0; i < nonCentralBlackholes.length; i++) {
1032
+ for (let j = i + 1; j < nonCentralBlackholes.length; j++) {
1033
+ const sim = nonCentralBlackholes[i].calculateAttributeCompatibility(nonCentralBlackholes[j]);
1034
+ if (sim < 0.6) {
1035
+ const d = nonCentralBlackholes[i].position.distanceTo(nonCentralBlackholes[j].position);
1036
+ if (d < minDistDissimilar)
1037
+ minDistDissimilar = d;
1038
+ }
1039
+ }
1040
+ }
1041
+ if (minDistDissimilar === Infinity)
1042
+ minDistDissimilar = 0;
1043
+ const pairwiseMult = this.options.blackhole.pairwiseRepulsionMultiplier ?? 1;
1044
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'pos', location: 'Cluster.ts:update', message: 'position over time: dist, distDelta, velocity, desiredDist, compat', data: { frame: getDebugFrameCount(), viewableRadius, supermassiveWidth, minDistanceWidths: minW, maxDistanceWidths: maxW, pairwiseRepulsionMultiplier: pairwiseMult, minDistBetweenDissimilar: minDistDissimilar, minDistDissimilarInWidths: minDistDissimilar / supermassiveWidth, centralPreferences: centralPrefs, positionsRel, outsideCount, maxDist, avgAbsDistDelta, centralPos: { x: central.position.x, y: central.position.y, z: central.position.z } }, timestamp: Date.now() }) }).catch(() => { });
1045
+ }
1046
+ if (nonCentralBlackholes.length >= 9 && getDebugFrameCount() % 60 === 0 && isDebugIngestEnabled()) {
1047
+ const positions = nonCentralBlackholes.map((p) => ({ x: p.position.x, z: p.position.z }));
1048
+ const groupA = [0, 1, 2], groupB = [3, 4, 5], groupC = [6, 7, 8, 9];
1049
+ const avgDist = (indices) => {
1050
+ let sum = 0, n = 0;
1051
+ for (let i = 0; i < indices.length; i++)
1052
+ for (let j = i + 1; j < indices.length; j++) {
1053
+ sum += nonCentralBlackholes[indices[i]].position.distanceTo(nonCentralBlackholes[indices[j]].position);
1054
+ n++;
1055
+ }
1056
+ return n ? sum / n : 0;
1057
+ };
1058
+ const avgBetween = (g1, g2) => {
1059
+ let sum = 0, n = 0;
1060
+ for (const i of g1)
1061
+ for (const j of g2) {
1062
+ sum += nonCentralBlackholes[i].position.distanceTo(nonCentralBlackholes[j].position);
1063
+ n++;
1064
+ }
1065
+ return n ? sum / n : 0;
1066
+ };
1067
+ const withinA = avgDist(groupA), withinB = avgDist(groupB), withinC = avgDist(groupC);
1068
+ const betweenAB = avgBetween(groupA, groupB), betweenAC = avgBetween(groupA, groupC), betweenBC = avgBetween(groupB, groupC);
1069
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'D', location: 'Cluster.ts:update', message: 'positions and group distances', data: { frame: getDebugFrameCount(), positions, withinA, withinB, withinC, betweenAB, betweenAC, betweenBC, physicsCalls: getPhysicsCallsThisFrame() }, timestamp: Date.now() }) }).catch(() => { });
1070
+ }
1071
+ if (getPhysicsCallsThisFrame() > 0 && getDebugFrameCount() % 60 === 0 && isDebugIngestEnabled()) {
1072
+ fetch('http://127.0.0.1:7243/ingest/eb74700b-8e83-4a1d-a30e-6c89d056a897', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hypothesisId: 'A', location: 'Cluster.ts:update', message: 'physics calls per frame', data: { physicsCallsThisFrame: getPhysicsCallsThisFrame(), blackholeCount: this.blackholes.length }, timestamp: Date.now() }) }).catch(() => { });
1073
+ }
1074
+ // #endregion
585
1075
  const BOUND = 8;
586
1076
  this.position.x = THREE.MathUtils.clamp(this.position.x, -BOUND, BOUND);
587
1077
  this.position.y = THREE.MathUtils.clamp(this.position.y, -BOUND, BOUND);
@@ -601,11 +1091,11 @@ class BlackHoleParticleField {
601
1091
  FIELD_RADIUS_FACTOR = 0.9; // relative scale
602
1092
  NEON_PURPLE = new THREE.Color(0xc300ff);
603
1093
  PINK_RED = new THREE.Color(0xff3366);
604
- palette = 'planet';
1094
+ palette = 'blackhole';
605
1095
  // Use even surface distribution (no view alignment by default)
606
1096
  useSurface = true;
607
1097
  seedCounter = 0; // Fibonacci seed index
608
- speedScale = 1; // instance speed scaler (planets slower)
1098
+ speedScale = 1; // instance speed scaler (blackholes slower)
609
1099
  // Animation params (slower, smoother orbits)
610
1100
  MIN_SPEED = 0.4; // radians/sec
611
1101
  MAX_SPEED = 1.2;
@@ -644,6 +1134,12 @@ class BlackHoleParticleField {
644
1134
  flowSpeedScale = 1.0;
645
1135
  prevTargetAnchor = new THREE.Vector3(); // for anchor-delta velocity
646
1136
  worldUp = new THREE.Vector3(0, 1, 0);
1137
+ // Particle size and depth (tunable via options and setters)
1138
+ particleSizeMax = 1.8;
1139
+ particleSizeScale = 1.0;
1140
+ surfaceRadiusMin = 1.0;
1141
+ surfaceRadiusMax = 1.01;
1142
+ rimMinShellFactor = 0.7;
647
1143
  constructor(scene, opts) {
648
1144
  this.scene = scene;
649
1145
  if (opts?.coreRadius && Number.isFinite(opts.coreRadius)) {
@@ -661,9 +1157,24 @@ class BlackHoleParticleField {
661
1157
  if (opts?.speedScale && Number.isFinite(opts.speedScale)) {
662
1158
  this.speedScale = Math.max(0.05, opts.speedScale);
663
1159
  }
1160
+ if (opts?.particleSizeMax != null && Number.isFinite(opts.particleSizeMax)) {
1161
+ this.particleSizeMax = Math.max(0.5, opts.particleSizeMax);
1162
+ }
1163
+ if (opts?.particleSizeScale != null && Number.isFinite(opts.particleSizeScale)) {
1164
+ this.particleSizeScale = Math.max(0.2, Math.min(3, opts.particleSizeScale));
1165
+ }
1166
+ if (opts?.surfaceRadiusMin != null && Number.isFinite(opts.surfaceRadiusMin)) {
1167
+ this.surfaceRadiusMin = opts.surfaceRadiusMin;
1168
+ }
1169
+ if (opts?.surfaceRadiusMax != null && Number.isFinite(opts.surfaceRadiusMax)) {
1170
+ this.surfaceRadiusMax = opts.surfaceRadiusMax;
1171
+ }
1172
+ if (opts?.rimMinShellFactor != null && Number.isFinite(opts.rimMinShellFactor)) {
1173
+ this.rimMinShellFactor = THREE.MathUtils.clamp(opts.rimMinShellFactor, 0.5, 1.05);
1174
+ }
664
1175
  // Enable debug via query param ?debugBands=1
665
1176
  try {
666
- const search = window?.location?.search ?? '';
1177
+ const search = getWindowLocationSearch();
667
1178
  this.debugBands = /(?:^|[?&])debugBands=1(?:&|$)/.test(search);
668
1179
  }
669
1180
  catch { }
@@ -904,7 +1415,7 @@ class BlackHoleParticleField {
904
1415
  this.setDebugBands(!this.debugBands);
905
1416
  return this.debugBands;
906
1417
  }
907
- // Switch palette at runtime (e.g., when a node becomes or ceases to be the sun)
1418
+ // Switch palette at runtime (e.g., when a node becomes or ceases to be the supermassive black hole)
908
1419
  setPalette(newPalette) {
909
1420
  if (this.palette === newPalette)
910
1421
  return;
@@ -913,7 +1424,7 @@ class BlackHoleParticleField {
913
1424
  return;
914
1425
  const geom = this.particleSystem.geometry;
915
1426
  const colorsAttr = geom.getAttribute('color');
916
- const base = this.palette === 'sun' ? this.NEON_PURPLE : this.PINK_RED;
1427
+ const base = this.palette === 'supermassiveBlackhole' ? this.NEON_PURPLE : this.PINK_RED;
917
1428
  for (let i = 0; i < this.particles.length; i++) {
918
1429
  const jitter = THREE.MathUtils.lerp(0.9, 1.1, Math.random());
919
1430
  const c = base.clone().multiplyScalar(jitter);
@@ -929,6 +1440,30 @@ class BlackHoleParticleField {
929
1440
  }
930
1441
  colorsAttr.needsUpdate = true;
931
1442
  }
1443
+ /** Set global particle size scale (affects shader uniform). */
1444
+ setParticleSizeScale(value) {
1445
+ const v = Math.max(0.2, Math.min(3, value));
1446
+ if (this.particleSizeScale === v)
1447
+ return;
1448
+ this.particleSizeScale = v;
1449
+ if (this.particleSystem) {
1450
+ const mat = this.particleSystem.material;
1451
+ const u = mat.uniforms?.['uSizeScale'];
1452
+ if (u)
1453
+ u.value = v;
1454
+ }
1455
+ }
1456
+ /** Set how far rim particles can go inside the node (0.7 = 30% inside, 1 = on surface). */
1457
+ setRimMinShellFactor(value) {
1458
+ this.rimMinShellFactor = THREE.MathUtils.clamp(value, 0.5, 1.05);
1459
+ }
1460
+ /** Set surface shell radius range (factors of coreRadius). */
1461
+ setSurfaceRadiusMin(value) {
1462
+ this.surfaceRadiusMin = value;
1463
+ }
1464
+ setSurfaceRadiusMax(value) {
1465
+ this.surfaceRadiusMax = value;
1466
+ }
932
1467
  dispose() {
933
1468
  if (this.particleSystem) {
934
1469
  this.scene.remove(this.particleSystem);
@@ -954,6 +1489,14 @@ class BlackHoleParticleField {
954
1489
  this.bandCounts[p.type]++;
955
1490
  }
956
1491
  }
1492
+ /** Base size: mostly 0.8–1.8, ~12% chance of small 0.4–0.7, clamped by particleSizeMax. */
1493
+ nextParticleSize() {
1494
+ const smallChance = 0.12;
1495
+ const base = Math.random() < smallChance
1496
+ ? THREE.MathUtils.lerp(0.4, 0.7, Math.random())
1497
+ : 0.8 + Math.random() * 1.0;
1498
+ return Math.min(base, this.particleSizeMax);
1499
+ }
957
1500
  createNewParticle() {
958
1501
  // New mode: evenly spread particles around the surface using per-particle orbit planes
959
1502
  if (this.useSurface) {
@@ -985,7 +1528,7 @@ class BlackHoleParticleField {
985
1528
  const radialJitterAmp = this.coreRadius * 0.002; // very subtle breathing
986
1529
  const radialJitterFreq = 0.2 + Math.random() * 0.3;
987
1530
  // Color from palette with small jitter
988
- const base = this.palette === 'sun' ? this.NEON_PURPLE : this.PINK_RED;
1531
+ const base = this.palette === 'supermassiveBlackhole' ? this.NEON_PURPLE : this.PINK_RED;
989
1532
  const cjit = THREE.MathUtils.lerp(0.9, 1.1, Math.random());
990
1533
  const color = base.clone().multiplyScalar(cjit);
991
1534
  color.r = Math.min(1, Math.max(0, color.r));
@@ -1002,7 +1545,7 @@ class BlackHoleParticleField {
1002
1545
  angularSpeed,
1003
1546
  radialJitterAmp,
1004
1547
  radialJitterFreq,
1005
- size: 0.8 + Math.random() * 1.4,
1548
+ size: this.nextParticleSize(),
1006
1549
  color,
1007
1550
  opacity: 0.85,
1008
1551
  lifetime: this.MIN_LIFETIME +
@@ -1143,8 +1686,8 @@ class BlackHoleParticleField {
1143
1686
  const radialJitterAmp = this.coreRadius *
1144
1687
  (type === 'topCap' || type === 'bottomCap' ? 0.012 : this.SHIMMER_FRAC);
1145
1688
  const radialJitterFreq = 0.3 + Math.random() * 0.5; // Hz (gentle)
1146
- // Color selection based on palette: sun = purple family, planet = pink-red
1147
- const base = this.palette === 'sun' ? this.NEON_PURPLE : this.PINK_RED;
1689
+ // Color selection based on palette: supermassiveBlackhole = purple family, blackhole = pink-red
1690
+ const base = this.palette === 'supermassiveBlackhole' ? this.NEON_PURPLE : this.PINK_RED;
1148
1691
  // subtle per-particle variation within same family (lighten/darken slightly)
1149
1692
  const jitter = THREE.MathUtils.lerp(0.9, 1.1, Math.random());
1150
1693
  const color = base.clone().multiplyScalar(jitter);
@@ -1176,7 +1719,7 @@ class BlackHoleParticleField {
1176
1719
  angularSpeed,
1177
1720
  radialJitterAmp,
1178
1721
  radialJitterFreq,
1179
- size: 0.8 + Math.random() * 1.4, // smaller discs (0.8..2.2)
1722
+ size: this.nextParticleSize(),
1180
1723
  color,
1181
1724
  opacity: 0.85,
1182
1725
  lifetime: this.MIN_LIFETIME +
@@ -1246,62 +1789,62 @@ class BlackHoleParticleField {
1246
1789
  geometry.getAttribute('position').needsUpdate = true;
1247
1790
  geometry.getAttribute('color').needsUpdate = true;
1248
1791
  // Custom bubble shader for perfect circular bubbles with dramatic lighting
1249
- const vertexShader = `
1250
- attribute float size;
1251
- attribute float opacity;
1252
- varying vec3 vColor;
1253
- varying float vOpacity;
1254
- varying float vSize;
1255
-
1256
- void main() {
1257
- vColor = color;
1258
- vOpacity = opacity;
1259
- vSize = size;
1260
-
1261
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1262
-
1263
- // Smaller bokeh-style circles
1264
- gl_PointSize = size * 4.0;
1265
-
1266
- gl_Position = projectionMatrix * mvPosition;
1267
- }
1792
+ const vertexShader = `
1793
+ uniform float uSizeScale;
1794
+ attribute float size;
1795
+ attribute float opacity;
1796
+ varying vec3 vColor;
1797
+ varying float vOpacity;
1798
+ varying float vSize;
1799
+
1800
+ void main() {
1801
+ vColor = color;
1802
+ vOpacity = opacity;
1803
+ vSize = size;
1804
+
1805
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1806
+
1807
+ gl_PointSize = size * 4.0 * uSizeScale;
1808
+
1809
+ gl_Position = projectionMatrix * mvPosition;
1810
+ }
1268
1811
  `;
1269
- const fragmentShader = `
1270
- varying vec3 vColor;
1271
- varying float vOpacity;
1272
- varying float vSize;
1273
-
1274
- void main() {
1275
- // Create circular bubble coordinate system
1276
- vec2 center = gl_PointCoord - 0.5;
1277
- float distance = length(center);
1278
-
1279
- // Perfect circular mask - discard pixels outside circle
1280
- if (distance > 0.5) discard;
1281
-
1282
- // Dramatic bubble lighting effects
1283
- float rimDistance = distance * 2.0; // 0.0 at center, 1.0 at edge
1284
-
1285
- // Bright center fading to edge (soap bubble effect)
1286
- float centerGlow = 1.0 - smoothstep(0.0, 0.35, rimDistance);
1287
-
1288
- // Rim lighting - bright edges
1289
- float rimGlow = smoothstep(0.65, 1.0, rimDistance) * 2.0;
1290
-
1291
- // Glass-like inner reflection
1292
- float bubble = smoothstep(0.85, 0.25, rimDistance);
1293
-
1294
- // Combine lighting effects
1295
- float totalGlow = centerGlow * 0.7 + rimGlow * 1.6 + bubble * 0.7;
1296
-
1297
- // Final bubble color with dramatic lighting
1298
- vec3 finalColor = vColor * totalGlow * 1.9;
1299
-
1300
- // Smooth alpha falloff for glass-like appearance
1301
- float alpha = (1.0 - smoothstep(0.35, 0.52, distance)) * vOpacity;
1302
-
1303
- gl_FragColor = vec4(finalColor, alpha);
1304
- }
1812
+ const fragmentShader = `
1813
+ varying vec3 vColor;
1814
+ varying float vOpacity;
1815
+ varying float vSize;
1816
+
1817
+ void main() {
1818
+ // Create circular bubble coordinate system
1819
+ vec2 center = gl_PointCoord - 0.5;
1820
+ float distance = length(center);
1821
+
1822
+ // Perfect circular mask - discard pixels outside circle
1823
+ if (distance > 0.5) discard;
1824
+
1825
+ // Dramatic bubble lighting effects
1826
+ float rimDistance = distance * 2.0; // 0.0 at center, 1.0 at edge
1827
+
1828
+ // Bright center fading to edge (soap bubble effect)
1829
+ float centerGlow = 1.0 - smoothstep(0.0, 0.35, rimDistance);
1830
+
1831
+ // Rim lighting - bright edges
1832
+ float rimGlow = smoothstep(0.65, 1.0, rimDistance) * 2.0;
1833
+
1834
+ // Glass-like inner reflection
1835
+ float bubble = smoothstep(0.85, 0.25, rimDistance);
1836
+
1837
+ // Combine lighting effects
1838
+ float totalGlow = centerGlow * 0.7 + rimGlow * 1.6 + bubble * 0.7;
1839
+
1840
+ // Final bubble color with dramatic lighting
1841
+ vec3 finalColor = vColor * totalGlow * 1.9;
1842
+
1843
+ // Smooth alpha falloff for glass-like appearance
1844
+ float alpha = (1.0 - smoothstep(0.35, 0.52, distance)) * vOpacity;
1845
+
1846
+ gl_FragColor = vec4(finalColor, alpha);
1847
+ }
1305
1848
  `;
1306
1849
  const material = new THREE.ShaderMaterial({
1307
1850
  vertexShader,
@@ -1310,6 +1853,9 @@ class BlackHoleParticleField {
1310
1853
  blending: THREE.AdditiveBlending,
1311
1854
  depthWrite: false,
1312
1855
  vertexColors: true,
1856
+ uniforms: {
1857
+ uSizeScale: { value: this.particleSizeScale },
1858
+ },
1313
1859
  });
1314
1860
  this.particleSystem = new THREE.Points(geometry, material);
1315
1861
  this.particleSystem.frustumCulled = false;
@@ -1337,9 +1883,9 @@ class BlackHoleParticleField {
1337
1883
  Math.sin(age * 2 * Math.PI * (p.wiggleFreqUp || 0.3) + (p.wigglePhase || 0));
1338
1884
  out.add(p.axis.clone().multiplyScalar(axWiggle));
1339
1885
  }
1340
- // Hard clamp to tight shell near exact surface
1341
- const minR = this.coreRadius * 1.0;
1342
- const maxR = this.coreRadius * 1.01;
1886
+ // Hard clamp to tight shell (tunable via surfaceRadiusMin/Max)
1887
+ const minR = this.coreRadius * this.surfaceRadiusMin;
1888
+ const maxR = this.coreRadius * this.surfaceRadiusMax;
1343
1889
  const len = out.length();
1344
1890
  if (len < minR)
1345
1891
  out.setLength(minR);
@@ -1380,9 +1926,9 @@ class BlackHoleParticleField {
1380
1926
  // Layer subtle extras
1381
1927
  out.add(up.clone().multiplyScalar(p.upBias + upWiggle + arcUp));
1382
1928
  out.add(forward.clone().multiplyScalar(p.depthBias + depthWiggle + arcDepth));
1383
- // Different penetration limits for different particle types
1929
+ // Different penetration limits for different particle types (rim tunable via rimMinShellFactor)
1384
1930
  const minShell = p.type === 'topCurve' || p.type === 'bottomCurve'
1385
- ? this.coreRadius * 0.7 // Rim particles: 30% inside surface
1931
+ ? this.coreRadius * this.rimMinShellFactor
1386
1932
  : this.coreRadius * 1.0; // Cap particles: stay at surface level
1387
1933
  const len = out.length();
1388
1934
  if (len < minShell) {
@@ -1431,6 +1977,13 @@ class TraitVisualComponent {
1431
1977
  preferenceWeights = [];
1432
1978
  attributeCount;
1433
1979
  preferenceCount;
1980
+ simulationOptions;
1981
+ /** When true, right-click on a node can show the context menu (when implemented). Default false. */
1982
+ showContextMenu = false;
1983
+ /** Default zoom level: multiplier on initial camera distance (1 = normal, > 1 = zoom out more). Default 1.25. */
1984
+ defaultZoomLevel = 1.25;
1985
+ /** Emits when the user selects a blackhole (e.g. via right-click). Host app can use this for context menu or sidenav. */
1986
+ blackholeSelected = new EventEmitter();
1434
1987
  // Three.js scene properties
1435
1988
  scene;
1436
1989
  camera;
@@ -1444,7 +1997,7 @@ class TraitVisualComponent {
1444
1997
  dragPlane = new THREE.Plane();
1445
1998
  dragOffset = new THREE.Vector3();
1446
1999
  newNodeCounter = 1;
1447
- isCameraLocked = false;
2000
+ isCameraLocked = true;
1448
2001
  // Private properties
1449
2002
  nodeAuras = new Map();
1450
2003
  starFieldNear;
@@ -1464,6 +2017,9 @@ class TraitVisualComponent {
1464
2017
  pointerYawSpeed = THREE.MathUtils.degToRad(0.15);
1465
2018
  pointerPitchDamping = 4;
1466
2019
  pointerYawDamping = 4;
2020
+ /** Reused buffer for dust-field attractors (avoids per-frame allocations). */
2021
+ _attractorsBuffer = [];
2022
+ _centerFallback = new THREE.Vector3(0, 0, 0);
1467
2023
  constructor(renderer2, ngZone) {
1468
2024
  this.renderer2 = renderer2;
1469
2025
  this.ngZone = ngZone;
@@ -1482,8 +2038,9 @@ class TraitVisualComponent {
1482
2038
  }
1483
2039
  }
1484
2040
  ngOnChanges(changes) {
1485
- if (changes['nodeData'] && !changes['nodeData'].firstChange && this.scene) {
1486
- // Reload nodes when nodeData changes
2041
+ const nodeDataChange = changes['nodeData'] && !changes['nodeData'].firstChange && this.scene;
2042
+ const simulationOptionsChange = changes['simulationOptions'] && !changes['simulationOptions'].firstChange && this.scene;
2043
+ if (nodeDataChange || simulationOptionsChange) {
1487
2044
  if (this.cluster) {
1488
2045
  this.scene.remove(this.cluster);
1489
2046
  this.nodeAuras.forEach((a) => a.dispose());
@@ -1497,6 +2054,22 @@ class TraitVisualComponent {
1497
2054
  if ((changes['attributeWeights'] || changes['preferenceWeights']) && !changes['attributeWeights']?.firstChange && !changes['preferenceWeights']?.firstChange) {
1498
2055
  this.syncNodeWeightGlobals();
1499
2056
  }
2057
+ if (changes['defaultZoomLevel'] && !changes['defaultZoomLevel'].firstChange && this.camera && this.controls) {
2058
+ this.applyDefaultZoomLevel();
2059
+ }
2060
+ }
2061
+ /** Apply defaultZoomLevel to camera: same direction from target, scaled distance. */
2062
+ applyDefaultZoomLevel() {
2063
+ const L = Math.max(0.5, Math.min(2.5, this.defaultZoomLevel));
2064
+ const target = this.controls.target;
2065
+ const offset = this.camera.position.clone().sub(target);
2066
+ const currentDist = offset.length();
2067
+ if (currentDist < 1e-6)
2068
+ return;
2069
+ const baseDist = Math.hypot(500, 10);
2070
+ const newDist = baseDist * L;
2071
+ offset.normalize().multiplyScalar(newDist);
2072
+ this.camera.position.copy(target).add(offset);
1500
2073
  }
1501
2074
  ngAfterViewInit() {
1502
2075
  this.animate();
@@ -1518,29 +2091,33 @@ class TraitVisualComponent {
1518
2091
  this.nodeAuras.clear();
1519
2092
  this.dustField?.dispose();
1520
2093
  }
1521
- currentCentral() {
2094
+ currentSupermassiveBlackhole() {
1522
2095
  if (!this.cluster)
1523
2096
  return null;
1524
- return this.cluster.nodes.find((node) => node.isSun) || null;
2097
+ return this.cluster.blackholes.find((bh) => bh.isSupermassiveBlackhole) || null;
1525
2098
  }
1526
- nonSuns() {
2099
+ nonSupermassiveBlackholes() {
1527
2100
  if (!this.cluster)
1528
2101
  return [];
1529
- return this.cluster.nodes.filter((node) => !node.isSun);
2102
+ return this.cluster.blackholes.filter((bh) => !bh.isSupermassiveBlackhole);
1530
2103
  }
1531
2104
  syncNodeWeightGlobals() {
1532
2105
  if (this.attributeWeights.length > 0) {
1533
- Node.attributeWeights = this.attributeWeights.slice();
2106
+ Blackhole.attributeWeights = this.attributeWeights.slice();
1534
2107
  }
1535
2108
  if (this.preferenceWeights.length > 0) {
1536
- Node.preferenceWeights = this.preferenceWeights.slice();
2109
+ Blackhole.preferenceWeights = this.preferenceWeights.slice();
1537
2110
  }
1538
2111
  }
1539
2112
  initScene() {
1540
2113
  this.scene = new THREE.Scene();
1541
- this.camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
2114
+ const container = this.canvasRef.nativeElement.parentElement;
2115
+ const width = container?.clientWidth || window.innerWidth;
2116
+ const height = container?.clientHeight || window.innerHeight;
2117
+ this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
1542
2118
  this.camera.up.set(0, 1, 0);
1543
- this.camera.position.set(0, 0, 60);
2119
+ const L = Math.max(0.5, Math.min(2.5, this.defaultZoomLevel));
2120
+ this.camera.position.set(0, 500 * L, 10 * L);
1544
2121
  const initialTilt = THREE.MathUtils.degToRad(-45);
1545
2122
  this.camera.position.applyAxisAngle(new THREE.Vector3(1, 0, 0), -initialTilt);
1546
2123
  this.camera.lookAt(0, 0, 0);
@@ -1550,10 +2127,11 @@ class TraitVisualComponent {
1550
2127
  alpha: true,
1551
2128
  });
1552
2129
  this.renderer.setClearColor(0x000000, 0);
1553
- this.renderer.setSize(window.innerWidth, window.innerHeight);
2130
+ this.renderer.setSize(width, height);
1554
2131
  this.controls = new OrbitControls(this.camera, this.renderer.domElement);
1555
2132
  this.controls.target.set(0, 0, 0);
1556
2133
  this.controls.enableDamping = true;
2134
+ this.controls.enabled = false;
1557
2135
  this.controls.enableRotate = true;
1558
2136
  this.controls.screenSpacePanning = true;
1559
2137
  this.controls.enablePan = true;
@@ -1583,17 +2161,17 @@ class TraitVisualComponent {
1583
2161
  loadNodes() {
1584
2162
  if (this.nodeData.length === 0)
1585
2163
  return;
1586
- this.cluster = new Cluster(this.nodeData);
2164
+ this.cluster = new Cluster(this.nodeData, this.simulationOptions);
1587
2165
  this.scene.add(this.cluster);
1588
- const initialCentral = this.cluster.nodes.find((node) => node.isSun) || this.cluster.nodes[0];
1589
- initialCentral.setSun(true, 5);
2166
+ const initialCentral = this.cluster.blackholes.find((bh) => bh.isSupermassiveBlackhole) || this.cluster.blackholes[0];
2167
+ initialCentral.setSupermassiveBlackhole(true, 5);
1590
2168
  initialCentral.mesh.scale.set(3, 3, 3);
1591
2169
  this.ensureNodeAuras();
1592
- const sun = this.currentCentral();
1593
- if (sun) {
1594
- const geom = sun.mesh.geometry;
2170
+ const supermassiveBlackhole = this.currentSupermassiveBlackhole();
2171
+ if (supermassiveBlackhole) {
2172
+ const geom = supermassiveBlackhole.mesh.geometry;
1595
2173
  const bs = geom.boundingSphere ?? new THREE.Sphere(new THREE.Vector3(), 0.05);
1596
- const coreRadius = (bs.radius || 0.05) * sun.mesh.scale.x;
2174
+ const coreRadius = (bs.radius || 0.05) * supermassiveBlackhole.mesh.scale.x;
1597
2175
  let dustOuter = 18.0;
1598
2176
  let dustThickness = 0.0;
1599
2177
  let dustMidCount = 160000;
@@ -1604,7 +2182,7 @@ class TraitVisualComponent {
1604
2182
  let dustMode = 'disk';
1605
2183
  let searchParams;
1606
2184
  try {
1607
- const rawSearch = window?.location?.search ?? '';
2185
+ const rawSearch = getWindowLocationSearch();
1608
2186
  searchParams = new URLSearchParams(rawSearch);
1609
2187
  }
1610
2188
  catch {
@@ -1633,9 +2211,12 @@ class TraitVisualComponent {
1633
2211
  }
1634
2212
  }
1635
2213
  onWindowResize() {
1636
- this.camera.aspect = window.innerWidth / window.innerHeight;
2214
+ const container = this.canvasRef.nativeElement.parentElement;
2215
+ const width = container?.clientWidth || window.innerWidth;
2216
+ const height = container?.clientHeight || window.innerHeight;
2217
+ this.camera.aspect = width / height;
1637
2218
  this.camera.updateProjectionMatrix();
1638
- this.renderer.setSize(window.innerWidth, window.innerHeight);
2219
+ this.renderer.setSize(width, height);
1639
2220
  this.dustField?.setViewportHeight(window.innerHeight);
1640
2221
  }
1641
2222
  onRightClick(event) {
@@ -1645,6 +2226,7 @@ class TraitVisualComponent {
1645
2226
  const intersects = this.raycaster.intersectObjects(this.cluster?.children || [], true);
1646
2227
  if (intersects.length > 0) {
1647
2228
  this.selectedNode = intersects[0].object.parent;
2229
+ this.blackholeSelected.emit(this.selectedNode);
1648
2230
  }
1649
2231
  }
1650
2232
  onMouseMove(event) {
@@ -1773,14 +2355,14 @@ class TraitVisualComponent {
1773
2355
  if (this.cluster)
1774
2356
  this.cluster.update(undefined, this.scene, this.camera);
1775
2357
  if (this.cluster) {
1776
- for (const node of this.cluster.nodes) {
1777
- const aura = this.nodeAuras.get(node);
2358
+ for (const bh of this.cluster.blackholes) {
2359
+ const aura = this.nodeAuras.get(bh);
1778
2360
  if (aura) {
1779
- const geom = node.mesh.geometry;
2361
+ const geom = bh.mesh.geometry;
1780
2362
  const bs = geom.boundingSphere ??
1781
2363
  new THREE.Sphere(new THREE.Vector3(), 0.05);
1782
- const newCoreRadius = (bs.radius || 0.05) * node.mesh.scale.x;
1783
- const ud = node.userData;
2364
+ const newCoreRadius = (bs.radius || 0.05) * bh.mesh.scale.x;
2365
+ const ud = bh.userData;
1784
2366
  const prevCoreRadius = ud._auraCoreRadius;
1785
2367
  const needsResize = !prevCoreRadius ||
1786
2368
  Math.abs(newCoreRadius - prevCoreRadius) / prevCoreRadius >
@@ -1794,22 +2376,32 @@ class TraitVisualComponent {
1794
2376
  }
1795
2377
  ud._auraCoreRadius = newCoreRadius;
1796
2378
  }
1797
- aura.update(node.position, node.velocity, deltaTime, this.camera);
2379
+ aura.update(bh.position, bh.velocity, deltaTime, this.camera);
1798
2380
  }
1799
2381
  }
1800
2382
  }
1801
- const center = this.currentCentral()?.position ?? new THREE.Vector3();
2383
+ const center = this.currentSupermassiveBlackhole()?.position ?? this._centerFallback;
1802
2384
  if (this.dustField && this.cluster) {
1803
- const atts = this.cluster.nodes
1804
- .filter((n) => !n.isSun)
1805
- .map((n) => {
2385
+ const blackholes = this.cluster.blackholes;
2386
+ const buf = this._attractorsBuffer;
2387
+ const maxAttractors = 24;
2388
+ while (buf.length < maxAttractors) {
2389
+ buf.push({ position: new THREE.Vector3(), radius: 0 });
2390
+ }
2391
+ let k = 0;
2392
+ for (let i = 0; i < blackholes.length && k < maxAttractors; i++) {
2393
+ const n = blackholes[i];
2394
+ if (n.isSupermassiveBlackhole)
2395
+ continue;
1806
2396
  const geom = n.mesh.geometry;
1807
2397
  const bs = geom.boundingSphere ??
1808
2398
  new THREE.Sphere(new THREE.Vector3(), 0.05);
1809
2399
  const radius = (bs.radius || 0.05) * n.mesh.scale.x;
1810
- return { position: n.position.clone(), radius };
1811
- });
1812
- this.dustField.setAttractorsWorld(atts);
2400
+ buf[k].position.copy(n.position);
2401
+ buf[k].radius = radius;
2402
+ k++;
2403
+ }
2404
+ this.dustField.setAttractorsWorld(buf, k);
1813
2405
  }
1814
2406
  this.dustField?.update(deltaTime, center);
1815
2407
  if (this.starFieldNear && this.starFieldFar) {
@@ -1824,20 +2416,20 @@ class TraitVisualComponent {
1824
2416
  ensureNodeAuras() {
1825
2417
  if (!this.cluster)
1826
2418
  return;
1827
- for (const node of this.cluster.nodes) {
1828
- if (!this.nodeAuras.has(node)) {
1829
- const geom = node.mesh.geometry;
2419
+ for (const bh of this.cluster.blackholes) {
2420
+ if (!this.nodeAuras.has(bh)) {
2421
+ const geom = bh.mesh.geometry;
1830
2422
  const bs = geom.boundingSphere ?? new THREE.Sphere(new THREE.Vector3(), 0.05);
1831
- const coreRadius = (bs.radius || 0.05) * node.mesh.scale.x;
1832
- const particleCount = node.isSun ? 900 : 200; // Increased from 600 to 900 for supermassive black hole effect
2423
+ const coreRadius = (bs.radius || 0.05) * bh.mesh.scale.x;
2424
+ const particleCount = bh.isSupermassiveBlackhole ? 900 : 200;
1833
2425
  const aura = new BlackHoleParticleField(this.scene, {
1834
2426
  coreRadius,
1835
2427
  particleCount,
1836
- palette: node.isSun ? 'sun' : 'planet',
2428
+ palette: bh.isSupermassiveBlackhole ? 'supermassiveBlackhole' : 'blackhole',
1837
2429
  distribution: 'surface',
1838
2430
  speedScale: 0.3,
1839
2431
  });
1840
- this.nodeAuras.set(node, aura);
2432
+ this.nodeAuras.set(bh, aura);
1841
2433
  const occGeom = new THREE.SphereGeometry(coreRadius * 0.99, 24, 24);
1842
2434
  const occMat = new THREE.MeshBasicMaterial({
1843
2435
  color: 0x000000,
@@ -1846,9 +2438,10 @@ class TraitVisualComponent {
1846
2438
  occMat.colorWrite = false;
1847
2439
  const occluder = new THREE.Mesh(occGeom, occMat);
1848
2440
  occluder.renderOrder = -0.5;
1849
- node.add(occluder);
1850
- node.userData._auraCoreRadius = coreRadius;
1851
- node.userData._occluder = occluder;
2441
+ bh.add(occluder);
2442
+ const ud = bh.userData;
2443
+ ud._auraCoreRadius = coreRadius;
2444
+ ud._occluder = occluder;
1852
2445
  }
1853
2446
  }
1854
2447
  }
@@ -1912,12 +2505,12 @@ class TraitVisualComponent {
1912
2505
  this.starTexture = texture;
1913
2506
  return texture;
1914
2507
  }
1915
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.11", ngImport: i0, type: TraitVisualComponent, deps: [{ token: i0.Renderer2 }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component });
1916
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.11", type: TraitVisualComponent, isStandalone: true, selector: "tv-trait-visual", inputs: { nodeData: "nodeData", attributeWeights: "attributeWeights", preferenceWeights: "preferenceWeights", attributeCount: "attributeCount", preferenceCount: "preferenceCount" }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["rendererCanvas"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"app-wrapper\">\n <canvas #rendererCanvas></canvas>\n</div>\n\n", styles: [".app-wrapper{display:flex;width:100vw;position:relative;height:100vh;background:#000}canvas{display:flex;width:100vw;height:100vh;overflow:hidden;position:relative;z-index:1;background:transparent!important}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] });
2508
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: TraitVisualComponent, deps: [{ token: i0.Renderer2 }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component });
2509
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: TraitVisualComponent, isStandalone: true, selector: "tv-trait-visual", inputs: { nodeData: "nodeData", attributeWeights: "attributeWeights", preferenceWeights: "preferenceWeights", attributeCount: "attributeCount", preferenceCount: "preferenceCount", simulationOptions: "simulationOptions", showContextMenu: "showContextMenu", defaultZoomLevel: "defaultZoomLevel" }, outputs: { blackholeSelected: "blackholeSelected" }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["rendererCanvas"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"app-wrapper\">\r\n <canvas #rendererCanvas></canvas>\r\n</div>\r\n\r\n", styles: [":host .app-wrapper{display:flex;width:100vw;position:relative;height:100vh;background:transparent}:host canvas{display:flex;width:100vw;height:100vh;overflow:hidden;position:relative;z-index:1;background:transparent}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "ngmodule", type: MatTooltipModule }] });
1917
2510
  }
1918
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.11", ngImport: i0, type: TraitVisualComponent, decorators: [{
2511
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: TraitVisualComponent, decorators: [{
1919
2512
  type: Component,
1920
- args: [{ selector: 'tv-trait-visual', standalone: true, imports: [CommonModule], template: "<div class=\"app-wrapper\">\n <canvas #rendererCanvas></canvas>\n</div>\n\n", styles: [".app-wrapper{display:flex;width:100vw;position:relative;height:100vh;background:#000}canvas{display:flex;width:100vw;height:100vh;overflow:hidden;position:relative;z-index:1;background:transparent!important}\n"] }]
2513
+ args: [{ selector: 'tv-trait-visual', standalone: true, imports: [CommonModule, MatIconModule, MatButtonModule, MatTooltipModule], template: "<div class=\"app-wrapper\">\r\n <canvas #rendererCanvas></canvas>\r\n</div>\r\n\r\n", styles: [":host .app-wrapper{display:flex;width:100vw;position:relative;height:100vh;background:transparent}:host canvas{display:flex;width:100vw;height:100vh;overflow:hidden;position:relative;z-index:1;background:transparent}\n"] }]
1921
2514
  }], ctorParameters: () => [{ type: i0.Renderer2 }, { type: i0.NgZone }], propDecorators: { canvasRef: [{
1922
2515
  type: ViewChild,
1923
2516
  args: ['rendererCanvas', { static: true }]
@@ -1931,84 +2524,2592 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.11", ngImpo
1931
2524
  type: Input
1932
2525
  }], preferenceCount: [{
1933
2526
  type: Input
2527
+ }], simulationOptions: [{
2528
+ type: Input
2529
+ }], showContextMenu: [{
2530
+ type: Input
2531
+ }], defaultZoomLevel: [{
2532
+ type: Input
2533
+ }], blackholeSelected: [{
2534
+ type: Output
1934
2535
  }] } });
1935
2536
 
1936
- // Dynamically resolve the PCA constructor for different build types
1937
- const PCAClass = PCA.default || PCA.PCA || PCA;
1938
- let dynamicCounts = {
1939
- attributes: 8,
1940
- preferences: 8
1941
- };
1942
- function updateCounts(attrCount, prefCount) {
1943
- dynamicCounts.attributes = attrCount;
1944
- dynamicCounts.preferences = prefCount;
2537
+ /** Ordered keys for attributes/preferences (attr1, attr2, ...). */
2538
+ function orderedKeys(record) {
2539
+ const keys = Object.keys(record || {});
2540
+ return keys.sort((a, b) => {
2541
+ const numA = parseInt(a.replace(/\D/g, ''), 10) || 0;
2542
+ const numB = parseInt(b.replace(/\D/g, ''), 10) || 0;
2543
+ return numA - numB;
2544
+ });
1945
2545
  }
1946
- /**
1947
- * Performs PCA on a list of attribute arrays to map them into 3D space.
1948
- * Returns:
1949
- * - positions: normalized [x, y, z] tuples
1950
- * - weights: per-attribute magnitude across PCA components
1951
- */
1952
- function computePCA3D(attributesList) {
1953
- const pca = new PCAClass(attributesList, { center: true, scale: true });
1954
- const reduced = pca.predict(attributesList, { nComponents: 3 }).to2DArray();
1955
- // --- Compute per-attribute weights ---
1956
- const loadings = pca.getLoadings().to2DArray();
1957
- const weights = loadings.map((arr) => Math.sqrt(arr[0] ** 2 + arr[1] ** 2 + arr[2] ** 2));
1958
- const maxAbs = Math.max(...reduced.flatMap((arr) => arr.map((v) => Math.abs(v))));
1959
- const positions = reduced.map((arr) => [
1960
- arr[0] / maxAbs,
1961
- arr[1] / maxAbs,
1962
- arr[2] / maxAbs
1963
- ]);
1964
- return { positions, weights };
2546
+ function toValuesArray(record) {
2547
+ return orderedKeys(record).map((k) => record[k] ?? 0);
1965
2548
  }
1966
- function generateAttributes(count, base = 0) {
1967
- const attrs = {};
1968
- for (let i = 0; i < count; i++)
1969
- attrs[`attr${i + 1}`] = (base + i * 20) % 101;
1970
- return attrs;
2549
+ class ConfigSidenavComponent {
2550
+ config;
2551
+ open = true;
2552
+ configChange = new EventEmitter();
2553
+ /** Active tab: 'docs' shows documentation, 'configs' shows configuration UI. */
2554
+ activeTab = 'configs';
2555
+ setActiveTab(tab) {
2556
+ this.activeTab = tab;
2557
+ }
2558
+ get centralNode() {
2559
+ return this.config?.nodeData?.find((n) => n.isSupermassiveBlackhole) ?? null;
2560
+ }
2561
+ get nonSupermassiveBlackholeNodes() {
2562
+ return this.config?.nodeData?.filter((n) => !n.isSupermassiveBlackhole) ?? [];
2563
+ }
2564
+ traitCount() {
2565
+ const central = this.centralNode;
2566
+ if (central?.preferences)
2567
+ return orderedKeys(central.preferences).length;
2568
+ const first = this.nonSupermassiveBlackholeNodes[0];
2569
+ if (first?.attributes)
2570
+ return orderedKeys(first.attributes).length;
2571
+ return 5;
2572
+ }
2573
+ centralPrefsArray() {
2574
+ const central = this.centralNode;
2575
+ return central ? toValuesArray(central.preferences) : [];
2576
+ }
2577
+ nodeAttrsArray(node) {
2578
+ return toValuesArray(node.attributes ?? {});
2579
+ }
2580
+ onPresetChange(value) {
2581
+ this.configChange.emit({ type: 'preset', selectedPreset: value });
2582
+ }
2583
+ onWeightChange(index, value) {
2584
+ const next = [...(this.config.attributeWeights ?? [])];
2585
+ while (next.length <= index)
2586
+ next.push(1);
2587
+ next[index] = value;
2588
+ this.configChange.emit({ type: 'weights', attributeWeights: next });
2589
+ }
2590
+ onPrefWeightChange(index, value) {
2591
+ const next = [...(this.config.preferenceWeights ?? [])];
2592
+ while (next.length <= index)
2593
+ next.push(1);
2594
+ next[index] = value;
2595
+ this.configChange.emit({ type: 'weights', preferenceWeights: next });
2596
+ }
2597
+ onSimulationSpeedChange(value) {
2598
+ this.configChange.emit({
2599
+ type: 'simulation',
2600
+ simulation: { ...this.config.simulation, simulationSpeed: value },
2601
+ });
2602
+ }
2603
+ onBlackholeRepulsionChange(value) {
2604
+ this.configChange.emit({
2605
+ type: 'simulation',
2606
+ simulation: {
2607
+ ...this.config.simulation,
2608
+ blackhole: { ...this.config.simulation?.blackhole, repulsion: value },
2609
+ },
2610
+ });
2611
+ }
2612
+ onTraitCountChange(count) {
2613
+ const c = Math.max(1, Math.min(20, Number(count) || this.traitCount()));
2614
+ this.configChange.emit({ type: 'setTraitCount', count: c });
2615
+ }
2616
+ onCentralPreferenceChange(index, value) {
2617
+ this.configChange.emit({ type: 'centralPreferenceChange', index, value });
2618
+ }
2619
+ onNodeAttributeChange(nodeId, attrIndex, value) {
2620
+ this.configChange.emit({ type: 'nodeAttributeChange', nodeId, attrIndex, value });
2621
+ }
2622
+ onAddNode() {
2623
+ this.configChange.emit({ type: 'addNode' });
2624
+ }
2625
+ onRemoveNode(nodeId) {
2626
+ this.configChange.emit({ type: 'removeNode', nodeId });
2627
+ }
2628
+ onShowContextMenuChange(value) {
2629
+ this.configChange.emit({ type: 'showContextMenu', value });
2630
+ }
2631
+ onDefaultZoomLevelChange(value) {
2632
+ const v = Math.max(0.5, Math.min(2.5, Number(value)));
2633
+ this.configChange.emit({ type: 'defaultZoomLevel', value: v });
2634
+ }
2635
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: ConfigSidenavComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2636
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.17", type: ConfigSidenavComponent, isStandalone: true, selector: "tv-config-sidenav", inputs: { config: "config", open: "open" }, outputs: { configChange: "configChange" }, ngImport: i0, template: "<aside class=\"sidebar scrollable-minimal\" [class.closed]=\"!open\">\r\n <div class=\"sidenav-tabs\">\r\n <button\r\n type=\"button\"\r\n class=\"sidenav-tab\"\r\n [class.active]=\"activeTab === 'docs'\"\r\n (click)=\"setActiveTab('docs')\"\r\n i18n\r\n >\r\n Docs\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"sidenav-tab\"\r\n [class.active]=\"activeTab === 'configs'\"\r\n (click)=\"setActiveTab('configs')\"\r\n i18n\r\n >\r\n Configs\r\n </button>\r\n </div>\r\n\r\n <div class=\"sidenav-tab-content\">\r\n @if (activeTab === 'docs') {\r\n <div class=\"docs-panel\">\r\n <h3 i18n>Inputs &amp; Outputs</h3>\r\n <p class=\"docs-lead\">tv-trait-visual component API:</p>\r\n <p class=\"docs-label\">Inputs</p>\r\n <ul class=\"docs-list\">\r\n <li><code>nodeData: INodeData[]</code> (required) \u2013 Array of node data to visualize.</li>\r\n <li><code>attributeWeights?: number[]</code> \u2013 Weights for attribute calculations.</li>\r\n <li><code>preferenceWeights?: number[]</code> \u2013 Weights for preference calculations.</li>\r\n <li><code>attributeCount?: number</code> \u2013 Number of attributes per node (optional).</li>\r\n <li><code>preferenceCount?: number</code> \u2013 Number of preferences per node (optional).</li>\r\n <li><code>simulationOptions?: Partial&lt;ISimulationConfigs&gt;</code> \u2013 Simulation options (optional).</li>\r\n <li><code>showContextMenu?: boolean</code> \u2013 When true, right-click on a node can show the context menu. Default false.</li>\r\n <li><code>defaultZoomLevel?: number</code> \u2013 Multiplier on initial camera distance (1 = normal, &gt; 1 = zoom out more). Default 1.25.</li>\r\n </ul>\r\n <p class=\"docs-label\">Outputs</p>\r\n <ul class=\"docs-list\">\r\n <li><code>blackholeSelected: Blackhole | null</code> \u2013 Emitted when the user selects a blackhole (e.g. right-click).</li>\r\n </ul>\r\n <p class=\"docs-label\">Usage</p>\r\n <pre class=\"docs-code\"><code>&lt;tv-trait-visual\r\n [nodeData]=\"nodeData\"\r\n [attributeWeights]=\"attributeWeights\"\r\n [preferenceWeights]=\"preferenceWeights\"\r\n [showContextMenu]=\"showContextMenu\"\r\n [defaultZoomLevel]=\"defaultZoomLevel\"\r\n&gt;&lt;/tv-trait-visual&gt;</code></pre>\r\n\r\n <h3 i18n>Quick Start</h3>\r\n <p class=\"docs-label\">1. Import the component</p>\r\n <pre class=\"docs-code\"><code>import &#123; TraitVisualComponent &#125; from '&#64;naniteninja/trait-visual';\r\n\r\n&#64;Component(&#123;\r\n selector: 'app-my-component',\r\n standalone: true,\r\n imports: [TraitVisualComponent],\r\n template: `\r\n &lt;tv-trait-visual\r\n [nodeData]=\"myNodeData\"\r\n [attributeWeights]=\"myAttributeWeights\"\r\n [preferenceWeights]=\"myPreferenceWeights\"\r\n [showContextMenu]=\"false\"\r\n [defaultZoomLevel]=\"1.25\"\r\n &gt;&lt;/tv-trait-visual&gt;\r\n `\r\n&#125;)\r\nexport class MyComponent &#123; &#125;</code></pre>\r\n <p class=\"docs-label\">2. Prepare your data</p>\r\n <pre class=\"docs-code\"><code>import &#123; INodeData &#125; from '&#64;naniteninja/trait-visual';\r\n\r\nconst myNodeData: INodeData[] = [\r\n &#123;\r\n id: 1,\r\n name: 'Central Node',\r\n initialPosition: [0, 0, 0],\r\n isSupermassiveBlackhole: true,\r\n color: '#C300FF',\r\n attributes: &#123; attr1: 50, attr2: 75, attr3: 25 &#125;,\r\n preferences: &#123; attr1: 100, attr2: 80, attr3: 60 &#125;\r\n &#125;,\r\n &#123;\r\n id: 2,\r\n name: 'Node 1',\r\n initialPosition: [5, 0, 0],\r\n isSupermassiveBlackhole: false,\r\n color: '#FF3366',\r\n attributes: &#123; attr1: 0, attr2: 50, attr3: 100 &#125;,\r\n preferences: &#123; attr1: 0, attr2: 0, attr3: 0 &#125;\r\n &#125;\r\n];\r\n\r\nconst myAttributeWeights = [1.0, 1.2, 0.8];\r\nconst myPreferenceWeights = [1.0, 1.0, 1.0];</code></pre>\r\n </div>\r\n }\r\n\r\n @if (activeTab === 'configs') {\r\n <div class=\"configs-panel\">\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Configuration Preset</h3>\r\n <mat-form-field appearance=\"outline\" style=\"width: 100%;\">\r\n <mat-label i18n>Select Preset</mat-label>\r\n <mat-select [value]=\"config.selectedPreset\" (selectionChange)=\"onPresetChange($event.value)\">\r\n <mat-option value=\"custom\" i18n>Custom (Manual)</mat-option>\r\n @for (preset of config.availablePresets; track preset.name) {\r\n <mat-option [value]=\"preset.name\">{{ preset.name }}</mat-option>\r\n }\r\n </mat-select>\r\n </mat-form-field>\r\n </div>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 24px;\">\r\n <h3 i18n>Options</h3>\r\n <label class=\"context-menu-checkbox\">\r\n <input\r\n type=\"checkbox\"\r\n [checked]=\"config.showContextMenu ?? false\"\r\n (change)=\"onShowContextMenuChange($any($event.target).checked)\"\r\n />\r\n <span i18n>Show context menu</span>\r\n </label>\r\n <p class=\"sidebar-hint\" i18n>When on, right-click a node to open its menu (set as central, remove, color).</p>\r\n <ul class=\"weights-list\" style=\"margin-top: 12px;\">\r\n <li>\r\n <label>\r\n <span i18n>Default zoom (1 = normal, higher = zoom out more)</span>\r\n <input\r\n type=\"range\"\r\n min=\"0.5\"\r\n max=\"2.5\"\r\n step=\"0.05\"\r\n [value]=\"config.defaultZoomLevel ?? 1.25\"\r\n (input)=\"onDefaultZoomLevelChange($any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ (config.defaultZoomLevel ?? 1.25) | number:'1.2-2' }}</span>\r\n </label>\r\n </li>\r\n </ul>\r\n </div>\r\n\r\n <ng-content select=\"[config-appearance]\"></ng-content>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Attribute Weights</h3>\r\n <ul class=\"weights-list\">\r\n @for (w of config.attributeWeights; track $index; let i = $index) {\r\n <li>\r\n <label>\r\n Attr {{ i + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n step=\"0.01\"\r\n [value]=\"w\"\r\n (input)=\"onWeightChange(i, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ w | number:'1.2-2' }}</span>\r\n </label>\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n <div class=\"sidebar-section\" style=\"margin-top: 20px;\">\r\n <h3 i18n>Preference Weights</h3>\r\n <ul class=\"weights-list\">\r\n @for (w of config.preferenceWeights; track $index; let i = $index) {\r\n <li>\r\n <label>\r\n Pref {{ i + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n step=\"0.01\"\r\n [value]=\"w\"\r\n (input)=\"onPrefWeightChange(i, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ w | number:'1.2-2' }}</span>\r\n </label>\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n\r\n @if (config.simulation != null) {\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Dissimilarity Repulsion</h3>\r\n <ul class=\"weights-list\">\r\n <li>\r\n <label>\r\n Blackhole repulsion:\r\n <input\r\n type=\"range\"\r\n min=\"0.2\"\r\n max=\"3\"\r\n step=\"0.1\"\r\n [value]=\"config.simulation.blackhole?.repulsion ?? 1\"\r\n (input)=\"onBlackholeRepulsionChange($any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ (config.simulation.blackhole?.repulsion ?? 1) | number:'1.1-1' }}</span>\r\n </label>\r\n </li>\r\n <li>\r\n <label>\r\n Simulation speed:\r\n <input\r\n type=\"range\"\r\n min=\"0.25\"\r\n max=\"4\"\r\n step=\"0.25\"\r\n [value]=\"config.simulation.simulationSpeed ?? 1\"\r\n (input)=\"onSimulationSpeedChange($any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ (config.simulation.simulationSpeed ?? 1) | number:'1.2-2' }}\u00D7</span>\r\n </label>\r\n </li>\r\n </ul>\r\n </div>\r\n }\r\n\r\n <ng-content select=\"[config-simulation-extra]\"></ng-content>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <div class=\"blackhole-head\">\r\n <h3 style=\"margin: 0;\" i18n>Supermassive Black Hole Preferences</h3>\r\n <button mat-icon-button i18n-matTooltip=\"Tooltip for add blackhole button\" matTooltip=\"Add Blackhole\" (click)=\"onAddNode()\">\r\n <mat-icon>add</mat-icon>\r\n </button>\r\n </div>\r\n <div class=\"attribute-list\" style=\"margin-bottom: 8px;\">\r\n <label>\r\n Trait Count:\r\n <input\r\n type=\"number\"\r\n min=\"1\"\r\n max=\"20\"\r\n [value]=\"traitCount()\"\r\n (change)=\"onTraitCountChange($any($event.target).valueAsNumber)\"\r\n />\r\n </label>\r\n </div>\r\n @if (centralNode) {\r\n @for (pref of centralPrefsArray(); track $index; let i = $index) {\r\n <div class=\"attribute-list\">\r\n <label>\r\n Preference {{ i + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n max=\"100\"\r\n [value]=\"pref\"\r\n (input)=\"onCentralPreferenceChange(i, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ pref }}</span>\r\n </label>\r\n </div>\r\n }\r\n }\r\n </div>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Blackhole Attributes</h3>\r\n @for (node of nonSupermassiveBlackholeNodes; track node.id) {\r\n <div class=\"blackhole-block\">\r\n <div class=\"blackhole-head\">\r\n <div class=\"blackhole-name\">{{ node.name }}</div>\r\n <button mat-icon-button i18n-matTooltip=\"Tooltip for remove blackhole button\" matTooltip=\"Remove Blackhole\" (click)=\"onRemoveNode(node.id)\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n @for (attr of nodeAttrsArray(node); track $index; let j = $index) {\r\n <div class=\"attribute-list\">\r\n <label>\r\n Attr {{ j + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n max=\"100\"\r\n [value]=\"attr\"\r\n (input)=\"onNodeAttributeChange(node.id, j, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ attr }}</span>\r\n </label>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n }\r\n </div>\r\n</aside>\r\n", styles: [".sidebar{position:absolute;top:0;left:0;height:100vh;width:320px;overflow-y:auto;padding:16px 16px 24px;background:#1a0f18;box-shadow:4px 0 12px #0006;z-index:100;transition:transform .2s ease}.sidebar.closed{transform:translate(-100%)}.sidebar h3{margin:8px 0 12px;color:#f6c;font-weight:600}.sidebar .blackhole-name{margin:12px 0 6px;color:#c300ff;font-weight:500}.sidebar .attribute-list label{display:flex;align-items:center;justify-content:space-between;gap:8px}.sidebar input[type=range]{flex:1;margin:0 8px}.sidebar input[type=number]{width:72px;margin:0 8px;padding:6px 8px;border-radius:8px;border:none;background:#231321;color:#f6c;box-shadow:inset 2px 2px 5px #0006,inset -2px -2px 5px #c300ff26}.blackhole-head{display:flex;align-items:center;justify-content:space-between}.blackhole-block{margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.08)}.weights-list{list-style:none;padding:0;margin:0}.weights-list li{margin-bottom:8px}.weights-list label{display:flex;align-items:center;gap:8px}.context-menu-checkbox{display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:4px}.context-menu-checkbox input[type=checkbox]{width:18px;height:18px}.sidebar-hint{margin:0 0 12px;font-size:12px;color:#ff66ccd9}.sidenav-tabs{display:flex;flex-direction:row;gap:0;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,.12)}.sidenav-tab{flex:1;padding:10px 16px;border:none;border-bottom:3px solid transparent;background:transparent;color:#f6cc;font-size:14px;font-weight:500;cursor:pointer;transition:color .2s ease,border-color .2s ease}.sidenav-tab:hover{color:#f6c}.sidenav-tab.active{color:#f6c;border-bottom-color:#c300ff}.docs-panel h3{margin:16px 0 10px;color:#f6c;font-weight:600}.docs-panel h3:first-child{margin-top:0}.docs-lead{margin:0 0 8px;font-size:13px;color:#ffffffe6}.docs-label{margin:12px 0 6px;font-size:12px;font-weight:600;color:#c300ff}.docs-list{margin:0 0 12px;padding-left:20px;font-size:13px;color:#ffffffe6;line-height:1.5}.docs-list li{margin-bottom:4px}.docs-list code{font-family:ui-monospace,monospace;font-size:12px;color:#f6c;background:#231321cc;padding:2px 6px;border-radius:4px}.docs-code{margin:8px 0 16px;padding:12px;overflow-x:auto;font-family:ui-monospace,monospace;font-size:11px;line-height:1.4;color:#f6c;background:#231321;border-radius:8px;border:1px solid rgba(195,0,255,.25);box-shadow:inset 2px 2px 6px #0000004d}.docs-code code{padding:0;background:none;color:inherit}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "pipe", type: i1.DecimalPipe, name: "number" }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i5.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i5.MatLabel, selector: "mat-label" }, { kind: "component", type: i5.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i5.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatFormFieldModule }] });
1971
2637
  }
1972
- const colors = ['#C300FF', '#FF3366'];
1973
- const baseNodes = [
1974
- {
1975
- id: 1,
1976
- name: 'James',
1977
- initialPosition: [0, 0, 0],
1978
- isSun: true,
1979
- color: '#C300FF',
1980
- attributes: generateAttributes(dynamicCounts.attributes, 0),
1981
- preferences: generateAttributes(dynamicCounts.preferences, 100),
1982
- },
1983
- ...[...Array(10)].map((_, i) => {
1984
- const id = i + 2;
1985
- const names = ['John', 'Alice', 'Robert', 'Emma', 'Michael', 'Sarah', 'David', 'Lisa', 'Chris', 'Maria'];
2638
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: ConfigSidenavComponent, decorators: [{
2639
+ type: Component,
2640
+ args: [{ selector: 'tv-config-sidenav', standalone: true, imports: [
2641
+ CommonModule,
2642
+ MatIconModule,
2643
+ MatButtonModule,
2644
+ MatTooltipModule,
2645
+ MatSelectModule,
2646
+ MatFormFieldModule,
2647
+ ], template: "<aside class=\"sidebar scrollable-minimal\" [class.closed]=\"!open\">\r\n <div class=\"sidenav-tabs\">\r\n <button\r\n type=\"button\"\r\n class=\"sidenav-tab\"\r\n [class.active]=\"activeTab === 'docs'\"\r\n (click)=\"setActiveTab('docs')\"\r\n i18n\r\n >\r\n Docs\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"sidenav-tab\"\r\n [class.active]=\"activeTab === 'configs'\"\r\n (click)=\"setActiveTab('configs')\"\r\n i18n\r\n >\r\n Configs\r\n </button>\r\n </div>\r\n\r\n <div class=\"sidenav-tab-content\">\r\n @if (activeTab === 'docs') {\r\n <div class=\"docs-panel\">\r\n <h3 i18n>Inputs &amp; Outputs</h3>\r\n <p class=\"docs-lead\">tv-trait-visual component API:</p>\r\n <p class=\"docs-label\">Inputs</p>\r\n <ul class=\"docs-list\">\r\n <li><code>nodeData: INodeData[]</code> (required) \u2013 Array of node data to visualize.</li>\r\n <li><code>attributeWeights?: number[]</code> \u2013 Weights for attribute calculations.</li>\r\n <li><code>preferenceWeights?: number[]</code> \u2013 Weights for preference calculations.</li>\r\n <li><code>attributeCount?: number</code> \u2013 Number of attributes per node (optional).</li>\r\n <li><code>preferenceCount?: number</code> \u2013 Number of preferences per node (optional).</li>\r\n <li><code>simulationOptions?: Partial&lt;ISimulationConfigs&gt;</code> \u2013 Simulation options (optional).</li>\r\n <li><code>showContextMenu?: boolean</code> \u2013 When true, right-click on a node can show the context menu. Default false.</li>\r\n <li><code>defaultZoomLevel?: number</code> \u2013 Multiplier on initial camera distance (1 = normal, &gt; 1 = zoom out more). Default 1.25.</li>\r\n </ul>\r\n <p class=\"docs-label\">Outputs</p>\r\n <ul class=\"docs-list\">\r\n <li><code>blackholeSelected: Blackhole | null</code> \u2013 Emitted when the user selects a blackhole (e.g. right-click).</li>\r\n </ul>\r\n <p class=\"docs-label\">Usage</p>\r\n <pre class=\"docs-code\"><code>&lt;tv-trait-visual\r\n [nodeData]=\"nodeData\"\r\n [attributeWeights]=\"attributeWeights\"\r\n [preferenceWeights]=\"preferenceWeights\"\r\n [showContextMenu]=\"showContextMenu\"\r\n [defaultZoomLevel]=\"defaultZoomLevel\"\r\n&gt;&lt;/tv-trait-visual&gt;</code></pre>\r\n\r\n <h3 i18n>Quick Start</h3>\r\n <p class=\"docs-label\">1. Import the component</p>\r\n <pre class=\"docs-code\"><code>import &#123; TraitVisualComponent &#125; from '&#64;naniteninja/trait-visual';\r\n\r\n&#64;Component(&#123;\r\n selector: 'app-my-component',\r\n standalone: true,\r\n imports: [TraitVisualComponent],\r\n template: `\r\n &lt;tv-trait-visual\r\n [nodeData]=\"myNodeData\"\r\n [attributeWeights]=\"myAttributeWeights\"\r\n [preferenceWeights]=\"myPreferenceWeights\"\r\n [showContextMenu]=\"false\"\r\n [defaultZoomLevel]=\"1.25\"\r\n &gt;&lt;/tv-trait-visual&gt;\r\n `\r\n&#125;)\r\nexport class MyComponent &#123; &#125;</code></pre>\r\n <p class=\"docs-label\">2. Prepare your data</p>\r\n <pre class=\"docs-code\"><code>import &#123; INodeData &#125; from '&#64;naniteninja/trait-visual';\r\n\r\nconst myNodeData: INodeData[] = [\r\n &#123;\r\n id: 1,\r\n name: 'Central Node',\r\n initialPosition: [0, 0, 0],\r\n isSupermassiveBlackhole: true,\r\n color: '#C300FF',\r\n attributes: &#123; attr1: 50, attr2: 75, attr3: 25 &#125;,\r\n preferences: &#123; attr1: 100, attr2: 80, attr3: 60 &#125;\r\n &#125;,\r\n &#123;\r\n id: 2,\r\n name: 'Node 1',\r\n initialPosition: [5, 0, 0],\r\n isSupermassiveBlackhole: false,\r\n color: '#FF3366',\r\n attributes: &#123; attr1: 0, attr2: 50, attr3: 100 &#125;,\r\n preferences: &#123; attr1: 0, attr2: 0, attr3: 0 &#125;\r\n &#125;\r\n];\r\n\r\nconst myAttributeWeights = [1.0, 1.2, 0.8];\r\nconst myPreferenceWeights = [1.0, 1.0, 1.0];</code></pre>\r\n </div>\r\n }\r\n\r\n @if (activeTab === 'configs') {\r\n <div class=\"configs-panel\">\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Configuration Preset</h3>\r\n <mat-form-field appearance=\"outline\" style=\"width: 100%;\">\r\n <mat-label i18n>Select Preset</mat-label>\r\n <mat-select [value]=\"config.selectedPreset\" (selectionChange)=\"onPresetChange($event.value)\">\r\n <mat-option value=\"custom\" i18n>Custom (Manual)</mat-option>\r\n @for (preset of config.availablePresets; track preset.name) {\r\n <mat-option [value]=\"preset.name\">{{ preset.name }}</mat-option>\r\n }\r\n </mat-select>\r\n </mat-form-field>\r\n </div>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 24px;\">\r\n <h3 i18n>Options</h3>\r\n <label class=\"context-menu-checkbox\">\r\n <input\r\n type=\"checkbox\"\r\n [checked]=\"config.showContextMenu ?? false\"\r\n (change)=\"onShowContextMenuChange($any($event.target).checked)\"\r\n />\r\n <span i18n>Show context menu</span>\r\n </label>\r\n <p class=\"sidebar-hint\" i18n>When on, right-click a node to open its menu (set as central, remove, color).</p>\r\n <ul class=\"weights-list\" style=\"margin-top: 12px;\">\r\n <li>\r\n <label>\r\n <span i18n>Default zoom (1 = normal, higher = zoom out more)</span>\r\n <input\r\n type=\"range\"\r\n min=\"0.5\"\r\n max=\"2.5\"\r\n step=\"0.05\"\r\n [value]=\"config.defaultZoomLevel ?? 1.25\"\r\n (input)=\"onDefaultZoomLevelChange($any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ (config.defaultZoomLevel ?? 1.25) | number:'1.2-2' }}</span>\r\n </label>\r\n </li>\r\n </ul>\r\n </div>\r\n\r\n <ng-content select=\"[config-appearance]\"></ng-content>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Attribute Weights</h3>\r\n <ul class=\"weights-list\">\r\n @for (w of config.attributeWeights; track $index; let i = $index) {\r\n <li>\r\n <label>\r\n Attr {{ i + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n step=\"0.01\"\r\n [value]=\"w\"\r\n (input)=\"onWeightChange(i, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ w | number:'1.2-2' }}</span>\r\n </label>\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n <div class=\"sidebar-section\" style=\"margin-top: 20px;\">\r\n <h3 i18n>Preference Weights</h3>\r\n <ul class=\"weights-list\">\r\n @for (w of config.preferenceWeights; track $index; let i = $index) {\r\n <li>\r\n <label>\r\n Pref {{ i + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n step=\"0.01\"\r\n [value]=\"w\"\r\n (input)=\"onPrefWeightChange(i, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ w | number:'1.2-2' }}</span>\r\n </label>\r\n </li>\r\n }\r\n </ul>\r\n </div>\r\n\r\n @if (config.simulation != null) {\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Dissimilarity Repulsion</h3>\r\n <ul class=\"weights-list\">\r\n <li>\r\n <label>\r\n Blackhole repulsion:\r\n <input\r\n type=\"range\"\r\n min=\"0.2\"\r\n max=\"3\"\r\n step=\"0.1\"\r\n [value]=\"config.simulation.blackhole?.repulsion ?? 1\"\r\n (input)=\"onBlackholeRepulsionChange($any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ (config.simulation.blackhole?.repulsion ?? 1) | number:'1.1-1' }}</span>\r\n </label>\r\n </li>\r\n <li>\r\n <label>\r\n Simulation speed:\r\n <input\r\n type=\"range\"\r\n min=\"0.25\"\r\n max=\"4\"\r\n step=\"0.25\"\r\n [value]=\"config.simulation.simulationSpeed ?? 1\"\r\n (input)=\"onSimulationSpeedChange($any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ (config.simulation.simulationSpeed ?? 1) | number:'1.2-2' }}\u00D7</span>\r\n </label>\r\n </li>\r\n </ul>\r\n </div>\r\n }\r\n\r\n <ng-content select=\"[config-simulation-extra]\"></ng-content>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <div class=\"blackhole-head\">\r\n <h3 style=\"margin: 0;\" i18n>Supermassive Black Hole Preferences</h3>\r\n <button mat-icon-button i18n-matTooltip=\"Tooltip for add blackhole button\" matTooltip=\"Add Blackhole\" (click)=\"onAddNode()\">\r\n <mat-icon>add</mat-icon>\r\n </button>\r\n </div>\r\n <div class=\"attribute-list\" style=\"margin-bottom: 8px;\">\r\n <label>\r\n Trait Count:\r\n <input\r\n type=\"number\"\r\n min=\"1\"\r\n max=\"20\"\r\n [value]=\"traitCount()\"\r\n (change)=\"onTraitCountChange($any($event.target).valueAsNumber)\"\r\n />\r\n </label>\r\n </div>\r\n @if (centralNode) {\r\n @for (pref of centralPrefsArray(); track $index; let i = $index) {\r\n <div class=\"attribute-list\">\r\n <label>\r\n Preference {{ i + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n max=\"100\"\r\n [value]=\"pref\"\r\n (input)=\"onCentralPreferenceChange(i, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ pref }}</span>\r\n </label>\r\n </div>\r\n }\r\n }\r\n </div>\r\n\r\n <div class=\"sidebar-section\" style=\"margin-top: 40px;\">\r\n <h3 i18n>Blackhole Attributes</h3>\r\n @for (node of nonSupermassiveBlackholeNodes; track node.id) {\r\n <div class=\"blackhole-block\">\r\n <div class=\"blackhole-head\">\r\n <div class=\"blackhole-name\">{{ node.name }}</div>\r\n <button mat-icon-button i18n-matTooltip=\"Tooltip for remove blackhole button\" matTooltip=\"Remove Blackhole\" (click)=\"onRemoveNode(node.id)\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n @for (attr of nodeAttrsArray(node); track $index; let j = $index) {\r\n <div class=\"attribute-list\">\r\n <label>\r\n Attr {{ j + 1 }}:\r\n <input\r\n type=\"number\"\r\n min=\"0\"\r\n max=\"100\"\r\n [value]=\"attr\"\r\n (input)=\"onNodeAttributeChange(node.id, j, $any($event.target).valueAsNumber)\"\r\n />\r\n <span>{{ attr }}</span>\r\n </label>\r\n </div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n }\r\n </div>\r\n</aside>\r\n", styles: [".sidebar{position:absolute;top:0;left:0;height:100vh;width:320px;overflow-y:auto;padding:16px 16px 24px;background:#1a0f18;box-shadow:4px 0 12px #0006;z-index:100;transition:transform .2s ease}.sidebar.closed{transform:translate(-100%)}.sidebar h3{margin:8px 0 12px;color:#f6c;font-weight:600}.sidebar .blackhole-name{margin:12px 0 6px;color:#c300ff;font-weight:500}.sidebar .attribute-list label{display:flex;align-items:center;justify-content:space-between;gap:8px}.sidebar input[type=range]{flex:1;margin:0 8px}.sidebar input[type=number]{width:72px;margin:0 8px;padding:6px 8px;border-radius:8px;border:none;background:#231321;color:#f6c;box-shadow:inset 2px 2px 5px #0006,inset -2px -2px 5px #c300ff26}.blackhole-head{display:flex;align-items:center;justify-content:space-between}.blackhole-block{margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.08)}.weights-list{list-style:none;padding:0;margin:0}.weights-list li{margin-bottom:8px}.weights-list label{display:flex;align-items:center;gap:8px}.context-menu-checkbox{display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:4px}.context-menu-checkbox input[type=checkbox]{width:18px;height:18px}.sidebar-hint{margin:0 0 12px;font-size:12px;color:#ff66ccd9}.sidenav-tabs{display:flex;flex-direction:row;gap:0;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,.12)}.sidenav-tab{flex:1;padding:10px 16px;border:none;border-bottom:3px solid transparent;background:transparent;color:#f6cc;font-size:14px;font-weight:500;cursor:pointer;transition:color .2s ease,border-color .2s ease}.sidenav-tab:hover{color:#f6c}.sidenav-tab.active{color:#f6c;border-bottom-color:#c300ff}.docs-panel h3{margin:16px 0 10px;color:#f6c;font-weight:600}.docs-panel h3:first-child{margin-top:0}.docs-lead{margin:0 0 8px;font-size:13px;color:#ffffffe6}.docs-label{margin:12px 0 6px;font-size:12px;font-weight:600;color:#c300ff}.docs-list{margin:0 0 12px;padding-left:20px;font-size:13px;color:#ffffffe6;line-height:1.5}.docs-list li{margin-bottom:4px}.docs-list code{font-family:ui-monospace,monospace;font-size:12px;color:#f6c;background:#231321cc;padding:2px 6px;border-radius:4px}.docs-code{margin:8px 0 16px;padding:12px;overflow-x:auto;font-family:ui-monospace,monospace;font-size:11px;line-height:1.4;color:#f6c;background:#231321;border-radius:8px;border:1px solid rgba(195,0,255,.25);box-shadow:inset 2px 2px 6px #0000004d}.docs-code code{padding:0;background:none;color:inherit}\n"] }]
2648
+ }], propDecorators: { config: [{
2649
+ type: Input
2650
+ }], open: [{
2651
+ type: Input
2652
+ }], configChange: [{
2653
+ type: Output
2654
+ }] } });
2655
+
2656
+ /**
2657
+ * StellarDustField
2658
+ *
2659
+ * A true 3D, GPU‑animated particle disk that orbits a given center (the supermassive black hole).
2660
+ * It uses two draw calls: a dense mid/far bed of tiny points and a sparse
2661
+ * foreground bokeh cohort of large, soft discs. Motion is computed fully on
2662
+ * the GPU (vertex shader) from per‑particle attributes; the CPU only updates
2663
+ * a few uniforms per frame.
2664
+ */
2665
+ class StellarDustField {
2666
+ scene;
2667
+ group; // positioned at the supermassive black hole; orientable disk
2668
+ points = null;
2669
+ // Node-attractor uniforms (shared across cohorts)
2670
+ MAX_ATTRACTORS = 24;
2671
+ MAX_PALETTE_STOPS = 12;
2672
+ // We store world-space attractors from the app; convert to local each update
2673
+ attractorWorldPos = [];
2674
+ attractorWorldRad = [];
2675
+ attractorLocalPos = []; // derived each frame
2676
+ attractorRadius = []; // derived each frame (same values)
2677
+ // Tunables for near-capture + stick approximation
2678
+ nearRadiusFactor = 8.0; // moderate capture zone relative to node radius
2679
+ // Phase 2: relative epsilon per node
2680
+ stickEpsRel = 0.12; // 12% of node radius
2681
+ stickEpsMin = 0.004; // world-units floor
2682
+ stickEpsMax = 0.02; // world-units cap
2683
+ attractStrength = 1.0; // strong pull to surface
2684
+ tangentialBias = 0.22; // slide, tapered near surface in shader
2685
+ debugDustTouch = false;
2686
+ lastDustLogMs = 0;
2687
+ // Phase 0: feature flag (disabled by default)
2688
+ useAttract = false;
2689
+ debugLogPalette = false;
2690
+ debugLogBands = true;
2691
+ debugEdgeMix = 0.3;
2692
+ // Debug: center brightness controls (off by default)
2693
+ debugDimLights = false;
2694
+ whitenScale = 1.0; // scales white mixes (rim/core highlights)
2695
+ alphaScale = 1.0; // global alpha scale to reduce additive blowout
2696
+ // Palette controls (phase 1)
2697
+ paletteStops = [];
2698
+ paletteHueBiasScale = 0.65;
2699
+ paletteBackgroundLift = 0.08;
2700
+ paletteCoreIntensity = 1.45;
2701
+ paletteUniformColors = [];
2702
+ paletteUniformStops = new Float32Array(this.MAX_PALETTE_STOPS);
2703
+ paletteUniformIntensities = new Float32Array(this.MAX_PALETTE_STOPS);
2704
+ paletteUniformCount = 0;
2705
+ // Disk/halo composition + arm shaping (disk mode)
2706
+ diskFraction = 0.62; // keep majority of particles in the thin disk
2707
+ diskBokehFraction = 0.7;
2708
+ haloRadiusMultiplier = 1.35; // extend halo slightly beyond outer radius
2709
+ armCount = 5;
2710
+ armPopulation = 0.68; // % of disk particles influenced by spiral arms
2711
+ armSpread = 0.42; // radians of jitter around each arm center
2712
+ armTwist = 4.6; // radians of twist across radius (log spiral feel)
2713
+ haloArmPopulation = 0.45;
2714
+ haloArmSpread = 0.75;
2715
+ haloArmTwist = 3.2;
2716
+ // Spherical mode parameters
2717
+ dustMode = 'disk';
2718
+ sphereJitter = 0.22; // controls local noise amplitude in sphere mode
2719
+ static DEFAULT_PALETTE = {
2720
+ stops: [
2721
+ { at: 0.0, color: '#f8fdff', intensity: 1.3 },
2722
+ { at: 0.12, color: '#67dbff', intensity: 1.05 },
2723
+ { at: 0.24, color: '#1c4cff', intensity: 0.92 },
2724
+ { at: 0.36, color: '#2c35f4', intensity: 0.9 },
2725
+ { at: 0.5, color: '#4321f2', intensity: 0.92 },
2726
+ { at: 0.64, color: '#6d23fa', intensity: 0.95 },
2727
+ { at: 0.76, color: '#a22eff', intensity: 0.96 },
2728
+ { at: 0.88, color: '#d641ff', intensity: 0.9 },
2729
+ { at: 1.0, color: '#ff63d9', intensity: 0.82 },
2730
+ ],
2731
+ hueBiasScale: 0.78,
2732
+ backgroundLift: 0.062,
2733
+ coreIntensity: 1.3,
2734
+ };
2735
+ opts = {
2736
+ innerRadius: 0.1,
2737
+ outerRadius: 6.0,
2738
+ thickness: 0.0,
2739
+ countMidFar: 24000,
2740
+ countBokeh: 1400,
2741
+ minAngularVel: 0.05,
2742
+ maxAngularVel: 0.18,
2743
+ noiseStrength: 0.06,
2744
+ colorInner: new THREE.Color('#f3fbff'),
2745
+ colorMid: new THREE.Color('#2f7aff'),
2746
+ colorOuter: new THREE.Color('#ff5dd6'),
2747
+ renderOrder: 0,
2748
+ };
2749
+ time = 0;
2750
+ viewportHeight = 800; // for size attenuation approx
2751
+ constructor(scene, options) {
2752
+ this.scene = scene;
2753
+ this.group = new THREE.Group();
2754
+ this.scene.add(this.group);
2755
+ this.applyPalette(StellarDustField.DEFAULT_PALETTE);
2756
+ if (options)
2757
+ this.applyOptions(options);
2758
+ else
2759
+ this.syncLegacyColorsFromPalette();
2760
+ // Optional flags via query string (fail-safe)
2761
+ try {
2762
+ const search = getWindowLocationSearch();
2763
+ // Attraction disabled by default; enable only when ?dustAttract=1
2764
+ this.useAttract = /(?:^|[?&])dustAttract=1(?:&|$)/.test(search);
2765
+ this.debugDustTouch = /(?:^|[?&])debugDust=(1|2)(?:&|$)/.test(search);
2766
+ // Optional tuning overrides
2767
+ const mNear = /(?:^|[?&])dustNear=([0-9.]+)(?:&|$)/.exec(search);
2768
+ if (mNear)
2769
+ this.nearRadiusFactor = Math.max(1, parseFloat(mNear[1]));
2770
+ const mRel = /(?:^|[?&])dustEpsRel=([0-9.]+)(?:&|$)/.exec(search);
2771
+ if (mRel)
2772
+ this.stickEpsRel = Math.max(0.001, parseFloat(mRel[1]));
2773
+ const mEpsMin = /(?:^|[?&])dustEpsMin=([0-9.]+)(?:&|$)/.exec(search);
2774
+ if (mEpsMin) {
2775
+ this.stickEpsMin = Math.max(0, parseFloat(mEpsMin[1]));
2776
+ // Keep invariant so clamping honours the override even without dustEpsMax.
2777
+ if (this.stickEpsMin > this.stickEpsMax) {
2778
+ this.stickEpsMax = this.stickEpsMin;
2779
+ }
2780
+ }
2781
+ const mEpsMax = /(?:^|[?&])dustEpsMax=([0-9.]+)(?:&|$)/.exec(search);
2782
+ if (mEpsMax)
2783
+ this.stickEpsMax = Math.max(this.stickEpsMin, parseFloat(mEpsMax[1]));
2784
+ const modeMatch = /(?:^|[?&])dustMode=(sphere|disk)(?:&|$)/.exec(search);
2785
+ if (modeMatch)
2786
+ this.dustMode =
2787
+ modeMatch[1] === 'sphere' ? 'disk' : modeMatch[1];
2788
+ const mJitter = /(?:^|[?&])dustJitter=([0-9.]+)(?:&|$)/.exec(search);
2789
+ if (mJitter)
2790
+ this.sphereJitter = Math.max(0, parseFloat(mJitter[1]));
2791
+ this.debugLogPalette = /(?:^|[?&])dustPaletteLog=1(?:&|$)/.test(search);
2792
+ const mBandLog = /(?:^|[?&])dustBandLog=(0|1)(?:&|$)/.exec(search);
2793
+ if (mBandLog) {
2794
+ this.debugLogBands = mBandLog[1] !== '0';
2795
+ }
2796
+ const mEdgeDebug = /(?:^|[?&])dustEdge(?:Debug)?=([0-9.]+)(?:&|$)/.exec(search);
2797
+ if (mEdgeDebug) {
2798
+ const mix = parseFloat(mEdgeDebug[1]);
2799
+ if (Number.isFinite(mix)) {
2800
+ this.debugEdgeMix = THREE.MathUtils.clamp(mix, 0, 1);
2801
+ }
2802
+ }
2803
+ // Brightness reduction toggles
2804
+ // Enable dimming with ?dustDim=1 (aliases: dustReduceLight=1, dustDimLights=1)
2805
+ this.debugDimLights =
2806
+ /(?:^|[?&])dustDim=1(?:&|$)/.test(search) ||
2807
+ /(?:^|[?&])dustReduceLight=1(?:&|$)/.test(search) ||
2808
+ /(?:^|[?&])dustDimLights=1(?:&|$)/.test(search);
2809
+ // Optional explicit scales
2810
+ const mWhiten = /(?:^|[?&])dustWhitenScale=([0-9.]+)(?:&|$)/.exec(search);
2811
+ const mAlpha = /(?:^|[?&])dustAlphaScale=([0-9.]+)(?:&|$)/.exec(search);
2812
+ if (mWhiten) {
2813
+ const v = parseFloat(mWhiten[1]);
2814
+ if (Number.isFinite(v))
2815
+ this.whitenScale = THREE.MathUtils.clamp(v, 0, 2);
2816
+ }
2817
+ if (mAlpha) {
2818
+ const v = parseFloat(mAlpha[1]);
2819
+ if (Number.isFinite(v))
2820
+ this.alphaScale = THREE.MathUtils.clamp(v, 0, 2);
2821
+ }
2822
+ if (this.debugDimLights) {
2823
+ // Conservative defaults if not explicitly set
2824
+ if (!mWhiten)
2825
+ this.whitenScale = 0.35;
2826
+ if (!mAlpha)
2827
+ this.alphaScale = 0.75;
2828
+ }
2829
+ // (Removed optional cyan-band and blending debug toggles)
2830
+ }
2831
+ catch { }
2832
+ this.build();
2833
+ if (this.debugLogPalette) {
2834
+ this.logPaletteRamp();
2835
+ }
2836
+ }
2837
+ // Public: Set overall dust brightness (scales particle alpha)
2838
+ setBrightnessScale(scale) {
2839
+ const v = THREE.MathUtils.clamp(scale, 0, 3);
2840
+ if (Math.abs(v - this.alphaScale) < 1e-6)
2841
+ return;
2842
+ this.alphaScale = v;
2843
+ this.updatePaletteUniformsOnMaterials();
2844
+ }
2845
+ // Public: Set strength of white rim/core mixes
2846
+ setWhitenScale(scale) {
2847
+ const v = THREE.MathUtils.clamp(scale, 0, 3);
2848
+ if (Math.abs(v - this.whitenScale) < 1e-6)
2849
+ return;
2850
+ this.whitenScale = v;
2851
+ this.updatePaletteUniformsOnMaterials();
2852
+ }
2853
+ // Public: Simple 0..1 brightness control
2854
+ // 0 => fully dim, 1 => current maximum
2855
+ setBrightness(value01) {
2856
+ const v = THREE.MathUtils.clamp(value01, 0, 1);
2857
+ // Scale overall alpha directly
2858
+ this.alphaScale = v;
2859
+ // Tie white highlight strength to the same factor so we don’t wash out
2860
+ this.whitenScale = v;
2861
+ this.updatePaletteUniformsOnMaterials();
2862
+ }
2863
+ // (Removed setCoreCyanScale helper)
2864
+ // Public API --------------------------------------------------------------
2865
+ applyOptions(o) {
2866
+ if (!o)
2867
+ return;
2868
+ const { palette, colorInner, colorMid, colorOuter, ...rest } = o;
2869
+ Object.assign(this.opts, rest);
2870
+ if (colorInner !== undefined)
2871
+ this.opts.colorInner.set(colorInner);
2872
+ if (colorMid !== undefined)
2873
+ this.opts.colorMid.set(colorMid);
2874
+ if (colorOuter !== undefined)
2875
+ this.opts.colorOuter.set(colorOuter);
2876
+ if (palette) {
2877
+ this.applyPalette(palette);
2878
+ }
2879
+ else if (colorInner !== undefined ||
2880
+ colorMid !== undefined ||
2881
+ colorOuter !== undefined) {
2882
+ this.applyPalette(this.buildPaletteFromLegacyColors());
2883
+ }
2884
+ else {
2885
+ this.syncLegacyColorsFromPalette();
2886
+ }
2887
+ }
2888
+ setMode(mode) {
2889
+ const resolved = mode === 'sphere' ? 'disk' : mode;
2890
+ if (resolved === this.dustMode)
2891
+ return;
2892
+ this.dustMode = resolved;
2893
+ if (this.points)
2894
+ this.build();
2895
+ }
2896
+ setSphereParameters(params) {
2897
+ let changed = false;
2898
+ if (params.jitter !== undefined) {
2899
+ const next = Math.max(0, params.jitter);
2900
+ if (Math.abs(next - this.sphereJitter) > 1e-5) {
2901
+ this.sphereJitter = next;
2902
+ changed = true;
2903
+ }
2904
+ }
2905
+ if (changed && this.points)
2906
+ this.build();
2907
+ }
2908
+ setDiskNormal(normal) {
2909
+ const n = normal.clone().normalize();
2910
+ // rotate local +Y to n
2911
+ const from = new THREE.Vector3(0, 0, 1);
2912
+ const q = new THREE.Quaternion().setFromUnitVectors(from, n);
2913
+ this.group.quaternion.copy(q);
2914
+ }
2915
+ setViewportHeight(h) {
2916
+ this.viewportHeight = Math.max(1, Math.floor(h));
2917
+ const cohorts = ['disk', 'diskBokeh', 'halo', 'haloBokeh'];
2918
+ for (const c of cohorts) {
2919
+ const pts = this.points?.[c];
2920
+ if (!pts)
2921
+ continue;
2922
+ const mat = pts.material;
2923
+ if (mat?.uniforms?.['uViewportH'] !== undefined) {
2924
+ mat.uniforms['uViewportH'].value = this.viewportHeight;
2925
+ mat.needsUpdate = true;
2926
+ }
2927
+ }
2928
+ }
2929
+ buildPaletteFromLegacyColors() {
2930
+ const legacyStops = [
2931
+ { at: 0.0, color: this.opts.colorInner, intensity: 1.35 },
2932
+ { at: 0.5, color: this.opts.colorMid, intensity: 1.0 },
2933
+ { at: 1.0, color: this.opts.colorOuter, intensity: 0.9 },
2934
+ ];
1986
2935
  return {
1987
- id,
1988
- name: names[i],
1989
- initialPosition: [0, 0, 0],
1990
- isSun: false,
1991
- color: colors[i % 2],
1992
- attributes: generateAttributes(dynamicCounts.attributes, i * 10),
1993
- preferences: generateAttributes(dynamicCounts.preferences, 0),
2936
+ stops: legacyStops,
2937
+ hueBiasScale: this.paletteHueBiasScale,
2938
+ backgroundLift: this.paletteBackgroundLift,
2939
+ coreIntensity: this.paletteCoreIntensity,
1994
2940
  };
1995
- }),
1996
- ];
1997
- const attributesList = baseNodes.map((n) => Object.values(n.attributes));
1998
- const { positions, weights } = computePCA3D(attributesList);
1999
- const nodeData = baseNodes.map((node, i) => {
2000
- if (node.isSun)
2001
- return { ...node, initialPosition: [0, 0, 0] };
2002
- const [x, y, z] = positions[i];
2003
- return {
2004
- ...node,
2005
- initialPosition: [x * 5, y * 5, z * 5],
2941
+ }
2942
+ applyPalette(palette) {
2943
+ const source = palette && palette.stops?.length
2944
+ ? {
2945
+ stops: palette.stops,
2946
+ hueBiasScale: palette.hueBiasScale ??
2947
+ StellarDustField.DEFAULT_PALETTE.hueBiasScale,
2948
+ backgroundLift: palette.backgroundLift ??
2949
+ StellarDustField.DEFAULT_PALETTE.backgroundLift,
2950
+ coreIntensity: palette.coreIntensity ??
2951
+ StellarDustField.DEFAULT_PALETTE.coreIntensity,
2952
+ }
2953
+ : StellarDustField.DEFAULT_PALETTE;
2954
+ const sorted = [...source.stops]
2955
+ .map((stop) => ({
2956
+ at: THREE.MathUtils.clamp(stop.at, 0, 1),
2957
+ color: new THREE.Color(stop.color),
2958
+ intensity: stop.intensity !== undefined ? stop.intensity : 1.0,
2959
+ }))
2960
+ .sort((a, b) => a.at - b.at);
2961
+ if (sorted.length === 0) {
2962
+ sorted.push({
2963
+ at: 0,
2964
+ color: new THREE.Color('#ffffff'),
2965
+ intensity: 1.0,
2966
+ });
2967
+ }
2968
+ if (sorted[0].at > 0) {
2969
+ const first = sorted[0];
2970
+ sorted.unshift({ ...first, at: 0 });
2971
+ }
2972
+ if (sorted[sorted.length - 1].at < 1) {
2973
+ const last = sorted[sorted.length - 1];
2974
+ sorted.push({ ...last, at: 1 });
2975
+ }
2976
+ this.paletteStops = sorted.slice(0, this.MAX_PALETTE_STOPS);
2977
+ this.paletteHueBiasScale =
2978
+ palette?.hueBiasScale ?? StellarDustField.DEFAULT_PALETTE.hueBiasScale;
2979
+ this.paletteBackgroundLift =
2980
+ palette?.backgroundLift ??
2981
+ StellarDustField.DEFAULT_PALETTE.backgroundLift;
2982
+ this.paletteCoreIntensity =
2983
+ palette?.coreIntensity ?? StellarDustField.DEFAULT_PALETTE.coreIntensity;
2984
+ this.refreshPaletteUniformData();
2985
+ this.updatePaletteUniformsOnMaterials();
2986
+ this.syncLegacyColorsFromPalette();
2987
+ if (this.debugLogPalette) {
2988
+ this.logPaletteRamp();
2989
+ }
2990
+ }
2991
+ syncLegacyColorsFromPalette() {
2992
+ if (!this.paletteStops.length)
2993
+ return;
2994
+ const first = this.paletteStops[0];
2995
+ const middle = this.paletteStops[Math.floor(this.paletteStops.length / 2)];
2996
+ const last = this.paletteStops[this.paletteStops.length - 1];
2997
+ this.opts.colorInner.set(first.color);
2998
+ this.opts.colorMid.set(middle.color);
2999
+ this.opts.colorOuter.set(last.color);
3000
+ }
3001
+ updatePaletteUniformsOnMaterials() {
3002
+ if (!this.points)
3003
+ return;
3004
+ const cohorts = ['disk', 'diskBokeh', 'halo', 'haloBokeh'];
3005
+ for (const c of cohorts) {
3006
+ const pts = this.points[c];
3007
+ if (!pts)
3008
+ continue;
3009
+ const mat = pts.material;
3010
+ const uniforms = mat?.uniforms;
3011
+ if (!uniforms)
3012
+ continue;
3013
+ if (uniforms['uPaletteCount'] !== undefined) {
3014
+ uniforms['uPaletteCount'].value = this.paletteUniformCount;
3015
+ }
3016
+ if (uniforms['uPaletteStops']?.value instanceof Float32Array) {
3017
+ uniforms['uPaletteStops'].value.set(this.paletteUniformStops);
3018
+ }
3019
+ if (uniforms['uPaletteIntensities']?.value instanceof Float32Array) {
3020
+ uniforms['uPaletteIntensities'].value.set(this.paletteUniformIntensities);
3021
+ }
3022
+ if (uniforms['uPaletteColors']) {
3023
+ uniforms['uPaletteColors'].value = this.paletteUniformColors;
3024
+ }
3025
+ if (uniforms['uHueBiasScale'] !== undefined) {
3026
+ uniforms['uHueBiasScale'].value = this.paletteHueBiasScale;
3027
+ }
3028
+ if (uniforms['uBackgroundLift'] !== undefined) {
3029
+ const bg = this.debugDimLights
3030
+ ? this.paletteBackgroundLift * 0.25
3031
+ : this.paletteBackgroundLift;
3032
+ uniforms['uBackgroundLift'].value = bg;
3033
+ }
3034
+ if (uniforms['uCoreIntensity'] !== undefined) {
3035
+ const core = this.debugDimLights
3036
+ ? this.paletteCoreIntensity * 0.8
3037
+ : this.paletteCoreIntensity;
3038
+ uniforms['uCoreIntensity'].value = core;
3039
+ }
3040
+ if (uniforms['uEdgeDebugMix'] !== undefined) {
3041
+ uniforms['uEdgeDebugMix'].value = this.debugEdgeMix;
3042
+ }
3043
+ if (uniforms['uWhitenScale'] !== undefined) {
3044
+ uniforms['uWhitenScale'].value = this.whitenScale;
3045
+ }
3046
+ if (uniforms['uAlphaScale'] !== undefined) {
3047
+ uniforms['uAlphaScale'].value = this.alphaScale;
3048
+ }
3049
+ mat.needsUpdate = true;
3050
+ }
3051
+ }
3052
+ samplePaletteAt(radialT) {
3053
+ const clamped = THREE.MathUtils.clamp(radialT, 0, 1);
3054
+ if (!this.paletteStops.length) {
3055
+ return { color: new THREE.Color(1, 1, 1), intensity: 1 };
3056
+ }
3057
+ let prev = this.paletteStops[0];
3058
+ if (clamped <= prev.at) {
3059
+ return { color: prev.color.clone(), intensity: prev.intensity };
3060
+ }
3061
+ for (let i = 1; i < this.paletteStops.length; i++) {
3062
+ const curr = this.paletteStops[i];
3063
+ if (clamped <= curr.at + 1e-6) {
3064
+ const span = Math.max(1e-5, curr.at - prev.at);
3065
+ const t = (clamped - prev.at) / span;
3066
+ const color = prev.color.clone().lerp(curr.color, t);
3067
+ const intensity = THREE.MathUtils.lerp(prev.intensity, curr.intensity, t);
3068
+ return { color, intensity };
3069
+ }
3070
+ prev = curr;
3071
+ }
3072
+ return {
3073
+ color: prev.color.clone(),
3074
+ intensity: prev.intensity,
3075
+ };
3076
+ }
3077
+ colorToHexString(color) {
3078
+ const safe = new THREE.Color(THREE.MathUtils.clamp(color.r, 0, 1), THREE.MathUtils.clamp(color.g, 0, 1), THREE.MathUtils.clamp(color.b, 0, 1));
3079
+ return `#${safe.getHexString().toUpperCase()}`;
3080
+ }
3081
+ logPaletteRamp(samples = 8) {
3082
+ const step = Math.max(1, samples);
3083
+ const rows = [];
3084
+ for (let i = 0; i < step; i++) {
3085
+ const t = step === 1 ? 0 : i / (step - 1);
3086
+ const { color, intensity } = this.samplePaletteAt(t);
3087
+ rows.push({
3088
+ radial: parseFloat(t.toFixed(2)),
3089
+ color: this.colorToHexString(color),
3090
+ intensity: parseFloat(intensity.toFixed(2)),
3091
+ });
3092
+ }
3093
+ // eslint-disable-next-line no-console
3094
+ console.table(rows);
3095
+ }
3096
+ logDiskBandStats(kind, total, stats) {
3097
+ if (!total)
3098
+ return;
3099
+ const rows = stats.map((band) => {
3100
+ const share = band.count / total;
3101
+ const avgT = band.count ? band.sumT / band.count : 0;
3102
+ const alphaAvg = band.count ? band.alphaSum / band.count : 0;
3103
+ const voidRate = band.count ? band.voidHits / band.count : 0;
3104
+ const innerAvg = band.count ? band.innerSum / band.count : 0;
3105
+ const outerAvg = band.count ? band.outerSum / band.count : 0;
3106
+ const minT = band.count
3107
+ ? parseFloat(band.minT.toFixed(3))
3108
+ : '–';
3109
+ const maxT = band.count
3110
+ ? parseFloat(band.maxT.toFixed(3))
3111
+ : '–';
3112
+ const radiusMin = band.count
3113
+ ? parseFloat(band.radiusMin.toFixed(3))
3114
+ : '–';
3115
+ const radiusMax = band.count
3116
+ ? parseFloat(band.radiusMax.toFixed(3))
3117
+ : '–';
3118
+ const innerAvgFmt = band.count
3119
+ ? parseFloat(innerAvg.toFixed(2))
3120
+ : '–';
3121
+ const outerAvgFmt = band.count
3122
+ ? parseFloat(outerAvg.toFixed(2))
3123
+ : '–';
3124
+ const innerMin = band.count
3125
+ ? parseFloat(band.innerMin.toFixed(2))
3126
+ : '–';
3127
+ const innerMax = band.count
3128
+ ? parseFloat(band.innerMax.toFixed(2))
3129
+ : '–';
3130
+ const outerMin = band.count
3131
+ ? parseFloat(band.outerMin.toFixed(2))
3132
+ : '–';
3133
+ const outerMax = band.count
3134
+ ? parseFloat(band.outerMax.toFixed(2))
3135
+ : '–';
3136
+ return {
3137
+ cohort: kind,
3138
+ band: band.id,
3139
+ share: parseFloat(share.toFixed(3)),
3140
+ avgT: parseFloat(avgT.toFixed(3)),
3141
+ minT,
3142
+ maxT,
3143
+ radiusMin,
3144
+ radiusMax,
3145
+ innerAvg: innerAvgFmt,
3146
+ outerAvg: outerAvgFmt,
3147
+ innerMin,
3148
+ innerMax,
3149
+ outerMin,
3150
+ outerMax,
3151
+ alphaAvg: parseFloat(alphaAvg.toFixed(3)),
3152
+ voidRate: parseFloat(voidRate.toFixed(3)),
3153
+ count: band.count,
3154
+ };
3155
+ });
3156
+ // eslint-disable-next-line no-console
3157
+ console.table(rows);
3158
+ }
3159
+ refreshPaletteUniformData() {
3160
+ if (!this.paletteUniformColors.length) {
3161
+ this.paletteUniformColors = Array.from({ length: this.MAX_PALETTE_STOPS }, () => new THREE.Vector3());
3162
+ }
3163
+ if (this.paletteUniformStops.length !== this.MAX_PALETTE_STOPS) {
3164
+ this.paletteUniformStops = new Float32Array(this.MAX_PALETTE_STOPS);
3165
+ }
3166
+ if (this.paletteUniformIntensities.length !== this.MAX_PALETTE_STOPS) {
3167
+ this.paletteUniformIntensities = new Float32Array(this.MAX_PALETTE_STOPS);
3168
+ }
3169
+ const count = Math.min(this.MAX_PALETTE_STOPS, this.paletteStops.length);
3170
+ for (let i = 0; i < count; i++) {
3171
+ const stop = this.paletteStops[i];
3172
+ const colorVec = this.paletteUniformColors[i] ?? new THREE.Vector3(stop.color.r, stop.color.g, stop.color.b);
3173
+ colorVec.set(stop.color.r, stop.color.g, stop.color.b);
3174
+ this.paletteUniformColors[i] = colorVec;
3175
+ this.paletteUniformStops[i] = stop.at;
3176
+ this.paletteUniformIntensities[i] = stop.intensity;
3177
+ }
3178
+ for (let i = count; i < this.MAX_PALETTE_STOPS; i++) {
3179
+ const fallbackColor = count > 0 ? this.paletteStops[count - 1].color : new THREE.Color(0, 0, 0);
3180
+ const colorVec = this.paletteUniformColors[i] ??
3181
+ new THREE.Vector3(fallbackColor.r, fallbackColor.g, fallbackColor.b);
3182
+ colorVec.set(fallbackColor.r, fallbackColor.g, fallbackColor.b);
3183
+ this.paletteUniformColors[i] = colorVec;
3184
+ this.paletteUniformStops[i] = 1.0;
3185
+ this.paletteUniformIntensities[i] = 1.0;
3186
+ }
3187
+ this.paletteUniformCount = count;
3188
+ }
3189
+ update(dt, center) {
3190
+ this.time += Math.max(0, dt);
3191
+ this.group.position.copy(center);
3192
+ // Slow global precession to avoid a static read (rotate around local Y)
3193
+ this.group.rotateZ(dt * 0.02);
3194
+ const cohorts = ['disk', 'diskBokeh', 'halo', 'haloBokeh'];
3195
+ for (const c of cohorts) {
3196
+ const pts = this.points?.[c];
3197
+ if (!pts)
3198
+ continue;
3199
+ const mat = pts.material;
3200
+ if (mat?.uniforms?.['uTime'] !== undefined) {
3201
+ mat.uniforms['uTime'].value = this.time;
3202
+ }
3203
+ if (mat?.uniforms?.['uMode'] !== undefined) {
3204
+ mat.uniforms['uMode'].value = this.dustMode === 'sphere' ? 1 : 0;
3205
+ }
3206
+ if (mat?.uniforms?.['uSphereJitter'] !== undefined) {
3207
+ mat.uniforms['uSphereJitter'].value = this.sphereJitter;
3208
+ }
3209
+ if (mat?.uniforms?.['uEdgeDebugMix'] !== undefined) {
3210
+ mat.uniforms['uEdgeDebugMix'].value = this.debugEdgeMix;
3211
+ }
3212
+ // Only drive attraction uniforms when the feature flag is enabled
3213
+ if (this.useAttract) {
3214
+ this.syncAttractorUniforms(mat);
3215
+ if (mat?.uniforms?.['uUseAttract'] !== undefined) {
3216
+ mat.uniforms['uUseAttract'].value = 1.0;
3217
+ }
3218
+ }
3219
+ else if (mat?.uniforms?.['uUseAttract'] !== undefined) {
3220
+ mat.uniforms['uUseAttract'].value = 0.0;
3221
+ }
3222
+ }
3223
+ // Lightweight contact sampling for logs (once/sec)
3224
+ if (this.debugDustTouch)
3225
+ this.sampleAndLogContacts();
3226
+ }
3227
+ dispose() {
3228
+ if (this.points) {
3229
+ const cohorts = ['disk', 'diskBokeh', 'halo', 'haloBokeh'];
3230
+ for (const c of cohorts) {
3231
+ const pts = this.points[c];
3232
+ if (!pts)
3233
+ continue;
3234
+ this.group.remove(pts);
3235
+ pts.geometry.dispose();
3236
+ pts.material.dispose();
3237
+ }
3238
+ }
3239
+ this.scene.remove(this.group);
3240
+ this.points = null;
3241
+ }
3242
+ // --- Attractors API -----------------------------------------------------
3243
+ /**
3244
+ * Provide world-space node centers and radii; internally converted to
3245
+ * dust-local coordinates and pushed to shader uniforms.
3246
+ * @param usedCount If set, only the first usedCount entries of attractors are read (allows reusing a buffer).
3247
+ */
3248
+ setAttractorsWorld(attractors, usedCount) {
3249
+ // Store world-space list; conversion to local happens inside update()
3250
+ const len = usedCount !== undefined ? usedCount : attractors.length;
3251
+ const n = Math.min(this.MAX_ATTRACTORS, len);
3252
+ if (this.attractorWorldPos.length !== this.MAX_ATTRACTORS) {
3253
+ this.attractorWorldPos = Array.from({ length: this.MAX_ATTRACTORS }, () => new THREE.Vector3());
3254
+ this.attractorWorldRad = new Array(this.MAX_ATTRACTORS).fill(0);
3255
+ this.attractorLocalPos = Array.from({ length: this.MAX_ATTRACTORS }, () => new THREE.Vector3());
3256
+ this.attractorRadius = new Array(this.MAX_ATTRACTORS).fill(0);
3257
+ }
3258
+ for (let i = 0; i < n; i++) {
3259
+ this.attractorWorldPos[i].copy(attractors[i].position);
3260
+ this.attractorWorldRad[i] = attractors[i].radius;
3261
+ }
3262
+ for (let i = n; i < this.MAX_ATTRACTORS; i++) {
3263
+ this.attractorWorldPos[i].set(9999, 9999, 9999);
3264
+ this.attractorWorldRad[i] = 0;
3265
+ }
3266
+ }
3267
+ // Phase 0: small helper to toggle attraction at runtime (optional)
3268
+ setAttractEnabled(enabled) {
3269
+ this.useAttract = !!enabled;
3270
+ }
3271
+ // Build ------------------------------------------------------------------
3272
+ build() {
3273
+ // Clean previous
3274
+ if (this.points)
3275
+ this.dispose();
3276
+ this.scene.add(this.group);
3277
+ const totalMid = Math.max(0, this.opts.countMidFar || 0);
3278
+ const totalBokeh = Math.max(0, this.opts.countBokeh || 0);
3279
+ this.points = {};
3280
+ if (this.dustMode === 'sphere') {
3281
+ console.warn('[StellarDustField] sphere mode disabled in 2D view; falling back to disk.');
3282
+ this.dustMode = 'disk';
3283
+ }
3284
+ const disk = this.makeCohort('disk', totalMid);
3285
+ const diskBokeh = this.makeCohort('diskBokeh', totalBokeh);
3286
+ if (disk)
3287
+ this.points.disk = disk;
3288
+ if (diskBokeh)
3289
+ this.points.diskBokeh = diskBokeh;
3290
+ }
3291
+ makeCohort(kind, count) {
3292
+ const isBokeh = kind === 'diskBokeh' || kind === 'haloBokeh';
3293
+ const isHalo = kind === 'halo' || kind === 'haloBokeh';
3294
+ if (count <= 0)
3295
+ return null;
3296
+ const g = new THREE.BufferGeometry();
3297
+ // We compute motion entirely from attributes; base positions are not used
3298
+ const base = new Float32Array(count * 3); // kept at 0s
3299
+ const aRadius = new Float32Array(count);
3300
+ const aTheta0 = new Float32Array(count);
3301
+ const aOmega = new Float32Array(count);
3302
+ const aLayer = new Float32Array(count);
3303
+ const aSize = new Float32Array(count);
3304
+ const aIntensity = new Float32Array(count);
3305
+ const aHue = new Float32Array(count);
3306
+ const aSeed = new Float32Array(count);
3307
+ const aAnchor = new Float32Array(count * 3);
3308
+ const aU = new Float32Array(count * 3); // per-particle plane U (for halo or disk default)
3309
+ const aV = new Float32Array(count * 3); // per-particle plane V
3310
+ const aIsHalo = new Float32Array(count);
3311
+ const aBandIndex = new Float32Array(count); // which band this particle belongs to
3312
+ const aEdgeParams = new Float32Array(count * 4);
3313
+ const inner = this.opts.innerRadius;
3314
+ const outer = this.opts.outerRadius;
3315
+ const halfThickness = Math.max(0, this.opts.thickness);
3316
+ const twoPi = Math.PI * 2;
3317
+ const diskBand = Math.max(outer - inner, 1e-5);
3318
+ const haloOuter = outer * this.haloRadiusMultiplier;
3319
+ const haloBand = Math.max(haloOuter - inner, 1e-5);
3320
+ const wrapAngle = (theta) => {
3321
+ const twopi = twoPi;
3322
+ theta %= twopi;
3323
+ return theta < 0 ? theta + twopi : theta;
3324
+ };
3325
+ const diskBands = [
3326
+ {
3327
+ id: 'core-void',
3328
+ start: 0,
3329
+ end: 0.07,
3330
+ z: 0.52,
3331
+ curve: 3.1,
3332
+ weight: 0.18,
3333
+ colorIntensity: 0.4,
3334
+ bias: 0.05,
3335
+ rim: {
3336
+ thicknessScale: 0.55,
3337
+ thicknessMin: 0.08,
3338
+ sigma: 0.01,
3339
+ inner: {
3340
+ amplitude: 0.25,
3341
+ frequency: 2.6,
3342
+ octaves: 3,
3343
+ lacunarity: 2.2,
3344
+ gain: 0.55,
3345
+ warpAmplitude: 0.3,
3346
+ warpFrequency: 0.9,
3347
+ },
3348
+ outer: {
3349
+ amplitude: 0.32,
3350
+ frequency: 3.0,
3351
+ octaves: 3,
3352
+ lacunarity: 2.1,
3353
+ gain: 0.58,
3354
+ warpAmplitude: 0.28,
3355
+ warpFrequency: 1.1,
3356
+ },
3357
+ },
3358
+ radialPockets: [
3359
+ { center: 0.06, width: 0.02, depth: 0.8, feather: 0.6 },
3360
+ ],
3361
+ angularPockets: [
3362
+ { angle: Math.PI * 0.55, width: 0.38, depth: 0.4, feather: 0.85 },
3363
+ ],
3364
+ alphaFloor: 0.04,
3365
+ },
3366
+ {
3367
+ id: 'inner-rim',
3368
+ start: 0.05,
3369
+ end: 0.28,
3370
+ z: 0.28,
3371
+ curve: 0.9,
3372
+ weight: 1.6,
3373
+ colorIntensity: 1.32,
3374
+ bias: 0.22,
3375
+ rim: {
3376
+ thicknessScale: 0.82,
3377
+ thicknessMin: 0.18,
3378
+ sigma: 0.018,
3379
+ inner: {
3380
+ amplitude: 0.85,
3381
+ frequency: 3.6,
3382
+ octaves: 4,
3383
+ lacunarity: 2.05,
3384
+ gain: 0.52,
3385
+ warpAmplitude: 0.48,
3386
+ warpFrequency: 1.25,
3387
+ },
3388
+ outer: {
3389
+ amplitude: 1.15,
3390
+ frequency: 3.9,
3391
+ octaves: 4,
3392
+ lacunarity: 2.2,
3393
+ gain: 0.55,
3394
+ warpAmplitude: 0.52,
3395
+ warpFrequency: 1.42,
3396
+ },
3397
+ },
3398
+ radialPockets: [
3399
+ { center: 0.16, width: 0.026, depth: 0.42, feather: 0.9 },
3400
+ ],
3401
+ angularPockets: [
3402
+ { angle: -Math.PI * 0.12, width: 0.34, depth: 0.34, feather: 0.9 },
3403
+ { angle: Math.PI * 0.62, width: 0.28, depth: 0.28, feather: 1.0 },
3404
+ ],
3405
+ alphaNoise: 0.1,
3406
+ alphaFloor: 0.32,
3407
+ },
3408
+ {
3409
+ id: 'mid-shelf',
3410
+ start: 0.24,
3411
+ end: 0.6,
3412
+ z: 0.02,
3413
+ curve: 1.05,
3414
+ weight: 1.42,
3415
+ colorIntensity: 1.78,
3416
+ bias: 0.08,
3417
+ rim: {
3418
+ thicknessScale: 0.95,
3419
+ thicknessMin: 0.16,
3420
+ sigma: 0.024,
3421
+ inner: {
3422
+ amplitude: 0.95,
3423
+ frequency: 2.8,
3424
+ octaves: 4,
3425
+ lacunarity: 2.18,
3426
+ gain: 0.6,
3427
+ warpAmplitude: 0.54,
3428
+ warpFrequency: 1.05,
3429
+ },
3430
+ outer: {
3431
+ amplitude: 1.4,
3432
+ frequency: 3.4,
3433
+ octaves: 4,
3434
+ lacunarity: 2.25,
3435
+ gain: 0.58,
3436
+ warpAmplitude: 0.62,
3437
+ warpFrequency: 1.32,
3438
+ },
3439
+ },
3440
+ radialPockets: [
3441
+ { center: 0.42, width: 0.03, depth: 0.28, feather: 1.0 },
3442
+ { center: 0.55, width: 0.04, depth: 0.22, feather: 1.1 },
3443
+ ],
3444
+ angularPockets: [
3445
+ { angle: Math.PI * 1.18, width: 0.38, depth: 0.36, feather: 0.8 },
3446
+ { angle: Math.PI * 0.28, width: 0.3, depth: 0.32, feather: 1.0 },
3447
+ { angle: -Math.PI * 0.2, width: 0.26, depth: 0.36, feather: 1.1 },
3448
+ ],
3449
+ alphaNoise: 0.18,
3450
+ alphaFloor: 0.18,
3451
+ },
3452
+ {
3453
+ id: 'outer-rim',
3454
+ start: 0.56,
3455
+ end: 0.9,
3456
+ z: -0.18,
3457
+ curve: 1.2,
3458
+ weight: 1.05,
3459
+ colorIntensity: 1.95,
3460
+ bias: 0.05,
3461
+ rim: {
3462
+ thicknessScale: 1.08,
3463
+ thicknessMin: 0.18,
3464
+ sigma: 0.026,
3465
+ inner: {
3466
+ amplitude: 1.05,
3467
+ frequency: 2.6,
3468
+ octaves: 4,
3469
+ lacunarity: 2.15,
3470
+ gain: 0.62,
3471
+ warpAmplitude: 0.68,
3472
+ warpFrequency: 1.08,
3473
+ },
3474
+ outer: {
3475
+ amplitude: 1.55,
3476
+ frequency: 3.1,
3477
+ octaves: 4,
3478
+ lacunarity: 2.32,
3479
+ gain: 0.6,
3480
+ warpAmplitude: 0.74,
3481
+ warpFrequency: 1.24,
3482
+ },
3483
+ },
3484
+ radialPockets: [
3485
+ { center: 0.7, width: 0.045, depth: 0.35, feather: 0.95 },
3486
+ { center: 0.82, width: 0.05, depth: 0.28, feather: 1.05 },
3487
+ ],
3488
+ angularPockets: [
3489
+ { angle: Math.PI * 0.64, width: 0.4, depth: 0.34, feather: 0.95 },
3490
+ { angle: -Math.PI * 0.52, width: 0.48, depth: 0.28, feather: 1.15 },
3491
+ ],
3492
+ alphaNoise: 0.2,
3493
+ alphaFloor: 0.14,
3494
+ },
3495
+ {
3496
+ id: 'outer-halo',
3497
+ start: 0.82,
3498
+ end: 1.0,
3499
+ z: -0.62,
3500
+ curve: 1.65,
3501
+ weight: 0.75,
3502
+ colorIntensity: 2.92,
3503
+ angularPockets: [
3504
+ { angle: Math.PI * 0.58, width: 0.45, depth: 0.36, feather: 1.05 },
3505
+ { angle: -Math.PI * 0.05, width: 0.32, depth: 0.32, feather: 1.15 },
3506
+ { angle: Math.PI * 1.1, width: 0.88, depth: 0.26, feather: 1.2 },
3507
+ ],
3508
+ radialPockets: [
3509
+ { center: 0.88, width: 0.05, depth: 0.38, feather: 0.8 },
3510
+ { center: 0.96, width: 0.04, depth: 0.35, feather: 0.9 },
3511
+ ],
3512
+ alphaNoise: 0.22,
3513
+ rim: {
3514
+ thicknessScale: 1.15,
3515
+ thicknessMin: 0.12,
3516
+ sigma: 0.032,
3517
+ inner: {
3518
+ amplitude: 1.2,
3519
+ frequency: 2.4,
3520
+ octaves: 4,
3521
+ lacunarity: 2.1,
3522
+ gain: 0.6,
3523
+ warpAmplitude: 0.72,
3524
+ warpFrequency: 0.95,
3525
+ },
3526
+ outer: {
3527
+ amplitude: 1.8,
3528
+ frequency: 2.9,
3529
+ octaves: 4,
3530
+ lacunarity: 2.28,
3531
+ gain: 0.62,
3532
+ warpAmplitude: 0.78,
3533
+ warpFrequency: 1.12,
3534
+ },
3535
+ },
3536
+ },
3537
+ ];
3538
+ const diskWeightTotal = diskBands.reduce((sum, band) => sum + band.weight, 0);
3539
+ const diskWeightSafe = diskWeightTotal > 0 ? diskWeightTotal : diskBands.length;
3540
+ const defaultRimResolution = 720;
3541
+ const bandProfiles = diskBands.map((band, bandIndex) => {
3542
+ const baseSpan = Math.max(band.end - band.start, 1e-4);
3543
+ const rim = band.rim ??
3544
+ {
3545
+ thicknessScale: 1.0,
3546
+ thicknessMin: 0.2,
3547
+ sigma: baseSpan * 0.2,
3548
+ inner: {
3549
+ amplitude: 0.65,
3550
+ frequency: 3.2,
3551
+ octaves: 4,
3552
+ lacunarity: 2.05,
3553
+ gain: 0.58,
3554
+ warpAmplitude: 0.45,
3555
+ warpFrequency: 1.05,
3556
+ },
3557
+ outer: {
3558
+ amplitude: 0.95,
3559
+ frequency: 3.6,
3560
+ octaves: 4,
3561
+ lacunarity: 2.18,
3562
+ gain: 0.58,
3563
+ warpAmplitude: 0.5,
3564
+ warpFrequency: 1.2,
3565
+ },
3566
+ resolution: defaultRimResolution,
3567
+ };
3568
+ const resolution = Math.max(64, Math.floor(rim.resolution ?? defaultRimResolution));
3569
+ const innerSamples = new Float32Array(resolution);
3570
+ const outerSamples = new Float32Array(resolution);
3571
+ const midSamples = new Float32Array(resolution);
3572
+ const halfSamples = new Float32Array(resolution);
3573
+ const baseCenter = (band.start + band.end) * 0.5;
3574
+ const baseHalfThickness = Math.max(baseSpan * 0.5, 1e-4) *
3575
+ THREE.MathUtils.clamp(rim.thicknessScale ?? 1, 0.2, 3.0);
3576
+ const minThickness = Math.max(baseSpan * THREE.MathUtils.clamp(rim.thicknessMin ?? 0.18, 0.02, 0.9), 1e-4);
3577
+ const sigma = rim.sigma !== undefined
3578
+ ? Math.max(rim.sigma, 1e-4)
3579
+ : Math.max(baseSpan * 0.18, 0.01);
3580
+ let minInner = Number.POSITIVE_INFINITY;
3581
+ let maxOuter = Number.NEGATIVE_INFINITY;
3582
+ for (let s = 0; s < resolution; s++) {
3583
+ const theta = (s / resolution) * twoPi;
3584
+ const innerNoise = this.samplePeriodicFbm(theta, rim.inner, bandIndex, 137 + bandIndex * 19);
3585
+ const outerNoise = this.samplePeriodicFbm(theta, rim.outer, bandIndex, 211 + bandIndex * 23);
3586
+ let inner = baseCenter -
3587
+ baseHalfThickness +
3588
+ innerNoise * baseHalfThickness;
3589
+ let outer = baseCenter +
3590
+ baseHalfThickness +
3591
+ outerNoise * baseHalfThickness;
3592
+ if (!isFinite(inner))
3593
+ inner = baseCenter - baseHalfThickness;
3594
+ if (!isFinite(outer))
3595
+ outer = baseCenter + baseHalfThickness;
3596
+ let thickness = outer - inner;
3597
+ if (thickness < minThickness) {
3598
+ const correction = (minThickness - thickness) * 0.5;
3599
+ inner -= correction;
3600
+ outer += correction;
3601
+ thickness = outer - inner;
3602
+ }
3603
+ const clampMin = Math.max(0, band.start - baseSpan * 0.4);
3604
+ const clampMax = Math.min(1, band.end + baseSpan * 0.6);
3605
+ inner = THREE.MathUtils.clamp(inner, clampMin, clampMax);
3606
+ outer = THREE.MathUtils.clamp(outer, clampMin, clampMax);
3607
+ if (outer <= inner) {
3608
+ outer = inner + Math.max(minThickness, 1e-4);
3609
+ }
3610
+ innerSamples[s] = inner;
3611
+ outerSamples[s] = outer;
3612
+ const mid = (inner + outer) * 0.5;
3613
+ const half = Math.max((outer - inner) * 0.5, minThickness * 0.5);
3614
+ midSamples[s] = mid;
3615
+ halfSamples[s] = half;
3616
+ minInner = Math.min(minInner, inner);
3617
+ maxOuter = Math.max(maxOuter, outer);
3618
+ }
3619
+ return {
3620
+ inner: innerSamples,
3621
+ outer: outerSamples,
3622
+ mid: midSamples,
3623
+ halfThickness: halfSamples,
3624
+ sigma,
3625
+ resolution,
3626
+ step: twoPi / resolution,
3627
+ minInner,
3628
+ maxOuter,
3629
+ };
3630
+ });
3631
+ let globalMinInnerRatio = Number.POSITIVE_INFINITY;
3632
+ let globalMaxOuterRatio = Number.NEGATIVE_INFINITY;
3633
+ for (const profile of bandProfiles) {
3634
+ if (!profile)
3635
+ continue;
3636
+ if (isFinite(profile.minInner)) {
3637
+ globalMinInnerRatio = Math.min(globalMinInnerRatio, profile.minInner);
3638
+ }
3639
+ if (isFinite(profile.maxOuter)) {
3640
+ globalMaxOuterRatio = Math.max(globalMaxOuterRatio, profile.maxOuter);
3641
+ }
3642
+ }
3643
+ if (!isFinite(globalMinInnerRatio)) {
3644
+ globalMinInnerRatio = diskBands.length ? diskBands[0].start : 0;
3645
+ }
3646
+ if (!isFinite(globalMaxOuterRatio) || globalMaxOuterRatio <= 0) {
3647
+ globalMaxOuterRatio = diskBands.length ? diskBands[diskBands.length - 1].end : 1;
3648
+ }
3649
+ const diskInnerEnvelope = inner + diskBand * globalMinInnerRatio;
3650
+ const diskOuterEnvelope = inner + diskBand * globalMaxOuterRatio;
3651
+ const selectDiskBandIndex = (idx) => {
3652
+ let pick = rand(idx, 0) * diskWeightSafe;
3653
+ for (let b = 0; b < diskBands.length; b++) {
3654
+ pick -= diskBands[b].weight;
3655
+ if (pick <= 0)
3656
+ return b;
3657
+ }
3658
+ return diskBands.length - 1;
3659
+ };
3660
+ const sampleGaussian = (idxSeed, salt) => {
3661
+ const u1 = Math.max(1e-6, rand(idxSeed, 700 + salt));
3662
+ const u2 = Math.max(1e-6, rand(idxSeed, 701 + salt));
3663
+ const mag = Math.sqrt(-2 * Math.log(u1));
3664
+ const angle = twoPi * u2;
3665
+ return mag * Math.cos(angle);
3666
+ };
3667
+ const lerpProfile = (arr, samplePos, resolution) => {
3668
+ if (!resolution)
3669
+ return arr[0] ?? 0;
3670
+ const wrapped = ((samplePos % resolution) + resolution) % resolution;
3671
+ const idx0 = Math.floor(wrapped);
3672
+ const idx1 = (idx0 + 1) % resolution;
3673
+ const frac = wrapped - idx0;
3674
+ const v0 = arr[idx0] ?? arr[0] ?? 0;
3675
+ const v1 = arr[idx1] ?? v0;
3676
+ return v0 + (v1 - v0) * frac;
3677
+ };
3678
+ const fetchRimSample = (bandIndex, theta) => {
3679
+ const profile = bandProfiles[bandIndex];
3680
+ if (!profile) {
3681
+ const band = diskBands[bandIndex];
3682
+ const start = band?.start ?? 0;
3683
+ const end = band?.end ?? 1;
3684
+ const mid = (start + end) * 0.5;
3685
+ const half = Math.max((end - start) * 0.5, 1e-4);
3686
+ return {
3687
+ innerRatio: mid - half,
3688
+ outerRatio: mid + half,
3689
+ midRatio: mid,
3690
+ halfRatio: half,
3691
+ sigmaRatio: Math.max(half * 0.35, 1e-4),
3692
+ };
3693
+ }
3694
+ const norm = wrapAngle(theta) / twoPi;
3695
+ const samplePos = norm * profile.resolution;
3696
+ const innerRatio = lerpProfile(profile.inner, samplePos, profile.resolution);
3697
+ const outerRatio = lerpProfile(profile.outer, samplePos, profile.resolution);
3698
+ const midRatio = lerpProfile(profile.mid, samplePos, profile.resolution);
3699
+ const halfRatio = lerpProfile(profile.halfThickness, samplePos, profile.resolution);
3700
+ return {
3701
+ innerRatio,
3702
+ outerRatio,
3703
+ midRatio,
3704
+ halfRatio,
3705
+ sigmaRatio: profile.sigma,
3706
+ };
3707
+ };
3708
+ const resolveBandShell = (bandIndex, theta) => {
3709
+ const rim = fetchRimSample(bandIndex, theta);
3710
+ const sigmaRadius = Math.max(rim.sigmaRatio * diskBand, 1e-4);
3711
+ const halfRadius = Math.max(rim.halfRatio * diskBand, sigmaRadius * 1.6);
3712
+ const innerRadius = inner + diskBand * rim.innerRatio;
3713
+ const outerRadius = inner + diskBand * rim.outerRatio;
3714
+ const midRadius = inner + diskBand * rim.midRatio;
3715
+ const span = Math.max(outerRadius - innerRadius, 1e-4);
3716
+ const innerSoft = Math.max(sigmaRadius * 1.65, span * 0.48, diskBand * 0.022);
3717
+ const outerSoft = Math.max(sigmaRadius * 1.5, span * 0.44, diskBand * 0.028);
3718
+ const innerBleed = Math.max(innerSoft * 1.35, diskBand * 0.05);
3719
+ const outerBleed = Math.max(outerSoft * 1.35, diskBand * 0.055);
3720
+ const supportInner = Math.max(inner + diskBand * 0.0005, innerRadius - innerBleed);
3721
+ const supportOuter = Math.min(haloOuter * 1.08, outerRadius + outerBleed);
3722
+ return {
3723
+ sigmaRadius,
3724
+ halfRadius,
3725
+ innerRadius,
3726
+ outerRadius,
3727
+ midRadius,
3728
+ innerSoft,
3729
+ outerSoft,
3730
+ supportInner,
3731
+ supportOuter,
3732
+ };
3733
+ };
3734
+ const applySpiralArms = (baseTheta, radialTForArm, idxSeed, isHaloParticle) => {
3735
+ let theta = baseTheta;
3736
+ if (!isHaloParticle) {
3737
+ if (this.armCount > 0 && rand(idxSeed, 23) < this.armPopulation) {
3738
+ const armIdx = Math.floor(rand(idxSeed, 24) * this.armCount);
3739
+ const armBase = (armIdx / this.armCount) * twoPi;
3740
+ const twist = radialTForArm * this.armTwist;
3741
+ const jitter = (rand(idxSeed, 25) - 0.5) * this.armSpread;
3742
+ const armTheta = armBase + twist + jitter;
3743
+ theta = wrapAngle(THREE.MathUtils.lerp(theta, armTheta, 0.65));
3744
+ }
3745
+ }
3746
+ else {
3747
+ if (this.armCount > 0 && rand(idxSeed, 26) < this.haloArmPopulation) {
3748
+ const armIdx = Math.floor(rand(idxSeed, 27) * this.armCount);
3749
+ const haloTwist = THREE.MathUtils.clamp(radialTForArm, 0, 1) * this.haloArmTwist;
3750
+ const jitter = (rand(idxSeed, 28) - 0.5) * this.haloArmSpread;
3751
+ const armTheta = (armIdx / this.armCount) * twoPi + haloTwist + jitter;
3752
+ theta = wrapAngle(THREE.MathUtils.lerp(theta, armTheta, 0.55));
3753
+ }
3754
+ }
3755
+ return theta;
3756
+ };
3757
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
3758
+ const kindSalt = kind === 'disk'
3759
+ ? 11
3760
+ : kind === 'diskBokeh'
3761
+ ? 37
3762
+ : kind === 'halo'
3763
+ ? 73
3764
+ : 109;
3765
+ const rand = (idx, salt = 0) => this.hashFloat(idx + 1, kindSalt + salt);
3766
+ const evaluateRadialMask = (ratio, band) => {
3767
+ if (!band.radialPockets?.length)
3768
+ return 1.0;
3769
+ let mask = 1.0;
3770
+ for (let p = 0; p < band.radialPockets.length; p++) {
3771
+ const pocket = band.radialPockets[p];
3772
+ const width = Math.max(1e-4, pocket.width);
3773
+ const center = THREE.MathUtils.clamp(pocket.center, band.start + width * 0.35, band.end - width * 0.35);
3774
+ const dist = Math.abs(ratio - center) / (width * 1.1);
3775
+ const feather = pocket.feather ?? 1.0;
3776
+ const falloff = Math.exp(-Math.pow(dist, feather * 1.2) * 2.4);
3777
+ const depth = THREE.MathUtils.clamp(pocket.depth, 0, 1);
3778
+ mask = Math.min(mask, 1 - falloff * depth);
3779
+ }
3780
+ return THREE.MathUtils.clamp(mask, 0.05, 1.0);
3781
+ };
3782
+ const shortestAngularDistance = (a, b) => {
3783
+ let diff = a - b;
3784
+ diff = ((diff + Math.PI) % (Math.PI * 2)) - Math.PI;
3785
+ return Math.abs(diff);
3786
+ };
3787
+ const applyAngularPockets = (theta, radialT, band, idxSeed) => {
3788
+ if (!band.angularPockets?.length)
3789
+ return 1.0;
3790
+ let alpha = 1.0;
3791
+ const bandSpan = Math.max(band.end - band.start, 1e-4);
3792
+ for (let p = 0; p < band.angularPockets.length; p++) {
3793
+ const pocket = band.angularPockets[p];
3794
+ const width = Math.max(1e-4, pocket.width);
3795
+ const feather = pocket.feather ?? 1.0;
3796
+ const depth = THREE.MathUtils.clamp(pocket.depth, 0, 1);
3797
+ const swirl = (radialT - band.start) / bandSpan +
3798
+ (rand(idxSeed, 500 + p) - 0.5) * 0.2;
3799
+ const offset = swirl * 0.9;
3800
+ const target = pocket.angle + offset;
3801
+ const dist = shortestAngularDistance(theta, target);
3802
+ if (dist > width)
3803
+ continue;
3804
+ const norm = dist / width;
3805
+ const falloff = 1 - Math.pow(norm, feather * 1.25);
3806
+ const noise = (rand(idxSeed, 520 + p) - 0.5) * 0.12;
3807
+ const influence = THREE.MathUtils.clamp(falloff + noise, 0, 1);
3808
+ alpha = Math.min(alpha, 1 - influence * depth);
3809
+ }
3810
+ return THREE.MathUtils.clamp(alpha, 0, 1);
3811
+ };
3812
+ const sampleDiskLayer = (idx, bandIndex, radialT) => {
3813
+ if (halfThickness <= 1e-5)
3814
+ return 0;
3815
+ const clampedIndex = THREE.MathUtils.clamp(bandIndex, 0, diskBands.length - 1);
3816
+ const band = diskBands[clampedIndex];
3817
+ const baseLayer = THREE.MathUtils.clamp(band.z, -1, 1) * halfThickness;
3818
+ const jitter = (rand(idx, 2) - 0.5) * halfThickness * 0.35 * (0.6 + radialT * 0.8);
3819
+ return THREE.MathUtils.clamp(baseLayer + jitter, -halfThickness, halfThickness);
3820
+ };
3821
+ const haloInnerBase = Math.max(inner + diskBand * 0.55, diskOuterEnvelope - diskBand * 0.08);
3822
+ const haloOuterBase = haloOuter;
3823
+ const haloWidth = Math.max(haloOuterBase - haloInnerBase, 1e-4);
3824
+ const haloInnerSoft = Math.max(haloWidth * 0.28, diskBand * 0.05);
3825
+ const haloOuterSoft = Math.max(haloWidth * 0.36, diskBand * 0.08);
3826
+ const haloSupportInner = Math.max(inner + diskBand * 0.35, haloInnerBase - haloInnerSoft * 1.35);
3827
+ const haloSupportOuter = Math.min(haloOuter * 1.35, haloOuterBase + haloOuterSoft * 1.35);
3828
+ const haloSpan = Math.max(haloOuterBase - haloInnerBase, 1e-5);
3829
+ const sampleHaloRadius = (idx) => {
3830
+ const roll = rand(idx, 3);
3831
+ let radius;
3832
+ if (roll < 0.4) {
3833
+ const sub = Math.pow(rand(idx, 4), 0.82);
3834
+ radius = THREE.MathUtils.lerp(haloSupportInner, haloInnerBase + haloInnerSoft * 1.05, sub);
3835
+ }
3836
+ else if (roll < 0.88) {
3837
+ const sub = Math.pow(rand(idx, 5), 1.18);
3838
+ radius = THREE.MathUtils.lerp(haloInnerBase, haloOuterBase, sub);
3839
+ }
3840
+ else {
3841
+ const sub = Math.pow(rand(idx, 6), 0.58);
3842
+ radius = THREE.MathUtils.lerp(haloOuterBase, haloSupportOuter, sub);
3843
+ }
3844
+ const radialT = THREE.MathUtils.clamp((radius - haloInnerBase) / haloSpan, 0, 1);
3845
+ return { radius: THREE.MathUtils.clamp(radius, haloSupportInner, haloSupportOuter), radialT };
3846
+ };
3847
+ const anchorVec = new THREE.Vector3();
3848
+ const dirVec = new THREE.Vector3();
3849
+ const axisVec = new THREE.Vector3();
3850
+ const fallbackVec = new THREE.Vector3();
3851
+ const uVec = new THREE.Vector3();
3852
+ const vVec = new THREE.Vector3();
3853
+ const tmpVec = new THREE.Vector3();
3854
+ const tmpVec2 = new THREE.Vector3();
3855
+ const sphereMode = this.dustMode === 'sphere';
3856
+ const collectBandStats = this.debugLogBands && kind === 'disk';
3857
+ const bandStats = collectBandStats
3858
+ ? diskBands.map((band) => ({
3859
+ id: band.id,
3860
+ count: 0,
3861
+ minT: Number.POSITIVE_INFINITY,
3862
+ maxT: Number.NEGATIVE_INFINITY,
3863
+ sumT: 0,
3864
+ alphaSum: 0,
3865
+ voidHits: 0,
3866
+ innerSum: 0,
3867
+ outerSum: 0,
3868
+ radiusMin: Number.POSITIVE_INFINITY,
3869
+ radiusMax: Number.NEGATIVE_INFINITY,
3870
+ innerMin: Number.POSITIVE_INFINITY,
3871
+ innerMax: Number.NEGATIVE_INFINITY,
3872
+ outerMin: Number.POSITIVE_INFINITY,
3873
+ outerMax: Number.NEGATIVE_INFINITY,
3874
+ }))
3875
+ : null;
3876
+ for (let i = 0; i < count; i++) {
3877
+ if (sphereMode) {
3878
+ const clampedInner = Math.max(0, inner);
3879
+ const safeOuter = Math.max(clampedInner + 1e-3, outer);
3880
+ const span = Math.max(safeOuter - clampedInner, 1e-5);
3881
+ const coreOffset = Math.max(span * 0.015, 0.01);
3882
+ const startRadius = Math.min(clampedInner + coreOffset, safeOuter);
3883
+ const startC = Math.pow(startRadius, 3);
3884
+ const endRadius = clampedInner + span * 0.5; // trim outer population to ~50% of span
3885
+ const endC = Math.pow(Math.min(endRadius, safeOuter), 3);
3886
+ const radius = Math.cbrt(startC + rand(i, 7) * Math.max(endC - startC, 1e-6));
3887
+ const radialT = THREE.MathUtils.clamp((radius - inner) / Math.max(outer - inner, 1e-5), 0, 1);
3888
+ const v = (i + 0.5) / Math.max(1, count);
3889
+ const phiBase = Math.acos(1 - 2 * v);
3890
+ const phiJitter = (rand(i, 8) - 0.5) * 0.12;
3891
+ const thetaBase = wrapAngle(i * goldenAngle);
3892
+ const thetaJitter = (rand(i, 9) - 0.5) * 0.25;
3893
+ const lon = wrapAngle(thetaBase + thetaJitter);
3894
+ const phi = THREE.MathUtils.clamp(phiBase + phiJitter, 0, Math.PI);
3895
+ dirVec.set(Math.sin(phi) * Math.cos(lon), Math.cos(phi), Math.sin(phi) * Math.sin(lon));
3896
+ anchorVec.copy(dirVec).multiplyScalar(radius);
3897
+ axisVec.copy(anchorVec);
3898
+ if (axisVec.lengthSq() < 1e-8)
3899
+ axisVec.set(0, 1, 0);
3900
+ axisVec.normalize();
3901
+ fallbackVec.set(0, 1, 0);
3902
+ if (Math.abs(axisVec.dot(fallbackVec)) > 0.9)
3903
+ fallbackVec.set(1, 0, 0);
3904
+ uVec.crossVectors(axisVec, fallbackVec).normalize();
3905
+ if (!isFinite(uVec.x) || !isFinite(uVec.y) || !isFinite(uVec.z)) {
3906
+ uVec.set(-axisVec.y, axisVec.x, 0).normalize();
3907
+ if (uVec.lengthSq() < 1e-6)
3908
+ uVec.set(1, 0, 0);
3909
+ }
3910
+ vVec.crossVectors(axisVec, uVec).normalize();
3911
+ const w = THREE.MathUtils.lerp(this.opts.minAngularVel, this.opts.maxAngularVel, rand(i, 10));
3912
+ aRadius[i] = radius;
3913
+ aTheta0[i] = wrapAngle(thetaBase + thetaJitter);
3914
+ aOmega[i] = w * 0.35 * (rand(i, 11) < 0.5 ? -1 : 1);
3915
+ aLayer[i] = 0;
3916
+ if (isBokeh) {
3917
+ aSize[i] = THREE.MathUtils.lerp(14, 30, rand(i, 12));
3918
+ aIntensity[i] = THREE.MathUtils.lerp(0.4, 0.62, rand(i, 13));
3919
+ }
3920
+ else {
3921
+ const rPick = rand(i, 14);
3922
+ if (rPick < 0.7)
3923
+ aSize[i] = THREE.MathUtils.lerp(1.2, 2.6, rand(i, 15));
3924
+ else if (rPick < 0.95)
3925
+ aSize[i] = THREE.MathUtils.lerp(2.6, 5.1, rand(i, 16));
3926
+ else
3927
+ aSize[i] = THREE.MathUtils.lerp(5.2, 8.0, rand(i, 17));
3928
+ aIntensity[i] = THREE.MathUtils.lerp(0.45, 1.1, rand(i, 18));
3929
+ }
3930
+ const radialForIntensity = isHalo
3931
+ ? THREE.MathUtils.clamp((radius - inner) / haloBand, 0, 1)
3932
+ : radialT;
3933
+ aIntensity[i] *= THREE.MathUtils.lerp(1.08, 0.9, radialT);
3934
+ const magentaBias = THREE.MathUtils.lerp(1.0, 1.45, radialForIntensity);
3935
+ aIntensity[i] *= magentaBias;
3936
+ const hueBase = THREE.MathUtils.lerp(-0.05, 0.12, rand(i, 19));
3937
+ const hueRadialBias = THREE.MathUtils.lerp(-0.02, 0.08, radialT);
3938
+ aHue[i] = hueBase + hueRadialBias * 0.3;
3939
+ aSeed[i] = rand(i, 20) * 1000.0;
3940
+ aAnchor[i * 3 + 0] = anchorVec.x;
3941
+ aAnchor[i * 3 + 1] = anchorVec.y;
3942
+ aAnchor[i * 3 + 2] = anchorVec.z;
3943
+ aU[i * 3 + 0] = uVec.x;
3944
+ aU[i * 3 + 1] = uVec.y;
3945
+ aU[i * 3 + 2] = uVec.z;
3946
+ aV[i * 3 + 0] = vVec.x;
3947
+ aV[i * 3 + 1] = vVec.y;
3948
+ aV[i * 3 + 2] = vVec.z;
3949
+ aIsHalo[i] = 1;
3950
+ continue;
3951
+ }
3952
+ let radius = 0;
3953
+ let radialT = 0;
3954
+ let theta = 0;
3955
+ let bandIndex = -1;
3956
+ let bandTransparency = 1.0;
3957
+ let innerDist = 0;
3958
+ let outerDist = 0;
3959
+ let innerSoft = 1;
3960
+ let outerSoft = 1;
3961
+ if (isHalo) {
3962
+ const sample = sampleHaloRadius(i);
3963
+ radius = sample.radius;
3964
+ radialT = sample.radialT;
3965
+ const baseTheta = wrapAngle(i * goldenAngle);
3966
+ const angleJitter = (rand(i, 22) - 0.5) * this.haloArmSpread * 0.35;
3967
+ theta = applySpiralArms(wrapAngle(baseTheta + angleJitter), radialT, i, true);
3968
+ innerDist = radius - haloInnerBase;
3969
+ outerDist = haloOuterBase - radius;
3970
+ innerSoft = haloInnerSoft;
3971
+ outerSoft = haloOuterSoft;
3972
+ const innerNorm = THREE.MathUtils.clamp(innerDist / Math.max(1e-4, innerSoft), -4, 4);
3973
+ const outerNorm = THREE.MathUtils.clamp(outerDist / Math.max(1e-4, outerSoft), -4, 4);
3974
+ const haloBody = THREE.MathUtils.smoothstep(innerNorm, -1.4, 0.25) *
3975
+ THREE.MathUtils.smoothstep(outerNorm, -1.6, 0.3);
3976
+ const haloShell = 1.0 - THREE.MathUtils.smoothstep(Math.min(innerNorm, outerNorm), 0.1, 1.4);
3977
+ const haloFringe = THREE.MathUtils.smoothstep(Math.max(innerNorm, outerNorm), -2.2, 0.8);
3978
+ bandTransparency = THREE.MathUtils.clamp(haloBody * 0.85 + haloShell * 0.55 + haloFringe * 0.28, 0.26, 1.28);
3979
+ aLayer[i] = 0;
3980
+ aBandIndex[i] = -1;
3981
+ }
3982
+ else {
3983
+ bandIndex = selectDiskBandIndex(i);
3984
+ const bandData = diskBands[bandIndex];
3985
+ const baseTheta = wrapAngle(i * goldenAngle);
3986
+ const jitterMag = THREE.MathUtils.lerp(0.16, 0.48, bandData.end);
3987
+ const jitter = (rand(i, 22) - 0.5) * jitterMag;
3988
+ let workingTheta = wrapAngle(baseTheta + jitter);
3989
+ const gaussian = sampleGaussian(i, 30 + bandIndex * 7);
3990
+ const jitterScalar = rand(i, 602 + bandIndex) - 0.5;
3991
+ const sampleFromShell = (shellData, salt) => {
3992
+ const spread = shellData.halfRadius * 0.52;
3993
+ const wobble = (rand(i, 608 + salt) - 0.5) *
3994
+ (shellData.innerSoft + shellData.outerSoft) *
3995
+ 0.25;
3996
+ const candidate = shellData.midRadius +
3997
+ gaussian * shellData.sigmaRadius +
3998
+ jitterScalar * spread +
3999
+ wobble;
4000
+ return THREE.MathUtils.clamp(candidate, shellData.supportInner, shellData.supportOuter);
4001
+ };
4002
+ const sampleUniformTail = (shellData, salt) => {
4003
+ const tailMin = Math.max(shellData.supportInner, shellData.innerRadius - shellData.innerSoft * 1.6);
4004
+ const tailMax = Math.min(shellData.supportOuter, shellData.outerRadius + shellData.outerSoft * 1.65);
4005
+ const span = tailMax - tailMin;
4006
+ if (span <= 1e-5) {
4007
+ return sampleFromShell(shellData, salt + 11);
4008
+ }
4009
+ const wobble = (rand(i, 702 + salt) - 0.5) *
4010
+ (shellData.innerSoft + shellData.outerSoft) *
4011
+ 0.2;
4012
+ const t = rand(i, 701 + salt);
4013
+ const candidate = tailMin + span * t;
4014
+ return THREE.MathUtils.clamp(candidate + wobble, shellData.supportInner, shellData.supportOuter);
4015
+ };
4016
+ const bridgeToNeighbor = (dir, primary, salt) => {
4017
+ const neighborIndex = bandIndex + dir;
4018
+ if (neighborIndex < 0 || neighborIndex >= diskBands.length) {
4019
+ return null;
4020
+ }
4021
+ const neighborShell = resolveBandShell(neighborIndex, workingTheta);
4022
+ const start = dir < 0
4023
+ ? Math.min(neighborShell.outerRadius, primary.innerRadius)
4024
+ : Math.min(primary.outerRadius, neighborShell.innerRadius);
4025
+ const end = dir < 0
4026
+ ? Math.max(neighborShell.outerRadius, primary.innerRadius)
4027
+ : Math.max(primary.outerRadius, neighborShell.innerRadius);
4028
+ const span = end - start;
4029
+ if (span <= 1e-4)
4030
+ return null;
4031
+ const softness = primary.sigmaRadius + neighborShell.sigmaRadius;
4032
+ const blendT = Math.pow(rand(i, 720 + salt), 0.88);
4033
+ const base = start + span * blendT;
4034
+ const jitter = (rand(i, 721 + salt) - 0.5) * softness * 0.35;
4035
+ const supportMin = Math.min(primary.supportInner, neighborShell.supportInner);
4036
+ const supportMax = Math.max(primary.supportOuter, neighborShell.supportOuter);
4037
+ return THREE.MathUtils.clamp(base + jitter, supportMin, supportMax);
4038
+ };
4039
+ let shell = resolveBandShell(bandIndex, workingTheta);
4040
+ radius = sampleFromShell(shell, bandIndex * 17 + 5);
4041
+ radialT = THREE.MathUtils.clamp((radius - inner) / diskBand, 0, 1);
4042
+ workingTheta = applySpiralArms(workingTheta, radialT, i, false);
4043
+ shell = resolveBandShell(bandIndex, workingTheta);
4044
+ radius = sampleFromShell(shell, bandIndex * 31 + 9);
4045
+ radialT = THREE.MathUtils.clamp((radius - inner) / diskBand, 0, 1);
4046
+ theta = workingTheta;
4047
+ const mixRoll = rand(i, 618 + bandIndex);
4048
+ if (mixRoll < 0.22) {
4049
+ const tail = sampleUniformTail(shell, bandIndex * 41 + 3);
4050
+ radius = THREE.MathUtils.lerp(radius, tail, 0.6);
4051
+ radialT = THREE.MathUtils.clamp((radius - inner) / diskBand, 0, 1);
4052
+ }
4053
+ else if (mixRoll < 0.48) {
4054
+ const dir = mixRoll < 0.35 ? -1 : 1;
4055
+ const bridged = bridgeToNeighbor(dir, shell, bandIndex * 53 + (dir < 0 ? 7 : 13));
4056
+ if (bridged !== null) {
4057
+ radius = THREE.MathUtils.lerp(radius, bridged, 0.7);
4058
+ radialT = THREE.MathUtils.clamp((radius - inner) / diskBand, 0, 1);
4059
+ }
4060
+ }
4061
+ else if (mixRoll < 0.62) {
4062
+ const dir = mixRoll < 0.55 ? -1 : 1;
4063
+ const neighborIndex = bandIndex + dir;
4064
+ if (neighborIndex >= 0 && neighborIndex < diskBands.length) {
4065
+ const neighborShell = resolveBandShell(neighborIndex, workingTheta);
4066
+ const neighborRadius = sampleFromShell(neighborShell, bandIndex * 61 + (dir < 0 ? 9 : 17));
4067
+ const supportMin = Math.min(shell.supportInner, neighborShell.supportInner);
4068
+ const supportMax = Math.max(shell.supportOuter, neighborShell.supportOuter);
4069
+ const blend = THREE.MathUtils.clamp(rand(i, 623 + bandIndex), 0.25, 0.75);
4070
+ const mixed = THREE.MathUtils.lerp(radius, neighborRadius, blend);
4071
+ radius = THREE.MathUtils.clamp(mixed, supportMin, supportMax);
4072
+ radialT = THREE.MathUtils.clamp((radius - inner) / diskBand, 0, 1);
4073
+ }
4074
+ }
4075
+ innerDist = radius - shell.innerRadius;
4076
+ outerDist = shell.outerRadius - radius;
4077
+ innerSoft = shell.innerSoft;
4078
+ outerSoft = shell.outerSoft;
4079
+ const innerNorm = THREE.MathUtils.clamp(innerDist / Math.max(1e-4, innerSoft), -4, 4);
4080
+ const outerNorm = THREE.MathUtils.clamp(outerDist / Math.max(1e-4, outerSoft), -4, 4);
4081
+ const bandBody = THREE.MathUtils.smoothstep(innerNorm, -1.5, 0.35) *
4082
+ THREE.MathUtils.smoothstep(outerNorm, -1.4, 0.32);
4083
+ const rimShell = 1.0 -
4084
+ THREE.MathUtils.smoothstep(Math.min(innerNorm, outerNorm), 0.25, 1.6);
4085
+ const baselineGlow = THREE.MathUtils.smoothstep(Math.max(innerNorm, outerNorm), -2.6, 0.9);
4086
+ bandTransparency = THREE.MathUtils.clamp(bandBody * 0.9 + rimShell * 0.52 + baselineGlow * 0.24, bandData.alphaFloor ?? 0.16, 1.45);
4087
+ const innerFiller = THREE.MathUtils.smoothstep(innerNorm, -2.15, 0.45);
4088
+ const outerFiller = THREE.MathUtils.smoothstep(outerNorm, -2.25, 0.42);
4089
+ const filler = Math.max(innerFiller, outerFiller);
4090
+ bandTransparency = Math.max(bandTransparency, filler * 0.22);
4091
+ const radialMask = evaluateRadialMask(radialT, bandData);
4092
+ const angularMask = applyAngularPockets(theta, radialT, bandData, i);
4093
+ bandTransparency *= radialMask * angularMask;
4094
+ const maskFloor = Math.max(filler * 0.14, (bandData.alphaFloor ?? 0) * 0.6);
4095
+ bandTransparency = Math.max(bandTransparency, maskFloor);
4096
+ bandTransparency = THREE.MathUtils.clamp(bandTransparency, 0.1, 1.45);
4097
+ if (bandData.alphaNoise && bandData.alphaNoise > 0) {
4098
+ const noise = THREE.MathUtils.lerp(1 - bandData.alphaNoise, 1 + bandData.alphaNoise, rand(i, 560 + bandIndex));
4099
+ bandTransparency *= THREE.MathUtils.clamp(noise, 0.15, 1.25);
4100
+ }
4101
+ if (bandData.alphaFloor !== undefined) {
4102
+ bandTransparency = Math.max(bandData.alphaFloor, bandTransparency);
4103
+ }
4104
+ if (bandData.id === 'core-void') {
4105
+ bandTransparency = Math.min(bandTransparency, 0.2);
4106
+ }
4107
+ else if (bandData.id === 'outer-halo') {
4108
+ bandTransparency *= 0.82;
4109
+ }
4110
+ bandTransparency = THREE.MathUtils.clamp(bandTransparency, 0, 1.5);
4111
+ aLayer[i] = sampleDiskLayer(i, bandIndex, radialT);
4112
+ aBandIndex[i] = bandIndex;
4113
+ if (bandStats) {
4114
+ const stat = bandStats[bandIndex];
4115
+ stat.count++;
4116
+ stat.sumT += radialT;
4117
+ stat.minT = Math.min(stat.minT, radialT);
4118
+ stat.maxT = Math.max(stat.maxT, radialT);
4119
+ stat.alphaSum += bandTransparency;
4120
+ if (bandTransparency < 0.35)
4121
+ stat.voidHits++;
4122
+ stat.innerSum += innerNorm;
4123
+ stat.outerSum += outerNorm;
4124
+ stat.radiusMin = Math.min(stat.radiusMin, radius);
4125
+ stat.radiusMax = Math.max(stat.radiusMax, radius);
4126
+ stat.innerMin = Math.min(stat.innerMin, innerNorm);
4127
+ stat.innerMax = Math.max(stat.innerMax, innerNorm);
4128
+ stat.outerMin = Math.min(stat.outerMin, outerNorm);
4129
+ stat.outerMax = Math.max(stat.outerMax, outerNorm);
4130
+ }
4131
+ }
4132
+ aEdgeParams[i * 4 + 0] = innerDist;
4133
+ aEdgeParams[i * 4 + 1] = outerDist;
4134
+ aEdgeParams[i * 4 + 2] = innerSoft;
4135
+ aEdgeParams[i * 4 + 3] = outerSoft;
4136
+ const w = THREE.MathUtils.lerp(this.opts.minAngularVel, this.opts.maxAngularVel, rand(i, 29));
4137
+ const direction = rand(i, 30) < 0.5 ? -1 : 1;
4138
+ aOmega[i] = (isBokeh ? w * 0.6 : w) * direction;
4139
+ aTheta0[i] = theta;
4140
+ const radialForIntensity = isHalo
4141
+ ? THREE.MathUtils.clamp((radius - haloInnerBase) / haloSpan, 0, 1)
4142
+ : radialT;
4143
+ const magentaBias = THREE.MathUtils.lerp(1.0, 1.35, radialForIntensity);
4144
+ if (isBokeh) {
4145
+ aSize[i] = THREE.MathUtils.lerp(12, 26, rand(i, 31));
4146
+ aIntensity[i] = THREE.MathUtils.lerp(0.32, 0.58, rand(i, 32));
4147
+ }
4148
+ else {
4149
+ const r = rand(i, 33);
4150
+ if (rand(i, 40) < 0.15 && !isHalo) {
4151
+ // 15% chance of bright cluster particles
4152
+ aSize[i] = THREE.MathUtils.lerp(3.5, 6.2, rand(i, 41));
4153
+ aIntensity[i] = THREE.MathUtils.lerp(0.8, 1.3, rand(i, 42));
4154
+ }
4155
+ else if (r < 0.68) {
4156
+ aSize[i] = THREE.MathUtils.lerp(1.0, 2.3, rand(i, 34));
4157
+ aIntensity[i] = THREE.MathUtils.lerp(0.4, 1.05, rand(i, 37));
4158
+ }
4159
+ else if (r < 0.94) {
4160
+ aSize[i] = THREE.MathUtils.lerp(2.3, 4.6, rand(i, 35));
4161
+ aIntensity[i] = THREE.MathUtils.lerp(0.4, 1.05, rand(i, 37));
4162
+ }
4163
+ else {
4164
+ aSize[i] = THREE.MathUtils.lerp(4.8, 7.4, rand(i, 36));
4165
+ aIntensity[i] = THREE.MathUtils.lerp(0.4, 1.05, rand(i, 37));
4166
+ }
4167
+ }
4168
+ // Apply per-band color intensity multiplier and transparency gradient
4169
+ if (!isHalo) {
4170
+ const band = diskBands[bandIndex];
4171
+ const bandAlpha = Math.max(0, bandTransparency);
4172
+ const posInBand = THREE.MathUtils.clamp((radialT - band.start) /
4173
+ Math.max(1e-5, band.end - band.start), 0, 1);
4174
+ const coreBoost = band.id === 'inner-rim'
4175
+ ? THREE.MathUtils.lerp(1.6, 1.05, Math.pow(posInBand, 1.4))
4176
+ : radialT < 0.22
4177
+ ? THREE.MathUtils.lerp(1.55, 1.0, radialT / 0.22)
4178
+ : 1.0;
4179
+ const densityLift = band.id === 'mid-shelf'
4180
+ ? THREE.MathUtils.lerp(1.0, 1.22, rand(i, 47))
4181
+ : band.id === 'outer-rim'
4182
+ ? THREE.MathUtils.lerp(0.9, 1.08, rand(i, 470))
4183
+ : band.id === 'outer-halo'
4184
+ ? THREE.MathUtils.lerp(0.88, 1.02, rand(i, 471))
4185
+ : 1.0;
4186
+ const intensityVariation = THREE.MathUtils.lerp(0.88, 1.32, rand(i, 46));
4187
+ const bokehComp = isBokeh ? 0.9 : 1.0;
4188
+ aIntensity[i] *=
4189
+ band.colorIntensity *
4190
+ bandAlpha *
4191
+ coreBoost *
4192
+ densityLift *
4193
+ intensityVariation *
4194
+ magentaBias *
4195
+ bokehComp;
4196
+ }
4197
+ else {
4198
+ const rimBoost = Math.max(0.65, bandTransparency);
4199
+ const haloInnerNorm = THREE.MathUtils.clamp(innerDist / Math.max(1e-4, innerSoft), -3, 3);
4200
+ const haloOuterNorm = THREE.MathUtils.clamp(outerDist / Math.max(1e-4, outerSoft), -3, 3);
4201
+ const contourMix = THREE.MathUtils.smoothstep(haloInnerNorm, -1.1, 0.5) *
4202
+ THREE.MathUtils.smoothstep(haloOuterNorm, -1.1, 0.55);
4203
+ const bandContour = THREE.MathUtils.clamp(contourMix, 0.2, 1.2);
4204
+ aIntensity[i] *= THREE.MathUtils.lerp(1.25, 0.82, radialForIntensity);
4205
+ aIntensity[i] *= rimBoost * bandContour;
4206
+ }
4207
+ aRadius[i] = radius;
4208
+ // Organic color mixing: blue/purple/magenta dominant with cyan accents
4209
+ // Distribute colors: majority blue/magenta, less cyan
4210
+ const colorRoll = rand(i, 38);
4211
+ let hueBase = 0.0;
4212
+ if (colorRoll < 0.4) {
4213
+ // 40% deep blue to purple
4214
+ hueBase = THREE.MathUtils.lerp(0.05, 0.2, rand(i, 49));
4215
+ }
4216
+ else if (colorRoll < 0.75) {
4217
+ // 35% magenta to purple
4218
+ hueBase = THREE.MathUtils.lerp(0.2, 0.4, rand(i, 50));
4219
+ }
4220
+ else {
4221
+ // 25% cyan to blue
4222
+ hueBase = THREE.MathUtils.lerp(-0.1, 0.05, rand(i, 51));
4223
+ }
4224
+ // Create color regions using spatial patterns
4225
+ const spatialCluster1 = Math.sin(theta * 2.3 + radialForIntensity * 4.5) * 0.1;
4226
+ const spatialCluster2 = Math.cos(theta * 5.1 - radialForIntensity * 3.2) * 0.08;
4227
+ // Subtle radial gradient: don't force too much cyan in inner
4228
+ const hueRadialBias = THREE.MathUtils.lerp(0.0, // inner: mixed colors
4229
+ 0.15, // outer: slightly more magenta
4230
+ Math.pow(radialForIntensity, 1.2));
4231
+ const magentaPull = THREE.MathUtils.lerp(-0.4, 0.45, radialForIntensity);
4232
+ const bandIndexNormalized = diskBands.length > 1
4233
+ ? THREE.MathUtils.clamp(bandIndex, 0, diskBands.length - 1) /
4234
+ (diskBands.length - 1)
4235
+ : 0;
4236
+ const bandBias = THREE.MathUtils.lerp(-0.18, 0.25, bandIndexNormalized);
4237
+ aHue[i] = hueBase * 0.25 + magentaPull + bandBias + spatialCluster1 + spatialCluster2;
4238
+ aSeed[i] = rand(i, 39) * 1000.0;
4239
+ if (isHalo) {
4240
+ axisVec
4241
+ .set(rand(i, 40) * 2 - 1, rand(i, 41) * 2 - 1, rand(i, 42) * 2 - 1)
4242
+ .normalize();
4243
+ if (!isFinite(axisVec.x))
4244
+ axisVec.set(0, 1, 0);
4245
+ const ref = Math.abs(axisVec.y) > 0.9 ? tmpVec.set(1, 0, 0) : tmpVec.set(0, 1, 0);
4246
+ uVec.crossVectors(ref, axisVec).normalize();
4247
+ vVec.crossVectors(axisVec, uVec).normalize();
4248
+ tmpVec.copy(uVec).multiplyScalar(Math.cos(theta) * radius);
4249
+ tmpVec2.copy(vVec).multiplyScalar(Math.sin(theta) * radius);
4250
+ anchorVec.copy(tmpVec.add(tmpVec2));
4251
+ aIsHalo[i] = 1;
4252
+ }
4253
+ else {
4254
+ uVec.set(1, 0, 0);
4255
+ vVec.set(0, 1, 0);
4256
+ anchorVec.set(radius * Math.cos(theta), radius * Math.sin(theta), 0);
4257
+ aIsHalo[i] = 0;
4258
+ }
4259
+ aAnchor[i * 3 + 0] = anchorVec.x;
4260
+ aAnchor[i * 3 + 1] = anchorVec.y;
4261
+ aAnchor[i * 3 + 2] = anchorVec.z;
4262
+ aU[i * 3 + 0] = uVec.x;
4263
+ aU[i * 3 + 1] = uVec.y;
4264
+ aU[i * 3 + 2] = uVec.z;
4265
+ aV[i * 3 + 0] = vVec.x;
4266
+ aV[i * 3 + 1] = vVec.y;
4267
+ aV[i * 3 + 2] = vVec.z;
4268
+ }
4269
+ g.setAttribute('position', new THREE.BufferAttribute(base, 3));
4270
+ g.setAttribute('aRadius', new THREE.BufferAttribute(aRadius, 1));
4271
+ g.setAttribute('aTheta0', new THREE.BufferAttribute(aTheta0, 1));
4272
+ g.setAttribute('aOmega', new THREE.BufferAttribute(aOmega, 1));
4273
+ g.setAttribute('aLayer', new THREE.BufferAttribute(aLayer, 1));
4274
+ g.setAttribute('aSize', new THREE.BufferAttribute(aSize, 1));
4275
+ g.setAttribute('aIntensity', new THREE.BufferAttribute(aIntensity, 1));
4276
+ g.setAttribute('aHue', new THREE.BufferAttribute(aHue, 1));
4277
+ g.setAttribute('aSeed', new THREE.BufferAttribute(aSeed, 1));
4278
+ g.setAttribute('aAnchor', new THREE.BufferAttribute(aAnchor, 3));
4279
+ g.setAttribute('aU', new THREE.BufferAttribute(aU, 3));
4280
+ g.setAttribute('aV', new THREE.BufferAttribute(aV, 3));
4281
+ g.setAttribute('aIsHalo', new THREE.BufferAttribute(aIsHalo, 1));
4282
+ g.setAttribute('aBandIndex', new THREE.BufferAttribute(aBandIndex, 1));
4283
+ g.setAttribute('aEdgeParams', new THREE.BufferAttribute(aEdgeParams, 4));
4284
+ if (collectBandStats && bandStats) {
4285
+ const loggedTotal = bandStats.reduce((sum, band) => sum + band.count, 0);
4286
+ this.logDiskBandStats(kind, loggedTotal || count, bandStats);
4287
+ }
4288
+ const mat = this.makeMaterial(isBokeh);
4289
+ const pts = new THREE.Points(g, mat);
4290
+ pts.renderOrder = this.opts.renderOrder;
4291
+ pts.frustumCulled = false;
4292
+ this.group.add(pts);
4293
+ return pts;
4294
+ }
4295
+ makeMaterial(isBokeh) {
4296
+ this.refreshPaletteUniformData();
4297
+ const uniforms = {
4298
+ uTime: { value: 0 },
4299
+ uViewportH: { value: this.viewportHeight },
4300
+ uInner: { value: this.opts.innerRadius },
4301
+ uOuter: { value: this.opts.outerRadius },
4302
+ uHalfThickness: { value: this.opts.thickness },
4303
+ uNoise: { value: this.opts.noiseStrength },
4304
+ uColorInner: { value: new THREE.Color(this.opts.colorInner) },
4305
+ uColorMid: { value: new THREE.Color(this.opts.colorMid) },
4306
+ uColorOuter: { value: new THREE.Color(this.opts.colorOuter) },
4307
+ uPaletteCount: { value: this.paletteUniformCount },
4308
+ uPaletteStops: { value: this.paletteUniformStops },
4309
+ uPaletteIntensities: { value: this.paletteUniformIntensities },
4310
+ uPaletteColors: { value: this.paletteUniformColors },
4311
+ uHueBiasScale: { value: this.paletteHueBiasScale },
4312
+ uBackgroundLift: {
4313
+ value: this.debugDimLights
4314
+ ? this.paletteBackgroundLift * 0.25
4315
+ : this.paletteBackgroundLift,
4316
+ },
4317
+ uCoreIntensity: {
4318
+ value: this.debugDimLights
4319
+ ? this.paletteCoreIntensity * 0.8
4320
+ : this.paletteCoreIntensity,
4321
+ },
4322
+ uEdgeDebugMix: { value: this.debugEdgeMix },
4323
+ uSoftness: { value: isBokeh ? 2.6 : 3.6 },
4324
+ uSizeScale: { value: (isBokeh ? 1.0 : 1.2) * 5.0 },
4325
+ uMode: { value: this.dustMode === 'sphere' ? 1 : 0 },
4326
+ uSphereJitter: { value: this.sphereJitter },
4327
+ // Brightness control uniforms
4328
+ uWhitenScale: { value: this.whitenScale },
4329
+ uAlphaScale: { value: this.alphaScale },
4330
+ // Attractor uniforms
4331
+ uUseAttract: { value: 0.0 },
4332
+ uAttractorCount: { value: 0 },
4333
+ uAttractorPos: {
4334
+ // array of vec3 in dust local space
4335
+ value: Array.from({ length: this.MAX_ATTRACTORS }, () => new THREE.Vector3(9999, 9999, 9999)),
4336
+ },
4337
+ uAttractorRad: {
4338
+ value: new Array(this.MAX_ATTRACTORS).fill(0),
4339
+ },
4340
+ uNearFactor: { value: this.nearRadiusFactor },
4341
+ uStickEpsRel: { value: this.stickEpsRel },
4342
+ uStickEpsMin: { value: this.stickEpsMin },
4343
+ uStickEpsMax: { value: this.stickEpsMax },
4344
+ uAttractStrength: { value: this.attractStrength },
4345
+ uTangentialBias: { value: this.tangentialBias },
4346
+ };
4347
+ const vertex = `
4348
+ uniform float uTime;
4349
+ uniform float uInner;
4350
+ uniform float uOuter;
4351
+ uniform float uHalfThickness;
4352
+ uniform float uNoise;
4353
+ uniform float uViewportH;
4354
+ uniform float uSizeScale;
4355
+ uniform int uMode;
4356
+ uniform float uSphereJitter;
4357
+ uniform float uAlphaScale;
4358
+ // Attractor uniforms
4359
+ const int MAX_ATTRACTORS = ${24};
4360
+ uniform float uUseAttract; // 0 = off, 1 = on
4361
+ uniform int uAttractorCount;
4362
+ uniform vec3 uAttractorPos[MAX_ATTRACTORS];
4363
+ uniform float uAttractorRad[MAX_ATTRACTORS];
4364
+ uniform float uNearFactor;
4365
+ uniform float uStickEpsRel;
4366
+ uniform float uStickEpsMin;
4367
+ uniform float uStickEpsMax;
4368
+ uniform float uAttractStrength;
4369
+ uniform float uTangentialBias;
4370
+
4371
+ attribute float aRadius; // base orbit radius
4372
+ attribute float aTheta0; // initial angle
4373
+ attribute float aOmega; // angular velocity (rad/s)
4374
+ attribute float aLayer; // vertical offset across disk thickness (disk only)
4375
+ attribute float aSize; // pixel size (base)
4376
+ attribute float aIntensity;// brightness multiplier
4377
+ attribute float aHue; // slight hue bias [-~0.06..+~0.08]
4378
+ attribute float aSeed; // noise seed
4379
+ attribute vec3 aAnchor; // base anchor (sphere mode or cached local)
4380
+ attribute vec3 aU; // plane basis U (halo or disk default)
4381
+ attribute vec3 aV; // plane basis V
4382
+ attribute float aIsHalo; // 1 = halo, 0 = disk
4383
+ attribute vec4 aEdgeParams;
4384
+
4385
+ varying float vAlpha;
4386
+ varying float vRadialT;
4387
+ varying float vHueBias;
4388
+ varying vec4 vEdgeParams;
4389
+
4390
+ // Smooth value noise (cheap 1D) to avoid jitter/vibrate
4391
+ float hash(float n){ return fract(sin(n)*43758.5453123); }
4392
+ float vnoise1(float x){
4393
+ float i = floor(x);
4394
+ float f = fract(x);
4395
+ float a = hash(i);
4396
+ float b = hash(i + 1.0);
4397
+ float s = f*f*(3.0-2.0*f); // smoothstep
4398
+ return mix(a, b, s);
4399
+ }
4400
+
4401
+ void main(){
4402
+ float t = aTheta0 + aOmega * uTime;
4403
+
4404
+ float r = aRadius;
4405
+ vec3 local;
4406
+ if (uMode == 1) {
4407
+ vec3 anchor = aAnchor;
4408
+ float baseR = length(anchor);
4409
+ vec3 axis = baseR > 1e-5 ? normalize(anchor) : vec3(0.0, 1.0, 0.0);
4410
+ vec3 u = normalize(aU);
4411
+ vec3 v = normalize(aV);
4412
+ float spin = aOmega * uTime * 0.4;
4413
+ float cs = cos(spin);
4414
+ float sn = sin(spin);
4415
+ vec3 su = u * cs + v * sn;
4416
+ vec3 sv = -u * sn + v * cs;
4417
+ vec3 n = normalize(cross(su, sv));
4418
+ float jitter = uSphereJitter;
4419
+ float j1 = (vnoise1(aSeed*1.41 + uTime*0.11) - 0.5) * jitter;
4420
+ float j2 = (vnoise1(aSeed*2.17 + uTime*0.09) - 0.5) * jitter;
4421
+ float j3 = (vnoise1(aSeed*3.07 + uTime*0.07) - 0.5) * jitter;
4422
+ float breathe = (vnoise1(aSeed*4.19 + uTime*0.045) - 0.5) * jitter;
4423
+ local = anchor
4424
+ + su * (j1 * baseR * 0.45)
4425
+ + sv * (j2 * baseR * 0.45)
4426
+ + n * (j3 * baseR * 0.35)
4427
+ + axis * (breathe * baseR * 0.18);
4428
+ float radialDen = max(0.0001, (uOuter - uInner));
4429
+ vRadialT = clamp((baseR - uInner) / radialDen, 0.0, 1.0);
4430
+ } else if (aIsHalo > 0.5) {
4431
+ // Halo: orbit in per‑particle plane (aU, aV) with tiny precession
4432
+ float prec = mix(0.01, 0.03, fract(aSeed*0.317)) * uTime;
4433
+ float cp = cos(prec), sp = sin(prec);
4434
+ vec3 u = aU * cp + aV * sp;
4435
+ vec3 v = -aU * sp + aV * cp;
4436
+ vec3 n = normalize(cross(u, v));
4437
+
4438
+ local = r * (cos(t) * u + sin(t) * v);
4439
+
4440
+ // Smooth 3D drift: in-plane + along normal
4441
+ float j1 = vnoise1(aSeed*1.37 + t*0.09) - 0.5;
4442
+ float j2 = vnoise1(aSeed*2.11 + t*0.07) - 0.5;
4443
+ float j3 = vnoise1(aSeed*3.03 + uTime*0.06) - 0.5;
4444
+ local += (u * j1 + v * j2) * uNoise * r * 0.08;
4445
+ local += n * j3 * uNoise * 0.18;
4446
+ } else {
4447
+ // Disk: flattened local XY plane (treating Z as zero for 2D look)
4448
+ local = vec3(r * cos(t), r * sin(t), 0.0);
4449
+ float jx = vnoise1(aSeed*1.37 + t*0.09) - 0.5;
4450
+ float jy = vnoise1(aSeed*2.11 + t*0.07) - 0.5;
4451
+ local.x += jx * uNoise * r * 0.15;
4452
+ local.y += jy * uNoise * r * 0.15;
4453
+ }
4454
+
4455
+ float radialDen = max(0.0001, (uOuter - uInner));
4456
+ float radialNorm = clamp((r - uInner) / radialDen, 0.0, 1.0);
4457
+ if (uMode != 1) {
4458
+ // Radial ratio for color gradient (disk/halo paths)
4459
+ vRadialT = radialNorm;
4460
+ } else {
4461
+ // Preserve sphere-provided value but keep it clamped
4462
+ vRadialT = clamp(vRadialT, 0.0, 1.0);
4463
+ radialNorm = vRadialT;
4464
+ }
4465
+ float hueBoost = mix(0.22, 1.0, pow(radialNorm, 0.85));
4466
+ vHueBias = aHue * hueBoost;
4467
+ vAlpha = aIntensity * uAlphaScale;
4468
+ vEdgeParams = aEdgeParams;
4469
+ // --- Node attractor warping (object local space) ---
4470
+ if (uUseAttract > 0.5) {
4471
+ // Plane normal in dust local space is +Z (group rotates the whole object)
4472
+ vec3 n = vec3(0.0, 0.0, 1.0);
4473
+ // Iterate all active attractors, apply strongest influence (nearest)
4474
+ float bestS = 0.0;
4475
+ vec3 bestTarget = local;
4476
+ for (int i = 0; i < MAX_ATTRACTORS; i++) {
4477
+ if (i >= uAttractorCount) break;
4478
+ vec3 aPos = uAttractorPos[i];
4479
+ float aR = uAttractorRad[i];
4480
+ // distance in local space
4481
+ vec3 d = aPos - local;
4482
+ float dist = length(d);
4483
+ float nearR = max(0.0001, aR * uNearFactor);
4484
+ float eps = clamp(aR * uStickEpsRel, uStickEpsMin, uStickEpsMax);
4485
+ float stickR = max(0.0001, aR + eps);
4486
+ if (dist < nearR) {
4487
+ // Project vector onto plane around node
4488
+ vec3 toP = local - aPos;
4489
+ vec3 tang = toP - n * dot(toP, n);
4490
+ float tl = length(tang);
4491
+ // If degenerate, pick a small tangent perpendicular to n
4492
+ if (tl < 1e-5) {
4493
+ vec3 fallback = normalize(cross(n, vec3(1.0, 0.0, 0.0)));
4494
+ if (length(fallback) < 1e-3) fallback = normalize(cross(n, vec3(0.0, 0.0, 1.0)));
4495
+ tang = fallback * stickR;
4496
+ tl = length(tang);
4497
+ }
4498
+ vec3 tangN = tang / max(1e-5, tl);
4499
+ // Influence grows from nearR → stickR
4500
+ float s = clamp((nearR - dist) / max(1e-6, (nearR - stickR)), 0.0, 1.0);
4501
+ // Quadratic ease for smoother capture
4502
+ s = s * s * (3.0 - 2.0 * s);
4503
+ // Add tangential bias that tapers near the surface (avoid donut ring)
4504
+ vec3 tangOrtho = normalize(cross(n, tangN));
4505
+ float slide = uTangentialBias * (1.0 - s);
4506
+ vec3 target = aPos + tangN * stickR + tangOrtho * (slide * stickR * 0.05);
4507
+ if (s > bestS) {
4508
+ bestS = s;
4509
+ bestTarget = target;
4510
+ }
4511
+ }
4512
+ }
4513
+ // Blend toward the strongest attractor target, with snap when very close
4514
+ if (bestS > 0.0) {
4515
+ float t = clamp(uAttractStrength * bestS + 0.6, 0.0, 1.0);
4516
+ if (bestS > 0.9) {
4517
+ local = bestTarget;
4518
+ } else {
4519
+ local = mix(local, bestTarget, t);
4520
+ }
4521
+ }
4522
+ }
4523
+
4524
+ if (uMode != 1) {
4525
+ local.z = clamp(local.z, -uHalfThickness, uHalfThickness);
4526
+ }
4527
+
4528
+ vec4 mv = modelViewMatrix * vec4(local, 1.0);
4529
+ gl_Position = projectionMatrix * mv;
4530
+
4531
+ float att = uViewportH * 0.5 / max(1.0, -mv.z);
4532
+ float depthScale = mix(1.3, 0.7, clamp(vRadialT, 0.0, 1.0));
4533
+ gl_PointSize = aSize * uSizeScale * att * depthScale * 0.0035; // tuned factor
4534
+ }
4535
+ `;
4536
+ const fragment = `
4537
+ precision highp float;
4538
+ uniform vec3 uColorInner;
4539
+ uniform vec3 uColorMid;
4540
+ uniform vec3 uColorOuter;
4541
+ uniform float uSoftness; // gaussian falloff strength
4542
+ uniform int uMode;
4543
+ const int MAX_PALETTE_STOPS = ${this.MAX_PALETTE_STOPS};
4544
+ uniform int uPaletteCount;
4545
+ uniform float uPaletteStops[MAX_PALETTE_STOPS];
4546
+ uniform float uPaletteIntensities[MAX_PALETTE_STOPS];
4547
+ uniform vec3 uPaletteColors[MAX_PALETTE_STOPS];
4548
+ uniform float uHueBiasScale;
4549
+ uniform float uBackgroundLift;
4550
+ uniform float uCoreIntensity;
4551
+ uniform float uEdgeDebugMix;
4552
+ uniform float uWhitenScale;
4553
+
4554
+ varying float vAlpha;
4555
+ varying float vRadialT;
4556
+ varying float vHueBias;
4557
+ varying vec4 vEdgeParams;
4558
+
4559
+ vec3 samplePalette(float t, out float intensity){
4560
+ float clamped = clamp(t, 0.0, 1.0);
4561
+ if (uPaletteCount <= 0){
4562
+ intensity = 1.0;
4563
+ return vec3(1.0);
4564
+ }
4565
+ float prevStop = uPaletteStops[0];
4566
+ vec3 prevColor = uPaletteColors[0];
4567
+ float prevIntensity = uPaletteIntensities[0];
4568
+ if (clamped <= prevStop){
4569
+ intensity = prevIntensity;
4570
+ return prevColor;
4571
+ }
4572
+ for (int i = 1; i < MAX_PALETTE_STOPS; i++){
4573
+ if (i >= uPaletteCount) break;
4574
+ float stop = uPaletteStops[i];
4575
+ vec3 color = uPaletteColors[i];
4576
+ float inten = uPaletteIntensities[i];
4577
+ if (clamped <= stop + 1e-5){
4578
+ float span = max(1e-4, stop - prevStop);
4579
+ float localT = (clamped - prevStop) / span;
4580
+ intensity = mix(prevIntensity, inten, localT);
4581
+ return mix(prevColor, color, localT);
4582
+ }
4583
+ prevStop = stop;
4584
+ prevColor = color;
4585
+ prevIntensity = inten;
4586
+ }
4587
+ intensity = prevIntensity;
4588
+ return prevColor;
4589
+ }
4590
+
4591
+ vec3 resolveColor(float radialT, float hueBias, out float paletteIntensity){
4592
+ float baseIntensity;
4593
+ vec3 baseColor = samplePalette(radialT, baseIntensity);
4594
+ float biasMag = mix(0.05, 0.32, pow(radialT, 1.4));
4595
+ float offsetT = clamp(radialT + hueBias * biasMag, 0.0, 1.0);
4596
+ float accentIntensity;
4597
+ vec3 accentColor = samplePalette(offsetT, accentIntensity);
4598
+ float mixAmt = clamp(abs(hueBias) * uHueBiasScale * mix(0.8, 1.25, radialT), 0.0, 1.0);
4599
+ paletteIntensity = mix(baseIntensity, accentIntensity, mixAmt);
4600
+ vec3 color = mix(baseColor, accentColor, mixAmt);
4601
+ return color;
4602
+ }
4603
+
4604
+ vec3 applyCoreBoost(vec3 color, float radialT){
4605
+ float boost = mix(uCoreIntensity, 1.0, smoothstep(0.18, 0.5, radialT));
4606
+ return clamp(color * boost, 0.0, 1.2);
4607
+ }
4608
+
4609
+ void main(){
4610
+ vec2 uv = gl_PointCoord * 2.0 - 1.0;
4611
+ float r2 = dot(uv, uv);
4612
+ if (r2 > 1.0) discard;
4613
+
4614
+ float falloff = exp(-r2 * uSoftness);
4615
+ float alpha = falloff * vAlpha;
4616
+ vec3 color;
4617
+
4618
+ if (uMode == 1) {
4619
+ float mixT = clamp(vRadialT, 0.0, 1.0);
4620
+ float modeIntensity;
4621
+ color = samplePalette(mixT, modeIntensity);
4622
+ alpha *= mix(1.2, 0.6, mixT);
4623
+ } else {
4624
+ float radialT = clamp(vRadialT, 0.0, 1.0);
4625
+ float edgeFade = smoothstep(1.0, 0.45, sqrt(r2));
4626
+ float radialFade = mix(1.15, 0.38, pow(radialT, 1.4));
4627
+ alpha *= edgeFade * radialFade;
4628
+
4629
+ float paletteIntensity;
4630
+ color = resolveColor(radialT, vHueBias, paletteIntensity);
4631
+ float floorMix = smoothstep(0.05, 0.65, paletteIntensity);
4632
+ color = mix(vec3(uBackgroundLift), color, floorMix);
4633
+ color = applyCoreBoost(color, radialT);
4634
+
4635
+ vec2 edgeDist = vEdgeParams.xy;
4636
+ vec2 edgeSoft = max(vec2(1e-4), vEdgeParams.zw);
4637
+ vec2 norm = clamp(edgeDist / edgeSoft, vec2(-4.0), vec2(4.0));
4638
+ float body = smoothstep(-1.35, 0.2, norm.x) * smoothstep(-1.38, 0.32, norm.y);
4639
+ float shell = 1.0 - smoothstep(0.18, 1.55, min(norm.x, norm.y));
4640
+ float innerGlow = smoothstep(-2.3, 0.6, norm.x);
4641
+ float outerGlow = smoothstep(-2.4, 0.8, norm.y);
4642
+ float baseline = max(innerGlow * 0.18, outerGlow * 0.28);
4643
+ float rimAmount = clamp(shell * mix(0.5, 1.0, body), 0.0, 1.0);
4644
+ float bodyScale = mix(0.68, 1.35, body);
4645
+ float rimScale = mix(0.92, 1.16, rimAmount);
4646
+ float outerBias = mix(1.02, 0.88, smoothstep(-0.35, 0.8, norm.y));
4647
+ alpha *= bodyScale;
4648
+ alpha *= rimScale;
4649
+ alpha *= outerBias;
4650
+ float ambient = max(baseline, body * 0.32);
4651
+ float alphaFloor = ambient * mix(0.45, 1.0, clamp(vAlpha, 0.0, 1.0));
4652
+ alpha = max(alpha, alphaFloor);
4653
+ alpha = max(alpha, rimAmount * vAlpha * 0.24);
4654
+ color = mix(color, vec3(0.92, 0.98, 1.12), rimAmount * 0.22 * uWhitenScale);
4655
+
4656
+ if (uEdgeDebugMix > 0.0) {
4657
+ vec3 dbg = vec3(
4658
+ clamp(norm.x * 0.25 + 0.5, 0.0, 1.0),
4659
+ clamp(norm.y * 0.25 + 0.5, 0.0, 1.0),
4660
+ clamp((norm.x - norm.y) * 0.25 + 0.5, 0.0, 1.0)
4661
+ );
4662
+ color = mix(color, dbg, clamp(uEdgeDebugMix, 0.0, 1.0));
4663
+ }
4664
+
4665
+ if (radialT < 0.22) {
4666
+ float coreBright = smoothstep(0.22, 0.0, radialT);
4667
+ color = mix(color, vec3(0.94, 0.97, 1.0), coreBright * 0.45 * uWhitenScale);
4668
+ alpha *= mix(1.0, uCoreIntensity, coreBright * 0.35);
4669
+ }
4670
+ }
4671
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), alpha);
4672
+ }
4673
+ `;
4674
+ return new THREE.ShaderMaterial({
4675
+ uniforms,
4676
+ vertexShader: vertex,
4677
+ fragmentShader: fragment,
4678
+ transparent: true,
4679
+ blending: THREE.AdditiveBlending,
4680
+ depthWrite: false,
4681
+ depthTest: true,
4682
+ });
4683
+ }
4684
+ valueNoise2D(x, y, saltPrimary, saltSecondary) {
4685
+ const fade = (t) => t * t * (3 - 2 * t);
4686
+ const xi = Math.floor(x);
4687
+ const yi = Math.floor(y);
4688
+ const xf = x - xi;
4689
+ const yf = y - yi;
4690
+ const hashCorner = (ix, iy, saltOffset) => {
4691
+ const sx = ix | 0;
4692
+ const sy = iy | 0;
4693
+ let key = Math.imul(sx, 374761393) ^ Math.imul(sy, 668265263);
4694
+ key ^= saltOffset * 1597334677;
4695
+ return this.hashFloat(key, saltPrimary + saltSecondary + saltOffset * 31);
4696
+ };
4697
+ const v00 = hashCorner(xi, yi, 0);
4698
+ const v10 = hashCorner(xi + 1, yi, 1);
4699
+ const v01 = hashCorner(xi, yi + 1, 2);
4700
+ const v11 = hashCorner(xi + 1, yi + 1, 3);
4701
+ const u = fade(xf);
4702
+ const v = fade(yf);
4703
+ const lerp = THREE.MathUtils.lerp;
4704
+ const x0 = lerp(v00, v10, u);
4705
+ const x1 = lerp(v01, v11, u);
4706
+ return lerp(x0, x1, v);
4707
+ }
4708
+ samplePeriodicFbm(theta, params, bandIndex, salt) {
4709
+ const octaves = Math.max(1, Math.floor(params.octaves ?? 4));
4710
+ const lacunarity = params.lacunarity ?? 2.0;
4711
+ const gain = params.gain ?? 0.5;
4712
+ const baseFrequency = Math.max(0.05, params.frequency);
4713
+ const warpAmplitude = params.warpAmplitude ?? 0;
4714
+ const warpFrequency = params.warpFrequency ?? Math.max(0.05, baseFrequency * 0.45);
4715
+ const warpOctaves = Math.max(1, Math.floor(params.warpOctaves ?? 2));
4716
+ const baseX = Math.cos(theta);
4717
+ const baseY = Math.sin(theta);
4718
+ let warpX = 0;
4719
+ let warpY = 0;
4720
+ if (warpAmplitude > 1e-4) {
4721
+ let wAmp = 1;
4722
+ let wTotal = 0;
4723
+ let wSumX = 0;
4724
+ let wSumY = 0;
4725
+ for (let o = 0; o < warpOctaves; o++) {
4726
+ const wf = warpFrequency * Math.pow(1.8, o);
4727
+ const nx = baseX * wf + bandIndex * 1.137 + salt * 0.017;
4728
+ const ny = baseY * wf + bandIndex * 1.931 + salt * 0.029;
4729
+ const noiseX = this.valueNoise2D(nx + 19.1, ny + 4.73, salt + o * 37, 97) * 2 - 1;
4730
+ const noiseY = this.valueNoise2D(nx - 3.44, ny + 11.82, salt + o * 41, 151) * 2 - 1;
4731
+ wSumX += noiseX * wAmp;
4732
+ wSumY += noiseY * wAmp;
4733
+ wTotal += wAmp;
4734
+ wAmp *= 0.55;
4735
+ }
4736
+ if (wTotal > 0) {
4737
+ warpX = (wSumX / wTotal) * warpAmplitude;
4738
+ warpY = (wSumY / wTotal) * warpAmplitude;
4739
+ }
4740
+ }
4741
+ let sum = 0;
4742
+ let amplitude = 1;
4743
+ let totalAmplitude = 0;
4744
+ for (let o = 0; o < octaves; o++) {
4745
+ const freq = baseFrequency * Math.pow(lacunarity, o);
4746
+ const nx = baseX * freq + warpX;
4747
+ const ny = baseY * freq + warpY;
4748
+ const n = this.valueNoise2D(nx + bandIndex * 9.713 + salt * 0.13 + o * 13.37, ny + bandIndex * 7.291 + salt * 0.19 + o * 17.53, salt + o * 59, 211 + bandIndex * 13) *
4749
+ 2 -
4750
+ 1;
4751
+ sum += n * amplitude;
4752
+ totalAmplitude += amplitude;
4753
+ amplitude *= gain;
4754
+ }
4755
+ if (totalAmplitude <= 1e-6)
4756
+ return 0;
4757
+ return THREE.MathUtils.clamp(sum / totalAmplitude, -1, 1) * (params.amplitude ?? 1);
4758
+ }
4759
+ // Debug: sample a small subset of particles across cohorts and log near/stick counts
4760
+ sampleAndLogContacts() {
4761
+ const now = performance.now();
4762
+ if (now - this.lastDustLogMs < 1000)
4763
+ return;
4764
+ this.lastDustLogMs = now;
4765
+ const cohorts = ['disk', 'diskBokeh', 'halo', 'haloBokeh'];
4766
+ let sample = 0;
4767
+ let nearHits = 0;
4768
+ let stickHits = 0;
4769
+ const perAttractorNear = new Array(this.MAX_ATTRACTORS).fill(0);
4770
+ const perAttractorStick = new Array(this.MAX_ATTRACTORS).fill(0);
4771
+ const perAttractorSumR = new Array(this.MAX_ATTRACTORS).fill(0);
4772
+ const perAttractorSumAbsDelta = new Array(this.MAX_ATTRACTORS).fill(0);
4773
+ // Build local copies of attractors
4774
+ const nAttr = Math.min(this.MAX_ATTRACTORS, this.attractorLocalPos.length);
4775
+ const apos = this.attractorLocalPos.slice(0, nAttr);
4776
+ const arad = this.attractorRadius.slice(0, nAttr);
4777
+ const MAX_SAMPLE = 400; // keep it light
4778
+ for (const c of cohorts) {
4779
+ const pts = this.points?.[c];
4780
+ if (!pts)
4781
+ continue;
4782
+ const g = pts.geometry;
4783
+ const aRadius = g.getAttribute('aRadius');
4784
+ const aTheta0 = g.getAttribute('aTheta0');
4785
+ const aOmega = g.getAttribute('aOmega');
4786
+ const aLayer = g.getAttribute('aLayer');
4787
+ const aU = g.getAttribute('aU');
4788
+ const aV = g.getAttribute('aV');
4789
+ const aIsHalo = g.getAttribute('aIsHalo');
4790
+ const count = aRadius?.count ?? 0;
4791
+ if (!count)
4792
+ continue;
4793
+ const step = Math.max(1, Math.floor(count / Math.max(1, Math.floor(MAX_SAMPLE / cohorts.length))));
4794
+ for (let i = 0; i < count && sample < MAX_SAMPLE; i += step) {
4795
+ const r = aRadius.getX(i);
4796
+ const t = aTheta0.getX(i) + aOmega.getX(i) * this.time;
4797
+ let local;
4798
+ if ((aIsHalo.getX(i) || 0) > 0.5) {
4799
+ // halo
4800
+ const u = new THREE.Vector3(aU.getX(i), aU.getY(i), aU.getZ(i));
4801
+ const v = new THREE.Vector3(aV.getX(i), aV.getY(i), aV.getZ(i));
4802
+ local = u
4803
+ .multiplyScalar(Math.cos(t) * r)
4804
+ .add(v.multiplyScalar(Math.sin(t) * r));
4805
+ }
4806
+ else {
4807
+ const y = aLayer ? aLayer.getX(i) : 0;
4808
+ local = new THREE.Vector3(Math.cos(t) * r, y, Math.sin(t) * r);
4809
+ }
4810
+ // Check against attractors (mirror shader warp when enabled)
4811
+ let usedK = -1;
4812
+ let usedNearR = 0;
4813
+ let usedStickR = 0;
4814
+ let posAfter = local;
4815
+ // Find strongest influence
4816
+ let bestS = 0;
4817
+ let bestK = -1;
4818
+ let bestNearR = 0;
4819
+ let bestStickR = 0;
4820
+ let bestTarget = null;
4821
+ for (let k = 0; k < nAttr; k++) {
4822
+ const aPos = apos[k];
4823
+ const aR = arad[k];
4824
+ const d = local.distanceTo(aPos);
4825
+ const nearR = aR * this.nearRadiusFactor;
4826
+ const eps = Math.min(this.stickEpsMax, Math.max(this.stickEpsMin, aR * this.stickEpsRel));
4827
+ const stickR = aR + eps;
4828
+ if (d < nearR) {
4829
+ // plane projection and target (same as shader)
4830
+ const n = new THREE.Vector3(0, 1, 0);
4831
+ const toP = local.clone().sub(aPos);
4832
+ let tang = toP.clone().sub(n.clone().multiplyScalar(toP.dot(n)));
4833
+ let tl = tang.length();
4834
+ if (tl < 1e-5) {
4835
+ const fallback = new THREE.Vector3(0, 1, 0)
4836
+ .cross(new THREE.Vector3(1, 0, 0))
4837
+ .normalize();
4838
+ tang = fallback.clone().multiplyScalar(stickR);
4839
+ tl = tang.length();
4840
+ }
4841
+ const tangN = tang.clone().multiplyScalar(1 / Math.max(1e-5, tl));
4842
+ const tangOrtho = new THREE.Vector3()
4843
+ .crossVectors(n, tangN)
4844
+ .normalize();
4845
+ // Tapered slide mirrors shader
4846
+ const slide = this.tangentialBias *
4847
+ (1 - (nearR - d) / Math.max(1e-6, nearR - stickR)); // approx
4848
+ const target = aPos
4849
+ .clone()
4850
+ .add(tangN.clone().multiplyScalar(stickR))
4851
+ .add(tangOrtho
4852
+ .clone()
4853
+ .multiplyScalar(Math.max(0, slide) * stickR * 0.05));
4854
+ let s = (nearR - d) / Math.max(1e-6, nearR - stickR);
4855
+ s = Math.max(0, Math.min(1, s));
4856
+ s = s * s * (3 - 2 * s);
4857
+ if (s > bestS) {
4858
+ bestS = s;
4859
+ bestK = k;
4860
+ bestNearR = nearR;
4861
+ bestStickR = stickR;
4862
+ bestTarget = target;
4863
+ }
4864
+ }
4865
+ }
4866
+ if (this.useAttract && bestS > 0 && bestTarget) {
4867
+ let tBlend = Math.max(0, Math.min(1, this.attractStrength * bestS + 0.6));
4868
+ // Snap when close to stick band (mirror shader approx)
4869
+ const distBest = local.clone().sub(bestTarget).length();
4870
+ if (distBest <= 1.2 * Math.abs(bestStickR)) {
4871
+ posAfter = bestTarget.clone();
4872
+ }
4873
+ else {
4874
+ posAfter = local.clone().lerp(bestTarget, tBlend);
4875
+ }
4876
+ usedK = bestK;
4877
+ usedNearR = bestNearR;
4878
+ usedStickR = bestStickR;
4879
+ }
4880
+ else if (bestK >= 0) {
4881
+ // Attraction off: measure pre-warp distance using bestK
4882
+ usedK = bestK;
4883
+ usedNearR = bestNearR;
4884
+ usedStickR = bestStickR;
4885
+ }
4886
+ if (usedK >= 0) {
4887
+ const d2 = posAfter.distanceTo(apos[usedK]);
4888
+ if (d2 < usedNearR) {
4889
+ nearHits++;
4890
+ perAttractorNear[usedK]++;
4891
+ perAttractorSumR[usedK] += d2;
4892
+ perAttractorSumAbsDelta[usedK] += Math.abs(d2 - usedStickR);
4893
+ if (d2 < usedStickR) {
4894
+ stickHits++;
4895
+ perAttractorStick[usedK]++;
4896
+ }
4897
+ }
4898
+ }
4899
+ sample++;
4900
+ }
4901
+ }
4902
+ // Always print once/second for analysis (low volume)
4903
+ console.info('[DustAttract] near:', nearHits, 'stick-ish:', stickHits, 'sample:', sample, '| config:', {
4904
+ useAttract: this.useAttract,
4905
+ nearFactor: this.nearRadiusFactor,
4906
+ epsRel: this.stickEpsRel,
4907
+ epsMin: this.stickEpsMin,
4908
+ epsMax: this.stickEpsMax,
4909
+ attractStrength: this.attractStrength,
4910
+ tangentialBias: this.tangentialBias,
4911
+ });
4912
+ try {
4913
+ const search = getWindowLocationSearch();
4914
+ const verbose = /(?:^|[?&])debugDust=2(?:&|$)/.test(search);
4915
+ if (verbose && nAttr > 0) {
4916
+ const rows = [];
4917
+ for (let i = 0; i < nAttr; i++) {
4918
+ const n = perAttractorNear[i];
4919
+ const s = perAttractorStick[i];
4920
+ if (n === 0 && s === 0)
4921
+ continue;
4922
+ const aR = arad[i];
4923
+ const stickR = aR +
4924
+ Math.min(this.stickEpsMax, Math.max(this.stickEpsMin, aR * this.stickEpsRel));
4925
+ const avgR = n > 0 ? perAttractorSumR[i] / n : 0;
4926
+ const avgDelta = n > 0 ? perAttractorSumAbsDelta[i] / n : 0;
4927
+ rows.push({ idx: i, n, s, avgR, avgDelta, aR, stickR });
4928
+ }
4929
+ rows.sort((a, b) => b.n - a.n);
4930
+ const top = rows
4931
+ .slice(0, 6)
4932
+ .map((p) => `#${p.idx} aR:${p.aR.toFixed(3)} stickR:${p.stickR.toFixed(3)} avgR:${p.avgR.toFixed(3)} avg|d-stickR|:${p.avgDelta.toFixed(3)} near:${p.n} stick:${p.s}`);
4933
+ if (top.length)
4934
+ console.info('[DustAttract] per-node (top6):', top.join(' | '));
4935
+ }
4936
+ }
4937
+ catch { }
4938
+ }
4939
+ hashFloat(index, salt = 0) {
4940
+ let x = Math.imul(index | 0, 0x6d2b79f5);
4941
+ x ^= Math.imul((salt | 0) ^ 0x9e3779b9, 0x1b873593);
4942
+ x ^= x >>> 16;
4943
+ x = Math.imul(x, 0x85ebca6b);
4944
+ x ^= x >>> 13;
4945
+ x = Math.imul(x, 0xc2b2ae35);
4946
+ x ^= x >>> 16;
4947
+ return (x >>> 0) / 4294967296;
4948
+ }
4949
+ syncAttractorUniforms(mat) {
4950
+ // Guard: ensure our shader uniforms exist (HMR/alternate materials)
4951
+ if (!mat?.uniforms)
4952
+ return;
4953
+ if (mat.uniforms['uAttractorCount'] === undefined)
4954
+ return;
4955
+ // Recompute local-space positions using the CURRENT group transform
4956
+ const n = this.attractorWorldPos.length
4957
+ ? Math.min(this.MAX_ATTRACTORS, this.attractorWorldPos.length)
4958
+ : 0;
4959
+ for (let i = 0; i < n; i++) {
4960
+ const lp = this.group.worldToLocal(this.attractorWorldPos[i].clone());
4961
+ this.attractorLocalPos[i].copy(lp);
4962
+ this.attractorRadius[i] = this.attractorWorldRad[i];
4963
+ }
4964
+ for (let i = n; i < this.MAX_ATTRACTORS; i++) {
4965
+ this.attractorLocalPos[i].set(9999, 9999, 9999);
4966
+ this.attractorRadius[i] = 0;
4967
+ }
4968
+ mat.uniforms['uAttractorCount'].value = n;
4969
+ const arr = mat.uniforms['uAttractorPos'].value;
4970
+ for (let i = 0; i < this.MAX_ATTRACTORS; i++)
4971
+ arr[i].copy(this.attractorLocalPos[i] || new THREE.Vector3(9999, 9999, 9999));
4972
+ const rarr = mat.uniforms['uAttractorRad'].value;
4973
+ for (let i = 0; i < this.MAX_ATTRACTORS; i++)
4974
+ rarr[i] = this.attractorRadius[i] || 0;
4975
+ if (mat.uniforms['uNearFactor'] !== undefined)
4976
+ mat.uniforms['uNearFactor'].value = this.nearRadiusFactor;
4977
+ if (mat.uniforms['uStickEpsRel'] !== undefined)
4978
+ mat.uniforms['uStickEpsRel'].value = this.stickEpsRel;
4979
+ if (mat.uniforms['uStickEpsMin'] !== undefined)
4980
+ mat.uniforms['uStickEpsMin'].value = this.stickEpsMin;
4981
+ if (mat.uniforms['uStickEpsMax'] !== undefined)
4982
+ mat.uniforms['uStickEpsMax'].value = this.stickEpsMax;
4983
+ if (mat.uniforms['uAttractStrength'] !== undefined)
4984
+ mat.uniforms['uAttractStrength'].value = this.attractStrength;
4985
+ if (mat.uniforms['uTangentialBias'] !== undefined)
4986
+ mat.uniforms['uTangentialBias'].value = this.tangentialBias;
4987
+ }
4988
+ }
4989
+
4990
+ let useBlue = true;
4991
+ function generateThemeColor() {
4992
+ const color = useBlue ? '#60a5fa' : '#c084fc'; // Tailwind blue-400 / purple-400
4993
+ useBlue = !useBlue; // alternate next time
4994
+ return color;
4995
+ }
4996
+ function addNode(cluster, newNodeCounter) {
4997
+ const newId = Date.now();
4998
+ const newName = `New-Node-[${newNodeCounter}]`;
4999
+ const randomPosition = [
5000
+ (Math.random() - 0.5) * 400,
5001
+ (Math.random() - 0.5) * 10,
5002
+ (Math.random() - 0.5) * 400,
5003
+ ];
5004
+ // Determine trait count based on current cluster configuration (central preferences length)
5005
+ const central = cluster.blackholes.find((n) => n.isSupermassiveBlackhole) || null;
5006
+ const traitCount = central
5007
+ ? (Array.isArray(central.preferences) ? central.preferences.length : 5)
5008
+ : 5;
5009
+ function randomAttributes(count = traitCount) {
5010
+ const attrs = {};
5011
+ for (let i = 0; i < count; i++) {
5012
+ attrs[`attr${i + 1}`] = Math.floor(Math.random() * 100);
5013
+ }
5014
+ return attrs;
5015
+ }
5016
+ function randomPreferences(count = traitCount) {
5017
+ const prefs = {};
5018
+ for (let i = 0; i < count; i++) {
5019
+ prefs[`attr${i + 1}`] = Math.floor(Math.random() * 100);
5020
+ }
5021
+ return prefs;
5022
+ }
5023
+ const newNodeData = {
5024
+ id: newId,
5025
+ name: newName,
5026
+ color: '#00001E', // default BH color; matches presets (except Diverse Groups / Match Group A)
5027
+ isSupermassiveBlackhole: false,
5028
+ initialPosition: randomPosition,
5029
+ attributes: randomAttributes(),
5030
+ preferences: randomPreferences(),
2006
5031
  };
2007
- });
2008
- const attributeWeights = weights;
5032
+ const newBlackhole = new Blackhole(newNodeData, cluster.options);
5033
+ cluster.blackholes.push(newBlackhole);
5034
+ cluster.add(newBlackhole);
5035
+ return newNodeCounter + 1;
5036
+ }
5037
+ function removeNode(cluster, selectedBlackhole, hiddenBlackholes, contextMenuElement) {
5038
+ if (!selectedBlackhole || selectedBlackhole.isSupermassiveBlackhole)
5039
+ return;
5040
+ cluster.remove(selectedBlackhole);
5041
+ const index = cluster.blackholes.indexOf(selectedBlackhole);
5042
+ if (index > -1) {
5043
+ cluster.blackholes.splice(index, 1);
5044
+ }
5045
+ hiddenBlackholes.push(selectedBlackhole);
5046
+ contextMenuElement.style.display = 'none';
5047
+ }
5048
+
5049
+ function handleRightClick(event, mouse, camera, raycaster, cluster, contextMenuRef, renderer2, setSelectedNode, canvas, containerEl) {
5050
+ event.preventDefault();
5051
+ if (canvas) {
5052
+ const rect = canvas.getBoundingClientRect();
5053
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
5054
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
5055
+ }
5056
+ else {
5057
+ mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
5058
+ mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
5059
+ }
5060
+ raycaster.setFromCamera(mouse, camera);
5061
+ const intersects = raycaster.intersectObjects(cluster.children, true);
5062
+ if (intersects.length > 0) {
5063
+ const selected = intersects[0].object.parent;
5064
+ setSelectedNode(selected);
5065
+ const nodeName = selected.userData['name'] || 'Blackhole';
5066
+ const menuElement = contextMenuRef.nativeElement;
5067
+ menuElement.style.display = 'block';
5068
+ menuElement.style.left = `${event.clientX}px`;
5069
+ menuElement.style.top = `${event.clientY}px`;
5070
+ menuElement.querySelector('#contextNodeName').textContent = nodeName;
5071
+ if (containerEl) {
5072
+ const menuRect = menuElement.getBoundingClientRect();
5073
+ const containerRect = containerEl.getBoundingClientRect();
5074
+ const menuWidth = menuRect.width;
5075
+ const menuHeight = menuRect.height;
5076
+ let left = event.clientX;
5077
+ let top = event.clientY;
5078
+ if (top + menuHeight > containerRect.bottom) {
5079
+ top = containerRect.bottom - menuHeight;
5080
+ }
5081
+ if (left < containerRect.left) {
5082
+ left = containerRect.left;
5083
+ }
5084
+ if (left + menuWidth > containerRect.right) {
5085
+ left = containerRect.right - menuWidth;
5086
+ }
5087
+ menuElement.style.left = `${left}px`;
5088
+ menuElement.style.top = `${top}px`;
5089
+ }
5090
+ renderer2.listen('document', 'click', (ev) => {
5091
+ if (menuElement.contains(ev.target))
5092
+ return;
5093
+ menuElement.style.display = 'none';
5094
+ });
5095
+ }
5096
+ }
2009
5097
 
2010
5098
  /*
2011
5099
  * Public API Surface of trait-visual
5100
+ *
5101
+ * Canvas component (tv-trait-visual) API for host app / sidenav integration:
5102
+ *
5103
+ * Inputs:
5104
+ * nodeData: INodeData[] - Data for each blackhole (required to render).
5105
+ * attributeWeights: number[] - Weights for outer blackhole attributes.
5106
+ * preferenceWeights: number[] - Weights for supermassive blackhole preferences.
5107
+ * attributeCount?: number - Optional override for attribute count.
5108
+ * preferenceCount?: number - Optional override for preference count.
5109
+ *
5110
+ * Outputs:
5111
+ * blackholeSelected: Blackhole | null - Emitted when the user selects a blackhole (e.g. right-click).
5112
+ * Host app can bind to this for context menu or sidenav (e.g. show details, remove, edit).
2012
5113
  */
2013
5114
  // Main component
2014
5115
 
@@ -2016,5 +5117,5 @@ const attributeWeights = weights;
2016
5117
  * Generated bundle index. Do not edit.
2017
5118
  */
2018
5119
 
2019
- export { TraitVisualComponent, attributeWeights, dynamicCounts, nodeData, updateCounts };
5120
+ export { BlackHoleParticleField, Blackhole, Cluster, ConfigSidenavComponent, StellarDustField, TraitVisualComponent, addNode, attributeWeights, dynamicCounts, getWindowLocationSearch, handleRightClick, nodeData, removeNode, updateCounts };
2020
5121
  //# sourceMappingURL=naniteninja-trait-visual.mjs.map