@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.
- package/README.md +216 -216
- package/fesm2022/naniteninja-trait-visual.mjs +3555 -454
- package/fesm2022/naniteninja-trait-visual.mjs.map +1 -1
- package/index.d.ts +5 -162
- package/lib/app.types.d.ts +124 -0
- package/lib/config-sidenav/config-sidenav.component.d.ts +30 -0
- package/lib/data/nodes.data.d.ts +20 -0
- package/lib/objects/Blackhole.d.ts +45 -0
- package/lib/objects/Cluster.d.ts +11 -0
- package/lib/services/black-hole-particle-field.d.ts +74 -0
- package/lib/services/node-actions.d.ts +4 -0
- package/lib/services/stellar-dust-field.d.ts +102 -0
- package/lib/trait-visual.component.d.ts +85 -0
- package/lib/types/dust-field.types.d.ts +61 -0
- package/lib/types/glow.types.d.ts +18 -0
- package/lib/types/particle-field.types.d.ts +53 -0
- package/lib/utils/debug-counters.d.ts +6 -0
- package/lib/utils/on-right-click.util.d.ts +4 -0
- package/lib/utils/window.util.d.ts +7 -0
- package/package.json +1 -1
- package/public-api.d.ts +12 -0
- package/naniteninja-trait-visual-1.0.3.tgz +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 <
|
|
97
|
-
const b =
|
|
368
|
+
for (let j = i + 1; j < blackholes.length; j++) {
|
|
369
|
+
const b = blackholes[j];
|
|
98
370
|
if (b === central)
|
|
99
371
|
continue;
|
|
100
|
-
|
|
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
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
120
|
-
const
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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 <
|
|
135
|
-
const b =
|
|
482
|
+
for (let j = i + 1; j < blackholes.length; j++) {
|
|
483
|
+
const b = blackholes[j];
|
|
136
484
|
if (b === central)
|
|
137
485
|
continue;
|
|
138
|
-
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
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 (!(
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 <
|
|
179
|
-
if (j === i ||
|
|
596
|
+
for (let j = 0; j < blackholes.length; j++) {
|
|
597
|
+
if (j === i || blackholes[j] === central)
|
|
180
598
|
continue;
|
|
181
|
-
const d =
|
|
599
|
+
const d = bh.position.distanceTo(blackholes[j].position);
|
|
182
600
|
if (d < 0.35)
|
|
183
|
-
nearCount++;
|
|
601
|
+
nearCount++;
|
|
184
602
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
194
|
-
const separationRadius = 0.
|
|
195
|
-
const stiffness = 0.
|
|
196
|
-
for (let i = 0; i <
|
|
197
|
-
const a =
|
|
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 <
|
|
201
|
-
const b =
|
|
617
|
+
for (let j = i + 1; j < blackholes.length; j++) {
|
|
618
|
+
const b = blackholes[j];
|
|
202
619
|
if (b === central)
|
|
203
620
|
continue;
|
|
204
|
-
|
|
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
|
-
|
|
210
|
-
a.position.add(
|
|
211
|
-
b.position.sub(
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
707
|
+
update(blackholes, cursorPosition, scene, camera) {
|
|
299
708
|
if (this.swap) {
|
|
300
709
|
this.handleSwapAnimation();
|
|
301
710
|
return;
|
|
302
711
|
}
|
|
303
|
-
//
|
|
304
|
-
if (this.
|
|
305
|
-
const
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
773
|
+
calculateSupermassiveBlackHoleForce(supermassiveBlackhole) {
|
|
385
774
|
let force = new THREE.Vector3();
|
|
386
|
-
const compatibility = this.calculatePreferredCompatibility(
|
|
387
|
-
const desiredDistance = 0.
|
|
388
|
-
const currentDistance =
|
|
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
|
|
391
|
-
.subVectors(
|
|
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.
|
|
783
|
+
const repulsionForce = -this.options.supermassiveBlackHole.repulsion *
|
|
395
784
|
(desiredDistance - currentDistance) *
|
|
396
785
|
compatibility;
|
|
397
|
-
force.add(
|
|
786
|
+
force.add(directionToCentral.multiplyScalar(repulsionForce));
|
|
398
787
|
}
|
|
399
788
|
else {
|
|
400
|
-
const attractionForce = 2 * this.options.
|
|
401
|
-
force.add(
|
|
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
|
-
|
|
794
|
+
calculateBlackholeAttraction(blackholes) {
|
|
406
795
|
let attractionForce = new THREE.Vector3();
|
|
407
796
|
const attractionConstant = 0.001;
|
|
408
|
-
|
|
409
|
-
if (other !== this && !other.
|
|
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
|
-
|
|
809
|
+
calculateBlackholeRepulsion(blackholes) {
|
|
421
810
|
let repulsionForce = new THREE.Vector3();
|
|
422
|
-
|
|
423
|
-
if (other !== this && !other.
|
|
811
|
+
blackholes.forEach((other) => {
|
|
812
|
+
if (other !== this && !other.isSupermassiveBlackhole) {
|
|
424
813
|
const distance = this.position.distanceTo(other.position);
|
|
425
|
-
if (distance < this.options.
|
|
814
|
+
if (distance < this.options.blackhole.repulsionInitializationThreshold) {
|
|
426
815
|
const compatibility = this.calculateAttributeCompatibility(other);
|
|
427
|
-
const
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
936
|
+
Blackhole.prototype.makeCoreGlowMaterial = makeCoreGlowMaterial;
|
|
937
|
+
Blackhole.prototype.makeRimGlowMaterial = makeRimGlowMaterial;
|
|
547
938
|
|
|
548
939
|
class Cluster extends THREE.Object3D {
|
|
549
940
|
options;
|
|
550
|
-
|
|
941
|
+
blackholes;
|
|
942
|
+
_lastLogDist = [];
|
|
551
943
|
constructor(nodeData, options) {
|
|
552
944
|
super();
|
|
553
945
|
this.options = {
|
|
554
|
-
|
|
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
|
-
|
|
953
|
+
blackhole: {
|
|
560
954
|
attraction: 1,
|
|
561
955
|
repulsion: 1,
|
|
562
|
-
repulsionInitializationThreshold:
|
|
956
|
+
repulsionInitializationThreshold: 30.2,
|
|
957
|
+
pairwiseRepulsionMain: 52,
|
|
958
|
+
pairwiseRepulsionSecondary: 42,
|
|
959
|
+
pairwiseRepulsionMultiplier: 1,
|
|
960
|
+
dissimilarityRepulsionExponent: 2,
|
|
563
961
|
},
|
|
564
|
-
maxVelocity: 0
|
|
565
|
-
velocityDamping: 0.
|
|
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.
|
|
971
|
+
this.blackholes = [];
|
|
573
972
|
this.setUp(nodeData);
|
|
574
973
|
}
|
|
575
974
|
setUp(nodeData) {
|
|
576
975
|
nodeData.forEach((data) => {
|
|
577
|
-
const
|
|
578
|
-
this.
|
|
579
|
-
this.add(
|
|
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
|
-
|
|
584
|
-
|
|
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 = '
|
|
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 (
|
|
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 =
|
|
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
|
|
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 === '
|
|
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 === '
|
|
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:
|
|
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:
|
|
1147
|
-
const base = this.palette === '
|
|
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:
|
|
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
|
-
|
|
1251
|
-
attribute float
|
|
1252
|
-
|
|
1253
|
-
varying
|
|
1254
|
-
varying float
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
|
1341
|
-
const minR = this.coreRadius *
|
|
1342
|
-
const maxR = this.coreRadius *
|
|
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 *
|
|
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 =
|
|
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
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
2094
|
+
currentSupermassiveBlackhole() {
|
|
1522
2095
|
if (!this.cluster)
|
|
1523
2096
|
return null;
|
|
1524
|
-
return this.cluster.
|
|
2097
|
+
return this.cluster.blackholes.find((bh) => bh.isSupermassiveBlackhole) || null;
|
|
1525
2098
|
}
|
|
1526
|
-
|
|
2099
|
+
nonSupermassiveBlackholes() {
|
|
1527
2100
|
if (!this.cluster)
|
|
1528
2101
|
return [];
|
|
1529
|
-
return this.cluster.
|
|
2102
|
+
return this.cluster.blackholes.filter((bh) => !bh.isSupermassiveBlackhole);
|
|
1530
2103
|
}
|
|
1531
2104
|
syncNodeWeightGlobals() {
|
|
1532
2105
|
if (this.attributeWeights.length > 0) {
|
|
1533
|
-
|
|
2106
|
+
Blackhole.attributeWeights = this.attributeWeights.slice();
|
|
1534
2107
|
}
|
|
1535
2108
|
if (this.preferenceWeights.length > 0) {
|
|
1536
|
-
|
|
2109
|
+
Blackhole.preferenceWeights = this.preferenceWeights.slice();
|
|
1537
2110
|
}
|
|
1538
2111
|
}
|
|
1539
2112
|
initScene() {
|
|
1540
2113
|
this.scene = new THREE.Scene();
|
|
1541
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1589
|
-
initialCentral.
|
|
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
|
|
1593
|
-
if (
|
|
1594
|
-
const geom =
|
|
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) *
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
1777
|
-
const aura = this.nodeAuras.get(
|
|
2358
|
+
for (const bh of this.cluster.blackholes) {
|
|
2359
|
+
const aura = this.nodeAuras.get(bh);
|
|
1778
2360
|
if (aura) {
|
|
1779
|
-
const geom =
|
|
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) *
|
|
1783
|
-
const ud =
|
|
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(
|
|
2379
|
+
aura.update(bh.position, bh.velocity, deltaTime, this.camera);
|
|
1798
2380
|
}
|
|
1799
2381
|
}
|
|
1800
2382
|
}
|
|
1801
|
-
const center = this.
|
|
2383
|
+
const center = this.currentSupermassiveBlackhole()?.position ?? this._centerFallback;
|
|
1802
2384
|
if (this.dustField && this.cluster) {
|
|
1803
|
-
const
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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
|
|
1828
|
-
if (!this.nodeAuras.has(
|
|
1829
|
-
const geom =
|
|
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) *
|
|
1832
|
-
const particleCount =
|
|
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:
|
|
2428
|
+
palette: bh.isSupermassiveBlackhole ? 'supermassiveBlackhole' : 'blackhole',
|
|
1837
2429
|
distribution: 'surface',
|
|
1838
2430
|
speedScale: 0.3,
|
|
1839
2431
|
});
|
|
1840
|
-
this.nodeAuras.set(
|
|
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
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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: "
|
|
1916
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "
|
|
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: "
|
|
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
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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 & 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<ISimulationConfigs></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, > 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><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></tv-trait-visual></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 { TraitVisualComponent } from '@naniteninja/trait-visual';\r\n\r\n@Component({\r\n selector: 'app-my-component',\r\n standalone: true,\r\n imports: [TraitVisualComponent],\r\n template: `\r\n <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 ></tv-trait-visual>\r\n `\r\n})\r\nexport class MyComponent { }</code></pre>\r\n <p class=\"docs-label\">2. Prepare your data</p>\r\n <pre class=\"docs-code\"><code>import { INodeData } from '@naniteninja/trait-visual';\r\n\r\nconst myNodeData: INodeData[] = [\r\n {\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: { attr1: 50, attr2: 75, attr3: 25 },\r\n preferences: { attr1: 100, attr2: 80, attr3: 60 }\r\n },\r\n {\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: { attr1: 0, attr2: 50, attr3: 100 },\r\n preferences: { attr1: 0, attr2: 0, attr3: 0 }\r\n }\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
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
preferences: generateAttributes(dynamicCounts.preferences, 100),
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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 & 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<ISimulationConfigs></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, > 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><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></tv-trait-visual></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 { TraitVisualComponent } from '@naniteninja/trait-visual';\r\n\r\n@Component({\r\n selector: 'app-my-component',\r\n standalone: true,\r\n imports: [TraitVisualComponent],\r\n template: `\r\n <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 ></tv-trait-visual>\r\n `\r\n})\r\nexport class MyComponent { }</code></pre>\r\n <p class=\"docs-label\">2. Prepare your data</p>\r\n <pre class=\"docs-code\"><code>import { INodeData } from '@naniteninja/trait-visual';\r\n\r\nconst myNodeData: INodeData[] = [\r\n {\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: { attr1: 50, attr2: 75, attr3: 25 },\r\n preferences: { attr1: 100, attr2: 80, attr3: 60 }\r\n },\r\n {\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: { attr1: 0, attr2: 50, attr3: 100 },\r\n preferences: { attr1: 0, attr2: 0, attr3: 0 }\r\n }\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
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
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
|