implemented elastic collisions

This commit is contained in:
Ladd 2026-01-29 11:28:39 -06:00
parent f707618ec6
commit 21820c53af
7 changed files with 204 additions and 60 deletions

View File

@ -16,7 +16,7 @@ import {
VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_WIDTH,
} from './config.js'; } from './config.js';
import {add, components, copy, direction, div, magnitude, zero} from './vector.js'; import {add, components, copy, direction, div, magnitude, mult, square, zero} from './vector.js';
export class MassObject { export class MassObject {
sim = undefined; sim = undefined;
@ -31,7 +31,6 @@ export class MassObject {
forces = []; // [{x, y}] forces = []; // [{x, y}]
history = []; history = [];
alive = true; alive = true;
kineticEnergy = 0;
workDoneByPointer = 0; workDoneByPointer = 0;
workDoneByForces = 0; workDoneByForces = 0;
@ -92,6 +91,14 @@ export class MassObject {
return div(netForce, this.mass); return div(netForce, this.mass);
} }
get kineticEnergy() {
return this.mass * square(this.velocity) / 2;
}
get momentum() {
return mult(this.mass, this.velocity);
}
drawPath(display) { drawPath(display) {
const {ctx} = display; const {ctx} = display;
const {color: {r, g, b}} = this; const {color: {r, g, b}} = this;

View File

@ -53,7 +53,12 @@ export class Pointer {
}); });
} }
handlePointerDown({x: clientX, y: clientY}) { handlePointerDown({x: clientX, y: clientY, touches}) {
// TODO: Check if pointer was already down;
// equivalently, e.touches.length > 1
// Global pinch to zoom? Or only in certain modes?
this.updatePointer({x: clientX, y: clientY}); this.updatePointer({x: clientX, y: clientY});
switch (this.sim.getCurrentMode()) { switch (this.sim.getCurrentMode()) {

View File

@ -51,6 +51,8 @@ export class Sim {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.host === 'gravity.dev.laddhoffman.com') { if (url.host === 'gravity.dev.laddhoffman.com') {
document.title += ' [Dev]'; document.title += ' [Dev]';
} else if (url.host === 'gravity.local') {
document.title += ' [Local]';
} }
this.options = new Options(this, simOptions); this.options = new Options(this, simOptions);
@ -109,6 +111,7 @@ export class Sim {
requestAnimationFrame(t => this.frame(t)); requestAnimationFrame(t => this.frame(t));
} }
// Record frame timestamp information
markFrame(t) { markFrame(t) {
const {recentFrames: rfs} = this; const {recentFrames: rfs} = this;
if (!rfs.length) { if (!rfs.length) {
@ -133,6 +136,7 @@ export class Sim {
return false; return false;
} }
// toJSON is used to export the state of the simulation
toJSON() { toJSON() {
return { return {
dateSaved: new Date().toISOString(), dateSaved: new Date().toISOString(),
@ -145,6 +149,7 @@ export class Sim {
}; };
} }
// fromJSON is used to import the state of the simulation
fromJSON(state) { fromJSON(state) {
this.pause(); this.pause();
this.system.fromJSON(state.system); this.system.fromJSON(state.system);
@ -165,6 +170,7 @@ export class Sim {
return this.display.screenToSim(x, y); return this.display.screenToSim(x, y);
} }
// Start the simulation if it's not already running
play() { play() {
this.playing = true; this.playing = true;
@ -176,6 +182,7 @@ export class Sim {
this.div.dispatchEvent(e); this.div.dispatchEvent(e);
} }
// Pause the simulation if it's currently running
pause() { pause() {
this.playing = false; this.playing = false;
@ -187,6 +194,7 @@ export class Sim {
this.div.dispatchEvent(e); this.div.dispatchEvent(e);
} }
// Generate the display content representing the current scale of the simulation
getScaleDisplay() { getScaleDisplay() {
const scale = 2 ** Math.abs(this.display.scalePower); const scale = 2 ** Math.abs(this.display.scalePower);
const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`; const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;

189
system.js
View File

@ -72,6 +72,15 @@ export class System {
// TODO: Calculate work done by pointer here? // TODO: Calculate work done by pointer here?
// Either interpolate the acceleration and use m*a, or // Either interpolate the acceleration and use m*a, or
// measure the change in the object's kinetic energy // measure the change in the object's kinetic energy
// Or both!
// In the frame loop, for the selected object we already calculate
// the work done by the pointer in preventing the acceleration the object
// would have experienced.
// But that doesn't capture the work done by the pointer moving the object.
// If the sim is paused, the dot(force, displacement) method loses accuracy.
// If the sim is paused though, then channge in kinetic energy will be easy to measure.
// If the sim is not paused, we can use dot(force, displacement) method.
obj.position = add(start, delta); obj.position = add(start, delta);
obj.velocity = zero; obj.velocity = zero;
} }
@ -111,54 +120,6 @@ export class System {
)); ));
}); });
// Collisions
this.forEachObject((A, i) => {
this.forEachObject((B, j) => {
const dx = (B.position.x - A.position.x);
const dy = (B.position.y - A.position.y);
const dSquared = dx ** 2 + dy ** 2;
const d = Math.sqrt(dSquared);
if (d < A.radius + B.radius) {
let S, T;
// Merge the older into the newer, in order to provide mass creation rate continuity
if (A.age > B.age) {
// A merges into B; B survives
S = B;
T = A;
// If A was selected or being created, select S instead
if (this.creatingObject === i) this.creatingObject = j;
if (this.selectedObject === i) this.selectedObject = j;
} else {
// B merges into A; A survives
S = A;
T = B;
// If B was selected or being created, select S instead
if (this.creatingObject === j) this.creatingObject = i;
if (this.selectedObject === j) this.selectedObject = i;
}
// Merge T into S:
// Set position = center of mass
// Set velocity = total momentum / total mass
// Combine forces
// Add masses
// Average color
S.position = weightedAvg([[S.position, S.mass], [T.position, T.mass]]);
S.velocity = weightedAvg([[S.velocity, S.mass], [T.velocity, T.mass]]);
S.forces.push(...T.forces);
S.mass += T.mass;
S.color = {
r: (S.mass * S.color.r + T.mass * T.color.r) / (S.mass + T.mass),
g: (S.mass * S.color.g + T.mass * T.color.g) / (S.mass + T.mass),
b: (S.mass * S.color.b + T.mass * T.color.b) / (S.mass + T.mass),
};
T.alive = false;
T.forces = [];
const e = new CustomEvent(EVENT_OBJECT_MERGE, {detail: {surviving: S, merged: T}});
this.sim.div.dispatchEvent(e);
}
}, {alive: true, startWith: i + 1});
});
// Recompute forces // Recompute forces
this.computeForces(); this.computeForces();
@ -189,8 +150,41 @@ export class System {
obj.history.shift(); obj.history.shift();
} }
}); });
// Collisions
this.forEachObject((A, i) => {
this.forEachObject((B, j) => {
if (this.objectsOverlap(A, B)) {
// this.mergeObjects(i, j);
this.bounceObjects(i, j);
}
}, {alive: true, startWith: i + 1});
});
} }
// Display total energy
const totalKE = this.reduce((total, obj) => {
const energy = obj.mass * square(obj.velocity) / 2;
return total + energy;
}, 0, {alive: true});
const G = this.sim.getOption('param.gravity');
const totalGPE = this.reduce((total, A, i) => {
return total + this.reduce((objTotal, B) => {
const energy = -G * A.mass * B.mass / magnitude(sub(A.position, B.position));
return objTotal + energy;
}, 0, {alive: true, startWith: i + 1});
}, 0, {alive: true});
const netMomentum = this.reduce((net, obj) => add(net, obj.momentum), zero);
this.sim.info['Total E'] = (totalKE + totalGPE).toExponential(2);
this.sim.info['Total K'] = totalKE.toExponential(2);
this.sim.info['Total U<sub>g</sub>'] = totalGPE.toExponential(2);
this.sim.info['Net Momentum'] = magnitude(netMomentum).toExponential(2);
// Display objects info // Display objects info
// First clear info from previous frame // First clear info from previous frame
this.forEachObject((_obj, i) => { this.forEachObject((_obj, i) => {
@ -395,6 +389,95 @@ export class System {
return objects; return objects;
} }
objectsOverlap(A, B) {
const dx = (B.position.x - A.position.x);
const dy = (B.position.y - A.position.y);
const dSquared = dx ** 2 + dy ** 2;
const d = Math.sqrt(dSquared);
return d < A.radius + B.radius;
}
bounceObjects(i, j) {
const A = this.object(i);
const B = this.object(j);
// TODO: Handle scenario where an object overlaps more than one other object
// const elasticity = 1;
const r = sub(A.position, B.position);
const normal = div(r, magnitude(r));
const tangent = {x: -normal.y, y: normal.x};
const vAt = dot(A.velocity, tangent);
// Require that objects are moving toward each other!
// Normal is directed toward A, so they are moving toward each other if vBn > vAn
const vAn = dot(A.velocity, normal);
const vBn = dot(B.velocity, normal);
if (vAn >= vBn) {
// The objects are already moving away from each other
return;
}
// Are these objects sticking together?
// One way to determine this is,
// does either object have enough KE to escape their gravitational potential?
// const vRel = sub(A.velocity, B.velocity);
// const G = this.sim.getOption('param.gravity');
// if (square(vRel) < 0.1 * G * (A.mass + B.mass) / magnitude(r)) {
// // Neither object looks like it can escape!
// this.mergeObjects(i, j);
// return;
// }
// Continue with rebound calculations
const vAnNew = (vAn * (A.mass - B.mass) + vBn * 2 * B.mass) / (A.mass + B.mass);
const vA = add(mult(tangent, vAt), mult(normal, vAnNew));
const vB = div(add(mult(A.velocity, A.mass), mult(B.velocity, B.mass), mult(vA, -1 * A.mass)), B.mass);
A.velocity = vA;
B.velocity = vB;
}
mergeObjects(i, j) {
const A = this.object(i);
const B = this.object(j);
let S, T;
// Merge the older into the newer, in order to provide mass creation rate continuity
if (A.age > B.age) {
// A merges into B; B survives
S = B;
T = A;
// If A was selected or being created, select S instead
if (this.creatingObject === i) this.creatingObject = j;
if (this.selectedObject === i) this.selectedObject = j;
} else {
// B merges into A; A survives
S = A;
T = B;
// If B was selected or being created, select S instead
if (this.creatingObject === j) this.creatingObject = i;
if (this.selectedObject === j) this.selectedObject = i;
}
// Merge T into S:
// Set position = center of mass
// Set velocity = total momentum / total mass
// Combine forces
// Add masses
// Average color
S.position = weightedAvg([[S.position, S.mass], [T.position, T.mass]]);
S.velocity = weightedAvg([[S.velocity, S.mass], [T.velocity, T.mass]]);
S.mass += T.mass;
S.color = {
r: (S.mass * S.color.r + T.mass * T.color.r) / (S.mass + T.mass),
g: (S.mass * S.color.g + T.mass * T.color.g) / (S.mass + T.mass),
b: (S.mass * S.color.b + T.mass * T.color.b) / (S.mass + T.mass),
};
T.alive = false;
T.forces = [];
const e = new CustomEvent(EVENT_OBJECT_MERGE, {detail: {surviving: S, merged: T}});
this.sim.div.dispatchEvent(e);
}
computeForces() { computeForces() {
const gravity = this.sim.getOption('param.gravity'); const gravity = this.sim.getOption('param.gravity');
if (this.objects.length < 2) return; if (this.objects.length < 2) return;
@ -406,6 +489,11 @@ export class System {
const r = sub(B.position, A.position); const r = sub(B.position, A.position);
const dSquared = square(r); const dSquared = square(r);
const d = Math.sqrt(dSquared); const d = Math.sqrt(dSquared);
// If the objects are overlapping, don't apply gravity;
// This should give them a chance to fully rebound, and avoid accidental capture.
// if (d <= A.radius + B.radius) {
// return;
// }
const F = gravity * A.mass * B.mass / dSquared; const F = gravity * A.mass * B.mass / dSquared;
const Fx = F * r.x / d; const Fx = F * r.x / d;
const Fy = F * r.y / d; const Fy = F * r.y / d;
@ -423,9 +511,6 @@ export class System {
this.forEachObject(obj => { this.forEachObject(obj => {
// Acceleration // Acceleration
obj.acceleration = obj.getAcceleration(); obj.acceleration = obj.getAcceleration();
// Kinetic Energy
obj.kineticEnergy = obj.mass * square(obj.velocity) / 2;
}); });
} }
@ -452,7 +537,7 @@ export class System {
const netMomentum = this.reduce((acc, obj) => { const netMomentum = this.reduce((acc, obj) => {
if (objects.length && !objects.includes(obj)) return acc; if (objects.length && !objects.includes(obj)) return acc;
return add(acc, mult(obj.velocity, obj.mass)); return add(acc, mult(obj.velocity, obj.mass));
}, zero); }, zero, {alive: true});
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum}; return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
} }

View File

@ -1,5 +1,6 @@
import {hide, show} from '../helper.js'; import {hide, show} from '../helper.js';
import {Tool} from '../tool.js'; import {Tool} from '../tool.js';
import {magnitude} from '../vector.js';
export class ObjectsTool extends Tool { export class ObjectsTool extends Tool {
objects = []; objects = [];
@ -63,9 +64,12 @@ export class ObjectsTool extends Tool {
// }))); // })));
objectEl.innerHTML = objectEl.innerHTML =
`<span style="background-color: rgb(${r},${g},${b});">&nbsp;&nbsp;</span>` + `<span style="background-color: rgb(${r},${g},${b});">&nbsp;&nbsp;</span>` +
`E<sub>k</sub>: ${obj.kineticEnergy.toFixed(0)}` + `<div style="display:inline-block; vertical-align:middle; padding: 2px;">` +
`<br>&nbsp;&nbsp;W<sub>p</sub>: ${obj.workDoneByPointer.toFixed(0)}` + `E<sub>k</sub>: ${obj.kineticEnergy.toFixed(0)}<br>` +
`<br>&nbsp;&nbsp;W<sub>f</sub>: ${obj.workDoneByForces.toFixed(0)}`; `|p|: ${magnitude(obj.momentum).toFixed(0)}<br>` +
// `<br>&nbsp;&nbsp;W<sub>p</sub>: ${obj.workDoneByPointer.toFixed(0)}` +
// `<br>&nbsp;&nbsp;W<sub>f</sub>: ${obj.workDoneByForces.toFixed(0)}`;
'</div>';
// `${obj.mass.toPrecision(3)} ` + // `${obj.mass.toPrecision(3)} ` +
// `${distance.toPrecision(3)}`; // `${distance.toPrecision(3)}`;

View File

@ -1,7 +1,7 @@
export const copy = (v) => ({x: v.x, y: v.y}); export const copy = (v) => ({x: v.x, y: v.y});
export const dot = (a, b) => a.x * b.x + a.y * b.y; export const dot = (a, b) => a.x * b.x + a.y * b.y;
export const cross = (a, b) => a.x * b.y - a.y * b.x; export const cross = (a, b) => a.x * b.y - a.y * b.x;
export const add = (a, b) => ({x: a.x + b.x, y: a.y + b.y}); const _add = (a, b) => ({x: a.x + b.x, y: a.y + b.y});
export const sub = (a, b) => ({x: a.x - b.x, y: a.y - b.y}); export const sub = (a, b) => ({x: a.x - b.x, y: a.y - b.y});
export const square = ({x, y}) => x ** 2 + y ** 2; export const square = ({x, y}) => x ** 2 + y ** 2;
export const magnitude = ({x, y}) => Math.sqrt(square({x, y})); export const magnitude = ({x, y}) => Math.sqrt(square({x, y}));
@ -20,7 +20,7 @@ export const weightedAvg = (items) => {
let res = zero; let res = zero;
let W = 0; let W = 0;
for (const [v, w] of items) { for (const [v, w] of items) {
res = add(res, mult(v, w)); res = _add(res, mult(v, w));
W += w; W += w;
} }
return div(res, W); return div(res, W);
@ -33,3 +33,11 @@ export const components = (mag, dir) => mult(mag, {
export const direction = ({x, y}) => Math.atan2(y, x); export const direction = ({x, y}) => Math.atan2(y, x);
export const degrees = (rad) => rad * 180 / Math.PI; export const degrees = (rad) => rad * 180 / Math.PI;
export const add = (...vectors) => {
let res = zero;
for (const v of vectors) {
res = _add(res, v);
}
return res;
}

27
vector.test.js Normal file
View File

@ -0,0 +1,27 @@
import {add, weightedAvg} from "./vector.js";
const assert = (expected, actual) => {
if (expected !== actual) {
console.error(`fail: ${expected} !== ${actual}`);
throw new Error('fail');
}
console.log(`${expected} === ${actual}`);
};
// add
{
const v1 = {x: 1, y: 1};
const v = add(v1, v1, v1);
assert('{"x":3,"y":3}', JSON.stringify(v));
}
// weightedAvg
{
const v1 = {x: 1, y: 1};
const v2 = {x: -1, y: -1};
const v3 = {x: 2, y: 2};
assert('{"x":0,"y":0}', JSON.stringify(weightedAvg([[v1, 1], [v2, 1]])));
assert('{"x":1,"y":1}', JSON.stringify(weightedAvg([[v1, 1], [v1, 1]])));
assert('{"x":1.25,"y":1.25}', JSON.stringify(weightedAvg([[v1, 3], [v3, 1]])));
}