From 6922011ca7b863c3d9ee7167d792a374feaa65a9 Mon Sep 17 00:00:00 2001 From: Ladd Date: Fri, 2 Jan 2026 11:51:52 -0600 Subject: [PATCH] Adjustments to panning; Added Selection Box --- Layout.md | 23 --- Readme.md | 81 +++++++--- display.js | 91 ++++++------ object.js | 27 +++- options-1.js | 129 ++++++++++++++++ options.js | 25 +++- pointer.js | 262 +++++++++++++++++--------------- select.js | 33 +++++ sim-options.js | 4 +- sim-tools.js | 5 - simulator.js | 34 +++-- system.js | 386 ++++++++++++++++++++++++------------------------ tool/options.js | 4 +- zoom.js | 3 +- 14 files changed, 672 insertions(+), 435 deletions(-) delete mode 100644 Layout.md create mode 100644 options-1.js create mode 100644 select.js diff --git a/Layout.md b/Layout.md deleted file mode 100644 index aecf8b1..0000000 --- a/Layout.md +++ /dev/null @@ -1,23 +0,0 @@ -Overlay > Info Box - -Each toolbar gives itself a header tool which expands/collapses the toolbar. - -- Toolbar Group: Left Side - - Toolbar: Tools - - Tool: Zoom - - Toolbar: Modes - - Tool: Mode Switch - - Toolbar: Utils - - Tool: Utility Tool -- Toolbar Group: Right Side - - Toolbar: Options - - Tool: Options Tool - - Section: Pause During - - Section: Display - - Section: Collision - - Toolbar: Params - - Tool: Options Tool - - Section: Param - - Toolbar: Debug - - Tool: Options Tool - - Section: Debug diff --git a/Readme.md b/Readme.md index 63c94a2..2330e05 100644 --- a/Readme.md +++ b/Readme.md @@ -15,27 +15,66 @@ Screenshots TODO ---- -- [ ] Selection Box -- [ ] Object List -- [ ] Object Detail -- [ ] Left Button Panning -- [ ] Middle Button Pause -- [ ] Parameter Slider (Invisible, mouse/touch drag) -- [ ] Grid -- [ ] Zoom Easing -- [ ] 2-touch Pan & Zoom - - Either continuous zoom, or discrete with animation of bounding box proposed changes -- [ ] Multi-touch Mass Create -- [ ] Camera Rotation +- [x] Selection Box + +- [ ] Enhancement: Create Vector class and refactor to use + +- [ ] Feature: Object List + +- [ ] Feature: Object Detail + +- [ ] Enhancement: Handle pointerleave or other mechanism when window loses focus + +- [ ] Enhancement: Calculate Work as FxD as measure of energy flux + +- [ ] Feature: Automatically slow time when energy flux is greater + +- [ ] Enhancement: Add z-axis (Initially nothing changes, z = 0) + +- [ ] Feature: Isometric 3d View + +- [ ] Feature: Grid + +- [ ] Feature: Polar Coordinates + +- [ ] Feature: Cylindrical Coordinates + +- [ ] Feature: Spherical Coordinates + +- [ ] Feature: Camera Velocity Display + +- [ ] Enhancement: World State Snapshots + +- [ ] Feature: List / Save / Load World States + +- [ ] Feature: Left Button Panning + +- [ ] Feature: Middle Button Pause + +- [ ] Feature: Parameter Slider (Invisible, mouse/touch drag) + +- [ ] Enhancement: Zoom Easing + +- [ ] Feature: 2-touch Pan & Zoom + Either continuous zoom, or discrete with animation of bounding box proposed changes + +- [ ] Feature: Multi-touch Mass Create + +- [ ] Feature: Camera Rotation + - [ ] 2-touch Rotation - [ ] Spinning Frame -- [ ] Undo feature: + +- [ ] Feature: Undo + - [ ] Undo "Clear Traces" Action - - [ ] Undo "Reset -- [ ] Time Control: Reverse Time -- [ ] Lossy Rescaling To Widen Zoom (Handling overflow/underflow) -- [ ] Track farthest reaches, min/max in each dimension (x, y) -- [ ] Calculate Work as FxD as measure of energy flux - - [ ] Option to automatically slow time when energy flux is greater -- [ ] Handle pointerleave or other mechanism when window loses focus -- [ ] Verify stationary pointer leads to zero pointer velocity + - [ ] In general have a user action history + - [ ] Point-in-time restoral of world state (Marked points, i.e. Snapshots) + +- [ ] Feature: Time Control: Reverse Time + +- [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow) + +- [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y) + +- [x] Task: Verify stationary pointer leads to zero pointer velocity diff --git a/display.js b/display.js index df9bab2..9fdbd22 100644 --- a/display.js +++ b/display.js @@ -22,6 +22,45 @@ export class Display { window.addEventListener('resize', () => this.fullscreen()); } + frame(elapsedTime) { + const { + touchStart: start, + touchLatest: latest, + } = this.sim.pointer ?? {}; + + // Direct translate, unless using fast panning + if (start && latest && !this.sim.getOption('compensate.fastPanning')) { + this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale; + this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale; + } + + // Apply update to viewOrigin based on panning + if (this.sim.panning && !this.sim.panning.paused) { + // elapsedTime is scaled by time scale, is that what we want? + // Yes because if panning.velocity == obj.velocity, object should stay in view + this.viewOrigin.x += this.sim.panning.velocity.x * elapsedTime; + this.viewOrigin.y += this.sim.panning.velocity.y * elapsedTime; + } + + // Update what's considered start + if (start && latest) { + start.viewOrigin = {...this.viewOrigin}; + start.x = latest.x; + start.y = latest.y; + } + + if (this.sim.getOption('debug.panningInfo')) { + const {x, y} = this.sim.panning?.velocity ?? {}; + this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)]; + const {centerOfMass} = this.sim.system.computeSystemCenter(); + this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)]; + this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6); + } + + // Clear canvas in preparation for other modules to render this frame + this.fillCanvas(); + } + get scale() { return 2 ** this.scalePower; } @@ -61,13 +100,15 @@ export class Display { this.canvas.height = document.documentElement.clientHeight; } - fillCanvas() { + fillCanvas() { const ctx = this.ctx; ctx.fillStyle = '#000'; ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height); } - drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) { + drawArrow(startX, startY, endX, endY, { + style, width, arrowhead, arrowheadLength, fill, ifShort + }) { const ctx = this.ctx; ctx.strokeStyle = style; // Keep arrows at normal scale @@ -135,47 +176,9 @@ export class Display { ctx.resetTransform(); } - frame(elapsedTime) { - const { - touchStart: start, - touchLatest: latest, - } = this.sim.pointer ?? {}; - - if (start && latest) { - // Direct translate - this.viewOrigin.x = start.viewOrigin.x - (latest.x - start.x) / this.scale; - this.viewOrigin.y = start.viewOrigin.y - (latest.y - start.y) / this.scale; - } - - let pdx = 0; - let pdy = 0; - - if (this.sim.panning && !this.sim.panning.paused) { - // Apply update to viewOrigin based on panning - // TODO: elapsedTime is scaled by time scale, is that what we want? - pdx = this.sim.panning.velocity.x * elapsedTime; - pdy = this.sim.panning.velocity.y * elapsedTime; - } - - this.viewOrigin.x += pdx; - this.viewOrigin.y += pdy; - - if (start && latest) { - // Update what's considered start - start.viewOrigin = {...this.viewOrigin}; - start.x = latest.x; - start.y = latest.y; - } - - if (this.sim.getOption('debug.panningInfo')) { - const {x, y} = this.sim.panning?.velocity ?? {}; - this.sim.info['Panning Velocity'] = [`${x?.toPrecision(6)}, `, y?.toPrecision(6)]; - const { centerOfMass } = this.sim.system.computeSystemCenter(); - this.sim.info['Center of Mass'] = [`${centerOfMass.x.toPrecision(6)}, `, centerOfMass.y.toPrecision(6)]; - this.sim.info['Net Angular Momentum'] = this.sim.system.computeSystemAngularMomentum().toPrecision(6); - } - - // Clear canvas in preparation for other modules to render this frame - this.fillCanvas(); + drawBox({start, end}) { + const ctx = this.ctx; + ctx.strokeStyle = 'rgb(0, 255, 0)'; + ctx.strokeRect(start.x, start.y, end.x - start.x, end.y - start.y); } } diff --git a/object.js b/object.js index fd946dd..7a982e0 100644 --- a/object.js +++ b/object.js @@ -28,6 +28,9 @@ export class MassObject { history = []; alive = true; + currentPosition = undefined; + currentAcceleration = undefined; + constructor(sim, x, y) { this.sim = sim; this.id = crypto.randomUUID(); @@ -192,12 +195,16 @@ export class MassObject { const arrowDirection = Math.atan2(velocity.y, velocity.x); // Prevent negative numbers by adding 1 // TODO: Make logarithmic vector length scale optional - const arrowLength = Math.log10(speed + 1) * vecScale + radius; - const endVx = x + arrowLength * Math.cos(arrowDirection); - const endVy = y + arrowLength * Math.sin(arrowDirection); + const arrowLength = Math.log10(speed + 1) * vecScale; + const offsetX = radius * Math.cos(arrowDirection); + const offsetY = radius * Math.sin(arrowDirection); + const startVx = x + offsetX; + const startVy = y + offsetY; + const endVx = startVx + arrowLength * Math.cos(arrowDirection); + const endVy = startVy + arrowLength * Math.sin(arrowDirection); const style = VELOCITY_VECTOR_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR; - sim.display.drawArrow(x, y, endVx, endVy, { + sim.display.drawArrow(startVx, startVy, endVx, endVy, { style, width: VELOCITY_VECTOR_WIDTH, arrowhead: VELOCITY_VECTOR_ARROWHEAD, @@ -213,13 +220,17 @@ export class MassObject { this.sim.display.scale; const arrowDirection = Math.atan2(acceleration.y, acceleration.x); // Prevent negative numbers by adding 1 - const arrowLength = Math.log10(accelerationMagnitude + 1) * vecScale + radius; + const arrowLength = Math.log10(accelerationMagnitude + 1) * vecScale; //const arrowLength = accelerationMagnitude * vecScale; - const endAx = x + arrowLength * Math.cos(arrowDirection); - const endAy = y + arrowLength * Math.sin(arrowDirection); + const offsetX = radius * Math.cos(arrowDirection); + const offsetY = radius * Math.sin(arrowDirection); + const startAx = x + offsetX; + const startAy = y + offsetY; + const endAx = startAx + arrowLength * Math.cos(arrowDirection); + const endAy = startAy + arrowLength * Math.sin(arrowDirection); const style = ACCELERATION_VECTOR_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR; - sim.display.drawArrow(x, y, endAx, endAy, { + sim.display.drawArrow(startAx, startAy, endAx, endAy, { style, width: ACCELERATION_VECTOR_WIDTH, arrowhead: ACCELERATION_VECTOR_ARROWHEAD, diff --git a/options-1.js b/options-1.js new file mode 100644 index 0000000..dec1961 --- /dev/null +++ b/options-1.js @@ -0,0 +1,129 @@ +import { + EVENT_OPTION_SET, + OBJECT_MAGIC_PROP_PREFIX, +} from './config.js'; + +export class Options { + sim = undefined; + options = undefined; + values = {}; + undefinedObj = {[OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true}; + nullObj = {[OBJECT_MAGIC_PROP_PREFIX + 'null']: true}; + + getStorageKey(path) { + return `${path}:options`; + } + + constructor(sim, options) { + this.sim = sim; + this.options = options; + + // Global methods to get/set current option values + this.sim.getOption = (path) => this.getOption(path); + this.sim.setOption = (path, value) => this.setOption(path, value); + this.sim.onOptionSet = (path, cb) => this.onOptionSet(path, cb); + + // Initialize values from localStorage + for (const groupName of Object.keys(options)) { + for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) { + const path = [groupName, name].join('.'); + let value = this.getFromLocalStorage(path); + if (value === undefined) { + value = defaultValue; + } + this.values[path] = value; + } + } + } + + toStored(value) { + if (value === undefined) { + // Do we want to interpret this as removing from storage? + // Let's just treat it as a value for now; + // Semantically it works because when retrieved, it will return undefined, + // which is the same result you get if the key is not set + return JSON.stringify(this.undefinedObj); + } else if (value === null) { + return JSON.stringify(this.nullObj); + } + return JSON.stringify(value); + } + + // value: string + fromStored(value) { + if (value === null) { + return undefined; + } else if (value === JSON.stringify(this.undefinedObj)) { + return undefined; + } else if (value === JSON.stringify(this.nullObj)) { + return null; + } + return JSON.parse(value); + } + + getFromLocalStorage(path) { + const storageKey = this.getStorageKey(path); + const value = this.fromStored(window.localStorage.getItem(storageKey)); + this.values[path] = value; + return value; + } + + getOption(path) { + const [groupName, name] = path.split('.'); + const group = this.options[groupName]; + const item = group[name]; + const {type} = item; + const value = this.values[path]; + switch (type) { + case 'number': return Number(value); + case 'boolean': return value === true || value === 'true'; + default: { + console.log({ + path, groupName, name, + group, item, + type, value + }); + throw new Error('unknown option type'); + } + } + } + + setOption(path, value) { + this.values[path] = value; + const storageKey = this.getStorageKey(path); + window.localStorage.setItem(storageKey, this.toStored(value)); + const e = new CustomEvent(EVENT_OPTION_SET, {detail: {path, value}}); + this.sim.div.dispatchEvent(e); + } + + // cb: (value) => undefined + onOptionSet(path, cb) { + this.sim.div.addEventListener(EVENT_OPTION_SET, (e) => { + if (path === e.detail.path) { + cb(e.detail.value); + } + }); + } + + getSection(sectionName) { + const section = this.options[sectionName]; + const group = { + type: 'group', + name: sectionName, + title: section._title, + items: [], + }; + for (const name in section) { + if (name.startsWith('_')) continue; + const [title, type, defaultValue, opts] = section[name]; + group.items.push({ + name, + type, + title, + default: defaultValue, + ...opts + }) + } + return group; + } +} diff --git a/options.js b/options.js index 6d5f151..eee362f 100644 --- a/options.js +++ b/options.js @@ -7,8 +7,8 @@ export class Options { sim = undefined; options = undefined; values = {}; - undefinedObj = { [OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true}; - nullObj = { [OBJECT_MAGIC_PROP_PREFIX + 'null']: true}; + undefinedObj = {[OBJECT_MAGIC_PROP_PREFIX + 'undefined']: true}; + nullObj = {[OBJECT_MAGIC_PROP_PREFIX + 'null']: true}; getStorageKey(path) { return `${path}:options`; @@ -22,7 +22,7 @@ export class Options { this.sim.getOption = (path) => this.getOption(path); this.sim.setOption = (path, value) => this.setOption(path, value); this.sim.onOptionSet = (path, cb) => this.onOptionSet(path, cb); - + // Initialize values from localStorage for (const groupName of Object.keys(options)) { for (const [name, [, , defaultValue]] of Object.entries(this.options[groupName])) { @@ -69,13 +69,22 @@ export class Options { } getOption(path) { - const [group, name] = path.split('.'); - const {type} = this.options[group][name]; + const [groupName, name] = path.split('.'); + const group = this.options[groupName]; + const item = group[name]; + const [,type] = item; const value = this.values[path]; switch (type) { case 'number': return Number(value); case 'boolean': return value === true || value === 'true'; - default: return value; + default: { + console.error({ + path, groupName, name, + group, item, + type, value + }); + throw new Error('unknown option type'); + } } } @@ -99,8 +108,8 @@ export class Options { getSection(sectionName) { const section = this.options[sectionName]; const group = { - type: 'group', - name: sectionName, + type: 'group', + name: sectionName, title: section._title, items: [], }; diff --git a/pointer.js b/pointer.js index ac26897..d1779fc 100644 --- a/pointer.js +++ b/pointer.js @@ -57,6 +57,152 @@ export class Pointer { }); } + handlePointerDown({x: clientX, y: clientY}) { + // this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE); + this.updatePointer({x: clientX, y: clientY}); + + switch (this.sim.getCurrentMode()) { + case MODE_MASS_GENERATION: { + const {x, y} = this.sim.screenToSim(clientX, clientY) + this.sim.system.handlePointerDown({x, y}); + break; + } + case MODE_PAN_VIEW: { + this.touchStart = { + x: clientX, + y: clientY, + t: this.sim.rawTime, + viewOrigin: {...this.sim.display.viewOrigin}, + }; + // Since we've processed this increment, reset + this.touchLatest = { + ...this.touchStart, + dx: 0, + dy: 0, + dt: 0, + }; + break; + } + case MODE_OBJECT_SELECT: { + this.sim.select.handlePointerDown({x: clientX, y: clientY}); + break; + } + } + } + + // Handle cursor (mouse or touch) movement + handlePointerMove({x: clientX, y: clientY}) { + // TODO: If e.touches.length > 1, user may be engaging pinch to zoom + this.updatePointer({x: clientX, y: clientY}); + + switch (this.sim.getCurrentMode()) { + case MODE_MASS_GENERATION: { + const {x, y} = this.sim.screenToSim(clientX, clientY); + this.sim.system.handlePointerMove({x, y}); + break; + } + case MODE_PAN_VIEW: { + if (this.touchStart) { + this.touchLatest = { + x: clientX, + y: clientY, + t: this.sim.rawTime, + dx: clientX - this.touchStart.x, + dy: clientY - this.touchStart.y, + dt: this.sim.rawTime - this.touchStart.t, + }; + + // With fast panning, this calculation happens every move + // With normal panning, this calculation only happens at pointer up + if (this.sim.getOption('compensate.fastPanning')) { + const panning = {...this.latestVelocity}; + // Convert pointer velocity to simulation scale. + // Also multiply by -1 because the camera is + // panning opposite to the pointer velocity. + panning.x /= -this.sim.display.scale; + panning.y /= -this.sim.display.scale; + + if (this.sim.getOption('compensate.timeScale')) { + panning.x /= this.sim.timeScale; + panning.y /= this.sim.timeScale; + } + + // Also add current panning + panning.x += this.sim.panning?.velocity.x ?? 0; + panning.y += this.sim.panning?.velocity.y ?? 0; + + this.sim.panning = { + velocity: panning + }; + } + } + + break; + } + case MODE_OBJECT_SELECT: { + this.sim.select.handlePointerMove({x: clientX, y: clientY}); + break; + } + } + } + + handlePointerUp({x: clientX, y: clientY}) { + switch (this.sim.getCurrentMode()) { + case MODE_MASS_GENERATION: { + const {x, y} = this.sim.screenToSim(clientX, clientY); + this.sim.system.handlePointerUp({x, y}); + break; + } + case MODE_PAN_VIEW: { + // Set panning velocity + if (this.touchStart && this.touchLatest) { + if (this.touchLatest.dt === 0) { + this.sim.panning = undefined; + } + this.touchStart = undefined; + + if (this.sim.getOption('compensate.fastPanning')) { + this.sim.panning = undefined; + } else { + const panning = {...this.latestVelocity}; + // Convert pointer velocity to simulation scale. + // Also multiply by -1 because the camera is + // panning opposite to the pointer velocity. + panning.x /= -this.sim.display.scale; + panning.y /= -this.sim.display.scale; + + if (this.sim.getOption('compensate.timeScale')) { + panning.x /= this.sim.timeScale; + panning.y /= this.sim.timeScale; + } + + // Also add current panning + panning.x += this.sim.panning?.velocity.x ?? 0; + panning.y += this.sim.panning?.velocity.y ?? 0; + + this.sim.panning = { + velocity: panning + }; + } + } + break; + } + case MODE_OBJECT_SELECT: { + this.sim.select.handlePointerUp({x: clientX, y: clientY}); + break; + } + } + } + + frame() { + // Add another entry for the current pointer position + const {pointerHistory} = this; + if (pointerHistory.length) { + const currentPointer = pointerHistory[pointerHistory.length - 1]; + this.updatePointer(currentPointer); + } + } + getPointerVelocity(points = POINTER_HISTORY_SIZE) { // Average over pointer history if (this.pointerHistory.length < 2) { @@ -95,120 +241,4 @@ export class Pointer { ...latestPointer?.v } } - - handlePointerDown({x: clientX, y: clientY}) { - // this.clearPointerHistory(POINTER_DOWN_HISTORY_SIZE); - this.updatePointer({x: clientX, y: clientY}); - - switch (this.sim.getCurrentMode()) { - case MODE_MASS_GENERATION: { - const {x, y} = this.sim.screenToSim(clientX, clientY) - this.sim.system.handlePointerDown({x, y}); - break; - } - case MODE_PAN_VIEW: { - this.touchStart = { - x: clientX, - y: clientY, - t: this.sim.rawTime, - viewOrigin: {...this.sim.display.viewOrigin}, - }; - // Since we've processed this increment, reset - this.touchLatest = { - ...this.touchStart, - dx: 0, - dy: 0, - dt: 0, - }; - break; - } - case MODE_OBJECT_SELECT: { - // TODO: Start a selection box - break; - } - } - } - - // Handle cursor (mouse or touch) movement - handlePointerMove({x: clientX, y: clientY}) { - // TODO: If e.touches.length > 1, user may be engaging pinch to zoom - this.updatePointer({x: clientX, y: clientY}); - - switch (this.sim.getCurrentMode()) { - case MODE_MASS_GENERATION: { - const {x, y} = this.sim.screenToSim(clientX, clientY); - this.sim.system.handlePointerMove({x, y}); - break; - } - case MODE_PAN_VIEW: { - if (this.touchStart) { - // Event loop should be able to read - this.touchLatest = { - x: clientX, - y: clientY, - t: this.sim.rawTime, - dx: clientX - this.touchStart.x, - dy: clientY - this.touchStart.y, - dt: this.sim.rawTime - this.touchStart.t, - }; - } - break; - } - case MODE_OBJECT_SELECT: { - break; - } - } - } - - handlePointerUp({x: clientX, y: clientY}) { - switch (this.sim.getCurrentMode()) { - case MODE_MASS_GENERATION: { - const {x, y} = this.sim.screenToSim(clientX, clientY); - this.sim.system.handlePointerUp({x, y}); - break; - } - case MODE_PAN_VIEW: { - // Set panning velocity - if (this.touchStart && this.touchLatest) { - if (!this.touchLatest.dt) { - this.sim.panning = undefined; - } else { - const panning = {...this.latestVelocity}; - // Convert pointer velocity to simulation scale. - // Also multiply by -1 because the camera is - // panning opposite to the pointer velocity. - panning.x /= -this.sim.display.scale; - panning.y /= -this.sim.display.scale; - - if (this.sim.getOption('compensate.timeScale')) { - panning.x /= this.sim.timeScale; - panning.y /= this.sim.timeScale; - } - - // Also add current panning - panning.x += this.sim.panning?.velocity.x ?? 0; - panning.y += this.sim.panning?.velocity.y ?? 0; - - this.sim.panning = { - velocity: panning - }; - } - this.touchStart = undefined; - } - break; - } - case MODE_OBJECT_SELECT: { - break; - } - } - } - - frame() { - // Add another entry for the current pointer position - const { pointerHistory } = this; - if (pointerHistory.length) { - const currentPointer = pointerHistory[pointerHistory.length - 1]; - this.updatePointer(currentPointer); - } - } } diff --git a/select.js b/select.js new file mode 100644 index 0000000..187ade5 --- /dev/null +++ b/select.js @@ -0,0 +1,33 @@ +export class Select { + sim = undefined; + box = { + start: undefined, + end: undefined, + }; + + constructor(sim) { + this.sim = sim; + } + + handlePointerDown({x: clientX, y: clientY}) { + this.box.start = this.sim.screenToSim(clientX, clientY); + } + + handlePointerMove({x: clientX, y: clientY}) { + this.box.end = this.sim.screenToSim(clientX, clientY); + } + + handlePointerUp() { + console.log('selection box', {...this.box}); + this.box = { + start: undefined, + end: undefined, + }; + } + + frame() { + if (!this.box.start) return; + + this.sim.display.drawBox(this.box) + } +} diff --git a/sim-options.js b/sim-options.js index 2ce1c2c..cee36c4 100644 --- a/sim-options.js +++ b/sim-options.js @@ -12,11 +12,9 @@ export const simOptions = { accelerationScale: ['Accel
Vec Scale', 'number', 800, {hideUnless: 'display.acceleration'}], targetFrameRate: ['Frame Rate', 'number', 60], }, - collision: { - merge: ['Merge Masses on Collision', 'boolean', true, {wide: true}], - }, compensate: { timeScale: ['Time Scale Compensator', 'boolean', false, {wide: true}], + fastPanning: ['Fast Panning', 'boolean', false], }, param: { gravity: ['Gravity', 'number', 1], diff --git a/sim-tools.js b/sim-tools.js index 4c37913..45f98b4 100644 --- a/sim-tools.js +++ b/sim-tools.js @@ -1,5 +1,3 @@ -import {Overlay} from './overlay.js'; -import {Pointer} from './pointer.js'; import {ModeSwitch} from './tool/modes.js'; import {OptionsTool} from './tool/options.js'; import {PlayPause} from './tool/play-pause.js'; @@ -21,7 +19,6 @@ export function initializeTools(sim) { .addTool(new OptionsTool([ 'pauseDuring', 'display', - 'collision', 'compensate', ])), params: new Toolbar(sim, 'Params') @@ -44,8 +41,6 @@ export function initializeTools(sim) { .addToolbar(params) .addToolbar(debug), }; - sim.overlay = new Overlay(sim); - sim.pointer = new Pointer(sim); for (const id in sim.toolbars) { const toolbar = sim.toolbars[id]; diff --git a/simulator.js b/simulator.js index beccbb8..51f0663 100644 --- a/simulator.js +++ b/simulator.js @@ -2,12 +2,15 @@ import { EVENT_ZOOM, FRAMERATE_SAMPLE_DURATION, } from './config.js'; -import { Display } from './display.js'; -import { System } from './system.js'; -import { Options } from './options.js'; -import { Zoom } from './zoom.js'; -import { simOptions } from './sim-options.js'; -import { initializeTools } from './sim-tools.js'; +import {Display} from './display.js'; +import {System} from './system.js'; +import {Overlay} from './overlay.js'; +import {Pointer} from './pointer.js'; +import {Options} from './options.js'; +import {Zoom} from './zoom.js'; +import {Select} from './select.js'; +import {simOptions} from './sim-options.js'; +import {initializeTools} from './sim-tools.js'; export class Sim { info = {}; @@ -25,7 +28,8 @@ export class Sim { overlay = undefined; pointer = undefined; zoom = undefined; - + select = undefined; + toolbarGroups = {}; toolbars = {}; @@ -45,7 +49,10 @@ export class Sim { this.options = new Options(this, simOptions); this.display = new Display(this); this.system = new System(this); + this.overlay = new Overlay(this); + this.pointer = new Pointer(this); this.zoom = new Zoom(this); + this.select = new Select(this); initializeTools(this); @@ -78,6 +85,7 @@ export class Sim { this.zoom.frame(); this.pointer.frame(); this.display.frame(elapsedTime); + this.select.frame(); this.system.frame(elapsedTime); this.overlay.frame(); for (const group in this.toolbarGroups) { @@ -87,7 +95,7 @@ export class Sim { } markFrame(t) { - const { recentFrames: rfs } = this; + const {recentFrames: rfs} = this; if (!rfs.length) { rfs.push(t); return; @@ -96,7 +104,7 @@ export class Sim { let duration = t - oldest; const count = rfs.length + 1; const frameRate = 1000 * count / duration; - const targetFrameRate = parseInt(this.getOption('display.targetFrameRate')); + const targetFrameRate = this.getOption('display.targetFrameRate'); if (frameRate > targetFrameRate + 1) { return true; } @@ -122,10 +130,18 @@ export class Sim { play() { this.playing = true; + + if (this.panning?.paused) { + this.panning.paused = false; + } } pause() { this.playing = false; + + if (this.panning?.velocity) { + this.panning.paused = true; + } } getScaleDisplay() { diff --git a/system.js b/system.js index e9ca9f5..df5794b 100644 --- a/system.js +++ b/system.js @@ -6,30 +6,208 @@ export class System { creatingObject = undefined; selectedObject = undefined; selectObjectStart = undefined; - paused = false; panVelocityPaused = undefined; + paused = false; constructor(sim) { this.sim = sim; } - pause() { - if (this.sim.playing) { - this.sim.playing = false; - this.paused = true; - if (this.sim.panning?.velocity) { - this.sim.panning.paused = true; - } + handlePointerDown({x, y}) { + // If pointer is touching an object, select the object + const touchingObject = this.objectAtLocation(x, y); + + if (touchingObject !== undefined) { + this.selectObject(touchingObject, {x, y}); + } else { + // Otherwise, create a new object + this.createObject(x, y); } } + handlePointerUp() { + const obj = this.getSelectedOrCreating(); + if (obj === undefined) return; + this.doneCreatingObject(); + this.deselect(); + // Convert pointer velocity to simulation scale + const pointer = {...this.sim.pointer.latestVelocity}; + obj.velocity.x = pointer.x / this.sim.display.scale; + obj.velocity.y = pointer.y / this.sim.display.scale; + + // Including time scale - if time is slow, our motion is relatively faster + if (this.sim.getOption('compensate.timeScale')) { + obj.velocity.x /= this.sim.timeScale; + obj.velocity.y /= this.sim.timeScale; + } + + if (this.sim.panning?.velocity) { + obj.velocity.x += this.sim.panning.velocity.x; + obj.velocity.y += this.sim.panning.velocity.y; + } + } + + handlePointerMove({x, y}) { + // If the cursor moves while creating an object, or while an object is selected, + // update the position using the pointer motion but the velocity using the pointer velocity + const obj = this.getSelectedOrCreating(); + if (obj === undefined) return; + const start = this.selectedObjectStart; + obj.position.x = start.x + (x - start.pointer.x); + obj.position.y = start.y + (y - start.pointer.y); + obj.velocity.x = 0; + obj.velocity.y = 0; + } + + // elapsedTime is given in milliseconds + frame(elapsedTime) { + // If we're creating an object, increment its mass + // with the mass creation rate accelerating over time + + // Scaling this parameter because of millisecond conversion + + if (this.creatingObject !== undefined) { + const obj = this.objects[this.creatingObject]; + let massCreationRate = this.sim.getOption('param.massCreationRate'); + massCreationRate /= this.sim.display.scale; + // Keep consistent time scale + if (this.sim.getOption('compensate.timeScale')) { + massCreationRate /= this.sim.timeScale; + } + obj.mass += massCreationRate * elapsedTime; + } + + // Calculate forces due to gravity. + this.computeForces(); + + if (this.sim.playing) { + // Predict positions (Velocity verlet method) + this.forEachObject(obj => { + obj.currentAcceleration = {...obj.acceleration}; + + // If this object is being created/selected, clamp its position + if (obj.id === this.getSelectedOrCreating()?.id) { + return; + } + + 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) { + 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 + // Sdd masses + // Sverage color + S.position.x = (S.position.x * S.mass + T.position.x * T.mass) / (S.mass + T.mass); + S.position.y = (S.position.y * S.mass + T.position.y * T.mass) / (S.mass + T.mass); + S.velocity.x = (S.velocity.x * S.mass + T.velocity.x * T.mass) / (S.mass + T.mass); + S.velocity.y = (S.velocity.y * S.mass + T.velocity.y * T.mass) / (S.mass + 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 = []; + } + }, {alive: true, startWith: i + 1}); + }); + + // Recompute forces + this.computeForces(); + + // Predict velocities + this.forEachObject(obj => { + const acceleration = {...obj.acceleration}; + obj.acceleration = { + x: (obj.currentAcceleration.x + acceleration.x) / 2, + y: (obj.currentAcceleration.y + acceleration.y) / 2, + }; + obj.velocity.x += obj.acceleration.x * elapsedTime; + obj.velocity.y += obj.acceleration.y * elapsedTime; + + // Append to object history + obj.history.push({position: {...obj.position}}); + + // TODO: store object color changes in history + + // Enforce object history length + while (obj.history.length > OBJECT_HISTORY_SIZE) { + obj.history.shift(); + } + }); + } + + // Display objects info + // First clear info from previous frame + this.forEachObject((_obj, i) => { + delete this.sim.info[`Object ${i}`]; + }, {alive: null}); + if (this.sim.getOption('debug.objectsInfo')) { + const aliveOnly = this.sim.getOption('debug.aliveObjects'); + this.forEachObject((obj, i) => { + const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1 / 2); + const accel = Math.pow(obj.acceleration.x ** 2 + obj.acceleration.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; + const accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI; + const {r, g, b} = obj.color; + this.sim.info[`Object ${i}`] = [ + `  `, + `${obj.position.x.toPrecision(4)}, `, + `${obj.position.y.toPrecision(4)}, `, + `${obj.mass.toPrecision(4)} kg, `, + `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, + `${accel.toPrecision(2)} m/s2, ${accelDir.toPrecision(2)}°`, + `Alive: ${obj.alive}`, + ]; + }, {alive: aliveOnly || null}); + } + + // Render the objects + this.drawObjects(); + } + + pause() { + this.sim.pause(); + this.paused = true; + } + resume() { if (this.paused) { - this.sim.playing = true; this.paused = false; - if (this.sim.panning?.paused) { - this.sim.panning.paused = false; - } + this.sim.play(); } } @@ -138,52 +316,6 @@ export class System { return idx; } - handlePointerDown({x, y}) { - // If pointer is touching an object, select the object - const touchingObject = this.objectAtLocation(x, y); - - if (touchingObject !== undefined) { - this.selectObject(touchingObject, {x, y}); - } else { - // Otherwise, create a new object - this.createObject(x, y); - } - } - - handlePointerUp() { - const obj = this.getSelectedOrCreating(); - if (obj === undefined) return; - this.doneCreatingObject(); - this.deselect(); - // Convert pointer velocity to simulation scale - const pointer = {...this.sim.pointer.latestVelocity}; - obj.velocity.x = pointer.x / this.sim.display.scale; - obj.velocity.y = pointer.y / this.sim.display.scale; - - // Including time scale - if time is slow, our motion is relatively faster - if (this.sim.getOption('compensate.timeScale')) { - obj.velocity.x /= this.sim.timeScale; - obj.velocity.y /= this.sim.timeScale; - } - - if (this.sim.panning?.velocity) { - obj.velocity.x += this.sim.panning.velocity.x; - obj.velocity.y += this.sim.panning.velocity.y; - } - } - - handlePointerMove({x, y}) { - // If the cursor moves while creating an object, or while an object is selected, - // update the position using the pointer motion but the velocity using the pointer velocity - const obj = this.getSelectedOrCreating(); - if (obj === undefined) return; - const start = this.selectedObjectStart; - obj.position.x = start.x + (x - start.pointer.x); - obj.position.y = start.y + (y - start.pointer.y); - obj.velocity.x = 0; - obj.velocity.y = 0; - } - // cb: (obj, idx) => {} forEachObject(cb, {alive, startWith} = {}) { if (alive === undefined) alive = true; @@ -237,143 +369,6 @@ export class System { }); } - // elapsedTime is given in milliseconds - frame(elapsedTime) { - // If we're creating an object, increment its mass - // with the mass creation rate accelerating over time - - // Scaling this parameter because of millisecond conversion - - if (this.creatingObject !== undefined) { - const obj = this.objects[this.creatingObject]; - let massCreationRate = this.sim.getOption('param.massCreationRate'); - massCreationRate /= this.sim.display.scale; - // Keep consistent time scale - if (this.sim.getOption('compensate.timeScale')) { - massCreationRate /= this.sim.timeScale; - } - obj.mass += massCreationRate * elapsedTime; - } - - // Calculate forces due to gravity. - this.computeForces(); - - if (this.sim.playing) { - // Predict positions (Velocity verlet method) - 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 - if (this.sim.getOption('collision.merge')) { - 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 - // Sdd masses - // Sverage color - S.position.x = (S.position.x * S.mass + T.position.x * T.mass) / (S.mass + T.mass); - S.position.y = (S.position.y * S.mass + T.position.y * T.mass) / (S.mass + T.mass); - S.velocity.x = (S.velocity.x * S.mass + T.velocity.x * T.mass) / (S.mass + T.mass); - S.velocity.y = (S.velocity.y * S.mass + T.velocity.y * T.mass) / (S.mass + 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 = []; - } - }, {alive: true, startWith: i + 1}); - }); - } - - // Recompute forces - this.computeForces(); - - // Predict velocities - this.forEachObject(obj => { - const acceleration = {...obj.acceleration}; - obj.acceleration = { - x: (obj.currentAcceleration.x + acceleration.x) / 2, - y: (obj.currentAcceleration.y + acceleration.y) / 2, - }; - obj.velocity.x += obj.acceleration.x * elapsedTime; - obj.velocity.y += obj.acceleration.y * elapsedTime; - - // Append to object history - obj.history.push({position: {...obj.position}}); - - // TODO: store object color changes in history - - // Enforce object history length - while (obj.history.length > OBJECT_HISTORY_SIZE) { - obj.history.shift(); - } - }); - } - - // Display objects info - // First clear info from previous frame - this.forEachObject((_obj, i) => { - delete this.sim.info[`Object ${i}`]; - }, {alive: null}); - if (this.sim.getOption('debug.objectsInfo')) { - const aliveOnly = this.sim.getOption('debug.aliveObjects'); - this.forEachObject((obj, i) => { - const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1 / 2); - const accel = Math.pow(obj.acceleration.x ** 2 + obj.acceleration.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; - const accelDir = Math.atan2(-obj.acceleration.y, obj.acceleration.x) * 180 / Math.PI; - const {r, g, b} = obj.color; - this.sim.info[`Object ${i}`] = [ - `  `, - `${obj.position.x.toPrecision(4)}, `, - `${obj.position.y.toPrecision(4)}, `, - `${obj.mass.toPrecision(4)} kg, `, - `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, - `${accel.toPrecision(2)} m/s2, ${accelDir.toPrecision(2)}°`, - `Alive: ${obj.alive}`, - ]; - }, {alive: aliveOnly || null}); - } - - // Render the objects - this.drawObjects(); - } - computeSystemCenter() { // Determine center of mass const {totalMass, count, totalMassLocation} = @@ -423,4 +418,5 @@ export class System { return acc + obj.mass * s / d; }, 0); } + } diff --git a/tool/options.js b/tool/options.js index 92c0d2e..0a87010 100644 --- a/tool/options.js +++ b/tool/options.js @@ -19,8 +19,8 @@ export class OptionsTool extends Tool { super.setContainer(container); for (const sectionName of this.sections) { const option = this.sim.options.getSection(sectionName); - const child = this.visitItem(option); - this.div.appendChild(child); + const item = this.visitItem(option); + this.div.appendChild(item); } } diff --git a/zoom.js b/zoom.js index e24395b..0594a7a 100644 --- a/zoom.js +++ b/zoom.js @@ -48,7 +48,8 @@ export class Zoom { // if (this.sim.playing && velocity) { if (velocity) { this.sim.panning = { - velocity: { ...velocity } + velocity: { ...velocity }, + paused: !this.sim.playing, }; }