@brandonlukas/luminar 0.1.1 → 0.2.1
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 +86 -32
- package/bin/luminar.mjs +22 -36
- package/dist/assets/index-BTv18fJQ.css +1 -0
- package/dist/assets/index-DqXax9_P.js +4181 -0
- package/dist/index.html +2 -2
- package/dist/vector-field.json +3692 -3380
- package/package.json +1 -1
- package/public/vector-field.json +3692 -3380
- package/src/lib/constants.ts +37 -0
- package/src/lib/csv-parser.ts +24 -0
- package/src/lib/types.ts +26 -0
- package/src/main.ts +243 -486
- package/src/modules/controls.ts +424 -0
- package/src/modules/field-loader.ts +71 -0
- package/src/modules/particle-system.ts +329 -0
- package/src/modules/recording.ts +227 -0
- package/src/style.css +130 -0
- package/dist/assets/index-BTqubGOw.js +0 -4151
- package/dist/assets/index-CCp_I16V.css +0 -1
package/src/main.ts
CHANGED
|
@@ -1,70 +1,29 @@
|
|
|
1
1
|
import './style.css'
|
|
2
|
-
import {
|
|
3
|
-
AdditiveBlending,
|
|
4
|
-
BufferAttribute,
|
|
5
|
-
BufferGeometry,
|
|
6
|
-
Color,
|
|
7
|
-
OrthographicCamera,
|
|
8
|
-
Points,
|
|
9
|
-
PointsMaterial,
|
|
10
|
-
Scene,
|
|
11
|
-
Vector2,
|
|
12
|
-
WebGLRenderer,
|
|
13
|
-
} from 'three'
|
|
2
|
+
import { AdditiveBlending, BufferGeometry, Color, OrthographicCamera, Points, PointsMaterial, Scene, Vector2, WebGLRenderer } from 'three'
|
|
14
3
|
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
|
|
15
4
|
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
|
|
16
5
|
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const SPEED_TO_GLOW = 2.6
|
|
26
|
-
const JITTER = 0.015
|
|
27
|
-
const FIELD_BORDER_MIN = 0.01
|
|
28
|
-
const FIELD_BORDER_MAX = 0.1
|
|
29
|
-
const COLOR_PRESETS: ColorPreset[] = [
|
|
30
|
-
{ key: 'luminous-violet', label: 'Luminous violet', rgb: [0.6, 0.25, 0.9] }, // current color
|
|
31
|
-
{ key: 'pure-white', label: 'Pure white', rgb: [1, 1, 1] },
|
|
32
|
-
{ key: 'neon-cyan', label: 'Neon cyan', rgb: [0.25, 0.95, 1] },
|
|
33
|
-
{ key: 'electric-lime', label: 'Electric lime', rgb: [0.75, 1, 0.25] },
|
|
34
|
-
{ key: 'solar-flare', label: 'Solar flare', rgb: [1, 0.55, 0.15] },
|
|
35
|
-
{ key: 'aurora-mint', label: 'Aurora mint', rgb: [0.4, 1, 0.85] },
|
|
36
|
-
{ key: 'magenta-nova', label: 'Magenta nova', rgb: [0.95, 0.2, 0.7] },
|
|
37
|
-
{ key: 'glacier-blue', label: 'Glacier blue', rgb: [0.35, 0.75, 1] },
|
|
38
|
-
{ key: 'sunrise-coral', label: 'Sunrise coral', rgb: [1, 0.6, 0.5] },
|
|
39
|
-
{ key: 'ember-gold', label: 'Ember gold', rgb: [1, 0.8, 0.2] },
|
|
40
|
-
]
|
|
41
|
-
const DEFAULT_COLOR_PRESET = 'luminous-violet'
|
|
42
|
-
const defaultParams = {
|
|
43
|
-
size: 2,
|
|
44
|
-
bloomStrength: 1.2,
|
|
45
|
-
bloomRadius: 0.35,
|
|
46
|
-
lifeMin: 0.5,
|
|
47
|
-
lifeMax: 1.4,
|
|
48
|
-
fieldValidDistance: 0.03,
|
|
49
|
-
speed: 3.0,
|
|
50
|
-
particleCount: 4200,
|
|
51
|
-
colorPreset: DEFAULT_COLOR_PRESET,
|
|
52
|
-
}
|
|
53
|
-
const params = { ...defaultParams }
|
|
54
|
-
|
|
55
|
-
let positions = new Float32Array(params.particleCount * 3)
|
|
56
|
-
let colors = new Float32Array(params.particleCount * 3)
|
|
57
|
-
let lifetimes = new Float32Array(params.particleCount)
|
|
6
|
+
import { AfterimagePass } from 'three/examples/jsm/postprocessing/AfterimagePass.js'
|
|
7
|
+
import { ParticleSystem } from './modules/particle-system'
|
|
8
|
+
import { FieldLoader } from './modules/field-loader'
|
|
9
|
+
import { ControlPanel } from './modules/controls'
|
|
10
|
+
import { RecordingManager } from './modules/recording'
|
|
11
|
+
import { defaultParams, COLOR_PRESETS } from './lib/constants'
|
|
12
|
+
import { parseCsv } from './lib/csv-parser'
|
|
13
|
+
import type { ParticleParams } from './lib/types'
|
|
58
14
|
|
|
59
15
|
const container = document.querySelector<HTMLDivElement>('#app')
|
|
60
16
|
if (!container) throw new Error('Missing #app container')
|
|
61
17
|
|
|
62
|
-
const
|
|
18
|
+
const params: ParticleParams = { ...defaultParams }
|
|
63
19
|
|
|
64
|
-
|
|
20
|
+
// Camera framing constant
|
|
21
|
+
const VIEW_SIZE = 1.8
|
|
22
|
+
|
|
23
|
+
// Scene setup
|
|
65
24
|
const renderer = new WebGLRenderer({ antialias: false, alpha: true })
|
|
66
25
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
67
|
-
|
|
26
|
+
container.appendChild(renderer.domElement)
|
|
68
27
|
|
|
69
28
|
const scene = new Scene()
|
|
70
29
|
scene.background = new Color(0x02040a)
|
|
@@ -75,14 +34,16 @@ camera.position.z = 2
|
|
|
75
34
|
const composer = new EffectComposer(renderer)
|
|
76
35
|
const renderPass = new RenderPass(scene, camera)
|
|
77
36
|
const bloomPass = new UnrealBloomPass(new Vector2(1, 1), params.bloomStrength, 0.82, params.bloomRadius)
|
|
78
|
-
|
|
37
|
+
const afterimagePass = new AfterimagePass(params.trailDecay)
|
|
79
38
|
composer.addPass(renderPass)
|
|
80
39
|
composer.addPass(bloomPass)
|
|
40
|
+
composer.addPass(afterimagePass)
|
|
41
|
+
afterimagePass.enabled = params.trailsEnabled
|
|
42
|
+
afterimagePass.uniforms['damp'].value = params.trailDecay
|
|
81
43
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
44
|
+
// Particle geometry and material
|
|
45
|
+
const geometryA = new BufferGeometry()
|
|
46
|
+
const geometryB = new BufferGeometry()
|
|
86
47
|
const material = new PointsMaterial({
|
|
87
48
|
size: params.size,
|
|
88
49
|
sizeAttenuation: true,
|
|
@@ -92,479 +53,275 @@ const material = new PointsMaterial({
|
|
|
92
53
|
blending: AdditiveBlending,
|
|
93
54
|
depthWrite: false,
|
|
94
55
|
})
|
|
95
|
-
material.onBeforeCompile = (shader) => {
|
|
96
|
-
// Make points circular with a smooth falloff toward the edges
|
|
97
|
-
shader.fragmentShader = shader.fragmentShader.replace(
|
|
98
|
-
'gl_FragColor = vec4( outgoingLight, diffuseColor.a );',
|
|
99
|
-
`float d = length(gl_PointCoord - 0.5);
|
|
100
|
-
if (d > 0.5) discard;
|
|
101
|
-
float alpha = diffuseColor.a * smoothstep(0.5, 0.45, d);
|
|
102
|
-
gl_FragColor = vec4(outgoingLight, alpha);`,
|
|
103
|
-
)
|
|
104
|
-
}
|
|
105
56
|
|
|
106
|
-
const
|
|
107
|
-
|
|
57
|
+
const particlesA = new Points(geometryA, material)
|
|
58
|
+
const particlesB = new Points(geometryB, material)
|
|
59
|
+
scene.add(particlesA)
|
|
60
|
+
scene.add(particlesB)
|
|
108
61
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
62
|
+
// Initialize modules
|
|
63
|
+
const particleSystemA = new ParticleSystem(geometryA, params)
|
|
64
|
+
const particleSystemB = new ParticleSystem(geometryB, params)
|
|
65
|
+
particleSystemA.init()
|
|
66
|
+
particleSystemB.init()
|
|
113
67
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
window.addEventListener('resize', resize)
|
|
117
|
-
let lastTime = performance.now()
|
|
118
|
-
animate(0)
|
|
119
|
-
createOverlay()
|
|
120
|
-
createControls()
|
|
121
|
-
reseedLifetimes()
|
|
122
|
-
loadVectorField()
|
|
123
|
-
|
|
124
|
-
function initParticles() {
|
|
125
|
-
for (let i = 0; i < params.particleCount; i += 1) {
|
|
126
|
-
resetParticle(i)
|
|
127
|
-
}
|
|
128
|
-
geometry.attributes.position.needsUpdate = true
|
|
129
|
-
geometry.attributes.color.needsUpdate = true
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function resetParticle(i: number) {
|
|
133
|
-
const i3 = i * 3
|
|
134
|
-
const palette = getActiveColorPreset()
|
|
135
|
-
|
|
136
|
-
// If field data exists, spawn particles near random data points (within valid region)
|
|
137
|
-
if (fieldData && fieldData.length > 0) {
|
|
138
|
-
const randomPoint = fieldData[Math.floor(Math.random() * fieldData.length)]
|
|
139
|
-
const jitterRange = params.fieldValidDistance * 0.3
|
|
140
|
-
const dataX = randomPoint.x + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
|
|
141
|
-
const dataY = randomPoint.y + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
|
|
142
|
-
positions[i3] = dataX * fieldTransform.scale + fieldTransform.offsetX
|
|
143
|
-
positions[i3 + 1] = dataY * fieldTransform.scale + fieldTransform.offsetY
|
|
144
|
-
} else {
|
|
145
|
-
// Fallback: uniform spawning across entire world
|
|
146
|
-
positions[i3] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
|
|
147
|
-
positions[i3 + 1] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
|
|
148
|
-
}
|
|
149
|
-
positions[i3 + 2] = 0
|
|
150
|
-
|
|
151
|
-
lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
|
|
152
|
-
|
|
153
|
-
const glow = 0.4 + Math.random() * 0.2
|
|
154
|
-
applyColor(i3, glow, palette, true)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function sampleField(x: number, y: number, _time: number): VectorSample | null {
|
|
158
|
-
if (fieldData && fieldData.length > 0) {
|
|
159
|
-
// Transform particle position to data coordinates
|
|
160
|
-
const dataX = (x - fieldTransform.offsetX) / fieldTransform.scale
|
|
161
|
-
const dataY = (y - fieldTransform.offsetY) / fieldTransform.scale
|
|
162
|
-
|
|
163
|
-
let nearest = fieldData[0]
|
|
164
|
-
let bestDist = Number.MAX_VALUE
|
|
165
|
-
for (let i = 0; i < fieldData.length; i += 1) {
|
|
166
|
-
const d = fieldData[i]
|
|
167
|
-
const dx = d.x - dataX
|
|
168
|
-
const dy = d.y - dataY
|
|
169
|
-
const dist = dx * dx + dy * dy
|
|
170
|
-
if (dist < bestDist) {
|
|
171
|
-
bestDist = dist
|
|
172
|
-
nearest = d
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const threshold = params.fieldValidDistance / fieldTransform.scale
|
|
176
|
-
if (Math.sqrt(bestDist) > threshold) {
|
|
177
|
-
return null
|
|
178
|
-
}
|
|
179
|
-
// Scale the vector components by the same factor
|
|
180
|
-
return { x: nearest.dx * fieldTransform.scale, y: nearest.dy * fieldTransform.scale }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Fallback: uniform rightward flow
|
|
184
|
-
return { x: 1, y: 0 }
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function animate(timestamp: number) {
|
|
188
|
-
const now = timestamp || performance.now()
|
|
189
|
-
const dt = Math.min(0.033, (now - lastTime) / 1000)
|
|
190
|
-
lastTime = now
|
|
191
|
-
const time = now * 0.001
|
|
192
|
-
const palette = getActiveColorPreset()
|
|
193
|
-
|
|
194
|
-
for (let i = 0; i < params.particleCount; i += 1) {
|
|
195
|
-
const i3 = i * 3
|
|
196
|
-
let x = positions[i3]
|
|
197
|
-
let y = positions[i3 + 1]
|
|
198
|
-
|
|
199
|
-
const v = sampleField(x, y, time)
|
|
200
|
-
if (!v) {
|
|
201
|
-
resetParticle(i)
|
|
202
|
-
continue
|
|
203
|
-
}
|
|
68
|
+
let hasFieldA = false
|
|
69
|
+
let hasFieldB = false
|
|
204
70
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
applyColor(i3, glow, palette, false)
|
|
212
|
-
|
|
213
|
-
lifetimes[i] -= dt
|
|
214
|
-
|
|
215
|
-
if (lifetimes[i] <= 0 || Math.abs(x) > WORLD_EXTENT || Math.abs(y) > WORLD_EXTENT) {
|
|
216
|
-
resetParticle(i)
|
|
217
|
-
} else {
|
|
218
|
-
positions[i3] = x
|
|
219
|
-
positions[i3 + 1] = y
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
geometry.attributes.position.needsUpdate = true
|
|
224
|
-
geometry.attributes.color.needsUpdate = true
|
|
225
|
-
|
|
226
|
-
composer.render()
|
|
227
|
-
requestAnimationFrame(animate)
|
|
228
|
-
}
|
|
71
|
+
const fieldLoaderA = new FieldLoader((data, transform) => {
|
|
72
|
+
particleSystemA.setFieldData(data, transform)
|
|
73
|
+
hasFieldA = particleSystemA.hasFieldData()
|
|
74
|
+
updateLayout()
|
|
75
|
+
})
|
|
229
76
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
77
|
+
const fieldLoaderB = new FieldLoader((data, transform) => {
|
|
78
|
+
particleSystemB.setFieldData(data, transform)
|
|
79
|
+
hasFieldB = particleSystemB.hasFieldData()
|
|
80
|
+
updateLayout()
|
|
81
|
+
})
|
|
235
82
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
83
|
+
const controlPanel = new ControlPanel(container, params, material, bloomPass, {
|
|
84
|
+
onParticleCountChange: (count) => {
|
|
85
|
+
particleSystemA.resizeBuffers(count)
|
|
86
|
+
particleSystemB.resizeBuffers(count)
|
|
87
|
+
},
|
|
88
|
+
onLifetimeChange: () => {
|
|
89
|
+
particleSystemA.reseedLifetimes()
|
|
90
|
+
particleSystemB.reseedLifetimes()
|
|
91
|
+
},
|
|
92
|
+
onTrailToggle: (enabled) => updateTrails(enabled, params.trailDecay),
|
|
93
|
+
onTrailDecayChange: (value) => updateTrails(params.trailsEnabled, value),
|
|
94
|
+
onClearFieldA: () => clearField('left'),
|
|
95
|
+
onClearFieldB: () => clearField('right'),
|
|
96
|
+
onColorChange: () => updateColorPresetCache(),
|
|
97
|
+
})
|
|
241
98
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
99
|
+
function updateColorPresetCache() {
|
|
100
|
+
cachedPresetA = COLOR_PRESETS.find((p) => p.key === params.colorPresetA) ?? COLOR_PRESETS[0]
|
|
101
|
+
cachedPresetB = COLOR_PRESETS.find((p) => p.key === params.colorPresetB) ?? COLOR_PRESETS[0]
|
|
245
102
|
}
|
|
246
103
|
|
|
247
|
-
|
|
248
|
-
return min + Math.random() * (max - min)
|
|
249
|
-
}
|
|
104
|
+
const recordingManager = new RecordingManager(renderer, composer, bloomPass, resize)
|
|
250
105
|
|
|
106
|
+
// HUD overlay
|
|
251
107
|
function createOverlay() {
|
|
108
|
+
if (!container) return
|
|
252
109
|
const hud = document.createElement('div')
|
|
253
110
|
hud.className = 'hud'
|
|
254
|
-
hud.innerHTML = `<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field: <span id="field-status">default (built-in)</span></div>`
|
|
255
|
-
|
|
256
|
-
|
|
111
|
+
hud.innerHTML = `<div class="title">luminar</div><div class="subtitle">2D vector field bloom study</div><div class="status">Field A: <span id="field-status-a">default (built-in)</span> · Field B: <span id="field-status-b">default (built-in)</span></div>`
|
|
112
|
+
container.appendChild(hud)
|
|
113
|
+
fieldLoaderA.setStatusElement(document.getElementById('field-status-a'))
|
|
114
|
+
fieldLoaderB.setStatusElement(document.getElementById('field-status-b'))
|
|
257
115
|
}
|
|
258
116
|
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
advancedSection.className = 'controls__advanced'
|
|
283
|
-
advancedSection.style.display = 'none'
|
|
284
|
-
|
|
285
|
-
const sizeControl = addSlider(advancedSection, 'Size', 0.5, 4, 0.1, params.size, (value) => {
|
|
286
|
-
params.size = value
|
|
287
|
-
material.size = value
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
const particleCountControl = addSlider(advancedSection, 'Particle count', 100, 8000, 100, params.particleCount, (value) => {
|
|
291
|
-
params.particleCount = Math.round(value)
|
|
292
|
-
resizeParticleBuffers()
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
const bloomStrengthControl = addSlider(advancedSection, 'Bloom strength', 0.2, 2.5, 0.05, params.bloomStrength, (value) => {
|
|
296
|
-
params.bloomStrength = value
|
|
297
|
-
updateBloom()
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
const bloomRadiusControl = addSlider(advancedSection, 'Bloom radius', 0.0, 1.2, 0.02, params.bloomRadius, (value) => {
|
|
301
|
-
params.bloomRadius = value
|
|
302
|
-
updateBloom()
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
let lifeMinControl: SliderHandle
|
|
306
|
-
let lifeMaxControl: SliderHandle
|
|
307
|
-
|
|
308
|
-
lifeMinControl = addSlider(advancedSection, 'Life min (s)', 0.1, 2.0, 0.05, params.lifeMin, (value) => {
|
|
309
|
-
params.lifeMin = value
|
|
310
|
-
if (params.lifeMin > params.lifeMax) {
|
|
311
|
-
params.lifeMax = value
|
|
312
|
-
syncSlider(lifeMaxControl, params.lifeMax)
|
|
117
|
+
function setupDragAndDrop() {
|
|
118
|
+
if (!container) return
|
|
119
|
+
|
|
120
|
+
// Create side-specific overlays
|
|
121
|
+
const dropOverlayLeft = document.createElement('div')
|
|
122
|
+
dropOverlayLeft.className = 'drop-overlay drop-overlay--left'
|
|
123
|
+
dropOverlayLeft.textContent = 'Drop to load Field A (left)'
|
|
124
|
+
dropOverlayLeft.style.display = 'none'
|
|
125
|
+
container.appendChild(dropOverlayLeft)
|
|
126
|
+
|
|
127
|
+
const dropOverlayRight = document.createElement('div')
|
|
128
|
+
dropOverlayRight.className = 'drop-overlay drop-overlay--right'
|
|
129
|
+
dropOverlayRight.textContent = 'Drop to load Field B (right)'
|
|
130
|
+
dropOverlayRight.style.display = 'none'
|
|
131
|
+
container.appendChild(dropOverlayRight)
|
|
132
|
+
|
|
133
|
+
const showOverlay = (side: 'left' | 'right') => {
|
|
134
|
+
if (side === 'left') {
|
|
135
|
+
dropOverlayLeft.style.display = 'flex'
|
|
136
|
+
dropOverlayRight.style.display = 'none'
|
|
137
|
+
} else {
|
|
138
|
+
dropOverlayLeft.style.display = 'none'
|
|
139
|
+
dropOverlayRight.style.display = 'flex'
|
|
313
140
|
}
|
|
314
|
-
|
|
315
|
-
|
|
141
|
+
}
|
|
142
|
+
const hideOverlay = () => {
|
|
143
|
+
dropOverlayLeft.style.display = 'none'
|
|
144
|
+
dropOverlayRight.style.display = 'none'
|
|
145
|
+
}
|
|
316
146
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
147
|
+
const handleFiles = async (file: File, target: 'left' | 'right') => {
|
|
148
|
+
try {
|
|
149
|
+
const text = await file.text()
|
|
150
|
+
const rows = parseCsv(text)
|
|
151
|
+
if (!rows.length) {
|
|
152
|
+
if (target === 'left') {
|
|
153
|
+
fieldLoaderA.updateStatus('CSV empty or invalid')
|
|
154
|
+
} else {
|
|
155
|
+
fieldLoaderB.updateStatus('CSV empty or invalid')
|
|
156
|
+
}
|
|
157
|
+
hideOverlay()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const loader = target === 'left' ? fieldLoaderA : fieldLoaderB
|
|
161
|
+
const { transform, bounds } = loader.computeFieldTransform(rows)
|
|
162
|
+
if (target === 'left') {
|
|
163
|
+
particleSystemA.setFieldData(rows, transform)
|
|
164
|
+
hasFieldA = particleSystemA.hasFieldData()
|
|
165
|
+
} else {
|
|
166
|
+
particleSystemB.setFieldData(rows, transform)
|
|
167
|
+
hasFieldB = particleSystemB.hasFieldData()
|
|
168
|
+
}
|
|
169
|
+
loader.updateStatus(`loaded ${rows.length} vectors (${bounds.width.toFixed(1)}×${bounds.height.toFixed(1)})`)
|
|
170
|
+
updateLayout()
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error('Failed to load dropped CSV', error)
|
|
173
|
+
if (target === 'left') {
|
|
174
|
+
fieldLoaderA.updateStatus('CSV load error')
|
|
175
|
+
} else {
|
|
176
|
+
fieldLoaderB.updateStatus('CSV load error')
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
hideOverlay()
|
|
322
180
|
}
|
|
323
|
-
|
|
324
|
-
})
|
|
181
|
+
}
|
|
325
182
|
|
|
326
|
-
|
|
327
|
-
|
|
183
|
+
window.addEventListener('dragover', (e) => {
|
|
184
|
+
e.preventDefault()
|
|
185
|
+
const target = e.clientX < window.innerWidth * 0.5 ? 'left' : 'right'
|
|
186
|
+
showOverlay(target)
|
|
328
187
|
})
|
|
329
|
-
fieldDistControlHandle = fieldDistControl
|
|
330
188
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
advancedToggle.textContent = isHidden ? 'Hide advanced' : 'Show advanced'
|
|
189
|
+
window.addEventListener('dragleave', (e) => {
|
|
190
|
+
e.preventDefault()
|
|
191
|
+
hideOverlay()
|
|
335
192
|
})
|
|
336
193
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
resetBtn.addEventListener('click', () => {
|
|
345
|
-
Object.assign(params, defaultParams)
|
|
346
|
-
material.size = params.size
|
|
347
|
-
updateBloom()
|
|
348
|
-
reseedLifetimes()
|
|
349
|
-
syncSlider(sizeControl, params.size)
|
|
350
|
-
syncSlider(speedControl, params.speed)
|
|
351
|
-
syncSlider(particleCountControl, params.particleCount)
|
|
352
|
-
syncSlider(bloomStrengthControl, params.bloomStrength)
|
|
353
|
-
syncSlider(bloomRadiusControl, params.bloomRadius)
|
|
354
|
-
syncSlider(lifeMinControl, params.lifeMin)
|
|
355
|
-
syncSlider(lifeMaxControl, params.lifeMax)
|
|
356
|
-
syncSlider(fieldDistControl, params.fieldValidDistance)
|
|
357
|
-
syncSelect(colorControl, params.colorPreset)
|
|
194
|
+
window.addEventListener('drop', (e) => {
|
|
195
|
+
e.preventDefault()
|
|
196
|
+
const dt = e.dataTransfer
|
|
197
|
+
if (dt && dt.files && dt.files[0]) {
|
|
198
|
+
const target = e.clientX < window.innerWidth * 0.5 ? 'left' : 'right'
|
|
199
|
+
handleFiles(dt.files[0], target)
|
|
200
|
+
}
|
|
358
201
|
})
|
|
359
|
-
panel.appendChild(resetBtn)
|
|
360
|
-
|
|
361
|
-
app.appendChild(panel)
|
|
362
202
|
}
|
|
363
203
|
|
|
364
|
-
function
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
min: number,
|
|
368
|
-
max: number,
|
|
369
|
-
step: number,
|
|
370
|
-
value: number,
|
|
371
|
-
onChange: (value: number) => void,
|
|
372
|
-
): SliderHandle {
|
|
373
|
-
const row = document.createElement('label')
|
|
374
|
-
row.className = 'controls__row'
|
|
375
|
-
|
|
376
|
-
const text = document.createElement('span')
|
|
377
|
-
text.textContent = label
|
|
378
|
-
|
|
379
|
-
const input = document.createElement('input')
|
|
380
|
-
input.type = 'range'
|
|
381
|
-
input.min = String(min)
|
|
382
|
-
input.max = String(max)
|
|
383
|
-
input.step = String(step)
|
|
384
|
-
input.value = String(value)
|
|
385
|
-
|
|
386
|
-
const valueTag = document.createElement('span')
|
|
387
|
-
valueTag.className = 'controls__value'
|
|
388
|
-
valueTag.textContent = formatValue(value, step)
|
|
389
|
-
|
|
390
|
-
input.addEventListener('input', (event) => {
|
|
391
|
-
const next = parseFloat((event.target as HTMLInputElement).value)
|
|
392
|
-
valueTag.textContent = formatValue(next, step)
|
|
393
|
-
onChange(next)
|
|
394
|
-
})
|
|
204
|
+
function computeViewOffset(): number {
|
|
205
|
+
const aspect = window.innerWidth / window.innerHeight
|
|
206
|
+
const cameraWidth = VIEW_SIZE * aspect * 2
|
|
395
207
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
row.appendChild(valueTag)
|
|
399
|
-
panel.appendChild(row)
|
|
208
|
+
// Visual sweet spot: fields shouldn't spread beyond this on wide windows
|
|
209
|
+
const maxVisualOffset = 1.4
|
|
400
210
|
|
|
401
|
-
|
|
402
|
-
|
|
211
|
+
// Minimum separation
|
|
212
|
+
const minOffset = 1.0
|
|
403
213
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
options: ColorPreset[],
|
|
408
|
-
value: string,
|
|
409
|
-
onChange: (key: string) => void,
|
|
410
|
-
): HTMLSelectElement {
|
|
411
|
-
const row = document.createElement('label')
|
|
412
|
-
row.className = 'controls__row'
|
|
413
|
-
|
|
414
|
-
const text = document.createElement('span')
|
|
415
|
-
text.textContent = label
|
|
416
|
-
|
|
417
|
-
const select = document.createElement('select')
|
|
418
|
-
select.className = 'controls__select'
|
|
419
|
-
for (const option of options) {
|
|
420
|
-
const optEl = document.createElement('option')
|
|
421
|
-
optEl.value = option.key
|
|
422
|
-
optEl.textContent = option.label
|
|
423
|
-
select.appendChild(optEl)
|
|
424
|
-
}
|
|
425
|
-
select.value = value
|
|
426
|
-
select.addEventListener('change', (event) => {
|
|
427
|
-
const next = (event.target as HTMLSelectElement).value
|
|
428
|
-
onChange(next)
|
|
429
|
-
})
|
|
214
|
+
// Calculate desired offset based on window width
|
|
215
|
+
// Each field should take ~45% of camera width, leaving ~10% gap
|
|
216
|
+
const desiredOffset = (cameraWidth * 0.45) / 2
|
|
430
217
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
218
|
+
// Account for controls panel (240px + 18px margin = 258px on right side)
|
|
219
|
+
// Only apply panel constraint if window is narrow
|
|
220
|
+
const panelPixels = 258
|
|
221
|
+
const panelCameraUnits = (panelPixels / window.innerWidth) * cameraWidth
|
|
222
|
+
const availableWidth = cameraWidth - panelCameraUnits - 0.5
|
|
223
|
+
const offsetWithPanelConstraint = availableWidth / 2
|
|
434
224
|
|
|
435
|
-
|
|
436
|
-
|
|
225
|
+
// Final offset: respect visual maximum, but reduce if panel constrains space
|
|
226
|
+
const maxOffset = Math.min(maxVisualOffset, offsetWithPanelConstraint)
|
|
437
227
|
|
|
438
|
-
|
|
439
|
-
bloomPass.strength = params.bloomStrength
|
|
440
|
-
bloomPass.radius = params.bloomRadius
|
|
228
|
+
return Math.max(minOffset, Math.min(desiredOffset, maxOffset))
|
|
441
229
|
}
|
|
442
230
|
|
|
443
|
-
function
|
|
444
|
-
|
|
231
|
+
function clearField(target: 'left' | 'right') {
|
|
232
|
+
const transform = { scale: 1, offsetX: 0, offsetY: 0 }
|
|
233
|
+
if (target === 'left') {
|
|
234
|
+
particleSystemA.setFieldData(null, transform)
|
|
235
|
+
particleSystemA.reseedLifetimes()
|
|
236
|
+
fieldLoaderA.updateStatus('default (cleared)')
|
|
237
|
+
hasFieldA = false
|
|
238
|
+
} else {
|
|
239
|
+
particleSystemB.setFieldData(null, transform)
|
|
240
|
+
particleSystemB.reseedLifetimes()
|
|
241
|
+
fieldLoaderB.updateStatus('default (cleared)')
|
|
242
|
+
hasFieldB = false
|
|
243
|
+
}
|
|
244
|
+
updateLayout()
|
|
445
245
|
}
|
|
446
246
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
247
|
+
// Resize handler
|
|
248
|
+
function resize() {
|
|
249
|
+
const width = window.innerWidth
|
|
250
|
+
const height = window.innerHeight
|
|
251
|
+
const aspect = width / height
|
|
451
252
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
253
|
+
camera.left = -VIEW_SIZE * aspect
|
|
254
|
+
camera.right = VIEW_SIZE * aspect
|
|
255
|
+
camera.top = VIEW_SIZE
|
|
256
|
+
camera.bottom = -VIEW_SIZE
|
|
257
|
+
camera.updateProjectionMatrix()
|
|
455
258
|
|
|
456
|
-
|
|
457
|
-
|
|
259
|
+
renderer.setSize(width, height, false)
|
|
260
|
+
composer.setSize(width, height)
|
|
261
|
+
bloomPass.setSize(width, height)
|
|
262
|
+
updateLayout()
|
|
458
263
|
}
|
|
459
264
|
|
|
460
|
-
function
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
265
|
+
function updateLayout() {
|
|
266
|
+
const count = (hasFieldA ? 1 : 0) + (hasFieldB ? 1 : 0)
|
|
267
|
+
const offset = computeViewOffset()
|
|
268
|
+
|
|
269
|
+
if (count === 2) {
|
|
270
|
+
particlesA.visible = true
|
|
271
|
+
particlesB.visible = true
|
|
272
|
+
particleSystemA.setViewOffset(-offset)
|
|
273
|
+
particleSystemB.setViewOffset(offset)
|
|
274
|
+
} else if (count === 1) {
|
|
275
|
+
if (hasFieldA) {
|
|
276
|
+
particlesA.visible = true
|
|
277
|
+
particlesB.visible = false
|
|
278
|
+
particleSystemA.setViewOffset(0)
|
|
469
279
|
} else {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
280
|
+
particlesA.visible = false
|
|
281
|
+
particlesB.visible = true
|
|
282
|
+
particleSystemB.setViewOffset(0)
|
|
473
283
|
}
|
|
474
|
-
|
|
284
|
+
} else {
|
|
285
|
+
particlesA.visible = true
|
|
286
|
+
particlesB.visible = false
|
|
287
|
+
particleSystemA.setViewOffset(0)
|
|
288
|
+
particleSystemB.setViewOffset(0)
|
|
475
289
|
}
|
|
476
|
-
|
|
477
|
-
// For other palettes, scale their hue by brightness without white mixing
|
|
478
|
-
const brightness = isSpawn ? clampedGlow : 0.35 + clampedGlow * 0.65
|
|
479
|
-
const [r, g, b] = palette.rgb
|
|
480
|
-
colors[i3] = r * brightness
|
|
481
|
-
colors[i3 + 1] = g * brightness
|
|
482
|
-
colors[i3 + 2] = b * brightness
|
|
483
290
|
}
|
|
484
291
|
|
|
485
|
-
function
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
292
|
+
function updateTrails(enabled: boolean, decay: number) {
|
|
293
|
+
params.trailsEnabled = enabled
|
|
294
|
+
params.trailDecay = decay
|
|
295
|
+
afterimagePass.enabled = enabled
|
|
296
|
+
afterimagePass.uniforms['damp'].value = decay
|
|
489
297
|
}
|
|
490
298
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
geometry.setAttribute('position', new BufferAttribute(positions, 3))
|
|
496
|
-
geometry.setAttribute('color', new BufferAttribute(colors, 3))
|
|
497
|
-
initParticles()
|
|
498
|
-
}
|
|
299
|
+
// Animation loop
|
|
300
|
+
let lastTime = performance.now()
|
|
301
|
+
let cachedPresetA = COLOR_PRESETS.find((p) => p.key === params.colorPresetA) ?? COLOR_PRESETS[0]
|
|
302
|
+
let cachedPresetB = COLOR_PRESETS.find((p) => p.key === params.colorPresetB) ?? COLOR_PRESETS[0]
|
|
499
303
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
updateFieldStatus('default (built-in)')
|
|
505
|
-
return
|
|
506
|
-
}
|
|
507
|
-
const data = (await res.json()) as VectorDatum[]
|
|
508
|
-
if (Array.isArray(data) && data.length > 0) {
|
|
509
|
-
// Compute bounding box
|
|
510
|
-
let minX = data[0].x
|
|
511
|
-
let maxX = data[0].x
|
|
512
|
-
let minY = data[0].y
|
|
513
|
-
let maxY = data[0].y
|
|
514
|
-
for (const d of data) {
|
|
515
|
-
if (d.x < minX) minX = d.x
|
|
516
|
-
if (d.x > maxX) maxX = d.x
|
|
517
|
-
if (d.y < minY) minY = d.y
|
|
518
|
-
if (d.y > maxY) maxY = d.y
|
|
519
|
-
}
|
|
520
|
-
const dataWidth = maxX - minX
|
|
521
|
-
const dataHeight = maxY - minY
|
|
522
|
-
const dataSize = Math.max(dataWidth, dataHeight)
|
|
523
|
-
|
|
524
|
-
// Normalize to fit within [-WORLD_EXTENT, WORLD_EXTENT]
|
|
525
|
-
const targetSize = WORLD_EXTENT * 1.8 // leave some margin
|
|
526
|
-
fieldTransform.scale = dataSize > 0 ? targetSize / dataSize : 1
|
|
527
|
-
fieldTransform.offsetX = -(minX + maxX) * 0.5 * fieldTransform.scale
|
|
528
|
-
fieldTransform.offsetY = -(minY + maxY) * 0.5 * fieldTransform.scale
|
|
529
|
-
|
|
530
|
-
// Auto-compute field valid distance from average nearest-neighbor spacing
|
|
531
|
-
let sumDist = 0
|
|
532
|
-
const sampleSize = Math.min(data.length, 200) // sample subset for efficiency
|
|
533
|
-
for (let i = 0; i < sampleSize; i += 1) {
|
|
534
|
-
const pt = data[i]
|
|
535
|
-
let minDist = Number.MAX_VALUE
|
|
536
|
-
for (let j = 0; j < data.length; j += 1) {
|
|
537
|
-
if (i === j) continue
|
|
538
|
-
const dx = data[j].x - pt.x
|
|
539
|
-
const dy = data[j].y - pt.y
|
|
540
|
-
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
541
|
-
if (dist < minDist) minDist = dist
|
|
542
|
-
}
|
|
543
|
-
sumDist += minDist
|
|
544
|
-
}
|
|
545
|
-
const avgSpacing = sumDist / sampleSize
|
|
546
|
-
const autoThreshold = Math.min(
|
|
547
|
-
FIELD_BORDER_MAX,
|
|
548
|
-
Math.max(FIELD_BORDER_MIN, avgSpacing * fieldTransform.scale * 0.7),
|
|
549
|
-
) // 70% of avg spacing, clamped to UI range
|
|
550
|
-
params.fieldValidDistance = autoThreshold
|
|
551
|
-
if (fieldDistControlHandle) {
|
|
552
|
-
syncSlider(fieldDistControlHandle, params.fieldValidDistance)
|
|
553
|
-
}
|
|
554
|
-
console.log('Auto field border:', autoThreshold.toFixed(4), 'avg spacing:', avgSpacing.toFixed(4))
|
|
304
|
+
function animate(timestamp: number) {
|
|
305
|
+
const now = timestamp || performance.now()
|
|
306
|
+
const dt = Math.min(0.033, (now - lastTime) / 1000)
|
|
307
|
+
lastTime = now
|
|
555
308
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
console.log('Field bounds:', { minX, maxX, minY, maxY, scale: fieldTransform.scale })
|
|
559
|
-
} else {
|
|
560
|
-
updateFieldStatus('default (empty file)')
|
|
561
|
-
}
|
|
562
|
-
} catch (error) {
|
|
563
|
-
console.error('Failed to load vector field', error)
|
|
564
|
-
updateFieldStatus('default (load error)')
|
|
565
|
-
}
|
|
566
|
-
}
|
|
309
|
+
particleSystemA.update(dt, cachedPresetA)
|
|
310
|
+
particleSystemB.update(dt, cachedPresetB)
|
|
567
311
|
|
|
568
|
-
|
|
569
|
-
|
|
312
|
+
composer.render()
|
|
313
|
+
recordingManager.update()
|
|
314
|
+
|
|
315
|
+
requestAnimationFrame(animate)
|
|
570
316
|
}
|
|
317
|
+
|
|
318
|
+
// Initialize
|
|
319
|
+
createOverlay()
|
|
320
|
+
controlPanel.create()
|
|
321
|
+
recordingManager.createControls(container.querySelector('.controls')!)
|
|
322
|
+
resize()
|
|
323
|
+
window.addEventListener('resize', resize)
|
|
324
|
+
fieldLoaderA.load()
|
|
325
|
+
fieldLoaderB.load()
|
|
326
|
+
setupDragAndDrop()
|
|
327
|
+
animate(0)
|