From 3233052f63ff4a571cd3e4543a249881dfd38218 Mon Sep 17 00:00:00 2001 From: Ladd Date: Sat, 27 Dec 2025 02:14:57 -0600 Subject: [PATCH] zero total momentum; options tool --- config.js | 19 ++++++++---- index.html | 2 +- object.js | 1 - objects.js | 49 +++++++++++++++++++++++------- simulator.js | 3 ++ tool/options.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++ tool/play-pause.js | 2 +- tool/zoom.js | 32 ++++++++++++++++++++ 8 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 tool/options.js diff --git a/config.js b/config.js index 78e39d0..9de7133 100644 --- a/config.js +++ b/config.js @@ -1,3 +1,4 @@ +// DISPLAY export const DISPLAY_OBJECTS_INFO = false; export const DISPLAY_CURSOR_INFO = false; export const DISPLAY_CANVAS_SIZE = false; @@ -6,23 +7,23 @@ export const DISPLAY_CURRENT_MODE = false; export const DISPLAY_VELOCITY_VECTORS = true; export const DISPLAY_ACCELERATION_VECTORS = true; -export const MASS_CREATION_RATE = 1E1; -export const POINTER_HISTORY_SIZE = 15; - +// VELOCITY export const VELOCITY_VECTOR_SCALE = 5E0; 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_ARROWHEAD = true; +// ACCELERATION export const ACCELERATION_VECTOR_SCALE = 5E0; -export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 255, 0)'; // optionally set to 'object color' -// export const ACCELERATION_VECTOR_COLOR = 'object color'; +export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 128, 0)'; // optionally set to 'object color' export const ACCELERATION_VECTOR_WIDTH = 1.5; export const ACCELERATION_VECTOR_ARROWHEAD = true; +// SCALING FACTORS +export const MASS_CREATION_RATE = 1E1; +export const POINTER_HISTORY_SIZE = 15; export const MOTION_TIME_SCALE = 1E-4; export const PAN_VELOCITY_SCALE_FACTOR = 1E-3; - export const ARROWHEAD_LENGTH = 7; export const ARROWHEAD_WIDTH = 5; export const OFFSCREEN_OBJECT_LINE_SCALE = 7; @@ -34,7 +35,13 @@ export const SCALE_POWER_MAX = 8; export const SCALE_POWER_MIN = -8; export const GRAVITATIONAL_CONSTANT = 1E5; +// CSS CLASS NAMES export const DRAGGABLE_ELEMENT_CLASSNAME = 'lhg-draggable-element'; +// MODES export const MODE_MASS_GENERATION = 'mass-gen'; export const MODE_PAN_VIEW = 'pan-view'; + +// OPTIONS +export const PAUSE_DURING_CREATION = true; +export const PAUSE_DURING_SELECTION = true; diff --git a/index.html b/index.html index a5c355a..7a84a0e 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ body { background-color: #000; color: #8f8; font-family: monospace; - font-size: 16pt; + font-size: 14pt; overflow: hidden; user-select: none; -webkit-user-select: none; diff --git a/object.js b/object.js index 3b4f22b..9bdfd0b 100644 --- a/object.js +++ b/object.js @@ -8,7 +8,6 @@ export class MassObject { color = {r: undefined, g: undefined, b: undefined}; created = undefined; forces = []; // [{x, y}] - active = false; constructor(x, y, index) { this.index = index; diff --git a/objects.js b/objects.js index f2efac5..112e975 100644 --- a/objects.js +++ b/objects.js @@ -10,24 +10,44 @@ export class Objects { objects = []; creatingObject = undefined; selectedObject = undefined; + paused = false; constructor(sim) { this.sim = sim; } + pause() { + if (this.sim.playing) { + this.sim.playing = false; + this.paused = true; + } + } + + resume() { + if (this.paused) { + this.sim.playing = true; + this.paused = false; + } + } + // Create an object with mass that grows as pointer is held down createObject(x, y) { const idx = this.objects.length; const obj = new MassObject(x, y, idx); this.creatingObject = idx; this.objects.push(obj); + // Pause the simulation during mass creation; this avoids some complex local dynamics + // TODO: Make this optional (toolbar item to enable) + if (this.sim.getOption('pauseDuring.creation')) { + this.pause(); + } } doneCreatingObject() { if (this.creatingObject !== undefined) { const obj = this.objects[this.creatingObject]; - obj.active = true; this.creatingObject = undefined; + this.resume(); } } @@ -37,10 +57,14 @@ export class Objects { selectObject(i) { this.selectedObject = i; + if (this.sim.getOption('pauseDuring.selection')) { + this.pause(); + } } deselect() { this.selectedObject = undefined; + this.resume(); } getSelectedOrCreating() { @@ -123,7 +147,6 @@ export class Objects { 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; @@ -135,6 +158,12 @@ export class Objects { B.forces.push({ x: -Fx, y: -Fy }); } } + // Also compute acceleration + for (let i = 0; i < this.objects.length; i++) { + const obj = this.objects[i]; + obj.acceleration = obj.getAcceleration(); + } + } computeFrame(elapsedTime) { @@ -151,10 +180,11 @@ export class Objects { this.computeForces(); if (this.sim.playing) { - // Generate predicted positions (Velocity verlet method) + // Predict positions (Velocity verlet method) for (let i = 0; i < this.objects.length; i++) { const obj = this.objects[i]; - obj.currentAcceleration = obj.getAcceleration(); + obj.currentAcceleration = {...obj.acceleration}; + obj.position.x += elapsedTime * (obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime); obj.position.y += elapsedTime * @@ -164,10 +194,10 @@ export class Objects { // Recompute forces this.computeForces(); - // Generated predicted velocities + // Predict velocities for (let i = 0; i < this.objects.length; i++) { const obj = this.objects[i]; - const acceleration = obj.getAcceleration(); + const acceleration = {...obj.acceleration}; obj.acceleration = { x: (obj.currentAcceleration.x + acceleration.x) / 2, y: (obj.currentAcceleration.y + acceleration.y) / 2, @@ -175,11 +205,8 @@ export class Objects { obj.velocity.x += obj.acceleration.x * elapsedTime; obj.velocity.y += obj.acceleration.y * elapsedTime; } - } else { - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; - obj.acceleration = obj.getAcceleration(); - } + // TODO: Collisions + } // Display objects info diff --git a/simulator.js b/simulator.js index c500e81..a6b6c5a 100644 --- a/simulator.js +++ b/simulator.js @@ -6,6 +6,7 @@ import { Toolbar } from './toolbar.js'; import { PlayPause } from './tool/play-pause.js'; import { Zoom } from './tool/zoom.js'; import { ModeSwitch } from './tool/modes.js'; +import { Options } from './tool/options.js'; import { SCALE_POWER_MAX, SCALE_POWER_MIN, @@ -25,6 +26,7 @@ export class Sim { objects = undefined; isCurrentMode = () => false; + getOption = () => undefined; init(divId) { this.divId = divId; @@ -41,6 +43,7 @@ export class Sim { this.toolbar.addTool(new Zoom(this.toolbar)); this.toolbar.addTool(new PlayPause(this.toolbar)); this.toolbar.addTool(new ModeSwitch(this.toolbar)); + this.toolbar.addTool(new Options(this.toolbar)); // Initiate main loop this.time = document.timeline.currentTime; diff --git a/tool/options.js b/tool/options.js new file mode 100644 index 0000000..b601891 --- /dev/null +++ b/tool/options.js @@ -0,0 +1,75 @@ +// Options picker +import { Tool } from '../tool.js'; +import { + MODE_PAN_VIEW, + PAUSE_DURING_CREATION, + PAUSE_DURING_SELECTION, +} from '../config.js'; + +export class Options extends Tool { + options = [{ + type: 'group', name: 'pauseDuring', title: 'Pause During', + items: [ + { type: 'boolean', name: 'creation', title: 'Creation', default: PAUSE_DURING_CREATION }, + { type: 'boolean', name: 'selection', title: 'Selection', default: PAUSE_DURING_SELECTION }, + ] + }]; + + values = {}; + + visitItem(item, path) { + path = [path, item.name].filter(x => !!x).join('.'); + switch (item.type) { + case 'group': { + const group = document.createElement('div'); + const heading = document.createElement('h3'); + heading.innerHTML = item.title; + group.appendChild(heading); + for (const next of item.items) { + const child = this.visitItem(next, path); + group.appendChild(child); + } + return group; + } + case 'boolean': { + const button = document.createElement('button'); + button.innerHTML = `

${item.title}

`; + this.setOption(path, item.default); + button.style.opacity = this.values[path] ? '100%' : '50%'; + button.addEventListener('click', (e) => { + this.setOption(path, !this.getOption(path)); + button.style.opacity = this.values[path] ? '100%' : '50%'; + }); + return button; + break; + } + default: + throw new Error('unknown option type'); + } + } + + constructor(toolbar) { + super(toolbar); + const heading = document.createElement('h2'); + heading.innerHTML = 'Options'; + this.div.appendChild(heading); + for (const item of this.options) { + const child = this.visitItem(item); + this.div.appendChild(child); + } + + // Global methods to get/set current option values + this.sim.getOption = (path) => this.getOption(path); + this.sim.setOption = (path, value) => this.setOption(path, value); + } + + getOption(path) { + const val = this.values[path]; + return val; + } + + setOption(path, value) { + this.values[path] = value; + // TODO Update button opacity in case this is called from elsewhere + } +} diff --git a/tool/play-pause.js b/tool/play-pause.js index 6acc896..ffbab92 100644 --- a/tool/play-pause.js +++ b/tool/play-pause.js @@ -10,8 +10,8 @@ export class PlayPause extends Tool { const pauseButton = document.createElement('button'); const playButton = document.createElement('button'); - this.div.appendChild(pauseButton); this.div.appendChild(playButton); + this.div.appendChild(pauseButton); pauseButton.innerHTML = this.pauseHTML; playButton.innerHTML = this.playHTML; diff --git a/tool/zoom.js b/tool/zoom.js index cb671ea..38307ba 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -11,17 +11,22 @@ 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 = '

Zoom
Out

'; zoomIn.innerHTML = '

Zoom
In

'; zoomAll.innerHTML = '

Zoom to Fit

'; + zeroNetMomentum.innerHTML = '

Zero Net Momentum

'; zoomOut.addEventListener('click', (e) => { // Aim at center of view @@ -55,5 +60,32 @@ 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; + } + }); } }