diff --git a/Readme.md b/Readme.md
index 40ba8ab..751e1e8 100644
--- a/Readme.md
+++ b/Readme.md
@@ -8,11 +8,11 @@ Uses `npm` for `eslint`.
Screenshots
-----------
-
+
-
+
-
+
TODO
diff --git a/config.js b/config.js
index cb706d7..c94ac4b 100644
--- a/config.js
+++ b/config.js
@@ -20,7 +20,7 @@ export const PATH_TRACES_WIDTH = 1.5;
export const PATH_TRACES_DASHED_OPACITY = 1.0;
// PANNING
-export const PANNING_ZERO_TOUCH_THRESHOLD = 200;
+export const PANNING_ZERO_TOUCH_THRESHOLD = 200; // Milliseconds
// SIZES
export const POINTER_HISTORY_SIZE = 20;
diff --git a/gravity-simulator-5.png b/gravity-simulator-5.png
deleted file mode 100644
index f69c4d6..0000000
Binary files a/gravity-simulator-5.png and /dev/null differ
diff --git a/object.js b/object.js
index 77bd2d7..c45919f 100644
--- a/object.js
+++ b/object.js
@@ -16,7 +16,7 @@ import {
VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH,
} 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 {
sim = undefined;
@@ -31,7 +31,6 @@ export class MassObject {
forces = []; // [{x, y}]
history = [];
alive = true;
- kineticEnergy = 0;
workDoneByPointer = 0;
workDoneByForces = 0;
@@ -92,6 +91,14 @@ export class MassObject {
return div(netForce, this.mass);
}
+ get kineticEnergy() {
+ return this.mass * square(this.velocity) / 2;
+ }
+
+ get momentum() {
+ return mult(this.mass, this.velocity);
+ }
+
drawPath(display) {
const {ctx} = display;
const {color: {r, g, b}} = this;
diff --git a/panning.js b/panning.js
index 6f7dbb2..31227bf 100644
--- a/panning.js
+++ b/panning.js
@@ -69,8 +69,6 @@ export class Panning {
// Additional scaling factor
velocity = mult(velocity, this.sim.getOption('display.panningSpeed'));
- // TODO: Make it easier to slow down the camera
-
// Add pointer velocity to current panning velocity
this.velocity = add(this.velocity, velocity);
}
@@ -82,10 +80,6 @@ export class Panning {
this.velocity = zero;
}
this.touchStart = undefined;
-
- if (this.sim.getOption('compensate.fastPanning')) {
- this.velocity = zero;
- }
}
}
diff --git a/pointer.js b/pointer.js
index 496a51d..f416711 100644
--- a/pointer.js
+++ b/pointer.js
@@ -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});
switch (this.sim.getCurrentMode()) {
diff --git a/gravity-simulator-2.png b/screenshots/gravity-simulator-2.png
similarity index 100%
rename from gravity-simulator-2.png
rename to screenshots/gravity-simulator-2.png
diff --git a/gravity-simulator-3.png b/screenshots/gravity-simulator-3.png
similarity index 100%
rename from gravity-simulator-3.png
rename to screenshots/gravity-simulator-3.png
diff --git a/gravity-simulator-4.png b/screenshots/gravity-simulator-4.png
similarity index 100%
rename from gravity-simulator-4.png
rename to screenshots/gravity-simulator-4.png
diff --git a/screenshots/gravity-simulator-5.png b/screenshots/gravity-simulator-5.png
new file mode 100644
index 0000000..fbe3e35
Binary files /dev/null and b/screenshots/gravity-simulator-5.png differ
diff --git a/gravity-simulator-6.png b/screenshots/gravity-simulator-6.png
similarity index 100%
rename from gravity-simulator-6.png
rename to screenshots/gravity-simulator-6.png
diff --git a/sim-options.js b/sim-options.js
index c79a310..27f2ddc 100644
--- a/sim-options.js
+++ b/sim-options.js
@@ -10,18 +10,19 @@ export const simOptions = {
traces: ['Path Traces', 'boolean', true],
dashedTraces: ['Dashed', 'boolean', false, {tall: true, showIf: 'display.traces'}],
velocityScale: ['Velocity
Vec Scale', 'number', 80, {showIf: 'display.velocity'}],
- accelerationScale: ['Accel
Vec Scale', 'number', 800, {showIf: 'display.acceleration'}],
+ accelerationScale: ['Accel
Vec Scale', 'number', 80, {showIf: 'display.acceleration'}],
zoomVectors: ['Zoom Vectors', 'boolean', true],
panningSpeed: ['Pan
Speed', 'number', 0.1],
},
compensate: {
timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}],
- fastPanning: ['Fast Panning', 'boolean', false],
},
param: {
gravity: ['Gravity', 'number', 1],
timeScale: ['Time Scale', 'number', 0.1],
massCreationRate: ['Mass Creation Rate', 'number', 1],
+ elasticity: ['Elasticity', 'number', 0.7, {min: 0, max: 1}],
+ mergeThreshold: ['Merge Threshold', 'number', 0.1, {min: 0, max: 1}],
},
debug: {
objectsInfo: ['Objects Info', 'boolean', false],
diff --git a/simulator.js b/simulator.js
index e85551e..4dd86fa 100644
--- a/simulator.js
+++ b/simulator.js
@@ -51,6 +51,8 @@ export class Sim {
const url = new URL(window.location.href);
if (url.host === 'gravity.dev.laddhoffman.com') {
document.title += ' [Dev]';
+ } else if (url.host === 'gravity.local') {
+ document.title += ' [Local]';
}
this.options = new Options(this, simOptions);
@@ -109,6 +111,7 @@ export class Sim {
requestAnimationFrame(t => this.frame(t));
}
+ // Record frame timestamp information
markFrame(t) {
const {recentFrames: rfs} = this;
if (!rfs.length) {
@@ -133,6 +136,7 @@ export class Sim {
return false;
}
+ // toJSON is used to export the state of the simulation
toJSON() {
return {
dateSaved: new Date().toISOString(),
@@ -145,6 +149,7 @@ export class Sim {
};
}
+ // fromJSON is used to import the state of the simulation
fromJSON(state) {
this.pause();
this.system.fromJSON(state.system);
@@ -165,6 +170,7 @@ export class Sim {
return this.display.screenToSim(x, y);
}
+ // Start the simulation if it's not already running
play() {
this.playing = true;
@@ -176,6 +182,7 @@ export class Sim {
this.div.dispatchEvent(e);
}
+ // Pause the simulation if it's currently running
pause() {
this.playing = false;
@@ -187,6 +194,7 @@ export class Sim {
this.div.dispatchEvent(e);
}
+ // Generate the display content representing the current scale of the simulation
getScaleDisplay() {
const scale = 2 ** Math.abs(this.display.scalePower);
const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
diff --git a/system.js b/system.js
index 6699336..995b8eb 100644
--- a/system.js
+++ b/system.js
@@ -72,6 +72,15 @@ export class System {
// TODO: Calculate work done by pointer here?
// Either interpolate the acceleration and use m*a, or
// 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.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
this.computeForces();
@@ -189,8 +150,41 @@ export class System {
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 Ug'] = totalGPE.toExponential(2);
+ this.sim.info['Net Momentum'] = magnitude(netMomentum).toExponential(2);
+
// Display objects info
// First clear info from previous frame
this.forEachObject((_obj, i) => {
@@ -395,6 +389,107 @@ export class System {
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);
+ const totalMass = A.mass + B.mass;
+
+ // TODO: Handle scenario where an object overlaps more than one other object
+
+ const autoMerge = true;
+
+ const Z = this.sim.getOption('param.elasticity'); // Elasticity: 0 = inelastic, 1 = elastic
+ 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?
+ if (autoMerge) {
+ // const vSquared = Math.abs(vAn - vBn) ** 2;
+ const vSquared = square(sub(A.velocity, B.velocity));
+ const G = this.sim.getOption('param.gravity');
+ const mergeThreshold = this.sim.getOption('param.mergeThreshold');
+ if (vSquared < mergeThreshold * G * totalMass / magnitude(r)) {
+ // Neither object looks like it can escape!
+ this.mergeObjects(i, j);
+ return;
+ }
+ }
+
+ // Calculate the collision
+ const Zscaled = Math.sqrt(Z);
+ const vAnNew = (vAn * (A.mass - Zscaled * B.mass) + vBn * (1 + Zscaled) * B.mass) / totalMass;
+
+ 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, -A.mass)), B.mass);
+
+ // const Ki = A.kineticEnergy + B.kineticEnergy;
+ A.velocity = vA;
+ B.velocity = vB;
+ // const Kf = A.kineticEnergy + B.kineticEnergy;
+ // console.log('Collision: Zscaled', Zscaled, 'Energy before', Ki, 'Energy after', Kf, 'Fraction change', (Kf - Ki) / Ki);
+ }
+
+ 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() {
const gravity = this.sim.getOption('param.gravity');
if (this.objects.length < 2) return;
@@ -406,6 +501,11 @@ export class System {
const r = sub(B.position, A.position);
const dSquared = square(r);
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 Fx = F * r.x / d;
const Fy = F * r.y / d;
@@ -423,9 +523,6 @@ export class System {
this.forEachObject(obj => {
// Acceleration
obj.acceleration = obj.getAcceleration();
-
- // Kinetic Energy
- obj.kineticEnergy = obj.mass * square(obj.velocity) / 2;
});
}
@@ -452,7 +549,7 @@ export class System {
const netMomentum = this.reduce((acc, obj) => {
if (objects.length && !objects.includes(obj)) return acc;
return add(acc, mult(obj.velocity, obj.mass));
- }, zero);
+ }, zero, {alive: true});
return {totalMass, count, totalMassLocation, centerOfMass, netMomentum};
}
diff --git a/tool/objects.js b/tool/objects.js
index a01a9bb..d4bda50 100644
--- a/tool/objects.js
+++ b/tool/objects.js
@@ -1,5 +1,6 @@
import {hide, show} from '../helper.js';
import {Tool} from '../tool.js';
+import {magnitude} from '../vector.js';
export class ObjectsTool extends Tool {
objects = [];
@@ -63,9 +64,12 @@ export class ObjectsTool extends Tool {
// })));
objectEl.innerHTML =
` ` +
- `Ek: ${obj.kineticEnergy.toFixed(0)}` +
- `
Wp: ${obj.workDoneByPointer.toFixed(0)}` +
- `
Wf: ${obj.workDoneByForces.toFixed(0)}`;
+ `