From 64e574ca8a0a9ba4cf40f907b3b34a25b6757aa7 Mon Sep 17 00:00:00 2001 From: snkmcb Date: Mon, 21 Sep 2026 19:52:45 +0900 Subject: [PATCH 1/4] Add Phase 9 shared motion adapters --- .gitignore | 1 + CHANGELOG.md | 21 +- CMakeLists.txt | 9 + README.md | 11 +- docs/README.md | 9 +- docs/architecture/DEPENDENCIES.md | 16 +- docs/architecture/PACKAGE_CONTRACT.md | 42 ++- docs/architecture/WORKSPACE.md | 28 +- docs/design/MOTION_CONTRACT.md | 33 ++- docs/reference/CAPABILITY_MATRIX.md | 26 +- docs/reference/DIAGNOSTICS.md | 2 + docs/roadmap/README.md | 15 +- docs/roadmap/current.md | 40 +-- libs/mmdMotionAdapter/CMakeLists.txt | 86 +++++++ .../cmake/mmdMotionAdapterConfig.cmake.in | 17 ++ .../include/mmdMotionAdapter/Adapter.h | 28 ++ .../include/mmdMotionAdapter/Codes.h | 12 + libs/mmdMotionAdapter/openstrata.library.yaml | 40 +++ libs/mmdMotionAdapter/src/Adapter.cpp | 200 ++++++++++++++ libs/mmdMotionAdapter/tests/CMakeLists.txt | 35 +++ libs/mmdMotionAdapter/tests/test_adapter.cpp | 131 ++++++++++ .../include/mmdMotionBinding/Bind.h | 3 + libs/mmdMotionBinding/src/Bind.cpp | 1 + libs/mmdMotionBinding/tests/test_bind.cpp | 1 + libs/mmdSkeletonAdapter/CMakeLists.txt | 77 ++++++ .../cmake/mmdSkeletonAdapterConfig.cmake.in | 11 + .../include/mmdSkeletonAdapter/Adapter.h | 38 +++ .../openstrata.library.yaml | 36 +++ libs/mmdSkeletonAdapter/src/Adapter.cpp | 243 ++++++++++++++++++ libs/mmdSkeletonAdapter/tests/CMakeLists.txt | 34 +++ .../mmdSkeletonAdapter/tests/test_adapter.cpp | 98 +++++++ openstrata.toml | 2 + scripts/check_docs.py | 26 +- scripts/check_installed_consumer.py | 31 ++- scripts/check_library_boundaries.py | 13 + tests/CMakeLists.txt | 14 + tests/installed_consumer/CMakeLists.txt | 11 + tests/installed_consumer/adapter_probe.cpp | 54 ++++ 38 files changed, 1390 insertions(+), 105 deletions(-) create mode 100644 libs/mmdMotionAdapter/CMakeLists.txt create mode 100644 libs/mmdMotionAdapter/cmake/mmdMotionAdapterConfig.cmake.in create mode 100644 libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h create mode 100644 libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h create mode 100644 libs/mmdMotionAdapter/openstrata.library.yaml create mode 100644 libs/mmdMotionAdapter/src/Adapter.cpp create mode 100644 libs/mmdMotionAdapter/tests/CMakeLists.txt create mode 100644 libs/mmdMotionAdapter/tests/test_adapter.cpp create mode 100644 libs/mmdSkeletonAdapter/CMakeLists.txt create mode 100644 libs/mmdSkeletonAdapter/cmake/mmdSkeletonAdapterConfig.cmake.in create mode 100644 libs/mmdSkeletonAdapter/include/mmdSkeletonAdapter/Adapter.h create mode 100644 libs/mmdSkeletonAdapter/openstrata.library.yaml create mode 100644 libs/mmdSkeletonAdapter/src/Adapter.cpp create mode 100644 libs/mmdSkeletonAdapter/tests/CMakeLists.txt create mode 100644 libs/mmdSkeletonAdapter/tests/test_adapter.cpp create mode 100644 tests/installed_consumer/adapter_probe.cpp diff --git a/.gitignore b/.gitignore index 31e05ef..ab974d4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ strata.lock # its OST_HOME (the materialized runtime). /.ost-ci/ /.ost-ci-home/ +/.ost-*/ # Built plugin libraries are staged into each bundle's lib/ by the build, and # tools into their own bin/. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2ad05..7d14aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ Stage-contract version: **1**, authored since the Phase 0 importer. ### Added +- **Phase 9 shared-motion adapters.** `mmdSkeletonAdapter` implements role-table + version 1 and builds the PMX stage's `SkeletonDescriptor`, source rest and + target `RetargetMap`; `mmdMotionAdapter` samples `mmdControl` over an explicit + time range into evaluated `MotionClip` poses, root motion and namespaced MMD + morph channels. Their manifests consume digest-pinned `motionCore` and + `motionRetarget` v0.5.0 artifacts, and unit, boundary and installed-consumer + tests cover both edges. `mmdMotionBinding` now preserves the VMD model name + as provenance. End-to-end retarget and `UsdSkelAnimation` authoring remains + Phase 9 work. (`MOTION_CONTRACT.md` §10, §12.) + - **Physics runtime integration direction.** A proposed focused contract now fixes the future boundary: the existing `/Asset/physics` stage remains the hand-off, MMD bone/body coupling stays in this repository, generic @@ -76,13 +86,14 @@ Stage-contract version: **1**, authored since the Phase 0 importer. (`MOTION_CONTRACT.md` §9, §10, §12; `DESIGN_POLICY.md` §5.7, §14, §20.1; `WORKSPACE.md` §1.2, §2, §2.4; `DEPENDENCIES.md` §6.) -- **The shared-motion edge is split by responsibility.** The planned - `mmdMotionAdapter` now emits only fully evaluated `MotionPose`/`MotionClip` - data. A separate planned `mmdSkeletonAdapter` exposes +- **The shared-motion edge is split by responsibility.** The + `mmdMotionAdapter` emits only fully evaluated `MotionPose`/`MotionClip` + data. A separate `mmdSkeletonAdapter` exposes `SkeletonDescriptor`, `RetargetMap` and `SourceRestPose` and owns the versioned MMD humanoid mapping. Both remain narrow consumers of - `usd-motion-plugins`; neither implements generic retargeting. Documentation - only; no component or manifest exists yet. (`MOTION_CONTRACT.md` §10, §12; + `usd-motion-plugins`; neither implements generic retargeting. This boundary + was decided in documentation first and implemented by the adapter change above. + (`MOTION_CONTRACT.md` §10, §12; `DESIGN_POLICY.md` §5.7; `WORKSPACE.md` §1.2, §2.4.) - **The design documents follow the `usd-motion-plugins` design policy.** diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f0a8a0..ec17a1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,6 +66,15 @@ add_subdirectory("tools/vmdInspect") find_package(pxr REQUIRED CONFIG) include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/UsdMmdOpenUsd.cmake") +# The two Phase 9 adapters are the only libraries that cross into the shared +# motion packages. Resolve them after OpenUSD so motionCore/motionRetarget can +# reuse the already-defined foundation targets, then add the adapters in their +# dependency order. +find_package(motionCore 0.5 CONFIG REQUIRED) +find_package(motionRetarget 0.5 CONFIG REQUIRED) +add_subdirectory("libs/mmdSkeletonAdapter") +add_subdirectory("libs/mmdMotionAdapter") + # The interpreter the integration tests import OpenUSD's Python bindings into. # It must be the Python OpenUSD was built against, and pxrConfig.cmake names # that one -- it sets Python3_EXECUTABLE unless it is already defined -- so the diff --git a/README.md b/README.md index bc5906f..54ec5b1 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ assets: PMX models, and VMD motion bound to them. > identifiers, and `mmd_inspect` reports what a file contains. A VMD is read > without a model and reported by `vmd_inspect`, and bound to a model by MMD's > own name rule. Phase 9 now evaluates MMD's IK and append transforms over a -> bound motion; next its motion and skeleton adapters hand evaluated clips and +> bound motion, and its motion and skeleton adapters hand evaluated clips and > PMX rig descriptions to `usd-motion-plugins`, which retargets and authors -> `UsdSkelAnimation`. Future physics execution consumes the existing static +> `UsdSkelAnimation`. End-to-end retarget and authoring acceptance remains. +> Future physics execution consumes the existing static > stage through `usd-physics-plugins`; the importer remains solver-free. The > [capability matrix](docs/reference/CAPABILITY_MATRIX.md) is the only page > that says what is implemented, and [the roadmap](docs/roadmap/current.md) @@ -45,7 +46,7 @@ VMD bytes ─→ motionVmd ─→ mmdMotionBinding (+ mmdModel) ─→ a bound m syntax, by source name, in the model's tracks basis ─→ mmdControl ─→ mmdMotionAdapter ─→ MotionClip ─→ usd-motion-plugins - IK, append, (Phase 9, planned) retarget, record, UsdSkelAnimation + IK, append, retarget, record, UsdSkelAnimation bone morphs PMX skeleton ─→ mmdSkeletonAdapter ─→ SkeletonDescriptor / RetargetMap @@ -77,8 +78,8 @@ contract is [docs/design/PHYSICS_INTEGRATION.md](docs/design/PHYSICS_INTEGRATION | `motionVmd` | plain C++ library | VMD syntax, CP932 names and tracks — no dependency at all | reads every section | | `mmdMotionBinding` | plain C++ library | binds a VMD motion to a canonical model by source name, in the model's basis — no OpenUSD, nothing evaluated | exists | | `mmdControl` | plain C++ library | evaluates a bound motion at an explicit time over MMD's control rig — Bézier curves, bone morphs, appends, IK — into deformation-joint transforms; no OpenUSD, scheduled by a runtime | exists | -| `mmdSkeletonAdapter` | planned plain C++ library | exposes the PMX skeleton, source rest and versioned humanoid map to `usd-motion-plugins`; no retarget algorithm | waits for installable `motionRetarget` | -| `mmdMotionAdapter` | planned plain C++ library | turns fully evaluated MMD poses into `MotionClip`; no target-avatar knowledge | waits for installable `motionCore` and `motionRetarget` | +| `mmdSkeletonAdapter` | plain C++ library | exposes the PMX skeleton, source rest and versioned humanoid map to `usd-motion-plugins`; no retarget algorithm | exists | +| `mmdMotionAdapter` | plain C++ library | turns fully evaluated MMD poses into `MotionClip`; no target-avatar knowledge | exists | | `vmd_inspect` | CLI | what a VMD contains, without a model or USD | exists ([guide](docs/guides/inspecting.md)) | `mmdSchema` exists only if an MMD API schema passes the diff --git a/docs/README.md b/docs/README.md index 1ec5bdc..e8bf964 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ Documentation is organized by responsibility: each category answers one class of question. The layout is the one `usd-vrm-plugins`, `open-strata` and `hydra-merlin` use, so the repositories read the same way. -**The tree holds Phases 0–7 (2026-09-17):** the PMX structural parser reads +**The tree holds Phases 0–7 and most of Phase 9 (2026-09-21):** the PMX structural parser reads every table of a PMX 2.0 or 2.1 file, `mmd_inspect` reports on it, and `.pmx` opens as the canonical stage — mesh, UVs, material prims and subsets, skeleton and skinning, Y-up, in meters. Phase 3 adds canonical MMD material @@ -15,9 +15,10 @@ chains, append relations, axes — under `/Asset/rig`, solving nothing, and Phase 6 every rigid body and joint under `/Asset/physics`, as `UsdPhysics` where it matches, simulating nothing. Phase 7 reads VMD motion without a model (`motionVmd`, `vmd_inspect`) and binds it to one by MMD's name rule -(`mmdMotionBinding`), baking nothing. Phase 9, in progress, evaluates MMD's -control rig over a bound motion (`mmdControl`, done) and will hand the result -to `usd-motion-plugins`, through separate motion and skeleton adapters. Future +(`mmdMotionBinding`), baking nothing. Phase 9 evaluates MMD's control rig over +a bound motion (`mmdControl`) and hands the result to `usd-motion-plugins` +through `mmdMotionAdapter` and `mmdSkeletonAdapter`. End-to-end retarget and +animation authoring acceptance remains. Future physics execution consumes the existing static stage through `usd-physics-plugins`, with MMD coupling kept here and simulation outside the importer. Everything diff --git a/docs/architecture/DEPENDENCIES.md b/docs/architecture/DEPENDENCIES.md index a4d16fb..5099020 100644 --- a/docs/architecture/DEPENDENCIES.md +++ b/docs/architecture/DEPENDENCIES.md @@ -15,7 +15,7 @@ component links it yet. §7 records the proposed optional runtime edge to | | | | --- | --- | | Pin | OpenUSD **26.08**, exactly (`PXR_VERSION` 2608), enforced by [cmake/UsdMmdOpenUsd.cmake](../../cmake/UsdMmdOpenUsd.cmake) for `ost` and plain-CMake builds alike and declared as `runtime.openusd: "==26.08"` in the bundle manifest; the release the rest of the ecosystem pins (`usd-vrm-plugins` too), because `usd-avatar-runtime` composes every plugin into one OpenUSD process | -| Used by | `usdMmdFileFormat` (and later `mmdSchema`, `usdVmdFileFormat`) only; later `mmdMotionAdapter` too, for the foundation types (`gf`, `tf`, `vt`) `usd-motion-plugins`' `motionCore` and `motionRetarget` expose, and no stage (§6) | +| Used by | `usdMmdFileFormat`; `mmdMotionAdapter` and `mmdSkeletonAdapter` use only the foundation types (`gf`, `tf`, `vt`) exposed through the shared motion packages, and no stage (§6) | | Modules | linked today: `arch`, `tf`, `gf`, `vt`, `ar`, `sdf`, `usd`, `usdGeom`, `usdPhysics`, `usdShade`, `usdSkel`, `kind` (`usdShade` and `usdSkel` since Phase 2, `usdPhysics` since Phase 6) | | Not used | OpenExec, Hydra, `usdImaging` — nothing is evaluated or rendered here, so unlike `usd-vrm-plugins`' pin module this one probes for no OpenExec | | CI runtimes | the OpenUSD 26.08 leaves of OpenStrata's runtime matrix, the same digests `usd-vrm-plugins` pins ([openstrata.ci.yaml](../../openstrata.ci.yaml)) | @@ -95,20 +95,16 @@ exact version, and listed in `THIRD_PARTY_NOTICES.md`. The shared motion core: vendor- and avatar-format-neutral poses and clips, humanoid joint semantics, sampling, retargeting, recording and the -`UsdSkelAnimation` bridge. Planned, not linked. Its first tag, -`v0.1.0-alpha.1` (2026-09-19), is a source-only pre-release of `motionCore` -alone, and its notes say it is not `v0.1.0`. Its `main` has since received -`motionRetarget` — `SkeletonDescriptor`, `RetargetMap`, `SourceRestPose`, -the root-motion policy and the retarget diagnostics — imported from -`usd-vrm-plugins` on 2026-09-19 ahead of the `v0.2.0` that ships it. Nothing -here links either until each is released. +`UsdSkelAnimation` bridge. v0.5.0 was published on 2026-09-20 with installable +`motionCore` and `motionRetarget`; the two adapters consume their per-target, +digest-pinned OpenStrata artifacts. | | | | --- | --- | | Packages | `motionCore` (`HumanJoint`, `MotionPose`, `RootMotion`, `MotionClip`) and `motionRetarget` (`SkeletonDescriptor`, `RetargetMap`, `SourceRestPose`), each by `find_package( CONFIG)` and linked as `::`; `motionUsd` only where [WORKSPACE.md §2.4](WORKSPACE.md#24-edges-out-of-this-repository) allows it | -| Used by | `mmdMotionAdapter`; later perhaps `usdVmdFileFormat` (MOT-O2) | +| Used by | `mmdMotionAdapter` (`motionCore`) and `mmdSkeletonAdapter` (`motionRetarget`); later perhaps `usdVmdFileFormat` (MOT-O2) | | Consumed as | an installed package, by `find_package` with a version range admitting the release it was verified against, the way siblings are ([WORKSPACE.md §5](WORKSPACE.md#5-build-modes)) | -| Version | unset until its first release; its `v0.1.0` (core contract) carries `motionCore`, and `v0.2.0` (`motionRetarget`, with `motionUsd`'s reading half) is the one Phase 9 is designed against, since the adapter needs both | +| Version | `>=0.5,<0.6`, verified against v0.5.0 | | OpenUSD | the same exact pin as §1 | | Direction | one way: `usd-motion-plugins` never depends on this repository | diff --git a/docs/architecture/PACKAGE_CONTRACT.md b/docs/architecture/PACKAGE_CONTRACT.md index 542bf0b..4d4a026 100644 --- a/docs/architecture/PACKAGE_CONTRACT.md +++ b/docs/architecture/PACKAGE_CONTRACT.md @@ -4,13 +4,13 @@ What each installed package promises a consumer: the name it is found by, the target it links, the headers it installs, and what it needs besides. A consumer relies on this page and on nothing else in the build tree; the installed-consumer lane -([WORKSPACE.md §6](WORKSPACE.md#6-tests)) builds against a clean prefix to -keep it true. +([WORKSPACE.md §6](WORKSPACE.md#6-tests)) builds against a clean repository +prefix plus the explicitly pinned external motion packages to keep it true. -Status (2026-09-19): the two Phase 0 packages exist, `mmd_inspect` installs +Status (2026-09-21): the two Phase 0 packages exist, `mmd_inspect` installs with the workspace since Phase 1, `mmdModel` since Phase 2, `motionVmd`, -`mmdMotionBinding` and `vmd_inspect` since Phase 7, and `mmdControl` since -Phase 9. Identities and +`mmdMotionBinding` and `vmd_inspect` since Phase 7, and `mmdControl`, +`mmdSkeletonAdapter` and `mmdMotionAdapter` since Phase 9. Identities and dependency edges are [WORKSPACE.md](WORKSPACE.md)'s; this page does not restate them. @@ -110,6 +110,38 @@ builds a consumer that includes `mmdControl/Evaluator.h` and names five packages alone. The installed-consumer lane's `control_probe` finds `mmdControl` only, binds a VMD fixture to a PMX fixture and evaluates it. +## `mmdSkeletonAdapter` + +| | | +| --- | --- | +| `find_package` | `find_package(mmdSkeletonAdapter 0.1 CONFIG REQUIRED)` | +| Imported target | `mmdSkeletonAdapter::mmdSkeletonAdapter` (static library), which links `mmdModel::mmdModel` and `motionRetarget::motionRetarget` publicly | +| Headers | `include/mmdSkeletonAdapter/` — `Adapter.h` | +| Required packages | `mmdModel` and released `motionRetarget >=0.5,<0.6`; the external package finds `motionCore` and the same OpenUSD foundation runtime | +| Language | C++20 (`cxx_std_20` is a usage requirement) | +| Version compatibility | `SameMinorVersion`, as `mmdPmx` | +| Installed files | `${CMAKE_INSTALL_LIBDIR}/` (the archive), `${CMAKE_INSTALL_LIBDIR}/cmake/mmdSkeletonAdapter/`, and `include/mmdSkeletonAdapter/` | + +Its manifest pins `motionRetarget` by archive and OCI digest for each supported +target. The installed-consumer lane verifies the package from outside the +source tree against that external package. + +## `mmdMotionAdapter` + +| | | +| --- | --- | +| `find_package` | `find_package(mmdMotionAdapter 0.1 CONFIG REQUIRED)` | +| Imported target | `mmdMotionAdapter::mmdMotionAdapter` (static library), which links `mmdControl`, `mmdModel`, `mmdSkeletonAdapter` and `motionCore` publicly | +| Headers | `include/mmdMotionAdapter/` — `Adapter.h`, `Codes.h` | +| Required packages | the three repository packages above and released `motionCore >=0.5,<0.6` | +| Language | C++20 (`cxx_std_20` is a usage requirement) | +| Version compatibility | `SameMinorVersion`, as `mmdPmx` | +| Installed files | `${CMAKE_INSTALL_LIBDIR}/` (the archive), `${CMAKE_INSTALL_LIBDIR}/cmake/mmdMotionAdapter/`, and `include/mmdMotionAdapter/` | + +Its manifest pins `motionCore` per target. The installed-consumer lane binds a +generated VMD to a generated PMX, evaluates it, and builds a shared +`MotionClip` through both installed adapters. + ## `usdMmdFileFormat` A plugin bundle, found by OpenUSD's plug registry rather than by CMake. It diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index 5ea3dac..63d99c5 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -12,9 +12,9 @@ every table of 2.0 and 2.1), `mmdModel` (the canonical model), `mmd_inspect` (which reports on a PMX through the parser) and `usdMmdFileFormat` (which registers `.pmx` and authors the canonical stage) exist since Phases 0–2; `motionVmd` (the VMD reader), `mmdMotionBinding` (which binds a motion to a -model) and `vmd_inspect` (which reports on a VMD) since Phase 7; `mmdControl` -(which evaluates a bound motion over the control rig) since Phase 9. All are -built by `ost` and by plain CMake. Every other identity below is *reserved* +model) and `vmd_inspect` (which reports on a VMD) since Phase 7; `mmdControl`, +`mmdSkeletonAdapter` and `mmdMotionAdapter` since Phase 9. All are built by +`ost` and by plain CMake. Every other identity below is *reserved* until the Phase that creates it lands (Phases are [DESIGN_POLICY.md §14](../design/DESIGN_POLICY.md#14-phases)), and its row then records that. `mmdControl`, `mmdMotionAdapter` and the edge into @@ -58,6 +58,8 @@ And the evaluator Phase 9 created | Identity | Kind | Directory | Manifest | Role | Created in | Status | | --- | --- | --- | --- | --- | --- | --- | | `mmdControl` | plain static CMake library | `libs/mmdControl/` | `openstrata.library.yaml` | MMD control evaluation: samples a bound motion's Bézier curves at an explicit time, and evaluates bone morphs, append transforms and IK chains over `mmdModel`'s control semantics into deformation-joint local transforms. No OpenUSD, no `usd-motion-plugins`. | Phase 9 | exists | +| `mmdSkeletonAdapter` | plain static CMake library | `libs/mmdSkeletonAdapter/` | `openstrata.library.yaml` | Exposes a canonical PMX skeleton as `SkeletonDescriptor`, `RetargetMap` and `SourceRestPose`; owns role-table version 1 and no retarget algorithm. | Phase 9 | exists | +| `mmdMotionAdapter` | plain static CMake library | `libs/mmdMotionAdapter/` | `openstrata.library.yaml` | Converts fully evaluated `mmdControl` output into `MotionClip`, using `mmdSkeletonAdapter` for roles and source rest; owns no target-avatar knowledge. | Phase 9 | exists | ### 1.2 Later, only when their responsibility is real @@ -69,8 +71,6 @@ created ahead of that. | --- | --- | --- | --- | --- | | `mmdMaterial` | plain static CMake library | `libs/mmdMaterial/` | Canonical material semantics, extracted from `mmdModel` | material translation outgrows `mmdModel`, or a second consumer needs it alone ([DESIGN_POLICY.md §5.3](../design/DESIGN_POLICY.md#53-mmdmaterial--deferred)) | | `mmdSchema` | plugin bundle (`usd-schema`) | `plugins/mmdSchema/` | Narrow applied API schemas | an API passes the admission test ([DESIGN_POLICY.md §6](../design/DESIGN_POLICY.md#6-the-schema-admission-test)) | -| `mmdSkeletonAdapter` | plain static CMake library | `libs/mmdSkeletonAdapter/` | Exposes a canonical PMX skeleton as `SkeletonDescriptor`, `RetargetMap` and `SourceRestPose`; owns the versioned MMD humanoid role table and no retarget algorithm. | Phase 9, once `usd-motion-plugins` releases installable `motionRetarget` ([MOTION_CONTRACT.md §10.4](../design/MOTION_CONTRACT.md#104-skeleton-and-humanoid-map)) | -| `mmdMotionAdapter` | plain static CMake library | `libs/mmdMotionAdapter/` | Converts `mmdControl`'s fully evaluated output into `MotionPose`/`MotionClip`, using `mmdSkeletonAdapter` for roles and source rest. Owns no target-avatar knowledge or generic algorithm. | Phase 9, once `usd-motion-plugins` releases installable `motionCore` and `motionRetarget` packages ([MOTION_CONTRACT.md §10](../design/MOTION_CONTRACT.md#10-normalizing-into-the-shared-motion-core)) | | `usdVmdFileFormat` | plugin bundle (`usd-fileformat`) | `plugins/usdVmdFileFormat/` | `.vmd` `SdfFileFormat` over `motionVmd` | MOT-O2 is resolved against `usd-motion-plugins`' standalone motion stage (`/Animation`) ([MOTION_CONTRACT.md §9](../design/MOTION_CONTRACT.md#9-open-questions)) | | `mmd_convert` | CLI executable | `tools/mmdConvert/` | PMX → `.usda`/`.usdc` on disk | `usdcat` over the file format proves insufficient | | `mmdPmd` | plain static CMake library | `libs/mmdPmd/` | PMD syntax with its own CP932 policy | PMD support is decided ([DESIGN_POLICY.md §16](../design/DESIGN_POLICY.md#16-decisions-deliberately-left-flexible)) | @@ -170,10 +170,12 @@ both also refusing any `mmdPmx/` or `mmdModel/` include (`--forbid-include`); `mmdModel::mmdModel` and `motionVmd::motionVmd`; and `mmdControl_boundaries` over `libs/mmdControl` with `mmdMotionBinding::mmdMotionBinding` and `mmdModel::mmdModel`, also refusing any `motionCore/` include. -`mmdSkeletonAdapter_boundaries` is added with that library, allowing +`mmdSkeletonAdapter_boundaries` runs with that library, allowing `motionRetarget`; `mmdMotionAdapter_boundaries` allows `motionCore` and the -skeleton adapter. Those are the two narrow external adapter edges (§2.4). All of them are added to the -root build before OpenUSD is resolved, as `mmdPmx` is. +skeleton adapter. Those are the two narrow external adapter edges (§2.4). +The five OpenUSD-free libraries are added before OpenUSD is resolved; the +adapters follow it because their shared packages expose OpenUSD foundation +types and reuse the root's already-resolved targets. ### 2.4 Edges out of this repository @@ -196,9 +198,9 @@ usd-stage-runner ────→ usd-physics-plugins | Never the reverse | `usd-motion-plugins` never depends on any component here, and nothing here is designed to be moved there: VMD is MMD's format (the motion policy's §26). | | Same OpenUSD | `motionCore` and `motionRetarget` are built against the OpenUSD release this repository pins ([DEPENDENCIES.md §1](DEPENDENCIES.md#1-openusd)); a mismatch is a configure error, not a warning. | -Nothing crosses today: `usd-motion-plugins` has published no installable -package, so both adapter edges are reserved, and no component or manifest -declares them yet. A future MMD-specific physics adapter may similarly consume +The two adapter edges are active since `usd-motion-plugins` v0.5.0: their +manifests pin `motionCore` and `motionRetarget` artifacts by target and digest. +A future MMD-specific physics adapter may similarly consume `usd-physics-plugins`; its identity and edge are added here only when the first runtime consumer makes them concrete ([PHYSICS_INTEGRATION.md §8](../design/PHYSICS_INTEGRATION.md#8-dependency-policy)). @@ -218,7 +220,9 @@ usd-mmd-plugins/ │ ├─ motionVmd/ include/ src/ tests/ fuzz/ cmake/ CMakeLists.txt openstrata.library.yaml; │ │ tools/generate_cp932_table.py, the one author of src/Cp932Table.inc │ ├─ mmdMotionBinding/ include/ src/ tests/ cmake/ CMakeLists.txt openstrata.library.yaml -│ └─ mmdControl/ include/ src/ tests/ cmake/ CMakeLists.txt openstrata.library.yaml +│ ├─ mmdControl/ include/ src/ tests/ cmake/ CMakeLists.txt openstrata.library.yaml +│ ├─ mmdSkeletonAdapter/ include/ src/ tests/ cmake/ CMakeLists.txt openstrata.library.yaml +│ └─ mmdMotionAdapter/ include/ src/ tests/ cmake/ CMakeLists.txt openstrata.library.yaml ├─ plugins/ │ └─ usdMmdFileFormat/ │ ├─ plugin/resources/usdMmdFileFormat/ plugInfo.json.in, buildInfo.json.in (the build writes both .json) diff --git a/docs/design/MOTION_CONTRACT.md b/docs/design/MOTION_CONTRACT.md index 5dc5ebe..a4b9d64 100644 --- a/docs/design/MOTION_CONTRACT.md +++ b/docs/design/MOTION_CONTRACT.md @@ -4,10 +4,10 @@ > VMD as §3–§6 say, `vmd_inspect` reports on it, and `mmdMotionBinding` binds > a motion to a model as §7 and §8.1 say, each with fixtures. §11 is > **binding** since Phase 9's `mmdControl`: it evaluates a bound motion as §11 -> says, with a synthetic rig behind each rule. §10 is **accepted**, and of it -> only `mmdControl`'s part (§10.1–§10.3, §10.7's choice of channels) is -> implemented; `mmdMotionAdapter` and `mmdSkeletonAdapter` wait for -> `usd-motion-plugins`. This document holds +> says, with a synthetic rig behind each rule. §10 and §12 are **binding** +> since `mmdMotionAdapter` and `mmdSkeletonAdapter` adopted the released +> `usd-motion-plugins` v0.5.0 packages, with synthetic adapter tests and an +> installed consumer. This document holds > only what is specific to MMD motion — VMD's source facts, its text encoding, > where it meets a PMX model, how MMD's control rig is evaluated, and how the > result enters the shared motion core. Generic motion concepts (`MotionPose`, @@ -22,12 +22,14 @@ > the same day: §10 follows what `usd-motion-plugins` has merged — the rig > types are `motionRetarget`'s, not `motionCore`'s, and a map binds a *target* > rig — §12 is new, MOT-O5 and MOT-O6 are resolved by it, and MOT-O10 is -> opened. §12 is **accepted** and, like the rest of §10, waits for the adapter. +> opened. §12 was accepted there and became binding with the adapters. > Revised again the same day: MOT-O9 is resolved against an independent > implementation with §11.7 unchanged, and MOT-O11 is opened. > Revised 2026-09-21: the shared-motion edge is split by responsibility: > `mmdMotionAdapter` emits evaluated motion, while `mmdSkeletonAdapter` exposes > the PMX skeleton, rest pose and versioned humanoid mapping. +> Revised again on 2026-09-21: `usd-motion-plugins` v0.5.0 shipped installable +> `motionCore` and `motionRetarget`, and both adapters implemented §10 and §12. --- @@ -303,14 +305,12 @@ Resolved: ## 10. Normalizing into the shared motion core -Accepted: this section is Phase 9 -([DESIGN_POLICY.md §14](DESIGN_POLICY.md#14-phases)). `mmdControl` exists and -evaluates as §11 says; `mmdMotionAdapter` and `mmdSkeletonAdapter` wait for -`usd-motion-plugins` to -release the two packages it needs: `motionCore` (`HumanJoint`, `MotionPose`, -`RootMotion`, `MotionChannelSet`, `MotionClip`) and `motionRetarget` -(`SkeletonDescriptor`, `RetargetMap`, `SourceRestPose`). Both are on that -repository's `main` since 2026-09-19; neither is released yet +Binding: this section is Phase 9 +([DESIGN_POLICY.md §14](DESIGN_POLICY.md#14-phases)). `mmdControl` evaluates +as §11 says; `mmdMotionAdapter` and `mmdSkeletonAdapter` consume the two +packages released by `usd-motion-plugins` v0.5.0: `motionCore` (`HumanJoint`, +`MotionPose`, `RootMotion`, `MotionChannelSet`, `MotionClip`) and +`motionRetarget` (`SkeletonDescriptor`, `RetargetMap`, `SourceRestPose`) ([DEPENDENCIES.md §6](../architecture/DEPENDENCIES.md#6-usd-motion-plugins)). The type names are that repository's, and where its published contract differs from this section, the published contract wins and this section is @@ -467,6 +467,11 @@ its catalog with the code that raises them. Diagnostics the shared core raises (`MOTION-E####`, `MOTION-W####`, `MOTION-I####`) are passed through unchanged, never re-coded. +`mmdMotionAdapter` rejects a non-finite, reversed or non-positive-rate sample +request with `MMD_MOTION_INVALID_SAMPLE_RANGE`. It reports each required +source role absent from the versioned table once per clip as +`MMD_MOTION_MISSING_REQUIRED_JOINT`; the partial clip remains valid (§12.4). + ## 11. Evaluating the control rig Binding since Phase 9: `mmdControl` evaluates a bound motion over a model as @@ -654,7 +659,7 @@ Raised by `Prepare`, once per element, never by `Evaluate`: ## 12. The humanoid role table -Accepted, with §10: `mmdSkeletonAdapter` implements it when it lands, and +Binding, with §10: `mmdSkeletonAdapter` implements it, and `mmdMotionAdapter` consumes it for source clips. It resolves MOT-O5 and MOT-O6, against the 13 local characters and two distributed motions of the diff --git a/docs/reference/CAPABILITY_MATRIX.md b/docs/reference/CAPABILITY_MATRIX.md index 3687c6c..2941521 100644 --- a/docs/reference/CAPABILITY_MATRIX.md +++ b/docs/reference/CAPABILITY_MATRIX.md @@ -4,7 +4,7 @@ What the current code supports, feature by feature. This page states **facts about the tree**, not plans; a status here changes only in the change that adds the fixture proving it. -**As of 2026-09-19 the tree holds Phases 0–7 and the first part of Phase 9:** `.pmx` is registered, every +**As of 2026-09-21 the tree holds Phases 0–7 and most of Phase 9:** `.pmx` is registered, every table of a PMX 2.0 or 2.1 file is parsed and validated (by `mmdPmx`, reported by `mmd_inspect`), canonicalized (by `mmdModel`), and authored as the canonical stage — mesh, UVs, material prims and subsets, skeleton and @@ -19,7 +19,9 @@ VMD motion (`motionVmd`, reported by `vmd_inspect`) and binds it to a canonical model by MMD's name rule (`mmdMotionBinding`), baking nothing. Phase 9's `mmdControl` evaluates a bound motion at an explicit time over the control rig — Bézier curves, bone and group morphs, appends, IK — into -deformation-joint transforms, outside the importer. +deformation-joint transforms, outside the importer. `mmdSkeletonAdapter` +builds the shared skeleton, rest and versioned role map, and +`mmdMotionAdapter` turns evaluated poses into a `MotionClip`. The *intended* column is the claim the design makes for the first substantial release ([DESIGN_POLICY.md §14.1](../design/DESIGN_POLICY.md#141-first-substantial-release--definition-of-done)); @@ -108,6 +110,22 @@ rule by `mmdControl_robustness`. | Physics before after-physics bones | unsupported by design (nothing is simulated) | [MOTION §10.3](../design/MOTION_CONTRACT.md#103-evaluation) | | The same inputs give the same bits, whatever was evaluated before | supported | [MOTION §11.1](../design/MOTION_CONTRACT.md#111-the-evaluator) | +## Shared-motion adapters (`mmdSkeletonAdapter`, `mmdMotionAdapter`) + +Each claim is covered by synthetic adapter tests and by the installed-consumer +lane against digest-pinned `usd-motion-plugins` v0.5.0 packages. + +| Capability | Current | Contract | +| --- | :---: | --- | +| Stage-token `SkeletonDescriptor` with canonical rest translations | supported | [MOTION §10.4](../design/MOTION_CONTRACT.md#104-skeleton-and-humanoid-map) | +| Role-table version 1, exact Japanese source names, deforming `D` candidates first, English names ignored | supported | [MOTION §12.1](../design/MOTION_CONTRACT.md#121-matching), [§12.2](../design/MOTION_CONTRACT.md#122-the-table-version-1) | +| Source role mapping and `SourceRestPose`; target `RetargetMap` with hips at the common spine/leg ancestor | supported | [MOTION §12.3](../design/MOTION_CONTRACT.md#123-evaluated-motion-as-humanjoint-rotations-and-root-motion) | +| Evaluated world rotations made local to the nearest mapped humanoid ancestor | supported | [MOTION §12.3](../design/MOTION_CONTRACT.md#123-evaluated-motion-as-humanjoint-rotations-and-root-motion) | +| Root motion from the evaluated world transform of the source hips role | supported | [MOTION §10.5](../design/MOTION_CONTRACT.md#105-time-coordinates-and-root-motion) | +| Explicit time range and rate, endpoint sampling, seconds and 30 VMD frames per second | supported | [MOTION §10.3](../design/MOTION_CONTRACT.md#103-evaluation), [§10.5](../design/MOTION_CONTRACT.md#105-time-coordinates-and-root-motion) | +| Non-bone morph weights under `mmd:` channels | supported | [MOTION §10.7](../design/MOTION_CONTRACT.md#107-morphs-as-channels) | +| Rest-direction correction for an A-pose source (MOT-O10) | unverified | [MOTION §9](../design/MOTION_CONTRACT.md#9-open-questions) | + ## PMX model import | Capability | Current | Intended | Phase | Contract | @@ -171,13 +189,13 @@ rule by `mmdControl_robustness`. | --- | --- | --- | | IK solving, append-transform evaluation at import | unsupported by design | never the importer ([DESIGN_POLICY.md §2.2](../design/DESIGN_POLICY.md#22-the-static-importer-boundary)) | | IK solving, append-transform evaluation of a bound motion (`mmdControl`) | supported, outside the importer — see [Control evaluation](#control-evaluation-mmdcontrol) | [MOTION §11](../design/MOTION_CONTRACT.md#11-evaluating-the-control-rig) | -| A VMD as a `MotionClip`, and a PMX model as a retarget target (`SkeletonDescriptor`, humanoid `RetargetMap`) (`mmdMotionAdapter`) | — (Phase 9; the role table is decided, the adapter waits for `usd-motion-plugins`) | [MOTION §10](../design/MOTION_CONTRACT.md#10-normalizing-into-the-shared-motion-core), [§12](../design/MOTION_CONTRACT.md#12-the-humanoid-role-table) | +| A VMD as a `MotionClip`, and a PMX model as a retarget target (`SkeletonDescriptor`, humanoid `RetargetMap`) | supported — see [Shared-motion adapters](#shared-motion-adapters-mmdskeletonadapter-mmdmotionadapter) | [MOTION §10](../design/MOTION_CONTRACT.md#10-normalizing-into-the-shared-motion-core), [§12](../design/MOTION_CONTRACT.md#12-the-humanoid-role-table) | | Retargeting, recording, `UsdSkelAnimation` authoring of motion | unsupported by design | `usd-motion-plugins` ([MOTION §10.6](../design/MOTION_CONTRACT.md#106-what-this-repository-does-not-do-with-the-result)) | | Physics simulation | unsupported by design | `usd-stage-runner` or another runtime | | Toon rendering | unsupported by design | `hydra-toon` | | Opening a `.vmd` as a stage (`usdVmdFileFormat`) | — (waits for MOT-O2) | [MOTION §2](../design/MOTION_CONTRACT.md#2-components-and-boundaries) | | VMD playback | unsupported by design | a runtime scheduling `mmdControl` (Phase 8) | -| VMD bake | — (Phase 9; `mmdControl` exists, the shared core's authoring waits for `usd-motion-plugins`) | `mmdControl` evaluates, the shared core authors ([MOTION §8.2](../design/MOTION_CONTRACT.md#82-a-bake-is-not-a-data-conversion)) | +| VMD bake to `UsdSkelAnimation` | — (end-to-end acceptance remains) | `mmdControl` evaluates, the adapters normalize, the shared core authors ([MOTION §8.2](../design/MOTION_CONTRACT.md#82-a-bake-is-not-a-data-conversion)) | | PMD | — (not planned) | `mmdPmd`, if ever | | PMX / VMD writing | — (not planned) | [DESIGN_POLICY.md §2.4](../design/DESIGN_POLICY.md#24-reader-first) | diff --git a/docs/reference/DIAGNOSTICS.md b/docs/reference/DIAGNOSTICS.md index 2197ceb..f3f484a 100644 --- a/docs/reference/DIAGNOSTICS.md +++ b/docs/reference/DIAGNOSTICS.md @@ -226,6 +226,8 @@ raises; every other code is reserved. | `MMD_MOTION_EXTERNAL_PARENT_IGNORED` *emitted* | info | `mmdControl` (`Prepare`) | [MOTION §11.8](../design/MOTION_CONTRACT.md#118-diagnostics) | | `MMD_MOTION_LOCAL_APPEND_APPROXIMATED` *emitted* | info | `mmdControl` (`Prepare`) | [MOTION §11.8](../design/MOTION_CONTRACT.md#118-diagnostics) | | `MMD_MOTION_IK_LOOP_CLAMPED` *emitted* | warning | `mmdControl` (`Prepare`) | [MOTION §11.8](../design/MOTION_CONTRACT.md#118-diagnostics) | +| `MMD_MOTION_INVALID_SAMPLE_RANGE` *emitted* | fatal | `mmdMotionAdapter` (`BuildClip`) | [MOTION §10.8](../design/MOTION_CONTRACT.md#108-diagnostics) | +| `MMD_MOTION_MISSING_REQUIRED_JOINT` *emitted* | warning | `mmdMotionAdapter` (`BuildClip`) | [MOTION §12.4](../design/MOTION_CONTRACT.md#124-required-joints) | ### 5.7 USD boundary diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index d716fdc..9f65317 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -37,7 +37,7 @@ in.** No other document states a version for a Phase. | 6 | physics preservation | ✅ done | [v0.1.0](../releases/v0.1.0.md) | | 7 | VMD | ✅ done | [v0.1.0](../releases/v0.1.0.md) | | 8 | avatar runtime composition, including optional physics coupling | ⬜ | unassigned, and owned mostly outside this repository | -| 9 | shared motion core adoption — runs before Phase 8 | 🚧 `mmdControl` and the role table done | unassigned; its motion and skeleton adapters wait for installable `usd-motion-plugins` packages | +| 9 | shared motion core adoption — runs before Phase 8 | 🚧 evaluator and adapters done; end-to-end acceptance remains | unassigned | Phases 0–7 ship together in v0.1.0, the first release, decided on 2026-09-17: it is the one that meets @@ -47,7 +47,7 @@ Phase 7 too. It is a 0.x release because no consumer has used the packages yet: Phase 8's consumer may still show a contract wrong ([current.md](current.md)). No earlier Phase had a release of its own. -Where things stand, as of 2026-09-19: +Where things stand, as of 2026-09-21: - The documentation baseline exists: the design policy, six focused design contracts, the workspace contract, and reference pages that state what is @@ -102,8 +102,8 @@ Where things stand, as of 2026-09-19: VMD stays here, MMD IK and append evaluation moves here from the runtime (MOT-O3 superseded), and Phase 9 was added for the hand-off to the shared motion core. -- Phase 9, shared motion core adoption, is the current milestone. Its first - part is done: `mmdControl` evaluates a bound motion over the control rig — +- Phase 9, shared motion core adoption, is the current milestone. `mmdControl` + evaluates a bound motion over the control rig — Bézier curves, bone and group morphs, appends, IK — deterministically, as [MOTION_CONTRACT.md §11](../design/MOTION_CONTRACT.md#11-evaluating-the-control-rig) says, with MOT-O7 resolved and legs following their IK goals over 13 local @@ -112,9 +112,10 @@ Where things stand, as of 2026-09-19: The humanoid role table and root motion are decided — MOT-O5 and MOT-O6 ([MOTION_CONTRACT.md §12](../design/MOTION_CONTRACT.md#12-the-humanoid-role-table), [report](../reports/2026-09-19-phase9-roles-and-root.md)). - `mmdMotionAdapter` and `mmdSkeletonAdapter` wait for - `usd-motion-plugins` to release `motionCore` and `motionRetarget`, both on - its `main` since 2026-09-19. + `mmdMotionAdapter` and `mmdSkeletonAdapter` consume the released, + digest-pinned `motionCore` and `motionRetarget` v0.5.0 packages and are + covered by unit, boundary and installed-consumer tests. End-to-end retarget + and `UsdSkelAnimation` authoring acceptance remains. - Phase 8, avatar runtime composition, follows Phase 9 and is owned mostly outside this repository. Its MMD physics work consumes the already-authored stage through `usd-physics-plugins`; the importer never gains a solver diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 4b618cd..57a8f1a 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1,7 +1,7 @@ # Phase 9, then Phase 8 — shared motion, avatar and physics composition -Status: 🚧 Phase 9 in progress — `mmdControl`, MOT-O5, MOT-O6 and MOT-O9 -are done; Phase 8 not started. +Status: 🚧 Phase 9 in progress — `mmdControl`, both shared-motion adapters, +MOT-O5, MOT-O6 and MOT-O9 are done; Phase 8 not started. Two Phases remain, and they run in this order although they are numbered the other way ([DESIGN_POLICY.md §14](../design/DESIGN_POLICY.md#14-phases)): @@ -21,15 +21,11 @@ other way ([DESIGN_POLICY.md §14](../design/DESIGN_POLICY.md#14-phases)): What is listed here is only the part this repository owes, or waits for; what the runtime consumes from here today is [v0.1.0](../releases/v0.1.0.md). -As of 2026-09-19 `usd-avatar-runtime` holds no commits and -`motion-connectors` a scaffold. `usd-motion-plugins` has tagged -`v0.1.0-alpha.1`, a source-only pre-release of `motionCore` alone; its `main` -has since received `motionRetarget` — `SkeletonDescriptor`, `RetargetMap`, -`SourceRestPose` — for its `v0.2.0` +As of 2026-09-21 `usd-motion-plugins` v0.5.0 is published with installable +`motionCore`, `motionRetarget` and `motionUsd` ([DEPENDENCIES.md §6](../architecture/DEPENDENCIES.md#6-usd-motion-plugins)). -Neither is released, so `mmdMotionAdapter` and `mmdSkeletonAdapter` still -cannot start. `mmdControl` and the role-table decision depended on neither, -and are done. +The first two are consumed by digest-pinned artifacts, and +`mmdMotionAdapter` and `mmdSkeletonAdapter` are implemented. ## Outcome @@ -74,23 +70,13 @@ usd-avatar-runtime: composes the above per frame and coordinates rendering - ⬜ **MOT-O10**: whether `mmdSkeletonAdapter` states a `SourceRestPose` measured from the rest bone directions — MMD's arms rest in an A — decided with the adapters' first retarget onto a non-MMD skeleton. -- ⛔ **`mmdMotionAdapter` and `mmdSkeletonAdapter`** wait for - `usd-motion-plugins` to release - installable `motionCore` and `motionRetarget` packages - ([DEPENDENCIES.md §6](../architecture/DEPENDENCIES.md#6-usd-motion-plugins)). - The role table is implemented in `mmdSkeletonAdapter`, not before: it is - written against `HumanJoint`, and a copy of that vocabulary here would be - the second taxonomy the shared core forbids. - `mmdControl`'s pose is its input: local transforms per canonical joint in - the USD basis, and the morph channels. - When the packages ship, the two adapter edges are declared in their - manifests and gated as - [WORKSPACE.md §2.4](../architecture/WORKSPACE.md#24-edges-out-of-this-repository) - says, and `MotionClip`, `SkeletonDescriptor`, `SourceRestPose` and - `RetargetMap` are built as - MOTION_CONTRACT §10.4–§10.7 and §12 say. -- ⛔ **Acceptance end to end** waits for `usd-motion-plugins`' retarget and - `UsdSkelAnimation` authoring: a VMD-derived clip poses the PMX stage's +- ✅ **`mmdMotionAdapter` and `mmdSkeletonAdapter`** (2026-09-21): digest-pinned + `motionCore` and `motionRetarget` v0.5.0 packages; role-table version 1; + stage-token `SkeletonDescriptor`, source rest and target `RetargetMap`; + evaluated world rotations normalized into `MotionClip`, root motion and + namespaced morph channels; unit, boundary and installed-consumer tests. +- ⬜ **Acceptance end to end**: through `motionRetarget` and `motionUsd`, a + VMD-derived clip poses the PMX stage's skeleton with legs driven by IK, and retargets to a non-MMD synthetic skeleton with no MMD code on that path. - ⬜ **Expression interoperability** follows the skeletal adapter path. Keep diff --git a/libs/mmdMotionAdapter/CMakeLists.txt b/libs/mmdMotionAdapter/CMakeLists.txt new file mode 100644 index 0000000..4b62dca --- /dev/null +++ b/libs/mmdMotionAdapter/CMakeLists.txt @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.22) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../../VERSION") + file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../../VERSION" + _mmd_motion_adapter_version LIMIT_COUNT 1) + string(STRIP "${_mmd_motion_adapter_version}" _mmd_motion_adapter_version) +else() + set(_mmd_motion_adapter_version "0.1.0") +endif() + +project(mmdMotionAdapter VERSION ${_mmd_motion_adapter_version} + DESCRIPTION "Evaluated MMD motion adapter for usd-motion-plugins" LANGUAGES CXX) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 20) +endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED MMDMOTIONADAPTER_BUILD_TESTS) + if(DEFINED USDMMD_BUILD_TESTS) + set(_mmd_motion_adapter_tests_default "${USDMMD_BUILD_TESTS}") + else() + set(_mmd_motion_adapter_tests_default "${PROJECT_IS_TOP_LEVEL}") + endif() + option(MMDMOTIONADAPTER_BUILD_TESTS "Build mmdMotionAdapter tests" + ${_mmd_motion_adapter_tests_default}) +endif() + +include("${CMAKE_CURRENT_SOURCE_DIR}/../../cmake/UsdMmdTargets.cmake") + +if(NOT TARGET mmdModel::mmdModel) + find_package(mmdModel CONFIG REQUIRED) +endif() +if(NOT TARGET mmdControl::mmdControl) + find_package(mmdControl CONFIG REQUIRED) +endif() +if(NOT TARGET mmdSkeletonAdapter::mmdSkeletonAdapter) + find_package(mmdSkeletonAdapter CONFIG REQUIRED) +endif() +if(NOT TARGET motionCore::motionCore) + find_package(motionCore 0.5 CONFIG REQUIRED) +endif() + +add_library(mmdMotionAdapter STATIC src/Adapter.cpp) +add_library(mmdMotionAdapter::mmdMotionAdapter ALIAS mmdMotionAdapter) +set_target_properties(mmdMotionAdapter PROPERTIES + EXPORT_NAME mmdMotionAdapter POSITION_INDEPENDENT_CODE ON) +target_compile_features(mmdMotionAdapter PUBLIC cxx_std_20) +target_include_directories(mmdMotionAdapter PUBLIC + "$" + "$") +target_link_libraries(mmdMotionAdapter PUBLIC + mmdControl::mmdControl + mmdModel::mmdModel + mmdSkeletonAdapter::mmdSkeletonAdapter + motionCore::motionCore) +usdmmd_target_defaults(mmdMotionAdapter) + +include(GNUInstallDirs) +install(TARGETS mmdMotionAdapter EXPORT mmdMotionAdapterTargets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install(DIRECTORY include/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +include(CMakePackageConfigHelpers) +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mmdMotionAdapterConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/mmdMotionAdapterConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/mmdMotionAdapter") +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/mmdMotionAdapterConfigVersion.cmake" + VERSION ${PROJECT_VERSION} COMPATIBILITY SameMinorVersion) +install(EXPORT mmdMotionAdapterTargets FILE mmdMotionAdapterTargets.cmake + NAMESPACE mmdMotionAdapter:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/mmdMotionAdapter") +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/mmdMotionAdapterConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/mmdMotionAdapterConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/mmdMotionAdapter") + +if(MMDMOTIONADAPTER_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/libs/mmdMotionAdapter/cmake/mmdMotionAdapterConfig.cmake.in b/libs/mmdMotionAdapter/cmake/mmdMotionAdapterConfig.cmake.in new file mode 100644 index 0000000..4455c9c --- /dev/null +++ b/libs/mmdMotionAdapter/cmake/mmdMotionAdapterConfig.cmake.in @@ -0,0 +1,17 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +if(NOT TARGET mmdModel::mmdModel) + find_dependency(mmdModel CONFIG) +endif() +if(NOT TARGET mmdControl::mmdControl) + find_dependency(mmdControl CONFIG) +endif() +if(NOT TARGET mmdSkeletonAdapter::mmdSkeletonAdapter) + find_dependency(mmdSkeletonAdapter CONFIG) +endif() +if(NOT TARGET motionCore::motionCore) + find_dependency(motionCore 0.5 CONFIG) +endif() +include("${CMAKE_CURRENT_LIST_DIR}/mmdMotionAdapterTargets.cmake") +check_required_components(mmdMotionAdapter) diff --git a/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h new file mode 100644 index 0000000..aea99d3 --- /dev/null +++ b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Converts fully evaluated MMD control poses into the vendor-neutral shared +// motion values. It never retargets and knows no target avatar. +#pragma once + +#include +#include +#include + +namespace mmd::motion { + +struct ClipOptions { + double startTime = 0.0; ///< seconds, inclusive + double endTime = 0.0; ///< seconds, inclusive + double samplesPerSecond = 30.0; +}; + +/// Evaluates `bound` at the requested rate and emits humanoid rotations, root +/// motion and source-preserving MMD morph channels. A valid range always has +/// a sample at both endpoints (one sample when they are equal). +Result BuildClip(const CanonicalDocument& model, + const binding::BoundMotion& bound, + const control::Evaluator& evaluator, + const skeleton::AdaptedSkeleton& skeleton, + ClipOptions options = {}); + +} // namespace mmd::motion diff --git a/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h new file mode 100644 index 0000000..ab4e989 --- /dev/null +++ b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +namespace mmd::codes { + +inline constexpr Code MotionInvalidSampleRange{"MMD_MOTION_INVALID_SAMPLE_RANGE", Severity::Fatal}; +inline constexpr Code MotionMissingRequiredJoint{"MMD_MOTION_MISSING_REQUIRED_JOINT", + Severity::Warning}; + +} // namespace mmd::codes diff --git a/libs/mmdMotionAdapter/openstrata.library.yaml b/libs/mmdMotionAdapter/openstrata.library.yaml new file mode 100644 index 0000000..f86dc8f --- /dev/null +++ b/libs/mmdMotionAdapter/openstrata.library.yaml @@ -0,0 +1,40 @@ +# OpenStrata plain-library descriptor. MMD evaluation stays local; only the +# evaluated value crosses into the released motionCore package. +schema: openstrata.library/v1alpha1 +library: + id: mmdMotionAdapter + version: 0.1.0 +requires: + libraries: + - id: mmdControl + version: ">=0.1,<0.2" + - id: mmdModel + version: ">=0.1,<0.2" + - id: mmdSkeletonAdapter + version: ">=0.1,<0.2" + - id: motionCore + version: ">=0.5,<0.6" + artifact: + targets: + cy2026-linux-x86_64-py313-usd: + digest: sha256:2aa549d1d764ef99527f31b71533133ac1c98e48843c63feccf6c69cc551219d + source: oci://ghcr.io/animu-sphere/usd-motion-plugins@sha256:32b6ba1bc4b155278fbc388e24a0e9130f19540be9a980cb48bf049637bfe9e7 + cy2026-macos-arm64-py313-usd: + digest: sha256:0a2c796e1929abcca6b4bba75509b8139a1fd757cb1c4fe6f9a165eee55e8d49 + source: oci://ghcr.io/animu-sphere/usd-motion-plugins@sha256:54f62ff034b02babe2470a78d78aa18c44b4c2245d09d53ec7d7af0ccc6888e0 + cy2026-windows-x86_64-py313-usd: + digest: sha256:f06fef790e19371c3d085c667e44805d738b63e8af77df2d8fafa6a41d5969a5 + source: oci://ghcr.io/animu-sphere/usd-motion-plugins@sha256:7e5c67b9ae5defa40887b7dbaccea0bb5258ae3e86b1e2d14e7c82ae13ce0564 +cmake: + package: mmdMotionAdapter + target: mmdMotionAdapter::mmdMotionAdapter +package: + standalone: true + aggregate_member: false +package_contract: + package_name: mmdMotionAdapter + exported_targets: [mmdMotionAdapter::mmdMotionAdapter] + public_headers: [include/mmdMotionAdapter/**] + consumer: + include: mmdMotionAdapter/Adapter.h + symbol: mmd::motion::BuildClip diff --git a/libs/mmdMotionAdapter/src/Adapter.cpp b/libs/mmdMotionAdapter/src/Adapter.cpp new file mode 100644 index 0000000..09ac60c --- /dev/null +++ b/libs/mmdMotionAdapter/src/Adapter.cpp @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "mmdMotionAdapter/Adapter.h" + +#include "mmdMotionAdapter/Codes.h" + +#include +#include +#include +#include +#include +#include + +namespace mmd::motion { +namespace { + +using openstrata::motion::HumanJoint; +using openstrata::motion::HumanJointCount; +using openstrata::motion::MotionClip; +using openstrata::motion::MotionPose; +using openstrata::motion::SourceMetadata; + +control::Quat +Multiply(const control::Quat& a, const control::Quat& b) +{ + return { + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + }; +} + +control::Quat +Inverse(const control::Quat& q) +{ + const double norm = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]; + if (!(norm > 0.0) || !std::isfinite(norm)) { + return control::kIdentity; + } + return {-q[0] / norm, -q[1] / norm, -q[2] / norm, q[3] / norm}; +} + +pxr::GfQuatf +ToGf(const control::Quat& source) +{ + const double norm = std::sqrt(source[0] * source[0] + source[1] * source[1] + + source[2] * source[2] + source[3] * source[3]); + if (!(norm > 0.0) || !std::isfinite(norm)) { + return pxr::GfQuatf(1.0f, pxr::GfVec3f(0.0f)); + } + const float x = static_cast(source[0] / norm); + const float y = static_cast(source[1] / norm); + const float z = static_cast(source[2] / norm); + const float w = static_cast(source[3] / norm); + return pxr::GfQuatf(w, pxr::GfVec3f(x, y, z)); +} + +SourceMetadata +Metadata(const binding::BoundMotion& bound) +{ + SourceMetadata source; + source.kind = openstrata::motion::MotionSourceKind::Clip; + source.provider = "usd-mmd-plugins"; + source.protocol = "vmd"; + source.sourceId = bound.sourceModelName; + return source; +} + +std::vector +SampleTimes(const ClipOptions& options) +{ + std::vector times; + if (options.startTime == options.endTime) { + times.push_back(options.startTime); + return times; + } + + const double step = 1.0 / options.samplesPerSecond; + const double duration = options.endTime - options.startTime; + const std::size_t regular = static_cast(std::floor(duration / step)); + times.reserve(regular + 2); + for (std::size_t i = 0; i <= regular; ++i) { + const double time = options.startTime + static_cast(i) * step; + if (time <= options.endTime) { + times.push_back(time); + } + } + const double tolerance = + std::numeric_limits::epsilon() * std::max(1.0, std::abs(options.endTime)) * 8.0; + if (times.empty() || options.endTime - times.back() > tolerance) { + times.push_back(options.endTime); + } else { + times.back() = options.endTime; + } + return times; +} + +} // namespace + +Result +BuildClip(const CanonicalDocument& model, const binding::BoundMotion& bound, + const control::Evaluator& evaluator, const skeleton::AdaptedSkeleton& skeleton, + ClipOptions options) +{ + if (!std::isfinite(options.startTime) || !std::isfinite(options.endTime) || + !std::isfinite(options.samplesPerSecond) || options.samplesPerSecond <= 0.0 || + options.endTime < options.startTime) { + return Result::Failure(MakeDiagnostic( + codes::MotionInvalidSampleRange, + "clip range must have finite ordered endpoints and a finite positive sample rate")); + } + + const double sampleEstimate = (options.endTime - options.startTime) * options.samplesPerSecond; + if (!std::isfinite(sampleEstimate) || + sampleEstimate > static_cast(std::numeric_limits::max() - 2)) { + return Result::Failure(MakeDiagnostic(codes::MotionInvalidSampleRange, + "clip range contains too many samples")); + } + + std::vector diagnostics; + for (const HumanJoint required : skeleton.requiredJoints) { + if (skeleton.SourceJoint(required) != openstrata::motion::RetargetMap::kUnmapped) { + continue; + } + Location where; + where.table = "bones"; + where.field = std::string(openstrata::motion::HumanJointName(required)); + diagnostics.push_back( + MakeDiagnostic(codes::MotionMissingRequiredJoint, + "MMD source skeleton has no role for '" + where.field + "'", + std::move(where))); + } + + MotionClip clip; + clip.startTime = options.startTime; + clip.endTime = options.endTime; + clip.nominalFrameRate = options.samplesPerSecond; + clip.source = Metadata(bound); + + const std::vector times = SampleTimes(options); + clip.samples.reserve(times.size()); + for (const double time : times) { + const control::Pose evaluated = evaluator.Evaluate(bound, time * 30.0); + const std::vector world = evaluator.World(evaluated); + + MotionPose pose; + pose.timestamp = time; + pose.metadata = clip.source; + + for (std::size_t i = 0; i < HumanJointCount; ++i) { + if (!skeleton.sourcePresent.test(i)) { + continue; + } + const HumanJoint role = static_cast(i); + const int joint = skeleton.SourceJoint(role); + if (joint < 0 || static_cast(joint) >= world.size()) { + continue; + } + control::Quat local = world[static_cast(joint)].rotation; + const auto parent = + openstrata::motion::NearestPresentAncestor(role, skeleton.sourcePresent); + if (parent) { + const int parentJoint = skeleton.SourceJoint(*parent); + if (parentJoint >= 0 && static_cast(parentJoint) < world.size()) { + local = Multiply(Inverse(world[static_cast(parentJoint)].rotation), + local); + } + } + pose.localRotations[i] = ToGf(local); + pose.validRotations.set(i); + } + + const int hips = skeleton.SourceJoint(HumanJoint::Hips); + if (hips >= 0 && static_cast(hips) < world.size()) { + const control::JointTransform& root = world[static_cast(hips)]; + pose.root.worldPosition = pxr::GfVec3f(static_cast(root.translation[0]), + static_cast(root.translation[1]), + static_cast(root.translation[2])); + pose.root.worldOrientation = ToGf(root.rotation); + pose.root.hasPosition = true; + pose.root.hasOrientation = true; + } + + for (const control::MorphChannel& channel : evaluated.channels) { + if (channel.morph < 0 || + static_cast(channel.morph) >= model.morphs.size()) { + continue; + } + pose.channels.Set("mmd:" + + model.morphs[static_cast(channel.morph)].name.source, + static_cast(channel.weight)); + } + clip.samples.push_back(std::move(pose)); + } + + return Result::Success(std::move(clip), std::move(diagnostics)); +} + +} // namespace mmd::motion diff --git a/libs/mmdMotionAdapter/tests/CMakeLists.txt b/libs/mmdMotionAdapter/tests/CMakeLists.txt new file mode 100644 index 0000000..fe431da --- /dev/null +++ b/libs/mmdMotionAdapter/tests/CMakeLists.txt @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: Apache-2.0 + +add_executable(mmdMotionAdapter_tests test_adapter.cpp) +target_link_libraries(mmdMotionAdapter_tests PRIVATE mmdMotionAdapter::mmdMotionAdapter) +usdmmd_target_defaults(mmdMotionAdapter_tests) +target_compile_options(mmdMotionAdapter_tests + PRIVATE $,/UNDEBUG,-UNDEBUG>) +add_test(NAME mmdMotionAdapter_unit COMMAND mmdMotionAdapter_tests) + +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/mmdMotionAdapter_link_$.txt" + CONTENT "LINK_LIBRARIES=$ +INTERFACE_LINK_LIBRARIES=$ +") + +if(NOT DEFINED USDMMD_TEST_PYTHON) + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + set(USDMMD_TEST_PYTHON "${Python3_EXECUTABLE}") + endif() +endif() +if(USDMMD_TEST_PYTHON) + add_test(NAME mmdMotionAdapter_boundaries + COMMAND "${USDMMD_TEST_PYTHON}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../../scripts/check_library_boundaries.py" + --name mmdMotionAdapter + --source "${CMAKE_CURRENT_SOURCE_DIR}/.." + --link-file "${CMAKE_CURRENT_BINARY_DIR}/mmdMotionAdapter_link_$.txt" + --binary "$" + --allow mmdControl::mmdControl + --allow mmdModel::mmdModel + --allow mmdSkeletonAdapter::mmdSkeletonAdapter + --allow motionCore::motionCore + --allow-openusd-foundation) +endif() diff --git a/libs/mmdMotionAdapter/tests/test_adapter.cpp b/libs/mmdMotionAdapter/tests/test_adapter.cpp new file mode 100644 index 0000000..adc242a --- /dev/null +++ b/libs/mmdMotionAdapter/tests/test_adapter.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include + +namespace { + +int +AddBone(mmd::CanonicalDocument& model, std::string source, std::string stable, int parent, + mmd::Double3 position) +{ + mmd::Bone bone; + bone.name = {std::move(source), "", stable}; + bone.sourceIndex = model.skeleton.bones.size(); + bone.parent = parent; + bone.position = position; + if (parent == mmd::kNone) { + bone.jointPath = stable; + bone.localTranslation = position; + } else { + const mmd::Bone& p = model.skeleton.bones[static_cast(parent)]; + bone.jointPath = p.jointPath + "/" + stable; + bone.localTranslation = { + position[0] - p.position[0], position[1] - p.position[1], position[2] - p.position[2]}; + } + model.skeleton.bones.push_back(std::move(bone)); + return static_cast(model.skeleton.bones.size() - 1); +} + +mmd::binding::BoneKey +Key(std::uint32_t frame, mmd::Float3 translation, mmd::Float4 rotation) +{ + mmd::binding::BoneKey key; + key.frame = frame; + key.translation = translation; + key.rotation = rotation; + return key; +} + +void +TestClip() +{ + mmd::CanonicalDocument model; + const int root = AddBone(model, "全ての親", "Root", mmd::kNone, {0.0, 0.0, 0.0}); + const int waist = AddBone(model, "腰", "Waist", root, {0.0, 1.0, 0.0}); + const int lower = AddBone(model, "下半身", "Lower", waist, {0.0, 1.1, 0.0}); + const int upper = AddBone(model, "上半身", "Upper", waist, {0.0, 1.4, 0.0}); + AddBone(model, "左足", "LeftLeg", waist, {0.1, 0.9, 0.0}); + AddBone(model, "右足", "RightLeg", waist, {-0.1, 0.9, 0.0}); + AddBone(model, "頭", "Head", upper, {0.0, 1.8, 0.0}); + model.rig.bones.resize(model.skeleton.bones.size()); + + mmd::Morph smile; + smile.name = {"笑い", "", "Smile"}; + smile.type = mmd::MorphType::Vertex; + model.morphs.push_back(smile); + + mmd::binding::BoundMotion bound; + bound.sourceModelName = "テストモデル"; + bound.bones.push_back({root, + {Key(0, {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f, 1.0f}), + Key(30, {2.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f, 1.0f})}}); + constexpr float s = 0.7071067811865476f; + bound.bones.push_back({upper, + {Key(0, {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f, 1.0f}), + Key(30, {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, s, s})}}); + bound.morphs.push_back({0, {{0, 0.25f}, {30, 0.75f}}}); + + const mmd::skeleton::AdaptedSkeleton skeleton = mmd::skeleton::Adapt(model); + const auto prepared = mmd::control::Evaluator::Prepare(model); + assert(prepared); + + const auto result = mmd::motion::BuildClip( + model, bound, prepared.value(), skeleton, mmd::motion::ClipOptions{0.0, 1.0, 1.0}); + assert(result); + const openstrata::motion::MotionClip& clip = result.value(); + assert(clip.samples.size() == 2); + assert(clip.startTime == 0.0 && clip.endTime == 1.0); + assert(clip.nominalFrameRate == 1.0); + assert(clip.source.protocol == "vmd"); + assert(clip.source.sourceId == "テストモデル"); + + const openstrata::motion::MotionPose& first = clip.samples.front(); + const openstrata::motion::MotionPose& last = clip.samples.back(); + assert(first.timestamp == 0.0 && last.timestamp == 1.0); + assert(first.root.hasPosition && first.root.hasOrientation); + assert(std::abs(last.root.worldPosition[0] - 2.0f) < 1.0e-6f); + assert(std::abs(last.root.worldPosition[1] - 1.1f) < 1.0e-6f); + assert( + last.validRotations.test(static_cast(openstrata::motion::HumanJoint::Hips))); + const auto spine = static_cast(openstrata::motion::HumanJoint::Spine); + assert(last.validRotations.test(spine)); + assert(std::abs(last.localRotations[spine].GetImaginary()[2] - s) < 1.0e-5f); + assert(std::abs(*first.channels.Find("mmd:笑い") - 0.25f) < 1.0e-6f); + assert(std::abs(*last.channels.Find("mmd:笑い") - 0.75f) < 1.0e-6f); + + bool missingRequired = false; + for (const mmd::Diagnostic& diagnostic : result.diagnostics()) { + missingRequired |= diagnostic.code == "MMD_MOTION_MISSING_REQUIRED_JOINT"; + } + assert(missingRequired); + assert(skeleton.SourceJoint(openstrata::motion::HumanJoint::Hips) == lower); +} + +void +TestRangeValidation() +{ + const mmd::CanonicalDocument model; + const mmd::binding::BoundMotion bound; + const mmd::skeleton::AdaptedSkeleton skeleton = mmd::skeleton::Adapt(model); + const auto prepared = mmd::control::Evaluator::Prepare(model); + assert(prepared); + const auto result = mmd::motion::BuildClip( + model, bound, prepared.value(), skeleton, mmd::motion::ClipOptions{1.0, 0.0, 30.0}); + assert(!result); + assert(result.fatal()); + assert(result.fatal()->code == "MMD_MOTION_INVALID_SAMPLE_RANGE"); +} + +} // namespace + +int +main() +{ + TestClip(); + TestRangeValidation(); + return 0; +} diff --git a/libs/mmdMotionBinding/include/mmdMotionBinding/Bind.h b/libs/mmdMotionBinding/include/mmdMotionBinding/Bind.h index f0843b2..12fd3ab 100644 --- a/libs/mmdMotionBinding/include/mmdMotionBinding/Bind.h +++ b/libs/mmdMotionBinding/include/mmdMotionBinding/Bind.h @@ -70,6 +70,9 @@ struct IkTrack { }; struct BoundMotion { + /// The VMD header's decoded model name. Binding keeps it as provenance; + /// it never participates in name matching after this point. + std::string sourceModelName; std::vector bones; ///< ascending joint index std::vector morphs; ///< ascending morph index std::vector ik; ///< ascending joint index diff --git a/libs/mmdMotionBinding/src/Bind.cpp b/libs/mmdMotionBinding/src/Bind.cpp index 20ef922..94d293e 100644 --- a/libs/mmdMotionBinding/src/Bind.cpp +++ b/libs/mmdMotionBinding/src/Bind.cpp @@ -127,6 +127,7 @@ Result Bind(const motionVmd::Motion& motion, const CanonicalDocument& model) { BoundMotion bound; + bound.sourceModelName = motion.header.modelName.text; DiagnosticList diagnostics; const std::vector& bones = model.skeleton.bones; diff --git a/libs/mmdMotionBinding/tests/test_bind.cpp b/libs/mmdMotionBinding/tests/test_bind.cpp index c445045..4646113 100644 --- a/libs/mmdMotionBinding/tests/test_bind.cpp +++ b/libs/mmdMotionBinding/tests/test_bind.cpp @@ -170,6 +170,7 @@ TestBind() assert(result.diagnostics()[5].location.table == "ikKeyframes"); const binding::BoundMotion& bound = result.value(); + assert(bound.sourceModelName == motion.header.modelName.text); // The ambiguous name binds the lower source index, 右腕捩りボーン線. std::vector joints; for (const binding::BoneTrack& t : bound.bones) { diff --git a/libs/mmdSkeletonAdapter/CMakeLists.txt b/libs/mmdSkeletonAdapter/CMakeLists.txt new file mode 100644 index 0000000..3d28e06 --- /dev/null +++ b/libs/mmdSkeletonAdapter/CMakeLists.txt @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: Apache-2.0 +cmake_minimum_required(VERSION 3.22) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../../VERSION") + file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../../VERSION" + _mmd_skeleton_adapter_version LIMIT_COUNT 1) + string(STRIP "${_mmd_skeleton_adapter_version}" _mmd_skeleton_adapter_version) +else() + set(_mmd_skeleton_adapter_version "0.1.0") +endif() + +project(mmdSkeletonAdapter VERSION ${_mmd_skeleton_adapter_version} + DESCRIPTION "MMD skeleton adapter for usd-motion-plugins" LANGUAGES CXX) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 20) +endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT DEFINED MMDSKELETONADAPTER_BUILD_TESTS) + if(DEFINED USDMMD_BUILD_TESTS) + set(_mmd_skeleton_adapter_tests_default "${USDMMD_BUILD_TESTS}") + else() + set(_mmd_skeleton_adapter_tests_default "${PROJECT_IS_TOP_LEVEL}") + endif() + option(MMDSKELETONADAPTER_BUILD_TESTS "Build mmdSkeletonAdapter tests" + ${_mmd_skeleton_adapter_tests_default}) +endif() + +include("${CMAKE_CURRENT_SOURCE_DIR}/../../cmake/UsdMmdTargets.cmake") + +if(NOT TARGET mmdModel::mmdModel) + find_package(mmdModel CONFIG REQUIRED) +endif() +if(NOT TARGET motionRetarget::motionRetarget) + find_package(motionRetarget 0.5 CONFIG REQUIRED) +endif() + +add_library(mmdSkeletonAdapter STATIC src/Adapter.cpp) +add_library(mmdSkeletonAdapter::mmdSkeletonAdapter ALIAS mmdSkeletonAdapter) +set_target_properties(mmdSkeletonAdapter PROPERTIES + EXPORT_NAME mmdSkeletonAdapter POSITION_INDEPENDENT_CODE ON) +target_compile_features(mmdSkeletonAdapter PUBLIC cxx_std_20) +target_include_directories(mmdSkeletonAdapter PUBLIC + "$" + "$") +target_link_libraries(mmdSkeletonAdapter + PUBLIC mmdModel::mmdModel motionRetarget::motionRetarget) +usdmmd_target_defaults(mmdSkeletonAdapter) + +include(GNUInstallDirs) +install(TARGETS mmdSkeletonAdapter EXPORT mmdSkeletonAdapterTargets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + INCLUDES DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install(DIRECTORY include/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +include(CMakePackageConfigHelpers) +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mmdSkeletonAdapterConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/mmdSkeletonAdapterConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/mmdSkeletonAdapter") +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/mmdSkeletonAdapterConfigVersion.cmake" + VERSION ${PROJECT_VERSION} COMPATIBILITY SameMinorVersion) +install(EXPORT mmdSkeletonAdapterTargets FILE mmdSkeletonAdapterTargets.cmake + NAMESPACE mmdSkeletonAdapter:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/mmdSkeletonAdapter") +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/mmdSkeletonAdapterConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/mmdSkeletonAdapterConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/mmdSkeletonAdapter") + +if(MMDSKELETONADAPTER_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/libs/mmdSkeletonAdapter/cmake/mmdSkeletonAdapterConfig.cmake.in b/libs/mmdSkeletonAdapter/cmake/mmdSkeletonAdapterConfig.cmake.in new file mode 100644 index 0000000..5c8aef3 --- /dev/null +++ b/libs/mmdSkeletonAdapter/cmake/mmdSkeletonAdapterConfig.cmake.in @@ -0,0 +1,11 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +if(NOT TARGET mmdModel::mmdModel) + find_dependency(mmdModel CONFIG) +endif() +if(NOT TARGET motionRetarget::motionRetarget) + find_dependency(motionRetarget 0.5 CONFIG) +endif() +include("${CMAKE_CURRENT_LIST_DIR}/mmdSkeletonAdapterTargets.cmake") +check_required_components(mmdSkeletonAdapter) diff --git a/libs/mmdSkeletonAdapter/include/mmdSkeletonAdapter/Adapter.h b/libs/mmdSkeletonAdapter/include/mmdSkeletonAdapter/Adapter.h new file mode 100644 index 0000000..9bb0c93 --- /dev/null +++ b/libs/mmdSkeletonAdapter/include/mmdSkeletonAdapter/Adapter.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The narrow PMX-skeleton edge into usd-motion-plugins. It owns MMD's +// versioned source-name role table, but no generic retarget algorithm. +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace mmd::skeleton { + +inline constexpr int kRoleTableVersion = 1; + +/// Everything the shared motion core needs from one canonical PMX skeleton. +/// Source joints build VMD-derived clips; the target map drives the PMX stage. +struct AdaptedSkeleton { + openstrata::motion::SkeletonDescriptor skeleton; + openstrata::motion::RetargetMap targetMap; + openstrata::motion::SourceRestPose sourceRest; + std::array sourceJoints{}; + std::bitset sourcePresent; + std::vector requiredJoints; + int roleTableVersion = kRoleTableVersion; + + int SourceJoint(openstrata::motion::HumanJoint role) const noexcept; +}; + +/// Applies role-table version 1 to `model`, and builds target data whose joint +/// tokens and rest transforms exactly match /Asset/skel/Skeleton. +AdaptedSkeleton Adapt(const CanonicalDocument& model); + +} // namespace mmd::skeleton diff --git a/libs/mmdSkeletonAdapter/openstrata.library.yaml b/libs/mmdSkeletonAdapter/openstrata.library.yaml new file mode 100644 index 0000000..0c4f3a6 --- /dev/null +++ b/libs/mmdSkeletonAdapter/openstrata.library.yaml @@ -0,0 +1,36 @@ +# OpenStrata plain-library descriptor. The only cross-repository edge is the +# released, digest-pinned motionRetarget package (WORKSPACE.md §2.4). +schema: openstrata.library/v1alpha1 +library: + id: mmdSkeletonAdapter + version: 0.1.0 +requires: + libraries: + - id: mmdModel + version: ">=0.1,<0.2" + - id: motionRetarget + version: ">=0.5,<0.6" + artifact: + targets: + cy2026-linux-x86_64-py313-usd: + digest: sha256:16f18e2808e44569b6251faf163bd6f91387058fe1e009608af7d631d776ee67 + source: oci://ghcr.io/animu-sphere/usd-motion-plugins@sha256:044aef49eb9f6a1b2b44a666e734e14d000978e144b66f1b7445eddb4f8d4459 + cy2026-macos-arm64-py313-usd: + digest: sha256:adc79c4391c8abe9ebb3782fb1e73defdd037cf36957bd1ac17da1c56d525775 + source: oci://ghcr.io/animu-sphere/usd-motion-plugins@sha256:8dbe77cb246a6250275dc047edd6d31468c96be3c6bb5761b9d981013eb5ed98 + cy2026-windows-x86_64-py313-usd: + digest: sha256:d6f835997a5a31fb26672fb05008e0bae2a936b90e36f7488ae5a8a0f1625c37 + source: oci://ghcr.io/animu-sphere/usd-motion-plugins@sha256:ed3ead135894767aa04de2232c4e65206d7ad3bb5fd43ad8e60fbc44e4e79d1a +cmake: + package: mmdSkeletonAdapter + target: mmdSkeletonAdapter::mmdSkeletonAdapter +package: + standalone: true + aggregate_member: false +package_contract: + package_name: mmdSkeletonAdapter + exported_targets: [mmdSkeletonAdapter::mmdSkeletonAdapter] + public_headers: [include/mmdSkeletonAdapter/**] + consumer: + include: mmdSkeletonAdapter/Adapter.h + symbol: mmd::skeleton::Adapt diff --git a/libs/mmdSkeletonAdapter/src/Adapter.cpp b/libs/mmdSkeletonAdapter/src/Adapter.cpp new file mode 100644 index 0000000..4b43384 --- /dev/null +++ b/libs/mmdSkeletonAdapter/src/Adapter.cpp @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include "mmdSkeletonAdapter/Adapter.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace mmd::skeleton { +namespace { + +using openstrata::motion::HumanJoint; +using openstrata::motion::HumanJointCount; + +constexpr int kUnmapped = openstrata::motion::RetargetMap::kUnmapped; + +struct RoleSpec { + HumanJoint role; + std::initializer_list candidates; +}; + +const std::array& +RoleSpecs() +{ + static const std::array specs{{ + {HumanJoint::Hips, {"下半身"}}, + {HumanJoint::Spine, {"上半身"}}, + {HumanJoint::Chest, {"上半身2"}}, + {HumanJoint::UpperChest, {"上半身3"}}, + {HumanJoint::Neck, {"首"}}, + {HumanJoint::Head, {"頭"}}, + {HumanJoint::LeftEye, {"左目"}}, + {HumanJoint::RightEye, {"右目"}}, + {HumanJoint::LeftUpperLeg, {"左足D", "左足"}}, + {HumanJoint::LeftLowerLeg, {"左ひざD", "左ひざ"}}, + {HumanJoint::LeftFoot, {"左足首D", "左足首"}}, + {HumanJoint::LeftToes, {"左足先EX"}}, + {HumanJoint::RightUpperLeg, {"右足D", "右足"}}, + {HumanJoint::RightLowerLeg, {"右ひざD", "右ひざ"}}, + {HumanJoint::RightFoot, {"右足首D", "右足首"}}, + {HumanJoint::RightToes, {"右足先EX"}}, + {HumanJoint::LeftShoulder, {"左肩"}}, + {HumanJoint::LeftUpperArm, {"左腕"}}, + {HumanJoint::LeftLowerArm, {"左ひじ"}}, + {HumanJoint::LeftHand, {"左手首"}}, + {HumanJoint::RightShoulder, {"右肩"}}, + {HumanJoint::RightUpperArm, {"右腕"}}, + {HumanJoint::RightLowerArm, {"右ひじ"}}, + {HumanJoint::RightHand, {"右手首"}}, + {HumanJoint::LeftThumbMetacarpal, {"左親指0"}}, + {HumanJoint::LeftThumbProximal, {"左親指1"}}, + {HumanJoint::LeftThumbDistal, {"左親指2"}}, + {HumanJoint::LeftIndexProximal, {"左人指1"}}, + {HumanJoint::LeftIndexIntermediate, {"左人指2"}}, + {HumanJoint::LeftIndexDistal, {"左人指3"}}, + {HumanJoint::LeftMiddleProximal, {"左中指1"}}, + {HumanJoint::LeftMiddleIntermediate, {"左中指2"}}, + {HumanJoint::LeftMiddleDistal, {"左中指3"}}, + {HumanJoint::LeftRingProximal, {"左薬指1"}}, + {HumanJoint::LeftRingIntermediate, {"左薬指2"}}, + {HumanJoint::LeftRingDistal, {"左薬指3"}}, + {HumanJoint::LeftLittleProximal, {"左小指1"}}, + {HumanJoint::LeftLittleIntermediate, {"左小指2"}}, + {HumanJoint::LeftLittleDistal, {"左小指3"}}, + {HumanJoint::RightThumbMetacarpal, {"右親指0"}}, + {HumanJoint::RightThumbProximal, {"右親指1"}}, + {HumanJoint::RightThumbDistal, {"右親指2"}}, + {HumanJoint::RightIndexProximal, {"右人指1"}}, + {HumanJoint::RightIndexIntermediate, {"右人指2"}}, + {HumanJoint::RightIndexDistal, {"右人指3"}}, + {HumanJoint::RightMiddleProximal, {"右中指1"}}, + {HumanJoint::RightMiddleIntermediate, {"右中指2"}}, + {HumanJoint::RightMiddleDistal, {"右中指3"}}, + {HumanJoint::RightRingProximal, {"右薬指1"}}, + {HumanJoint::RightRingIntermediate, {"右薬指2"}}, + {HumanJoint::RightRingDistal, {"右薬指3"}}, + {HumanJoint::RightLittleProximal, {"右小指1"}}, + {HumanJoint::RightLittleIntermediate, {"右小指2"}}, + {HumanJoint::RightLittleDistal, {"右小指3"}}, + }}; + return specs; +} + +const std::vector& +RequiredJoints() +{ + static const std::vector required{ + HumanJoint::Hips, + HumanJoint::Spine, + HumanJoint::Head, + HumanJoint::LeftUpperLeg, + HumanJoint::LeftLowerLeg, + HumanJoint::LeftFoot, + HumanJoint::RightUpperLeg, + HumanJoint::RightLowerLeg, + HumanJoint::RightFoot, + HumanJoint::LeftUpperArm, + HumanJoint::LeftLowerArm, + HumanJoint::LeftHand, + HumanJoint::RightUpperArm, + HumanJoint::RightLowerArm, + HumanJoint::RightHand, + }; + return required; +} + +int +FindBone(const CanonicalDocument& model, std::initializer_list candidates) +{ + for (const std::string_view candidate : candidates) { + int found = kUnmapped; + std::size_t sourceIndex = std::numeric_limits::max(); + for (std::size_t i = 0; i < model.skeleton.bones.size(); ++i) { + const Bone& bone = model.skeleton.bones[i]; + if (bone.name.source == candidate && bone.sourceIndex < sourceIndex) { + found = static_cast(i); + sourceIndex = bone.sourceIndex; + } + } + if (found != kUnmapped) { + return found; + } + } + return kUnmapped; +} + +bool +IsAncestor(const std::vector& bones, int ancestor, int joint) +{ + while (joint != kNone && joint >= 0 && static_cast(joint) < bones.size()) { + if (joint == ancestor) { + return true; + } + joint = bones[static_cast(joint)].parent; + } + return false; +} + +int +TargetHips(const CanonicalDocument& model, const std::array& source) +{ + const int spine = source[static_cast(HumanJoint::Spine)]; + const int left = source[static_cast(HumanJoint::LeftUpperLeg)]; + const int right = source[static_cast(HumanJoint::RightUpperLeg)]; + if (spine == kUnmapped || left == kUnmapped || right == kUnmapped) { + return kUnmapped; + } + int candidate = spine; + while (candidate != kNone && candidate >= 0 && + static_cast(candidate) < model.skeleton.bones.size()) { + if (IsAncestor(model.skeleton.bones, candidate, left) && + IsAncestor(model.skeleton.bones, candidate, right)) { + return candidate; + } + candidate = model.skeleton.bones[static_cast(candidate)].parent; + } + return kUnmapped; +} + +} // namespace + +int +AdaptedSkeleton::SourceJoint(openstrata::motion::HumanJoint role) const noexcept +{ + const std::size_t index = static_cast(role); + return index < sourceJoints.size() ? sourceJoints[index] : kUnmapped; +} + +AdaptedSkeleton +Adapt(const CanonicalDocument& model) +{ + AdaptedSkeleton adapted; + adapted.sourceJoints.fill(kUnmapped); + adapted.requiredJoints = RequiredJoints(); + + for (const RoleSpec& spec : RoleSpecs()) { + const int joint = FindBone(model, spec.candidates); + const std::size_t role = static_cast(spec.role); + adapted.sourceJoints[role] = joint; + adapted.sourcePresent.set(role, joint != kUnmapped); + } + + std::vector tokens; + std::vector rests; + tokens.reserve(model.skeleton.bones.size()); + rests.reserve(model.skeleton.bones.size()); + for (const Bone& bone : model.skeleton.bones) { + tokens.push_back(bone.jointPath); + pxr::GfMatrix4d rest(1.0); + rest.SetTranslateOnly(pxr::GfVec3d( + bone.localTranslation[0], bone.localTranslation[1], bone.localTranslation[2])); + rests.push_back(rest); + } + const openstrata::motion::SkeletonDescriptorResult built = + openstrata::motion::BuildSkeletonDescriptor(tokens, rests); + if (built.skeleton) { + adapted.skeleton = *built.skeleton; + } + + for (const RoleSpec& spec : RoleSpecs()) { + int joint = adapted.SourceJoint(spec.role); + if (spec.role == HumanJoint::Hips) { + joint = TargetHips(model, adapted.sourceJoints); + } + if (joint != kUnmapped) { + adapted.targetMap.SetJointIndex(spec.role, joint, model.skeleton.bones.size()); + } + } + + for (std::size_t i = 0; i < HumanJointCount; ++i) { + if (!adapted.sourcePresent.test(i)) { + continue; + } + const HumanJoint role = static_cast(i); + const int joint = adapted.sourceJoints[i]; + const auto parent = openstrata::motion::NearestPresentAncestor(role, adapted.sourcePresent); + if (parent) { + adapted.sourceRest.SetParent(role, *parent); + } + + const Double3& position = model.skeleton.bones[static_cast(joint)].position; + pxr::GfVec3f translation(static_cast(position[0]), + static_cast(position[1]), + static_cast(position[2])); + if (parent) { + const int parentJoint = adapted.SourceJoint(*parent); + const Double3& parentPosition = + model.skeleton.bones[static_cast(parentJoint)].position; + translation -= pxr::GfVec3f(static_cast(parentPosition[0]), + static_cast(parentPosition[1]), + static_cast(parentPosition[2])); + } + adapted.sourceRest.localTranslations[i] = translation; + } + + return adapted; +} + +} // namespace mmd::skeleton diff --git a/libs/mmdSkeletonAdapter/tests/CMakeLists.txt b/libs/mmdSkeletonAdapter/tests/CMakeLists.txt new file mode 100644 index 0000000..8eb5898 --- /dev/null +++ b/libs/mmdSkeletonAdapter/tests/CMakeLists.txt @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 + +add_executable(mmdSkeletonAdapter_tests test_adapter.cpp) +target_link_libraries(mmdSkeletonAdapter_tests PRIVATE + mmdSkeletonAdapter::mmdSkeletonAdapter) +usdmmd_target_defaults(mmdSkeletonAdapter_tests) +target_compile_options(mmdSkeletonAdapter_tests + PRIVATE $,/UNDEBUG,-UNDEBUG>) +add_test(NAME mmdSkeletonAdapter_unit COMMAND mmdSkeletonAdapter_tests) + +file(GENERATE + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/mmdSkeletonAdapter_link_$.txt" + CONTENT "LINK_LIBRARIES=$ +INTERFACE_LINK_LIBRARIES=$ +") + +if(NOT DEFINED USDMMD_TEST_PYTHON) + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + set(USDMMD_TEST_PYTHON "${Python3_EXECUTABLE}") + endif() +endif() +if(USDMMD_TEST_PYTHON) + add_test(NAME mmdSkeletonAdapter_boundaries + COMMAND "${USDMMD_TEST_PYTHON}" + "${CMAKE_CURRENT_SOURCE_DIR}/../../../scripts/check_library_boundaries.py" + --name mmdSkeletonAdapter + --source "${CMAKE_CURRENT_SOURCE_DIR}/.." + --link-file "${CMAKE_CURRENT_BINARY_DIR}/mmdSkeletonAdapter_link_$.txt" + --binary "$" + --allow mmdModel::mmdModel + --allow motionRetarget::motionRetarget + --allow-openusd-foundation) +endif() diff --git a/libs/mmdSkeletonAdapter/tests/test_adapter.cpp b/libs/mmdSkeletonAdapter/tests/test_adapter.cpp new file mode 100644 index 0000000..597d4db --- /dev/null +++ b/libs/mmdSkeletonAdapter/tests/test_adapter.cpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include + +namespace { + +using openstrata::motion::HumanJoint; + +int +AddBone(mmd::CanonicalDocument& model, std::string source, std::string english, std::string stable, + int parent, mmd::Double3 position, std::size_t sourceIndex) +{ + mmd::Bone bone; + bone.name = {std::move(source), std::move(english), stable}; + bone.sourceIndex = sourceIndex; + bone.parent = parent; + bone.position = position; + if (parent == mmd::kNone) { + bone.jointPath = stable; + bone.localTranslation = position; + } else { + const mmd::Bone& p = model.skeleton.bones[static_cast(parent)]; + bone.jointPath = p.jointPath + "/" + stable; + bone.localTranslation = { + position[0] - p.position[0], position[1] - p.position[1], position[2] - p.position[2]}; + } + model.skeleton.bones.push_back(std::move(bone)); + return static_cast(model.skeleton.bones.size() - 1); +} + +void +TestRolesAndSkeleton() +{ + mmd::CanonicalDocument model; + const int root = AddBone(model, "全ての親", "", "Root", mmd::kNone, {0.0, 0.0, 0.0}, 0); + const int waist = AddBone(model, "腰", "", "Waist", root, {0.0, 1.0, 0.0}, 1); + const int lower = AddBone(model, "下半身", "", "Lower", waist, {0.0, 1.1, 0.0}, 2); + const int spine = AddBone(model, "上半身", "", "Spine", waist, {0.0, 1.4, 0.0}, 3); + const int leftRegular = AddBone(model, "左足", "", "LeftLeg", waist, {0.1, 0.9, 0.0}, 4); + const int leftD = AddBone(model, "左足D", "", "LeftLegD", waist, {0.1, 0.9, 0.0}, 40); + const int right = AddBone(model, "右足", "", "RightLeg", waist, {-0.1, 0.9, 0.0}, 5); + const int head = AddBone(model, "頭", "", "Head", spine, {0.0, 1.8, 0.0}, 6); + AddBone(model, "別名", "左腕", "WrongEnglish", spine, {0.2, 1.5, 0.0}, 7); + const int arm = AddBone(model, "左腕", "Bip001", "LeftArm", spine, {0.3, 1.5, 0.0}, 8); + + const mmd::skeleton::AdaptedSkeleton adapted = mmd::skeleton::Adapt(model); + assert(adapted.roleTableVersion == 1); + assert(adapted.SourceJoint(HumanJoint::Hips) == lower); + assert(adapted.SourceJoint(HumanJoint::Spine) == spine); + assert(adapted.SourceJoint(HumanJoint::LeftUpperLeg) == leftD); + assert(adapted.SourceJoint(HumanJoint::RightUpperLeg) == right); + assert(adapted.SourceJoint(HumanJoint::Head) == head); + assert(adapted.SourceJoint(HumanJoint::LeftUpperArm) == arm); + assert(adapted.SourceJoint(HumanJoint::Jaw) == -1); + assert(adapted.targetMap.GetJointIndex(HumanJoint::Hips) == waist); + assert(adapted.targetMap.GetJointIndex(HumanJoint::Spine) == spine); + assert(adapted.targetMap.GetJointIndex(HumanJoint::LeftUpperLeg) == leftD); + assert(leftRegular != leftD); + + assert(adapted.skeleton.GetSize() == model.skeleton.bones.size()); + assert(adapted.skeleton.GetJoints()[static_cast(arm)].token == + "Root/Waist/Spine/LeftArm"); + assert(adapted.skeleton.GetJoints()[static_cast(spine)].parent == waist); + assert( + std::abs(adapted.skeleton.GetJoints()[static_cast(spine)].restTranslation[1] - + 0.4f) < 1.0e-6f); + + const std::size_t spineRole = static_cast(HumanJoint::Spine); + assert(adapted.sourceRest.parents[spineRole] == static_cast(HumanJoint::Hips)); + assert(std::abs(adapted.sourceRest.localTranslations[spineRole][1] - 0.3f) < 1.0e-6f); + assert(adapted.requiredJoints.size() == 15); +} + +void +TestTargetHipsRequiresBothLegs() +{ + mmd::CanonicalDocument model; + const int root = AddBone(model, "腰", "", "Waist", mmd::kNone, {0.0, 0.0, 0.0}, 0); + AddBone(model, "下半身", "", "Lower", root, {0.0, 0.1, 0.0}, 1); + AddBone(model, "上半身", "", "Spine", root, {0.0, 0.2, 0.0}, 2); + AddBone(model, "左足", "", "LeftLeg", root, {0.1, -0.1, 0.0}, 3); + const mmd::skeleton::AdaptedSkeleton adapted = mmd::skeleton::Adapt(model); + assert(!adapted.targetMap.IsMapped(HumanJoint::Hips)); +} + +} // namespace + +int +main() +{ + TestRolesAndSkeleton(); + TestTargetHipsRequiresBothLegs(); + return 0; +} diff --git a/openstrata.toml b/openstrata.toml index ab75e37..1134cfe 100644 --- a/openstrata.toml +++ b/openstrata.toml @@ -18,6 +18,8 @@ members = [ "libs/motionVmd", "libs/mmdMotionBinding", "libs/mmdControl", + "libs/mmdSkeletonAdapter", + "libs/mmdMotionAdapter", "tools/mmdInspect", "tools/vmdInspect", ] diff --git a/scripts/check_docs.py b/scripts/check_docs.py index d6c0ef9..5320e32 100644 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -106,7 +106,8 @@ def markdown_files(root: pathlib.Path) -> list[pathlib.Path]: """ try: listed = subprocess.run( - ["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", + ["git", "-c", f"safe.directory={root}", "-C", str(root), + "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "*.md"], check=True, stdout=subprocess.PIPE).stdout.decode("utf-8") files = [root / name for name in listed.split("\0") if name] @@ -148,8 +149,11 @@ def check_file(path: pathlib.Path, cache: dict) -> list[str]: return errors -# `version: ">=0.1,<0.2"` under a manifest's `requires.libraries`. -REQUIRED_RANGE = re.compile(r'^\s+version:\s*">=([0-9.]+),<([0-9.]+)"', re.MULTILINE) +# One `requires.libraries` row. External libraries have their own version and +# are deliberately not compared with this repository's VERSION. +REQUIRED_RANGE = re.compile( + r'^\s+- id:\s*([^\s#]+)\s*\n\s+version:\s*">=([0-9.]+),<([0-9.]+)"', + re.MULTILINE) FIND_PACKAGE_VERSION = re.compile(r"find_package\((\w+)\s+([0-9.]+)\s+CONFIG") @@ -160,13 +164,15 @@ def key(text: str) -> tuple[int, ...]: return key(lower) <= key(version) < key(upper) -def check_ranges(root: pathlib.Path, manifest: pathlib.Path, version: str) -> list[str]: +def check_ranges(root: pathlib.Path, manifest: pathlib.Path, version: str, + sibling_ids: set[str]) -> list[str]: """Every sibling this workspace requires is built at VERSION, so every required range has to admit it -- or the release cannot resolve itself.""" where = manifest.relative_to(root).as_posix() return [f"{where}: required range >={lower},<{upper} excludes {version}" - for lower, upper in REQUIRED_RANGE.findall(manifest.read_text(encoding="utf-8")) - if not in_range(version, lower, upper)] + for dependency, lower, upper in REQUIRED_RANGE.findall( + manifest.read_text(encoding="utf-8")) + if dependency in sibling_ids and not in_range(version, lower, upper)] def check_mirrors(root: pathlib.Path) -> list[str]: @@ -183,9 +189,11 @@ def expect(path: pathlib.Path, pattern: str, want: str, what: str) -> None: expect(root / "openstrata.toml", r'^version\s*=\s*"([^"]+)"', version, "project version") - for manifest in sorted(root.glob("*/*/openstrata.*.yaml")): + manifests = sorted(root.glob("*/*/openstrata.*.yaml")) + sibling_ids = {manifest.parent.name for manifest in manifests} + for manifest in manifests: expect(manifest, r"^\s+version:\s*([0-9][^\s#]*)", version, "version") - errors.extend(check_ranges(root, manifest, version)) + errors.extend(check_ranges(root, manifest, version, sibling_ids)) for cmake in sorted(root.glob("*/*/CMakeLists.txt")): if "../../VERSION" in cmake.read_text(encoding="utf-8"): expect(cmake, r'set\(_mmd_\w+_version "([^"]+)"\)', version, @@ -292,7 +300,7 @@ def selftest() -> int: if in_range(version, lower, upper) != want: failures.append(f"in_range({version}, {lower}, {upper}) != {want}") ranges = REQUIRED_RANGE.findall(' - id: a\n version: ">=0.1,<0.2"\n') - if ranges != [("0.1", "0.2")]: + if ranges != [("a", "0.1", "0.2")]: failures.append(f"REQUIRED_RANGE found {ranges}") declarations = CODE_DECLARATION.findall( diff --git a/scripts/check_installed_consumer.py b/scripts/check_installed_consumer.py index 369ec30..3116151 100644 --- a/scripts/check_installed_consumer.py +++ b/scripts/check_installed_consumer.py @@ -10,7 +10,8 @@ 2. tests/installed_consumer/, copied out of the repository, configures against the prefix alone -- finding mmdModel, whose package finds mmdPmx, mmdMotionBinding, whose package finds mmdModel and motionVmd, and - mmdControl, whose package finds mmdMotionBinding and mmdModel --, + mmdControl, and both Phase 9 adapters with their released shared-motion + dependencies --, builds, reads and canonicalizes every PMX fixture, reads every VMD fixture, binds one to a PMX and evaluates it; 3. the installed mmd_inspect and vmd_inspect read every fixture from the @@ -61,7 +62,8 @@ def check_prefix(prefix: pathlib.Path, build_dir: pathlib.Path) -> list[str]: # The libraries install under CMAKE_INSTALL_LIBDIR, which GNUInstallDirs # makes lib64 on some Linux distributions; the plugin bundle's lib/ is # fixed by its plugInfo.json LibraryPath (PACKAGE_CONTRACT.md). - for package in ("mmdPmx", "mmdModel", "motionVmd", "mmdMotionBinding", "mmdControl"): + for package in ("mmdPmx", "mmdModel", "motionVmd", "mmdMotionBinding", "mmdControl", + "mmdSkeletonAdapter", "mmdMotionAdapter"): config_dirs = sorted(p.parent for p in prefix.glob( f"lib*/cmake/{package}/{package}Config.cmake")) if len(config_dirs) != 1: @@ -78,6 +80,8 @@ def check_prefix(prefix: pathlib.Path, build_dir: pathlib.Path) -> list[str]: pathlib.Path("include", "motionVmd", "Reader.h"), pathlib.Path("include", "mmdMotionBinding", "Bind.h"), pathlib.Path("include", "mmdControl", "Evaluator.h"), + pathlib.Path("include", "mmdSkeletonAdapter", "Adapter.h"), + pathlib.Path("include", "mmdMotionAdapter", "Adapter.h"), pathlib.Path("bin", executable("mmd_inspect")), pathlib.Path("bin", executable("vmd_inspect")), pathlib.Path("lib", shared_library("UsdMmdFileFormat")), @@ -134,6 +138,8 @@ def main() -> int: parser.add_argument("--build-dir", required=True, type=pathlib.Path) parser.add_argument("--config", default="Release") parser.add_argument("--usd-root", required=True, type=pathlib.Path) + parser.add_argument("--dependency-prefix", action="append", default=[], + type=pathlib.Path) parser.add_argument("--generator") parser.add_argument("--make-program") parser.add_argument("--cxx-compiler") @@ -168,8 +174,9 @@ def main() -> int: fixtures = work / "fixtures" shutil.copytree(FIXTURES, fixtures) build = work / "consumer-build" + search = [prefix, args.usd_root, *args.dependency_prefix] configure = ["cmake", "-S", source, "-B", build, - f"-DCMAKE_PREFIX_PATH={prefix.as_posix()}"] + "-DCMAKE_PREFIX_PATH=" + ";".join(p.as_posix() for p in search)] if args.generator: configure += ["-G", args.generator] if args.make_program: @@ -280,6 +287,24 @@ def main() -> int: print(f"ok the installed mmdControl evaluated sample.vmd over {model}: " f"{lines[0]}") + adapter_probes = sorted(build.rglob(executable("adapter_probe"))) + if not adapter_probes: + print("the consumer built no adapter_probe", file=sys.stderr) + return 1 + adapted = subprocess.run( + [str(adapter_probes[0]), str(vmd_fixtures / "sample.vmd"), + str(fixtures / model)], text=True, encoding="utf-8", + stdout=subprocess.PIPE) + adapter_lines = adapted.stdout.splitlines() + if (adapted.returncode != 0 or len(adapter_lines) != 1 + or not adapter_lines[0].startswith("samples=") + or f"joints={joints}" not in adapter_lines[0] + or not adapter_lines[0].endswith("roleTable=1")): + print(f"the installed adapters printed {adapted.stdout!r}", file=sys.stderr) + return 1 + print(f"ok the installed Phase 9 adapters built a shared MotionClip: " + f"{adapter_lines[0]}") + # The Python host, with only the prefix on the plugin path. env = dict(os.environ) env["PXR_PLUGINPATH_NAME"] = str(prefix / PLUGIN_RESOURCES) diff --git a/scripts/check_library_boundaries.py b/scripts/check_library_boundaries.py index 2a92d41..ff20813 100644 --- a/scripts/check_library_boundaries.py +++ b/scripts/check_library_boundaries.py @@ -48,6 +48,9 @@ # libusd_tf.dylib on macOS, and the monolithic usd_ms in any of those forms. USD_LIBRARY = re.compile(r"\b(?:lib)?usd_[A-Za-z0-9]+\.(?:dll|so|dylib)\b", re.IGNORECASE) +USD_FOUNDATION_LIBRARY = re.compile( + r"^(?:lib)?usd_(?:arch|tf|gf|js|trace|work|plug|vt)\.(?:dll|so|dylib)$", + re.IGNORECASE) SOURCE_SUFFIXES = {".h", ".hpp", ".hh", ".inl", ".c", ".cc", ".cpp", ".cxx"} @@ -155,6 +158,8 @@ def check(args: argparse.Namespace) -> list[str]: errors.append(f"could not inspect {args.binary}: {exc}") dependencies = "" for match in sorted(set(USD_LIBRARY.findall(dependencies))): + if args.allow_openusd_foundation and USD_FOUNDATION_LIBRARY.match(match): + continue errors.append(f"{args.binary.name}, which links only {args.name}, " f"imports the OpenUSD library {match}") return errors @@ -193,6 +198,12 @@ def expect(condition: bool, what: str) -> None: "libc.so.6"): expect(not USD_LIBRARY.search(f" {name}\n"), f"{name} is mistaken for OpenUSD") + for name in ("usd_gf.dll", "libusd_tf.so", "libusd_vt.dylib"): + expect(bool(USD_FOUNDATION_LIBRARY.match(name)), + f"{name} is not recognized as an allowed foundation library") + for name in ("usd_sdf.dll", "libusd_usd.so", "usd_ms.dll"): + expect(not USD_FOUNDATION_LIBRARY.match(name), + f"{name} is mistaken for a foundation library") import tempfile with tempfile.TemporaryDirectory() as scratch: @@ -224,6 +235,8 @@ def main() -> int: help="a link item WORKSPACE.md §2.1 permits") parser.add_argument("--forbid-include", action="append", default=[], help="a header prefix WORKSPACE.md §2.2 forbids, e.g. mmdPmx/") + parser.add_argument("--allow-openusd-foundation", action="store_true", + help="allow only the arch/tf/gf/js/trace/work/plug/vt runtime leaves") args = parser.parse_args() errors = check(args) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8c5f259..989599c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -85,12 +85,26 @@ if(CMAKE_GENERATOR MATCHES "Ninja|Makefiles") --make-program "${CMAKE_MAKE_PROGRAM}" --cxx-compiler "${CMAKE_CXX_COMPILER}") endif() +set(_motion_dependency_prefixes) +foreach(_motion_package_dir IN ITEMS "${motionCore_DIR}" "${motionRetarget_DIR}") + if(_motion_package_dir) + get_filename_component(_motion_package_prefix + "${_motion_package_dir}/../../.." ABSOLUTE) + list(APPEND _motion_dependency_prefixes "${_motion_package_prefix}") + endif() +endforeach() +list(REMOVE_DUPLICATES _motion_dependency_prefixes) +set(_consumer_dependencies) +foreach(_motion_package_prefix IN LISTS _motion_dependency_prefixes) + list(APPEND _consumer_dependencies --dependency-prefix "${_motion_package_prefix}") +endforeach() add_test(NAME workspace_installed_consumer COMMAND "${USDMMD_TEST_PYTHON}" "${PROJECT_SOURCE_DIR}/scripts/check_installed_consumer.py" --build-dir "${PROJECT_BINARY_DIR}" --config "$" --usd-root "${_usd_root}" + ${_consumer_dependencies} ${_consumer_toolchain}) set_tests_properties(workspace_installed_consumer PROPERTIES LABELS "installed-consumer") diff --git a/tests/installed_consumer/CMakeLists.txt b/tests/installed_consumer/CMakeLists.txt index d518882..63c953d 100644 --- a/tests/installed_consumer/CMakeLists.txt +++ b/tests/installed_consumer/CMakeLists.txt @@ -42,3 +42,14 @@ target_link_libraries(control_probe PRIVATE mmdControl::mmdControl) if(MSVC) target_compile_options(control_probe PRIVATE /utf-8) endif() + +# The Phase 9 edge: both adapters must resolve their digest-pinned shared +# motion dependencies from installed packages, never sibling source trees. +find_package(mmdSkeletonAdapter 0.1 CONFIG REQUIRED) +find_package(mmdMotionAdapter 0.1 CONFIG REQUIRED) + +add_executable(adapter_probe adapter_probe.cpp) +target_link_libraries(adapter_probe PRIVATE mmdMotionAdapter::mmdMotionAdapter) +if(MSVC) + target_compile_options(adapter_probe PRIVATE /utf-8) +endif() diff --git a/tests/installed_consumer/adapter_probe.cpp b/tests/installed_consumer/adapter_probe.cpp new file mode 100644 index 0000000..be51b97 --- /dev/null +++ b/tests/installed_consumer/adapter_probe.cpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +int +main(int argc, char** argv) +{ + if (argc != 3) { + return 2; + } + const auto vmd = motionVmd::ReadFile(std::filesystem::path(argv[1])); + const auto pmx = mmd::pmx::ReadFile(std::filesystem::path(argv[2])); + if (!vmd || !pmx) { + return 2; + } + const mmd::CanonicalDocument model = mmd::Canonicalize(pmx.value()).value(); + const auto bound = + mmd::binding::Bind(motionVmd::BuildMotion(vmd.value()).value(), model).value(); + const auto evaluator = mmd::control::Evaluator::Prepare(model).value(); + const auto skeleton = mmd::skeleton::Adapt(model); + + std::uint32_t last = 0; + for (const auto& track : bound.bones) { + if (!track.keys.empty()) { + last = std::max(last, track.keys.back().frame); + } + } + const auto clip = mmd::motion::BuildClip( + model, + bound, + evaluator, + skeleton, + mmd::motion::ClipOptions{0.0, static_cast(last) / 30.0, 30.0}); + if (!clip) { + return 2; + } + std::printf("samples=%zu joints=%zu roleTable=%d\n", + clip.value().samples.size(), + skeleton.skeleton.GetSize(), + skeleton.roleTableVersion); + return 0; +} From 9640330a689f958c503e8a572a978ef3158eb608 Mon Sep 17 00:00:00 2001 From: snkmcb Date: Tue, 22 Sep 2026 00:31:54 +0900 Subject: [PATCH 2/4] Fix shared motion adapter review findings --- CHANGELOG.md | 6 +- docs/design/DESIGN_POLICY.md | 2 +- docs/design/MOTION_CONTRACT.md | 10 ++- docs/reference/CAPABILITY_MATRIX.md | 4 +- docs/reference/DIAGNOSTICS.md | 1 + docs/roadmap/current.md | 5 +- .../include/mmdMotionAdapter/Adapter.h | 4 +- .../include/mmdMotionAdapter/Codes.h | 1 + libs/mmdMotionAdapter/src/Adapter.cpp | 65 +++++++++++++++---- libs/mmdMotionAdapter/tests/test_adapter.cpp | 39 ++++++++++- 10 files changed, 116 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d14aff..2df0b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,10 @@ Stage-contract version: **1**, authored since the Phase 0 importer. - **Phase 9 shared-motion adapters.** `mmdSkeletonAdapter` implements role-table version 1 and builds the PMX stage's `SkeletonDescriptor`, source rest and target `RetargetMap`; `mmdMotionAdapter` samples `mmdControl` over an explicit - time range into evaluated `MotionClip` poses, root motion and namespaced MMD - morph channels. Their manifests consume digest-pinned `motionCore` and + time range into evaluated `MotionClip` poses, root motion, namespaced MMD + morph channels and a reserved visibility channel. Non-finite evaluated + values are rejected at the shared boundary rather than replaced. Their + manifests consume digest-pinned `motionCore` and `motionRetarget` v0.5.0 artifacts, and unit, boundary and installed-consumer tests cover both edges. `mmdMotionBinding` now preserves the VMD model name as provenance. End-to-end retarget and `UsdSkelAnimation` authoring remains diff --git a/docs/design/DESIGN_POLICY.md b/docs/design/DESIGN_POLICY.md index 1226a08..b8ea3b3 100644 --- a/docs/design/DESIGN_POLICY.md +++ b/docs/design/DESIGN_POLICY.md @@ -662,7 +662,7 @@ document records why. | §26 — VMD is owned by `usd-mmd-plugins` | `motionVmd` stays, and is no longer described as extraction-ready (§9.1) | | §3.2, §38 — MMD IK conventions, bone flags, morph semantics stay in the format repository | `mmdControl` evaluates them here (§5.6); MOT-O3 superseded ([MOTION_CONTRACT.md §8.2](MOTION_CONTRACT.md#82-a-bake-is-not-a-data-conversion)) | | §4.2, §11 — the format repository builds `SkeletonDescriptor` and `RetargetMap` | `mmdSkeletonAdapter` (§5.7), from a heuristic role table ([MOTION_CONTRACT.md §12](MOTION_CONTRACT.md#12-the-humanoid-role-table)) | -| §5.3 — format-specific channels travel namespaced, uninterpreted | morph weights as `mmd:` ([MOTION_CONTRACT.md §10.7](MOTION_CONTRACT.md#107-morphs-as-channels)) | +| §5.3 — format-specific channels travel namespaced, uninterpreted | morph weights as `mmd:morph:` ([MOTION_CONTRACT.md §10.7](MOTION_CONTRACT.md#107-morphs-as-channels)) | | §9 — seconds, meters, Y-up, local rotations, explicit root motion | [MOTION_CONTRACT.md §10.5](MOTION_CONTRACT.md#105-time-coordinates-and-root-motion); root motion is the hips joint's evaluated world transform ([§12.3](MOTION_CONTRACT.md#123-evaluated-motion-as-humanjoint-rotations-and-root-motion)) | | §17.3, §39 (11) — Unicode source names survive | source names stay on the canonical model and the stage's display names, and the role table matches them; the `SkeletonDescriptor` carries the stage's joint tokens, because the shared core requires `UsdSkelSkeleton.joints` ([MOTION_CONTRACT.md §10.4](MOTION_CONTRACT.md#104-skeleton-and-humanoid-map), [TEXT_ENCODING_POLICY.md §5](TEXT_ENCODING_POLICY.md#5-identity-versus-display)) | | §21 — OpenExec is an optional layer over plain libraries | `mmdControl` is plain; any OpenExec node wraps it outside this repository | diff --git a/docs/design/MOTION_CONTRACT.md b/docs/design/MOTION_CONTRACT.md index a4b9d64..0b8881b 100644 --- a/docs/design/MOTION_CONTRACT.md +++ b/docs/design/MOTION_CONTRACT.md @@ -448,7 +448,7 @@ component here keeps a private copy of any of it ### 10.7 Morphs as channels Morph tracks that are not evaluated into the pose (§11.3) always reach -`MotionChannelSet` under the namespaced semantic `mmd:`, with +`MotionChannelSet` under the namespaced semantic `mmd:morph:`, with the bound weight as a scalar. That source-preserving channel is retained even when an optional semantic expression is emitted beside it. @@ -459,6 +459,11 @@ mouth visemes. It preserves the original channel, diagnoses ambiguity and never turns an unknown model-specific morph into a guess. Generic motion code contains no MMD morph-name table. +The evaluated model-visibility step track is carried on every sample as +`mmd:model:visibility`, with `1` for visible and `0` for hidden. The separate +`mmd:morph:` and `mmd:model:` sub-namespaces ensure that arbitrary +source-authored morph text cannot collide with this reserved adapter semantic. + ### 10.8 Diagnostics MMD-side events keep this repository's `MMD_MOTION_*` family @@ -471,6 +476,9 @@ unchanged, never re-coded. request with `MMD_MOTION_INVALID_SAMPLE_RANGE`. It reports each required source role absent from the versioned table once per clip as `MMD_MOTION_MISSING_REQUIRED_JOINT`; the partial clip remains valid (§12.4). +An evaluated rotation, root position or channel that cannot be represented as +a finite shared value rejects the clip with `MMD_MOTION_NON_FINITE_SAMPLE`; +the adapter never hides it by substituting identity or zero. ## 11. Evaluating the control rig diff --git a/docs/reference/CAPABILITY_MATRIX.md b/docs/reference/CAPABILITY_MATRIX.md index 2941521..3c7ba50 100644 --- a/docs/reference/CAPABILITY_MATRIX.md +++ b/docs/reference/CAPABILITY_MATRIX.md @@ -123,7 +123,9 @@ lane against digest-pinned `usd-motion-plugins` v0.5.0 packages. | Evaluated world rotations made local to the nearest mapped humanoid ancestor | supported | [MOTION §12.3](../design/MOTION_CONTRACT.md#123-evaluated-motion-as-humanjoint-rotations-and-root-motion) | | Root motion from the evaluated world transform of the source hips role | supported | [MOTION §10.5](../design/MOTION_CONTRACT.md#105-time-coordinates-and-root-motion) | | Explicit time range and rate, endpoint sampling, seconds and 30 VMD frames per second | supported | [MOTION §10.3](../design/MOTION_CONTRACT.md#103-evaluation), [§10.5](../design/MOTION_CONTRACT.md#105-time-coordinates-and-root-motion) | -| Non-bone morph weights under `mmd:` channels | supported | [MOTION §10.7](../design/MOTION_CONTRACT.md#107-morphs-as-channels) | +| Non-bone morph weights under `mmd:morph:` channels | supported | [MOTION §10.7](../design/MOTION_CONTRACT.md#107-morphs-as-channels) | +| Evaluated model visibility under the reserved `mmd:model:visibility` channel | supported | [MOTION §10.7](../design/MOTION_CONTRACT.md#107-morphs-as-channels) | +| Non-finite evaluated shared values | rejected, never replaced | [MOTION §10.8](../design/MOTION_CONTRACT.md#108-diagnostics) | | Rest-direction correction for an A-pose source (MOT-O10) | unverified | [MOTION §9](../design/MOTION_CONTRACT.md#9-open-questions) | ## PMX model import diff --git a/docs/reference/DIAGNOSTICS.md b/docs/reference/DIAGNOSTICS.md index f3f484a..8536b74 100644 --- a/docs/reference/DIAGNOSTICS.md +++ b/docs/reference/DIAGNOSTICS.md @@ -227,6 +227,7 @@ raises; every other code is reserved. | `MMD_MOTION_LOCAL_APPEND_APPROXIMATED` *emitted* | info | `mmdControl` (`Prepare`) | [MOTION §11.8](../design/MOTION_CONTRACT.md#118-diagnostics) | | `MMD_MOTION_IK_LOOP_CLAMPED` *emitted* | warning | `mmdControl` (`Prepare`) | [MOTION §11.8](../design/MOTION_CONTRACT.md#118-diagnostics) | | `MMD_MOTION_INVALID_SAMPLE_RANGE` *emitted* | fatal | `mmdMotionAdapter` (`BuildClip`) | [MOTION §10.8](../design/MOTION_CONTRACT.md#108-diagnostics) | +| `MMD_MOTION_NON_FINITE_SAMPLE` *emitted* | fatal | `mmdMotionAdapter` (`BuildClip`) | [MOTION §10.8](../design/MOTION_CONTRACT.md#108-diagnostics) | | `MMD_MOTION_MISSING_REQUIRED_JOINT` *emitted* | warning | `mmdMotionAdapter` (`BuildClip`) | [MOTION §12.4](../design/MOTION_CONTRACT.md#124-required-joints) | ### 5.7 USD boundary diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 57a8f1a..c8fc192 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -80,10 +80,11 @@ usd-avatar-runtime: composes the above per frame and coordinates rendering skeleton with legs driven by IK, and retargets to a non-MMD synthetic skeleton with no MMD code on that path. - ⬜ **Expression interoperability** follows the skeletal adapter path. Keep - every original `mmd:` channel, then optionally emit only + every original `mmd:morph:` channel, then optionally emit only explicit, versioned, high-confidence semantic mappings such as blink and basic mouth visemes. Unknown model-specific morphs remain source channels - and generic motion code contains no MMD name table + and generic motion code contains no MMD name table. Model visibility remains + independently available under `mmd:model:visibility` ([MOTION_CONTRACT.md §10.7](../design/MOTION_CONTRACT.md#107-morphs-as-channels)). ## Phase 8 — what remains diff --git a/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h index aea99d3..6dec64e 100644 --- a/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h +++ b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Adapter.h @@ -18,7 +18,9 @@ struct ClipOptions { /// Evaluates `bound` at the requested rate and emits humanoid rotations, root /// motion and source-preserving MMD morph channels. A valid range always has -/// a sample at both endpoints (one sample when they are equal). +/// a sample at both endpoints (one sample when they are equal). `model`, +/// `evaluator` and `skeleton` must have been built from the same canonical +/// document that `bound` targets. Result BuildClip(const CanonicalDocument& model, const binding::BoundMotion& bound, const control::Evaluator& evaluator, diff --git a/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h index ab4e989..5dd90da 100644 --- a/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h +++ b/libs/mmdMotionAdapter/include/mmdMotionAdapter/Codes.h @@ -6,6 +6,7 @@ namespace mmd::codes { inline constexpr Code MotionInvalidSampleRange{"MMD_MOTION_INVALID_SAMPLE_RANGE", Severity::Fatal}; +inline constexpr Code MotionNonFiniteSample{"MMD_MOTION_NON_FINITE_SAMPLE", Severity::Fatal}; inline constexpr Code MotionMissingRequiredJoint{"MMD_MOTION_MISSING_REQUIRED_JOINT", Severity::Warning}; diff --git a/libs/mmdMotionAdapter/src/Adapter.cpp b/libs/mmdMotionAdapter/src/Adapter.cpp index 09ac60c..0e69b97 100644 --- a/libs/mmdMotionAdapter/src/Adapter.cpp +++ b/libs/mmdMotionAdapter/src/Adapter.cpp @@ -41,14 +41,18 @@ Inverse(const control::Quat& q) return {-q[0] / norm, -q[1] / norm, -q[2] / norm, q[3] / norm}; } +bool +IsUsableRotation(const control::Quat& q) +{ + const double norm = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]; + return norm > 0.0 && std::isfinite(norm); +} + pxr::GfQuatf ToGf(const control::Quat& source) { const double norm = std::sqrt(source[0] * source[0] + source[1] * source[1] + source[2] * source[2] + source[3] * source[3]); - if (!(norm > 0.0) || !std::isfinite(norm)) { - return pxr::GfQuatf(1.0f, pxr::GfVec3f(0.0f)); - } const float x = static_cast(source[0] / norm); const float y = static_cast(source[1] / norm); const float z = static_cast(source[2] / norm); @@ -140,10 +144,23 @@ BuildClip(const CanonicalDocument& model, const binding::BoundMotion& bound, const std::vector times = SampleTimes(options); clip.samples.reserve(times.size()); - for (const double time : times) { + for (std::size_t sampleIndex = 0; sampleIndex < times.size(); ++sampleIndex) { + const double time = times[sampleIndex]; const control::Pose evaluated = evaluator.Evaluate(bound, time * 30.0); const std::vector world = evaluator.World(evaluated); + const auto nonFinite = [&](std::string field) { + Location where; + where.table = "samples"; + where.index = sampleIndex; + where.field = std::move(field); + return Result::Failure( + MakeDiagnostic(codes::MotionNonFiniteSample, + "evaluated motion cannot be represented as a finite shared sample", + std::move(where)), + diagnostics); + }; + MotionPose pose; pose.timestamp = time; pose.metadata = clip.source; @@ -158,15 +175,28 @@ BuildClip(const CanonicalDocument& model, const binding::BoundMotion& bound, continue; } control::Quat local = world[static_cast(joint)].rotation; + if (!IsUsableRotation(local)) { + return nonFinite(std::string(openstrata::motion::HumanJointName(role)) + + ".rotation"); + } const auto parent = openstrata::motion::NearestPresentAncestor(role, skeleton.sourcePresent); if (parent) { const int parentJoint = skeleton.SourceJoint(*parent); if (parentJoint >= 0 && static_cast(parentJoint) < world.size()) { - local = Multiply(Inverse(world[static_cast(parentJoint)].rotation), - local); + const control::Quat& parentRotation = + world[static_cast(parentJoint)].rotation; + if (!IsUsableRotation(parentRotation)) { + return nonFinite(std::string(openstrata::motion::HumanJointName(*parent)) + + ".rotation"); + } + local = Multiply(Inverse(parentRotation), local); } } + if (!IsUsableRotation(local)) { + return nonFinite(std::string(openstrata::motion::HumanJointName(role)) + + ".rotation"); + } pose.localRotations[i] = ToGf(local); pose.validRotations.set(i); } @@ -174,9 +204,17 @@ BuildClip(const CanonicalDocument& model, const binding::BoundMotion& bound, const int hips = skeleton.SourceJoint(HumanJoint::Hips); if (hips >= 0 && static_cast(hips) < world.size()) { const control::JointTransform& root = world[static_cast(hips)]; - pose.root.worldPosition = pxr::GfVec3f(static_cast(root.translation[0]), - static_cast(root.translation[1]), - static_cast(root.translation[2])); + const pxr::GfVec3f position(static_cast(root.translation[0]), + static_cast(root.translation[1]), + static_cast(root.translation[2])); + if (!std::isfinite(position[0]) || !std::isfinite(position[1]) || + !std::isfinite(position[2])) { + return nonFinite("root.position"); + } + if (!IsUsableRotation(root.rotation)) { + return nonFinite("root.orientation"); + } + pose.root.worldPosition = position; pose.root.worldOrientation = ToGf(root.rotation); pose.root.hasPosition = true; pose.root.hasOrientation = true; @@ -187,10 +225,15 @@ BuildClip(const CanonicalDocument& model, const binding::BoundMotion& bound, static_cast(channel.morph) >= model.morphs.size()) { continue; } - pose.channels.Set("mmd:" + + const float weight = static_cast(channel.weight); + if (!std::isfinite(weight)) { + return nonFinite("channels"); + } + pose.channels.Set("mmd:morph:" + model.morphs[static_cast(channel.morph)].name.source, - static_cast(channel.weight)); + weight); } + pose.channels.Set("mmd:model:visibility", evaluated.visible ? 1.0f : 0.0f); clip.samples.push_back(std::move(pose)); } diff --git a/libs/mmdMotionAdapter/tests/test_adapter.cpp b/libs/mmdMotionAdapter/tests/test_adapter.cpp index adc242a..35b9d27 100644 --- a/libs/mmdMotionAdapter/tests/test_adapter.cpp +++ b/libs/mmdMotionAdapter/tests/test_adapter.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace { @@ -57,6 +58,10 @@ TestClip() smile.name = {"笑い", "", "Smile"}; smile.type = mmd::MorphType::Vertex; model.morphs.push_back(smile); + mmd::Morph collidingName; + collidingName.name = {"model:visibility", "", "VisibilityNamedMorph"}; + collidingName.type = mmd::MorphType::Vertex; + model.morphs.push_back(collidingName); mmd::binding::BoundMotion bound; bound.sourceModelName = "テストモデル"; @@ -68,6 +73,8 @@ TestClip() {Key(0, {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, 0.0f, 1.0f}), Key(30, {0.0f, 0.0f, 0.0f}, {0.0f, 0.0f, s, s})}}); bound.morphs.push_back({0, {{0, 0.25f}, {30, 0.75f}}}); + bound.morphs.push_back({1, {{0, 0.5f}}}); + bound.visibility = {{0, true}, {30, false}}; const mmd::skeleton::AdaptedSkeleton skeleton = mmd::skeleton::Adapt(model); const auto prepared = mmd::control::Evaluator::Prepare(model); @@ -94,8 +101,11 @@ TestClip() const auto spine = static_cast(openstrata::motion::HumanJoint::Spine); assert(last.validRotations.test(spine)); assert(std::abs(last.localRotations[spine].GetImaginary()[2] - s) < 1.0e-5f); - assert(std::abs(*first.channels.Find("mmd:笑い") - 0.25f) < 1.0e-6f); - assert(std::abs(*last.channels.Find("mmd:笑い") - 0.75f) < 1.0e-6f); + assert(std::abs(*first.channels.Find("mmd:morph:笑い") - 0.25f) < 1.0e-6f); + assert(std::abs(*last.channels.Find("mmd:morph:笑い") - 0.75f) < 1.0e-6f); + assert(*first.channels.Find("mmd:morph:model:visibility") == 0.5f); + assert(*first.channels.Find("mmd:model:visibility") == 1.0f); + assert(*last.channels.Find("mmd:model:visibility") == 0.0f); bool missingRequired = false; for (const mmd::Diagnostic& diagnostic : result.diagnostics()) { @@ -105,6 +115,30 @@ TestClip() assert(skeleton.SourceJoint(openstrata::motion::HumanJoint::Hips) == lower); } +void +TestNonFiniteSample() +{ + mmd::CanonicalDocument model; + const int hips = AddBone(model, "下半身", "Hips", mmd::kNone, {0.0, 0.0, 0.0}); + model.rig.bones.resize(1); + + mmd::binding::BoundMotion bound; + bound.bones.push_back( + {hips, + {Key( + 0, {0.0f, 0.0f, 0.0f}, {std::numeric_limits::quiet_NaN(), 0.0f, 0.0f, 1.0f})}}); + + const mmd::skeleton::AdaptedSkeleton skeleton = mmd::skeleton::Adapt(model); + const auto prepared = mmd::control::Evaluator::Prepare(model); + assert(prepared); + const auto result = mmd::motion::BuildClip(model, bound, prepared.value(), skeleton); + assert(!result); + assert(result.fatal()); + assert(result.fatal()->code == "MMD_MOTION_NON_FINITE_SAMPLE"); + assert(result.fatal()->location.table == "samples"); + assert(result.fatal()->location.index == 0); +} + void TestRangeValidation() { @@ -127,5 +161,6 @@ main() { TestClip(); TestRangeValidation(); + TestNonFiniteSample(); return 0; } From adf9206e8130054973baaf3947c2166d0dca285b Mon Sep 17 00:00:00 2001 From: snkmcb Date: Tue, 22 Sep 2026 00:41:59 +0900 Subject: [PATCH 3/4] Fix external library CI bootstrap --- .github/workflows/ost-source-ci.yml | 101 +++++++++++++++++----------- .github/workflows/release.yml | 13 ++-- CHANGELOG.md | 4 +- docs/architecture/DEPENDENCIES.md | 2 +- docs/guides/building.md | 2 +- openstrata.ci.yaml | 11 +-- 6 files changed, 80 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ost-source-ci.yml b/.github/workflows/ost-source-ci.yml index 4ea456e..60d2907 100644 --- a/.github/workflows/ost-source-ci.yml +++ b/.github/workflows/ost-source-ci.yml @@ -106,7 +106,7 @@ jobs: if: ${{ matrix.hosted }} shell: bash run: echo "::notice title=OpenStrata hosted-runner usage::This job uses GitHub-hosted infrastructure. Private repositories may incur GitHub Actions usage charges. Review repository billing and Actions usage settings." - - name: Bootstrap ost 0.22.10 (pinned release asset, checksum-verified) + - name: Bootstrap ost 0.23.2 (pinned release asset, checksum-verified) if: ${{ matrix.hosted }} shell: bash run: | @@ -124,7 +124,7 @@ jobs: *) : ;; esac asset="ost-cli-${triple}.${ext}" - base="https://github.com/animu-sphere/open-strata/releases/download/v0.22.10" + base="https://github.com/animu-sphere/open-strata/releases/download/v0.23.2" curl -fsSLo "$asset" "$base/$asset" curl -fsSLo "$asset.sha256" "$base/$asset.sha256" actual="$( (command -v sha256sum > /dev/null && sha256sum "$asset" || shasum -a 256 "$asset") | cut -d' ' -f1 )" @@ -158,7 +158,7 @@ jobs: echo "$exported_path" >> "$GITHUB_PATH" json_executable="$(printf '%s' "$executable" | sed 's/\\/\\\\/g; s/"/\\"/g')" json_exported_path="$(printf '%s' "$exported_path" | sed 's/\\/\\\\/g; s/"/\\"/g')" - printf '{"schema":1,"pinned_version":"%s","asset":"%s","sha256":"%s","executable":"%s","exported_path":"%s"}\n' "0.22.10" "$asset" "$actual" "$json_executable" "$json_exported_path" > .ost-ci/bootstrap.json + printf '{"schema":1,"pinned_version":"%s","asset":"%s","sha256":"%s","executable":"%s","exported_path":"%s"}\n' "0.23.2" "$asset" "$actual" "$json_executable" "$json_exported_path" > .ost-ci/bootstrap.json - name: Check ost is available and record its version shell: bash run: | @@ -171,8 +171,8 @@ jobs: fi echo "$version" - if [ "${{ matrix.hosted }}" = "true" ] && [ "$version" != "ost 0.22.10" ]; then - echo "::error title=ost bootstrap::expected 'ost 0.22.10', got '$version'" ; exit 1 + if [ "${{ matrix.hosted }}" = "true" ] && [ "$version" != "ost 0.23.2" ]; then + echo "::error title=ost bootstrap::expected 'ost 0.23.2', got '$version'" ; exit 1 fi printf '{"schema":1,"ost_version":"%s"}\n' "$version" > .ost-ci/ost-version.json - name: Validate the CI manifest @@ -184,28 +184,28 @@ jobs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .ost-ci-home/artifacts - key: ost-registry-0.22.10-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} + key: ost-registry-0.23.2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} - name: Pull the pinned runtime SDK from its remote reference if: ${{ matrix.runtime_remote != '' }} shell: bash run: | set -euo pipefail mkdir -p .ost-ci - openusd_args=() + set -- if [ -n "${{ matrix.require_openusd }}" ]; then - openusd_args+=(--require-openusd "${{ matrix.require_openusd }}") + set -- "$@" --require-openusd "${{ matrix.require_openusd }}" fi if [ -n "${{ matrix.require_openusd_version }}" ]; then - openusd_args+=(--require-openusd-version "${{ matrix.require_openusd_version }}") + set -- "$@" --require-openusd-version "${{ matrix.require_openusd_version }}" fi if ost artifact show "${{ matrix.runtime_artifact }}" --json > /dev/null 2>&1 \ - && ost artifact verify "${{ matrix.runtime_artifact }}" ${{ matrix.evidence_flags }} "${openusd_args[@]}" --json > .ost-ci/runtime-cache-verify.json; then + && ost artifact verify "${{ matrix.runtime_artifact }}" ${{ matrix.evidence_flags }} "$@" --json > .ost-ci/runtime-cache-verify.json; then echo "pinned runtime already present and verified (cache hit) -- skipping the remote pull" else if [ "${{ matrix.hosted }}" = "true" ] && [ -n "${OST_HOME:-}" ]; then rm -rf "${OST_HOME}/artifacts" fi - ost artifact pull "${{ matrix.runtime_remote }}" --expect-artifact "${{ matrix.runtime_artifact }}" --require-kind runtime "${openusd_args[@]}" --json | tee .ost-ci/runtime-pull.json + ost artifact pull "${{ matrix.runtime_remote }}" --expect-artifact "${{ matrix.runtime_artifact }}" --require-kind runtime "$@" --json | tee .ost-ci/runtime-pull.json fi - name: Verify and materialize the pinned runtime SDK shell: bash @@ -213,15 +213,18 @@ jobs: set -euo pipefail mkdir -p .ost-ci printf '{"schema":1,"runtime_artifact":"%s","require_openusd":"%s","require_openusd_version":"%s","source":"%s"}\n' "${{ matrix.runtime_artifact }}" "${{ matrix.require_openusd }}" "${{ matrix.require_openusd_version }}" "${{ matrix.runtime_remote != '' && 'remote-pull' || 'local-registry' }}" > .ost-ci/runtime-source.json - openusd_args=() + set -- if [ -n "${{ matrix.require_openusd }}" ]; then - openusd_args+=(--require-openusd "${{ matrix.require_openusd }}") + set -- "$@" --require-openusd "${{ matrix.require_openusd }}" fi if [ -n "${{ matrix.require_openusd_version }}" ]; then - openusd_args+=(--require-openusd-version "${{ matrix.require_openusd_version }}") + set -- "$@" --require-openusd-version "${{ matrix.require_openusd_version }}" fi - ost artifact verify ${{ matrix.runtime_artifact }} --minimum-trust ${{ matrix.minimum_trust }} ${{ matrix.evidence_flags }} "${openusd_args[@]}" + ost artifact verify ${{ matrix.runtime_artifact }} --minimum-trust ${{ matrix.minimum_trust }} ${{ matrix.evidence_flags }} "$@" ost runtime pull ${{ matrix.platform }} --profile ${{ matrix.profile }} --from-artifact ${{ matrix.runtime_artifact }} --force + - name: Pull digest-pinned external library artifacts + shell: bash + run: ost library pull --target ${{ matrix.platform }} --profile ${{ matrix.profile }} --json - name: Remove resumable transfer state before caching if: ${{ matrix.hosted && vars.OST_CI_DISABLE_CACHE != 'true' && steps.runtime-cache-restore.outputs.cache-hit != 'true' }} shell: bash @@ -231,7 +234,7 @@ jobs: uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .ost-ci-home/artifacts - key: ost-registry-0.22.10-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} + key: ost-registry-0.23.2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} - name: Install the host packages this runtime needs to be consumed if: ${{ matrix.host_packages_apt != '' || matrix.host_packages_brew != '' }} shell: bash @@ -343,7 +346,7 @@ jobs: if: ${{ matrix.hosted }} shell: bash run: echo "::notice title=OpenStrata hosted-runner usage::This job uses GitHub-hosted infrastructure. Private repositories may incur GitHub Actions usage charges. Review repository billing and Actions usage settings." - - name: Bootstrap ost 0.22.10 (pinned release asset, checksum-verified) + - name: Bootstrap ost 0.23.2 (pinned release asset, checksum-verified) if: ${{ matrix.hosted }} shell: bash run: | @@ -361,7 +364,7 @@ jobs: *) : ;; esac asset="ost-cli-${triple}.${ext}" - base="https://github.com/animu-sphere/open-strata/releases/download/v0.22.10" + base="https://github.com/animu-sphere/open-strata/releases/download/v0.23.2" curl -fsSLo "$asset" "$base/$asset" curl -fsSLo "$asset.sha256" "$base/$asset.sha256" actual="$( (command -v sha256sum > /dev/null && sha256sum "$asset" || shasum -a 256 "$asset") | cut -d' ' -f1 )" @@ -395,7 +398,7 @@ jobs: echo "$exported_path" >> "$GITHUB_PATH" json_executable="$(printf '%s' "$executable" | sed 's/\\/\\\\/g; s/"/\\"/g')" json_exported_path="$(printf '%s' "$exported_path" | sed 's/\\/\\\\/g; s/"/\\"/g')" - printf '{"schema":1,"pinned_version":"%s","asset":"%s","sha256":"%s","executable":"%s","exported_path":"%s"}\n' "0.22.10" "$asset" "$actual" "$json_executable" "$json_exported_path" > .ost-ci/bootstrap.json + printf '{"schema":1,"pinned_version":"%s","asset":"%s","sha256":"%s","executable":"%s","exported_path":"%s"}\n' "0.23.2" "$asset" "$actual" "$json_executable" "$json_exported_path" > .ost-ci/bootstrap.json - name: Check ost is available and record its version shell: bash run: | @@ -408,8 +411,8 @@ jobs: fi echo "$version" - if [ "${{ matrix.hosted }}" = "true" ] && [ "$version" != "ost 0.22.10" ]; then - echo "::error title=ost bootstrap::expected 'ost 0.22.10', got '$version'" ; exit 1 + if [ "${{ matrix.hosted }}" = "true" ] && [ "$version" != "ost 0.23.2" ]; then + echo "::error title=ost bootstrap::expected 'ost 0.23.2', got '$version'" ; exit 1 fi printf '{"schema":1,"ost_version":"%s"}\n' "$version" > .ost-ci/ost-version.json - name: Validate the CI manifest @@ -450,6 +453,7 @@ jobs: hosted: true runner_profile: windows-hosted verify: test + intent_flags: "" runtime_remote: "oci://ghcr.io/animu-sphere/openstrata-runtime-cy2026-usd@sha256:d3ff79a6f330558c3b9a427a927d340fe3dcab1fe89107faa0ea9f66a104b7bf" host_python: "3.13" host_packages_apt: "" @@ -470,6 +474,7 @@ jobs: hosted: true runner_profile: macos-arm64-hosted verify: test + intent_flags: "" runtime_remote: "oci://ghcr.io/animu-sphere/openstrata-runtime-cy2026-usd@sha256:8486d94286b60cc20b53c926757f1802442569f9d9a7eecdadf2c9895b2efeb6" host_python: "3.13" host_packages_apt: "" @@ -490,6 +495,7 @@ jobs: hosted: true runner_profile: linux-hosted verify: test + intent_flags: "" runtime_remote: "oci://ghcr.io/animu-sphere/openstrata-runtime-cy2026-usd@sha256:8d2d599489ee623044f4360ad3a2b979e2966e6ad2f949e5aa9083570e59cc4f" host_python: "3.13" host_packages_apt: "libx11-dev libxt-dev" @@ -501,7 +507,7 @@ jobs: if: ${{ matrix.hosted }} shell: bash run: echo "::notice title=OpenStrata hosted-runner usage::This job uses GitHub-hosted infrastructure. Private repositories may incur GitHub Actions usage charges. Review repository billing and Actions usage settings." - - name: Bootstrap ost 0.22.10 (pinned release asset, checksum-verified) + - name: Bootstrap ost 0.23.2 (pinned release asset, checksum-verified) if: ${{ matrix.hosted }} shell: bash run: | @@ -519,7 +525,7 @@ jobs: *) : ;; esac asset="ost-cli-${triple}.${ext}" - base="https://github.com/animu-sphere/open-strata/releases/download/v0.22.10" + base="https://github.com/animu-sphere/open-strata/releases/download/v0.23.2" curl -fsSLo "$asset" "$base/$asset" curl -fsSLo "$asset.sha256" "$base/$asset.sha256" actual="$( (command -v sha256sum > /dev/null && sha256sum "$asset" || shasum -a 256 "$asset") | cut -d' ' -f1 )" @@ -553,7 +559,7 @@ jobs: echo "$exported_path" >> "$GITHUB_PATH" json_executable="$(printf '%s' "$executable" | sed 's/\\/\\\\/g; s/"/\\"/g')" json_exported_path="$(printf '%s' "$exported_path" | sed 's/\\/\\\\/g; s/"/\\"/g')" - printf '{"schema":1,"pinned_version":"%s","asset":"%s","sha256":"%s","executable":"%s","exported_path":"%s"}\n' "0.22.10" "$asset" "$actual" "$json_executable" "$json_exported_path" > .ost-ci/bootstrap.json + printf '{"schema":1,"pinned_version":"%s","asset":"%s","sha256":"%s","executable":"%s","exported_path":"%s"}\n' "0.23.2" "$asset" "$actual" "$json_executable" "$json_exported_path" > .ost-ci/bootstrap.json - name: Check ost is available and record its version shell: bash run: | @@ -566,8 +572,8 @@ jobs: fi echo "$version" - if [ "${{ matrix.hosted }}" = "true" ] && [ "$version" != "ost 0.22.10" ]; then - echo "::error title=ost bootstrap::expected 'ost 0.22.10', got '$version'" ; exit 1 + if [ "${{ matrix.hosted }}" = "true" ] && [ "$version" != "ost 0.23.2" ]; then + echo "::error title=ost bootstrap::expected 'ost 0.23.2', got '$version'" ; exit 1 fi printf '{"schema":1,"ost_version":"%s"}\n' "$version" > .ost-ci/ost-version.json - name: Validate the CI manifest @@ -579,28 +585,28 @@ jobs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .ost-ci-home/artifacts - key: ost-registry-0.22.10-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} + key: ost-registry-0.23.2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} - name: Pull the pinned runtime SDK from its remote reference if: ${{ matrix.runtime_remote != '' }} shell: bash run: | set -euo pipefail mkdir -p .ost-ci - openusd_args=() + set -- if [ -n "${{ matrix.require_openusd }}" ]; then - openusd_args+=(--require-openusd "${{ matrix.require_openusd }}") + set -- "$@" --require-openusd "${{ matrix.require_openusd }}" fi if [ -n "${{ matrix.require_openusd_version }}" ]; then - openusd_args+=(--require-openusd-version "${{ matrix.require_openusd_version }}") + set -- "$@" --require-openusd-version "${{ matrix.require_openusd_version }}" fi if ost artifact show "${{ matrix.runtime_artifact }}" --json > /dev/null 2>&1 \ - && ost artifact verify "${{ matrix.runtime_artifact }}" ${{ matrix.evidence_flags }} "${openusd_args[@]}" --json > .ost-ci/runtime-cache-verify.json; then + && ost artifact verify "${{ matrix.runtime_artifact }}" ${{ matrix.evidence_flags }} "$@" --json > .ost-ci/runtime-cache-verify.json; then echo "pinned runtime already present and verified (cache hit) -- skipping the remote pull" else if [ "${{ matrix.hosted }}" = "true" ] && [ -n "${OST_HOME:-}" ]; then rm -rf "${OST_HOME}/artifacts" fi - ost artifact pull "${{ matrix.runtime_remote }}" --expect-artifact "${{ matrix.runtime_artifact }}" --require-kind runtime "${openusd_args[@]}" --json | tee .ost-ci/runtime-pull.json + ost artifact pull "${{ matrix.runtime_remote }}" --expect-artifact "${{ matrix.runtime_artifact }}" --require-kind runtime "$@" --json | tee .ost-ci/runtime-pull.json fi - name: Verify and materialize the pinned runtime SDK shell: bash @@ -608,15 +614,18 @@ jobs: set -euo pipefail mkdir -p .ost-ci printf '{"schema":1,"runtime_artifact":"%s","require_openusd":"%s","require_openusd_version":"%s","source":"%s"}\n' "${{ matrix.runtime_artifact }}" "${{ matrix.require_openusd }}" "${{ matrix.require_openusd_version }}" "${{ matrix.runtime_remote != '' && 'remote-pull' || 'local-registry' }}" > .ost-ci/runtime-source.json - openusd_args=() + set -- if [ -n "${{ matrix.require_openusd }}" ]; then - openusd_args+=(--require-openusd "${{ matrix.require_openusd }}") + set -- "$@" --require-openusd "${{ matrix.require_openusd }}" fi if [ -n "${{ matrix.require_openusd_version }}" ]; then - openusd_args+=(--require-openusd-version "${{ matrix.require_openusd_version }}") + set -- "$@" --require-openusd-version "${{ matrix.require_openusd_version }}" fi - ost artifact verify ${{ matrix.runtime_artifact }} --minimum-trust ${{ matrix.minimum_trust }} ${{ matrix.evidence_flags }} "${openusd_args[@]}" + ost artifact verify ${{ matrix.runtime_artifact }} --minimum-trust ${{ matrix.minimum_trust }} ${{ matrix.evidence_flags }} "$@" ost runtime pull ${{ matrix.platform }} --profile ${{ matrix.profile }} --from-artifact ${{ matrix.runtime_artifact }} --force + - name: Pull digest-pinned external library artifacts + shell: bash + run: ost library pull --target ${{ matrix.platform }} --profile ${{ matrix.profile }} --json - name: Remove resumable transfer state before caching if: ${{ matrix.hosted && vars.OST_CI_DISABLE_CACHE != 'true' && steps.runtime-cache-restore.outputs.cache-hit != 'true' }} shell: bash @@ -626,7 +635,7 @@ jobs: uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .ost-ci-home/artifacts - key: ost-registry-0.22.10-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} + key: ost-registry-0.23.2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.name }}-${{ matrix.runtime_artifact }} - name: Install the host packages this runtime needs to be consumed if: ${{ matrix.host_packages_apt != '' || matrix.host_packages_brew != '' }} shell: bash @@ -687,11 +696,19 @@ jobs: run: ost plugin test --workspace --graph-only --json - name: Build the workspace from source shell: bash - run: ost build --target ${{ matrix.platform }} --profile ${{ matrix.profile }} + run: ost build --target ${{ matrix.platform }} --profile ${{ matrix.profile }} ${{ matrix.intent_flags }} - name: Run the workspace test suite - if: ${{ matrix.verify == 'test' }} + if: ${{ matrix.verify != 'build' }} + shell: bash + run: ost test --target ${{ matrix.platform }} --profile ${{ matrix.profile }} ${{ matrix.intent_flags }} + - name: Run the workspace verification pyramid + if: ${{ matrix.verify == 'pyramid' || matrix.verify == 'package' }} + shell: bash + run: ost plugin test --workspace --target ${{ matrix.platform }} --profile ${{ matrix.profile }} --up-to ${{ matrix.up_to }} --json + - name: Package the workspace and aggregate product (never published from this workflow) + if: ${{ matrix.verify == 'package' }} shell: bash - run: ost test --target ${{ matrix.platform }} --profile ${{ matrix.profile }} + run: ost plugin package --workspace --product --target ${{ matrix.platform }} --profile ${{ matrix.profile }} --json - name: Upload the build logs and CI evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -699,4 +716,8 @@ jobs: name: report-${{ matrix.name }} path: | .strata/targets/ + **/.strata/reports/ + **/dist/plugins/ + **/dist/tools/ + dist/products/ .ost-ci/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 935c529..8f6cda1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,7 +112,7 @@ jobs: set -euo pipefail sudo apt-get update sudo apt-get install -y --no-install-recommends libx11-dev libxt-dev - - name: Bootstrap ost 0.22.10 (pinned release asset, checksum-verified) + - name: Bootstrap ost 0.23.2 (pinned release asset, checksum-verified) shell: bash run: | set -euo pipefail @@ -123,7 +123,7 @@ jobs: *) echo "::error title=ost bootstrap::no ost release asset for ${RUNNER_OS}-${RUNNER_ARCH}" ; exit 1 ;; esac asset="ost-cli-${triple}.${ext}" - base="https://github.com/animu-sphere/open-strata/releases/download/v0.22.10" + base="https://github.com/animu-sphere/open-strata/releases/download/v0.23.2" curl -fsSLo "$asset" "$base/$asset" curl -fsSLo "$asset.sha256" "$base/$asset.sha256" actual="$( (command -v sha256sum > /dev/null && sha256sum "$asset" || shasum -a 256 "$asset") | cut -d' ' -f1 )" @@ -155,8 +155,8 @@ jobs: version="$(ost --version)" fi echo "$version" - if [ "$version" != "ost 0.22.10" ]; then - echo "::error title=ost bootstrap::expected 'ost 0.22.10', got '$version'" ; exit 1 + if [ "$version" != "ost 0.23.2" ]; then + echo "::error title=ost bootstrap::expected 'ost 0.23.2', got '$version'" ; exit 1 fi # Restore only: the pull request lanes own these entries, and a release # run must not write a cache a later pull request would trust. @@ -165,7 +165,7 @@ jobs: uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .ost-ci-home/artifacts - key: ost-registry-0.22.10-${{ runner.os }}-${{ runner.arch }}-${{ matrix.cache_cell }}-${{ matrix.runtime_artifact }} + key: ost-registry-0.23.2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.cache_cell }}-${{ matrix.runtime_artifact }} # The cache-hit test is the same predicate as the gate below, so a cached # record that would fail the gate drops through to a fresh pull. - name: Pull the pinned runtime SDK from its remote reference @@ -189,6 +189,9 @@ jobs: --require-openusd "${{ matrix.require_openusd }}" --require-openusd-version 26.08 ost runtime pull cy2026 --profile usd --from-artifact "${{ matrix.runtime_artifact }}" --force ost runtime validate cy2026 --profile usd --json > /dev/null + - name: Pull digest-pinned external library artifacts + shell: bash + run: ost library pull --target cy2026 --profile usd --json - name: Set up host Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df0b53..22272bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,9 @@ Stage-contract version: **1**, authored since the Phase 0 importer. values are rejected at the shared boundary rather than replaced. Their manifests consume digest-pinned `motionCore` and `motionRetarget` v0.5.0 artifacts, and unit, boundary and installed-consumer - tests cover both edges. `mmdMotionBinding` now preserves the VMD model name + tests cover both edges. Source and release CI pin `ost` 0.23.2 so those + external artifacts are parsed, pulled and composed into root builds. + `mmdMotionBinding` now preserves the VMD model name as provenance. End-to-end retarget and `UsdSkelAnimation` authoring remains Phase 9 work. (`MOTION_CONTRACT.md` §10, §12.) diff --git a/docs/architecture/DEPENDENCIES.md b/docs/architecture/DEPENDENCIES.md index 5099020..5ae00d2 100644 --- a/docs/architecture/DEPENDENCIES.md +++ b/docs/architecture/DEPENDENCIES.md @@ -38,7 +38,7 @@ calls the MaterialX library. The MaterialX document version it declares | Compilers | MSVC on Windows, Clang on macOS (arm64), GCC on Linux — the three hosted lanes `usd-vrm-plugins` runs | | Windows flags | `/utf-8`, `NOMINMAX`, applied by `usdmmd_target_defaults()` in [cmake/UsdMmdTargets.cmake](../../cmake/UsdMmdTargets.cmake) ([WORKSPACE.md §5](WORKSPACE.md#5-build-modes)) | | Python | the Python OpenUSD was built against — 3.13 for the 26.08 runtimes — for stage tests and tooling. The root project finds the interpreter *after* OpenUSD, so it inherits the one `pxrConfig.cmake` names | -| OpenStrata | `ost` **0.22.10**, pinned in `openstrata.ci.yaml` | +| OpenStrata | `ost` **0.23.2**, pinned in `openstrata.ci.yaml`; required for digest-pinned external-library artifacts in root builds | | Unit-test framework | **none**, as in `usd-vrm-plugins`: each suite is a plain executable that checks with `assert()`, compiled with `NDEBUG` undefined so Release builds still check, and registered with CTest | | Sanitizers and fuzzing | Clang 18's AddressSanitizer, UndefinedBehaviorSanitizer and libFuzzer, from Ubuntu 24.04's packages, in [parser-sanitizers.yml](../../.github/workflows/parser-sanitizers.yml) only; `mmdPmx`'s `MMDPMX_SANITIZERS` and `MMDPMX_BUILD_FUZZER` options, and the matching `MMDMODEL_`, `MOTIONVMD_` and `MMDMOTIONBINDING_` ones, switch them on, and nothing shipped is built with them. Toolchain runtimes, not dependencies: no code is vendored and nothing links them outside that lane | diff --git a/docs/guides/building.md b/docs/guides/building.md index 3f806d7..cc192fc 100644 --- a/docs/guides/building.md +++ b/docs/guides/building.md @@ -19,7 +19,7 @@ Commands are PowerShell, run from the repository root. integration tests import OpenUSD's bindings, which refuse any other version (`Module use of python313.dll conflicts with this version of Python`). - CMake 3.22 or later and a C++20 compiler. -- For the OpenStrata path: `ost` 0.22.10 and a `cy2026` / `usd` runtime. +- For the OpenStrata path: `ost` 0.23.2 and a `cy2026` / `usd` runtime. ## Plain CMake diff --git a/openstrata.ci.yaml b/openstrata.ci.yaml index bd0ba98..8edd269 100644 --- a/openstrata.ci.yaml +++ b/openstrata.ci.yaml @@ -36,10 +36,11 @@ # `require_openusd` (the leaf's platform/os/arch/variant) and # `require_openusd_version: "26.08"`, so `ost artifact pull|verify` refuses a # re-pinned digest that is not the release cmake/UsdMmdOpenUsd.cmake pins. It -# is also what keeps the rendered macOS steps runnable under ost 0.22.10: they -# expand "${openusd_args[@]}" under `set -u`, and macOS's bash 3.2 treats an -# empty array there as unbound (the first CI run of this repository failed on -# exactly that, before any build step). +# also keeps the rendered macOS steps runnable when they expand +# "${openusd_args[@]}" under `set -u`: macOS's bash 3.2 treats an empty array +# there as unbound (the first CI run of this repository failed on exactly that, +# before any build step). The 0.23.2 pin is required for external-library +# artifact declarations and for composing their prefixes into root builds. # # Cell inventory -- seven: # * workspace-graph-pr: the WORKSPACE.md §2 dependency gate, before anything @@ -62,7 +63,7 @@ schema: 1 bootstrap: ost: - version: "0.22.10" + version: "0.23.2" repository: animu-sphere/open-strata runners: From 42f1016cb1eb76635a39856e9de88942b492d403 Mon Sep 17 00:00:00 2001 From: snkmcb Date: Tue, 22 Sep 2026 00:52:26 +0900 Subject: [PATCH 4/4] Fix cross-platform Phase 9 CI --- .github/workflows/ost-source-ci.yml | 2 +- CHANGELOG.md | 5 ++++- docs/architecture/WORKSPACE.md | 6 +++++- docs/guides/building.md | 1 + openstrata.ci.yaml | 3 +++ scripts/check_installed_consumer.py | 13 +++++++++++++ scripts/check_library_boundaries.py | 9 +++++++-- tests/CMakeLists.txt | 16 ++++++++++++++++ 8 files changed, 50 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ost-source-ci.yml b/.github/workflows/ost-source-ci.yml index 60d2907..87c4490 100644 --- a/.github/workflows/ost-source-ci.yml +++ b/.github/workflows/ost-source-ci.yml @@ -54,7 +54,7 @@ jobs: runner_profile: windows-hosted bundle: plugins/usdMmdFileFormat runtime_remote: "oci://ghcr.io/animu-sphere/openstrata-runtime-cy2026-usd@sha256:d3ff79a6f330558c3b9a427a927d340fe3dcab1fe89107faa0ea9f66a104b7bf" - host_python: "" + host_python: "3.13" host_packages_apt: "" host_packages_brew: "" - name: usdmmdfileformat-pr-macos-arm64 diff --git a/CHANGELOG.md b/CHANGELOG.md index 22272bf..ec412d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,10 @@ Stage-contract version: **1**, authored since the Phase 0 importer. manifests consume digest-pinned `motionCore` and `motionRetarget` v0.5.0 artifacts, and unit, boundary and installed-consumer tests cover both edges. Source and release CI pin `ost` 0.23.2 so those - external artifacts are parsed, pulled and composed into root builds. + external artifacts are parsed, pulled and composed into root builds. CI + also forwards the host-resolved Python development paths into the clean + installed-consumer configure, provisions Python for Windows runtime + validation, and recognizes the complete macOS OpenUSD foundation closure. `mmdMotionBinding` now preserves the VMD model name as provenance. End-to-end retarget and `UsdSkelAnimation` authoring remains Phase 9 work. (`MOTION_CONTRACT.md` §10, §12.) diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index 63d99c5..b7e641d 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -175,7 +175,11 @@ over `libs/mmdControl` with `mmdMotionBinding::mmdMotionBinding` and skeleton adapter. Those are the two narrow external adapter edges (§2.4). The five OpenUSD-free libraries are added before OpenUSD is resolved; the adapters follow it because their shared packages expose OpenUSD foundation -types and reuse the root's already-resolved targets. +types and reuse the root's already-resolved targets. The binary-import part +allows only the foundation closure (`arch`, `tf`, `gf`, `js`, `trace`, `work`, +`plug`, `vt` and the private `boost`/`python` support libraries some shared +macOS runtimes attach to it); stage, schema and imaging libraries still fail +the gate. ### 2.4 Edges out of this repository diff --git a/docs/guides/building.md b/docs/guides/building.md index cc192fc..8128669 100644 --- a/docs/guides/building.md +++ b/docs/guides/building.md @@ -59,6 +59,7 @@ bundle's `plugInfo.json` expects it, `mmd_inspect` into | `mmdControl_unit` | evaluation over synthetic rigs with known answers: Bézier progress and which key's curve a segment follows, held and stepped tracks, forward kinematics, the evaluation order, bone morphs through nested groups and the channels left, appends with negative ratios and in chains, IK on one link, with an angle limit and with Euler limits, a leg with a plane knee and `足D`, the IK-enable track, the three diagnostics, and a VMD through `Bind` | | `mmdControl_robustness` | 20,000 generated rigs and motions — appends and IK chains naming any joint, wild loop counts, limits and keys, cyclic group morphs — each evaluated at five times, twice: no crash, the same bits both times, and finite unit-rotation poses from the tame half | | `mmdControl_boundaries` | `mmdControl`'s sources include no OpenUSD and no `motionCore/` header, it links `mmdMotionBinding` and `mmdModel` and nothing else, and a binary linking it imports no OpenUSD library | +| `mmdSkeletonAdapter_boundaries`, `mmdMotionAdapter_boundaries` | each adapter links only its declared local and shared-motion packages; its test binary may import the OpenUSD foundation closure those packages expose, including the private `usd_boost`/`usd_python` support libraries on macOS, but no stage, schema or imaging library | | `mmd_inspect_fixtures` | `mmd_inspect` reads every generated fixture as `fixtures.json` says, from an ASCII and a non-ASCII directory | | `mmd_inspect_boundaries` | `mmd_inspect` links `mmdPmx` and nothing else, and imports no OpenUSD library | | `vmd_inspect_fixtures` | `vmd_inspect` reads every generated VMD fixture as its `fixtures.json` says, from an ASCII and a non-ASCII directory | diff --git a/openstrata.ci.yaml b/openstrata.ci.yaml index 8edd269..8264b77 100644 --- a/openstrata.ci.yaml +++ b/openstrata.ci.yaml @@ -159,6 +159,9 @@ cells: profile: usd require_openusd: cy2026/windows/x86_64/gl require_openusd_version: "26.08" + # Runtime validation links and executes an OpenUSD consumer. The Windows + # runtime expects the matching host Python DLL on PATH. + host_python: "3.13" up_to: 4 - name: usdmmdfileformat-pr-macos-arm64 diff --git a/scripts/check_installed_consumer.py b/scripts/check_installed_consumer.py index 3116151..2cc586b 100644 --- a/scripts/check_installed_consumer.py +++ b/scripts/check_installed_consumer.py @@ -138,6 +138,9 @@ def main() -> int: parser.add_argument("--build-dir", required=True, type=pathlib.Path) parser.add_argument("--config", default="Release") parser.add_argument("--usd-root", required=True, type=pathlib.Path) + parser.add_argument("--python3-executable", type=pathlib.Path) + parser.add_argument("--python3-library", type=pathlib.Path) + parser.add_argument("--python3-include-dir", type=pathlib.Path) parser.add_argument("--dependency-prefix", action="append", default=[], type=pathlib.Path) parser.add_argument("--generator") @@ -177,6 +180,16 @@ def main() -> int: search = [prefix, args.usd_root, *args.dependency_prefix] configure = ["cmake", "-S", source, "-B", build, "-DCMAKE_PREFIX_PATH=" + ";".join(p.as_posix() for p in search)] + # OpenUSD's relocatable pxrConfig.cmake carries build-host Python paths + # as fallbacks. Define the three inputs before find_package(pxr) so + # those fallbacks cannot override the Python resolved by the workspace + # configure on this host. + for cmake_name, value in ( + ("Python3_EXECUTABLE", args.python3_executable), + ("Python3_LIBRARY", args.python3_library), + ("Python3_INCLUDE_DIR", args.python3_include_dir)): + if value: + configure.append(f"-D{cmake_name}={value.as_posix()}") if args.generator: configure += ["-G", args.generator] if args.make_program: diff --git a/scripts/check_library_boundaries.py b/scripts/check_library_boundaries.py index ff20813..c932fdb 100644 --- a/scripts/check_library_boundaries.py +++ b/scripts/check_library_boundaries.py @@ -49,7 +49,11 @@ USD_LIBRARY = re.compile(r"\b(?:lib)?usd_[A-Za-z0-9]+\.(?:dll|so|dylib)\b", re.IGNORECASE) USD_FOUNDATION_LIBRARY = re.compile( - r"^(?:lib)?usd_(?:arch|tf|gf|js|trace|work|plug|vt)\.(?:dll|so|dylib)$", + # boost and python are private support libraries in the transitive closure + # of the public tf/gf/vt targets on shared-library macOS runtimes. They do + # not grant an adapter access to stage, schema or imaging APIs. + r"^(?:lib)?usd_(?:arch|boost|python|tf|gf|js|trace|work|plug|vt)" + r"\.(?:dll|so|dylib)$", re.IGNORECASE) SOURCE_SUFFIXES = {".h", ".hpp", ".hh", ".inl", ".c", ".cc", ".cpp", ".cxx"} @@ -198,7 +202,8 @@ def expect(condition: bool, what: str) -> None: "libc.so.6"): expect(not USD_LIBRARY.search(f" {name}\n"), f"{name} is mistaken for OpenUSD") - for name in ("usd_gf.dll", "libusd_tf.so", "libusd_vt.dylib"): + for name in ("usd_gf.dll", "libusd_tf.so", "libusd_vt.dylib", + "libusd_boost.dylib", "libusd_python.dylib"): expect(bool(USD_FOUNDATION_LIBRARY.match(name)), f"{name} is not recognized as an allowed foundation library") for name in ("usd_sdf.dll", "libusd_usd.so", "usd_ms.dll"): diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 989599c..c4ece33 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -98,12 +98,28 @@ set(_consumer_dependencies) foreach(_motion_package_prefix IN LISTS _motion_dependency_prefixes) list(APPEND _consumer_dependencies --dependency-prefix "${_motion_package_prefix}") endforeach() +set(_consumer_python) +# pxrConfig.cmake records the Python used to build the runtime as a fallback. +# A relocatable runtime can therefore name a path from its build host. The +# root configure has already resolved that fallback against this host; carry +# the resolved values into the clean installed-consumer configure so it does +# not try the artifact producer's absolute path again. +foreach(_python_variable IN ITEMS + Python3_EXECUTABLE Python3_LIBRARY Python3_INCLUDE_DIR) + if(DEFINED ${_python_variable} AND NOT "${${_python_variable}}" STREQUAL "") + string(TOLOWER "${_python_variable}" _python_option) + string(REPLACE "_" "-" _python_option "${_python_option}") + list(APPEND _consumer_python + "--${_python_option}" "${${_python_variable}}") + endif() +endforeach() add_test(NAME workspace_installed_consumer COMMAND "${USDMMD_TEST_PYTHON}" "${PROJECT_SOURCE_DIR}/scripts/check_installed_consumer.py" --build-dir "${PROJECT_BINARY_DIR}" --config "$" --usd-root "${_usd_root}" + ${_consumer_python} ${_consumer_dependencies} ${_consumer_toolchain}) set_tests_properties(workspace_installed_consumer PROPERTIES