Initial commit
This commit is contained in:
commit
9e6e6666ab
31 changed files with 3799 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
147
README.md
Normal file
147
README.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
|
||||||
|
strong nuclear force (quark confinement)
|
||||||
|
the force between particles stays constant
|
||||||
|
no matter what distance
|
||||||
|
|
||||||
|
|
||||||
|
spring universe (hooks law)
|
||||||
|
- when particles are far apart, it gives a huge attractive force
|
||||||
|
|
||||||
|
|
||||||
|
BAND
|
||||||
|
- when particles are far apart, it gives attraction,
|
||||||
|
but when particles are too close, it gives repulsion,
|
||||||
|
so you have this "band" in which the force kinda drops off... and is negligable
|
||||||
|
- molecular dynamics:
|
||||||
|
- when far apart, you have very small attractive force
|
||||||
|
- when close, strong repulsive force
|
||||||
|
|
||||||
|
|
||||||
|
Galactic Gravity
|
||||||
|
- instead of newtons 1/r^2, use 1/r
|
||||||
|
- orbits around a heavy body will move at constant speed
|
||||||
|
reg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Boids
|
||||||
|
- don't crowd neighbours
|
||||||
|
- neighbours that are close have repulsive force
|
||||||
|
- alignment
|
||||||
|
|
||||||
|
|
||||||
|
What about different types of "masses"?
|
||||||
|
e.g. something like charge, or perhaps something weirder?
|
||||||
|
|
||||||
|
====Implementation====
|
||||||
|
|
||||||
|
Should I track momentum or velocity?
|
||||||
|
Since I have constant mass particle,
|
||||||
|
I can always compute velocity easily.
|
||||||
|
|
||||||
|
# Time and Evolution
|
||||||
|
Decoupling simulation step from `dt` given by the browser.
|
||||||
|
|
||||||
|
# Global attributes
|
||||||
|
- net force (should be very close to 0)
|
||||||
|
- net momentum (should be very close to a constant)
|
||||||
|
What else?
|
||||||
|
- average kinetic energy (temperature?)
|
||||||
|
- center-of-mass (should be constant)
|
||||||
|
|
||||||
|
- WTF potential energy? How to calculate that? Probably
|
||||||
|
always different for each of the systems? Does it always make sense?
|
||||||
|
|
||||||
|
|
||||||
|
# Camera & Culling
|
||||||
|
|
||||||
|
- zoom & pan
|
||||||
|
- render only those particles that are on the screen
|
||||||
|
- note that this is gonna fuck up the trails, but whatever
|
||||||
|
|
||||||
|
|
||||||
|
# Brushes
|
||||||
|
## Spawning
|
||||||
|
- spray paint - bunch of particles near each other but with non-zero initial velocity
|
||||||
|
- single heavy click brush spawning particles
|
||||||
|
- brush that spawns particles orthogonal to the center of mass
|
||||||
|
|
||||||
|
## Filtering
|
||||||
|
- delete particles in mass range
|
||||||
|
- delete particles in region
|
||||||
|
|
||||||
|
## Mapping
|
||||||
|
- change mass of all particles based on a function.
|
||||||
|
|
||||||
|
## Reduce
|
||||||
|
- Replace particles in a region by one particle that has combined mass
|
||||||
|
|
||||||
|
# Local Attributes
|
||||||
|
- follow a single particle and highlight it, name it, display its attributes
|
||||||
|
|
||||||
|
- Select a square/circle s.t. all particles within it
|
||||||
|
are now tracked... or perhaps those that enter/exit it?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Dimensions/Layers?
|
||||||
|
What about having quantities such as a `phase`?
|
||||||
|
Where basically particles of different phases don't interact... but then we could change phases of particles, and suddenly there is interaction etc...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Symplectic vs Explicit Euler
|
||||||
|
Suppose we have `(pos0, vel0)`, and we have `dt`. We compute `acc` (acceleration).
|
||||||
|
Then the enxt state is:
|
||||||
|
- Explicit:
|
||||||
|
```
|
||||||
|
(pos0 + dt*vel0, vel0 + dt*acc)
|
||||||
|
```
|
||||||
|
- Symplectic:
|
||||||
|
```
|
||||||
|
let {
|
||||||
|
vel1 = vel0 + dt*acc,
|
||||||
|
pos1 = pos0 + dt*vel1,
|
||||||
|
.
|
||||||
|
(pos1, vel1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
I have no idea why symplectic works...
|
||||||
|
Does it work only for some systems or all?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Energy
|
||||||
|
- Kinetic Energy is the energy of motion...
|
||||||
|
- Potential Energy is the energy of position/configuration (relative position? idk...)
|
||||||
|
|
||||||
|
|
||||||
|
Compute? Potential Energy is integral of force over distance...
|
||||||
|
|
||||||
|
Hmm. Let's have `f : M -> R` the "potential".
|
||||||
|
The force field that's generated by `f` is `d(f)` - but actually... the gradient?.
|
||||||
|
But given a force field, how can we attempt to
|
||||||
|
compute the potential energy? Atleast on the trajectory of the particle...
|
||||||
|
|
||||||
|
There's no unique solution... so let's say we spawn a particle, and give it potential of 0.
|
||||||
|
|
||||||
|
Then the acceleartion field tells us in which direction to move. I guess we're computing an integral over some 1-form. How do we get the 1-form?
|
||||||
|
|
||||||
|
It is at a point, the inner product of the infinitesimal velocity with the infinitesimal force...
|
||||||
|
```
|
||||||
|
<dv|df>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Angular Momentum?
|
||||||
|
|
||||||
|
|
||||||
|
What if our basic element would be like "rods" of two particles where the constraint is that the distance between them is fixed. Then we could naturally have
|
||||||
|
angular velocity etc...
|
||||||
|
|
||||||
|
|
||||||
BIN
connections_and_accelerations_0.kra
Executable file
BIN
connections_and_accelerations_0.kra
Executable file
Binary file not shown.
BIN
connections_and_accelerations_0.kra~
Executable file
BIN
connections_and_accelerations_0.kra~
Executable file
Binary file not shown.
BIN
connections_and_accelerations_1.kra
Executable file
BIN
connections_and_accelerations_1.kra
Executable file
Binary file not shown.
BIN
connections_and_accelerations_1.kra~
Executable file
BIN
connections_and_accelerations_1.kra~
Executable file
Binary file not shown.
11
index.html
Normal file
11
index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Newtonian Dance</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
BIN
notes_0.kra
Executable file
BIN
notes_0.kra
Executable file
Binary file not shown.
BIN
notes_0.kra~
Executable file
BIN
notes_0.kra~
Executable file
Binary file not shown.
1937
package-lock.json
generated
Normal file
1937
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "newtonian_dance",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Yuriy Dupyn",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build --base ./",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-solid": "^2.11.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"solid-js": "^1.9.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/App.tsx
Normal file
97
src/App.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { createSignal, For, Switch, Match } from "solid-js";
|
||||||
|
import { FlatGravitySystem } from "./systems/flat_gravity";
|
||||||
|
import { FlatGravityView, ViewConfig as FlatGravityViewConfig } from "./systems/flat_gravity_solid";
|
||||||
|
import { SimpleKinematicsSystem } from "./systems/simple_kinematics";
|
||||||
|
import { SimpleKinematicsView } from "./systems/simple_kinematics_solid";
|
||||||
|
|
||||||
|
const systemIds = [
|
||||||
|
"simple-kinematics",
|
||||||
|
"flat-gravity",
|
||||||
|
] as const;
|
||||||
|
export type SystemId = (typeof systemIds)[number];
|
||||||
|
|
||||||
|
const defaultSystemId: SystemId = "flat-gravity";
|
||||||
|
|
||||||
|
const systemToLabel: Record<SystemId, string> = {
|
||||||
|
"simple-kinematics": "Simple Kinematics",
|
||||||
|
"flat-gravity": "Flat Gravity",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FlatGravityHost() {
|
||||||
|
const flatGravity = FlatGravitySystem({
|
||||||
|
dt: 16,
|
||||||
|
topLeft: { x: 200, y: 200 },
|
||||||
|
bottomRight: { x: 600, y: 600 },
|
||||||
|
maxSpeed: 100 / 1000,
|
||||||
|
|
||||||
|
countSmall: 200,
|
||||||
|
countBig: 3,
|
||||||
|
massRangeSmall: { min: 0.1, max: 0.5 },
|
||||||
|
massRangeBig: { min: 200.0, max: 300.0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewConfig: FlatGravityViewConfig = {
|
||||||
|
massRange: { min: 0, max: 300 },
|
||||||
|
radiusRange: { min: 2, max: 20 },
|
||||||
|
expectedNetMass: 20000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FlatGravityView system={flatGravity} viewConfig={viewConfig} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleKinematicsHost() {
|
||||||
|
const simpleKinematics = SimpleKinematicsSystem({
|
||||||
|
dt: 4,
|
||||||
|
count: 500,
|
||||||
|
topLeft: { x: 200, y: 200 },
|
||||||
|
bottomRight: { x: 400, y: 400 },
|
||||||
|
maxSpeed: 200 / 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <SimpleKinematicsView system={simpleKinematics} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function SystemView(props: { systemId: SystemId }) {
|
||||||
|
return (
|
||||||
|
<Switch fallback={<div>Select a system</div>}>
|
||||||
|
<Match when={props.systemId === "flat-gravity"}>
|
||||||
|
<FlatGravityHost />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.systemId === "simple-kinematics"}>
|
||||||
|
<SimpleKinematicsHost />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [systemId, setSystemId] = createSignal<SystemId>(defaultSystemId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style="display: flex; flex-direction: column; height: 100vh;">
|
||||||
|
<header class="top-nav">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<label for="system-select">System:</label>
|
||||||
|
<select
|
||||||
|
id="system-select"
|
||||||
|
class="system-dropdown"
|
||||||
|
value={systemId()}
|
||||||
|
onInput={ e => setSystemId(e.currentTarget.value as SystemId) }
|
||||||
|
>
|
||||||
|
<For each={ systemIds }>
|
||||||
|
{ (id) => (
|
||||||
|
<option value={id}>{ systemToLabel[id] }</option>
|
||||||
|
) }
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<SystemView systemId={systemId()} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
217
src/arithmetic.ts
Normal file
217
src/arithmetic.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
// CONVENTIONS:
|
||||||
|
// - scalars are: A, B, C, ...
|
||||||
|
// - vectors are: V, W, U, ...
|
||||||
|
// - points are: P, Q, R
|
||||||
|
// - numbers are: a, b, c, ..., s, t,..., u, v, w,..., x, y, z, ...
|
||||||
|
|
||||||
|
export type Scalar = {
|
||||||
|
val: Float64Array,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vector = {
|
||||||
|
x: Float64Array,
|
||||||
|
y: Float64Array,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
capacity: number,
|
||||||
|
count: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Arithmetic({ capacity: CAPACITY, count: COUNT }: Config) {
|
||||||
|
|
||||||
|
const Scalar = {
|
||||||
|
allocate(): Scalar {
|
||||||
|
return {
|
||||||
|
val: new Float64Array(CAPACITY),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := 0
|
||||||
|
zero(Out: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := A + B
|
||||||
|
add(Out: Scalar, A: Scalar, B: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = A.val[i] + B.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := A * B
|
||||||
|
mul(Out: Scalar, A: Scalar, B: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = A.val[i] * B.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := A + s*B
|
||||||
|
addScaledByConstant(Out: Scalar, A: Scalar, s: number, B: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = A.val[i] + s*B.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := A / B
|
||||||
|
div(Out: Scalar, A: Scalar, B: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = A.val[i] / B.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := 1/A
|
||||||
|
reciprocal(Out: Scalar, A: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = 1/A.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := A*s
|
||||||
|
scaleByNumber(Out: Scalar, A: Scalar, s: number) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = A.val[i] * s;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sum(A: Scalar): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
sum += A.val[i];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
},
|
||||||
|
|
||||||
|
average(A: Scalar): number {
|
||||||
|
// TODO: Perhaps not the best method... might cause overflows...?
|
||||||
|
return this.sum(A)/COUNT;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Do I need a general `map`? Mapping one `number => number` over each scalar value in the scalar field
|
||||||
|
// It will not be performant. Better to write the loop by hand in JS.
|
||||||
|
};
|
||||||
|
|
||||||
|
const Vector = {
|
||||||
|
allocate(): Vector {
|
||||||
|
return {
|
||||||
|
x: new Float64Array(CAPACITY),
|
||||||
|
y: new Float64Array(CAPACITY),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := 0
|
||||||
|
zero(Out: Vector) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.x[i] = 0;
|
||||||
|
Out.y[i] = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := V + W
|
||||||
|
add(Out: Vector, V: Vector, W: Vector) {
|
||||||
|
// in js a single loop wins (bounds checking, dealing with `i` etc). But in something like C or WASM two loops might actually be better (better patterns of access to memory)
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.x[i] = V.x[i] + W.x[i];
|
||||||
|
Out.y[i] = V.y[i] + W.y[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := V + s*W
|
||||||
|
addScaledByConstant(Out: Vector, V: Vector, s: number, W: Vector) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.x[i] = V.x[i] + s*W.x[i];
|
||||||
|
Out.y[i] = V.y[i] + s*W.y[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := V*s
|
||||||
|
scaleByConstant(Out: Vector, V: Vector, s: number) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.x[i] = V.x[i] * s;
|
||||||
|
Out.y[i] = V.y[i] * s;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := V*S
|
||||||
|
scaleByScalar(Out: Vector, V: Vector, S: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.x[i] = V.x[i] * S.val[i];
|
||||||
|
Out.y[i] = V.y[i] * S.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := V / S
|
||||||
|
divideByScalar(Out: Vector, V: Vector, S: Scalar) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.x[i] = V.x[i] / S.val[i];
|
||||||
|
Out.y[i] = V.y[i] / S.val[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := <V, W>
|
||||||
|
innerProduct(Out: Scalar, V: Vector, W: Vector) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
Out.val[i] = V.x[i]*W.x[i] + V.y[i]*W.y[i];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := |V|
|
||||||
|
magnitude(Out: Scalar, V: Vector) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const x = V.x[i];
|
||||||
|
const y = V.y[i];
|
||||||
|
Out.val[i] = Math.sqrt(x*x + y*y);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Out := V/|V|
|
||||||
|
normalize(Out: Vector, V: Vector) {
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const x = V.x[i];
|
||||||
|
const y = V.y[i];
|
||||||
|
const s = Math.sqrt(x*x + y*y);
|
||||||
|
|
||||||
|
Out.x[i] = x / s;
|
||||||
|
Out.y[i] = y / s;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sum(V: Vector): { x: number, y: number } {
|
||||||
|
let x: number = 0;
|
||||||
|
let y: number = 0;
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
x += V.x[i];
|
||||||
|
y += V.y[i];
|
||||||
|
}
|
||||||
|
return { x, y };
|
||||||
|
},
|
||||||
|
|
||||||
|
average(V: Vector): { x: number, y: number } {
|
||||||
|
const v = this.sum(V);
|
||||||
|
v.x = v.x/COUNT;
|
||||||
|
v.y = v.y/COUNT;
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO: Later we can consider other things like wedge product, or rotation by 90... or general rotation etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCount(newCount: number) {
|
||||||
|
COUNT = newCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCount(): number {
|
||||||
|
return COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Later also implement resizing capabilities that change Capacity.
|
||||||
|
return {
|
||||||
|
Scalar,
|
||||||
|
Vector,
|
||||||
|
setCount,
|
||||||
|
getCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
45
src/draw.ts
Normal file
45
src/draw.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
|
||||||
|
export namespace Draw {
|
||||||
|
export function particle(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number, y: number,
|
||||||
|
radius: number,
|
||||||
|
fill: string,
|
||||||
|
stroke = "lightblue"
|
||||||
|
) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = stroke;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crosshair(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number, y: number,
|
||||||
|
size: number,
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
const circleRadius = size * 0.5;
|
||||||
|
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2; // Fixed thickness
|
||||||
|
|
||||||
|
// The Cross
|
||||||
|
ctx.beginPath();
|
||||||
|
// Horizontal line
|
||||||
|
ctx.moveTo(x - size, y);
|
||||||
|
ctx.lineTo(x + size, y);
|
||||||
|
// Vertical line
|
||||||
|
ctx.moveTo(x, y - size);
|
||||||
|
ctx.lineTo(x, y + size);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// The Circle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, circleRadius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/helpers.ts
Normal file
57
src/helpers.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
export function random(a: number, b: number) {
|
||||||
|
const intervalSize = b - a;
|
||||||
|
return a + Math.random()*intervalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectToInterval(a: number, x: number, b: number) {
|
||||||
|
return Math.min(Math.max(a, x), b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number) {
|
||||||
|
const t = (value - inMin) / (inMax - inMin);
|
||||||
|
const clampedT = projectToInterval(0, t, 1);
|
||||||
|
return outMin + clampedT * (outMax - outMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snaps microscopic floating point errors to 0, and rounds to 2 decimals
|
||||||
|
export function formatNumber(x: number): string {
|
||||||
|
if (Math.abs(x) < 1e-10) return "0.00";
|
||||||
|
return x.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Time ===
|
||||||
|
|
||||||
|
export type MicroDuration = number;
|
||||||
|
export type Duration = number;
|
||||||
|
|
||||||
|
|
||||||
|
// === Events ===
|
||||||
|
export function usePointer(ref: () => HTMLCanvasElement) {
|
||||||
|
const [position, setPosition] = createSignal([0, 0]);
|
||||||
|
const [isDown, setIsDown] = createSignal(false);
|
||||||
|
|
||||||
|
const handle = (e: PointerEvent) => {
|
||||||
|
const rect = ref().getBoundingClientRect();
|
||||||
|
setPosition([e.clientX - rect.left, e.clientY - rect.top]);
|
||||||
|
setIsDown(e.buttons === 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { position, isDown, handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// === Generic System ===
|
||||||
|
|
||||||
|
// Observables contain derived attributes that can be computed purely from the `State`.
|
||||||
|
export interface System<State, Observables, Capabilities> {
|
||||||
|
state: State,
|
||||||
|
observables: Observables,
|
||||||
|
flow(t: Duration): void, // Updates just the state, not observables!
|
||||||
|
refreshObservables(): void, // Updates just the observables from the current state.
|
||||||
|
totalTime(): Duration, // gets the time from the start of the simulation
|
||||||
|
reset(): void,
|
||||||
|
capabilities: Capabilities,
|
||||||
|
}
|
||||||
|
|
||||||
9
src/main.tsx
Normal file
9
src/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import "./styles/main.css";
|
||||||
|
import { render } from "solid-js/web";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
// === Coordinate Transformations ===
|
||||||
|
// TODO: canvas to system coordinates and the other way
|
||||||
|
|
||||||
|
render(() => <App />, document.body);
|
||||||
|
|
||||||
129
src/styles/app.css
Normal file
129
src/styles/app.css
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
display: grid;
|
||||||
|
/* Columns: Brushes | Canvas (800px) | Observables */
|
||||||
|
grid-template-columns: 100px 800px 400px;
|
||||||
|
/* Rows: Controls | Main content */
|
||||||
|
grid-template-rows: min-content min-content;
|
||||||
|
|
||||||
|
gap: 5px;
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
grid-column: 2; /* Center column */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
color: var(--text-main);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
grid-column: 2;
|
||||||
|
height: 600px; /* Force the canvas height here */
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container canvas {
|
||||||
|
background: #000;
|
||||||
|
cursor: crosshair;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brushes-panel {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2; /* Sits next to the canvas */
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2; /* Sits next to the canvas */
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Buttons --- */
|
||||||
|
.btn {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Checkboxes & Labels --- */
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-sub-label); /* Modern browsers will use your blue for the check */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Top Stats --- */
|
||||||
|
.top-stats {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--bg-toolbar);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-toolbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple Select Styling */
|
||||||
|
.system-dropdown {
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-dropdown:hover {
|
||||||
|
border-color: var(--accent-sub-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-dropdown:focus {
|
||||||
|
outline: 1px solid var(--accent-sub-label);
|
||||||
|
}
|
||||||
|
|
||||||
18
src/styles/main.css
Normal file
18
src/styles/main.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
@import "./variables.css";
|
||||||
|
@import "./observables.css";
|
||||||
|
@import "./app.css";
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
37
src/styles/observables.css
Normal file
37
src/styles/observables.css
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
|
||||||
|
.observables-container {
|
||||||
|
column-gap: 12px;
|
||||||
|
row-gap: 6px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observables-header {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observables-label {
|
||||||
|
color: var(--accent-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.observables-component-label {
|
||||||
|
color: var(--accent-sub-label);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observables-value {
|
||||||
|
color: var(--color-number);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
20
src/styles/variables.css
Normal file
20
src/styles/variables.css
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
:root {
|
||||||
|
/* Surface Colors */
|
||||||
|
--bg-app: #121212;
|
||||||
|
--bg-panel: #1a1a1a;
|
||||||
|
--border-subtle: #333333;
|
||||||
|
|
||||||
|
/* Typography & Accents */
|
||||||
|
--text-main: #e0e0e0;
|
||||||
|
--text-dim: #888888;
|
||||||
|
--accent-label: #9cdcfe; /* Soft Blue */
|
||||||
|
--accent-sub-label: #569cd6; /* Deeper Blue */
|
||||||
|
|
||||||
|
/* Data Colors */
|
||||||
|
--color-number: #b5cea8; /* Pale Green */
|
||||||
|
--color-critical: #f44747; /* Red */
|
||||||
|
/* Toolbar specific */
|
||||||
|
--bg-toolbar: #252526;
|
||||||
|
--text-toolbar: #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
346
src/systems/flat_gravity.ts
Normal file
346
src/systems/flat_gravity.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
// A particle creates an attractive force field towards it s.t. the strength is proportional to the particle's mass.
|
||||||
|
// It doesn't depend on the distance - the force is constant.
|
||||||
|
|
||||||
|
import { Arithmetic, Vector, Scalar } from "../arithmetic";
|
||||||
|
import { Duration, MicroDuration, random, System } from "../helpers";
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
dt: MicroDuration, // e.g. 5 miliseconds
|
||||||
|
// Box in which to generate random particles with uniform distribution, and random velocities
|
||||||
|
topLeft: {x: number, y: number},
|
||||||
|
bottomRight: {x: number, y: number},
|
||||||
|
maxSpeed: number,
|
||||||
|
|
||||||
|
countSmall: number,
|
||||||
|
massRangeSmall: { min: number, max: number },
|
||||||
|
countBig: number,
|
||||||
|
massRangeBig: { min: number, max: number },
|
||||||
|
}
|
||||||
|
|
||||||
|
export type State = {
|
||||||
|
position: Vector,
|
||||||
|
velocity: Vector,
|
||||||
|
mass: Scalar,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Observables = {
|
||||||
|
particleCount: number,
|
||||||
|
averageSpeed: number,
|
||||||
|
netMass: number,
|
||||||
|
netForce: { fx: number, fy: number },
|
||||||
|
netMomentum: { px: number, py: number },
|
||||||
|
centerOfMass: { x: number, y: number },
|
||||||
|
netKineticEnergy: number,
|
||||||
|
netPotentialEnergy: number,
|
||||||
|
netPotentialEnergyIntegralCalculation: number,
|
||||||
|
netEnergy: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Capabilities = {
|
||||||
|
spawn(x: number, y: number, vx: number, vy: number, m: number): void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlatGravitySystem = System<State, Observables, Capabilities>;
|
||||||
|
|
||||||
|
export function FlatGravitySystem(config: Config): FlatGravitySystem {
|
||||||
|
const arith = Arithmetic({ count: 0, capacity: 10000 });
|
||||||
|
const { Vector, Scalar } = arith;
|
||||||
|
|
||||||
|
// Responsible for computing the force/acceleration fields
|
||||||
|
function Force() {
|
||||||
|
const force = Vector.allocate();
|
||||||
|
const acceleration = Vector.allocate();
|
||||||
|
|
||||||
|
function LOOM() {
|
||||||
|
const count = arith.getCount();
|
||||||
|
|
||||||
|
Vector.zero(force);
|
||||||
|
Vector.zero(acceleration);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Compute the net acceleration on i coming from interaction with all the other particles.
|
||||||
|
for (let j = i + 1; j < count; j++) {
|
||||||
|
const mi = mass.val[i];
|
||||||
|
const mj = mass.val[j];
|
||||||
|
const dx = position.x[j] - position.x[i];
|
||||||
|
const dy = position.y[j] - position.y[i];
|
||||||
|
const size = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
if (size == 0) { continue }
|
||||||
|
const ux = dx/size;
|
||||||
|
const uy = dy/size;
|
||||||
|
// [ux, uy] is the unit vector pointing from i to j
|
||||||
|
|
||||||
|
const ax_i = mj*G*ux;
|
||||||
|
const ay_i = mj*G*uy;
|
||||||
|
const fx_i = mi*ax_i;
|
||||||
|
const fy_i = mi*ay_i;
|
||||||
|
const ax_j = -mi*G*ux;
|
||||||
|
const ay_j = -mi*G*uy;
|
||||||
|
|
||||||
|
acceleration.x[i] += ax_i;
|
||||||
|
acceleration.y[i] += ay_i;
|
||||||
|
acceleration.x[j] += ax_j;
|
||||||
|
acceleration.y[j] += ay_j;
|
||||||
|
|
||||||
|
force.x[i] += fx_i;
|
||||||
|
force.y[i] += fy_i;
|
||||||
|
force.x[j] += -fx_i;
|
||||||
|
force.y[j] += -fy_i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { force, acceleration, LOOM };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Better name
|
||||||
|
// Responsible for stepping (position, velocity)
|
||||||
|
function MicroEvolution() {
|
||||||
|
const microDisplacement: Vector = Vector.allocate();
|
||||||
|
|
||||||
|
function LOOM(dt: MicroDuration) {
|
||||||
|
// Symplectic Euler:
|
||||||
|
// velocity = velocity + dt*acceleration
|
||||||
|
// position = position + dt*velocity
|
||||||
|
//
|
||||||
|
// Explicit Euler (seems to increase total energy over time):
|
||||||
|
// position = position + dt*velocity
|
||||||
|
// velocity = velocity + dt*acceleration
|
||||||
|
|
||||||
|
Vector.addScaledByConstant(velocity, velocity, dt, acceleration);
|
||||||
|
|
||||||
|
// If you don't need micro-displacement, you can just use:
|
||||||
|
// Vector.addScaledByConstant(position, position, dtInSeconds, velocity);
|
||||||
|
Vector.scaleByConstant(microDisplacement, velocity, dt);
|
||||||
|
Vector.add(position, position, microDisplacement);
|
||||||
|
}
|
||||||
|
return { microDisplacement, LOOM };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates potential energy by current forces
|
||||||
|
function PotentialEnergyUpdate() {
|
||||||
|
const potentialEnergy: Scalar = Scalar.allocate();
|
||||||
|
const microWork: Scalar = Scalar.allocate();
|
||||||
|
|
||||||
|
function LOOM(force: Vector, microDisplacement: Vector) {
|
||||||
|
// U := U - <F, microDisplacement>
|
||||||
|
Vector.innerProduct(microWork, force, microDisplacement);
|
||||||
|
Scalar.addScaledByConstant(potentialEnergy, potentialEnergy, -1, microWork);
|
||||||
|
}
|
||||||
|
return { potentialEnergy, LOOM };
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetKineticEnergy() {
|
||||||
|
const kineticEnergy: Scalar = Scalar.allocate();
|
||||||
|
const velocitySquared: Scalar = Scalar.allocate();
|
||||||
|
const netKineticEnergy = { value: 0 };
|
||||||
|
|
||||||
|
function LOOM(mass: Scalar, velocity: Vector) {
|
||||||
|
// totalKineticEnergy = 1/2 * sum(m*v^2)
|
||||||
|
Vector.innerProduct(velocitySquared, velocity, velocity);
|
||||||
|
Scalar.mul(kineticEnergy, mass, velocitySquared);
|
||||||
|
netKineticEnergy.value = 0.5*Scalar.sum(kineticEnergy);
|
||||||
|
}
|
||||||
|
return { netKineticEnergy, LOOM };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetMomentumAndCenterOfMass() {
|
||||||
|
const momentum: Vector = Vector.allocate();
|
||||||
|
const weightedPosition: Vector = Vector.allocate();
|
||||||
|
const centerOfMass = { x: 0, y: 0 };
|
||||||
|
const netMass = { value: 0 };
|
||||||
|
|
||||||
|
function LOOM(mass: Scalar, velocity: Vector) {
|
||||||
|
Vector.scaleByScalar(momentum, velocity, mass);
|
||||||
|
|
||||||
|
netMass.value = Scalar.sum(mass);
|
||||||
|
|
||||||
|
// centerOfMass := sum(mass * position) / totalMass
|
||||||
|
Vector.scaleByScalar(weightedPosition, position, mass);
|
||||||
|
const { x: mx, y: my } = Vector.sum(weightedPosition);
|
||||||
|
if (netMass.value !== 0) {
|
||||||
|
centerOfMass.x = mx/netMass.value;
|
||||||
|
centerOfMass.y = my/netMass.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { momentum, centerOfMass, LOOM }
|
||||||
|
}
|
||||||
|
|
||||||
|
let time: Duration = 0;
|
||||||
|
|
||||||
|
const state: State = {
|
||||||
|
position: Vector.allocate(), // in pixels
|
||||||
|
velocity: Vector.allocate(), // in pixels per second (not miliseconds)
|
||||||
|
mass: Scalar.allocate(),
|
||||||
|
};
|
||||||
|
const { velocity, position, mass } = state;
|
||||||
|
|
||||||
|
const G = 0.05;
|
||||||
|
|
||||||
|
const { force, acceleration, LOOM: forceLoom } = Force();
|
||||||
|
const { microDisplacement, LOOM: microEvolutionLoom } = MicroEvolution();
|
||||||
|
const { potentialEnergy, LOOM: potentialEnergyUpdateLoom } = PotentialEnergyUpdate();
|
||||||
|
const { netKineticEnergy, LOOM: netKineticEnergyLoom } = NetKineticEnergy();
|
||||||
|
const { momentum, centerOfMass, LOOM: setMomentumAndCenterOfMassLoom } = SetMomentumAndCenterOfMass();
|
||||||
|
const weightedPosition: Vector = Vector.allocate();
|
||||||
|
const speed: Scalar = Scalar.allocate();
|
||||||
|
|
||||||
|
function spawn(x: number, y: number, vx: number, vy: number, m: number) {
|
||||||
|
const idx = arith.getCount();
|
||||||
|
|
||||||
|
position.x[idx] = x;
|
||||||
|
position.y[idx] = y;
|
||||||
|
velocity.x[idx] = vx;
|
||||||
|
velocity.y[idx] = vy;
|
||||||
|
state.mass.val[idx] = m;
|
||||||
|
|
||||||
|
arith.setCount(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
arith.setCount(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < config.countBig; i++) {
|
||||||
|
spawn(
|
||||||
|
// Random position inside the bounding box
|
||||||
|
random(config.topLeft.x, config.bottomRight.x),
|
||||||
|
random(config.topLeft.y, config.bottomRight.y),
|
||||||
|
// Random velocity between -maxVelocity and +maxVelocity
|
||||||
|
random(-1, 1) * config.maxSpeed,
|
||||||
|
random(-1, 1) * config.maxSpeed,
|
||||||
|
// Random mass
|
||||||
|
random(config.massRangeBig.min, config.massRangeBig.max),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < config.countSmall; i++) {
|
||||||
|
spawn(
|
||||||
|
// Random position inside the bounding box
|
||||||
|
random(config.topLeft.x, config.bottomRight.x),
|
||||||
|
random(config.topLeft.y, config.bottomRight.y),
|
||||||
|
// Random velocity between -maxVelocity and +maxVelocity
|
||||||
|
random(-1, 1) * config.maxSpeed,
|
||||||
|
random(-1, 1) * config.maxSpeed,
|
||||||
|
// Random mass
|
||||||
|
random(config.massRangeSmall.min, config.massRangeSmall.max),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Scalar.zero(potentialEnergy);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
|
||||||
|
// TODO: how to initialize this without code-duplication?
|
||||||
|
const observables: Observables = {
|
||||||
|
particleCount: 0,
|
||||||
|
averageSpeed: 0,
|
||||||
|
netMass: 0,
|
||||||
|
netForce: { fx: 0, fy: 0 },
|
||||||
|
netMomentum: { px: 0, py: 0 },
|
||||||
|
centerOfMass: { x: 0, y: 0 },
|
||||||
|
netKineticEnergy: 0,
|
||||||
|
netPotentialEnergy: 0,
|
||||||
|
netPotentialEnergyIntegralCalculation: 0,
|
||||||
|
netEnergy: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function refreshObservables() {
|
||||||
|
const count = arith.getCount();
|
||||||
|
observables.particleCount = count;
|
||||||
|
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
Vector.magnitude(speed, velocity);
|
||||||
|
// Note that this would produce division by zero for `count === 0`.
|
||||||
|
observables.averageSpeed = Scalar.average(speed);
|
||||||
|
|
||||||
|
// netMass := sum(mass)
|
||||||
|
const netMass = Scalar.sum(mass);
|
||||||
|
observables.netMass = netMass;
|
||||||
|
|
||||||
|
// netForce := sum(force)
|
||||||
|
const { x: fx, y: fy } = Vector.sum(force);
|
||||||
|
observables.netForce.fx = fx;
|
||||||
|
observables.netForce.fy = fy;
|
||||||
|
|
||||||
|
setMomentumAndCenterOfMassLoom(mass, velocity);
|
||||||
|
const { x: px, y: py } = Vector.sum(momentum);
|
||||||
|
observables.netMomentum.px = px;
|
||||||
|
observables.netMomentum.py = py;
|
||||||
|
|
||||||
|
observables.centerOfMass.x = centerOfMass.x;
|
||||||
|
observables.centerOfMass.y = centerOfMass.y;
|
||||||
|
|
||||||
|
netKineticEnergyLoom(mass, velocity);
|
||||||
|
observables.netKineticEnergy = netKineticEnergy.value;
|
||||||
|
|
||||||
|
// Potential energy
|
||||||
|
let totalPotential = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
for (let j = i + 1; j < count; j++) {
|
||||||
|
const dx = position.x[j] - position.x[i];
|
||||||
|
const dy = position.y[j] - position.y[i];
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// U = G * m1 * m2 * distance
|
||||||
|
totalPotential += G * mass.val[i] * mass.val[j] * distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observables.netPotentialEnergy = totalPotential;
|
||||||
|
observables.netEnergy = observables.netKineticEnergy + observables.netPotentialEnergy;
|
||||||
|
observables.netPotentialEnergyIntegralCalculation = Scalar.sum(potentialEnergy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function microFlow(dt: MicroDuration) {
|
||||||
|
forceLoom();
|
||||||
|
const dtInSeconds = dt / 1000.0;
|
||||||
|
microEvolutionLoom(dtInSeconds);
|
||||||
|
potentialEnergyUpdateLoom(force, microDisplacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppose that we need to flow for 43 miliseconds, and dt is 10 miliseconds.
|
||||||
|
// Then we do micro-flow 4 times totalling 40 miliseconds. We don't do micro-flow for 3 seconds
|
||||||
|
// - that could cause problems with integration.
|
||||||
|
// Instead we remember this leftover time, so the next time we flow, we add this leftover time to the total time to flow.
|
||||||
|
let leftover_time: Duration = 0;
|
||||||
|
// Integrates the micro-flow, updates global time, and recomputes the observables.
|
||||||
|
function flow(t: Duration) {
|
||||||
|
// TODO: They way we compute leftover time is not so great. There must be a better way.
|
||||||
|
const end_time = time + t + leftover_time;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const next_time = time + config.dt;
|
||||||
|
if (next_time <= end_time) {
|
||||||
|
time = next_time;
|
||||||
|
microFlow(config.dt);
|
||||||
|
} else {
|
||||||
|
// assume at the start
|
||||||
|
// dt = 10 ms
|
||||||
|
//
|
||||||
|
// time = 0
|
||||||
|
// leftover_time = 0
|
||||||
|
// t = 43 ms
|
||||||
|
// then here
|
||||||
|
// next_time = 50 ms
|
||||||
|
// time = 40 ms
|
||||||
|
// end_time = 43 ms
|
||||||
|
// so actually we have 3 miliseconds leftover, i.e. we set
|
||||||
|
leftover_time = end_time - time;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
observables,
|
||||||
|
flow,
|
||||||
|
refreshObservables,
|
||||||
|
totalTime() { return time; },
|
||||||
|
reset,
|
||||||
|
capabilities: {
|
||||||
|
spawn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
245
src/systems/flat_gravity_solid.tsx
Normal file
245
src/systems/flat_gravity_solid.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import { Config, State, Observables, Capabilities, FlatGravitySystem } from "./flat_gravity";
|
||||||
|
import { Duration, mapRange, random, usePointer } from "../helpers";
|
||||||
|
import { createEffect, createSignal, onMount } from "solid-js";
|
||||||
|
import { ObservableEntry, ObservablesPanel } from "../ui_helpers";
|
||||||
|
import { Draw } from "../draw";
|
||||||
|
import { useSimulation } from "../useSimulation";
|
||||||
|
|
||||||
|
export type ViewConfig = {
|
||||||
|
massRange: { min: number, max: number },
|
||||||
|
radiusRange: { min: number, max: number },
|
||||||
|
expectedNetMass: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
function entries(obs: Observables): ObservableEntry[] {
|
||||||
|
return [
|
||||||
|
{ type: "header", label: "General" },
|
||||||
|
{ type: "scalar", label: "Particles", value: obs.particleCount },
|
||||||
|
{ type: "scalar", label: "Net Mass", value: obs.netMass },
|
||||||
|
|
||||||
|
{ type: "header", label: "Energy" },
|
||||||
|
{ type: "scalar", label: "Kinetic", value: obs.netKineticEnergy },
|
||||||
|
{ type: "scalar", label: "Potential (formula)", value: obs.netPotentialEnergy },
|
||||||
|
{ type: "scalar", label: "Potential (integral)", value: obs.netPotentialEnergyIntegralCalculation },
|
||||||
|
{ type: "scalar", label: "formula - integral", value: obs.netPotentialEnergy - obs.netPotentialEnergyIntegralCalculation },
|
||||||
|
{ type: "scalar", label: "Total (formula)", value: obs.netEnergy },
|
||||||
|
{ type: "scalar", label: "Total (integral)", value: obs.netKineticEnergy + obs.netPotentialEnergyIntegralCalculation },
|
||||||
|
|
||||||
|
{ type: "header", label: "Vectors" },
|
||||||
|
{
|
||||||
|
type: "vector",
|
||||||
|
label: "Center of Mass",
|
||||||
|
vector: [ obs.centerOfMass.x, obs.centerOfMass.y ],
|
||||||
|
components: ["x", "y"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "vector",
|
||||||
|
label: "Net Force",
|
||||||
|
vector: [ obs.netForce.fx, obs.netForce.fy ],
|
||||||
|
components: ["fx", "fy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "vector",
|
||||||
|
label: "Net Momentum",
|
||||||
|
vector: [ obs.netMomentum.px, obs.netMomentum.py ],
|
||||||
|
components: ["px", "py"]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
system: FlatGravitySystem,
|
||||||
|
{ massRange, radiusRange, expectedNetMass }: ViewConfig,
|
||||||
|
showCenterOfMass: boolean,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const { particleCount, centerOfMass, netMass } = system.observables;
|
||||||
|
const { position, mass } = system.state;
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const m = mass.val[i];
|
||||||
|
const radius = mapRange(m, massRange.min, massRange.max, radiusRange.min, radiusRange.max);
|
||||||
|
// Interpolate Color (Blue = 240, Red = 0)
|
||||||
|
const hue = mapRange(m, massRange.min, massRange.max, 240, 0);
|
||||||
|
|
||||||
|
Draw.particle(ctx, position.x[i], position.y[i], radius, `hsl(${hue}, 100%, 50%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCenterOfMass && particleCount > 0) {
|
||||||
|
const radius = mapRange(netMass, 0, expectedNetMass, 0, 300);
|
||||||
|
Draw.crosshair(ctx, centerOfMass.x, centerOfMass.y, radius, "red");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrails(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
system: FlatGravitySystem,
|
||||||
|
config: ViewConfig,
|
||||||
|
opacity: number = 0.03
|
||||||
|
) {
|
||||||
|
ctx.fillStyle = `rgba(0, 0, 0, ${opacity})`;
|
||||||
|
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
const { particleCount } = system.observables;
|
||||||
|
const { position, mass } = system.state;
|
||||||
|
const { massRange } = config;
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; // Faint white dots
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const m = mass.val[i];
|
||||||
|
|
||||||
|
const trailSize = mapRange(m, massRange.min, massRange.max, 1, 3);
|
||||||
|
|
||||||
|
ctx.fillRect(
|
||||||
|
position.x[i] - trailSize / 2,
|
||||||
|
position.y[i] - trailSize / 2,
|
||||||
|
trailSize,
|
||||||
|
trailSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlatGravityView(props: { system: FlatGravitySystem, viewConfig: ViewConfig }) {
|
||||||
|
|
||||||
|
const [uiState, setUiState] = createSignal<Observables>(structuredClone(props.system.observables));
|
||||||
|
const [showCenterOfMass, setShowCenterOfMass] = createSignal(true);
|
||||||
|
const [showTrails, setShowTrails] = createSignal(true);
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
let canvasRef!: HTMLCanvasElement;
|
||||||
|
let ctx: CanvasRenderingContext2D;
|
||||||
|
let trailCanvasRef!: HTMLCanvasElement;
|
||||||
|
let trailCtx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ctx = canvasRef.getContext("2d")!;
|
||||||
|
trailCtx = trailCanvasRef.getContext("2d")!;
|
||||||
|
|
||||||
|
// Stop the 'ghost image' drag behavior
|
||||||
|
canvasRef.addEventListener('dragstart', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
// prevent the context menu on right-click
|
||||||
|
// if you plan to use right-click for panning later
|
||||||
|
// canvasRef.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fps, isPlaying, togglePlay, requestSyncedRender } = useSimulation({
|
||||||
|
step(dt: Duration) {
|
||||||
|
props.system.flow(dt);
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
props.system.refreshObservables();
|
||||||
|
if (showTrails()) {
|
||||||
|
renderTrails(trailCtx, props.system, props.viewConfig);
|
||||||
|
}
|
||||||
|
render(canvasRef, ctx, props.system, props.viewConfig, showCenterOfMass());
|
||||||
|
},
|
||||||
|
sync() {
|
||||||
|
setUiState(structuredClone(props.system.observables));
|
||||||
|
},
|
||||||
|
isPlaying: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { position, isDown, handle: handlePointer } = usePointer(() => canvasRef);
|
||||||
|
|
||||||
|
function toggleTrails() {
|
||||||
|
if (showTrails()) {
|
||||||
|
// Clear the trail canvas entirely when turned off
|
||||||
|
trailCtx.clearRect(0, 0, trailCanvasRef.width, trailCanvasRef.height);
|
||||||
|
setShowTrails(false);
|
||||||
|
} else {
|
||||||
|
setShowTrails(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawn new particles on mouse-down
|
||||||
|
createEffect(() => {
|
||||||
|
if (isDown()) {
|
||||||
|
if (props.system.observables.particleCount >= 10000) return;
|
||||||
|
|
||||||
|
const [x, y] = position();
|
||||||
|
|
||||||
|
// Add a tiny bit of random scatter so they don't perfectly overlap.
|
||||||
|
const rx = x + random(-5, 5);
|
||||||
|
const ry = y + random(-5, 5);
|
||||||
|
|
||||||
|
// Spawn a small particle with zero initial velocity (note that net-momentum is not gonna change)
|
||||||
|
const m = random(1, 5);
|
||||||
|
props.system.capabilities.spawn(rx, ry, 0, 0, m);
|
||||||
|
|
||||||
|
requestSyncedRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="app-layout">
|
||||||
|
<div class="cell-0"></div>
|
||||||
|
|
||||||
|
{/* === Top Controls === */}
|
||||||
|
<div class="cell-1 controls">
|
||||||
|
<button class="btn" onClick={togglePlay} style="width: 70px">
|
||||||
|
{isPlaying() ? "Pause" : "Play"}
|
||||||
|
</button>
|
||||||
|
<button class="btn" onClick={props.system.reset} style="width: 70px">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="top-stats">
|
||||||
|
FPS: {fps()}
|
||||||
|
</span>
|
||||||
|
<label class="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showCenterOfMass()}
|
||||||
|
onChange={(e) => setShowCenterOfMass(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
Show Center-of-Mass
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showTrails()}
|
||||||
|
onChange={() => toggleTrails() }
|
||||||
|
/>
|
||||||
|
Show trails
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell-2"></div>
|
||||||
|
|
||||||
|
{/* === Reserved for future brushes === */}
|
||||||
|
<div class="cell-3 brushes-panel">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Canvas === */}
|
||||||
|
<div class="cell-4 canvas-container">
|
||||||
|
<div style="position: relative">
|
||||||
|
<canvas
|
||||||
|
ref={trailCanvasRef}
|
||||||
|
width={width} height={height}
|
||||||
|
style={{ position: "absolute" }}
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width} height={height}
|
||||||
|
onPointerDown={handlePointer}
|
||||||
|
onPointerMove={handlePointer}
|
||||||
|
style={{ position: "absolute", background: "transparent" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Observables === */}
|
||||||
|
<div class="cell-5 side-panel">
|
||||||
|
<ObservablesPanel data={ entries(uiState()) } />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
150
src/systems/simple_kinematics.ts
Normal file
150
src/systems/simple_kinematics.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
|
||||||
|
import { Arithmetic, Vector, Scalar } from "../arithmetic";
|
||||||
|
import { Duration, MicroDuration, random, System } from "../helpers";
|
||||||
|
|
||||||
|
// read-only config used to initialize the system
|
||||||
|
export type Config = {
|
||||||
|
dt: MicroDuration, // e.g. 5 miliseconds
|
||||||
|
count: number,
|
||||||
|
// Box in which to generate random particles with uniform distribution, and random velocities
|
||||||
|
topLeft: {x: number, y: number},
|
||||||
|
bottomRight: {x: number, y: number},
|
||||||
|
maxSpeed: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type State = {
|
||||||
|
position: Vector,
|
||||||
|
velocity: Vector,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Observables = {
|
||||||
|
particleCount: number,
|
||||||
|
averageSpeed: number,
|
||||||
|
|
||||||
|
// bounding-box
|
||||||
|
topLeft: {x: number, y: number},
|
||||||
|
bottomRight: {x: number, y: number},
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SimpleKinematicsSystem = System<State, Observables, {}>;
|
||||||
|
|
||||||
|
export function SimpleKinematicsSystem(config: Config): SimpleKinematicsSystem {
|
||||||
|
const arith = Arithmetic({ count: 0, capacity: 1000 });
|
||||||
|
const { Vector, Scalar } = arith;
|
||||||
|
|
||||||
|
let time: Duration = 0;
|
||||||
|
|
||||||
|
const state: State = {
|
||||||
|
position: Vector.allocate(),
|
||||||
|
velocity: Vector.allocate(),
|
||||||
|
};
|
||||||
|
const { velocity, position } = state;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
arith.setCount(config.count);
|
||||||
|
const count = arith.getCount();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Random position inside the bounding box
|
||||||
|
position.x[i] = random(config.topLeft.x, config.bottomRight.x);
|
||||||
|
position.y[i] = random(config.topLeft.y, config.bottomRight.y);
|
||||||
|
|
||||||
|
// Random velocity between -maxVelocity and +maxVelocity
|
||||||
|
velocity.x[i] = random(-1, 1) * config.maxSpeed;
|
||||||
|
velocity.y[i] = random(-1, 1) * config.maxSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
|
||||||
|
// TODO: how to initialize this without code-duplication?
|
||||||
|
const observables: Observables = {
|
||||||
|
// TODO: Is there a better way of doing this?
|
||||||
|
// These are dummy values that will be overwritten
|
||||||
|
particleCount: 0,
|
||||||
|
averageSpeed: 0,
|
||||||
|
topLeft: {x: 0, y: 0},
|
||||||
|
bottomRight: {x: 0, y: 0},
|
||||||
|
};
|
||||||
|
|
||||||
|
const speed: Scalar = Scalar.allocate();
|
||||||
|
function refreshObservables() {
|
||||||
|
const count = arith.getCount();
|
||||||
|
observables.particleCount = count;
|
||||||
|
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
// bounding-box
|
||||||
|
let minX = Infinity, minY = Infinity;
|
||||||
|
let maxX = -Infinity, maxY = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const x = position.x[i];
|
||||||
|
const y = position.y[i];
|
||||||
|
if (x < minX) { minX = x; }
|
||||||
|
if (x > maxX) { maxX = x; }
|
||||||
|
if (y < minY) { minY = y; }
|
||||||
|
if (y > maxY) { maxY = y; }
|
||||||
|
}
|
||||||
|
observables.topLeft.x = minX;
|
||||||
|
observables.topLeft.y = minY;
|
||||||
|
observables.bottomRight.x = maxX;
|
||||||
|
observables.bottomRight.y = maxY;
|
||||||
|
|
||||||
|
Vector.magnitude(speed, velocity);
|
||||||
|
// Note that this would produce division by zero for `count === 0`.
|
||||||
|
observables.averageSpeed = Scalar.average(speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// const acceleration: Vector = Vector.allocate();
|
||||||
|
// const force: Vector = Vector.allocate();
|
||||||
|
// const momentum: Vector = Vector.allocate();
|
||||||
|
// const kineticEnergy: Scalar = Vector.allocate();
|
||||||
|
function microFlow(dt: MicroDuration) {
|
||||||
|
// position = position + dt*velocity
|
||||||
|
Vector.addScaledByConstant(position, position, dt, velocity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppose that we need to flow for 43 miliseconds, and dt is 10 miliseconds.
|
||||||
|
// Then we do micro-flow 4 times totalling 40 miliseconds. We don't do micro-flow for 3 seconds
|
||||||
|
// - that could cause problems with integration.
|
||||||
|
// Instead we remember this leftover time, so the next time we flow, we add this leftover time to the total time to flow.
|
||||||
|
let leftover_time: Duration = 0;
|
||||||
|
// Integrates the micro-flow, updates global time, and recomputes the observables.
|
||||||
|
function flow(t: Duration) {
|
||||||
|
// TODO: They way we compute leftover time is not so great. There must be a better way.
|
||||||
|
const end_time = time + t + leftover_time;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const next_time = time + config.dt;
|
||||||
|
if (next_time <= end_time) {
|
||||||
|
time = next_time;
|
||||||
|
microFlow(config.dt);
|
||||||
|
} else {
|
||||||
|
// assume at the start
|
||||||
|
// dt = 10 ms
|
||||||
|
//
|
||||||
|
// time = 0
|
||||||
|
// leftover_time = 0
|
||||||
|
// t = 43 ms
|
||||||
|
// then here
|
||||||
|
// next_time = 50 ms
|
||||||
|
// time = 40 ms
|
||||||
|
// end_time = 43 ms
|
||||||
|
// so actually we have 3 miliseconds leftover, i.e. we set
|
||||||
|
leftover_time = end_time - time;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
observables,
|
||||||
|
flow,
|
||||||
|
refreshObservables,
|
||||||
|
totalTime() { return time; },
|
||||||
|
reset,
|
||||||
|
capabilities: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
96
src/systems/simple_kinematics_solid.tsx
Normal file
96
src/systems/simple_kinematics_solid.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { Config, State, Observables, SimpleKinematicsSystem } from "./simple_kinematics";
|
||||||
|
import { Duration } from "../helpers";
|
||||||
|
import { createSignal, onMount } from "solid-js";
|
||||||
|
import { ObservableEntry, ObservablesPanel } from "../ui_helpers";
|
||||||
|
import { Draw } from "../draw";
|
||||||
|
import { useSimulation } from "../useSimulation";
|
||||||
|
|
||||||
|
function entries(obs: Observables): ObservableEntry[] {
|
||||||
|
return [
|
||||||
|
{ type: "header", label: "General" },
|
||||||
|
{ type: "scalar", label: "Particles", value: obs.particleCount },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
system: SimpleKinematicsSystem,
|
||||||
|
) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const { particleCount } = system.observables;
|
||||||
|
const { position } = system.state;
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const radius = 2;
|
||||||
|
Draw.particle(ctx, position.x[i], position.y[i], radius, `hsl(30, 100%, 50%)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleKinematicsView(props: { system: SimpleKinematicsSystem }) {
|
||||||
|
|
||||||
|
const [uiState, setUiState] = createSignal<Observables>(structuredClone(props.system.observables));
|
||||||
|
|
||||||
|
let canvasRef!: HTMLCanvasElement;
|
||||||
|
let ctx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ctx = canvasRef.getContext("2d")!;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fps, isPlaying, togglePlay } = useSimulation({
|
||||||
|
step(dt: Duration) {
|
||||||
|
props.system.flow(dt);
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
props.system.refreshObservables();
|
||||||
|
render(canvasRef, ctx, props.system);
|
||||||
|
},
|
||||||
|
sync() {
|
||||||
|
setUiState(structuredClone(props.system.observables));
|
||||||
|
},
|
||||||
|
isPlaying: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="app-layout">
|
||||||
|
<div class="cell-0"></div>
|
||||||
|
|
||||||
|
{/* === Top Controls === */}
|
||||||
|
<div class="cell-1 controls">
|
||||||
|
<button class="btn" onClick={togglePlay} style="width: 70px">
|
||||||
|
{isPlaying() ? "Pause" : "Play"}
|
||||||
|
</button>
|
||||||
|
<button class="btn" onClick={props.system.reset} style="width: 70px">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="top-stats">
|
||||||
|
FPS: {fps()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cell-2"></div>
|
||||||
|
|
||||||
|
{/* === Reserved for future brushes === */}
|
||||||
|
<div class="cell-3 brushes-panel">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Canvas === */}
|
||||||
|
<div class="cell-4 canvas-container">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={800} height={600}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* === Observables === */}
|
||||||
|
<div class="cell-5 side-panel">
|
||||||
|
<ObservablesPanel data={ entries(uiState()) } />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
83
src/ui_helpers.tsx
Normal file
83
src/ui_helpers.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { formatNumber } from "./helpers";
|
||||||
|
import { For, Switch, Match } from "solid-js";
|
||||||
|
|
||||||
|
type ScalarEntry = {
|
||||||
|
type: "scalar",
|
||||||
|
label: string,
|
||||||
|
value: number,
|
||||||
|
}
|
||||||
|
type VectorEntry = {
|
||||||
|
type: "vector",
|
||||||
|
label: string,
|
||||||
|
vector: [number, number]
|
||||||
|
components: [string, string],
|
||||||
|
}
|
||||||
|
type HeaderEntry = {
|
||||||
|
type: "header",
|
||||||
|
label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObservableEntry =
|
||||||
|
| ScalarEntry
|
||||||
|
| VectorEntry
|
||||||
|
| HeaderEntry
|
||||||
|
|
||||||
|
|
||||||
|
export function ObservablesPanel(props: { data: ObservableEntry[] }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="observables-container"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
// 1: Main Label | 2: Sub-label X | 3: Value X | 4: Sub-label Y | 5: Value Y
|
||||||
|
"grid-template-columns": "auto min-content 1fr min-content 1fr",
|
||||||
|
"align-items": "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={ props.data }>
|
||||||
|
{ (item: ObservableEntry) => (
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Match when={ item.type === "header" }>
|
||||||
|
<div
|
||||||
|
class="observables-header"
|
||||||
|
style="grid-column: 1 / -1"
|
||||||
|
>
|
||||||
|
{ item.label }
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<Match when={ item.type === "scalar" }>
|
||||||
|
<div class="observables-row scalar-row" style="display: contents">
|
||||||
|
<span class="observables-label">{ item.label }</span>
|
||||||
|
<span class="observables-value scalar-value" style="grid-column: 2 / -1">
|
||||||
|
{ formatNumber((item as ScalarEntry).value) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<Match when={ item.type === "vector" }>
|
||||||
|
<div class="observables-row vector-row" style="display: contents">
|
||||||
|
<span class="observables-label">{ item.label }</span>
|
||||||
|
|
||||||
|
<span class="observables-component-label">
|
||||||
|
{ (item as VectorEntry).components[0] }:
|
||||||
|
</span>
|
||||||
|
<span class="observables-value">
|
||||||
|
{ formatNumber((item as VectorEntry).vector[0]) }
|
||||||
|
</span>
|
||||||
|
<span class="observables-component-label">
|
||||||
|
{ (item as VectorEntry).components[1] }:
|
||||||
|
</span>
|
||||||
|
<span class="observables-value">
|
||||||
|
{ formatNumber((item as VectorEntry).vector[1]) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
0
src/usePointer.ts
Normal file
0
src/usePointer.ts
Normal file
88
src/useSimulation.ts
Normal file
88
src/useSimulation.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { createSignal, onCleanup } from "solid-js";
|
||||||
|
import { Duration } from "./helpers";
|
||||||
|
|
||||||
|
export type SimConfig = {
|
||||||
|
step(dt: Duration): void, // The physics step
|
||||||
|
render(): void, // The pure re-rendering
|
||||||
|
sync(): void, // The UI update (throttled)
|
||||||
|
isPlaying: boolean, // Should the simulation start immediately, or should it be paused?
|
||||||
|
syncEveryFrames?: number, // Default 10
|
||||||
|
panicCap?: number, // Default 300
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncEveryFramesDefault: number = 10;
|
||||||
|
const panicCapDefault: number = 300;
|
||||||
|
|
||||||
|
// isPlaying == True:
|
||||||
|
// - the control of the rendering/stepping/syncing is in the hands of the simulation
|
||||||
|
// - client of the simulation has no control
|
||||||
|
// isPlaying == False:
|
||||||
|
// - the control of the rendering/stepping/syncing is in the hands of the client
|
||||||
|
//
|
||||||
|
// TODO: Perhaps you should create like two types for such objects, and think of them linearly...
|
||||||
|
// - one can become the other... but both can't really exist at once... Where are Linear Types when I need them?
|
||||||
|
export function useSimulation(config: SimConfig) {
|
||||||
|
const [fps, setFps] = createSignal(0);
|
||||||
|
const [isPlaying, setIsPlaying] = createSignal(config.isPlaying);
|
||||||
|
|
||||||
|
const { step, render, sync } = config;
|
||||||
|
const syncEveryFrames = config.syncEveryFrames ?? syncEveryFramesDefault;
|
||||||
|
const panicCap = config.panicCap ?? panicCapDefault;
|
||||||
|
|
||||||
|
let frameId: number = 0;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
let frameCounter = 0;
|
||||||
|
|
||||||
|
function loop(now: number) {
|
||||||
|
frameCounter += 1;
|
||||||
|
|
||||||
|
let dt = now - lastTime;
|
||||||
|
lastTime = now;
|
||||||
|
if (dt > panicCap) dt = panicCap;
|
||||||
|
|
||||||
|
step(dt);
|
||||||
|
render();
|
||||||
|
|
||||||
|
if (frameCounter % syncEveryFrames === 0) {
|
||||||
|
setFps(Math.round(1000 / dt));
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
frameId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (isPlaying()) {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
// Reset lastTime so we don't calculate a massive time jump from while it was paused
|
||||||
|
lastTime = performance.now();
|
||||||
|
frameId = requestAnimationFrame(loop);
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSyncedRender() {
|
||||||
|
if (!isPlaying()) {
|
||||||
|
render();
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSimpleRender() {
|
||||||
|
if (!isPlaying()) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaying()) {
|
||||||
|
frameId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelAnimationFrame(frameId)
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fps, isPlaying, togglePlay, requestSyncedRender, requestSimpleRender };
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Strictness */
|
||||||
|
"strict": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
BIN
units_and_types_0.kra
Executable file
BIN
units_and_types_0.kra
Executable file
Binary file not shown.
BIN
units_and_types_0.kra~
Executable file
BIN
units_and_types_0.kra~
Executable file
Binary file not shown.
17
vite.config.ts
Normal file
17
vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import solidPlugin from 'vite-plugin-solid';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [solidPlugin()],
|
||||||
|
build: {
|
||||||
|
target: 'esnext',
|
||||||
|
minify: 'esbuild',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Required for SharedArrayBuffer or multi-threading
|
||||||
|
headers: {
|
||||||
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue