diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..242fa6a02584e345b472f643ddc32cb1fda20706 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.DS_Store +__pycache__ +*.pyc +*.pyo +.pytest_cache +.claude +node_modules +build +research +docs +plans +RESEARCH_NOTES.md +trainer +train.py +sim +viz +planner diff --git a/.gitignore b/.gitignore index aa3cd947100233a030e88417f9ad930429c56b97..6563588a202c10e6718b3cd7e592c885e79a613d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ # testing /coverage -# production -/build - # misc .DS_Store .env.local @@ -28,3 +25,5 @@ __pycache__/ # Reference repos (not pushed to HF) .reference/ +*.pyc +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..aaf15a9780b45d05060a4b961d6671cc9e3a7296 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS web-builder + +WORKDIR /web +COPY package*.json ./ +RUN npm ci --no-audit --no-fund +COPY public ./public +COPY src ./src +RUN npm run build + +FROM ghcr.io/meta-pytorch/openenv-base:latest + +WORKDIR /app + +# Install Python deps first for better layer caching +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir "openenv-core[core]>=0.2.1" + +# Copy application source +COPY . /app + +# Overlay the compiled React frontend +COPY --from=web-builder /web/build /app/build + +EXPOSE 8000 + +CMD ["uvicorn", "openenv_server.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index cacdb4ddc76d310ab967af8f4440b6b7d28a3f91..9136cba301c0a82d944368088e90a806aaa41d33 100644 --- a/README.md +++ b/README.md @@ -3,81 +3,35 @@ title: Optigami emoji: 🐠 colorFrom: indigo colorTo: red -sdk: static +sdk: docker pinned: false -app_build_command: npm run build -app_file: build/index.html +app_port: 8000 license: mit -short_description: ':)' +short_description: OpenEnv origami environment and demo --- -# Getting Started with Create React App +# Optigami -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +OpenEnv-compatible origami RL environment with: +- environment + reward checks in `env/` +- OpenEnv server adapter in `openenv_runtime/` and `openenv_server/` +- Dockerized deployment for Hugging Face Spaces -## Available Scripts +Entry point: `openenv_server.app:app` +Manifest: `openenv.yaml` +Container: `Dockerfile` -In the project directory, you can run: +## Local Run -### `npm start` +```bash +uvicorn openenv_server.app:app --host 0.0.0.0 --port 8000 +``` -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in your browser. +## Frontend (optional local React demo) -The page will reload when you make changes.\ -You may also see any lint errors in the console. +```bash +npm install +npm start +``` -### `npm test` - -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - -### `npm run build` - -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! - -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `npm run eject` - -**Note: this is a one-way operation. Once you `eject`, you can't go back!** - -If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. - -You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). - -### Code Splitting - -This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) - -### Analyzing the Bundle Size - -This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) - -### Making a Progressive Web App - -This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) - -### Advanced Configuration - -This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) - -### Deployment - -This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) - -### `npm run build` fails to minify - -This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) +This serves the dashboard against the FastAPI API. diff --git a/build/asset-manifest.json b/build/asset-manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..0a5696da84d7ba994c73da4c14720129ec13b860 --- /dev/null +++ b/build/asset-manifest.json @@ -0,0 +1,13 @@ +{ + "files": { + "main.css": "/static/css/main.2ef3bb14.css", + "main.js": "/static/js/main.b94094b6.js", + "index.html": "/index.html", + "main.2ef3bb14.css.map": "/static/css/main.2ef3bb14.css.map", + "main.b94094b6.js.map": "/static/js/main.b94094b6.js.map" + }, + "entrypoints": [ + "static/css/main.2ef3bb14.css", + "static/js/main.b94094b6.js" + ] +} \ No newline at end of file diff --git a/build/favicon.ico b/build/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a Binary files /dev/null and b/build/favicon.ico differ diff --git a/build/index.html b/build/index.html new file mode 100644 index 0000000000000000000000000000000000000000..fd4f0e1d789001296bc778cc962254dbbf571762 --- /dev/null +++ b/build/index.html @@ -0,0 +1 @@ +
React build not found. Run npm run build in the frontend directory.
Training viewer: /viewer/training.html
" + ) + + +def run(host: str = "0.0.0.0", port: int = 9001) -> None: + """Start the training server. Call from Colab notebook.""" + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + run() diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000000000000000000000000000000000000..6d468ca73921b45db9d77ed28f0ba951a2787449 --- /dev/null +++ b/server/models.py @@ -0,0 +1,72 @@ +""" +OpenEnv Pydantic models for the origami RL environment. + +OrigamiAction — one fold per step +OrigamiObservation — everything the LLM and Three.js viewer need +OrigamiState — server-side episode tracking +""" +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, Field + +# openenv base classes — use them if available, fall back to plain Pydantic +try: + from openenv.core.env_server.types import Action, Observation, State +except ImportError: + Action = BaseModel + class State(BaseModel): + """Minimal stand-in for openenv State base class.""" + episode_id: Optional[str] = None + step_count: int = 0 + + class Observation(BaseModel): + """Minimal stand-in for openenv Observation base class.""" + done: bool = False + reward: Optional[float] = None + + +class OrigamiAction(Action): + """One fold operation sent by the client each step.""" + + fold_type: str = Field( + default="valley", + description="'valley' | 'mountain' | 'pleat' | 'crimp' | 'stop'", + ) + fold_line: dict[str, list[float]] = Field( + default_factory=lambda: {"start": [0.0, 0.5], "end": [1.0, 0.5]}, + description="{'start': [x, y], 'end': [x, y]} normalized 0-1", + ) + fold_angle: float = Field( + default=180.0, + description="Fold angle in degrees, 0-180", + ) + layer_select: str = Field( + default="all", + description="'all' | 'top' | 'bottom'", + ) + + +class OrigamiObservation(Observation): + """Everything the LLM and Three.js viewer need. + + paper_state contains FOLD-compatible geometry + physics data. + metrics contains all computed quality metrics. + No render_urls — the browser renders from paper_state directly. + """ + + task: dict[str, Any] = Field(default_factory=dict) + paper_state: dict[str, Any] = Field(default_factory=dict) + metrics: dict[str, Any] = Field(default_factory=dict) + fold_history: list[dict[str, Any]] = Field(default_factory=list) + error: Optional[str] = Field(default=None) + + +class OrigamiState(State): + """Server-side episode tracking.""" + + task_name: str = Field(default="") + num_folds_applied: int = Field(default=0) + is_valid: bool = Field(default=True) + total_reward: float = Field(default=0.0) diff --git a/server/origami_environment.py b/server/origami_environment.py new file mode 100644 index 0000000000000000000000000000000000000000..886e7c97b2b6379d09fa0b8caeb86fa80f70a99c --- /dev/null +++ b/server/origami_environment.py @@ -0,0 +1,221 @@ +""" +OrigamiEnvironment — OpenEnv environment wrapping the origami physics engine. + +Implements reset() / step() / state following the OpenEnv interface. +Engine (physics, fold, validation, metrics) lives in engine/. +No server-side image rendering — paper_state contains all geometry data. +""" +from __future__ import annotations + +import json +import os +import uuid +from typing import Any, Optional + +# openenv base class — fall back to plain object if not installed +try: + from openenv.core.env_server.interfaces import Environment +except ImportError: + from typing import Generic, TypeVar + A = TypeVar("A") + O = TypeVar("O") + S = TypeVar("S") + class Environment(Generic[A, O, S]): + """Minimal stand-in for openenv.core.env_server.interfaces.Environment.""" + def __init__(self, **kwargs): pass + +from engine.paper import Paper +from engine.fold_engine import apply_fold +from engine.physics import simulate +from engine.validation import validate_state +from engine.metrics import compute_all_metrics +from server.models import OrigamiAction, OrigamiObservation, OrigamiState +from server.tasks import get_task_by_name, sample_task + + +def _get_material(name: str): + """Get material by name, falling back to paper.""" + try: + from engine.materials import get_material + return get_material(name) + except Exception: + from engine.materials import get_material + return get_material("paper") + + +class OrigamiEnvironment(Environment[OrigamiAction, OrigamiObservation, OrigamiState]): + """Origami folding RL environment. + + Each episode: agent receives paper_state + task, applies folds one at a + time via step(), receives metrics + reward, ends with 'stop' action or + when max_folds is reached. + """ + + SUPPORTS_CONCURRENT_SESSIONS = False + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._paper: Optional[Paper] = None + self._task: Optional[dict] = None + self._fold_history: list[dict] = [] + self._metrics: dict = {} + self._validation: dict = {} + self._error: Optional[str] = None + self._episode_id: Optional[str] = None + self._step_count: int = 0 + self._total_reward: float = 0.0 + + # ── reset ───────────────────────────────────────────────────────── + + def reset( + self, + seed: Optional[int] = None, + episode_id: Optional[str] = None, + **kwargs: Any, + ) -> OrigamiObservation: + self._episode_id = episode_id or str(uuid.uuid4()) + self._step_count = 0 + self._fold_history = [] + self._error = None + self._total_reward = 0.0 + + # Select task + task_name = kwargs.get("task_name") + if task_name: + self._task = get_task_by_name(task_name) + if not self._task: + self._task = sample_task(seed=seed) + + # Create flat sheet + mat = _get_material(self._task["material"]) + self._paper = Paper.create_flat_sheet( + width=self._task["width"], + height=self._task["height"], + material=mat, + ) + + # Initial validation + metrics (no physics needed for flat sheet) + self._validation = validate_state(self._paper) + self._metrics = compute_all_metrics(self._paper, self._task, self._validation) + + return self._make_observation(done=False, reward=None) + + # ── step ────────────────────────────────────────────────────────── + + def step( + self, + action: OrigamiAction, + timeout_s: Optional[float] = None, + **kwargs: Any, + ) -> OrigamiObservation: + if self._paper is None or self._task is None: + return self._make_observation(done=True, reward=-5.0) + + self._step_count += 1 + self._error = None + + # ── Stop action ─────────────────────────────────────────────── + if action.fold_type == "stop": + return self._finalize_episode() + + # ── Build fold dict ─────────────────────────────────────────── + fold_dict = { + "type": action.fold_type, + "line": action.fold_line, + "angle": action.fold_angle, + } + + # ── Apply fold ──────────────────────────────────────────────── + new_paper, err = apply_fold(self._paper, fold_dict) + if err: + self._error = err + return self._make_observation(done=True, reward=-5.0) + + self._paper = new_paper + self._fold_history.append({**fold_dict, "step": self._step_count}) + + # ── Physics relaxation ──────────────────────────────────────── + try: + self._paper = simulate(self._paper, fold_percent=1.0) + except Exception as exc: + self._error = f"Physics failed: {exc}" + # Continue — don't abort episode on physics failure + + # ── Validate ────────────────────────────────────────────────── + self._validation = validate_state(self._paper) + + # ── Metrics ─────────────────────────────────────────────────── + self._metrics = compute_all_metrics(self._paper, self._task, self._validation) + + # ── Check termination ───────────────────────────────────────── + max_folds = self._task.get("max_folds", 50) + if self._step_count >= max_folds: + return self._finalize_episode() + + if self._validation.get("self_intersections", 0) > 0: + self._error = "Self-intersection detected" + return self._finalize_episode() + + return self._make_observation(done=False, reward=None) + + # ── state ───────────────────────────────────────────────────────── + + @property + def state(self) -> OrigamiState: + return OrigamiState( + episode_id=self._episode_id, + step_count=self._step_count, + task_name=self._task.get("name", "") if self._task else "", + num_folds_applied=len(self._fold_history), + is_valid=self._metrics.get("is_valid", True), + total_reward=self._total_reward, + ) + + # ── internals ───────────────────────────────────────────────────── + + def _finalize_episode(self) -> OrigamiObservation: + reward = self._compute_reward() + self._total_reward = reward + return self._make_observation(done=True, reward=reward) + + def _make_observation(self, done: bool, reward: Optional[float]) -> OrigamiObservation: + return OrigamiObservation( + done=done, + reward=reward, + task=self._task or {}, + paper_state=self._paper.to_observation_dict() if self._paper else {}, + metrics=self._metrics, + fold_history=self._fold_history, + error=self._error, + ) + + def _compute_reward(self) -> float: + m = self._metrics + reward = 0.0 + + # Compactness is the main signal + reward += m.get("compactness", 0.0) * 20.0 + + # Bonus for fitting in target box + if m.get("fits_target_box", False): + reward += 10.0 + + # Bonus for deployability (if task requires it) + if m.get("is_deployable", False): + reward += 5.0 + + # Penalties for violations + reward -= m.get("kawasaki_violations", 0) * 2.0 + reward -= m.get("maekawa_violations", 0) * 2.0 + reward -= m.get("self_intersections", 0) * 5.0 + + # Penalty for too many folds (encourage efficiency) + reward -= m.get("fold_count", 0) * 0.5 + + # Penalty for exceeding material strain limit + max_strain = m.get("max_strain", 0.0) + strain_limit = self._paper.material.max_strain if self._paper else 0.05 + if max_strain > strain_limit: + reward -= 3.0 * (max_strain / strain_limit) + + return float(reward) diff --git a/server/tasks.py b/server/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..bbcaa8add7105bd162c0d2bc00b135c303a45a9c --- /dev/null +++ b/server/tasks.py @@ -0,0 +1,123 @@ +""" +Task pool and curriculum for the origami RL environment. + +7 tasks across 4 difficulty levels. +""" +from __future__ import annotations + +import random +from typing import Optional + + +TASKS: dict[str, dict] = { + "half_fold": { + "name": "half_fold", + "description": "Fold a 1x1 paper sheet in half along the horizontal midline.", + "width": 1.0, + "height": 1.0, + "material": "paper", + "target_ratio": 0.50, + "max_folds": 3, + "target_box": [1.0, 0.5, 0.02], + "must_deploy": False, + "difficulty": 1, + }, + "quarter_fold": { + "name": "quarter_fold", + "description": "Fold a 1x1 paper sheet into quarters using two perpendicular folds.", + "width": 1.0, + "height": 1.0, + "material": "paper", + "target_ratio": 0.25, + "max_folds": 5, + "target_box": [0.5, 0.5, 0.04], + "must_deploy": False, + "difficulty": 1, + }, + "letter_fold": { + "name": "letter_fold", + "description": "Fold a 1x1 paper into thirds (letter fold) using two parallel folds.", + "width": 1.0, + "height": 1.0, + "material": "paper", + "target_ratio": 0.33, + "max_folds": 5, + "target_box": [1.0, 0.34, 0.03], + "must_deploy": False, + "difficulty": 2, + }, + "map_fold": { + "name": "map_fold", + "description": "Fold a 1x1 paper into eighths using a grid fold pattern. Must be re-deployable.", + "width": 1.0, + "height": 1.0, + "material": "paper", + "target_ratio": 0.125, + "max_folds": 8, + "target_box": [0.5, 0.25, 0.08], + "must_deploy": True, + "difficulty": 2, + }, + "solar_panel": { + "name": "solar_panel", + "description": "Pack a 1x1 Mylar solar panel into a compact configuration using a Miura-ori style fold. Must deploy.", + "width": 1.0, + "height": 1.0, + "material": "mylar", + "target_ratio": 0.05, + "max_folds": 20, + "target_box": [0.25, 0.25, 0.05], + "must_deploy": True, + "difficulty": 3, + }, + "shelter_wall": { + "name": "shelter_wall", + "description": "Fold a 1x1 aluminum sheet into a compact structural panel within strain limits.", + "width": 1.0, + "height": 1.0, + "material": "aluminum", + "target_ratio": 0.10, + "max_folds": 15, + "target_box": [0.5, 0.25, 0.1], + "must_deploy": False, + "difficulty": 3, + }, + "stent": { + "name": "stent", + "description": "Fold a 0.5x1.5 nitinol sheet into a compact tube configuration for a medical stent. Superelastic material.", + "width": 0.5, + "height": 1.5, + "material": "nitinol", + "target_ratio": 0.09, + "max_folds": 25, + "target_box": [0.1, 0.1, 0.15], + "must_deploy": True, + "difficulty": 4, + }, +} + + +def get_task_by_name(name: str) -> Optional[dict]: + """Return task dict by name, or None if not found.""" + return TASKS.get(name) + + +def sample_task(seed: Optional[int] = None, difficulty: Optional[int] = None) -> dict: + """Sample a random task, optionally filtered by difficulty level.""" + rng = random.Random(seed) + pool = list(TASKS.values()) + if difficulty is not None: + pool = [t for t in pool if t["difficulty"] == difficulty] + if not pool: + pool = list(TASKS.values()) + return dict(rng.choice(pool)) + + +def get_tasks_by_difficulty(level: int) -> list[dict]: + """Return all tasks at a given difficulty level.""" + return [dict(t) for t in TASKS.values() if t["difficulty"] == level] + + +def available_task_names() -> list[str]: + """Return sorted list of all task names.""" + return sorted(TASKS.keys()) diff --git a/server/training_broadcast.py b/server/training_broadcast.py new file mode 100644 index 0000000000000000000000000000000000000000..1d1a2518a0425fa41a41fb450a225fc6e2bc970e --- /dev/null +++ b/server/training_broadcast.py @@ -0,0 +1,216 @@ +""" +TrainingBroadcastServer — fire-and-forget broadcast hub for live training viewer. + +The RL training process calls publish() after each env.step(). +Spectator browsers connect via /ws/training WebSocket. +Broadcast is async and non-blocking: if no viewers are connected, observations are dropped. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Optional + +from fastapi import WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + + +@dataclass +class EpisodeInfo: + episode_id: str + task_name: str + status: str = "running" # "running" | "done" | "timeout" | "error" + step: int = 0 + observation: dict = field(default_factory=dict) + metrics: dict = field(default_factory=dict) + fold_history: list = field(default_factory=list) + steps: list = field(default_factory=list) # full step history for replay + score: Optional[float] = None + final_metrics: Optional[dict] = None + + +class TrainingBroadcastServer: + """Central hub for broadcasting RL training observations to spectator WebSockets. + + Thread-safe: publish() can be called from training threads (ThreadPoolExecutor). + WebSocket handlers run in the asyncio event loop. + """ + + def __init__(self) -> None: + self._spectators: list[WebSocket] = [] + self._registry: dict[str, EpisodeInfo] = {} + self._batch_id: int = 0 + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._lock = asyncio.Lock() + + # ── Episode publishing (called from training thread / async context) ── + + def publish(self, episode_id: str, data: dict) -> None: + """Fire-and-forget: push an update from the training process. + + Safe to call from any thread. Schedules onto the stored event loop + (set by the FastAPI startup handler). No-op if no loop is available. + """ + loop = self._loop + if loop is None or loop.is_closed(): + return + asyncio.run_coroutine_threadsafe(self._async_publish(episode_id, data), loop) + + async def _async_publish(self, episode_id: str, data: dict) -> None: + msg_type = data.get("type", "episode_update") + + async with self._lock: + if msg_type == "batch_start": + self._batch_id = data.get("batch_id", self._batch_id + 1) + self._registry.clear() + await self._broadcast(data) + return + + if msg_type == "batch_done": + await self._broadcast(data) + return + + if msg_type == "training_done": + await self._broadcast(data) + return + + # episode_update or episode_done + ep = self._registry.setdefault( + episode_id, + EpisodeInfo(episode_id=episode_id, task_name=data.get("task_name", "")), + ) + + if msg_type == "episode_done": + ep.status = data.get("status", "done") + ep.score = data.get("score") + ep.final_metrics = data.get("final_metrics") + else: + step_num = data.get("step", ep.step) + ep.step = step_num + ep.status = "running" + obs = data.get("observation", {}) + ep.observation = obs + ep.metrics = obs.get("metrics", {}) + ep.fold_history = obs.get("fold_history", ep.fold_history) + # Accumulate full step history for /episode/replay + if step_num > 0: + fold_hist = obs.get("fold_history", []) + latest_fold = fold_hist[-1] if fold_hist else {} + ep.steps.append({ + "step": step_num, + "fold": latest_fold, + "paper_state": obs.get("paper_state", {}), + "metrics": obs.get("metrics", {}), + "done": obs.get("done", False), + }) + + await self._broadcast({"episode_id": episode_id, **data}) + + # ── Spectator management ── + + async def connect_spectator(self, websocket: WebSocket) -> None: + """Accept a new viewer WebSocket and serve it until disconnect.""" + await websocket.accept() + + async with self._lock: + self._spectators.append(websocket) + + # Send current registry snapshot immediately + await self._send_registry(websocket) + + try: + while True: + # Viewers are read-only; drain any incoming messages (pings etc) + await asyncio.wait_for(websocket.receive_text(), timeout=30.0) + except (WebSocketDisconnect, asyncio.TimeoutError, Exception): + pass + finally: + await self.disconnect_spectator(websocket) + + async def disconnect_spectator(self, websocket: WebSocket) -> None: + async with self._lock: + self._spectators = [s for s in self._spectators if s is not websocket] + + # ── Batch control ── + + async def start_batch(self, batch_id: int, num_episodes: int, prompt_index: int = 0) -> None: + """Call before starting a new training batch.""" + data = { + "type": "batch_start", + "batch_id": batch_id, + "num_episodes": num_episodes, + "prompt_index": prompt_index, + } + await self._async_publish("__batch__", data) + + async def finish_batch( + self, + batch_id: int, + scores: list[float], + best_episode_id: str = "", + ) -> None: + """Call after all episodes in a batch complete.""" + data = { + "type": "batch_done", + "batch_id": batch_id, + "scores": scores, + "best_episode_id": best_episode_id, + "avg_score": sum(scores) / len(scores) if scores else 0.0, + } + await self._async_publish("__batch__", data) + + async def clear_batch(self) -> None: + """Reset episode registry for next batch.""" + async with self._lock: + self._registry.clear() + + # ── Internals ── + + async def _broadcast(self, message: dict) -> None: + """Send message to all spectators, removing dead connections.""" + if not self._spectators: + return + payload = json.dumps(message, default=str) + dead: list[WebSocket] = [] + for ws in list(self._spectators): + try: + await ws.send_text(payload) + except Exception: + dead.append(ws) + for ws in dead: + self._spectators = [s for s in self._spectators if s is not ws] + + async def _send_registry(self, websocket: WebSocket) -> None: + """Send the full episode registry to a newly connected viewer.""" + async with self._lock: + episodes = { + ep_id: { + "status": ep.status, + "task": ep.task_name, + "step": ep.step, + "observation": ep.observation, + "metrics": ep.metrics, + "score": ep.score, + } + for ep_id, ep in self._registry.items() + } + payload = { + "type": "registry", + "batch_id": self._batch_id, + "episodes": episodes, + } + try: + await websocket.send_text(json.dumps(payload, default=str)) + except Exception: + pass + + @property + def spectator_count(self) -> int: + return len(self._spectators) + + @property + def active_episodes(self) -> int: + return sum(1 for ep in self._registry.values() if ep.status == "running") diff --git a/src/App.css b/src/App.css index 74b5e053450a48a6bdb4d71aad648e7af821975c..1631274e73a4fbc2f4fde20e54234f4eadc07b91 100644 --- a/src/App.css +++ b/src/App.css @@ -1,38 +1,572 @@ -.App { - text-align: center; +:root { + --bg: #0d0d14; + --surface: #13131d; + --surface-2: #1a1a2e; + --paper-white: #fafaf5; + --paper-edge: #2a2a3a; + --mountain: #f59e0b; + --valley: #38bdf8; + --target-ghost: rgba(124, 58, 237, 0.20); + --target-ghost-stroke: rgba(124, 58, 237, 0.45); + --validity: #22d3ee; + --progress: #22c55e; + --economy: #a78bfa; + --text-primary: #f8fafc; + --text-dim: #64748b; + --border: #2a2a3a; + --border-bright: #3a3a5a; + --font-display: 'JetBrains Mono', monospace; + --font-mono: 'IBM Plex Mono', monospace; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg); + overflow: hidden; +} + +/* ─── HEADER ─── */ +.app-header { + display: flex; + align-items: center; + gap: 24px; + padding: 0 20px; + height: 48px; + border-bottom: 1px solid var(--border); + background: var(--surface); + flex-shrink: 0; + z-index: 10; +} + +.app-title { + font-family: var(--font-display); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--text-primary); + white-space: nowrap; +} + +.app-title .title-accent { + color: var(--mountain); +} + +.header-sep { + width: 1px; + height: 24px; + background: var(--border); + flex-shrink: 0; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; + margin-left: auto; +} + +.replay-badge { + font-size: 10px; + font-family: var(--font-display); + letter-spacing: 0.1em; + color: #38bdf8; + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.3); + padding: 3px 8px; + border-radius: 3px; +} + +.back-to-grid-btn { + font-size: 10px; + font-family: var(--font-display); + letter-spacing: 0.08em; + color: #64748b; + background: transparent; + border: 1px solid #1e2a3a; + padding: 3px 10px; + border-radius: 3px; + cursor: pointer; } +.back-to-grid-btn:hover { color: #e2e8f0; border-color: #64748b; } -.App-logo { - height: 40vmin; - pointer-events: none; +.api-status { + font-size: 11px; + font-family: var(--font-display); + letter-spacing: 0.08em; + display: flex; + align-items: center; + gap: 6px; +} + +.api-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); +} + +.api-status-dot.ok { + background: var(--progress); + box-shadow: 0 0 6px var(--progress); } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } +.api-status-dot.err { + background: #ef4444; + box-shadow: 0 0 6px #ef4444; +} + +/* ─── MAIN LAYOUT ─── */ +.app-body { + display: grid; + grid-template-columns: 1fr 280px; + flex: 1; + overflow: hidden; +} + +.app-left { + display: flex; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--border); } -.App-header { - background-color: #282c34; - min-height: 100vh; +.app-right { display: flex; flex-direction: column; + overflow: hidden; + background: var(--surface); +} + +/* ─── CANVAS ROW ─── */ +.canvas-row { + display: flex; + gap: 0; + padding: 16px; + flex-shrink: 0; + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.canvas-wrap { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 280px; +} + +.canvas-wrap + .canvas-wrap { + margin-left: 16px; +} + +.canvas-label { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.14em; + color: var(--text-dim); + text-transform: uppercase; +} + +.canvas-svg { + display: block; + background: var(--paper-white); +} + +.canvas-3d { + display: block; + background: linear-gradient(180deg, #1a1a2e 0%, #0f101a 100%); + border: 1px solid var(--border); +} + +.canvas-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.fold-mode-toggle { + display: inline-flex; + border: 1px solid var(--border); + background: var(--surface); +} + +.fold-mode-btn { + border: none; + background: transparent; + color: var(--text-dim); + font-family: var(--font-display); + font-size: 9px; + letter-spacing: 0.08em; + padding: 3px 7px; + cursor: pointer; +} + +.fold-mode-btn + .fold-mode-btn { + border-left: 1px solid var(--border); +} + +.fold-mode-btn.active { + color: var(--text-primary); + background: #1f2538; +} + +/* ─── STEP FEED ─── */ +.step-feed-section { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.section-header { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.14em; + color: var(--text-dim); + text-transform: uppercase; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.step-feed { + overflow-y: auto; + flex: 1; + padding: 4px 0; +} + +.step-entry { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + cursor: default; + transition: background 0.1s; +} + +.step-entry:hover { + background: var(--surface); +} + +.step-entry.active { + background: var(--surface-2); + border-left: 2px solid var(--valley); + padding-left: 14px; +} + +.step-entry-top { + display: flex; + align-items: center; + gap: 8px; +} + +.step-num { + font-family: var(--font-display); + font-size: 10px; + font-weight: 700; + color: var(--text-dim); + width: 24px; + flex-shrink: 0; +} + +.step-instruction { + font-size: 12px; + color: var(--text-primary); + flex: 1; +} + +.assign-badge { + font-family: var(--font-display); + font-size: 10px; + font-weight: 700; + padding: 1px 5px; + line-height: 1.4; + flex-shrink: 0; +} + +.assign-badge.M { + background: var(--mountain); + color: #0d0d14; +} + +.assign-badge.V { + background: var(--valley); + color: #0d0d14; +} + +.assign-badge.B { + background: var(--border-bright); + color: var(--text-dim); +} + +.step-reward-delta { + font-size: 11px; + color: var(--text-dim); + padding-left: 32px; +} + +.step-reward-delta .delta-positive { + color: var(--progress); +} + +.step-reward-delta .delta-negative { + color: #ef4444; +} + +/* ─── REWARD PANEL ─── */ +.reward-panel { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.reward-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.reward-row:last-child { + margin-bottom: 0; +} + +.reward-label { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; + color: var(--text-dim); + width: 72px; + flex-shrink: 0; + text-transform: uppercase; +} + +.reward-track { + flex: 1; + height: 8px; + background: var(--bg); + border: 1px solid var(--border); + overflow: hidden; +} + +.reward-bar { + height: 100%; + transition: width 0.4s ease; +} + +.reward-value { + font-family: var(--font-display); + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + width: 36px; + text-align: right; + flex-shrink: 0; +} + +.reward-value.dim { + color: var(--text-dim); +} + +.reward-divider { + height: 1px; + background: var(--border); + margin: 6px 0; +} + +/* ─── INFO BADGES ─── */ +.info-badges { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.info-key { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; + color: var(--text-dim); + text-transform: uppercase; +} + +.info-val { + font-family: var(--font-display); + font-size: 11px; + font-weight: 700; + color: var(--text-primary); +} + +.info-val.bool-true { + color: var(--progress); +} + +.info-val.bool-false { + color: #ef4444; +} + +.info-val.dim { + color: var(--text-dim); +} + +/* ─── TARGET SELECTOR ─── */ +.target-selector { + display: flex; + align-items: center; + gap: 8px; +} + +.target-selector-label { + font-family: var(--font-display); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.10em; + color: var(--text-dim); + text-transform: uppercase; + white-space: nowrap; +} + +.target-select { + background: var(--surface-2); + border: 1px solid var(--border-bright); + color: var(--text-primary); + font-family: var(--font-display); + font-size: 11px; + padding: 4px 8px; + outline: none; + cursor: pointer; + min-width: 180px; +} + +.target-select:focus { + border-color: var(--valley); +} + +optgroup { + background: var(--surface); + color: var(--text-dim); + font-family: var(--font-display); + font-size: 10px; +} + +option { + background: var(--surface-2); + color: var(--text-primary); + font-family: var(--font-display); +} + +/* ─── PLAYER CONTROLS ─── */ +.player-controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.ctrl-btn { + background: var(--surface-2); + border: 1px solid var(--border-bright); + color: var(--text-primary); + font-family: var(--font-display); + font-size: 11px; + font-weight: 500; + padding: 4px 10px; + cursor: pointer; + white-space: nowrap; + line-height: 1.4; + letter-spacing: 0.04em; + transition: background 0.1s, border-color 0.1s; +} + +.ctrl-btn:hover:not(:disabled) { + background: var(--surface); + border-color: var(--text-dim); +} + +.ctrl-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.ctrl-btn.play { + border-color: var(--valley); + color: var(--valley); +} + +.ctrl-btn.play:hover:not(:disabled) { + background: rgba(56, 189, 248, 0.1); +} + +.ctrl-step-display { + font-family: var(--font-display); + font-size: 11px; + color: var(--text-dim); + padding: 4px 8px; + border: 1px solid var(--border); + background: var(--bg); + white-space: nowrap; + min-width: 72px; + text-align: center; +} + +/* ─── LOADING / ERROR ─── */ +.app-overlay { + position: fixed; + inset: 0; + display: flex; align-items: center; justify-content: center; - font-size: calc(10px + 2vmin); - color: white; + background: var(--bg); + z-index: 100; +} + +.overlay-message { + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 0.1em; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 12px; } -.App-link { - color: #61dafb; +.pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--valley); + animation: pulse 1.2s ease-in-out infinite; } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +@keyframes pulse { + 0%, 100% { opacity: 0.2; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1); } +} + +/* ─── MISC ─── */ +.episode-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 16px; + font-family: var(--font-display); + font-size: 11px; + color: var(--text-dim); + letter-spacing: 0.08em; } diff --git a/src/App.js b/src/App.js index 37845757234ccb68531c10cf7a2ffc589c47e342..5b5842f6787d369c34cd3e280637d38071b9c2b7 100644 --- a/src/App.js +++ b/src/App.js @@ -1,23 +1,239 @@ -import logo from './logo.svg'; +import { useState, useEffect, useCallback, useRef } from 'react'; import './App.css'; +import CreaseCanvas from './components/CreaseCanvas'; +import RewardPanel from './components/RewardPanel'; +import StepFeed from './components/StepFeed'; +import InfoBadges from './components/InfoBadges'; +import TargetSelector from './components/TargetSelector'; +import PlayerControls from './components/PlayerControls'; +import Fold3DCanvas from './components/Fold3DCanvas'; + +const API_BASE = ''; + +// Read ?ep=
- Edit src/App.js and save to reload.
-