path traces; collisions

This commit is contained in:
Ladd 2025-12-27 11:29:13 -06:00
parent 952ea553c7
commit 103fd7f694
8 changed files with 145 additions and 78 deletions

View File

@ -6,6 +6,7 @@ export const DISPLAY_CURRENT_SCALE = true;
export const DISPLAY_CURRENT_MODE = false;
export const DISPLAY_VELOCITY_VECTORS = true;
export const DISPLAY_ACCELERATION_VECTORS = true;
export const DISPLAY_PATH_TRACES = false;
// VELOCITY
export const VELOCITY_VECTOR_SCALE = 5E0;
@ -19,6 +20,11 @@ export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 128, 0)'; // optionally set to
export const ACCELERATION_VECTOR_WIDTH = 1.5;
export const ACCELERATION_VECTOR_ARROWHEAD = true;
// PATH TRACES
// export const PATH_TRACES_COLOR = 'rgb(128, 128, 0)'; // optionally set to 'object color'
export const PATH_TRACES_COLOR = 'object color';
export const PATH_TRACES_WIDTH = 1.5;
// SCALING FACTORS
export const MASS_CREATION_RATE = 1E1;
export const POINTER_HISTORY_SIZE = 15;
@ -45,3 +51,4 @@ export const MODE_PAN_VIEW = 'pan-view';
// OPTIONS
export const PAUSE_DURING_CREATION = true;
export const PAUSE_DURING_SELECTION = true;
export const MERGE_ON_COLLIDE = true;

View File

@ -13,8 +13,8 @@ import {
OFFSCREEN_OBJECT_LINE_WIDTH,
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
DISPLAY_CANVAS_SIZE,
DISPLAY_VELOCITY_VECTORS,
DISPLAY_ACCELERATION_VECTORS,
PATH_TRACES_COLOR,
PATH_TRACES_WIDTH,
} from './config.js';
export class Display {
@ -63,6 +63,13 @@ export class Display {
};
}
simToScreen(x, y) {
return {
x: (x - this.viewOrigin.x) * this.scale,
y: (y - this.viewOrigin.y) * this.scale,
};
}
fullscreen() {
this.canvas.width = document.documentElement.clientWidth;
this.canvas.height = document.documentElement.clientHeight;
@ -77,8 +84,7 @@ export class Display {
ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height);
}
drawObject(idx) {
const obj = this.sim.objects.object(idx);
drawObject(obj) {
const ctx = this.ctx;
const {r, g, b} = obj.color;
const {x, y} = obj.position;
@ -90,6 +96,47 @@ export class Display {
const oy = this.viewOrigin.y;
const cx = ox + W / 2;
const cy = oy + H / 2;
// Draw path traces
if (this.sim.getOption('display.traces') && obj.history?.length) {
ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ?
`rgb(${r}, ${g}, ${b})` : PATH_TRACES_COLOR;
ctx.lineWidth = PATH_TRACES_WIDTH / this.scale;
ctx.beginPath();
let dash = false;
const skip = 1;
let skipped = 0;
{
const [{position}] = obj.history;
const x = position.x;
const y = position.y;
if (Math.abs(x - cx) <= W / 2 &&
Math.abs(y - cy) <= H / 2) {
ctx.moveTo(x, y);
dash = true;
}
}
for (let i = 1; i < obj.history.length; i++) {
if (++skipped < skip) continue;
skipped = 0;
const {position} = obj.history[i];
const x = position.x;
const y = position.y;
if (dash) {
ctx.lineTo(x, y);
dash = false;
} else if (Math.abs(x - cx) <= W / 2 &&
Math.abs(y - cy) <= H / 2) {
ctx.moveTo(x, y);
dash = true;
}
}
ctx.stroke();
}
if (!obj.alive) return;
// If the object is outside the display area, draw an arrow at the edge of the display
if (Math.abs(x - cx) - radius >= W / 2 ||
Math.abs(y - cy) - radius >= H / 2) {
@ -146,7 +193,7 @@ export class Display {
ctx.fill();
// Draw arrow for the velocity
if (DISPLAY_VELOCITY_VECTORS) {
if (this.sim.getOption('show.velocity')) {
const speed = Math.sqrt(vx ** 2 + vy ** 2);
const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed);
const endVy = y + VELOCITY_VECTOR_SCALE * vy / speed * Math.log(speed);
@ -162,7 +209,7 @@ export class Display {
}
// Draw arrow for acceleration
if (DISPLAY_ACCELERATION_VECTORS) {
if (this.sim.getOption('show.acceleration')) {
const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2);
const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x /
accelerationMagnitude * Math.log(accelerationMagnitude);
@ -181,9 +228,7 @@ export class Display {
}
drawObjects() {
for (let i = 0; i < this.sim.objects.length; i++) {
this.drawObject(i);
}
this.sim.objects.forEachObject(obj => this.drawObject(obj), null);
}
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) {

View File

@ -8,7 +8,7 @@ body {
background-color: #000;
color: #8f8;
font-family: monospace;
font-size: 14pt;
font-size: 12pt;
overflow: hidden;
user-select: none;
-webkit-user-select: none;

View File

@ -1,5 +1,4 @@
export class MassObject {
index = undefined;
mass = 0;
density = 1;
position = {x: undefined, y: undefined};
@ -8,9 +7,10 @@ export class MassObject {
color = {r: undefined, g: undefined, b: undefined};
created = undefined;
forces = []; // [{x, y}]
history = [];
alive = true;
constructor(x, y, index) {
this.index = index;
constructor(x, y) {
this.position.x = x;
this.position.y = y;
this.color.r = Math.random() * 256;

View File

@ -83,8 +83,7 @@ export class Objects {
start: {x: undefined, y: undefined},
end: {x: undefined, y: undefined},
};
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
this.forEachObject(obj => {
if (box.start.x === undefined) {
box.start = {...obj.position};
box.end = {...obj.position};
@ -94,19 +93,18 @@ export class Objects {
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) {
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
this.forEachObject(obj => {
// If distance to object is less than object's radius, we are touching the object
const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2);
if (dist <= obj.radius) {
return i;
}
}
});
}
handlePointerDown({x, y}) {
@ -138,15 +136,23 @@ export class Objects {
}
}
// cb: (obj, idx) => {}
forEachObject(cb, alive = true, startWith = 0) {
for (let i = startWith; i < this.objects.length; i++) {
const obj = this.objects[i];
if (alive === null || alive == obj.alive) {
cb(obj, i);
}
}
}
computeForces() {
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];
this.forEachObject(obj => {
obj.forces = [];
});
this.forEachObject((A, i) => {
this.forEachObject(B => {
const dx = (B.position.x - A.position.x);
const dy = (B.position.y - A.position.y);
const dSquared = dx ** 2 + dy ** 2;
@ -156,14 +162,12 @@ export class Objects {
const Fy = F * dy / d;
A.forces.push({ x: Fx, y: Fy });
B.forces.push({ x: -Fx, y: -Fy });
}
}
}, true, i + 1);
});
// Also compute acceleration
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
this.forEachObject(obj => {
obj.acceleration = obj.getAcceleration();
}
});
}
computeFrame(elapsedTime) {
@ -181,22 +185,55 @@ export class Objects {
if (this.sim.playing) {
// Predict positions (Velocity verlet method)
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
this.forEachObject(obj => {
obj.currentAcceleration = {...obj.acceleration};
obj.position.x += elapsedTime *
(obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime);
obj.position.y += elapsedTime *
(obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime);
});
// 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) {
// Merge B into A:
// Set position = center of mass
// Set velocity = total momentum / total mass
// Combine forces
// Add masses
// Average color?
// If B was selected or being created, select A instead
// Remove B using splice
A.position.x = (A.position.x * A.mass + B.position.x * B.mass) / (A.mass + B.mass);
A.position.y = (A.position.y * A.mass + B.position.y * B.mass) / (A.mass + B.mass);
A.velocity.x = (A.velocity.x * A.mass + B.velocity.x * B.mass) / (A.mass + B.mass);
A.velocity.y = (A.velocity.y * A.mass + B.velocity.y * B.mass) / (A.mass + B.mass);
A.forces.push(...B.forces);
A.mass += B.mass;
A.color = {
r: (A.color.r + B.color.r) / 2,
g: (A.color.g + B.color.g) / 2,
b: (A.color.b + B.color.b) / 2,
};
if (this.creatingObject === j) this.creatingObject = i;
if (this.selectedObject === j) this.selectedObject = i;
B.alive = false;
B.forces = [];
}
}, true, i + 1);
});
// Recompute forces
this.computeForces();
// Predict velocities
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
this.forEachObject(obj => {
const acceleration = {...obj.acceleration};
obj.acceleration = {
x: (obj.currentAcceleration.x + acceleration.x) / 2,
@ -204,15 +241,17 @@ export class Objects {
};
obj.velocity.x += obj.acceleration.x * elapsedTime;
obj.velocity.y += obj.acceleration.y * elapsedTime;
}
// TODO: Collisions
// Append to object history
// TODO: enforce object history length
// TODO: store object color changes in history
obj.history.push({position: {...obj.position}});
});
}
// Display objects info
if (DISPLAY_OBJECTS_INFO) {
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
this.forEachObject(obj => {
const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2);
// Invert y so that the angle is counterclockwise from x-axis
const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI;
@ -222,7 +261,7 @@ export class Objects {
`${obj.mass.toPrecision(6)} kg, `,
`${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`,
];
}
});
}
}
}

View File

@ -5,6 +5,7 @@ import {
DISPLAY_VELOCITY_VECTORS,
PAUSE_DURING_CREATION,
PAUSE_DURING_SELECTION,
MERGE_ON_COLLIDE,
} from '../config.js';
export class Options extends Tool {
@ -14,12 +15,17 @@ export class Options extends Tool {
{ type: 'boolean', name: 'creation', title: 'Creation', default: PAUSE_DURING_CREATION },
{ type: 'boolean', name: 'selection', title: 'Selection', default: PAUSE_DURING_SELECTION },
]}, {
type: 'group', name: 'show', title: 'Show Vectors',
type: 'group', name: 'display', title: 'Display',
items: [
{ type: 'boolean', name: 'velocity', title: 'Velocity', default: DISPLAY_VELOCITY_VECTORS },
{ type: 'boolean', name: 'acceleration', title: 'Acceleration', default: DISPLAY_ACCELERATION_VECTORS },
]
}];
{ type: 'boolean', name: 'traces', title: 'Trace Path', default: DISPLAY_ACCELERATION_VECTORS },
]}, {
type: 'group', name: 'collision', title: 'Collisions',
items: [
{ type: 'boolean', name: 'merge', title: 'Merge', default: MERGE_ON_COLLIDE },
]},
];
values = {};
@ -58,6 +64,7 @@ export class Options extends Tool {
const heading = document.createElement('h2');
heading.innerHTML = 'Options';
this.div.appendChild(heading);
this.div.style.paddingTop = '0px';
for (const item of this.options) {
const child = this.visitItem(item);
this.div.appendChild(child);

View File

@ -11,22 +11,18 @@ export class Zoom extends Tool {
const zoomOut = document.createElement('button');
const zoomIn = document.createElement('button');
const zoomAll = document.createElement('button');
const zeroNetMomentum = document.createElement('button');
this.div.appendChild(zoomOut);
this.div.appendChild(zoomIn);
this.div.appendChild(document.createElement('br'));
this.div.appendChild(zoomAll);
this.div.appendChild(document.createElement('br'));
this.div.appendChild(zeroNetMomentum);
zoomAll.classList.add('wide');
zeroNetMomentum.classList.add('wide');
zoomOut.innerHTML = '<h2>Zoom<br>Out</h2>';
zoomIn.innerHTML = '<h2>Zoom<br>In</h2>';
zoomAll.innerHTML = '<h2>Zoom to Fit</h2>';
zeroNetMomentum.innerHTML = '<h2>Zero Net Momentum</h2>';
zoomOut.addEventListener('click', (e) => {
// Aim at center of view
@ -60,32 +56,5 @@ export class Zoom extends Tool {
}
}
});
zeroNetMomentum.addEventListener('click', (e) => {
const { objects } = this.sim.objects;
// Find total momentum
let totalMomentum = objects.reduce((total, obj) => {
const px = obj.mass * obj.velocity.x;
const py = obj.mass * obj.velocity.y;
return {
x: total.x + px,
y: total.y + py,
};
}, {x: 0, y: 0});
// Find average momentum
let averageMomentum = {
x: totalMomentum.x / objects.length,
y: totalMomentum.y / objects.length,
};
// Subtract average from each
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
obj.velocity.x -= averageMomentum.x / obj.mass;
obj.velocity.y -= averageMomentum.y / obj.mass;
}
});
}
}

View File

@ -10,7 +10,7 @@ export class Toolbar {
this.div = div;
this.sim.div.appendChild(div);
div.style.position = 'relative';
div.style.width = 'fit-content';
div.style.width = '20EM';
div.style.top = 0;
div.style.left = 0;
div.style.zIndex = 2;