gravity/system.js
2025-12-31 19:03:15 -06:00

416 lines
13 KiB
JavaScript

import { MassObject } from './object.js';
export class System {
objects = [];
creatingObject = undefined;
selectedObject = undefined;
selectObjectStart = undefined;
paused = false;
panVelocityPaused = undefined;
constructor(sim) {
this.sim = sim;
}
pause() {
if (this.sim.playing) {
this.sim.playing = false;
this.paused = true;
if (this.sim.panning?.velocity) {
this.sim.panning.paused = true;
}
}
}
resume() {
if (this.paused) {
this.sim.playing = true;
this.paused = false;
if (this.sim.panning?.paused) {
this.sim.panning.paused = false;
}
}
}
// Create an object with mass that grows as pointer is held down
createObject(x, y) {
const idx = this.objects.length;
const obj = new MassObject(this.sim, x, y);
this.creatingObject = idx;
this.selectedObjectStart = {x, y, pointer: {x, y}};
this.objects.push(obj);
// Pause the simulation during mass creation; this avoids some complex local dynamics
if (this.sim.getOption('pauseDuring.creation')) {
this.pause();
}
if (this.sim.panning?.velocity) {
obj.velocity = {...this.sim.panning.velocity};
}
}
doneCreatingObject() {
if (this.creatingObject !== undefined) {
this.creatingObject = undefined;
this.resume();
}
}
object(i) {
return this.objects[i];
}
selectObject(i, pointer) {
this.selectedObject = i;
const {x, y} = this.object(i).position;
this.selectedObjectStart = {x, y, pointer};
if (this.sim.getOption('pauseDuring.selection')) {
this.pause();
}
}
deselect() {
this.selectedObject = undefined;
this.selectedObjectStart = undefined;
this.resume();
}
getSelectedOrCreating() {
let i = this.creatingObject ?? this.selectedObject;
if (i !== undefined) {
return this.objects[i];
}
}
get length() {
return this.objects.length;
}
get boundingBox() {
const box = this.reduce(({start, end}, obj) => {
const lx = obj.position.x - obj.radius;
const gx = obj.position.x + obj.radius;
const ly = obj.position.y - obj.radius;
const gy = obj.position.y + obj.radius;
let ret;
if (start.x === undefined) {
ret = {
start: {x: lx, y: ly},
end: {x: gx, y: gy},
};
return ret;
}
ret = {
start: {
x: Math.min(start.x, lx),
y: Math.min(start.y, ly),
},
end: {
x: Math.max(end.x, gx),
y: Math.max(end.y, gy),
}
};
return ret;
}, {
start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined},
});
box.start.x = (box.start.x ?? 0);
box.start.y = (box.start.y ?? 0);
box.end.x = (box.end.x ?? 0);
box.end.y = (box.end.y ?? 0);
return box;
}
objectAtLocation(x, y) {
let idx = undefined;
this.selectedObjectStart = undefined;
this.forEachObject((obj, i) => {
// If distance to object is less than object's radius, we are touching the object
const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2);
if (dist <= obj.radius) {
idx = i;
return null;
}
});
return idx;
}
handlePointerDown({x, y}) {
// If pointer is touching an object, select the object
const touchingObject = this.objectAtLocation(x, y);
if (touchingObject !== undefined) {
this.selectObject(touchingObject, {x, y});
} else {
// Otherwise, create a new object
this.createObject(x, y);
}
}
handlePointerUp() {
const obj = this.getSelectedOrCreating();
if (obj === undefined) return;
this.doneCreatingObject();
this.deselect();
// Convert pointer velocity to simulation scale
// Including time scale - if time is slow, our motion is relatively faster
const pointer = {...this.sim.pointer.latestVelocity};
obj.velocity.x = pointer.x / this.sim.display.scale / this.sim.timeScale;
obj.velocity.y = pointer.y / this.sim.display.scale / this.sim.timeScale;
if (this.sim.panning?.velocity) {
obj.velocity.x += this.sim.panning.velocity.x;
obj.velocity.y += this.sim.panning.velocity.y;
}
}
handlePointerMove({x, y}) {
// If the cursor moves while creating an object, or while an object is selected,
// update the position using the pointer motion but the velocity using the pointer velocity
const obj = this.getSelectedOrCreating();
if (obj === undefined) return;
const start = this.selectedObjectStart;
obj.position.x = start.x + (x - start.pointer.x);
obj.position.y = start.y + (y - start.pointer.y);
obj.velocity.x = 0;
obj.velocity.y = 0;
}
// cb: (obj, idx) => {}
forEachObject(cb, {alive, startWith} = {}) {
if (alive === undefined) alive = true;
for (let i = startWith ?? 0; i < this.objects.length; i++) {
const obj = this.objects[i];
if (alive === null || alive == obj.alive) {
const ret = cb(obj, i);
if (ret === null) break;
}
}
}
drawObjects() {
this.forEachObject(obj => obj.drawObject(this.sim), {alive: null});
}
// cb: (acc, obj, idx) => {}
reduce(cb, initial, opts) {
let acc = initial;
this.forEachObject((obj, idx) => {
const ret = cb(acc, obj, idx);
if (ret !== undefined) {
acc = ret;
}
}, opts);
return acc;
}
computeForces() {
const gravity = this.sim.getOption('param.gravity');
if (this.objects.length < 2) return;
this.forEachObject(obj => {
obj.forces = [];
});
this.forEachObject((A, i) => {
this.forEachObject(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);
const F = gravity * A.mass * B.mass / dSquared;
const Fx = F * dx / d;
const Fy = F * dy / d;
A.forces.push({ x: Fx, y: Fy });
B.forces.push({ x: -Fx, y: -Fy });
}, {alive: true, startWith: i + 1});
});
// Also compute acceleration
this.forEachObject(obj => {
obj.acceleration = obj.getAcceleration();
});
}
// elapsedTime is given in milliseconds
frame(elapsedTime) {
// If we're creating an object, increment its mass
// with the mass creation rate accelerating over time
// Scaling this parameter because of millisecond conversion
if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject];
let massCreationRate = this.sim.getOption('param.massCreationRate');
// Mass creation rate acceleration
if (this.sim.getOption('param.massAcceleration')) {
// TODO: Separate parameter for mass creation acceleration rate
massCreationRate *= obj.rawAge;
}
// Keep consistent time scale
obj.mass += massCreationRate * elapsedTime / this.sim.timeScale;
}
// Calculate forces due to gravity.
this.computeForces();
if (this.sim.playing) {
// Predict positions (Velocity verlet method)
this.forEachObject(obj => {
obj.currentAcceleration = {...obj.acceleration};
obj.position.x += elapsedTime *
(obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime);
obj.position.y += elapsedTime *
(obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime);
});
// Collisions
if (this.sim.getOption('collision.merge')) {
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
// Sdd masses
// Sverage color
S.position.x = (S.position.x * S.mass + T.position.x * T.mass) / (S.mass + T.mass);
S.position.y = (S.position.y * S.mass + T.position.y * T.mass) / (S.mass + T.mass);
S.velocity.x = (S.velocity.x * S.mass + T.velocity.x * T.mass) / (S.mass + T.mass);
S.velocity.y = (S.velocity.y * S.mass + T.velocity.y * T.mass) / (S.mass + 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 = [];
}
}, {alive: true, startWith: i + 1});
});
}
// Recompute forces
this.computeForces();
// Predict velocities
this.forEachObject(obj => {
const acceleration = {...obj.acceleration};
obj.acceleration = {
x: (obj.currentAcceleration.x + acceleration.x) / 2,
y: (obj.currentAcceleration.y + acceleration.y) / 2,
};
obj.velocity.x += obj.acceleration.x * elapsedTime;
obj.velocity.y += obj.acceleration.y * elapsedTime;
// Append to object history
// TODO: enforce object history length
// TODO: store object color changes in history
obj.history.push({position: {...obj.position}});
});
}
// Display objects info
// First clear info from previous frame
this.forEachObject((_obj, i) => {
delete this.sim.info[`Object ${i}`];
}, { alive: null });
if (this.sim.getOption('debug.objectsInfo')) {
const aliveOnly = this.sim.getOption('debug.aliveObjects');
this.forEachObject((obj, i) => {
const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2);
const accel = Math.pow(obj.acceleration.x ** 2 + obj.acceleration.y ** 2, 1/2);
// Invert y so that the angle is counterclockwise from x-axis
const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI;
const accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI;
const {r, g, b} = obj.color;
this.sim.info[`Object ${i}`] = [
`<span style="background-color: rgb(${r},${g},${b});">&nbsp;&nbsp;</span>`,
`${obj.position.x.toPrecision(4)}, `,
`${obj.position.y.toPrecision(4)}, `,
`${obj.mass.toPrecision(4)} kg, `,
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
`${accel.toPrecision(2)} m/s<sup>2</sup>, ${accelDir.toPrecision(2)}°`,
`Alive: ${obj.alive}`,
];
}, { alive: aliveOnly || null });
}
// Render the objects
this.drawObjects();
}
computeSystemCenter() {
// Determine center of mass
const { totalMass, count, totalMassLocation } =
this.reduce((acc, obj) => ({
count: acc.count + 1,
totalMass: acc.totalMass + obj.mass,
totalMassLocation: {
x: acc.totalMassLocation.x + obj.position.x * obj.mass,
y: acc.totalMassLocation.y + obj.position.y * obj.mass,
},
}), {
totalMassLocation: {x: 0, y: 0},
totalMass: 0,
count: 0,
});
const centerOfMass = count ? {
x: totalMassLocation.x / totalMass,
y: totalMassLocation.y / totalMass,
} : {x: 0, y: 0};
// Determine average momentum
const netMomentum = this.reduce((acc, obj) => ({
x: acc.x + obj.mass * obj.velocity.x,
y: acc.y + obj.mass * obj.velocity.y,
}), { x: 0, y: 0 });
return { totalMass, count, totalMassLocation, centerOfMass, netMomentum };
}
computeSystemAngularMomentum(centerOfMass) {
if (!centerOfMass) {
const sys = this.computeSystemCenter();
centerOfMass = sys.centerOfMass;
}
return this.reduce((acc, obj) => {
// Angular momentum for each object is m * s / d
// where d is the distance of the object from the global center of mass
// and s is the magnitude of the cross product of v and r
const r = {
x: obj.position.x - centerOfMass.x,
y: obj.position.y - centerOfMass.y,
};
const v = obj.velocity;
const s = v.x * r.y - v.y * r.x;
const d = Math.sqrt(r.x ** 2 + r.y ** 2);
return acc + obj.mass * s / d;
}, 0);
}
}