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:
parent
d7961cdc98
commit
ea64617569
11 changed files with 1356 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
22
engine/ds-physics/Cargo.toml
Normal file
22
engine/ds-physics/Cargo.toml
Normal 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
|
||||
453
engine/ds-physics/src/lib.rs
Normal file
453
engine/ds-physics/src/lib.rs
Normal 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
149
engine/ds-stream/README.md
Normal 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
24
examples/physics.ds
Normal 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 }
|
||||
]
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue