@guinetik/gcanvas 1.0.5 → 2.0.0
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/dist/aizawa.html +27 -0
- package/dist/clifford.html +25 -0
- package/dist/cmb.html +24 -0
- package/dist/dadras.html +26 -0
- package/dist/dejong.html +25 -0
- package/dist/gcanvas.es.js +5130 -372
- package/dist/gcanvas.es.min.js +1 -1
- package/dist/gcanvas.umd.js +1 -1
- package/dist/gcanvas.umd.min.js +1 -1
- package/dist/halvorsen.html +27 -0
- package/dist/index.html +96 -48
- package/dist/js/aizawa.js +425 -0
- package/dist/js/bezier.js +5 -5
- package/dist/js/clifford.js +236 -0
- package/dist/js/cmb.js +594 -0
- package/dist/js/dadras.js +405 -0
- package/dist/js/dejong.js +257 -0
- package/dist/js/halvorsen.js +405 -0
- package/dist/js/isometric.js +34 -46
- package/dist/js/lorenz.js +425 -0
- package/dist/js/painter.js +8 -8
- package/dist/js/rossler.js +480 -0
- package/dist/js/schrodinger.js +314 -18
- package/dist/js/thomas.js +394 -0
- package/dist/lorenz.html +27 -0
- package/dist/rossler.html +27 -0
- package/dist/scene-interactivity-test.html +220 -0
- package/dist/thomas.html +27 -0
- package/package.json +1 -1
- package/readme.md +30 -22
- package/src/game/objects/go.js +7 -0
- package/src/game/objects/index.js +2 -0
- package/src/game/objects/isometric-scene.js +53 -3
- package/src/game/objects/layoutscene.js +57 -0
- package/src/game/objects/mask.js +241 -0
- package/src/game/objects/scene.js +19 -0
- package/src/game/objects/wrapper.js +14 -2
- package/src/game/pipeline.js +17 -0
- package/src/game/ui/button.js +101 -16
- package/src/game/ui/theme.js +0 -6
- package/src/game/ui/togglebutton.js +25 -14
- package/src/game/ui/tooltip.js +12 -4
- package/src/index.js +3 -0
- package/src/io/gesture.js +409 -0
- package/src/io/index.js +4 -1
- package/src/io/keys.js +9 -1
- package/src/io/screen.js +476 -0
- package/src/math/attractors.js +664 -0
- package/src/math/heat.js +106 -0
- package/src/math/index.js +1 -0
- package/src/mixins/draggable.js +15 -19
- package/src/painter/painter.shapes.js +11 -5
- package/src/particle/particle-system.js +165 -1
- package/src/physics/index.js +26 -0
- package/src/physics/physics-updaters.js +333 -0
- package/src/physics/physics.js +375 -0
- package/src/shapes/image.js +5 -5
- package/src/shapes/index.js +2 -0
- package/src/shapes/parallelogram.js +147 -0
- package/src/shapes/righttriangle.js +115 -0
- package/src/shapes/svg.js +281 -100
- package/src/shapes/text.js +22 -6
- package/src/shapes/transformable.js +5 -0
- package/src/sound/effects.js +807 -0
- package/src/sound/index.js +13 -0
- package/src/webgl/index.js +7 -0
- package/src/webgl/shaders/clifford-point-shaders.js +131 -0
- package/src/webgl/shaders/dejong-point-shaders.js +131 -0
- package/src/webgl/shaders/point-sprite-shaders.js +152 -0
- package/src/webgl/webgl-clifford-renderer.js +477 -0
- package/src/webgl/webgl-dejong-renderer.js +472 -0
- package/src/webgl/webgl-line-renderer.js +391 -0
- package/src/webgl/webgl-particle-renderer.js +410 -0
- package/types/index.d.ts +30 -2
- package/types/io.d.ts +217 -0
- package/types/physics.d.ts +299 -0
- package/types/shapes.d.ts +8 -0
- package/types/webgl.d.ts +188 -109
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module physics/physics-updaters
|
|
3
|
+
* @description Composable physics updaters for ParticleSystem.
|
|
4
|
+
*
|
|
5
|
+
* These updaters add physics behavior to particles:
|
|
6
|
+
* - Mutual attraction/repulsion between particles
|
|
7
|
+
* - Particle-particle elastic collisions
|
|
8
|
+
* - 3D boundary bouncing
|
|
9
|
+
* - Gravity fields
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* import { ParticleSystem, PhysicsUpdaters } from '@guinetik/gcanvas';
|
|
13
|
+
*
|
|
14
|
+
* const system = new ParticleSystem(game, {
|
|
15
|
+
* updaters: [
|
|
16
|
+
* Updaters.velocity,
|
|
17
|
+
* Updaters.lifetime,
|
|
18
|
+
* PhysicsUpdaters.mutualAttraction(50, 100),
|
|
19
|
+
* PhysicsUpdaters.particleCollisions(0.9),
|
|
20
|
+
* PhysicsUpdaters.bounds3D({ minX: 0, maxX: 800, minY: 0, maxY: 600, minZ: -200, maxZ: 200 }),
|
|
21
|
+
* ]
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Physics } from './physics.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Physics updaters for ParticleSystem
|
|
29
|
+
*/
|
|
30
|
+
export const PhysicsUpdaters = {
|
|
31
|
+
/**
|
|
32
|
+
* Apply mutual attraction/repulsion between all particles.
|
|
33
|
+
* Uses inverse-square law (like gravity).
|
|
34
|
+
*
|
|
35
|
+
* @param {number} [strength=100] - Force strength (positive=attract, negative=repel)
|
|
36
|
+
* @param {number} [cutoffDistance=200] - Max distance for force calculation (optimization)
|
|
37
|
+
* @param {number} [minDistance=5] - Minimum distance to prevent extreme forces
|
|
38
|
+
* @returns {Function} Updater function
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* PhysicsUpdaters.mutualAttraction(50, 150) // Gravity-like attraction
|
|
42
|
+
* PhysicsUpdaters.mutualAttraction(-100, 100) // Repulsion
|
|
43
|
+
*/
|
|
44
|
+
mutualAttraction: (strength = 100, cutoffDistance = 200, minDistance = 5) => {
|
|
45
|
+
const cutoffSq = cutoffDistance * cutoffDistance;
|
|
46
|
+
|
|
47
|
+
return (p, dt, system) => {
|
|
48
|
+
if (!p.alive) return;
|
|
49
|
+
|
|
50
|
+
const particles = system.particles;
|
|
51
|
+
const mass1 = p.mass ?? p.custom?.mass ?? 1;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < particles.length; i++) {
|
|
54
|
+
const other = particles[i];
|
|
55
|
+
if (other === p || !other.alive) continue;
|
|
56
|
+
|
|
57
|
+
// Quick distance check (squared for performance)
|
|
58
|
+
const dx = other.x - p.x;
|
|
59
|
+
const dy = other.y - p.y;
|
|
60
|
+
const dz = (other.z || 0) - (p.z || 0);
|
|
61
|
+
const distSq = dx * dx + dy * dy + dz * dz;
|
|
62
|
+
|
|
63
|
+
if (distSq > cutoffSq || distSq < 0.01) continue;
|
|
64
|
+
|
|
65
|
+
const dist = Math.sqrt(distSq);
|
|
66
|
+
const safeDist = Math.max(dist, minDistance);
|
|
67
|
+
|
|
68
|
+
// Mass-weighted force: F = G * m1 * m2 / r²
|
|
69
|
+
const mass2 = other.mass ?? other.custom?.mass ?? 1;
|
|
70
|
+
const force = (strength * mass1 * mass2) / (safeDist * safeDist);
|
|
71
|
+
|
|
72
|
+
// Apply to velocity (normalized direction * force * dt)
|
|
73
|
+
const f = force * dt / dist;
|
|
74
|
+
p.vx += dx * f;
|
|
75
|
+
p.vy += dy * f;
|
|
76
|
+
if (p.vz !== undefined) p.vz += dz * f;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Apply linear mutual attraction/repulsion (constant force, not inverse-square).
|
|
83
|
+
*
|
|
84
|
+
* @param {number} [strength=50] - Force strength
|
|
85
|
+
* @param {number} [cutoffDistance=100] - Max distance for force
|
|
86
|
+
* @returns {Function} Updater function
|
|
87
|
+
*/
|
|
88
|
+
mutualAttractionLinear: (strength = 50, cutoffDistance = 100) => {
|
|
89
|
+
const cutoffSq = cutoffDistance * cutoffDistance;
|
|
90
|
+
|
|
91
|
+
return (p, dt, system) => {
|
|
92
|
+
if (!p.alive) return;
|
|
93
|
+
|
|
94
|
+
for (const other of system.particles) {
|
|
95
|
+
if (other === p || !other.alive) continue;
|
|
96
|
+
|
|
97
|
+
const dx = other.x - p.x;
|
|
98
|
+
const dy = other.y - p.y;
|
|
99
|
+
const dz = (other.z || 0) - (p.z || 0);
|
|
100
|
+
const distSq = dx * dx + dy * dy + dz * dz;
|
|
101
|
+
|
|
102
|
+
if (distSq > cutoffSq || distSq < 0.01) continue;
|
|
103
|
+
|
|
104
|
+
const dist = Math.sqrt(distSq);
|
|
105
|
+
const f = strength * dt / dist;
|
|
106
|
+
|
|
107
|
+
p.vx += dx * f;
|
|
108
|
+
p.vy += dy * f;
|
|
109
|
+
if (p.vz !== undefined) p.vz += dz * f;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handle particle-particle elastic collisions with momentum conservation.
|
|
116
|
+
*
|
|
117
|
+
* @param {number} [restitution=0.9] - Bounciness (0-1)
|
|
118
|
+
* @param {number} [threshold=1.0] - Collision distance multiplier
|
|
119
|
+
* @returns {Function} Updater function
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* PhysicsUpdaters.particleCollisions(0.95) // High bounce
|
|
123
|
+
* PhysicsUpdaters.particleCollisions(0.5) // Low bounce (more energy loss)
|
|
124
|
+
*/
|
|
125
|
+
particleCollisions: (restitution = 0.9, threshold = 1.0) => {
|
|
126
|
+
// Track processed pairs to avoid double-processing
|
|
127
|
+
const processedPairs = new Set();
|
|
128
|
+
|
|
129
|
+
return (p, dt, system) => {
|
|
130
|
+
if (!p.alive) return;
|
|
131
|
+
|
|
132
|
+
// Clear set at start of new frame (check first particle)
|
|
133
|
+
if (system.particles.indexOf(p) === 0) {
|
|
134
|
+
processedPairs.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pIndex = system.particles.indexOf(p);
|
|
138
|
+
|
|
139
|
+
for (let i = pIndex + 1; i < system.particles.length; i++) {
|
|
140
|
+
const other = system.particles[i];
|
|
141
|
+
if (!other.alive) continue;
|
|
142
|
+
|
|
143
|
+
// Create unique pair key
|
|
144
|
+
const pairKey = `${pIndex}-${i}`;
|
|
145
|
+
if (processedPairs.has(pairKey)) continue;
|
|
146
|
+
|
|
147
|
+
const collision = Physics.checkCollision(p, other, threshold);
|
|
148
|
+
if (collision) {
|
|
149
|
+
processedPairs.add(pairKey);
|
|
150
|
+
|
|
151
|
+
// Separate overlapping particles
|
|
152
|
+
Physics.separate(p, other, collision, 0.5);
|
|
153
|
+
|
|
154
|
+
// Apply elastic collision response
|
|
155
|
+
const response = Physics.elasticCollision(p, other, collision, restitution);
|
|
156
|
+
if (response) {
|
|
157
|
+
p.vx = response.v1.vx;
|
|
158
|
+
p.vy = response.v1.vy;
|
|
159
|
+
p.vz = response.v1.vz;
|
|
160
|
+
|
|
161
|
+
other.vx = response.v2.vx;
|
|
162
|
+
other.vy = response.v2.vy;
|
|
163
|
+
other.vz = response.v2.vz;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bounce particles off 3D box boundaries.
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} bounds - Boundary box
|
|
174
|
+
* @param {number} bounds.minX - Left boundary
|
|
175
|
+
* @param {number} bounds.maxX - Right boundary
|
|
176
|
+
* @param {number} bounds.minY - Top boundary
|
|
177
|
+
* @param {number} bounds.maxY - Bottom boundary
|
|
178
|
+
* @param {number} [bounds.minZ] - Near boundary (optional for 3D)
|
|
179
|
+
* @param {number} [bounds.maxZ] - Far boundary (optional for 3D)
|
|
180
|
+
* @param {number} [restitution=0.9] - Bounciness
|
|
181
|
+
* @returns {Function} Updater function
|
|
182
|
+
*/
|
|
183
|
+
bounds3D: (bounds, restitution = 0.9) => (p, dt) => {
|
|
184
|
+
if (!p.alive) return;
|
|
185
|
+
Physics.boundsCollision(p, bounds, restitution);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Bounce particles inside a spherical boundary.
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} sphere - Sphere definition
|
|
192
|
+
* @param {number} sphere.x - Center X
|
|
193
|
+
* @param {number} sphere.y - Center Y
|
|
194
|
+
* @param {number} [sphere.z=0] - Center Z
|
|
195
|
+
* @param {number} sphere.radius - Sphere radius
|
|
196
|
+
* @param {number} [restitution=0.9] - Bounciness
|
|
197
|
+
* @returns {Function} Updater function
|
|
198
|
+
*/
|
|
199
|
+
sphereBounds: (sphere, restitution = 0.9) => (p, dt) => {
|
|
200
|
+
if (!p.alive) return;
|
|
201
|
+
Physics.sphereBoundsCollision(p, sphere, restitution, true);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Attract particles toward a point.
|
|
206
|
+
*
|
|
207
|
+
* @param {Object|Function} target - Target {x, y, z} or function returning target
|
|
208
|
+
* @param {number} [strength=100] - Attraction strength
|
|
209
|
+
* @param {number} [minDist=10] - Minimum distance
|
|
210
|
+
* @returns {Function} Updater function
|
|
211
|
+
*/
|
|
212
|
+
attractToPoint: (target, strength = 100, minDist = 10) => (p, dt) => {
|
|
213
|
+
if (!p.alive) return;
|
|
214
|
+
const t = typeof target === 'function' ? target() : target;
|
|
215
|
+
const force = Physics.attract(p, t, strength, minDist);
|
|
216
|
+
p.vx += force.fx * dt;
|
|
217
|
+
p.vy += force.fy * dt;
|
|
218
|
+
if (p.vz !== undefined) p.vz += force.fz * dt;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Apply uniform gravity (constant downward acceleration).
|
|
223
|
+
*
|
|
224
|
+
* @param {number} [gx=0] - Gravity X component
|
|
225
|
+
* @param {number} [gy=200] - Gravity Y component (positive = down)
|
|
226
|
+
* @param {number} [gz=0] - Gravity Z component
|
|
227
|
+
* @returns {Function} Updater function
|
|
228
|
+
*/
|
|
229
|
+
gravity: (gx = 0, gy = 200, gz = 0) => (p, dt) => {
|
|
230
|
+
if (!p.alive) return;
|
|
231
|
+
p.vx += gx * dt;
|
|
232
|
+
p.vy += gy * dt;
|
|
233
|
+
if (p.vz !== undefined) p.vz += gz * dt;
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Clamp particle velocity to a maximum speed.
|
|
238
|
+
*
|
|
239
|
+
* @param {number} maxSpeed - Maximum speed
|
|
240
|
+
* @returns {Function} Updater function
|
|
241
|
+
*/
|
|
242
|
+
maxSpeed: (maxSpeed) => (p, dt) => {
|
|
243
|
+
if (!p.alive) return;
|
|
244
|
+
Physics.clampVelocity(p, maxSpeed);
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Apply drag/friction proportional to velocity squared.
|
|
249
|
+
*
|
|
250
|
+
* @param {number} [coefficient=0.01] - Drag coefficient
|
|
251
|
+
* @returns {Function} Updater function
|
|
252
|
+
*/
|
|
253
|
+
drag: (coefficient = 0.01) => (p, dt) => {
|
|
254
|
+
if (!p.alive) return;
|
|
255
|
+
const speed = Physics.speed(p);
|
|
256
|
+
if (speed > 0.01) {
|
|
257
|
+
const dragForce = coefficient * speed * speed;
|
|
258
|
+
const dragFactor = Math.max(0, 1 - (dragForce * dt / speed));
|
|
259
|
+
p.vx *= dragFactor;
|
|
260
|
+
p.vy *= dragFactor;
|
|
261
|
+
if (p.vz !== undefined) p.vz *= dragFactor;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Apply separation force to prevent particle overlap.
|
|
267
|
+
* Lighter-weight alternative to full collision response.
|
|
268
|
+
*
|
|
269
|
+
* @param {number} [strength=100] - Separation force strength
|
|
270
|
+
* @param {number} [threshold=1.2] - How close before separating (multiplier of combined radii)
|
|
271
|
+
* @returns {Function} Updater function
|
|
272
|
+
*/
|
|
273
|
+
separation: (strength = 100, threshold = 1.2) => (p, dt, system) => {
|
|
274
|
+
if (!p.alive) return;
|
|
275
|
+
|
|
276
|
+
for (const other of system.particles) {
|
|
277
|
+
if (other === p || !other.alive) continue;
|
|
278
|
+
|
|
279
|
+
const collision = Physics.checkCollision(p, other, threshold);
|
|
280
|
+
if (collision) {
|
|
281
|
+
// Repulsion force based on overlap
|
|
282
|
+
const force = strength * collision.overlap;
|
|
283
|
+
const nx = -collision.dx / collision.dist;
|
|
284
|
+
const ny = -collision.dy / collision.dist;
|
|
285
|
+
const nz = -collision.dz / collision.dist;
|
|
286
|
+
|
|
287
|
+
p.vx += nx * force * dt;
|
|
288
|
+
p.vy += ny * force * dt;
|
|
289
|
+
if (p.vz !== undefined) p.vz += nz * force * dt;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Apply thermal motion (random velocity jitter based on temperature).
|
|
296
|
+
*
|
|
297
|
+
* @param {number|Function} temperature - Temperature value or function returning temperature
|
|
298
|
+
* @param {number} [scale=10] - Scale factor for jitter
|
|
299
|
+
* @returns {Function} Updater function
|
|
300
|
+
*/
|
|
301
|
+
thermal: (temperature, scale = 10) => (p, dt) => {
|
|
302
|
+
if (!p.alive) return;
|
|
303
|
+
const temp = typeof temperature === 'function' ? temperature() : temperature;
|
|
304
|
+
const jitter = temp * scale * dt;
|
|
305
|
+
p.vx += (Math.random() - 0.5) * jitter;
|
|
306
|
+
p.vy += (Math.random() - 0.5) * jitter;
|
|
307
|
+
if (p.vz !== undefined) p.vz += (Math.random() - 0.5) * jitter;
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Orbital motion around a center point.
|
|
312
|
+
* Applies centripetal force for stable orbits.
|
|
313
|
+
*
|
|
314
|
+
* @param {Object} center - Orbit center {x, y, z}
|
|
315
|
+
* @param {number} [strength=100] - Orbital force strength
|
|
316
|
+
* @returns {Function} Updater function
|
|
317
|
+
*/
|
|
318
|
+
orbital: (center, strength = 100) => (p, dt) => {
|
|
319
|
+
if (!p.alive) return;
|
|
320
|
+
|
|
321
|
+
const dx = center.x - p.x;
|
|
322
|
+
const dy = center.y - p.y;
|
|
323
|
+
const dz = (center.z || 0) - (p.z || 0);
|
|
324
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
|
|
325
|
+
|
|
326
|
+
// Centripetal force = v² / r, but we use strength as a constant
|
|
327
|
+
const force = strength / dist;
|
|
328
|
+
|
|
329
|
+
p.vx += (dx / dist) * force * dt;
|
|
330
|
+
p.vy += (dy / dist) * force * dt;
|
|
331
|
+
if (p.vz !== undefined) p.vz += (dz / dist) * force * dt;
|
|
332
|
+
},
|
|
333
|
+
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module physics/physics
|
|
3
|
+
* @description Stateless physics calculations for particle systems.
|
|
4
|
+
*
|
|
5
|
+
* Provides core physics primitives:
|
|
6
|
+
* - Collision detection (particle-particle, particle-boundary)
|
|
7
|
+
* - Elastic collision response with momentum conservation
|
|
8
|
+
* - Force calculations (attraction, repulsion, gravity)
|
|
9
|
+
* - Kinetic energy calculations
|
|
10
|
+
*
|
|
11
|
+
* All methods are static and pure - no internal state.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { Physics } from '@guinetik/gcanvas';
|
|
15
|
+
*
|
|
16
|
+
* // Check collision between two particles
|
|
17
|
+
* const collision = Physics.checkCollision(p1, p2);
|
|
18
|
+
* if (collision) {
|
|
19
|
+
* const response = Physics.elasticCollision(p1, p2, collision);
|
|
20
|
+
* if (response) {
|
|
21
|
+
* Object.assign(p1, response.v1);
|
|
22
|
+
* Object.assign(p2, response.v2);
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Physics - Stateless physics calculations
|
|
29
|
+
* @class
|
|
30
|
+
*/
|
|
31
|
+
export class Physics {
|
|
32
|
+
/**
|
|
33
|
+
* Calculate attraction/repulsion force between two points using inverse square law.
|
|
34
|
+
* @param {Object} p1 - First position {x, y, z}
|
|
35
|
+
* @param {Object} p2 - Second position {x, y, z}
|
|
36
|
+
* @param {number} [strength=100] - Force multiplier (positive=attract, negative=repel)
|
|
37
|
+
* @param {number} [minDist=1] - Minimum distance to prevent infinite force
|
|
38
|
+
* @returns {{fx: number, fy: number, fz: number, dist: number}} Force vector and distance
|
|
39
|
+
*/
|
|
40
|
+
static attract(p1, p2, strength = 100, minDist = 1) {
|
|
41
|
+
const dx = p2.x - p1.x;
|
|
42
|
+
const dy = p2.y - p1.y;
|
|
43
|
+
const dz = (p2.z || 0) - (p1.z || 0);
|
|
44
|
+
|
|
45
|
+
const distSq = dx * dx + dy * dy + dz * dz;
|
|
46
|
+
const dist = Math.sqrt(distSq) || minDist;
|
|
47
|
+
const safeDist = Math.max(dist, minDist);
|
|
48
|
+
|
|
49
|
+
// Inverse square law: F = strength / dist^2
|
|
50
|
+
const force = strength / (safeDist * safeDist);
|
|
51
|
+
|
|
52
|
+
// Normalize direction and apply force
|
|
53
|
+
return {
|
|
54
|
+
fx: (dx / dist) * force,
|
|
55
|
+
fy: (dy / dist) * force,
|
|
56
|
+
fz: (dz / dist) * force,
|
|
57
|
+
dist: dist,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Calculate linear attraction force (constant strength, not inverse square).
|
|
63
|
+
* @param {Object} p1 - First position {x, y, z}
|
|
64
|
+
* @param {Object} p2 - Second position {x, y, z}
|
|
65
|
+
* @param {number} [strength=100] - Force multiplier
|
|
66
|
+
* @returns {{fx: number, fy: number, fz: number, dist: number}} Force vector and distance
|
|
67
|
+
*/
|
|
68
|
+
static attractLinear(p1, p2, strength = 100) {
|
|
69
|
+
const dx = p2.x - p1.x;
|
|
70
|
+
const dy = p2.y - p1.y;
|
|
71
|
+
const dz = (p2.z || 0) - (p1.z || 0);
|
|
72
|
+
|
|
73
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
fx: (dx / dist) * strength,
|
|
77
|
+
fy: (dy / dist) * strength,
|
|
78
|
+
fz: (dz / dist) * strength,
|
|
79
|
+
dist: dist,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if two particles are colliding based on their sizes.
|
|
85
|
+
* @param {Object} p1 - First particle {x, y, z, size}
|
|
86
|
+
* @param {Object} p2 - Second particle {x, y, z, size}
|
|
87
|
+
* @param {number} [threshold=1.0] - Collision distance multiplier
|
|
88
|
+
* @returns {{dist: number, overlap: number, dx: number, dy: number, dz: number}|null} Collision data or null
|
|
89
|
+
*/
|
|
90
|
+
static checkCollision(p1, p2, threshold = 1.0) {
|
|
91
|
+
const dx = p2.x - p1.x;
|
|
92
|
+
const dy = p2.y - p1.y;
|
|
93
|
+
const dz = (p2.z || 0) - (p1.z || 0);
|
|
94
|
+
|
|
95
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
96
|
+
const r1 = (p1.size || p1.radius || 1);
|
|
97
|
+
const r2 = (p2.size || p2.radius || 1);
|
|
98
|
+
const collisionDist = (r1 + r2) * threshold;
|
|
99
|
+
|
|
100
|
+
if (dist < collisionDist && dist > 0) {
|
|
101
|
+
return {
|
|
102
|
+
dist: dist,
|
|
103
|
+
overlap: collisionDist - dist,
|
|
104
|
+
dx: dx,
|
|
105
|
+
dy: dy,
|
|
106
|
+
dz: dz,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Calculate elastic collision response with conservation of momentum.
|
|
114
|
+
* @param {Object} p1 - First particle {vx, vy, vz, mass}
|
|
115
|
+
* @param {Object} p2 - Second particle {vx, vy, vz, mass}
|
|
116
|
+
* @param {Object} collision - Collision data from checkCollision
|
|
117
|
+
* @param {number} [restitution=0.9] - Coefficient of restitution (0-1, 1=perfectly elastic)
|
|
118
|
+
* @returns {{v1: {vx, vy, vz}, v2: {vx, vy, vz}}|null} New velocities or null if separating
|
|
119
|
+
*/
|
|
120
|
+
static elasticCollision(p1, p2, collision, restitution = 0.9) {
|
|
121
|
+
const m1 = p1.mass ?? p1.custom?.mass ?? 1;
|
|
122
|
+
const m2 = p2.mass ?? p2.custom?.mass ?? 1;
|
|
123
|
+
const totalMass = m1 + m2;
|
|
124
|
+
|
|
125
|
+
// Normalize collision axis
|
|
126
|
+
const dist = collision.dist || 1;
|
|
127
|
+
const nx = collision.dx / dist;
|
|
128
|
+
const ny = collision.dy / dist;
|
|
129
|
+
const nz = collision.dz / dist;
|
|
130
|
+
|
|
131
|
+
// Relative velocity along collision axis
|
|
132
|
+
const dvx = p1.vx - p2.vx;
|
|
133
|
+
const dvy = p1.vy - p2.vy;
|
|
134
|
+
const dvz = (p1.vz || 0) - (p2.vz || 0);
|
|
135
|
+
const relVel = dvx * nx + dvy * ny + dvz * nz;
|
|
136
|
+
|
|
137
|
+
// Don't resolve if already moving apart
|
|
138
|
+
if (relVel < 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Impulse magnitude (conservation of momentum + restitution)
|
|
143
|
+
const impulse = (-(1 + restitution) * relVel) / totalMass;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
v1: {
|
|
147
|
+
vx: p1.vx + impulse * m2 * nx,
|
|
148
|
+
vy: p1.vy + impulse * m2 * ny,
|
|
149
|
+
vz: (p1.vz || 0) + impulse * m2 * nz,
|
|
150
|
+
},
|
|
151
|
+
v2: {
|
|
152
|
+
vx: p2.vx - impulse * m1 * nx,
|
|
153
|
+
vy: p2.vy - impulse * m1 * ny,
|
|
154
|
+
vz: (p2.vz || 0) - impulse * m1 * nz,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Separate two overlapping particles to prevent sticking.
|
|
161
|
+
* @param {Object} p1 - First particle {x, y, z}
|
|
162
|
+
* @param {Object} p2 - Second particle {x, y, z}
|
|
163
|
+
* @param {Object} collision - Collision data from checkCollision
|
|
164
|
+
* @param {number} [separationFactor=0.5] - How much overlap to resolve (0-1)
|
|
165
|
+
*/
|
|
166
|
+
static separate(p1, p2, collision, separationFactor = 0.5) {
|
|
167
|
+
const m1 = p1.mass ?? p1.custom?.mass ?? 1;
|
|
168
|
+
const m2 = p2.mass ?? p2.custom?.mass ?? 1;
|
|
169
|
+
const totalMass = m1 + m2;
|
|
170
|
+
|
|
171
|
+
const dist = collision.dist || 1;
|
|
172
|
+
const nx = collision.dx / dist;
|
|
173
|
+
const ny = collision.dy / dist;
|
|
174
|
+
const nz = collision.dz / dist;
|
|
175
|
+
|
|
176
|
+
const separation = collision.overlap * separationFactor;
|
|
177
|
+
|
|
178
|
+
// Move particles apart proportional to inverse mass
|
|
179
|
+
const move1 = separation * (m2 / totalMass);
|
|
180
|
+
const move2 = separation * (m1 / totalMass);
|
|
181
|
+
|
|
182
|
+
p1.x -= nx * move1;
|
|
183
|
+
p1.y -= ny * move1;
|
|
184
|
+
if (p1.z !== undefined) p1.z -= nz * move1;
|
|
185
|
+
|
|
186
|
+
p2.x += nx * move2;
|
|
187
|
+
p2.y += ny * move2;
|
|
188
|
+
if (p2.z !== undefined) p2.z += nz * move2;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Check and respond to 3D boundary collision with bounce.
|
|
193
|
+
* @param {Object} p - Particle {x, y, z, vx, vy, vz, size}
|
|
194
|
+
* @param {Object} bounds - Boundary box {minX, maxX, minY, maxY, minZ, maxZ}
|
|
195
|
+
* @param {number} [restitution=0.9] - Bounciness (0-1)
|
|
196
|
+
* @returns {boolean} True if collision occurred
|
|
197
|
+
*/
|
|
198
|
+
static boundsCollision(p, bounds, restitution = 0.9) {
|
|
199
|
+
const radius = (p.size || p.radius || 1) / 2;
|
|
200
|
+
let collided = false;
|
|
201
|
+
|
|
202
|
+
// X bounds
|
|
203
|
+
if (bounds.minX !== undefined) {
|
|
204
|
+
const minX = bounds.minX + radius;
|
|
205
|
+
const maxX = bounds.maxX - radius;
|
|
206
|
+
|
|
207
|
+
if (p.x < minX) {
|
|
208
|
+
p.x = minX;
|
|
209
|
+
if (p.vx < 0) p.vx = -p.vx * restitution;
|
|
210
|
+
collided = true;
|
|
211
|
+
} else if (p.x > maxX) {
|
|
212
|
+
p.x = maxX;
|
|
213
|
+
if (p.vx > 0) p.vx = -p.vx * restitution;
|
|
214
|
+
collided = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Y bounds
|
|
219
|
+
if (bounds.minY !== undefined) {
|
|
220
|
+
const minY = bounds.minY + radius;
|
|
221
|
+
const maxY = bounds.maxY - radius;
|
|
222
|
+
|
|
223
|
+
if (p.y < minY) {
|
|
224
|
+
p.y = minY;
|
|
225
|
+
if (p.vy < 0) p.vy = -p.vy * restitution;
|
|
226
|
+
collided = true;
|
|
227
|
+
} else if (p.y > maxY) {
|
|
228
|
+
p.y = maxY;
|
|
229
|
+
if (p.vy > 0) p.vy = -p.vy * restitution;
|
|
230
|
+
collided = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Z bounds (optional for 3D)
|
|
235
|
+
if (bounds.minZ !== undefined && p.z !== undefined) {
|
|
236
|
+
const minZ = bounds.minZ + radius;
|
|
237
|
+
const maxZ = bounds.maxZ - radius;
|
|
238
|
+
|
|
239
|
+
if (p.z < minZ) {
|
|
240
|
+
p.z = minZ;
|
|
241
|
+
if (p.vz < 0) p.vz = -p.vz * restitution;
|
|
242
|
+
collided = true;
|
|
243
|
+
} else if (p.z > maxZ) {
|
|
244
|
+
p.z = maxZ;
|
|
245
|
+
if (p.vz > 0) p.vz = -p.vz * restitution;
|
|
246
|
+
collided = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return collided;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Check collision with a spherical boundary.
|
|
255
|
+
* @param {Object} p - Particle {x, y, z, vx, vy, vz, size}
|
|
256
|
+
* @param {Object} sphere - Sphere {x, y, z, radius}
|
|
257
|
+
* @param {number} [restitution=0.9] - Bounciness (0-1)
|
|
258
|
+
* @param {boolean} [inside=true] - True to contain inside, false to bounce off outside
|
|
259
|
+
* @returns {boolean} True if collision occurred
|
|
260
|
+
*/
|
|
261
|
+
static sphereBoundsCollision(p, sphere, restitution = 0.9, inside = true) {
|
|
262
|
+
const dx = p.x - sphere.x;
|
|
263
|
+
const dy = p.y - sphere.y;
|
|
264
|
+
const dz = (p.z || 0) - (sphere.z || 0);
|
|
265
|
+
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
266
|
+
const particleRadius = (p.size || p.radius || 1) / 2;
|
|
267
|
+
const effectiveRadius = sphere.radius - particleRadius;
|
|
268
|
+
|
|
269
|
+
if (inside && dist > effectiveRadius) {
|
|
270
|
+
// Particle outside sphere - bounce back in
|
|
271
|
+
const nx = dx / dist;
|
|
272
|
+
const ny = dy / dist;
|
|
273
|
+
const nz = dz / dist;
|
|
274
|
+
|
|
275
|
+
// Position correction
|
|
276
|
+
p.x = sphere.x + nx * effectiveRadius;
|
|
277
|
+
p.y = sphere.y + ny * effectiveRadius;
|
|
278
|
+
if (p.z !== undefined) p.z = sphere.z + nz * effectiveRadius;
|
|
279
|
+
|
|
280
|
+
// Velocity reflection
|
|
281
|
+
const vDotN = p.vx * nx + p.vy * ny + (p.vz || 0) * nz;
|
|
282
|
+
if (vDotN > 0) {
|
|
283
|
+
p.vx -= 2 * vDotN * nx * restitution;
|
|
284
|
+
p.vy -= 2 * vDotN * ny * restitution;
|
|
285
|
+
if (p.vz !== undefined) p.vz -= 2 * vDotN * nz * restitution;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Calculate kinetic energy of a particle.
|
|
296
|
+
* @param {Object} p - Particle {vx, vy, vz, mass}
|
|
297
|
+
* @returns {number} Kinetic energy (0.5 * m * v²)
|
|
298
|
+
*/
|
|
299
|
+
static kineticEnergy(p) {
|
|
300
|
+
const vx = p.vx || 0;
|
|
301
|
+
const vy = p.vy || 0;
|
|
302
|
+
const vz = p.vz || 0;
|
|
303
|
+
const mass = p.mass ?? p.custom?.mass ?? 1;
|
|
304
|
+
return 0.5 * mass * (vx * vx + vy * vy + vz * vz);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Calculate speed (velocity magnitude) of a particle.
|
|
309
|
+
* @param {Object} p - Particle {vx, vy, vz}
|
|
310
|
+
* @returns {number} Speed
|
|
311
|
+
*/
|
|
312
|
+
static speed(p) {
|
|
313
|
+
const vx = p.vx || 0;
|
|
314
|
+
const vy = p.vy || 0;
|
|
315
|
+
const vz = p.vz || 0;
|
|
316
|
+
return Math.sqrt(vx * vx + vy * vy + vz * vz);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Calculate distance between two particles.
|
|
321
|
+
* @param {Object} p1 - First position {x, y, z}
|
|
322
|
+
* @param {Object} p2 - Second position {x, y, z}
|
|
323
|
+
* @returns {number} Distance
|
|
324
|
+
*/
|
|
325
|
+
static distance(p1, p2) {
|
|
326
|
+
const dx = p2.x - p1.x;
|
|
327
|
+
const dy = p2.y - p1.y;
|
|
328
|
+
const dz = (p2.z || 0) - (p1.z || 0);
|
|
329
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Calculate squared distance between two particles (faster than distance).
|
|
334
|
+
* @param {Object} p1 - First position {x, y, z}
|
|
335
|
+
* @param {Object} p2 - Second position {x, y, z}
|
|
336
|
+
* @returns {number} Squared distance
|
|
337
|
+
*/
|
|
338
|
+
static distanceSquared(p1, p2) {
|
|
339
|
+
const dx = p2.x - p1.x;
|
|
340
|
+
const dy = p2.y - p1.y;
|
|
341
|
+
const dz = (p2.z || 0) - (p1.z || 0);
|
|
342
|
+
return dx * dx + dy * dy + dz * dz;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Clamp velocity magnitude to a maximum.
|
|
347
|
+
* @param {Object} p - Particle {vx, vy, vz}
|
|
348
|
+
* @param {number} maxSpeed - Maximum speed
|
|
349
|
+
*/
|
|
350
|
+
static clampVelocity(p, maxSpeed) {
|
|
351
|
+
const speed = Physics.speed(p);
|
|
352
|
+
if (speed > maxSpeed) {
|
|
353
|
+
const scale = maxSpeed / speed;
|
|
354
|
+
p.vx *= scale;
|
|
355
|
+
p.vy *= scale;
|
|
356
|
+
if (p.vz !== undefined) p.vz *= scale;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Apply a force to a particle (F = ma, so a = F/m).
|
|
362
|
+
* @param {Object} p - Particle {vx, vy, vz, mass}
|
|
363
|
+
* @param {number} fx - Force X component
|
|
364
|
+
* @param {number} fy - Force Y component
|
|
365
|
+
* @param {number} fz - Force Z component
|
|
366
|
+
* @param {number} dt - Delta time
|
|
367
|
+
*/
|
|
368
|
+
static applyForce(p, fx, fy, fz, dt) {
|
|
369
|
+
const mass = p.mass ?? p.custom?.mass ?? 1;
|
|
370
|
+
const invMass = mass > 0 ? 1 / mass : 0;
|
|
371
|
+
p.vx += fx * invMass * dt;
|
|
372
|
+
p.vy += fy * invMass * dt;
|
|
373
|
+
if (p.vz !== undefined) p.vz += (fz || 0) * invMass * dt;
|
|
374
|
+
}
|
|
375
|
+
}
|