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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ shortcuts.

### Added

- **Credits in the viewer.** The About dialog (the control panel's logo, or the new `I`
key, which works with the panel hidden) has a **Credits** tab listing the scene in
view: each third-party component with its license, its `NOTICE` in full and the license
text on demand; every skin of the scene, the one on screen first, a VRM by its own
`meta`; the work's own license; and the engine's, with `THIRD_PARTY_LICENSES.md`. The
logo shows a hover background. The manifest names
the license files the build writes, since a static host lists no directory: a
project's `work` and a scene's `credits` (each file's `spdx`, `restriction` and
`copyright` line, and a summary of each VRM skin's `meta`), optional keys an older
engine ignores, so `format` stays 7. `mjswan/manifest` exposes them as
`ProjectCatalog.work` and `SceneEntry.credits`, fetching a text only when asked.
`mjswan.license.copyright_line` reads a license text's holder line.
- **Skins: a rigged character drawn over the robot** (ADR 0010, **document format 7**).
`SceneHandle.add_skin(name, mjswan.SkinConfig(...))` adds a `.vrm` or a `.glb` to the
scene's **Skin** menu, posed every frame from the robot's bodies while physics,
Expand Down
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ src/mjswan/ Python package source: the object model at the root, one pa
manifest.py project / scene / MDP / policy entries, and the manifest.json write
asset.py a scene's assets/: motion clips (deduplicated by content), splats, skins
skin.py a skin's manifest entry: node indices and measured turns, no names
credit.py a scene's `credits` and a project's `work`: its license files, named
mjz.py scene.mjz: MjSpec plus every asset, DEFLATE-compressed, paths rewritten
frontend.py the Python side of template/: nodeenv + Vite build, install_spa
mdp/ one .onnx per traced term and the JSON entry that names it,
Expand Down Expand Up @@ -147,7 +148,7 @@ against the caller's file and hands the projects to `build.pipeline.write_app`.
`from_mjlab` / `add_project_mjlab` are the one-liner for a single mjlab task.

### `build/`: what `Builder.build()` does
- `pipeline.py`: `write_app` builds the frontend first (`ClientBuilder`, reusing a cached SPA when it matches), lays the engine down (`install_spa`), then per scene: runs pending checkpoint conversions, validates muscle action terms, writes the scene as DEFLATE-compressed `scene.mjz` (`mjz.to_zip_deflated`, since `mujoco.to_zip` stores entries uncompressed) or `.mjb`, copies clips and splats into `assets/` (`asset.py`; a tracking task's env is pointed at the bundled clip before it is built), builds or reuses the trace env, traces each `MdpConfig` once into `mdp/<mdp-id>/`, writes `policy/<policy-id>.onnx`, and finally the one `manifest.json` at the output root: `{format, version, uses_custom_js, plugins?, projects}` (ADR 0006). `write_mt_headers` writes the COOP/COEP `_headers` file for `mt=True`.
- `pipeline.py`: `write_app` builds the frontend first (`ClientBuilder`, reusing a cached SPA when it matches), lays the engine down (`install_spa`), then per scene: runs pending checkpoint conversions, validates muscle action terms, writes the scene as DEFLATE-compressed `scene.mjz` (`mjz.to_zip_deflated`, since `mujoco.to_zip` stores entries uncompressed) or `.mjb`, copies clips and splats into `assets/` (`asset.py`; a tracking task's env is pointed at the bundled clip before it is built), builds or reuses the trace env, traces each `MdpConfig` once into `mdp/<mdp-id>/`, writes `policy/<policy-id>.onnx`, and finally the one `manifest.json` at the output root: `{format, version, uses_custom_js, plugins?, projects}` (ADR 0006). A project's `work` and a scene's `credits` (`credit.py`) name the license files written beside them, each identified (`spdx`, `restriction`, `copyright`), and summarise each VRM skin's `meta`, so the viewer can find them on a host that lists no directory. `write_mt_headers` writes the COOP/COEP `_headers` file for `mt=True`.
- `manifest.py`: the entries `mdp_entry` (calls `build.mdp`), `policy_entry`, `scene_entry` and `splat_entry`, plus `write_manifest`. A `config_path` sidecar (`load_sidecar`) contributes a checkpoint's own defaults to its policy entry; its slot tables are ignored with a warning, since `in_keys` / `out_keys` are declared on `add_policy` and checked against the network there and against the MDP's observation groups here.
- `mdp/`: one module per term kind, each tracing plain-callable bodies via `compile/` against the scene's live env, writing `.onnx` bytes under `<scene_dir>/mdp/<mdp-id>/{obs,term,command,event,action}/` (`graph.py`: the ref the manifest carries *is* the path on disk, scoped by MDP so two MDPs' `actor` groups cannot collide, and the write refuses to replace a *different* graph already at one path) and returning the manifest-shaped entry. Emits **fused** graphs: one per observation group (clip-then-scale folded in, mjlab's order) and one per termination group (a bool lane per term, so per-term reset reasons survive). A group with a `*Binding` term or per-term history does not fuse; a lone traced termination is deliberately left unfused. An event that writes `mjModel` (mjlab's `dr.*`: `geom_friction`, `body_com_offset`, `geom_size`, ...) traces like any other, its model reads and writes keyed by element *names* since the browser compiles its own model, and `event.py` marks it `set_const` when the write leaves MuJoCo's derived constants stale. `provenance.py` records which function each entry came from (`func` / `doc` / `params`, and the `metadata_props` stamped into the graph); `sensor.py` describes the raycast and contact sensors a graph reads; `debug_vis.py` describes the Debug Viz drawings that are not command terms (raycast sensors with `debug_vis`, mjlab's `upright` reward), warning for a reward drawing it does not know; `action.py` merges each action term's `to_dict()` over the sidecar's authored block (a motor robot's PD gains), and for a task's own action class (`TracedActionCfg`) builds the term on the trace env, writes its `action/` process and reset graphs, and names the native apply its binding or mjlab base gives (ADR 0009). Actions serialize before observations, which may read a traced term's state.
- `frontend.py`: the Python side of `template/`, which stays where npm, Vite, CI and hatch expect it. Manages a local `nodeenv` if Node isn't available system-wide, runs the Vite build, bundles author `ts_src` terms into `plugins.js` with esbuild; `uses_custom_js` is what the manifest's flag reads.
Expand Down Expand Up @@ -231,6 +232,7 @@ TypeScript + React + Vite + three.js. Built by `Builder.build()` via `build/fron
- Renders via three.js (reflections, shadows, Gaussian Splat background).
- Supports WebXR (VR and passthrough AR) through `engine.xr`; the host draws the buttons. Tracked hands join the sim as bodies, compiled in at the next model build after the switch is turned on.
- Reads `manifest.json` to discover projects/scenes/MDPs/policies at runtime; `?project=` / `?scene=` / `?policy=` / `?skin=` take ids (`sanitizeName` = `name2id`, pinned by a shared case table).
- Shows the scene in view's credits in the About dialog (the panel's logo, or `I`): `ControlPanel/CreditsPanel.tsx` lists the manifest's `credits` and `work`, then the engine's own license, fetching a `NOTICE` when the tab opens and a `LICENSE` when asked.

`src/core/` mirrors mjlab's layout (`observation/`, `termination/`, `action/`, `event/`, `command/`) plus `onnx/` (`session.ts` caches split by lifetime, `runQueue.ts`, `slotReader/` serving graph inputs from `mjModel`/`mjData` (`indexing.ts` resolves an entity's elements as mjlab's `EntityIndexing` does, `fields/` holds every `EntityData` property as a reader, one file per section of mjlab's `entity/data.py`, `model.ts` resolves an `mjModel` field's elements by name, and `index.ts` dispatches a slot by namespace), `raycast.ts` casting height-scan rays with `mj_ray`, `contact.ts`, `graphRefs.ts`), `rng.ts` (xoshiro128\*\*, snapshot-able), `policy/`, `scene/`, `xr/` (`handMocap.ts` injects a capsule per hand bone and writes `mocap_pos`/`mocap_quat`; `session.ts` builds each session's options and decides whether a scene can take the hands), and `engine/` (`runtime.ts` step loop and episode step counter, `stepDeadline.ts`, `yieldToBrowser.ts`, `resetChain.ts`, `viewer_config.ts`). Each manager is native orchestration around ONNX term bodies: `FusedObservation`/`OnnxObservation`/`NativeObservation`/`HistoryObservation`, `FusedTermination`/`OnnxTermination` (awaited every step) and `TimeOutTermination` (a pre-format-4 document's native `time_out`), `OnnxEvent` + `triggers.ts` + `entityWrite.ts` + `modelWrite.ts` (and `modelFieldDr.ts`, a pre-format-5 document's model-field descriptor), `OnnxCommand`. Action splits as mjlab's does (ADR 0009): `ActionManager` runs each term's `process` once per control step (`applyAction.ts`'s native formulas, or a traced term's `OnnxActionProcess`, awaited) and its reset graphs in the reset chain; `applyAction` runs every substep, synchronous, with the ramp, hold, relative target, `commandDelay.ts` and the PD a motor needs.

Expand Down
10 changes: 8 additions & 2 deletions docs/docs/api/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,11 +325,17 @@ only when named.
| Type | Shape |
|---|---|
| `Catalog` | `{ projects: ProjectCatalog[], pluginsPath?: string }` |
| `ProjectCatalog` | `{ id, name, default, scenes }` |
| `SceneEntry` | `{ id, name, camera?, splatSection, policies, splats, skins, buildScene(opts?) }` |
| `ProjectCatalog` | `{ id, name, default, work?, scenes }` |
| `SceneEntry` | `{ id, name, camera?, splatSection, policies, splats, skins, credits, buildScene(opts?) }` |
| `PolicyEntry` | `{ id, name, default, motions, build() }` |
| `SplatEntry` | `{ id, name, control, transform, build() }` |
| `SkinEntry` | `{ id, name, build() }` |
| `CreditEntry` | `{ component, skin?, vrm?, spdx?, restriction?, copyright?, license?(), notice?() }` |
| `LicenseEntry` | `{ spdx?, restriction?, copyright?, license?(), notice?() }`, a project's `work` |

`credits` are the scene's third-party components and every skin's terms, in manifest
order; `license()` and `notice()` fetch a file's text only when called. An app with its own
UI shows them itself, as the bundled app does in About › Credits.

Every entry carries its `id` — `name2id(name)`, the directory it lives in and the value
the bundled app's `?project=` / `?scene=` / `?policy=` parameters take. `sanitizeName(name)`
Expand Down
10 changes: 9 additions & 1 deletion docs/docs/getting-started/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ policy.add_motion_hf(

A build carries its license files as files, beside the data they cover
([ADR 0007](https://github.com/ttktjmt/mjswan/blob/main/docs/adr/0007-license-files-in-the-build.md)).
There are two kinds, and `manifest.json` never mentions either:
There are two kinds. `manifest.json` lists both (a project's `work`, a scene's
`credits`), since a static host cannot list a directory for the viewer to look in:

- **The work's own**: `<project-id>/LICENSE` and `NOTICE`. Declare them once on the
builder, or per project:
Expand Down Expand Up @@ -336,6 +337,13 @@ There are two kinds, and `manifest.json` never mentions either:
scene.clear_attributions() # when a detection is wrong
```

The viewer shows them in its About dialog (the logo at the control panel's top left, or
the `I` key, which works with the panel hidden too). Its **Credits** tab lists the scene in
view: each component with its license, its `NOTICE` in full and the license text on
demand; every skin of the scene, the one on screen first, a `.glb` by the files above and
a VRM by its own `meta`; the work's own license; and the engine's, with the licenses of
the packages it bundles.

The root `dist/LICENSE` is the engine's own Apache-2.0 and stays out of every publish.
`mjswan info` lists the files it finds with the license each one is; `mjswan publish`
prints them, warns for a restricted license (CC NC / SA / ND, the GPL family) and refuses
Expand Down
7 changes: 7 additions & 0 deletions docs/docs/guides/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ and chrome you mean rather than the first thing in the build:
when exactly `1`", and the app writes the current state back into the URL, so you can
arrange a view by hand and copy the address bar into your `src`.

A hidden panel does not hide the scene's credits: `I` opens the About dialog, whose
**Credits** tab lists the licenses and notices of what is on screen.

```html
<iframe src="https://ttktjmt.github.io/mjswan/?project=showcase&scene=g1_on_street&policy=locomotion&panel=0" …></iframe>
```
Expand Down Expand Up @@ -261,4 +264,8 @@ Or serve the package's `dist/` directory from your own origin: the bundle resolv
WebAssembly relative to itself, so that one directory needs no other host, no configuration,
and a single `script-src` prefix under a strict CSP.

Without the bundled app there is no About dialog, so showing each scene's credits is up to
your page: the catalog's `scene.credits` and `project.work` list them, and fetch a
`LICENSE` or `NOTICE` only when asked.

See the [Engine API](../api/engine.md) for the full surface.
80 changes: 80 additions & 0 deletions src/mjswan/build/credit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""A scene's ``credits`` and a project's ``work``: the license files written beside them
(ADR 0007), named in the manifest because a static host lists no directory. The texts
stay in the files."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from ..license import copyright_line, identify_license, restriction_label
from ..license.vrm import vrm_license

if TYPE_CHECKING:
from ..project import ProjectConfig
from ..scene import SceneConfig


def _license_fields(name: str, data: bytes) -> dict[str, str]:
text = data.decode("utf-8", "replace")
spdx = identify_license(text).spdx
restriction = restriction_label(spdx)
holder = copyright_line(text)
return {
"license": name,
"spdx": spdx,
**({"restriction": restriction} if restriction else {}),
**({"copyright": holder} if holder else {}),
}


def scene_credits(scene: SceneConfig) -> list[dict[str, Any]]:
"""One entry per third-party component, paths relative to the scene directory. A
``skin-<id>`` component belongs to that skin; a VRM skin adds a summary of its
``meta``."""
skins = {f"skin-{skin.config.id}": skin for skin in scene.skins}
entries: dict[str, dict[str, Any]] = {}
for attribution in scene.attributions:
component = attribution.component
entry: dict[str, Any] = {"component": component}
if component in skins:
entry["skin"] = skins[component].config.id
if attribution.license is not None:
entry.update(_license_fields(f"LICENSE.{component}", attribution.license))
if attribution.notice is not None:
entry["notice"] = f"NOTICE.{component}"
entries[component] = entry
for component, skin in skins.items():
if skin.file.vrm_version is None:
continue
terms = vrm_license(skin.file.meta, skin.file.vrm_version)
entry = entries.setdefault(
component, {"component": component, "skin": skin.config.id}
)
entry["vrm"] = {
**({"name": terms.name} if terms.name else {}),
"authors": list(terms.authors),
**({"license": terms.license} if terms.license else {}),
**(
{"other_license_url": terms.other_license_url}
if terms.other_license_url
else {}
),
}
return list(entries.values())


def work_entry(project: ProjectConfig) -> dict[str, Any] | None:
"""The work's own ``LICENSE`` and ``NOTICE``, relative to the project directory."""
if project.license is None and project.notice is None:
return None
return {
**(
_license_fields("LICENSE", project.license)
if project.license is not None
else {}
),
**({"notice": "NOTICE"} if project.notice is not None else {}),
}


__all__ = ["scene_credits", "work_entry"]
3 changes: 3 additions & 0 deletions src/mjswan/build/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from ..mjlab.gui import record_scene_gui
from ..viewer import ViewerConfig
from .asset import motion_key
from .credit import scene_credits, work_entry
from .frontend import uses_custom_js
from .mdp import (
command_widths,
Expand Down Expand Up @@ -354,6 +355,7 @@ def scene_entry(
**(
{"skins": [skin_entry(skin) for skin in scene.skins]} if scene.skins else {}
),
**({"credits": credits} if (credits := scene_credits(scene)) else {}),
"mdps": mdps,
"policies": policies,
}
Expand Down Expand Up @@ -411,6 +413,7 @@ def write_manifest(
"id": project.id,
"name": project.name,
**({"default": True} if project.default else {}),
**({"work": work} if (work := work_entry(project)) else {}),
"scenes": [
scene_entries[(project.id, scene.id)] for scene in project.scenes
],
Expand Down
2 changes: 2 additions & 0 deletions src/mjswan/license/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
GENERATABLE_LICENSES,
Identification,
blocked_refusal,
copyright_line,
display_name,
generate_license_text,
identify_license,
Expand All @@ -58,6 +59,7 @@
"LicenseLocation",
"blocked_refusal",
"component_id",
"copyright_line",
"declare",
"detect_attributions",
"display_name",
Expand Down
21 changes: 21 additions & 0 deletions src/mjswan/license/spdx.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ def identify_license(text: str) -> Identification:
return Identification(spdx, tier_of(spdx))


_COPYRIGHT = re.compile(r"^\s*(?:Copyright\s+(?:\(c\)|©|\d)|©).*$", re.I | re.M)
# Left unfilled in a template; Apache-2.0's appendix keeps one in every copy.
_PLACEHOLDER = re.compile(
r"\[yyyy\]|\[name of copyright owner\]|<year>|<owner>|<copyright holders>", re.I
)


def copyright_line(text: str) -> str | None:
"""The first line of a license text that names a holder (``Copyright (c) 2024
Unitree``, ``Copyright 2024 …``, ``© …``), or ``None``. A template's placeholder line
and a line with no holder after the mark are not one."""
for match in _COPYRIGHT.finditer(text):
line = match.group(0).strip()
if _PLACEHOLDER.search(line):
continue
if re.sub(r"copyright|\(c\)|©|[\s.,]", "", line, flags=re.I):
return line
return None


def _search(pattern: str, t: str) -> re.Match[str] | None:
return re.search(pattern, t, re.IGNORECASE)

Expand Down Expand Up @@ -269,6 +289,7 @@ def resolve_notice(notice: str | os.PathLike[str]) -> bytes:
"Identification",
"Tier",
"blocked_refusal",
"copyright_line",
"display_name",
"generate_license_text",
"identify_license",
Expand Down
8 changes: 8 additions & 0 deletions src/mjswan/template/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,14 @@ function AppContent() {
}}
onHandTrackingChange={(enabled) => engineRef.current?.xr.setHandTracking(enabled)}
onReset={() => engineRef.current?.reset()}
credits={{
scene: sceneEntry.name,
project: project?.name ?? '',
credits: sceneEntry.credits,
work: project?.work,
skins: sceneEntry.skins.map(({ id, name }) => ({ id, name })),
skin: sceneEntry.skins.find((s) => s.name === skinName)?.id ?? null,
}}
/>
)}
<div ref={containerRef} className="viewer" />
Expand Down
11 changes: 11 additions & 0 deletions src/mjswan/template/src/ControlPanel/ControlPanel.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { style } from "@vanilla-extract/css";

/** The panel's logo opens the About dialog; the hover shows it is a button. */
export const aboutButton = style({
padding: "0.2em",
borderRadius: "var(--mantine-radius-sm)",
transition: "background-color 100ms",
":hover": {
backgroundColor: "var(--mantine-color-gray-light-hover)",
},
});
Loading
Loading