@brandonlukas/luminar 0.2.0 → 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 +87 -50
- 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/package.json +1 -1
- 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 +240 -690
- 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 +75 -0
- package/dist/assets/index-BHq4NTuF.css +0 -1
- package/dist/assets/index-Dn5q-yAE.js +0 -4151
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,686 +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
|
-
|
|
106
|
-
const particles = new Points(geometry, material)
|
|
107
|
-
scene.add(particles)
|
|
108
|
-
|
|
109
|
-
let fieldData: VectorDatum[] | null = null
|
|
110
|
-
let fieldStatusEl: HTMLElement | null = null
|
|
111
|
-
let fieldTransform = { scale: 1, offsetX: 0, offsetY: 0 }
|
|
112
|
-
let fieldDistControlHandle: SliderHandle | null = null
|
|
113
|
-
|
|
114
|
-
let mediaRecorder: MediaRecorder | null = null
|
|
115
|
-
let recordedChunks: Blob[] = []
|
|
116
|
-
let isRecording = false
|
|
117
|
-
let recordingStartTime = 0
|
|
118
|
-
let recordingDuration = 5 // seconds
|
|
119
|
-
let recordingResolution: 'current' | '1080p' | '1440p' | '4k' = 'current'
|
|
120
|
-
let recordButton: HTMLButtonElement | null = null
|
|
121
|
-
let recordStatus: HTMLDivElement | null = null
|
|
122
|
-
let originalCanvasSize: { width: number; height: number } | null = null
|
|
123
|
-
|
|
124
|
-
initParticles()
|
|
125
|
-
resize()
|
|
126
|
-
window.addEventListener('resize', resize)
|
|
127
|
-
let lastTime = performance.now()
|
|
128
|
-
animate(0)
|
|
129
|
-
createOverlay()
|
|
130
|
-
createControls()
|
|
131
|
-
reseedLifetimes()
|
|
132
|
-
loadVectorField()
|
|
133
|
-
|
|
134
|
-
function initParticles() {
|
|
135
|
-
for (let i = 0; i < params.particleCount; i += 1) {
|
|
136
|
-
resetParticle(i)
|
|
137
|
-
}
|
|
138
|
-
geometry.attributes.position.needsUpdate = true
|
|
139
|
-
geometry.attributes.color.needsUpdate = true
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function resetParticle(i: number) {
|
|
143
|
-
const i3 = i * 3
|
|
144
|
-
const palette = getActiveColorPreset()
|
|
145
|
-
|
|
146
|
-
// If field data exists, spawn particles near random data points (within valid region)
|
|
147
|
-
if (fieldData && fieldData.length > 0) {
|
|
148
|
-
const randomPoint = fieldData[Math.floor(Math.random() * fieldData.length)]
|
|
149
|
-
const jitterRange = params.fieldValidDistance * 0.3
|
|
150
|
-
const dataX = randomPoint.x + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
|
|
151
|
-
const dataY = randomPoint.y + randomRange(-jitterRange, jitterRange) / fieldTransform.scale
|
|
152
|
-
positions[i3] = dataX * fieldTransform.scale + fieldTransform.offsetX
|
|
153
|
-
positions[i3 + 1] = dataY * fieldTransform.scale + fieldTransform.offsetY
|
|
154
|
-
} else {
|
|
155
|
-
// Fallback: uniform spawning across entire world
|
|
156
|
-
positions[i3] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
|
|
157
|
-
positions[i3 + 1] = randomRange(-WORLD_EXTENT, WORLD_EXTENT)
|
|
158
|
-
}
|
|
159
|
-
positions[i3 + 2] = 0
|
|
160
|
-
|
|
161
|
-
lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
|
|
162
|
-
|
|
163
|
-
const glow = 0.4 + Math.random() * 0.2
|
|
164
|
-
applyColor(i3, glow, palette, true)
|
|
165
|
-
}
|
|
166
56
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const dataY = (y - fieldTransform.offsetY) / fieldTransform.scale
|
|
172
|
-
|
|
173
|
-
let nearest = fieldData[0]
|
|
174
|
-
let bestDist = Number.MAX_VALUE
|
|
175
|
-
for (let i = 0; i < fieldData.length; i += 1) {
|
|
176
|
-
const d = fieldData[i]
|
|
177
|
-
const dx = d.x - dataX
|
|
178
|
-
const dy = d.y - dataY
|
|
179
|
-
const dist = dx * dx + dy * dy
|
|
180
|
-
if (dist < bestDist) {
|
|
181
|
-
bestDist = dist
|
|
182
|
-
nearest = d
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
const threshold = params.fieldValidDistance / fieldTransform.scale
|
|
186
|
-
if (Math.sqrt(bestDist) > threshold) {
|
|
187
|
-
return null
|
|
188
|
-
}
|
|
189
|
-
// Scale the vector components by the same factor
|
|
190
|
-
return { x: nearest.dx * fieldTransform.scale, y: nearest.dy * fieldTransform.scale }
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Fallback: uniform rightward flow
|
|
194
|
-
return { x: 1, y: 0 }
|
|
195
|
-
}
|
|
57
|
+
const particlesA = new Points(geometryA, material)
|
|
58
|
+
const particlesB = new Points(geometryB, material)
|
|
59
|
+
scene.add(particlesA)
|
|
60
|
+
scene.add(particlesB)
|
|
196
61
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const palette = getActiveColorPreset()
|
|
203
|
-
|
|
204
|
-
for (let i = 0; i < params.particleCount; i += 1) {
|
|
205
|
-
const i3 = i * 3
|
|
206
|
-
let x = positions[i3]
|
|
207
|
-
let y = positions[i3 + 1]
|
|
208
|
-
|
|
209
|
-
const v = sampleField(x, y, time)
|
|
210
|
-
if (!v) {
|
|
211
|
-
resetParticle(i)
|
|
212
|
-
continue
|
|
213
|
-
}
|
|
62
|
+
// Initialize modules
|
|
63
|
+
const particleSystemA = new ParticleSystem(geometryA, params)
|
|
64
|
+
const particleSystemB = new ParticleSystem(geometryB, params)
|
|
65
|
+
particleSystemA.init()
|
|
66
|
+
particleSystemB.init()
|
|
214
67
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const speed = Math.hypot(v.x, v.y)
|
|
219
|
-
const glow = Math.min(1, speed * SPEED_TO_GLOW)
|
|
220
|
-
|
|
221
|
-
applyColor(i3, glow, palette, false)
|
|
222
|
-
|
|
223
|
-
lifetimes[i] -= dt
|
|
224
|
-
|
|
225
|
-
if (lifetimes[i] <= 0 || Math.abs(x) > WORLD_EXTENT || Math.abs(y) > WORLD_EXTENT) {
|
|
226
|
-
resetParticle(i)
|
|
227
|
-
} else {
|
|
228
|
-
positions[i3] = x
|
|
229
|
-
positions[i3 + 1] = y
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
geometry.attributes.position.needsUpdate = true
|
|
234
|
-
geometry.attributes.color.needsUpdate = true
|
|
235
|
-
|
|
236
|
-
composer.render()
|
|
68
|
+
let hasFieldA = false
|
|
69
|
+
let hasFieldB = false
|
|
237
70
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
requestAnimationFrame(animate)
|
|
247
|
-
}
|
|
71
|
+
const fieldLoaderA = new FieldLoader((data, transform) => {
|
|
72
|
+
particleSystemA.setFieldData(data, transform)
|
|
73
|
+
hasFieldA = particleSystemA.hasFieldData()
|
|
74
|
+
updateLayout()
|
|
75
|
+
})
|
|
248
76
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
77
|
+
const fieldLoaderB = new FieldLoader((data, transform) => {
|
|
78
|
+
particleSystemB.setFieldData(data, transform)
|
|
79
|
+
hasFieldB = particleSystemB.hasFieldData()
|
|
80
|
+
updateLayout()
|
|
81
|
+
})
|
|
254
82
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
})
|
|
260
98
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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]
|
|
264
102
|
}
|
|
265
103
|
|
|
266
|
-
|
|
267
|
-
return min + Math.random() * (max - min)
|
|
268
|
-
}
|
|
104
|
+
const recordingManager = new RecordingManager(renderer, composer, bloomPass, resize)
|
|
269
105
|
|
|
106
|
+
// HUD overlay
|
|
270
107
|
function createOverlay() {
|
|
108
|
+
if (!container) return
|
|
271
109
|
const hud = document.createElement('div')
|
|
272
110
|
hud.className = 'hud'
|
|
273
|
-
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>`
|
|
274
|
-
|
|
275
|
-
|
|
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'))
|
|
276
115
|
}
|
|
277
116
|
|
|
278
|
-
function
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
advancedSection.className = 'controls__advanced'
|
|
302
|
-
advancedSection.style.display = 'none'
|
|
303
|
-
|
|
304
|
-
const sizeControl = addSlider(advancedSection, 'Size', 0.5, 4, 0.1, params.size, (value) => {
|
|
305
|
-
params.size = value
|
|
306
|
-
material.size = value
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
const particleCountControl = addSlider(advancedSection, 'Particle count', 100, 8000, 100, params.particleCount, (value) => {
|
|
310
|
-
params.particleCount = Math.round(value)
|
|
311
|
-
resizeParticleBuffers()
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
const bloomStrengthControl = addSlider(advancedSection, 'Bloom strength', 0.2, 2.5, 0.05, params.bloomStrength, (value) => {
|
|
315
|
-
params.bloomStrength = value
|
|
316
|
-
updateBloom()
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
const bloomRadiusControl = addSlider(advancedSection, 'Bloom radius', 0.0, 1.2, 0.02, params.bloomRadius, (value) => {
|
|
320
|
-
params.bloomRadius = value
|
|
321
|
-
updateBloom()
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
let lifeMinControl: SliderHandle
|
|
325
|
-
let lifeMaxControl: SliderHandle
|
|
326
|
-
|
|
327
|
-
lifeMinControl = addSlider(advancedSection, 'Life min (s)', 0.1, 2.0, 0.05, params.lifeMin, (value) => {
|
|
328
|
-
params.lifeMin = value
|
|
329
|
-
if (params.lifeMin > params.lifeMax) {
|
|
330
|
-
params.lifeMax = value
|
|
331
|
-
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'
|
|
332
140
|
}
|
|
333
|
-
|
|
334
|
-
|
|
141
|
+
}
|
|
142
|
+
const hideOverlay = () => {
|
|
143
|
+
dropOverlayLeft.style.display = 'none'
|
|
144
|
+
dropOverlayRight.style.display = 'none'
|
|
145
|
+
}
|
|
335
146
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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()
|
|
341
180
|
}
|
|
342
|
-
|
|
343
|
-
})
|
|
181
|
+
}
|
|
344
182
|
|
|
345
|
-
|
|
346
|
-
|
|
183
|
+
window.addEventListener('dragover', (e) => {
|
|
184
|
+
e.preventDefault()
|
|
185
|
+
const target = e.clientX < window.innerWidth * 0.5 ? 'left' : 'right'
|
|
186
|
+
showOverlay(target)
|
|
347
187
|
})
|
|
348
|
-
fieldDistControlHandle = fieldDistControl
|
|
349
188
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
advancedToggle.textContent = isHidden ? 'Hide advanced' : 'Show advanced'
|
|
189
|
+
window.addEventListener('dragleave', (e) => {
|
|
190
|
+
e.preventDefault()
|
|
191
|
+
hideOverlay()
|
|
354
192
|
})
|
|
355
193
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
recordingSection.innerHTML = '<div class="controls__subtitle">Export (WebM)</div>'
|
|
363
|
-
|
|
364
|
-
const resolutionRow = document.createElement('label')
|
|
365
|
-
resolutionRow.className = 'controls__row'
|
|
366
|
-
const resolutionLabel = document.createElement('span')
|
|
367
|
-
resolutionLabel.textContent = 'Resolution'
|
|
368
|
-
const resolutionSelect = document.createElement('select')
|
|
369
|
-
resolutionSelect.className = 'controls__select'
|
|
370
|
-
resolutionSelect.innerHTML = '<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>'
|
|
371
|
-
resolutionSelect.value = recordingResolution
|
|
372
|
-
resolutionSelect.addEventListener('change', (e) => {
|
|
373
|
-
recordingResolution = (e.target as HTMLSelectElement).value as typeof recordingResolution
|
|
374
|
-
})
|
|
375
|
-
resolutionRow.appendChild(resolutionLabel)
|
|
376
|
-
resolutionRow.appendChild(resolutionSelect)
|
|
377
|
-
recordingSection.appendChild(resolutionRow)
|
|
378
|
-
|
|
379
|
-
const durationRow = document.createElement('label')
|
|
380
|
-
durationRow.className = 'controls__row'
|
|
381
|
-
const durationLabel = document.createElement('span')
|
|
382
|
-
durationLabel.textContent = 'Duration'
|
|
383
|
-
const durationSelect = document.createElement('select')
|
|
384
|
-
durationSelect.className = 'controls__select'
|
|
385
|
-
durationSelect.innerHTML = '<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>'
|
|
386
|
-
durationSelect.value = String(recordingDuration)
|
|
387
|
-
durationSelect.addEventListener('change', (e) => {
|
|
388
|
-
recordingDuration = parseInt((e.target as HTMLSelectElement).value)
|
|
389
|
-
})
|
|
390
|
-
durationRow.appendChild(durationLabel)
|
|
391
|
-
durationRow.appendChild(durationSelect)
|
|
392
|
-
recordingSection.appendChild(durationRow)
|
|
393
|
-
|
|
394
|
-
recordButton = document.createElement('button')
|
|
395
|
-
recordButton.type = 'button'
|
|
396
|
-
recordButton.className = 'controls__button controls__button--record'
|
|
397
|
-
recordButton.textContent = '⏺ Start recording'
|
|
398
|
-
recordButton.addEventListener('click', () => {
|
|
399
|
-
if (isRecording) {
|
|
400
|
-
stopRecording()
|
|
401
|
-
} else {
|
|
402
|
-
startRecording()
|
|
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)
|
|
403
200
|
}
|
|
404
201
|
})
|
|
405
|
-
recordingSection.appendChild(recordButton)
|
|
406
|
-
|
|
407
|
-
recordStatus = document.createElement('div')
|
|
408
|
-
recordStatus.className = 'controls__status'
|
|
409
|
-
recordStatus.style.display = 'none'
|
|
410
|
-
recordingSection.appendChild(recordStatus)
|
|
411
|
-
|
|
412
|
-
panel.appendChild(recordingSection)
|
|
413
|
-
|
|
414
|
-
const resetBtn = document.createElement('button')
|
|
415
|
-
resetBtn.type = 'button'
|
|
416
|
-
resetBtn.className = 'controls__button'
|
|
417
|
-
resetBtn.textContent = 'Reset to defaults'
|
|
418
|
-
resetBtn.addEventListener('click', () => {
|
|
419
|
-
Object.assign(params, defaultParams)
|
|
420
|
-
material.size = params.size
|
|
421
|
-
updateBloom()
|
|
422
|
-
reseedLifetimes()
|
|
423
|
-
syncSlider(sizeControl, params.size)
|
|
424
|
-
syncSlider(speedControl, params.speed)
|
|
425
|
-
syncSlider(particleCountControl, params.particleCount)
|
|
426
|
-
syncSlider(bloomStrengthControl, params.bloomStrength)
|
|
427
|
-
syncSlider(bloomRadiusControl, params.bloomRadius)
|
|
428
|
-
syncSlider(lifeMinControl, params.lifeMin)
|
|
429
|
-
syncSlider(lifeMaxControl, params.lifeMax)
|
|
430
|
-
syncSlider(fieldDistControl, params.fieldValidDistance)
|
|
431
|
-
syncSelect(colorControl, params.colorPreset)
|
|
432
|
-
})
|
|
433
|
-
panel.appendChild(resetBtn)
|
|
434
|
-
|
|
435
|
-
app.appendChild(panel)
|
|
436
202
|
}
|
|
437
203
|
|
|
438
|
-
function
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
min: number,
|
|
442
|
-
max: number,
|
|
443
|
-
step: number,
|
|
444
|
-
value: number,
|
|
445
|
-
onChange: (value: number) => void,
|
|
446
|
-
): SliderHandle {
|
|
447
|
-
const row = document.createElement('label')
|
|
448
|
-
row.className = 'controls__row'
|
|
449
|
-
|
|
450
|
-
const text = document.createElement('span')
|
|
451
|
-
text.textContent = label
|
|
452
|
-
|
|
453
|
-
const input = document.createElement('input')
|
|
454
|
-
input.type = 'range'
|
|
455
|
-
input.min = String(min)
|
|
456
|
-
input.max = String(max)
|
|
457
|
-
input.step = String(step)
|
|
458
|
-
input.value = String(value)
|
|
459
|
-
|
|
460
|
-
const valueTag = document.createElement('span')
|
|
461
|
-
valueTag.className = 'controls__value'
|
|
462
|
-
valueTag.textContent = formatValue(value, step)
|
|
463
|
-
|
|
464
|
-
input.addEventListener('input', (event) => {
|
|
465
|
-
const next = parseFloat((event.target as HTMLInputElement).value)
|
|
466
|
-
valueTag.textContent = formatValue(next, step)
|
|
467
|
-
onChange(next)
|
|
468
|
-
})
|
|
204
|
+
function computeViewOffset(): number {
|
|
205
|
+
const aspect = window.innerWidth / window.innerHeight
|
|
206
|
+
const cameraWidth = VIEW_SIZE * aspect * 2
|
|
469
207
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
row.appendChild(valueTag)
|
|
473
|
-
panel.appendChild(row)
|
|
208
|
+
// Visual sweet spot: fields shouldn't spread beyond this on wide windows
|
|
209
|
+
const maxVisualOffset = 1.4
|
|
474
210
|
|
|
475
|
-
|
|
476
|
-
|
|
211
|
+
// Minimum separation
|
|
212
|
+
const minOffset = 1.0
|
|
477
213
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
options: ColorPreset[],
|
|
482
|
-
value: string,
|
|
483
|
-
onChange: (key: string) => void,
|
|
484
|
-
): HTMLSelectElement {
|
|
485
|
-
const row = document.createElement('label')
|
|
486
|
-
row.className = 'controls__row'
|
|
487
|
-
|
|
488
|
-
const text = document.createElement('span')
|
|
489
|
-
text.textContent = label
|
|
490
|
-
|
|
491
|
-
const select = document.createElement('select')
|
|
492
|
-
select.className = 'controls__select'
|
|
493
|
-
for (const option of options) {
|
|
494
|
-
const optEl = document.createElement('option')
|
|
495
|
-
optEl.value = option.key
|
|
496
|
-
optEl.textContent = option.label
|
|
497
|
-
select.appendChild(optEl)
|
|
498
|
-
}
|
|
499
|
-
select.value = value
|
|
500
|
-
select.addEventListener('change', (event) => {
|
|
501
|
-
const next = (event.target as HTMLSelectElement).value
|
|
502
|
-
onChange(next)
|
|
503
|
-
})
|
|
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
|
|
504
217
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
508
224
|
|
|
509
|
-
|
|
510
|
-
|
|
225
|
+
// Final offset: respect visual maximum, but reduce if panel constrains space
|
|
226
|
+
const maxOffset = Math.min(maxVisualOffset, offsetWithPanelConstraint)
|
|
511
227
|
|
|
512
|
-
|
|
513
|
-
bloomPass.strength = params.bloomStrength
|
|
514
|
-
bloomPass.radius = params.bloomRadius
|
|
228
|
+
return Math.max(minOffset, Math.min(desiredOffset, maxOffset))
|
|
515
229
|
}
|
|
516
230
|
|
|
517
|
-
function
|
|
518
|
-
|
|
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()
|
|
519
245
|
}
|
|
520
246
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
247
|
+
// Resize handler
|
|
248
|
+
function resize() {
|
|
249
|
+
const width = window.innerWidth
|
|
250
|
+
const height = window.innerHeight
|
|
251
|
+
const aspect = width / height
|
|
525
252
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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()
|
|
529
258
|
|
|
530
|
-
|
|
531
|
-
|
|
259
|
+
renderer.setSize(width, height, false)
|
|
260
|
+
composer.setSize(width, height)
|
|
261
|
+
bloomPass.setSize(width, height)
|
|
262
|
+
updateLayout()
|
|
532
263
|
}
|
|
533
264
|
|
|
534
|
-
function
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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)
|
|
543
279
|
} else {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
280
|
+
particlesA.visible = false
|
|
281
|
+
particlesB.visible = true
|
|
282
|
+
particleSystemB.setViewOffset(0)
|
|
547
283
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const [r, g, b] = palette.rgb
|
|
554
|
-
colors[i3] = r * brightness
|
|
555
|
-
colors[i3 + 1] = g * brightness
|
|
556
|
-
colors[i3 + 2] = b * brightness
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
function reseedLifetimes() {
|
|
560
|
-
for (let i = 0; i < params.particleCount; i += 1) {
|
|
561
|
-
lifetimes[i] = randomRange(params.lifeMin, params.lifeMax)
|
|
284
|
+
} else {
|
|
285
|
+
particlesA.visible = true
|
|
286
|
+
particlesB.visible = false
|
|
287
|
+
particleSystemA.setViewOffset(0)
|
|
288
|
+
particleSystemB.setViewOffset(0)
|
|
562
289
|
}
|
|
563
290
|
}
|
|
564
291
|
|
|
565
|
-
function
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
geometry.setAttribute('color', new BufferAttribute(colors, 3))
|
|
571
|
-
initParticles()
|
|
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
|
|
572
297
|
}
|
|
573
298
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const canvas = renderer.domElement
|
|
579
|
-
|
|
580
|
-
// Determine recording resolution
|
|
581
|
-
let recordWidth: number, recordHeight: number
|
|
582
|
-
const currentAspect = canvas.width / canvas.height
|
|
583
|
-
|
|
584
|
-
if (recordingResolution === 'current') {
|
|
585
|
-
recordWidth = canvas.width
|
|
586
|
-
recordHeight = canvas.height
|
|
587
|
-
} else {
|
|
588
|
-
// Save original size and resize for recording
|
|
589
|
-
originalCanvasSize = { width: canvas.width, height: canvas.height }
|
|
590
|
-
|
|
591
|
-
switch (recordingResolution) {
|
|
592
|
-
case '1080p':
|
|
593
|
-
recordHeight = 1080
|
|
594
|
-
recordWidth = Math.round(recordHeight * currentAspect)
|
|
595
|
-
break
|
|
596
|
-
case '1440p':
|
|
597
|
-
recordHeight = 1440
|
|
598
|
-
recordWidth = Math.round(recordHeight * currentAspect)
|
|
599
|
-
break
|
|
600
|
-
case '4k':
|
|
601
|
-
recordHeight = 2160
|
|
602
|
-
recordWidth = Math.round(recordHeight * currentAspect)
|
|
603
|
-
break
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Resize canvas for recording
|
|
607
|
-
renderer.setSize(recordWidth, recordHeight, false)
|
|
608
|
-
composer.setSize(recordWidth, recordHeight)
|
|
609
|
-
bloomPass.setSize(recordWidth, recordHeight)
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
const stream = canvas.captureStream(60) // 60 fps
|
|
613
|
-
|
|
614
|
-
// Calculate bitrate based on resolution (higher res = higher bitrate)
|
|
615
|
-
const pixelCount = recordWidth * recordHeight
|
|
616
|
-
const bitrate = Math.min(25000000, Math.max(8000000, pixelCount * 4)) // 4 bits per pixel, capped at 25 Mbps
|
|
617
|
-
|
|
618
|
-
recordedChunks = []
|
|
619
|
-
mediaRecorder = new MediaRecorder(stream, {
|
|
620
|
-
mimeType: 'video/webm;codecs=vp9',
|
|
621
|
-
videoBitsPerSecond: bitrate,
|
|
622
|
-
})
|
|
623
|
-
|
|
624
|
-
mediaRecorder.ondataavailable = (event) => {
|
|
625
|
-
if (event.data.size > 0) {
|
|
626
|
-
recordedChunks.push(event.data)
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
mediaRecorder.onstop = () => {
|
|
631
|
-
// Restore original canvas size if we resized
|
|
632
|
-
if (originalCanvasSize) {
|
|
633
|
-
renderer.setSize(originalCanvasSize.width, originalCanvasSize.height, false)
|
|
634
|
-
composer.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
635
|
-
bloomPass.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
636
|
-
originalCanvasSize = null
|
|
637
|
-
resize() // Trigger full resize to ensure proper state
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const blob = new Blob(recordedChunks, { type: 'video/webm' })
|
|
641
|
-
const url = URL.createObjectURL(blob)
|
|
642
|
-
const a = document.createElement('a')
|
|
643
|
-
a.href = url
|
|
644
|
-
a.download = `luminar-${recordWidth}x${recordHeight}-${Date.now()}.webm`
|
|
645
|
-
a.click()
|
|
646
|
-
URL.revokeObjectURL(url)
|
|
647
|
-
|
|
648
|
-
if (recordStatus) {
|
|
649
|
-
recordStatus.textContent = 'Recording complete! Download started.'
|
|
650
|
-
setTimeout(() => {
|
|
651
|
-
if (recordStatus) recordStatus.style.display = 'none'
|
|
652
|
-
}, 3000)
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
mediaRecorder.start()
|
|
657
|
-
isRecording = true
|
|
658
|
-
recordingStartTime = performance.now()
|
|
659
|
-
|
|
660
|
-
if (recordButton) {
|
|
661
|
-
recordButton.textContent = '⏹ Stop recording'
|
|
662
|
-
recordButton.style.opacity = '1'
|
|
663
|
-
}
|
|
664
|
-
if (recordStatus) {
|
|
665
|
-
recordStatus.style.display = 'block'
|
|
666
|
-
recordStatus.textContent = `Recording at ${recordWidth}x${recordHeight} (${(bitrate / 1000000).toFixed(0)} Mbps): 0.0s / ${recordingDuration}s`
|
|
667
|
-
}
|
|
668
|
-
} catch (error) {
|
|
669
|
-
console.error('Failed to start recording:', error)
|
|
670
|
-
// Restore size if recording failed
|
|
671
|
-
if (originalCanvasSize) {
|
|
672
|
-
renderer.setSize(originalCanvasSize.width, originalCanvasSize.height, false)
|
|
673
|
-
composer.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
674
|
-
bloomPass.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
675
|
-
originalCanvasSize = null
|
|
676
|
-
resize()
|
|
677
|
-
}
|
|
678
|
-
if (recordStatus) {
|
|
679
|
-
recordStatus.style.display = 'block'
|
|
680
|
-
recordStatus.textContent = 'Recording not supported in this browser.'
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function stopRecording() {
|
|
686
|
-
if (!isRecording || !mediaRecorder) return
|
|
687
|
-
|
|
688
|
-
isRecording = false
|
|
689
|
-
mediaRecorder.stop()
|
|
690
|
-
mediaRecorder = null
|
|
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]
|
|
691
303
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
}
|
|
304
|
+
function animate(timestamp: number) {
|
|
305
|
+
const now = timestamp || performance.now()
|
|
306
|
+
const dt = Math.min(0.033, (now - lastTime) / 1000)
|
|
307
|
+
lastTime = now
|
|
697
308
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const current = elapsed.toFixed(1)
|
|
701
|
-
const canvas = renderer.domElement
|
|
702
|
-
const bitrate = Math.min(25000000, Math.max(8000000, canvas.width * canvas.height * 4))
|
|
703
|
-
recordStatus.textContent = `Recording at ${canvas.width}x${canvas.height} (${(bitrate / 1000000).toFixed(0)} Mbps): ${current}s / ${recordingDuration}s`
|
|
704
|
-
}
|
|
705
|
-
}
|
|
309
|
+
particleSystemA.update(dt, cachedPresetA)
|
|
310
|
+
particleSystemB.update(dt, cachedPresetB)
|
|
706
311
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const res = await fetch('/vector-field.json', { cache: 'no-store' })
|
|
710
|
-
if (!res.ok) {
|
|
711
|
-
updateFieldStatus('default (built-in)')
|
|
712
|
-
return
|
|
713
|
-
}
|
|
714
|
-
const data = (await res.json()) as VectorDatum[]
|
|
715
|
-
if (Array.isArray(data) && data.length > 0) {
|
|
716
|
-
// Compute bounding box
|
|
717
|
-
let minX = data[0].x
|
|
718
|
-
let maxX = data[0].x
|
|
719
|
-
let minY = data[0].y
|
|
720
|
-
let maxY = data[0].y
|
|
721
|
-
for (const d of data) {
|
|
722
|
-
if (d.x < minX) minX = d.x
|
|
723
|
-
if (d.x > maxX) maxX = d.x
|
|
724
|
-
if (d.y < minY) minY = d.y
|
|
725
|
-
if (d.y > maxY) maxY = d.y
|
|
726
|
-
}
|
|
727
|
-
const dataWidth = maxX - minX
|
|
728
|
-
const dataHeight = maxY - minY
|
|
729
|
-
const dataSize = Math.max(dataWidth, dataHeight)
|
|
730
|
-
|
|
731
|
-
// Normalize to fit within [-WORLD_EXTENT, WORLD_EXTENT]
|
|
732
|
-
const targetSize = WORLD_EXTENT * 1.8 // leave some margin
|
|
733
|
-
fieldTransform.scale = dataSize > 0 ? targetSize / dataSize : 1
|
|
734
|
-
fieldTransform.offsetX = -(minX + maxX) * 0.5 * fieldTransform.scale
|
|
735
|
-
fieldTransform.offsetY = -(minY + maxY) * 0.5 * fieldTransform.scale
|
|
736
|
-
|
|
737
|
-
// Auto-compute field valid distance from average nearest-neighbor spacing
|
|
738
|
-
let sumDist = 0
|
|
739
|
-
const sampleSize = Math.min(data.length, 200) // sample subset for efficiency
|
|
740
|
-
for (let i = 0; i < sampleSize; i += 1) {
|
|
741
|
-
const pt = data[i]
|
|
742
|
-
let minDist = Number.MAX_VALUE
|
|
743
|
-
for (let j = 0; j < data.length; j += 1) {
|
|
744
|
-
if (i === j) continue
|
|
745
|
-
const dx = data[j].x - pt.x
|
|
746
|
-
const dy = data[j].y - pt.y
|
|
747
|
-
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
748
|
-
if (dist < minDist) minDist = dist
|
|
749
|
-
}
|
|
750
|
-
sumDist += minDist
|
|
751
|
-
}
|
|
752
|
-
const avgSpacing = sumDist / sampleSize
|
|
753
|
-
const autoThreshold = Math.min(
|
|
754
|
-
FIELD_BORDER_MAX,
|
|
755
|
-
Math.max(FIELD_BORDER_MIN, avgSpacing * fieldTransform.scale * 0.7),
|
|
756
|
-
) // 70% of avg spacing, clamped to UI range
|
|
757
|
-
params.fieldValidDistance = autoThreshold
|
|
758
|
-
if (fieldDistControlHandle) {
|
|
759
|
-
syncSlider(fieldDistControlHandle, params.fieldValidDistance)
|
|
760
|
-
}
|
|
761
|
-
console.log('Auto field border:', autoThreshold.toFixed(4), 'avg spacing:', avgSpacing.toFixed(4))
|
|
312
|
+
composer.render()
|
|
313
|
+
recordingManager.update()
|
|
762
314
|
|
|
763
|
-
|
|
764
|
-
updateFieldStatus(`loaded ${data.length} vectors (${dataWidth.toFixed(1)}×${dataHeight.toFixed(1)})`)
|
|
765
|
-
console.log('Field bounds:', { minX, maxX, minY, maxY, scale: fieldTransform.scale })
|
|
766
|
-
} else {
|
|
767
|
-
updateFieldStatus('default (empty file)')
|
|
768
|
-
}
|
|
769
|
-
} catch (error) {
|
|
770
|
-
console.error('Failed to load vector field', error)
|
|
771
|
-
updateFieldStatus('default (load error)')
|
|
772
|
-
}
|
|
315
|
+
requestAnimationFrame(animate)
|
|
773
316
|
}
|
|
774
317
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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)
|