diff --git a/DREAMSTACK.md b/DREAMSTACK.md index 92e2693..8c06231 100644 --- a/DREAMSTACK.md +++ b/DREAMSTACK.md @@ -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` | 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 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 92cd00b..59bfa90 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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 1–3 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. diff --git a/compiler/ds-analyzer/src/signal_graph.rs b/compiler/ds-analyzer/src/signal_graph.rs index 8edc974..297403c 100644 --- a/compiler/ds-analyzer/src/signal_graph.rs +++ b/compiler/ds-analyzer/src/signal_graph.rs @@ -345,6 +345,7 @@ fn collect_bindings(expr: &Expr, bindings: &mut Vec) { 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 { diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index f0099de..002c83b 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -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+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 }; })(); "#; diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs index d90a856..475ffea 100644 --- a/compiler/ds-parser/src/ast.rs +++ b/compiler/ds-parser/src/ast.rs @@ -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), } diff --git a/compiler/ds-parser/src/lexer.rs b/compiler/ds-parser/src/lexer.rs index c59a290..af3d639 100644 --- a/compiler/ds-parser/src/lexer.rs +++ b/compiler/ds-parser/src/lexer.rs @@ -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, diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index c4f67a4..8f80a73 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -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 { + 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, 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 ) } diff --git a/engine/ds-physics/Cargo.toml b/engine/ds-physics/Cargo.toml new file mode 100644 index 0000000..2f8b0e3 --- /dev/null +++ b/engine/ds-physics/Cargo.toml @@ -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 diff --git a/engine/ds-physics/src/lib.rs b/engine/ds-physics/src/lib.rs new file mode 100644 index 0000000..a4385e4 --- /dev/null +++ b/engine/ds-physics/src/lib.rs @@ -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, + segments: usize, +} + +// ─── Physics World ─── + +#[wasm_bindgen] +pub struct PhysicsWorld { + // Rapier pipeline + rigid_body_set: RigidBodySet, + collider_set: ColliderSet, + gravity: Vector2, + 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, + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/engine/ds-stream/README.md b/engine/ds-stream/README.md new file mode 100644 index 0000000..eecda42 --- /dev/null +++ b/engine/ds-stream/README.md @@ -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. diff --git a/examples/physics.ds b/examples/physics.ds new file mode 100644 index 0000000..7de1136 --- /dev/null +++ b/examples/physics.ds @@ -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 } + ] +]