From 6f36921b05cb48969d6b98cd203df727df0946f3 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Sat, 24 Oct 2026 12:37:50 +0800 Subject: [PATCH 1/5] fix: preserve angular velocity frame and compose actuator mappings --- docs/component-reference.md | 2 +- src/botlab/_runtime/_host_runtime.py | 6 ++- src/botlab/_runtime/_observations.py | 7 ++- tests/runtime/test_host_runtime.py | 3 ++ tests/runtime/test_observations.py | 28 ++++++++-- tests/test_headless_actuator_mapping.py | 71 +++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 tests/test_headless_actuator_mapping.py diff --git a/docs/component-reference.md b/docs/component-reference.md index 17c305a..cbbc84c 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -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 | | diff --git a/src/botlab/_runtime/_host_runtime.py b/src/botlab/_runtime/_host_runtime.py index 579f3ff..0ca8982 100644 --- a/src/botlab/_runtime/_host_runtime.py +++ b/src/botlab/_runtime/_host_runtime.py @@ -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 @@ -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) @@ -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 diff --git a/src/botlab/_runtime/_observations.py b/src/botlab/_runtime/_observations.py index 1e90d41..b59825e 100644 --- a/src/botlab/_runtime/_observations.py +++ b/src/botlab/_runtime/_observations.py @@ -1037,10 +1037,9 @@ def _base_angular_velocity(model, data, config): if sensor_gyro is not None: return sensor_gyro if _has_root_free_joint(model, data): - return _inverse_rotate_by_quaternion( - _float32_slice(data.qvel, "data.qvel", 3, 6), - _root_kinematic_quaternion(model, data), - ) + # Free-joint rotational qvel is already in the root body frame. + # Only its translational component needs a world-to-body rotation. + return _float32_slice(data.qvel, "data.qvel", 3, 6) body_index = _base_body_index(model) address = body_index * 6 return _float32_slice(data.cvel, "data.cvel", address, address + 3) diff --git a/tests/runtime/test_host_runtime.py b/tests/runtime/test_host_runtime.py index 0231ffd..9d90c0f 100644 --- a/tests/runtime/test_host_runtime.py +++ b/tests/runtime/test_host_runtime.py @@ -210,12 +210,14 @@ def get_substep_pd(self): } physics = SpyPhysics(stop_after=1) + physics.compose_payloads = mock.Mock(side_effect=lambda actions: (None, actions["robot-1"][1])) session = _host_runtime.LocalRuntimeSession( physics, registry={**FAKE_REGISTRY, "proc-pd": PdStub}, ) self.addCleanup(session.close) session.registerPolicyController({ + "robot": "robot-1", "nodes": [ {"id": "c", "type": "obs-constant", "data": {"values": [1.0, 2.0]}}, {"id": "pd", "type": "proc-pd", "data": {}}, @@ -233,6 +235,7 @@ def get_substep_pd(self): _label, _steps, ctrl, substep_pd = pd_calls[0] self.assertIsNone(ctrl) self.assertEqual(substep_pd["target"], [0.5]) + physics.compose_payloads.assert_called_once_with({"robot-1": ("pd", substep_pd)}) def test_missing_policy_output_is_explicit(self): class NoOutputSource(ConstantSource): diff --git a/tests/runtime/test_observations.py b/tests/runtime/test_observations.py index 8515612..68e0108 100644 --- a/tests/runtime/test_observations.py +++ b/tests/runtime/test_observations.py @@ -368,12 +368,32 @@ def value(self, node_type, node_data=None, **extra): node_type, context(self.model, self.data, node_data, **extra) ) - def test_obs_base_ang_vel_inverse_rotates_root_qvel(self): - # R^T @ [0.1, 0, 0] for a 90 deg z quaternion = [0, -0.1, 0]. + def test_obs_base_ang_vel_preserves_body_local_root_qvel(self): + # MuJoCo free-joint angular qvel is already body-local, unlike translation. np.testing.assert_allclose( - self.value("obs-base-ang-vel"), [0, -0.1, 0], atol=1e-6 + self.value("obs-base-ang-vel"), [0.1, 0, 0], atol=1e-6 ) + def test_root_angular_velocity_matches_mujoco_local_object_velocity(self): + for quat in ([HALF_SQRT2, 0, 0, HALF_SQRT2], [0.5, 0.5, 0.5, 0.5]): + with self.subTest(quat=quat): + model, data = make_forward_model( + quat=quat, qvel=[0.2, -0.3, 0.4, 0.7, -0.8, 0.9, 0] + ) + velocity = np.zeros(6) + mujoco.mj_objectVelocity( + model, data, mujoco.mjtObj.mjOBJ_BODY, 1, velocity, 1 + ) + root = obs.get_mujoco_observation( + "obs-base-ang-vel", context(model, data, {"velocitySource": "root"}) + ) + np.testing.assert_allclose(root, velocity[:3], atol=1e-6) + np.testing.assert_allclose(root, data.sensordata[4:7], atol=1e-6) + imu = obs.get_mujoco_observation( + "obs-imu", context(model, data, {"velocitySource": "root"}) + ) + np.testing.assert_allclose(imu[4:], velocity[:3], atol=1e-6) + def test_obs_base_lin_vel_inverse_rotates_root_qvel(self): np.testing.assert_allclose( self.value("obs-base-lin-vel"), [0, -0.2, 0], atol=1e-6 @@ -402,7 +422,7 @@ def test_obs_projected_gravity_body_target(self): def test_obs_imu_g1_reads_sensors(self): # With a g1 profile the quaternion comes from the imu_quat sensor and # the angular velocity from the imu_gyro sensor (body frame), which - # differs from the kinematic inverse-rotation path. + # agrees with the root path when the site axes match the root body. output = self.value("obs-imu", {"robot": "g1"}) gyro = self.data.sensordata[4:7] np.testing.assert_allclose(output[:4], [HALF_SQRT2, 0, 0, HALF_SQRT2], atol=1e-6) diff --git a/tests/test_headless_actuator_mapping.py b/tests/test_headless_actuator_mapping.py new file mode 100644 index 0000000..bfa10da --- /dev/null +++ b/tests/test_headless_actuator_mapping.py @@ -0,0 +1,71 @@ +"""Exercise compiled PolicyController mappings through the public native runner.""" +import numpy as np +import pytest + +from botlab import open_headless +from tests.conftest import MAIN + + +def mapped_project(path, *, floating, order, mapping): + path.mkdir() + motors = [f'' for i in order] + root = '' if floating else '' + (path / 'robot.xml').write_text(f''' + ''') + config = dict(actionDimension=2, jointQposOffset=7 if floating else 0, + jointQvelOffset=6 if floating else 0, policyJointOrder=[0, 1], + motorJointOrder=[0, 1], defaultJointPositions=[0, 0], actionScale=1, + kp=[10, 20], kd=[0.5, 0.8], effortLimit=[5, 7]) + if mapping is not None: + config['actuatorJointOrder'] = mapping + (path / 'controller.py').write_text('''from botlab import Robot, PolicyController, constant, project +project("Actuator mapping", control_hz=50, physics_hz=200) +robot = Robot("robot.xml", config=''' + repr(config) + ''') +def create_controller(sim): + return PolicyController(robot, target=constant([0.2, -0.3]).action()) +''') + (path / 'main.py').write_text(MAIN) + return path + + +def rollout(path): + states, forces, controls = [], [], [] + with open_headless(path) as run: + for _ in range(25): + run.step() + data = run.physics.data + states.append(np.concatenate([data.qpos, data.qvel])) + forces.append(data.qfrc_actuator.copy()) + controls.append(data.ctrl.copy()) + return np.array(states), np.array(forces), np.array(controls) + + +@pytest.mark.parametrize('floating', [False, True]) +def test_single_policy_actuator_mapping_preserves_joint_forces_and_trajectory(tmp_path, floating): + baseline = rollout(mapped_project(tmp_path / 'plain', floating=floating, + order=[0, 1], mapping=None)) + for name, order, mapping in [('identity', [0, 1], [0, 1]), ('swapped', [1, 0], [1, 0])]: + actual = rollout(mapped_project(tmp_path / name, floating=floating, + order=order, mapping=mapping)) + np.testing.assert_allclose(actual[0], baseline[0], rtol=0, atol=1e-12) + np.testing.assert_allclose(actual[1], baseline[1], rtol=0, atol=1e-12) + np.testing.assert_allclose(actual[2][:, mapping], baseline[2], rtol=0, atol=1e-12) + assert np.max(np.abs(baseline[1])) > 0.1 + + +def test_single_policy_rejects_out_of_scope_actuator_mapping(tmp_path): + path = mapped_project(tmp_path / 'invalid', floating=True, order=[0, 1], mapping=[2, 0]) + with open_headless(path) as run: + with pytest.raises(ValueError, match='outside robot action dimension'): + run.step() From e0df0410ced783569b15586d4635a06b24a62646 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Sat, 24 Oct 2026 13:08:25 +0800 Subject: [PATCH 2/5] fix: resolve resource identities and MJCF dependencies with browser-compatible digests --- README.md | 11 ++++ pyproject.toml | 1 + src/botlab/_mjcf.py | 103 ++++++++++++++++++++++++++++++++ src/botlab/_project.py | 57 +++++++++++------- src/botlab/_runtime/run.py | 74 +++++++---------------- tests/test_mjcf_dependencies.py | 92 ++++++++++++++++++++++++++++ tests/test_project.py | 49 +++++++++++++++ 7 files changed, 314 insertions(+), 73 deletions(-) create mode 100644 src/botlab/_mjcf.py create mode 100644 tests/test_mjcf_dependencies.py diff --git a/README.md b/README.md index f730c79..6a8a890 100644 --- a/README.md +++ b/README.md @@ -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 `) `botlab build --site docs/demo` writes a directory you can push to any static diff --git a/pyproject.toml b/pyproject.toml index 66f59a4..c13bad7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ "PyYAML>=6.0", + "rfc8785>=0.1.4,<0.2", ] [project.urls] diff --git a/src/botlab/_mjcf.py b/src/botlab/_mjcf.py new file mode 100644 index 0000000..151529c --- /dev/null +++ b/src/botlab/_mjcf.py @@ -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 diff --git a/src/botlab/_project.py b/src/botlab/_project.py index 5b23c19..f296c5d 100644 --- a/src/botlab/_project.py +++ b/src/botlab/_project.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any +import rfc8785 import yaml from . import _host @@ -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: diff --git a/src/botlab/_runtime/run.py b/src/botlab/_runtime/run.py index da66213..4fbc6d7 100644 --- a/src/botlab/_runtime/run.py +++ b/src/botlab/_runtime/run.py @@ -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"") + (shared / "nested.xml").write_text("") + entry = robot / "scene.xml" + entry.write_text(""" + + + """) + return entry + + +def test_sibling_nested_dependencies_match_native(scene): + mujoco = pytest.importorskip("mujoco") + documents, assets = mjcf_dependencies(scene, root=scene.parent.parent) + assert len(documents) == 3 + assert {p.name for p in assets} == {"tetra.obj"} + original = mujoco.MjModel.from_xml_path(str(scene)) + transformed = _load_model_with_transform(scene, lambda xml: xml, mujoco) + np.testing.assert_array_equal(original.mesh_vert, transformed.mesh_vert) + np.testing.assert_array_equal(original.geom_pos, transformed.geom_pos) + np.testing.assert_array_equal(original.body_mass, transformed.body_mass) + + +def test_missing_dependency_and_cycle_are_diagnostic(scene): + (scene.parent.parent / 'shared/tetra.obj').unlink() + with pytest.raises(BotLabError, match='tetra.obj'): + mjcf_dependencies(scene) + scene.write_text("") + with pytest.raises(BotLabError, match='include cycle'): + mjcf_dependencies(scene) + + +def test_dependencies_cannot_escape_project_or_follow_external_symlinks(scene, tmp_path): + with pytest.raises(BotLabError, match='escapes the project'): + mjcf_dependencies(scene, root=scene.parent) + external = tmp_path.parent / (tmp_path.name + '-external.obj') + external.write_text('external') + mesh = tmp_path / 'shared/tetra.obj' + mesh.unlink() + mesh.symlink_to(external) + with pytest.raises(BotLabError, match='escapes the project'): + mjcf_dependencies(scene, root=tmp_path) + + +def test_compiler_assetdir_strippath_and_texture(tmp_path): + (tmp_path / 'assets').mkdir() + for name in ('mesh.obj', 'height.bin', 'color.png'): + (tmp_path / 'assets' / name).write_bytes(b'fixture') + entry = tmp_path / 'scene.xml' + entry.write_text(""" + + """) + _, assets = mjcf_dependencies(entry, root=tmp_path) + assert {p.name for p in assets} == {'mesh.obj', 'height.bin', 'color.png'} + + +def test_transform_applies_to_included_document(scene): + mujoco = pytest.importorskip('mujoco') + result = _load_model_with_transform(scene, lambda xml: xml.replace( + '', ""), mujoco) + assert result.ngeom == 2 + np.testing.assert_array_equal(result.geom_pos[0], [0, 0, 3]) + + +def test_packaging_rejects_absolute_dependencies(scene): + scene.write_text(f"") + with pytest.raises(BotLabError, match='must be project-relative'): + mjcf_dependencies(scene, root=scene.parent.parent) + + +def test_attached_model_cycles_fail_without_recursion(tmp_path): + a = tmp_path / 'a.xml' + b = tmp_path / 'b.xml' + a.write_text("") + b.write_text("") + with pytest.raises(BotLabError, match='attached model cycle'): + mjcf_dependencies(a, root=tmp_path) diff --git a/tests/test_project.py b/tests/test_project.py index 555daf7..0f2aa76 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -249,3 +249,52 @@ def test_modify_hook_project_travels_as_sources(tmp_path: Path): assert "project/controller.py" in archive.namelist() # digest still covers manifest + sources assert config["project"]["projectDigest"] == project.project_digest + + +def test_named_robot_sensor_identity_is_not_an_asset_path(project_dir): + controller = CONTROLLER.replace('Robot("assets/robot/scene.xml",', 'Robot("assets/robot/scene.xml", name="G1",') + controller += '\nfrom botlab import Sensor\ncamera = Sensor(robot, name="Depth", config={"outputMode": "depth"})\n' + (project_dir / 'controller.py').write_text(controller) + loaded = load_project(project_dir) + sensor = next(r for r in loaded.manifest['resources'] if r['kind'] == 'sensor') + assert sensor['source'] == 'G1' + assert loaded.compiled_plan['robot'] == 'G1' + assert 'G1' not in loaded.asset_paths + assert 'assets/robot/scene.xml' in loaded.asset_paths + create_release_package(loaded) + + +def test_sensor_requires_robot_reference(project_dir): + (project_dir / 'controller.py').write_text(CONTROLLER + '\nfrom botlab import Sensor\ncamera = Sensor("missing", name="Depth")\n') + with pytest.raises(BotLabError, match='must reference a declared robot'): + load_project(project_dir) + + +def test_project_collects_mjcf_sibling_assets(project_dir): + shared = project_dir / 'assets/shared' + shared.mkdir() + (shared / 'body.xml').write_text('') + (shared / 'mesh.obj').write_bytes(b'fixture') + (project_dir / 'assets/robot/scene.xml').write_text(""" + """) + loaded = load_project(project_dir) + assert {'assets/shared/body.xml', 'assets/shared/mesh.obj'} <= set(loaded.asset_paths) + + +def test_digest_matches_actual_node_for_number_and_key_boundaries(): + import shutil + import subprocess + + node = shutil.which('node') + if node is None: + pytest.skip('Node is needed for the cross-language digest test') + values = [6.812889478169382e-05, 1e-7, 1e-6, 1e20, 1e21, -0.0, 5e-324, + 1.7976931348623157e308, 333333333.33333329, 1000000000000000128] + payload = {'values': values, 'keys': {'10': 1, '2': 2, '01': 3, '\U00010000': 4, '\ue000': 5}, + 'text': '中文\n\ud800'} + expected = subprocess.check_output([node, '-e', ''' +const fs=require('fs'); const c=v=>Array.isArray(v)?v.map(c):v&&typeof v==='object'? +Object.fromEntries(Object.keys(v).sort().map(k=>[k,c(v[k])])):v; +process.stdout.write(JSON.stringify(c(JSON.parse(fs.readFileSync(0,'utf8'))))); +'''], input=json.dumps(payload).encode()).decode() + assert canonical_json(payload) == expected From 879f42c352a20bd357970941532d94a8d3d124b9 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Sat, 24 Oct 2026 14:36:44 +0800 Subject: [PATCH 3/5] fix: build standalone sites from declared project dependencies --- README.md | 9 ++++++- src/botlab/_site.py | 32 +++++++++++++++++----- tests/test_site.py | 65 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6a8a890..142bd1d 100644 --- a/README.md +++ b/README.md @@ -133,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 ``` @@ -142,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. diff --git a/src/botlab/_site.py b/src/botlab/_site.py index 61a8cb6..3f7b0bd 100644 --- a/src/botlab/_site.py +++ b/src/botlab/_site.py @@ -7,8 +7,7 @@ ├── index.html ← the UI snapshot's embed.html, renamed ├── assets/… ← the embed runtime closure (static JS/WASM scan) ├── wasm/obj-parser/… ← the module-string referenced terrain runtime - ├── project/… ← the whole project directory, mirroring the - │ ``/project/`` graft the local server serves + ├── project/… ← declared source files and asset dependencies └── release.zip ← the release package, fetched by './release.zip' Every runtime URL is page-relative, so the site works from a sub-path such as @@ -285,6 +284,24 @@ def _iter_project_files(project_dir: Path) -> list[str]: ) + +def _site_project_files(config: dict[str, Any]) -> list[str]: + """Use the release inventory; never discover files by walking the project.""" + project = config["project"] + sources = project.get("files") + assets = project.get("assetPaths") + if not isinstance(sources, list) or not isinstance(assets, list): + raise BotLabError("Release project files and assetPaths must be arrays; rebuild the release.") + paths = set() + for entry in sources: + if not isinstance(entry, dict): + raise BotLabError("Release project files entry must be an object.") + paths.add(_require_safe_site_path(entry.get("path"), "project files entry")) + for path in assets: + paths.add(_require_safe_site_path(path, "assetPaths entry")) + return sorted(paths) + + def build_site_directory(project_dir: Path, package: bytes, out_dir: Path) -> dict[str, Any]: """Write the standalone site for one already-built release package.""" snapshot_dir = ensure_browser_runtime() @@ -329,10 +346,13 @@ def add_copy(source: Path, target_relative: str) -> None: add_copy(snapshot_dir / "embed.html", "index.html") for file in sorted(closure): add_copy(_contained_file(snapshot_dir, file, "UI snapshot input"), file) - # The /project/ graft serves the whole project directory so MJCF mesh - # includes resolve relative to their XML, exactly like `botlab run`. - for relative in _iter_project_files(project_dir): - add_copy(_contained_file(project_dir, relative, "Project asset"), f"{asset_base}/{relative}") + # Preserve paths within the declared dependency closure so MJCF includes + # resolve, without walking venvs, logs, downloads or previous site outputs. + for relative in _site_project_files(config): + source = _contained_file(project_dir, relative, "Project asset") + if not source.is_file(): + raise BotLabError(f'Project asset is missing or is not a file: "{relative}".') + add_copy(source, f"{asset_base}/{relative}") for source, target in copies: target.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_site.py b/tests/test_site.py index c722876..a699d56 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -177,7 +177,7 @@ def test_build_site_directory_layout( assert (out_dir / "assets" / "embed-abc.js").is_file() assert (out_dir / "wasm" / "obj-parser" / "objParser.wasm").is_file() assert not (out_dir / "assets" / "editor-main-unused.js").exists() - # The whole project directory is mirrored under /project/ like the local server graft. + # Declared source and asset paths are preserved under /project/. assert (out_dir / "project" / "assets" / "robot" / "scene.xml").is_file() assert (out_dir / "project" / "controller.py").is_file() assert (out_dir / "release.zip").read_bytes() == package @@ -227,3 +227,66 @@ def test_build_site_with_a_root_level_robot_asset( assert (out / "project/robot.xml").is_file() config = read_release_config((out / "release.zip").read_bytes()) assert config["project"]["manifest"]["resources"][0]["source"] == "robot.xml" + + +def test_site_copies_declared_files_and_mjcf_closure_only( + project_dir, mini_snapshot, monkeypatch, +): + from botlab import _site + + monkeypatch.setattr(_site, 'ensure_browser_runtime', lambda: mini_snapshot) + shared = project_dir / 'assets/shared' + shared.mkdir() + (shared / 'body.xml').write_text('') + (shared / 'terrain.obj').write_bytes(b'mesh fixture') + (project_dir / 'assets/robot/scene.xml').write_text(""" + + """) + (project_dir / 'helper.py').write_text('VALUE = 1\n') + project = load_project(project_dir) + package = create_release_package(project, metadata={'name': 'Test Duck'}) + # A real project has a venv, downloads, logs and previous outputs. None are + # runtime declarations, including unrelated files in the assets directory. + for name in ('.env', '.git/config', 'logs/run.log', 'downloads/archive.zip', + 'assets/unused.bin', 'tmp/old-site/index.html', 'release.zip'): + target = project_dir / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b'unrelated') + venv_python = project_dir / '.venv/bin/python' + venv_python.parent.mkdir(parents=True) + import sys + venv_python.symlink_to(sys.executable) + + out = project_dir / 'tmp/new-site' + build_site_directory(project_dir, package, out) + actual = {p.relative_to(out / 'project').as_posix() + for p in (out / 'project').rglob('*') if p.is_file()} + assert actual == set(project.files) | set(project.asset_paths) + assert {'assets/shared/body.xml', 'assets/shared/terrain.obj', 'helper.py'} <= actual + assert (out / 'release.zip').read_bytes() == package + # Subsequent outputs under the same project must not copy earlier sites. + second = project_dir / 'tmp/second-site' + build_site_directory(project_dir, package, second) + assert not (second / 'project/tmp').exists() + + +@pytest.mark.parametrize('change', ['missing', 'directory', 'external-symlink']) +def test_site_rejects_invalid_declared_file_before_writing( + project_dir, mini_snapshot, monkeypatch, change, tmp_path_factory, +): + from botlab import _site + + monkeypatch.setattr(_site, 'ensure_browser_runtime', lambda: mini_snapshot) + package = create_release_package(load_project(project_dir), metadata={'name': 'T'}) + path = project_dir / 'assets/robot/scene.xml' + path.unlink() + if change == 'directory': + path.mkdir() + elif change == 'external-symlink': + outside = tmp_path_factory.mktemp('outside') / 'scene.xml' + outside.write_text('') + path.symlink_to(outside) + out = project_dir / 'tmp/new-site' + with pytest.raises(BotLabError, match='Project asset'): + build_site_directory(project_dir, package, out) + assert not out.exists() From 31b44274d0cab0046eae604d92b68d55406057b9 Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Sat, 24 Oct 2026 14:44:37 +0800 Subject: [PATCH 4/5] fix: align angular frame markers with current BotLab contract --- src/botlab/_runtime/_observations.py | 13 ++++++++++--- src/botlab/sdk/_robot.py | 6 +++++- tests/runtime/test_observations.py | 16 +++++++++++++--- tests/test_project.py | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/botlab/_runtime/_observations.py b/src/botlab/_runtime/_observations.py index b59825e..356dd41 100644 --- a/src/botlab/_runtime/_observations.py +++ b/src/botlab/_runtime/_observations.py @@ -1037,9 +1037,16 @@ def _base_angular_velocity(model, data, config): if sensor_gyro is not None: return sensor_gyro if _has_root_free_joint(model, data): - # Free-joint rotational qvel is already in the root body frame. - # Only its translational component needs a world-to-body rotation. - return _float32_slice(data.qvel, "data.qvel", 3, 6) + # 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), + ) body_index = _base_body_index(model) address = body_index * 6 return _float32_slice(data.cvel, "data.cvel", address, address + 3) diff --git a/src/botlab/sdk/_robot.py b/src/botlab/sdk/_robot.py index 0c7535d..4d02559 100644 --- a/src/botlab/sdk/_robot.py +++ b/src/botlab/sdk/_robot.py @@ -169,7 +169,11 @@ def default_angles(self): def base_angular_velocity(self, *, scale=None, source="root"): return Signal("obs-base-ang-vel", "base-angular-velocity", _observation_data( - {"dim": 3, "velocitySource": _non_empty_string(source, "velocity source")}, + { + "dim": 3, + "velocitySource": _non_empty_string(source, "velocity source"), + "rootAngularVelocityFrame": "body", + }, scale=scale, )) diff --git a/tests/runtime/test_observations.py b/tests/runtime/test_observations.py index 68e0108..0d55b30 100644 --- a/tests/runtime/test_observations.py +++ b/tests/runtime/test_observations.py @@ -371,9 +371,19 @@ def value(self, node_type, node_data=None, **extra): def test_obs_base_ang_vel_preserves_body_local_root_qvel(self): # MuJoCo free-joint angular qvel is already body-local, unlike translation. np.testing.assert_allclose( - self.value("obs-base-ang-vel"), [0.1, 0, 0], atol=1e-6 + self.value("obs-base-ang-vel", {"rootAngularVelocityFrame": "body"}), + [0.1, 0, 0], atol=1e-6 ) + def test_unmarked_plan_preserves_legacy_angular_velocity(self): + np.testing.assert_allclose( + self.value("obs-base-ang-vel"), [0, -0.1, 0], atol=1e-6 + ) + + def test_rejects_unsupported_angular_velocity_frame(self): + with self.assertRaisesRegex(ValueError, "rootAngularVelocityFrame must be body"): + self.value("obs-base-ang-vel", {"rootAngularVelocityFrame": "world"}) + def test_root_angular_velocity_matches_mujoco_local_object_velocity(self): for quat in ([HALF_SQRT2, 0, 0, HALF_SQRT2], [0.5, 0.5, 0.5, 0.5]): with self.subTest(quat=quat): @@ -385,12 +395,12 @@ def test_root_angular_velocity_matches_mujoco_local_object_velocity(self): model, data, mujoco.mjtObj.mjOBJ_BODY, 1, velocity, 1 ) root = obs.get_mujoco_observation( - "obs-base-ang-vel", context(model, data, {"velocitySource": "root"}) + "obs-base-ang-vel", context(model, data, {"velocitySource": "root", "rootAngularVelocityFrame": "body"}) ) np.testing.assert_allclose(root, velocity[:3], atol=1e-6) np.testing.assert_allclose(root, data.sensordata[4:7], atol=1e-6) imu = obs.get_mujoco_observation( - "obs-imu", context(model, data, {"velocitySource": "root"}) + "obs-imu", context(model, data, {"velocitySource": "root", "rootAngularVelocityFrame": "body"}) ) np.testing.assert_allclose(imu[4:], velocity[:3], atol=1e-6) diff --git a/tests/test_project.py b/tests/test_project.py index 0f2aa76..a23dded 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -298,3 +298,17 @@ def test_digest_matches_actual_node_for_number_and_key_boundaries(): process.stdout.write(JSON.stringify(c(JSON.parse(fs.readFileSync(0,'utf8'))))); '''], input=json.dumps(payload).encode()).decode() assert canonical_json(payload) == expected + + +def test_public_angular_velocity_marks_body_frame(project_dir: Path): + controller = project_dir / "controller.py" + controller.write_text( + CONTROLLER.replace("robot.projected_gravity()", "robot.base_angular_velocity()"), + encoding="utf-8", + ) + project = load_project(project_dir) + angular_nodes = [ + node for node in project.compiled_plan["nodes"] if node["type"] == "obs-base-ang-vel" + ] + assert len(angular_nodes) == 1 + assert angular_nodes[0]["data"]["rootAngularVelocityFrame"] == "body" From cce53296720a380634c53d1716c5dbc6b60ea26b Mon Sep 17 00:00:00 2001 From: kleinlau17 Date: Sat, 24 Oct 2026 16:21:40 +0800 Subject: [PATCH 5/5] ci: set up Node 24.21.0 for digest parity tests --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daedbb0..1769f6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: