Compare commits

...

2 Commits

Author SHA1 Message Date
3b2122d2e6
verlet gravity first cut 2025-12-26 21:31:07 -06:00
429357469d
acceleration first cut 2025-12-26 20:55:43 -06:00
7 changed files with 182 additions and 35 deletions

View File

@ -4,16 +4,26 @@ export const DISPLAY_CANVAS_SIZE = false;
export const DISPLAY_CURRENT_SCALE = true; export const DISPLAY_CURRENT_SCALE = true;
export const DISPLAY_CURRENT_MODE = false; export const DISPLAY_CURRENT_MODE = false;
export const DISPLAY_VELOCITY_VECTORS = true; export const DISPLAY_VELOCITY_VECTORS = true;
export const DISPLAY_ACCELERATION_VECTORS = true;
export const MASS_CREATION_RATE = 0.001; export const MASS_CREATION_RATE = 0.001;
export const POINTER_HISTORY_SIZE = 15; export const POINTER_HISTORY_SIZE = 15;
export const VELOCITY_VECTOR_SCALE = 0.1; export const VELOCITY_VECTOR_SCALE = 0.1;
export const VELOCITY_VECTOR_COLOR = 'rgb(150, 150, 150)'; // optionally set to 'object color' export const VELOCITY_VECTOR_COLOR = 'rgb(150, 150, 150)'; // optionally set to 'object color'
export const VELOCITY_VECTOR_WIDTH = 1.5; export const VELOCITY_VECTOR_WIDTH = 1.5;
export const VELOCITY_VECTOR_ARROWHEAD = true; export const VELOCITY_VECTOR_ARROWHEAD = true;
export const ACCELERATION_VECTOR_SCALE = 1E-1;
export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 255, 0)'; // optionally set to 'object color'
export const ACCELERATION_VECTOR_WIDTH = 1.5;
export const ACCELERATION_VECTOR_ARROWHEAD = true;
export const MOTION_TIME_SCALE = 5E-5;
export const PAN_VELOCITY_SCALE_FACTOR = 1E-3;
export const ARROWHEAD_LENGTH = 7; export const ARROWHEAD_LENGTH = 7;
export const ARROWHEAD_WIDTH = 5; export const ARROWHEAD_WIDTH = 5;
export const MOTION_TIME_SCALE = 0.001;
export const OFFSCREEN_OBJECT_LINE_SCALE = 7; export const OFFSCREEN_OBJECT_LINE_SCALE = 7;
export const OFFSCREEN_OBJECT_LINE_WIDTH = 2; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2;
export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15;
@ -21,8 +31,7 @@ export const ZOOM_IN_FACTOR = 1;
export const ZOOM_OUT_FACTOR = -1; export const ZOOM_OUT_FACTOR = -1;
export const SCALE_POWER_MAX = 8; export const SCALE_POWER_MAX = 8;
export const SCALE_POWER_MIN = -8; export const SCALE_POWER_MIN = -8;
export const PAN_VELOCITY_SCALE_FACTOR = 1E-3; export const GRAVITATIONAL_CONSTANT = 1E5;
export const PAN_DRAG = 1;
export const DRAGGABLE_ELEMENT_CLASSNAME = 'lhg-draggable-element'; export const DRAGGABLE_ELEMENT_CLASSNAME = 'lhg-draggable-element';

View File

@ -3,12 +3,18 @@ import {
VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_WIDTH,
VELOCITY_VECTOR_ARROWHEAD, VELOCITY_VECTOR_ARROWHEAD,
ACCELERATION_VECTOR_SCALE,
ACCELERATION_VECTOR_COLOR,
ACCELERATION_VECTOR_WIDTH,
ACCELERATION_VECTOR_ARROWHEAD,
ARROWHEAD_LENGTH, ARROWHEAD_LENGTH,
ARROWHEAD_WIDTH, ARROWHEAD_WIDTH,
OFFSCREEN_OBJECT_LINE_SCALE, OFFSCREEN_OBJECT_LINE_SCALE,
OFFSCREEN_OBJECT_LINE_WIDTH, OFFSCREEN_OBJECT_LINE_WIDTH,
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
DISPLAY_CANVAS_SIZE, DISPLAY_CANVAS_SIZE,
DISPLAY_VELOCITY_VECTORS,
DISPLAY_ACCELERATION_VECTORS,
} from './config.js'; } from './config.js';
export class Display { export class Display {
@ -77,6 +83,7 @@ export class Display {
const {r, g, b} = obj.color; const {r, g, b} = obj.color;
const {x, y} = obj.position; const {x, y} = obj.position;
const {x: vx, y: vy} = obj.velocity; const {x: vx, y: vy} = obj.velocity;
const acceleration = obj.acceleration;
const radius = obj.radius; const radius = obj.radius;
const {height: H, width: W} = this; const {height: H, width: W} = this;
const ox = this.viewOrigin.x; const ox = this.viewOrigin.x;
@ -139,9 +146,11 @@ export class Display {
ctx.fill(); ctx.fill();
// Draw arrow for the velocity // Draw arrow for the velocity
if (DISPLAY_VELOCITY_VECTORS) {
const endVx = x + VELOCITY_VECTOR_SCALE * vx; const endVx = x + VELOCITY_VECTOR_SCALE * vx;
const endVy = y + VELOCITY_VECTOR_SCALE * vy; const endVy = y + VELOCITY_VECTOR_SCALE * vy;
const style = VELOCITY_VECTOR_COLOR === 'object color' ? ctx.fillStyle : VELOCITY_VECTOR_COLOR; const style = VELOCITY_VECTOR_COLOR === 'object color' ?
ctx.fillStyle : VELOCITY_VECTOR_COLOR;
this.drawArrow(x, y, endVx, endVy, { this.drawArrow(x, y, endVx, endVy, {
style, style,
width: VELOCITY_VECTOR_WIDTH, width: VELOCITY_VECTOR_WIDTH,
@ -149,8 +158,22 @@ export class Display {
fill: false, fill: false,
ifShort: 'head' ifShort: 'head'
}); });
}
// TODO: Draw arrow for acceleration // Draw arrow for acceleration
if (DISPLAY_ACCELERATION_VECTORS) {
const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x;
const endAy = y + ACCELERATION_VECTOR_SCALE * acceleration.y;
const style = ACCELERATION_VECTOR_COLOR === 'object color' ?
ctx.fillStyle : ACCELERATION_VECTOR_COLOR;
this.drawArrow(x, y, endAx, endAy, {
style,
width: ACCELERATION_VECTOR_WIDTH,
arrowhead: ACCELERATION_VECTOR_ARROWHEAD,
fill: false,
ifShort: 'tail'
});
}
} }
drawObjects() { drawObjects() {

View File

@ -6,6 +6,8 @@ export class MassObject {
velocity = {x: 0, y: 0}; velocity = {x: 0, y: 0};
color = {r: undefined, g: undefined, b: undefined}; color = {r: undefined, g: undefined, b: undefined};
created = undefined; created = undefined;
forces = []; // [{x, y}]
active = false;
constructor(x, y, index) { constructor(x, y, index) {
this.index = index; this.index = index;
@ -25,5 +27,18 @@ export class MassObject {
// radius should be proportional to cube root of mass // radius should be proportional to cube root of mass
return Math.pow(this.mass / this.density, 1/3); return Math.pow(this.mass / this.density, 1/3);
} }
get acceleration() {
let ax = 0;
let ay = 0;
for (let {x, y} of this.forces) {
ax += x;
ay += y;
}
return {
x: ax / this.mass,
y: ay / this.mass,
};
}
} }

View File

@ -3,6 +3,7 @@ import {
MASS_CREATION_RATE, MASS_CREATION_RATE,
DISPLAY_OBJECTS_INFO, DISPLAY_OBJECTS_INFO,
MOTION_TIME_SCALE, MOTION_TIME_SCALE,
GRAVITATIONAL_CONSTANT,
} from './config.js'; } from './config.js';
export class Objects { export class Objects {
@ -23,8 +24,12 @@ export class Objects {
} }
doneCreatingObject() { doneCreatingObject() {
if (this.creatingObject !== undefined) {
const obj = this.objects[this.creatingObject];
obj.active = true;
this.creatingObject = undefined; this.creatingObject = undefined;
} }
}
object(i) { object(i) {
return this.objects[i]; return this.objects[i];
@ -49,6 +54,26 @@ export class Objects {
return this.objects.length; return this.objects.length;
} }
get boundingBox() {
const box = {
start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined},
};
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
if (box.start.x === undefined) {
box.start = {...obj.position};
box.end = {...obj.position};
} else {
if (obj.position.x < box.start.x) box.start.x = obj.position.x;
if (obj.position.x > box.end.x) box.end.x = obj.position.x;
if (obj.position.y < box.start.y) box.start.y = obj.position.y;
if (obj.position.y > box.end.y) box.end.y = obj.position.y;
}
}
return box;
}
objectAtLocation(x, y) { objectAtLocation(x, y) {
for (let i = 0; i < this.objects.length; i++) { for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i]; const obj = this.objects[i];
@ -80,7 +105,7 @@ export class Objects {
handlePointerMove({x, y, vx, vy}) { handlePointerMove({x, y, vx, vy}) {
// If the cursor moves while creating an object, or while an object is selected, // If the cursor moves while creating an object, or while an object is selected,
// update the position and velocity of the object // update the position and velocity of the object
const obj = this.sim.objects.getSelectedOrCreating(); const obj = this.getSelectedOrCreating();
if (obj !== undefined) { if (obj !== undefined) {
obj.position.x = x; obj.position.x = x;
obj.position.y = y; obj.position.y = y;
@ -89,6 +114,29 @@ export class Objects {
} }
} }
computeForces(elapsedTime) {
if (this.objects.length < 2) return;
for (let i = 0; i < this.objects.length; i++) {
this.objects[i].forces = [];
}
for (let i = 0; i < this.objects.length; i++) {
const A = this.objects[i];
for (let j = i + 1; j < this.objects.length; j++) {
const B = this.objects[j];
if (!A.active || !B.active) continue;
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);
const F = GRAVITATIONAL_CONSTANT * A.mass * B.mass / dSquared;
const Fx = F * dx / d;
const Fy = F * dy / d;
A.forces.push({ x: Fx, y: Fy });
B.forces.push({ x: -Fx, y: -Fy });
}
}
}
computeFrame(elapsedTime) { computeFrame(elapsedTime) {
// If we're creating an object, increment its mass // If we're creating an object, increment its mass
// with the mass creation rate accelerating over time // with the mass creation rate accelerating over time
@ -99,12 +147,41 @@ export class Objects {
} }
if (this.sim.playing) { if (this.sim.playing) {
// Update positions. Simple Euler method for now. // Calculate forces due to gravity.
this.computeForces(elapsedTime);
// Generate predicted positions (Velocity verlet method)
for (let i = 0; i < this.objects.length; i++) { for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i]; const obj = this.objects[i];
obj.position.x += obj.velocity.x * MOTION_TIME_SCALE; obj.currentAcceleration = {...obj.acceleration};
obj.position.y += obj.velocity.y * MOTION_TIME_SCALE; obj.projectedPosition = {
x: obj.position.x + MOTION_TIME_SCALE * elapsedTime *
(obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime),
y: obj.position.y + MOTION_TIME_SCALE * elapsedTime *
(obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime),
};
obj.currentPosition = {...obj.position};
obj.position = obj.projectedPosition;
} }
// Recompute forces
this.computeForces();
// Average first and second predicted accelerations
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
const acceleration = {...obj.acceleration};
const meanAcceleration = {
x: (obj.currentAcceleration.x + acceleration.x) / 2,
y: (obj.currentAcceleration.y + acceleration.y) / 2,
};
obj.velocity.x += meanAcceleration.x * MOTION_TIME_SCALE * elapsedTime;
obj.velocity.y += meanAcceleration.y * MOTION_TIME_SCALE * elapsedTime;
obj.position.x = obj.currentPosition.x + obj.velocity.x * MOTION_TIME_SCALE * elapsedTime;
obj.position.y = obj.currentPosition.y + obj.velocity.y * MOTION_TIME_SCALE * elapsedTime;
}
} }
// Display objects info // Display objects info

View File

@ -68,7 +68,7 @@ export class Pointer {
}, },
}; };
} else { } else {
this.handlePointerDown(this.sim.screenToSim(e.clientX, e.clientY)); this.handlePointerDown({x: e.clientX, y: e.clientY});
} }
}); });
@ -79,7 +79,7 @@ export class Pointer {
this.draggingElement = undefined; this.draggingElement = undefined;
this.lastPosition = {x: undefined, y: undefined}; this.lastPosition = {x: undefined, y: undefined};
} else { } else {
this.handlePointerUp(this.sim.screenToSim(e.clientX, e.clientY)); this.handlePointerUp({x: e.clientX, y: e.clientY});
} }
}); });
@ -118,24 +118,29 @@ export class Pointer {
} }
} }
handlePointerDown({x, y}) { handlePointerDown({x: clientX, y: clientY}) {
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
const {x, y} = this.sim.screenToSim(clientX, clientY)
this.sim.objects.handlePointerDown({x, y}); this.sim.objects.handlePointerDown({x, y});
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
this.panning = { this.panning = {
gathering: true, gathering: true,
viewOriginStart: this.sim.display.viewOrigin, viewOriginStart: this.sim.display.viewOrigin,
pointerStart: {x, y}, pointerStart: {x: clientX, y: clientY},
pointerCurrent: {x, y}, pointerCurrent: {x: clientX, y: clientY},
velocity: this.getPointerVelocity(), velocity: this.getPointerVelocity(),
}; };
} }
} }
handlePointerUp({x, y}) { handlePointerUp({x: clientX, y: clientY}) {
this.clearPointerHistory(); this.clearPointerHistory();
if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) { if (this.sim.isCurrentMode(MODE_MASS_GENERATION)) {
const {x, y} = this.sim.screenToSim(clientX, clientY);
this.sim.objects.handlePointerUp({x, y}); this.sim.objects.handlePointerUp({x, y});
} else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) { } else if (this.sim.isCurrentMode(MODE_PAN_VIEW)) {
if (this.panning?.gathering) { if (this.panning?.gathering) {
this.panning.gathering = false; this.panning.gathering = false;

View File

@ -58,7 +58,7 @@ export class Sim {
// x, y are the mouse coordinates, which should be the center of the new view frame // x, y are the mouse coordinates, which should be the center of the new view frame
// the new view origin should be x, y minus half the new view width and height // the new view origin should be x, y minus half the new view width and height
// compute new scale // compute new scale
this.display.scalePower += factor > 0 ? 1 : -1; this.display.scalePower += factor;
if (this.display.scalePower > SCALE_POWER_MAX) this.display.scalePower = SCALE_POWER_MAX; if (this.display.scalePower > SCALE_POWER_MAX) this.display.scalePower = SCALE_POWER_MAX;
if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN; if (this.display.scalePower < SCALE_POWER_MIN) this.display.scalePower = SCALE_POWER_MIN;
// compute coordinates of new view frame // compute coordinates of new view frame

View File

@ -10,19 +10,18 @@ export class Zoom extends Tool {
const zoomOut = document.createElement('button'); const zoomOut = document.createElement('button');
const zoomIn = document.createElement('button'); const zoomIn = document.createElement('button');
const zoomAll = document.createElement('button');
for (let b of [zoomIn, zoomOut]) {
// b.style.width = '100px';
// b.style.height = '50px';
// b.style['padding-left'] = '25px';
// b.style['padding-right'] = '25px';
}
this.div.appendChild(zoomOut); this.div.appendChild(zoomOut);
this.div.appendChild(zoomIn); this.div.appendChild(zoomIn);
this.div.appendChild(document.createElement('br'));
this.div.appendChild(zoomAll);
zoomOut.innerHTML = '<h2>Zoom<br>-<br>Out</h2>'; zoomAll.classList.add('wide');
zoomIn.innerHTML = '<h2>Zoom<br>+<br>In</h2>';
zoomOut.innerHTML = '<h2>Zoom<br>Out</h2>';
zoomIn.innerHTML = '<h2>Zoom<br>In</h2>';
zoomAll.innerHTML = '<h2>Zoom to Fit</h2>';
zoomOut.addEventListener('click', (e) => { zoomOut.addEventListener('click', (e) => {
// Aim at center of view // Aim at center of view
@ -37,5 +36,24 @@ export class Zoom extends Tool {
const y = this.sim.display.height * this.sim.display.scale / 2; const y = this.sim.display.height * this.sim.display.scale / 2;
this.sim.scheduleZoom({x, y}, ZOOM_IN_FACTOR); this.sim.scheduleZoom({x, y}, ZOOM_IN_FACTOR);
}); });
zoomAll.addEventListener('click', (e) => {
// Determine bounding box
const box = this.sim.objects.boundingBox;
const x = (box.start.x + box.end.x) / 2;
const y = (box.start.y + box.end.y) / 2;
if (box.start.x !== box.end.x && box.start.y !== box.end.y) {
const widthRatio = Math.abs(box.start.x - box.end.x) / this.sim.display.width;
const heightRatio = Math.abs(box.start.y - box.end.y) / this.sim.display.height;
const biggerRatio = Math.max(widthRatio, heightRatio);
if (biggerRatio <= 1) {
const base2Ratio = Math.log(1/biggerRatio) / Math.log(2);
this.sim.scheduleZoom({x, y}, Math.floor(base2Ratio));
} else {
const base2Ratio = Math.log(1/biggerRatio) / Math.log(2);
this.sim.scheduleZoom({x, y}, Math.ceil(base2Ratio));
}
}
});
} }
} }