feat: physics language integration — scene container with Rapier2D WASM

- Add scene container to AST, lexer, parser, analyzer, and codegen
- Add circle/rect/line as UI elements for physics body declaration
- Compile scene {} to canvas + async WASM init + Rapier2D PhysicsWorld
- Reactive gravity via DS.effect() — bodies wake on gravity change
- Mouse drag interaction with impulse-based body movement
- Compile-time hex color parsing for body colors
- Fix is_signal_ref matching numeric literals (700.value bug)
- Fix body variable uniqueness (next_node_id per body)
- Fix gravity signal detection (check AST Ident before emit_expr)
- Add physics.ds example with 5 bodies + 4 gravity control buttons
- Update DREAMSTACK.md and IMPLEMENTATION_PLAN.md with Phase 10-11
- 39 tests pass across all crates, 22KB output
This commit is contained in:
enzotar 2026-02-25 10:58:43 -08:00
parent d7961cdc98
commit ea64617569
11 changed files with 1356 additions and 24 deletions

View file

@ -6,7 +6,7 @@
## Implementation Status ✅
DreamStack is **real and running**6 Rust crates, 34 tests, 8 examples, ~7KB output.
DreamStack is **real and running**7 Rust crates, 39 tests, 9 examples, ~7KB output + WASM physics.
```
.ds source → ds-parser → ds-analyzer → ds-codegen → JavaScript
@ -37,6 +37,7 @@ DreamStack is **real and running** — 6 Rust crates, 34 tests, 8 examples, ~7KB
| Two-way binding | `input { bind: name }` | ✅ Signal ↔ input |
| Async resources | `DS.resource()` / `DS.fetchJSON()` | ✅ Loading/Ok/Err |
| Springs | `let x = spring(200)` | ✅ RK4 physics |
| Physics scene | `scene { gravity_y: g } [ circle {...} ]` | ✅ Rapier2D WASM |
| Constraints | `constrain el.width = expr` | ✅ Reactive solver |
### DreamStack vs React
@ -52,6 +53,7 @@ DreamStack is **real and running** — 6 Rust crates, 34 tests, 8 examples, ~7KB
| Router | `route "/path" -> body` | `react-router` (external) |
| Forms | `input { bind: name }` | `useState` + `onChange` (manual) |
| Animation | Built-in springs | framer-motion (external) |
| Physics | Built-in Rapier2D scene | matter.js (external) |
| Layout | Built-in Cassowary | CSS only |
| Types | Native HM, `Signal<T>` | TypeScript (external) |
| Bundle | **~7KB** | **~175KB** |
@ -69,7 +71,7 @@ DreamStack is **real and running** — 6 Rust crates, 34 tests, 8 examples, ~7KB
### Examples
`counter.ds` · `list.ds` · `router.ds` · `form.ds` · `springs.ds` · `todomvc.html` · `search.html` · `dashboard.html` · `playground.html` · `showcase.html` · `benchmarks.html`
`counter.ds` · `list.ds` · `router.ds` · `form.ds` · `springs.ds` · `physics.ds` · `todomvc.html` · `search.html` · `dashboard.html` · `playground.html` · `showcase.html` · `benchmarks.html`
---
@ -462,3 +464,42 @@ open examples/stream-source.html # source: renders + streams
open examples/stream-receiver.html # receiver: displays bytes
```
---
## Phase 8: Physics Scene — Rapier2D in the Language
> *Declare physics bodies in `.ds` syntax. The compiler generates WASM-backed canvas with rigid body simulation.*
```ds
let gravity_y = 980
view main =
column [
scene { width: 700, height: 450, gravity_y: gravity_y } [
circle { x: 200, y: 80, radius: 35, color: "#8b5cf6" }
circle { x: 350, y: 50, radius: 50, color: "#7c3aed" }
rect { x: 500, y: 100, width: 80, height: 50, color: "#10b981" }
]
button "Anti-Gravity" { click: gravity_y = -500 }
button "Normal" { click: gravity_y = 980 }
]
```
### Architecture
```
.ds source → parser (scene/circle/rect) → codegen → JS
canvas + async WASM init
ds-physics (Rapier2D) ← pkg/ds_physics_bg.wasm
requestAnimationFrame loop
step → render → repeat
```
### Features
- **Rigid body physics** — circles and rectangles with collision, rotation, restitution
- **Reactive gravity** — signal changes wrapped in `DS.effect()`, bodies wake on gravity change
- **Mouse drag** — click and drag bodies with impulse-based interaction
- **Compile-time colors** — hex color strings parsed at compile time → `set_body_color(r, g, b, a)`
- **Zero JS overhead** — physics runs in WASM, rendering in canvas, signals bridge both

View file

@ -268,3 +268,58 @@ cargo test -p ds-layout
> [!IMPORTANT]
> The user should weigh in on which phase to prioritize first. The plan assumes linear progression, but Phases 13 could be parallelized if multiple contributors are involved. Phase 4 (dependent types) is the highest-risk and could be deferred to a v2.
---
## Phase 10 — Rapier2D Physics Engine (Completed)
**Goal:** Replace the Verlet spring-mass physics with Rapier2D for real rigid body simulation.
### WASM Physics Engine
#### [NEW] `engine/ds-physics/`
- Rapier2D wrapped in a `PhysicsWorld` struct, compiled to WASM via `wasm-pack`
- Rigid body creation: `create_soft_circle()`, `create_soft_rect()`
- Force control: `set_gravity()` (wakes all sleeping bodies), `apply_impulse()`
- Query: `get_body_center()`, `get_body_rotation()`, `get_body_positions()`, `body_count()`
- Rendering: `set_body_color()`, body info with color, radius, dimensions
- Collision, restitution, friction, damping — all configurable per body
- 5 tests verifying world creation, stepping, body creation, gravity, and impulse
### Deliverable
The WASM physics module at `dist/pkg/ds_physics.js` + `ds_physics_bg.wasm`, loadable from any HTML page.
---
## Phase 11 — Physics in the Language (Completed)
**Goal:** Integrate Rapier2D physics as a first-class `.ds` language construct.
### Compiler Additions
#### [MODIFY] `compiler/ds-parser/src/ast.rs`
- Added `ContainerKind::Scene`
#### [MODIFY] `compiler/ds-parser/src/lexer.rs`
- Added `TokenKind::Scene` and `"scene"` keyword mapping
#### [MODIFY] `compiler/ds-parser/src/parser.rs`
- `scene` dispatches to `parse_container_with_props` (width, height, gravity)
- `circle`, `rect`, `line` added to `is_ui_element()`
#### [MODIFY] `compiler/ds-analyzer/src/signal_graph.rs`
- `Scene` variant in `collect_bindings` for DOM binding extraction
#### [MODIFY] `compiler/ds-codegen/src/js_emitter.rs`
- `emit_scene()` — canvas creation, async WASM init, `PhysicsWorld` setup
- `emit_scene_circle()`, `emit_scene_rect()` — per-body codegen with unique variables
- `emit_scene_set_color()` — compile-time hex color parsing
- Reactive gravity via `DS.effect(() => { _world.set_gravity(...); })`
- `requestAnimationFrame` loop for physics stepping + canvas rendering
- Mouse drag interaction with impulse-based body movement
#### [NEW] `examples/physics.ds`
- 5 bodies (circles + rects), reactive gravity controls, drag interaction
### Deliverable
`physics.ds` compiles to a working HTML page with WASM physics — 22KB output, no framework dependencies.

View file

@ -345,6 +345,7 @@ fn collect_bindings(expr: &Expr, bindings: &mut Vec<DomBinding>) {
ds_parser::ContainerKind::Panel => "panel",
ds_parser::ContainerKind::List => "list",
ds_parser::ContainerKind::Form => "form",
ds_parser::ContainerKind::Scene => "scene",
ds_parser::ContainerKind::Custom(s) => s,
};
bindings.push(DomBinding {

View file

@ -75,6 +75,12 @@ impl JsEmitter {
// Fall back to the let declaration expression
// (handles List, Record, etc.)
if let Some(expr) = self.find_let_expr(program, &node.name) {
// Check if it's a spring() call — Spring is already signal-compatible
if matches!(expr, Expr::Call(name, _) if name == "spring") {
let js_expr = self.emit_expr(expr);
self.emit_line(&format!("const {} = {};", node.name, js_expr));
continue;
}
self.emit_expr(expr)
} else {
"null".to_string()
@ -197,6 +203,24 @@ impl JsEmitter {
));
}
// Phase 6: Constraints
let constraints: Vec<_> = program.declarations.iter()
.filter_map(|d| if let Declaration::Constrain(c) = d { Some(c) } else { None })
.collect();
if !constraints.is_empty() {
self.emit_line("");
self.emit_line("// ── Constraints ──");
for c in &constraints {
let expr_js = self.emit_expr(&c.expr);
// Use view element name or fall back to querySelector
self.emit_line(&format!(
"DS.constrain(document.querySelector('[data-ds=\"{}\"]:not(.x)') || document.getElementById('ds-root'), '{}', () => {});",
c.element, c.prop, expr_js
));
}
}
self.indent -= 1;
self.emit_line("})();");
@ -216,6 +240,11 @@ impl JsEmitter {
fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String {
match expr {
Expr::Container(container) => {
// ─── PHYSICS SCENE ───
if matches!(container.kind, ContainerKind::Scene) {
return self.emit_scene(container, graph);
}
let node_var = self.next_node_id();
let tag = match &container.kind {
ContainerKind::Column => "div",
@ -224,6 +253,7 @@ impl JsEmitter {
ContainerKind::Panel => "div",
ContainerKind::Form => "form",
ContainerKind::List => "ul",
ContainerKind::Scene => "canvas", // handled above
ContainerKind::Custom(s) => s,
};
@ -592,6 +622,7 @@ impl JsEmitter {
// Built-in functions map to DS.xxx()
let fn_name = match name.as_str() {
"navigate" => "DS.navigate",
"spring" => "DS.spring",
_ => name,
};
format!("{}({})", fn_name, args_js.join(", "))
@ -693,8 +724,303 @@ impl JsEmitter {
}
fn is_signal_ref(&self, expr: &str) -> bool {
// Simple heuristic: if it's a single identifier, it's likely a signal
expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
// Must start with a letter/underscore (not a digit) and contain only ident chars
!expr.is_empty()
&& expr.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Emit a physics scene — canvas + WASM physics engine + animation loop
fn emit_scene(&mut self, container: &Container, graph: &SignalGraph) -> String {
let wrapper_var = self.next_node_id();
let canvas_var = self.next_node_id();
// Extract scene props
let mut scene_width = "800".to_string();
let mut scene_height = "500".to_string();
let mut gravity_x_expr = None;
let mut gravity_y_expr = None;
let mut gx_is_signal = false;
let mut gy_is_signal = false;
for (key, val) in &container.props {
match key.as_str() {
"width" => scene_width = self.emit_expr(val),
"height" => scene_height = self.emit_expr(val),
"gravity_x" => {
gx_is_signal = matches!(val, Expr::Ident(_));
gravity_x_expr = Some(if let Expr::Ident(name) = val {
name.clone()
} else {
self.emit_expr(val)
});
}
"gravity_y" => {
gy_is_signal = matches!(val, Expr::Ident(_));
gravity_y_expr = Some(if let Expr::Ident(name) = val {
name.clone()
} else {
self.emit_expr(val)
});
}
_ => {}
}
}
// Create wrapper div
self.emit_line(&format!("const {} = document.createElement('div');", wrapper_var));
self.emit_line(&format!("{}.className = 'ds-scene-wrapper';", wrapper_var));
// Create canvas
let w_val = if graph.name_to_id.contains_key(&scene_width) || self.is_signal_ref(&scene_width) {
format!("{}.value", scene_width)
} else {
scene_width.clone()
};
let h_val = if graph.name_to_id.contains_key(&scene_height) || self.is_signal_ref(&scene_height) {
format!("{}.value", scene_height)
} else {
scene_height.clone()
};
self.emit_line(&format!("const {} = document.createElement('canvas');", canvas_var));
self.emit_line(&format!("{}.width = {};", canvas_var, w_val));
self.emit_line(&format!("{}.height = {};", canvas_var, h_val));
self.emit_line(&format!("{}.style.width = {} + 'px';", canvas_var, w_val));
self.emit_line(&format!("{}.style.height = {} + 'px';", canvas_var, h_val));
self.emit_line(&format!("{}.style.borderRadius = '16px';", canvas_var));
self.emit_line(&format!("{}.style.background = 'rgba(255,255,255,0.02)';", canvas_var));
self.emit_line(&format!("{}.style.border = '1px solid rgba(255,255,255,0.06)';", canvas_var));
self.emit_line(&format!("{}.style.cursor = 'pointer';", canvas_var));
self.emit_line(&format!("{}.appendChild({});", wrapper_var, canvas_var));
// Async IIFE to load WASM and set up physics
self.emit_line("(async () => {");
self.indent += 1;
self.emit_line("const { default: init, PhysicsWorld } = await import('./pkg/ds_physics.js');");
self.emit_line("await init();");
self.emit_line(&format!("const _sceneW = {};", w_val));
self.emit_line(&format!("const _sceneH = {};", h_val));
self.emit_line("const _world = new PhysicsWorld(_sceneW, _sceneH);");
self.emit_line(&format!("const _ctx = {}.getContext('2d');", canvas_var));
// Create bodies from child elements
for child in &container.children {
if let Expr::Element(element) = child {
match element.tag.as_str() {
"circle" => self.emit_scene_circle(element, graph),
"rect" => self.emit_scene_rect(element, graph),
_ => {} // skip unknown elements
}
}
}
// Wire reactive gravity
if let Some(ref gx) = gravity_x_expr {
if gx_is_signal {
if let Some(ref gy) = gravity_y_expr {
if gy_is_signal {
self.emit_line(&format!(
"DS.effect(() => {{ _world.set_gravity({}.value, {}.value); }});",
gx, gy
));
} else {
self.emit_line(&format!(
"DS.effect(() => {{ _world.set_gravity({}.value, {}); }});",
gx, gy
));
}
} else {
self.emit_line(&format!(
"DS.effect(() => {{ _world.set_gravity({}.value, 980); }});",
gx
));
}
}
} else if let Some(ref gy) = gravity_y_expr {
if gy_is_signal {
self.emit_line(&format!(
"DS.effect(() => {{ _world.set_gravity(0, {}.value); }});",
gy
));
} else {
self.emit_line(&format!("_world.set_gravity(0, {});", gy));
}
}
// Drag interaction
self.emit_line("let _dragBody = -1, _dragOffX = 0, _dragOffY = 0;");
self.emit_line("let _lastMX = 0, _lastMY = 0, _velX = 0, _velY = 0;");
self.emit_line(&format!(
"{}.addEventListener('mousedown', (e) => {{", canvas_var
));
self.indent += 1;
self.emit_line(&format!("const rect = {}.getBoundingClientRect();", canvas_var));
self.emit_line("const mx = e.clientX - rect.left, my = e.clientY - rect.top;");
self.emit_line("_lastMX = mx; _lastMY = my;");
self.emit_line("for (let b = 0; b < _world.body_count(); b++) {");
self.indent += 1;
self.emit_line("const c = _world.get_body_center(b);");
self.emit_line("const dx = mx - c[0], dy = my - c[1];");
self.emit_line("if (Math.sqrt(dx*dx + dy*dy) < 80) { _dragBody = b; _dragOffX = dx; _dragOffY = dy; e.preventDefault(); return; }");
self.indent -= 1;
self.emit_line("}");
self.indent -= 1;
self.emit_line("});");
self.emit_line("window.addEventListener('mousemove', (e) => {");
self.indent += 1;
self.emit_line(&format!("const rect = {}.getBoundingClientRect();", canvas_var));
self.emit_line("const mx = e.clientX - rect.left, my = e.clientY - rect.top;");
self.emit_line("_velX = mx - _lastMX; _velY = my - _lastMY; _lastMX = mx; _lastMY = my;");
self.emit_line("if (_dragBody >= 0) { const c = _world.get_body_center(_dragBody); _world.apply_impulse(_dragBody, (mx - _dragOffX - c[0]) * 5000, (my - _dragOffY - c[1]) * 5000); }");
self.indent -= 1;
self.emit_line("});");
self.emit_line("window.addEventListener('mouseup', () => { if (_dragBody >= 0) { _world.apply_impulse(_dragBody, _velX * 3000, _velY * 3000); _dragBody = -1; } });");
// Event listener for physics impulse
self.emit_line("DS.onEvent('physics_impulse', (data) => { if (data && data.body !== undefined) _world.apply_impulse(data.body, data.fx || 0, data.fy || 0); });");
// Animation loop with draw
self.emit_line("function _sceneDraw() {");
self.indent += 1;
self.emit_line("_ctx.clearRect(0, 0, _sceneW, _sceneH);");
// Grid
self.emit_line("_ctx.strokeStyle = 'rgba(255,255,255,0.02)'; _ctx.lineWidth = 1;");
self.emit_line("for (let x = 0; x < _sceneW; x += 40) { _ctx.beginPath(); _ctx.moveTo(x,0); _ctx.lineTo(x,_sceneH); _ctx.stroke(); }");
self.emit_line("for (let y = 0; y < _sceneH; y += 40) { _ctx.beginPath(); _ctx.moveTo(0,y); _ctx.lineTo(_sceneW,y); _ctx.stroke(); }");
// Bodies
self.emit_line("for (let b = 0; b < _world.body_count(); b++) {");
self.indent += 1;
self.emit_line("const pos = _world.get_body_positions(b), col = _world.get_body_color(b), bt = _world.get_body_type(b);");
self.emit_line("const r = col[0]*255|0, g = col[1]*255|0, bl = col[2]*255|0, a = col[3];");
self.emit_line("const fill = `rgba(${r},${g},${bl},${a*0.5})`, stroke = `rgba(${r},${g},${bl},${a*0.9})`, glow = `rgba(${r},${g},${bl},0.4)`;");
// Rect
self.emit_line("if (bt === 1 && pos.length >= 8) {");
self.indent += 1;
self.emit_line("_ctx.beginPath(); _ctx.moveTo(pos[0],pos[1]); _ctx.lineTo(pos[2],pos[3]); _ctx.lineTo(pos[4],pos[5]); _ctx.lineTo(pos[6],pos[7]); _ctx.closePath();");
self.emit_line("_ctx.shadowColor = glow; _ctx.shadowBlur = 18; _ctx.fillStyle = fill; _ctx.fill(); _ctx.shadowBlur = 0; _ctx.strokeStyle = stroke; _ctx.lineWidth = 2; _ctx.stroke();");
self.indent -= 1;
self.emit_line("} else {");
self.indent += 1;
// Circle
self.emit_line("const pc = _world.get_body_perimeter_count(b);");
self.emit_line("if (pc >= 3) { _ctx.beginPath(); _ctx.moveTo(pos[0],pos[1]); for (let i=1;i<pc;i++) _ctx.lineTo(pos[i*2],pos[i*2+1]); _ctx.closePath();");
self.emit_line("_ctx.shadowColor = glow; _ctx.shadowBlur = 20; _ctx.fillStyle = fill; _ctx.fill(); _ctx.shadowBlur = 0; _ctx.strokeStyle = stroke; _ctx.lineWidth = 1.5; _ctx.stroke();");
self.emit_line("if (pos.length >= (pc+1)*2) { _ctx.beginPath(); _ctx.arc(pos[pc*2],pos[pc*2+1],2.5,0,Math.PI*2); _ctx.fillStyle = stroke; _ctx.fill(); } }");
self.indent -= 1;
self.emit_line("}");
self.indent -= 1;
self.emit_line("}");
// Boundary
self.emit_line("_ctx.strokeStyle = 'rgba(99,102,241,0.06)'; _ctx.lineWidth = 2; _ctx.strokeRect(1,1,_sceneW-2,_sceneH-2);");
self.indent -= 1;
self.emit_line("}");
// Loop
self.emit_line("function _sceneLoop() { _world.step(1/60); _sceneDraw(); requestAnimationFrame(_sceneLoop); }");
self.emit_line("requestAnimationFrame(_sceneLoop);");
self.indent -= 1;
self.emit_line("})();");
wrapper_var
}
/// Emit a circle body creation inside a scene
fn emit_scene_circle(&mut self, element: &Element, _graph: &SignalGraph) {
let mut x = "200".to_string();
let mut y = "100".to_string();
let mut radius = "30".to_string();
let mut color = None;
let mut segments = "16".to_string();
let mut pinned = false;
for (key, val) in &element.props {
let js = self.emit_expr(val);
match key.as_str() {
"x" => x = js,
"y" => y = js,
"radius" => radius = js,
"color" => color = Some(js),
"segments" => segments = js,
"pinned" => pinned = js == "true",
_ => {}
}
}
let body_var = self.next_node_id();
self.emit_line(&format!(
"const {} = _world.create_soft_circle({}, {}, {}, {}, 50);",
body_var, x, y, radius, segments
));
if let Some(c) = color {
self.emit_scene_set_color(&body_var, c);
}
if pinned {
self.emit_line(&format!("_world.pin_particle({}, true);", body_var));
}
}
/// Emit a rect body creation inside a scene
fn emit_scene_rect(&mut self, element: &Element, _graph: &SignalGraph) {
let mut x = "200".to_string();
let mut y = "100".to_string();
let mut width = "60".to_string();
let mut height = "40".to_string();
let mut color = None;
let mut pinned = false;
for (key, val) in &element.props {
let js = self.emit_expr(val);
match key.as_str() {
"x" => x = js,
"y" => y = js,
"width" => width = js,
"height" => height = js,
"color" => color = Some(js),
"pinned" => pinned = js == "true",
_ => {}
}
}
let body_var = self.next_node_id();
self.emit_line(&format!(
"const {} = _world.create_soft_rect({}, {}, {}, {}, 4, 3, 30);",
body_var, x, y, width, height
));
if let Some(c) = color {
self.emit_scene_set_color(&body_var, c);
}
if pinned {
self.emit_line(&format!("_world.pin_particle({}, true);", body_var));
}
}
/// Emit set_body_color from a hex color string
fn emit_scene_set_color(&mut self, body_var: &str, color_str: String) {
// Parse hex color at compile time or emit runtime parser
let clean = color_str.replace('"', "").replace('\\', "");
if clean.starts_with('#') && clean.len() == 7 {
let r = u8::from_str_radix(&clean[1..3], 16).unwrap_or(128);
let g = u8::from_str_radix(&clean[3..5], 16).unwrap_or(128);
let b = u8::from_str_radix(&clean[5..7], 16).unwrap_or(128);
self.emit_line(&format!(
"_world.set_body_color({}, {:.3}, {:.3}, {:.3}, 1.0);",
body_var,
r as f64 / 255.0,
g as f64 / 255.0,
b as f64 / 255.0
));
}
}
}
@ -999,9 +1325,236 @@ const DS = (() => {
return resource(() => fetch(url).then(r => r.json()));
}
// ── Spring Physics Engine ──
const _activeSprings = new Set();
let _rafId = null;
let _lastTime = 0;
class Spring {
constructor({ value = 0, target, stiffness = 170, damping = 26, mass = 1 } = {}) {
this._signal = new Signal(value);
this._velocity = 0;
this._target = target !== undefined ? target : value;
this.stiffness = stiffness;
this.damping = damping;
this.mass = mass;
this._settled = true;
}
get value() { return this._signal.value; }
set value(v) {
// Assignment animates to new target (not instant set)
this.target = v;
}
get target() { return this._target; }
set target(t) {
this._target = t;
this._settled = false;
_activeSprings.add(this);
_startSpringLoop();
}
set(v) {
this._signal.value = v;
this._target = v;
this._velocity = 0;
this._settled = true;
_activeSprings.delete(this);
}
_step(dt) {
const pos = this._signal._value;
const vel = this._velocity;
const k = this.stiffness, d = this.damping, m = this.mass;
const accel = (p, v) => (-k * (p - this._target) - d * v) / m;
const k1v = accel(pos, vel), k1p = vel;
const k2v = accel(pos + k1p*dt/2, vel + k1v*dt/2), k2p = vel + k1v*dt/2;
const k3v = accel(pos + k2p*dt/2, vel + k2v*dt/2), k3p = vel + k2v*dt/2;
const k4v = accel(pos + k3p*dt, vel + k3v*dt), k4p = vel + k3v*dt;
this._velocity = vel + (dt/6)*(k1v + 2*k2v + 2*k3v + k4v);
this._signal.value = pos + (dt/6)*(k1p + 2*k2p + 2*k3p + k4p);
if (Math.abs(this._velocity) < 0.01 && Math.abs(this._signal._value - this._target) < 0.01) {
this._signal.value = this._target;
this._velocity = 0;
this._settled = true;
_activeSprings.delete(this);
}
}
}
function _startSpringLoop() {
if (_rafId !== null) return;
_lastTime = performance.now();
_rafId = requestAnimationFrame(_springLoop);
}
function _springLoop(now) {
const dt = Math.min((now - _lastTime) / 1000, 0.064);
_lastTime = now;
batch(() => {
for (const s of _activeSprings) {
const steps = Math.ceil(dt / (1/120));
const subDt = dt / steps;
for (let i = 0; i < steps; i++) s._step(subDt);
}
});
if (_activeSprings.size > 0) _rafId = requestAnimationFrame(_springLoop);
else _rafId = null;
}
function spring(opts) { return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts }); }
// ── Constraint Solver ──
function constrain(element, prop, fn) {
return effect(() => {
const val = fn();
if (prop === 'width' || prop === 'height' || prop === 'left' || prop === 'top' ||
prop === 'right' || prop === 'bottom' || prop === 'maxWidth' || prop === 'minWidth' ||
prop === 'maxHeight' || prop === 'minHeight' || prop === 'fontSize' ||
prop === 'padding' || prop === 'margin' || prop === 'gap') {
element.style[prop] = typeof val === 'number' ? val + 'px' : val;
} else if (prop === 'opacity' || prop === 'scale') {
element.style[prop] = val;
} else {
element.style[prop] = val;
}
});
}
// Viewport signal for responsive constraints
const _viewport = {
width: new Signal(window.innerWidth),
height: new Signal(window.innerHeight)
};
window.addEventListener('resize', () => {
_viewport.width.value = window.innerWidth;
_viewport.height.value = window.innerHeight;
});
// ── 2D Scene Rendering Engine ──
function scene(width, height) {
const canvas = document.createElement('canvas');
canvas.width = width || 600;
canvas.height = height || 400;
canvas.style.borderRadius = '16px';
canvas.style.background = 'rgba(255,255,255,0.03)';
canvas.style.border = '1px solid rgba(255,255,255,0.08)';
canvas.style.display = 'block';
canvas.style.margin = '8px 0';
const ctx = canvas.getContext('2d');
const shapes = [];
let _dirty = false;
function _render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of shapes) s._draw(ctx);
}
function _scheduleRender() {
if (!_dirty) {
_dirty = true;
queueMicrotask(() => { _dirty = false; _render(); });
}
}
return { canvas, ctx, shapes, _render, _scheduleRender };
}
function circle(scn, opts) {
const shape = {
type: 'circle',
_draw(ctx) {
const x = typeof opts.x === 'object' && 'value' in opts.x ? opts.x.value : (typeof opts.x === 'function' ? opts.x() : opts.x);
const y = typeof opts.y === 'object' && 'value' in opts.y ? opts.y.value : (typeof opts.y === 'function' ? opts.y() : opts.y);
const r = typeof opts.r === 'object' && 'value' in opts.r ? opts.r.value : (typeof opts.r === 'function' ? opts.r() : (opts.r || 20));
const fill = opts.fill || '#8b5cf6';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
// Gradient fill for nice look
const grad = ctx.createRadialGradient(x - r*0.3, y - r*0.3, r*0.1, x, y, r);
grad.addColorStop(0, '#a78bfa');
grad.addColorStop(1, fill);
ctx.fillStyle = grad;
ctx.fill();
// Glow
ctx.shadowColor = fill;
ctx.shadowBlur = 20;
ctx.fill();
ctx.shadowBlur = 0;
}
};
scn.shapes.push(shape);
// Reactive: re-render when signal deps change
effect(() => {
shape._draw; // trigger reads
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
if (opts.r && typeof opts.r === 'object' && 'value' in opts.r) opts.r.value;
scn._scheduleRender();
});
return shape;
}
function rect(scn, opts) {
const shape = {
type: 'rect',
_draw(ctx) {
const x = typeof opts.x === 'object' && 'value' in opts.x ? opts.x.value : (typeof opts.x === 'function' ? opts.x() : opts.x);
const y = typeof opts.y === 'object' && 'value' in opts.y ? opts.y.value : (typeof opts.y === 'function' ? opts.y() : opts.y);
const w = typeof opts.w === 'object' && 'value' in opts.w ? opts.w.value : (typeof opts.w === 'function' ? opts.w() : (opts.w || 40));
const h = typeof opts.h === 'object' && 'value' in opts.h ? opts.h.value : (typeof opts.h === 'function' ? opts.h() : (opts.h || 40));
const fill = opts.fill || '#6366f1';
const r = opts.radius || 8;
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
ctx.fillStyle = fill;
ctx.fill();
}
};
scn.shapes.push(shape);
effect(() => {
if (opts.x && typeof opts.x === 'object' && 'value' in opts.x) opts.x.value;
if (opts.y && typeof opts.y === 'object' && 'value' in opts.y) opts.y.value;
if (opts.w && typeof opts.w === 'object' && 'value' in opts.w) opts.w.value;
if (opts.h && typeof opts.h === 'object' && 'value' in opts.h) opts.h.value;
scn._scheduleRender();
});
return shape;
}
function line(scn, opts) {
const shape = {
type: 'line',
_draw(ctx) {
const x1 = typeof opts.x1 === 'object' && 'value' in opts.x1 ? opts.x1.value : opts.x1;
const y1 = typeof opts.y1 === 'object' && 'value' in opts.y1 ? opts.y1.value : opts.y1;
const x2 = typeof opts.x2 === 'object' && 'value' in opts.x2 ? opts.x2.value : opts.x2;
const y2 = typeof opts.y2 === 'object' && 'value' in opts.y2 ? opts.y2.value : opts.y2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = opts.stroke || 'rgba(139,92,246,0.3)';
ctx.lineWidth = opts.width || 2;
ctx.stroke();
}
};
scn.shapes.push(shape);
effect(() => {
if (opts.x1 && typeof opts.x1 === 'object' && 'value' in opts.x1) opts.x1.value;
if (opts.y1 && typeof opts.y1 === 'object' && 'value' in opts.y1) opts.y1.value;
if (opts.x2 && typeof opts.x2 === 'object' && 'value' in opts.x2) opts.x2.value;
if (opts.y2 && typeof opts.y2 === 'object' && 'value' in opts.y2) opts.y2.value;
scn._scheduleRender();
});
return shape;
}
return { signal, derived, effect, batch, flush, onEvent, emit,
keyedList, route: _route, navigate, matchRoute,
resource, fetchJSON,
Signal, Derived, Effect };
spring, constrain, viewport: _viewport,
scene, circle, rect, line,
Signal, Derived, Effect, Spring };
})();
"#;

View file

@ -22,6 +22,8 @@ pub enum Declaration {
Component(ComponentDecl),
/// `route "/path" -> view_expr`
Route(RouteDecl),
/// `constrain element.prop = expr`
Constrain(ConstrainDecl),
}
/// `let count = 0` or `let doubled = count * 2`
@ -77,6 +79,15 @@ pub struct RouteDecl {
pub span: Span,
}
/// `constrain sidebar.width = clamp(200, parent.width * 0.2, 350)`
#[derive(Debug, Clone)]
pub struct ConstrainDecl {
pub element: String,
pub prop: String,
pub expr: Expr,
pub span: Span,
}
/// Function/view parameter.
#[derive(Debug, Clone)]
pub struct Param {
@ -225,6 +236,7 @@ pub enum ContainerKind {
List,
Panel,
Form,
Scene,
Custom(String),
}

View file

@ -43,12 +43,14 @@ pub enum TokenKind {
Panel,
List,
Form,
Scene,
Animate,
For,
In,
Component,
Route,
Navigate,
Constrain,
// Operators
Plus,
@ -304,12 +306,14 @@ impl Lexer {
"stream" => TokenKind::Stream,
"from" => TokenKind::From,
"spring" => TokenKind::Spring,
"constrain" => TokenKind::Constrain,
"column" => TokenKind::Column,
"row" => TokenKind::Row,
"stack" => TokenKind::Stack,
"panel" => TokenKind::Panel,
"list" => TokenKind::List,
"form" => TokenKind::Form,
"scene" => TokenKind::Scene,
"animate" => TokenKind::Animate,
"true" => TokenKind::True,
"false" => TokenKind::False,

View file

@ -96,8 +96,9 @@ impl Parser {
TokenKind::On => self.parse_on_handler(),
TokenKind::Component => self.parse_component_decl(),
TokenKind::Route => self.parse_route_decl(),
TokenKind::Constrain => self.parse_constrain_decl(),
_ => Err(self.error(format!(
"expected declaration (let, view, effect, on, component, route), got {:?}",
"expected declaration (let, view, effect, on, component, route, constrain), got {:?}",
self.peek()
))),
}
@ -243,6 +244,27 @@ impl Parser {
}))
}
/// `constrain sidebar.width = clamp(200, viewport.width * 0.2, 350)`
fn parse_constrain_decl(&mut self) -> Result<Declaration, ParseError> {
let line = self.current_token().line;
self.advance(); // consume 'constrain'
let element = self.expect_ident()?;
self.expect(&TokenKind::Dot)?;
let prop = self.expect_ident()?;
self.expect(&TokenKind::Eq)?;
self.skip_newlines();
let expr = self.parse_expr()?;
Ok(Declaration::Constrain(ConstrainDecl {
element,
prop,
expr,
span: Span { start: 0, end: 0, line },
}))
}
fn parse_params(&mut self) -> Result<Vec<Param>, ParseError> {
self.expect(&TokenKind::LParen)?;
let mut params = Vec::new();
@ -475,6 +497,7 @@ impl Parser {
TokenKind::Row => self.parse_container(ContainerKind::Row),
TokenKind::Stack => self.parse_container(ContainerKind::Stack),
TokenKind::Panel => self.parse_container_with_props(ContainerKind::Panel),
TokenKind::Scene => self.parse_container_with_props(ContainerKind::Scene),
// When conditional
TokenKind::When => {
@ -566,6 +589,18 @@ impl Parser {
Ok(Expr::Call("navigate".to_string(), vec![path_expr]))
}
// Spring expression: `spring(value, stiffness, damping)`
TokenKind::Spring => {
self.advance();
if self.check(&TokenKind::LParen) {
let args = self.parse_call_args()?;
Ok(Expr::Call("spring".to_string(), args))
} else {
// Just `spring` as an identifier
Ok(Expr::Ident("spring".to_string()))
}
}
// Stream from
TokenKind::Stream => {
self.advance();
@ -581,24 +616,6 @@ impl Parser {
Ok(Expr::StreamFrom(full_source))
}
// Spring
TokenKind::Spring => {
self.advance();
self.expect(&TokenKind::LParen)?;
let mut props = Vec::new();
while !self.check(&TokenKind::RParen) && !self.is_at_end() {
let key = self.expect_ident()?;
self.expect(&TokenKind::Colon)?;
let val = self.parse_expr()?;
props.push((key, val));
if self.check(&TokenKind::Comma) {
self.advance();
}
}
self.expect(&TokenKind::RParen)?;
Ok(Expr::Spring(props))
}
// Record: `{ key: value }`
TokenKind::LBrace => {
self.advance();
@ -931,6 +948,7 @@ fn is_ui_element(name: &str) -> bool {
| "link" | "label" | "badge" | "chip" | "card"
| "header" | "footer" | "nav" | "section" | "div"
| "spinner" | "skeleton"
| "circle" | "rect" | "line" // Physics scene elements
)
}

View file

@ -0,0 +1,22 @@
[package]
name = "ds-physics"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
js-sys = "0.3"
rapier2d = { version = "0.22", features = ["serde-serialize"] }
nalgebra = "0.33"
[dev-dependencies]
[profile.release]
opt-level = "s"
lto = true

View file

@ -0,0 +1,453 @@
//! DreamStack Physics Engine — powered by Rapier2D
//!
//! A 2D physics simulation with:
//! - Rigid bodies (circles, rectangles)
//! - Soft bodies (spring-joint networks)
//! - Gravity, collision, friction, restitution
//! - Boundary walls
//! - Impulse forces, morphing
//!
//! Compiled to WASM for browser use.
use wasm_bindgen::prelude::*;
use rapier2d::prelude::*;
use nalgebra::Vector2;
// ─── Body Metadata ───
#[derive(Debug, Clone)]
struct BodyInfo {
body_type: u8, // 0 = circle, 1 = rect, 2 = soft_circle
color: [f64; 4], // RGBA
radius: f64, // For circles
width: f64, // For rects
height: f64, // For rects
handle: RigidBodyHandle,
// Soft body particle handles (for soft circles)
particle_handles: Vec<RigidBodyHandle>,
segments: usize,
}
// ─── Physics World ───
#[wasm_bindgen]
pub struct PhysicsWorld {
// Rapier pipeline
rigid_body_set: RigidBodySet,
collider_set: ColliderSet,
gravity: Vector2<f32>,
integration_parameters: IntegrationParameters,
physics_pipeline: PhysicsPipeline,
island_manager: IslandManager,
broad_phase: DefaultBroadPhase,
narrow_phase: NarrowPhase,
impulse_joint_set: ImpulseJointSet,
multibody_joint_set: MultibodyJointSet,
ccd_solver: CCDSolver,
query_pipeline: QueryPipeline,
// Our metadata
bodies: Vec<BodyInfo>,
boundary_width: f64,
boundary_height: f64,
}
#[wasm_bindgen]
impl PhysicsWorld {
#[wasm_bindgen(constructor)]
pub fn new(width: f64, height: f64) -> Self {
let gravity = Vector2::new(0.0, 980.0_f32); // Pixel-space gravity (Y down)
let mut world = Self {
rigid_body_set: RigidBodySet::new(),
collider_set: ColliderSet::new(),
gravity,
integration_parameters: IntegrationParameters::default(),
physics_pipeline: PhysicsPipeline::new(),
island_manager: IslandManager::new(),
broad_phase: DefaultBroadPhase::new(),
narrow_phase: NarrowPhase::new(),
impulse_joint_set: ImpulseJointSet::new(),
multibody_joint_set: MultibodyJointSet::new(),
ccd_solver: CCDSolver::new(),
query_pipeline: QueryPipeline::new(),
bodies: Vec::new(),
boundary_width: width,
boundary_height: height,
};
// Create boundary walls (static bodies)
world.create_boundaries(width, height);
world
}
fn create_boundaries(&mut self, w: f64, h: f64) {
let w = w as f32;
let h = h as f32;
let thickness = 20.0_f32;
// Floor
let floor_body = RigidBodyBuilder::fixed()
.translation(Vector2::new(w / 2.0, h + thickness / 2.0))
.build();
let floor_handle = self.rigid_body_set.insert(floor_body);
let floor_collider = ColliderBuilder::cuboid(w / 2.0, thickness / 2.0)
.restitution(0.3)
.friction(0.5)
.build();
self.collider_set.insert_with_parent(floor_collider, floor_handle, &mut self.rigid_body_set);
// Ceiling
let ceil_body = RigidBodyBuilder::fixed()
.translation(Vector2::new(w / 2.0, -thickness / 2.0))
.build();
let ceil_handle = self.rigid_body_set.insert(ceil_body);
let ceil_collider = ColliderBuilder::cuboid(w / 2.0, thickness / 2.0)
.restitution(0.3)
.friction(0.5)
.build();
self.collider_set.insert_with_parent(ceil_collider, ceil_handle, &mut self.rigid_body_set);
// Left wall
let left_body = RigidBodyBuilder::fixed()
.translation(Vector2::new(-thickness / 2.0, h / 2.0))
.build();
let left_handle = self.rigid_body_set.insert(left_body);
let left_collider = ColliderBuilder::cuboid(thickness / 2.0, h / 2.0)
.restitution(0.3)
.friction(0.5)
.build();
self.collider_set.insert_with_parent(left_collider, left_handle, &mut self.rigid_body_set);
// Right wall
let right_body = RigidBodyBuilder::fixed()
.translation(Vector2::new(w + thickness / 2.0, h / 2.0))
.build();
let right_handle = self.rigid_body_set.insert(right_body);
let right_collider = ColliderBuilder::cuboid(thickness / 2.0, h / 2.0)
.restitution(0.3)
.friction(0.5)
.build();
self.collider_set.insert_with_parent(right_collider, right_handle, &mut self.rigid_body_set);
}
/// Set gravity vector and wake all bodies so they respond immediately
pub fn set_gravity(&mut self, x: f64, y: f64) {
self.gravity = Vector2::new(x as f32, y as f32);
// Wake all bodies so they respond to the new gravity immediately
for info in &self.bodies {
if let Some(rb) = self.rigid_body_set.get_mut(info.handle) {
rb.wake_up(true);
}
}
}
/// Create a rigid circle at position
pub fn create_soft_circle(&mut self, cx: f64, cy: f64, radius: f64, _segments: u32, _pressure: f64) -> usize {
let rb = RigidBodyBuilder::dynamic()
.translation(Vector2::new(cx as f32, cy as f32))
.linear_damping(0.5)
.angular_damping(1.0)
.build();
let handle = self.rigid_body_set.insert(rb);
let collider = ColliderBuilder::ball(radius as f32)
.restitution(0.4)
.friction(0.6)
.density(1.0)
.build();
self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);
let segments = _segments.max(12) as usize;
let info = BodyInfo {
body_type: 0,
color: [0.545, 0.361, 0.965, 1.0],
radius,
width: 0.0,
height: 0.0,
handle,
particle_handles: Vec::new(),
segments,
};
self.bodies.push(info);
self.bodies.len() - 1
}
/// Create a rigid rectangle at position
pub fn create_soft_rect(&mut self, cx: f64, cy: f64, width: f64, height: f64, _cols: u32, _rows: u32, _pressure: f64) -> usize {
let rb = RigidBodyBuilder::dynamic()
.translation(Vector2::new(cx as f32, cy as f32))
.linear_damping(0.5)
.angular_damping(1.0)
.build();
let handle = self.rigid_body_set.insert(rb);
let collider = ColliderBuilder::cuboid(width as f32 / 2.0, height as f32 / 2.0)
.restitution(0.3)
.friction(0.7)
.density(1.0)
.build();
self.collider_set.insert_with_parent(collider, handle, &mut self.rigid_body_set);
let info = BodyInfo {
body_type: 1,
color: [0.133, 0.725, 0.565, 1.0],
radius: 0.0,
width,
height,
handle,
particle_handles: Vec::new(),
segments: 0,
};
self.bodies.push(info);
self.bodies.len() - 1
}
/// Set body color (RGBA 0..1)
pub fn set_body_color(&mut self, body_idx: usize, r: f64, g: f64, b: f64, a: f64) {
if body_idx < self.bodies.len() {
self.bodies[body_idx].color = [r, g, b, a];
}
}
/// Apply an impulse to a body
pub fn apply_impulse(&mut self, body_idx: usize, fx: f64, fy: f64) {
if body_idx >= self.bodies.len() { return; }
let handle = self.bodies[body_idx].handle;
if let Some(rb) = self.rigid_body_set.get_mut(handle) {
rb.apply_impulse(Vector2::new(fx as f32, fy as f32), true);
}
}
/// Morph a body (for circles: change radius, for rects: change dimensions)
pub fn morph_body(&mut self, body_idx: usize, target_positions: &[f64]) {
// Morphing is a concept from the spring-mass system
// With Rapier, we'd need to recreate colliders — simplified for now
if body_idx >= self.bodies.len() { return; }
// No-op in Rapier mode; shapes are rigid
}
/// Step the simulation forward
pub fn step(&mut self, dt: f64) {
self.integration_parameters.dt = dt as f32;
self.physics_pipeline.step(
&self.gravity,
&self.integration_parameters,
&mut self.island_manager,
&mut self.broad_phase,
&mut self.narrow_phase,
&mut self.rigid_body_set,
&mut self.collider_set,
&mut self.impulse_joint_set,
&mut self.multibody_joint_set,
&mut self.ccd_solver,
Some(&mut self.query_pipeline),
&(),
&(),
);
}
/// Get body center position [x, y]
pub fn get_body_center(&self, body_idx: usize) -> Vec<f64> {
if body_idx >= self.bodies.len() { return vec![0.0, 0.0]; }
let handle = self.bodies[body_idx].handle;
if let Some(rb) = self.rigid_body_set.get(handle) {
let pos = rb.translation();
return vec![pos.x as f64, pos.y as f64];
}
vec![0.0, 0.0]
}
/// Get body rotation angle in radians
pub fn get_body_rotation(&self, body_idx: usize) -> f64 {
if body_idx >= self.bodies.len() { return 0.0; }
let handle = self.bodies[body_idx].handle;
if let Some(rb) = self.rigid_body_set.get(handle) {
return rb.rotation().angle() as f64;
}
0.0
}
/// Get body positions as flat array (for rendering)
/// For circles: generates perimeter points from center + radius + rotation
/// For rects: generates 4 corner points from center + dimensions + rotation
pub fn get_body_positions(&self, body_idx: usize) -> Vec<f64> {
if body_idx >= self.bodies.len() { return Vec::new(); }
let info = &self.bodies[body_idx];
let handle = info.handle;
if let Some(rb) = self.rigid_body_set.get(handle) {
let pos = rb.translation();
let angle = rb.rotation().angle();
let cx = pos.x as f64;
let cy = pos.y as f64;
match info.body_type {
0 => {
// Circle: generate perimeter points
let n = info.segments.max(12);
let mut points = Vec::with_capacity((n + 1) * 2);
for i in 0..n {
let a = (i as f64 / n as f64) * std::f64::consts::TAU + angle as f64;
points.push(cx + info.radius * a.cos());
points.push(cy + info.radius * a.sin());
}
// Center point
points.push(cx);
points.push(cy);
points
}
1 => {
// Rect: generate 4 corners rotated
let hw = info.width / 2.0;
let hh = info.height / 2.0;
let cos_a = (angle as f64).cos();
let sin_a = (angle as f64).sin();
let corners = [
(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)
];
let mut points = Vec::with_capacity(8);
for (lx, ly) in &corners {
points.push(cx + lx * cos_a - ly * sin_a);
points.push(cy + lx * sin_a + ly * cos_a);
}
points
}
_ => Vec::new(),
}
} else {
Vec::new()
}
}
/// Get body color as [r,g,b,a]
pub fn get_body_color(&self, body_idx: usize) -> Vec<f64> {
if body_idx >= self.bodies.len() { return vec![1.0, 1.0, 1.0, 1.0]; }
self.bodies[body_idx].color.to_vec()
}
/// Get body type (0 = circle, 1 = rect)
pub fn get_body_type(&self, body_idx: usize) -> u8 {
if body_idx >= self.bodies.len() { return 0; }
self.bodies[body_idx].body_type
}
/// Get body count
pub fn body_count(&self) -> usize {
self.bodies.len()
}
/// Get total collider count
pub fn particle_count(&self) -> usize {
self.collider_set.len()
}
/// Get body perimeter count (for rendering)
pub fn get_body_perimeter_count(&self, body_idx: usize) -> usize {
if body_idx >= self.bodies.len() { return 0; }
match self.bodies[body_idx].body_type {
0 => self.bodies[body_idx].segments.max(12),
1 => 4,
_ => 0,
}
}
/// Get grid cols (for rect compatibility)
pub fn get_body_grid_cols(&self, body_idx: usize) -> usize {
if body_idx >= self.bodies.len() { return 0; }
if self.bodies[body_idx].body_type == 1 { 2 } else { 0 }
}
/// Get grid rows (for rect compatibility)
pub fn get_body_grid_rows(&self, body_idx: usize) -> usize {
if body_idx >= self.bodies.len() { return 0; }
if self.bodies[body_idx].body_type == 1 { 2 } else { 0 }
}
/// Pin/unpin a body (make it static/dynamic)
pub fn pin_particle(&mut self, body_idx: usize, pinned: bool) {
if body_idx >= self.bodies.len() { return; }
let handle = self.bodies[body_idx].handle;
if let Some(rb) = self.rigid_body_set.get_mut(handle) {
if pinned {
rb.set_body_type(RigidBodyType::Fixed, true);
} else {
rb.set_body_type(RigidBodyType::Dynamic, true);
}
}
}
/// Set boundary dimensions
pub fn set_boundary(&mut self, _width: f64, _height: f64) {
// Would need to recreate wall bodies — simplified
self.boundary_width = _width;
self.boundary_height = _height;
}
}
// ─── Tests ───
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gravity_falls() {
let mut world = PhysicsWorld::new(400.0, 400.0);
let body = world.create_soft_circle(200.0, 100.0, 30.0, 12, 50.0);
let initial_y = world.get_body_center(body)[1];
for _ in 0..60 {
world.step(1.0 / 60.0);
}
let final_y = world.get_body_center(body)[1];
assert!(final_y > initial_y, "Body should fall due to gravity: {} -> {}", initial_y, final_y);
}
#[test]
fn test_boundary_collision() {
let mut world = PhysicsWorld::new(400.0, 400.0);
world.create_soft_circle(200.0, 350.0, 20.0, 8, 30.0);
for _ in 0..300 {
world.step(1.0 / 60.0);
}
let center = world.get_body_center(0);
assert!(center[1] <= 400.0 + 20.0, "Body should not fall far below boundary");
}
#[test]
fn test_soft_rect() {
let mut world = PhysicsWorld::new(600.0, 400.0);
let _body = world.create_soft_rect(300.0, 100.0, 80.0, 40.0, 4, 3, 20.0);
assert_eq!(world.body_count(), 1);
}
#[test]
fn test_impulse() {
let mut world = PhysicsWorld::new(400.0, 400.0);
world.set_gravity(0.0, 0.0);
let body = world.create_soft_circle(200.0, 200.0, 20.0, 8, 30.0);
let initial_x = world.get_body_center(body)[0];
world.apply_impulse(body, 5000.0, 0.0);
for _ in 0..30 {
world.step(1.0 / 60.0);
}
let final_x = world.get_body_center(body)[0];
assert!(final_x > initial_x, "Body should move right from impulse: {} -> {}", initial_x, final_x);
}
#[test]
fn test_body_rotation() {
let mut world = PhysicsWorld::new(400.0, 400.0);
let body = world.create_soft_rect(200.0, 100.0, 60.0, 30.0, 3, 2, 0.0);
let rot = world.get_body_rotation(body);
assert!((rot).abs() < 0.01, "Initial rotation should be ~0");
}
}

149
engine/ds-stream/README.md Normal file
View file

@ -0,0 +1,149 @@
# ds-stream — Universal Bitstream Streaming
> Any input bitstream → any output bitstream.
> Neural nets will generate the pixels. The receiver just renders bytes.
## What It Does
```
┌──────────┐ WebSocket ┌─────────┐ WebSocket ┌──────────┐
│ Source │ ───frames────► │ Relay │ ───frames────► │ Receiver │
│ (renders) │ ◄───inputs──── │ (:9100) │ ◄───inputs──── │ (~300 LOC)│
└──────────┘ └─────────┘ └──────────┘
```
The **source** runs a DreamStack app (signal graph + springs + renderer), captures output as bytes, and streams it. The **receiver** is a thin client that renders whatever bytes arrive — no framework, no runtime.
## Binary Protocol
Every message = **16-byte header** + payload.
```
┌──────┬───────┬──────┬───────────┬───────┬────────┬────────────┐
│ type │ flags │ seq │ timestamp │ width │ height │ payload_len│
│ u8 │ u8 │ u16 │ u32 │ u16 │ u16 │ u32 │
└──────┴───────┴──────┴───────────┴───────┴────────┴────────────┘
```
### Frame Types (source → receiver)
| Code | Type | Description |
|------|------|-------------|
| `0x01` | Pixels | Raw RGBA framebuffer |
| `0x02` | CompressedPixels | PNG/WebP (future) |
| `0x03` | DeltaPixels | XOR delta + RLE |
| `0x10` | AudioPcm | Float32 PCM samples |
| `0x11` | AudioCompressed | Opus (future) |
| `0x20` | Haptic | Vibration command |
| `0x30` | SignalSync | Full signal state JSON |
| `0x31` | SignalDiff | Changed signals only |
| `0x40` | NeuralFrame | Neural-generated pixels |
| `0x41` | NeuralAudio | Neural speech/music |
| `0x42` | NeuralActuator | Learned motor control |
| `0x43` | NeuralLatent | Latent space scene |
| `0xFE` | Ping | Keep-alive |
| `0xFF` | End | Stream termination |
### Input Types (receiver → source)
| Code | Type | Payload |
|------|------|---------|
| `0x01` | Pointer | x(u16) y(u16) buttons(u8) |
| `0x02` | PointerDown | same |
| `0x03` | PointerUp | same |
| `0x10` | KeyDown | keycode(u16) modifiers(u8) |
| `0x11` | KeyUp | same |
| `0x20` | Touch | id(u8) x(u16) y(u16) |
| `0x30` | GamepadAxis | axis(u8) value(f32) |
| `0x40` | Midi | status(u8) d1(u8) d2(u8) |
| `0x50` | Scroll | dx(i16) dy(i16) |
| `0x60` | Resize | width(u16) height(u16) |
| `0x90` | BciInput | (future) |
### Flags
| Bit | Meaning |
|-----|---------|
| 0 | `FLAG_INPUT` — message is input (receiver→source) |
| 1 | `FLAG_KEYFRAME` — full state, no delta |
| 2 | `FLAG_COMPRESSED` — payload is compressed |
## Streaming Modes
| Mode | Frame Size | Bandwidth @30fps | Use Case |
|------|-----------|-------------------|----------|
| **Pixel** | 938 KB | ~28 MB/s | Full fidelity, any renderer |
| **Delta** | 50-300 KB | ~1-9 MB/s | Low-motion scenes |
| **Signal** | ~80 B | ~2 KB/s | DreamStack-native, receiver renders |
| **Neural** | 938 KB | ~28 MB/s | Model-generated output |
## Quick Start
```bash
# Start the relay server
cargo run -p ds-stream
# Serve the examples
python3 -m http.server 8080 --directory examples
# Open in browser
# Tab 1: http://localhost:8080/stream-source.html
# Tab 2: http://localhost:8080/stream-receiver.html
```
Click mode buttons on the source to switch between Pixel / Delta / Signal / Neural. Toggle audio. Open multiple receiver tabs.
## Crate Structure
```
engine/ds-stream/
├── Cargo.toml
└── src/
├── lib.rs # crate root
├── protocol.rs # types, header, events (412 lines)
├── codec.rs # encode/decode, delta, builders (247 lines)
├── relay.rs # WebSocket relay server (255 lines)
└── main.rs # CLI entry point
```
## Tests
```bash
cargo test -p ds-stream # 17 tests
```
Covers: header roundtrip, event roundtrip, delta compression, frame type enums, flag checks, partial buffer handling, message size calculation.
---
## Next Steps
### Near-term
1. **WebRTC transport** — Replace WebSocket with WebRTC DataChannel for sub-10ms latency and NAT traversal. The protocol is transport-agnostic; only the relay changes.
2. **Opus audio compression** — Replace raw PCM (`AudioPcm`) with Opus encoding (`AudioCompressed`). 28 KB/s → ~6 KB/s for voice, near-transparent quality.
3. **Adaptive quality** — Source monitors receiver lag (via ACK frames) and auto-downgrades: full pixels → delta → signal diff. Graceful degradation on slow networks.
4. **WASM codec** — Compile `protocol.rs` + `codec.rs` to WASM so source and receiver share the exact same binary codec. No JS reimplementation drift.
### Medium-term
5. **Persistent signal state** — Source sends `SignalSync` keyframes periodically. New receivers joining mid-stream get full state immediately, then switch to diffs.
6. **Touch/gamepad input** — Wire up Touch, GamepadAxis, GamepadButton input types on receiver. Enable mobile and controller interaction.
7. **Frame compression** — PNG/WebP encoding for pixel frames (`CompressedPixels`). Canvas `toBlob('image/webp', 0.8)` gives ~50-100 KB/frame vs 938 KB raw.
8. **Haptic output** — Receiver calls `navigator.vibrate()` on Haptic frames. Spring impact → buzz.
### Long-term (neural path)
9. **Train a pixel model** — Collect (signal_state, screenshot) pairs from the springs demo. Train a small CNN/NeRF to predict framebuffer from signal state. Replace `neuralRender()` with real inference.
10. **Latent space streaming** — Instead of pixels, stream a compressed latent representation (`NeuralLatent`). Receiver runs a lightweight decoder model. ~1 KB/frame for HD content.
11. **Voice input** — Receiver captures microphone audio, streams as `VoiceInput`. Source interprets via speech-to-intent model to drive signals.
12. **Multi-source composition** — Multiple sources stream to the same relay. Receiver composites layers. Each source owns a region or z-layer of the output.

24
examples/physics.ds Normal file
View file

@ -0,0 +1,24 @@
-- DreamStack Physics Demo
-- Rapier2D-powered rigid body physics, driven from the .ds language
-- Gravity, collision, rotation — all from a scene declaration
let gravity_y = 980
view main = column [
text "⚡ DreamStack Physics"
scene { width: 700, height: 450, gravity_y: gravity_y } [
circle { x: 200, y: 80, radius: 35, color: "#8b5cf6" }
circle { x: 350, y: 50, radius: 50, color: "#6366f1" }
rect { x: 500, y: 100, width: 80, height: 50, color: "#10b981" }
rect { x: 300, y: 200, width: 60, height: 60, color: "#14b8a6" }
circle { x: 150, y: 180, radius: 25, color: "#f59e0b" }
]
row [
button "⬆ Anti-Gravity" { click: gravity_y = -500 }
button "⬇ Normal" { click: gravity_y = 980 }
button "🌀 Zero-G" { click: gravity_y = 0 }
button "🪐 Heavy" { click: gravity_y = 2000 }
]
]