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