416 lines
12 KiB
JavaScript
416 lines
12 KiB
JavaScript
import {OBJECT_HISTORY_SIZE} from './config.js';
|
|
import {MassObject} from './object.js';
|
|
import {
|
|
add, copy, cross, degrees,
|
|
direction, div, magnitude, mult,
|
|
square, sub, weightedAvg, zero
|
|
} from './vector.js';
|
|
|
|
export class System {
|
|
objects = [];
|
|
creatingObject = undefined;
|
|
selectedObject = undefined;
|
|
selectObjectStart = undefined;
|
|
panVelocityPaused = undefined;
|
|
paused = false;
|
|
|
|
constructor(sim) {
|
|
this.sim = sim;
|
|
}
|
|
|
|
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
|
|
obj.velocity = div(this.sim.pointer.latestVelocity, this.sim.display.scale);
|
|
|
|
// Including time scale - if time is slow, our motion is relatively faster
|
|
if (this.sim.getOption('compensate.timeScale')) {
|
|
obj.velocity = div(obj.velocity, this.sim.timeScale);
|
|
}
|
|
|
|
obj.velocity = add(obj.velocity, this.sim.panning.velocity);
|
|
}
|
|
|
|
handlePointerMove(r) {
|
|
// 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 = add(start, sub(r, start.pointer));
|
|
obj.velocity = zero;
|
|
}
|
|
|
|
frame(elapsedTime) {
|
|
// If we're creating an object, increment its mass
|
|
// with the mass creation rate accelerating over time
|
|
if (this.creatingObject !== undefined) {
|
|
const obj = this.objects[this.creatingObject];
|
|
let massCreationRate = this.sim.getOption('param.massCreationRate');
|
|
massCreationRate /= this.sim.display.scale;
|
|
// Keep consistent time scale
|
|
if (this.sim.getOption('compensate.timeScale')) {
|
|
massCreationRate /= this.sim.timeScale;
|
|
}
|
|
obj.mass += massCreationRate * elapsedTime;
|
|
}
|
|
|
|
// Calculate forces due to gravity.
|
|
this.computeForces();
|
|
|
|
if (this.sim.playing) {
|
|
// Predict positions (Velocity verlet method)
|
|
this.forEachObject(obj => {
|
|
obj.currentAcceleration = {...obj.acceleration};
|
|
|
|
// If this object is being created/selected, clamp its position
|
|
if (obj.id === this.getSelectedOrCreating()?.id) {
|
|
return;
|
|
}
|
|
|
|
obj.position = add(obj.position, mult(
|
|
elapsedTime,
|
|
add(
|
|
obj.velocity,
|
|
mult(obj.currentAcceleration, elapsedTime / 2)
|
|
),
|
|
));
|
|
});
|
|
|
|
// 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 = [];
|
|
}
|
|
}, {alive: true, startWith: i + 1});
|
|
});
|
|
|
|
// Recompute forces
|
|
this.computeForces();
|
|
|
|
// Predict velocities
|
|
this.forEachObject(obj => {
|
|
const acceleration = {...obj.acceleration};
|
|
obj.acceleration = div(add(obj.currentAcceleration, acceleration), 2);
|
|
obj.velocity = add(obj.velocity, mult(obj.acceleration, elapsedTime));
|
|
|
|
// Append to object history
|
|
obj.history.push({position: {...obj.position}});
|
|
|
|
// TODO: store object color changes in history
|
|
|
|
// Enforce object history length
|
|
while (obj.history.length > OBJECT_HISTORY_SIZE) {
|
|
obj.history.shift();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 = magnitude(obj.velocity);
|
|
const accel = magnitude(obj.acceleration);
|
|
// Give angle counterclockwise from horizontal
|
|
const velocityDir = -1 * degrees(direction(obj.velocity));
|
|
const accelDir = -1 * degrees(direction(obj.acceleration));
|
|
const {r, g, b} = obj.color;
|
|
this.sim.info[`Object ${i}`] = [
|
|
`<span style="background-color: rgb(${r},${g},${b});"> </span>`,
|
|
`${obj.position.x.toPrecision(4)}, `,
|
|
`${obj.position.y.toPrecision(4)}, `,
|
|
`${obj.mass.toPrecision(4)} kg, `,
|
|
`${speed.toPrecision(2)} m/s, ${velocityDir.toPrecision(2)}°`,
|
|
`${accel.toPrecision(2)} m/s<sup>2</sup>, ${accelDir.toPrecision(2)}°`,
|
|
`Alive: ${obj.alive}`,
|
|
];
|
|
}, {alive: aliveOnly || null});
|
|
}
|
|
|
|
// Render the objects
|
|
this.drawObjects();
|
|
}
|
|
|
|
pause() {
|
|
this.sim.pause();
|
|
this.paused = true;
|
|
}
|
|
|
|
resume() {
|
|
if (this.paused) {
|
|
this.paused = false;
|
|
this.sim.play();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
obj.velocity = copy(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 = magnitude(sub(obj.position, {x, y}));
|
|
if (dist <= obj.radius) {
|
|
idx = i;
|
|
return null;
|
|
}
|
|
});
|
|
return idx;
|
|
}
|
|
|
|
// 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() {
|
|
// Draw all paths, all objects, and then all arrows
|
|
this.forEachObject(obj => obj.drawPath(this.sim.display), {alive: null});
|
|
this.forEachObject(obj => obj.drawObject(this.sim.display), {alive: true});
|
|
this.forEachObject(obj => obj.drawSelection(this.sim.display), {alive: true});
|
|
this.forEachObject(obj => obj.drawArrows(this.sim.display), {alive: true});
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// cb: (obj, idx) => boolean
|
|
filter(cb, opts) {
|
|
let objects = [];
|
|
this.forEachObject((obj, idx) => {
|
|
const ret = cb(obj, idx);
|
|
if (ret) {
|
|
objects.push(obj);
|
|
}
|
|
}, opts);
|
|
return objects;
|
|
}
|
|
|
|
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 r = sub(B.position, A.position);
|
|
const dSquared = square(r);
|
|
const d = Math.sqrt(dSquared);
|
|
const F = gravity * A.mass * B.mass / dSquared;
|
|
const Fx = F * r.x / d;
|
|
const Fy = F * r.y / d;
|
|
// Equal and opposite forces
|
|
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();
|
|
});
|
|
}
|
|
|
|
computeSystemCenter() {
|
|
// Determine center of mass
|
|
const {totalMass, count, totalMassLocation} =
|
|
this.reduce((acc, obj) => ({
|
|
count: acc.count + 1,
|
|
totalMass: acc.totalMass + obj.mass,
|
|
totalMassLocation: add(acc.totalMassLocation,
|
|
mult(obj.position, obj.mass)),
|
|
}), {
|
|
totalMassLocation: {x: 0, y: 0},
|
|
totalMass: 0,
|
|
count: 0,
|
|
});
|
|
|
|
const centerOfMass = count ? div(totalMassLocation, totalMass) : zero;
|
|
|
|
// Determine average momentum
|
|
const netMomentum = this.reduce((acc, obj) =>
|
|
add(acc, mult(obj.velocity, obj.mass)), zero);
|
|
|
|
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 = sub(obj.position, centerOfMass);
|
|
const s = cross(obj.velocity, r);
|
|
const d = Math.sqrt(r.x ** 2 + r.y ** 2);
|
|
return acc + obj.mass * s / d;
|
|
}, 0);
|
|
}
|
|
|
|
}
|