loginowskid commited on
Commit
c65d575
·
1 Parent(s): e82c2b6

Initial Space scaffold from simready-oem-library-pm@e82c2b6

Browse files
Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SimReady Validator — HuggingFace Space image
2
+ #
3
+ # Phase-3 of the PRD: validation runs where the dataset lives. The Space
4
+ # downloads a target dataset via huggingface_hub, runs the bundled
5
+ # simready-report validator with `--no-use-kit` (no Isaac Sim on HF
6
+ # hardware), and writes the verdict back to the dataset as a PR.
7
+ #
8
+ # Image strategy: pre-bake all heavy deps (usd-core, omni.asset_validator,
9
+ # simready-validate) and the simready-foundation specs checkout into the
10
+ # image so cold-start is fast — the per-validation cost is just the
11
+ # dataset download + USD parsing, not pip resolution.
12
+
13
+ FROM python:3.11-slim
14
+
15
+ # Base build deps. usd-core ships wheels for x86_64 so we don't need
16
+ # build-essential, but git is required to clone simready-foundation at
17
+ # build time and rsync is handy for the dashboard `_static/` mirror.
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ git curl ca-certificates rsync \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # HF Spaces run as a non-root user (UID 1000). Match it so the volume
23
+ # mounts are writable without permission gymnastics.
24
+ ARG USER=appuser
25
+ RUN useradd -m -u 1000 ${USER}
26
+
27
+ WORKDIR /home/${USER}/app
28
+
29
+ # Python deps first so they layer-cache independently of the validator
30
+ # code. Pinned to the same versions install-simready-sdk.sh uses on the
31
+ # DGXC runner (single source of truth for "what runs the validator").
32
+ COPY tools/hf_space/requirements.txt ./requirements.txt
33
+ RUN pip install --no-cache-dir -r requirements.txt
34
+
35
+ # Foundation specs: the validator wants SIMREADY_FOUNDATIONS_PATH to
36
+ # point at a checkout. Pinned tag avoids surprise spec churn between
37
+ # Space builds. Bump the tag deliberately when foundation rules change.
38
+ ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
39
+ RUN git clone --depth 1 https://github.com/NVIDIA/simready-foundation \
40
+ ${SIMREADY_FOUNDATIONS_PATH} \
41
+ && chown -R ${USER}:${USER} ${SIMREADY_FOUNDATIONS_PATH}
42
+
43
+ # Copy the bundled validator (the same code our DGXC runner uses) and
44
+ # the Space's own entry points. Keep the in-repo path so the validator's
45
+ # relative imports (report.py, external_deps.py) still resolve.
46
+ COPY tools/validation /home/${USER}/app/tools/validation
47
+ COPY tools/hf_space/app.py ./app.py
48
+ COPY tools/hf_space/runner.py ./runner.py
49
+ RUN chown -R ${USER}:${USER} /home/${USER}/app
50
+
51
+ USER ${USER}
52
+
53
+ # HF Spaces with the Docker SDK serve whatever listens on $PORT
54
+ # (default 7860). Gradio honors GRADIO_SERVER_PORT.
55
+ ENV GRADIO_SERVER_NAME=0.0.0.0
56
+ ENV GRADIO_SERVER_PORT=7860
57
+ EXPOSE 7860
58
+
59
+ CMD ["python", "app.py"]
tools/hf_space/.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Local artifacts when iterating on the Space outside Docker.
2
+ __pycache__/
3
+ *.pyc
4
+ .gradio/
5
+ gradio_cached_examples/
6
+ flagged/
tools/hf_space/Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SimReady Validator — HuggingFace Space image
2
+ #
3
+ # Phase-3 of the PRD: validation runs where the dataset lives. The Space
4
+ # downloads a target dataset via huggingface_hub, runs the bundled
5
+ # simready-report validator with `--no-use-kit` (no Isaac Sim on HF
6
+ # hardware), and writes the verdict back to the dataset as a PR.
7
+ #
8
+ # Image strategy: pre-bake all heavy deps (usd-core, omni.asset_validator,
9
+ # simready-validate) and the simready-foundation specs checkout into the
10
+ # image so cold-start is fast — the per-validation cost is just the
11
+ # dataset download + USD parsing, not pip resolution.
12
+
13
+ FROM python:3.11-slim
14
+
15
+ # Base build deps. usd-core ships wheels for x86_64 so we don't need
16
+ # build-essential, but git is required to clone simready-foundation at
17
+ # build time and rsync is handy for the dashboard `_static/` mirror.
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ git curl ca-certificates rsync \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # HF Spaces run as a non-root user (UID 1000). Match it so the volume
23
+ # mounts are writable without permission gymnastics.
24
+ ARG USER=appuser
25
+ RUN useradd -m -u 1000 ${USER}
26
+
27
+ WORKDIR /home/${USER}/app
28
+
29
+ # Python deps first so they layer-cache independently of the validator
30
+ # code. Pinned to the same versions install-simready-sdk.sh uses on the
31
+ # DGXC runner (single source of truth for "what runs the validator").
32
+ COPY tools/hf_space/requirements.txt ./requirements.txt
33
+ RUN pip install --no-cache-dir -r requirements.txt
34
+
35
+ # Foundation specs: the validator wants SIMREADY_FOUNDATIONS_PATH to
36
+ # point at a checkout. Pinned tag avoids surprise spec churn between
37
+ # Space builds. Bump the tag deliberately when foundation rules change.
38
+ ENV SIMREADY_FOUNDATIONS_PATH=/opt/simready_foundations
39
+ RUN git clone --depth 1 https://github.com/NVIDIA/simready-foundation \
40
+ ${SIMREADY_FOUNDATIONS_PATH} \
41
+ && chown -R ${USER}:${USER} ${SIMREADY_FOUNDATIONS_PATH}
42
+
43
+ # Copy the bundled validator (the same code our DGXC runner uses) and
44
+ # the Space's own entry points. Keep the in-repo path so the validator's
45
+ # relative imports (report.py, external_deps.py) still resolve.
46
+ COPY tools/validation /home/${USER}/app/tools/validation
47
+ COPY tools/hf_space/app.py ./app.py
48
+ COPY tools/hf_space/runner.py ./runner.py
49
+ RUN chown -R ${USER}:${USER} /home/${USER}/app
50
+
51
+ USER ${USER}
52
+
53
+ # HF Spaces with the Docker SDK serve whatever listens on $PORT
54
+ # (default 7860). Gradio honors GRADIO_SERVER_PORT.
55
+ ENV GRADIO_SERVER_NAME=0.0.0.0
56
+ ENV GRADIO_SERVER_PORT=7860
57
+ EXPOSE 7860
58
+
59
+ CMD ["python", "app.py"]
tools/hf_space/README.md ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SimReady Validator — HuggingFace Space (Phase-3 Spike)
2
+
3
+ This directory scaffolds an HF Space that runs the bundled
4
+ [`simready-report`](../validation/plugins/simready-report/) validator
5
+ against any HF dataset, then opens a verdict PR back on the dataset.
6
+
7
+ It is the **phase-3 prove-it step** described in [PRD §3](../../PRD.md):
8
+ move validation execution to where the dataset already lives, so we stop
9
+ paying to copy 20 GiB of customer assets onto NVIDIA-controlled
10
+ infrastructure on every run.
11
+
12
+ | | DGXC runner today | HF Space (this dir) |
13
+ |---|---|---|
14
+ | Asset transfer | 10–20 GiB per submission onto a 49 GiB PVC | None — `huggingface_hub.snapshot_download` reads from HF storage directly |
15
+ | Cost model | NVIDIA pays for the runner | Customer pays for their Space's hardware hours |
16
+ | Concurrency | Single runner, jobs serialized | One Space per dataset → scales linearly |
17
+ | Where verdicts land | `docs/dashboard/data/status.json` in this repo | `validation/results.json` in the dataset, via PR |
18
+ | Trigger | GitHub Actions `workflow_dispatch` | Gradio UI (spike) → HF Hub webhook (next) |
19
+
20
+ The Space is **internal pilot scope**: the HF_TOKEN that opens the verdict
21
+ PR is the Space's own secret, not the requester's. A customer-facing
22
+ end-state would either (a) deploy one Space per partner under their org,
23
+ or (b) keep a single multi-tenant Space and have customers pass their own
24
+ token explicitly.
25
+
26
+ ---
27
+
28
+ ## What's here
29
+
30
+ | File | Purpose |
31
+ |---|---|
32
+ | `Dockerfile` | Docker SDK image: pip-installs the validator runtime, clones `NVIDIA/simready-foundation` for `SIMREADY_FOUNDATIONS_PATH`, bakes in the in-repo `tools/validation/` skill |
33
+ | `requirements.txt` | Python deps. Pinned to match `tools/runner/install-simready-sdk.sh` so verdicts are byte-for-byte reproducible across environments |
34
+ | `app.py` | Gradio UI. Single form: dataset name + profile + version + open-PR checkbox |
35
+ | `runner.py` | Orchestration. `run(dataset, profile, version, open_pr) → RunResult` — also the entry point a future webhook handler will call |
36
+
37
+ The validator engine itself is **unchanged** — the Space invokes the same
38
+ `tools/validation/plugins/simready-report/skills/simready-report/validate.py`
39
+ that Windows users run locally and that the DGXC runner runs in CI.
40
+ That's the whole point of phase 3: the verdict logic is portable; only
41
+ the trigger surface changes.
42
+
43
+ ---
44
+
45
+ ## Hardware tier choice
46
+
47
+ The validator's heavy work is USD parsing + composition-arc traversal,
48
+ which is CPU-bound. **GPU is only required if a profile re-execs under
49
+ Kit (Isaac Sim)**, which the Space's `--no-use-kit` flag explicitly
50
+ disables. The validator's P2 patch drops the
51
+ `physxschema_unavailable` / `omnipbr_unresolved` issues that the
52
+ no-Kit path produces, so PhysX-bearing profiles still report a clean
53
+ verdict for everything that can be checked without Kit.
54
+
55
+ | Tier | $/hr | Verdict |
56
+ |---|---|---|
57
+ | `cpu-basic` (2 vCPU, 16 GB) | $0.03 | Marginal — small datasets OK, 50+ asset bundles will time out |
58
+ | **`cpu-upgrade`** (8 vCPU, 32 GB) | **$0.05** | **Recommended for the spike.** Comfortable headroom; the validator's parallel worker pool actually scales here |
59
+ | `t4-small` (1 × T4, 4 vCPU, 15 GB) | $0.40 | Only needed once we add Kit; overkill for `--no-use-kit` |
60
+ | `a10g-small` (1 × A10G, 4 vCPU, 15 GB) | $1.05 | Future state: enables Kit-rooted PhysX/MDL rules (currently filtered out by the P2 patch) |
61
+
62
+ Set the tier in the Space's **Settings → Hardware** page (or in
63
+ `README.md` frontmatter when the Space repo is created — see Deploy).
64
+
65
+ ---
66
+
67
+ ## Deploy
68
+
69
+ The Space is **not deployed yet** — this is a scaffold living in the
70
+ internal repo. To stand it up:
71
+
72
+ ### 1. Create the Space `[BROWSER]`
73
+
74
+ 1. Sign in at https://huggingface.co with an account that has write
75
+ access to wherever the Space will live (an NVIDIA org for the
76
+ internal pilot).
77
+ 2. New → Space.
78
+ 3. Name: `simready-validator` (or any name).
79
+ 4. SDK: **Docker**.
80
+ 5. Hardware: **CPU upgrade** (~$0.05/hr) for the spike.
81
+ 6. Visibility: **Private** while internal-pilot.
82
+
83
+ ### 2. Set the HF_TOKEN secret `[BROWSER]`
84
+
85
+ The Space needs a write-scoped token to open the verdict PR on customer
86
+ datasets.
87
+
88
+ 1. https://huggingface.co/settings/tokens → New token → **Write** scope.
89
+ 2. Space → Settings → Variables and secrets → New secret.
90
+ 3. Name: `HF_TOKEN`. Value: paste the token.
91
+
92
+ Tokens are not exposed in the build log. The `runner.py` code reads it
93
+ via `os.environ["HF_TOKEN"]` (with `HUGGING_FACE_HUB_TOKEN` as a
94
+ fallback for compatibility with the HF SDK's standard env name).
95
+
96
+ ### 3. Push the code `[LOCAL]`
97
+
98
+ The Space is a git repo of its own. From this checkout:
99
+
100
+ ```bash
101
+ # Replace <space-name> with the Space you created (e.g. nvidia/simready-validator)
102
+ hf auth login # one-time
103
+ git clone https://huggingface.co/spaces/<space-name> /tmp/space
104
+ cp -r tools/hf_space/* /tmp/space/
105
+ # Important: the Dockerfile COPYs tools/validation/ from the repo root,
106
+ # so we have to vendor that subtree into the Space repo too.
107
+ mkdir -p /tmp/space/tools
108
+ cp -r tools/validation /tmp/space/tools/
109
+ cd /tmp/space
110
+ git add .
111
+ git commit -m "Initial Space scaffold from simready-oem-library-pm@main"
112
+ git push
113
+ ```
114
+
115
+ The Space will start building automatically. First build takes ~5 min
116
+ (usd-core + omniverse-asset-validator wheels + the foundation clone).
117
+ Subsequent builds reuse Docker layer cache and finish in ~1 min if only
118
+ `app.py` or `runner.py` changed.
119
+
120
+ ### 4. Smoke-test `[BROWSER]`
121
+
122
+ Open the Space's URL. Enter a known-good dataset (the foundation
123
+ clone's bundled examples work well — point at something small like a
124
+ single-asset dataset first), pick **Robot-Body-Runnable**, leave
125
+ **Open PR** unchecked, click **Validate**. Watch the log stream.
126
+
127
+ Expected output ends with:
128
+
129
+ ```
130
+ PASS: 1/1 assets passed
131
+ ```
132
+
133
+ …and a downloadable `index.html` report.
134
+
135
+ If the verdict makes sense, re-run with **Open PR** checked against a
136
+ dataset you have write access to. A new PR appears on the dataset under
137
+ `https://huggingface.co/datasets/<dataset>/discussions` with the
138
+ verdict body + the `validation/` subtree.
139
+
140
+ ---
141
+
142
+ ## What this spike intentionally does NOT do
143
+
144
+ To stay scoped to "prove the engine works on HF":
145
+
146
+ - **No HF Hub webhook.** Triggering is Gradio-only. Phase 3.2 wires
147
+ `https://<space>/api/run` to `dataset.commit.push` events.
148
+ - **No status callback into this repo.** The DGXC dashboard's
149
+ `hf-watch.yml` already polls dataset commits — once the Space lands
150
+ verdicts as `validation/results.json` on the dataset, the existing
151
+ watcher picks them up. No new integration needed.
152
+ - **No Kit re-exec.** Profiles requiring PhysX/MDL rules currently
153
+ report partial verdicts (the P2 patch drops env-blocked rules). A
154
+ future iteration with an `a10g-small` tier + Isaac Sim wheels in the
155
+ Dockerfile unlocks these.
156
+ - **No multi-tenant token isolation.** The Space's own `HF_TOKEN` opens
157
+ every PR. Fine for internal pilot; needs rework before exposing the
158
+ Space outside NVIDIA.
159
+
160
+ These are tracked in [PRD §7 — roadmap](../../PRD.md).
161
+
162
+ ---
163
+
164
+ ## Cutover criteria (when do we retire the DGXC runner?)
165
+
166
+ Stop using `hf-validate.yml` once **all three** are true:
167
+
168
+ 1. The Space verdict matches the DGXC verdict on the same dataset for
169
+ the top three onboarded clients (imagineio kitchens, plus two TBD).
170
+ Byte-for-byte equality on `results.json` is the bar.
171
+ 2. The HF Hub webhook handler (phase 3.2) is in place and a customer
172
+ can open a dataset PR and see a verdict comment without anyone at
173
+ NVIDIA pressing a button.
174
+ 3. The PRD §7 roadmap items 3.3 (auto-merge on pass) and 3.4 (block-on-fail
175
+ gate at the GitHub side) are wired through, so the GitHub coordinator
176
+ only deals with state — never validation.
177
+
178
+ Until then both paths coexist; the DGXC runner is the source of truth.
tools/hf_space/app.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SimReady Validator — Gradio UI for the HuggingFace Space.
2
+
3
+ Manual-trigger surface for the spike. A future webhook handler (HF Hub
4
+ webhook → `/api/run`) reuses `runner.run()` directly without the UI.
5
+
6
+ The Space is internal-pilot scope: HF_TOKEN comes from the Space's
7
+ secrets, NOT from the requester. When a customer's dataset PR triggers
8
+ this (next milestone), the webhook payload identifies the dataset and
9
+ the Space's own token opens the verdict PR.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import gradio as gr
17
+
18
+ from runner import run as run_validator
19
+
20
+
21
+ PROFILE_CHOICES = [
22
+ "Robot-Body-Runnable",
23
+ "Robot-Body-Physx",
24
+ "Robot-Body-Isaac",
25
+ "Prop-Robotics-Physx",
26
+ "Prop-Robotics-Isaac",
27
+ "Prop-Static",
28
+ ]
29
+ DEFAULT_PROFILE = "Robot-Body-Runnable"
30
+ DEFAULT_VERSION = "1.0.0"
31
+
32
+
33
+ def _run_streaming(dataset: str, profile: str, version: str, open_pr: bool):
34
+ """Generator that yields incremental log output to the UI as the
35
+ validator runs. Gradio streams each yielded tuple to the connected
36
+ outputs."""
37
+ lines: list[str] = []
38
+
39
+ def log(line: str) -> None:
40
+ lines.append(line)
41
+
42
+ yield "\n".join(lines), "", "(running…)", None
43
+
44
+ try:
45
+ result = run_validator(
46
+ dataset=dataset.strip(),
47
+ profile=profile,
48
+ version=version.strip() or DEFAULT_VERSION,
49
+ open_pr=open_pr,
50
+ log=log,
51
+ )
52
+ except Exception as e:
53
+ lines.append(f"\nERROR: {type(e).__name__}: {e}")
54
+ yield "\n".join(lines), "", f"error: {e}", None
55
+ return
56
+
57
+ status_badge = f"**{result.status.upper()}** — {result.summary}"
58
+ if result.pr_url:
59
+ status_badge += f"\n\nPR: {result.pr_url}"
60
+
61
+ report_index = result.report_path / "index.html"
62
+ report_url = str(report_index) if report_index.is_file() else None
63
+
64
+ yield (
65
+ "\n".join(lines),
66
+ status_badge,
67
+ result.summary,
68
+ report_url,
69
+ )
70
+
71
+
72
+ with gr.Blocks(title="SimReady Validator") as demo:
73
+ gr.Markdown(
74
+ "# SimReady Validator\n"
75
+ "Validate a HuggingFace dataset against a SimReady profile. "
76
+ "Reads the dataset directly from HF storage — no copy onto NVIDIA "
77
+ "infrastructure. With **Open PR** enabled, the verdict is uploaded "
78
+ "back to the dataset as a `validation/` pull request."
79
+ )
80
+ with gr.Row():
81
+ dataset = gr.Textbox(
82
+ label="Dataset",
83
+ placeholder="org/dataset (e.g. imagineio/PhysicalAI-SimReady-Kitchens-v1)",
84
+ )
85
+ with gr.Row():
86
+ profile = gr.Dropdown(
87
+ choices=PROFILE_CHOICES, value=DEFAULT_PROFILE, label="Profile",
88
+ )
89
+ version = gr.Textbox(label="Version", value=DEFAULT_VERSION)
90
+ open_pr = gr.Checkbox(label="Open PR on dataset with verdict", value=False)
91
+ run_btn = gr.Button("Validate", variant="primary")
92
+ status_md = gr.Markdown(label="Verdict")
93
+ summary_box = gr.Textbox(label="Summary", interactive=False)
94
+ log_box = gr.Textbox(label="Log", lines=20, interactive=False)
95
+ report_link = gr.File(label="HTML report (download)", interactive=False)
96
+
97
+ run_btn.click(
98
+ fn=_run_streaming,
99
+ inputs=[dataset, profile, version, open_pr],
100
+ outputs=[log_box, status_md, summary_box, report_link],
101
+ )
102
+
103
+
104
+ if __name__ == "__main__":
105
+ demo.queue().launch(
106
+ server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
107
+ server_port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
108
+ )
tools/hf_space/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime deps for the SimReady validator on HuggingFace Spaces.
2
+ # Match the versions install-simready-sdk.sh pins on the DGXC runner so
3
+ # verdicts are byte-for-byte reproducible across environments.
4
+
5
+ gradio>=4.0
6
+ huggingface_hub>=0.34
7
+
8
+ # Validator runtime
9
+ usd-core
10
+ omniverse-asset-validator
11
+ markdown-it-py
12
+ simready-validate>=2026.4.8
tools/hf_space/runner.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator: dataset name → validator → verdict PR on the dataset.
2
+
3
+ Phase-3 of the PRD. Same flow the DGXC `tools/hf_watch/validate.py`
4
+ performs, minus:
5
+ - the Claude Code subprocess wrapper (we call the validator directly,
6
+ keeping the agentic-decision layer for our internal coordinator path)
7
+ - the status.json patching (the HF Space is per-dataset; the
8
+ coordinator dashboard polls verdicts back via its existing watcher)
9
+ - the GitHub commit step (we open an HF Dataset PR instead)
10
+
11
+ The validator engine itself is unchanged — it's the same simready-report
12
+ skill that runs on Windows, on DGXC, and now here.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import json
18
+ import os
19
+ import shlex
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Iterator
27
+
28
+ from huggingface_hub import HfApi, snapshot_download
29
+
30
+ VALIDATOR = (
31
+ Path(__file__).resolve().parent
32
+ / "tools" / "validation" / "plugins" / "simready-report"
33
+ / "skills" / "simready-report" / "validate.py"
34
+ )
35
+
36
+ # Same exclude set the DGXC path uses. HF datasets ship a lot of bulk the
37
+ # validator doesn't open; skipping shrinks downloads from tens-of-GB to
38
+ # hundreds-of-MB on assets like nvidia/PhysicalAI-SimReady-Warehouse-01.
39
+ HF_DOWNLOAD_EXCLUDES = (
40
+ ".thumbs/*",
41
+ "images/*",
42
+ "*_renders/*", "renders/*",
43
+ "*.mp4", "*.mov", "*.webm",
44
+ "*.jpg", "*.jpeg", "*.png", "*.gif", "*.tiff", "*.tif",
45
+ "*.zip", "*.tar", "*.tgz",
46
+ )
47
+ USD_EXTS = (".usd", ".usda", ".usdc", ".usdz")
48
+
49
+
50
+ @dataclasses.dataclass
51
+ class RunResult:
52
+ dataset: str
53
+ profile: str
54
+ version: str
55
+ status: str # "pass" | "warn" | "fail" | "error"
56
+ summary: str # one-line human-readable digest
57
+ results_json: dict # the validator's results.json contents
58
+ report_path: Path # local path to the HTML report tree
59
+ pr_url: str | None # discussion URL when --open-pr was used
60
+
61
+
62
+ def _now() -> str:
63
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
64
+
65
+
66
+ def _wrap_layout_for_validator(downloaded: Path, work: Path) -> Path:
67
+ """Adapt HF's flat dataset layout to the validator's expected shape.
68
+
69
+ The validator iterates *subdirectories* of its target and finds the
70
+ interface USD inside each. HF datasets ship flat — root USD plus
71
+ Geometry/Materials/SubLayers — so the unmodified validator finds
72
+ zero assets when pointed at the download root.
73
+
74
+ If there's a root-level USD, wrap the download in a parent dir so the
75
+ validator sees exactly one asset (the download itself). If the
76
+ download already has subdirs with their own interface USDs (rare),
77
+ pass it through.
78
+ """
79
+ root_usds = [p for ext in USD_EXTS for p in downloaded.glob(f"*{ext}")]
80
+ subdir_with_usds = 0
81
+ for sub in downloaded.iterdir():
82
+ if not sub.is_dir() or sub.name.startswith((".", "_")):
83
+ continue
84
+ if any(sub.glob(f"*{ext}") for ext in USD_EXTS):
85
+ subdir_with_usds += 1
86
+ break
87
+
88
+ if subdir_with_usds and not root_usds:
89
+ return downloaded # already SimReady-shaped
90
+
91
+ target_parent = work / "target"
92
+ target_parent.mkdir(parents=True, exist_ok=True)
93
+ asset_dir = target_parent / downloaded.name
94
+ if asset_dir.exists():
95
+ shutil.rmtree(asset_dir)
96
+ shutil.move(str(downloaded), str(asset_dir))
97
+ return target_parent
98
+
99
+
100
+ def _summarize(results_json: dict) -> tuple[str, str]:
101
+ """Return (status, one-line summary)."""
102
+ counts = {"error": 0, "failure": 0, "warning": 0}
103
+ total = len(results_json.get("results", []))
104
+ failed = 0
105
+ for asset in results_json.get("results", []):
106
+ if not asset.get("passed"):
107
+ failed += 1
108
+ for issue in asset.get("issues", []):
109
+ sev = (issue.get("severity") or "").lower()
110
+ if sev in counts:
111
+ counts[sev] += 1
112
+ if counts["error"] or counts["failure"]:
113
+ status = "fail"
114
+ elif counts["warning"]:
115
+ status = "warn"
116
+ elif total > 0:
117
+ status = "pass"
118
+ else:
119
+ status = "warn"
120
+ parts = [f"{total - failed}/{total} assets passed"]
121
+ parts += [f"{k}={v}" for k, v in counts.items() if v]
122
+ coverage = results_json.get("profile_coverage") or {}
123
+ if coverage.get("missing"):
124
+ parts.append(f"coverage {coverage.get('loaded')}/{coverage.get('declared')} features")
125
+ return status, " · ".join(parts)
126
+
127
+
128
+ def _open_verdict_pr(
129
+ api: HfApi, dataset: str, results_path: Path, report_dir: Path,
130
+ profile: str, version: str, status: str, summary: str,
131
+ ) -> str | None:
132
+ """Upload `validation/results.json` + `validation/report/` to the dataset
133
+ as a PR. Returns the discussion URL.
134
+
135
+ Why PR rather than commit-to-main: the dataset owner reviews the
136
+ verdict like any other change. The HF Hub PR flow is exactly the
137
+ surface the production end-state assumes — see PRD §3.
138
+ """
139
+ import io
140
+
141
+ pr_branch = f"simready-validate/{profile}-v{version}-{_now().replace(':', '-')}"
142
+ body_md = (
143
+ f"### SimReady validation\n\n"
144
+ f"- **Profile**: `{profile}` v{version}\n"
145
+ f"- **Status**: **{status.upper()}**\n"
146
+ f"- **Summary**: {summary}\n"
147
+ f"- **Generated**: {_now()}\n\n"
148
+ f"Run by the SimReady Validator HF Space. The full HTML report "
149
+ f"is in `validation/report/index.html`; machine-readable "
150
+ f"results in `validation/results.json`.\n"
151
+ )
152
+
153
+ # Stage everything that should land in the dataset under a single
154
+ # tree we can iterate. `validation/results.json` plus the entire
155
+ # `validation/report/` directory.
156
+ additions: list[tuple[str, bytes]] = []
157
+ additions.append(("validation/results.json", results_path.read_bytes()))
158
+ for path in report_dir.rglob("*"):
159
+ if path.is_file():
160
+ rel = path.relative_to(report_dir.parent) # keep `report/...`
161
+ additions.append((f"validation/{rel.as_posix()}", path.read_bytes()))
162
+
163
+ from huggingface_hub import CommitOperationAdd
164
+ operations = [
165
+ CommitOperationAdd(path_in_repo=p, path_or_fileobj=io.BytesIO(b))
166
+ for p, b in additions
167
+ ]
168
+ commit = api.create_commit(
169
+ repo_id=dataset, repo_type="dataset",
170
+ operations=operations,
171
+ commit_message=f"simready-validate: {profile} v{version} → {status}",
172
+ create_pr=True,
173
+ )
174
+ # `create_pr=True` returns the PR's revision; the discussion URL is
175
+ # derivable from it. HfApi exposes the field but its key name has
176
+ # varied across versions — fall back gracefully.
177
+ return getattr(commit, "pr_url", None) or getattr(commit, "discussion_url", None)
178
+
179
+
180
+ def run(
181
+ dataset: str,
182
+ profile: str = "Robot-Body-Runnable",
183
+ version: str = "1.0.0",
184
+ open_pr: bool = False,
185
+ hf_token: str | None = None,
186
+ log: Iterator[str] | None = None,
187
+ ) -> RunResult:
188
+ """Validate a single HF dataset. Yields log lines via the `log` callable.
189
+
190
+ The Space's Gradio UI passes a callable that streams lines to the
191
+ output panel; the test harness can pass `print` directly.
192
+ """
193
+ out = log or (lambda s: print(s, flush=True))
194
+ out(f"[{_now()}] validating dataset={dataset} profile={profile} v{version}")
195
+
196
+ token = hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
197
+ api = HfApi(token=token)
198
+
199
+ with tempfile.TemporaryDirectory(prefix=f"hfsp-{dataset.replace('/', '_')}-") as td:
200
+ work = Path(td)
201
+ out(f" workdir: {work}")
202
+
203
+ # 1. Materialize. `snapshot_download` writes into a deterministic
204
+ # subdir; we hold a stable reference for the layout-wrap step.
205
+ local = work / "raw"
206
+ local.mkdir(parents=True, exist_ok=True)
207
+ out(f" $ snapshot_download {dataset} ignore_patterns={list(HF_DOWNLOAD_EXCLUDES)}")
208
+ snapshot_download(
209
+ repo_id=dataset,
210
+ repo_type="dataset",
211
+ local_dir=str(local),
212
+ ignore_patterns=list(HF_DOWNLOAD_EXCLUDES),
213
+ token=token,
214
+ )
215
+
216
+ # 2. Wrap layout. The validator expects parent-of-asset-dirs; HF
217
+ # datasets land flat.
218
+ target = _wrap_layout_for_validator(local, work)
219
+ out(f" validator target: {target}")
220
+
221
+ # 3. Run the validator. `--no-use-kit` skips the Isaac Sim re-exec
222
+ # (no Kit on HF hardware); the P2 patch inside the validator
223
+ # drops physxschema-unavailable issues so the asset isn't
224
+ # penalized for the missing runtime.
225
+ out_dir = work / "out"
226
+ out_dir.mkdir(parents=True, exist_ok=True)
227
+ cmd = [
228
+ sys.executable, str(VALIDATOR),
229
+ str(target),
230
+ "--profile", profile,
231
+ "--version", version,
232
+ "--output", str(out_dir),
233
+ "--no-use-kit",
234
+ ]
235
+ out(f" $ {shlex.join(cmd)}")
236
+ rc = subprocess.call(cmd)
237
+ results_path = out_dir / "results.json"
238
+ if rc != 0 and not results_path.exists():
239
+ return RunResult(
240
+ dataset=dataset, profile=profile, version=version,
241
+ status="error",
242
+ summary=f"validator exited {rc}; no results.json produced",
243
+ results_json={}, report_path=out_dir, pr_url=None,
244
+ )
245
+ results_json = json.loads(results_path.read_text(encoding="utf-8"))
246
+ status, summary = _summarize(results_json)
247
+ out(f" {status.upper()}: {summary}")
248
+
249
+ # 4. (Optional) open the verdict PR on the dataset.
250
+ pr_url = None
251
+ if open_pr:
252
+ if not token:
253
+ out(" ! HF_TOKEN missing; cannot open PR")
254
+ else:
255
+ try:
256
+ pr_url = _open_verdict_pr(
257
+ api=api, dataset=dataset,
258
+ results_path=results_path, report_dir=out_dir,
259
+ profile=profile, version=version,
260
+ status=status, summary=summary,
261
+ )
262
+ out(f" PR opened: {pr_url}")
263
+ except Exception as e:
264
+ out(f" ! PR creation failed: {type(e).__name__}: {e}")
265
+
266
+ # 5. Copy the report tree OUT of the tempdir so the caller can
267
+ # keep displaying it after this function returns and the
268
+ # tempdir is cleaned.
269
+ persisted = Path("/tmp") / f"hfsp-report-{dataset.replace('/', '_')}"
270
+ if persisted.exists():
271
+ shutil.rmtree(persisted)
272
+ shutil.copytree(out_dir, persisted)
273
+
274
+ return RunResult(
275
+ dataset=dataset, profile=profile, version=version,
276
+ status=status, summary=summary,
277
+ results_json=results_json,
278
+ report_path=persisted, pr_url=pr_url,
279
+ )
tools/validation/.claude-plugin/marketplace.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-marketplace",
3
+ "name": "simready-playbook",
4
+ "description": "SimReady asset pipeline plugins for Claude Code.",
5
+ "owner": {
6
+ "name": "dloginowski",
7
+ "email": "dloginowski@nvidia.com"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "simready-report",
12
+ "source": "./plugins/simready-report",
13
+ "description": "SimReady asset pipeline tooling. Provides /simready-report (validation + HTML dashboard) and /simready-package (standalone simready ingest packaging)."
14
+ }
15
+ ]
16
+ }
tools/validation/PROBLEMS.md ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Known problems
2
+
3
+ Issues encountered with the validation pipeline, the foundation specs, or the
4
+ SimReady SDK that aren't bugs in this repo's code but affect the report
5
+ output. Each entry: **symptom → root cause → workaround / how to address →
6
+ status**.
7
+
8
+ When you hit a new issue, add it here. Keep the list short — fold resolved
9
+ items into git history once they're fixed.
10
+
11
+ ---
12
+
13
+ ## P1 — Profiles silently drop features that aren't registered
14
+
15
+ **Symptom.** A profile loads with far fewer features than its
16
+ `profiles.toml` declares. Concretely on the current foundations checkout:
17
+
18
+ | Profile | Declared in `profiles.toml` | Loaded by validator | Dropped feature IDs |
19
+ |---|---|---|---|
20
+ | `Robot-Body-Runnable` v1.0.0 | 5 | **1** | `FET004_ROBOT_PHYSX` v0.2.0, `FET021_ROBOT_CORE_RUNNABLE` v0.2.0, `FET022_DRIVEN_JOINTS_PHYSX` v0.1.0, `FET024_BASE_ARTICULATION_PHYSX` v0.1.0 |
21
+ | `Robot-Body-Isaac` v1.0.0 | 6 | 2 | `FET004_ROBOT_PHYSX` v0.2.0, `FET021_ROBOT_CORE_ISAAC` v0.2.0, `FET022_DRIVEN_JOINTS_ISAAC` v0.1.0, `FET024_BASE_ARTICULATION_PHYSX` v0.1.0 |
22
+ | `Robot-Body-Neutral` v1.0.0 | 5 | 3 | `FET022_DRIVEN_JOINTS_NEUTRAL` v0.1.0, `FET024_BASE_ARTICULATION_NEUTRAL` v0.1.0 |
23
+ | `Prop-Robotics-Neutral` v2.0.0 | 6 | 5 | `FET006_BASE_MDL` v0.1.0 |
24
+
25
+ What's actually registered for the FET02x family (after `repo
26
+ usd_profiles_codegen`):
27
+
28
+ - `fet_021_robot_core` v0.2.0 (no variant suffix)
29
+ - `fet_022_driven_joints` v0.1.0
30
+ - `fet_023_robot_materials` v0.1.0
31
+ - `fet_024_base_articulation` v0.1.0
32
+
33
+ And for FET004/FET006 the only `_BASE_NEUTRAL` / `_BASE_USDPREVIEW`
34
+ forms are registered, not the `_ROBOT_PHYSX` / `_BASE_MDL` variants the
35
+ profiles ask for.
36
+
37
+ **Root cause.** Profile entries in
38
+ `<foundations>/nv_core/sr_specs/docs/profiles/profiles.toml` reference
39
+ variant feature IDs that the local foundations registry doesn't ship — e.g.
40
+ profiles ask for `FET021_ROBOT_CORE_RUNNABLE`, `FET022_DRIVEN_JOINTS_PHYSX`,
41
+ `FET004_ROBOT_PHYSX`, `FET024_BASE_ARTICULATION_NEUTRAL`. What is actually
42
+ registered (after `repo usd_profiles_codegen` runs against our checkout) is
43
+ only the unsuffixed `_BASE_NEUTRAL` / `_BASE_USDPREVIEW` / `_CORE` /
44
+ `_BASE_ISAACSIM` variants. The variant features the profiles ask for are
45
+ silently absent.
46
+
47
+ `omni.asset_validator.ProfileRegistry` resolves features by ID + version and
48
+ silently drops any it can't find. There's no warning emitted at profile
49
+ load time.
50
+
51
+ **Verified the gap is local, not universal.** Sample reports from another
52
+ SimReady environment (Fanuc content, sr9iar.usd.usd) show all five
53
+ declared `Robot-Body-Runnable` features actually loaded —
54
+ `FET001_BASE_NEUTRAL` (failed), plus `FET004_ROBOT_PHYSX`,
55
+ `FET021_ROBOT_CORE_RUNNABLE`, `FET022_DRIVEN_JOINTS_PHYSX`,
56
+ `FET024_BASE_ARTICULATION_PHYSX`, `FET024_BASE_ARTICULATION_NEUTRAL`
57
+ (passed). So the variants *are* registrable; our local codegen output
58
+ just doesn't include them.
59
+
60
+ **Where the variants live.** The foundation source ships JSON variant
61
+ definitions next to each feature's markdown. For FET004:
62
+
63
+ ```
64
+ docs/features/FET_004-simulate_multi_body_physics.md (md, _BASE_NEUTRAL only)
65
+ docs/features/FET_004_base_neutral-0.1.0-...json
66
+ docs/features/FET_004_base_physx-0.1.0-...json
67
+ docs/features/FET_004_base_physx-0.2.0-...json
68
+ docs/features/FET_004_robot_physx-0.1.0-...json <- this is the one profiles ask for
69
+ docs/features/FET_004_robot_physx-0.2.0-...json
70
+ ```
71
+
72
+ Each JSON declares an `id` (e.g. `FET004_ROBOT_PHYSX`), `version`, `path`,
73
+ and a `requirements` list. **Our `repo usd_profiles_codegen` run only
74
+ parses the `.md` files** — the generated
75
+ `_build/python/omni/capabilities/_features.py` contains zero
76
+ `_ROBOT_PHYSX` / `_BASE_PHYSX` / `_RUNNABLE` entries even though the JSON
77
+ sources are right there.
78
+
79
+ **How to address.** Three paths, in priority order:
80
+
81
+ 1. **Codegen — pick up the JSON variant files.** The foundations
82
+ `repo usd_profiles_codegen` tool needs to also enumerate
83
+ `FET_*_*-*-...json` files in the features directory and emit Feature
84
+ entries for each, not just the `Internal ID` declared in the `.md`.
85
+ This closes the gap without changing source data — the variants
86
+ already exist.
87
+ 2. **Validator — emit a warning when a profile entry doesn't resolve.**
88
+ `omni.asset_validator.ProfileRegistry` silently drops missing
89
+ features. A `WARNING:` log at profile-load time would have made this
90
+ bug visible from day one.
91
+ 3. **Documentation** — once #1 lands, `repo.toml`'s codegen description
92
+ should call out that JSON variants are part of the source of truth so
93
+ downstream tooling doesn't repeat the mistake.
94
+
95
+ Open this with the SimReady foundations team
96
+ (`#omni-simready` / `#simready-next-support`); cite the discrepancy
97
+ between our local codegen output and the working Fanuc environment
98
+ sample.
99
+
100
+ **In-repo workaround.** The dashboard now surfaces the coverage gap
101
+ explicitly: when a profile declares more features than the validator
102
+ loaded, a banner at the top lists the missing IDs and tells the user which
103
+ ones got silently dropped. The validation result the report shows is
104
+ genuinely against the *loaded* feature set — running with
105
+ `Robot-Body-Runnable` does *not* check the four declared-but-missing
106
+ features.
107
+
108
+ **Status.** Local patch applied — see "Local patch applied" below.
109
+ Upstream codegen fix still needed. Banner workaround still in place
110
+ for environments without the patch.
111
+
112
+ ### Local patch applied
113
+
114
+ A patch lives in
115
+ `plugins/simready-report/skills/simready-report/validate.py` —
116
+ function `_patch_register_json_variant_features()`. It runs at module
117
+ load (so worker processes pick it up too) right after
118
+ `import omni.asset_validator as oav`. It is loud about itself: every
119
+ run prints a `[PATCH P1] FeatureRegistry: +N JSON-variant feature(s)
120
+ ... ProfileRegistry: rebuilt feature lists for M profile(s) ...`
121
+ line so it's never invisible.
122
+
123
+ **Concretely on the Yaskawa-local checkout** the patch lifts:
124
+ - FeatureRegistry: 11 features → 30 features (+19 from JSON variants).
125
+ - Robot-Body-Runnable v1.0.0: 1 feature / 8 requirements → **5 / 41**.
126
+ - 11 profiles got their `.features` lists rebuilt; +24 feature
127
+ references appended in total.
128
+
129
+ **Exactly what the patch does**, step by step:
130
+
131
+ 1. **Discover variant JSON files.** Globs
132
+ `<foundations>/nv_core/sr_specs/docs/features/FET_*.json`. The
133
+ filename styles in this repo are inconsistent
134
+ (`FET_021-robot_core_isaac-0.1.0.json`,
135
+ `FET_021_robot_core_runnable_0.2.0.json`,
136
+ `FET_004_base_physx-0.2.0-simulate_multi_body_phyics.json`), so we
137
+ ignore the filename and read the `id` + `version` fields from the
138
+ JSON content.
139
+
140
+ 2. **Build a Requirement code lookup.** `r.code -> r` for every
141
+ `Requirement` in `omni.asset_validator.RequirementsRegistry()`.
142
+ The JSON variants list dotted code strings like `"RB.COL.003"`,
143
+ `"DJ.001"`, `"BA.002"` — those are looked up here.
144
+
145
+ 3. **Construct a Feature-protocol object per JSON variant.**
146
+ `omni.asset_validator._features.Feature` is a `typing.Protocol`
147
+ declaring `id: str, version: str, path: str, requirements: list`.
148
+ We use a frozen dataclass that satisfies the protocol structurally:
149
+
150
+ ```python
151
+ @dataclass(frozen=True)
152
+ class _PatchedFeature:
153
+ id: str
154
+ version: str
155
+ path: str
156
+ requirements: list
157
+ ```
158
+
159
+ 4. **Skip duplicates and unresolvable variants.** If
160
+ `FeatureRegistry.find(fid, fver)` already returns something, skip
161
+ (don't double-register). If any of the JSON's requirement code
162
+ strings doesn't resolve in the code lookup, skip the whole feature
163
+ and record it in `unresolved_codes`. (Currently zero unresolved on
164
+ our checkout — every JSON variant's required codes exist.)
165
+
166
+ 5. **Register via `FeatureRegistry().add(feat)`.** The internal
167
+ `create_key` uses `.id` + `.version` so our dataclass satisfies it.
168
+
169
+ 6. **Re-resolve every profile's feature list — the critical step.**
170
+ `Profile` is a frozen dataclass, so we can't reassign
171
+ `profile.features`. But the field is a *mutable list*, and the
172
+ freeze only prevents field reassignment, not mutation of held
173
+ collections. So for every entry in `profiles.toml`'s declared
174
+ features that isn't already in the live profile, we look it up in
175
+ the now-fuller `FeatureRegistry` and `profile.features.append(...)`
176
+ in place. **Without this step the patch is invisible** — adding to
177
+ `FeatureRegistry` after profiles are already built doesn't
178
+ retroactively join existing Profile objects.
179
+
180
+ **What the patch does NOT touch**: foundation source files, the
181
+ codegen tool, generated `_features.py`, profile definitions in
182
+ `profiles.toml`, or any rule code. The local patch is purely in our
183
+ validation runner.
184
+
185
+ **Remove the patch when**: the foundation `repo usd_profiles_codegen`
186
+ tool is updated to enumerate JSON variant files alongside the
187
+ markdown. Once `_features.py` carries every variant on its own,
188
+ delete `_patch_register_json_variant_features()` and its call site.
189
+
190
+ ---
191
+
192
+ ## P2 — Public ur10 sample asset fails current foundation specs
193
+
194
+ **Symptom.** Running `/simready-report` against the public ur10 sample
195
+ in
196
+ `simready_foundations/sample_content/common_assets/robots_general/ur10/`
197
+ (release `2026.04.0`, commit `805d2c5`) reports failures on every
198
+ variant when validated against its declared profile (or against
199
+ Robot-Body-Runnable cross-profile). Concretely, against each interface
200
+ USD's declared profile, with `--use-kit` so PhysX rules actually run:
201
+
202
+ | Variant interface | Declared profile | Result | Failing requirements |
203
+ |---|---|---|---|
204
+ | `simready_usd/ur10.usda` | Robot-Body-Neutral v1.0.0 | **PASS** | — |
205
+ | `simready_physx_usd/ur10.usda` | Robot-Body-Physx v1.0.0 | **FAIL** | (profile evaluates 0 features against the asset; `features_summary` empty in JSON output) |
206
+ | `simready_isaac_usd/ur10.usda` | Robot-Body-Isaac v1.0.0 | **FAIL** | `RC.005` (`VerifyRobotPhysicsAttributesSourceLayer`), `RC.008` (`robot-type`), `RC.009` (`root-joint-pinned`), `DJ.010`, `ISA.001` |
207
+
208
+ Under the cross-profile `Robot-Body-Runnable` run, Neutral and PhysX
209
+ variants additionally light up with `RC.007` (`robot-schema` — no
210
+ `IsaacRobotAPI` on the default prim) because neither of those variants
211
+ applies `IsaacRobotAPI`, only the Isaac variant does.
212
+
213
+ **Root cause.** Asset-side metadata gaps and layer-organization issues
214
+ in the shipped public sample, *not* validator or skill code. The asset
215
+ file is tracked by Git LFS at the same OID as the release (`b30f9c1b…`
216
+ for `simready_isaac_usd/payloads/base.usda`), so no in-tree edit
217
+ caused this. Each asset's `customLayerData.SimReady_Metadata.validation`
218
+ records `validated_features` against a snapshot dated **2026-01-09**;
219
+ between then and the current `805d2c5` foundation specs the rules in
220
+ question were tightened (or added), so the asset that passed in
221
+ January no longer passes today. Specifically:
222
+
223
+ - **`RC.008` (`robot-type`)** — `simready_isaac_usd/payloads/base.usda`
224
+ lines 81-83 declare `token isaac:robotType` with **no value**. The
225
+ current rule requires a valid schema-defined token (e.g.
226
+ `"Manipulator"`, `"End Effector"`); the placeholder `"Default"` and
227
+ the absent-value case both fail.
228
+ - **`RC.009` (`root-joint-pinned`)** — depends on `isaac:robotType`
229
+ to decide whether the root joint must be pinned. The asset's
230
+ `root_joint` *is* a `PhysicsFixedJoint` with empty `physics:body0`
231
+ (pinned to world, correct for a Manipulator), but without a valid
232
+ `robotType` value the rule can't evaluate and fails.
233
+ - **`RC.005` (`VerifyRobotPhysicsAttributesSourceLayer`)** — physics
234
+ attributes (`physics:axis`, `physics:mass`, `physics:centerOfMass`,
235
+ `physics:diagonalInertia`, `physics:principalAxes`) on each robot
236
+ link are authored in *both* `payloads/base.usda` and
237
+ `payloads/Physics/physics.usda`. The current rule wants them in the
238
+ physics payload only.
239
+ - **PhysX variant empty `features_summary`** — Robot-Body-Physx v1.0.0
240
+ evaluates zero features against the asset, suggesting the profile's
241
+ declared feature set no longer matches what the asset has stamped
242
+ (related to P1, but for a different feature family).
243
+
244
+ **How to address.** Asset fixes belong in
245
+ `github.com/NVIDIA/simready-foundation`, not here:
246
+
247
+ 1. **`RC.008`/`RC.009`** — *verified one-liner*. In
248
+ `simready_isaac_usd/payloads/base.usda` line 81, change
249
+ `token isaac:robotType (…)` to
250
+ `token isaac:robotType = "Manipulator" (…)`. Verified locally with
251
+ `--use-kit`:
252
+ - Against `Robot-Body-Isaac`, `FET021_ROBOT_CORE_ISAAC` failing
253
+ codes went from `[RC.005, RC.008, RC.009]` →
254
+ **`[RC.003, RC.005]`** (RC.008 and RC.009 cleared; RC.003 was
255
+ previously cascade-suppressed and now surfaces).
256
+ - Against `Robot-Body-Runnable` cross-profile, the **Isaac variant
257
+ now PASSES** (`FET021_ROBOT_CORE_RUNNABLE` only requires
258
+ RC.007/008/009 — all three OK once `isaac:robotType` is set,
259
+ because IsaacRobotAPI is already applied with the correct
260
+ joints/links relationships and the root joint is pinned via
261
+ empty `physics:body0`). Cross-profile summary moved from
262
+ `0 passed / 3 failed` → `1 passed / 2 failed` (Isaac passes,
263
+ Neutral and PhysX still fail RC.007/008/009 because they don't
264
+ apply IsaacRobotAPI at all).
265
+ 2. **`RC.005`**: move every `physics:*` attribute currently authored
266
+ on links in `payloads/base.usda` into `payloads/Physics/physics.usda`
267
+ only (or refactor so `base.usda` carries no physics attributes at
268
+ all). This is a meaningful layer-organization change, not a
269
+ one-liner. Still failing after fix 1 (88 RC.005 issues remain).
270
+ 3. **`RC.003` (`RobotNaming`)** — surfaced *only after* fix 1. The
271
+ rule compares the **immediate folder name** (`simready_isaac_usd`)
272
+ to the **interface USD stem** (`ur10`). Under the standard SimReady
273
+ packaging convention these never match — every Isaac variant of
274
+ every SimReady asset fails RC.003. Likely a rule bug (should
275
+ probably look at the *bundle* parent, e.g. `ur10/`, not the
276
+ variant folder) or a misalignment between rule and packaging spec.
277
+ Worth raising upstream.
278
+ 4. **`RC.007` (cross-profile only)**: if the intent is that the
279
+ Neutral and PhysX variants should also satisfy Robot-Body-Runnable,
280
+ apply `IsaacRobotAPI` to their default prims with the same
281
+ `isaac:physics:robotJoints`/`robotLinks` relationships used in the
282
+ Isaac variant. If the intent is that each variant only ever
283
+ validates against its own declared profile, this is not needed.
284
+ 5. **PhysX empty features**: align the variant USDs and Robot-Body-Physx
285
+ profile's expected feature set — likely needs the assets to carry
286
+ the variant feature IDs that the profile declares (intersect with
287
+ P1's missing-feature list once that's resolved). Also: `validate.py`
288
+ currently emits an empty `.reports/<…>/` dir when zero features
289
+ match (no `index.html` / `results.json` written). Tracked
290
+ separately as a `validate.py` robustness issue.
291
+
292
+ **In-repo workaround.** None — this is downstream of the validator. The
293
+ `simready-report` skill faithfully reports what the rules say. The
294
+ dashboard's "Failing requirements" panel surfaces the offending codes
295
+ so users can map each failure back to its requirement doc.
296
+
297
+ **Status.** Open against the foundations repo. Filed for tracking via
298
+ this MR; no action in `simready-explorer` is needed beyond surfacing
299
+ the issue here. Fix 1 above is **verified to work locally** (one-line
300
+ edit to `base.usda`) — upstream PR against `NVIDIA/simready-foundation`
301
+ is the right channel to land it. Fixes 2–5 still open. Re-validate
302
+ after the foundation team updates the sample assets, the rule code
303
+ (notably `RC.003 RobotNaming`), or relaxes the unmatched-feature
304
+ behavior.
305
+
306
+ ---
307
+
308
+ ## How to file a new entry here
309
+
310
+ Use this skeleton:
311
+
312
+ ```
313
+ ## P<N> — <one-line title>
314
+
315
+ **Symptom.** What the user/reader sees that's wrong.
316
+
317
+ **Root cause.** Where the bug actually lives (which repo / file / step).
318
+
319
+ **How to address.** Concrete fix path(s), upstream or local.
320
+
321
+ **In-repo workaround.** What this repo does today to mitigate, if anything.
322
+
323
+ **Status.** Open / mitigated / fixed (link to commit).
324
+ ```
325
+
326
+ Bump the number; don't reuse retired ones — the git history is the
327
+ canonical record once an entry is resolved and removed.
tools/validation/README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validation reporting — staging branch
2
+
3
+ ## Install the Claude Code plugin
4
+
5
+ The `simready-report` plugin ships as a Claude Code marketplace under
6
+ `validation/.claude-plugin/`. From a checkout of this repo, run inside Claude Code:
7
+
8
+ ```
9
+ /plugin marketplace add <path-to-repo>/validation
10
+ /plugin install simready-report@simready-playbook
11
+ ```
12
+
13
+ That registers two slash commands: `/simready-report` and `/simready-package`.
14
+ You still need a Python with the SimReady runtime deps and the foundations + sdk
15
+ checkouts on disk — see [To run the demo as-is](#to-run-the-demo-as-is) below.
16
+
17
+ > [!IMPORTANT]
18
+ > ## Read [`PROBLEMS.md`](PROBLEMS.md) before trusting any report
19
+ >
20
+ > Reports produced by this prototype lean on a **local patch** for
21
+ > [PROBLEMS.md P1](PROBLEMS.md): without it, every Robot-Body profile
22
+ > silently drops 4 of its 5 declared features at load time, because
23
+ > the foundation `repo usd_profiles_codegen` step doesn't read the
24
+ > JSON variant definitions next to each feature's markdown. A
25
+ > "passing" report against `Robot-Body-Runnable` would only have
26
+ > checked 1 feature out of 5.
27
+ >
28
+ > The patch lives in
29
+ > `plugins/simready-report/skills/simready-report/validate.py` and is
30
+ > loud about itself — every run prints a `[PATCH P1]` line. With the
31
+ > patch active, `Robot-Body-Runnable` resolves to **5/5 features, 41
32
+ > requirements** instead of 1/8.
33
+ >
34
+ > The dashboard's **Caveats** panel still surfaces the gap if anyone
35
+ > runs without the patch.
36
+ >
37
+ > [`PROBLEMS.md`](PROBLEMS.md) **P1** has the full root cause, the
38
+ > patch walkthrough, and the upstream fix path. The patch is meant to
39
+ > be temporary — once foundations codegen is fixed, delete
40
+ > `_patch_register_json_variant_features()` and its call site.
41
+
42
+ ---
43
+
44
+ > **Status: demo / review staging.** Not part of `simready-explorer` proper yet.
45
+ >
46
+ > This directory is a verbatim copy of the prototype work currently living at
47
+ > [`loginowskid/simready-playbook`](https://github.com/loginowskid/simready-playbook),
48
+ > dropped here on the `dev/dloginowski/validation-reporting` branch so the
49
+ > SimReady team can review the artifact before we refactor it into the repo's
50
+ > existing library + skills patterns.
51
+
52
+ ## What this is
53
+
54
+ A Claude-Code-driven validation reporting pipeline for SimReady customer assets.
55
+ On top of the existing `omni.asset_validator` engine and SimReady profile registry,
56
+ it adds:
57
+
58
+ - **HTML dashboard** with summary cards, per-asset feature pass/fail, an
59
+ asset filter, and a "Other failed requirements" panel for issues outside the
60
+ loaded profile.
61
+ - **External-dependencies provenance report** — walks USD composition arcs per
62
+ asset, classifies each layer as internal/external relative to the target,
63
+ records actions taken to obtain each.
64
+ - **Foundation doc rendering** — renders the markdown source for every
65
+ feature/requirement code referenced by the report into HTML inside the output.
66
+ - **Auto-packaging convenience** — for a raw asset bundle under
67
+ `assets_to_validate/<name>/`, runs `simready ingest usd` per interface USD
68
+ before validating against the packaged tree.
69
+ - **Spec selector** — `--list-profiles` enumerates registered profiles so the
70
+ user can pick a SimReady profile interactively.
71
+ - **Parallel asset validation** — `ProcessPoolExecutor` workers, default auto.
72
+
73
+ Two slash commands ship together as a single Claude Code plugin:
74
+
75
+ - `/simready-report` — validate + dashboard + provenance + docs.
76
+ - `/simready-package` — standalone packaging (no validation).
77
+
78
+ ## Layout (mirrors the source repo)
79
+
80
+ ```
81
+ validation/
82
+ ├── bootstrap.ps1 # Windows convenience installer
83
+ ├── marketplace.json # Claude Code marketplace manifest
84
+ ├── plugins/simready-report/
85
+ │ ├── plugin.json
86
+ │ └── skills/
87
+ │ ├── simready-report/ # HTML dashboard + validation
88
+ │ └── simready-package/ # standalone packaging
89
+ └── playbooks/
90
+ └── foundations-deviations.md # observed gaps between specs and asset_validator
91
+ ```
92
+
93
+ ## Why it doesn't fit the repo's existing patterns yet
94
+
95
+ `simready-explorer` already ships `source/libraries/validate/` with a clean
96
+ public API (`validate_asset`, `validate_asset_list`, etc.) and three skills in
97
+ the `agentskills.io/specification` format (`validate-asset`, `validate-batch`,
98
+ `validate-metadata`). The work in this `validation/` directory:
99
+
100
+ - Reinvents a thinner version of `simready.validate.validate_asset_list` —
101
+ this is duplication that should be removed during integration.
102
+ - Uses a different skill format (Claude Code marketplace plugin) — should be
103
+ rewritten as `agentskills.io` skills that call into the library.
104
+ - Is a standalone-script architecture (`python validate.py …`) — should be
105
+ exposed via the existing `repo.bat` / library entry points.
106
+
107
+ ## Proposed integration after review
108
+
109
+ 1. New skill `validate-report` under
110
+ `source/libraries/validate/src/validate/skills/validate-report/` —
111
+ instructional doc that drives a new `simready.validate.report` module.
112
+ 2. New module `source/libraries/validate/src/validate/report/` containing the
113
+ dashboard renderer, doc renderer, external-deps tracker, thumbnail mirrorer
114
+ — extracted from `report.py` + `external_deps.py` and adapted to consume
115
+ `AssetValidationResult` instead of the raw `omni.asset_validator` issue
116
+ stream.
117
+ 3. Optional new skill `validate-package` — thin wrapper around
118
+ `simready ingest usd` (independent of validation).
119
+ 4. Drop `bootstrap.ps1` / `marketplace.json` / Claude-Code-plugin layout — not
120
+ relevant here; the team uses `repo.bat` + `_build/` flow.
121
+
122
+ ## To run the demo as-is
123
+
124
+ The scripts in this directory are self-contained and need a Python with the
125
+ SimReady runtime deps installed plus the foundations + sdk checkouts on disk.
126
+ Easiest path on Windows: `& bootstrap.ps1` (creates a venv at
127
+ `%LOCALAPPDATA%\simready\` and persists `SIMREADY_PYTHON` /
128
+ `SIMREADY_FOUNDATIONS_PATH` / `SIMREADY_SDK_PATH` env vars). On other systems
129
+ or custom layouts, install the deps into your Python and set those env vars
130
+ manually.
131
+
132
+ After that:
133
+
134
+ ```
135
+ python plugins/simready-report/skills/simready-report/validate.py \
136
+ <path-to-asset-bundle> --profile Robot-Body-Isaac --version 1.0.0
137
+ ```
138
+
139
+ Output lands at `<bundle>/.reports/<bundle-name>.<profile>/index.html`.
tools/validation/UPSTREAM.md ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Upstream sync
2
+
3
+ `tools/validation/` is a **verbatim fork** of the `validation/` tree from:
4
+
5
+ - **Repo:** `omniverse/kit-extensions/simready-explorer`
6
+ - **Host:** `gitlab-master.nvidia.com`
7
+ - **Branch:** `dev/dloginowski/validation-reporting`
8
+ - **URL:** https://gitlab-master.nvidia.com/omniverse/kit-extensions/simready-explorer/-/tree/dev/dloginowski/validation-reporting
9
+
10
+ It is **not** a git submodule. Upstream changes do not flow here automatically.
11
+
12
+ ## Current pin
13
+
14
+ ```
15
+ upstream_sha: 4bd42394685156d97f3e3bf371117f53e8dd8ab1
16
+ synced_at: 2026-05-22
17
+ synced_by: dloginowski
18
+ ```
19
+
20
+ > Bump these three fields whenever you re-run `_sync.sh`.
21
+
22
+ ## Check for drift
23
+
24
+ ```bash
25
+ git ls-remote https://gitlab-master.nvidia.com/omniverse/kit-extensions/simready-explorer.git \
26
+ refs/heads/dev/dloginowski/validation-reporting
27
+ ```
28
+
29
+ Compare the printed SHA to `upstream_sha` above. If they differ, upstream has
30
+ new commits we don't have.
31
+
32
+ ## Pull a new version
33
+
34
+ ```bash
35
+ bash tools/validation/_sync.sh
36
+ ```
37
+
38
+ The script:
39
+
40
+ 1. Shallow-clones the upstream branch to a temp dir.
41
+ 2. Reports the new SHA and how many commits ahead of `upstream_sha` it is.
42
+ 3. Rsyncs upstream `validation/*` over `tools/validation/*`, preserving
43
+ `UPSTREAM.md` and `_sync.sh` themselves.
44
+ 4. Prints a diff stat so you can see what changed.
45
+
46
+ After running, review the diff, **manually update the pin block above**, then
47
+ commit:
48
+
49
+ ```bash
50
+ git add tools/validation/
51
+ git commit -m "Sync tools/validation/ from upstream <short-sha>"
52
+ ```
53
+
54
+ ## When upstream lands in `simready-explorer` proper
55
+
56
+ The upstream README (`tools/validation/README.md`) calls this directory
57
+ "demo / review staging" and outlines the intended integration into
58
+ `simready-explorer`'s existing library + skills patterns. Once that lands:
59
+
60
+ 1. Delete `tools/validation/`.
61
+ 2. Replace the runner-side driver `tools/hf_watch/validate.py` with calls
62
+ into `simready-explorer`'s public CLI (likely `repo.bat validate` or
63
+ the `simready.validate` library entry points).
64
+ 3. Drop this file.
65
+
66
+ Until then, treat this fork as a snapshot.
tools/validation/_sync.sh ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Sync tools/validation/ from the upstream GitLab branch.
3
+ #
4
+ # Run from the repo root:
5
+ # bash tools/validation/_sync.sh
6
+ #
7
+ # After running, review the diff, bump the pin block in
8
+ # tools/validation/UPSTREAM.md, and commit.
9
+
10
+ set -euo pipefail
11
+
12
+ UPSTREAM_URL="https://gitlab-master.nvidia.com/omniverse/kit-extensions/simready-explorer.git"
13
+ UPSTREAM_BRANCH="dev/dloginowski/validation-reporting"
14
+ UPSTREAM_PATH="validation"
15
+ LOCAL_PATH="tools/validation"
16
+ KEEP=(UPSTREAM.md _sync.sh)
17
+
18
+ if [[ ! -d "${LOCAL_PATH}" ]]; then
19
+ echo "expected ${LOCAL_PATH} to exist; are you in the repo root?" >&2
20
+ exit 1
21
+ fi
22
+
23
+ # Compare current pin to upstream HEAD.
24
+ current_pin="$(grep -oE 'upstream_sha:\s*[0-9a-f]+' "${LOCAL_PATH}/UPSTREAM.md" \
25
+ | awk '{print $2}' | head -n1 || true)"
26
+ upstream_sha="$(git ls-remote "${UPSTREAM_URL}" "refs/heads/${UPSTREAM_BRANCH}" \
27
+ | awk '{print $1}')"
28
+
29
+ if [[ -z "${upstream_sha}" ]]; then
30
+ echo "could not resolve upstream branch SHA — check network / auth" >&2
31
+ exit 2
32
+ fi
33
+
34
+ echo "current pin: ${current_pin:-(unset)}"
35
+ echo "upstream HEAD: ${upstream_sha}"
36
+
37
+ if [[ "${current_pin}" == "${upstream_sha}" ]]; then
38
+ echo "already in sync — nothing to do"
39
+ exit 0
40
+ fi
41
+
42
+ tmpdir="$(mktemp -d)"
43
+ trap 'rm -rf "${tmpdir}"' EXIT
44
+
45
+ echo ">> shallow-clone upstream"
46
+ git clone --depth 50 --branch "${UPSTREAM_BRANCH}" --single-branch \
47
+ "${UPSTREAM_URL}" "${tmpdir}/upstream" >/dev/null
48
+
49
+ # How many commits ahead of the current pin (best-effort; falls back to "?")
50
+ ahead="?"
51
+ if [[ -n "${current_pin}" ]]; then
52
+ if git -C "${tmpdir}/upstream" cat-file -e "${current_pin}" 2>/dev/null; then
53
+ ahead="$(git -C "${tmpdir}/upstream" rev-list --count "${current_pin}..HEAD")"
54
+ fi
55
+ fi
56
+ echo "commits ahead of pin: ${ahead}"
57
+
58
+ upstream_dir="${tmpdir}/upstream/${UPSTREAM_PATH}"
59
+ if [[ ! -d "${upstream_dir}" ]]; then
60
+ echo "upstream ${UPSTREAM_PATH}/ missing in branch ${UPSTREAM_BRANCH}" >&2
61
+ exit 3
62
+ fi
63
+
64
+ echo ">> rsync upstream → ${LOCAL_PATH}"
65
+ # Stash KEEP files so rsync --delete doesn't remove them.
66
+ keep_stash="$(mktemp -d)"
67
+ for f in "${KEEP[@]}"; do
68
+ if [[ -f "${LOCAL_PATH}/${f}" ]]; then
69
+ cp -p "${LOCAL_PATH}/${f}" "${keep_stash}/${f}"
70
+ fi
71
+ done
72
+
73
+ rsync -a --delete "${upstream_dir}/" "${LOCAL_PATH}/"
74
+
75
+ for f in "${KEEP[@]}"; do
76
+ if [[ -f "${keep_stash}/${f}" ]]; then
77
+ cp -p "${keep_stash}/${f}" "${LOCAL_PATH}/${f}"
78
+ fi
79
+ done
80
+ rm -rf "${keep_stash}"
81
+
82
+ echo
83
+ echo ">> changes:"
84
+ git -c color.ui=always diff --stat -- "${LOCAL_PATH}" | tail -20 || true
85
+
86
+ cat <<EOF
87
+
88
+ Sync staged. Next steps:
89
+
90
+ 1. Bump the pin block in ${LOCAL_PATH}/UPSTREAM.md:
91
+
92
+ upstream_sha: ${upstream_sha}
93
+ synced_at: $(date -u +%Y-%m-%d)
94
+ synced_by: \$(git config user.name)
95
+
96
+ 2. Review the diff: git diff -- ${LOCAL_PATH}
97
+ 3. Commit: git add ${LOCAL_PATH} && git commit -m "Sync tools/validation/ from upstream ${upstream_sha:0:12}"
98
+ EOF
tools/validation/bootstrap.ps1 ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <#
2
+ .SYNOPSIS
3
+ Bootstrap the SimReady Pipeline Bot runtime dependencies.
4
+
5
+ .DESCRIPTION
6
+ Idempotent. Run once on a fresh machine, or re-run any time to fix a
7
+ missing dependency. Creates a Python venv, clones the foundation + SDK
8
+ repos, and pip-installs everything the /simready-report skill needs.
9
+
10
+ The foundation specs are *not* editable-installed any more. validate.py
11
+ now defaults to populating the OAV registries via the simready-validate
12
+ CLI loader against on-disk paths under the foundations checkout.
13
+ Avoids the `repo usd_profiles_codegen` codegen step (which depends on
14
+ internal packman tooling not shipped in the public GitHub repo) and the
15
+ Forced-include trap in nv_core/sr_specs/pyproject.toml.
16
+
17
+ Layout produced (all under -DepsRoot, default $env:LOCALAPPDATA\simready):
18
+
19
+ <DepsRoot>\
20
+ ├── venv\ # Python venv (created if missing)
21
+ ├── simready_foundations\ # cloned from NVIDIA GitHub
22
+ └── simready-oem-sdk-poc\ # cloned from NVIDIA-dev GitHub
23
+
24
+ After a successful install the script writes three User-level environment
25
+ variables so the /simready-report skill can locate everything from any shell:
26
+
27
+ SIMREADY_PYTHON = <DepsRoot>\venv\Scripts\python.exe
28
+ SIMREADY_FOUNDATIONS_PATH = <DepsRoot>\simready_foundations
29
+ SIMREADY_SDK_PATH = <DepsRoot>\simready-oem-sdk-poc
30
+
31
+ Override the location with -DepsRoot, e.g.:
32
+ .\bootstrap.ps1 -DepsRoot D:\simready-deps
33
+
34
+ Already have these repos checked out elsewhere? Skip this script and
35
+ set the two env vars manually.
36
+
37
+ Optional (not bootstrapped): a `usd_validation_dashboard_final\` clone —
38
+ used only to copy Sphinx _static\ assets into rendered doc pages. If
39
+ absent, the dashboard's doc pages fall back to minimal inline styling.
40
+
41
+ .NOTES
42
+ - Reversible: the dirs created here are safe to delete; rerun this
43
+ script to re-create them. The two env vars can be cleared with
44
+ `[Environment]::SetEnvironmentVariable("SIMREADY_FOUNDATIONS_PATH", $null, "User")`.
45
+ - Requires: git, python (3.11+ recommended), and access to
46
+ github.com/NVIDIA + github.com/NVIDIA-dev (creds via the user's
47
+ existing git/gh auth).
48
+ - Never elevates / never modifies system PATH.
49
+ #>
50
+ [CmdletBinding()]
51
+ param(
52
+ [switch]$Force, # re-clone even if dirs exist
53
+ [string]$Python = "python", # python interpreter to seed the venv
54
+ [string]$DepsRoot = (Join-Path $env:LOCALAPPDATA "simready"),
55
+ [string]$FoundationsRepo = "https://github.com/NVIDIA/simready-foundation.git",
56
+ [string]$SdkRepo = "https://github.com/NVIDIA-dev/simready-oem-sdk-poc.git"
57
+ )
58
+
59
+ $ErrorActionPreference = "Stop"
60
+
61
+ if (-not (Test-Path $DepsRoot)) {
62
+ New-Item -ItemType Directory -Path $DepsRoot -Force | Out-Null
63
+ }
64
+ $DepsRoot = (Resolve-Path $DepsRoot).Path
65
+
66
+ $VenvPath = Join-Path $DepsRoot "venv"
67
+ $FoundationsPath = Join-Path $DepsRoot "simready_foundations"
68
+ $SdkPath = Join-Path $DepsRoot "simready-oem-sdk-poc"
69
+
70
+ function Step($msg) { Write-Host ""; Write-Host "==> $msg" -ForegroundColor Cyan }
71
+ function Note($msg) { Write-Host " $msg" -ForegroundColor DarkGray }
72
+ function Done($msg) { Write-Host " OK: $msg" -ForegroundColor Green }
73
+ function Fail($msg) { Write-Host " FAIL: $msg" -ForegroundColor Red; exit 1 }
74
+
75
+ Write-Host "DepsRoot: $DepsRoot" -ForegroundColor Yellow
76
+
77
+ # ---- Pre-flight ----------------------------------------------------------
78
+
79
+ Step "Pre-flight"
80
+ foreach ($cmd in @("git", $Python)) {
81
+ $found = Get-Command $cmd -ErrorAction SilentlyContinue
82
+ if (-not $found) { Fail "$cmd not on PATH" }
83
+ Note "$cmd -> $($found.Source)"
84
+ }
85
+
86
+ # ---- Clone the repos -----------------------------------------------------
87
+
88
+ function Ensure-Repo($name, $url, $path) {
89
+ if ((Test-Path $path) -and -not $Force) {
90
+ if (Test-Path (Join-Path $path ".git")) {
91
+ $existingUrl = $null
92
+ Push-Location $path
93
+ try { $existingUrl = (git remote get-url origin 2>$null) } finally { Pop-Location }
94
+ if ($existingUrl -and ($existingUrl.Trim() -ne $url.Trim())) {
95
+ Fail "$name at $path has origin '$($existingUrl.Trim())' but expected '$url'. Re-run with -Force to re-clone, or fix manually: git -C `"$path`" remote set-url origin `"$url`""
96
+ }
97
+ Note "$name already cloned at $path (use -Force to re-clone)"
98
+ return
99
+ } else {
100
+ Fail "$path exists but is not a git checkout. Move or delete it first."
101
+ }
102
+ }
103
+ if ((Test-Path $path) -and $Force) {
104
+ Note "Removing $path (-Force)"
105
+ Remove-Item -Recurse -Force $path
106
+ }
107
+ Note "git clone $url $path"
108
+ git clone $url $path
109
+ if ($LASTEXITCODE -ne 0) { Fail "git clone $name failed" }
110
+ Done "$name cloned"
111
+ }
112
+
113
+ Step "simready_foundations"
114
+ Ensure-Repo "simready_foundations" $FoundationsRepo $FoundationsPath
115
+
116
+ Step "simready-oem-sdk-poc"
117
+ Ensure-Repo "simready-oem-sdk-poc" $SdkRepo $SdkPath
118
+
119
+ # ---- Python venv ---------------------------------------------------------
120
+
121
+ Step "Python venv at $VenvPath"
122
+ if (-not (Test-Path (Join-Path $VenvPath "Scripts\python.exe"))) {
123
+ & $Python -m venv $VenvPath
124
+ if ($LASTEXITCODE -ne 0) { Fail "venv creation failed" }
125
+ Done "venv created"
126
+ } else {
127
+ Note "venv already present"
128
+ }
129
+ $VenvPython = Join-Path $VenvPath "Scripts\python.exe"
130
+ $VenvPip = Join-Path $VenvPath "Scripts\pip.exe"
131
+
132
+ # ---- Pip installs --------------------------------------------------------
133
+
134
+ Step "Upgrade pip / install runtime deps"
135
+ & $VenvPython -m pip install --upgrade pip wheel setuptools | Out-Null
136
+ if ($LASTEXITCODE -ne 0) { Fail "pip self-upgrade failed" }
137
+
138
+ $Pkgs = @(
139
+ "usd-core==26.5",
140
+ "omniverse-asset-validator==1.15.1",
141
+ "omniverse-usd-profiles==1.11.0",
142
+ "markdown-it-py>=3.0",
143
+ "click>=8.0",
144
+ "simready-validate>=2026.4.8"
145
+ )
146
+ & $VenvPip install @Pkgs
147
+ if ($LASTEXITCODE -ne 0) { Fail "pip install (runtime deps) failed" }
148
+ Done "runtime deps installed (incl. simready-validate from PyPI — provides the CLI loader)"
149
+
150
+ # Editable install of nv_core/sr_specs is no longer required. validate.py
151
+ # now defaults to populating the OAV registries via
152
+ # simready.validate.impl.loader.load_validation_implementation against
153
+ # on-disk paths under the foundations checkout. The legacy editable
154
+ # install + repo usd_profiles_codegen flow is reachable via the
155
+ # --use-plugin opt-in flag if needed.
156
+
157
+ Step "Editable install of simready-oem-sdk-poc (for auto-package step)"
158
+ & $VenvPip install -e $SdkPath
159
+ if ($LASTEXITCODE -ne 0) { Fail "pip install -e simready-oem-sdk-poc failed" }
160
+ Done "SDK editable install complete"
161
+
162
+ # ---- Smoke check ---------------------------------------------------------
163
+
164
+ Step "Smoke check"
165
+ & $VenvPython -c @"
166
+ import omni.asset_validator as oav
167
+ from pxr import Usd
168
+ from markdown_it import MarkdownIt
169
+ import simready_sdk
170
+ from simready.validate.impl.loader import load_validation_implementation
171
+ from pathlib import Path
172
+ import os
173
+ foundations = Path(os.environ.get('SIMREADY_FOUNDATIONS_PATH') or r'$FoundationsPath')
174
+ load_validation_implementation(
175
+ rules_and_requirements_paths=[foundations / 'nv_core/sr_specs/docs/capabilities'],
176
+ features_paths=[foundations / 'nv_core/sr_specs/docs/features'],
177
+ profiles_paths=[foundations / 'nv_core/sr_specs/docs/profiles/profiles.toml'],
178
+ )
179
+ pr = oav.ProfileRegistry()
180
+ profiles = list(pr.values())
181
+ print(f' asset-validator: {oav.__version__ if hasattr(oav,"__version__") else "(no version attr)"}')
182
+ print(f' profiles loaded: {len(profiles)}')
183
+ print(f' simready_sdk: {simready_sdk.__file__}')
184
+ "@
185
+ if ($LASTEXITCODE -ne 0) { Fail "smoke check failed" }
186
+ Done "imports + CLI-loader populated ProfileRegistry working"
187
+
188
+ # ---- Persist env vars so validate.py finds the deps without args -------
189
+
190
+ Step "Persist locator env vars (User scope)"
191
+ [Environment]::SetEnvironmentVariable("SIMREADY_PYTHON", $VenvPython, "User")
192
+ [Environment]::SetEnvironmentVariable("SIMREADY_FOUNDATIONS_PATH", $FoundationsPath, "User")
193
+ [Environment]::SetEnvironmentVariable("SIMREADY_SDK_PATH", $SdkPath, "User")
194
+ $env:SIMREADY_PYTHON = $VenvPython
195
+ $env:SIMREADY_FOUNDATIONS_PATH = $FoundationsPath
196
+ $env:SIMREADY_SDK_PATH = $SdkPath
197
+ Note "SIMREADY_PYTHON = $VenvPython"
198
+ Note "SIMREADY_FOUNDATIONS_PATH = $FoundationsPath"
199
+ Note "SIMREADY_SDK_PATH = $SdkPath"
200
+ Done "env vars set for current user"
201
+
202
+ # ---- Done ---------------------------------------------------------------
203
+
204
+ Write-Host ""
205
+ Write-Host "Bootstrap complete." -ForegroundColor Green
206
+ Write-Host "DepsRoot: $DepsRoot" -ForegroundColor Green
207
+ Write-Host "Next: invoke /simready-report against any asset directory." -ForegroundColor Green
tools/validation/playbooks/foundations-deviations.md ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SimReady Foundations — process deviations
2
+
3
+ Issues encountered while running the `/simready-report` skill against `yaskawa_local`
4
+ that require changes in **`simready_foundations`** (or a clearly documented
5
+ client-side workaround). These are not playbook bugs — the playbook is working
6
+ around upstream gaps.
7
+
8
+ Source data for each item: paths/codes observed during the validation run on
9
+ `packages/yaskawa_local/` against `Robot-Body-Isaac` v1.0.0.
10
+
11
+ ---
12
+
13
+ ## 1. `Profile.capabilities` returns an empty list for every profile
14
+
15
+ **Where:** `omni.asset_validator` profile schema (Python registry).
16
+
17
+ **Observed:**
18
+ ```python
19
+ profile = oav.ProfileRegistry().find('Robot-Body-Isaac', '1.0.0')
20
+ profile.capabilities # → []
21
+ profile.features # → 2 features, 8 requirements
22
+ ```
23
+ Every profile in the registry returns `[]` for `.capabilities`. Profiles now
24
+ declare scope through `.features` — each feature owns its requirements
25
+ directly. The `capabilities` attribute is a leftover from an older schema.
26
+
27
+ **Impact:** `validate_yaskawa.py` and earlier versions of this playbook's
28
+ `validate.py` filtered the engine's enabled capabilities against
29
+ `profile.capabilities`. Because the latter is always empty, the filter
30
+ disabled **every** capability and reported `enabled capabilities: 0` while
31
+ producing 6,551 issues from rules that ignored the disable calls (see #2).
32
+
33
+ **Required:** remove `Profile.capabilities`, or populate it from the union of
34
+ the features' capabilities, and update all reference scripts/docs that still
35
+ use it.
36
+
37
+ ---
38
+
39
+ ## 2. `engine.enable_*` / `disable_*` do not constrain rule execution
40
+
41
+ **Where:** `omni.asset_validator.ValidationEngine`.
42
+
43
+ **Observed:** `engine.enable_feature(f)` and `engine.enable_requirement(r)`
44
+ update `engine.enabled_features` / `engine.enabled_requirements` counters,
45
+ but `engine.validate(stage)` still runs every rule registered via
46
+ `@register_rule`. `engine.enabled_rules` stays at 0 even after enabling
47
+ features and requirements; rules execute regardless.
48
+
49
+ **Impact:** profile-scoping has to happen post-hoc, by filtering issues
50
+ against the profile's requirement codes. The current playbook does this in
51
+ `validate_one()` (filters failures by `code in profile_codes`).
52
+
53
+ **Required:** either
54
+ 1. make `enable_*` / `disable_*` actually scope rule execution, or
55
+ 2. document explicitly that they are display-only and that callers must
56
+ filter results themselves. Today the API name strongly implies (1).
57
+
58
+ ---
59
+
60
+ ## 3. `Robot-Body-Isaac` v1.0.0 silently drops 4 of 6 declared features
61
+ (TOML ↔ Python registry mismatch)
62
+
63
+ **Where:** `simready_foundations/_build/target-deps/pip_prebundle/simready/foundation/core/profiles/profiles.toml`
64
+ vs the `Features` enum loaded from
65
+ `simready.foundation.core.features`.
66
+
67
+ **Observed:** `profiles.toml` declares Robot-Body-Isaac v1.0.0 with six
68
+ features:
69
+
70
+ | TOML entry | Loads? | Notes |
71
+ |---|---|---|
72
+ | `FET001_BASE_NEUTRAL` v0.1.0 | ✓ | 8 requirements |
73
+ | `FET004_ROBOT_PHYSX` v0.2.0 | ✗ | No such name in `Features` enum (only `FET004_BASE_NEUTRAL`) |
74
+ | `FET021_ROBOT_CORE_ISAAC` v0.2.0 | ✗ | Closest is `FET_021_ROBOT_CORE` v0.2.0 (note underscore + missing `_ISAAC`) |
75
+ | `FET022_DRIVEN_JOINTS_ISAAC` v0.1.0 | ✗ | Closest is `FET_022_DRIVEN_JOINTS` v0.1.0 |
76
+ | `FET024_BASE_ARTICULATION_PHYSX` v0.1.0 | ✗ | Closest is `FET_024_BASE_ARTICULATION` v0.1.0 |
77
+ | `FET100_BASE_ISAACSIM` v0.1.0 | ✓ | 0 requirements |
78
+
79
+ `pr.find('Robot-Body-Isaac', '1.0.0').features` therefore returns only the
80
+ two features that successfully resolve. Four features (Robot Core, Driven
81
+ Joints, Base Articulation, Robot Physx) are silently dropped — no warning,
82
+ no error.
83
+
84
+ The rules associated with the dropped features (RC.*, JT.*, RB.*, AT.*) still
85
+ execute (per #2 the engine ignores feature gating), so they appear in the
86
+ raw issue list, but they cannot be attributed to a profile feature for
87
+ roll-up reporting.
88
+
89
+ **Required:** reconcile the names. Either rename the TOML entries to match
90
+ the `Features` enum (`FET_021_ROBOT_CORE` etc.), or add the `_ISAAC` /
91
+ `_PHYSX` variants to the foundation as real feature implementations. Also
92
+ make profile-feature lookup *fail loudly* when a declared feature can't be
93
+ resolved — silent drop is the worst outcome.
94
+
95
+ Validation also surfaced real failures with codes from features that
96
+ **aren't even declared in the TOML profile**:
97
+
98
+ | Code(s) | Source | Notes |
99
+ |---|---|---|
100
+ | `RC.002`, `RC.004` (`thumbnail-exist`), `RC.008`, `RC.009` | `capabilities/isaac_sim/robot_core` | Robot-core rules registered & raising issues, but no profile feature includes them |
101
+ | `NP.001`, `NP.005`, `NP.006`, `NP.008` | `capabilities/visualization/nonvisual_materials` | |
102
+ | `HI.001`, `HI.002` | `capabilities/hierarchy` | (`HI.004` is in profile, others aren't) |
103
+ | `VG.007`, `VG.009`, `VG.016`, `VG.019`, `VG.MESH.001` | `capabilities/visualization/geometry` | (`VG.001` is in profile, the rest aren't) |
104
+ | `EX.01`, `EX.03`, `GSP.001`, `ISA.001`, `VM.MDL.001` | various | |
105
+
106
+ A "Robot-Body-Isaac" profile that does not require the asset to have a
107
+ thumbnail, robot type, or pinned root joint, but the foundations repo ships
108
+ those as registered rules, is a process gap.
109
+
110
+ **Required:** extend the profile's features to include the robot-core,
111
+ nonvisual-materials, hierarchy-completeness, and geometry-completeness
112
+ features that are already authored in the foundation; align the JSON capability
113
+ configs (`config/robot-core.json` etc.) with the Python `Profiles` enum.
114
+
115
+ ---
116
+
117
+ ## 4. JSON capability config and Python profile registry are not linked
118
+
119
+ **Where:** `simready_foundations/nv_core/sr_specs/config/robot-core.json` vs
120
+ `simready.foundation.core` Python plugin.
121
+
122
+ **Observed:** `robot-core.json` declares `RobotCore` capability v1.0.0 with
123
+ the `thumbnail-exist` requirement (and others). The Python registry exposes
124
+ that requirement to the rule machinery (issues with `RC.004` are emitted),
125
+ but no `Profile` exposes `RobotCore` as a feature, so client code that
126
+ introspects `profile.features` to compute pass/fail per feature can't see it.
127
+
128
+ **Impact:** validation reports list `RC.*` codes in raw issues but cannot map
129
+ them to a feature, so they appear as "uncategorized" failures.
130
+
131
+ **Required:** wire the JSON capability declarations into the `Features` /
132
+ `Profiles` enums (or into a new `profile.capabilities` accessor that returns
133
+ real entries — see #1).
134
+
135
+ ---
136
+
137
+ ## 5. ~87% of issues report `code: UNKNOWN`
138
+
139
+ **Where:** rules registered in `omni.asset_validator` and SimReady plugins.
140
+
141
+ **Observed:** of 6,551 issues across 7 robots, **5,695** have `iss.requirement
142
+ is None`, so the issue cannot be attributed to any requirement code:
143
+ ```
144
+ 5695 UNKNOWN
145
+ 129 NP.001
146
+ 116 HI.002
147
+ 116 VG.009
148
+ 96 VG.007
149
+ ...
150
+ ```
151
+ These come from rules that don't decorate with `@register_requirements`, so
152
+ they run but have no requirement linkage.
153
+
154
+ **Impact:** profile-scoped filtering (#2's workaround) drops 87% of the signal;
155
+ those failures appear in the raw issue list but never roll up to a feature
156
+ pass/fail.
157
+
158
+ **Required:** every rule registered in foundations should declare the
159
+ requirement(s) it enforces via `@register_requirements`, so issues are
160
+ attributable.
161
+
162
+ ---
163
+
164
+ ## 6. `init_rules` defaults are inconsistent across consumers
165
+
166
+ **Where:** `omni.asset_validator.ValidationEngine` constructor and the
167
+ SimReady SDK's engine wrapper.
168
+
169
+ **Observed:** `validate_yaskawa.py` (top-level reference) explicitly passes
170
+ `init_rules=True` and notes that `simready_sdk.core.engine` passes
171
+ `init_rules=False`, which leaves the engine empty:
172
+ ```
173
+ The SDK passes init_rules=False which leaves the engine with no checks,
174
+ producing 'No rules or requirements have been enabled.' for every asset.
175
+ ```
176
+ With `init_rules=True` the engine has 124 rules / 118 requirements. With
177
+ `False` it has 0 / 0.
178
+
179
+ **Required:** make `init_rules=True` the default in the engine, or remove the
180
+ parameter; update `simready_sdk.core.engine` to call it correctly. Today
181
+ choosing the wrong path silently produces a "passes everything" report.
182
+
183
+ ---
184
+
185
+ ## 7. Foundation has the wrapper but ships no runnable thumbnail generator
186
+
187
+ **Where:** `simready_foundations/nv_core/testing_tools/testing_framework/source/job_runner/core/engines/usdrecord/`
188
+ plus lighting rigs at `simready_foundations/nv_core/common_tools/thumbnails/auto_thumbnail_indoor_lighting_rig_{small,medium,large}.usd`.
189
+
190
+ **Observed:** the foundation defines `UsdRecordCommandBuilder` which builds
191
+ the command `<usdrecord_exe> <asset.usd> <output.png>` and references three
192
+ preset lighting USDs intended to be composed with the asset before rendering.
193
+ But:
194
+ - The `<usdrecord_exe>` is taken from `job.runner_config.executable`. The
195
+ foundation does not bundle the binary, the venv does not have it, and
196
+ `pxr.UsdAppUtils` (the Python library backing `usdrecord`) is absent.
197
+ - The `auto_thumbnail_indoor_lighting_rig_*.usd` rigs have no documented
198
+ composition recipe — there is no published "open the rig, payload the
199
+ asset, render to 256×256" wrapper script.
200
+
201
+ `usdrecord` is part of OpenUSD; on this machine it must be installed
202
+ externally. Three working install paths (commands documented for the record;
203
+ none are run automatically by the validator):
204
+
205
+ 1. **Kit Kernel from NGC** (the user already has `ngc` on PATH):
206
+ ```powershell
207
+ $env:KIT_VERSION = "106.5.0"
208
+ ngc registry resource download-version "nvidia/omniverse/kit:$env:KIT_VERSION" `
209
+ --dest "$env:USERPROFILE\.kit"
210
+ # `usdrecord.bat` lives at <kit_root>\dev\tools\packman\repo\bld\target-deps\usd\release\bin\usdrecord.bat
211
+ $env:USDRECORD_BIN = "$env:USERPROFILE\.kit\kit-$env:KIT_VERSION\...\usdrecord.bat"
212
+ ```
213
+ 2. **OpenUSD prebuilt** (NVIDIA developer release):
214
+ ```powershell
215
+ # Download from https://developer.nvidia.com/usd (signed-in NGC link),
216
+ # extract, and point to the binary:
217
+ $env:USDRECORD_BIN = "C:\openusd\bin\usdrecord.cmd"
218
+ ```
219
+ 3. **Build OpenUSD from source** (heaviest, only if the prebuilt isn't
220
+ acceptable):
221
+ ```powershell
222
+ git clone https://github.com/PixarAnimationStudios/OpenUSD.git C:\src\OpenUSD
223
+ python C:\src\OpenUSD\build_scripts\build_usd.py --tools C:\openusd
224
+ $env:USDRECORD_BIN = "C:\openusd\bin\usdrecord.cmd"
225
+ ```
226
+
227
+ Once `USDRECORD_BIN` is set (or the binary is on PATH), `validate.py`'s
228
+ `_find_usdrecord()` will pick it up and the validator will invoke it for
229
+ each asset missing a thumbnail at the spec path. Generated PNGs go to
230
+ `report/<set>/_generated_thumbs/<filename>.png` — never into the
231
+ packaged asset tree. Every command run is logged in `results.json` under
232
+ `thumbnail_generation.attempts` and surfaced in the dashboard's "External
233
+ tooling used" panel.
234
+
235
+ For `yaskawa_local`, six of seven thumbnails were instead pulled from
236
+ `Assets/Isaac/6.0/Isaac/Robots/Yaskawa/Motoman Next/.../.thumbs/256x256/`
237
+ on the Omniverse content S3 bucket (free; no install required). One robot
238
+ (`NHC10DE-A00`) has no S3 thumbnail; without `usdrecord` installed, its tile
239
+ renders the inline SVG "no thumbnail" placeholder.
240
+
241
+ **Required:** either ship `usdrecord` in the foundation's bundled toolchain,
242
+ or distribute a foundation-side wrapper (e.g. a Kit ext that does
243
+ `UsdAppUtils.framerec.FrameRecorder` programmatically) so `simready ingest
244
+ usd` can produce the spec thumbnail without external installs.
245
+
246
+ ---
247
+
248
+ ## 8. Spec docs disagree on the thumbnail filename
249
+
250
+ **Where:**
251
+ - Spec doc: `nv_core/sr_specs/docs/capabilities/isaac_sim/robot_core/requirements/thumbnail-exist.md`
252
+ - Validation rule: `nv_core/sr_specs/docs/capabilities/isaac_sim/robot_core/validation.py:213`
253
+ - OEM publishing guide: `simready-oem-sdk-poc/docs/oem-publishing-guide-hf.md`
254
+
255
+ **Observed:**
256
+ - The spec doc's example uses `…/Robot_Name/.thumbs/256x256/Robot.png`
257
+ (filename = USD stem only, no `.usd` suffix).
258
+ - The rule code computes `…/<filename>.png` where `<filename>` is the full
259
+ USD filename **including** the `.usd` extension, e.g. `nex4_c00.usd.png`.
260
+ - The OEM publishing guide also uses `robot-a.usd.png` (with `.usd.`).
261
+ - The Isaac S3 bucket uses `NEX4_C00.usd.png` (with `.usd.`).
262
+
263
+ The rule and most published assets agree (`<filename>.png` with `.usd`); the
264
+ spec doc's example is wrong.
265
+
266
+ **Required:** fix the spec example in `thumbnail-exist.md` to match the rule.
267
+
268
+ ---
269
+
270
+ ## 9. `simready ingest usd` does not lift companion thumbnails
271
+
272
+ **Where:** `simready-oem-sdk-poc/src/simready_sdk/packager/organizer.py`.
273
+
274
+ **Observed:** `simready ingest usd ./robot/robot.usd` produces the spec
275
+ layout (`<name>/<name>.usd`, `configuration/`, `materials/`,
276
+ `.thumbs/256x256/` folder) but **does not** copy/rename a thumbnail from any
277
+ companion location into `.thumbs/256x256/<file>.usd.png`. The packager docs
278
+ (`SKILL.md`) call it "thumbnail placeholder" — the directory is created
279
+ empty.
280
+
281
+ For `yaskawa_local/images/<stem>.usd.usd.png`, no automated path exists for
282
+ the packager to know that file should land at the spec's thumbnail path.
283
+
284
+ **Required:** the packager should accept a companion-images directory hint
285
+ (e.g. `--thumbs-from <dir>` or a sibling `images/` convention) and lift
286
+ matching files into `.thumbs/256x256/<file>.usd.png` during organize.
287
+
288
+ ---
289
+
290
+ ## 10. A foundation rule crashes with `'NoneType' object has no attribute 'JointStateAPI'`
291
+
292
+ **Where:** somewhere in the `simready.foundation.core` plugin's joint-state
293
+ checker (rule not yet pinned down — issue is reported as `code: UNKNOWN`,
294
+ `rule: None`).
295
+
296
+ **Observed:** the rule fires on every prim it visits and produces an
297
+ *Uncaught error* issue, accounting for between **566 and 698 issues per
298
+ asset** in our run — i.e., most of what makes the report look noisy:
299
+ ```
300
+ UNKNOWN ×668 Uncaught error: 'NoneType' object has no attribute 'JointStateAPI'
301
+ UNKNOWN ×566 Uncaught error: 'NoneType' object has no attribute 'JointStateAPI'
302
+ UNKNOWN ×698 Uncaught error: 'NoneType' object has no attribute 'JointStateAPI'
303
+ ```
304
+ The remaining ~150 issues per asset (HI.*, NP.*, RC.*, etc.) are the *real*
305
+ findings.
306
+
307
+ The crash strongly suggests the rule reads
308
+ `stage.GetPrimAtPath(...).GetAPISchemas()`-style and assumes the result is
309
+ non-`None`. A defensive `if foo is None: continue` would silence the noise;
310
+ the underlying joint-state coverage is presumably what the rule was meant to
311
+ be checking, but currently it produces nothing useful.
312
+
313
+ **Required:** pin down the rule (grep `JointStateAPI` across
314
+ `simready.foundation.core`), add the missing None-guard, and either register
315
+ the rule's requirement properly or remove it from the engine until it works.
316
+
317
+ ---
318
+
319
+ ## 11. `NP.005` — layout mismatch between the rule's spec and `simready ingest usd`, plus a path-printing bug
320
+
321
+ **Where:**
322
+ - Rule code: `simready_foundations/nv_core/sr_specs/docs/capabilities/core/naming_paths/validation.py:271-320` (`AssetFolderStructureChecker`).
323
+ - Rule spec doc: `simready_foundations/nv_core/sr_specs/docs/capabilities/core/naming_paths/requirements/asset-folder-structure.md`.
324
+ - Packager output: `simready-oem-sdk-poc/src/simready_sdk/packager/organizer.py` (invoked via `simready ingest usd`).
325
+
326
+ ### 11a. Layout mismatch
327
+
328
+ The NP.005 spec doc requires this structure:
329
+
330
+ ```
331
+ <asset>/ ← asset folder
332
+ ├── <intermediate>/ ← exactly ONE intermediate folder (any name)
333
+ │ └── <main>.usd ← main USD; NO other .usd files here or below
334
+ └── materials/ ← supporting folders are SIBLINGS of <intermediate>/
335
+ ```
336
+
337
+ The reference dashboard's FANUC content follows this exactly:
338
+
339
+ ```
340
+ robots/CR/cr_50f_16b/isaac_ready_usd/cr_50f_16b.usd.usd
341
+ robots/CR/cr_50f_16b/configuration/...
342
+ ```
343
+
344
+ But `simready ingest usd` produces a *flat* layout — main USD at the asset
345
+ root, sublayers nested inside the same dir:
346
+
347
+ ```
348
+ <asset>/
349
+ ├── <asset>.usd
350
+ └── configuration/
351
+ ├── <asset>_base.usd
352
+ ├── <asset>_physics.usd
353
+ └── <asset>_sensor.usd
354
+ ```
355
+
356
+ The rule walks `os.path.dirname(stage_path)` (which is `<asset>/` in the
357
+ flat layout) and flags every sublayer as "stray". The sublayers aren't
358
+ stray — they're the spec-required configuration files — but the rule's
359
+ walk-set includes them because the packager produced an
360
+ NP.005-non-compliant layout.
361
+
362
+ So: **two foundation artifacts disagree on the canonical asset layout.**
363
+ The packaging spec (`simready-packaging`'s SKILL.md) and `simready ingest
364
+ usd` produce one layout; NP.005's spec doc and the FANUC reference content
365
+ use a different layout.
366
+
367
+ ### 11b. Path-printing bug (independent)
368
+
369
+ ```python
370
+ # capabilities/core/naming_paths/validation.py:308-313
371
+ for root, dirs, files in os.walk(current_dir):
372
+ for file in files:
373
+ found_file = os.path.join(root, file).replace("\\", "/")
374
+ if file.endswith((".usd", ".usda")) and found_file != stage_path:
375
+ relative_path = os.path.relpath(file, current_dir) # ← bug
376
+ other_usd_files.append(relative_path)
377
+ ```
378
+
379
+ `file` here is just the basename returned by `os.walk` (e.g.
380
+ `nex10_c00_base.usd`). `os.path.relpath` resolves it against
381
+ `os.getcwd()`, not against `root`. With CWD =
382
+ `<workspace>/.claude/skills/simready-report/`, the rule prints:
383
+
384
+ ```
385
+ ..\..\..\.claude\skills\simready-report\nex10_c00_base.usd
386
+ ```
387
+
388
+ — a path back to the validator's CWD, where the file emphatically does not
389
+ exist. Should be `os.path.relpath(found_file, current_dir)` (`found_file`
390
+ is the correct joined path, already computed on the line above and
391
+ otherwise unused). With that one-character fix the cited path becomes
392
+ `configuration/nex10_c00_base.usd` — pointing at the real file.
393
+
394
+ ### Required
395
+
396
+ **Pick one canonical layout and reconcile both sides.** Either:
397
+
398
+ - **Update the packager** (`simready ingest usd`) to emit the
399
+ NP.005-compliant `<asset>/<intermediate>/<main>.usd` structure with
400
+ `configuration/` and `materials/` as siblings of `<intermediate>/`. This
401
+ is what the FANUC reference content already uses. The packaging skill
402
+ doc and any consumers (manifest tooling, dashboard, etc.) need updating
403
+ in lockstep.
404
+
405
+ - **Or update NP.005's spec doc and rule** to bless the flat layout that
406
+ `simready ingest usd` actually produces — i.e., explicitly whitelist the
407
+ spec-mandated subdirectories (`configuration/`, `materials/`,
408
+ `.thumbs/`, `.simready/`) when scanning for stray USDs.
409
+
410
+ Whichever is chosen, fix the path-printing bug at the same time
411
+ (`relpath(file, …)` → `relpath(found_file, …)`) so the message names a
412
+ real on-disk path.
413
+
414
+ ---
415
+
416
+ ## 12. The "example" capability is active in production validation
417
+
418
+ **Where:**
419
+ `simready_foundations/nv_core/sr_specs/docs/capabilities/example/example.py`,
420
+ codes `EX.01`, `EX.02`, `EX.03`.
421
+
422
+ **Observed:** every asset fails:
423
+ - `EX.01` — *"Stage has no mesh prims."*
424
+ - `EX.03` — *"Test prim 'TestBlob' not found under default prim."*
425
+
426
+ The source comment on this capability is literally `"example"` and the
427
+ rule docs (`example.md`) describe it as a tutorial — `EX.03` requires a
428
+ prim named `TestBlob` to exist as a child of the default prim. That's
429
+ clearly not an asset requirement; it's pedagogical scaffolding to teach
430
+ plugin authors how to register a rule.
431
+
432
+ **Required:** unregister the `Example` capability from production rule
433
+ loading (move it to test fixtures, gate it behind an env var, or delete it
434
+ from the shipped plugin). Today every real asset takes 2–3 spurious EX
435
+ failures.
436
+
437
+ ---
438
+
439
+ ## 13. `EX.01` and `VG.MESH.001` are duplicate checks under different codes
440
+
441
+ **Where:**
442
+ - `capabilities/example/example.py:52` — `EX.01`: *"Stage has no mesh prims."*
443
+ - `capabilities/visualization/geometry/validation.py:836` — `VG.MESH.001`:
444
+ *"Stage does not contain any meshes."*
445
+
446
+ **Observed:** both fire on every asset, with effectively identical
447
+ semantics. `EX.01` is the example/tutorial version; `VG.MESH.001` is the
448
+ real version. Reporting both is noise.
449
+
450
+ **Required:** when #12 is fixed, this disappears for free. Otherwise,
451
+ `EX.01` should be retired and any consumer that still references it should
452
+ be redirected to `VG.MESH.001`.
453
+
454
+ ---
455
+
456
+ ## 14. `HI.002` rejects valid USD xform-op decompositions
457
+
458
+ **Where:** `capabilities/hierarchy/validation.py:62-104`
459
+ (`ExclusiveXFormParentChecker`).
460
+
461
+ **Observed:** ~14 issues per asset, message *"Prim Parent has no
462
+ xformOp:rotate."* The rule requires every Gprim parent to author both
463
+ `xformOp:translate` and (a substring-matching) `xformOp:rotate*`:
464
+
465
+ ```python
466
+ if not any("xformOp:rotate" in op.GetAttr().GetName() for op in xform_ops):
467
+ self._AddFailedCheck("Prim Parent has no xformOp:rotate.", …)
468
+ ```
469
+
470
+ That's an over-strict reading of USD. Valid alternatives the rule rejects:
471
+
472
+ - `xformOp:transform` (single 4×4 matrix — encodes translate + rotate +
473
+ scale together).
474
+ - No xform ops at all (identity transform — also a valid stateless prim).
475
+ - Any combination authored on an inherited parent and overridden by the
476
+ Gprim's own ops.
477
+
478
+ Yaskawa's robots author matrix transforms in many places, which is what
479
+ trips this. The check should accept *any* xformable spec that resolves to a
480
+ defined transform — likely via `Xformable.GetLocalTransformation()`
481
+ returning a non-error result — rather than pattern-matching specific op
482
+ names.
483
+
484
+ **Required:** rewrite the check to validate *that the prim resolves to a
485
+ defined transform*, not that it authored a particular op vocabulary.
486
+
487
+ ---
488
+
489
+ ## 15. `NP.001` flags the conventional `/Looks` scope as a naming violation
490
+
491
+ **Where:** `capabilities/core/naming_paths/validation.py:125-160`
492
+ (`PrimNamingConventionChecker`).
493
+
494
+ **Observed:** every asset fails NP.001 ×8 with *"Prim 'Looks' does not
495
+ follow consistent naming convention (camelCase or snake_case)."*
496
+
497
+ `Looks` is the long-established USD convention for the materials scope
498
+ under an asset's default prim — it's used by Pixar's published USD
499
+ samples, NVIDIA's Isaac/Omniverse content, and most authoring tools
500
+ (Houdini, Maya USD, Kit). The rule's `CAMEL_CASE_PATTERN` and
501
+ `SNAKE_CASE_PATTERN` regexes don't accept the `Looks` PascalCase
502
+ single-word, so it reports a violation on a name the broader USD ecosystem
503
+ treats as canonical.
504
+
505
+ **Required:** either whitelist the well-known USD scope names (`Looks`,
506
+ `Geometry`, `Cameras`, `Lights`, `Animation`, `Render`, etc.) or accept
507
+ PascalCase as a valid convention alongside camelCase / snake_case.
508
+
509
+ ---
510
+
511
+ ## 16. `VM.MDL.001` and `NP.008` double-report the same MDL resolution failure
512
+
513
+ **Where:**
514
+ - `capabilities/visualization/materials/...` (VM.MDL.001 — *"The path
515
+ OmniPBR.mdl does not exist."*)
516
+ - `capabilities/core/naming_paths/...` (NP.008 — *"Asset path 'OmniPBR.mdl'
517
+ on attribute 'info:mdl:sourceAsset' at prim '/.../Looks/.../...' does not
518
+ resolve to an existing file."*)
519
+
520
+ **Observed:** every asset emits the same number of VM.MDL.001 and NP.008
521
+ issues (9 each on `nex10_c00`, 6 each on `nex20_c00`, etc.) — both rules
522
+ fire on the same `info:mdl:sourceAsset = @OmniPBR.mdl@` attribute. NP.008
523
+ gives a useful prim path; VM.MDL.001 just says "doesn't exist".
524
+
525
+ Separately: `OmniPBR.mdl` is a **standard NVIDIA MDL** that ships with Kit
526
+ and Isaac. The validator's asset resolver doesn't know where to find it
527
+ (likely missing an MDL search path in the SimReady plugin's resolver
528
+ config), so a perfectly portable, NVIDIA-blessed material reference is
529
+ flagged as broken on every asset.
530
+
531
+ **Required:**
532
+ 1. Configure the SimReady validator plugin to register Kit's standard MDL
533
+ search paths (`mdl/`, `omni/mdl/`, etc.) so `OmniPBR.mdl` and other
534
+ shipped MDLs resolve without the customer having to vendor them.
535
+ 2. Pick one of VM.MDL.001 / NP.008 as the canonical "MDL doesn't resolve"
536
+ code. Have the other defer when the first has fired against the same
537
+ attribute.
538
+
539
+ ---
540
+
541
+ ## 17. `RC.002` flags spec-mandated robot metadata as "overrides"
542
+
543
+ **Where:** `capabilities/isaac_sim/robot_core/...`, `RC.002`.
544
+
545
+ **Observed:** every asset fails RC.002 with messages like:
546
+
547
+ ```
548
+ Prim is overridden: /nex10_c00, ['isaac:description', 'isaac:license',
549
+ 'isaac:namespace', 'isaac:robotType']
550
+ ```
551
+
552
+ `isaac:description`, `isaac:license`, `isaac:namespace`,
553
+ `isaac:robotType` look like exactly the kind of metadata the SimReady
554
+ robot spec wants you to author on the robot's default prim — but RC.002
555
+ is treating any authored override of the default prim's properties as a
556
+ violation.
557
+
558
+ If those four `isaac:*` attributes are spec-mandated, the rule should
559
+ *require* them, not flag their presence. If they're optional metadata, the
560
+ rule should ignore them — overriding metadata fields on the default prim
561
+ is a normal authoring pattern, not a layout error.
562
+
563
+ **Required:** clarify the spec around the `isaac:*` namespace on the
564
+ robot default prim and update RC.002 to either require those fields, or
565
+ exclude them from the "this prim shouldn't be overridden" check, or split
566
+ into two rules (one for "structural" overrides, one for the spec
567
+ metadata).
568
+
569
+ ---
570
+
571
+ ## Summary
572
+
573
+ | # | Severity | Area | One-line |
574
+ |---|---|---|---|
575
+ | 1 | high | profile schema | `Profile.capabilities` is dead; should be removed or populated |
576
+ | 2 | high | engine API | `enable_*`/`disable_*` don't actually scope rule execution |
577
+ | 3 | high | profile content | Robot-Body-Isaac v1.0.0 silently drops 4 of 6 declared features |
578
+ | 4 | high | profile schema | JSON capability configs aren't linked to the Python profile registry |
579
+ | 5 | high | rule registration | Most rules don't declare `@register_requirements` → 87% UNKNOWN |
580
+ | 6 | medium | engine API | `init_rules` defaults inconsistent; SDK silently turns off all checks |
581
+ | 7 | medium | tooling | No headless thumbnail generator ships with foundations |
582
+ | 8 | low | spec docs | `thumbnail-exist.md` example contradicts the actual rule |
583
+ | 9 | medium | packaging | `simready ingest usd` doesn't lift companion thumbnails into spec layout |
584
+ | 10 | high | rule reliability | Rule crashes on `JointStateAPI` access → ~600 UNKNOWN issues per asset |
585
+ | 11 | high | spec/packager mismatch | NP.005 expects `<asset>/<intermediate>/<main>.usd`; packager produces flat layout. Plus `relpath(file,…)` should be `relpath(found_file,…)` |
586
+ | 12 | high | rule scoping | "Example" tutorial capability (`EX.*`) is active in production validation |
587
+ | 13 | medium | rule duplication | `EX.01` and `VG.MESH.001` are the same check |
588
+ | 14 | high | rule correctness | `HI.002` rejects matrix transforms (`xformOp:transform`) and identity transforms |
589
+ | 15 | medium | rule correctness | `NP.001` rejects the conventional `/Looks` scope name |
590
+ | 16 | high | rule correctness | `OmniPBR.mdl` (standard NVIDIA MDL) doesn't resolve; both VM.MDL.001 and NP.008 fire on the same attribute |
591
+ | 17 | medium | spec ambiguity | `RC.002` flags spec-looking `isaac:*` metadata as illegal "overrides" |
tools/validation/plugins/simready-report/plugin.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-plugin",
3
+ "name": "simready-report",
4
+ "version": "0.2.0",
5
+ "description": "SimReady asset pipeline tooling. Ships two slash commands: /simready-report (validate customer USD assets and emit an HTML dashboard + external-deps provenance report) and /simready-package (run the simready ingest packaging step alone, no validation).",
6
+ "author": {
7
+ "name": "loginowskid",
8
+ "email": "dloginowski@nvidia.com"
9
+ },
10
+ "homepage": "https://github.com/loginowskid/simready-playbook"
11
+ }
tools/validation/plugins/simready-report/skills/simready-package/SKILL.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: simready-package
3
+ description: Package SimReady customer assets by running `simready ingest usd` per interface USD. Standalone alternative to /simready-report's auto-package step. Invoke with /simready-package <name-or-path>.
4
+ ---
5
+
6
+ # /simready-package
7
+
8
+ Run the SimReady packaging step on a directory of customer asset subfolders, without running validation or generating a dashboard.
9
+
10
+ ## Usage
11
+
12
+ - `/simready-package` (no arg) — package the **current working directory**.
13
+ - `/simready-package <path>` — package the given directory by absolute or relative path.
14
+
15
+ ## When to use
16
+
17
+ - You have a directory like `assets_to_validate/<name>/` with one subfolder per asset (each containing an interface USD), and want to produce a packaged tree without running validation.
18
+ - You want to inspect the packaged output before invoking `/simready-report`.
19
+ - You're iterating on packaging configuration and don't want the validation overhead each time.
20
+
21
+ `/simready-report` still auto-packages when its target sits under an `assets_to_validate/` dir, so you don't *need* `/simready-package` to use the report. They're independent.
22
+
23
+ ## Steps for Claude when `/simready-package` is invoked
24
+
25
+ 1. **Resolve the target.** No arg → CWD. With an arg, pass it through to the script.
26
+ 2. **Run the packager** from this skill directory using whatever Python the user's environment provides — prefer `$env:SIMREADY_PYTHON` (set by `bootstrap.ps1`) over `python` on PATH:
27
+
28
+ PowerShell:
29
+ ```
30
+ $py = if ($env:SIMREADY_PYTHON) { $env:SIMREADY_PYTHON } else { "python" }
31
+ & $py package.py "<arg-or-empty>"
32
+ ```
33
+ Bash/zsh:
34
+ ```
35
+ "${SIMREADY_PYTHON:-python}" package.py "<arg-or-empty>"
36
+ ```
37
+ Add `--profile NAME` only if the user explicitly asked for a non-default profile. Add `--output DIR` only if the user explicitly asked for a non-default output.
38
+ 3. **Surface results.** Echo the script's `SUMMARY:` line and the absolute `OUTPUT:` path.
39
+ 4. **If the script exits with "simready CLI not found"**, relay verbatim. The user needs `simready-oem-sdk-poc` installed into the active Python, or to run `bootstrap.ps1`. Don't run the bootstrap on the user's behalf without explicit confirmation.
40
+
41
+ ## Default output location
42
+
43
+ - When the target sits under an `assets_to_validate/<name>/` directory, output goes to the sibling `packages/<name>/`.
44
+ - Otherwise, output goes to `<target>.parent/packages/<target.name>/`.
45
+ - Override with `--output <path>`.
46
+
47
+ ## What the packager does
48
+
49
+ - **Interface discovery.** For each immediate subfolder of the target, picks one interface USD per asset:
50
+ - exactly one USD at root → that one
51
+ - otherwise → the USD whose stem matches the folder name (case- and hyphen/underscore-insensitive)
52
+ - otherwise → all USDs (rare)
53
+ - subfolders starting with `.` or `_` are ignored
54
+ - **Idempotent.** Skips assets already packaged (existing `<output>/<stem>/<filename>.usd`).
55
+ - **Per-asset invocation.** Calls `simready ingest usd <interface> -o <output> -p <profile> --no-validate` for each interface, with a 120s timeout.
56
+ - **Summary.** Prints `SUMMARY: packaged=N skipped=N failed=N` and the resolved `OUTPUT:` path. Exits non-zero if any asset failed to ingest (the rest still get packaged).
57
+
58
+ ## Configuration
59
+
60
+ - `SIMREADY_PYTHON` (recommended): full path to the Python interpreter that has `simready-oem-sdk-poc` installed. Set automatically by `bootstrap.ps1`.
61
+ - Default profile: `Robot-Body-Runnable`. Override with `--profile NAME`.
62
+ - Default output: see above. Override with `--output DIR`.
tools/validation/plugins/simready-report/skills/simready-package/package.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SimReady asset packaging — entry point for the /simready-package skill.
2
+
3
+ Usage:
4
+ python package.py <target_dir> [--profile NAME] [--output DIR]
5
+
6
+ For each immediate subfolder of <target_dir> containing a USD file, picks
7
+ the interface USD and runs `simready ingest usd` on it, producing a
8
+ packaged tree under <output_dir>. Independent of /simready-report — runs
9
+ the packaging step alone, without validation or dashboard generation.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ USD_EXTS = (".usd", ".usda", ".usdc", ".usdz")
21
+
22
+
23
+ def _find_simready_cli() -> str | None:
24
+ """Locate the `simready` CLI in a layout-agnostic way.
25
+
26
+ Order: (1) on PATH, (2) alongside `sys.executable` (covers any active
27
+ venv/conda regardless of OS).
28
+ """
29
+ on_path = shutil.which("simready")
30
+ if on_path:
31
+ return on_path
32
+ py_bin = Path(sys.executable).parent
33
+ for candidate in (py_bin / "simready.exe", py_bin / "simready"):
34
+ if candidate.is_file():
35
+ return str(candidate)
36
+ return None
37
+
38
+
39
+ def _discover_interface_usds(target: Path) -> list[Path]:
40
+ """Pick one interface USD per immediate subfolder of `target`.
41
+
42
+ Heuristic per subfolder:
43
+ - If exactly one USD at root, that's the interface.
44
+ - Else prefer the USD whose stem matches the folder name (case- and
45
+ hyphen/underscore-insensitive).
46
+ - Else fall back to packaging all of them.
47
+ Subfolders starting with "." or "_" are ignored.
48
+ """
49
+ interface_files: list[Path] = []
50
+ for sub in sorted(target.iterdir()):
51
+ if not sub.is_dir() or sub.name.startswith((".", "_")):
52
+ continue
53
+ usds = [p for ext in USD_EXTS for p in sub.glob(f"*{ext}")]
54
+ if not usds:
55
+ continue
56
+ if len(usds) == 1:
57
+ interface_files.append(usds[0])
58
+ continue
59
+ named = [p for p in usds if p.stem.lower() == sub.name.lower().replace("-", "_")]
60
+ if named:
61
+ interface_files.append(named[0])
62
+ else:
63
+ interface_files.extend(usds)
64
+ return interface_files
65
+
66
+
67
+ def _default_output(target: Path) -> Path:
68
+ """Pick a default output dir based on target location."""
69
+ target_str = str(target).replace("\\", "/")
70
+ if "/assets_to_validate/" in target_str:
71
+ # Workspace-style layout: <X>/assets_to_validate/<name>
72
+ # → output at sibling packages dir: <X>/packages/<name>
73
+ return target.parent.parent / "packages" / target.name
74
+ return target.parent / "packages" / target.name
75
+
76
+
77
+ def main() -> int:
78
+ ap = argparse.ArgumentParser(description="Package SimReady assets via `simready ingest usd`.")
79
+ ap.add_argument("target", nargs="?", default=None,
80
+ help="Directory containing one or more asset subfolders (default: cwd)")
81
+ ap.add_argument("--profile", default="Robot-Body-Runnable",
82
+ help="Profile passed to `simready ingest usd -p` (default: Robot-Body-Runnable)")
83
+ ap.add_argument("--output", default=None,
84
+ help="Output dir (default: <target>.parent/packages/<target.name>, "
85
+ "or sibling-of-assets_to_validate/packages/<name> when applicable)")
86
+ args = ap.parse_args()
87
+
88
+ target = Path(args.target).resolve() if args.target else Path.cwd().resolve()
89
+ if not target.is_dir():
90
+ print(f"ERROR: target is not a directory: {target}", flush=True)
91
+ return 2
92
+
93
+ simready_exe = _find_simready_cli()
94
+ if simready_exe is None:
95
+ print("ERROR: `simready` CLI not found on PATH or alongside the active Python.", flush=True)
96
+ print(" Install simready-oem-sdk-poc into the active Python, or run bootstrap.ps1.", flush=True)
97
+ return 2
98
+
99
+ interface_files = _discover_interface_usds(target)
100
+ if not interface_files:
101
+ print(f"ERROR: no USD files found in immediate subfolders of {target}.", flush=True)
102
+ print(" /simready-package expects each asset to live in its own subfolder "
103
+ "with an interface USD.", flush=True)
104
+ return 2
105
+
106
+ output = Path(args.output).resolve() if args.output else _default_output(target)
107
+ output.mkdir(parents=True, exist_ok=True)
108
+
109
+ print(f"Target: {target}", flush=True)
110
+ print(f"Output: {output}", flush=True)
111
+ print(f"Profile: {args.profile}", flush=True)
112
+ print(f"CLI: {simready_exe}", flush=True)
113
+ print(f"Discovered {len(interface_files)} interface USD(s).", flush=True)
114
+
115
+ env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
116
+ packaged = 0
117
+ skipped = 0
118
+ failures: list[tuple[str, str]] = []
119
+
120
+ for usd in interface_files:
121
+ already = output / usd.stem / usd.name
122
+ if already.is_file():
123
+ print(f" skip (already packaged): {usd.name}", flush=True)
124
+ skipped += 1
125
+ continue
126
+ print(f" package: {usd.name}", flush=True)
127
+ cmd = [simready_exe, "ingest", "usd", str(usd),
128
+ "-o", str(output), "-p", args.profile, "--no-validate"]
129
+ try:
130
+ subprocess.run(cmd, env=env, check=True, capture_output=True, text=True, timeout=120)
131
+ packaged += 1
132
+ except subprocess.CalledProcessError as e:
133
+ err = (e.stderr or "")[-300:] or str(e)
134
+ failures.append((usd.name, err))
135
+ print(f" FAILED: {usd.name} - {err}", flush=True)
136
+
137
+ print(f"\nSUMMARY: packaged={packaged} skipped={skipped} failed={len(failures)}", flush=True)
138
+ print(f"OUTPUT: {output}", flush=True)
139
+ return 0 if not failures else 1
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
tools/validation/plugins/simready-report/skills/simready-report/SKILL.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: simready-report
3
+ description: Validate SimReady customer assets against the foundation specs. Produces a dashboard HTML report and an external-dependencies provenance report. Invoke with /simready-report <name-or-path>.
4
+ ---
5
+
6
+ # /simready-report
7
+
8
+ Validate a SimReady customer asset set.
9
+
10
+ ## Usage
11
+
12
+ - `/simready-report` (no arg) — validate the **current working directory**. This is the normal way to run the skill: `cd` into the directory holding the customer asset(s) and invoke.
13
+ - `/simready-report <path>` — validate any directory by absolute or relative path. Override for the no-arg default.
14
+ - `/simready-report <name>` — bare name (no path separators). Tries `<cwd>/<name>` first; if running inside a playbook clone, also tries `<workspace>/assets_to_validate/<name>` for back-compat.
15
+
16
+ ## Install paths
17
+
18
+ The skill is layout-agnostic — it auto-detects whether it's running inside a playbook clone (`BOT.md` in an ancestor directory) and adjusts defaults accordingly.
19
+
20
+ **Plugin marketplace install (no clone needed):**
21
+ ```
22
+ /plugin marketplace add loginowskid/simready-playbook
23
+ /plugin install simready-report
24
+ ```
25
+ Then `/simready-report` works from any directory. Reports default to `<cwd>/report/<target-name>/`. Foundation specs and SDK locations come from env vars (see Configuration).
26
+
27
+ **Cloned playbook (project skill or local marketplace):**
28
+ If `simready-playbook` is cloned, the skill walks up from its own location to find `BOT.md` and uses workspace-relative defaults: reports under `<workspace>/report/`, optional auto-package into `<workspace>/packages/`, default foundations/sdk lookup at `<workspace>/../simready_foundations` and `<workspace>/../simready-oem-sdk-poc`.
29
+
30
+ ## Runtime dependencies
31
+
32
+ Wherever the skill is installed, it needs:
33
+
34
+ - A Python with `usd-core`, `omniverse-asset-validator`, `markdown-it-py`, and `simready-validate>=2026.4.8` importable. Any Python works (system, venv, conda). The default validation path populates the OAV registries via `simready.validate.impl.loader.load_validation_implementation`, matching the official `simready-validate` CLI workflow — no editable install of the foundation specs is required.
35
+ - A `simready_foundations` checkout cloned from `github.com/NVIDIA/simready-foundation` (or the `simready-foundation-staging` mirror). Override default lookup with `SIMREADY_FOUNDATIONS_PATH=<path>`.
36
+ - The `simready-oem-sdk-poc` checkout (only if the user wants the auto-package step for raw asset trees). Override with `SIMREADY_SDK_PATH=<path>`. The `simready` CLI itself is found via `PATH` first, then alongside the active Python.
37
+
38
+ Legacy fallback: pass `--use-plugin` to load via the `simready.foundation.core:SimReadyPlugin` setuptools entry-point instead (requires the editable install of `nv_core/sr_specs` + the internal `repo usd_profiles_codegen` step). Kept for the GitLab-era workflow; not needed for GitHub-based runs.
39
+
40
+ For a Windows convenience setup that creates a sibling venv + clones, run `bootstrap.ps1` from a cloned playbook. **Don't run it without confirming with the user first** — they may already have these dependencies elsewhere, or be on a non-Windows system.
41
+
42
+ Optional: a `usd_validation_dashboard_final/` clone next to the playbook supplies a Sphinx `_static/` theme for rendered doc pages. Without it, doc pages use minimal inline styling — the dashboard still works.
43
+
44
+ ## Steps for Claude when `/simready-report` is invoked
45
+
46
+ The Python invocation pattern (used in steps 2 and 3 below) — prefer `$env:SIMREADY_PYTHON` (set by `bootstrap.ps1`), fall back to `python` on PATH:
47
+
48
+ PowerShell: `$py = if ($env:SIMREADY_PYTHON) { $env:SIMREADY_PYTHON } else { "python" }; & $py …`
49
+ Bash/zsh: `"${SIMREADY_PYTHON:-python}" …`
50
+
51
+ 1. **Resolve the target.**
52
+ - No arg → CWD.
53
+ - With an arg: pass it through. The script tries it as a path, then `<cwd>/<name>`, then `<workspace>/assets_to_validate/<name>` (clone only) for back-compat.
54
+ 2. **Pick the profile (spec selector).** Unless the user already named a specific profile in their request, list the registered profiles and ask them to pick before validating:
55
+ ```
56
+ <py> validate.py --list-profiles
57
+ ```
58
+ This emits one `PROFILE: <id> v<version>` line per registered profile (typically ~11 across the Robot-Body / Prop-Robotics / Package families). Present them grouped by family with `Robot-Body-Runnable v1.0.0` marked as the default; let the user accept the default or pick another. Pass the chosen `<id>` and `<version>` through as `--profile` and `--version` in step 3.
59
+ 3. **Run the validator** from the skill directory:
60
+ ```
61
+ <py> validate.py "<arg-or-empty>" --profile <id> --version <version>
62
+ ```
63
+ If you skipped the selector because the user named a profile up front, use that. If the user said something like "just use the default", pass `Robot-Body-Runnable` v1.0.0 without prompting.
64
+ 4. **Surface results.** Echo the script's `SUMMARY:` line and the absolute path to the report's `index.html`.
65
+ 5. **If `validate.py` exits with "runtime prerequisites missing"**, relay the message verbatim. If running inside a clone on Windows, suggest `bootstrap.ps1` (after confirming with the user). Otherwise direct the user to install the deps into their Python and/or set `SIMREADY_FOUNDATIONS_PATH` / `SIMREADY_SDK_PATH` to existing checkouts. Do not run the bootstrap on the user's behalf without explicit confirmation.
66
+ 6. **If `validate.py` exits with "target … failed sanity check"**, relay the listed problems verbatim. Don't retry without an explicit user instruction — the check is there to prevent accidental whole-drive scans, output-dir self-validation, and empty-dir runs.
67
+
68
+ ## What `validate.py` does on its own
69
+
70
+ - **Workspace detection.** Walks up from the skill's directory and treats the first ancestor containing `BOT.md` as the workspace. None of these defaults matter when overridden by env vars / `--output`.
71
+ - **Prerequisite check.** Verifies `omni.asset_validator` / `pxr` / `markdown_it` import in the active Python and that the foundations checkout is findable. On miss: prints actionable error and exits 2.
72
+ - **Sanity check on the resolved target.** Refuses (exit 2) if any of:
73
+ - Inside a clone: target is the workspace root, or under `<workspace>/.claude/`, `playbooks/`, `report/`, `packages/`, or `plugins/`.
74
+ - Target is too high in the filesystem (≤2 path components — e.g. `C:\` or `C:\Users\`).
75
+ - Target has no USD files at root **and** none in any of its immediate subfolders.
76
+ - **Auto-package.** When the resolved target lives under an `assets_to_validate/<name>/` directory, calls `simready ingest usd` per interface USD into a sibling `packages/<name>/` (workspace-anchored when in a clone, else sibling-of-`assets_to_validate/`). Skipped per asset if already packaged. Skipped with a warning if the `simready` CLI isn't on PATH.
77
+ - **Asset thumbnails.** For each validated asset, copies `<asset_dir>/.thumbs/256x256/<filename>.png` (if present at the spec path) into `<out_dir>/images/<filename>.png`. Missing thumbnails render the inline placeholder.
78
+ - **Doc rendering.** For every feature/requirement code referenced by the report, renders the foundation's source markdown to HTML inside `<out_dir>/docs/<rel_path>`. If `usd_validation_dashboard_final/_static/` exists, it's copied once so pages match the reference Sphinx theme; otherwise pages use minimal inline CSS.
79
+
80
+ ## Outputs
81
+
82
+ Written to the resolved output directory. **Default**: `<target>/.reports/<target-name>.<profile>/` — i.e. dropped inside the asset dir the user pointed at, in a `.reports/` subdir. Examples:
83
+
84
+ - `/simready-report yaskawa_local` (default profile) → `yaskawa_local/.reports/yaskawa_local.Robot-Body-Runnable/`
85
+ - `/simready-report yaskawa_local` (with `--profile Prop-Robotics-Neutral --version 2.0.0`) → `yaskawa_local/.reports/yaskawa_local.Prop-Robotics-Neutral/`
86
+
87
+ The `.reports/` prefix starts with `.`, so `discover_assets` automatically skips it on subsequent runs — re-running `/simready-report` from inside the target is safe. Multiple profiles against the same target produce sibling subfolders under `.reports/`, never overwriting each other. Override the whole path with `--output`.
88
+
89
+ - `index.html` — dashboard: summary cards, asset filter, per-asset failed/passed features, "Other failed requirements" panel, compliance footer.
90
+ - `results.json` — raw issues per asset, plus thumbnail provenance.
91
+ - `images/<filename>.png` — per-asset thumbnails (mirrored from the spec path inside the packaged tree).
92
+ - `docs/` — foundation feature/requirement docs rendered to HTML; `_static/` carries the Sphinx theme assets so pages match the reference dashboard.
93
+ - `external_dependencies_report.html` + `.json` — **only emitted when there's something to report** (one or more externally-resolved layers, or one or more scan-time issues). Anonymous in-memory layers are not counted as external. Three sections when present:
94
+ 1. External assets needed (anything resolved outside the target dir).
95
+ 2. What was done to obtain each (provenance log per layer).
96
+ 3. Issues encountered while building the report.
97
+
98
+ ## Kit-rooted mode (`--use-kit`)
99
+
100
+ **Auto-default:** `validate.py` auto-enables `--use-kit` whenever the chosen profile is in the PhysX-bearing set (`Robot-Body-Physx`, `Robot-Body-Isaac`, `Robot-Body-Runnable`, `Prop-Robotics-Physx`, `Prop-Robotics-Isaac`). The pip-only path's P2 filter silently drops `physxschema_unavailable` / `omnipbr_unresolved` findings, which makes a "passed" result misleading on those profiles — the checks that matter never actually ran. Pass `--no-use-kit` to opt out (e.g. when you knowingly want the fast filtered run); pass `--use-kit` explicitly to force it on a Neutral-family profile.
101
+
102
+ Under the hood: when `--use-kit` is on, the script re-execs itself through `_kit_wrapper.py` using a Kit-rooted Python that boots `isaacsim.SimulationApp({"headless": True})` before importing `pxr`, so `PhysxSchema`, the PhysX joint rules, and Kit's stock MDL resolution all work for real. When off (Neutral-family profiles by default, or after `--no-use-kit`), the pip-only venv runs and the P2 filter trims the resulting noise (~900 issues dropped on a typical robot asset) so reports stay readable.
103
+
104
+ - Resolution order for the Kit Python: `--kit-python <path>` → `$SIMREADY_KIT_PYTHON` → `C:\isaacsim6\_build\windows-x86_64\release\python.bat` (Isaac Sim 6 source build on this team's machines).
105
+ - Cost: ~10–30 s of SimulationApp boot on top of normal validation time. The script automatically forces `--workers 1` in Kit mode so we don't pay the boot again per worker.
106
+ - One-time setup: the Kit-rooted Python needs the same runtime deps the pip venv has. From within the Kit Python:
107
+ ```
108
+ <kit-python.bat> -m pip install omniverse-asset-validator markdown-it-py
109
+ <kit-python.bat> -m pip install -e <foundations>/nv_core/sr_specs
110
+ <kit-python.bat> -m pip install -e <simready-oem-sdk-poc> # only if auto-packaging is needed
111
+ ```
112
+ If `validate.py` aborts with "runtime prerequisites missing" inside Kit mode, the error includes a copy-pasteable hint pointing at the active Kit Python.
113
+
114
+ See `PROBLEMS.md` P2 for the architectural context behind the env-blocked-issue filter and the Kit-vs-pip split.
115
+
116
+ ## Loader paths
117
+
118
+ The plugin can populate the OAV registries two ways. The default matches the GitHub-canonical workflow.
119
+
120
+ **Default — CLI loader (no flag).** `validate.py` calls `simready.validate.impl.loader.load_validation_implementation` against on-disk paths under `$SIMREADY_FOUNDATIONS_PATH` — the same loader the official `simready-validate` CLI uses. Requires only `simready-validate>=2026.4.8` (PyPI) and a foundations checkout from `github.com/NVIDIA/simready-foundation*`. No editable install of `nv_core/sr_specs`, no `repo usd_profiles_codegen` step. The loader generates `omni.capabilities` at runtime via `omni.usd_profiles.codegen`.
121
+
122
+ **Legacy — `--use-plugin`.** Loads via the `simready.foundation.core:SimReadyPlugin` setuptools entry-point. Requires the editable install of `nv_core/sr_specs` after running `repo usd_profiles_codegen`. Useful only for the GitLab-era workflow on machines that still have that editable install in place; not a recommended path for new setups. When this mode is active, the P1 JSON-variant patch in `validate.py` is also applied (it's redundant under the default loader).
123
+
124
+ Compose with `--use-kit`: e.g. `python validate.py … --use-kit` runs PhysX checks inside Kit using the default loader path.
125
+
126
+ See `PROBLEMS.md` P2 for the env-blocked-issue filter that runs alongside both paths.
127
+
128
+ ## Configuration
129
+
130
+ - `SIMREADY_PYTHON` (recommended): full path to the Python interpreter that has the runtime deps installed. Set automatically by `bootstrap.ps1`. If unset, the skill falls back to `python` on PATH.
131
+ - `SIMREADY_KIT_PYTHON` (optional, used only with `--use-kit`): full path to a Kit-rooted `python.bat`. Default: `C:\isaacsim6\_build\windows-x86_64\release\python.bat`.
132
+ - Default profile: `Robot-Body-Runnable` v1.0.0
133
+ - `SIMREADY_FOUNDATIONS_PATH` (optional): override the foundations checkout location. Default in a clone: `<workspace>/../simready_foundations`. Required when running outside a clone (set automatically by `bootstrap.ps1`).
134
+ - `SIMREADY_SDK_PATH` (optional): override the SDK checkout location. Default in a clone: `<workspace>/../simready-oem-sdk-poc`. Only relevant for auto-packaging (set automatically by `bootstrap.ps1`).
135
+ - `SIMREADY_SPECS_PATH` is set automatically from the resolved foundations path so the validator's spec loader picks it up.
136
+ - `SIMREADY_INSIDE_KIT` is set automatically by `_kit_wrapper.py` to `1` once SimulationApp is booted; `validate.py` uses it to skip the pip-only assumptions and force sequential workers.
137
+ - `OMNI_ASSET_VALIDATOR_ISOLATE_ENTRYPOINTS` is set automatically to `omni.asset_validator:DefaultPlugin,simready.foundation.core:SimReadyPlugin`
tools/validation/plugins/simready-report/skills/simready-report/_kit_wrapper.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kit-rooted entry point for validate.py.
2
+
3
+ Boots `isaacsim.SimulationApp({"headless": True})` once in the calling
4
+ process so `pxr.PhysxSchema` and Kit's MDL search path are registered,
5
+ then hands control to `validate.py` via `runpy`. Used by `--use-kit`
6
+ in validate.py — see PROBLEMS.md P2 for why this exists.
7
+
8
+ This file is intentionally tiny so SimulationApp's boot happens before
9
+ *any* pxr / omni.asset_validator import. Don't add other imports above
10
+ the SimulationApp() call.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import runpy
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from isaacsim import SimulationApp
20
+
21
+ _sim = SimulationApp({"headless": True})
22
+ os.environ["SIMREADY_INSIDE_KIT"] = "1"
23
+
24
+ _this_dir = Path(__file__).resolve().parent
25
+ _target = _this_dir / "validate.py"
26
+ sys.argv = [str(_target)] + sys.argv[1:]
27
+ try:
28
+ runpy.run_path(str(_target), run_name="__main__")
29
+ finally:
30
+ try:
31
+ _sim.close()
32
+ except Exception:
33
+ pass
tools/validation/plugins/simready-report/skills/simready-report/external_deps.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """External-dependency tracker for the /simready-report skill.
2
+
3
+ For each USD stage, walks all used layers (sublayers, references, payloads,
4
+ clips) and classifies each as internal (under target_root) or external.
5
+ Tracks per-layer provenance ("what was done to obtain it") and any issues
6
+ encountered while scanning.
7
+
8
+ Outputs:
9
+ external_dependencies_report.json
10
+ external_dependencies_report.html
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import html
15
+ import json
16
+ from dataclasses import dataclass, field, asdict
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from pxr import Usd
21
+
22
+
23
+ @dataclass
24
+ class ExternalLayerRecord:
25
+ layer_identifier: str
26
+ resolved_path: str
27
+ referenced_by: list[str] = field(default_factory=list)
28
+ actions: list[str] = field(default_factory=list)
29
+ status: str = "resolved" # resolved | missing | error
30
+
31
+
32
+ @dataclass
33
+ class IssueRecord:
34
+ asset: str
35
+ message: str
36
+
37
+
38
+ class ExternalDepsTracker:
39
+ """Collects external-layer references across all validated stages."""
40
+
41
+ def __init__(self, target_root: Path):
42
+ self.target_root = target_root.resolve()
43
+ self.external: dict[str, ExternalLayerRecord] = {}
44
+ self.issues: list[IssueRecord] = []
45
+
46
+ # ---- public API ----
47
+
48
+ def scan_stage(self, stage: Usd.Stage, asset_path: Path, target_root: Path) -> None:
49
+ try:
50
+ used = stage.GetUsedLayers(includeClipLayers=True)
51
+ except Exception as e:
52
+ self.record_issue(str(asset_path), f"GetUsedLayers failed: {type(e).__name__}: {e}")
53
+ return
54
+
55
+ root_layer = stage.GetRootLayer()
56
+ for layer in used:
57
+ try:
58
+ if layer is None or layer == root_layer:
59
+ continue
60
+ identifier = layer.identifier
61
+ # Skip anonymous (in-memory) layers — `anon:0x...` identifiers
62
+ # are USD-internal handles, not external dependencies.
63
+ if identifier.startswith("anon:") or (hasattr(layer, "anonymous") and layer.anonymous):
64
+ continue
65
+ resolved = layer.realPath or layer.resolvedPath.GetPathString() if hasattr(layer, "resolvedPath") else (layer.realPath or "")
66
+
67
+ if self._is_internal(resolved):
68
+ continue
69
+
70
+ rec = self.external.get(identifier)
71
+ if rec is None:
72
+ rec = ExternalLayerRecord(
73
+ layer_identifier=identifier,
74
+ resolved_path=resolved or "",
75
+ )
76
+ if resolved and Path(resolved).exists():
77
+ rec.actions.append(f"Resolved by USD asset resolver to: {resolved}")
78
+ rec.status = "resolved"
79
+ elif not resolved:
80
+ rec.actions.append(
81
+ f"Could not resolve layer identifier '{identifier}' to a filesystem path."
82
+ )
83
+ rec.status = "missing"
84
+ else:
85
+ rec.actions.append(f"Resolved to '{resolved}' but path does not exist on disk.")
86
+ rec.status = "missing"
87
+ self.external[identifier] = rec
88
+
89
+ ref = str(asset_path.relative_to(self.target_root)) if asset_path.is_relative_to(self.target_root) else str(asset_path)
90
+ if ref not in rec.referenced_by:
91
+ rec.referenced_by.append(ref)
92
+ except Exception as e:
93
+ self.record_issue(str(asset_path), f"Error scanning layer {layer}: {type(e).__name__}: {e}")
94
+
95
+ def record_issue(self, asset: str, message: str) -> None:
96
+ self.issues.append(IssueRecord(asset=asset, message=message))
97
+
98
+ def external_count(self) -> int:
99
+ return len(self.external)
100
+
101
+ def write_reports(self, out_dir: Path) -> None:
102
+ # Skip emitting the report entirely when there's nothing to say —
103
+ # no external assets resolved AND no issues encountered.
104
+ if not self.external and not self.issues:
105
+ for stale in ("external_dependencies_report.json", "external_dependencies_report.html"):
106
+ p = out_dir / stale
107
+ if p.is_file():
108
+ p.unlink()
109
+ return
110
+ payload = {
111
+ "generated": datetime.now(timezone.utc).isoformat(),
112
+ "target_root": str(self.target_root),
113
+ "external_assets": [asdict(r) for r in self.external.values()],
114
+ "issues": [asdict(i) for i in self.issues],
115
+ "summary": {
116
+ "external_count": len(self.external),
117
+ "missing_count": sum(1 for r in self.external.values() if r.status == "missing"),
118
+ "issue_count": len(self.issues),
119
+ },
120
+ }
121
+ (out_dir / "external_dependencies_report.json").write_text(
122
+ json.dumps(payload, indent=2), encoding="utf-8"
123
+ )
124
+ (out_dir / "external_dependencies_report.html").write_text(
125
+ self._render_html(payload), encoding="utf-8"
126
+ )
127
+
128
+ # ---- internal ----
129
+
130
+ def _is_internal(self, resolved_path: str) -> bool:
131
+ if not resolved_path:
132
+ return False
133
+ try:
134
+ p = Path(resolved_path).resolve()
135
+ except Exception:
136
+ return False
137
+ try:
138
+ return p.is_relative_to(self.target_root)
139
+ except AttributeError:
140
+ try:
141
+ p.relative_to(self.target_root)
142
+ return True
143
+ except ValueError:
144
+ return False
145
+
146
+ def _render_html(self, payload: dict) -> str:
147
+ s = payload["summary"]
148
+ rows = []
149
+ for rec in payload["external_assets"]:
150
+ actions_html = "".join(f"<li>{html.escape(a)}</li>" for a in rec["actions"]) or "<li>(none)</li>"
151
+ refs_html = "".join(f"<li><code>{html.escape(r)}</code></li>" for r in rec["referenced_by"]) or "<li>(unknown)</li>"
152
+ status_class = "status-" + rec["status"]
153
+ rows.append(f"""
154
+ <tr>
155
+ <td><code>{html.escape(rec['layer_identifier'])}</code></td>
156
+ <td><code>{html.escape(rec['resolved_path'] or '')}</code></td>
157
+ <td><span class="badge {status_class}">{html.escape(rec['status'])}</span></td>
158
+ <td><ul>{refs_html}</ul></td>
159
+ <td><ul>{actions_html}</ul></td>
160
+ </tr>""")
161
+
162
+ issues_html = "".join(
163
+ f"<tr><td><code>{html.escape(i['asset'])}</code></td><td>{html.escape(i['message'])}</td></tr>"
164
+ for i in payload["issues"]
165
+ )
166
+ if not issues_html:
167
+ issues_html = '<tr><td colspan="2" class="muted">No issues recorded.</td></tr>'
168
+
169
+ return f"""<!doctype html>
170
+ <html lang="en"><head><meta charset="utf-8" />
171
+ <title>External Dependencies Report</title>
172
+ <script>(function(){{var t=null;try{{t=localStorage.getItem('vr-theme');}}catch(e){{}}if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}})();</script>
173
+ <style>
174
+ :root {{ --bg:#f4f6fb; --panel:#fff; --text:#1f2a44; --muted:#7280a7; --border:#e6ebf5; --resolved:#0a0; --missing:#c00; --error:#b35900; }}
175
+ @media (prefers-color-scheme: dark) {{
176
+ :root:not([data-theme="light"]) {{ --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --resolved:#4ade80; --missing:#ff6b6b; --error:#fbbf24; }}
177
+ }}
178
+ :root[data-theme="dark"] {{ --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --resolved:#4ade80; --missing:#ff6b6b; --error:#fbbf24; }}
179
+ * {{ box-sizing: border-box; }}
180
+ body {{ font-family: "Segoe UI", Arial, sans-serif; margin:0; padding:16px; background:var(--bg); color:var(--text); }}
181
+ header {{ background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:12px 16px; margin-bottom:16px; display:flex; align-items:center; gap:12px; }}
182
+ header .header-text {{ flex:1; }}
183
+ header h1 {{ margin:0; font-size:18px; }}
184
+ header .meta {{ color:var(--muted); font-size:12px; margin-top:4px; }}
185
+ .theme-toggle {{ padding:6px 12px; background:transparent; border:1px solid var(--border); border-radius:8px; color:var(--text); cursor:pointer; font-size:13px; }}
186
+ .theme-toggle:hover {{ background:var(--bg); }}
187
+ .summary {{ display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }}
188
+ .summary .card {{ background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; }}
189
+ .summary .label {{ color:var(--muted); font-size:12px; }}
190
+ .summary .value {{ font-size:20px; font-weight:700; }}
191
+ section {{ background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; margin-bottom:14px; }}
192
+ section h2 {{ margin:0 0 10px 0; font-size:15px; }}
193
+ table {{ width:100%; border-collapse:collapse; font-size:13px; }}
194
+ th, td {{ text-align:left; vertical-align:top; padding:8px 10px; border-bottom:1px solid var(--border); }}
195
+ th {{ color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.04em; }}
196
+ ul {{ margin:0; padding-left:18px; }}
197
+ code {{ font-family: ui-monospace, monospace; font-size:12px; word-break:break-all; }}
198
+ .badge {{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:700; color:#fff; }}
199
+ .status-resolved {{ background:var(--resolved); }}
200
+ .status-missing {{ background:var(--missing); }}
201
+ .status-error {{ background:var(--error); }}
202
+ .muted {{ color:var(--muted); }}
203
+ </style></head>
204
+ <body>
205
+ <header>
206
+ <div class="header-text">
207
+ <h1>External Dependencies Report</h1>
208
+ <div class="meta">Target: <code>{html.escape(payload['target_root'])}</code> &middot; Generated: {html.escape(payload['generated'])}</div>
209
+ </div>
210
+ <button type="button" id="themeToggle" class="theme-toggle" aria-label="Toggle theme">Dark mode</button>
211
+ </header>
212
+ <div class="summary">
213
+ <div class="card"><div class="label">External assets</div><div class="value">{s['external_count']}</div></div>
214
+ <div class="card"><div class="label">Missing</div><div class="value">{s['missing_count']}</div></div>
215
+ <div class="card"><div class="label">Issues</div><div class="value">{s['issue_count']}</div></div>
216
+ </div>
217
+
218
+ <section>
219
+ <h2>External assets &mdash; what was needed and how it was obtained</h2>
220
+ <table>
221
+ <thead><tr><th>Layer identifier</th><th>Resolved path</th><th>Status</th><th>Referenced by</th><th>Actions taken</th></tr></thead>
222
+ <tbody>{''.join(rows) or '<tr><td colspan="5" class="muted">No external assets referenced.</td></tr>'}</tbody>
223
+ </table>
224
+ </section>
225
+
226
+ <section>
227
+ <h2>Issues encountered while building this report</h2>
228
+ <table>
229
+ <thead><tr><th>Asset</th><th>Message</th></tr></thead>
230
+ <tbody>{issues_html}</tbody>
231
+ </table>
232
+ </section>
233
+ <script>
234
+ (function() {{
235
+ var btn = document.getElementById("themeToggle");
236
+ var mq = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
237
+ function isDark() {{
238
+ var attr = document.documentElement.getAttribute("data-theme");
239
+ if (attr === "dark") return true;
240
+ if (attr === "light") return false;
241
+ return mq ? mq.matches : false;
242
+ }}
243
+ function syncLabel() {{
244
+ if (btn) btn.textContent = isDark() ? "Light mode" : "Dark mode";
245
+ }}
246
+ syncLabel();
247
+ if (mq && mq.addEventListener) mq.addEventListener("change", syncLabel);
248
+ if (btn) {{
249
+ btn.addEventListener("click", function() {{
250
+ var next = isDark() ? "light" : "dark";
251
+ document.documentElement.setAttribute("data-theme", next);
252
+ try {{ localStorage.setItem("vr-theme", next); }} catch(e) {{}}
253
+ syncLabel();
254
+ }});
255
+ }}
256
+ }})();
257
+ </script>
258
+ </body></html>
259
+ """
tools/validation/plugins/simready-report/skills/simready-report/report.py ADDED
@@ -0,0 +1,850 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dashboard HTML generator for the /simready-report skill.
2
+
3
+ Given the per-asset results produced by validate.py, emits index.html in the
4
+ style of usd_validation_dashboard_final/: summary cards, asset filter,
5
+ per-asset blocks (failed/passed features, affected prims), compliance footer.
6
+
7
+ Thumbnails are embedded inline (base64 data URI) from the spec path
8
+ <asset_dir>/.thumbs/256x256/<filename>.png — matching the ThumbnailExists rule.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+
17
+ def _ensure_static_copied(out_docs_dir: Path, dashboard_docs_dir: Path | None) -> bool:
18
+ """Mirror the reference dashboard's `_static/` into `<out_docs_dir>/_static/` once.
19
+
20
+ Returns True if the static tree is available (so pages can link to it),
21
+ False if the dashboard isn't present or the copy failed.
22
+ """
23
+ if dashboard_docs_dir is None:
24
+ return False
25
+ src = dashboard_docs_dir / "_static"
26
+ if not src.is_dir():
27
+ return False
28
+ dest = out_docs_dir / "_static"
29
+ if dest.exists():
30
+ return True
31
+ import shutil
32
+ try:
33
+ shutil.copytree(src, dest)
34
+ return True
35
+ except Exception:
36
+ return False
37
+
38
+
39
+ def _render_md_page(title: str, rel_path: str, source_path: Path, body_html: str,
40
+ has_static: bool) -> str:
41
+ """Render a markdown page using the reference dashboard's Sphinx assets.
42
+
43
+ `rel_path` is the doc's path relative to `out_docs_dir` (e.g.
44
+ `features/FET_001-minimal.html`); we compute how many `../` are needed to
45
+ reach `_static/` based on its depth.
46
+ """
47
+ depth = rel_path.replace("\\", "/").count("/")
48
+ static_prefix = "../" * depth + "_static/"
49
+ if has_static:
50
+ head_links = f"""
51
+ <link rel="stylesheet" href="{static_prefix}styles/theme.css" />
52
+ <link rel="stylesheet" href="{static_prefix}styles/bootstrap.css" />
53
+ <link rel="stylesheet" href="{static_prefix}styles/pydata-sphinx-theme.css" />
54
+ <link rel="stylesheet" href="{static_prefix}styles/nvidia-sphinx-theme.css" />
55
+ <link rel="stylesheet" href="{static_prefix}pygments.css" />
56
+ <link rel="stylesheet" href="{static_prefix}_style.css" />
57
+ <link rel="stylesheet" href="{static_prefix}custom.css" />
58
+ <link rel="stylesheet" href="{static_prefix}sphinx-design.min.css" />
59
+ <link rel="stylesheet" href="{static_prefix}vendor/fontawesome/6.5.2/css/all.min.css" />
60
+ """
61
+ body_open = '<main class="bd-main"><div class="bd-content"><div class="bd-article-container"><article class="bd-article">'
62
+ body_close = '</article></div></div></main>'
63
+ else:
64
+ head_links = ""
65
+ body_open = '<article style="max-width:880px;margin:24px auto;padding:24px 28px;font-family:Segoe UI,Arial,sans-serif;line-height:1.55;">'
66
+ body_close = '</article>'
67
+
68
+ # `source_path` is an absolute machine-specific path inside the foundations
69
+ # checkout — don't render it. Show the source's path relative to the
70
+ # foundation docs root (i.e. `rel_path` minus the .html for the .md form),
71
+ # which is portable across machines that share the same foundations layout.
72
+ source_md_rel = (rel_path[:-5] + ".md") if rel_path.endswith(".html") else rel_path
73
+ return f"""<!DOCTYPE html>
74
+ <html lang="en" data-content_root="{'../' * depth}">
75
+ <head>
76
+ <meta charset="utf-8" />
77
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
78
+ <title>{html.escape(title)}</title>
79
+ <script>document.documentElement.dataset.mode = localStorage.getItem("mode") || "";document.documentElement.dataset.theme = localStorage.getItem("theme") || "";</script>{head_links}
80
+ </head>
81
+ <body data-bs-spy="scroll">
82
+ {body_open}
83
+ <p class="text-muted" style="font-size:12px;margin-bottom:14px;">Source: <code>nv_core/sr_specs/docs/{html.escape(source_md_rel)}</code></p>
84
+ {body_html}
85
+ {body_close}
86
+ </body>
87
+ </html>
88
+ """
89
+
90
+
91
+ _NO_SPEC_TAG = ' <span class="no-spec">(no spec source)</span>'
92
+
93
+
94
+ def _relativize_msg(msg: str, target: Path | None) -> str:
95
+ """Strip machine-specific absolute path prefixes from validator messages.
96
+
97
+ `omni.asset_validator` rule messages embed the absolute path of each USD
98
+ being inspected. We replace the target's path prefix (and the path up to
99
+ `packages/` / `assets_to_validate/`) with relative tokens so reports
100
+ don't leak the report-author's user dir.
101
+ """
102
+ if not msg or target is None:
103
+ return msg
104
+ out = msg
105
+ target_fwd = str(target).replace("\\", "/")
106
+ target_bwd = str(target).replace("/", "\\")
107
+ for prefix in (target_fwd, target_bwd):
108
+ if prefix and prefix in out:
109
+ out = out.replace(prefix, "<target>")
110
+ # Catch absolute paths that point at the workspace's packages/ or
111
+ # assets_to_validate/ — same machine-specificity, different anchor.
112
+ for marker in ("/packages/", "\\packages\\", "/assets_to_validate/", "\\assets_to_validate\\"):
113
+ idx = out.find(marker)
114
+ while idx != -1:
115
+ # Find the start of the absolute path containing this marker
116
+ # (a drive letter "X:\" or POSIX "/" boundary going left).
117
+ start = idx
118
+ while start > 0 and out[start - 1] not in (" ", "<", "(", "[", '"', "'", "\n", "\t"):
119
+ start -= 1
120
+ # Replace from `start` through the marker with the marker's tail.
121
+ anchor = marker.strip("/\\").replace("\\", "/") # "packages" / "assets_to_validate"
122
+ tail_pos = idx + len(marker)
123
+ out = out[:start] + f"<{anchor}>/" + out[tail_pos:]
124
+ idx = out.find(marker, start + len(anchor) + 3)
125
+ return out
126
+
127
+ _md_renderer = None
128
+
129
+
130
+ def _md_to_html(md_text: str) -> str:
131
+ global _md_renderer
132
+ if _md_renderer is None:
133
+ from markdown_it import MarkdownIt
134
+ _md_renderer = MarkdownIt("commonmark", {"html": True, "linkify": True, "typographer": True}).enable("table")
135
+ return _md_renderer.render(md_text)
136
+
137
+
138
+ _IMG_PLACEHOLDER_SVG = (
139
+ "data:image/svg+xml;utf8,"
140
+ "<svg xmlns='http://www.w3.org/2000/svg' width='320' height='180' viewBox='0 0 320 180'>"
141
+ "<rect width='320' height='180' fill='%23f0f2f8' stroke='%23c7cee0' stroke-dasharray='6 4' stroke-width='2'/>"
142
+ "<text x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' "
143
+ "font-family='Segoe UI,Arial,sans-serif' font-size='14' fill='%237280a7'>image missing</text>"
144
+ "</svg>"
145
+ )
146
+
147
+
148
+ def _localize_images(body_html: str, md_source: Path, out_html_path: Path) -> str:
149
+ """Walk every `<img src="...">` in `body_html`. If src is a relative path,
150
+ copy the source file (relative to `md_source.parent`) into the equivalent
151
+ location next to `out_html_path`. When the source file is missing, replace
152
+ the src with an inline SVG placeholder so the page never 404s.
153
+ """
154
+ import re
155
+ import shutil
156
+
157
+ md_dir = md_source.parent
158
+ out_dir = out_html_path.parent
159
+
160
+ def repl(match):
161
+ full_tag = match.group(0)
162
+ src_match = re.search(r'src="([^"]+)"', full_tag)
163
+ if not src_match:
164
+ return full_tag
165
+ src = src_match.group(1)
166
+ if src.startswith(("http://", "https://", "data:", "//", "/")):
167
+ return full_tag # absolute / data URI / protocol-relative — leave alone
168
+ rel = src.lstrip("./").replace("\\", "/")
169
+ source_file = (md_dir / src).resolve()
170
+ if source_file.is_file():
171
+ dest_file = (out_dir / rel).resolve()
172
+ try:
173
+ dest_file.relative_to(out_dir.resolve()) # guard against ../ escape
174
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
175
+ if not dest_file.exists() or dest_file.stat().st_size != source_file.stat().st_size:
176
+ shutil.copy2(source_file, dest_file)
177
+ return full_tag
178
+ except (ValueError, OSError):
179
+ pass
180
+ # Missing source — swap to placeholder
181
+ return re.sub(r'src="[^"]+"', f'src="{_IMG_PLACEHOLDER_SVG}"', full_tag)
182
+
183
+ return re.sub(r"<img\b[^>]*>", repl, body_html)
184
+
185
+
186
+ _A_TAG_RE = None
187
+
188
+
189
+ def _localize_links(body_html: str, md_source: Path, out_html_path: Path,
190
+ specs_docs_dir: Path, out_docs_root: Path,
191
+ resolver) -> str:
192
+ """Rewrite relative <a href="..."> targets so the docs tree is self-contained.
193
+
194
+ - .md targets that resolve to a file inside `specs_docs_dir`: rewrite href
195
+ to .html and recursively render via `resolver(<rel>.html)`.
196
+ - non-.md targets inside `specs_docs_dir`: copy verbatim into the mirrored
197
+ location under `out_docs_root` and rewrite href to that copy.
198
+ - root-relative ("/...") hrefs treated as relative to `specs_docs_dir`;
199
+ bare paths without an extension are tried as `.md`.
200
+ - links pointing outside `specs_docs_dir` or at non-existent targets are
201
+ replaced with <span class="docref-disabled" title="...">TEXT</span> so
202
+ the visible text remains but no href can 404.
203
+ """
204
+ import os
205
+ import re
206
+ import shutil
207
+
208
+ global _A_TAG_RE
209
+ if _A_TAG_RE is None:
210
+ _A_TAG_RE = re.compile(r'<a\s+([^>]*?)>(.*?)</a>', re.DOTALL | re.IGNORECASE)
211
+
212
+ md_dir = md_source.parent
213
+ out_dir = out_html_path.parent
214
+ specs_root = specs_docs_dir.resolve()
215
+ out_docs_resolved = out_docs_root.resolve()
216
+
217
+ def _disable(inner: str, why: str = "link target not available in report") -> str:
218
+ return f'<span class="docref-disabled" title="{html.escape(why)}">{inner}</span>'
219
+
220
+ def _resolve_target(href_path: str) -> Path | None:
221
+ if href_path.startswith("/"):
222
+ base = specs_root / href_path.lstrip("/")
223
+ if not base.suffix:
224
+ base = base.with_suffix(".md")
225
+ return base
226
+ return (md_dir / href_path)
227
+
228
+ def repl(m: "re.Match[str]") -> str:
229
+ attrs = m.group(1)
230
+ inner = m.group(2)
231
+ href_m = re.search(r'href\s*=\s*"([^"]*)"', attrs)
232
+ if not href_m:
233
+ return m.group(0)
234
+ href = href_m.group(1).strip()
235
+ if not href:
236
+ return m.group(0)
237
+ if href.startswith(("http://", "https://", "data:", "mailto:", "//", "#", "javascript:")):
238
+ return m.group(0)
239
+
240
+ if "#" in href:
241
+ path_part, frag = href.split("#", 1)
242
+ frag = "#" + frag
243
+ else:
244
+ path_part, frag = href, ""
245
+ if not path_part:
246
+ return m.group(0)
247
+
248
+ target = _resolve_target(path_part)
249
+ if target is None:
250
+ return _disable(inner)
251
+ try:
252
+ target = target.resolve()
253
+ except OSError:
254
+ return _disable(inner)
255
+ try:
256
+ rel_to_specs = target.relative_to(specs_root)
257
+ except ValueError:
258
+ return _disable(inner, "link target outside foundation docs")
259
+ if not target.exists():
260
+ return _disable(inner, "link target missing in foundation docs")
261
+
262
+ rel_str = str(rel_to_specs).replace("\\", "/")
263
+
264
+ if target.suffix.lower() == ".md":
265
+ html_rel = rel_str[:-3] + ".html"
266
+ url = resolver(html_rel)
267
+ if url is None:
268
+ return _disable(inner)
269
+ target_out = out_docs_resolved / html_rel
270
+ else:
271
+ target_out = out_docs_resolved / rel_str
272
+ try:
273
+ target_out.parent.mkdir(parents=True, exist_ok=True)
274
+ if not target_out.exists() or target_out.stat().st_size != target.stat().st_size:
275
+ shutil.copy2(target, target_out)
276
+ except OSError:
277
+ return _disable(inner, "could not copy link target into report")
278
+
279
+ try:
280
+ new_href = os.path.relpath(target_out, out_dir).replace("\\", "/") + frag
281
+ except ValueError:
282
+ return _disable(inner)
283
+ new_attrs = re.sub(r'href\s*=\s*"[^"]*"', f'href="{new_href}"', attrs, count=1)
284
+ return f'<a {new_attrs}>{inner}</a>'
285
+
286
+ return _A_TAG_RE.sub(repl, body_html)
287
+
288
+
289
+ def _make_doc_url_resolver(out_dir: Path, dashboard_docs_dir: Path | None,
290
+ specs_docs_dir: Path | None):
291
+ """Build a `_doc_url(rel_path)` callable.
292
+
293
+ Every resolvable doc is rendered from the foundation markdown source into
294
+ `<out_dir>/docs/<rel_path>` so the dashboard has a single, self-contained
295
+ docs tree. The reference-dashboard pre-built HTML is no longer used as a
296
+ link target — it would split the source of truth.
297
+
298
+ `dashboard_docs_dir` is kept on the signature for API stability but not
299
+ consulted here.
300
+ """
301
+ rendered_cache: dict[str, str] = {}
302
+ out_docs_dir = out_dir / "docs"
303
+ out_docs_dir.mkdir(parents=True, exist_ok=True)
304
+ has_static = _ensure_static_copied(out_docs_dir, dashboard_docs_dir)
305
+
306
+ def resolve(rel_path: str | None) -> str | None:
307
+ if not rel_path or specs_docs_dir is None:
308
+ return None
309
+ if rel_path in rendered_cache:
310
+ return rendered_cache[rel_path]
311
+ md_rel = rel_path[:-5] + ".md" if rel_path.endswith(".html") else rel_path
312
+ md_source = specs_docs_dir / md_rel
313
+ if not md_source.is_file():
314
+ return None
315
+ # Cache the URL before rendering body so cycles in the foundation's
316
+ # cross-doc links (e.g. profile -> feature -> profile) terminate.
317
+ url = "docs/" + rel_path.replace("\\", "/")
318
+ rendered_cache[rel_path] = url
319
+ out_path = out_docs_dir / rel_path
320
+ out_path.parent.mkdir(parents=True, exist_ok=True)
321
+ body = _md_to_html(md_source.read_text(encoding="utf-8"))
322
+ body = _localize_images(body, md_source, out_path)
323
+ body = _localize_links(body, md_source, out_path, specs_docs_dir, out_docs_dir, resolve)
324
+ out_path.write_text(
325
+ _render_md_page(rel_path, rel_path, md_source, body, has_static),
326
+ encoding="utf-8",
327
+ )
328
+ return url
329
+
330
+ return resolve
331
+
332
+
333
+ CSS = """
334
+ :root { --bg:#f4f6fb; --panel:#fff; --text:#1f2a44; --muted:#7280a7; --border:#e6ebf5; --fail:#c00; --pass:#0a0; --link:#2b6de2; --note-bg:#fff8e6; --note-border:#f1d38a; --note-text:#6b4f00; --note-code:#fff3cc; --thumb-bg:#f4f6fb; }
335
+ @media (prefers-color-scheme: dark) {
336
+ :root:not([data-theme="light"]) { --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --fail:#ff6b6b; --pass:#4ade80; --link:#7ba9ff; --note-bg:#2a2410; --note-border:#5a4a1f; --note-text:#f1d38a; --note-code:#3a3018; --thumb-bg:#0f1320; }
337
+ }
338
+ :root[data-theme="dark"] { --bg:#0f1320; --panel:#1a2030; --text:#e6ebf5; --muted:#8a93b3; --border:#2a3142; --fail:#ff6b6b; --pass:#4ade80; --link:#7ba9ff; --note-bg:#2a2410; --note-border:#5a4a1f; --note-text:#f1d38a; --note-code:#3a3018; --thumb-bg:#0f1320; }
339
+ * { box-sizing: border-box; }
340
+ body { font-family: "Segoe UI", Arial, sans-serif; margin:0; background:var(--bg); color:var(--text); padding:16px; }
341
+ header { display:flex; align-items:center; gap:10px; margin-bottom:16px; padding:12px 16px; background:var(--panel); border-radius:12px; border:1px solid var(--border); }
342
+ header .logo { width:36px; height:36px; border-radius:10px; background:#2b6de2; color:#fff; display:grid; place-items:center; font-weight:700; }
343
+ header h1 { margin:0; font-size:18px; }
344
+ header .meta { color:var(--muted); font-size:12px; }
345
+ .summary { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px; }
346
+ .summary .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; }
347
+ .summary .card .label { color:var(--muted); font-size:12px; }
348
+ .summary .card .value { font-size:20px; font-weight:700; }
349
+ .asset-block { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; margin-bottom:14px; display:flex; gap:14px; align-items:flex-start; }
350
+ .asset-block .asset-thumb-wrap { flex-shrink:0; width:140px; }
351
+ .asset-block .asset-thumb { width:100%; height:auto; aspect-ratio:1; object-fit:cover; border-radius:8px; background:var(--border); display:block; }
352
+ .asset-block .asset-thumb-missing { background:var(--thumb-bg); border:1px dashed var(--border); display:grid; place-items:center; color:var(--muted); font-size:11px; text-align:center; padding:8px; }
353
+ .asset-block .asset-thumb-missing span { line-height:1.3; }
354
+ .code { font-family: ui-monospace, monospace; font-size:12px; padding:1px 6px; border-radius:4px; background:var(--note-code); color:var(--note-text); margin-right:4px; }
355
+ .feat-id { font-family: ui-monospace, monospace; font-size:13px; }
356
+ a.docref { color:var(--link); text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; font-size:12px; }
357
+ a.docref:hover { text-decoration-style:solid; }
358
+ a.code { text-decoration:underline; text-decoration-style:dotted; text-underline-offset:2px; }
359
+ a.code:hover { text-decoration-style:solid; }
360
+ .failed-features .feat-id { color:var(--fail); }
361
+ .passed-features .feat-id { color:var(--pass); }
362
+ .asset-block .asset-body { flex:1; min-width:0; }
363
+ .asset-block h2 { margin:0 0 6px 0; font-size:15px; }
364
+ .asset-block .path { font-size:12px; color:var(--muted); margin-bottom:10px; word-break:break-all; }
365
+ .asset-block section { margin-top:10px; }
366
+ .asset-block section h3 { margin:0 0 6px 0; font-size:13px; color:var(--muted); }
367
+ .asset-block ul { margin:0; padding-left:20px; }
368
+ .asset-block li { margin:4px 0; }
369
+ .failed-features { color:var(--fail); }
370
+ .passed-features { color:var(--pass); }
371
+ .filter { margin-bottom:12px; }
372
+ .filter input { padding:8px 12px; width:100%; max-width:320px; border:1px solid var(--border); border-radius:8px; font-size:14px; }
373
+ .compliance { margin-top:28px; background:var(--panel); border-radius:12px; padding:18px; border:1px solid var(--border); }
374
+ .compliance h2 { margin:0 0 12px 0; font-size:16px; }
375
+ .compliance-list { margin:0; padding-left:20px; }
376
+ .compliance-list li { margin:8px 0; }
377
+ .compliance-list .code { font-weight:700; }
378
+ .affected-prims { font-size:12px; color:var(--muted); margin:8px 0; }
379
+ .affected-prims .label { font-weight:600; color:var(--text); }
380
+ .prim-list-inline { list-style:none; padding-left:0; margin:4px 0 0 0; display:flex; flex-wrap:wrap; gap:6px 12px; }
381
+ .prim-list-inline li { font-family: ui-monospace, monospace; font-size:11px; word-break:break-all; }
382
+ .muted { color:var(--muted); }
383
+ .no-spec { color:var(--muted); font-style:italic; font-size:11px; }
384
+ .packaging-note { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
385
+ .packaging-note code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
386
+ .coverage-banner { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
387
+ .coverage-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
388
+ .coverage-banner details { margin-top:8px; }
389
+ .coverage-banner summary { cursor:pointer; font-weight:600; }
390
+ .coverage-banner ul { margin:6px 0 0 0; padding-left:20px; }
391
+ .outside-spec-banner { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
392
+ .outside-spec-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:12px; }
393
+ .thumbnail-banner { background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; padding:10px 14px; margin:0 0 16px 0; font-size:13px; color:var(--note-text); }
394
+ .thumbnail-banner code { background:var(--note-code); padding:1px 5px; border-radius:4px; font-size:11px; word-break:break-all; }
395
+ .thumbnail-banner details { margin-top:8px; }
396
+ .thumbnail-banner summary { cursor:pointer; font-weight:600; }
397
+ .thumbnail-banner ul { margin:6px 0 0 0; padding-left:20px; }
398
+ .header-actions { margin-left:auto; display:flex; gap:8px; align-items:center; }
399
+ .caveat-toggle { padding:6px 12px; background:var(--note-bg); border:1px solid var(--note-border); border-radius:8px; color:var(--note-text); cursor:pointer; font-size:13px; font-weight:600; display:inline-flex; align-items:center; gap:6px; animation:caveat-pulse 1.6s ease-in-out infinite; }
400
+ .caveat-toggle:hover { filter:brightness(1.05); }
401
+ .caveat-toggle .caveat-count { background:var(--note-text); color:var(--note-bg); border-radius:999px; padding:1px 8px; font-size:11px; font-weight:700; line-height:1.4; min-width:18px; text-align:center; }
402
+ .caveat-toggle.open { animation:none; box-shadow:none; }
403
+ @keyframes caveat-pulse {
404
+ 0%, 100% { box-shadow:0 0 0 0 var(--note-border); }
405
+ 50% { box-shadow:0 0 0 6px transparent; }
406
+ }
407
+ #caveats[hidden] { display:none; }
408
+ .theme-toggle { padding:6px 12px; background:transparent; border:1px solid var(--border); border-radius:8px; color:var(--text); cursor:pointer; font-size:13px; }
409
+ .theme-toggle:hover { background:var(--bg); }
410
+ """.strip()
411
+
412
+
413
+ def write_dashboard(out_dir: Path, target: Path, profile: str, profile_version: str,
414
+ results: list[dict],
415
+ specs_docs_dir: Path | None = None,
416
+ dashboard_docs_dir: Path | None = None,
417
+ thumbnail_provenance: list[dict] | None = None,
418
+ profile_coverage: dict | None = None) -> Path:
419
+ total = len(results)
420
+ failed = sum(1 for r in results if not r["passed"])
421
+ passed_checks = sum(len(r.get("passed_features", [])) for r in results)
422
+
423
+ doc_url = _make_doc_url_resolver(out_dir, dashboard_docs_dir, specs_docs_dir)
424
+ # Map asset name -> relative path under <out_dir>/images/, only when actually
425
+ # copied. Reference relatively so the dashboard contains no external URLs.
426
+ thumb_rel_by_asset = {
427
+ e["asset"]: e["rel"]
428
+ for e in (thumbnail_provenance or [])
429
+ if e.get("present")
430
+ }
431
+ asset_blocks = "\n".join(_render_asset_block(r, doc_url, thumb_rel_by_asset, target) for r in results)
432
+ thumbnail_banner = _render_thumbnail_banner(thumbnail_provenance or [], target)
433
+ coverage_banner = _render_coverage_banner(profile_coverage)
434
+ outside_spec_banner = _render_outside_spec_banner(results)
435
+ env_blocked_banner = _render_env_blocked_banner(results)
436
+ compliance = _render_compliance(results, doc_url, target)
437
+ generated = datetime.now(timezone.utc).isoformat()
438
+ target_str = str(target).replace("\\", "/")
439
+ # Show only the packages-relative tail, never the absolute path —
440
+ # absolute paths leak machine-specific user dirs into shared reports.
441
+ if "/packages/" in target_str:
442
+ raw_hint = target_str.split("/packages/", 1)[1]
443
+ packaging_note = (
444
+ f'<div class="packaging-note">'
445
+ f'<strong>Packaged tree.</strong> This report validates assets after the '
446
+ f'<code>simready ingest usd</code> step. Raw inputs live at '
447
+ f'<code>assets_to_validate/{html.escape(raw_hint)}</code>; '
448
+ f'packaged output lives at <code>packages/{html.escape(raw_hint)}</code>.'
449
+ f'</div>'
450
+ )
451
+ else:
452
+ packaging_note = ""
453
+
454
+ # Group all the yellow-warning banners under a single `Caveats` toggle so
455
+ # the dashboard stays clean by default and the user opts in to read them.
456
+ caveats_list = [b for b in (env_blocked_banner, coverage_banner, outside_spec_banner, packaging_note, thumbnail_banner) if b]
457
+ caveats_count = len(caveats_list)
458
+ if caveats_count > 0:
459
+ caveats_button = (
460
+ f'<button type="button" id="caveatsToggle" class="caveat-toggle" '
461
+ f'aria-expanded="false" aria-controls="caveats" aria-label="Show caveats">'
462
+ f'<span>Caveats</span>'
463
+ f'<span class="caveat-count">{caveats_count}</span>'
464
+ f'</button>'
465
+ )
466
+ caveats_section = (
467
+ '<div id="caveats" hidden>' + "".join(caveats_list) + '</div>'
468
+ )
469
+ else:
470
+ caveats_button = ""
471
+ caveats_section = ""
472
+
473
+ html_doc = f"""<!doctype html>
474
+ <html lang="en">
475
+ <head>
476
+ <meta charset="utf-8" />
477
+ <title>Validation Report &mdash; {html.escape(target.name)}</title>
478
+ <script>(function(){{var t=null;try{{t=localStorage.getItem('vr-theme');}}catch(e){{}}if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}})();</script>
479
+ <style>{CSS}</style>
480
+ </head>
481
+ <body>
482
+ <header>
483
+ <div class="logo">V</div>
484
+ <div>
485
+ <h1>Validation Report &mdash; {html.escape(target.name)}</h1>
486
+ <div class="meta">Profile: <strong>{html.escape(profile)}</strong> v{html.escape(profile_version)} &middot; Generated: {html.escape(generated)}</div>
487
+ </div>
488
+ <div class="header-actions">
489
+ {caveats_button}
490
+ <button type="button" id="themeToggle" class="theme-toggle" aria-label="Toggle theme">Dark mode</button>
491
+ </div>
492
+ </header>
493
+ {caveats_section}
494
+ <div class="summary">
495
+ <div class="card"><div class="label">Total assets</div><div class="value">{total}</div></div>
496
+ <div class="card"><div class="label">Failed assets</div><div class="value">{failed}</div></div>
497
+ <div class="card"><div class="label">Passed feature checks</div><div class="value">{passed_checks}</div></div>
498
+ </div>
499
+ <div class="filter"><input type="text" id="filterInput" placeholder="Filter by asset name..." /></div>
500
+ {asset_blocks}
501
+ {compliance}
502
+ <script>
503
+ (function() {{
504
+ var input = document.getElementById("filterInput");
505
+ var blocks = document.querySelectorAll(".asset-block");
506
+ if (input) {{
507
+ function filter() {{
508
+ var q = (input.value || "").trim().toLowerCase();
509
+ for (var i = 0; i < blocks.length; i++) {{
510
+ var name = (blocks[i].getAttribute("data-asset-name") || "");
511
+ blocks[i].style.display = (!q || name.indexOf(q) !== -1) ? "" : "none";
512
+ }}
513
+ }}
514
+ input.addEventListener("input", filter);
515
+ input.addEventListener("keyup", filter);
516
+ }}
517
+ var caveatsBtn = document.getElementById("caveatsToggle");
518
+ var caveatsBox = document.getElementById("caveats");
519
+ if (caveatsBtn && caveatsBox) {{
520
+ caveatsBtn.addEventListener("click", function() {{
521
+ var open = caveatsBox.hidden;
522
+ caveatsBox.hidden = !open;
523
+ caveatsBtn.setAttribute("aria-expanded", open ? "true" : "false");
524
+ caveatsBtn.classList.toggle("open", open);
525
+ }});
526
+ }}
527
+ var btn = document.getElementById("themeToggle");
528
+ var mq = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)") : null;
529
+ function isDark() {{
530
+ var attr = document.documentElement.getAttribute("data-theme");
531
+ if (attr === "dark") return true;
532
+ if (attr === "light") return false;
533
+ return mq ? mq.matches : false;
534
+ }}
535
+ function syncLabel() {{
536
+ if (btn) btn.textContent = isDark() ? "Light mode" : "Dark mode";
537
+ }}
538
+ syncLabel();
539
+ if (mq && mq.addEventListener) mq.addEventListener("change", syncLabel);
540
+ if (btn) {{
541
+ btn.addEventListener("click", function() {{
542
+ var next = isDark() ? "light" : "dark";
543
+ document.documentElement.setAttribute("data-theme", next);
544
+ try {{ localStorage.setItem("vr-theme", next); }} catch(e) {{}}
545
+ syncLabel();
546
+ }});
547
+ }}
548
+ }})();
549
+ </script>
550
+ </body>
551
+ </html>
552
+ """
553
+ out = out_dir / "index.html"
554
+ out.write_text(html_doc, encoding="utf-8")
555
+ return out
556
+
557
+
558
+ def _render_outside_spec_banner(results: list[dict]) -> str:
559
+ """Banner summarizing how many issues the validator reported against
560
+ requirements that aren't part of the loaded profile's feature set.
561
+ These are deliberately suppressed from the per-asset blocks and the
562
+ compliance footer — the report scope is the chosen spec only — so we
563
+ surface the count here for transparency.
564
+ """
565
+ total_issues = 0
566
+ unique_codes: set[str] = set()
567
+ affected_assets: set[str] = set()
568
+ for r in results:
569
+ if not r.get("extra_failures"):
570
+ continue
571
+ affected_assets.add(r["name"])
572
+ for e in r["extra_failures"]:
573
+ total_issues += int(e.get("count") or 0)
574
+ if e.get("code"):
575
+ unique_codes.add(e["code"])
576
+ if total_issues == 0:
577
+ return ""
578
+ return f'''
579
+ <div class="outside-spec-banner">
580
+ <strong>Out-of-spec issues hidden.</strong> The validator reported
581
+ <strong>{total_issues}</strong> issue(s) against
582
+ <strong>{len(unique_codes)}</strong> requirement code(s) that aren\'t
583
+ part of this profile\'s feature set, across
584
+ <strong>{len(affected_assets)}</strong> asset(s). These are not listed
585
+ in the report — the displayed pass/fail is scoped to the loaded profile
586
+ only. See <code>PROBLEMS.md</code> for why this filter exists.
587
+ </div>
588
+ '''
589
+
590
+
591
+ def _render_coverage_banner(coverage: dict | None) -> str:
592
+ """Banner shown when the loaded profile has fewer features than its
593
+ `profiles.toml` entry declares. The validator silently drops feature
594
+ entries whose ID + version aren't in the registry, which can leave a
595
+ profile reporting against a tiny subset of what it claims to check.
596
+ See PROBLEMS.md P1.
597
+ """
598
+ if not coverage or coverage.get("declared") is None:
599
+ return ""
600
+ missing = coverage.get("missing") or []
601
+ if not missing:
602
+ return ""
603
+ declared = coverage["declared"]
604
+ loaded = coverage["loaded"]
605
+ missing_items = "".join(
606
+ f'<li><code>{html.escape(m["id"])}</code> '
607
+ f'<span class="muted">v{html.escape(m["version"])}</span></li>'
608
+ for m in missing
609
+ )
610
+ return f'''
611
+ <div class="coverage-banner">
612
+ <strong>Coverage gap.</strong> This profile declares <strong>{declared}</strong> features
613
+ in <code>profiles.toml</code> but the validator loaded only <strong>{loaded}</strong>.
614
+ The remaining {len(missing)} were silently dropped because their ID + version
615
+ isn\'t registered in the foundation specs. The pass/fail numbers below reflect
616
+ only the loaded set — the dropped features were <em>not</em> checked.
617
+ See <code>PROBLEMS.md</code> P1 for root cause and fix path.
618
+ <details><summary>Dropped feature IDs ({len(missing)})</summary>
619
+ <ul>{missing_items}</ul></details>
620
+ </div>
621
+ '''
622
+
623
+
624
+ def _render_env_blocked_banner(results: list[dict]) -> str:
625
+ """Banner shown when validate.py's P2 filter dropped env-blocked issues.
626
+
627
+ Aggregates per-asset env_blocked counts so the user sees both that a
628
+ suppression happened and roughly how much was suppressed. See PROBLEMS.md P2.
629
+ """
630
+ physx_total = 0
631
+ mdl_total = 0
632
+ for r in results:
633
+ eb = r.get("env_blocked") or {}
634
+ physx_total += int(eb.get("physxschema_unavailable") or 0)
635
+ mdl_total += int(eb.get("omnipbr_unresolved") or 0)
636
+ if not (physx_total or mdl_total):
637
+ return ""
638
+ parts = []
639
+ if physx_total:
640
+ parts.append(
641
+ f'<strong>{physx_total}</strong> PhysX-rule errors '
642
+ f'(<code>PhysxSchema is not available in this environment</code>)'
643
+ )
644
+ if mdl_total:
645
+ parts.append(
646
+ f'<strong>{mdl_total}</strong> stock MDL-reference failures '
647
+ f'(<code>OmniPBR.mdl</code> not resolvable outside Kit)'
648
+ )
649
+ return f'''
650
+ <div class="coverage-banner">
651
+ <strong>Environment-blocked checks suppressed.</strong>
652
+ This run dropped {" and ".join(parts)} because the active Python venv has
653
+ <code>usd-core</code> but not the Kit-bundled <code>PhysxSchema</code> /
654
+ Kit MDL search path. These checks couldn\'t produce useful results here,
655
+ so they would otherwise have inflated the issue count without telling you
656
+ anything about the asset. Pass/fail below reflects only checks that ran
657
+ meaningfully. See <code>PROBLEMS.md</code> P2 for root cause and fix path.
658
+ </div>
659
+ '''
660
+
661
+
662
+ def _render_thumbnail_banner(provenance: list[dict], target: Path | None = None) -> str:
663
+ """Banner explaining where the asset thumbnails came from (since they
664
+ weren't produced by this run's packaging step)."""
665
+ present = [p for p in provenance if p.get("present")]
666
+ if not present:
667
+ return ""
668
+ items = "".join(
669
+ f'<li><code>{html.escape(p["rel"])}</code> &mdash; copied from <code>{html.escape(_relativize_msg(p.get("copied_from",""), target))}</code></li>'
670
+ for p in present
671
+ )
672
+ return f'''
673
+ <div class="thumbnail-banner">
674
+ <strong>Asset thumbnails — sourced externally.</strong>
675
+ These tiles were copied into this report\'s <code>images/</code> directory from
676
+ per-asset spec paths (<code>&lt;asset_dir&gt;/.thumbs/256x256/&lt;filename&gt;.png</code>) populated
677
+ earlier from NVIDIA\'s Isaac 6.0 content S3 bucket
678
+ (<code>Assets/Isaac/6.0/Isaac/Robots/Yaskawa/Motoman Next/...</code>).
679
+ They are <strong>not</strong> the output of this run\'s packaging step;
680
+ the customer\'s pipeline should generate authoritative thumbnails before the
681
+ validation reports them as spec-compliant.
682
+ <details><summary>Per-tile source ({len(present)} of {len(provenance)})</summary>
683
+ <ul>{items}</ul></details>
684
+ </div>
685
+ '''
686
+
687
+
688
+ def _render_asset_block(r: dict, doc_url, thumb_rel_by_asset: dict | None = None,
689
+ target: Path | None = None) -> str:
690
+ name = r["name"]
691
+ rel = r.get("rel_path") or r.get("path", "")
692
+ affected = r.get("affected_prims", [])
693
+ failed = r.get("failed_features", [])
694
+ passed = r.get("passed_features", [])
695
+
696
+ if affected:
697
+ prims_html = (
698
+ '<div class="affected-prims"><span class="label">Affected prims:</span>'
699
+ '<ul class="prim-list-inline">'
700
+ + "".join(f"<li>{html.escape(p)}</li>" for p in affected)
701
+ + "</ul></div>"
702
+ )
703
+ else:
704
+ prims_html = ""
705
+
706
+ def _feature_link(rel_path: str | None) -> str:
707
+ """Inline 'View feature doc.' link — empty string when no doc available."""
708
+ url = doc_url(rel_path)
709
+ if not url:
710
+ return ''
711
+ return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View feature doc.</a>'
712
+
713
+ def _capability_link(rel_path: str | None) -> str:
714
+ """Inline 'View capability doc' link — empty string when no doc available."""
715
+ url = doc_url(rel_path)
716
+ if not url:
717
+ return ''
718
+ return f' <a class="docref" href="{html.escape(url)}" target="_blank" rel="noopener">View capability doc</a>'
719
+
720
+ if failed:
721
+ items = []
722
+ for f in failed:
723
+ req_paths = f.get("requirement_paths") or {}
724
+ feat_link = _feature_link(f.get("path"))
725
+ codes_parts = []
726
+ for c in f.get("failed_codes", []):
727
+ codes_parts.append(
728
+ f'<span class="code">{html.escape(c)}</span>'
729
+ f'{_capability_link(req_paths.get(c))}'
730
+ )
731
+ items.append(
732
+ f'<li><strong class="feat-id">{html.escape(f["id"])}</strong>'
733
+ f'{feat_link} '
734
+ + " ".join(codes_parts)
735
+ + '</li>'
736
+ )
737
+ failed_html = "<ul>" + "".join(items) + "</ul>"
738
+ else:
739
+ failed_html = '<p class="muted" style="margin:0">None</p>'
740
+
741
+ if passed:
742
+ # Passed features render as plain IDs — no doc link, matching the
743
+ # reference dashboard. Users only need quick access to docs when
744
+ # something failed.
745
+ items = [
746
+ f'<li><strong class="feat-id">{html.escape(f["id"])}</strong></li>'
747
+ for f in passed
748
+ ]
749
+ passed_html = "<ul>" + "".join(items) + "</ul>"
750
+ else:
751
+ passed_html = '<p class="muted" style="margin:0">None</p>'
752
+
753
+ thumb_rel = (thumb_rel_by_asset or {}).get(name)
754
+ if thumb_rel:
755
+ thumb_html = (
756
+ f'<div class="asset-thumb-wrap">'
757
+ f'<img class="asset-thumb" src="{html.escape(thumb_rel)}" alt="{html.escape(name)} thumbnail" />'
758
+ f'</div>'
759
+ )
760
+ else:
761
+ thumb_html = (
762
+ '<div class="asset-thumb-wrap">'
763
+ '<div class="asset-thumb asset-thumb-missing" aria-label="no thumbnail">'
764
+ '<span>missing image</span>'
765
+ '</div></div>'
766
+ )
767
+
768
+ # `extra_failures` (issues against requirements outside the loaded
769
+ # profile) are intentionally NOT rendered per-asset. They get summarized
770
+ # as a count in the Caveats panel; the report scope is the chosen
771
+ # profile only.
772
+ extras_html = ""
773
+
774
+ return f"""
775
+ <div class="asset-block" data-asset-name="{html.escape(name.lower())}">
776
+ {thumb_html}
777
+ <div class="asset-body">
778
+ <h2>{html.escape(name)}</h2>
779
+ <div class="path">{html.escape(rel)}</div>
780
+ {prims_html}
781
+ <section class="failed-features">
782
+ <h3>Failed features</h3>
783
+ {failed_html}
784
+ </section>
785
+ <section class="passed-features">
786
+ <h3>Passed features</h3>
787
+ {passed_html}
788
+ </section>{extras_html}
789
+ </div>
790
+ </div>"""
791
+
792
+
793
+ def _render_compliance(results: list[dict], doc_url=None, target: Path | None = None) -> str:
794
+ """Footer listing every in-profile requirement code that failed, with its
795
+ short message and (when available) a link to the foundation spec doc
796
+ that defines it. Codes outside the loaded profile are NOT listed here —
797
+ the report scope is the chosen spec only; out-of-spec failure counts
798
+ surface in the Caveats panel instead. Codes without a `path` get a
799
+ "(no spec source)" tag so the accountability gap is visible."""
800
+ # Build the set of in-profile failure codes from `failed_features`.
801
+ # Anything not in this set is an "extra" outside the loaded profile and
802
+ # is intentionally suppressed from the per-asset and footer views.
803
+ profile_failure_codes: set[str] = set()
804
+ for r in results:
805
+ for ff in r.get("failed_features", []):
806
+ profile_failure_codes.update(ff.get("failed_codes", []))
807
+ code_to_entry: dict[str, dict] = {}
808
+ for r in results:
809
+ for issue in r.get("issues", []):
810
+ if issue["severity"] not in ("failure", "error"):
811
+ continue
812
+ code = issue.get("code") or "UNKNOWN"
813
+ if code not in profile_failure_codes:
814
+ continue
815
+ if code not in code_to_entry:
816
+ code_to_entry[code] = {
817
+ "msg": issue.get("msg", ""),
818
+ "path": issue.get("path"),
819
+ }
820
+ if not code_to_entry:
821
+ return ""
822
+
823
+ items_html_parts: list[str] = []
824
+ accounted = 0
825
+ for code, e in sorted(code_to_entry.items()):
826
+ url = doc_url(e["path"]) if doc_url else None
827
+ if url:
828
+ code_html = f'<a class="code" href="{html.escape(url)}" target="_blank" rel="noopener">{html.escape(code)}</a>'
829
+ accounted += 1
830
+ no_spec_tag = ""
831
+ else:
832
+ code_html = f'<span class="code">{html.escape(code)}</span>'
833
+ no_spec_tag = ' <span class="no-spec">(no spec source)</span>'
834
+ msg = _relativize_msg(e["msg"], target)
835
+ items_html_parts.append(
836
+ f'<li>{code_html}: {html.escape(msg) if msg else "No guidance available."}{no_spec_tag}</li>'
837
+ )
838
+ items_html = "".join(items_html_parts)
839
+ coverage = (
840
+ f'<p class="muted">{accounted} of {len(code_to_entry)} requirement '
841
+ f'code(s) link to a foundation spec source. The rest are flagged '
842
+ f'<em>(no spec source)</em>.</p>'
843
+ )
844
+ return f"""
845
+ <section class="compliance">
846
+ <h2>How to comply with failed requirements</h2>
847
+ <p class="muted">Each requirement code below is shown with the first message recorded for it.</p>
848
+ {coverage}
849
+ <ul class="compliance-list">{items_html}</ul>
850
+ </section>"""
tools/validation/plugins/simready-report/skills/simready-report/validate.py ADDED
@@ -0,0 +1,1243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SimReady asset validator — entry point for the /simready-report skill.
2
+
3
+ Usage:
4
+ python validate.py <target_dir> [--profile NAME] [--version X.Y.Z] [--output DIR]
5
+
6
+ Discovers every USD file under <target_dir>, validates each against the named
7
+ profile via omni.asset_validator, walks composition arcs for external deps,
8
+ and writes the dashboard + provenance reports into <output> (default
9
+ <target_dir>/validation_output).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tomllib
20
+ from concurrent.futures import ProcessPoolExecutor, as_completed
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ SKILL_DIR = Path(__file__).resolve().parent
26
+
27
+ # Profiles whose validation rules need a real Kit runtime (PhysxSchema +
28
+ # Kit's MDL search path). Used both by the early Kit re-exec gate and by
29
+ # the auto-default in main(). Keep these in sync.
30
+ _PHYSX_PROFILES = frozenset({
31
+ "Robot-Body-Physx", "Robot-Body-Isaac", "Robot-Body-Runnable",
32
+ "Prop-Robotics-Physx", "Prop-Robotics-Isaac",
33
+ })
34
+
35
+
36
+ def _sniff_arg(argv: list[str], name: str, default: str | None = None) -> str | None:
37
+ """Pull `--<name> <value>` out of argv without argparse. Returns default if absent."""
38
+ flag = f"--{name}"
39
+ if flag in argv:
40
+ i = argv.index(flag)
41
+ if i + 1 < len(argv):
42
+ return argv[i + 1]
43
+ return default
44
+
45
+
46
+ # ---- Kit re-exec gate ---------------------------------------------------
47
+ # When --use-kit is on and we're not already inside a Kit-rooted process,
48
+ # re-exec this script through _kit_wrapper.py using the Kit-rooted Python.
49
+ # Must run BEFORE any pxr / omni.asset_validator import attempt below
50
+ # (otherwise _check_bootstrap fires against the pip venv first). Argparse
51
+ # is too heavy here — sniff sys.argv directly.
52
+ def _maybe_reexec_under_kit() -> None:
53
+ if __name__ != "__main__":
54
+ return
55
+ if os.environ.get("SIMREADY_INSIDE_KIT") == "1":
56
+ return
57
+ # Explicit opt-in/out takes precedence. Otherwise auto-enable for
58
+ # PhysX-bearing profiles. Mirror this in main() — keep _PHYSX_PROFILES
59
+ # the source of truth.
60
+ if "--no-use-kit" in sys.argv:
61
+ return
62
+ auto = False
63
+ if "--use-kit" not in sys.argv:
64
+ profile = _sniff_arg(sys.argv, "profile", default="Robot-Body-Runnable")
65
+ if profile in _PHYSX_PROFILES:
66
+ print(f"[skill] auto-enabled --use-kit for PhysX-bearing profile "
67
+ f"{profile}; pass --no-use-kit to override.", flush=True)
68
+ auto = True
69
+ else:
70
+ return
71
+ # Resolve the Kit-rooted Python.
72
+ kit_python = os.environ.get("SIMREADY_KIT_PYTHON") or ""
73
+ if not kit_python:
74
+ # Best-known default on this team's machines (Isaac Sim 6 source build).
75
+ default = r"C:\isaacsim6\_build\windows-x86_64\release\python.bat"
76
+ if Path(default).is_file():
77
+ kit_python = default
78
+ # Allow --kit-python <path> override (sniff without argparse).
79
+ if "--kit-python" in sys.argv:
80
+ i = sys.argv.index("--kit-python")
81
+ if i + 1 < len(sys.argv):
82
+ kit_python = sys.argv[i + 1]
83
+ if not kit_python or not Path(kit_python).is_file():
84
+ print(
85
+ "ERROR: --use-kit requested but no Kit-rooted Python found.\n"
86
+ " Set SIMREADY_KIT_PYTHON to an Isaac Sim 6 python.bat,\n"
87
+ " or pass --kit-python <path>.",
88
+ flush=True,
89
+ )
90
+ sys.exit(2)
91
+ # Drop --use-kit and --kit-python from the child's argv. The child runs
92
+ # validate.py via _kit_wrapper.py, which sets SIMREADY_INSIDE_KIT=1 so
93
+ # this gate is a no-op the second time around.
94
+ child_argv: list[str] = []
95
+ skip_next = False
96
+ for a in sys.argv[1:]:
97
+ if skip_next:
98
+ skip_next = False
99
+ continue
100
+ if a == "--use-kit":
101
+ continue
102
+ if a == "--kit-python":
103
+ skip_next = True
104
+ continue
105
+ child_argv.append(a)
106
+ wrapper = SKILL_DIR / "_kit_wrapper.py"
107
+ print(f" --use-kit: re-exec via {kit_python}", flush=True)
108
+ print(f" (SimulationApp boot — expect 10-30s of Kit startup)", flush=True)
109
+ import subprocess
110
+ rc = subprocess.call([kit_python, str(wrapper), *child_argv])
111
+ sys.exit(rc)
112
+
113
+
114
+ _maybe_reexec_under_kit()
115
+
116
+
117
+ def _find_workspace() -> Path | None:
118
+ """Locate the simready-playbook workspace by walking up from this script.
119
+
120
+ The workspace is identified by a `BOT.md` marker at its root. This works
121
+ when the skill is loaded from the cloned playbook (the historical layout
122
+ AND the new plugin-in-repo layout). When the plugin is installed via
123
+ marketplace into a user-global location, no `BOT.md` is in the ancestry,
124
+ so this returns None and the script falls back to CWD-anchored defaults.
125
+ """
126
+ for ancestor in SKILL_DIR.parents:
127
+ if (ancestor / "BOT.md").is_file():
128
+ return ancestor
129
+ return None
130
+
131
+
132
+ WORKSPACE_ROOT = _find_workspace()
133
+ # Reports default to <target>/.reports/<name>--<profile>/ — i.e. dropped
134
+ # next to the assets they describe. The `.reports/` prefix starts with `.`
135
+ # so `discover_assets` automatically skips it on subsequent runs even when
136
+ # the report dir lives inside the target. The default is computed in main()
137
+ # from the *pre-packaging* target so the report ends up where the user
138
+ # pointed, not under packages/.
139
+ DEFAULT_TARGET_DIR = (WORKSPACE_ROOT / "assets_to_validate") if WORKSPACE_ROOT else None
140
+ # Foundation specs + SDK checkouts: env var first; otherwise sibling-of-workspace
141
+ # fallback (the layout `bootstrap.ps1` produces). When WORKSPACE_ROOT is None,
142
+ # only the env vars matter — there's nothing to fall back to.
143
+ _specs_env = os.environ.get("SIMREADY_FOUNDATIONS_PATH")
144
+ _sdk_env = os.environ.get("SIMREADY_SDK_PATH")
145
+ SPECS_DIR = Path(_specs_env) if _specs_env else (
146
+ WORKSPACE_ROOT.parent / "simready_foundations" if WORKSPACE_ROOT else None
147
+ )
148
+ SDK_DIR = Path(_sdk_env) if _sdk_env else (
149
+ WORKSPACE_ROOT.parent / "simready-oem-sdk-poc" if WORKSPACE_ROOT else None
150
+ )
151
+ # Optional reference dashboard checkout — used only for its `_static/` (theme
152
+ # CSS/JS/fonts) so generated doc pages match the reference look. Resolution order:
153
+ # 1. SIMREADY_DASHBOARD_PATH env var (points at the inner `.../docs` dir or
154
+ # the outer `usd_validation_dashboard_final/` — both forms accepted)
155
+ # 2. sibling-of-workspace (what bootstrap.ps1 produces)
156
+ # 3. ~/Downloads/usd_validation_dashboard_final/usd_validation_dashboard_final/docs
157
+ def _resolve_dashboard_docs_dir() -> Path | None:
158
+ env = os.environ.get("SIMREADY_DASHBOARD_PATH")
159
+ candidates: list[Path] = []
160
+ if env:
161
+ p = Path(env)
162
+ # Accept both <root>/usd_validation_dashboard_final/docs and the docs dir directly.
163
+ candidates += [p, p / "docs", p / "usd_validation_dashboard_final" / "docs"]
164
+ if WORKSPACE_ROOT:
165
+ candidates.append(WORKSPACE_ROOT.parent / "usd_validation_dashboard_final" / "usd_validation_dashboard_final" / "docs")
166
+ home = Path.home()
167
+ candidates.append(home / "Downloads" / "usd_validation_dashboard_final" / "usd_validation_dashboard_final" / "docs")
168
+ for c in candidates:
169
+ if c and c.is_dir() and (c / "_static").is_dir():
170
+ return c
171
+ return None
172
+
173
+
174
+ DASHBOARD_DOCS_DIR = _resolve_dashboard_docs_dir()
175
+
176
+
177
+
178
+ # Default to loading only the OAV plugin; SimReadyPlugin's entry-point
179
+ # isn't needed because we populate the registries from on-disk paths via
180
+ # `simready.validate.impl.loader.load_validation_implementation` (matching
181
+ # the official simready-validate CLI). Pass `--use-plugin` to opt back in
182
+ # to the entry-point flow (requires the simready-foundation-core editable
183
+ # install — kept as a fallback during the GitHub migration).
184
+ os.environ.setdefault(
185
+ "OMNI_ASSET_VALIDATOR_ISOLATE_ENTRYPOINTS",
186
+ "omni.asset_validator:DefaultPlugin",
187
+ )
188
+ if "--use-plugin" in sys.argv:
189
+ os.environ["OMNI_ASSET_VALIDATOR_ISOLATE_ENTRYPOINTS"] = (
190
+ "omni.asset_validator:DefaultPlugin,simready.foundation.core:SimReadyPlugin"
191
+ )
192
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
193
+ if SPECS_DIR and SPECS_DIR.is_dir():
194
+ os.environ.setdefault("SIMREADY_SPECS_PATH", str(SPECS_DIR))
195
+
196
+
197
+ def _check_bootstrap() -> None:
198
+ """Verify the runtime can do its job before importing foundation modules.
199
+
200
+ What matters is that the Python imports work and the foundation specs are
201
+ findable; we don't care *where* they came from (sibling venv, system
202
+ Python, an active venv, conda, etc.). Missing pieces are reported with
203
+ actionable hints rather than a raw ImportError.
204
+ """
205
+ missing: list[str] = []
206
+ try:
207
+ import omni.asset_validator # noqa: F401
208
+ import pxr # noqa: F401
209
+ import markdown_it # noqa: F401
210
+ import simready.validate.impl.loader # noqa: F401 -- the CLI loader the default path depends on
211
+ except ImportError as e:
212
+ missing.append(
213
+ f"Python package not importable: {e.name or e}. "
214
+ f"Install runtime deps: pip install usd-core omniverse-asset-validator "
215
+ f"markdown-it-py 'simready-validate>=2026.4.8'."
216
+ )
217
+ if SPECS_DIR is None or not SPECS_DIR.is_dir():
218
+ missing.append(
219
+ "simready_foundations checkout not findable"
220
+ + (f" at {SPECS_DIR}" if SPECS_DIR else "")
221
+ + ". Clone github.com/NVIDIA/simready-foundation (or -staging) and set "
222
+ "SIMREADY_FOUNDATIONS_PATH to the checkout."
223
+ )
224
+
225
+ if missing:
226
+ print("ERROR: runtime prerequisites missing:", flush=True)
227
+ for m in missing:
228
+ print(f" - {m}", flush=True)
229
+ print("", flush=True)
230
+ if os.environ.get("SIMREADY_INSIDE_KIT") == "1":
231
+ print(
232
+ "Running inside Kit (SIMREADY_INSIDE_KIT=1) but the validator stack "
233
+ "isn't installed in this Kit-rooted Python.\n"
234
+ f"Install once with: \"{sys.executable}\" -m pip install omniverse-asset-validator markdown-it-py "
235
+ "-e <foundations>/nv_core/sr_specs (and -e <sdk> if auto-packaging is needed).",
236
+ flush=True,
237
+ )
238
+ elif WORKSPACE_ROOT is not None:
239
+ bootstrap = WORKSPACE_ROOT / "bootstrap.ps1"
240
+ print(f"On Windows, the canned setup is: & '{bootstrap}'", flush=True)
241
+ print("Install the runtime deps (usd-core, omniverse-asset-validator, "
242
+ "markdown-it-py) into your active Python and set SIMREADY_FOUNDATIONS_PATH "
243
+ "(and optionally SIMREADY_SDK_PATH) to existing checkouts.", flush=True)
244
+ raise SystemExit(2)
245
+
246
+
247
+ _check_bootstrap()
248
+
249
+ from pxr import Usd # noqa: E402 (deferred until _check_bootstrap verifies env)
250
+ import omni.asset_validator as oav # noqa: E402
251
+
252
+ import report # noqa: E402
253
+ import external_deps # noqa: E402
254
+
255
+
256
+ def _patch_register_json_variant_features() -> None:
257
+ """LOCAL PATCH for PROBLEMS.md P1 — remove once foundations codegen is fixed.
258
+
259
+ The foundations `repo usd_profiles_codegen` tool only emits Feature
260
+ entries from each `.md` file's declared `Internal ID`, missing the
261
+ JSON variant feature definitions next to the markdown
262
+ (`<features>/FET_*.json`). This patch does two things:
263
+
264
+ 1. Read every `FET_*.json` and register each variant in
265
+ `omni.asset_validator.FeatureRegistry()` (skipping ones already
266
+ registered by codegen).
267
+ 2. Re-resolve every profile's feature list against the now-fuller
268
+ registry. Profiles are built once at plugin-load time with
269
+ eager feature resolution, so features added after the fact
270
+ never join existing Profile objects unless we mutate them
271
+ directly. `Profile` is a frozen dataclass but `.features` is a
272
+ mutable list, so we append in place.
273
+
274
+ Step 2 is what actually makes profiles like `Robot-Body-Runnable`
275
+ resolve their `_ROBOT_PHYSX` / `_RUNNABLE` / `_DRIVEN_JOINTS_PHYSX`
276
+ entries. Without it, registering features alone is invisible to
277
+ the validator.
278
+ """
279
+ if SPECS_DIR is None or not SPECS_DIR.is_dir():
280
+ return
281
+ features_dir = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "features"
282
+ profiles_toml = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "profiles" / "profiles.toml"
283
+ if not features_dir.is_dir() or not profiles_toml.is_file():
284
+ return
285
+
286
+ # Force plugin init so codegen-derived features + profiles are in
287
+ # their respective registries first; we additively patch on top.
288
+ fr = oav.FeatureRegistry()
289
+ pr = oav.ProfileRegistry()
290
+ rr = oav.RequirementsRegistry()
291
+ code_to_req = {r.code: r for r in rr.values() if getattr(r, "code", None)}
292
+
293
+ @dataclass(frozen=True)
294
+ class _PatchedFeature:
295
+ id: str
296
+ version: str
297
+ path: str
298
+ requirements: list
299
+
300
+ # ---- Step 1: register JSON variant features ----
301
+ added = 0
302
+ skipped_existing = 0
303
+ unresolved_codes: dict[str, list[str]] = {}
304
+ # Glob is intentionally permissive — JSON variant filenames in this
305
+ # repo use a mix of `FET_021-robot_core_isaac-0.1.0.json`,
306
+ # `FET_021_robot_core_runnable_0.2.0.json`, and
307
+ # `FET_004_base_physx-0.2.0-simulate_multi_body_phyics.json` styles.
308
+ # We rely on `id` + `version` from the JSON content, not the filename.
309
+ for jf in sorted(features_dir.glob("FET_*.json")):
310
+ try:
311
+ data = json.loads(jf.read_text(encoding="utf-8"))
312
+ except Exception:
313
+ continue
314
+ fid = data.get("id")
315
+ fver = data.get("version")
316
+ if not fid or not fver:
317
+ continue
318
+ if fr.find(fid, fver) is not None:
319
+ skipped_existing += 1
320
+ continue
321
+ resolved, missing = [], []
322
+ for code in data.get("requirements", []):
323
+ if code in code_to_req:
324
+ resolved.append(code_to_req[code])
325
+ else:
326
+ missing.append(code)
327
+ if missing:
328
+ unresolved_codes[f"{fid} v{fver}"] = missing
329
+ continue
330
+ feat = _PatchedFeature(
331
+ id=fid, version=fver, path=data.get("path", ""), requirements=resolved
332
+ )
333
+ try:
334
+ fr.add(feat)
335
+ added += 1
336
+ except Exception:
337
+ pass
338
+
339
+ # ---- Step 2: re-resolve each profile's feature list ----
340
+ try:
341
+ with profiles_toml.open("rb") as fh:
342
+ toml_data = tomllib.load(fh)
343
+ except Exception:
344
+ toml_data = {}
345
+ profiles_patched = 0
346
+ profile_features_appended = 0
347
+ for profile_id, versions in toml_data.items():
348
+ if not isinstance(versions, dict):
349
+ continue
350
+ for ver, body in versions.items():
351
+ if not isinstance(body, dict) or "features" not in body:
352
+ continue
353
+ try:
354
+ profile = pr.find(profile_id, ver)
355
+ except Exception:
356
+ continue
357
+ if profile is None:
358
+ continue
359
+ existing = {(f.id, str(f.version)) for f in profile.features}
360
+ appended_here = 0
361
+ for entry in body["features"]:
362
+ if not isinstance(entry, dict) or len(entry) != 1:
363
+ continue
364
+ fid, meta = next(iter(entry.items()))
365
+ fver_decl = str((meta or {}).get("version", ""))
366
+ if (fid, fver_decl) in existing:
367
+ continue
368
+ feat = fr.find(fid, fver_decl)
369
+ if feat is None:
370
+ continue
371
+ profile.features.append(feat) # mutate the list inside the frozen dataclass
372
+ appended_here += 1
373
+ if appended_here:
374
+ profiles_patched += 1
375
+ profile_features_appended += appended_here
376
+
377
+ if added or unresolved_codes or profiles_patched:
378
+ msg = (
379
+ f" [PATCH P1] FeatureRegistry: +{added} JSON-variant feature(s) "
380
+ f"({skipped_existing} already registered, "
381
+ f"{len(unresolved_codes)} skipped on missing requirement codes); "
382
+ f"ProfileRegistry: rebuilt feature lists for {profiles_patched} profile(s) "
383
+ f"(+{profile_features_appended} feature(s))."
384
+ )
385
+ print(msg, flush=True)
386
+
387
+
388
+ # P1 patch is no longer applied unconditionally. It runs only when
389
+ # --use-plugin is set (the legacy SimReadyPlugin entry-point path). The
390
+ # default CLI-loader path populates the registry from JSON variant files
391
+ # natively, making this patch redundant. Kept callable here so workers
392
+ # can invoke it through _pool_init when the legacy path is selected.
393
+
394
+
395
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
396
+ log = logging.getLogger("validate")
397
+
398
+ USD_EXTS = (".usd", ".usda", ".usdc", ".usdz")
399
+ SEVERITY_NAMES = {
400
+ oav.IssueSeverity.ERROR: "error",
401
+ oav.IssueSeverity.FAILURE: "failure",
402
+ oav.IssueSeverity.WARNING: "warning",
403
+ }
404
+
405
+
406
+ def _find_simready_cli() -> str | None:
407
+ """Locate the `simready` CLI in a layout-agnostic way.
408
+
409
+ Order: (1) on PATH, (2) in the same bin/ or Scripts/ dir as `sys.executable`
410
+ — covers any active venv/conda regardless of OS.
411
+ """
412
+ on_path = shutil.which("simready")
413
+ if on_path:
414
+ return on_path
415
+ py_bin = Path(sys.executable).parent
416
+ for candidate in (py_bin / "simready.exe", py_bin / "simready"):
417
+ if candidate.is_file():
418
+ return str(candidate)
419
+ return None
420
+
421
+
422
+ def maybe_package_target(target: Path) -> Path:
423
+ """If `target` is a raw asset set under assets_to_validate/, run
424
+ `simready ingest usd` per interface file into packages/<name>/ and
425
+ return the packaged path. Otherwise return `target` unchanged.
426
+
427
+ A raw set is detected by the path containing `/assets_to_validate/`.
428
+ The packaged tree is built only if absent (idempotent). Each subfolder
429
+ of the raw set is treated as one asset; the interface file is the USD
430
+ inside it whose stem matches the folder name (case-insensitive,
431
+ `-`/`_` normalized), or — if there's exactly one USD — that one.
432
+ """
433
+ target_str = str(target).replace("\\", "/")
434
+ if "/assets_to_validate/" not in target_str:
435
+ return target
436
+
437
+ name = target.name
438
+ # Anchor the packaged tree to the workspace when present; otherwise to the
439
+ # parent of the assets_to_validate/ dir so the layout is preserved without
440
+ # requiring a workspace.
441
+ if WORKSPACE_ROOT is not None:
442
+ packaged = WORKSPACE_ROOT / "packages" / name
443
+ else:
444
+ # target is <X>/assets_to_validate/<name>; sibling-of-X for packages.
445
+ packaged = target.parent.parent / "packages" / name
446
+ packaged.mkdir(parents=True, exist_ok=True)
447
+
448
+ simready_exe = _find_simready_cli()
449
+ if simready_exe is None:
450
+ print(" WARNING: `simready` CLI not found on PATH or in the active Python's bin/ "
451
+ "directory. Cannot auto-package; validating the raw tree instead.", flush=True)
452
+ return target
453
+
454
+ import subprocess
455
+ print(f" auto-package: {target} -> {packaged}", flush=True)
456
+ interface_files = []
457
+ for sub in sorted(target.iterdir()):
458
+ if not sub.is_dir() or sub.name.startswith((".", "_")):
459
+ continue
460
+ usds = [p for ext in USD_EXTS for p in sub.glob(f"*{ext}")]
461
+ if not usds:
462
+ continue
463
+ if len(usds) == 1:
464
+ interface_files.append(usds[0])
465
+ continue
466
+ named = [p for p in usds if p.stem.lower() == sub.name.lower().replace("-", "_")]
467
+ if named:
468
+ interface_files.append(named[0])
469
+ else:
470
+ interface_files.extend(usds)
471
+
472
+ env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
473
+ packaged_count = 0
474
+ for usd in interface_files:
475
+ already = packaged / usd.stem / usd.name
476
+ if already.is_file():
477
+ continue
478
+ cmd = [simready_exe, "ingest", "usd", str(usd),
479
+ "-o", str(packaged), "-p", "Robot-Body-Runnable", "--no-validate"]
480
+ try:
481
+ subprocess.run(cmd, env=env, check=True, capture_output=True, text=True, timeout=120)
482
+ packaged_count += 1
483
+ except subprocess.CalledProcessError as e:
484
+ print(f" WARNING: simready ingest failed for {usd.name}: {e.stderr[-300:] if e.stderr else e}", flush=True)
485
+ if packaged_count:
486
+ print(f" auto-package: {packaged_count} asset(s) packaged", flush=True)
487
+ return packaged
488
+
489
+
490
+ def resolve_target(arg: str | None) -> Path | None:
491
+ """Map the user's argument to a concrete asset directory.
492
+
493
+ Rules:
494
+ - No arg: use the current working directory.
495
+ - Absolute or existing relative path: use as-is.
496
+ - Bare name `<name>` (no path separators): try CWD/<name>, then the
497
+ workspace's `assets_to_validate/<name>` as a fallback for users still
498
+ using the original layout.
499
+ """
500
+ if arg is None:
501
+ cwd = Path.cwd().resolve()
502
+ print(f" target: {cwd} (cwd)", flush=True)
503
+ return cwd
504
+
505
+ candidate = Path(arg)
506
+ if candidate.is_absolute() and candidate.is_dir():
507
+ return candidate.resolve()
508
+ if candidate.is_dir():
509
+ return candidate.resolve()
510
+ # Bare name — try CWD-relative first, then the workspace's
511
+ # assets_to_validate/<arg> when running inside a playbook clone.
512
+ cwd_fallback = (Path.cwd() / arg).resolve()
513
+ if cwd_fallback.is_dir():
514
+ return cwd_fallback
515
+ workspace_fallback = DEFAULT_TARGET_DIR / arg if DEFAULT_TARGET_DIR else None
516
+ if workspace_fallback and workspace_fallback.is_dir():
517
+ return workspace_fallback.resolve()
518
+ print(f"ERROR: target dir does not exist: {arg}", flush=True)
519
+ print(f" Tried: {candidate.resolve()}", flush=True)
520
+ print(f" Tried: {cwd_fallback}", flush=True)
521
+ if workspace_fallback:
522
+ print(f" Tried: {workspace_fallback}", flush=True)
523
+ return None
524
+
525
+
526
+ def sanity_check_target(target: Path) -> list[str]:
527
+ """Refuse obviously-wrong invocations before any work happens.
528
+
529
+ Returns a list of human-readable problems. Empty list means OK.
530
+ Used to interrupt `/simready-report` (especially when it ran with no arg and
531
+ fell back to CWD) when the resolved target couldn't sensibly be an
532
+ asset directory.
533
+ """
534
+ problems: list[str] = []
535
+ target = target.resolve()
536
+
537
+ def _under(child: Path, parent: Path) -> bool:
538
+ try:
539
+ child.relative_to(parent)
540
+ return True
541
+ except ValueError:
542
+ return False
543
+
544
+ # Workspace-aware guards only apply when running inside a playbook clone.
545
+ if WORKSPACE_ROOT is not None:
546
+ workspace = WORKSPACE_ROOT.resolve()
547
+ if target == workspace:
548
+ problems.append(
549
+ f"target is the workspace root ({target}). "
550
+ f"cd into an asset directory or pass a path."
551
+ )
552
+ for guard in (".claude", "playbooks", "report", "_reports", "packages", "plugins"):
553
+ guard_path = (workspace / guard).resolve()
554
+ if target == guard_path or _under(target, guard_path):
555
+ problems.append(
556
+ f"target sits under <workspace>/{guard}/ ({target}). "
557
+ f"that's not an asset directory — pick a customer-asset folder."
558
+ )
559
+ break
560
+
561
+ # Refuse anything close to a drive root or user home — likely a stray cd.
562
+ if len(target.parts) <= 2:
563
+ problems.append(
564
+ f"target ({target}) is too high in the filesystem. "
565
+ f"cd into a specific asset directory before running /simready-report."
566
+ )
567
+
568
+ # Refuse if the target has no USDs at root and no USDs in immediate subfolders.
569
+ if target.is_dir():
570
+ root_usds = [p for ext in USD_EXTS for p in target.glob(f"*{ext}")]
571
+ sub_with_usds = 0
572
+ for sub in target.iterdir():
573
+ if not sub.is_dir() or sub.name.startswith((".", "_")):
574
+ continue
575
+ if any(sub.glob(f"*{ext}") for ext in USD_EXTS):
576
+ sub_with_usds += 1
577
+ if sub_with_usds >= 1:
578
+ break
579
+ if not root_usds and sub_with_usds == 0:
580
+ problems.append(
581
+ f"no USD files found in {target} or its immediate subfolders. "
582
+ f"`/simready-report` expects either a single asset directory containing "
583
+ f"<name>.usd, or a parent directory whose subfolders each contain "
584
+ f"an interface USD."
585
+ )
586
+
587
+ return problems
588
+
589
+
590
+ def discover_assets(root: Path, exclude: Path | None = None) -> list[Path]:
591
+ """Return one interface USD per top-level subdirectory of root.
592
+
593
+ Per the SimReady packaging spec, each asset lives under <root>/<name>/
594
+ with an interface file at <root>/<name>/<name>.usd (and sublayers under
595
+ configuration/). We validate only the interface file — never individual
596
+ config layers — per BOT.md.
597
+
598
+ Heuristic: in each top-level subfolder, take the single top-level USD if
599
+ there is one, otherwise the one whose stem matches the folder name.
600
+ """
601
+ found: list[Path] = []
602
+ for sub in sorted(root.iterdir()):
603
+ if not sub.is_dir() or sub.name.startswith(".") or sub.name.startswith("_"):
604
+ continue
605
+ if exclude is not None and (sub == exclude or exclude in sub.parents):
606
+ continue
607
+ if sub.name in ("validation_output", "validations", "report", "images"):
608
+ continue
609
+ usds = [p for ext in USD_EXTS for p in sub.glob(f"*{ext}")]
610
+ if not usds:
611
+ continue
612
+ if len(usds) == 1:
613
+ found.append(usds[0])
614
+ continue
615
+ named = [p for p in usds if p.stem.lower() == sub.name.lower().replace("-", "_")]
616
+ if named:
617
+ found.append(named[0])
618
+ else:
619
+ found.extend(usds)
620
+ return sorted(set(found))
621
+
622
+
623
+ # Issue rule + message patterns we drop because they're produced by the validator
624
+ # detecting that its own runtime is incomplete (PhysX schema not available,
625
+ # Kit-bundled MDL not resolvable in a pip-only venv), NOT by an asset defect.
626
+ # Documented in PROBLEMS.md P2.
627
+ _P2_PHYSX_RULES = {
628
+ "JointHasJointStateAPI",
629
+ "PhysicsDriveAndJointState",
630
+ "PhysicsJointHasDriveOrMimicAPI",
631
+ "MimicAPICheck",
632
+ "PhysicsJointMaxVelocity",
633
+ "DriveJointValueReasonable",
634
+ }
635
+ _P2_MDL_RULES = {
636
+ "MissingReferenceChecker",
637
+ "MaterialPathChecker",
638
+ "PathsExistChecker",
639
+ }
640
+
641
+
642
+ def _drop_env_blocked_issues(issues: list[dict]) -> tuple[list[dict], dict]:
643
+ """LOCAL PATCH for PROBLEMS.md P2 — drop env-blocked issues.
644
+
645
+ The PhysX rules raise `"PhysxSchema is not available in this environment"`
646
+ once per joint when `from pxr import PhysxSchema` fails (i.e. running in
647
+ a pip-only venv without Kit). The MDL checkers report `OmniPBR.mdl` as
648
+ unresolvable for the same reason — `OmniPBR.mdl` ships with Kit, not with
649
+ `usd-core`. Both are environment limitations, not asset defects, and the
650
+ asset's own SimReady_Metadata records that these features previously
651
+ passed in a Kit-rooted environment.
652
+
653
+ We only drop when the rule + message pattern match precisely, so real
654
+ findings from these same checkers (e.g. a different unresolvable
655
+ reference, or a genuine path that doesn't exist) are preserved.
656
+
657
+ Returns (kept_issues, dropped_counts) where dropped_counts is a dict like
658
+ `{"physxschema_unavailable": int, "omnipbr_unresolved": int}`.
659
+ """
660
+ kept: list[dict] = []
661
+ dropped = {"physxschema_unavailable": 0, "omnipbr_unresolved": 0}
662
+ for iss in issues:
663
+ rule = iss.get("rule") or ""
664
+ msg = iss.get("msg") or ""
665
+ if rule in _P2_PHYSX_RULES and msg.startswith("PhysxSchema is not available"):
666
+ dropped["physxschema_unavailable"] += 1
667
+ continue
668
+ if rule in _P2_MDL_RULES and "OmniPBR.mdl" in msg:
669
+ dropped["omnipbr_unresolved"] += 1
670
+ continue
671
+ kept.append(iss)
672
+ return kept, dropped
673
+
674
+
675
+ def issue_to_dict(iss) -> dict:
676
+ sev = SEVERITY_NAMES.get(iss.severity, str(iss.severity))
677
+ req = getattr(iss.requirement, "code", None) if iss.requirement else None
678
+ # Pull the requirement's spec-doc rel path so the dashboard can link every
679
+ # issue (including ones outside the loaded profile) back to the foundation
680
+ # markdown that defines it.
681
+ req_path = getattr(iss.requirement, "path", None) if iss.requirement else None
682
+ rule = iss.rule.__name__ if iss.rule else None
683
+ at = None
684
+ try:
685
+ if hasattr(iss.at, "GetPath"):
686
+ at = str(iss.at.GetPath())
687
+ except Exception:
688
+ pass
689
+ msg = iss.message if hasattr(iss, "message") else str(iss)
690
+ return {
691
+ "code": req or "UNKNOWN",
692
+ "path": req_path,
693
+ "severity": sev,
694
+ "msg": msg,
695
+ "prim": at or "/",
696
+ "rule": rule,
697
+ }
698
+
699
+
700
+ def compute_profile_coverage(profile_id: str, profile_version: str,
701
+ loaded_profile: oav.Profile) -> dict:
702
+ """Compare what `profiles.toml` declares for this profile against what
703
+ the validator actually loaded. The validator silently drops feature
704
+ entries whose ID + version aren't in the registry; this helper surfaces
705
+ that delta so the dashboard can warn the user.
706
+
707
+ Returns:
708
+ {
709
+ "declared": int, # feature count in profiles.toml
710
+ "loaded": int, # feature count on `loaded_profile`
711
+ "missing": [{"id": str, "version": str}, ...], # declared but not loaded
712
+ }
713
+ Or an empty `{"declared": None, ...}` shape if profiles.toml can't be
714
+ located (best-effort — coverage display is suppressed when unknown).
715
+ """
716
+ none_result = {"declared": None, "loaded": len(loaded_profile.features),
717
+ "missing": [], "specs_path": None}
718
+ if SPECS_DIR is None or not SPECS_DIR.is_dir():
719
+ return none_result
720
+ profiles_toml = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "profiles" / "profiles.toml"
721
+ if not profiles_toml.is_file():
722
+ return none_result
723
+ try:
724
+ with profiles_toml.open("rb") as fh:
725
+ data = tomllib.load(fh)
726
+ except Exception:
727
+ return none_result
728
+ block = data.get(profile_id, {}).get(profile_version)
729
+ if not block or "features" not in block:
730
+ return none_result
731
+ declared: list[dict] = []
732
+ for entry in block["features"]:
733
+ # Each entry is a single-key table: {"FET_ID": {"version": "X"}}.
734
+ if not isinstance(entry, dict) or len(entry) != 1:
735
+ continue
736
+ fid, meta = next(iter(entry.items()))
737
+ version = (meta or {}).get("version", "")
738
+ declared.append({"id": fid, "version": str(version)})
739
+ loaded_keys = {(f.id, str(f.version)) for f in loaded_profile.features}
740
+ missing = [d for d in declared if (d["id"], d["version"]) not in loaded_keys]
741
+ return {
742
+ "declared": len(declared),
743
+ "loaded": len(loaded_profile.features),
744
+ "missing": missing,
745
+ "specs_path": str(profiles_toml.relative_to(SPECS_DIR)) if SPECS_DIR else None,
746
+ }
747
+
748
+
749
+ def _load_via_cli_loader() -> None:
750
+ """Populate OAV registries by file paths, the way `simready-validate` does.
751
+
752
+ Used by `--use-cli-loader`. Mirrors what
753
+ `simready.validate.impl.loader.load_validation_implementation` does:
754
+ clears the OAV registries, then loads rules/requirements (Python),
755
+ features (JSON/TOML), and profiles (TOML) from filesystem paths under
756
+ the foundations checkout. No dependency on the
757
+ `simready.foundation.core:SimReadyPlugin` entry point.
758
+
759
+ The loader handles JSON variant feature files natively, so the P1
760
+ patch is redundant when this path is active (we just skip calling it
761
+ from the call-site).
762
+ """
763
+ if SPECS_DIR is None or not SPECS_DIR.is_dir():
764
+ raise SystemExit(
765
+ "ERROR: --use-cli-loader requires SIMREADY_FOUNDATIONS_PATH "
766
+ "(or a sibling-of-workspace simready_foundations/ checkout). "
767
+ "Nothing to load."
768
+ )
769
+ try:
770
+ from simready.validate.impl.loader import load_validation_implementation
771
+ except ImportError as e:
772
+ raise SystemExit(
773
+ f"ERROR: --use-cli-loader needs `simready-validate` installed in "
774
+ f"the active Python (the PyPI package, not just the editable "
775
+ f"foundations). pip install simready-validate>=2026.4.8 . "
776
+ f"Underlying: {e}"
777
+ )
778
+ rules_path = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "capabilities"
779
+ features_path = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "features"
780
+ profiles_toml = SPECS_DIR / "nv_core" / "sr_specs" / "docs" / "profiles" / "profiles.toml"
781
+ for p, label in ((rules_path, "rules"), (features_path, "features"), (profiles_toml, "profiles.toml")):
782
+ if not p.exists():
783
+ raise SystemExit(f"ERROR: --use-cli-loader: {label} path not found at {p}")
784
+ print(f" [CLI-loader] rules={rules_path}", flush=True)
785
+ print(f" [CLI-loader] features={features_path}", flush=True)
786
+ print(f" [CLI-loader] profiles={profiles_toml}", flush=True)
787
+ load_validation_implementation(
788
+ rules_and_requirements_paths=[rules_path],
789
+ features_paths=[features_path],
790
+ profiles_paths=[profiles_toml],
791
+ )
792
+ pr = oav.ProfileRegistry()
793
+ # The loader creates Profile objects with `.capabilities`; the rest of
794
+ # validate.py expects `.features` (the older OAV plugin API). Alias so
795
+ # the call sites keep working without a sweeping rename. Profile is a
796
+ # frozen dataclass, so bypass via object.__setattr__.
797
+ for profile in pr.values():
798
+ if not hasattr(profile, "features") and hasattr(profile, "capabilities"):
799
+ object.__setattr__(profile, "features", profile.capabilities)
800
+ cr = oav.CapabilityRegistry()
801
+ print(f" [CLI-loader] loaded: profiles={len(pr)} capabilities={len(cr)}", flush=True)
802
+
803
+
804
+ def build_engine(profile_id: str, profile_version: str) -> tuple[oav.ValidationEngine, oav.Profile]:
805
+ pr = oav.ProfileRegistry()
806
+ profile = pr.find(profile_id, profile_version)
807
+ if profile is None:
808
+ raise SystemExit(f"FATAL: profile {profile_id} v{profile_version} not registered")
809
+
810
+ # Engine runs every registered rule regardless of enable_*/disable_* state
811
+ # (those only adjust counters), so don't bother filtering the engine. We
812
+ # scope to the profile by filtering issues against profile requirements
813
+ # in validate_one / derive_features.
814
+ engine = oav.ValidationEngine(init_rules=True, variants=False)
815
+ return engine, profile
816
+
817
+
818
+ # ---- Parallel-validation worker plumbing ---------------------------------
819
+ # Each worker process initializes its own engine + profile once via
820
+ # `_pool_init`, then handles N tasks via `_pool_validate`. The engine isn't
821
+ # picklable so we can't share a single one across processes; instead the
822
+ # init cost is amortized over multiple assets per worker.
823
+
824
+ _pool_engine: oav.ValidationEngine | None = None
825
+ _pool_profile: oav.Profile | None = None
826
+
827
+
828
+ def _pool_init(profile_id: str, profile_version: str, use_plugin: bool = False) -> None:
829
+ global _pool_engine, _pool_profile
830
+ if use_plugin:
831
+ _patch_register_json_variant_features()
832
+ else:
833
+ _load_via_cli_loader()
834
+ _pool_engine, _pool_profile = build_engine(profile_id, profile_version)
835
+
836
+
837
+ def _pool_validate(asset_path_str: str, target_root_str: str,
838
+ profile_id: str, profile_version: str) -> tuple[dict, dict, list]:
839
+ """Validate one asset in a worker process.
840
+
841
+ Returns: (result_dict, external_records_by_identifier, issue_records).
842
+ The tracker pieces are returned (not the live tracker) so they can be
843
+ merged into the main process's tracker without sharing state.
844
+ """
845
+ asset = Path(asset_path_str)
846
+ target_root = Path(target_root_str)
847
+ tracker = external_deps.ExternalDepsTracker(target_root=target_root)
848
+ try:
849
+ result = validate_one(_pool_engine, asset, target_root, _pool_profile,
850
+ profile_id, profile_version, tracker)
851
+ except Exception as e:
852
+ rel = asset.relative_to(target_root) if asset.is_relative_to(target_root) else asset
853
+ tracker.record_issue(str(asset), f"Validator crashed: {type(e).__name__}: {e}")
854
+ result = {
855
+ "name": asset.name, "path": str(asset),
856
+ "rel_path": str(rel),
857
+ "profile": profile_id, "profile_version": profile_version,
858
+ "passed": False,
859
+ "issues": [{"code": "SDK.CRASH", "severity": "error",
860
+ "msg": f"{type(e).__name__}: {e}", "prim": "/", "rule": None}],
861
+ "passed_features": [], "failed_features": [], "affected_prims": [],
862
+ }
863
+ return result, dict(tracker.external), list(tracker.issues)
864
+
865
+
866
+ def _merge_tracker(dst: external_deps.ExternalDepsTracker,
867
+ ext_records: dict, issues: list) -> None:
868
+ """Fold a worker's tracker state into the main tracker."""
869
+ for key, rec in ext_records.items():
870
+ existing = dst.external.get(key)
871
+ if existing is None:
872
+ dst.external[key] = rec
873
+ else:
874
+ for ref in rec.referenced_by:
875
+ if ref not in existing.referenced_by:
876
+ existing.referenced_by.append(ref)
877
+ for action in rec.actions:
878
+ if action not in existing.actions:
879
+ existing.actions.append(action)
880
+ dst.issues.extend(issues)
881
+
882
+
883
+ def validate_one(engine: oav.ValidationEngine, asset_path: Path, target_root: Path,
884
+ profile: oav.Profile, profile_id: str, profile_version: str,
885
+ ext_tracker: external_deps.ExternalDepsTracker) -> dict:
886
+ rel = asset_path.relative_to(target_root) if asset_path.is_relative_to(target_root) else asset_path
887
+ name = asset_path.name
888
+ print(f" validate: {rel}", flush=True)
889
+
890
+ stage = Usd.Stage.Open(str(asset_path))
891
+ if not stage:
892
+ ext_tracker.record_issue(str(asset_path), "Failed to open USD stage")
893
+ return {
894
+ "name": name,
895
+ "path": str(asset_path),
896
+ "rel_path": str(rel),
897
+ "profile": profile_id,
898
+ "profile_version": profile_version,
899
+ "passed": False,
900
+ "issues": [{"code": "SDK.STAGE_OPEN", "severity": "error",
901
+ "msg": f"Failed to open USD stage: {asset_path}", "prim": "/", "rule": None}],
902
+ "passed_features": [],
903
+ "failed_features": [],
904
+ "affected_prims": [],
905
+ }
906
+
907
+ ext_tracker.scan_stage(stage, asset_path, target_root)
908
+
909
+ profile_codes = {r.code for f in profile.features for r in f.requirements}
910
+
911
+ oav_results = engine.validate(stage)
912
+ issues = [issue_to_dict(i) for i in oav_results.issues()]
913
+ issues, env_blocked = _drop_env_blocked_issues(issues)
914
+ if any(env_blocked.values()):
915
+ parts = [f"{n} {k}" for k, n in env_blocked.items() if n]
916
+ print(f" [PATCH P2] env-blocked: dropped " + ", ".join(parts), flush=True)
917
+ all_failures = [i for i in issues if i["severity"] in ("failure", "error")]
918
+ profile_failures = [i for i in all_failures if i["code"] in profile_codes]
919
+ extra_failures = [i for i in all_failures if i["code"] not in profile_codes]
920
+ # An asset passes only if no profile requirement fails. Issues from rules
921
+ # outside the loaded profile (per deviations.md #3) are reported separately
922
+ # but don't drive the pass/fail flag.
923
+ passed = not profile_failures
924
+
925
+ failed_codes = sorted({i["code"] for i in profile_failures})
926
+ passed_features, failed_features = derive_features(profile, failed_codes)
927
+
928
+ # Bucket extra failures by code -> example message + count + spec path,
929
+ # for the "outside-profile" panel in the report. The path lets the
930
+ # dashboard link each code back to its foundation markdown.
931
+ extra_by_code: dict[str, dict] = {}
932
+ for i in extra_failures:
933
+ bucket = extra_by_code.setdefault(i["code"], {
934
+ "code": i["code"],
935
+ "msg": i["msg"],
936
+ "path": i.get("path"),
937
+ "count": 0,
938
+ })
939
+ bucket["count"] += 1
940
+ extra_failures_summary = sorted(extra_by_code.values(), key=lambda x: -x["count"])
941
+
942
+ affected_prims = sorted({i["prim"] for i in all_failures if i["prim"] and i["prim"] != "/"})
943
+
944
+ return {
945
+ "name": name,
946
+ "path": str(asset_path),
947
+ "rel_path": str(rel),
948
+ "profile": profile_id,
949
+ "profile_version": profile_version,
950
+ "passed": passed,
951
+ "issues": issues,
952
+ "passed_features": passed_features,
953
+ "failed_features": failed_features,
954
+ "extra_failures": extra_failures_summary,
955
+ "affected_prims": affected_prims,
956
+ "env_blocked": env_blocked,
957
+ }
958
+
959
+
960
+ def derive_features(profile: oav.Profile, failed_codes: list[str]) -> tuple[list[dict], list[dict]]:
961
+ """Map profile features to passed/failed feature entries based on requirement codes."""
962
+ passed: list[dict] = []
963
+ failed: list[dict] = []
964
+ for feat in profile.features:
965
+ feat_path = getattr(feat, "path", None)
966
+ req_path_by_code = {r.code: getattr(r, "path", None) for r in feat.requirements}
967
+ feat_codes = sorted(req_path_by_code.keys())
968
+ feat_failed = [c for c in feat_codes if c in failed_codes]
969
+ entry = {
970
+ "id": feat.id,
971
+ "version": str(feat.version),
972
+ "path": feat_path,
973
+ "requirement_codes": feat_codes,
974
+ "requirement_paths": req_path_by_code,
975
+ }
976
+ if feat_failed:
977
+ entry["failed_codes"] = feat_failed
978
+ failed.append(entry)
979
+ else:
980
+ passed.append(entry)
981
+ return passed, failed
982
+
983
+
984
+ def main() -> int:
985
+ ap = argparse.ArgumentParser(description="Validate SimReady customer assets in a directory.")
986
+ ap.add_argument("target", nargs="?", default=None,
987
+ help=f"Directory containing customer assets (default: {DEFAULT_TARGET_DIR})")
988
+ ap.add_argument("--profile", default="Robot-Body-Runnable", help="Profile ID (default: Robot-Body-Runnable)")
989
+ ap.add_argument("--version", default="1.0.0", help="Profile version (default: 1.0.0)")
990
+ ap.add_argument("--output", default=None,
991
+ help="Output dir (default: <target>/.reports/<target-name>.<profile>)")
992
+ ap.add_argument("--workers", type=int, default=0,
993
+ help="Number of parallel worker processes. 0=auto "
994
+ "(min(cpu_count, asset_count, 8)); 1=sequential.")
995
+ ap.add_argument("--list-profiles", action="store_true",
996
+ help="Print every registered profile (one `PROFILE: <id> v<version>` line each), "
997
+ "then exit. Used by /simready-report to drive the spec selector before validating.")
998
+ ap.add_argument("--use-kit", action=argparse.BooleanOptionalAction, default=None,
999
+ help="Re-exec this script through a Kit-rooted Python (Isaac Sim 6) via "
1000
+ "_kit_wrapper.py so `pxr.PhysxSchema` and Kit's MDL search path are "
1001
+ "registered for real, instead of the pip-only venv falling back to "
1002
+ "the P2 filter. Adds ~10-30s of SimulationApp boot to the main process "
1003
+ "(plus per worker, so prefer --workers 1 in Kit mode). Default: auto-enabled "
1004
+ "for PhysX-bearing profiles (Robot-Body-Physx/Isaac/Runnable, "
1005
+ "Prop-Robotics-Physx/Isaac); pass --no-use-kit to override. "
1006
+ "See PROBLEMS.md P2.")
1007
+ ap.add_argument("--kit-python", default=None,
1008
+ help="Path to the Kit-rooted python.bat used by --use-kit. "
1009
+ "Defaults to $SIMREADY_KIT_PYTHON or "
1010
+ r"C:\isaacsim6\_build\windows-x86_64\release\python.bat.")
1011
+ ap.add_argument("--use-plugin", action="store_true",
1012
+ help="Fall back to loading rules/features/profiles via the "
1013
+ "simready.foundation.core:SimReadyPlugin entry-point (the "
1014
+ "pre-GitHub-migration flow). Requires the editable install "
1015
+ "of nv_core/sr_specs in the active Python. Default behavior "
1016
+ "is to load from on-disk paths via "
1017
+ "simready.validate.impl.loader.load_validation_implementation, "
1018
+ "matching the official simready-validate CLI. See PROBLEMS.md P2.")
1019
+ args = ap.parse_args()
1020
+
1021
+ # Auto-enable --use-kit for profiles that pull in PhysX features. The
1022
+ # pip-only path's P2 filter silently drops physxschema_unavailable /
1023
+ # omnipbr_unresolved findings, which makes "passed" misleading for these
1024
+ # profiles — the rules that matter never actually ran. The same auto
1025
+ # logic runs earlier in _maybe_reexec_under_kit() so this just keeps
1026
+ # args.use_kit truthful for the rest of main() (e.g. logging, worker
1027
+ # forcing). _PHYSX_PROFILES is the module-level source of truth.
1028
+ if args.use_kit is None:
1029
+ args.use_kit = args.profile in _PHYSX_PROFILES
1030
+
1031
+ # Default path: populate the OAV registries via the CLI loader. The
1032
+ # legacy --use-plugin route keeps the SimReadyPlugin entry-point as a
1033
+ # fallback, in which case we also run the P1 JSON-variant patch on top
1034
+ # (the loader makes P1 redundant on the default path).
1035
+ if not args.use_plugin:
1036
+ _load_via_cli_loader()
1037
+ else:
1038
+ _patch_register_json_variant_features()
1039
+
1040
+ if args.list_profiles:
1041
+ pr = oav.ProfileRegistry()
1042
+ profiles = sorted(pr.values(), key=lambda p: (p.id, str(p.version)))
1043
+ for p in profiles:
1044
+ print(f"PROFILE: {p.id} v{p.version}", flush=True)
1045
+ return 0
1046
+
1047
+ target = resolve_target(args.target)
1048
+ if target is None:
1049
+ return 2
1050
+ if not target.is_dir():
1051
+ print(f"ERROR: target dir does not exist: {target}", flush=True)
1052
+ return 2
1053
+
1054
+ problems = sanity_check_target(target)
1055
+ if problems:
1056
+ print(f"ERROR: target {target} failed sanity check; nothing to validate.", flush=True)
1057
+ for p in problems:
1058
+ print(f" - {p}", flush=True)
1059
+ if args.target is None:
1060
+ print(" hint: /simready-report inferred the current working directory. "
1061
+ "cd into an asset directory or pass an explicit path.", flush=True)
1062
+ return 2
1063
+
1064
+ # Capture the pre-packaging target so the default output lands where
1065
+ # the user pointed (inside their asset dir), not under packages/.
1066
+ original_target = target
1067
+ # If the user pointed at raw assets under assets_to_validate/, auto-package
1068
+ # into packages/<name>/ first and validate the packaged tree.
1069
+ target = maybe_package_target(target)
1070
+
1071
+ # Default output: <original_target>/.reports/<name>.<profile>/. The
1072
+ # `.reports/` prefix is auto-skipped by discover_assets so re-running
1073
+ # /simready-report from inside the target is safe. The `.` separator
1074
+ # between target name and profile id is unambiguous because asset bundle
1075
+ # names use snake_case (no dots) and profile ids use Title-Case-Hyphens
1076
+ # (no dots either). No PASS/FAIL suffix — status belongs inside the report.
1077
+ default_out_name = f"{original_target.name}.{args.profile}"
1078
+ out_dir = Path(args.output).resolve() if args.output else original_target / ".reports" / default_out_name
1079
+
1080
+ if SPECS_DIR is None or not SPECS_DIR.is_dir():
1081
+ print(
1082
+ "WARNING: spec source not found"
1083
+ + (f" at {SPECS_DIR}" if SPECS_DIR else "")
1084
+ + ". Set SIMREADY_FOUNDATIONS_PATH so doc rendering can locate the markdown source.",
1085
+ flush=True,
1086
+ )
1087
+
1088
+ print(f"Target: {target}", flush=True)
1089
+ print(f"Output: {out_dir}", flush=True)
1090
+ print(f"Profile: {args.profile} v{args.version}", flush=True)
1091
+
1092
+ assets = discover_assets(target, exclude=out_dir)
1093
+ print(f"Discovered {len(assets)} USD asset(s) under {target}", flush=True)
1094
+ if not assets:
1095
+ print(f"ERROR: no USD assets (.usd/.usda/.usdc/.usdz) found under {target}. "
1096
+ f"Add assets and re-run.", flush=True)
1097
+ return 2
1098
+
1099
+ out_dir.mkdir(parents=True, exist_ok=True)
1100
+
1101
+ # Resolve worker count. Default: cap at cpu_count, asset count, and 8 (each
1102
+ # worker re-imports the validator stack at startup, so wider isn't always
1103
+ # faster).
1104
+ if args.workers <= 0:
1105
+ workers = max(1, min(os.cpu_count() or 1, len(assets), 8))
1106
+ else:
1107
+ workers = args.workers
1108
+ # In Kit mode each worker would have to re-boot SimulationApp (10-30 s of
1109
+ # Kit startup per worker — net slowdown). Force sequential.
1110
+ if os.environ.get("SIMREADY_INSIDE_KIT") == "1" and workers > 1:
1111
+ print(f" --use-kit: forcing workers=1 (Kit-rooted runs avoid per-worker SimulationApp boot)", flush=True)
1112
+ workers = 1
1113
+ print(f"Workers: {workers} ({'parallel' if workers > 1 else 'sequential'})", flush=True)
1114
+
1115
+ ext_tracker = external_deps.ExternalDepsTracker(target_root=target)
1116
+ results: list[dict] = []
1117
+
1118
+ if workers <= 1 or len(assets) <= 1:
1119
+ # Sequential path. One engine, all assets.
1120
+ engine, profile = build_engine(args.profile, args.version)
1121
+ profile_req_count = sum(len(f.requirements) for f in profile.features)
1122
+ print(f" profile features: {len(profile.features)}", flush=True)
1123
+ print(f" profile requirements: {profile_req_count}", flush=True)
1124
+ for asset in assets:
1125
+ try:
1126
+ results.append(validate_one(engine, asset, target, profile, args.profile, args.version, ext_tracker))
1127
+ except Exception as e:
1128
+ log.exception("Validation failed for %s", asset)
1129
+ ext_tracker.record_issue(str(asset), f"Validator crashed: {type(e).__name__}: {e}")
1130
+ results.append({
1131
+ "name": asset.name, "path": str(asset),
1132
+ "rel_path": str(asset.relative_to(target)) if asset.is_relative_to(target) else str(asset),
1133
+ "profile": args.profile, "profile_version": args.version,
1134
+ "passed": False,
1135
+ "issues": [{"code": "SDK.CRASH", "severity": "error", "msg": str(e), "prim": "/", "rule": None}],
1136
+ "passed_features": [], "failed_features": [], "affected_prims": [],
1137
+ })
1138
+ else:
1139
+ # Parallel path. Each worker initializes its own engine via _pool_init.
1140
+ # Profile metadata is logged from the main process (also constructs an
1141
+ # engine here just for the print; cheap relative to parallel speedup).
1142
+ _, profile = build_engine(args.profile, args.version)
1143
+ profile_req_count = sum(len(f.requirements) for f in profile.features)
1144
+ print(f" profile features: {len(profile.features)}", flush=True)
1145
+ print(f" profile requirements: {profile_req_count}", flush=True)
1146
+ with ProcessPoolExecutor(
1147
+ max_workers=workers,
1148
+ initializer=_pool_init,
1149
+ initargs=(args.profile, args.version, args.use_plugin),
1150
+ ) as ex:
1151
+ futures = {
1152
+ ex.submit(_pool_validate, str(a), str(target), args.profile, args.version): a
1153
+ for a in assets
1154
+ }
1155
+ for fut in as_completed(futures):
1156
+ asset = futures[fut]
1157
+ try:
1158
+ result, ext_records, issues = fut.result()
1159
+ except Exception as e:
1160
+ log.exception("Worker failed for %s", asset)
1161
+ ext_tracker.record_issue(str(asset), f"Worker crashed: {type(e).__name__}: {e}")
1162
+ results.append({
1163
+ "name": asset.name, "path": str(asset),
1164
+ "rel_path": str(asset.relative_to(target)) if asset.is_relative_to(target) else str(asset),
1165
+ "profile": args.profile, "profile_version": args.version,
1166
+ "passed": False,
1167
+ "issues": [{"code": "SDK.WORKER_CRASH", "severity": "error",
1168
+ "msg": str(e), "prim": "/", "rule": None}],
1169
+ "passed_features": [], "failed_features": [], "affected_prims": [],
1170
+ })
1171
+ continue
1172
+ results.append(result)
1173
+ _merge_tracker(ext_tracker, ext_records, issues)
1174
+ # Sort results to match the assets list order (parallel completion is
1175
+ # arbitrary; stable order makes the dashboard reproducible).
1176
+ order = {str(a): i for i, a in enumerate(assets)}
1177
+ results.sort(key=lambda r: order.get(r["path"], len(order)))
1178
+
1179
+ # Mirror per-asset thumbnails from <asset_dir>/.thumbs/256x256/<filename>.png
1180
+ # into <out_dir>/images/<filename>.png so the dashboard can reference them
1181
+ # via a relative path (matches the reference dashboard's `images/` layout).
1182
+ # The source PNGs were sourced externally (e.g. NVIDIA Isaac content
1183
+ # bucket) and are not produced by the packaging pipeline — `thumbnail_provenance`
1184
+ # records each so the dashboard can surface that fact.
1185
+ images_dir = out_dir / "images"
1186
+ images_dir.mkdir(parents=True, exist_ok=True)
1187
+ thumbnail_provenance: list[dict] = []
1188
+ import shutil as _shutil
1189
+ for r in results:
1190
+ usd_path = Path(r["path"])
1191
+ spec_thumb = usd_path.parent / ".thumbs" / "256x256" / f"{usd_path.name}.png"
1192
+ local_thumb = images_dir / f"{usd_path.name}.png"
1193
+ rel = f"images/{usd_path.name}.png"
1194
+ entry = {"asset": r["name"], "rel": rel}
1195
+ if spec_thumb.is_file():
1196
+ try:
1197
+ _shutil.copy2(spec_thumb, local_thumb)
1198
+ entry.update({"present": True, "copied_from": str(spec_thumb)})
1199
+ except Exception as e:
1200
+ entry.update({"present": False, "error": f"{type(e).__name__}: {e}"})
1201
+ else:
1202
+ entry["present"] = False
1203
+ thumbnail_provenance.append(entry)
1204
+
1205
+ specs_docs_dir = (SPECS_DIR / "nv_core" / "sr_specs" / "docs") if (SPECS_DIR and SPECS_DIR.is_dir()) else None
1206
+ dashboard_docs_dir = DASHBOARD_DOCS_DIR if (DASHBOARD_DOCS_DIR and DASHBOARD_DOCS_DIR.is_dir()) else None
1207
+ coverage = compute_profile_coverage(args.profile, args.version, profile)
1208
+ if coverage["declared"] is not None and coverage["missing"]:
1209
+ print(f" profile coverage: {coverage['loaded']}/{coverage['declared']} features loaded "
1210
+ f"({len(coverage['missing'])} silently dropped — see PROBLEMS.md P1)", flush=True)
1211
+ (out_dir / "results.json").write_text(json.dumps({
1212
+ "generated": datetime.now(timezone.utc).isoformat(),
1213
+ "target": str(target),
1214
+ "profile": args.profile,
1215
+ "profile_version": args.version,
1216
+ "specs_docs_dir": str(specs_docs_dir) if specs_docs_dir else None,
1217
+ "dashboard_docs_dir": str(dashboard_docs_dir) if dashboard_docs_dir else None,
1218
+ "profile_coverage": coverage,
1219
+ "thumbnail_provenance": thumbnail_provenance,
1220
+ "results": results,
1221
+ }, indent=2), encoding="utf-8")
1222
+
1223
+ report.write_dashboard(out_dir, target, args.profile, args.version, results,
1224
+ specs_docs_dir=specs_docs_dir,
1225
+ dashboard_docs_dir=dashboard_docs_dir,
1226
+ thumbnail_provenance=thumbnail_provenance,
1227
+ profile_coverage=coverage)
1228
+ ext_tracker.write_reports(out_dir)
1229
+
1230
+ summary = {
1231
+ "total": len(results),
1232
+ "passed": sum(1 for r in results if r["passed"]),
1233
+ "failed": sum(1 for r in results if not r["passed"]),
1234
+ "issues": sum(len(r["issues"]) for r in results),
1235
+ "external": ext_tracker.external_count(),
1236
+ }
1237
+ print(f"\nReport: {out_dir / 'index.html'}", flush=True)
1238
+ print(f"SUMMARY: {summary}", flush=True)
1239
+ return 0
1240
+
1241
+
1242
+ if __name__ == "__main__":
1243
+ sys.exit(main())