Merge branch 'dev'
@ -8,11 +8,11 @@ Uses `npm` for `eslint`.
|
||||
Screenshots
|
||||
-----------
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
TODO
|
||||
|
||||
@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 152 KiB |
11
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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()) {
|
||||
|
||||
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
BIN
screenshots/gravity-simulator-5.png
Normal file
|
After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
@ -10,18 +10,19 @@ export const simOptions = {
|
||||
traces: ['Path Traces', 'boolean', true],
|
||||
dashedTraces: ['Dashed', 'boolean', false, {tall: true, showIf: 'display.traces'}],
|
||||
velocityScale: ['Velocity<br>Vec Scale', 'number', 80, {showIf: 'display.velocity'}],
|
||||
accelerationScale: ['Accel<br>Vec Scale', 'number', 800, {showIf: 'display.acceleration'}],
|
||||
accelerationScale: ['Accel<br>Vec Scale', 'number', 80, {showIf: 'display.acceleration'}],
|
||||
zoomVectors: ['Zoom Vectors', 'boolean', true],
|
||||
panningSpeed: ['Pan<br>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],
|
||||
|
||||
@ -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}`;
|
||||
|
||||
201
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 U<sub>g</sub>'] = 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};
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
`<span style="background-color: rgb(${r},${g},${b});"> </span>` +
|
||||
`E<sub>k</sub>: ${obj.kineticEnergy.toFixed(0)}` +
|
||||
`<br> W<sub>p</sub>: ${obj.workDoneByPointer.toFixed(0)}` +
|
||||
`<br> W<sub>f</sub>: ${obj.workDoneByForces.toFixed(0)}`;
|
||||
`<div style="display:inline-block; vertical-align:middle; padding: 2px;">` +
|
||||
`E<sub>k</sub>: ${obj.kineticEnergy.toFixed(0)}<br>` +
|
||||
`|p|: ${magnitude(obj.momentum).toFixed(0)}<br>` +
|
||||
// `<br> W<sub>p</sub>: ${obj.workDoneByPointer.toFixed(0)}` +
|
||||
// `<br> W<sub>f</sub>: ${obj.workDoneByForces.toFixed(0)}`;
|
||||
'</div>';
|
||||
|
||||
// `${obj.mass.toPrecision(3)} ` +
|
||||
// `${distance.toPrecision(3)}`;
|
||||
|
||||
@ -126,6 +126,13 @@ export class OptionsTool extends Tool {
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
this.sim.setOption(path, input.value);
|
||||
// Enforce min/max if provided
|
||||
if (item.max !== undefined && this.sim.getOption(path) > item.max) {
|
||||
this.sim.setOption(path, item.max);
|
||||
}
|
||||
if (item.min !== undefined && this.sim.getOption(path) < item.min) {
|
||||
this.sim.setOption(path, item.min);
|
||||
}
|
||||
});
|
||||
|
||||
this.sim.onOptionSet(path, ({value}) => {
|
||||
|
||||
12
vector.js
@ -1,7 +1,7 @@
|
||||
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 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 square = ({x, y}) => x ** 2 + y ** 2;
|
||||
export const magnitude = ({x, y}) => Math.sqrt(square({x, y}));
|
||||
@ -20,7 +20,7 @@ export const weightedAvg = (items) => {
|
||||
let res = zero;
|
||||
let W = 0;
|
||||
for (const [v, w] of items) {
|
||||
res = add(res, mult(v, w));
|
||||
res = _add(res, mult(v, w));
|
||||
W += 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 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
@ -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]])));
|
||||
}
|
||||