Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Setup Node for cross-language digest tests
uses: actions/setup-node@v7
with:
# Keep aligned with OpenLegged/BotLab's .nvmrc.
node-version: '24.21.0'

- name: Set up uv with Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v5
with:
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ resource preset fields.
| `botlab ui status` | Show the status of the bundled UI snapshot. |
| `botlab docs` | Print where the bundled docs (component reference, decision table, error guide) live in this install. |

Resource names are identities: `Sensor(robot, name="Depth")` refers to the
robot's declared ID even when `Robot(..., name="G1")` has a separate MJCF path.
The CLI validates that reference without treating `G1` as a file.

For MJCF robot and scene resources, validation collects XML includes and file
assets (including compiler `meshdir`, `texturedir`, and `assetdir`) into the
release asset inventory. Relative dependencies may live in sibling directories
inside the project; missing files, cycles and dependencies escaping the project
are reported before packaging. Native model transforms preserve that dependency
layout, so terrain meshes do not need to be copied beneath the robot directory.

## Standalone site (`botlab build --site <dir>`)

`botlab build --site docs/demo` writes a directory you can push to any static
Expand All @@ -122,7 +133,7 @@ host — GitHub Pages included — next to the `release.zip`:
docs/demo/
├── index.html ← the embed runtime entry, renamed
├── assets/, wasm/ ← the runtime closure scanned from the bundled UI snapshot
├── project/ ← the whole project directory (robot MJCF, meshes, …)
├── project/ ← declared source files and asset dependencies (MJCF, meshes, …)
└── release.zip ← fetched by the page itself
```

Expand All @@ -131,6 +142,13 @@ Every runtime URL is page-relative, so the site works from a sub-path such as
runtime is single-threaded). Visitors can drag the robot, drive it with the
keyboard, and switch policies without installing anything.

The site copies only the source files and asset paths recorded in the release,
including the collected MJCF dependency closure. Unrelated files such as virtual
environments, logs, downloads and previous site outputs are not scanned or copied.
Missing declared files and symlinks escaping the project fail before site files
are written. You can build directly from the project root, including when the
output lives below it; no staging copy is needed.

The project must be self-contained — every resource under the project
directory. A package that still needs a live-Python (`modify=…`) controller is
rejected with a hint, matching what the embed runtime itself would do.
Expand Down
2 changes: 1 addition & 1 deletion docs/component-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

| 调用 | 维度 | 备注 |
|------|------|------|
| `robot.base_angular_velocity(scale=, source=)` | 3 | |
| `robot.base_angular_velocity(scale=, source=)` | 3 | 自由根关节读取机体系角速度,无需再次逆旋转 |
| `robot.base_linear_velocity(scale=, source=)` | 3 | |
| `robot.projected_gravity(target=)` | 3 | `target="root"` 或 body 名 |
| `robot.base_height()` | 1 | |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ classifiers = [
]
dependencies = [
"PyYAML>=6.0",
"rfc8785>=0.1.4,<0.2",
]

[project.urls]
Expand Down
103 changes: 103 additions & 0 deletions src/botlab/_mjcf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Local MJCF dependency closure shared by validation and native staging."""
from __future__ import annotations

import os
import xml.etree.ElementTree as ET
from pathlib import Path

from ._host import BotLabError


def mjcf_dependencies(source: Path, *, root: Path | None = None, transform=None,
entry_transform=None, _model_stack=()) -> tuple[dict[Path, ET.Element], set[Path]]:
"""Collect includes and file assets without walking unrelated directories.

Include lookup follows MuJoCo: main XML directory first, then the including
directory. Asset paths use the main XML's compiler directories. When packaging,
``root`` bounds both lexical paths and symlink targets to the project.
"""
source = Path(os.path.abspath(source))
if source.resolve() in _model_stack:
raise BotLabError(f'MJCF attached model cycle: "{source}"')
base = source.parent
documents: dict[Path, ET.Element] = {}
assets: set[Path] = set()
compiler: dict[str, str] = {}
elements: list[ET.Element] = []

def checked(path: Path) -> Path:
path = Path(os.path.abspath(path))
if root is not None and (not path.is_relative_to(root.resolve())
or not path.resolve().is_relative_to(root.resolve())):
raise BotLabError(f'MJCF dependency escapes the project: "{path}"')
if not path.is_file():
raise BotLabError(f'MJCF dependency does not exist: "{path}"')
return path

def visit(path: Path, stack: tuple[Path, ...]):
path = checked(path)
if path.resolve() in {item.resolve() for item in stack}:
raise BotLabError(f'MJCF include cycle: {" -> ".join(map(str, (*stack, path)))}')
if path in documents:
return
text = path.read_text(encoding="utf-8")
if transform is not None:
text = transform(text)
if path == source and entry_transform is not None:
text = entry_transform(text)
try:
document = ET.fromstring(text)
except ET.ParseError as error:
raise BotLabError(f'Invalid MJCF XML "{path}": {error}') from error
documents[path] = document
for element in document.iter():
if element.tag == "include":
file = element.get("file")
if not file:
raise BotLabError(f'MJCF include has no file in "{path}"')
if root is not None and Path(file).is_absolute():
raise BotLabError(f'MJCF include must be project-relative: "{file}"')
candidate = base / file
if not candidate.is_file():
candidate = path.parent / file
candidate = checked(candidate)
# Explicit main-relative paths preserve resolution after staging.
element.set("file", os.path.relpath(candidate, base))
visit(candidate, (*stack, path))
elif element.tag == "compiler":
compiler.update(element.attrib)
else:
elements.append(element)

visit(source, ())
for element in elements:
if element.tag not in {"mesh", "texture", "hfield", "skin", "model", "flexcomp"}:
continue
keys = ("file", "filename")
if element.tag == "texture":
keys += ("fileleft", "fileright", "fileup", "filedown", "filefront", "fileback")
for key in keys:
file = element.get(key)
if not file:
continue
if compiler.get("strippath") == "true":
file = file.replace("\\", "/").rsplit("/", 1)[-1]
directory = ""
if element.tag in {"mesh", "hfield"}:
directory = compiler.get("meshdir", compiler.get("assetdir", ""))
elif element.tag == "texture":
directory = compiler.get("texturedir", compiler.get("assetdir", ""))
if root is not None and (Path(directory).is_absolute() or Path(file).is_absolute()):
raise BotLabError(f'MJCF asset must be project-relative: "{directory}/{file}"')
candidate = checked(base / directory / file)
if element.tag == "model":
# Attached MJCF models have their own compiler/include scope.
nested, nested_assets = mjcf_dependencies(
candidate, root=root, transform=transform,
_model_stack=(*_model_stack, source.resolve()),
)
documents.update(nested)
assets.update(nested_assets)
else:
assets.add(candidate)
return documents, assets
57 changes: 37 additions & 20 deletions src/botlab/_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from pathlib import Path
from typing import Any

import rfc8785
import yaml

from . import _host
Expand Down Expand Up @@ -146,49 +147,65 @@ def _project_relative_assets(project_dir: Path, source_files: dict[str, str], ma
assets: set[str] = set()
for resource in manifest["resources"]:
source = resource["source"]
if resource["kind"] == "sensor":
target = next((item for item in manifest["resources"] if item["id"] == source), None)
if target is None or target["kind"] != "robot":
raise BotLabError(f'Sensor {resource["id"]!r} must reference a declared robot, got {source!r}')
continue
if source.startswith(("http://", "https://", "data:", "procedural://")):
continue
if not _is_safe_archive_path(source):
raise BotLabError(f'Unsafe project asset path "{source}" in resource {resource["id"]!r}')
candidate = project_dir / source
if not candidate.resolve().is_relative_to(project_dir.resolve()):
raise BotLabError(f'Resource path "{source}" escapes the project.')
if not candidate.exists():
raise BotLabError(
f'Resource {resource["id"]!r} points at "{source}", which does not exist in the project.'
)
if candidate.is_file():
assets.add(source)
if resource["kind"] in ("robot", "scene") and candidate.suffix.lower() in (".xml", ".mjcf"):
from ._mjcf import mjcf_dependencies

documents, dependencies = mjcf_dependencies(candidate, root=project_dir)
assets.update(path.relative_to(project_dir).as_posix() for path in set(documents) | dependencies)
elif candidate.is_dir():
for item in sorted(candidate.rglob("*")):
if item.is_file():
if not item.resolve().is_relative_to(project_dir.resolve()):
raise BotLabError(f'Resource path "{item}" escapes the project.')
assets.add(item.relative_to(project_dir).as_posix())
else:
raise BotLabError(f'Resource path "{source}" is neither a file nor a directory.')
return sorted(assets)


def _canonical_value(value: Any) -> Any:
"""Mirror the TS ``canonicalValue``: sort object keys recursively.

JS ``Object.keys(value).sort()`` reorders every nested object, so the
digest input is key-order-canonical on both sides.
"""
if isinstance(value, dict):
return {key: _canonical_value(value[key]) for key in sorted(value)}
if isinstance(value, list):
return [_canonical_value(item) for item in value]
return value


def canonical_json(value: Any) -> str:
"""Serialize like JS ``JSON.stringify`` of the canonicalized digest input.
"""Match existing browser canonicalValue + JSON.stringify bytes (release v3).

``pythonReleaseProjectDigest`` canonicalizes (recursively sorted object
keys, finite numbers only) then digests ``JSON.stringify`` output.
Python's ``json`` with ``separators=(',', ':')`` produces the same bytes
as JS as long as floats render identically; integer-valued floats were
normalized to ``int`` upstream (JS ``JSON.stringify(1.0)`` is ``"1"``).
JCS supplies ECMAScript number rendering. Objects retain JSON.stringify's
numeric-index-first enumeration, which differs from pure JCS key ordering.
"""
return json.dumps(_canonical_value(value), ensure_ascii=False, separators=(",", ":"))
if isinstance(value, dict):
def key_order(key):
index = int(key) if key.isascii() and key.isdecimal() else -1
if 0 <= index < 2**32 - 1 and str(index) == key:
return (0, index)
return (1, key.encode("utf-16-be", errors="surrogatepass"))

return "{" + ",".join(canonical_json(key) + ":" + canonical_json(value[key])
for key in sorted(value, key=key_order)) + "}"
if isinstance(value, (list, tuple)):
return "[" + ",".join(map(canonical_json, value)) + "]"
if isinstance(value, (int, float)) and not isinstance(value, bool):
try:
return rfc8785.dumps(float(value)).decode("utf-8")
except (ValueError, OverflowError) as error:
raise BotLabError("Release digest contains a non-finite number") from error
# Well-formed JSON.stringify escapes lone UTF-16 surrogates, not other Unicode.
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
return "".join(f"\\u{ord(char):04x}" if 0xD800 <= ord(char) <= 0xDFFF else char for char in text)


def python_release_project_digest(manifest: dict, files: dict[str, str]) -> str:
Expand Down
6 changes: 5 additions & 1 deletion src/botlab/_runtime/_host_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def __init__(self, physics, display=None, models=None, registry=None,
self._groups = []
self._plan_nodes = None
self._plan_output = {}
self._plan_robot_id = None
self._modifier = None
self._reset_callback = None
self._step_callback = None
Expand Down Expand Up @@ -153,6 +154,7 @@ def registerPolicyController(self, plan, modifier=None):
self._executor = executor
self._plan_nodes = nodes
self._plan_output = plan.get("output") or {}
self._plan_robot_id = plan.get("robot")
self._modifier = modifier
if modifier is not None:
self._attach_action_hook(modifier)
Expand Down Expand Up @@ -542,7 +544,9 @@ def _control_payload(self):
view = processor.get_substep_pd() if processor is not None else None
if view is None:
raise RuntimeError("[botlab] PolicyController PD output is unavailable")
return None, view
# The PD processor emits robot-local indices, just like a controller
# group. Resolve all three absolute index arrays before worker stepping.
return self._physics.compose_payloads({self._plan_robot_id: ("pd", view)})

def _fill_parameters(self, name):
import botlab_host
Expand Down
6 changes: 6 additions & 0 deletions src/botlab/_runtime/_observations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,12 @@ def _base_angular_velocity(model, data, config):
if sensor_gyro is not None:
return sensor_gyro
if _has_root_free_joint(model, data):
# Match the browser contract: marked plans use MuJoCo's body-local
# rotational qvel; previously published unmarked plans keep their path.
if (config or {}).get("rootAngularVelocityFrame") == "body":
return _float32_slice(data.qvel, "data.qvel", 3, 6)
if "rootAngularVelocityFrame" in (config or {}):
raise _error("mujocoObservations", "rootAngularVelocityFrame must be body")
return _inverse_rotate_by_quaternion(
_float32_slice(data.qvel, "data.qvel", 3, 6),
_root_kinematic_quaternion(model, data),
Expand Down
74 changes: 21 additions & 53 deletions src/botlab/_runtime/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,62 +113,30 @@ def _robot_format(path):


def _load_model_with_transform(source, transform, mujoco_module, entry_transform=None):
"""Compile the entry XML with ``transform`` applied to every XML in its
include chain — the browser does the same per VFS file (transforms.all).

XMLs land in a temp directory with the include layout preserved; every
other file of the model tree is symlinked so meshes still resolve.
``entry_transform`` runs after ``transform`` on the entry file only — the
composed-scene injection point (the browser composes on the entry too).
"""
"""Stage only the model dependency closure, preserving sibling directories."""
import os
import re
import shutil
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

model_dir = os.path.dirname(os.path.abspath(source))
entry_name = os.path.basename(source)
tmp_dir = tempfile.mkdtemp(prefix="botlab-model-")
try:
written = set()

def process(xml_path, rel_name):
if rel_name in written:
return
written.add(rel_name)
with open(xml_path, encoding="utf-8") as handle:
text = handle.read()
text = transform(text)
if entry_transform is not None and rel_name == entry_name:
text = entry_transform(text)
target = os.path.join(tmp_dir, rel_name)
os.makedirs(os.path.dirname(target) or tmp_dir, exist_ok=True)
with open(target, "w", encoding="utf-8") as handle:
handle.write(text)
for include in re.finditer(r"<include\s+file=\"([^\"]+)\"", text):
include_rel = os.path.normpath(include.group(1)).replace(os.sep, "/")
include_abs = os.path.normpath(os.path.join(model_dir, include_rel))
if os.path.isfile(include_abs):
process(include_abs, include_rel)

process(os.path.abspath(source), entry_name)

for root, _dirs, files in os.walk(model_dir):
for name in files:
absolute = os.path.join(root, name)
rel = os.path.relpath(absolute, model_dir).replace(os.sep, "/")
if rel in written:
continue
destination = os.path.join(tmp_dir, rel)
os.makedirs(os.path.dirname(destination) or tmp_dir, exist_ok=True)
if not os.path.lexists(destination):
os.symlink(absolute, destination)

return mujoco_module.MjModel.from_xml_path(
os.path.join(tmp_dir, os.path.basename(source))
)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
from .._mjcf import mjcf_dependencies

source = Path(os.path.abspath(source))
documents, assets = mjcf_dependencies(
source, transform=transform, entry_transform=entry_transform,
)
common = Path(os.path.commonpath([str(path.parent) for path in set(documents) | assets]))
with tempfile.TemporaryDirectory(prefix="botlab-model-") as temporary:
root = Path(temporary)
for path, document in documents.items():
target = root / path.relative_to(common)
target.parent.mkdir(parents=True, exist_ok=True)
ET.ElementTree(document).write(target, encoding="utf-8")
for path in assets - set(documents):
target = root / path.relative_to(common)
target.parent.mkdir(parents=True, exist_ok=True)
target.symlink_to(path)
return mujoco_module.MjModel.from_xml_path(str(root / source.relative_to(common)))


def _build_physics(state, directory, path_resolver):
Expand Down
Loading
Loading