From 21820c53af2ebc67dc9dcc19a036fae0a7c6174f Mon Sep 17 00:00:00 2001 From: Ladd Date: Thu, 29 Jan 2026 11:28:39 -0600 Subject: [PATCH] implemented elastic collisions --- object.js | 11 ++- pointer.js | 7 +- simulator.js | 8 ++ system.js | 189 +++++++++++++++++++++++++++++++++++------------- tool/objects.js | 10 ++- vector.js | 12 ++- vector.test.js | 27 +++++++ 7 files changed, 204 insertions(+), 60 deletions(-) create mode 100644 vector.test.js 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/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/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..d4ddb49 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,95 @@ 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); + + // TODO: Handle scenario where an object overlaps more than one other object + + // const elasticity = 1; + 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? + // const vRel = sub(A.velocity, B.velocity); + // const G = this.sim.getOption('param.gravity'); + // if (square(vRel) < 0.1 * G * (A.mass + B.mass) / magnitude(r)) { + // // Neither object looks like it can escape! + // this.mergeObjects(i, j); + // return; + // } + + // Continue with rebound calculations + const vAnNew = (vAn * (A.mass - B.mass) + vBn * 2 * B.mass) / (A.mass + B.mass); + 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, -1 * A.mass)), B.mass); + + A.velocity = vA; + B.velocity = vB; + } + + 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 +489,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 +511,6 @@ export class System { this.forEachObject(obj => { // Acceleration obj.acceleration = obj.getAcceleration(); - - // Kinetic Energy - obj.kineticEnergy = obj.mass * square(obj.velocity) / 2; }); } @@ -452,7 +537,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/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]]))); +}