@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.
Files changed (78) hide show
  1. package/dist/aizawa.html +27 -0
  2. package/dist/clifford.html +25 -0
  3. package/dist/cmb.html +24 -0
  4. package/dist/dadras.html +26 -0
  5. package/dist/dejong.html +25 -0
  6. package/dist/gcanvas.es.js +5130 -372
  7. package/dist/gcanvas.es.min.js +1 -1
  8. package/dist/gcanvas.umd.js +1 -1
  9. package/dist/gcanvas.umd.min.js +1 -1
  10. package/dist/halvorsen.html +27 -0
  11. package/dist/index.html +96 -48
  12. package/dist/js/aizawa.js +425 -0
  13. package/dist/js/bezier.js +5 -5
  14. package/dist/js/clifford.js +236 -0
  15. package/dist/js/cmb.js +594 -0
  16. package/dist/js/dadras.js +405 -0
  17. package/dist/js/dejong.js +257 -0
  18. package/dist/js/halvorsen.js +405 -0
  19. package/dist/js/isometric.js +34 -46
  20. package/dist/js/lorenz.js +425 -0
  21. package/dist/js/painter.js +8 -8
  22. package/dist/js/rossler.js +480 -0
  23. package/dist/js/schrodinger.js +314 -18
  24. package/dist/js/thomas.js +394 -0
  25. package/dist/lorenz.html +27 -0
  26. package/dist/rossler.html +27 -0
  27. package/dist/scene-interactivity-test.html +220 -0
  28. package/dist/thomas.html +27 -0
  29. package/package.json +1 -1
  30. package/readme.md +30 -22
  31. package/src/game/objects/go.js +7 -0
  32. package/src/game/objects/index.js +2 -0
  33. package/src/game/objects/isometric-scene.js +53 -3
  34. package/src/game/objects/layoutscene.js +57 -0
  35. package/src/game/objects/mask.js +241 -0
  36. package/src/game/objects/scene.js +19 -0
  37. package/src/game/objects/wrapper.js +14 -2
  38. package/src/game/pipeline.js +17 -0
  39. package/src/game/ui/button.js +101 -16
  40. package/src/game/ui/theme.js +0 -6
  41. package/src/game/ui/togglebutton.js +25 -14
  42. package/src/game/ui/tooltip.js +12 -4
  43. package/src/index.js +3 -0
  44. package/src/io/gesture.js +409 -0
  45. package/src/io/index.js +4 -1
  46. package/src/io/keys.js +9 -1
  47. package/src/io/screen.js +476 -0
  48. package/src/math/attractors.js +664 -0
  49. package/src/math/heat.js +106 -0
  50. package/src/math/index.js +1 -0
  51. package/src/mixins/draggable.js +15 -19
  52. package/src/painter/painter.shapes.js +11 -5
  53. package/src/particle/particle-system.js +165 -1
  54. package/src/physics/index.js +26 -0
  55. package/src/physics/physics-updaters.js +333 -0
  56. package/src/physics/physics.js +375 -0
  57. package/src/shapes/image.js +5 -5
  58. package/src/shapes/index.js +2 -0
  59. package/src/shapes/parallelogram.js +147 -0
  60. package/src/shapes/righttriangle.js +115 -0
  61. package/src/shapes/svg.js +281 -100
  62. package/src/shapes/text.js +22 -6
  63. package/src/shapes/transformable.js +5 -0
  64. package/src/sound/effects.js +807 -0
  65. package/src/sound/index.js +13 -0
  66. package/src/webgl/index.js +7 -0
  67. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  68. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  69. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  70. package/src/webgl/webgl-clifford-renderer.js +477 -0
  71. package/src/webgl/webgl-dejong-renderer.js +472 -0
  72. package/src/webgl/webgl-line-renderer.js +391 -0
  73. package/src/webgl/webgl-particle-renderer.js +410 -0
  74. package/types/index.d.ts +30 -2
  75. package/types/io.d.ts +217 -0
  76. package/types/physics.d.ts +299 -0
  77. package/types/shapes.d.ts +8 -0
  78. 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
+ }