import {EVENT_OBJECT_CREATE, EVENT_OBJECT_MERGE, 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 = []; const e = new CustomEvent(EVENT_OBJECT_MERGE, {detail: {surviving: S, merged: T}}); this.sim.div.dispatchEvent(e); } }, {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}`] = [ `  `, `${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/s2, ${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); const e = new CustomEvent(EVENT_OBJECT_CREATE, {detail: {obj}}); this.sim.div.dispatchEvent(e); } doneCreatingObject() { if (this.creatingObject !== undefined) { this.creatingObject = undefined; this.resume(); } } // cb: (obj) => undefined onCreate(cb) { this.sim.div.addEventListener(EVENT_OBJECT_CREATE, ({detail: {obj}}) => { cb(obj); }); } // cb: ({surviving, merged}) => undefined onMerge(cb) { this.sim.div.addEventListener(EVENT_OBJECT_MERGE, ({detail: {surviving, merged}}) => { cb({surviving, merged}); }); } 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; } getBoundingBox(objects = []) { const box = this.reduce(({start, end}, obj) => { if (objects.length && !objects.includes(obj)) return {start, end}; 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; if (start.x === undefined) { return { start: {x: lx, y: ly}, end: {x: gx, y: gy}, }; } return { 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), } }; }, { 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(objects = []) { // Determine center of mass const {totalMass, count, totalMassLocation} = this.reduce((acc, obj) => { if (objects.length && !objects.includes(obj)) return acc; return { 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) => { if (objects.length && !objects.includes(obj)) return acc; return 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); } }