diff --git a/docs/user-guides/dojo.md b/docs/user-guides/dojo.md index aaac6b7b..74109951 100644 --- a/docs/user-guides/dojo.md +++ b/docs/user-guides/dojo.md @@ -78,5 +78,18 @@ Dojo is split into two primary environments depending on your current needs: --- +## Plot Notes + +The trial viewer supports freeform notes anchored to specific points on a plot. + +- **Add a note:** Middle-click anywhere on the plot area. The Notes panel opens with the cursor's data coordinates pre-filled. +- **Edit or delete:** Open the Notes panel (the speech-bubble icon in the sidebar) and use the edit/delete controls next to each note. +- **Jump to a note:** Click the note's coordinates in the list to zoom the plot to that location. +- **Close the panel:** Left-click anywhere outside the Notes panel, or press ++esc++. + +Notes are persisted in the trial's saved configuration and are included when sharing a view via the share URL. + +--- + !!! success Dojo is now live! Now that you have the interface running, move on to the **Monitor** tool to learn how to track your job's progress in real-time. diff --git a/docs/user-guides/mosaic.md b/docs/user-guides/mosaic.md index 27411eea..60f92fe0 100644 --- a/docs/user-guides/mosaic.md +++ b/docs/user-guides/mosaic.md @@ -58,7 +58,7 @@ Adjust the visual style. Switch between Lines, Markers, or both. Change interpol ### Notes Editor -Click anywhere on the plot to drop a persistent note. These are saved into the JSON config and help you flag specific events (like "Impact Start" or "Sensor Saturation"). The editor allows you to go back and edit the note's label. Typos happen! +**Middle-click** anywhere on the plot to drop a persistent note at the exact data coordinates of your cursor. Notes are saved into the JSON config and help you flag specific events (like "Impact Start" or "Sensor Saturation"). The editor allows you to go back and edit the note's label. Press ++esc++ to cancel an in-progress note or close the panel. ???+ tip "Tip: Clickable Buttons" You can click on a note in the editor to "focus" on the note! @@ -71,18 +71,38 @@ Click anywhere on the plot to drop a persistent note. These are saved into the J ### Shapes Editor -Add various shapes to the plot. After selecting an option in the menu, your mouse becomes a placement tool. Clicking on the plot configures the placement of the shape. +Add various shapes to the plot. After selecting an option in the menu, your mouse becomes a placement tool. Clicking on the plot configures the placement of the shape. Press ++esc++ at any time to cancel placement. - **V-Line:** Draws a vertical line on the plot. The abscissa of the mouse determines the abscissa of the line. - **H-Line:** Draws a horizontal line on the plot. The ordinate of the mouse determines the ordinate of the line. - **Rect:** Draws a rectangle on the plot. The abscissa and ordinate of the mouse determines one of the corners of the rectangle. Likewise, the second click determines the other corner. +Each new shape is automatically assigned the next color in the preset palette so overlapping shapes remain visually distinct. The shape editor includes a full color picker (identical to the signal color picker) for precise color control. +
Shapes editor menu Shapes editor menu
The menu for shapes editor is active and in editing mode. A shape's label and color is being edited. The plot area shows a vertical (yellow) and horizontal (red) line and a rectangle (purple).
+### Saved Profiles + +Profiles save your complete plot configuration (selected signals, axes, display settings, zoom, annotations, and shapes) under a named key so you can restore a view instantly. + +Profiles are stored globally in `~/.mujoco-mojo/profiles/` and are available across all workdirs, making it easy to share a standard analysis setup between projects. + +**Creating a profile** + +Type a name in the save box and press **Save** or ++enter++. To organize profiles into folders use a `folder/name` path, for example `arm-reach/baseline`. Folders are created automatically and the input field offers Tab-completion for existing folder names. + +**Loading a profile** + +Click **Load** next to any profile in the list. If the profile references signals, axes, or reference frames that do not exist in the current trial it will be rejected with an error toast rather than silently applying an invalid configuration. Profiles that are incompatible with the current trial are marked with a warning icon in the list. + +**Searching profiles** + +A search box filters the profile list by name (including folder path) as you type. + ### Undo/Redo Every change to the plot configuration is tracked. Use the standard shortcuts (++ctrl+z++ / ++ctrl+y++ or ++cmd+z++ / ++cmd+shift+z++) to step through your edits. diff --git a/scripts/gen_ts_models.py b/scripts/gen_ts_models.py new file mode 100644 index 00000000..cb84f905 --- /dev/null +++ b/scripts/gen_ts_models.py @@ -0,0 +1,176 @@ +""" +Generate TypeScript types from Pydantic models in plot_config.py. + +Run from the repo root: + + python scripts/gen_ts_models.py + +Writes: + src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/plot-config.generated.ts +""" + +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +ROOT = Path(__file__).parent.parent +TS_OUT = ( + ROOT + / "src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib" + / "plot-config.generated.ts" +) + +# --------------------------------------------------------------------------- +# JSON Schema → TypeScript converter +# --------------------------------------------------------------------------- + + +def _ref_name(ref: str) -> str: + """'#/$defs/FooBar' → 'FooBar'""" + return ref.rsplit("/", 1)[-1] + + +def _schema_to_ts(node: dict, defs: dict) -> str: + """Recursively convert a JSON Schema node to a TypeScript type string.""" + if "$ref" in node: + return _ref_name(node["$ref"]) + + if "anyOf" in node: + parts = [_schema_to_ts(s, defs) for s in node["anyOf"]] + return " | ".join(parts) + + if "enum" in node: + return " | ".join(f'"{v}"' for v in node["enum"]) + + t = node.get("type") + + if t == "string": + return "string" + if t in ("number", "integer"): + return "number" + if t == "boolean": + return "boolean" + if t == "null": + return "null" + + if t == "array": + if "prefixItems" in node: + items = [_schema_to_ts(s, defs) for s in node["prefixItems"]] + return f"[{', '.join(items)}]" + if "items" in node: + return f"{_schema_to_ts(node['items'], defs)}[]" + return "unknown[]" + + if t == "object": + add_props = node.get("additionalProperties") + props = node.get("properties", {}) + required = set(node.get("required", [])) + + if add_props and not props: + # Pure dict / Record + if add_props is True or add_props == {}: + return "Record" + return f"Record" + + # Inline object (rare — models surface as $defs, not inline) + lines = _props_to_ts_lines(props, required, defs) + if add_props is True: + lines.append(" [key: string]: unknown;") + body = "\n".join(lines) + return "{\n" + body + "\n}" + + return "unknown" + + +def _props_to_ts_lines( + props: dict, + required: set[str], + defs: dict, +) -> list[str]: + """Return one ' field?: Type;' string per property.""" + lines: list[str] = [] + for name, schema in props.items(): + ts_type = _schema_to_ts(schema, defs) + opt = "" if name in required else "?" + lines.append(f" {name}{opt}: {ts_type};") + return lines + + +# --------------------------------------------------------------------------- +# $defs → top-level TypeScript declarations +# --------------------------------------------------------------------------- + + +def _def_to_ts(name: str, schema: dict, defs: dict) -> str: + """Convert one $defs entry to a TypeScript type or interface block.""" + # StrEnum / Literal → export type Name = "a" | "b"; + if "enum" in schema: + values = " | ".join(f'"{v}"' for v in schema["enum"]) + return f"export type {name} = {values};\n" + + # BaseModel → export interface Name { ... } + if schema.get("type") == "object": + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + lines = _props_to_ts_lines(props, required, defs) + if schema.get("additionalProperties") is True: + lines.append(" [key: string]: unknown;") + body = "\n".join(lines) if lines else " [key: string]: unknown;" + return f"export interface {name} {{\n{body}\n}}\n" + + return f"// Unhandled $def: {name}\n" + + +def _top_level_interface(schema: dict, defs: dict) -> str: + """Generate the top-level model (PlotConfig) as an interface.""" + name = schema.get("title", "PlotConfig") + desc = schema.get("description", "") + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + lines = _props_to_ts_lines(props, required, defs) + body = "\n".join(lines) + comment = f"/** {desc} */\n" if desc else "" + return f"{comment}export interface {name} {{\n{body}\n}}\n" + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + # Import inside main so sys.path manipulation is localised + sys.path.insert(0, str(ROOT / "src")) + from mujoco_mojo.utils.layers.dojo.plot_config import PlotConfig + + schema = PlotConfig.model_json_schema() + defs: dict = schema.get("$defs", {}) + + blocks: list[str] = [] + + # Emit each $def in definition order + for def_name, def_schema in defs.items(): + blocks.append(_def_to_ts(def_name, def_schema, defs)) + + # Emit the top-level PlotConfig interface + blocks.append(_top_level_interface(schema, defs)) + + header = textwrap.dedent("""\ + // ============================================================ + // AUTO-GENERATED - do not edit manually. + // Source: src/mujoco_mojo/utils/layers/dojo/plot_config.py + // Regenerate: python scripts/gen_ts_models.py + // ============================================================ + + """) + + output = header + "\n".join(blocks) + TS_OUT.parent.mkdir(parents=True, exist_ok=True) + TS_OUT.write_text(output, encoding="utf-8") + print(f"Written → {TS_OUT.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/src/mujoco_mojo/utils/layers/dojo/plot_config.py b/src/mujoco_mojo/utils/layers/dojo/plot_config.py new file mode 100644 index 00000000..8efa02b3 --- /dev/null +++ b/src/mujoco_mojo/utils/layers/dojo/plot_config.py @@ -0,0 +1,152 @@ +""" +Pydantic models for the Dojo trial-viewer PlotConfig. + +These are the single source of truth for both server-side validation +(profile save/load) and the generated TypeScript types in +``lib/plot-config.generated.ts``. + +To regenerate TypeScript types after changing this file: + + python scripts/gen_ts_models.py +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# String-enum types (generate named TypeScript union types) +# --------------------------------------------------------------------------- + + +class DashStyle(StrEnum): + solid = "solid" + dash = "dash" + dot = "dot" + dashdot = "dashdot" + + +class MarkerSymbol(StrEnum): + none = "none" + circle = "circle" + square = "square" + diamond = "diamond" + cross = "cross" + + +class GridMode(StrEnum): + none = "none" + major = "major" + all = "all" + + +class LineMode(StrEnum): + lines = "lines" + markers = "markers" + lines_and_markers = "lines+markers" + + +class InterpMode(StrEnum): + linear = "linear" + spline = "spline" + hv = "hv" + vh = "vh" + hvh = "hvh" + vhv = "vhv" + + +class HoverMode(StrEnum): + x_unified = "x unified" + y_unified = "y unified" + closest = "closest" + x = "x" + y = "y" + none = "none" + + +class LegendPos(StrEnum): + bottom = "bottom" + right = "right" + hidden = "hidden" + + +class ScaleType(StrEnum): + linear = "linear" + log = "log" + + +class ShapeType(StrEnum): + vline = "vline" + hline = "hline" + rect = "rect" + + +# --------------------------------------------------------------------------- +# Composite models +# --------------------------------------------------------------------------- + + +class FilterEntry(BaseModel): + """A single filter step applied to a signal. Extra keys are preserved.""" + + model_config = ConfigDict(extra="allow") + + type: str + enabled: bool = True + + +class YAxisConfig(BaseModel): + label: str + color: str + width: float + opacity: float + filters: list[FilterEntry] + dash: DashStyle + marker: MarkerSymbol + + +class Annotation(BaseModel): + x: float + y: float + text: str + + +class Shape(BaseModel): + type: ShapeType + x0: float + x1: float | None = None + y0: float | None = None + y1: float | None = None + color: str + dash: DashStyle | None = None + label: str + + +class PlotConfig(BaseModel): + """Complete serialisable state of a trial-viewer plot.""" + + xAxis: str + yAxes: dict[str, YAxisConfig] + refFrame: str | None + grid: GridMode + linemode: LineMode + interp: InterpMode + hover: HoverMode + title: str + xAxisTitle: str + yAxisTitle: str + showSpike: bool + legendPos: LegendPos + rangeX: Annotated[tuple[float, float], Field()] | None + rangeY: Annotated[tuple[float, float], Field()] | None + xScale: ScaleType + yScale: ScaleType + xLogBase: float | None = None + yLogBase: float | None = None + vsEnabled: bool + vsRange: Annotated[tuple[float, float], Field()] + annotations: list[Annotation] + shapes: list[Shape] diff --git a/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py b/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py index b9ed1616..53642a5c 100644 --- a/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py +++ b/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py @@ -19,16 +19,17 @@ from mujoco_mojo.utils.log import get_logger from .. import shared +from ..plot_config import PlotConfig as _PlotConfig logger = get_logger(__name__) router = APIRouter() -# Derived from FilterType enum — automatically includes any new filter type added to filters.py. +# Derived from FilterType enum ; automatically includes any new filter type added to filters.py. # Used to identify the filter name in Pydantic error location tuples when formatting messages. _FILTER_TYPE_NAMES: set[str] = {str(ft) for ft in _FilterType} -# Derived from AnyFilter's union members — automatically includes any new filter class. +# Derived from AnyFilter's union members ; automatically includes any new filter class. # AnyFilter = Annotated[ScaleFilter | AbsoluteValueFilter | ..., Field(discriminator="type")] # get_args(AnyFilter)[0] is the bare union; get_args of that gives the individual classes. _annotated_args = get_args(_AnyFilter) @@ -57,7 +58,7 @@ def _format_filter_error(exc: Exception) -> str: errors = exc.errors() # older pydantic build without include_url kwarg if not errors: - return "Filter validation failed — check filter settings" + return "Filter validation failed - check filter settings" first = errors[0] loc: tuple = first.get("loc", ()) @@ -100,7 +101,7 @@ def _format_filter_error(exc: Exception) -> str: # ── Tagged-union discriminator mismatch (bad filter type string) ─────── if "union" in err_type or "tagged" in err_type: - return "Unknown filter type — check filter configuration" + return "Unknown filter type - check filter configuration" # ── Fallback: clean up the raw Pydantic message ─────────────────────── clean = re.sub(r"\s*\[type=\w+.*?\]\s*$", "", msg).strip() @@ -108,7 +109,7 @@ def _format_filter_error(exc: Exception) -> str: return ( f"{prefix}{clean}" if clean - else "Filter validation failed — check filter settings" + else "Filter validation failed - check filter settings" ) @@ -209,7 +210,7 @@ async def get_filter_schema(): """ Returns metadata for all available filter types, derived from Pydantic models. - Filter classes are auto-discovered from AnyFilter's union — no changes needed here + Filter classes are auto-discovered from AnyFilter's union; no changes needed here when a new filter is added to filters.py. """ from pydantic_core import PydanticUndefined @@ -280,80 +281,116 @@ def _infer_type(prop: dict) -> str: # --------------------------------------------------------------------------- -# Profiles · named saved views stored under {workdir}/profiles/ +# Profiles · named saved views stored under ~/.mujoco-mojo/profiles/ # --------------------------------------------------------------------------- -def _get_profiles_dir() -> Path | None: - job = shared.CURRENT_JOB - if not job: - return None - d: Path = job.workdir / "profiles" - d.mkdir(exist_ok=True) +def _get_profiles_dir() -> Path: + d: Path = Path.home() / ".mujoco-mojo" / "profiles" + d.mkdir(parents=True, exist_ok=True) return d def _sanitize_profile_name(name: str) -> str: - """Return a filesystem-safe stem from the user-supplied profile name.""" - name = name.strip()[:128] - name = re.sub(r"[^\w\s\-]", "", name) # keep word chars, whitespace, hyphens - name = re.sub(r"\s+", "_", name) # spaces → underscores - name = re.sub(r"_+", "_", name).strip("_") - return name[:64] or "profile" + """ + Return a filesystem-safe relative path from the user-supplied profile name. + + Supports folder separators, e.g. 'robotics/arm_reach/baseline'. + Each segment is sanitized independently; empty segments are dropped. + """ + name = name.strip()[:256] + segments = [s.strip() for s in name.split("/") if s.strip()] + safe: list[str] = [] + for seg in segments: + seg = re.sub(r"[^\w\s\-]", "", seg) + seg = re.sub(r"\s+", "_", seg) + seg = re.sub(r"_+", "_", seg).strip("_") + if seg: + safe.append(seg[:64]) + return "/".join(safe) or "profile" + + +def _resolve_profile_path(name: str) -> Path: + """Return the resolved path for a profile, raising 400 if it escapes the profiles dir.""" + d = _get_profiles_dir() + path = (d / f"{_sanitize_profile_name(name)}.json").resolve() + if not path.is_relative_to(d.resolve()): + raise HTTPException(status_code=400, detail="Invalid profile name") + return path @router.get("/api/profiles") async def list_profiles(): - """List all saved profiles for the current job.""" + """List all saved profiles, including those in sub-folders.""" d = _get_profiles_dir() - if d is None: - raise HTTPException(status_code=404, detail="No active job") profiles = [ - {"name": f.stem, "modified": int(f.stat().st_mtime * 1000)} - for f in sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + { + "name": f.relative_to(d).with_suffix("").as_posix(), + "modified": int(f.stat().st_mtime * 1000), + } + for f in sorted( + d.rglob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True + ) ] return profiles -@router.get("/api/profiles/{name}") +@router.get("/api/profiles/{name:path}") async def get_profile(name: str): - """Return the PlotConfig JSON for a saved profile.""" - d = _get_profiles_dir() - if d is None: - raise HTTPException(status_code=404, detail="No active job") - path = d / f"{_sanitize_profile_name(name)}.json" + """Return the PlotConfig JSON for a saved profile, validated against the schema.""" + from pydantic import ValidationError + + path = _resolve_profile_path(name) if not path.exists(): raise HTTPException(status_code=404, detail="Profile not found") - return json.loads(path.read_text(encoding="utf-8")) + try: + data = json.loads(path.read_text(encoding="utf-8")) + config = _PlotConfig.model_validate(data) + except (json.JSONDecodeError, ValidationError) as exc: + raise HTTPException( + status_code=422, + detail=f"Profile '{name}' failed validation and cannot be loaded: {exc}", + ) from exc + return config.model_dump() + +_PROFILE_MAX_BYTES = 512 * 1024 # 512 KB (more than enough for any real PlotConfig) + + +@router.post("/api/profiles/{name:path}") +async def save_profile(name: str, request: Request, body: _PlotConfig): + """ + Save the current PlotConfig as a named profile. -@router.post("/api/profiles/{name}") -async def save_profile(name: str, request: Request): - """Save the current PlotConfig as a named profile.""" + FastAPI/Pydantic validates the request body structure automatically. + The Content-Length header is checked first as a lightweight size guard. + Sub-folder paths (e.g. 'project/baseline') are supported; directories + are created automatically. + """ + cl = request.headers.get("content-length") + if cl and int(cl) > _PROFILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="Profile payload too large") + path = _resolve_profile_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body.model_dump_json(), encoding="utf-8") d = _get_profiles_dir() - if d is None: - raise HTTPException(status_code=404, detail="No active job") - safe = _sanitize_profile_name(name) - if not safe: - raise HTTPException(status_code=400, detail="Invalid profile name") - body = await request.json() - (d / f"{safe}.json").write_text( - json.dumps(body, separators=(",", ":")), encoding="utf-8" - ) - return {"name": safe} + return {"name": path.relative_to(d).with_suffix("").as_posix()} -@router.delete("/api/profiles/{name}") +@router.delete("/api/profiles/{name:path}") async def delete_profile(name: str): """Delete a saved profile.""" - d = _get_profiles_dir() - if d is None: - raise HTTPException(status_code=404, detail="No active job") - path = d / f"{_sanitize_profile_name(name)}.json" + path = _resolve_profile_path(name) if not path.exists(): raise HTTPException(status_code=404, detail="Profile not found") path.unlink() - return {"deleted": name} + d = _get_profiles_dir() + # Remove empty parent directories up to (but not including) the profiles root. + parent = path.parent + while parent != d and parent.is_dir() and not any(parent.iterdir()): + parent.rmdir() + parent = parent.parent + return {"deleted": path.relative_to(d).with_suffix("").as_posix()} @lru_cache(maxsize=128) diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/base.html b/src/mujoco_mojo/utils/layers/dojo/templates/base.html index 562f4f2d..d9917000 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/base.html +++ b/src/mujoco_mojo/utils/layers/dojo/templates/base.html @@ -463,7 +463,7 @@

- +
+ @click.outside="if ($event.button !== 1) { annotationsOpen = false; annDraft = null; annEditIndex = null; }">
@@ -776,28 +776,19 @@ + {% from 'partials/trial_viewer/_macros.html' import color_picker %}