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 ----------- -![A binary pair orbiting a larger partner](./gravity-simulator-4.png "Gravity Simulator Screenshot 4") +![A binary pair orbiting a larger partner](./screenshots/gravity-simulator-4.png "Gravity Simulator Screenshot 4") -![A greater mass attracts others which had been on escape trajectories](./gravity-simulator-5.png "Gravity Simulator Screenshot 5") +![A greater mass attracts others which had been on escape trajectories](./screenshots/gravity-simulator-5.png "Gravity Simulator Screenshot 5") -![A small object orbits a more massive binary pair](./gravity-simulator-6.png "Gravity Simulator Screenshot 6") +![A small object orbits a more massive binary pair](./screenshots/gravity-simulator-6.png "Gravity Simulator Screenshot 6") 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)}`; + `
` + + `Ek: ${obj.kineticEnergy.toFixed(0)}
` + + `|p|: ${magnitude(obj.momentum).toFixed(0)}
` + + // `
  Wp: ${obj.workDoneByPointer.toFixed(0)}` + + // `
  Wf: ${obj.workDoneByForces.toFixed(0)}`; + '
'; // `${obj.mass.toPrecision(3)} ` + // `${distance.toPrecision(3)}`; diff --git a/tool/options.js b/tool/options.js index 83ac97f..86cf065 100644 --- a/tool/options.js +++ b/tool/options.js @@ -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}) => { diff --git a/vector.js b/vector.js index 5b53704..25ed0aa 100644 --- a/vector.js +++ b/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; +} diff --git a/vector.test.js b/vector.test.js new file mode 100644 index 0000000..f572aca --- /dev/null +++ b/vector.test.js @@ -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]]))); +}