diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7744af8..30c7614 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,13 +3,76 @@ name: CI on: push: branches: ["main", "copilot/**"] + paths-ignore: + - "docs/**" + - "**/*.md" + - "**/*.rst" + - ".github/ISSUE_TEMPLATE/**" + - ".github/*.md" pull_request: - branches: ["main"] + branches: ["main", "copilot/**"] + paths-ignore: + - "docs/**" + - "**/*.md" + - "**/*.rst" + - ".github/ISSUE_TEMPLATE/**" + - ".github/*.md" permissions: contents: read jobs: + lint: + name: Lint (ruff check) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install package and dev dependencies + run: pip install -e ".[dev]" + - name: Run ruff check + run: ruff check motion_player tests + + format-check: + name: Format check (ruff format) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install package and dev dependencies + run: pip install -e ".[dev]" + - name: Run ruff format --check + run: ruff format --check motion_player tests + + precommit: + name: Pre-commit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install package and dev dependencies + run: pip install -e ".[dev]" + - name: Run pre-commit + run: pre-commit run --all-files + test: name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -36,21 +99,5 @@ jobs: # Install without optional mujoco/ik extras for headless CI run: pip install -e ".[dev]" - - name: Lint with ruff - run: ruff check motion_player tests - - name: Run tests run: pytest tests/ -v --tb=short - - lint-only: - name: Lint (ruff) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - run: pip install ruff - - run: ruff check motion_player tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d78d9ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,53 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build and smoke test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install build dependencies + run: pip install build + + - name: Install package and dev dependencies + run: pip install -e ".[dev]" + + - name: Run release gate checks + run: make release-check + + - name: Build wheel and sdist + run: python -m build + + - name: Smoke test built wheel + run: | + python -m venv .venv-smoke + . .venv-smoke/bin/activate + python -m pip install --upgrade pip + pip install dist/*.whl + motion_player --version + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-dist + path: dist/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2669366 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.9 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index ae58c21..8755d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,76 +2,58 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.8.0] - 2026-04-16 + ### Added +- Added startup font-size controls for GUI entry commands via `--font-size` (`small|medium|large|xlarge`) with environment fallback `RMP_GUI_FONT_SIZE`. +- Added OSS governance baseline guidance via `CLAUDE.md` and OSS-focused release checks. + +### Changed +- Refactored GUI font-size handling to track per-size availability and reject unavailable selections with explicit status feedback. +- Updated OSS quickstart and IK docs to align with v0.8.0 font-size startup behavior. + +### Fixed +- Fixed persistent runtime font-size issue where only `small` effectively applied when higher-size font handles were unavailable. +- Hardened font installation to fail per size instead of aborting the full font registry initialization path. +- Updated OSS docs/release tests to validate OSS-owned artifacts only. + +## [0.7.9] - 2026-04-15 -- Web documentation site (planned) -- Conda package distribution (planned) +### Fixed +- Fixed directional font sync so startup and runtime GUI font sizing stay aligned. +- Increased the progress row depth to improve readability and reduce clipping. -## [0.7.0] - 2026-04-14 +## [0.7.7] - 2026-04-15 -### Highlights +### Fixed +- Enforced GUI font authority so panel font settings remain the single source of truth. +- Tuned the Runtime State and Tool Call dock area to a 2:1 height balance for clearer status scanning. -First public release! Robot Motion Player is now open-source under Apache 2.0 license. +## [0.7.6] - 2026-04-15 + +### Fixed +- Font-size dropdown now applies all size options reliably across callback payload variants. +- Replaced the redundant top Runtime State block with a unified top status dock. +- Reworked status display into a balanced 2-row layout: + - Row 1: Runtime State + Tool Call + - Row 2: Progress +- Increased default Runtime State / Tool Call panel heights to reduce routine scrolling. + +## [0.7.5] - 2026-04-14 ### Added +- Isolated panel runtime mode for `motion_player gui`, with viewer-path fallback behavior. +- Queue-based IPC adapters for panel command transport and monitor snapshots. +- Regression coverage for runtime fallback paths, IPC behavior, and panel process entry hardening. + +### Changed +- Full GUI startup now prefers isolated panel process mode on Linux desktop sessions. -#### Core Features -- **Playback Module** - Real-time motion playback with MuJoCo backend - - Keyboard controls (play/pause, step forward/backward) - - Frame-by-frame navigation - - Marked frame support -- **IK Tuning Module** - 6D end-effector pose adjustment - - Jacobian-based inverse kinematics - - Unit-aware controls (meters, degrees) - - Real-time visualization - - Cross-frame propagation -- **Metrics Module** - AMP-aligned quality evaluation - - Joint velocity/acceleration limits - - GMR loss parity checking - - JSON/CSV report export -- **Editing Module** - Keyframe-safe trajectory editing - - Frame/segment editing - - Undo/redo support - - Edit history tracking -- **Convert Module** - URDF/XML format conversion - - MuJoCo XML support - - URDF to XML conversion -- **Export Module** - GIF/Video output - - GIF export - - MP4 export - - Frame sequence export -- **GUI Workbench** - Full graphical interface - - Play/Tune/Metrics/Audit tabs - - Timeline widget - - Real-time visualization - -#### Integration -- MuJoCo backend (primary) -- Isaac backend (experimental) -- Pinocchio IK backend (optional) - -#### Documentation -- Quick Start Guide (English/Chinese) -- IK Usage Guide -- Example robot model (Booster T1) -- Sample motion data - -#### Testing -- 80+ unit tests -- Pytest configuration -- CI workflow (GitHub Actions) - -### Technical Details - -- **Python Support**: 3.9, 3.10, 3.11, 3.12 -- **Primary Backend**: MuJoCo 3.0+ -- **GUI Framework**: Dear PyGui -- **License**: Apache 2.0 - -[unreleased]: https://github.com/bitroboticslab/robot-motion-player/compare/v0.7.0...HEAD -[0.7.0]: https://github.com/bitroboticslab/robot-motion-player/releases/tag/v0.7.0 +### Fixed +- DearPyGui status-dock rebuild parent-chain handling in callback paths. +- Guarded fail-open status-dock rebuild logic to avoid panel-loop aborts during rebuild errors. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..552f23b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md + +## Scope +- Applies to the full `robot-motion-player-oss` repository. +- Instruction precedence: system/developer rules > `AGENTS.md` > this file > default docs. + +## Source of Truth (OSS) +- User onboarding: `README.md`, `docs/QUICKSTART_en.md`, `docs/QUICKSTART_zh.md` +- IK behavior and workflows: `docs/IK_USAGE.md` +- Release-facing history: `CHANGELOG.md` + +## Standard Workflow +1. Install development dependencies with `make install-dev`. +2. Validate changes with: + - `make lint` + - `make test` (or `make test-quick` while iterating) + - `make check` before handoff +3. For release or docs-governance updates, also run `make release-check`. +4. Update tests/docs whenever user-facing behavior changes. + +## Implementation Guardrails +- State explicit assumptions when requirements are ambiguous. +- Prefer the simplest viable solution; avoid speculative abstractions. +- Keep changes surgical and within task scope. +- Keep OSS tests scoped to OSS-owned files only. + +## Release-Sensitive Rules +- Keep version markers synchronized by role: + - `pyproject.toml`: release version `X.Y.Z` + - `motion_player/__init__.py`: metadata fallback `X.Y.Z.dev0` + - `motion_player/cli/main.py`: `_get_version` fallback `X.Y.Z.dev0` +- Keep `CHANGELOG.md`, `docs/QUICKSTART_en.md`, `docs/QUICKSTART_zh.md`, and `docs/IK_USAGE.md` aligned with release behavior. +- Run `make release-check` before tagging. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18393a7..af89889 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,165 +1,46 @@ -# Contributing to Robot Motion Player +# CONTRIBUTING.md -Thank you for your interest in contributing to Robot Motion Player! +Thanks for contributing to `robot-motion-player`. -## Development Setup +## Source of truth -```bash -# Clone repository -git clone https://github.com/bitroboticslab/robot-motion-player.git -cd robot-motion-player +Before implementing changes, align with: +- `docs/requirements.md` +- `docs/design.md` +- `docs/summary.md` +- `AGENTS.md` -# Create virtual environment -python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate +## Local workflow -# Install in development mode with all dependencies -pip install -e ".[all,dev]" +1. Install development dependencies: + - `make install-dev` +2. Run checks while iterating: + - `make lint` + - `make test-quick` +3. Run full validation before opening a PR: + - `make check` -# Run tests -pytest +## Required checks for PRs -# Run linting -ruff check . -ruff format . -``` +- Minimum: `make lint` +- Expected for functional changes: `make test` +- For release-related changes: `make release-check` +- Optional local hygiene: `make precommit` -## Prerequisites +## Changelog discipline -- Python 3.9+ -- MuJoCo 3.0+ (for playback backend) -- Pinocchio (optional, for IK backend) +- Add user-visible changes under `## [Unreleased]` in `CHANGELOG.md`. +- Use Keep a Changelog sections (`Added`, `Changed`, `Fixed`). +- Keep entries concise, behavior-oriented, and scoped to the same PR. -## Code Style +## Release-sensitive change rules -- Follow [PEP 8](https://peps.python.org/pep-0008/) conventions -- Use [ruff](https://docs.astral.sh/ruff/) for linting and formatting -- Line length: 100 characters -- All public APIs should have docstrings (Google style) -- Run `ruff format` before committing +If your change touches release/version surfaces: +- Follow `RELEASING.md`. +- Keep version markers synchronized by role: + - `pyproject.toml` release version `X.Y.Z` + - `motion_player/__init__.py` fallback `X.Y.Z.dev0` + - `motion_player/cli/main.py` fallback `X.Y.Z.dev0` +- Add/update `docs/releases/vX.Y.Z.md` for the target release. -### Docstring Example - -```python -def load_motion(filepath: str, format: str = "auto") -> MotionData: - """Load motion data from file. - - Args: - filepath: Path to motion file (.pkl, .npz, etc.) - format: File format. If "auto", infer from extension. - - Returns: - MotionData object containing joint positions and timestamps. - - Raises: - FileNotFoundError: If filepath does not exist. - ValueError: If format is unsupported. - """ - pass -``` - -## Pull Request Process - -1. Fork and create a feature branch from `main` -2. Ensure all tests pass: `pytest` -3. Add tests for new functionality -4. Update documentation: - - Docstrings for new APIs - - README.md if adding user-facing features - - docs/ for detailed guides -5. Submit PR with a clear description of changes and motivation - -### PR Checklist - -- [ ] Tests pass (`pytest`) -- [ ] Code is formatted (`ruff format`) -- [ ] No linting errors (`ruff check`) -- [ ] Docstrings added for new public APIs -- [ ] README updated if needed - -## Commit Messages - -Follow [Conventional Commits](https://www.conventionalcommits.org/): - -| Prefix | Usage | -|--------|-------| -| `feat:` | New feature | -| `fix:` | Bug fix | -| `docs:` | Documentation only | -| `test:` | Test additions/changes | -| `refactor:` | Code restructuring | -| `perf:` | Performance improvement | -| `chore:` | Maintenance tasks | - -### Examples - -``` -feat: add support for URDF robot models -fix: resolve IK solver convergence issue for near-singular poses -docs: add quick start guide with example data -test: add unit tests for motion data loader -``` - -## Testing - -```bash -# Run all tests -pytest - -# Run with coverage -pytest --cov=motion_player - -# Run specific test file -pytest tests/core/test_dataset.py - -# Run specific test -pytest tests/core/test_dataset.py::test_load_motion -``` - -### Test Categories - -- `tests/core/` - Core functionality (loader, metrics, IK) -- `tests/backends/` - MuJoCo and Isaac backends -- `tests/gui/` - GUI components -- `tests/cli/` - CLI commands - -### Markers - -```bash -# Skip tests requiring MuJoCo display -pytest -m "not mujoco_runtime" - -# Run only headless integration tests -pytest -m "headless_integration" -``` - -## Project Structure - -``` -robot-motion-player/ -├── motion_player/ # Main package -│ ├── core/ # Core algorithms (loader, metrics, IK) -│ ├── backends/ # Physics backends (MuJoCo, Isaac) -│ ├── gui/ # Dear PyGui interface -│ └── cli/ # Command-line interface -├── tests/ # Test suite -├── docs/ # Documentation -├── example/ # Example assets -│ ├── robots/ # Robot models -│ └── standard_dataset/ # Sample motion data -└── assets/ # Demo media -``` - -## License - -By contributing, you agree that your contributions will be licensed under the Apache License 2.0. - -## Questions? - -Feel free to open an issue for: -- Bug reports -- Feature requests -- Questions about usage -- Discussions about architecture - -Thank you for contributing! 🦞 +Avoid bundling unrelated refactors into release/version patches. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c537497 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: help install-dev lint test test-quick check precommit release release-check + +.DEFAULT_GOAL := help + +PIP ?= pip +PYTEST ?= pytest + +help: ## Show available development targets + @echo "robot-motion-player development commands" + @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_.-]+:.*##/ {printf " %-14s %s\\n", $$1, $$2}' $(MAKEFILE_LIST) + +install-dev: ## Install package with development dependencies + $(PIP) install -e ".[dev]" + +lint: ## Run Ruff lint checks + ruff check motion_player tests + +test: ## Run full pytest suite + $(PYTEST) tests/ -v --tb=short + +test-quick: ## Run targeted fast test subset + $(PYTEST) tests/gui/test_dearpygui_panel.py tests/backends/test_mujoco_viewer.py tests/core/test_ui.py -q + +check: lint test ## Run lint and tests + +precommit: ## Run pre-commit on all files + pre-commit run --all-files + +release: release-check ## Run release validation gates + +release-check: lint test ## Run OSS release checks + $(PYTEST) -q tests/test_docs_version_state.py tests/test_release_backfill_state.py tests/test_roadmap_versions.py diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..7477be9 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,36 @@ +# RELEASING.md + +This guide describes the standard OSS release workflow for `robot-motion-player`. + +## 1) Pre-release checks + +1. Confirm release scope and collect release notes in `CHANGELOG.md`. +2. Update the `## [Unreleased]` section and promote release entries to `## [X.Y.Z] - YYYY-MM-DD`. +3. Synchronize version markers by role: + - `pyproject.toml`: `[project].version = "X.Y.Z"` (release version) + - `motion_player/__init__.py`: metadata fallback `"X.Y.Z.dev0"` + - `motion_player/cli/main.py`: `_get_version()` fallback `"X.Y.Z.dev0"` +4. Keep OSS user docs aligned with the release behavior: + - `docs/QUICKSTART_en.md` + - `docs/QUICKSTART_zh.md` + - `docs/IK_USAGE.md` +5. Run validations: + - `make release-check` + - optional: `make precommit` + +## 2) Tag and publish + +1. Commit release changes (version sync, changelog, docs). +2. Create annotated tag: + - `git tag -a vX.Y.Z -m "Release vX.Y.Z"` +3. Push branch and tag: + - `git push origin ` + - `git push origin vX.Y.Z` +4. Create GitHub release from `vX.Y.Z` and use the corresponding `CHANGELOG.md` section as the release body. + +## 3) Post-release checks + +1. Verify the Git tag and GitHub release point to the intended commit. +2. Verify version markers from local source files: + - `motion_player --version` +3. Keep `## [Unreleased]` in `CHANGELOG.md` ready for the next cycle. diff --git a/docs/IK_USAGE.md b/docs/IK_USAGE.md index 234734c..9b07662 100644 --- a/docs/IK_USAGE.md +++ b/docs/IK_USAGE.md @@ -20,6 +20,18 @@ Both runtime backend paths (`.xml` MuJoCo and `.urdf` Pinocchio when installed) Unit conversion is done only at the UI/payload boundary. +## GUI Startup Font Size (v0.8.0) + +For GUI entry commands, startup font-size can be set with `--font-size`: + +```bash +motion_player play --motion --robot --gui --font-size large +motion_player gui --motion --robot --font-size xlarge +``` + +Supported values are `small`, `medium`, `large`, `xlarge`. +You can set `RMP_GUI_FONT_SIZE` as the default startup selection. + ## GUI Workflow Use either: diff --git a/docs/OSS_SYNC_AUDIT.md b/docs/OSS_SYNC_AUDIT.md new file mode 100644 index 0000000..eb38186 --- /dev/null +++ b/docs/OSS_SYNC_AUDIT.md @@ -0,0 +1,8 @@ +# OSS Sync Audit + +- Date/time: 2026-04-17T20:33:28+08:00 +- Dry-run build command: + - `cd /home/junhang/packages/robot-motion-player && python scripts/oss_sync/build_staging.py --src /home/junhang/packages/robot-motion-player --staging /home/junhang/packages/robot-motion-player-oss-stage --allowlist scripts/oss_sync/allowlist.txt --denylist scripts/oss_sync/denylist.txt --dry-run` +- Dry-run apply command: + - `cd /home/junhang/packages/robot-motion-player && python scripts/oss_sync/apply_to_oss.py --staging /home/junhang/packages/robot-motion-player-oss-stage --oss /home/junhang/packages/robot-motion-player-oss --dry-run` +- Result summary: success; build `report.json` copied count = 12, apply `apply_report.json` copied count = 0 (dry-run). diff --git a/docs/QUICKSTART_en.md b/docs/QUICKSTART_en.md index f23eae7..03194a4 100644 --- a/docs/QUICKSTART_en.md +++ b/docs/QUICKSTART_en.md @@ -24,6 +24,26 @@ motion_player play --motion path/to/clip.pkl --robot path/to/robot.xml --gui motion_player gui --motion path/to/clip.pkl --robot path/to/robot.xml ``` +`motion_player gui` now prefers an isolated panel process mode. If panel startup fails, +the runtime prints a warning and continues in MuJoCo keyboard-only mode. + +### v0.8.0 GUI startup font-size controls + +Use startup font-size selection when launching GUI entry commands: + +```bash +motion_player play --motion path/to/clip.pkl --robot path/to/robot.xml --gui --font-size large +motion_player gui --motion path/to/clip.pkl --robot path/to/robot.xml --font-size xlarge +``` + +You can also set a default via environment variable: + +```bash +export RMP_GUI_FONT_SIZE=medium +``` + +Supported values: `small`, `medium`, `large`, `xlarge`. + ## 3) Essential keys - `Space`: play/pause @@ -41,24 +61,17 @@ When GUI is enabled (`play --gui` or `gui`), the workbench provides tabbed tools The control deck still exposes all keyboard-mapped actions (`±1/10/100` stepping, loop/ping-pong, save, HUD toggle, speed ±, clip select, exit). Each interactive control also provides a hover tooltip that explains the action. -The deck now presents a dedicated, high-visibility runtime monitor card with live playback -state and mode flags for beginner-friendly status tracking while controlling playback. +The status dock groups the runtime monitor, output log, and progress display for quick +state checks while controlling playback. The Tune tab exposes full-pose IK numeric editing (position + orientation), unit switching (`m / cm / mm`, `rad / deg`), and step-based nudge controls for precise refinement. Tune workflow uses a dual-level data flow: `Current Pose` (runtime read-only) and `Target Pose` (editable fields), with explicit `Reference Frame` selection (`world` / local). +For high-resolution displays, use the GUI header `Font Size` selector (next to `Language`); +if no CJK font is available, the panel falls back to the best readable platform font. +The output menu provides a `Clear` action, and export progress advances per rendered frame. For details, see [IK Usage Guide](IK_USAGE.md). -GUI visual QA command (desktop session): - -```bash -RMP_GUI_SNAPSHOT_OUT=/tmp/rmp-monitor-card.png \ -RMP_GUI_LAYOUT_REPORT_OUT=/tmp/rmp-monitor-card-layout.json \ -motion_player play --motion path/to/clip.pkl --robot path/to/robot.xml --gui -``` - -Expected report field in `/tmp/rmp-monitor-card-layout.json`: `"fits_all_lines": true`. - ## 4) Generate quality report ```bash @@ -96,7 +109,7 @@ Notes: motion_player export \ --motion path/to/clip.pkl \ --robot path/to/robot.xml \ - --output /tmp/clip.gif \ + --output clip.gif \ --fps 20 ``` @@ -107,3 +120,4 @@ motion_player export \ - `Motion path not found` / `Robot model path not found`: verify absolute file paths. - Chinese labels show as `???`: install a CJK font and set `RMP_GUI_FONT=/absolute/path/to/font.ttf`. - If GUI startup reports a DearPyGui font-range error, upgrade to latest patch version and keep `RMP_GUI_FONT` pointing to a valid CJK font file. +- If GUI startup warns that panel process could not start, playback is still running in viewer-only fallback mode. diff --git a/docs/QUICKSTART_zh.md b/docs/QUICKSTART_zh.md index 75560b8..7109326 100644 --- a/docs/QUICKSTART_zh.md +++ b/docs/QUICKSTART_zh.md @@ -24,6 +24,26 @@ motion_player play --motion path/to/clip.pkl --robot path/to/robot.xml --gui motion_player gui --motion path/to/clip.pkl --robot path/to/robot.xml ``` +`motion_player gui` 现在默认优先使用面板隔离进程模式。 +若面板启动失败,运行时会打印告警并自动回退到 MuJoCo 键盘控制模式。 + +### v0.8.0 GUI 启动字号控制 + +可在 GUI 入口命令启动时指定字号: + +```bash +motion_player play --motion path/to/clip.pkl --robot path/to/robot.xml --gui --font-size large +motion_player gui --motion path/to/clip.pkl --robot path/to/robot.xml --font-size xlarge +``` + +也可通过环境变量设置默认值: + +```bash +export RMP_GUI_FONT_SIZE=medium +``` + +支持值:`small`、`medium`、`large`、`xlarge`。 + ## 3)常用按键 - `Space`:播放/暂停 @@ -41,23 +61,16 @@ motion_player gui --motion path/to/clip.pkl --robot path/to/robot.xml 控制面板仍可覆盖全部键盘映射操作(`±1/10/100` 步进、循环/乒乓、 保存、HUD 开关、速度 ±、片段切换、退出)。 每个交互控件均支持悬停提示,可说明该操作的含义。 -控制台状态监控现采用独立高可见度运行卡片,实时展示播放与模式状态,便于新手一边控制一边监控。 +状态停靠区将运行监控、输出日志和进度显示合并在一起,便于快速查看播放状态。 Tune 分栏支持全姿态 IK 数值编辑(位置 + 朝向)、单位切换(`m / cm / mm`、`rad / deg`) 与步长微调按钮,便于精确调参。 Tune 工作流采用双层数据流:`Current Pose`(运行态只读)与 `Target Pose`(可编辑输入), 并提供 `Reference Frame`(`world` / 局部坐标)显式切换。 +高分辨率屏幕可使用 GUI 顶部 `字号` 选择器(位于 `语言` 旁)调整显示大小; +若没有可用的 CJK 字体,界面会回退到可读的平台字体。 +输出菜单提供 `清空` 操作,导出进度条按逐帧渲染进度更新。 当前 IK 说明请参考 [IK 使用说明](IK_USAGE.md)。 -GUI 可视化质检命令(桌面会话): - -```bash -RMP_GUI_SNAPSHOT_OUT=/tmp/rmp-monitor-card.png \ -RMP_GUI_LAYOUT_REPORT_OUT=/tmp/rmp-monitor-card-layout.json \ -motion_player play --motion path/to/clip.pkl --robot path/to/robot.xml --gui -``` - -`/tmp/rmp-monitor-card-layout.json` 的期望字段:`"fits_all_lines": true`。 - ## 4)导出质量报告 ```bash @@ -95,7 +108,7 @@ motion_player convert --input path/to/robot.xml --output path/to/robot.urdf motion_player export \ --motion path/to/clip.pkl \ --robot path/to/robot.xml \ - --output /tmp/clip.gif \ + --output clip.gif \ --fps 20 ``` @@ -106,3 +119,4 @@ motion_player export \ - `Motion path not found` / `Robot model path not found`:检查路径是否真实存在。 - 中文标签显示为 `???`:请安装 CJK 字体,并设置 `RMP_GUI_FONT=/absolute/path/to/font.ttf`。 - 若 GUI 启动出现 DearPyGui 字体范围错误,请升级到最新补丁版本,并确保 `RMP_GUI_FONT` 指向有效的 CJK 字体文件。 +- 若 GUI 启动提示面板进程失败,说明当前已进入 viewer-only 回退模式,播放仍可继续。 diff --git a/example/robots/booster_t1/T1_23dof.urdf b/example/robots/booster_t1/T1_23dof.urdf index c224870..2724660 100644 --- a/example/robots/booster_t1/T1_23dof.urdf +++ b/example/robots/booster_t1/T1_23dof.urdf @@ -1,5 +1,5 @@ - - + - None: if not isinstance(frame_idx, int): raise TypeError(f"frame_idx must be int, got {type(frame_idx).__name__}.") if frame_idx < 0 or frame_idx >= self._motion.num_frames: - raise IndexError( - f"Frame {frame_idx} out of range [0, {self._motion.num_frames})." - ) + raise IndexError(f"Frame {frame_idx} out of range [0, {self._motion.num_frames}).") raise NotImplementedError( "IsaacBackend.apply_frame is not implemented. " "Subclass IsaacBackend and implement this method." diff --git a/motion_player/backends/mujoco_backend/state_driver.py b/motion_player/backends/mujoco_backend/state_driver.py index 493b1d7..9bc3387 100644 --- a/motion_player/backends/mujoco_backend/state_driver.py +++ b/motion_player/backends/mujoco_backend/state_driver.py @@ -210,21 +210,19 @@ def apply_frame(self, frame_idx: int) -> None: if not isinstance(frame_idx, int): raise TypeError(f"frame_idx must be int, got {type(frame_idx).__name__}.") if frame_idx < 0 or frame_idx >= self._motion.num_frames: - raise IndexError( - f"Frame {frame_idx} out of range [0, {self._motion.num_frames})." - ) + raise IndexError(f"Frame {frame_idx} out of range [0, {self._motion.num_frames}).") m = self._motion mj = self._mujoco # Root position (x, y, z) adr = self._free_joint_adr - self.data.qpos[adr: adr + 3] = m.root_pos[frame_idx] + self.data.qpos[adr : adr + 3] = m.root_pos[frame_idx] # Root quaternion: xyzw → wxyz q_xyzw = m.root_rot[frame_idx].astype(np.float64) q_wxyz = xyzw_to_wxyz(q_xyzw) - self.data.qpos[adr + 3: adr + 7] = q_wxyz + self.data.qpos[adr + 3 : adr + 7] = q_wxyz # DOF positions for j, dof_adr in enumerate(self._dof_qpos_adr): diff --git a/motion_player/backends/mujoco_backend/viewer.py b/motion_player/backends/mujoco_backend/viewer.py index 2290dc9..0f7495e 100644 --- a/motion_player/backends/mujoco_backend/viewer.py +++ b/motion_player/backends/mujoco_backend/viewer.py @@ -93,8 +93,7 @@ def __init__( import mujoco.viewer # type: ignore[import] except ImportError as exc: raise ImportError( - "mujoco package is required for MuJoCoViewer. " - "Install it with: pip install mujoco" + "mujoco package is required for MuJoCoViewer. Install it with: pip install mujoco" ) from exc self._mujoco = mujoco @@ -715,7 +714,9 @@ def _handle_apply_ik_payload(self, payload: object | None) -> None: target_idx = joint_names.index(target_joint) body_id = int(self._driver.dof_joint_body_id(target_idx)) current_pos = np.asarray(self._driver.data.xpos[body_id], dtype=np.float64) - current_quat = np.asarray(getattr(self._driver.data, "xquat", np.array([[1.0, 0.0, 0.0, 0.0]])), dtype=np.float64) + current_quat = np.asarray( + getattr(self._driver.data, "xquat", np.array([[1.0, 0.0, 0.0, 0.0]])), dtype=np.float64 + ) if body_id >= current_quat.shape[0]: current_quat_wxyz = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64) else: diff --git a/motion_player/cli/gui_runtime.py b/motion_player/cli/gui_runtime.py index d1a7aca..7dd0fe4 100644 --- a/motion_player/cli/gui_runtime.py +++ b/motion_player/cli/gui_runtime.py @@ -30,11 +30,48 @@ def run_backend_connected_gui( backend: str, require_panel: bool, warn_if_panel_unavailable: bool = False, + prefer_isolated: bool = False, + initial_font_size_key: str = "medium", ) -> int: """Run a backend-connected playback session with optional DearPyGui panel. When ``require_panel`` is True, DearPyGui availability is treated as required. """ + if prefer_isolated: + from motion_player.cli.gui_runtime_isolated import run_backend_connected_gui_isolated + + return run_backend_connected_gui_isolated( + motion=motion, + robot=robot, + root_joint=root_joint, + backend=backend, + require_panel=require_panel, + warn_if_panel_unavailable=warn_if_panel_unavailable, + initial_font_size_key=initial_font_size_key, + ) + + return _run_backend_connected_gui_in_process( + motion=motion, + robot=robot, + root_joint=root_joint, + backend=backend, + require_panel=require_panel, + warn_if_panel_unavailable=warn_if_panel_unavailable, + initial_font_size_key=initial_font_size_key, + ) + + +def _run_backend_connected_gui_in_process( + *, + motion: str, + robot: str, + root_joint: str, + backend: str, + require_panel: bool, + warn_if_panel_unavailable: bool = False, + initial_font_size_key: str = "medium", +) -> int: + """Original in-process panel runtime used by `play --gui`.""" motion_path = Path(motion) robot_path = Path(robot) if not motion_path.exists(): @@ -81,6 +118,7 @@ def run_backend_connected_gui( command_runner=CommandRunner(), default_motion_path=str(motion_path), default_robot_path=str(robot_path), + initial_font_size_key=initial_font_size_key, ) panel.launch_non_blocking() elif require_panel: diff --git a/motion_player/cli/gui_runtime_isolated.py b/motion_player/cli/gui_runtime_isolated.py new file mode 100644 index 0000000..cb01741 --- /dev/null +++ b/motion_player/cli/gui_runtime_isolated.py @@ -0,0 +1,237 @@ +# Copyright 2026 Mr-tooth +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Process-isolated runtime launcher for full GUI workbench mode.""" + +from __future__ import annotations + +import multiprocessing +import queue +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from motion_player.core.dataset.loader import DatasetLoader +from motion_player.gui.panel_ipc import PanelCommandReceiver, PanelMonitorPublisher + + +@dataclass +class _PanelRuntime: + command_receiver: PanelCommandReceiver + monitor_publisher: PanelMonitorPublisher + process: Any + stop_event: Any + + def close(self) -> None: + try: + self.stop_event.set() + except Exception: # noqa: BLE001 + pass + + try: + if self.process.is_alive(): + self.process.join(timeout=0.8) + if self.process.is_alive(): + self.process.terminate() + self.process.join(timeout=1.0) + except Exception: # noqa: BLE001 + return + + +def _status_put(status_queue: Any, message: str) -> None: + try: + status_queue.put_nowait(message) + except Exception: # noqa: BLE001 + return + + +def _panel_process_entry( + command_queue: Any, + monitor_queue: Any, + status_queue: Any, + stop_event: Any, + default_motion_path: str, + default_robot_path: str, + initial_font_size_key: str, +) -> None: + from motion_player.gui.command_runner import CommandRunner + from motion_player.gui.controller import GuiController + from motion_player.gui.dearpygui_panel import DearPyGuiPanel + from motion_player.gui.panel_ipc import PanelCommandSender, PanelMonitorSubscriber + + try: + controller = GuiController(PanelCommandSender(command_queue)) + monitor_bus = PanelMonitorSubscriber(monitor_queue) + panel = DearPyGuiPanel( + controller=controller, + monitor_bus=monitor_bus, + command_runner=CommandRunner(), + default_motion_path=default_motion_path, + default_robot_path=default_robot_path, + initial_font_size_key=initial_font_size_key, + ) + except Exception as exc: # noqa: BLE001 + _status_put(status_queue, f"failed:{exc}") + return + + rc = panel.run_process_entry(lambda msg: _status_put(status_queue, msg)) + if rc != 0 and not stop_event.is_set(): + _status_put(status_queue, f"failed:exit-{rc}") + + +def _start_panel_runtime( + *, + default_motion_path: str, + default_robot_path: str, + initial_font_size_key: str, + startup_timeout_s: float = 2.0, +) -> _PanelRuntime: + ctx = multiprocessing.get_context("spawn") + command_queue = ctx.Queue(maxsize=256) + monitor_queue = ctx.Queue(maxsize=1) + status_queue = ctx.Queue(maxsize=16) + stop_event = ctx.Event() + + process = ctx.Process( + target=_panel_process_entry, + args=( + command_queue, + monitor_queue, + status_queue, + stop_event, + default_motion_path, + default_robot_path, + initial_font_size_key, + ), + daemon=True, + ) + process.start() + + deadline = time.monotonic() + max(0.2, float(startup_timeout_s)) + while time.monotonic() < deadline: + if not process.is_alive(): + break + try: + message = status_queue.get(timeout=0.05) + except queue.Empty: + continue + msg = str(message) + if msg == "ready": + return _PanelRuntime( + command_receiver=PanelCommandReceiver(command_queue), + monitor_publisher=PanelMonitorPublisher(monitor_queue), + process=process, + stop_event=stop_event, + ) + if msg.startswith("failed:"): + process.join(timeout=0.2) + raise RuntimeError(msg.partition(":")[2] or "panel process failed") + + if process.is_alive(): + process.terminate() + process.join(timeout=1.0) + raise RuntimeError("panel process startup timeout") + + +def _warn_fallback(message: str) -> None: + print(message, file=sys.stderr) + + +def run_backend_connected_gui_isolated( + *, + motion: str, + robot: str, + root_joint: str, + backend: str, + require_panel: bool, + warn_if_panel_unavailable: bool = False, + initial_font_size_key: str = "medium", +) -> int: + """Run backend-connected playback with panel in isolated subprocess.""" + motion_path = Path(motion) + robot_path = Path(robot) + if not motion_path.exists(): + raise FileNotFoundError(f"Motion path not found: '{motion_path}'") + if not robot_path.exists(): + raise FileNotFoundError(f"Robot model path not found: '{robot_path}'") + + loader = DatasetLoader() + if motion_path.is_dir(): + motions = loader.load_folder(motion_path) + if not motions: + raise ValueError(f"No motion files found in '{motion_path}'.") + else: + motions = [loader.load(motion_path)] + + if backend != "mujoco": + raise ValueError(f"Unsupported backend '{backend}'.") + + from motion_player.backends.mujoco_backend.state_driver import MuJoCoStateDriver + from motion_player.backends.mujoco_backend.viewer import MuJoCoViewer + + driver = MuJoCoStateDriver( + model_path=robot_path, + root_joint_name=root_joint or "root", + ) + driver.bind_motion(motions[0]) + + panel_runtime: _PanelRuntime | None = None + + from motion_player.gui.dearpygui_panel import DearPyGuiPanel + + if DearPyGuiPanel.is_available(): + try: + panel_runtime = _start_panel_runtime( + default_motion_path=str(motion_path), + default_robot_path=str(robot_path), + initial_font_size_key=initial_font_size_key, + ) + except Exception as exc: # noqa: BLE001 + if require_panel: + raise RuntimeError("DearPyGui panel process startup failed.") from exc + if warn_if_panel_unavailable: + _warn_fallback( + "--gui was requested but DearPyGui panel process failed to start. " + "Continuing with MuJoCo keyboard controls only. " + f"Reason: {exc}" + ) + elif require_panel: + raise ImportError( + "DearPyGui is not installed. Install GUI extras with: " + "pip install 'robot-motion-player[gui]'" + ) + elif warn_if_panel_unavailable: + _warn_fallback( + "--gui was requested but DearPyGui is not installed. " + "Install GUI extras with: pip install 'robot-motion-player[gui]'. " + "Continuing with MuJoCo keyboard controls only." + ) + + if panel_runtime is None: + viewer = MuJoCoViewer(driver, motions) + else: + viewer = MuJoCoViewer( + driver, + motions, + external_queue=panel_runtime.command_receiver, + monitor_bus=panel_runtime.monitor_publisher, + ) + try: + viewer.run() + finally: + if panel_runtime is not None: + panel_runtime.close() + return 0 diff --git a/motion_player/cli/main.py b/motion_player/cli/main.py index 1987cca..aaa30df 100644 --- a/motion_player/cli/main.py +++ b/motion_player/cli/main.py @@ -46,11 +46,25 @@ import argparse import logging +import os import sys from pathlib import Path logger = logging.getLogger(__name__) +_FONT_SIZE_CHOICES: tuple[str, ...] = ("small", "medium", "large", "xlarge") + + +def _resolve_gui_font_size_key(cli_value: str | None) -> str: + if isinstance(cli_value, str): + token = cli_value.strip().lower() + if token in _FONT_SIZE_CHOICES: + return token + env_value = os.getenv("RMP_GUI_FONT_SIZE", "").strip().lower() + if env_value in _FONT_SIZE_CHOICES: + return env_value + return "medium" + def _cmd_play(args: argparse.Namespace) -> int: """Launch the interactive MuJoCo viewer.""" @@ -83,8 +97,7 @@ def _cmd_play(args: argparse.Namespace) -> int: if not IsaacBackend.is_available(): print( - "Isaac backend is not available in this environment; " - "falling back to MuJoCo.", + "Isaac backend is not available in this environment; falling back to MuJoCo.", file=sys.stderr, ) selected_backend = "mujoco" @@ -121,6 +134,10 @@ def _cmd_play(args: argparse.Namespace) -> int: backend=selected_backend, require_panel=False, warn_if_panel_unavailable=True, + prefer_isolated=False, + initial_font_size_key=_resolve_gui_font_size_key( + getattr(args, "font_size", None) + ), ) from motion_player.backends.mujoco_backend.state_driver import MuJoCoStateDriver @@ -180,10 +197,7 @@ def _cmd_metrics(args: argparse.Namespace) -> int: print(f"Overall score: {engine.overall_score():.4f} (lower is better)") for name, score in scores.items(): bad = len(score.bad_frames) - print( - f" {name:<35} summary={score.summary:.4f} " - f"bad_frames={bad}" - ) + print(f" {name:<35} summary={score.summary:.4f} bad_frames={bad}") if args.output: fmt = "csv" if args.output.endswith(".csv") else "json" @@ -269,6 +283,7 @@ def _cmd_export(args: argparse.Namespace) -> int: driver=driver, frame_idx=frame_idx, ), + progress_callback=getattr(args, "progress_callback", None), ) finally: if hasattr(renderer, "close"): @@ -292,13 +307,29 @@ def _cmd_gui(args: argparse.Namespace) -> int: return 1 try: - return run_backend_connected_gui( + runtime_rc = run_backend_connected_gui( motion=str(args.motion), robot=str(args.robot), root_joint=args.root_joint or "root", backend=args.backend, - require_panel=True, + require_panel=False, + warn_if_panel_unavailable=True, + prefer_isolated=True, + initial_font_size_key=_resolve_gui_font_size_key(getattr(args, "font_size", None)), ) + if int(runtime_rc) == 0: + return 0 + if int(runtime_rc) in {139, -11}: + print( + "Full GUI runtime terminated abnormally (signal-like exit code).", + file=sys.stderr, + ) + else: + print( + f"Full GUI runtime exited with code {int(runtime_rc)}.", + file=sys.stderr, + ) + return 1 except (ImportError, RuntimeError, ValueError, OSError, FileNotFoundError, KeyError) as exc: print(f"Failed to launch full GUI mode: {exc}", file=sys.stderr) return 1 @@ -315,8 +346,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="motion_player", description=( - "robot-motion-player — cross-platform AMP motion dataset " - "visualiser & editor." + "robot-motion-player — cross-platform AMP motion dataset visualiser & editor." ), ) parser.add_argument( @@ -342,10 +372,15 @@ def build_parser() -> argparse.ArgumentParser: choices=["mujoco", "isaac"], default="mujoco", help=( - "Rendering backend. 'isaac' is minimal in v0.1 and " - "falls back to MuJoCo if unavailable." + "Rendering backend. 'isaac' is minimal in v0.1 and falls back to MuJoCo if unavailable." ), ) + p_play.add_argument( + "--font-size", + choices=list(_FONT_SIZE_CHOICES), + default=None, + help="Startup GUI font size key for --gui mode (runtime combo still overrides).", + ) # --- audit --- p_audit = subparsers.add_parser("audit", help="Inspect joint ordering.") @@ -383,8 +418,12 @@ def build_parser() -> argparse.ArgumentParser: # --- gui --- p_gui = subparsers.add_parser("gui", help="Launch full-feature GUI workbench.") - p_gui.add_argument("--motion", default=None, help="Optional default motion path for GUI tool tabs.") - p_gui.add_argument("--robot", default=None, help="Optional default robot path for GUI tool tabs.") + p_gui.add_argument( + "--motion", default=None, help="Optional default motion path for GUI tool tabs." + ) + p_gui.add_argument( + "--robot", default=None, help="Optional default robot path for GUI tool tabs." + ) p_gui.add_argument("--root-joint", default="root", help="Name of the root free joint.") p_gui.add_argument( "--backend", @@ -392,6 +431,12 @@ def build_parser() -> argparse.ArgumentParser: default="mujoco", help="Rendering backend for full GUI mode.", ) + p_gui.add_argument( + "--font-size", + choices=list(_FONT_SIZE_CHOICES), + default=None, + help="Startup GUI font size key (runtime combo still overrides).", + ) return parser @@ -423,7 +468,7 @@ def _get_version() -> str: return version("robot-motion-player") except PackageNotFoundError: logger.debug("Package metadata not found, using dev version.") - return "0.7.0.dev0" + return "0.8.0.dev0" if __name__ == "__main__": diff --git a/motion_player/core/dataset/loader.py b/motion_player/core/dataset/loader.py index 2da4f1e..da6932a 100644 --- a/motion_player/core/dataset/loader.py +++ b/motion_player/core/dataset/loader.py @@ -177,19 +177,13 @@ def _read_file(self, path: Path) -> dict: with open(path, "rb") as f: return pickle.load(f) # noqa: S301 else: - raise ValueError( - f"Unsupported file extension '{suffix}'; expected .npy or .pkl" - ) + raise ValueError(f"Unsupported file extension '{suffix}'; expected .npy or .pkl") - def _parse_dict( - self, raw: dict, source_path: str | None = None - ) -> StandardMotion: + def _parse_dict(self, raw: dict, source_path: str | None = None) -> StandardMotion: """Convert a raw dict to a ``StandardMotion``.""" missing = _REQUIRED_KEYS - set(raw.keys()) if missing: - raise KeyError( - f"Motion file is missing required keys: {sorted(missing)}" - ) + raise KeyError(f"Motion file is missing required keys: {sorted(missing)}") fps = float(raw["fps"]) root_pos = np.asarray(raw["root_pos"], dtype=np.float32) @@ -208,11 +202,7 @@ def _parse_dict( # Always normalise quaternions to unit length root_rot = normalize(root_rot.astype(np.float64)).astype(np.float32) - joint_names: list[str] | None = ( - list(raw["joint_names"]) - if "joint_names" in raw - else None - ) + joint_names: list[str] | None = list(raw["joint_names"]) if "joint_names" in raw else None motion_weight = float(raw.get("motion_weight", 1.0)) return StandardMotion( diff --git a/motion_player/core/dataset/motion.py b/motion_player/core/dataset/motion.py index ef63692..de43701 100644 --- a/motion_player/core/dataset/motion.py +++ b/motion_player/core/dataset/motion.py @@ -80,13 +80,13 @@ class StandardMotion: """ fps: float - root_pos: np.ndarray # (N, 3) world frame, metres - root_rot: np.ndarray # (N, 4) xyzw scalar-last - dof_pos: np.ndarray # (N, D) radians - dof_vel: np.ndarray # (N, D) rad/s - projected_gravity: np.ndarray # (N, 3) root-local - root_lin_vel: np.ndarray # (N, 3) root-local m/s - root_ang_vel: np.ndarray # (N, 3) root-local rad/s + root_pos: np.ndarray # (N, 3) world frame, metres + root_rot: np.ndarray # (N, 4) xyzw scalar-last + dof_pos: np.ndarray # (N, D) radians + dof_vel: np.ndarray # (N, D) rad/s + projected_gravity: np.ndarray # (N, 3) root-local + root_lin_vel: np.ndarray # (N, 3) root-local m/s + root_ang_vel: np.ndarray # (N, 3) root-local rad/s key_body_pos_local: np.ndarray # (N, K*3) root-local metres joint_names: list[str] | None = None source_path: str | None = None @@ -136,16 +136,12 @@ def validate(self) -> None: for attr, shape in expected.items(): arr = getattr(self, attr) if arr.shape != shape: - raise ValueError( - f"StandardMotion.{attr}: expected shape {shape}, " - f"got {arr.shape}" - ) + raise ValueError(f"StandardMotion.{attr}: expected shape {shape}, got {arr.shape}") # key_body_pos_local: first dim must be N, second must be multiple of 3 kbl = self.key_body_pos_local if kbl.shape[0] != n: raise ValueError( - f"StandardMotion.key_body_pos_local: first dim must be {n}, " - f"got {kbl.shape[0]}" + f"StandardMotion.key_body_pos_local: first dim must be {n}, got {kbl.shape[0]}" ) if kbl.shape[1] % 3 != 0: raise ValueError( diff --git a/motion_player/core/editing/editor_session.py b/motion_player/core/editing/editor_session.py index e892eb7..b11208e 100644 --- a/motion_player/core/editing/editor_session.py +++ b/motion_player/core/editing/editor_session.py @@ -87,13 +87,17 @@ def prev_marked_frame(self, frame: int, wrap: bool = True) -> int | None: return int(marked) return int(self._keyframes[-1]) if wrap else None - def apply_dof_edit(self, frame: int, joint_idx: int, delta: float, propagate_radius: int = 0) -> None: + def apply_dof_edit( + self, frame: int, joint_idx: int, delta: float, propagate_radius: int = 0 + ) -> None: self.frame_editor.edit_dof(frame=frame, joint_idx=joint_idx, delta=delta, push_history=True) if propagate_radius <= 0: return delta_vec = np.zeros(self.motion.num_dofs, dtype=self.motion.dof_pos.dtype) delta_vec[joint_idx] = delta - self._propagate_with_keyframe_guards(anchor_frame=frame, delta_dof=delta_vec, radius=propagate_radius) + self._propagate_with_keyframe_guards( + anchor_frame=frame, delta_dof=delta_vec, radius=propagate_radius + ) def undo(self) -> None: self.frame_editor.undo() @@ -113,7 +117,9 @@ def apply_eef_edit( current = self.motion.dof_pos[frame].astype(np.float64) solved = np.asarray(self.ik_solver.solve(current, targets), dtype=np.float64) if solved.shape != current.shape: - raise ValueError(f"IK solve shape mismatch: expected {current.shape}, got {solved.shape}") + raise ValueError( + f"IK solve shape mismatch: expected {current.shape}, got {solved.shape}" + ) self.frame_editor.snapshot() self.motion.dof_pos[frame] = solved.astype(self.motion.dof_pos.dtype) if propagate_radius > 0: @@ -137,7 +143,9 @@ def save_versioned(self, source_path: str | None = None) -> Path: DatasetLoader(validate=False).save(self.motion, out, fmt=fmt) return out - def _propagate_with_keyframe_guards(self, anchor_frame: int, delta_dof: np.ndarray, radius: int) -> None: + def _propagate_with_keyframe_guards( + self, anchor_frame: int, delta_dof: np.ndarray, radius: int + ) -> None: end = min(anchor_frame + radius + 1, self.motion.num_frames) blocked = set(self._keyframes) - {anchor_frame} for frame in range(anchor_frame + 1, end): diff --git a/motion_player/core/editing/frame_editor.py b/motion_player/core/editing/frame_editor.py index 27c567d..308f51d 100644 --- a/motion_player/core/editing/frame_editor.py +++ b/motion_player/core/editing/frame_editor.py @@ -53,12 +53,15 @@ def _quat_multiply_xyzw(q1: np.ndarray, q2: np.ndarray) -> np.ndarray: """Multiply two xyzw quaternions: q_result = q1 ⊗ q2.""" x1, y1, z1, w1 = q1 x2, y2, z2, w2 = q2 - return np.array([ - w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2, - w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2, - w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2, - w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2, - ], dtype=np.float64) + return np.array( + [ + w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2, + w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2, + w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2, + w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2, + ], + dtype=np.float64, + ) class FrameEditor: @@ -200,9 +203,7 @@ def clamp_joint_limits(self, frame: int) -> None: not provided at construction time. """ if self._lo is not None and self._hi is not None: - self.motion.dof_pos[frame] = np.clip( - self.motion.dof_pos[frame], self._lo, self._hi - ) + self.motion.dof_pos[frame] = np.clip(self.motion.dof_pos[frame], self._lo, self._hi) def normalize_quat(self, frame: int) -> None: """Normalise the root quaternion at *frame* to unit length.""" @@ -215,6 +216,4 @@ def normalize_quat(self, frame: int) -> None: def _check_frame(self, frame: int) -> None: if not (0 <= frame < self.motion.num_frames): - raise IndexError( - f"Frame {frame} out of range [0, {self.motion.num_frames})." - ) + raise IndexError(f"Frame {frame} out of range [0, {self.motion.num_frames}).") diff --git a/motion_player/core/editing/segment_editor.py b/motion_player/core/editing/segment_editor.py index fe9d4e2..861ee83 100644 --- a/motion_player/core/editing/segment_editor.py +++ b/motion_player/core/editing/segment_editor.py @@ -131,9 +131,7 @@ def keyframe_interpolate( ).astype(np.float32) # --- root_rot --- - q0, q1 = m.root_rot[i0].astype(np.float64), m.root_rot[i1].astype( - np.float64 - ) + q0, q1 = m.root_rot[i0].astype(np.float64), m.root_rot[i1].astype(np.float64) if mode in ("slerp", "spline"): m.root_rot[i0 : i1 + 1] = _slerp(q0, q1, t).astype(np.float32) else: @@ -216,13 +214,9 @@ def smooth_segment( for col in range(segment.shape[1]): smoothed[:, col] = sosfilt(sos, segment[:, col]) else: - raise ValueError( - f"Unknown filter_type '{filter_type}'; use 'savgol' or 'butter'." - ) + raise ValueError(f"Unknown filter_type '{filter_type}'; use 'savgol' or 'butter'.") - arr[i0 : i1 + 1] = smoothed.reshape(arr[i0 : i1 + 1].shape).astype( - arr.dtype - ) + arr[i0 : i1 + 1] = smoothed.reshape(arr[i0 : i1 + 1].shape).astype(arr.dtype) # Re-normalise quaternions if we smoothed root_rot if field == "root_rot": @@ -268,6 +262,4 @@ def propagate_edit( def _check_segment(self, i0: int, i1: int) -> None: n = self.motion.num_frames if not (0 <= i0 < i1 < n): - raise IndexError( - f"Segment [{i0}, {i1}] out of valid range [0, {n})." - ) + raise IndexError(f"Segment [{i0}, {i1}] out of valid range [0, {n}).") diff --git a/motion_player/core/export/video_export.py b/motion_player/core/export/video_export.py index 7170994..08f5880 100644 --- a/motion_player/core/export/video_export.py +++ b/motion_player/core/export/video_export.py @@ -45,13 +45,20 @@ def export_video_with_renderer( out_fps: float, render_frame: Callable[[int], np.ndarray], output_path: str | Path, + progress_callback: Callable[[int, int], None] | None = None, ) -> Path: """Render sampled frames and encode to gif/mp4.""" path = Path(output_path) frame_ids = _frame_schedule(num_frames=num_frames, src_fps=src_fps, out_fps=out_fps) if not frame_ids: raise ValueError("No source frames available for export.") - _encode_video(path=path, frame_ids=frame_ids, render_frame=render_frame, fps=out_fps) + _encode_video( + path=path, + frame_ids=frame_ids, + render_frame=render_frame, + fps=out_fps, + progress_callback=progress_callback, + ) return path @@ -61,6 +68,7 @@ def _encode_video( frame_ids: list[int], render_frame: Callable[[int], np.ndarray], fps: float, + progress_callback: Callable[[int, int], None] | None = None, ) -> None: suffix = path.suffix.lower() if suffix not in (".gif", ".mp4"): @@ -77,12 +85,15 @@ def _encode_video( writer_kwargs["format"] = "GIF" writer = imageio.get_writer(str(path), **writer_kwargs) try: - for frame_idx in frame_ids: + total = len(frame_ids) + for done, frame_idx in enumerate(frame_ids, start=1): frame = np.asarray(render_frame(frame_idx), dtype=np.uint8) if frame.ndim != 3 or frame.shape[2] not in (3, 4): raise ValueError( f"render_frame({frame_idx}) must return HxWx3/4 uint8 image, got {frame.shape}" ) writer.append_data(frame[:, :, :3]) + if progress_callback is not None: + progress_callback(done, total) finally: writer.close() diff --git a/motion_player/core/kinematics/frame_transform.py b/motion_player/core/kinematics/frame_transform.py index 86718f5..651849e 100644 --- a/motion_player/core/kinematics/frame_transform.py +++ b/motion_player/core/kinematics/frame_transform.py @@ -58,8 +58,12 @@ def rotate_point_wxyz(quat_wxyz: np.ndarray, point_xyz: np.ndarray) -> np.ndarra return p + q[0] * t + np.cross(q_vec, t) -def transform_point(parent_pos: np.ndarray, parent_quat_wxyz: np.ndarray, point_local: np.ndarray) -> np.ndarray: - return np.asarray(parent_pos, dtype=np.float64) + rotate_point_wxyz(parent_quat_wxyz, point_local) +def transform_point( + parent_pos: np.ndarray, parent_quat_wxyz: np.ndarray, point_local: np.ndarray +) -> np.ndarray: + return np.asarray(parent_pos, dtype=np.float64) + rotate_point_wxyz( + parent_quat_wxyz, point_local + ) def invert_pose(pos_w: np.ndarray, quat_wxyz: np.ndarray) -> tuple[np.ndarray, np.ndarray]: diff --git a/motion_player/core/kinematics/ik_backends/mujoco_xml_backend.py b/motion_player/core/kinematics/ik_backends/mujoco_xml_backend.py index 2ee5137..c1d6d6e 100644 --- a/motion_player/core/kinematics/ik_backends/mujoco_xml_backend.py +++ b/motion_player/core/kinematics/ik_backends/mujoco_xml_backend.py @@ -118,7 +118,9 @@ def _normalize_quat(q: np.ndarray) -> np.ndarray: return quat / norm @classmethod - def _quat_delta_to_rotvec(cls, q_target_wxyz: np.ndarray, q_current_wxyz: np.ndarray) -> np.ndarray: + def _quat_delta_to_rotvec( + cls, q_target_wxyz: np.ndarray, q_current_wxyz: np.ndarray + ) -> np.ndarray: qt = cls._normalize_quat(q_target_wxyz) qc = cls._normalize_quat(q_current_wxyz) diff --git a/motion_player/core/kinematics/ik_backends/pinocchio_urdf_backend.py b/motion_player/core/kinematics/ik_backends/pinocchio_urdf_backend.py index 1bef297..40439be 100644 --- a/motion_player/core/kinematics/ik_backends/pinocchio_urdf_backend.py +++ b/motion_player/core/kinematics/ik_backends/pinocchio_urdf_backend.py @@ -41,7 +41,9 @@ def _normalize_quat(q: np.ndarray) -> np.ndarray: return quat / n @classmethod - def _quat_delta_to_rotvec(cls, q_target_wxyz: np.ndarray, q_current_wxyz: np.ndarray) -> np.ndarray: + def _quat_delta_to_rotvec( + cls, q_target_wxyz: np.ndarray, q_current_wxyz: np.ndarray + ) -> np.ndarray: qt = cls._normalize_quat(q_target_wxyz) qc = cls._normalize_quat(q_current_wxyz) w1, x1, y1, z1 = qt @@ -188,7 +190,9 @@ def _solver(current_qpos: np.ndarray, targets: dict[str, PoseTarget]) -> np.ndar rot_cur = np.asarray(getattr(transform, "rotation", np.eye(3)), dtype=np.float64) q_cur = PinocchioUrdfIKBackend._rotmat_to_quat_wxyz(rot_cur) pos_err = target.position_m - p_cur - rot_err = PinocchioUrdfIKBackend._quat_delta_to_rotvec(target.orientation_wxyz, q_cur) + rot_err = PinocchioUrdfIKBackend._quat_delta_to_rotvec( + target.orientation_wxyz, q_cur + ) if np.linalg.norm(pos_err) < 1e-4 and np.linalg.norm(rot_err) < 1e-4: break diff --git a/motion_player/core/kinematics/joint_mapper.py b/motion_player/core/kinematics/joint_mapper.py index 84575ae..cae577c 100644 --- a/motion_player/core/kinematics/joint_mapper.py +++ b/motion_player/core/kinematics/joint_mapper.py @@ -168,17 +168,13 @@ def apply(self, dof_pos: np.ndarray) -> np.ndarray: return dof_pos if self._perm is None: - self._perm = self.build_mapping( - self.dataset_joint_names, self.model_joint_names - ) + self._perm = self.build_mapping(self.dataset_joint_names, self.model_joint_names) n = dof_pos.shape[0] d_model = len(self.model_joint_names) out = np.zeros((n, d_model), dtype=dof_pos.dtype) - for j, (src_idx, mname) in enumerate( - zip(self._perm, self.model_joint_names) - ): + for j, (src_idx, mname) in enumerate(zip(self._perm, self.model_joint_names)): if src_idx is not None: col = dof_pos[:, src_idx].copy() col *= self.sign_flip.get(mname, 1.0) diff --git a/motion_player/core/kinematics/joint_order_auditor.py b/motion_player/core/kinematics/joint_order_auditor.py index c93693d..b7f23e5 100644 --- a/motion_player/core/kinematics/joint_order_auditor.py +++ b/motion_player/core/kinematics/joint_order_auditor.py @@ -113,9 +113,7 @@ def _load_from_mjcf(self) -> list[str]: jnt_type = model.jnt_type[i] # mujoco.mjtJoint.mjJNT_FREE == 0; skip free joints if jnt_type != 0: - name = mujoco.mj_id2name( - model, mujoco.mjtObj.mjOBJ_JOINT, i - ) + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i) if name: names.append(name) return names @@ -254,9 +252,7 @@ def register_strategy(self, name: str, fn: Callable) -> None: """ self._strategies[name] = fn - def apply_strategy( - self, name: str, motion: StandardMotion, **kwargs - ) -> StandardMotion: + def apply_strategy(self, name: str, motion: StandardMotion, **kwargs) -> StandardMotion: """Apply a registered repair strategy. Parameters @@ -269,8 +265,5 @@ def apply_strategy( Passed through to the strategy function. """ if name not in self._strategies: - raise KeyError( - f"Unknown strategy '{name}'. " - f"Available: {list(self._strategies)}" - ) + raise KeyError(f"Unknown strategy '{name}'. Available: {list(self._strategies)}") return self._strategies[name](motion, **kwargs) diff --git a/motion_player/core/metrics/engine.py b/motion_player/core/metrics/engine.py index a0deb9f..be4442c 100644 --- a/motion_player/core/metrics/engine.py +++ b/motion_player/core/metrics/engine.py @@ -84,15 +84,15 @@ class MetricConfig: w_amp_feature_stability: float = 1.0 w_dof_vel_distribution: float = 1.0 w_joint_limit_violation: float = 1.0 # matches GMR w_joint_limit - w_foot_penetration: float = 1.0 # matches GMR w_foot_ground + w_foot_penetration: float = 1.0 # matches GMR w_foot_ground w_com_height: float = 0.5 - w_joint_acc: float = 0.5 # matches GMR w_smoothness + w_joint_acc: float = 0.5 # matches GMR w_smoothness w_joint_jerk: float = 0.2 # --- Thresholds (for PerFrameScore.bad_frames) --- - joint_limit_threshold: float = 0.01 # radians + joint_limit_threshold: float = 0.01 # radians foot_penetration_threshold: float = 0.01 # metres below ground - joint_acc_threshold: float = 50.0 # rad/s² + joint_acc_threshold: float = 50.0 # rad/s² # --- Joint limits --- joint_lower_limits: np.ndarray | None = None # (D,) @@ -118,8 +118,8 @@ class MetricConfig: ) # --- DOF velocity reference distribution --- - dof_vel_mean: np.ndarray | None = None # (D,) - dof_vel_std: np.ndarray | None = None # (D,) + dof_vel_mean: np.ndarray | None = None # (D,) + dof_vel_std: np.ndarray | None = None # (D,) dof_vel_sigma_threshold: float = 3.0 @@ -141,9 +141,7 @@ def __init__( ) -> None: self.motion = motion self.config = config or MetricConfig() - self._custom_terms: dict[ - str, tuple[Callable[[StandardMotion], PerFrameScore], float] - ] = {} + self._custom_terms: dict[str, tuple[Callable[[StandardMotion], PerFrameScore], float]] = {} # ------------------------------------------------------------------ # Extension point: custom terms @@ -197,7 +195,7 @@ def term_amp_feature_stability(self) -> PerFrameScore: weight=self.config.w_amp_feature_stability, ) feat = np.concatenate(parts, axis=1) # (N, F) - delta = np.diff(feat, axis=0) # (N-1, F) + delta = np.diff(feat, axis=0) # (N-1, F) norms = np.linalg.norm(delta, axis=1) # (N-1,) # Pad first frame with zero for uniform length values = np.concatenate([[0.0], norms]) @@ -217,19 +215,11 @@ def term_dof_vel_distribution(self) -> PerFrameScore: (``MetricConfig.dof_vel_mean/std``), the clip's own mean/std is used. """ dv = self.motion.dof_vel # (N, D) - mean = ( - self.config.dof_vel_mean - if self.config.dof_vel_mean is not None - else dv.mean(axis=0) - ) - std = ( - self.config.dof_vel_std - if self.config.dof_vel_std is not None - else dv.std(axis=0) - ) + mean = self.config.dof_vel_mean if self.config.dof_vel_mean is not None else dv.mean(axis=0) + std = self.config.dof_vel_std if self.config.dof_vel_std is not None else dv.std(axis=0) # Avoid division by zero safe_std = np.where(std < 1e-6, 1.0, std) - z = np.abs(dv - mean) / safe_std # (N, D) + z = np.abs(dv - mean) / safe_std # (N, D) # Count outlier joints per frame outlier_count = (z > self.config.dof_vel_sigma_threshold).sum(axis=1) return PerFrameScore( @@ -267,8 +257,8 @@ def term_joint_limit_violation(self) -> PerFrameScore: lo = np.asarray(lo) hi = np.asarray(hi) - violation_lo = np.maximum(0.0, lo - dof) # (N, D) - violation_hi = np.maximum(0.0, dof - hi) # (N, D) + violation_lo = np.maximum(0.0, lo - dof) # (N, D) + violation_hi = np.maximum(0.0, dof - hi) # (N, D) per_frame = (violation_lo + violation_hi).sum(axis=1) # (N,) return PerFrameScore( "joint_limit_violation", @@ -339,11 +329,11 @@ def term_joint_acc(self) -> PerFrameScore: High values indicate jitter that degrades both AMP discriminator performance and visual quality. Mirrors GMR ``smoothness_penalty``. """ - dof = self.motion.dof_pos # (N, D) + dof = self.motion.dof_pos # (N, D) dt = self.motion.dt vel = np.diff(dof, axis=0) / dt # (N-1, D) acc = np.diff(vel, axis=0) / dt # (N-2, D) - rms = np.sqrt((acc ** 2).mean(axis=1)) # (N-2,) + rms = np.sqrt((acc**2).mean(axis=1)) # (N-2,) # Pad to length N values = np.concatenate([[0.0, 0.0], rms]).astype(np.float32) return PerFrameScore( @@ -359,8 +349,8 @@ def term_joint_jerk(self) -> PerFrameScore: dt = self.motion.dt vel = np.diff(dof, axis=0) / dt acc = np.diff(vel, axis=0) / dt - jerk = np.diff(acc, axis=0) / dt # (N-3, D) - rms = np.sqrt((jerk ** 2).mean(axis=1)) + jerk = np.diff(acc, axis=0) / dt # (N-3, D) + rms = np.sqrt((jerk**2).mean(axis=1)) values = np.concatenate([[0.0, 0.0, 0.0], rms]).astype(np.float32) return PerFrameScore( "joint_jerk", @@ -423,9 +413,7 @@ def overall_score(self) -> float: total_weight = sum(s.weight for s in scores.values()) if total_weight < 1e-12: return 0.0 - weighted_sum = sum( - s.weight * (s.summary or 0.0) for s in scores.values() - ) + weighted_sum = sum(s.weight * (s.summary or 0.0) for s in scores.values()) return weighted_sum / total_weight # ------------------------------------------------------------------ @@ -473,9 +461,7 @@ def export_report( writer = csv.writer(f) writer.writerow(["frame"] + list(scores.keys())) for i in range(self.motion.num_frames): - row = [i] + [ - float(score.values[i]) for score in scores.values() - ] + row = [i] + [float(score.values[i]) for score in scores.values()] writer.writerow(row) else: raise ValueError(f"Unknown format '{fmt}'; use 'json' or 'csv'.") diff --git a/motion_player/core/metrics/per_frame_score.py b/motion_player/core/metrics/per_frame_score.py index 2d5566c..ae5b5cc 100644 --- a/motion_player/core/metrics/per_frame_score.py +++ b/motion_player/core/metrics/per_frame_score.py @@ -43,7 +43,7 @@ class PerFrameScore: """ term_name: str - values: np.ndarray # (N,) + values: np.ndarray # (N,) weight: float = 1.0 summary: float | None = None threshold: float | None = None diff --git a/motion_player/core/ui/__init__.py b/motion_player/core/ui/__init__.py index cadcdf5..d68b1e4 100644 --- a/motion_player/core/ui/__init__.py +++ b/motion_player/core/ui/__init__.py @@ -41,26 +41,28 @@ class PlayerCommand(enum.Enum): STEP_BWD_10 = "step_bwd_10" STEP_FWD_100 = "step_fwd_100" STEP_BWD_100 = "step_bwd_100" - SEEK_FRAME = "seek_frame" # payload: target frame index (int) + SEEK_FRAME = "seek_frame" # payload: target frame index (int) RESET = "reset" TOGGLE_LOOP = "toggle_loop" TOGGLE_PINGPONG = "toggle_pingpong" - CLIP_SELECT = "clip_select" # payload: clip index (int) + CLIP_SELECT = "clip_select" # payload: clip index (int) MARK_KEYFRAME = "mark_keyframe" PREV_MARKED_FRAME = "prev_marked_frame" NEXT_MARKED_FRAME = "next_marked_frame" TOGGLE_GHOST = "toggle_ghost" TOGGLE_EDIT = "toggle_edit" - EDIT_DOF_DELTA = "edit_dof_delta" # payload: {joint_idx, delta, propagate_radius?} + EDIT_DOF_DELTA = "edit_dof_delta" # payload: {joint_idx, delta, propagate_radius?} EDIT_ROOT_POS_DELTA = "edit_root_pos_delta" # payload: {dx, dy, dz} - APPLY_IK_TARGET = "apply_ik_target" # payload: legacy {target_joint, dx, dy, dz} or full-pose payload - SET_EDIT_JOINT = "set_edit_joint" # payload: dof index (int) + APPLY_IK_TARGET = ( + "apply_ik_target" # payload: legacy {target_joint, dx, dy, dz} or full-pose payload + ) + SET_EDIT_JOINT = "set_edit_joint" # payload: dof index (int) UNDO_EDIT = "undo_edit" REDO_EDIT = "redo_edit" SAVE_MOTION = "save_motion" - EXPORT_VIDEO = "export_video" # payload: {output, fps} + EXPORT_VIDEO = "export_video" # payload: {output, fps} TOGGLE_HUD = "toggle_hud" - SET_SPEED = "set_speed" # payload: playback speed (float) + SET_SPEED = "set_speed" # payload: playback speed (float) SPEED_UP = "speed_up" SPEED_DOWN = "speed_down" EXIT = "exit" @@ -82,7 +84,7 @@ class PlayerState: keyframes: list[int] = field(default_factory=list) mark_history: list[int] = field(default_factory=list) selected_joint_idx: int = 0 - direction: int = 1 # 1 = forward, -1 = backward (ping-pong) + direction: int = 1 # 1 = forward, -1 = backward (ping-pong) def toggle_play(self) -> None: self.playing = not self.playing diff --git a/motion_player/gui/command_runner.py b/motion_player/gui/command_runner.py index 1f49129..d3988e2 100644 --- a/motion_player/gui/command_runner.py +++ b/motion_player/gui/command_runner.py @@ -31,6 +31,8 @@ MetricsRequest, ) +ProgressCallback = Callable[[float, str], None] + class CommandRunner: """Wrapper over CLI command handlers for GUI parity.""" @@ -47,29 +49,83 @@ def __init__( self._convert_handler = convert_handler self._export_handler = export_handler - def _run_handler(self, handler: Callable[[argparse.Namespace], int], args: argparse.Namespace) -> CommandResult: + def _run_handler( + self, + handler: Callable[[argparse.Namespace], int], + args: argparse.Namespace, + *, + progress_callback: ProgressCallback | None = None, + title: str = "task", + emit_running_ratio: bool = True, + ) -> CommandResult: + if progress_callback is not None: + progress_callback(0.0, f"{title}: queued") out = io.StringIO() err = io.StringIO() try: + if progress_callback is not None and emit_running_ratio: + progress_callback(0.15, f"{title}: running") with redirect_stdout(out), redirect_stderr(err): rc = int(handler(args)) except Exception as exc: # noqa: BLE001 - return CommandResult(return_code=1, stdout=out.getvalue(), stderr=err.getvalue() + f"{exc}\n") + if progress_callback is not None: + progress_callback(1.0, f"{title}: failed") + return CommandResult( + return_code=1, stdout=out.getvalue(), stderr=err.getvalue() + f"{exc}\n" + ) + if progress_callback is not None: + progress_callback(1.0, f"{title}: complete") return CommandResult(return_code=rc, stdout=out.getvalue(), stderr=err.getvalue()) - def run_metrics(self, req: MetricsRequest) -> CommandResult: - args = argparse.Namespace(command="metrics", motion=req.motion, robot=req.robot, output=req.output) - return self._run_handler(self._metrics_handler, args) + def run_metrics( + self, req: MetricsRequest, progress_callback: ProgressCallback | None = None + ) -> CommandResult: + args = argparse.Namespace( + command="metrics", motion=req.motion, robot=req.robot, output=req.output + ) + return self._run_handler( + self._metrics_handler, + args, + progress_callback=progress_callback, + title="metrics", + ) - def run_audit(self, req: AuditRequest) -> CommandResult: - args = argparse.Namespace(command="audit", motion=req.motion, robot=req.robot, output=req.output) - return self._run_handler(self._audit_handler, args) + def run_audit( + self, req: AuditRequest, progress_callback: ProgressCallback | None = None + ) -> CommandResult: + args = argparse.Namespace( + command="audit", motion=req.motion, robot=req.robot, output=req.output + ) + return self._run_handler( + self._audit_handler, + args, + progress_callback=progress_callback, + title="audit", + ) - def run_convert(self, req: ConvertRequest) -> CommandResult: + def run_convert( + self, req: ConvertRequest, progress_callback: ProgressCallback | None = None + ) -> CommandResult: args = argparse.Namespace(command="convert", input=req.input_path, output=req.output_path) - return self._run_handler(self._convert_handler, args) + return self._run_handler( + self._convert_handler, + args, + progress_callback=progress_callback, + title="convert", + ) + + def run_export( + self, req: ExportRequest, progress_callback: ProgressCallback | None = None + ) -> CommandResult: + def _emit_export_progress(done: int, total: int) -> None: + if progress_callback is None: + return + if total <= 0: + progress_callback(0.0, "export: preparing") + return + ratio = max(0.0, min(1.0, float(done) / float(total))) + progress_callback(ratio, f"export: {done}/{total} frames") - def run_export(self, req: ExportRequest) -> CommandResult: args = argparse.Namespace( command="export", motion=req.motion, @@ -79,8 +135,15 @@ def run_export(self, req: ExportRequest) -> CommandResult: root_joint=req.root_joint, width=int(req.width), height=int(req.height), + progress_callback=_emit_export_progress, + ) + return self._run_handler( + self._export_handler, + args, + progress_callback=progress_callback, + title="export", + emit_running_ratio=False, ) - return self._run_handler(self._export_handler, args) def run_audio(self, req: AudioRequest) -> CommandResult: del req diff --git a/motion_player/gui/dearpygui_panel.py b/motion_player/gui/dearpygui_panel.py index 648170c..c69237e 100644 --- a/motion_player/gui/dearpygui_panel.py +++ b/motion_player/gui/dearpygui_panel.py @@ -22,7 +22,10 @@ import os import threading import time +from collections import deque +from dataclasses import dataclass from pathlib import Path +from queue import Empty, SimpleQueue from typing import TYPE_CHECKING, Callable import numpy as np @@ -37,9 +40,10 @@ MetricsRequest, ) from motion_player.gui.command_runner import CommandRunner -from motion_player.gui.font_support import resolve_cjk_font +from motion_player.gui.font_support import resolve_ui_font from motion_player.gui.layout_policy import MonitorCardLayout, build_monitor_card_layout from motion_player.gui.monitor_presenter import build_monitor_view_model +from motion_player.gui.status_dock_layout import StatusDockLayout, build_status_dock_layout from motion_player.gui.tabs import TAB_IDS from motion_player.gui.timeline_widget import format_keyframe_line from motion_player.gui.tune_state import IkTuneState @@ -50,6 +54,21 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class _FontIntent: + intent_id: int + target_key: str + source: str + + +@dataclass(frozen=True) +class _FontApplyResult: + intent_id: int + ok: bool + target_key: str + reason: str | None = None + + class DearPyGuiPanel: """Lightweight non-blocking control panel.""" @@ -59,6 +78,11 @@ class DearPyGuiPanel: "hero_title": "Playback Console", "hero_subtitle": "Use buttons/sliders here or keyboard shortcuts in MuJoCo.", "language_label": "Language", + "font_size_label": "Font Size", + "font_size_small": "Small", + "font_size_medium": "Medium", + "font_size_large": "Large", + "font_size_xlarge": "XLarge", "status_label": "Last Action", "status_idle": "Waiting for interaction", "status_prefix": "Executed: ", @@ -140,6 +164,9 @@ class DearPyGuiPanel: "metrics_output": "Output (.json/.csv)", "metrics_run": "Run Metrics", "tool_output": "Tool Output", + "tool_clear": "Clear", + "status_dock_output_menu": "Output", + "tool_progress": "Task Progress", "tool_ready": "Ready.", "audit_motion": "Motion", "audit_robot": "Robot (.xml)", @@ -163,6 +190,11 @@ class DearPyGuiPanel: "hero_title": "播放控制面板", "hero_subtitle": "可在此使用按钮/滑条,也可在 MuJoCo 使用键盘快捷键。", "language_label": "语言", + "font_size_label": "字号", + "font_size_small": "小", + "font_size_medium": "中", + "font_size_large": "大", + "font_size_xlarge": "超大", "status_label": "最近操作", "status_idle": "等待交互", "status_prefix": "已执行:", @@ -244,6 +276,9 @@ class DearPyGuiPanel: "metrics_output": "输出路径 (.json/.csv)", "metrics_run": "执行指标", "tool_output": "工具输出", + "tool_clear": "清空", + "status_dock_output_menu": "输出", + "tool_progress": "任务进度", "tool_ready": "就绪。", "audit_motion": "动作文件", "audit_robot": "机器人模型 (.xml)", @@ -267,6 +302,12 @@ class DearPyGuiPanel: "English": "en", "中文": "zh", } + _FONT_SIZE_SPECS: tuple[tuple[str, int], ...] = ( + ("small", 14), + ("medium", 18), + ("large", 22), + ("xlarge", 26), + ) _CONTROL_KEYS: tuple[str, ...] = ( "play_pause", "reset", @@ -380,6 +421,7 @@ def __init__( command_runner: CommandRunner | None = None, default_motion_path: str = "", default_robot_path: str = "", + initial_font_size_key: str = "medium", ) -> None: self._controller = controller self._title = title @@ -389,6 +431,7 @@ def __init__( self._default_robot_path = default_robot_path self._tune_state = IkTuneState() self._language = "en" + self._font_size_key = self._normalize_font_size_key(initial_font_size_key) self._last_action_key: str | None = None self._worker: threading.Thread | None = None self._dpg = None @@ -396,20 +439,30 @@ def __init__( self._text_keys: dict[str, str] = {} self._window_tag = "rmp_gui_window" self._language_combo_tag = "rmp_gui_language_combo" + self._font_size_combo_tag = "rmp_gui_font_size_combo" self._hero_title_tag = "rmp_gui_hero_title" self._hero_subtitle_tag = "rmp_gui_hero_subtitle" self._language_text_tag = "rmp_gui_language_text" + self._font_size_text_tag = "rmp_gui_font_size_text" self._status_text_tag = "rmp_gui_status_text" self._monitor_title_tag = "rmp_gui_monitor_title" self._monitor_card_tag = "rmp_gui_monitor_card" self._monitor_line_1_tag = "rmp_gui_monitor_line_1" self._monitor_line_2_tag = "rmp_gui_monitor_line_2" self._monitor_line_3_tag = "rmp_gui_monitor_line_3" + self._dock_monitor_title_tag = "rmp_gui_dock_monitor_title" + self._dock_monitor_line_1_tag = "rmp_gui_dock_monitor_line_1" + self._dock_monitor_line_2_tag = "rmp_gui_dock_monitor_line_2" + self._dock_monitor_line_3_tag = "rmp_gui_dock_monitor_line_3" self._timeline_line_tag = "rmp_gui_timeline_line" self._mark_combo_tag = "rmp_gui_mark_combo" self._mark_history_text_tag = "rmp_gui_mark_history_text" self._workbench_tabbar_tag = "rmp_gui_workbench_tabs" self._tool_result_tag = "rmp_gui_tool_result" + self._tool_progress_bar_tag = "rmp_gui_tool_progress_bar" + self._tool_progress_text_tag = "rmp_gui_tool_progress_text" + self._status_dock_container_tag = "rmp_gui_status_dock_container" + self._status_dock_tag = "rmp_gui_status_dock" self._metrics_motion_tag = "rmp_gui_metrics_motion" self._metrics_output_tag = "rmp_gui_metrics_output" self._audit_motion_tag = "rmp_gui_audit_motion" @@ -446,10 +499,28 @@ def __init__( self._last_monitor_refresh = 0.0 self._monitor_refresh_interval_s = 0.12 self._tooltip_text_tags: dict[str, str] = {} + self._font_handles: dict[str, int] = {} + self._font_unavailable_reasons: dict[str, str] = {} + self._applied_font_size_key: str | None = None + self._last_font_status_message: str | None = None + self._last_rejected_font_size_key: str | None = None + self._font_intent_seq = 0 + # Thread-safety: `_font_intents` is only accessed from the GUI thread + # (DearPyGui callbacks + the render loop), so we intentionally avoid locks. + self._font_intents: deque[_FontIntent] = deque() + self._font_inflight_intent_id: int | None = None + self._font_requested_key: str = self._font_size_key + self._ui_commands: deque[Callable[[], None]] = deque() + self._ui_commands_lock = threading.Lock() + self._tool_event_queue: SimpleQueue[tuple[str, object]] = SimpleQueue() + self._tool_task_running = False + self._tool_progress_ratio = 0.0 self._visual_qa_snapshot_out = os.environ.get("RMP_GUI_SNAPSHOT_OUT") self._visual_qa_layout_report_out = os.environ.get("RMP_GUI_LAYOUT_REPORT_OUT") self._visual_qa_exported = False self._last_tune_sync_key: tuple[int, int] | None = None + self._last_runtime_error: str | None = None + self._process_status_callback: Callable[[str], None] | None = None @staticmethod def is_available() -> bool: @@ -460,6 +531,13 @@ def _text(self, key: str) -> str: table = self._I18N.get(self._language, self._I18N["en"]) return table.get(key, self._I18N["en"].get(key, key)) + def _normalize_font_size_key(self, value: object) -> str: + if isinstance(value, str): + token = value.strip().lower() + if any(token == key for key, _size in self._FONT_SIZE_SPECS): + return token + return "medium" + def _tooltip_text(self, key: str) -> str: table = self._TOOLTIPS.get(self._language, self._TOOLTIPS["en"]) return table.get(key, self._TOOLTIPS["en"].get(key, key)) @@ -477,13 +555,230 @@ def _on_language_changed( if mapped is not None: self._set_language(mapped) + def _font_size_items(self) -> list[str]: + labels = { + "small": self._text("font_size_small"), + "medium": self._text("font_size_medium"), + "large": self._text("font_size_large"), + "xlarge": self._text("font_size_xlarge"), + } + suffix = self._font_unavailable_suffix() + items: list[str] = [] + for key, size in self._FONT_SIZE_SPECS: + label = f"{labels[key]} ({size})" + if key in self._font_unavailable_reasons: + label = f"{label} [{suffix}]" + items.append(label) + return items + + def _font_unavailable_suffix(self) -> str: + return "不可用" if self._language == "zh" else "Unavailable" + + def _font_size_key_from_label_or_none(self, value: object) -> str | None: + if not isinstance(value, str): + return None + token = value.strip().lower() + mapping = { + "small": "small", + "medium": "medium", + "large": "large", + "xlarge": "xlarge", + "小": "small", + "中": "medium", + "大": "large", + "超大": "xlarge", + } + if token in mapping: + return mapping[token] + head = value.split("(", 1)[0].strip() + head_token = head.lower() + if head_token in mapping: + return mapping[head_token] + if head in mapping: + return mapping[head] + return None + + def _font_size_key_from_label(self, value: object) -> str: + resolved = self._font_size_key_from_label_or_none(value) + if resolved is not None: + return resolved + return self._font_size_key + + def _font_size_key_from_index_or_none(self, value: object) -> str | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + idx = value + elif isinstance(value, float) and value.is_integer(): + idx = int(value) + else: + return None + if idx < 0 or idx >= len(self._FONT_SIZE_SPECS): + return None + key, _size = self._FONT_SIZE_SPECS[idx] + return key + + def _font_size_label(self, key: str) -> str: + for item in self._font_size_items(): + if self._font_size_key_from_label(item) == key: + return item + items = self._font_size_items() + return items[1] if items else "Medium (18)" + + def _set_font_status(self, message: str) -> None: + self._last_font_status_message = message + self._set_dpg_value_if_exists(self._status_text_tag, message) + + def _reject_unavailable_font_selection(self, key: str) -> None: + message = f"Font size '{key}' unavailable." + if self._last_rejected_font_size_key != key: + logger.warning( + "Rejected unavailable GUI font size '%s': %s", + key, + self._font_unavailable_reasons.get(key, "no handle"), + ) + self._last_rejected_font_size_key = key + self._set_font_status(message) + self._set_dpg_value_if_exists( + self._font_size_combo_tag, self._font_size_label(self._font_size_key) + ) + + def _apply_font_size_if_needed(self, dpg: object | None = None) -> None: + binder = dpg if dpg is not None else self._dpg + if binder is None: + return + font_handle = self._font_handles.get(self._font_size_key) + if font_handle is None: + return + if self._applied_font_size_key == self._font_size_key: + return + try: + binder.bind_font(font_handle) + except Exception: # noqa: BLE001 + return + self._applied_font_size_key = self._font_size_key + self._last_rejected_font_size_key = None + + def _apply_font_size_key(self, key: str) -> bool: + if key in self._font_unavailable_reasons or key not in self._font_handles: + self._reject_unavailable_font_selection(key) + return False + self._font_size_key = key + self._apply_font_size_if_needed() + return True + + def _bind_current_font(self, dpg: object | None = None) -> None: + self._apply_font_size_if_needed(dpg) + + def _reconcile_font_size_combo_display(self) -> None: + if self._dpg is None: + return + getter = getattr(self._dpg, "get_value", None) + if not callable(getter): + return + try: + current_value = getter(self._font_size_combo_tag) + except Exception: # noqa: BLE001 + return + desired_value = self._font_size_label(self._font_requested_key) + if current_value == desired_value: + return + self._set_dpg_value_if_exists(self._font_size_combo_tag, desired_value) + + def _enqueue_font_intent(self, key: str, source: str = "ui") -> None: + self._font_intent_seq += 1 + self._font_requested_key = key + # "Latest wins": coalesce pending intents to the most recent selection. + self._font_intents.clear() + self._font_intents.append(_FontIntent(self._font_intent_seq, key, source)) + + def _apply_font_intent(self, intent: _FontIntent) -> _FontApplyResult: + key = intent.target_key + if key in self._font_unavailable_reasons or key not in self._font_handles: + return _FontApplyResult( + intent.intent_id, + ok=False, + target_key=key, + reason=self._font_unavailable_reasons.get(key, "no handle"), + ) + binder = self._dpg + if binder is None: + return _FontApplyResult(intent.intent_id, ok=True, target_key=key) + if self._applied_font_size_key == key: + return _FontApplyResult(intent.intent_id, ok=True, target_key=key) + try: + binder.bind_font(self._font_handles[key]) + except Exception as exc: # noqa: BLE001 + return _FontApplyResult(intent.intent_id, ok=False, target_key=key, reason=str(exc)) + self._applied_font_size_key = key + self._last_rejected_font_size_key = None + return _FontApplyResult(intent.intent_id, ok=True, target_key=key) + + def _process_font_intents(self) -> None: + if self._font_inflight_intent_id is not None: + return + if not self._font_intents: + return + intent = self._font_intents.popleft() + self._font_inflight_intent_id = intent.intent_id + result = self._apply_font_intent(intent) + self._ack_font_apply( + result.intent_id, + result.target_key, + ok=result.ok, + reason=result.reason, + ) + + def _ack_font_apply( + self, + intent_id: int, + applied_key: str, + ok: bool = True, + reason: str | None = None, + ) -> None: + if intent_id != self._font_inflight_intent_id: + return + self._font_inflight_intent_id = None + if ok: + self._font_size_key = applied_key + self._font_requested_key = ( + self._font_intents[-1].target_key if self._font_intents else applied_key + ) + self._apply_font_size_if_needed() + return + + self._font_requested_key = ( + self._font_intents[-1].target_key if self._font_intents else self._font_size_key + ) + if applied_key in self._font_unavailable_reasons or applied_key not in self._font_handles: + self._reject_unavailable_font_selection(applied_key) + return + + detail = reason or "unknown error" + logger.warning("GUI font apply failed for '%s': %s", applied_key, detail) + self._last_rejected_font_size_key = None + self._set_font_status(f"Font apply failed for '{applied_key}': {detail}") + if not self._font_intents: + self._set_dpg_value_if_exists( + self._font_size_combo_tag, self._font_size_label(self._font_size_key) + ) + + def _on_font_size_changed(self, value: object) -> None: + key = self._font_size_key_from_label_or_none(value) + if key is None: + key = self._font_size_key_from_index_or_none(value) + if key is None: + self._set_font_status("Font selection ignored: invalid payload.") + return + self._enqueue_font_intent(key, source="ui") + def _make_dpg_callback( self, fn: Callable[[], None], ) -> Callable[[object, object, object | None], None]: def _callback(_sender: object, _app_data: object, _user_data: object | None = None) -> None: del _sender, _app_data, _user_data - fn() + self._enqueue_ui_command(fn) return _callback @@ -493,10 +788,33 @@ def _make_dpg_value_callback( ) -> Callable[[object, object, object | None], None]: def _callback(_sender: object, app_data: object, _user_data: object | None = None) -> None: del _sender, _user_data - fn(app_data) + self._enqueue_ui_command(lambda: fn(app_data)) return _callback + def _enqueue_ui_command(self, command: Callable[[], None]) -> None: + with self._ui_commands_lock: + self._ui_commands.append(command) + + def _drain_ui_commands(self) -> None: + while True: + with self._ui_commands_lock: + if not self._ui_commands: + return + command = self._ui_commands.popleft() + command() + + def _drain_dpg_callback_queue(self) -> None: + if self._dpg is None: + return + getter = getattr(self._dpg, "get_callback_queue", None) + runner = getattr(self._dpg, "run_callbacks", None) + if not callable(getter) or not callable(runner): + return + jobs = getter() + if jobs: + runner(jobs) + def _register_label(self, tag: str, key: str) -> str: self._label_keys[tag] = key return tag @@ -505,27 +823,54 @@ def _register_text(self, tag: str, key: str) -> str: self._text_keys[tag] = key return tag + def _dpg_item_exists(self, tag: str) -> bool: + if self._dpg is None: + return False + if not hasattr(self._dpg, "does_item_exist"): + return True + return bool(self._dpg.does_item_exist(tag)) + + def _set_dpg_value_if_exists(self, tag: str, value: object) -> None: + if self._dpg is None or not self._dpg_item_exists(tag): + return + self._dpg.set_value(tag, value) + + def _configure_dpg_item_if_exists(self, tag: str, **kwargs: object) -> None: + if self._dpg is None or not self._dpg_item_exists(tag): + return + self._dpg.configure_item(tag, **kwargs) + def _refresh_translations(self) -> None: if self._dpg is None: return - dpg = self._dpg - dpg.configure_item(self._window_tag, label=self._text("window_title")) - dpg.set_value(self._hero_title_tag, self._text("hero_title")) - dpg.set_value(self._hero_subtitle_tag, self._text("hero_subtitle")) - dpg.set_value(self._language_text_tag, self._text("language_label")) - dpg.set_value(self._monitor_title_tag, self._text("monitor_label")) + self._configure_dpg_item_if_exists(self._window_tag, label=self._text("window_title")) + self._set_dpg_value_if_exists(self._hero_title_tag, self._text("hero_title")) + self._set_dpg_value_if_exists(self._hero_subtitle_tag, self._text("hero_subtitle")) + self._set_dpg_value_if_exists(self._language_text_tag, self._text("language_label")) + self._set_dpg_value_if_exists(self._font_size_text_tag, self._text("font_size_label")) for tag, key in self._label_keys.items(): - dpg.configure_item(tag, label=self._text(key)) + self._configure_dpg_item_if_exists(tag, label=self._text(key)) for tag, key in self._text_keys.items(): - dpg.set_value(tag, self._text(key)) - dpg.configure_item(self._ik_reference_frame_tag, items=self._reference_frame_items()) - dpg.set_value(self._ik_reference_frame_tag, self._reference_frame_label(self._tune_state.reference_frame)) + self._set_dpg_value_if_exists(tag, self._text(key)) + self._configure_dpg_item_if_exists( + self._ik_reference_frame_tag, items=self._reference_frame_items() + ) + self._set_dpg_value_if_exists( + self._ik_reference_frame_tag, + self._reference_frame_label(self._tune_state.reference_frame), + ) + self._configure_dpg_item_if_exists(self._font_size_combo_tag, items=self._font_size_items()) + self._set_dpg_value_if_exists( + self._font_size_combo_tag, self._font_size_label(self._font_requested_key) + ) + self._reconcile_font_size_combo_display() for tab_id in TAB_IDS: - dpg.configure_item(f"rmp_tab_{tab_id}", label=self._tab_label(tab_id)) + self._configure_dpg_item_if_exists(f"rmp_tab_{tab_id}", label=self._tab_label(tab_id)) self._refresh_status_text() self._refresh_tooltips() self._refresh_monitor_lines(force=True) self._apply_monitor_card_layout() + self._apply_status_dock_layout() def _refresh_status_text(self) -> None: if self._dpg is None: @@ -559,7 +904,11 @@ def _reference_frame_items(self) -> list[str]: return [self._text("ik_reference_world"), self._text("ik_reference_local")] def _reference_frame_label(self, value: str) -> str: - return self._text("ik_reference_local") if value == "local" else self._text("ik_reference_world") + return ( + self._text("ik_reference_local") + if value == "local" + else self._text("ik_reference_world") + ) def _reference_frame_value_from_label(self, value: object) -> str: if not isinstance(value, str): @@ -595,6 +944,63 @@ def _set_tool_result(self, result_text: str) -> None: except Exception: # noqa: BLE001 return + def _on_clear_tool_output_button(self) -> None: + self._set_tool_result(self._text("tool_ready")) + + def _apply_tool_progress(self, ratio: float, message: str) -> None: + safe_ratio = max(0.0, min(1.0, float(ratio))) + if safe_ratio < self._tool_progress_ratio: + return + self._tool_progress_ratio = safe_ratio + if self._dpg is None: + return + try: + self._dpg.set_value(self._tool_progress_bar_tag, safe_ratio) + self._dpg.set_value(self._tool_progress_text_tag, str(message)) + except Exception: # noqa: BLE001 + return + + def _drain_tool_events(self) -> None: + if self._dpg is None: + return + while True: + try: + kind, payload = self._tool_event_queue.get_nowait() + except Empty: + break + if kind == "progress": + ratio, message = payload + self._apply_tool_progress(float(ratio), str(message)) + elif kind == "result": + self._set_tool_result(str(payload)) + self._tool_task_running = False + + def _launch_tool_task( + self, + *, + task_name: str, + execute: Callable[[Callable[[float, str], None]], str], + ) -> None: + if self._tool_task_running: + self._set_tool_result("tool: rc=1\nstderr:\nA tool task is already running.") + return + self._tool_task_running = True + self._tool_progress_ratio = 0.0 + self._apply_tool_progress(0.0, f"{task_name}: queued") + + def _progress_callback(ratio: float, message: str) -> None: + self._tool_event_queue.put(("progress", (float(ratio), str(message)))) + + def _worker() -> None: + try: + result_text = execute(_progress_callback) + except Exception as exc: # noqa: BLE001 + result_text = f"{task_name}: rc=1\nstderr:\n{exc}" + self._tool_event_queue.put(("result", result_text)) + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + def _format_command_result(self, title: str, rc: int, stdout: str, stderr: str) -> str: lines = [f"{title}: rc={rc}"] if stdout.strip(): @@ -610,9 +1016,16 @@ def _run_metrics_tool(self) -> None: motion=self._get_text_input(self._metrics_motion_tag), output=self._get_text_input(self._metrics_output_tag) or None, ) - result = self._command_runner.run_metrics(req) - self._set_tool_result( - self._format_command_result("metrics", result.return_code, result.stdout, result.stderr) + + def _execute(progress_callback: Callable[[float, str], None]) -> str: + result = self._command_runner.run_metrics(req, progress_callback=progress_callback) + return self._format_command_result( + "metrics", result.return_code, result.stdout, result.stderr + ) + + self._launch_tool_task( + task_name="metrics", + execute=_execute, ) def _run_audit_tool(self) -> None: @@ -621,9 +1034,16 @@ def _run_audit_tool(self) -> None: robot=self._get_text_input(self._audit_robot_tag), output=self._get_text_input(self._audit_output_tag) or None, ) - result = self._command_runner.run_audit(req) - self._set_tool_result( - self._format_command_result("audit", result.return_code, result.stdout, result.stderr) + + def _execute(progress_callback: Callable[[float, str], None]) -> str: + result = self._command_runner.run_audit(req, progress_callback=progress_callback) + return self._format_command_result( + "audit", result.return_code, result.stdout, result.stderr + ) + + self._launch_tool_task( + task_name="audit", + execute=_execute, ) def _run_convert_tool(self) -> None: @@ -631,9 +1051,16 @@ def _run_convert_tool(self) -> None: input_path=self._get_text_input(self._convert_input_tag), output_path=self._get_text_input(self._convert_output_tag), ) - result = self._command_runner.run_convert(req) - self._set_tool_result( - self._format_command_result("convert", result.return_code, result.stdout, result.stderr) + + def _execute(progress_callback: Callable[[float, str], None]) -> str: + result = self._command_runner.run_convert(req, progress_callback=progress_callback) + return self._format_command_result( + "convert", result.return_code, result.stdout, result.stderr + ) + + self._launch_tool_task( + task_name="convert", + execute=_execute, ) def _run_export_tool(self) -> None: @@ -649,15 +1076,24 @@ def _run_export_tool(self) -> None: output=self._get_text_input(self._export_output_tag), fps=fps, ) - result = self._command_runner.run_export(req) - self._set_tool_result( - self._format_command_result("export", result.return_code, result.stdout, result.stderr) + + def _execute(progress_callback: Callable[[float, str], None]) -> str: + result = self._command_runner.run_export(req, progress_callback=progress_callback) + return self._format_command_result( + "export", result.return_code, result.stdout, result.stderr + ) + + self._launch_tool_task( + task_name="export", + execute=_execute, ) def _run_audio_tool(self, action: str) -> None: result = self._command_runner.run_audio(AudioRequest(action=action)) self._set_tool_result( - self._format_command_result(f"audio:{action}", result.return_code, result.stdout, result.stderr) + self._format_command_result( + f"audio:{action}", result.return_code, result.stdout, result.stderr + ) ) def _safe_float_input(self, tag: str, fallback: float) -> float: @@ -678,7 +1114,10 @@ def _sync_tune_inputs_from_state(self) -> None: rot = self._tune_state.display_target_rotation() self._dpg.set_value(self._ik_pos_unit_tag, self._tune_state.position_unit.value) self._dpg.set_value(self._ik_angle_unit_tag, self._tune_state.angle_unit.value) - self._dpg.set_value(self._ik_reference_frame_tag, self._reference_frame_label(self._tune_state.reference_frame)) + self._dpg.set_value( + self._ik_reference_frame_tag, + self._reference_frame_label(self._tune_state.reference_frame), + ) self._dpg.set_value(self._ik_pos_x_tag, float(pos[0])) self._dpg.set_value(self._ik_pos_y_tag, float(pos[1])) self._dpg.set_value(self._ik_pos_z_tag, float(pos[2])) @@ -714,28 +1153,47 @@ def _read_tune_inputs_into_state( ) -> None: if self._dpg is None: return - pos_unit = str(position_unit_override or self._dpg.get_value(self._ik_pos_unit_tag) or self._tune_state.position_unit.value) + pos_unit = str( + position_unit_override + or self._dpg.get_value(self._ik_pos_unit_tag) + or self._tune_state.position_unit.value + ) angle_unit = str( - angle_unit_override or self._dpg.get_value(self._ik_angle_unit_tag) or self._tune_state.angle_unit.value + angle_unit_override + or self._dpg.get_value(self._ik_angle_unit_tag) + or self._tune_state.angle_unit.value ) self._tune_state.set_reference_frame( self._reference_frame_value_from_label( - self._dpg.get_value(self._ik_reference_frame_tag) or self._tune_state.reference_frame + self._dpg.get_value(self._ik_reference_frame_tag) + or self._tune_state.reference_frame ) ) self._tune_state.set_position_display( ( - self._safe_float_input(self._ik_pos_x_tag, float(self._tune_state.display_position()[0])), - self._safe_float_input(self._ik_pos_y_tag, float(self._tune_state.display_position()[1])), - self._safe_float_input(self._ik_pos_z_tag, float(self._tune_state.display_position()[2])), + self._safe_float_input( + self._ik_pos_x_tag, float(self._tune_state.display_position()[0]) + ), + self._safe_float_input( + self._ik_pos_y_tag, float(self._tune_state.display_position()[1]) + ), + self._safe_float_input( + self._ik_pos_z_tag, float(self._tune_state.display_position()[2]) + ), ), unit=pos_unit, ) self._tune_state.set_rotation_display( ( - self._safe_float_input(self._ik_rot_roll_tag, float(self._tune_state.display_rotation()[0])), - self._safe_float_input(self._ik_rot_pitch_tag, float(self._tune_state.display_rotation()[1])), - self._safe_float_input(self._ik_rot_yaw_tag, float(self._tune_state.display_rotation()[2])), + self._safe_float_input( + self._ik_rot_roll_tag, float(self._tune_state.display_rotation()[0]) + ), + self._safe_float_input( + self._ik_rot_pitch_tag, float(self._tune_state.display_rotation()[1]) + ), + self._safe_float_input( + self._ik_rot_yaw_tag, float(self._tune_state.display_rotation()[2]) + ), ), unit=angle_unit, ) @@ -756,7 +1214,10 @@ def _on_tune_reference_frame_changed(self, value: object) -> None: self._tune_state.target_position_m = self._tune_state.current_position_m.copy() quat = np.asarray(self._tune_state.current_quat_wxyz, dtype=np.float64) euler = quat_wxyz_to_euler_xyz(quat, AngleUnit(self._tune_state.angle_unit.value)) - self._tune_state.set_rotation_display((float(euler[0]), float(euler[1]), float(euler[2])), unit=self._tune_state.angle_unit.value) + self._tune_state.set_rotation_display( + (float(euler[0]), float(euler[1]), float(euler[2])), + unit=self._tune_state.angle_unit.value, + ) self._sync_tune_inputs_from_state() def _on_tune_position_unit_changed(self, value: object) -> None: @@ -820,6 +1281,7 @@ def _on_apply_ik_pose_button(self) -> None: propagate_radius=propagate_radius, ), ) + self._apply_tool_progress(1.0, "tune: full-pose IK dispatched") def _monitor_placeholder_line_primary(self) -> str: return self._text("monitor_line_1_placeholder") @@ -888,7 +1350,9 @@ def _format_mark_history_text(self, snap: PlaybackSnapshot) -> str: return f"{self._text('mark_history_label')}: {self._text('mark_history_none')}" history_tail = [int(frame) + 1 for frame in snap.mark_history[-12:]] arrow = " -> " - return f"{self._text('mark_history_label')}: " + arrow.join(str(frame) for frame in history_tail) + return f"{self._text('mark_history_label')}: " + arrow.join( + str(frame) for frame in history_tail + ) def _reset_marked_frames_widgets(self) -> None: if self._dpg is None: @@ -984,25 +1448,41 @@ def _apply_monitor_card_layout(self, width_hint: int | None = None) -> None: if self._dpg is None: return + if not self._dpg_item_exists(self._monitor_card_tag): + return + width = width_hint if width is None: width = int(self._dpg.get_viewport_client_width()) layout = self._build_monitor_card_layout_for_width(width) - self._dpg.configure_item(self._monitor_card_tag, height=layout.card_height) - self._dpg.configure_item(self._monitor_line_1_tag, wrap=layout.line_wrap_px) - self._dpg.configure_item(self._monitor_line_2_tag, wrap=layout.line_wrap_px) - self._dpg.configure_item(self._monitor_line_3_tag, wrap=layout.line_wrap_px) + self._configure_dpg_item_if_exists(self._monitor_card_tag, height=layout.card_height) + self._configure_dpg_item_if_exists(self._monitor_line_1_tag, wrap=layout.line_wrap_px) + self._configure_dpg_item_if_exists(self._monitor_line_2_tag, wrap=layout.line_wrap_px) + self._configure_dpg_item_if_exists(self._monitor_line_3_tag, wrap=layout.line_wrap_px) + + def _apply_status_dock_layout(self, width_hint: int | None = None) -> None: + if self._dpg is None: + return + + width = width_hint + if width is None: + width = int(self._dpg.get_viewport_client_width()) + + self._rebuild_status_dock(width_hint=width) def _on_viewport_resized_dpg(self, app_data: object) -> None: width = None if isinstance(app_data, (list, tuple)) and len(app_data) >= 1: width = int(app_data[0]) + self._apply_status_dock_layout(width_hint=width) self._apply_monitor_card_layout(width_hint=width) def _build_monitor_card_layout_report(self) -> dict[str, object]: if self._dpg is None: return {"fits_all_lines": False, "reason": "dpg-unavailable"} + if not self._dpg_item_exists(self._monitor_card_tag): + return {"fits_all_lines": False, "reason": "monitor-card-unavailable"} card_w, card_h = self._dpg.get_item_rect_size(self._monitor_card_tag) _w1, h1 = self._dpg.get_item_rect_size(self._monitor_line_1_tag) @@ -1050,6 +1530,27 @@ def _format_monitor_line_secondary(self, snap: PlaybackSnapshot) -> str: _headline, _subline, flags = self._format_monitor_card_lines(snap) return flags + def _refresh_status_dock_monitor_lines(self, snap: PlaybackSnapshot | None) -> None: + if self._dpg is None: + return + if snap is None: + self._set_dpg_value_if_exists(self._dock_monitor_title_tag, self._text("monitor_label")) + self._set_dpg_value_if_exists( + self._dock_monitor_line_1_tag, self._monitor_placeholder_line_primary() + ) + self._set_dpg_value_if_exists( + self._dock_monitor_line_2_tag, self._monitor_placeholder_line_secondary() + ) + self._set_dpg_value_if_exists( + self._dock_monitor_line_3_tag, self._monitor_placeholder_line_flags() + ) + return + headline, subline, flags = self._format_monitor_card_lines(snap) + self._set_dpg_value_if_exists(self._dock_monitor_title_tag, self._text("monitor_label")) + self._set_dpg_value_if_exists(self._dock_monitor_line_1_tag, headline) + self._set_dpg_value_if_exists(self._dock_monitor_line_2_tag, subline) + self._set_dpg_value_if_exists(self._dock_monitor_line_3_tag, flags) + def _refresh_monitor_lines(self, force: bool = False) -> None: if self._dpg is None: return @@ -1058,24 +1559,39 @@ def _refresh_monitor_lines(self, force: bool = False) -> None: return self._last_monitor_refresh = now if self._monitor_bus is None: - self._dpg.set_value(self._monitor_line_1_tag, self._monitor_placeholder_line_primary()) - self._dpg.set_value(self._monitor_line_2_tag, self._monitor_placeholder_line_secondary()) - self._dpg.set_value(self._monitor_line_3_tag, self._monitor_placeholder_line_flags()) + self._set_dpg_value_if_exists( + self._monitor_line_1_tag, self._monitor_placeholder_line_primary() + ) + self._set_dpg_value_if_exists( + self._monitor_line_2_tag, self._monitor_placeholder_line_secondary() + ) + self._set_dpg_value_if_exists( + self._monitor_line_3_tag, self._monitor_placeholder_line_flags() + ) + self._refresh_status_dock_monitor_lines(None) self._dpg.set_value(self._timeline_line_tag, self._timeline_placeholder_line()) self._reset_marked_frames_widgets() return snap = self._monitor_bus.latest() if snap is None: - self._dpg.set_value(self._monitor_line_1_tag, self._monitor_placeholder_line_primary()) - self._dpg.set_value(self._monitor_line_2_tag, self._monitor_placeholder_line_secondary()) - self._dpg.set_value(self._monitor_line_3_tag, self._monitor_placeholder_line_flags()) + self._set_dpg_value_if_exists( + self._monitor_line_1_tag, self._monitor_placeholder_line_primary() + ) + self._set_dpg_value_if_exists( + self._monitor_line_2_tag, self._monitor_placeholder_line_secondary() + ) + self._set_dpg_value_if_exists( + self._monitor_line_3_tag, self._monitor_placeholder_line_flags() + ) + self._refresh_status_dock_monitor_lines(None) self._dpg.set_value(self._timeline_line_tag, self._timeline_placeholder_line()) self._reset_marked_frames_widgets() return headline, subline, flags = self._format_monitor_card_lines(snap) - self._dpg.set_value(self._monitor_line_1_tag, headline) - self._dpg.set_value(self._monitor_line_2_tag, subline) - self._dpg.set_value(self._monitor_line_3_tag, flags) + self._set_dpg_value_if_exists(self._monitor_line_1_tag, headline) + self._set_dpg_value_if_exists(self._monitor_line_2_tag, subline) + self._set_dpg_value_if_exists(self._monitor_line_3_tag, flags) + self._refresh_status_dock_monitor_lines(snap) self._refresh_joint_selector_from_snapshot(snap) self._refresh_marked_frames_from_snapshot(snap) self._dpg.set_value( @@ -1099,17 +1615,52 @@ def _default_cjk_candidates(self) -> list[Path]: Path("C:/Windows/Fonts/simhei.ttf"), ] + def _default_ui_font_candidates(self) -> list[Path]: + return [ + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"), + Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), + Path("C:/Windows/Fonts/arial.ttf"), + Path("C:/Windows/Fonts/segoeui.ttf"), + ] + def _install_fonts(self, dpg: object) -> None: - font_path = resolve_cjk_font(self._default_cjk_candidates()) + self._font_handles = {} + self._font_unavailable_reasons = {} + self._applied_font_size_key = None + font_path = resolve_ui_font( + cjk_candidates=self._default_cjk_candidates(), + fallback_candidates=self._default_ui_font_candidates(), + ) if font_path is None: - return - try: - with dpg.font_registry(): - font = dpg.add_font(str(font_path), 18) - dpg.add_font_range_hint(dpg.mvFontRangeHint_Chinese_Full, parent=font) - dpg.bind_font(font) - except Exception as exc: # noqa: BLE001 - logger.warning("GUI CJK font setup failed for %s: %s", font_path, exc) + for key, _size in self._FONT_SIZE_SPECS: + self._font_unavailable_reasons[key] = "font path not found" + raise RuntimeError("No GUI font file resolved.") + with dpg.font_registry(): + for key, size in self._FONT_SIZE_SPECS: + try: + font = dpg.add_font(str(font_path), int(size)) + dpg.add_font_range_hint(dpg.mvFontRangeHint_Chinese_Full, parent=font) + self._font_handles[key] = font + except Exception as exc: # noqa: BLE001 + self._font_unavailable_reasons[key] = str(exc) + logger.warning( + "GUI font size '%s' (%s) failed for %s: %s", + key, + size, + font_path, + exc, + ) + if self._font_size_key not in self._font_handles and self._font_handles: + fallback_key = next(iter(self._font_handles)) + self._set_font_status( + f"Font size '{self._font_size_key}' unavailable. Using '{fallback_key}'." + ) + self._font_size_key = fallback_key + self._font_requested_key = fallback_key + if not self._font_handles: + raise RuntimeError("No GUI font size variants available after initialization.") + self._apply_font_size_if_needed(dpg) def _attach_tooltip(self, dpg: object, item_tag: str, tip_key: str) -> None: text_tag = f"tooltip_text_{item_tag}" @@ -1155,6 +1706,203 @@ def _create_monitor_card_theme(self, dpg: object) -> int: dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 10, 8, category=dpg.mvThemeCat_Core) return theme + def _build_status_monitor_section(self, dpg: object, layout: StatusDockLayout) -> None: + with dpg.child_window( + border=True, + width=layout.monitor_width, + height=layout.row1_height, + ): + dpg.add_text( + self._text("monitor_label"), + tag=self._register_text(self._dock_monitor_title_tag, "monitor_label"), + color=(95, 200, 255), + ) + dpg.add_text( + self._monitor_placeholder_line_primary(), + tag=self._dock_monitor_line_1_tag, + wrap=layout.monitor_width - 24, + ) + dpg.add_text( + self._monitor_placeholder_line_secondary(), + tag=self._dock_monitor_line_2_tag, + wrap=layout.monitor_width - 24, + ) + dpg.add_text( + self._monitor_placeholder_line_flags(), + tag=self._dock_monitor_line_3_tag, + wrap=layout.monitor_width - 24, + ) + + def _build_status_output_section(self, dpg: object, layout: StatusDockLayout) -> None: + with dpg.child_window( + border=True, + width=layout.output_width, + height=layout.row1_height, + ): + with dpg.group(horizontal=True): + dpg.add_text( + self._text("tool_output"), + tag=self._register_text("txt_tool_output", "tool_output"), + ) + dpg.add_spacer(width=16) + dpg.add_button( + label=self._text("tool_clear"), + callback=self._make_dpg_callback(self._on_clear_tool_output_button), + tag=self._register_label("btn_tool_clear", "tool_clear"), + ) + dpg.add_input_text( + multiline=True, + readonly=True, + width=max(200, layout.output_width - 24), + height=max(44, layout.row1_height - 66), + tag=self._tool_result_tag, + default_value=self._text("tool_ready"), + ) + + def _build_status_progress_section(self, dpg: object, layout: StatusDockLayout) -> None: + with dpg.child_window( + border=True, + width=layout.progress_width, + height=layout.row2_height, + ): + dpg.add_text( + self._text("tool_progress"), + tag=self._register_text("txt_tool_progress", "tool_progress"), + ) + dpg.add_progress_bar( + default_value=0.0, + width=max(180, layout.progress_width - 24), + tag=self._tool_progress_bar_tag, + ) + dpg.add_text("", tag=self._tool_progress_text_tag) + + def _build_status_dock( + self, + dpg: object, + window_width: int = 760, + parent: str | None = None, + ) -> None: + layout = build_status_dock_layout(window_width) + parent_tag = parent or self._window_tag + with dpg.child_window( + height=layout.dock_height, + border=True, + tag=self._status_dock_tag, + parent=parent_tag, + ): + with dpg.menu_bar(): + with dpg.menu( + label=self._text("status_dock_output_menu"), + tag=self._register_label("menu_status_dock_output", "status_dock_output_menu"), + ): + dpg.add_menu_item( + label=self._text("tool_clear"), + callback=self._make_dpg_callback(self._on_clear_tool_output_button), + tag=self._register_label("btn_status_output_clear", "tool_clear"), + ) + if layout.stacked: + self._build_status_monitor_section(dpg, layout) + self._build_status_output_section(dpg, layout) + self._build_status_progress_section(dpg, layout) + else: + with dpg.group(horizontal=True): + self._build_status_monitor_section(dpg, layout) + self._build_status_output_section(dpg, layout) + self._build_status_progress_section(dpg, layout) + + def _rebuild_status_dock(self, width_hint: int | None = None) -> None: + if self._dpg is None: + return + if hasattr(self._dpg, "does_item_exist") and not self._dpg.does_item_exist( + self._status_dock_container_tag + ): + return + captured_tool_result: object | None = None + captured_tool_progress_ratio = self._tool_progress_ratio + captured_tool_progress_text: object | None = None + for tag, target in ( + (self._tool_result_tag, "result"), + (self._tool_progress_bar_tag, "ratio"), + (self._tool_progress_text_tag, "text"), + ): + try: + if ( + tag + and hasattr(self._dpg, "does_item_exist") + and not self._dpg.does_item_exist(tag) + ): + continue + value = self._dpg.get_value(tag) + except Exception: # noqa: BLE001 + continue + if target == "result": + captured_tool_result = value + elif target == "ratio": + try: + captured_tool_progress_ratio = float(value) + except Exception: # noqa: BLE001 + pass + else: + captured_tool_progress_text = value + try: + self._dpg.delete_item(self._status_dock_tag) + except Exception: # noqa: BLE001 + pass + if width_hint is None: + width_hint = int(self._dpg.get_viewport_client_width()) + try: + self._build_status_dock( + self._dpg, + window_width=width_hint, + parent=self._status_dock_container_tag, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Status dock rebuild failed (fail-open): %s", exc) + self._tool_progress_ratio = float(captured_tool_progress_ratio) + return + if captured_tool_result is not None: + try: + self._dpg.set_value(self._tool_result_tag, captured_tool_result) + except Exception: # noqa: BLE001 + pass + try: + self._dpg.set_value(self._tool_progress_bar_tag, captured_tool_progress_ratio) + except Exception: # noqa: BLE001 + pass + if captured_tool_progress_text is not None: + try: + self._dpg.set_value(self._tool_progress_text_tag, captured_tool_progress_text) + except Exception: # noqa: BLE001 + pass + self._tool_progress_ratio = float(captured_tool_progress_ratio) + + def _build_tool_output_panel(self, dpg: object) -> None: + dpg.add_separator() + with dpg.group(horizontal=True): + dpg.add_text( + self._text("tool_output"), tag=self._register_text("txt_tool_output", "tool_output") + ) + dpg.add_spacer(width=16) + dpg.add_button( + label=self._text("tool_clear"), + callback=self._make_dpg_callback(self._on_clear_tool_output_button), + tag=self._register_label("btn_tool_clear", "tool_clear"), + ) + dpg.add_input_text( + multiline=True, + readonly=True, + width=730, + height=120, + tag=self._tool_result_tag, + default_value=self._text("tool_ready"), + ) + dpg.add_text( + self._text("tool_progress"), + tag=self._register_text("txt_tool_progress", "tool_progress"), + ) + dpg.add_progress_bar(default_value=0.0, width=730, tag=self._tool_progress_bar_tag) + dpg.add_text("", tag=self._tool_progress_text_tag) + def _on_play_button(self) -> None: self._dispatch_action("play_pause", self._controller.on_play_pause) @@ -1198,7 +1946,9 @@ def _on_jump_marked_frame_button(self) -> None: frame = self._selected_marked_frame() if frame is None: return - self._dispatch_action("jump_marked_frame", lambda: self._controller.on_jump_marked_frame(int(frame))) + self._dispatch_action( + "jump_marked_frame", lambda: self._controller.on_jump_marked_frame(int(frame)) + ) def _on_ghost_toggle(self) -> None: self._dispatch_action("toggle_ghost", self._controller.on_toggle_ghost) @@ -1215,7 +1965,9 @@ def _on_redo_edit_button(self) -> None: def _on_apply_dof_delta_button(self) -> None: if self._dpg is None: return - joint_idx = self._joint_index_from_combo_value(self._dpg.get_value(self._edit_joint_combo_tag)) + joint_idx = self._joint_index_from_combo_value( + self._dpg.get_value(self._edit_joint_combo_tag) + ) delta = float(self._dpg.get_value(self._edit_joint_delta_tag)) propagate_radius = int(self._dpg.get_value(self._edit_propagate_tag)) self._dispatch_action( @@ -1235,10 +1987,12 @@ def _on_apply_ik_button(self) -> None: dx = float(self._dpg.get_value(self._ik_dx_tag)) dy = float(self._dpg.get_value(self._ik_dy_tag)) dz = float(self._dpg.get_value(self._ik_dz_tag)) + self._apply_tool_progress(0.2, "tune: IK apply queued") self._dispatch_action( "apply_ik_target", lambda: self._controller.on_apply_ik_target(target_joint, dx, dy, dz), ) + self._apply_tool_progress(1.0, "tune: IK apply dispatched") def _on_hud_toggle(self) -> None: self._dispatch_action("toggle_hud", self._controller.on_toggle_hud) @@ -1355,7 +2109,9 @@ def _build_play_tab(self, dpg: object) -> None: ) self._attach_tooltip(dpg, "btn_next_100", "next_100") - dpg.add_text(self._text("section_modes"), tag=self._register_text("txt_modes", "section_modes")) + dpg.add_text( + self._text("section_modes"), tag=self._register_text("txt_modes", "section_modes") + ) with dpg.group(horizontal=True): dpg.add_button( label=self._text("toggle_loop"), @@ -1379,7 +2135,9 @@ def _build_play_tab(self, dpg: object) -> None: ) self._attach_tooltip(dpg, "btn_hud", "toggle_hud") - dpg.add_text(self._text("section_speed"), tag=self._register_text("txt_speed", "section_speed")) + dpg.add_text( + self._text("section_speed"), tag=self._register_text("txt_speed", "section_speed") + ) with dpg.group(horizontal=True): dpg.add_button( label=self._text("speed_down"), @@ -1441,10 +2199,15 @@ def _build_tune_tab(self, dpg: object) -> None: callback=self._make_dpg_value_callback(self._on_tune_reference_frame_changed), ) self._attach_tooltip(dpg, self._ik_reference_frame_tag, "ik_reference_frame") - dpg.add_text(self._text("ik_current_pose"), tag=self._register_text("txt_ik_current_pose", "ik_current_pose")) + dpg.add_text( + self._text("ik_current_pose"), + tag=self._register_text("txt_ik_current_pose", "ik_current_pose"), + ) dpg.add_text( self._text("ik_current_pose_line_placeholder"), - tag=self._register_text(self._ik_current_pose_line_tag, "ik_current_pose_line_placeholder"), + tag=self._register_text( + self._ik_current_pose_line_tag, "ik_current_pose_line_placeholder" + ), wrap=700, ) dpg.add_text( @@ -1453,7 +2216,10 @@ def _build_tune_tab(self, dpg: object) -> None: wrap=700, color=(170, 200, 220), ) - dpg.add_text(self._text("ik_target_pose"), tag=self._register_text("txt_ik_target_pose", "ik_target_pose")) + dpg.add_text( + self._text("ik_target_pose"), + tag=self._register_text("txt_ik_target_pose", "ik_target_pose"), + ) with dpg.group(horizontal=True): dpg.add_button( label=self._text("mark_keyframe"), @@ -1518,7 +2284,9 @@ def _build_tune_tab(self, dpg: object) -> None: wrap=720, ) - dpg.add_text(self._text("section_editor"), tag=self._register_text("txt_editor", "section_editor")) + dpg.add_text( + self._text("section_editor"), tag=self._register_text("txt_editor", "section_editor") + ) with dpg.group(horizontal=True): dpg.add_button( label=self._text("undo_edit"), @@ -1663,31 +2431,101 @@ def _build_tune_tab(self, dpg: object) -> None: width=170, tag=self._register_label(self._ik_step_angle_tag, "ik_step_angle"), ) - dpg.add_text(self._text("tune_position_nudge"), tag=self._register_text("txt_tune_pos_nudge", "tune_position_nudge")) - with dpg.group(horizontal=True): - dpg.add_button(label="-X", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_position(0, -1))) - dpg.add_button(label="+X", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_position(0, 1))) - dpg.add_button(label="-Y", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_position(1, -1))) - dpg.add_button(label="+Y", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_position(1, 1))) - dpg.add_button(label="-Z", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_position(2, -1))) - dpg.add_button(label="+Z", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_position(2, 1))) - dpg.add_text( - self._text("tune_rotation_nudge"), - tag=self._register_text("txt_tune_rot_nudge", "tune_rotation_nudge"), - ) - with dpg.group(horizontal=True): - dpg.add_button(label="-R", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_rotation(0, -1))) - dpg.add_button(label="+R", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_rotation(0, 1))) - dpg.add_button(label="-P", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_rotation(1, -1))) - dpg.add_button(label="+P", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_rotation(1, 1))) - dpg.add_button(label="-Y", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_rotation(2, -1))) - dpg.add_button(label="+Y", callback=self._make_dpg_callback(lambda: self._on_tune_nudge_rotation(2, 1))) + self._build_tune_nudge_row(dpg) dpg.add_button( label=self._text("ik_apply_full_pose"), callback=self._make_dpg_callback(self._on_apply_ik_pose_button), tag=self._register_label("btn_apply_ik_pose_full", "ik_apply_full_pose"), ) + def _build_tune_nudge_row(self, dpg: object) -> None: + with dpg.group(horizontal=True): + with dpg.group(): + dpg.add_text( + self._text("tune_position_nudge"), + tag=self._register_text("txt_tune_pos_nudge", "tune_position_nudge"), + ) + with dpg.group(horizontal=True): + dpg.add_button( + label="-X", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_position(0, -1) + ), + ) + dpg.add_button( + label="+X", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_position(0, 1) + ), + ) + dpg.add_button( + label="-Y", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_position(1, -1) + ), + ) + dpg.add_button( + label="+Y", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_position(1, 1) + ), + ) + dpg.add_button( + label="-Z", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_position(2, -1) + ), + ) + dpg.add_button( + label="+Z", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_position(2, 1) + ), + ) + dpg.add_spacer(width=24) + with dpg.group(): + dpg.add_text( + self._text("tune_rotation_nudge"), + tag=self._register_text("txt_tune_rot_nudge", "tune_rotation_nudge"), + ) + with dpg.group(horizontal=True): + dpg.add_button( + label="-R", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_rotation(0, -1) + ), + ) + dpg.add_button( + label="+R", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_rotation(0, 1) + ), + ) + dpg.add_button( + label="-P", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_rotation(1, -1) + ), + ) + dpg.add_button( + label="+P", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_rotation(1, 1) + ), + ) + dpg.add_button( + label="-Y", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_rotation(2, -1) + ), + ) + dpg.add_button( + label="+Y", + callback=self._make_dpg_callback( + lambda: self._on_tune_nudge_rotation(2, 1) + ), + ) + def _build_metrics_tab(self, dpg: object) -> None: dpg.add_input_text( label=self._text("metrics_motion"), @@ -1783,7 +2621,10 @@ def _build_export_tab(self, dpg: object) -> None: ) def _build_audio_tab(self, dpg: object) -> None: - dpg.add_text(self._text("audio_placeholder"), tag=self._register_text("txt_audio_placeholder", "audio_placeholder")) + dpg.add_text( + self._text("audio_placeholder"), + tag=self._register_text("txt_audio_placeholder", "audio_placeholder"), + ) with dpg.group(horizontal=True): dpg.add_button( label=self._text("audio_play"), @@ -1801,22 +2642,12 @@ def _build_audio_tab(self, dpg: object) -> None: tag=self._register_label("btn_audio_stop", "audio_stop"), ) - def _build_tool_output_panel(self, dpg: object) -> None: - dpg.add_separator() - dpg.add_text(self._text("tool_output"), tag=self._register_text("txt_tool_output", "tool_output")) - dpg.add_input_text( - multiline=True, - readonly=True, - width=730, - height=120, - tag=self._tool_result_tag, - default_value=self._text("tool_ready"), - ) - def _run_blocking(self) -> None: + self._last_runtime_error = None try: import dearpygui.dearpygui as dpg # type: ignore[import] except ImportError: + self._last_runtime_error = "dearpygui import failed" return self._dpg = dpg @@ -1826,7 +2657,6 @@ def _run_blocking(self) -> None: context_created = True self._install_fonts(dpg) dpg.bind_theme(self._create_theme(dpg)) - monitor_card_theme = self._create_monitor_card_theme(dpg) with dpg.window( label=self._text("window_title"), @@ -1836,7 +2666,7 @@ def _run_blocking(self) -> None: ): with dpg.group(horizontal=True): dpg.add_text(self._text("hero_title"), tag=self._hero_title_tag) - dpg.add_spacer(width=60) + dpg.add_spacer(width=32) dpg.add_text(self._text("language_label"), tag=self._language_text_tag) dpg.add_combo( items=list(self._LANGUAGE_OPTIONS.keys()), @@ -1845,6 +2675,15 @@ def _run_blocking(self) -> None: tag=self._language_combo_tag, callback=self._make_dpg_value_callback(self._on_language_changed), ) + dpg.add_spacer(width=16) + dpg.add_text(self._text("font_size_label"), tag=self._font_size_text_tag) + dpg.add_combo( + items=self._font_size_items(), + default_value=self._font_size_label(self._font_size_key), + width=130, + tag=self._font_size_combo_tag, + callback=self._make_dpg_value_callback(self._on_font_size_changed), + ) dpg.add_text(self._text("hero_subtitle"), tag=self._hero_subtitle_tag) with dpg.group(horizontal=True): dpg.add_text( @@ -1852,16 +2691,11 @@ def _run_blocking(self) -> None: tag=self._register_text("txt_status_label", "status_label"), ) dpg.add_text(self._text("status_idle"), tag=self._status_text_tag) - with dpg.child_window( - height=124, - border=True, - tag=self._monitor_card_tag, - ): - dpg.add_text(self._text("monitor_label"), tag=self._monitor_title_tag, color=(95, 200, 255)) - dpg.add_text(self._text("monitor_line_1_placeholder"), tag=self._monitor_line_1_tag, wrap=650) - dpg.add_text(self._text("monitor_line_2_placeholder"), tag=self._monitor_line_2_tag, wrap=650) - dpg.add_text(self._text("monitor_line_3_placeholder"), tag=self._monitor_line_3_tag, wrap=650) - dpg.add_separator() + with dpg.group(tag=self._status_dock_container_tag): + self._build_status_dock( + dpg, + parent=self._status_dock_container_tag, + ) with dpg.tab_bar(tag=self._workbench_tabbar_tag): for tab_id in TAB_IDS: @@ -1880,12 +2714,10 @@ def _run_blocking(self) -> None: self._build_export_tab(dpg) else: self._build_audio_tab(dpg) - self._build_tool_output_panel(dpg) - - dpg.bind_item_theme(self._monitor_card_tag, monitor_card_theme) dpg.create_viewport(title=self._text("window_title"), width=780, height=680) dpg.setup_dearpygui() + dpg.configure_app(manual_callback_management=True) dpg.set_viewport_resize_callback( self._make_dpg_value_callback(self._on_viewport_resized_dpg) ) @@ -1893,16 +2725,36 @@ def _run_blocking(self) -> None: self._sync_tune_inputs_from_state() self._apply_monitor_card_layout() dpg.show_viewport() + self._emit_process_status(self._process_status_callback, "ready") while dpg.is_dearpygui_running(): + self._drain_dpg_callback_queue() + self._drain_ui_commands() + self._drain_tool_events() + self._process_font_intents() + self._reconcile_font_size_combo_display() self._refresh_monitor_lines() dpg.render_dearpygui_frame() self._maybe_export_visual_qa_artifacts() except Exception as exc: # noqa: BLE001 + self._last_runtime_error = str(exc) logger.exception("DearPyGui panel loop crashed: %s", exc) finally: if context_created: dpg.destroy_context() self._dpg = None + self._applied_font_size_key = None + + @staticmethod + def _emit_process_status( + status_callback: Callable[[str], None] | None, + message: str, + ) -> None: + if status_callback is None: + return + try: + status_callback(message) + except Exception: # noqa: BLE001 + logger.debug("Panel process status callback failed", exc_info=True) def launch_non_blocking(self) -> None: """Start panel loop in a background daemon thread.""" @@ -1915,3 +2767,23 @@ def launch_non_blocking(self) -> None: def run_blocking(self) -> None: """Run panel loop in foreground thread until window is closed.""" self._run_blocking() + + def run_process_entry( + self, + status_callback: Callable[[str], None] | None = None, + ) -> int: + """Run panel loop with subprocess-friendly status reporting.""" + self._emit_process_status(status_callback, "starting") + self._process_status_callback = status_callback + try: + self._run_blocking() + except Exception as exc: # pragma: no cover + self._emit_process_status(status_callback, f"failed:{exc}") + return 1 + finally: + self._process_status_callback = None + if self._last_runtime_error is not None: + self._emit_process_status(status_callback, f"failed:{self._last_runtime_error}") + return 1 + self._emit_process_status(status_callback, "stopped") + return 0 diff --git a/motion_player/gui/font_support.py b/motion_player/gui/font_support.py index 7e7827b..8db4c28 100644 --- a/motion_player/gui/font_support.py +++ b/motion_player/gui/font_support.py @@ -37,3 +37,15 @@ def resolve_cjk_font(candidates: Iterable[Path]) -> Path | None: if candidate.exists(): return candidate return None + + +def resolve_ui_font( + cjk_candidates: Iterable[Path], fallback_candidates: Iterable[Path] +) -> Path | None: + cjk = resolve_cjk_font(cjk_candidates) + if cjk is not None: + return cjk + for candidate in fallback_candidates: + if candidate.exists(): + return candidate + return None diff --git a/motion_player/gui/monitor_presenter.py b/motion_player/gui/monitor_presenter.py index cb18bee..9fdb352 100644 --- a/motion_player/gui/monitor_presenter.py +++ b/motion_player/gui/monitor_presenter.py @@ -36,7 +36,9 @@ def _on_off(value: bool) -> str: def build_monitor_view_model(snap: PlaybackSnapshot) -> MonitorViewModel: """Build a stable monitor view model from a playback snapshot.""" - headline = f"Clip {snap.clip + 1}/{snap.total_clips} Frame {snap.frame + 1}/{snap.total_frames}" + headline = ( + f"Clip {snap.clip + 1}/{snap.total_clips} Frame {snap.frame + 1}/{snap.total_frames}" + ) subline = f"{'PLAY' if snap.playing else 'PAUSE'} {snap.speed:.1f}x" flags_line = ( f"LOOP {_on_off(snap.loop)} " diff --git a/motion_player/gui/panel_ipc.py b/motion_player/gui/panel_ipc.py new file mode 100644 index 0000000..d321a41 --- /dev/null +++ b/motion_player/gui/panel_ipc.py @@ -0,0 +1,164 @@ +# Copyright 2026 Mr-tooth +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Queue-based IPC adapters between isolated GUI panel and viewer.""" + +from __future__ import annotations + +import queue +from dataclasses import asdict +from typing import Any, Protocol + +from motion_player.core.ui import PlayerCommand +from motion_player.core.ui.command_queue import QueuedCommand +from motion_player.core.ui.state_monitor import PlaybackSnapshot + + +class _QueueLike(Protocol): + """Minimal queue protocol shared by queue.Queue and multiprocessing.Queue.""" + + def put_nowait(self, item: object) -> None: ... + + def get_nowait(self) -> object: ... + + +def _put_drop_oldest(q: _QueueLike, item: object) -> None: + """Put item in queue, dropping oldest entries when queue is full.""" + try: + q.put_nowait(item) + return + except queue.Full: + pass + + while True: + try: + q.get_nowait() + except queue.Empty: + break + try: + q.put_nowait(item) + return + except queue.Full: + continue + + try: + q.put_nowait(item) + except queue.Full: + # If another producer races us, dropping this message is acceptable. + return + + +def _decode_command(message: object) -> QueuedCommand | None: + if not isinstance(message, dict): + return None + raw_command = message.get("command") + if not isinstance(raw_command, str): + return None + try: + command = PlayerCommand(raw_command) + except ValueError: + return None + return QueuedCommand(command=command, payload=message.get("payload")) + + +def _decode_snapshot(message: object) -> PlaybackSnapshot | None: + if not isinstance(message, dict): + return None + payload = message.get("snapshot") + if not isinstance(payload, dict): + return None + + data: dict[str, Any] = dict(payload) + for key in ( + "marked_frames", + "mark_history", + "joint_names", + "selected_joint_pos_m", + "selected_joint_quat_wxyz", + ): + value = data.get(key) + if isinstance(value, list): + data[key] = tuple(value) + + try: + return PlaybackSnapshot(**data) + except TypeError: + return None + + +class PanelCommandSender: + """Queue-compatible sender used by GuiController in the panel process.""" + + def __init__(self, ipc_queue: _QueueLike) -> None: + self._queue = ipc_queue + + def push(self, command: PlayerCommand, payload: object | None = None) -> None: + _put_drop_oldest( + self._queue, + { + "command": command.value, + "payload": payload, + }, + ) + + +class PanelCommandReceiver: + """Viewer-facing command queue adapter with CommandQueue-like API.""" + + def __init__(self, ipc_queue: _QueueLike) -> None: + self._queue = ipc_queue + + def drain(self) -> list[QueuedCommand]: + drained: list[QueuedCommand] = [] + while True: + try: + raw = self._queue.get_nowait() + except queue.Empty: + return drained + decoded = _decode_command(raw) + if decoded is not None: + drained.append(decoded) + + +class PanelMonitorPublisher: + """Viewer-side snapshot publisher that coalesces to latest state.""" + + def __init__(self, ipc_queue: _QueueLike) -> None: + self._queue = ipc_queue + + def publish(self, snapshot: PlaybackSnapshot) -> None: + _put_drop_oldest( + self._queue, + { + "snapshot": asdict(snapshot), + }, + ) + + +class PanelMonitorSubscriber: + """Panel-side monitor bus adapter with StateMonitorBus-like API.""" + + def __init__(self, ipc_queue: _QueueLike) -> None: + self._queue = ipc_queue + self._latest: PlaybackSnapshot | None = None + + def latest(self) -> PlaybackSnapshot | None: + while True: + try: + raw = self._queue.get_nowait() + except queue.Empty: + return self._latest + decoded = _decode_snapshot(raw) + if decoded is not None: + self._latest = decoded diff --git a/motion_player/gui/status_dock_layout.py b/motion_player/gui/status_dock_layout.py new file mode 100644 index 0000000..89446f1 --- /dev/null +++ b/motion_player/gui/status_dock_layout.py @@ -0,0 +1,51 @@ +"""Layout policy for the bottom status dock in the DearPyGui panel.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class StatusDockLayout: + """Computed status-dock dimensions and section sizing.""" + + stacked: bool + dock_height: int + row1_height: int + row2_height: int + monitor_width: int + output_width: int + progress_width: int + + +def build_status_dock_layout(window_width: int) -> StatusDockLayout: + """Compute a stable bottom-dock layout from the viewport width.""" + clamped_width = max(520, int(window_width)) + stacked = clamped_width < 760 + + if stacked: + content_width = max(460, clamped_width - 40) + return StatusDockLayout( + stacked=True, + dock_height=394, + row1_height=112, + row2_height=64, + monitor_width=content_width, + output_width=content_width, + progress_width=content_width, + ) + + available = max(0, clamped_width - 48) + monitor_width = max(0, int(available * 0.42)) + output_width = max(0, available - monitor_width) + progress_width = available + + return StatusDockLayout( + stacked=False, + dock_height=312, + row1_height=146, + row2_height=81, + monitor_width=monitor_width, + output_width=output_width, + progress_width=progress_width, + ) diff --git a/motion_player/gui/tune_state.py b/motion_player/gui/tune_state.py index 4f3b647..7fdd87f 100644 --- a/motion_player/gui/tune_state.py +++ b/motion_player/gui/tune_state.py @@ -35,7 +35,9 @@ class IkTuneState: target_joint: str = "" reference_frame: str = "world" current_position_m: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float64)) - current_quat_wxyz: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)) + current_quat_wxyz: np.ndarray = field( + default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64) + ) target_position_m: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float64)) target_euler_rad: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float64)) position_unit: PositionUnit = PositionUnit.M @@ -84,7 +86,9 @@ def display_current_position(self) -> np.ndarray: def set_position_display(self, vec: tuple[float, float, float], unit: str) -> None: self.position_unit = PositionUnit(unit) - self.target_position_m = convert_position_to_m(np.asarray(vec, dtype=np.float64), self.position_unit) + self.target_position_m = convert_position_to_m( + np.asarray(vec, dtype=np.float64), self.position_unit + ) def set_target_position_display(self, vec: tuple[float, float, float], unit: str) -> None: self.set_position_display(vec, unit) @@ -121,13 +125,19 @@ def switch_angle_unit(self, unit: str) -> None: self.angle_unit = AngleUnit(unit) def set_step_position_display(self, value: float) -> None: - converted = float(convert_position_to_m(np.array([value], dtype=np.float64), self.position_unit)[0]) + converted = float( + convert_position_to_m(np.array([value], dtype=np.float64), self.position_unit)[0] + ) if not np.isfinite(converted): converted = self.step_position_m self.step_position_m = max(abs(converted), self._min_step_position_m) def display_step_position(self) -> float: - return float(convert_position_from_m(np.array([self.step_position_m], dtype=np.float64), self.position_unit)[0]) + return float( + convert_position_from_m( + np.array([self.step_position_m], dtype=np.float64), self.position_unit + )[0] + ) def set_step_angle_display(self, value: float) -> None: v = float(value) diff --git a/pyproject.toml b/pyproject.toml index a3bee15..a878fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "robot-motion-player" -version = "0.7.0" -description = "Cross-platform robot motion data visualizer and editor for AMP datasets and trajectory optimization" +version = "0.8.0" +description = "Cross-platform AMP motion dataset visualiser and editor for legged robots" readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.9" -authors = [{ name = "Junhang Lai", email = "junhang.lai@example.com" }] -maintainers = [{ name = "Junhang Lai", email = "junhang.lai@example.com" }] +authors = [{ name = "Mr-tooth" }] keywords = [ "robotics", "motion-player", @@ -18,13 +17,10 @@ keywords = [ "MuJoCo", "motion-retargeting", "legged-robots", - "trajectory-optimization", - "visualization", ] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", - "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", @@ -33,7 +29,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Visualization", - "Topic :: Scientific/Engineering :: Robotics", ] dependencies = [ "numpy>=1.24", @@ -47,8 +42,9 @@ mujoco = [ "mujoco>=3.0", ] ik = [ - "pin>=2.6", + "pin>=2.6", # pinocchio Python bindings (conda: pinocchio) ] +isaac = [] # Isaac SDK must be installed separately gui = [ "dearpygui>=1.11", ] @@ -56,21 +52,23 @@ video = [ "imageio>=2.34", ] all = [ - "robot-motion-player[mujoco,ik,gui,video]", + "mujoco>=3.0", + "pin>=2.6", # pinocchio Python bindings (conda: pinocchio) + "dearpygui>=1.11", + "imageio>=2.34", ] dev = [ "pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4", "mypy>=1.0", + "pre-commit>=3.6", ] [project.urls] -Homepage = "https://github.com/bitroboticslab/robot-motion-player" -Documentation = "https://github.com/bitroboticslab/robot-motion-player#readme" -Repository = "https://github.com/bitroboticslab/robot-motion-player.git" -Issues = "https://github.com/bitroboticslab/robot-motion-player/issues" -Changelog = "https://github.com/bitroboticslab/robot-motion-player/releases" +Homepage = "https://github.com/Mr-tooth/robot-motion-player" +Issues = "https://github.com/Mr-tooth/robot-motion-player/issues" +Documentation = "https://github.com/Mr-tooth/robot-motion-player/blob/main/docs/design.md" [project.scripts] motion_player = "motion_player.cli.main:main" @@ -79,9 +77,6 @@ motion_player = "motion_player.cli.main:main" where = ["."] include = ["motion_player*"] -[tool.setuptools.package-data] -motion_player = ["py.typed"] - # --------------------------------------------------------------------------- # Ruff (linter + formatter) # --------------------------------------------------------------------------- diff --git a/scripts/setup_linux.sh b/scripts/setup_linux.sh index 1f9c47a..1e03271 100644 --- a/scripts/setup_linux.sh +++ b/scripts/setup_linux.sh @@ -47,4 +47,4 @@ echo "To activate environment:" echo " conda activate ${ENV_NAME}" echo echo "Try:" -echo " motion_player --help" \ No newline at end of file +echo " motion_player --help" diff --git a/scripts/setup_mac.sh b/scripts/setup_mac.sh index c9365c6..164b08c 100644 --- a/scripts/setup_mac.sh +++ b/scripts/setup_mac.sh @@ -47,4 +47,4 @@ echo "To activate environment:" echo " conda activate ${ENV_NAME}" echo echo "Try:" -echo " motion_player --help" \ No newline at end of file +echo " motion_player --help" diff --git a/scripts/setup_windows.bat b/scripts/setup_windows.bat index 8cfe1d8..849082e 100644 --- a/scripts/setup_windows.bat +++ b/scripts/setup_windows.bat @@ -52,4 +52,4 @@ echo Try: echo motion_player gui echo. -endlocal \ No newline at end of file +endlocal diff --git a/tests/backends/test_mujoco_viewer.py b/tests/backends/test_mujoco_viewer.py index 8c59def..75e0b8e 100644 --- a/tests/backends/test_mujoco_viewer.py +++ b/tests/backends/test_mujoco_viewer.py @@ -203,7 +203,9 @@ def test_set_speed_command_clamps_speed() -> None: def test_set_edit_joint_command_clamps_and_updates_state() -> None: viewer = MuJoCoViewer.__new__(MuJoCoViewer) viewer._state = PlayerState(frame=0, selected_joint_idx=0) - viewer._editor_sessions = [types.SimpleNamespace(motion=types.SimpleNamespace(num_dofs=4, joint_names=None))] + viewer._editor_sessions = [ + types.SimpleNamespace(motion=types.SimpleNamespace(num_dofs=4, joint_names=None)) + ] viewer._motions = [types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=4, joint_names=None)] viewer._driver = _DummyDriver() viewer._dispatcher = CommandDispatcher(viewer._state) @@ -216,7 +218,9 @@ def test_set_edit_joint_command_clamps_and_updates_state() -> None: def test_marked_frame_navigation_commands_seek_with_wrap() -> None: class _EditorStub: def __init__(self) -> None: - self.motion = types.SimpleNamespace(num_frames=30, fps=30.0, num_dofs=2, joint_names=["j0", "j1"]) + self.motion = types.SimpleNamespace( + num_frames=30, fps=30.0, num_dofs=2, joint_names=["j0", "j1"] + ) self._keys = [2, 8, 14] def next_marked_frame(self, frame: int, wrap: bool = True) -> int | None: @@ -302,7 +306,9 @@ class _EditorStub: def __init__(self) -> None: self.ik_solver = object() self.calls: list[tuple[int, dict[str, PoseTarget]]] = [] - self.motion = types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + self.motion = types.SimpleNamespace( + num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"] + ) def apply_eef_edit( self, @@ -315,16 +321,22 @@ def apply_eef_edit( viewer = MuJoCoViewer.__new__(MuJoCoViewer) viewer._state = PlayerState(frame=5, selected_joint_idx=0) viewer._editor_sessions = [_EditorStub()] - viewer._motions = [types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"])] + viewer._motions = [ + types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + ] viewer._driver = _DummyDriver() - viewer._driver.data = types.SimpleNamespace(xpos=np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float64)) + viewer._driver.data = types.SimpleNamespace( + xpos=np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float64) + ) viewer._driver.dof_joint_body_id = lambda _idx: 1 viewer._handle_apply_ik_payload({"target_joint": "joint_0", "dx": 0.1, "dy": 0.0, "dz": 0.0}) assert len(viewer._editor_sessions[0].calls) == 1 _frame, targets, propagate_radius = viewer._editor_sessions[0].calls[0] assert propagate_radius == 0 - np.testing.assert_allclose(targets["joint_0"].position_m, np.array([1.1, 1.0, 1.0], dtype=np.float64)) + np.testing.assert_allclose( + targets["joint_0"].position_m, np.array([1.1, 1.0, 1.0], dtype=np.float64) + ) np.testing.assert_allclose(targets["joint_0"].orientation_wxyz, np.array([1.0, 0.0, 0.0, 0.0])) @@ -333,7 +345,9 @@ class _Editor: def __init__(self) -> None: self.ik_solver = object() self.seen = None - self.motion = types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + self.motion = types.SimpleNamespace( + num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"] + ) def apply_eef_edit( self, @@ -346,9 +360,13 @@ def apply_eef_edit( viewer = MuJoCoViewer.__new__(MuJoCoViewer) viewer._state = PlayerState(frame=7, selected_joint_idx=0) viewer._editor_sessions = [_Editor()] - viewer._motions = [types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"])] + viewer._motions = [ + types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + ] viewer._driver = _DummyDriver() - viewer._driver.data = types.SimpleNamespace(xpos=np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float64)) + viewer._driver.data = types.SimpleNamespace( + xpos=np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float64) + ) viewer._driver.dof_joint_body_id = lambda _idx: 1 viewer._handle_apply_ik_payload( { @@ -369,7 +387,9 @@ class _Editor: def __init__(self) -> None: self.ik_solver = object() self.seen = None - self.motion = types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + self.motion = types.SimpleNamespace( + num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"] + ) def apply_eef_edit( self, @@ -382,7 +402,9 @@ def apply_eef_edit( viewer = MuJoCoViewer.__new__(MuJoCoViewer) viewer._state = PlayerState(frame=7, selected_joint_idx=0) viewer._editor_sessions = [_Editor()] - viewer._motions = [types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"])] + viewer._motions = [ + types.SimpleNamespace(num_frames=20, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + ] viewer._driver = _DummyDriver() viewer._driver.data = types.SimpleNamespace( xpos=np.array([[0.0, 0.0, 0.0], [1.0, 2.0, 3.0]], dtype=np.float64), @@ -399,10 +421,14 @@ def apply_eef_edit( ) assert viewer._editor_sessions[0].seen is not None _frame, targets, _radius = viewer._editor_sessions[0].seen - np.testing.assert_allclose(targets["joint_0"].position_m, np.array([1.1, 2.0, 3.0], dtype=np.float64), atol=1e-6) + np.testing.assert_allclose( + targets["joint_0"].position_m, np.array([1.1, 2.0, 3.0], dtype=np.float64), atol=1e-6 + ) -def test_undo_redo_without_history_logs_hint_and_keeps_running(caplog: pytest.LogCaptureFixture) -> None: +def test_undo_redo_without_history_logs_hint_and_keeps_running( + caplog: pytest.LogCaptureFixture, +) -> None: class _EditorEmpty: def undo(self) -> None: raise IndexError("Nothing to undo.") diff --git a/tests/backends/test_mujoco_viewer_ik_pose_sync.py b/tests/backends/test_mujoco_viewer_ik_pose_sync.py index 0f4fe23..b51fbdf 100644 --- a/tests/backends/test_mujoco_viewer_ik_pose_sync.py +++ b/tests/backends/test_mujoco_viewer_ik_pose_sync.py @@ -14,13 +14,17 @@ def test_publish_snapshot_includes_selected_joint_world_pose() -> None: viewer = MuJoCoViewer.__new__(MuJoCoViewer) viewer._state = PlayerState(frame=0, selected_joint_idx=0) - viewer._motions = [types.SimpleNamespace(num_frames=10, fps=30.0, num_dofs=1, joint_names=["joint_0"])] + viewer._motions = [ + types.SimpleNamespace(num_frames=10, fps=30.0, num_dofs=1, joint_names=["joint_0"]) + ] viewer._editor_sessions = [types.SimpleNamespace(motion=viewer._motions[0])] viewer._monitor_bus = StateMonitorBus() viewer._driver = types.SimpleNamespace( data=types.SimpleNamespace( xpos=np.array([[0.0, 0.0, 0.0], [0.25, -0.10, 0.55]], dtype=np.float64), - xquat=np.array([[1.0, 0.0, 0.0, 0.0], [0.9238795, 0.0, 0.3826834, 0.0]], dtype=np.float64), + xquat=np.array( + [[1.0, 0.0, 0.0, 0.0], [0.9238795, 0.0, 0.3826834, 0.0]], dtype=np.float64 + ), ), dof_joint_body_id=lambda _idx: 1, dof_joint_name=lambda _idx: "joint_0", @@ -29,5 +33,9 @@ def test_publish_snapshot_includes_selected_joint_world_pose() -> None: viewer._publish_state_snapshot() snap = viewer._monitor_bus.latest() assert snap is not None - np.testing.assert_allclose(snap.selected_joint_pos_m, np.array([0.25, -0.10, 0.55], dtype=np.float64)) - np.testing.assert_allclose(snap.selected_joint_quat_wxyz, np.array([0.9238795, 0.0, 0.3826834, 0.0])) + np.testing.assert_allclose( + snap.selected_joint_pos_m, np.array([0.25, -0.10, 0.55], dtype=np.float64) + ) + np.testing.assert_allclose( + snap.selected_joint_quat_wxyz, np.array([0.9238795, 0.0, 0.3826834, 0.0]) + ) diff --git a/tests/cli/test_convert_command.py b/tests/cli/test_convert_command.py index 8dc81e6..9c2b5e9 100644 --- a/tests/cli/test_convert_command.py +++ b/tests/cli/test_convert_command.py @@ -43,7 +43,9 @@ def test_convert_urdf_input_to_urdf_prints_already_urdf_hint(capsys, tmp_path) - assert "already URDF" in captured.out -def test_convert_xml_to_urdf_uses_external_backend_when_available(monkeypatch, capsys, tmp_path) -> None: +def test_convert_xml_to_urdf_uses_external_backend_when_available( + monkeypatch, capsys, tmp_path +) -> None: xml_in = tmp_path / "robot.xml" urdf_out = tmp_path / "robot.urdf" xml_in.write_text("", encoding="utf-8") diff --git a/tests/cli/test_gui_runtime.py b/tests/cli/test_gui_runtime.py index 1ac4509..fd6736b 100644 --- a/tests/cli/test_gui_runtime.py +++ b/tests/cli/test_gui_runtime.py @@ -3,74 +3,54 @@ from __future__ import annotations import argparse -import sys -import types from motion_player.cli.main import _cmd_gui -from motion_player.core.dataset.loader import DatasetLoader -from tests.conftest import make_motion -def _write_motion(path) -> None: - DatasetLoader().save(make_motion(num_frames=5), path, fmt=path.suffix.lstrip(".")) - - -def _install_fake_runtime(captured: dict[str, object]) -> None: - fake_state_driver = types.ModuleType("motion_player.backends.mujoco_backend.state_driver") - fake_viewer = types.ModuleType("motion_player.backends.mujoco_backend.viewer") - - class _Driver: - def __init__(self, model_path, root_joint_name="root"): - self.model_path = model_path - self.root_joint_name = root_joint_name - - def bind_motion(self, _motion): - return None - - class _Viewer: - def __init__(self, _driver, _motions, external_queue=None, monitor_bus=None): - captured["external_queue"] = external_queue - captured["monitor_bus"] = monitor_bus +def test_cmd_gui_requests_isolated_runtime(monkeypatch) -> None: + captured: dict[str, object] = {} - def run(self): - captured["viewer_run"] = True + def _fake_runtime(**kwargs): + captured.update(kwargs) + return 0 - fake_state_driver.MuJoCoStateDriver = _Driver - fake_viewer.MuJoCoViewer = _Viewer - sys.modules["motion_player.backends.mujoco_backend.state_driver"] = fake_state_driver - sys.modules["motion_player.backends.mujoco_backend.viewer"] = fake_viewer + monkeypatch.setattr("motion_player.cli.gui_runtime.run_backend_connected_gui", _fake_runtime) + args = argparse.Namespace( + command="gui", + motion="walk.pkl", + robot="robot.xml", + root_joint="root", + backend="mujoco", + font_size="large", + ) -def test_cmd_gui_wires_backend_and_runtime(tmp_path, monkeypatch) -> None: - captured: dict[str, object] = {} - _install_fake_runtime(captured) + rc = _cmd_gui(args) - motion = tmp_path / "walk.pkl" - robot = tmp_path / "robot.xml" - _write_motion(motion) - robot.write_text("", encoding="utf-8") + assert rc == 0 + assert captured["prefer_isolated"] is True + assert captured["require_panel"] is False + assert captured["warn_if_panel_unavailable"] is True + assert captured["initial_font_size_key"] == "large" - from motion_player.gui.dearpygui_panel import DearPyGuiPanel - monkeypatch.setattr(DearPyGuiPanel, "is_available", staticmethod(lambda: True), raising=False) +def test_cmd_gui_normalizes_sigsegv_like_return_code(monkeypatch, capsys) -> None: monkeypatch.setattr( - DearPyGuiPanel, - "launch_non_blocking", - lambda self: captured.setdefault("panel_launched", True), + "motion_player.cli.gui_runtime.run_backend_connected_gui", + lambda **_kwargs: 139, ) args = argparse.Namespace( command="gui", - motion=str(motion), - robot=str(robot), + motion="walk.pkl", + robot="robot.xml", root_joint="root", backend="mujoco", + font_size=None, ) rc = _cmd_gui(args) + captured = capsys.readouterr() - assert rc == 0 - assert captured.get("panel_launched") is True - assert captured.get("external_queue") is not None - assert captured.get("monitor_bus") is not None - assert captured.get("viewer_run") is True + assert rc == 1 + assert "abnormally" in captured.err.lower() diff --git a/tests/cli/test_gui_runtime_isolated.py b/tests/cli/test_gui_runtime_isolated.py new file mode 100644 index 0000000..cc6eeaa --- /dev/null +++ b/tests/cli/test_gui_runtime_isolated.py @@ -0,0 +1,252 @@ +"""Tests for isolated GUI runtime orchestration.""" + +from __future__ import annotations + +import queue +import sys +import types + +import pytest + +import motion_player.cli.gui_runtime_isolated as gui_runtime_isolated +from motion_player.cli.gui_runtime_isolated import run_backend_connected_gui_isolated +from motion_player.core.dataset.loader import DatasetLoader +from tests.conftest import make_motion + + +def _write_motion(path) -> None: + DatasetLoader().save(make_motion(num_frames=5), path, fmt=path.suffix.lstrip(".")) + + +def _install_fake_runtime(captured: dict[str, object]) -> None: + fake_state_driver = types.ModuleType("motion_player.backends.mujoco_backend.state_driver") + fake_viewer = types.ModuleType("motion_player.backends.mujoco_backend.viewer") + + class _Driver: + def __init__(self, model_path, root_joint_name="root"): + self.model_path = model_path + self.root_joint_name = root_joint_name + + def bind_motion(self, _motion): + return None + + class _Viewer: + def __init__(self, _driver, _motions, external_queue=None, monitor_bus=None): + captured["external_queue"] = external_queue + captured["monitor_bus"] = monitor_bus + + def run(self): + captured["viewer_run"] = True + + fake_state_driver.MuJoCoStateDriver = _Driver + fake_viewer.MuJoCoViewer = _Viewer + sys.modules["motion_player.backends.mujoco_backend.state_driver"] = fake_state_driver + sys.modules["motion_player.backends.mujoco_backend.viewer"] = fake_viewer + + +def test_isolated_runtime_panel_start_failure_falls_back_to_viewer_only( + tmp_path, + monkeypatch, + capsys, +) -> None: + captured: dict[str, object] = {} + _install_fake_runtime(captured) + + motion = tmp_path / "walk.pkl" + robot = tmp_path / "robot.xml" + _write_motion(motion) + robot.write_text("", encoding="utf-8") + + from motion_player.gui.dearpygui_panel import DearPyGuiPanel + + monkeypatch.setattr(DearPyGuiPanel, "is_available", staticmethod(lambda: True), raising=False) + + def _boom(**_kwargs): + raise RuntimeError("panel process failed") + + monkeypatch.setattr("motion_player.cli.gui_runtime_isolated._start_panel_runtime", _boom) + + rc = run_backend_connected_gui_isolated( + motion=str(motion), + robot=str(robot), + root_joint="root", + backend="mujoco", + require_panel=False, + warn_if_panel_unavailable=True, + ) + captured_io = capsys.readouterr() + + assert rc == 0 + assert captured.get("viewer_run") is True + assert captured.get("external_queue") is None + assert captured.get("monitor_bus") is None + assert "continuing with mujoco keyboard controls only" in captured_io.err.lower() + + +def test_isolated_runtime_warns_and_falls_back_when_dearpygui_missing( + tmp_path, + monkeypatch, + capsys, +) -> None: + captured: dict[str, object] = {} + _install_fake_runtime(captured) + + motion = tmp_path / "walk.pkl" + robot = tmp_path / "robot.xml" + _write_motion(motion) + robot.write_text("", encoding="utf-8") + + from motion_player.gui.dearpygui_panel import DearPyGuiPanel + + monkeypatch.setattr(DearPyGuiPanel, "is_available", staticmethod(lambda: False), raising=False) + + rc = run_backend_connected_gui_isolated( + motion=str(motion), + robot=str(robot), + root_joint="root", + backend="mujoco", + require_panel=False, + warn_if_panel_unavailable=True, + ) + captured_io = capsys.readouterr() + + assert rc == 0 + assert captured.get("viewer_run") is True + assert captured.get("external_queue") is None + assert captured.get("monitor_bus") is None + assert "dearpygui is not installed" in captured_io.err.lower() + + +def test_isolated_runtime_raises_when_panel_required_and_start_fails(tmp_path, monkeypatch) -> None: + captured: dict[str, object] = {} + _install_fake_runtime(captured) + + motion = tmp_path / "walk.pkl" + robot = tmp_path / "robot.xml" + _write_motion(motion) + robot.write_text("", encoding="utf-8") + + from motion_player.gui.dearpygui_panel import DearPyGuiPanel + + monkeypatch.setattr(DearPyGuiPanel, "is_available", staticmethod(lambda: True), raising=False) + + def _boom(**_kwargs): + raise RuntimeError("panel process failed") + + monkeypatch.setattr("motion_player.cli.gui_runtime_isolated._start_panel_runtime", _boom) + + with pytest.raises(RuntimeError): + run_backend_connected_gui_isolated( + motion=str(motion), + robot=str(robot), + root_joint="root", + backend="mujoco", + require_panel=True, + warn_if_panel_unavailable=True, + ) + + +def test_isolated_runtime_passes_initial_font_size_key_to_panel_runtime( + tmp_path, monkeypatch +) -> None: + captured: dict[str, object] = {} + _install_fake_runtime(captured) + + motion = tmp_path / "walk.pkl" + robot = tmp_path / "robot.xml" + _write_motion(motion) + robot.write_text("", encoding="utf-8") + + from motion_player.gui.dearpygui_panel import DearPyGuiPanel + + monkeypatch.setattr(DearPyGuiPanel, "is_available", staticmethod(lambda: True), raising=False) + + class _RuntimeStub: + command_receiver = object() + monitor_publisher = object() + + def close(self) -> None: + return None + + start_kwargs: dict[str, object] = {} + + def _fake_start_panel_runtime(**kwargs): + start_kwargs.update(kwargs) + return _RuntimeStub() + + monkeypatch.setattr( + "motion_player.cli.gui_runtime_isolated._start_panel_runtime", + _fake_start_panel_runtime, + ) + + rc = run_backend_connected_gui_isolated( + motion=str(motion), + robot=str(robot), + root_joint="root", + backend="mujoco", + require_panel=False, + warn_if_panel_unavailable=True, + initial_font_size_key="xlarge", + ) + + assert rc == 0 + assert start_kwargs["initial_font_size_key"] == "xlarge" + + +def test_start_panel_runtime_waits_for_ready_not_starting(monkeypatch) -> None: + class _FakeQueue: + def __init__(self, values: list[object]) -> None: + self._values = values + + def get(self, timeout: float = 0.0) -> object: + del timeout + if self._values: + return self._values.pop(0) + raise queue.Empty + + class _FakeProcess: + def __init__(self, *args, **kwargs) -> None: + del args, kwargs + self._alive = True + + def start(self) -> None: + return None + + def is_alive(self) -> bool: + return self._alive + + def join(self, timeout: float | None = None) -> None: + del timeout + return None + + def terminate(self) -> None: + self._alive = False + + class _FakeCtx: + def __init__(self) -> None: + self._status_queue = _FakeQueue(["starting", "failed:boom"]) + + def Queue(self, maxsize: int = 0): + if maxsize == 16: + return self._status_queue + return _FakeQueue([]) + + def Event(self): + return object() + + def Process(self, *args, **kwargs): + return _FakeProcess(*args, **kwargs) + + monkeypatch.setattr( + gui_runtime_isolated.multiprocessing, + "get_context", + lambda _name: _FakeCtx(), + ) + + with pytest.raises(RuntimeError, match="boom"): + gui_runtime_isolated._start_panel_runtime( + default_motion_path="walk.pkl", + default_robot_path="robot.xml", + initial_font_size_key="medium", + startup_timeout_s=0.2, + ) diff --git a/tests/conftest.py b/tests/conftest.py index e95e207..dcb72a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,9 +36,9 @@ def make_motion( # Root position: walk forward along X t = np.linspace(0, num_frames / fps, num_frames) - root_pos = np.stack( - [t * 0.5, np.zeros(num_frames), np.full(num_frames, 0.9)], axis=1 - ).astype(np.float32) + root_pos = np.stack([t * 0.5, np.zeros(num_frames), np.full(num_frames, 0.9)], axis=1).astype( + np.float32 + ) # Root rotation: identity quaternions (xyzw) root_rot = np.zeros((num_frames, 4), dtype=np.float32) @@ -99,8 +99,6 @@ def apply_frame(self, frame_idx: int) -> None: if self._motion is None: raise RuntimeError("No motion bound; call bind_motion() first.") if frame_idx < 0 or frame_idx >= self._motion.num_frames: - raise IndexError( - f"Frame {frame_idx} out of range [0, {self._motion.num_frames})." - ) + raise IndexError(f"Frame {frame_idx} out of range [0, {self._motion.num_frames}).") return _IsaacBackendMock() diff --git a/tests/core/test_dataset.py b/tests/core/test_dataset.py index a973012..6955bfc 100644 --- a/tests/core/test_dataset.py +++ b/tests/core/test_dataset.py @@ -109,14 +109,10 @@ def _make_raw_dict(self, num_frames=40, num_dofs=10, num_bodies=5) -> dict: "root_rot": root_rot, "dof_pos": rng.random((num_frames, num_dofs)).astype(np.float32), "dof_vel": rng.random((num_frames, num_dofs)).astype(np.float32), - "projected_gravity": np.tile( - [0.0, 0.0, -1.0], (num_frames, 1) - ).astype(np.float32), + "projected_gravity": np.tile([0.0, 0.0, -1.0], (num_frames, 1)).astype(np.float32), "root_lin_vel": rng.random((num_frames, 3)).astype(np.float32), "root_ang_vel": rng.random((num_frames, 3)).astype(np.float32), - "key_body_pos_local": rng.random( - (num_frames, num_bodies * 3) - ).astype(np.float32), + "key_body_pos_local": rng.random((num_frames, num_bodies * 3)).astype(np.float32), } def test_load_pkl(self, tmp_path): diff --git a/tests/core/test_editing.py b/tests/core/test_editing.py index edcd1f7..a7a5043 100644 --- a/tests/core/test_editing.py +++ b/tests/core/test_editing.py @@ -148,9 +148,7 @@ def test_smooth_segment_savgol(self): def test_smooth_segment_butter(self): m = make_motion(num_frames=100, num_dofs=4) seg = SegmentEditor(m) - seg.smooth_segment( - 0, 99, "dof_pos", filter_type="butter", cutoff_hz=5.0 - ) + seg.smooth_segment(0, 99, "dof_pos", filter_type="butter", cutoff_hz=5.0) assert np.all(np.isfinite(m.dof_pos)) def test_propagate_edit(self): diff --git a/tests/core/test_editor_session_ik_propagation.py b/tests/core/test_editor_session_ik_propagation.py index bb16b44..c22ad23 100644 --- a/tests/core/test_editor_session_ik_propagation.py +++ b/tests/core/test_editor_session_ik_propagation.py @@ -32,4 +32,3 @@ def test_apply_eef_edit_propagates_decay_to_following_frames() -> None: assert motion.dof_pos[5, 0] > base[5, 0] assert motion.dof_pos[9, 0] > base[9, 0] assert abs(float(motion.dof_pos[20, 0] - base[20, 0])) < 1e-6 - diff --git a/tests/core/test_frame_transform.py b/tests/core/test_frame_transform.py index 17e1677..611f6fc 100644 --- a/tests/core/test_frame_transform.py +++ b/tests/core/test_frame_transform.py @@ -35,4 +35,3 @@ def test_rotate_point_identity_no_change() -> None: p = np.array([0.2, 0.3, 0.4], dtype=np.float64) out = rotate_point_wxyz(np.array([1.0, 0.0, 0.0, 0.0]), p) np.testing.assert_allclose(out, p, atol=1e-9) - diff --git a/tests/core/test_ik_backend_factory.py b/tests/core/test_ik_backend_factory.py index 7cbb59b..ad86e48 100644 --- a/tests/core/test_ik_backend_factory.py +++ b/tests/core/test_ik_backend_factory.py @@ -46,7 +46,9 @@ def from_runtime_driver(cls, driver: object): @classmethod def from_xml_path(cls, _path: Path): - raise AssertionError("from_xml_path should not be used when runtime driver is provided.") + raise AssertionError( + "from_xml_path should not be used when runtime driver is provided." + ) def solve(self, current_qpos, targets): return current_qpos diff --git a/tests/core/test_ik_backend_stability.py b/tests/core/test_ik_backend_stability.py index 6cf16ed..c815da7 100644 --- a/tests/core/test_ik_backend_stability.py +++ b/tests/core/test_ik_backend_stability.py @@ -27,7 +27,9 @@ def test_xml_backend_bounds_single_iteration_joint_update_norm() -> None: max_dq_norm=0.05, ) q0 = np.zeros(2, dtype=np.float64) - target = PoseTarget(position_m=np.array([2.0, 2.0, 0.0], dtype=np.float64), orientation_wxyz=_identity_quat()) + target = PoseTarget( + position_m=np.array([2.0, 2.0, 0.0], dtype=np.float64), orientation_wxyz=_identity_quat() + ) q1 = backend.solve(q0, {"joint_0": target}) @@ -47,7 +49,9 @@ def test_xml_backend_skips_ill_conditioned_solve_when_requested() -> None: min_condition_eps=1e-8, ) q0 = np.zeros(1, dtype=np.float64) - target = PoseTarget(position_m=np.array([1.0, 0.0, 0.0], dtype=np.float64), orientation_wxyz=_identity_quat()) + target = PoseTarget( + position_m=np.array([1.0, 0.0, 0.0], dtype=np.float64), orientation_wxyz=_identity_quat() + ) q1 = backend.solve(q0, {"joint_0": target}) diff --git a/tests/core/test_video_export.py b/tests/core/test_video_export.py index 4198483..f788c04 100644 --- a/tests/core/test_video_export.py +++ b/tests/core/test_video_export.py @@ -1,6 +1,9 @@ from __future__ import annotations -from motion_player.core.export.video_export import _frame_schedule +import numpy as np +import pytest + +from motion_player.core.export.video_export import _frame_schedule, export_video_with_renderer def test_frame_schedule_matches_duration_and_fps() -> None: @@ -8,3 +11,22 @@ def test_frame_schedule_matches_duration_and_fps() -> None: assert idx[0] == 0 assert idx[-1] == 29 assert len(idx) == 15 + + +def test_export_video_progress_callback_reaches_completion(tmp_path) -> None: + pytest.importorskip("imageio.v2") + + seen: list[tuple[int, int]] = [] + output = tmp_path / "clip.gif" + export_video_with_renderer( + num_frames=5, + src_fps=5.0, + out_fps=5.0, + output_path=output, + render_frame=lambda _idx: np.zeros((8, 8, 3), dtype=np.uint8), + progress_callback=lambda done, total: seen.append((done, total)), + ) + + assert output.exists() + assert seen + assert seen[-1][0] == seen[-1][1] diff --git a/tests/gui/test_command_runner.py b/tests/gui/test_command_runner.py index 41db165..7a2ef76 100644 --- a/tests/gui/test_command_runner.py +++ b/tests/gui/test_command_runner.py @@ -1,6 +1,6 @@ from __future__ import annotations -from motion_player.gui.command_models import MetricsRequest +from motion_player.gui.command_models import ExportRequest, MetricsRequest from motion_player.gui.command_runner import CommandRunner @@ -17,3 +17,113 @@ def _fake_metrics(args): assert rc.return_code == 0 assert seen["command"] == "metrics" assert seen["motion"] == "walk.pkl" + + +def test_command_runner_reports_staged_progress_for_metrics() -> None: + seen: list[tuple[float, str]] = [] + + def _fake_metrics(_args): + return 0 + + runner = CommandRunner(metrics_handler=_fake_metrics) + rc = runner.run_metrics( + MetricsRequest(motion="walk.pkl", output="report.json"), + progress_callback=lambda ratio, msg: seen.append((ratio, msg)), + ) + assert rc.return_code == 0 + assert seen[0][0] == 0.0 + assert seen[-1][0] == 1.0 + assert "complete" in seen[-1][1] + + +def test_command_runner_forwards_export_progress_callback() -> None: + seen: list[tuple[float, str]] = [] + + def _fake_export(args): + cb = getattr(args, "progress_callback", None) + assert cb is not None + cb(2, 10) + cb(10, 10) + return 0 + + runner = CommandRunner(export_handler=_fake_export) + rc = runner.run_export( + ExportRequest( + motion="walk.pkl", + robot="robot.xml", + output="out.gif", + fps=30.0, + ), + progress_callback=lambda ratio, msg: seen.append((ratio, msg)), + ) + assert rc.return_code == 0 + assert seen[-1][0] == 1.0 + assert any("10/10" in message for _ratio, message in seen) + + +def test_export_progress_is_frame_driven_without_synthetic_running_ratio() -> None: + seen: list[tuple[float, str]] = [] + + def _fake_export(args): + cb = getattr(args, "progress_callback", None) + assert cb is not None + cb(1, 4) + cb(4, 4) + return 0 + + runner = CommandRunner(export_handler=_fake_export) + rc = runner.run_export( + ExportRequest( + motion="walk.pkl", + robot="robot.xml", + output="out.gif", + fps=30.0, + ), + progress_callback=lambda ratio, msg: seen.append((ratio, msg)), + ) + + assert rc.return_code == 0 + assert [ratio for ratio, _message in seen] == [0.0, 0.25, 1.0, 1.0] + assert all("running" not in message for _ratio, message in seen) + + +def test_export_progress_reports_preparing_for_empty_total() -> None: + seen: list[tuple[float, str]] = [] + + def _fake_export(args): + cb = getattr(args, "progress_callback", None) + assert cb is not None + cb(0, 0) + return 0 + + runner = CommandRunner(export_handler=_fake_export) + rc = runner.run_export( + ExportRequest( + motion="walk.pkl", + robot="robot.xml", + output="out.gif", + fps=30.0, + ), + progress_callback=lambda ratio, msg: seen.append((ratio, msg)), + ) + + assert rc.return_code == 0 + assert (0.0, "export: preparing") in seen + + +def test_command_runner_failure_path_emits_failed_state_and_rc1() -> None: + seen: list[tuple[float, str]] = [] + + def _boom(_args): + raise RuntimeError("boom") + + runner = CommandRunner(metrics_handler=_boom) + rc = runner.run_metrics( + MetricsRequest(motion="walk.pkl", output="report.json"), + progress_callback=lambda ratio, msg: seen.append((ratio, msg)), + ) + + assert rc.return_code == 1 + assert seen[0] == (0.0, "metrics: queued") + assert seen[-1] == (1.0, "metrics: failed") + assert "boom" in rc.stderr diff --git a/tests/gui/test_dearpygui_panel.py b/tests/gui/test_dearpygui_panel.py index 7147891..0d136f5 100644 --- a/tests/gui/test_dearpygui_panel.py +++ b/tests/gui/test_dearpygui_panel.py @@ -4,6 +4,7 @@ import numpy as np +import motion_player.gui.dearpygui_panel as dearpygui_panel_module from motion_player.core.ui.state_monitor import PlaybackSnapshot from motion_player.gui.dearpygui_panel import DearPyGuiPanel from motion_player.gui.tabs import TAB_IDS @@ -67,7 +68,9 @@ def on_undo_edit(self) -> None: def on_redo_edit(self) -> None: calls.append("redo") - def on_edit_dof_delta(self, joint_idx: int, delta: float, propagate_radius: int = 0) -> None: + def on_edit_dof_delta( + self, joint_idx: int, delta: float, propagate_radius: int = 0 + ) -> None: calls.append(f"dof:{joint_idx}:{delta:.2f}:{propagate_radius}") def on_save_motion(self) -> None: @@ -198,6 +201,237 @@ class StubController: assert missing == [] +def test_panel_exposes_status_dock_builder_for_consistent_bottom_layout() -> None: + class StubController: + pass + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + assert callable(panel._build_status_dock) + + +def test_panel_viewport_resize_rebuilds_status_dock_and_monitor_layout() -> None: + calls: list[tuple[str, int | None]] = [] + + class StubController: + pass + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = object() + panel._rebuild_status_dock = lambda width_hint=None: calls.append(("dock", width_hint)) + panel._apply_monitor_card_layout = lambda width_hint=None: calls.append(("monitor", width_hint)) + + panel._on_viewport_resized_dpg((960, 540)) + + assert calls == [("dock", 960), ("monitor", 960)] + + +def test_status_dock_output_menu_label_switches_with_language() -> None: + class StubController: + pass + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + assert panel._text("status_dock_output_menu") == "Output" + panel._set_language("zh") + assert panel._text("status_dock_output_menu") == "输出" + + +def test_status_dock_rebuild_preserves_tool_state() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self, panel: DearPyGuiPanel) -> None: + self.values: dict[str, object] = { + panel._tool_result_tag: "export: rc=0\nstdout:\nDone", + panel._tool_progress_bar_tag: 0.65, + panel._tool_progress_text_tag: "export: 13/20 frames", + } + self.deleted: list[str] = [] + self.viewport_width = 920 + + def does_item_exist(self, tag: str) -> bool: + return tag in self.values or tag == panel._status_dock_container_tag + + def get_value(self, tag: str) -> object: + return self.values[tag] + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + def delete_item(self, tag: str) -> None: + self.deleted.append(tag) + self.values.pop(tag, None) + + def get_viewport_client_width(self) -> int: + return self.viewport_width + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + fake_dpg = FakeDpg(panel) + panel._dpg = fake_dpg + panel._tool_progress_ratio = 0.65 + + def _fake_build_status_dock( + _dpg: object, window_width: int = 760, parent: str | None = None + ) -> None: + assert window_width == 920 + assert parent == panel._status_dock_container_tag + fake_dpg.values[panel._tool_result_tag] = panel._text("tool_ready") + fake_dpg.values[panel._tool_progress_bar_tag] = 0.0 + fake_dpg.values[panel._tool_progress_text_tag] = "" + + panel._build_status_dock = _fake_build_status_dock + + panel._rebuild_status_dock(width_hint=920) + + assert fake_dpg.deleted == [panel._status_dock_tag] + assert fake_dpg.values[panel._tool_result_tag] == "export: rc=0\nstdout:\nDone" + assert fake_dpg.values[panel._tool_progress_bar_tag] == 0.65 + assert fake_dpg.values[panel._tool_progress_text_tag] == "export: 13/20 frames" + assert panel._tool_progress_ratio == 0.65 + + +def test_status_dock_rebuild_passes_explicit_parent_container() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self, panel: DearPyGuiPanel) -> None: + self.values: dict[str, object] = { + panel._tool_result_tag: "ready", + panel._tool_progress_bar_tag: 0.0, + panel._tool_progress_text_tag: "", + } + self.viewport_width = 900 + + def does_item_exist(self, tag: str) -> bool: + return tag in self.values or tag == panel._status_dock_container_tag + + def get_value(self, tag: str) -> object: + return self.values[tag] + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + def delete_item(self, tag: str) -> None: + self.values.pop(tag, None) + + def get_viewport_client_width(self) -> int: + return self.viewport_width + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + fake_dpg = FakeDpg(panel) + panel._dpg = fake_dpg + + called: dict[str, object] = {} + + def _fake_build_status_dock( + _dpg: object, window_width: int = 760, parent: str | None = None + ) -> None: + called["window_width"] = window_width + called["parent"] = parent + + panel._build_status_dock = _fake_build_status_dock + + panel._rebuild_status_dock(width_hint=900) + + assert called["window_width"] == 900 + assert called["parent"] == panel._status_dock_container_tag + + +def test_status_dock_rebuild_noops_when_container_missing() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self, panel: DearPyGuiPanel) -> None: + self.values: dict[str, object] = { + panel._tool_result_tag: "ready", + panel._tool_progress_bar_tag: 0.0, + panel._tool_progress_text_tag: "", + } + self.viewport_width = 860 + + def does_item_exist(self, tag: str) -> bool: + return tag in self.values + + def get_value(self, tag: str) -> object: + return self.values[tag] + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + def delete_item(self, tag: str) -> None: + self.values.pop(tag, None) + + def get_viewport_client_width(self) -> int: + return self.viewport_width + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + fake_dpg = FakeDpg(panel) + panel._dpg = fake_dpg + + called = False + + def _fake_build_status_dock( + _dpg: object, window_width: int = 760, parent: str | None = None + ) -> None: + nonlocal called + called = True + + panel._build_status_dock = _fake_build_status_dock + + panel._rebuild_status_dock(width_hint=860) + + assert called is False + + +def test_status_dock_rebuild_fail_open_when_build_raises() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self, panel: DearPyGuiPanel) -> None: + self.values: dict[str, object] = { + panel._tool_result_tag: "export: rc=0", + panel._tool_progress_bar_tag: 0.5, + panel._tool_progress_text_tag: "export: running", + } + self.deleted: list[str] = [] + self.viewport_width = 880 + + def does_item_exist(self, tag: str) -> bool: + return tag in self.values or tag == panel._status_dock_container_tag + + def get_value(self, tag: str) -> object: + return self.values[tag] + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + def delete_item(self, tag: str) -> None: + self.deleted.append(tag) + self.values.pop(tag, None) + + def get_viewport_client_width(self) -> int: + return self.viewport_width + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + fake_dpg = FakeDpg(panel) + panel._dpg = fake_dpg + + def _fake_build_status_dock( + _dpg: object, window_width: int = 760, parent: str | None = None + ) -> None: + raise RuntimeError("status dock build failure") + + panel._build_status_dock = _fake_build_status_dock + + panel._rebuild_status_dock(width_hint=880) + + assert fake_dpg.deleted == [panel._status_dock_tag] + assert panel._tool_progress_ratio == 0.5 + + def test_panel_language_switch_changes_tooltip_translation() -> None: class StubController: pass @@ -237,7 +471,11 @@ class StubController: pos = panel._tune_state.display_position() assert tuple(round(float(v), 6) for v in pos) == (0.4, 0.1, 0.9) - assert tuple(round(float(v), 6) for v in panel._tune_state.current_position_m) == (0.4, 0.1, 0.9) + assert tuple(round(float(v), 6) for v in panel._tune_state.current_position_m) == ( + 0.4, + 0.1, + 0.9, + ) assert panel._tune_state.target_joint == "joint_0" @@ -260,6 +498,649 @@ class StubController: assert "Target Pose" in panel._text("ik_target_pose") +def test_font_size_option_mapping_supports_en_and_zh_labels() -> None: + class StubController: + pass + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + en_items = panel._font_size_items() + assert "Medium (18)" in en_items + + panel._set_language("zh") + zh_items = panel._font_size_items() + assert "中 (18)" in zh_items + + assert panel._font_size_key_from_label("Large (22)") == "large" + assert panel._font_size_key_from_label("大 (22)") == "large" + + +def test_font_size_changed_binds_selected_font_when_available() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound = None + + def bind_font(self, font: int) -> None: + self.bound = font + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"medium": 101, "large": 202} + + panel._on_font_size_changed("Large (22)") + panel._process_font_intents() + + assert panel._font_size_key == "large" + assert panel._dpg.bound == 202 + + +def test_font_size_changed_accepts_index_like_payload() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound = None + + def bind_font(self, font: int) -> None: + self.bound = font + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"medium": 101, "large": 202} + + panel._on_font_size_changed(2) + panel._process_font_intents() + + assert panel._font_size_key == "large" + assert panel._dpg.bound == 202 + + +def test_font_size_changed_ignores_invalid_payload_without_polling_combo_value() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound = None + self.values: dict[str, object] = {} + + def bind_font(self, font: int) -> None: + self.bound = font + + def get_value(self, _tag: str) -> object: + raise AssertionError("get_value should not be called") + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"medium": 101, "large": 202} + + panel._on_font_size_changed({"selected": "??"}) + + assert not panel._font_intents + assert panel._font_size_key == "medium" + assert panel._dpg.bound is None + assert panel._dpg.values[panel._status_text_tag] == "Font selection ignored: invalid payload." + + +def test_font_size_changed_uses_payload_key_even_if_combo_value_is_stale() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound = None + + def bind_font(self, font: int) -> None: + self.bound = font + + def get_value(self, _tag: str) -> object: + raise AssertionError("get_value should not be called") + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 101, "large": 202} + panel._font_size_key = "small" + panel._applied_font_size_key = "small" + + panel._on_font_size_changed("Large (22)") + panel._process_font_intents() + + assert panel._font_size_key == "large" + assert panel._applied_font_size_key == "large" + assert panel._dpg.bound == 202 + + +def test_font_size_callback_increase_not_reverted_by_stale_combo_display() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound: list[int] = [] + self.combo_value = "Small (14)" + self.values: dict[str, object] = {} + + def get_value(self, tag: str) -> object: + assert tag == panel._font_size_combo_tag + return self.combo_value + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + if tag == panel._font_size_combo_tag: + self.combo_value = str(value) + + def bind_font(self, font: int) -> None: + self.bound.append(font) + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22} + panel._font_size_key = "small" + panel._font_requested_key = "small" + panel._applied_font_size_key = "small" + + panel._on_font_size_changed("Medium (18)") + panel._process_font_intents() + assert panel._font_size_key == "medium" + + panel._dpg.combo_value = "Small (14)" + panel._reconcile_font_size_combo_display() + + assert panel._font_size_key == "medium" + assert panel._applied_font_size_key == "medium" + + +def test_value_callback_enqueues_command_until_ui_bus_drained() -> None: + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + seen: list[object] = [] + cb = panel._make_dpg_value_callback(lambda value: seen.append(value)) + + cb("sender", "Medium (18)", None) + + assert seen == [] + assert len(panel._ui_commands) == 1 + panel._drain_ui_commands() + assert seen == ["Medium (18)"] + + +def test_queued_font_event_applies_upward_after_ui_bus_drain() -> None: + class FakeDpg: + def bind_font(self, _font: int) -> None: + return None + + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22} + panel._font_size_key = "small" + panel._applied_font_size_key = "small" + + cb = panel._make_dpg_value_callback(panel._on_font_size_changed) + cb("sender", "Medium (18)", None) + panel._drain_ui_commands() + panel._process_font_intents() + + assert panel._font_size_key == "medium" + + +def test_font_size_unparseable_callback_does_not_poison_future_upscale() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound: list[int] = [] + self.values: dict[str, object] = {} + + def bind_font(self, font: int) -> None: + self.bound.append(font) + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22} + panel._font_size_key = "small" + panel._applied_font_size_key = "small" + + panel._on_font_size_changed({"opaque": "payload"}) + assert panel._font_size_key == "small" + + panel._on_font_size_changed("Medium (18)") + panel._process_font_intents() + + assert panel._font_size_key == "medium" + assert panel._applied_font_size_key == "medium" + assert panel._dpg.bound == [22] + + +def test_font_intent_stale_ack_does_not_revert_latest_applied_font() -> None: + class FakeDpg: + def __init__(self) -> None: + self.bound: list[int] = [] + + def bind_font(self, font: int) -> None: + self.bound.append(font) + + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22, "large": 33} + panel._font_size_key = "small" + panel._applied_font_size_key = "small" + panel._enqueue_font_intent("medium") + panel._process_font_intents() + panel._enqueue_font_intent("large") + panel._process_font_intents() + panel._ack_font_apply(intent_id=1, applied_key="medium") + assert panel._font_size_key == "large" + assert panel._applied_font_size_key == "large" + + +def test_stale_failure_ack_does_not_block_later_upward_font_event() -> None: + class FakeDpg: + def __init__(self) -> None: + self.bound: list[int] = [] + self.values: dict[str, object] = {} + + def bind_font(self, font: int) -> None: + self.bound.append(font) + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22, "large": 33} + panel._font_size_key = "small" + panel._font_requested_key = "small" + panel._applied_font_size_key = "small" + + panel._on_font_size_changed("Medium (18)") + panel._process_font_intents() + + panel._ack_font_apply(intent_id=999, applied_key="small", ok=False, reason="stale") + + assert panel._font_size_key == "medium" + assert panel._applied_font_size_key == "medium" + assert panel._dpg.bound == [22] + + panel._on_font_size_changed("Large (22)") + panel._process_font_intents() + + assert panel._font_size_key == "large" + assert panel._applied_font_size_key == "large" + assert panel._dpg.bound == [22, 33] + assert panel._last_font_status_message != "Font apply failed for 'small': stale" + + +def test_font_intent_queue_latest_wins() -> None: + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._enqueue_font_intent("medium") + panel._enqueue_font_intent("large") + + assert len(panel._font_intents) == 1 + assert next(iter(panel._font_intents)).target_key == "large" + assert panel._font_requested_key == "large" + + +def test_font_apply_failure_preserves_latest_requested_key_when_pending() -> None: + class FakeDpg: + def __init__(self) -> None: + self.values: dict[str, object] = {} + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22, "large": 33} + panel._font_size_key = "small" + panel._font_requested_key = "small" + panel._applied_font_size_key = "small" + + panel._enqueue_font_intent("medium") + intent = panel._font_intents.popleft() + panel._font_inflight_intent_id = intent.intent_id + + panel._enqueue_font_intent("large") + panel._ack_font_apply( + intent_id=intent.intent_id, + applied_key=intent.target_key, + ok=False, + reason="simulated bind failure", + ) + + assert panel._font_size_key == "small" + assert panel._applied_font_size_key == "small" + assert panel._font_requested_key == "large" + assert ( + panel._dpg.values[panel._status_text_tag] + == "Font apply failed for 'medium': simulated bind failure" + ) + assert panel._font_size_combo_tag not in panel._dpg.values + + +def test_font_apply_success_preserves_latest_requested_key_when_pending() -> None: + class FakeDpg: + def __init__(self) -> None: + self.bound: list[int] = [] + + def bind_font(self, font: int) -> None: + self.bound.append(font) + + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22, "large": 33} + panel._font_size_key = "small" + panel._font_requested_key = "small" + panel._applied_font_size_key = "small" + + panel._enqueue_font_intent("medium") + intent = panel._font_intents.popleft() + panel._font_inflight_intent_id = intent.intent_id + + panel._enqueue_font_intent("large") + panel._ack_font_apply(intent_id=intent.intent_id, applied_key=intent.target_key, ok=True) + + assert panel._font_size_key == "medium" + assert panel._applied_font_size_key == "medium" + assert panel._font_requested_key == "large" + assert len(panel._font_intents) == 1 + assert panel._font_intents[-1].target_key == "large" + assert panel._dpg.bound == [22] + + +def test_font_apply_intent_fast_path_avoids_rebinding_same_key() -> None: + class FakeDpg: + def __init__(self) -> None: + self.bind_calls = 0 + + def bind_font(self, _font: int) -> None: + self.bind_calls += 1 + + panel = DearPyGuiPanel(controller=type("Stub", (), {})(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"medium": 22} + panel._font_size_key = "medium" + panel._font_requested_key = "medium" + panel._applied_font_size_key = "medium" + + panel._enqueue_font_intent("medium") + panel._process_font_intents() + + assert panel._dpg.bind_calls == 0 + + +def test_font_size_combo_poll_no_longer_mutates_font_state() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.combo_value = "XLarge (26)" + + def get_value(self, tag: str) -> object: + assert tag == panel._font_size_combo_tag + return self.combo_value + + def set_value(self, tag: str, value: object) -> None: + assert tag == panel._font_size_combo_tag + self.combo_value = str(value) + + def bind_font(self, _font: int) -> None: + raise AssertionError("bind_font should not be called") + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22, "large": 33, "xlarge": 44} + panel._font_size_key = "medium" + panel._font_requested_key = "medium" + panel._applied_font_size_key = "medium" + + panel._reconcile_font_size_combo_display() + + assert panel._font_size_key == "medium" + assert panel._applied_font_size_key == "medium" + + +def test_font_size_items_marks_unavailable_variants() -> None: + class StubController: + pass + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._font_unavailable_reasons = {"large": "simulated install failure"} + + items = panel._font_size_items() + + assert "Large (22) [Unavailable]" in items + + +def test_font_size_changed_rejects_unavailable_choice_and_keeps_applied_font() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bound = None + self.status_values: dict[str, object] = {} + + def bind_font(self, font: int) -> None: + self.bound = font + + def get_value(self, tag: str) -> object: + assert tag == panel._font_size_combo_tag + return "Large (22) [Unavailable]" + + def set_value(self, tag: str, value: object) -> None: + self.status_values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 101} + panel._font_unavailable_reasons = {"large": "simulated install failure"} + panel._font_size_key = "small" + panel._applied_font_size_key = "small" + + panel._on_font_size_changed("Large (22) [Unavailable]") + panel._process_font_intents() + + assert panel._font_size_key == "small" + assert panel._applied_font_size_key == "small" + assert panel._dpg.bound is None + assert panel._dpg.status_values[panel._status_text_tag] == "Font size 'large' unavailable." + assert panel._last_font_status_message == "Font size 'large' unavailable." + assert panel._dpg.status_values[panel._font_size_combo_tag] == "Small (14)" + + +def test_font_apply_failure_surfaces_reason_and_keeps_applied_font() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.status_values: dict[str, object] = {} + + def bind_font(self, _font: int) -> None: + raise RuntimeError("simulated bind failure") + + def set_value(self, tag: str, value: object) -> None: + self.status_values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"small": 11, "medium": 22} + panel._font_size_key = "small" + panel._font_requested_key = "small" + panel._applied_font_size_key = "small" + + panel._on_font_size_changed("Medium (18)") + panel._process_font_intents() + + assert panel._font_size_key == "small" + assert panel._applied_font_size_key == "small" + assert ( + panel._dpg.status_values[panel._status_text_tag] + == "Font apply failed for 'medium': simulated bind failure" + ) + assert panel._dpg.status_values[panel._font_size_combo_tag] == "Small (14)" + + +def test_font_apply_is_idempotent_for_same_key() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.bind_calls = 0 + + def bind_font(self, _font: int) -> None: + self.bind_calls += 1 + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._font_handles = {"large": 33} + panel._font_size_key = "large" + + panel._apply_font_size_if_needed() + panel._apply_font_size_if_needed() + + assert panel._dpg.bind_calls == 1 + + +def test_clear_tool_output_resets_to_ready_message() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.values: dict[str, object] = {} + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + + panel._set_tool_result("export: rc=0\nstdout:\nDone") + panel._on_clear_tool_output_button() + + assert panel._dpg.values[panel._tool_result_tag] == panel._text("tool_ready") + + +def test_tool_progress_widget_updates_with_event_payload() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.values: dict[str, object] = {} + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + + panel._apply_tool_progress(0.42, "Exporting frames 42%") + + assert panel._dpg.values[panel._tool_progress_bar_tag] == 0.42 + assert panel._dpg.values[panel._tool_progress_text_tag] == "Exporting frames 42%" + + +def test_tool_progress_ratio_never_regresses_for_same_task() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.values: dict[str, object] = {} + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + + panel._apply_tool_progress(0.7, "export: 7/10 frames") + panel._apply_tool_progress(0.2, "export: 2/10 frames") + + assert panel._dpg.values[panel._tool_progress_bar_tag] == 0.7 + assert panel._dpg.values[panel._tool_progress_text_tag] == "export: 7/10 frames" + + +def test_tool_progress_ratio_resets_per_task() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.values: dict[str, object] = {} + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + class FakeThread: + def __init__(self, target, daemon: bool = False) -> None: + self.target = target + self.daemon = daemon + + def start(self) -> None: + return None + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._dpg = FakeDpg() + panel._tool_progress_ratio = 0.7 + + original_thread = dearpygui_panel_module.threading.Thread + dearpygui_panel_module.threading.Thread = FakeThread + try: + panel._launch_tool_task(task_name="export", execute=lambda _cb: "export: rc=0") + finally: + dearpygui_panel_module.threading.Thread = original_thread + + assert panel._tool_progress_ratio == 0.0 + assert panel._dpg.values[panel._tool_progress_bar_tag] == 0.0 + assert panel._dpg.values[panel._tool_progress_text_tag] == "export: queued" + + +def test_tune_action_sets_short_running_progress_state() -> None: + calls: list[tuple[str, float, float, float]] = [] + + class StubController: + def on_apply_ik_target(self, target_joint: str, dx: float, dy: float, dz: float) -> None: + calls.append((target_joint, dx, dy, dz)) + + class FakeDpg: + def __init__(self, panel: DearPyGuiPanel) -> None: + self.values: dict[str, object] = { + panel._edit_joint_combo_tag: "0 : joint_0", + panel._ik_dx_tag: 0.0, + panel._ik_dy_tag: 0.0, + panel._ik_dz_tag: 0.0, + } + + def get_value(self, tag: str) -> object: + return self.values[tag] + + def set_value(self, tag: str, value: object) -> None: + self.values[tag] = value + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + fake = FakeDpg(panel) + panel._dpg = fake + + panel._on_apply_ik_button() + + assert calls == [("joint_0", 0.0, 0.0, 0.0)] + assert float(fake.values[panel._tool_progress_bar_tag]) > 0.0 + + def test_primary_monitor_line_formats_core_playback_state() -> None: class StubController: pass @@ -496,6 +1377,224 @@ class StubController: assert hasattr(panel, "_build_audio_tab") +def test_panel_exposes_single_row_tune_nudge_builder() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.events: list[tuple[str, object]] = [] + + class _GroupCtx: + def __init__(self, events: list[tuple[str, object]], horizontal: bool) -> None: + self._events = events + self._horizontal = horizontal + + def __enter__(self) -> FakeDpg._GroupCtx: + self._events.append(("group_enter", self._horizontal)) + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + self._events.append(("group_exit", self._horizontal)) + return False + + def group(self, horizontal: bool = False) -> FakeDpg._GroupCtx: + return self._GroupCtx(self.events, horizontal) + + def add_text(self, label: str, tag: str | None = None) -> None: + self.events.append(("text", label)) + + def add_spacer(self, width: int) -> None: + self.events.append(("spacer", width)) + + def add_button(self, label: str, callback=None) -> None: + self.events.append(("button", label)) + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + assert callable(panel._build_tune_nudge_row) + + fake = FakeDpg() + panel._build_tune_nudge_row(fake) + + assert [event[1] for event in fake.events if event[0] == "text"] == [ + "Position Nudge", + "Rotation Nudge", + ] + assert [event[1] for event in fake.events if event[0] == "spacer"] == [24] + assert [event[1] for event in fake.events if event[0] == "button"] == [ + "-X", + "+X", + "-Y", + "+Y", + "-Z", + "+Z", + "-R", + "+R", + "-P", + "+P", + "-Y", + "+Y", + ] + assert fake.events[0] == ("group_enter", True) + + +def test_panel_tune_tab_invokes_single_row_tune_nudge_builder() -> None: + class StubController: + pass + + class FakeDpg: + class _Ctx: + def __init__(self, events: list[tuple[str, object]], name: str, value: object) -> None: + self._events = events + self._name = name + self._value = value + + def __enter__(self) -> FakeDpg._Ctx: + self._events.append((self._name, self._value)) + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + self._events.append((f"{self._name}_exit", self._value)) + return False + + def __init__(self) -> None: + self.events: list[tuple[str, object]] = [] + + def group(self, horizontal: bool = False) -> FakeDpg._Ctx: + return self._Ctx(self.events, "group", horizontal) + + def tooltip(self, item_tag: str) -> FakeDpg._Ctx: + return self._Ctx(self.events, "tooltip", item_tag) + + def add_combo(self, **kwargs) -> None: + self.events.append(("combo", kwargs["label"])) + + def add_text(self, text: str, tag: str | None = None, **kwargs) -> None: + self.events.append(("text", text)) + + def add_button(self, **kwargs) -> None: + self.events.append(("button", kwargs["label"])) + + def add_slider_float(self, **kwargs) -> None: + self.events.append(("slider_float", kwargs["label"])) + + def add_input_int(self, **kwargs) -> None: + self.events.append(("input_int", kwargs["label"])) + + def add_input_float(self, **kwargs) -> None: + self.events.append(("input_float", kwargs["label"])) + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + called: list[object] = [] + panel._build_tune_nudge_row = lambda dpg: called.append(dpg) + + panel._build_tune_tab(FakeDpg()) + + assert len(called) == 1 + + +def test_panel_tune_nudge_button_callbacks_preserve_axis_sign_mapping() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.buttons: list[tuple[str, object]] = [] + + class _GroupCtx: + def __enter__(self) -> FakeDpg._GroupCtx: + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + return False + + def group(self, horizontal: bool = False) -> FakeDpg._GroupCtx: + return self._GroupCtx() + + def add_text(self, label: str, tag: str | None = None) -> None: + return None + + def add_spacer(self, width: int) -> None: + return None + + def add_button(self, label: str, callback=None) -> None: + self.buttons.append((label, callback)) + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + fake = FakeDpg() + panel._build_tune_nudge_row(fake) + + expected = [ + ("-X", ("position", 0, -1)), + ("+X", ("position", 0, 1)), + ("-Y", ("position", 1, -1)), + ("+Y", ("position", 1, 1)), + ("-Z", ("position", 2, -1)), + ("+Z", ("position", 2, 1)), + ("-R", ("rotation", 0, -1)), + ("+R", ("rotation", 0, 1)), + ("-P", ("rotation", 1, -1)), + ("+P", ("rotation", 1, 1)), + ("-Y", ("rotation", 2, -1)), + ("+Y", ("rotation", 2, 1)), + ] + + assert [label for label, _ in fake.buttons] == [label for label, _ in expected] + assert len(fake.buttons) == len(expected), "Button mapping mismatch" + for (_label, callback), (_expected_label, (kind, axis, sign)) in zip(fake.buttons, expected): + panel._tune_state.target_position_m = np.zeros(3, dtype=np.float64) + panel._tune_state.target_euler_rad = np.zeros(3, dtype=np.float64) + callback(None, None, None) + panel._drain_ui_commands() + if kind == "position": + assert np.isclose( + panel._tune_state.target_position_m[axis], + float(sign) * panel._tune_state.step_position_m, + ) + else: + assert np.isclose( + panel._tune_state.target_euler_rad[axis], + float(sign) * panel._tune_state.step_angle_rad, + ) + + +def test_panel_tune_nudge_row_renders_in_zh_language_mode() -> None: + class StubController: + pass + + class FakeDpg: + def __init__(self) -> None: + self.texts: list[str] = [] + + class _GroupCtx: + def __enter__(self) -> FakeDpg._GroupCtx: + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + return False + + def group(self, horizontal: bool = False) -> FakeDpg._GroupCtx: + return self._GroupCtx() + + def add_text(self, label: str, tag: str | None = None) -> None: + self.texts.append(label) + + def add_spacer(self, width: int) -> None: + return None + + def add_button(self, label: str, callback=None) -> None: + return None + + panel = DearPyGuiPanel(controller=StubController(), title="Test") + panel._set_language("zh") + fake = FakeDpg() + + panel._build_tune_nudge_row(fake) + + assert "位置微调" in fake.texts + assert "旋转微调" in fake.texts + + def test_position_unit_switch_preserves_internal_target_and_step_si() -> None: class StubController: pass diff --git a/tests/gui/test_dearpygui_runtime_compat.py b/tests/gui/test_dearpygui_runtime_compat.py index 3385cf4..34863f6 100644 --- a/tests/gui/test_dearpygui_runtime_compat.py +++ b/tests/gui/test_dearpygui_runtime_compat.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from motion_player.gui.dearpygui_panel import DearPyGuiPanel @@ -53,6 +55,30 @@ def bind_font(self, _font): return None +class _FakeDPGPartialFontFailure: + mvFontRangeHint_Chinese_Full = 99 + + def __init__(self) -> None: + self.calls = 0 + self.bound_font = None + + def font_registry(self): + return _Ctx() + + def add_font(self, _path: str, _size: int): + self.calls += 1 + if self.calls == 1: + return 321 + raise RuntimeError("partial failure") + + def add_font_range_hint(self, _hint: int, parent=None): + del _hint, parent + return None + + def bind_font(self, font): + self.bound_font = font + + def test_install_fonts_uses_font_parent_for_range_hint(monkeypatch, tmp_path) -> None: panel = DearPyGuiPanel(controller=_DummyController(), title="Test") font_file = tmp_path / "NotoSansCJK-Regular.ttc" @@ -66,21 +92,146 @@ def test_install_fonts_uses_font_parent_for_range_hint(monkeypatch, tmp_path) -> assert fake.bound_font == 123 +def test_install_fonts_registers_all_size_variants(monkeypatch, tmp_path) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + font_file = tmp_path / "NotoSansCJK-Regular.ttc" + font_file.write_bytes(b"font") + monkeypatch.setattr(panel, "_default_cjk_candidates", lambda: [font_file]) + + fake = _FakeDPGFontOK() + panel._install_fonts(fake) + + assert set(panel._font_handles.keys()) == {"small", "medium", "large", "xlarge"} + + +def test_install_fonts_uses_platform_fallback_when_no_cjk(monkeypatch, tmp_path) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + latin = tmp_path / "DejaVuSans.ttf" + latin.write_bytes(b"font") + monkeypatch.delenv("RMP_GUI_FONT", raising=False) + monkeypatch.setattr(panel, "_default_cjk_candidates", lambda: []) + monkeypatch.setattr(panel, "_default_ui_font_candidates", lambda: [latin]) + + fake = _FakeDPGFontOK() + panel._install_fonts(fake) + + assert set(panel._font_handles.keys()) == {"small", "medium", "large", "xlarge"} + + +def test_install_fonts_leaves_no_fonts_when_resolution_fails(monkeypatch) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + monkeypatch.delenv("RMP_GUI_FONT", raising=False) + monkeypatch.setattr(panel, "_default_cjk_candidates", lambda: []) + monkeypatch.setattr(panel, "_default_ui_font_candidates", lambda: []) + + fake = _FakeDPGFontOK() + with pytest.raises(RuntimeError): + panel._install_fonts(fake) + + def test_make_dpg_callback_accepts_three_args() -> None: calls: list[str] = [] panel = DearPyGuiPanel(controller=_DummyController(), title="Test") cb = panel._make_dpg_callback(lambda: calls.append("ok")) cb("sender", 1.0, {"x": 1}) + panel._drain_ui_commands() assert calls == ["ok"] -def test_install_fonts_fail_open_when_hint_raises(monkeypatch, tmp_path) -> None: +def test_panel_drain_order_processes_callbacks_then_ui_commands() -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + events: list[str] = [] + panel._dpg = type( + "_FakeDpg", + (), + { + "get_callback_queue": staticmethod(lambda: [(lambda: events.append("dpg_cb"), (), {})]), + "run_callbacks": staticmethod( + lambda jobs: [fn(*args, **kwargs) for fn, args, kwargs in jobs] + ), + }, + )() + panel._enqueue_ui_command(lambda: events.append("ui_cmd")) + + panel._drain_dpg_callback_queue() + panel._drain_ui_commands() + + assert events == ["dpg_cb", "ui_cmd"] + + +def test_install_fonts_raises_when_all_size_hints_fail(monkeypatch, tmp_path) -> None: panel = DearPyGuiPanel(controller=_DummyController(), title="Test") font_file = tmp_path / "NotoSansCJK-Regular.ttc" font_file.write_bytes(b"font") monkeypatch.setattr(panel, "_default_cjk_candidates", lambda: [font_file]) fake = _FakeDPGHintRaises() + with pytest.raises(RuntimeError): + panel._install_fonts(fake) + + +def test_install_fonts_keeps_successful_sizes_when_later_sizes_fail(monkeypatch, tmp_path) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + font_file = tmp_path / "NotoSansCJK-Regular.ttc" + font_file.write_bytes(b"font") + monkeypatch.setattr(panel, "_default_cjk_candidates", lambda: [font_file]) + + fake = _FakeDPGPartialFontFailure() + panel._install_fonts(fake) + + assert panel._font_handles == {"small": 321} + assert "medium" in panel._font_unavailable_reasons + assert fake.bound_font == 321 + + +def test_install_fonts_fallback_syncs_requested_key(monkeypatch, tmp_path) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + font_file = tmp_path / "NotoSansCJK-Regular.ttc" + font_file.write_bytes(b"font") + monkeypatch.setattr(panel, "_default_cjk_candidates", lambda: [font_file]) + + fake = _FakeDPGPartialFontFailure() panel._install_fonts(fake) + + assert panel._font_size_key == "small" + assert panel._font_requested_key == "small" + + +def test_run_process_entry_reports_failed_status_without_raising(monkeypatch) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + + def _boom() -> None: + raise RuntimeError("startup failure") + + monkeypatch.setattr(panel, "_run_blocking", _boom) + statuses: list[str] = [] + + rc = panel.run_process_entry(statuses.append) + + assert rc == 1 + assert statuses[0] == "starting" + assert statuses[-1].startswith("failed:") + + +def test_run_process_entry_reports_stopped_on_clean_shutdown(monkeypatch) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + monkeypatch.setattr(panel, "_run_blocking", lambda: None) + statuses: list[str] = [] + + rc = panel.run_process_entry(statuses.append) + + assert rc == 0 + assert statuses == ["starting", "stopped"] + + +def test_run_process_entry_ignores_status_callback_errors(monkeypatch) -> None: + panel = DearPyGuiPanel(controller=_DummyController(), title="Test") + monkeypatch.setattr(panel, "_run_blocking", lambda: None) + + def _broken_status_cb(_message: str) -> None: + raise RuntimeError("callback failure") + + rc = panel.run_process_entry(_broken_status_cb) + assert rc == 0 diff --git a/tests/gui/test_font_support.py b/tests/gui/test_font_support.py index 22573ec..76d2878 100644 --- a/tests/gui/test_font_support.py +++ b/tests/gui/test_font_support.py @@ -2,7 +2,9 @@ from __future__ import annotations -from motion_player.gui.font_support import resolve_cjk_font +from pathlib import Path + +from motion_player.gui.font_support import resolve_cjk_font, resolve_ui_font def test_resolve_cjk_font_returns_first_existing(tmp_path) -> None: @@ -17,3 +19,12 @@ def test_resolve_cjk_font_honors_env_override(tmp_path, monkeypatch) -> None: custom.write_bytes(b"font") monkeypatch.setenv("RMP_GUI_FONT", str(custom)) assert resolve_cjk_font([]) == custom + + +def test_resolve_ui_font_falls_back_when_cjk_missing(tmp_path: Path, monkeypatch) -> None: + latin = tmp_path / "DejaVuSans.ttf" + latin.write_bytes(b"font") + monkeypatch.delenv("RMP_GUI_FONT", raising=False) + found = resolve_ui_font(cjk_candidates=[], fallback_candidates=[latin]) + + assert found == latin diff --git a/tests/gui/test_gui_i18n_coverage.py b/tests/gui/test_gui_i18n_coverage.py index 2ddf8b4..d13225f 100644 --- a/tests/gui/test_gui_i18n_coverage.py +++ b/tests/gui/test_gui_i18n_coverage.py @@ -59,6 +59,13 @@ def test_all_required_gui_i18n_keys_have_en_and_zh_entries() -> None: "audio_stop", "tune_position_nudge", "tune_rotation_nudge", + "font_size_label", + "font_size_small", + "font_size_medium", + "font_size_large", + "font_size_xlarge", + "tool_clear", + "tool_progress", ] missing = [k for k in required if k not in panel._I18N["en"] or k not in panel._I18N["zh"]] assert missing == [] diff --git a/tests/gui/test_panel_ipc.py b/tests/gui/test_panel_ipc.py new file mode 100644 index 0000000..c069be8 --- /dev/null +++ b/tests/gui/test_panel_ipc.py @@ -0,0 +1,69 @@ +"""Tests for queue-based panel IPC adapters.""" + +from __future__ import annotations + +import queue + +from motion_player.core.ui import PlayerCommand +from motion_player.core.ui.state_monitor import PlaybackSnapshot +from motion_player.gui.panel_ipc import ( + PanelCommandReceiver, + PanelCommandSender, + PanelMonitorPublisher, + PanelMonitorSubscriber, +) + + +def _snapshot(frame: int) -> PlaybackSnapshot: + return PlaybackSnapshot( + frame=frame, + total_frames=12, + clip=0, + total_clips=1, + speed=1.0, + playing=False, + loop=True, + pingpong=False, + edit_mode=False, + show_hud=True, + show_ghost=False, + keyframe_count=0, + ) + + +def test_panel_command_round_trip_preserves_payload() -> None: + q: queue.Queue[dict[str, object]] = queue.Queue(maxsize=8) + sender = PanelCommandSender(q) + receiver = PanelCommandReceiver(q) + + sender.push(PlayerCommand.APPLY_IK_TARGET, {"target_joint": "hip", "dx": 0.1}) + drained = receiver.drain() + + assert len(drained) == 1 + assert drained[0].command is PlayerCommand.APPLY_IK_TARGET + assert drained[0].payload == {"target_joint": "hip", "dx": 0.1} + + +def test_panel_monitor_subscriber_returns_latest_snapshot_after_coalescing() -> None: + q: queue.Queue[dict[str, object]] = queue.Queue(maxsize=1) + publisher = PanelMonitorPublisher(q) + subscriber = PanelMonitorSubscriber(q) + + publisher.publish(_snapshot(frame=1)) + publisher.publish(_snapshot(frame=9)) + + latest = subscriber.latest() + assert latest is not None + assert latest.frame == 9 + + +def test_panel_command_sender_drops_oldest_when_queue_is_full() -> None: + q: queue.Queue[dict[str, object]] = queue.Queue(maxsize=1) + sender = PanelCommandSender(q) + receiver = PanelCommandReceiver(q) + + sender.push(PlayerCommand.PLAY_PAUSE) + sender.push(PlayerCommand.RESET) + drained = receiver.drain() + + assert [item.command for item in drained] == [PlayerCommand.RESET] diff --git a/tests/gui/test_status_dock_layout.py b/tests/gui/test_status_dock_layout.py new file mode 100644 index 0000000..cc9237c --- /dev/null +++ b/tests/gui/test_status_dock_layout.py @@ -0,0 +1,64 @@ +"""Tests for the bottom status dock layout policy.""" + +from __future__ import annotations + +from motion_player.gui.status_dock_layout import StatusDockLayout, build_status_dock_layout + + +def test_status_dock_layout_wide_uses_two_row_policy() -> None: + layout = build_status_dock_layout(window_width=980) + available = 980 - 48 + + assert isinstance(layout, StatusDockLayout) + assert layout.stacked is False + assert layout.monitor_width > 0 + assert layout.output_width > 0 + assert layout.progress_width > 0 + assert layout.monitor_width + layout.output_width == available + assert layout.progress_width == available + assert layout.row1_height == 146 + assert layout.row2_height == 81 + assert layout.row1_height > layout.row2_height + assert layout.dock_height >= 280 + + +def test_status_dock_layout_wide_allocates_taller_first_row() -> None: + layout = build_status_dock_layout(window_width=760) + available = 760 - 48 + + assert layout.stacked is False + assert layout.monitor_width >= 0 + assert layout.output_width >= 0 + assert layout.progress_width >= 0 + assert layout.monitor_width <= available + assert layout.output_width <= available + assert layout.progress_width <= available + assert layout.monitor_width + layout.output_width == available + assert layout.progress_width == available + assert layout.row1_height > layout.row2_height + assert layout.dock_height >= 280 + + +def test_status_dock_layout_wide_keeps_row1_taller_than_row2() -> None: + layout = build_status_dock_layout(window_width=980) + + assert layout.stacked is False + assert layout.row1_height > layout.row2_height + + +def test_status_dock_layout_narrow_stacks_cards() -> None: + layout = build_status_dock_layout(window_width=560) + + assert layout.stacked is True + assert layout.monitor_width == layout.output_width == layout.progress_width + assert layout.row1_height == 112 + assert layout.row2_height == 64 + assert layout.row1_height > layout.row2_height + assert layout.dock_height >= 320 + + +def test_status_dock_layout_stacked_keeps_progress_shorter_than_row1() -> None: + layout = build_status_dock_layout(window_width=560) + + assert layout.stacked is True + assert layout.row1_height > layout.row2_height diff --git a/tests/gui/test_tune_dual_state.py b/tests/gui/test_tune_dual_state.py index d6bb4da..244dc0f 100644 --- a/tests/gui/test_tune_dual_state.py +++ b/tests/gui/test_tune_dual_state.py @@ -26,5 +26,6 @@ def test_reset_target_from_current_copies_position() -> None: state.set_current_position_m((0.4, -0.1, 0.9)) state.target_position_m = np.array([0.0, 0.0, 0.0], dtype=np.float64) state.reset_target_from_current() - np.testing.assert_allclose(state.target_position_m, np.array([0.4, -0.1, 0.9], dtype=np.float64)) - + np.testing.assert_allclose( + state.target_position_m, np.array([0.4, -0.1, 0.9], dtype=np.float64) + ) diff --git a/tests/test_cli_gui.py b/tests/test_cli_gui.py index a14c502..bbf92fd 100644 --- a/tests/test_cli_gui.py +++ b/tests/test_cli_gui.py @@ -6,7 +6,7 @@ import sys import types -from motion_player.cli.main import _cmd_play, build_parser +from motion_player.cli.main import _cmd_play, _resolve_gui_font_size_key, build_parser from motion_player.core.dataset.loader import DatasetLoader from tests.conftest import make_motion @@ -45,12 +45,45 @@ def run(self): def test_play_has_gui_flag() -> None: parser = build_parser() - args = parser.parse_args( - ["play", "--motion", "m.pkl", "--robot", "r.xml", "--gui"] - ) + args = parser.parse_args(["play", "--motion", "m.pkl", "--robot", "r.xml", "--gui"]) assert args.gui is True +def test_play_and_gui_expose_font_size_option() -> None: + parser = build_parser() + + args_play = parser.parse_args( + [ + "play", + "--motion", + "m.pkl", + "--robot", + "r.xml", + "--gui", + "--font-size", + "large", + ] + ) + args_gui = parser.parse_args( + ["gui", "--motion", "m.pkl", "--robot", "r.xml", "--font-size", "xlarge"] + ) + + assert args_play.font_size == "large" + assert args_gui.font_size == "xlarge" + + +def test_resolve_gui_font_size_key_uses_cli_then_env_then_default(monkeypatch) -> None: + monkeypatch.setenv("RMP_GUI_FONT_SIZE", "xlarge") + assert _resolve_gui_font_size_key("small") == "small" + assert _resolve_gui_font_size_key(None) == "xlarge" + + monkeypatch.setenv("RMP_GUI_FONT_SIZE", "invalid") + assert _resolve_gui_font_size_key(None) == "medium" + + monkeypatch.delenv("RMP_GUI_FONT_SIZE", raising=False) + assert _resolve_gui_font_size_key(None) == "medium" + + def test_parser_exposes_gui_subcommand() -> None: parser = build_parser() args = parser.parse_args(["gui"]) @@ -95,6 +128,36 @@ def test_cmd_play_gui_wires_external_queue(tmp_path, monkeypatch) -> None: assert captured.get("panel_launched") is True +def test_cmd_play_gui_passes_initial_font_size_key_to_runtime(tmp_path, monkeypatch) -> None: + motion_path = tmp_path / "walk.pkl" + robot_path = tmp_path / "robot.xml" + _write_valid_motion(motion_path) + robot_path.write_text("", encoding="utf-8") + + captured: dict[str, object] = {} + + def _fake_runtime(**kwargs): + captured.update(kwargs) + return 0 + + monkeypatch.setattr("motion_player.cli.gui_runtime.run_backend_connected_gui", _fake_runtime) + + args = argparse.Namespace( + motion=str(motion_path), + robot=str(robot_path), + root_joint="root", + mapping=None, + backend="mujoco", + gui=True, + font_size="large", + ) + + rc = _cmd_play(args) + + assert rc == 0 + assert captured["initial_font_size_key"] == "large" + + def test_cmd_play_gui_missing_dependency_falls_back(tmp_path, monkeypatch, capsys) -> None: captured: dict[str, object] = {} _install_fake_mujoco_runtime(captured) diff --git a/tests/test_docs_version_state.py b/tests/test_docs_version_state.py index 981530f..451e36c 100644 --- a/tests/test_docs_version_state.py +++ b/tests/test_docs_version_state.py @@ -1,33 +1,55 @@ -"""Version consistency checks for release.""" - -from __future__ import annotations +"""Version and OSS docs consistency checks.""" from pathlib import Path +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover + tomllib = None -def test_version_markers_are_synced_to_v070() -> None: - """Verify version is consistent across key files.""" - pyproject = Path("pyproject.toml").read_text(encoding="utf-8") - init = Path("motion_player/__init__.py").read_text(encoding="utf-8") - assert 'version = "0.7.0"' in pyproject - assert "__version__" in init +def _project_version() -> str: + pyproject = Path("pyproject.toml") + if tomllib is not None: + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + return data["project"]["version"] + for line in pyproject.read_text(encoding="utf-8").splitlines(): + if line.startswith("version = "): + return line.partition('"')[2].rpartition('"')[0] + raise AssertionError("project version not found in pyproject.toml") -def test_readme_has_banner_and_quickstart() -> None: - """Verify README has essential sections.""" - readme = Path("README.md").read_text(encoding="utf-8") +def test_version_markers_follow_project_version() -> None: + project_version = _project_version() + expected_dev_version = f"{project_version}.dev0" + + init_text = Path("motion_player/__init__.py").read_text(encoding="utf-8") + cli_text = Path("motion_player/cli/main.py").read_text(encoding="utf-8") + + assert f'__version__ = "{expected_dev_version}"' in init_text + assert f'return "{expected_dev_version}"' in cli_text - # Banner - assert "assets/banner.png" in readme or "assets/Banner" in readme - # Quick start section - assert "## Quick Start" in readme or "## Installation" in readme +def test_v080_docs_align_with_font_size_release_behavior() -> None: + quickstart_en = Path("docs/QUICKSTART_en.md").read_text(encoding="utf-8") + quickstart_zh = Path("docs/QUICKSTART_zh.md").read_text(encoding="utf-8") + ik_usage = Path("docs/IK_USAGE.md").read_text(encoding="utf-8") + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") - # Usage examples - assert "motion_player play" in readme - assert "motion_player gui" in readme + assert "## [0.8.0]" in changelog + assert "--font-size" in changelog + assert "RMP_GUI_FONT_SIZE" in changelog + + for text in (quickstart_en, quickstart_zh, ik_usage): + assert "--font-size" in text + assert "RMP_GUI_FONT_SIZE" in text + + +def test_readme_references_oss_docs_only() -> None: + readme = Path("README.md").read_text(encoding="utf-8") - # Integration links - assert "github.com/YanjieZe/GMR" in readme - assert "rsl-rl-ex" in readme + assert "[English](README.md)" in readme + assert "[中文](docs/QUICKSTART_zh.md)" in readme + assert "docs/QUICKSTART_en.md" in readme + assert "docs/IK_USAGE.md" in readme + assert "README_CN.md" not in readme diff --git a/tests/test_release_backfill_state.py b/tests/test_release_backfill_state.py new file mode 100644 index 0000000..91e5624 --- /dev/null +++ b/tests/test_release_backfill_state.py @@ -0,0 +1,34 @@ +"""OSS release governance checks.""" + +from pathlib import Path + +PRIVATE_RELEASE_PATHS = ( + Path("docs/releases"), + Path("scripts/release/backfill_releases.sh"), +) + + +def test_private_release_artifacts_are_not_present_in_oss() -> None: + for path in PRIVATE_RELEASE_PATHS: + assert not path.exists(), f"private-only artifact should be absent in OSS: {path}" + + +def test_releasing_guide_targets_oss_artifacts() -> None: + text = Path("RELEASING.md").read_text(encoding="utf-8") + + assert "CHANGELOG.md" in text + assert "docs/QUICKSTART_en.md" in text + assert "docs/QUICKSTART_zh.md" in text + assert "docs/IK_USAGE.md" in text + assert "docs/releases" not in text + assert "backfill_releases.sh" not in text + + +def test_make_release_check_uses_oss_tests() -> None: + text = Path("Makefile").read_text(encoding="utf-8") + + assert "release-check" in text + assert "tests/test_docs_version_state.py" in text + assert "tests/test_release_backfill_state.py" in text + assert "tests/test_roadmap_versions.py" in text + assert "scripts/release/check_release_markers.py" not in text diff --git a/tests/test_roadmap_versions.py b/tests/test_roadmap_versions.py new file mode 100644 index 0000000..6cd7fcd --- /dev/null +++ b/tests/test_roadmap_versions.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +def test_oss_docs_and_changelog_exist() -> None: + for path in ( + Path("CHANGELOG.md"), + Path("docs/QUICKSTART_en.md"), + Path("docs/QUICKSTART_zh.md"), + Path("docs/IK_USAGE.md"), + Path("docs/OSS_SYNC_AUDIT.md"), + ): + assert path.exists(), f"missing required OSS document: {path}" + + +def test_changelog_and_docs_are_v080_aligned() -> None: + changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") + quickstart_en = Path("docs/QUICKSTART_en.md").read_text(encoding="utf-8") + quickstart_zh = Path("docs/QUICKSTART_zh.md").read_text(encoding="utf-8") + ik_usage = Path("docs/IK_USAGE.md").read_text(encoding="utf-8") + + assert "## [0.8.0]" in changelog + assert "font-size" in changelog + assert "v0.8.0" in quickstart_en + assert "v0.8.0" in quickstart_zh + assert "RMP_GUI_FONT_SIZE" in ik_usage