From fe7a7de5874ffbc4ba290e97aae6a863205dd4f0 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 4 Jun 2026 13:41:32 -0700 Subject: [PATCH 1/8] spec: openspec init --- .gitignore | 2 + docs/coding-agents/index.md | 1 + docs/development/openspec.md | 102 ++++++++++++++ docs/docs.json | 1 + openspec/config.yaml | 45 ++++++ openspec/schemas/dimos-capability/schema.yaml | 128 ++++++++++++++++++ .../dimos-capability/templates/design.md | 35 +++++ .../dimos-capability/templates/docs.md | 19 +++ .../dimos-capability/templates/proposal.md | 32 +++++ .../dimos-capability/templates/spec.md | 16 +++ .../dimos-capability/templates/tasks.md | 15 ++ 11 files changed, 396 insertions(+) create mode 100644 docs/development/openspec.md create mode 100644 openspec/config.yaml create mode 100644 openspec/schemas/dimos-capability/schema.yaml create mode 100644 openspec/schemas/dimos-capability/templates/design.md create mode 100644 openspec/schemas/dimos-capability/templates/docs.md create mode 100644 openspec/schemas/dimos-capability/templates/proposal.md create mode 100644 openspec/schemas/dimos-capability/templates/spec.md create mode 100644 openspec/schemas/dimos-capability/templates/tasks.md diff --git a/.gitignore b/.gitignore index 42bdddfa45..787163e787 100644 --- a/.gitignore +++ b/.gitignore @@ -63,8 +63,10 @@ yolo11n.pt # symlink one of .envrc.* if you'd like to use .envrc .claude +.opencode/ **/CLAUDE.md .direnv/ +.omo/ /logs diff --git a/docs/coding-agents/index.md b/docs/coding-agents/index.md index ff778ac5cf..5ac7c854a7 100644 --- a/docs/coding-agents/index.md +++ b/docs/coding-agents/index.md @@ -3,6 +3,7 @@ ├── worktrees.md (creating provisioned worktrees with `bin/worktree`) ├── style.md (code style guidelines for dimos) ├── testing.md (docs about writing tests) +├── ../development/openspec.md (OpenSpec behavior-spec workflow) ├── docs (these are docs about writing docs) │   ├── codeblocks.md │   ├── doclinks.md diff --git a/docs/development/openspec.md b/docs/development/openspec.md new file mode 100644 index 0000000000..280eb0f57e --- /dev/null +++ b/docs/development/openspec.md @@ -0,0 +1,102 @@ +# OpenSpec Workflow + +DimOS uses OpenSpec as the checked-in planning layer for behavior changes. OpenSpec artifacts live under `openspec/` and should describe what the system is supposed to do, why it is changing, and how contributors or agents should validate the work. + +## Terminology + +Keep these two meanings separate: + +- **OpenSpec capability spec**: Markdown requirements under `openspec/specs//spec.md`. These describe observable behavior and acceptance scenarios. +- **DimOS Spec**: Python Protocol/RPC contracts in files like `dimos/navigation/navigation_spec.py` or `dimos/manipulation/control/arm_driver_spec.py`. These describe module interfaces for code wiring. + +Use "OpenSpec capability spec" in prose when there is any chance of confusion. + +## Schema + +The project uses the `dimos-capability` schema configured in `openspec/config.yaml`. + +The artifact flow is: + +```text +proposal + ├── specs + ├── design + └── docs + └── tasks +``` + +| Artifact | Purpose | +|---|---| +| `proposal.md` | Intent, scope, affected DimOS surfaces, and capability impact. | +| `specs//spec.md` | Behavior-first requirements and scenarios. | +| `design.md` | Module, stream, blueprint, skill/MCP, safety, and rollout decisions. | +| `docs.md` | Documentation impact and doc validation plan. | +| `tasks.md` | Implementation, docs, verification, and manual QA checklist. | + +## When to create a change + +Create an OpenSpec change when work changes observable behavior, public CLI/API/MCP behavior, robot behavior, hardware/simulation/replay workflows, docs that users rely on, or cross-module architecture. + +Do not create a change for a purely mechanical refactor, typo fix, or internal cleanup unless it changes behavior or needs cross-session planning context. + +## Writing specs + +OpenSpec capability specs are behavior contracts, not implementation plans. + +Good spec content: + +- User- or developer-visible behavior. +- Public CLI/API/MCP tool behavior. +- Stream or message behavior that downstream modules rely on. +- Robot safety constraints and hardware/simulation/replay expectations. +- Scenarios that can be tested or manually verified. + +Avoid in specs: + +- Private class/function names. +- Generated-file mechanics. +- Library choices and wiring details. +- Step-by-step implementation tasks. + +Put those details in `design.md` or `tasks.md`. + +## Capability names + +Prefer behavior-domain names over code names. Useful starting points: + +- `module-system` +- `blueprint-composition` +- `cli-lifecycle` +- `agent-skills-mcp` +- `configuration` +- `navigation-stack` +- `manipulation-stack` +- `hardware-adapters` +- `simulation-replay` +- `documentation-system` + +Add specs progressively as changes need them. Do not try to backfill the whole project at once. + +## Validation + +Use OpenSpec validation before implementation and before archiving: + +```bash skip +openspec schema validate dimos-capability +openspec validate +openspec templates --json +``` + +For documentation changes, also run the relevant doc checks from [Writing Docs](/docs/development/writing_docs.md): + +```bash skip +md-babel-py run +``` + +When a change touches blueprint names, module-level blueprint variables, or module registry inputs, run: + +```bash skip +pytest dimos/robot/test_all_blueprints_generation.py +``` + +Then run focused tests for the changed code and manually QA through the actual surface: CLI command, MCP tool, HTTP API, simulation/replay blueprint, hardware procedure, or library driver. diff --git a/docs/docs.json b/docs/docs.json index 58da2ff6a1..f0064c9ab9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "group": "Development", "pages": [ "development/conventions", + "development/openspec", "development/testing", "development/docker", "development/grid_testing", diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..62a72bba63 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,45 @@ +schema: dimos-capability + +context: | + DimOS is a robotics operating system for generalist robots. Modules communicate + through typed streams (`In[T]`, `Out[T]`) over LCM, SHM, ROS, DDS, or other + transports. Blueprints compose modules into runnable robot stacks. Skills are + `@skill`-annotated RPC methods exposed to agents and MCP clients. + + Terminology boundary: + - "OpenSpec spec" means a behavior specification under `openspec/specs/`. + - "DimOS Spec" means a Python Protocol/RPC contract in `*_spec.py` files, + usually inheriting `dimos.spec.utils.Spec` and `typing.Protocol`. + Keep these separate. OpenSpec specs describe observable behavior; DimOS Specs + describe code-level module interfaces. + + OpenSpec specs should capture current behavior, user/developer-visible + outcomes, public CLI/API/tool surfaces, robot safety constraints, and testable + scenarios. Put implementation choices, class names, module wiring, generated + registry updates, and rollout details in `design.md` or `tasks.md`. + + Documentation lives in: + - `docs/usage/` for user-facing concepts and APIs. + - `docs/capabilities/` for capability and platform guides. + - `docs/development/` for contributor process. + - `docs/coding-agents/` and `AGENTS.md` for coding-agent guidance. + +rules: + proposal: + - "Identify affected DimOS surfaces: modules, streams, blueprints, CLI, skills/MCP, docs, hardware, simulation, replay, or generated registries." + - Use capability names that match behavior domains, not Python class names. + - Mark hardware safety or public API/CLI changes explicitly. + specs: + - Write behavior-first requirements; avoid implementation detail unless it is externally observable. + - Every requirement must include at least one `#### Scenario:` block with concrete observable outcomes. + - Use "OpenSpec capability spec" when prose might otherwise be confused with DimOS Python `Spec` Protocols. + design: + - Call out DimOS `Spec` Protocols, adapter Protocols, blueprint composition, stream names/types, and skill/MCP exposure when relevant. + - Mention generated files and required regeneration commands, especially `pytest dimos/robot/test_all_blueprints_generation.py` for blueprint registry changes. + - Include hardware/simulation/replay assumptions and safety constraints for robot-facing work. + docs: + - List user-facing docs, contributor docs, coding-agent docs, and AGENTS.md updates required by the change. + - Include documentation validation commands for changed docs, such as `doclinks` and `md-babel-py run ` where applicable. + tasks: + - Include verification tasks for OpenSpec validation, relevant pytest targets, type checks when needed, and manual QA through the user-facing surface. + - Add registry generation tasks when blueprint names, module classes, or generated registry inputs change. diff --git a/openspec/schemas/dimos-capability/schema.yaml b/openspec/schemas/dimos-capability/schema.yaml new file mode 100644 index 0000000000..fedb7964ee --- /dev/null +++ b/openspec/schemas/dimos-capability/schema.yaml @@ -0,0 +1,128 @@ +name: dimos-capability +version: 1 +description: DimOS capability workflow - proposal → specs/design/docs → tasks +artifacts: + - id: proposal + generates: proposal.md + description: DimOS change proposal covering intent, scope, capability impact, and affected robot/software surfaces + template: proposal.md + instruction: | + Create the proposal document that establishes WHY this change is needed and what DimOS behavior it affects. + + Sections: + - **Why**: 1-2 concise paragraphs on the problem or opportunity. Explain why the change matters now. + - **What Changes**: Bullet list of added, modified, or removed behavior. Mark public API/CLI or hardware-safety breaking changes with **BREAKING**. + - **Affected DimOS Surfaces**: Identify modules, streams, blueprints, CLI commands, skills/MCP tools, docs, hardware, simulation, replay, generated registries, or external protocols touched by the change. + - **Capabilities**: Identify which OpenSpec capability specs will be created or modified: + - **New Capabilities**: List behavior domains introduced by the change. Each becomes `specs//spec.md`. Use kebab-case names (for example, `agent-skills-mcp`, `blueprint-composition`, `manipulation-stack`). + - **Modified Capabilities**: List existing `openspec/specs//` entries whose requirements change. Only include spec-level behavior changes, not implementation-only refactors. + - **Impact**: Summarize user/developer impact, compatibility risks, dependency changes, documentation updates, and test/QA scope. + + Keep proposals concise. Do not include line-by-line implementation details; put architecture and rollout decisions in `design.md`. + requires: [] + - id: specs + generates: specs/**/*.md + description: Behavior-first OpenSpec capability delta specifications + template: spec.md + instruction: | + Create OpenSpec capability specs that define WHAT DimOS should do, not how it is implemented. + + Create one delta spec file per capability listed in proposal.md: + - New capabilities: use `specs//spec.md` with the exact kebab-case name from the proposal. + - Modified capabilities: use the existing folder from `openspec/specs//`. + + Use these delta sections as `##` headers: + - **ADDED Requirements**: New externally observable behavior. + - **MODIFIED Requirements**: Changed behavior. Include the full updated requirement block, not a partial patch. + - **REMOVED Requirements**: Deprecated behavior. Include **Reason** and **Migration**. + - **RENAMED Requirements**: Name-only changes. Use FROM:/TO: format. + + Requirement format: + - Use `### Requirement: `. + - Use SHALL/MUST for normative requirements. + - Include at least one `#### Scenario: ` per requirement. Scenario headings MUST use exactly four `#` characters. + - Prefer `- **GIVEN**`, `- **WHEN**`, `- **THEN**`, and `- **AND**` bullets. + - Cover happy path plus meaningful edge/error/safety cases. + + DimOS-specific guidance: + - Specify user/developer-visible behavior, robot outcomes, CLI behavior, skill/MCP tool behavior, stream contracts, safety constraints, and compatibility expectations. + - Avoid Python class names, private module internals, transport implementation choices, and generated-file details unless those details are observable API contracts. + - Use "OpenSpec capability spec" in prose when needed to avoid confusion with DimOS Python `Spec` Protocols. + - If the behavior only changes implementation and not observable requirements, do not create a spec delta. + requires: + - proposal + - id: design + generates: design.md + description: DimOS technical design and architecture decisions + template: design.md + instruction: | + Create the design document that explains HOW the change should be implemented in DimOS. + + Include design.md for cross-module changes, new robot/hardware integration, new public interfaces, new dependencies, safety-sensitive behavior, generated registry changes, or unclear architecture. + + Sections: + - **Context**: Current state, relevant modules/blueprints/docs, and constraints. + - **Goals / Non-Goals**: What the design achieves and explicitly excludes. + - **DimOS Architecture**: Modules, streams, transports, blueprints, RPC/module refs, DimOS `Spec` Protocols, adapter Protocols, skills/MCP exposure, CLI entry points, and generated registries involved. + - **Decisions**: Key choices with rationale and alternatives considered. + - **Safety / Simulation / Replay**: Hardware assumptions, sim/replay behavior, safety constraints, and manual QA surface. + - **Risks / Trade-offs**: Known risks and mitigations. + - **Migration / Rollout**: Compatibility, generated files, docs, and deployment steps. + - **Open Questions**: Outstanding decisions or unknowns. + + Reference proposal.md for intent and specs for behavior. Keep line-by-line work in tasks.md. + requires: + - proposal + - id: docs + generates: docs.md + description: Documentation impact plan for user, contributor, and coding-agent docs + template: docs.md + instruction: | + Create the documentation impact plan for the change. + + Sections: + - **User-Facing Docs**: Updates under `docs/usage/`, `docs/capabilities/`, `docs/platforms/`, or README files. + - **Contributor Docs**: Updates under `docs/development/`. + - **Coding-Agent Docs**: Updates under `docs/coding-agents/` or `AGENTS.md`. + - **Doc Validation**: Commands needed for changed docs, such as `doclinks`, `md-babel-py run `, and `bin/gen-diagrams`. + - **No Docs Needed**: If no docs are needed, explain why. + + Match `docs/development/writing_docs.md`: contributor-only docs belong in `docs/development`; user-facing behavior belongs in `docs/usage` or `docs/capabilities`. + requires: + - proposal + - id: tasks + generates: tasks.md + description: Implementation, validation, docs, and manual-QA checklist + template: tasks.md + instruction: | + Create the implementation checklist. The apply phase parses checkbox format, so every actionable task MUST use `- [ ]`. + + Guidelines: + - Group tasks under numbered `##` headings. + - Each task must be `- [ ] X.Y Task description`. + - Keep tasks small enough to complete in one focused session. + - Order tasks by dependency. + - Include docs and validation tasks from docs.md. + - Include generated registry tasks when blueprints or module registry inputs change. + - Include manual QA through the actual user surface: CLI, TUI, HTTP API, MCP tool, simulation/replay blueprint, hardware procedure, or library driver. + + Typical DimOS validation tasks: + - Run `openspec validate `. + - Run focused pytest targets for changed modules. + - Run `pytest dimos/robot/test_all_blueprints_generation.py` when blueprint registry output may change. + - Run docs validation commands for changed docs. + - Run lints/types when the touched area requires them. + + Reference specs for WHAT, design for HOW, and docs.md for documentation work. + requires: + - specs + - design + - docs +apply: + requires: + - tasks + tracks: tasks.md + instruction: | + Read proposal.md, specs, design.md, docs.md, and tasks.md before editing code. + Work through pending tasks, mark checkboxes complete as they finish, and keep artifacts current when implementation changes the plan. + Verify with OpenSpec validation, focused tests, docs checks, and manual QA through the relevant DimOS surface. diff --git a/openspec/schemas/dimos-capability/templates/design.md b/openspec/schemas/dimos-capability/templates/design.md new file mode 100644 index 0000000000..25031ceb8b --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/design.md @@ -0,0 +1,35 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## DimOS Architecture + + + +## Decisions + + + +## Safety / Simulation / Replay + + + +## Risks / Trade-offs + + + +## Migration / Rollout + + + +## Open Questions + + diff --git a/openspec/schemas/dimos-capability/templates/docs.md b/openspec/schemas/dimos-capability/templates/docs.md new file mode 100644 index 0000000000..d274aed653 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/docs.md @@ -0,0 +1,19 @@ +## User-Facing Docs + + + +## Contributor Docs + + + +## Coding-Agent Docs + + + +## Doc Validation + + + +## No Docs Needed + + diff --git a/openspec/schemas/dimos-capability/templates/proposal.md b/openspec/schemas/dimos-capability/templates/proposal.md new file mode 100644 index 0000000000..98d409e8de --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/proposal.md @@ -0,0 +1,32 @@ +## Why + + + +## What Changes + + + +## Affected DimOS Surfaces + + +- Modules/streams: +- Blueprints/CLI: +- Skills/MCP: +- Hardware/simulation/replay: +- Docs/generated registries: + +## Capabilities + +### New Capabilities + +- ``: + +### Modified Capabilities + +- ``: + +## Impact + + diff --git a/openspec/schemas/dimos-capability/templates/spec.md b/openspec/schemas/dimos-capability/templates/spec.md new file mode 100644 index 0000000000..afc0c1ff58 --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **GIVEN** +- **WHEN** +- **THEN** +- **AND** + + diff --git a/openspec/schemas/dimos-capability/templates/tasks.md b/openspec/schemas/dimos-capability/templates/tasks.md new file mode 100644 index 0000000000..b38fcdfabb --- /dev/null +++ b/openspec/schemas/dimos-capability/templates/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [ ] 1.1 +- [ ] 1.2 + +## 2. Documentation + +- [ ] 2.1 + +## 3. Verification + +- [ ] 3.1 Run `openspec validate ` +- [ ] 3.2 Run focused tests for changed code +- [ ] 3.3 Run docs validation commands for changed docs +- [ ] 3.4 Manually QA through the relevant DimOS surface (CLI, MCP, simulation/replay, hardware procedure, HTTP API, or library driver) From e49a69f4294ef87175ef9ef5fa6b110eaae15b7b Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 09:48:52 -0700 Subject: [PATCH 2/8] spec: viser manipulation panel --- .../.openspec.yaml | 2 + .../add-viser-manipulation-panel/design.md | 370 +++++++++++++ .../add-viser-manipulation-panel/docs.md | 40 ++ .../add-viser-manipulation-panel/proposal.md | 38 ++ .../specs/manipulation-operator-panel/spec.md | 131 +++++ .../target-ui.html | 504 ++++++++++++++++++ .../add-viser-manipulation-panel/tasks.md | 40 ++ 7 files changed, 1125 insertions(+) create mode 100644 openspec/changes/add-viser-manipulation-panel/.openspec.yaml create mode 100644 openspec/changes/add-viser-manipulation-panel/design.md create mode 100644 openspec/changes/add-viser-manipulation-panel/docs.md create mode 100644 openspec/changes/add-viser-manipulation-panel/proposal.md create mode 100644 openspec/changes/add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md create mode 100644 openspec/changes/add-viser-manipulation-panel/target-ui.html create mode 100644 openspec/changes/add-viser-manipulation-panel/tasks.md diff --git a/openspec/changes/add-viser-manipulation-panel/.openspec.yaml b/openspec/changes/add-viser-manipulation-panel/.openspec.yaml new file mode 100644 index 0000000000..b2c3a043ce --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-06 diff --git a/openspec/changes/add-viser-manipulation-panel/design.md b/openspec/changes/add-viser-manipulation-panel/design.md new file mode 100644 index 0000000000..86cf34f456 --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/design.md @@ -0,0 +1,370 @@ +## Context + +The manipulation stack already exposes the core operator loop through `ManipulationModule`: robot discovery, robot state, end-effector pose, collision checking, planning to pose/joints, path preview, execution, cancel, reset, trajectory status, and obstacle add/remove RPCs. Existing runnable manipulation blueprints wire `ManipulationModule` to `ControlCoordinator` and route `joint_state: In[JointState]` over LCM, for example `xarm7_planner_coordinator` uses `/coordinator/joint_state`. + +The current operator experience is fragmented: Meshcat/Drake handles backend visualization, Rerun handles general stream visualization, and `dimos.manipulation.planning.examples.manipulation_client` provides a Python shell workflow for planning and execution. There is no Viser dependency or Viser UI module in the repo today. Viser can provide the missing browser-native operator surface with APIs that exist in Viser today: `ViserServer`, `server.gui.add_button`, `add_slider`, `add_dropdown`, `add_checkbox`, tab/folder grouping, `server.scene.add_transform_controls`, scene frames/lines/meshes, and mutable scene handle color/visibility. + +The requested scope is phase 1 and phase 2 only: a read-only live viewer and a basic planning panel. Perception-driven pick/place, rich obstacle editing, camera feeds, point clouds, full MoveIt RViz parity, and planner benchmarking are out of scope. + +## Goals / Non-Goals + +**Goals:** + +- Provide a Viser browser panel that can inspect a running manipulation stack without replacing Meshcat or Rerun. +- Render the configured manipulator robot from its model file and update it from live joint state. +- Show current module state, error text, end-effector pose, trajectory task status, and selected robot metadata. +- Provide explicit Viser-supported UI controls for the basic planning workflow: choose robot, move the end-effector target control or joint sliders, see synchronized target state, plan, preview, execute, cancel, reset. +- Keep all robot-facing actions routed through `ManipulationModule` RPCs so the existing planning and coordinator safety behavior remains authoritative. +- Add only small read-only or compatibility-preserving RPCs where Viser needs data not currently exposed, such as model paths, joint limits, and planned path samples. + +**Non-Goals:** + +- Do not implement pick/place, object scanning, grasp candidate visualization, perception obstacle refresh, or camera/point-cloud overlays in this change. +- Do not replace DrakeWorld/Meshcat visualization or the Rerun bridge. +- Do not add new MCP tools or change agent skills. +- Do not introduce custom planner algorithms, trajectory generators, or benchmarking UI. +- Do not make Viser a required dependency for all manipulation users; it should remain optional. + +## DimOS Architecture + +### New module and launch surfaces + +Add a dedicated optional module, tentatively `ViserManipulationPanelModule`, under `dimos/manipulation/viser_panel/`. It should be a `Module` with `dedicated_worker = True` because it hosts a web server and long-running polling/UI callbacks. + +The module should have no required typed stream inputs or outputs in phase 1/2. It communicates with the manipulation stack by RPC: + +```text +ViserManipulationPanelModule + In streams: none required + Out streams: none required + RPC clients: + RPCClient.remote(ManipulationModule) + Hosts: + viser.ViserServer(host, port) +``` + +Configuration fields should include: + +- `host: str = "127.0.0.1"` +- `port: int = 0` or a deterministic default if the project has an available Viser port convention +- `poll_hz: float = 5.0` for state refresh +- `preview_duration: float = 3.0` +- `open_browser: bool = False` +- `default_robot: str | None = None` +- `hardware_confirm_required: bool = True` + +Expose two launch paths: + +1. A companion Python entrypoint for developers: + - `python -m dimos.manipulation.viser_panel` + - This builds only the panel module and connects to an already-running manipulation stack through RPC. +2. Optional blueprint composition for convenience: + - `viser_manipulation_panel = ViserManipulationPanelModule.blueprint(...)` + - Optional combined blueprints can be added later, such as `xarm7_planner_coordinator_viser`, if useful. + +If new runnable blueprint variables are added, regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py`. + +### Existing RPCs used by the panel + +The panel should call these existing `ManipulationModule` RPCs: + +| Panel need | RPC | +|---|---| +| Discover robot list | `list_robots()` | +| Load robot metadata | `get_robot_info(robot_name)` | +| Read module state | `get_state()` | +| Read last error | `get_error()` | +| Read current joints | `get_current_joints(robot_name)` | +| Read current EE pose | `get_ee_pose(robot_name)` | +| Check target joints | `is_collision_free(joints, robot_name)` | +| Plan Cartesian target | `plan_to_pose(pose, robot_name)` | +| Plan joint target | `plan_to_joints(joint_state, robot_name)` | +| Trigger backend preview | `preview_path(duration, robot_name)` | +| Check if a plan exists | `has_planned_path()` | +| Clear current plan | `clear_planned_path()` | +| Execute stored plan | `execute(robot_name)` | +| Read execution status | `get_trajectory_status(robot_name)` | +| Cancel motion | `cancel()` | +| Reset fault | `reset()` | + +The panel should not call private attributes or `world_monitor` directly. That keeps the panel usable both as a separate process and as an optional module in a larger blueprint. + +### Additive RPCs needed for Viser-specific rendering + +Phase 1/2 needs data not fully exposed by the current RPC surface. Add compatibility-preserving RPCs to `ManipulationModule` rather than letting the panel inspect private fields: + +1. Extend existing `get_robot_info(robot_name: str | None = None) -> dict[str, Any] | None` + - Do not add a separate `get_robot_model_info` RPC. The existing `get_robot_info` already returns `name`, `world_robot_id`, `joint_names`, `end_effector_link`, `base_link`, velocity/acceleration limits, coordinator task name, home joints, and init joints. Extend that same dictionary with Viser/planning metadata: `model_path`, `base_pose`, `joint_limits` if configured or discoverable, `package_paths`, and `xacro_args` if needed for URDF loading. + - Purpose: one robot-info call should initialize `ViserUrdf`, build joint sliders, set limits, place the base frame, and avoid splitting robot metadata across two RPC names. + +2. `get_planned_path(robot_name: str | None = None) -> list[JointState] | None` + - Returns the currently stored planned joint path for preview rendering. + - Purpose: draw Viser path ghosts/scrubber without relying on Meshcat-only `preview_path()`. + +3. `solve_ik_preview(pose: Pose, robot_name: str | None = None) -> dict[str, Any]` + - Computes IK for the candidate end-effector pose without planning, storing, previewing, or executing a path. + - Internally this belongs in `ManipulationModule` next to `plan_to_pose()`, because `ManipulationModule` already owns the configured IK solver as `self._kinematics`. That solver is created by `create_kinematics(config.kinematics_name)`, so it uses `JacobianIK` by default or `DrakeOptimizationIK` when `kinematics_name="drake_optimization"` is configured. `solve_ik_preview()` should call the same `KinematicsSpec.solve(...)` path as `plan_to_pose()`: `self._kinematics.solve(world=self._world_monitor.world, robot_id=robot_id, target_pose=target_pose, seed=current_or_preview_seed, check_collision=True)`. + - The preview RPC should return `success`, `joint_state`, `status`, `position_error`, `orientation_error` if available, and a collision/feasibility flag. + - Purpose: support real-time IK as the operator moves the end-effector control. The panel calls this at a throttled UI rate, updates joint sliders from the returned joint state on success, and marks the target red on IK/collision failure. + +4. `solve_fk_preview(joints: JointState, robot_name: str | None = None) -> dict[str, Any]` + - Computes FK for candidate joint-slider values without planning or executing. + - Purpose: support bidirectional synchronization. When the user moves a joint slider, the panel updates the end-effector target control from FK and runs collision checking automatically. + +5. Optional: `get_panel_state(robot_name: str | None = None) -> dict[str, Any]` + - Bundles state, error, current joints, EE pose, trajectory status, and `has_planned_path` into one call. + - Purpose: reduce UI polling fan-out. This can be deferred if individual RPC polling is simpler. + +These RPCs are additive and should not change existing skill/MCP behavior. + +### UI layout and exact control wiring + +Use one Viser server with a fixed/collapsible control panel and a 3D scene. + +Viser UI callbacks must not call blocking IK/FK/planning RPCs directly. `on_update` handlers should only update local target state, enqueue a preview request, and return. A small background worker inside `ViserManipulationPanelModule` should process the latest queued preview request and publish the result back to Viser handles. Requirements: + +- Debounce high-frequency end-effector-control and slider updates, e.g. target 10-20 Hz preview requests instead of one RPC per browser event. +- Coalesce stale requests: keep only the newest `(sequence_id, source, target)` per selected robot and drop older pending work. +- Apply results only if their `sequence_id` still matches the latest local target; late IK/FK responses must not overwrite newer UI state. +- Keep `Plan`, `Preview`, and `Execute` disabled while feasibility for the latest target is unknown. +- Long operations (`Plan`, `Preview`, `Execute`) should also run through the same non-UI worker pattern so the Viser event loop remains responsive. + +#### Scene nodes + +- `/world/grid`: static grid with +Z up. +- `/robots//current`: `ViserUrdf` root for the selected robot's live/current state. Render it as the primary solid robot: normal visual mesh opacity/color, current joint values from `get_current_joints`, and a small labeled frame at `/robots//current/ee` for the current EE pose. +- `/targets//ee_control`: Viser `add_transform_controls` handle for the target end-effector pose. This is the draggable 6-DOF gizmo only; it is not the robot ghost. Render it with a compact axes/handle scale so it reads as the target pose control, not as another robot. +- `/targets//ghost`: optional second `ViserUrdf` instance rendered at the current target joint values. Render it visually distinct from the current robot: translucent/wireframe if Viser URDF material controls allow it, otherwise use lighter ghost mesh colors and disable heavy visual detail. Color-code feasibility: blue/green for feasible, red for IK/collision failure, gray while feasibility is unknown. +- `/plans//path`: line/frames/ghost robot samples for the currently planned path returned by `get_planned_path`. + +The current robot, target gizmo, and target ghost must be visually separate concepts: + +- **Current robot:** solid, authoritative live state, never colored red for target infeasibility. +- **Target gizmo:** small 6-DOF pose handle the user drags; color follows target feasibility. +- **Target ghost robot:** translucent target configuration computed from IK or joint sliders; color follows target feasibility. + +#### Header/status controls + +- **Robot dropdown** + - Created from `list_robots()`. + - `on_update`: set `PanelSession.selected_robot`, call `get_robot_info`, rebuild URDF/sliders if model changed, call `clear_planned_path()` only if switching robot and local plan state belongs to another robot, disable `Preview`/`Execute`, and refresh state. + +- **Refresh button** + - Calls `list_robots`, `get_robot_info`, `get_current_joints`, `get_ee_pose`, `get_state`, `get_error`, and `get_trajectory_status`. + - Updates all read-only labels and scene handles. + +- **State label / error label** + - Populated from `get_state()` and `get_error()` on each poll. + - If state is `FAULT`, show the error text and keep only safe controls enabled: `Refresh`, `Cancel`, `Reset Fault`, target editing. + +#### Viewer tab controls + +- **Target Preset dropdown** + - Purpose: give the operator clear one-shot target initializers instead of an always-on “follow current pose” mode. + - Implemented with Viser `server.gui.add_dropdown`, populated after `get_robot_info(selected_robot)` and state refresh. + - Required options: + - `Current`: calls `get_ee_pose(selected_robot)` and `get_current_joints(selected_robot)`, then sets the target gizmo, target ghost, and joint sliders to the live robot state. + - `Init`: uses `get_robot_info().init_joints` when available, calls `solve_fk_preview`, then updates target gizmo/ghost/sliders. + - `Home`: uses `get_robot_info().home_joints` when available, calls `solve_fk_preview`, then updates target gizmo/ghost/sliders. + - Optional options can be added from robot-specific named presets in `RobotModelConfig` if that field is introduced later. + - `on_update`: applies the selected preset once, runs automatic feasibility checking, invalidates any existing plan, and sets the dropdown value back to a neutral `Select preset...` item so it does not imply ongoing tracking. + - If a preset is unavailable, show it disabled or omit it; do not create a clickable preset that fails at runtime. + +- **Show Robot Visual checkbox** + - Toggles `ViserUrdf.show_visual` or root visibility. + +- **Show Collision Visual checkbox** + - Toggles `ViserUrdf.show_collision` if collision meshes are loaded and available. + +#### Target tab controls + +There is no target mode switch. Cartesian and joint controls are both always visible because Viser supports both primitives directly: `server.scene.add_transform_controls` for the end-effector target and `server.gui.add_slider` for joints. Editing either one automatically updates the other. + +- **End-effector target control** + - Implemented with Viser `server.scene.add_transform_controls("/targets//ee_target", ...)`. + - This is needed because Viser has no normal React-style form component for dragging a 6-DOF pose in the 3D scene. The transform-control handle is the supported Viser interaction primitive for Cartesian pose editing: the operator drags/translates/rotates the desired EEF pose in the scene instead of typing all six pose values manually. + - `on_update`: copy handle position/quaternion into read-only numeric labels, enqueue an async `solve_ik_preview(pose, selected_robot)` request, and mark the local plan stale. The callback must not wait for IK. + - On IK success: update all joint sliders from returned `joint_state`, update `/targets//target_robot`, call/consume automatic feasibility state, and color the target visuals normal/blue. + - On IK or collision failure: do not update joint sliders from a failed result; keep the previous feasible joint target, set feasibility status to `Infeasible`, disable `Plan`/`Preview`/`Execute`, and color the target visuals red. + - Programmatic updates from joint sliders must set a local `sync_source = "joints"` guard so the transform-control callback does not recurse. + +- **Joint sliders** + - Created in `get_robot_info().joint_names` order with limits from `get_robot_info().joint_limits` when available. + - Initial values come from `get_current_joints` or init/home fallback if current state is unavailable. + - `on_update`: update local joint target list, enqueue async `solve_fk_preview(joint_target, selected_robot)` plus feasibility checking, mark the local plan stale, and disable `Preview`/`Execute`. The callback must not wait for FK or collision checks. + - On FK success: update the end-effector target control pose from returned FK and refresh numeric labels. + - On collision failure: keep the sliders at the operator-selected values, set feasibility status to `In collision / invalid`, disable `Plan`/`Preview`/`Execute`, and color the target robot red. + - Programmatic updates from IK must set a local `sync_source = "cartesian"` guard so slider callbacks do not recurse. + +- **Apply Preset button** + - Optional if the dropdown should not apply immediately on selection. + - Runs the same preset application path described above. Prefer immediate dropdown application if it feels reliable in Viser; prefer this explicit button if users need to inspect the preset name before applying it. + +- **Feasibility status readout** + - No manual collision-check button is needed. + - Whenever the operator moves the end-effector target control or a joint slider, the panel automatically runs IK/FK synchronization and collision feasibility. + - Feasible targets are shown with normal/blue target visuals and enabled planning controls. Infeasible targets are shown red with planning controls disabled. + +#### Planning/action buttons + +- **Plan button** + - Disabled while a plan/execution RPC is in flight or when module state is not `IDLE`/`COMPLETED`. + - Uses the last feasible synchronized target. Prefer `plan_to_joints(joint_state, selected_robot)` because real-time IK has already converted the end-effector control into a joint target; this avoids solving IK twice with possibly different seeds. If no feasible joint target exists but a Cartesian target exists, fall back to `plan_to_pose(pose, selected_robot)`. + - On success: store local `PanelPlanState(robot, target_pose, target_joints, start_joints_snapshot, planned_at_time)`, call `get_planned_path`, render Viser preview, enable `Preview`, enable `Execute` if current joints still match `start_joints_snapshot` within tolerance. + - On failure: call `get_error`, show failure message, keep `Preview`/`Execute` disabled. + +- **Preview button** + - Calls `preview_path(preview_duration, selected_robot)` so existing Meshcat/Drake preview behavior still works. + - Also re-renders the local Viser path from `get_planned_path` when available. + - Does not execute motion. + +- **Execute button** + - Enabled only when local plan state is fresh, selected robot matches the plan, and current joints match the plan's stored start snapshot within tolerance. + - If `hardware_confirm_required` is true, opens a confirmation modal showing robot, target pose/joints summary, and latest module state. + - On confirmation, calls `execute(selected_robot)` and starts polling `get_trajectory_status` at a short interval until idle/completed/fault/timeout. + +- **Plan & Execute button** + - Runs the same handler as `Plan`; if planning succeeds and safety gates pass, opens the same execution confirmation modal before calling `execute`. + - This button should be disabled by default for real hardware unless explicitly enabled in configuration. + +- **Cancel button** + - Always visible. + - Calls `cancel()` immediately. + - Clears local in-flight operation state and refreshes `get_state`/`get_trajectory_status`. + +- **Reset Fault button** + - Calls `reset()`. + - On success, clears local error display, refreshes module state, and leaves any target unchanged but invalidates the plan. + +- **Clear Plan button** + - Calls `clear_planned_path()`. + - Removes `/plans/` scene nodes and disables `Preview`/`Execute`. + +### User workflow + +Typical motion-planning workflow: + +1. Start a manipulation stack, e.g. `dimos run xarm7-planner-coordinator` or the mock/sim equivalent. +2. Start the Viser panel via the companion entrypoint or optional panel blueprint. +3. Open the Viser URL in a browser. +4. Select the robot from the robot dropdown. +5. Confirm the status panel shows live joints, EE pose, and `IDLE` or `COMPLETED` state. +6. Choose a target preset such as `Current`, `Init`, or `Home` to initialize the target gizmo, target ghost, and joint sliders. +7. Refine the target with either control surface: + - Drag/rotate the Viser end-effector target control in the 3D scene. The panel computes IK in real time through `ManipulationModule.solve_ik_preview`, updates the joint sliders automatically, and marks infeasible targets red. + - Move joint sliders. The panel computes FK through `ManipulationModule.solve_fk_preview`, updates the end-effector target control automatically, runs collision checking, and marks infeasible targets red. +8. Click `Plan`. +9. Inspect the result: status message, rendered path/ghost preview, optional Meshcat preview via `Preview`. +10. Click `Execute` if the plan is fresh and the target is correct. +11. Confirm execution in the modal when running against hardware. +12. Watch trajectory status until complete. Use `Cancel` if needed. +13. If planning fails or the module enters `FAULT`, read the error, adjust target or click `Reset Fault`, then retry. + +### Internal state model + +The panel should maintain local UI/session state independent of `ManipulationModule` internals: + +```text +PanelSession + selected_robot: str | None + robot_info: dict | None + current_joints: list[float] | None + current_ee_pose: Pose | None + cartesian_target: Pose | None + joint_target: list[float] | None + feasibility: + status: unknown | feasible | ik_failed | collision | invalid + message: str + sequence_id: int + preview_queue: + latest_sequence_id: int + worker_busy: bool + sync_source: None | cartesian | joints + in_flight_operation: None | Refresh | Plan | Preview | Execute | Cancel | Reset + plan_state: + status: none | fresh | stale | executing | failed + robot: str | None + target_pose: Pose | None + target_joints: list[float] | None + start_joints_snapshot: list[float] | None + planned_path: list[JointState] | None +``` + +Plan freshness is local and conservative. Any target edit, robot change, reset, clear-plan, failed RPC, current-joint mismatch, or infeasible target state invalidates `Execute`. + +## Decisions + +1. **Implement as an optional DimOS companion module, not inside `ManipulationModule`.** + - Rationale: Viser is an operator UI dependency and should not affect core manipulation startup or planning. + - Alternative rejected: embedding Viser into `ManipulationModule`, which couples robot planning lifecycle to browser/server behavior. + +2. **Use RPC rather than direct module references or streams for phase 1/2.** + - Rationale: `RPCClient.remote(ManipulationModule)` works as a separate process and avoids requiring blueprint coupling. + - Alternative rejected: subscribing directly to `joint_state`, because robot selection, EE pose, planning state, and trajectory status still require manipulation/coordinator semantics. + +3. **Extend `get_robot_info` instead of adding `get_robot_model_info`.** + - Rationale: Viser needs more robot metadata, but the existing robot-info RPC is already the natural owner for model path, base pose, package paths, joint limits, and xacro args. One enriched robot-info shape is simpler for clients than two overlapping metadata calls. + - Alternative rejected: adding `get_robot_model_info`, which would split robot metadata and force clients to reconcile duplicate fields. + +4. **Add small preview RPCs for path rendering and UI synchronization.** + - Rationale: Viser requires planned path samples and real-time IK/FK synchronization data that are not safely exposed today. + - Alternative rejected: reading `_robots`, `_planned_paths`, or `world_monitor` internals from the panel. + +5. **Run real-time IK in `ManipulationModule`, not in Viser.** + - Rationale: IK must use the same `KinematicsSpec`, `WorldSpec`, current joint seed, and collision context as `plan_to_pose()`. The panel is only the UI; it should request `solve_ik_preview()` and render the result. + - Alternative rejected: loading a separate Viser-side IK solver, which risks mismatched limits, seeds, collision state, and backend-specific behavior. + +6. **Do not use a target-mode switch.** + - Rationale: Viser supports both 3D transform controls and GUI sliders. Keeping both visible lets operators choose whichever is natural and keeps Cartesian/joint state synchronized continuously. + - Alternative rejected: a `Cartesian`/`Joints` dropdown, which hides useful state and adds unnecessary workflow branching. + +7. **Run collision/feasibility checks automatically on every target change.** + - Rationale: The operator should not need to click a separate collision-check button after every drag/slider edit. The panel can throttle checks and mark infeasible targets red immediately. + - Alternative rejected: a manual `Collision Check` button. + +8. **Keep execution safety server-authoritative.** + - Rationale: The panel can disable buttons and warn users, but `ManipulationModule` and `ControlCoordinator` must remain the source of truth for planning/execution acceptance. + - Alternative rejected: bypassing `execute()` by sending trajectories directly to coordinator tasks from the panel. + +9. **Defer perception/pick/place controls.** + - Rationale: Object detection snapshots, grasp generation, and place workflows introduce additional stale-state risks outside the phase 1/2 scope. + +## Safety / Simulation / Replay + +- Hardware execution must be treated as safety-sensitive. `Execute` and `Plan & Execute` require a fresh local plan and hardware confirmation when configured. +- `Cancel` must remain visible and callable regardless of selected tab or in-flight operation. +- The panel must never send raw joint commands directly to hardware; it only calls `ManipulationModule` planning/execution RPCs. +- Real-time IK preview is not execution. `solve_ik_preview()` must not store paths, publish commands, or move hardware; it only returns candidate joints and feasibility for UI synchronization. +- The panel should handle missing hardware/coordinator configuration by showing execution unavailable rather than failing startup. +- Simulation and mock stacks should use the same UI workflow with confirmation optionally disabled. +- Replay mode should support read-only viewing if manipulation state is available, but execution controls must remain disabled unless a live manipulation/coordinator stack accepts execution RPCs. +- Manual QA should cover mock/sim before real hardware: launch a manipulation blueprint, open panel, verify live updates, plan Cartesian and joint targets, preview, execute, cancel/reset, and verify stale-plan gating after target edits. + +## Risks / Trade-offs + +- **Stale internal plan risk:** `execute()` runs the module's currently stored plan, which can be changed by another client. Mitigation: local start-state comparison and optional future `plan_id`/`execute_plan(plan_id)` RPC if multi-client use becomes common. +- **Real-time IK load:** Solving IK on every drag event can overload the backend or make the UI lag. Mitigation: throttle/debounce `solve_ik_preview()` calls, cancel stale UI requests locally, and only render the latest response. +- **IK seed drift:** Repeated Cartesian edits can produce discontinuous joint solutions if the seed changes unexpectedly. Mitigation: seed preview IK from the current synchronized `joint_target` when available, falling back to live current joints. +- **Joint order mismatch:** Viser sliders and URDF updates must use `joint_names` from robot info in planner order. Mitigation: only build targets from `get_robot_info().joint_names` and keep names with every `JointState`. +- **Missing model resources:** Viser URDF loading may fail if package paths or meshes are unavailable. Mitigation: show clear model-load errors and keep status/RPC controls available. +- **Blocking RPC callbacks:** Planning and preview can block. Mitigation: run long RPCs in a worker thread or background task queue and keep the Viser event loop responsive. +- **Dependency weight:** Viser and URDF extras add optional dependencies. Mitigation: add them to an optional extra and keep existing manipulation installs working without the panel. +- **Dual visualization confusion:** Meshcat preview and Viser preview may both exist. Mitigation: label Viser preview as the operator panel preview and keep `preview_path` as backend visualizer preview. + +## Migration / Rollout + +- Add the Viser panel behind optional dependencies, likely under a manipulation/web extra, so existing manipulation users are unaffected. +- Add the new panel package and module under `dimos/manipulation/viser_panel/`. +- Extend `ManipulationModule.get_robot_info` with model/Viser metadata and add preview RPCs for planned path, IK, and FK synchronization. +- Add a companion entrypoint documented as the first supported launch surface. +- Add optional blueprint(s) only if they improve discoverability; regenerate `dimos/robot/all_blueprints.py` with `pytest dimos/robot/test_all_blueprints_generation.py` if any runnable blueprint is added. +- Document the phase 1/2 workflow in manipulation capability docs and note that pick/place/perception controls are intentionally out of scope. +- Rollback is straightforward: remove the optional panel launch path and dependency without changing core manipulation planning/execution behavior. + +## Open Questions + +- Which default Viser port should DimOS reserve, if any, and should it be added to `GlobalConfig`? +- Should the first implementation add only the companion `python -m` entrypoint, or also a named `dimos run` blueprint? +- Should planned path preview expose `JointPath` only, or a time-parameterized `JointTrajectory` for scrubber playback fidelity? +- Should `solve_ik_preview()` return just the best solution, or include multiple IK branches when available for redundant arms? +- Do real hardware sessions need an explicit `--allow-execute` flag in addition to browser confirmation? diff --git a/openspec/changes/add-viser-manipulation-panel/docs.md b/openspec/changes/add-viser-manipulation-panel/docs.md new file mode 100644 index 0000000000..4b6ee0ee56 --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/docs.md @@ -0,0 +1,40 @@ +## User-Facing Docs + +- Add a capability guide under `docs/capabilities/manipulation/`, or extend the existing manipulation capability guide if one is already the canonical entry point, with: + - How to install the optional Viser panel dependencies. + - How to launch a manipulation stack and then open the Viser manipulation panel. + - The phase 1/2 operator workflow: select robot, choose a target preset, refine with the end-effector target control or joint sliders, inspect feasibility, plan, preview, execute, cancel, and reset. + - Visual semantics: solid current robot, translucent target ghost, draggable end-effector target gizmo, planned path preview, and red infeasible target state. + - Safety notes for hardware: execution requires a fresh plan and confirmation; cancel remains available; the panel does not bypass manipulation/coordinator safety checks. + - Scope notes: no perception pick/place, rich scene editing, camera/point-cloud overlays, or full RViz parity in this phase. +- Add launch examples to the manipulation docs for the first supported surface, such as: + - Start a stack: `dimos run xarm7-planner-coordinator` or the supported mock/sim equivalent. + - Start/open the panel: use the final companion entrypoint or blueprint name chosen by implementation. +- If a named runnable blueprint is added, update the appropriate blueprint listing docs and regenerate any generated blueprint registry references required by existing docs. + +## Contributor Docs + +- Add contributor notes to manipulation development docs only if the implementation introduces new extension points, such as named target presets on robot configs or preview RPC response shapes. +- If new optional dependency groups are added, update the contributor/development docs that describe dependency extras and local validation commands. +- No broad `docs/development/` process documentation is required for the first panel unless implementation changes blueprint generation, dependency conventions, or documentation build procedures. + +## Coding-Agent Docs + +- Update `docs/coding-agents/` or `AGENTS.md` only if implementation establishes a new recurring pattern for Viser-based DimOS modules. +- If no reusable pattern is introduced beyond this feature, no coding-agent docs are required. +- If added, the guidance should emphasize: + - Keep Viser UI callbacks non-blocking. + - Route robot actions through `ManipulationModule` RPCs. + - Do not read private manipulation state from panel code. + - Keep optional visualization dependencies out of core manipulation installs. + +## Doc Validation + +- Run documentation link validation used by the repo after user-facing docs are changed, for example `doclinks` if available in the development environment. +- Run `md-babel-py run ` for any changed markdown document that contains executable Python snippets. +- Run the relevant docs build or preview command if manipulation docs include generated diagrams or cross-linked pages. +- If no executable snippets are added, no `md-babel-py` command is needed for the new/changed panel docs. + +## No Docs Needed + +Documentation changes are needed because this change adds a new operator-facing workflow and launch surface. Users need to understand how to open the panel, distinguish current and target visuals, use target presets and synchronized controls, and safely plan/execute on simulation or hardware. diff --git a/openspec/changes/add-viser-manipulation-panel/proposal.md b/openspec/changes/add-viser-manipulation-panel/proposal.md new file mode 100644 index 0000000000..0ca62289bc --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/proposal.md @@ -0,0 +1,38 @@ +## Why + +DimOS manipulation already supports motion planning, trajectory preview, execution, robot state queries, obstacle management, and gripper control, but those capabilities are currently exposed through Python/RPC clients and backend visualizers rather than a focused operator panel. Developers need a browser-based way to inspect the live robot state, set a target, plan, preview, and execute without dropping into an interactive Python shell. + +This change introduces the first two phases of a Viser-based manipulation panel: a read-only live viewer and a basic planning panel. The scope intentionally excludes perception-driven pick/place workflows, rich scene editing, and full MoveIt RViz parity so the initial surface can validate the core operator loop safely. + +## What Changes + +- Add a Viser manipulation panel capability for viewing configured manipulator robots, live joint state, end-effector pose, gripper state, and manipulation module status. +- Add operator controls for selecting a robot, setting Cartesian or joint-space targets, requesting a plan, previewing the planned trajectory, executing the current plan, canceling execution, and resetting faults. +- Add basic 3D scene behavior for rendering the robot model, current end-effector frame, target transform control, and planned trajectory preview. +- Add safety-oriented UI behavior that prevents execution unless a fresh plan exists and keeps cancel/reset controls visible. +- Limit this change to standalone/operator-facing panel behavior over existing manipulation RPC surfaces; it does not replace Meshcat, Rerun, planning backends, or manipulation execution semantics. +- No **BREAKING** public API, CLI, or hardware-safety behavior is intended. + +## Affected DimOS Surfaces + +- Modules/streams: Manipulation state and planning RPC surfaces; current joint state, end-effector pose, trajectory status, gripper state, and existing plan/preview/execute/cancel/reset behavior. No new required robot data stream is expected for the initial scope. +- Blueprints/CLI: Potential companion launch entrypoint or optional blueprint wiring for the Viser panel; existing manipulation blueprints should remain usable without the panel. +- Skills/MCP: No agent skill behavior changes are planned. The panel may expose similar operator actions through UI controls, but it should not add or alter MCP tools in the initial scope. +- Hardware/simulation/replay: Real hardware, simulation, and replay manipulation stacks should retain existing planning and execution behavior. Hardware execution must remain gated by existing manipulation/coordinator safety checks plus UI confirmation/disabled-state affordances. +- Docs/generated registries: Add user/developer documentation for launching and using the phase-1/2 panel. Generated blueprint registries may be affected only if a new runnable blueprint is introduced. + +## Capabilities + +### New Capabilities + +- `manipulation-operator-panel`: Browser-based operator behavior for inspecting a manipulation robot, setting targets, planning, previewing, executing, canceling, and resetting through a Viser control panel. + +### Modified Capabilities + +- None. + +## Impact + +Users gain a MoveIt-RViz-inspired but lightweight web panel for the core manipulation loop: inspect robot state, choose a target, plan, preview, and execute. Developers gain a concrete UI surface for validating manipulation RPC behavior without using an interactive Python client. + +Compatibility risk is low if the panel remains optional and uses existing manipulation RPCs. Dependency impact includes adding Viser and any URDF visualization support as optional dependencies rather than required core manipulation dependencies. Testing should cover panel state derivation, execution gating, RPC error handling, and compatibility with mock/sim manipulation stacks. Manual QA should launch a mock or simulation manipulation blueprint, open the Viser panel, confirm live robot state updates, create a joint and Cartesian plan, preview it, execute it, cancel/reset where applicable, and verify the panel handles missing or faulted manipulation services gracefully. diff --git a/openspec/changes/add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md b/openspec/changes/add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md new file mode 100644 index 0000000000..74086a1471 --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md @@ -0,0 +1,131 @@ +## ADDED Requirements + +### Requirement: Browser-based manipulation operator panel +The system SHALL provide a browser-based manipulation operator panel for supported manipulation stacks that lets an operator inspect robot state, set a motion-planning target, plan, preview, execute, cancel, and reset through a graphical interface. + +#### Scenario: Open panel for a running manipulation stack +- **GIVEN** a supported manipulation stack is running and exposes manipulation state through its public control surface +- **WHEN** an operator opens the manipulation operator panel +- **THEN** the panel displays the available robot choices and the selected robot's current manipulation state +- **AND** the panel provides controls for target selection, planning, previewing, execution, cancel, and reset + +#### Scenario: Missing manipulation service +- **GIVEN** the panel is opened when no compatible manipulation stack is reachable +- **WHEN** the panel attempts to load robot state +- **THEN** the panel displays a clear unavailable or disconnected state +- **AND** the panel keeps planning and execution controls disabled + +### Requirement: Current and target visualization +The system SHALL visually distinguish the live current robot state from the editable target state in the operator panel. + +#### Scenario: Display current robot and target ghost +- **GIVEN** a robot is selected and current state is available +- **WHEN** the panel renders the 3D scene +- **THEN** the live current robot is rendered as the solid authoritative state +- **AND** the target robot configuration is rendered as a visually distinct translucent or ghosted target +- **AND** the draggable end-effector target control is visually separate from both robot renderings + +#### Scenario: Target is infeasible +- **GIVEN** an operator has changed the target pose or joint target +- **WHEN** the target is infeasible because IK fails, collision checking fails, or the target is otherwise invalid +- **THEN** the panel marks the target controls and target ghost as infeasible using a red visual state +- **AND** the live current robot remains visually unchanged as the current robot state + +### Requirement: Target presets +The system SHALL provide one-shot target presets that initialize the editable target without creating an ongoing follow mode. + +#### Scenario: Apply current-position preset +- **GIVEN** current robot joints and end-effector pose are available +- **WHEN** the operator selects the Current target preset +- **THEN** the panel sets the target gizmo, target ghost, and joint controls to the current robot state +- **AND** the preset selection does not continue tracking future current-state changes after it is applied + +#### Scenario: Apply configured init or home preset +- **GIVEN** the selected robot exposes init or home target data +- **WHEN** the operator applies the Init or Home preset +- **THEN** the panel updates the target ghost, target gizmo, and joint controls to represent that preset target +- **AND** the panel evaluates target feasibility before enabling planning controls + +### Requirement: Synchronized Cartesian and joint target controls +The system SHALL keep Cartesian end-effector target controls and joint target controls synchronized for the selected robot. + +#### Scenario: Drag end-effector target +- **GIVEN** the operator is viewing a selected robot in the panel +- **WHEN** the operator drags or rotates the end-effector target control +- **THEN** the panel computes a candidate joint target for that end-effector target +- **AND** the joint controls update when the candidate target is feasible +- **AND** the existing plan is marked stale until the operator creates a new plan + +#### Scenario: Move joint slider +- **GIVEN** the operator is viewing a selected robot in the panel +- **WHEN** the operator changes a joint target control +- **THEN** the panel updates the target robot visualization and end-effector target pose to match the joint target +- **AND** the existing plan is marked stale until the operator creates a new plan + +### Requirement: Non-blocking target feasibility preview +The system SHALL keep the operator panel responsive while evaluating IK, FK, and collision feasibility for target edits. + +#### Scenario: High-frequency target edits +- **GIVEN** the operator is dragging an end-effector target control or moving joint controls repeatedly +- **WHEN** feasibility preview requests are generated faster than they can complete +- **THEN** the panel remains responsive to further UI input +- **AND** stale preview results do not overwrite a newer target state +- **AND** planning and execution controls remain disabled while the latest target feasibility is unknown + +#### Scenario: Preview result completes +- **GIVEN** the panel has requested feasibility for the latest target +- **WHEN** the latest feasibility result completes successfully +- **THEN** the panel updates the target visualization and controls from that latest result +- **AND** the panel enables planning controls only when the target is feasible and the manipulation state permits planning + +### Requirement: Planning, preview, and execution gating +The system SHALL require a fresh feasible target and a fresh plan before enabling execution from the operator panel. + +#### Scenario: Create and preview plan +- **GIVEN** a feasible synchronized target exists for the selected robot +- **WHEN** the operator requests a plan +- **THEN** the panel requests a motion plan for the selected robot +- **AND** the panel displays whether planning succeeded or failed +- **AND** a successful plan can be previewed before execution + +#### Scenario: Target changes after planning +- **GIVEN** a plan exists for the selected robot +- **WHEN** the operator changes the target pose, target joints, target preset, or selected robot +- **THEN** the panel marks the plan stale +- **AND** the panel disables execution until a new plan is created for the latest feasible target + +#### Scenario: Execute fresh plan +- **GIVEN** a fresh plan exists for the selected robot and the current robot state still matches the plan start constraints +- **WHEN** the operator confirms execution +- **THEN** the panel requests execution through the manipulation stack's public execution surface +- **AND** the panel displays trajectory status until execution completes, fails, or is canceled + +### Requirement: Safety controls remain available +The system SHALL keep safety-relevant controls visible and usable during planning and execution states. + +#### Scenario: Cancel during execution +- **GIVEN** the selected robot is executing a trajectory requested from the panel +- **WHEN** the operator activates Cancel +- **THEN** the panel requests cancellation through the manipulation stack's public cancel surface +- **AND** the panel refreshes and displays the resulting manipulation and trajectory status + +#### Scenario: Reset after fault +- **GIVEN** the manipulation stack reports a fault state +- **WHEN** the operator activates Reset Fault +- **THEN** the panel requests reset through the manipulation stack's public reset surface +- **AND** planning and execution controls remain disabled until the refreshed state and target feasibility allow them + +### Requirement: Optional dependency and compatibility boundary +The system SHALL keep the Viser manipulation panel optional and compatible with existing manipulation workflows. + +#### Scenario: Use manipulation without panel +- **GIVEN** a user installs or runs an existing manipulation workflow without the Viser panel dependencies +- **WHEN** the user runs existing manipulation planning, preview, or execution flows +- **THEN** those existing flows continue to work without requiring the panel +- **AND** existing Meshcat, Rerun, and Python/RPC workflows remain valid + +#### Scenario: Panel does not add agent tools +- **GIVEN** the manipulation operator panel is enabled +- **WHEN** agent or MCP tools are listed for the robot stack +- **THEN** the panel does not add or change agent/MCP tools as part of this phase +- **AND** operator UI actions remain separate from agent skill exposure diff --git a/openspec/changes/add-viser-manipulation-panel/target-ui.html b/openspec/changes/add-viser-manipulation-panel/target-ui.html new file mode 100644 index 0000000000..3b4c918db7 --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/target-ui.html @@ -0,0 +1,504 @@ + + + + + + Viser Manipulation Target UI Mock + + + + +
+
+
+
+

Viser Manipulation Panel Mock

+

Static HTML only. Mirrors the proposed Viser target UI.

+
+
Manipulation state: COMPLETED
+
+ +
+
+ +
solid current robot
+
+ +
+ +
+ +
+ +
+ +
translucent target ghost
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ +
+
Current robot: live, solid, authoritative
+
Target ghost: IK/FK preview, translucent
+
EEF target gizmo: draggable pose handle
+
+
+ + +
+ + diff --git a/openspec/changes/add-viser-manipulation-panel/tasks.md b/openspec/changes/add-viser-manipulation-panel/tasks.md new file mode 100644 index 0000000000..354d54856d --- /dev/null +++ b/openspec/changes/add-viser-manipulation-panel/tasks.md @@ -0,0 +1,40 @@ +## 1. Implementation + +- [ ] 1.1 Add optional Viser dependencies to the project dependency configuration without making existing manipulation installs require Viser. +- [ ] 1.2 Extend `ManipulationModule.get_robot_info()` with panel-needed metadata: model path, base pose, package paths, xacro args, and joint limits when available. +- [ ] 1.3 Add `ManipulationModule.get_planned_path(robot_name=None)` to return the current stored planned joint path through the public RPC surface. +- [ ] 1.4 Add `ManipulationModule.solve_ik_preview(pose, robot_name=None)` to run the configured `KinematicsSpec` solver without storing paths, previewing, executing, or publishing commands. +- [ ] 1.5 Add `ManipulationModule.solve_fk_preview(joints, robot_name=None)` to return the candidate end-effector pose and feasibility data for joint-slider targets without planning or executing. +- [ ] 1.6 Add focused unit coverage for the new manipulation RPCs, including no-plan side effects for IK/FK preview and enriched `get_robot_info()` response shape. +- [ ] 1.7 Create the optional Viser panel package/module under `dimos/manipulation/viser_panel/` with a dedicated worker module that hosts a `viser.ViserServer`. +- [ ] 1.8 Implement panel RPC connection handling to discover a running `ManipulationModule`, show a disconnected state when unavailable, and keep plan/execution controls disabled while disconnected. +- [ ] 1.9 Implement current robot rendering as a solid live-state `ViserUrdf` and target ghost rendering as a visually distinct translucent or color-coded target `ViserUrdf`. +- [ ] 1.10 Implement the Viser end-effector target transform control as a separate draggable gizmo, visually distinct from both current robot and target ghost. +- [ ] 1.11 Implement target preset controls for Current, Init, and Home targets, applying presets once and returning the selector to a neutral state. +- [ ] 1.12 Implement always-visible synchronized Cartesian and joint controls, including IK-driven slider updates and FK-driven target-gizmo updates. +- [ ] 1.13 Implement the non-blocking preview worker: debounce/coalesce IK/FK/collision preview requests, tag them with sequence IDs, and ignore stale results. +- [ ] 1.14 Implement automatic feasibility display and gating so infeasible targets turn target controls/ghost red and disable Plan, Preview, and Execute. +- [ ] 1.15 Implement Plan, Preview, Execute, Cancel, Reset Fault, and Clear Plan UI actions through public `ManipulationModule` RPCs with stale-plan gating. +- [ ] 1.16 Implement hardware execution confirmation and ensure Execute remains disabled unless the plan is fresh, target is feasible, selected robot matches, and current state matches the plan start constraints. +- [ ] 1.17 Add the supported launch surface: companion `python -m dimos.manipulation.viser_panel` entrypoint, optional blueprint, or both as finalized during implementation. +- [ ] 1.18 If a new runnable blueprint is added, regenerate and verify the blueprint registry with `pytest dimos/robot/test_all_blueprints_generation.py`. + +## 2. Documentation + +- [ ] 2.1 Update manipulation user docs under `docs/capabilities/manipulation/` or the canonical manipulation guide with install, launch, UI workflow, visual semantics, and safety notes. +- [ ] 2.2 Document the optional Viser dependency extra and supported launch command once implementation finalizes the command or blueprint name. +- [ ] 2.3 Add contributor notes only if implementation introduces reusable extension points, such as named target presets or preview RPC response schemas. +- [ ] 2.4 Add coding-agent guidance only if implementation establishes a reusable Viser module pattern; otherwise explicitly leave coding-agent docs unchanged. + +## 3. Verification + +- [ ] 3.1 Run `openspec validate add-viser-manipulation-panel`. +- [ ] 3.2 Run focused tests for manipulation RPC changes, including existing manipulation unit tests and any new panel/RPC tests. +- [ ] 3.3 Run focused tests or a lightweight driver for the Viser panel state model, target preset application, async preview worker, stale-result dropping, and execution gating. +- [ ] 3.4 Run `pytest dimos/robot/test_all_blueprints_generation.py` if any runnable blueprint or generated blueprint input changes. +- [ ] 3.5 Run type/lint checks required for the touched Python modules. +- [ ] 3.6 Run documentation validation for changed docs, including link validation where available and `md-babel-py run ` for any changed docs with executable Python snippets. +- [ ] 3.7 Manually QA the static alignment mock or equivalent rendered panel surface to verify solid current robot, translucent target ghost, separate EEF gizmo, target presets, sliders, feasibility state, and plan controls are understandable. +- [ ] 3.8 Manually QA the running panel against a mock or simulation manipulation stack: open the panel, select a robot, apply Current/Init/Home presets, drag the EEF target, move joint sliders, observe async feasibility updates, plan, preview, execute when supported, cancel, reset, and verify stale-plan gating. +- [ ] 3.9 Manually QA the disconnected/faulted states: start the panel without a compatible manipulation stack and with a faulted manipulation state, then verify unavailable/fault messages and disabled planning/execution controls. +- [ ] 3.10 For hardware validation, follow the project hardware safety procedure: require explicit confirmation before Execute, verify Cancel remains visible, and do not execute on real hardware until mock/sim QA passes. From 7c08675b0e8264bc6ea69d8796b28cdd757877e7 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 13:48:43 -0700 Subject: [PATCH 3/8] feat: add viser manipulation panel --- dimos/manipulation/blueprints.py | 33 + dimos/manipulation/manipulation_module.py | 151 +++ dimos/manipulation/test_manipulation_unit.py | 78 ++ dimos/manipulation/test_viser_panel.py | 564 ++++++++++++ dimos/manipulation/viser_panel/__init__.py | 20 + dimos/manipulation/viser_panel/__main__.py | 60 ++ dimos/manipulation/viser_panel/module.py | 856 ++++++++++++++++++ dimos/manipulation/viser_panel/state.py | 245 +++++ dimos/robot/all_blueprints.py | 2 + docs/capabilities/manipulation/readme.md | 47 + .../add-viser-manipulation-panel/tasks.md | 64 +- pyproject.toml | 7 +- uv.lock | 454 +++++++++- 13 files changed, 2546 insertions(+), 35 deletions(-) create mode 100644 dimos/manipulation/test_viser_panel.py create mode 100644 dimos/manipulation/viser_panel/__init__.py create mode 100644 dimos/manipulation/viser_panel/__main__.py create mode 100644 dimos/manipulation/viser_panel/module.py create mode 100644 dimos/manipulation/viser_panel/state.py diff --git a/dimos/manipulation/blueprints.py b/dimos/manipulation/blueprints.py index 14409bab7d..4d45e4c3f7 100644 --- a/dimos/manipulation/blueprints.py +++ b/dimos/manipulation/blueprints.py @@ -38,6 +38,7 @@ from dimos.hardware.sensors.camera.realsense.camera import RealSenseCamera from dimos.manipulation.manipulation_module import ManipulationModule from dimos.manipulation.pick_and_place_module import PickAndPlaceModule +from dimos.manipulation.viser_panel.module import ViserManipulationPanelModule from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -121,6 +122,38 @@ ) +_xarm7_viser_mock_cfg = _catalog_xarm7( + name="arm", + adapter_type="mock", + add_gripper=True, +) + +xarm7_viser_panel_mock = autoconnect( + ManipulationModule.blueprint( + robots=[_xarm7_viser_mock_cfg.to_robot_model_config()], + planning_timeout=10.0, + enable_viz=True, + ), + ControlCoordinator.blueprint( + tick_rate=100.0, + publish_joint_state=True, + joint_state_frame_id="coordinator", + hardware=[_xarm7_viser_mock_cfg.to_hardware_component()], + tasks=[_xarm7_viser_mock_cfg.to_task_config()], + ), + ViserManipulationPanelModule.blueprint( + host="127.0.0.1", + port=8095, + default_robot="arm", + allow_plan_execute=True, + ), +).transports( + { + ("joint_state", JointState): LCMTransport("/coordinator/joint_state", JointState), + } +) + + # XArm7 planner + LLM agent for testing base ManipulationModule skills # No perception — uses the base module's planning + gripper skills only. # Usage: dimos run coordinator-mock, then dimos run xarm7-planner-coordinator-agent diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 559b3925af..be48a259d7 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -581,6 +581,21 @@ def has_planned_path(self) -> bool: path = self._planned_paths.get(robot_name) return path is not None and len(path) > 0 + @rpc + def get_planned_path(self, robot_name: RobotName | None = None) -> JointPath | None: + """Get the currently stored planned path for a robot. + + Args: + robot_name: Robot to query (required if multiple robots configured) + + Returns: + Planned path waypoints or None if no path is stored + """ + robot = self._get_robot(robot_name) + if robot is None: + return None + return self._planned_paths.get(robot[0]) + @rpc def get_visualization_url(self) -> str | None: """Get the visualization URL. @@ -633,12 +648,32 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] robot_name, robot_id, config, _ = robot + joint_limits = None + if config.joint_limits_lower is not None and config.joint_limits_upper is not None: + joint_limits = list(zip(config.joint_limits_lower, config.joint_limits_upper, strict=False)) + elif self._world_monitor is not None: + try: + lower, upper = self._world_monitor.get_joint_limits(robot_id) + joint_limits = [ + (float(lower_value), float(upper_value)) + for lower_value, upper_value in zip(lower, upper, strict=False) + ] + except Exception as e: + logger.debug(f"Could not load joint limits for '{robot_name}': {e}") + return { "name": config.name, "world_robot_id": robot_id, "joint_names": config.joint_names, "end_effector_link": config.end_effector_link, "base_link": config.base_link, + "model_path": str(config.model_path), + "base_pose": config.base_pose, + "joint_limits": joint_limits, + "package_paths": { + package: str(path) for package, path in config.package_paths.items() + }, + "xacro_args": dict(config.xacro_args), "max_velocity": config.max_velocity, "max_acceleration": config.max_acceleration, "has_joint_name_mapping": bool(config.joint_name_mapping), @@ -650,6 +685,122 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] else None, } + @rpc + def solve_ik_preview( + self, pose: Pose, robot_name: RobotName | None = None + ) -> dict[str, Any]: + """Preview IK for a target pose without storing, previewing, executing, or moving. + + Args: + pose: Target end-effector pose in world coordinates + robot_name: Robot to solve for (required if multiple robots configured) + """ + if self._world_monitor is None or self._kinematics is None: + return { + "success": False, + "joint_state": None, + "status": "UNAVAILABLE", + "message": "Planning is not initialized", + "position_error": None, + "orientation_error": None, + "collision_free": False, + } + robot = self._get_robot(robot_name) + if robot is None: + return { + "success": False, + "joint_state": None, + "status": "UNKNOWN_ROBOT", + "message": "Robot not found", + "position_error": None, + "orientation_error": None, + "collision_free": False, + } + _, robot_id, _, _ = robot + current = self._world_monitor.get_current_joint_state(robot_id) + if current is None: + return { + "success": False, + "joint_state": None, + "status": "NO_JOINT_STATE", + "message": "No joint state", + "position_error": None, + "orientation_error": None, + "collision_free": False, + } + + from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped + + target_pose = PoseStamped( + frame_id="world", + position=pose.position, + orientation=pose.orientation, + ) + ik = self._kinematics.solve( + world=self._world_monitor.world, + robot_id=robot_id, + target_pose=target_pose, + seed=current, + check_collision=True, + ) + joint_state = ik.joint_state if ik.is_success() else None + collision_free = bool( + joint_state is not None and self._world_monitor.is_state_valid(robot_id, joint_state) + ) + return { + "success": joint_state is not None and collision_free, + "joint_state": joint_state, + "status": ik.status.name, + "message": ik.message, + "position_error": ik.position_error, + "orientation_error": ik.orientation_error, + "collision_free": collision_free, + } + + @rpc + def solve_fk_preview( + self, joints: JointState, robot_name: RobotName | None = None + ) -> dict[str, Any]: + """Preview FK and feasibility for candidate joints without planning or moving. + + Args: + joints: Candidate joint state + robot_name: Robot to solve for (required if multiple robots configured) + """ + if self._world_monitor is None: + return { + "success": False, + "pose": None, + "joint_state": None, + "status": "UNAVAILABLE", + "message": "Planning is not initialized", + "collision_free": False, + } + robot = self._get_robot(robot_name) + if robot is None: + return { + "success": False, + "pose": None, + "joint_state": None, + "status": "UNKNOWN_ROBOT", + "message": "Robot not found", + "collision_free": False, + } + _, robot_id, config, _ = robot + joint_state = joints + if not joint_state.name: + joint_state = JointState(name=config.joint_names, position=list(joints.position)) + pose = self._world_monitor.get_ee_pose(robot_id, joint_state=joint_state) + collision_free = self._world_monitor.is_state_valid(robot_id, joint_state) + return { + "success": collision_free, + "pose": pose, + "joint_state": joint_state, + "status": "SUCCESS" if collision_free else "COLLISION", + "message": "" if collision_free else "Joint state is in collision or invalid", + "collision_free": collision_free, + } + @rpc def get_init_joints(self, robot_name: RobotName | None = None) -> JointState | None: """Get the init joint state (captured at startup or set manually). diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 660993cb97..47d35117fd 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -26,7 +26,10 @@ ManipulationModule, ManipulationState, ) +from dimos.manipulation.planning.spec.enums import IKStatus +from dimos.manipulation.planning.spec.models import IKResult from dimos.manipulation.planning.spec.config import RobotModelConfig +from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -45,6 +48,10 @@ def robot_config(): joint_names=["joint1", "joint2", "joint3"], end_effector_link="link_tcp", base_link="link_base", + package_paths={"robot_description": Path("/path/to")}, + joint_limits_lower=[-1.0, -2.0, -3.0], + joint_limits_upper=[1.0, 2.0, 3.0], + xacro_args={"prefix": "test_"}, max_velocity=1.0, max_acceleration=2.0, coordinator_task_name="traj_arm", @@ -269,6 +276,77 @@ def test_execute_rejected(self, robot_config, simple_trajectory): assert module._state == ManipulationState.FAULT +class TestManipulationPanelRPCs: + def test_get_robot_info_includes_panel_metadata(self, robot_config): + module = _make_module() + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + module._init_joints = { + "test_arm": JointState( + name=robot_config.joint_names, + position=[0.1, 0.2, 0.3], + ) + } + + info = module.get_robot_info("test_arm") + + assert info is not None + assert info["model_path"] == "/path/to/robot.urdf" + assert info["base_pose"] == robot_config.base_pose + assert info["package_paths"] == {"robot_description": "/path/to"} + assert info["xacro_args"] == {"prefix": "test_"} + assert info["joint_limits"] == [(-1.0, 1.0), (-2.0, 2.0), (-3.0, 3.0)] + assert info["init_joints"] == [0.1, 0.2, 0.3] + + def test_get_planned_path_returns_stored_path(self, robot_config): + module = _make_module() + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + path = [JointState(name=robot_config.joint_names, position=[0.0, 0.0, 0.0])] + module._planned_paths = {"test_arm": path} + + assert module.get_planned_path("test_arm") is path + + def test_solve_ik_preview_does_not_store_path_or_change_state(self, robot_config): + module = _make_module_with_monitor(robot_config) + module._kinematics = MagicMock() + current = JointState(name=robot_config.joint_names, position=[0.0, 0.0, 0.0]) + solution = JointState(name=robot_config.joint_names, position=[0.1, 0.2, 0.3]) + module._world_monitor.get_current_joint_state.return_value = current + module._world_monitor.is_state_valid.return_value = True + module._kinematics.solve.return_value = IKResult( + status=IKStatus.SUCCESS, + joint_state=solution, + position_error=0.001, + orientation_error=0.002, + message="ok", + ) + + result = module.solve_ik_preview(Pose(), "test_arm") + + assert result["success"] is True + assert result["joint_state"] is solution + assert result["status"] == "SUCCESS" + assert module._planned_paths == {} + assert module._planned_trajectories == {} + assert module._state == ManipulationState.IDLE + + def test_solve_fk_preview_returns_pose_and_feasibility(self, robot_config): + module = _make_module_with_monitor(robot_config) + target = JointState(name=[], position=[0.1, 0.2, 0.3]) + pose = PoseStamped(position=Vector3(0.4, 0.5, 0.6), orientation=Quaternion()) + module._world_monitor.get_ee_pose.return_value = pose + module._world_monitor.is_state_valid.return_value = True + + result = module.solve_fk_preview(target, "test_arm") + + assert result["success"] is True + assert result["pose"] is pose + assert result["joint_state"].name == robot_config.joint_names + assert result["joint_state"].position == [0.1, 0.2, 0.3] + assert result["collision_free"] is True + assert module._planned_paths == {} + assert module._state == ManipulationState.IDLE + + class TestRobotModelConfigMapping: """Test RobotModelConfig joint name mapping helpers.""" diff --git a/dimos/manipulation/test_viser_panel.py b/dimos/manipulation/test_viser_panel.py new file mode 100644 index 0000000000..bc70a2d6cc --- /dev/null +++ b/dimos/manipulation/test_viser_panel.py @@ -0,0 +1,564 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +import threading +import time +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest + +from dimos.manipulation.viser_panel.module import ( + GOAL_ROBOT_FEASIBLE_COLOR, + GOAL_ROBOT_FEASIBLE_OPACITY, + GOAL_ROBOT_INFEASIBLE_COLOR, + GOAL_ROBOT_INFEASIBLE_OPACITY, + GOAL_ROBOT_MESH_COLOR, + ViserManipulationPanelConfig, + ViserManipulationPanelModule, +) +from dimos.manipulation.viser_panel.state import ( + ActionStatus, + BackendConnectionStatus, + FeasibilityStatus, + PanelPlanState, + PanelRuntime, + PanelSession, + PlanStatus, + PreviewRequest, + PreviewWorker, + TargetStatus, +) +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.sensor_msgs.JointState import JointState + + +def _joint_state(position: list[float], name: list[str] | None = None) -> JointState: + joint_state = JointState.__new__(JointState) + joint_state.ts = time.time() + joint_state.frame_id = "" + joint_state.name = name or [] + joint_state.position = position + joint_state.velocity = [] + joint_state.effort = [] + return joint_state + + +class FakeHandle: + def __init__(self, value=None): + self.value = value + self.disabled = False + self.color = None + self.opacity = None + self.position = (0.0, 0.0, 0.0) + self.wxyz = (1.0, 0.0, 0.0, 0.0) + self.removed = False + + def remove(self): + self.removed = True + + +class FakeScene: + def __init__(self) -> None: + self.line_segments: list[dict[str, object]] = [] + + def add_line_segments(self, _name: str, **kwargs: object) -> FakeHandle: + self.line_segments.append(kwargs) + return FakeHandle() + + +class FakeViserUrdf: + def __init__(self) -> None: + self._urdf = type( + "FakeUrdf", (), {"actuated_joint_names": ["joint1", "joint2", "drive_joint"]} + )() + self.cfg: list[float] | None = None + self._meshes = [FakeHandle(), FakeHandle()] + self.cfg_history: list[list[float]] = [] + + def update_cfg(self, cfg: list[float]) -> None: + self.cfg = cfg + self.cfg_history.append(cfg) + + +def _make_panel() -> ViserManipulationPanelModule: + with patch.object(ViserManipulationPanelModule, "__init__", lambda self: None): + panel = ViserManipulationPanelModule.__new__(ViserManipulationPanelModule) + panel.config = ViserManipulationPanelConfig() + panel.config.allow_plan_execute = True + panel.session = PanelSession(selected_robot="arm") + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel._client = MagicMock() + panel._server = type("FakeServer", (), {"scene": FakeScene()})() + panel._urdfs = {"arm:ghost": FakeHandle()} + panel._handles = { + "status": FakeHandle(), + "error": FakeHandle(), + "feasibility": FakeHandle(), + "plan": FakeHandle(), + "preview": FakeHandle(), + "execute": FakeHandle(), + "ee_control": FakeHandle(), + } + panel._joint_sliders = {} + panel._viser_urdf = None + panel._lock = threading.RLock() + return panel + + +def test_panel_session_marks_fresh_plan_stale_on_new_target_sequence(): + session = PanelSession(selected_robot="arm") + session.plan_state = PanelPlanState(status=PlanStatus.FRESH) + + sequence_id = session.next_sequence_id() + + assert sequence_id == 1 + assert session.feasibility.status == FeasibilityStatus.UNKNOWN + assert session.plan_state.status == PlanStatus.STALE + + +def test_panel_session_requires_fresh_matching_plan_for_execution(): + session = PanelSession( + selected_robot="arm", + runtime=PanelRuntime.RUNNING, + backend_status=BackendConnectionStatus.READY, + target_status=TargetStatus.FEASIBLE, + manipulation_state="IDLE", + current_joints=[0.0, 0.01], + ) + session.feasibility.status = FeasibilityStatus.FEASIBLE + session.plan_state = PanelPlanState( + status=PlanStatus.FRESH, + robot="arm", + start_joints_snapshot=[0.0, 0.0], + ) + + assert session.can_execute(0.02) + session.current_joints = [0.0, 0.1] + assert not session.can_execute(0.02) + + +def test_preview_worker_coalesces_to_latest_request(): + handled: list[int] = [] + applied: list[int] = [] + + def handle(request: PreviewRequest) -> dict[str, object]: + handled.append(request.sequence_id) + return {"success": True} + + def apply(request: PreviewRequest, _result: dict[str, object]) -> None: + applied.append(request.sequence_id) + + worker = PreviewWorker(handle, apply, debounce_seconds=0.02) + worker.start() + worker.submit(PreviewRequest(1, "joints", "arm", joints=_joint_state([0.0]))) + worker.submit(PreviewRequest(2, "joints", "arm", joints=_joint_state([1.0]))) + time.sleep(0.12) + worker.stop() + + assert handled[-1:] == [2] + assert applied[-1:] == [2] + + +def test_panel_ignores_stale_preview_result(): + panel = _make_panel() + panel.session.latest_sequence_id = 2 + + panel._apply_preview_result(PreviewRequest(1, "joints", "arm"), {"success": True}) + + assert panel.session.feasibility.status == FeasibilityStatus.UNKNOWN + + +def test_panel_preview_request_times_out_without_blocking_worker_forever(): + panel = _make_panel() + cast(ViserManipulationPanelConfig, panel.config).preview_request_timeout = 0.01 + release = threading.Event() + + def slow_preview() -> dict[str, object]: + release.wait(timeout=1.0) + return {"success": True} + + result = panel._call_preview_with_timeout(slow_preview, "IK_TIMEOUT") + release.set() + time.sleep(0.02) + + assert result["success"] is False + assert result["status"] == "IK_TIMEOUT" + assert "timed out" in str(result["message"]) + + +def test_panel_cartesian_preview_uses_timeout_wrapper(): + panel = _make_panel() + panel._client = MagicMock() + panel._client.solve_ik_preview.return_value = {"success": True, "collision_free": True} + request = PreviewRequest(1, "cartesian", "arm", pose=Pose.__new__(Pose)) + + with patch.object(panel, "_call_preview_with_timeout", return_value={"success": False}) as timeout: + result = panel._handle_preview_request(request) + + assert result == {"success": False} + timeout.assert_called_once() + + +def test_panel_refresh_reports_disconnected_state_when_rpc_unavailable(): + panel = _make_panel() + panel._client = MagicMock() + panel._client.list_robots.side_effect = RuntimeError("rpc unavailable") + + snapshot = panel.refresh_panel_state() + + assert snapshot["connected"] is False + assert snapshot["module_state"] == "DISCONNECTED" + assert snapshot["can_plan"] is False + assert snapshot["can_execute"] is False + + +def test_panel_waits_when_default_robot_is_not_reported_yet(): + panel = _make_panel() + panel._client = MagicMock() + panel._client.list_robots.return_value = [] + + snapshot = panel.refresh_panel_state() + + assert snapshot["connected"] is True + assert snapshot["module_state"] == "WAITING_FOR_ROBOT" + assert snapshot["backend_status"] == "waiting_for_robot" + assert snapshot["can_plan"] is False + assert panel._handles["status"].value == "WAITING_FOR_ROBOT" + + +def test_panel_waits_when_manipulation_module_is_not_built_yet(): + panel = _make_panel() + panel._client = MagicMock() + panel._client.list_robots.side_effect = AttributeError("_robots") + + snapshot = panel.refresh_panel_state() + + assert snapshot["connected"] is True + assert snapshot["module_state"] == "WAITING_FOR_ROBOT" + assert snapshot["backend_status"] == "waiting_for_robot" + assert "_robots" in snapshot["error"] + + +def test_panel_recreates_stale_rpc_client_after_timeout(): + panel = _make_panel() + stale_client = MagicMock() + fresh_client = MagicMock() + stale_client.list_robots.side_effect = TimeoutError() + fresh_client.list_robots.return_value = [] + panel._client = stale_client + + with patch( + "dimos.manipulation.viser_panel.module.RPCClient.remote", + return_value=fresh_client, + ): + snapshot = panel.refresh_panel_state() + + stale_client.stop_rpc_client.assert_called_once() + assert panel._client is fresh_client + assert snapshot["connected"] is True + assert snapshot["module_state"] == "WAITING_FOR_ROBOT" + assert snapshot["backend_status"] == "waiting_for_robot" + + +def test_panel_waits_when_robot_world_is_not_finalized_yet(): + panel = _make_panel() + panel._client = MagicMock() + panel._client.list_robots.return_value = ["arm"] + panel._client.get_robot_info.return_value = {"joint_names": ["j1"]} + panel._client.get_current_joints.return_value = [0.0] + panel._client.get_ee_pose.side_effect = RuntimeError("World must be finalized first") + + snapshot = panel.refresh_panel_state() + + assert snapshot["connected"] is True + assert snapshot["module_state"] == "WAITING_FOR_ROBOT" + assert snapshot["backend_status"] == "waiting_for_robot" + assert "finalized" in snapshot["error"] + + +def test_panel_fails_fast_when_viser_urdf_dependency_is_missing(): + panel = _make_panel() + + with patch("importlib.import_module", side_effect=ImportError("missing yourdfpy")): + with pytest.raises(ModuleNotFoundError, match="yourdfpy"): + panel._import_viser_urdf() + + +def test_panel_applies_feasible_preview_and_updates_target_color(): + panel = _make_panel() + panel.session.latest_sequence_id = 1 + panel.session.robot_info = {"joint_names": ["j1", "j2"]} + joint_state = _joint_state([0.1, 0.2], ["j1", "j2"]) + + panel._apply_preview_result( + PreviewRequest(1, "cartesian", "arm"), + {"success": True, "collision_free": True, "joint_state": joint_state}, + ) + + assert panel.session.feasibility.status == FeasibilityStatus.FEASIBLE + assert panel.session.target_status == TargetStatus.FEASIBLE + assert panel.session.joint_target == [0.1, 0.2] + assert panel._handles["ee_control"].color == (0, 180, 255) + assert panel._urdfs["arm:ghost"].color == (0, 180, 255) + + +def test_panel_execute_enabled_after_fresh_completed_plan(): + panel = _make_panel() + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.selected_robot = "arm" + panel.session.manipulation_state = "COMPLETED" + panel.session.current_joints = [0.0, 0.0] + panel.session.feasibility.status = FeasibilityStatus.FEASIBLE + panel.session.target_status = TargetStatus.FEASIBLE + panel.session.plan_state = PanelPlanState( + status=PlanStatus.FRESH, + robot="arm", + start_joints_snapshot=[0.0, 0.0], + ) + + panel._update_gui_state() + + assert not panel._handles["execute"].disabled + + +def test_panel_execute_requires_operator_launch_opt_in(): + panel = _make_panel() + cast(ViserManipulationPanelConfig, panel.config).allow_plan_execute = False + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.manipulation_state = "IDLE" + panel.session.current_joints = [0.0] + panel.session.feasibility.status = FeasibilityStatus.FEASIBLE + panel.session.target_status = TargetStatus.FEASIBLE + panel.session.plan_state = PanelPlanState( + status=PlanStatus.FRESH, + robot="arm", + start_joints_snapshot=[0.0], + ) + + panel._update_gui_state() + + assert panel._handles["execute"].disabled + + +def test_panel_renders_plan_path_with_viser_line_segment_shape(): + panel = _make_panel() + path = [_joint_state([0.0]), _joint_state([0.1]), _joint_state([0.2])] + + panel._render_plan_path(path) + + assert panel._server is not None + assert panel._server.scene.line_segments[0]["points"] == [ + [[0.0, 0.0, 0.02], [1.0, 0.1, 0.02]], + [[1.0, 0.1, 0.02], [2.0, 0.2, 0.02]], + ] + + +def test_panel_snapshot_does_not_force_refresh(): + panel = _make_panel() + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.manipulation_state = "IDLE" + + snapshot = panel.get_panel_snapshot() + + assert snapshot["connected"] is True + assert snapshot["module_state"] == "IDLE" + + +def test_panel_updates_viser_urdf_with_matching_named_joints(): + panel = _make_panel() + panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} + urdf = FakeViserUrdf() + + panel._set_urdf_joints(urdf, [0.1, 0.2, 0.3]) + + assert urdf.cfg == [0.1, 0.2, 0.0] + + +def test_panel_creates_goal_robot_as_transparent_colored_overlay(): + panel = _make_panel() + panel._urdfs = {} + created: list[dict[str, object]] = [] + + class CapturingViserUrdf: + def __init__(self, _server: object, _path: object, **kwargs: object) -> None: + created.append(kwargs) + + panel._viser_urdf = CapturingViserUrdf + + with patch.object(panel, "_prepared_urdf_path", return_value=Path("robot.urdf")): + panel._ensure_scene_nodes("arm", {"model_path": "robot.xacro"}) + + assert created == [ + {"root_node_name": "/robots/arm/current", "mesh_color_override": None}, + {"root_node_name": "/targets/arm/ghost", "mesh_color_override": GOAL_ROBOT_MESH_COLOR}, + ] + + +def test_panel_updates_goal_robot_mesh_color_for_feasibility(): + panel = _make_panel() + ghost = FakeViserUrdf() + panel._urdfs["arm:ghost"] = ghost + + panel._set_target_visual_state(True) + + assert all(mesh.color == GOAL_ROBOT_FEASIBLE_COLOR for mesh in ghost._meshes) + assert all(mesh.opacity == GOAL_ROBOT_FEASIBLE_OPACITY for mesh in ghost._meshes) + + panel._set_target_visual_state(False) + + assert all(mesh.color == GOAL_ROBOT_INFEASIBLE_COLOR for mesh in ghost._meshes) + assert all(mesh.opacity == GOAL_ROBOT_INFEASIBLE_OPACITY for mesh in ghost._meshes) + + +def test_panel_plan_runs_while_plan_operation_is_marked_in_flight(): + panel = _make_panel() + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.selected_robot = "arm" + panel.session.manipulation_state = "IDLE" + panel.session.feasibility.status = FeasibilityStatus.FEASIBLE + panel.session.target_status = TargetStatus.FEASIBLE + panel.session.joint_target = [0.1, 0.2] + panel.session.current_joints = [0.0, 0.0] + panel.session.robot_info = {"joint_names": ["j1", "j2"]} + client = panel._client + assert client is not None + client.plan_to_joints.return_value = True + client.get_planned_path.return_value = [_joint_state([0.0]), _joint_state([0.1])] + + panel._plan() + + client.plan_to_joints.assert_called_once() + assert panel.session.plan_state.status == PlanStatus.FRESH + + +def test_panel_state_axes_keep_action_separate_from_plan_state(): + panel = _make_panel() + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.manipulation_state = "IDLE" + panel.session.target_status = TargetStatus.FEASIBLE + + assert panel.session.can_plan() + panel.session.action_status = ActionStatus.PREVIEWING + assert not panel.session.can_plan() + assert panel.session.plan_state.status == PlanStatus.NONE + + +def test_panel_preview_animates_viser_ghost_path(): + panel = _make_panel() + cast(ViserManipulationPanelConfig, panel.config).preview_duration = 0.0 + panel.session.selected_robot = "arm" + panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} + ghost = FakeViserUrdf() + panel._urdfs["arm:ghost"] = ghost + client = panel._client + assert client is not None + client.get_planned_path.return_value = [_joint_state([0.1, 0.2, 0.3])] + + panel._preview() + + client.preview_path.assert_not_called() + assert ghost.cfg == [0.1, 0.2, 0.0] + assert "Previewing" in panel.session.error + + +def test_panel_interpolates_sparse_preview_path(): + panel = _make_panel() + path = [_joint_state([0.0, 0.0]), _joint_state([1.0, 2.0])] + + frames = panel._interpolate_joint_path(path, duration=1.0, fps=2.0) + + assert frames == [[0.0, 0.0], [0.5, 1.0], [1.0, 2.0]] + + +def test_panel_preview_animates_interpolated_frames(): + panel = _make_panel() + cast(ViserManipulationPanelConfig, panel.config).preview_duration = 1.0 + cast(ViserManipulationPanelConfig, panel.config).preview_fps = 2.0 + panel.session.selected_robot = "arm" + panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2"]} + ghost = FakeViserUrdf() + panel._urdfs["arm:ghost"] = ghost + + with patch("dimos.manipulation.viser_panel.module.time.sleep"): + panel._animate_ghost_path([_joint_state([0.0, 0.0]), _joint_state([1.0, 2.0])], 1.0) + + assert ghost.cfg_history == [[0.0, 0.0, 0.0], [0.5, 1.0, 0.0], [1.0, 2.0, 0.0]] + + +def test_panel_preview_does_not_trigger_backend_drake_preview(): + panel = _make_panel() + cast(ViserManipulationPanelConfig, panel.config).preview_duration = 0.0 + panel.session.selected_robot = "arm" + panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} + ghost = FakeViserUrdf() + panel._urdfs["arm:ghost"] = ghost + client = panel._client + assert client is not None + client.get_planned_path.return_value = [_joint_state([0.1, 0.2, 0.3])] + + panel._preview() + + client.preview_path.assert_not_called() + assert ghost.cfg == [0.1, 0.2, 0.0] + + +def test_panel_target_sync_does_not_overwrite_ghost_during_preview(): + panel = _make_panel() + panel.session.selected_robot = "arm" + panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} + panel.session.joint_target = [0.9, 0.8, 0.7] + panel.session.action_status = ActionStatus.PREVIEWING + ghost = FakeViserUrdf() + ghost.update_cfg([0.1, 0.2, 0.0]) + panel._urdfs["arm:ghost"] = ghost + + panel._sync_controls_from_targets() + + assert ghost.cfg == [0.1, 0.2, 0.0] + + +def test_panel_execute_runs_while_execute_operation_is_marked_in_flight(): + panel = _make_panel() + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.selected_robot = "arm" + panel.session.manipulation_state = "COMPLETED" + panel.session.current_joints = [0.0, 0.0] + panel.session.feasibility.status = FeasibilityStatus.FEASIBLE + panel.session.target_status = TargetStatus.FEASIBLE + panel.session.plan_state = PanelPlanState( + status=PlanStatus.FRESH, + robot="arm", + start_joints_snapshot=[0.0, 0.0], + ) + panel.session.action_status = ActionStatus.EXECUTING + client = panel._client + assert client is not None + client.execute.return_value = True + + panel._execute() + + client.execute.assert_called_once_with("arm") + assert panel.session.plan_state.status == PlanStatus.EXECUTING + assert panel.session.error == "Execution accepted" diff --git a/dimos/manipulation/viser_panel/__init__.py b/dimos/manipulation/viser_panel/__init__.py new file mode 100644 index 0000000000..509522c6ab --- /dev/null +++ b/dimos/manipulation/viser_panel/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from dimos.manipulation.viser_panel.module import ( + ViserManipulationPanelConfig, + ViserManipulationPanelModule, +) + +__all__ = ["ViserManipulationPanelConfig", "ViserManipulationPanelModule"] diff --git a/dimos/manipulation/viser_panel/__main__.py b/dimos/manipulation/viser_panel/__main__.py new file mode 100644 index 0000000000..bf608b0644 --- /dev/null +++ b/dimos/manipulation/viser_panel/__main__.py @@ -0,0 +1,60 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +import argparse +import signal +import sys +import time + +from dimos.manipulation.viser_panel.module import ViserManipulationPanelModule + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8095) + parser.add_argument("--robot", default=None) + parser.add_argument("--open-browser", action="store_true") + parser.add_argument("--allow-execute", action="store_true") + args = parser.parse_args() + + module = ViserManipulationPanelModule( + host=args.host, + port=args.port, + default_robot=args.robot, + open_browser=args.open_browser, + allow_plan_execute=args.allow_execute, + ) + stopped = False + + def stop(_signum: int, _frame: object) -> None: + nonlocal stopped + stopped = True + module.stop() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + try: + module.start() + except ModuleNotFoundError as e: + print(str(e), file=sys.stderr) + raise SystemExit(1) from None + while not stopped: + time.sleep(0.2) + + +if __name__ == "__main__": + main() diff --git a/dimos/manipulation/viser_panel/module.py b/dimos/manipulation/viser_panel/module.py new file mode 100644 index 0000000000..6ffa22d30e --- /dev/null +++ b/dimos/manipulation/viser_panel/module.py @@ -0,0 +1,856 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from collections.abc import Sequence +import importlib +from pathlib import Path +import threading +import time +import traceback +from typing import Any, cast +import webbrowser + +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.rpc_client import RPCClient +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.manipulation.viser_panel.state import ( + ActionStatus, + BackendConnectionStatus, + FeasibilityStatus, + PanelSession, + PanelRuntime, + PlanStatus, + PreviewRequest, + PreviewWorker, + TargetStatus, +) +from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.protocol.rpc.pubsubrpc import LCMRPC +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +VISER_INSTALL_HINT = "Install the optional panel dependencies with: uv sync --extra manipulation-viser" +VISER_URDF_INSTALL_HINT = ( + "Viser URDF support requires yourdfpy. Install it with: uv sync --extra manipulation-viser" +) +GOAL_ROBOT_FEASIBLE_COLOR = (255, 122, 0) +GOAL_ROBOT_INFEASIBLE_COLOR = (255, 30, 30) +GOAL_ROBOT_FEASIBLE_OPACITY = 0.7 +GOAL_ROBOT_INFEASIBLE_OPACITY = 0.75 +GOAL_ROBOT_MESH_COLOR = (*GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY) + + +class ViserManipulationPanelConfig(ModuleConfig): + host: str = "127.0.0.1" + port: int = 8095 + poll_hz: float = 5.0 + preview_duration: float = 3.0 + open_browser: bool = False + default_robot: str | None = None + preview_debounce_seconds: float = 0.05 + preview_request_timeout: float = 5.0 + preview_fps: float = 30.0 + current_match_tolerance: float = 0.02 + allow_plan_execute: bool = False + + +class ViserManipulationPanelModule(Module): + dedicated_worker = True + manipulation: ManipulationModule + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.session = PanelSession(selected_robot=self.panel_config.default_robot) + self._client: Any | None = None + self._server: Any | None = None + self._viser_urdf: Any | None = None + self._urdfs: dict[str, Any] = {} + self._handles: dict[str, Any] = {} + self._joint_sliders: dict[str, Any] = {} + self._stop_event = threading.Event() + self._poll_thread: threading.Thread | None = None + self._preview_worker = PreviewWorker( + self._handle_preview_request, + self._apply_preview_result, + self.panel_config.preview_debounce_seconds, + ) + self._lock = threading.RLock() + + @property + def panel_config(self) -> ViserManipulationPanelConfig: + return cast(ViserManipulationPanelConfig, self.config) + + @rpc + def start(self) -> None: + super().start() + self.session.runtime = PanelRuntime.STARTING + viser = self._import_viser() + self._viser_urdf = self._import_viser_urdf() + self._reset_manipulation_client() + self._server = viser.ViserServer(host=self.panel_config.host, port=self.panel_config.port) + self._build_gui() + self._preview_worker.start() + self._stop_event.clear() + self.session.runtime = PanelRuntime.RUNNING + self.session.backend_status = BackendConnectionStatus.CONNECTING + self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True) + self._poll_thread.start() + url = f"http://{self.panel_config.host}:{self.panel_config.port}" + logger.info(f"Viser manipulation panel: {url}") + if self.panel_config.open_browser: + webbrowser.open_new_tab(url) + + @rpc + def stop(self) -> None: + self.session.runtime = PanelRuntime.STOPPING + self._stop_event.set() + self._preview_worker.stop() + if self._poll_thread and self._poll_thread.is_alive(): + self._poll_thread.join(timeout=2.0) + if self._client is not None: + self._client.stop_rpc_client() + self._client = None + if self._server is not None and hasattr(self._server, "stop"): + self._server.stop() + self.session.runtime = PanelRuntime.STOPPED + super().stop() + + def _import_viser(self) -> Any: + try: + viser = importlib.import_module("viser") + except ModuleNotFoundError as e: + raise ModuleNotFoundError(VISER_INSTALL_HINT) from e + return viser + + def _import_viser_urdf(self) -> Any: + try: + viser_extras = importlib.import_module("viser.extras") + except (ImportError, ModuleNotFoundError) as e: + raise ModuleNotFoundError(VISER_URDF_INSTALL_HINT) from e + return viser_extras.ViserUrdf + + def _build_gui(self) -> None: + if self._server is None: + return + gui = self._server.gui + self._handles["status"] = gui.add_text("Status", initial_value="Disconnected") + self._handles["error"] = gui.add_text("Error", initial_value="") + self._handles["feasibility"] = gui.add_text("Feasibility", initial_value="unknown") + self._handles["robot"] = gui.add_dropdown( + "Robot", options=["No robots"], initial_value="No robots" + ) + self._handles["preset"] = gui.add_dropdown( + "Target Preset", + options=["Select preset...", "Current"], + initial_value="Select preset...", + ) + self._handles["plan"] = gui.add_button("Plan", disabled=True) + self._handles["preview"] = gui.add_button("Preview", disabled=True) + self._handles["execute"] = gui.add_button("Execute", disabled=True) + self._handles["cancel"] = gui.add_button("Cancel") + self._handles["clear_plan"] = gui.add_button("Clear Plan") + self._wire_static_callbacks() + + def _wire_static_callbacks(self) -> None: + self._handles["robot"].on_update(lambda event: self._select_robot(str(event.target.value))) + self._handles["preset"].on_update(lambda event: self._apply_preset(str(event.target.value))) + self._handles["plan"].on_click(lambda _: self._run_operation("Plan", self._plan)) + self._handles["preview"].on_click(lambda _: self._run_operation("Preview", self._preview)) + self._handles["execute"].on_click(lambda _: self._run_operation("Execute", self._execute)) + self._handles["cancel"].on_click(lambda _: self._run_operation("Cancel", self._cancel)) + self._handles["clear_plan"].on_click(lambda _: self._run_operation("Clear Plan", self._clear_plan)) + + def _poll_loop(self) -> None: + interval = 1.0 / max(self.panel_config.poll_hz, 0.1) + while not self._stop_event.is_set(): + self.refresh_panel_state() + self._stop_event.wait(interval) + + def _reset_manipulation_client(self) -> None: + if self._client is not None: + self._client.stop_rpc_client() + if hasattr(self, "manipulation"): + self._client = self.manipulation + return + module_rpc = getattr(self, "rpc", None) + self._client = RPCClient.remote( + ManipulationModule, + rpc=module_rpc if isinstance(module_rpc, LCMRPC) else None, + ) + + def _list_robots(self) -> list[str]: + if self._client is None: + self._reset_manipulation_client() + client = self._client + if client is None: + raise RuntimeError("Manipulation RPC client is not connected") + try: + return list(client.list_robots()) + except TimeoutError: + self._reset_manipulation_client() + client = self._client + if client is None: + raise RuntimeError("Manipulation RPC client is not connected") + return list(client.list_robots()) + + @rpc + def refresh_panel_state(self) -> dict[str, Any]: + with self._lock: + try: + try: + robots = self._list_robots() + except AttributeError as e: + self._mark_waiting_for_robot(str(e)) + self._update_gui_state() + return self._snapshot() + client = self._client + if client is None: + raise RuntimeError("Manipulation RPC client is not connected") + self.session.backend_status = BackendConnectionStatus.READY + if robots and self.session.selected_robot not in robots: + self._select_robot(robots[0]) + self._update_robot_dropdown(robots) + if not robots: + self._mark_waiting_for_robot("No robots reported by ManipulationModule yet") + elif self.session.selected_robot: + robot = self.session.selected_robot + self.session.robot_info = client.get_robot_info(robot) + if self.session.robot_info is None: + self._mark_waiting_for_robot(f"Robot '{robot}' is not available yet") + self._update_gui_state() + return self._snapshot() + try: + self.session.current_joints = client.get_current_joints(robot) + self.session.current_ee_pose = self._pose_from_rpc(client.get_ee_pose(robot)) + self.session.manipulation_state = str(client.get_state()) + self.session.error = str(client.get_error() or "") + except TimeoutError as e: + self._reset_manipulation_client() + self._mark_waiting_for_robot(repr(e)) + self._update_gui_state() + return self._snapshot() + except (RuntimeError, ValueError) as e: + self._mark_waiting_for_robot(str(e)) + self._update_gui_state() + return self._snapshot() + self._ensure_robot_ui(robot) + else: + self.session.backend_status = BackendConnectionStatus.WAITING_FOR_ROBOT + self.session.manipulation_state = "NO_ROBOT" + except Exception as e: + self.session.backend_status = BackendConnectionStatus.DISCONNECTED + self.session.manipulation_state = "DISCONNECTED" + self.session.error = str(e) or traceback.format_exc() + self._update_gui_state() + return self._snapshot() + + @rpc + def get_panel_snapshot(self) -> dict[str, Any]: + with self._lock: + return self._snapshot() + + def _mark_waiting_for_robot(self, message: str) -> None: + self.session.backend_status = BackendConnectionStatus.WAITING_FOR_ROBOT + self.session.manipulation_state = "WAITING_FOR_ROBOT" + self.session.error = message + + def _select_robot(self, robot_name: str) -> None: + if robot_name in {"", "No robots"}: + return + with self._lock: + if self.session.selected_robot != robot_name: + self.session.selected_robot = robot_name + self.session.plan_state.status = PlanStatus.STALE + self.session.target_status = TargetStatus.EMPTY + self._joint_sliders.clear() + + def _update_robot_dropdown(self, robots: Sequence[str]) -> None: + handle = self._handles.get("robot") + if handle is None: + return + options = list(robots) or ["No robots"] + handle.options = options + handle.value = self.session.selected_robot or options[0] + + def _ensure_robot_ui(self, robot_name: str) -> None: + info = self.session.robot_info or {} + self._ensure_scene_nodes(robot_name, info) + if not self._joint_sliders: + self._build_joint_sliders(robot_name, info) + self._update_preset_options(info) + self._update_current_robot(robot_name) + + def _ensure_scene_nodes(self, robot_name: str, info: dict[str, Any]) -> None: + if self._server is None: + return + if "ee_control" not in self._handles: + self._handles["ee_control"] = self._server.scene.add_transform_controls( + f"/targets/{robot_name}/ee_control", scale=0.25 + ) + self._handles["ee_control"].on_update(lambda event: self._target_pose_changed(event.target)) + ViserUrdf = self._viser_urdf + if ViserUrdf is None or not info.get("model_path"): + return + for kind in ("current", "ghost"): + key = f"{robot_name}:{kind}" + if key not in self._urdfs: + root_node_name = ( + f"/robots/{robot_name}/current" + if kind == "current" + else f"/targets/{robot_name}/ghost" + ) + mesh_color_override = GOAL_ROBOT_MESH_COLOR if kind == "ghost" else None + self._urdfs[key] = ViserUrdf( + self._server, + self._prepared_urdf_path(info), + root_node_name=root_node_name, + mesh_color_override=mesh_color_override, + ) + if kind == "ghost": + self._set_urdf_mesh_material( + self._urdfs[key], GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY + ) + + def _build_joint_sliders(self, robot_name: str, info: dict[str, Any]) -> None: + if self._server is None: + return + names = list(info.get("joint_names") or []) + limits = info.get("joint_limits") or [] + values = self.session.current_joints or [0.0] * len(names) + for index, name in enumerate(names): + lower, upper = (-3.14, 3.14) + if index < len(limits) and limits[index] is not None: + lower, upper = limits[index] + initial = values[index] if index < len(values) else 0.0 + slider = self._server.gui.add_slider( + name, + min=float(lower), + max=float(upper), + step=0.001, + initial_value=float(initial), + ) + slider.on_update(lambda _event, joint_name=name: self._joint_slider_changed(joint_name)) + self._joint_sliders[name] = slider + + def _update_preset_options(self, info: dict[str, Any]) -> None: + preset = self._handles.get("preset") + if preset is None: + return + options = ["Select preset...", "Current"] + if info.get("init_joints") is not None: + options.append("Init") + if info.get("home_joints") is not None: + options.append("Home") + preset.options = options + + def _update_current_robot(self, robot_name: str) -> None: + current = self._urdfs.get(f"{robot_name}:current") + if current is not None and self.session.current_joints is not None: + self._set_urdf_joints(current, self.session.current_joints) + + def _target_pose_changed(self, target: Any) -> None: + with self._lock: + if self.session.sync_source == "joints" or self.session.selected_robot is None: + return + wxyz = tuple(target.wxyz) + pose = self._make_pose(tuple(target.position), (wxyz[1], wxyz[2], wxyz[3], wxyz[0])) + self.session.cartesian_target = pose + sequence_id = self.session.next_sequence_id() + self._preview_worker.submit( + PreviewRequest(sequence_id, "cartesian", self.session.selected_robot, pose=pose) + ) + + def _joint_slider_changed(self, _joint_name: str) -> None: + with self._lock: + if self.session.sync_source == "cartesian" or self.session.selected_robot is None: + return + info = self.session.robot_info or {} + names = list(info.get("joint_names") or self._joint_sliders.keys()) + joints = [float(self._joint_sliders[name].value) for name in names] + self.session.joint_target = joints + sequence_id = self.session.next_sequence_id() + self._preview_worker.submit( + PreviewRequest( + sequence_id, + "joints", + self.session.selected_robot, + joints=self._make_joint_state(names, joints), + ) + ) + + def _apply_preset(self, preset: str) -> None: + if preset in {"", "Select preset..."} or self.session.selected_robot is None: + return + with self._lock: + info = self.session.robot_info or {} + if preset == "Current": + self.session.cartesian_target = self.session.current_ee_pose + self.session.joint_target = self.session.current_joints + self._sync_controls_from_targets() + if self.session.selected_robot and self.session.current_ee_pose: + sequence_id = self.session.next_sequence_id() + self._preview_worker.submit( + PreviewRequest( + sequence_id, + "cartesian", + self.session.selected_robot, + pose=self.session.current_ee_pose, + ) + ) + else: + joints = info.get("init_joints") if preset == "Init" else info.get("home_joints") + if joints is not None: + self.session.joint_target = list(joints) + self._sync_controls_from_targets() + sequence_id = self.session.next_sequence_id() + self._preview_worker.submit( + PreviewRequest( + sequence_id, + "joints", + self.session.selected_robot, + joints=self._make_joint_state(info.get("joint_names") or [], list(joints)), + ) + ) + self._handles["preset"].value = "Select preset..." + + def _handle_preview_request(self, request: PreviewRequest) -> dict[str, Any]: + try: + if self._client is None: + return {"success": False, "status": "DISCONNECTED", "message": "Disconnected"} + client = self._client + if request.source == "cartesian" and request.pose is not None: + return self._call_preview_with_timeout( + lambda: dict(client.solve_ik_preview(request.pose, request.robot_name)), + "IK_TIMEOUT", + ) + if request.source == "joints" and request.joints is not None: + return self._call_preview_with_timeout( + lambda: dict(client.solve_fk_preview(request.joints, request.robot_name)), + "FK_TIMEOUT", + ) + return {"success": False, "status": "INVALID", "message": "Invalid preview request"} + except Exception as e: + return {"success": False, "status": "ERROR", "message": str(e)} + + def _call_preview_with_timeout( + self, call: Any, timeout_status: str + ) -> dict[str, Any]: + result: dict[str, Any] | None = None + error: Exception | None = None + + def run() -> None: + nonlocal result, error + try: + result = call() + except Exception as e: + error = e + + thread = threading.Thread(target=run, daemon=True) + thread.start() + timeout = max(self.panel_config.preview_request_timeout, 0.0) + thread.join(timeout=timeout) + if thread.is_alive(): + return { + "success": False, + "status": timeout_status, + "message": f"Preview request timed out after {timeout:.1f}s", + "collision_free": False, + } + if error is not None: + raise error + return result or { + "success": False, + "status": "EMPTY_RESULT", + "message": "Preview request returned no result", + "collision_free": False, + } + + def _apply_preview_result(self, request: PreviewRequest, result: dict[str, Any]) -> None: + with self._lock: + if request.sequence_id != self.session.latest_sequence_id: + return + success = bool(result.get("success")) and bool(result.get("collision_free", True)) + if success: + self.session.feasibility.status = FeasibilityStatus.FEASIBLE + self.session.feasibility.message = "Feasible" + self.session.target_status = TargetStatus.FEASIBLE + joint_state = result.get("joint_state") + if joint_state is not None: + self.session.joint_target = list(joint_state.position) + pose = self._pose_from_rpc(result.get("pose")) + if pose is not None: + self.session.cartesian_target = pose + self._sync_controls_from_targets() + else: + status = str(result.get("status") or "invalid") + self.session.feasibility.status = ( + FeasibilityStatus.COLLISION if status == "COLLISION" else FeasibilityStatus.IK_FAILED + ) + self.session.feasibility.message = str(result.get("message") or status) + self.session.target_status = TargetStatus.INFEASIBLE + self._set_target_visual_state(self.session.feasibility.status == FeasibilityStatus.FEASIBLE) + self._update_gui_state() + + def _sync_controls_from_targets(self) -> None: + if self.session.joint_target is not None: + self.session.sync_source = "cartesian" + try: + info = self.session.robot_info or {} + for name, value in zip(info.get("joint_names") or [], self.session.joint_target, strict=False): + if name in self._joint_sliders: + self._joint_sliders[name].value = float(value) + if self.session.selected_robot: + ghost = self._urdfs.get(f"{self.session.selected_robot}:ghost") + if ghost is not None and self.session.action_status != ActionStatus.PREVIEWING: + self._set_urdf_joints(ghost, self.session.joint_target) + finally: + self.session.sync_source = None + pose = self.session.cartesian_target + ee_control = self._handles.get("ee_control") + if pose is not None and ee_control is not None: + self.session.sync_source = "joints" + try: + ee_control.position = (pose.position.x, pose.position.y, pose.position.z) + ee_control.wxyz = ( + pose.orientation.w, + pose.orientation.x, + pose.orientation.y, + pose.orientation.z, + ) + finally: + self.session.sync_source = None + + def _run_operation(self, name: str, operation: Any) -> None: + action = { + "Preview": ActionStatus.PREVIEWING, + "Execute": ActionStatus.EXECUTING, + "Cancel": ActionStatus.CANCELLING, + "Clear Plan": ActionStatus.CLEARING_PLAN, + }.get(name, ActionStatus.IDLE) + + def run() -> None: + with self._lock: + self.session.action_status = action + self._update_gui_state() + try: + operation() + except Exception as e: + with self._lock: + self.session.error = str(e) + self.session.plan_state.status = PlanStatus.FAILED + finally: + with self._lock: + if self.session.action_status == action: + self.session.action_status = ActionStatus.IDLE + self._update_gui_state() + + threading.Thread(target=run, daemon=True).start() + + def _plan(self) -> None: + if ( + self._client is None + or not self._can_plan_for_operation() + or self.session.selected_robot is None + ): + return + robot = self.session.selected_robot + current = self.session.current_joints + target = self.session.joint_target + self.session.plan_state.status = PlanStatus.PLANNING + if target is not None: + names = list((self.session.robot_info or {}).get("joint_names") or []) + ok = bool(self._client.plan_to_joints(self._make_joint_state(names, target), robot)) + elif self.session.cartesian_target is not None: + ok = bool(self._client.plan_to_pose(self.session.cartesian_target, robot)) + else: + ok = False + if ok: + path = self._client.get_planned_path(robot) + self.session.plan_state.status = PlanStatus.FRESH + self.session.plan_state.robot = robot + self.session.plan_state.target_pose = self.session.cartesian_target + self.session.plan_state.target_joints = target + self.session.plan_state.start_joints_snapshot = list(current) if current is not None else None + self.session.plan_state.planned_path = list(path or []) + self._render_plan_path(self.session.plan_state.planned_path) + else: + self.session.plan_state.status = PlanStatus.FAILED + self.session.error = str(self._client.get_error() or "Planning failed") + + def _preview(self) -> None: + if self._client is None or self.session.selected_robot is None: + return + path = list(self._client.get_planned_path(self.session.selected_robot) or []) + self._render_plan_path(path) + if path: + self.session.error = "Previewing planned path in Viser" + self._animate_ghost_path(path, self.panel_config.preview_duration) + else: + self.session.error = "No planned path to preview" + self.session.plan_state.status = PlanStatus.FAILED + + def _execute(self) -> None: + if ( + self._client is None + or self.session.selected_robot is None + or not self._can_execute_for_operation() + ): + self.session.error = "Execute is not available for the current plan" + return + self.session.plan_state.status = PlanStatus.EXECUTING + if bool(self._client.execute(self.session.selected_robot)): + self.session.error = "Execution accepted" + else: + self.session.plan_state.status = PlanStatus.FAILED + self.session.error = str(self._client.get_error() or "Execution failed") + + def _cancel(self) -> None: + if self._client is not None: + self._client.cancel() + + def _clear_plan(self) -> None: + if self._client is not None: + self._client.clear_planned_path() + self.session.plan_state = self.session.plan_state.__class__() + self._render_plan_path([]) + + def _render_plan_path(self, path: Sequence[JointState]) -> None: + if self._server is None: + return + positions = [ + [float(index), waypoint.position[0] if waypoint.position else 0.0, 0.02] + for index, waypoint in enumerate(path) + ] + if "plan_path" in self._handles: + self._handles["plan_path"].remove() + self._handles.pop("plan_path", None) + if len(positions) >= 2: + self._handles["plan_path"] = self._server.scene.add_line_segments( + "/plans/path", + points=[[start, end] for start, end in zip(positions[:-1], positions[1:], strict=False)], + colors=(80, 180, 255), + ) + + def _animate_ghost_path(self, path: Sequence[JointState], duration: float) -> None: + robot = self.session.selected_robot + if robot is None or not path: + return + ghost = self._urdfs.get(f"{robot}:ghost") + if ghost is None: + return + frames = self._interpolate_joint_path(path, duration, self.panel_config.preview_fps) + step_delay = duration / max(len(frames) - 1, 1) if duration > 0.0 else 0.0 + for joints in frames: + self._set_urdf_joints(ghost, joints) + time.sleep(step_delay) + + def _interpolate_joint_path( + self, path: Sequence[JointState], duration: float, fps: float + ) -> list[list[float]]: + waypoints = [list(waypoint.position) for waypoint in path if waypoint.position] + if not waypoints: + return [] + if len(waypoints) == 1 or duration <= 0.0: + return [waypoints[-1]] + frame_count = max(int(duration * max(fps, 1.0)) + 1, len(waypoints)) + segment_count = len(waypoints) - 1 + frames: list[list[float]] = [] + for frame_index in range(frame_count): + path_t = frame_index / max(frame_count - 1, 1) + scaled = path_t * segment_count + segment_index = min(int(scaled), segment_count - 1) + local_t = scaled - segment_index + start = waypoints[segment_index] + end = waypoints[segment_index + 1] + if len(start) != len(end): + continue + frames.append( + [ + start_value + (end_value - start_value) * local_t + for start_value, end_value in zip(start, end, strict=False) + ] + ) + if frames[-1] != waypoints[-1]: + frames.append(waypoints[-1]) + return frames + + def _update_gui_state(self) -> None: + if not self._handles: + return + self._handles["status"].value = self.session.module_state + self._handles["error"].value = self.session.error + self._handles["feasibility"].value = self.session.feasibility.status.value + self._set_target_visual_state(self.session.feasibility.status == FeasibilityStatus.FEASIBLE) + self._handles["plan"].disabled = not self.session.can_plan() + self._handles["preview"].disabled = not self.session.can_preview() + self._handles["execute"].disabled = not self._can_execute_from_ui() + + def _snapshot(self) -> dict[str, Any]: + return { + "connected": self.session.connected, + "selected_robot": self.session.selected_robot, + "module_state": self.session.module_state, + "runtime": self.session.runtime.value, + "backend_status": self.session.backend_status.value, + "target_status": self.session.target_status.value, + "action_status": self.session.action_status.value, + "error": self.session.error, + "feasibility": self.session.feasibility.status.value, + "plan_status": self.session.plan_state.status.value, + "can_plan": self.session.can_plan(), + "can_execute": self._can_execute_from_ui(), + } + + def _pose_from_rpc(self, pose: Any) -> Pose | None: + if pose is None: + return None + if isinstance(pose, Pose): + return pose + return self._make_pose(pose.position, pose.orientation) + + def _make_pose(self, position: Any, orientation: Any) -> Pose: + pose = Pose.__new__(Pose) + pose.position = Vector3(position) + pose.orientation = Quaternion(orientation) + return pose + + def _make_joint_state(self, names: Sequence[str], positions: Sequence[float]) -> JointState: + joint_state = JointState.__new__(JointState) + joint_state.ts = time.time() + joint_state.frame_id = "" + joint_state.name = list(names) + joint_state.position = list(positions) + joint_state.velocity = [] + joint_state.effort = [] + return joint_state + + def _prepared_urdf_path(self, info: dict[str, Any]) -> Path: + package_paths = { + package: Path(path) for package, path in (info.get("package_paths") or {}).items() + } + return Path( + prepare_urdf_for_drake( + Path(str(info["model_path"])), + package_paths=package_paths, + xacro_args={str(key): str(value) for key, value in (info.get("xacro_args") or {}).items()}, + ) + ) + + def _set_urdf_joints(self, urdf: Any, joints: Sequence[float]) -> None: + joint_names = list((self.session.robot_info or {}).get("joint_names") or []) + named_joints = self._viser_joint_configuration(urdf, joint_names, joints) + if not named_joints: + return + if hasattr(urdf, "update_cfg"): + urdf.update_cfg(named_joints) + elif hasattr(urdf, "update_configuration"): + urdf.update_configuration(named_joints) + + def _viser_joint_configuration( + self, urdf: Any, joint_names: Sequence[str], joints: Sequence[float] + ) -> list[float]: + allowed_names = list(self._viser_actuated_joint_names(urdf)) + if not allowed_names: + return [] + values_by_name: dict[str, float] = {} + for name, value in zip(joint_names, joints, strict=False): + values_by_name[name] = float(value) + values_by_name[name.rsplit("/", 1)[-1]] = float(value) + return [values_by_name.get(name, 0.0) for name in allowed_names] + + def _viser_actuated_joint_names(self, urdf: Any) -> tuple[str, ...]: + wrapped_urdf = getattr(urdf, "_urdf", None) + names = getattr(wrapped_urdf, "actuated_joint_names", None) + if names is not None: + return tuple(names) + joint_map = getattr(wrapped_urdf, "joint_map", None) + if isinstance(joint_map, dict): + return tuple(joint_map) + return () + + def _set_target_visual_state(self, feasible: bool) -> None: + color = (0, 180, 255) if feasible else (255, 40, 40) + mesh_color = GOAL_ROBOT_FEASIBLE_COLOR if feasible else GOAL_ROBOT_INFEASIBLE_COLOR + mesh_opacity = GOAL_ROBOT_FEASIBLE_OPACITY if feasible else GOAL_ROBOT_INFEASIBLE_OPACITY + robot = self.session.selected_robot + handles = [self._handles.get("ee_control")] + if robot is not None: + ghost = self._urdfs.get(f"{robot}:ghost") + handles.append(ghost) + self._set_urdf_mesh_material(ghost, mesh_color, mesh_opacity) + for handle in handles: + if handle is None: + continue + for attr in ("color", "material_color"): + if hasattr(handle, attr): + try: + setattr(handle, attr, color) + except Exception: + pass + + def _set_urdf_mesh_material( + self, urdf: Any | None, color: tuple[int, int, int], opacity: float + ) -> None: + if urdf is None: + return + for mesh in getattr(urdf, "_meshes", ()): + for attr in ("color", "material_color"): + if hasattr(mesh, attr): + try: + setattr(mesh, attr, color) + except Exception: + pass + if hasattr(mesh, "opacity"): + try: + mesh.opacity = opacity + except Exception: + pass + + def _can_execute_from_ui(self, require_no_operation: bool = True) -> bool: + if not self.panel_config.allow_plan_execute: + return False + if require_no_operation and not self.session.can_execute(self.panel_config.current_match_tolerance): + return False + if not require_no_operation and not self._can_execute_for_operation(): + return False + return True + + def _can_plan_for_operation(self) -> bool: + return ( + self.session.runtime == PanelRuntime.RUNNING + and self.session.backend_status == BackendConnectionStatus.READY + and self.session.selected_robot is not None + and self.session.target_status == TargetStatus.FEASIBLE + and self.session.manipulation_state in {"IDLE", "COMPLETED"} + ) + + def _can_execute_for_operation(self) -> bool: + previous_action = self.session.action_status + try: + self.session.action_status = ActionStatus.IDLE + return self.session.can_execute(self.panel_config.current_match_tolerance) + finally: + self.session.action_status = previous_action + + +ViserManipulationPanelModule.__annotations__["config"] = ViserManipulationPanelConfig +viser_manipulation_panel = ViserManipulationPanelModule.blueprint diff --git a/dimos/manipulation/viser_panel/state.py b/dimos/manipulation/viser_panel/state.py new file mode 100644 index 0000000000..4ead09ce18 --- /dev/null +++ b/dimos/manipulation/viser_panel/state.py @@ -0,0 +1,245 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +import queue +import threading +import time +from typing import Any, Callable, Literal + +from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.sensor_msgs.JointState import JointState + + +class FeasibilityStatus(str, Enum): + UNKNOWN = "unknown" + FEASIBLE = "feasible" + IK_FAILED = "ik_failed" + COLLISION = "collision" + INVALID = "invalid" + + +class PanelRuntime(str, Enum): + STOPPED = "stopped" + STARTING = "starting" + RUNNING = "running" + STOPPING = "stopping" + FAILED = "failed" + + +class BackendConnectionStatus(str, Enum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + WAITING_FOR_ROBOT = "waiting_for_robot" + READY = "ready" + + +class TargetStatus(str, Enum): + EMPTY = "empty" + DIRTY = "dirty" + CHECKING = "checking" + FEASIBLE = "feasible" + INFEASIBLE = "infeasible" + + +class PlanStatus(str, Enum): + NONE = "none" + PLANNING = "planning" + FRESH = "fresh" + STALE = "stale" + EXECUTING = "executing" + FAILED = "failed" + + +class ActionStatus(str, Enum): + IDLE = "idle" + PREVIEWING = "previewing" + EXECUTING = "executing" + CANCELLING = "cancelling" + CLEARING_PLAN = "clearing_plan" + + +PreviewSource = Literal["cartesian", "joints"] + + +@dataclass +class FeasibilityState: + status: FeasibilityStatus = FeasibilityStatus.UNKNOWN + message: str = "" + sequence_id: int = 0 + + +@dataclass +class PanelPlanState: + status: PlanStatus = PlanStatus.NONE + robot: str | None = None + target_pose: Pose | None = None + target_joints: list[float] | None = None + start_joints_snapshot: list[float] | None = None + planned_path: list[JointState] | None = None + + +@dataclass +class PanelSession: + selected_robot: str | None = None + runtime: PanelRuntime = PanelRuntime.STOPPED + backend_status: BackendConnectionStatus = BackendConnectionStatus.DISCONNECTED + target_status: TargetStatus = TargetStatus.EMPTY + action_status: ActionStatus = ActionStatus.IDLE + manipulation_state: str = "DISCONNECTED" + robot_info: dict[str, Any] | None = None + current_joints: list[float] | None = None + current_ee_pose: Pose | None = None + cartesian_target: Pose | None = None + joint_target: list[float] | None = None + feasibility: FeasibilityState = field(default_factory=FeasibilityState) + latest_sequence_id: int = 0 + sync_source: PreviewSource | None = None + plan_state: PanelPlanState = field(default_factory=PanelPlanState) + error: str = "" + + def next_sequence_id(self) -> int: + self.latest_sequence_id += 1 + self.feasibility = FeasibilityState(sequence_id=self.latest_sequence_id) + self.target_status = TargetStatus.CHECKING + self.mark_plan_stale() + return self.latest_sequence_id + + def mark_plan_stale(self) -> None: + if self.plan_state.status == PlanStatus.FRESH: + self.plan_state.status = PlanStatus.STALE + + def can_plan(self) -> bool: + return ( + self.runtime == PanelRuntime.RUNNING + and self.backend_status == BackendConnectionStatus.READY + and self.selected_robot is not None + and self.action_status == ActionStatus.IDLE + and self.target_status == TargetStatus.FEASIBLE + and self.manipulation_state in {"IDLE", "COMPLETED"} + and self.plan_state.status != PlanStatus.PLANNING + ) + + def can_preview(self) -> bool: + return ( + self.runtime == PanelRuntime.RUNNING + and self.backend_status == BackendConnectionStatus.READY + and self.action_status == ActionStatus.IDLE + and self.plan_state.status == PlanStatus.FRESH + ) + + def can_cancel(self) -> bool: + return self.action_status in {ActionStatus.PREVIEWING, ActionStatus.EXECUTING} or ( + self.manipulation_state == "EXECUTING" + ) + + def can_execute(self, current_tolerance: float) -> bool: + plan = self.plan_state + if not ( + self.runtime == PanelRuntime.RUNNING + and self.backend_status == BackendConnectionStatus.READY + and self.action_status == ActionStatus.IDLE + and self.target_status == TargetStatus.FEASIBLE + and self.manipulation_state in {"IDLE", "COMPLETED"} + and plan.status == PlanStatus.FRESH + and plan.robot == self.selected_robot + and plan.start_joints_snapshot is not None + and self.current_joints is not None + ): + return False + if len(plan.start_joints_snapshot) != len(self.current_joints): + return False + return all( + abs(expected - current) <= current_tolerance + for expected, current in zip(plan.start_joints_snapshot, self.current_joints, strict=False) + ) + + @property + def connected(self) -> bool: + return self.backend_status in { + BackendConnectionStatus.WAITING_FOR_ROBOT, + BackendConnectionStatus.READY, + } + + @property + def module_state(self) -> str: + if self.backend_status == BackendConnectionStatus.DISCONNECTED: + return "DISCONNECTED" + if self.backend_status == BackendConnectionStatus.WAITING_FOR_ROBOT: + return "WAITING_FOR_ROBOT" + return self.manipulation_state + + +@dataclass +class PreviewRequest: + sequence_id: int + source: PreviewSource + robot_name: str + pose: Pose | None = None + joints: JointState | None = None + + +class PreviewWorker: + def __init__( + self, + handler: Callable[[PreviewRequest], dict[str, Any]], + apply_result: Callable[[PreviewRequest, dict[str, Any]], None], + debounce_seconds: float, + ) -> None: + self._handler = handler + self._apply_result = apply_result + self._debounce_seconds = debounce_seconds + self._requests: queue.Queue[PreviewRequest] = queue.Queue(maxsize=1) + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread is not None: + return + self._thread = threading.Thread(target=self._run, name="ViserPreviewWorker", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + + def submit(self, request: PreviewRequest) -> None: + while True: + try: + self._requests.get_nowait() + except queue.Empty: + break + try: + self._requests.put_nowait(request) + except queue.Full: + pass + + def _run(self) -> None: + while not self._stop_event.is_set(): + try: + request = self._requests.get(timeout=0.1) + except queue.Empty: + continue + time.sleep(self._debounce_seconds) + while True: + try: + request = self._requests.get_nowait() + except queue.Empty: + break + result = self._handler(request) + self._apply_result(request, result) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index efd0f563ba..0ed8da5b0c 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -123,6 +123,7 @@ "xarm6-planner-only": "dimos.manipulation.blueprints:xarm6_planner_only", "xarm7-planner-coordinator": "dimos.manipulation.blueprints:xarm7_planner_coordinator", "xarm7-planner-coordinator-agent": "dimos.manipulation.blueprints:xarm7_planner_coordinator_agent", + "xarm7-viser-panel-mock": "dimos.manipulation.blueprints:xarm7_viser_panel_mock", } @@ -224,6 +225,7 @@ "unitree-skill-container": "dimos.robot.unitree.unitree_skill_container.UnitreeSkillContainer", "unity-bridge-module": "dimos.simulation.unity.module.UnityBridgeModule", "video-arm-teleop-module": "dimos.teleop.quest.quest_extensions.VideoArmTeleopModule", + "viser-manipulation-panel-module": "dimos.manipulation.viser_panel.module.ViserManipulationPanelModule", "vlm-agent": "dimos.agents.vlm_agent.VLMAgent", "vlm-stream-tester": "dimos.agents.vlm_stream_tester.VlmStreamTester", "voxel-grid-mapper": "dimos.mapping.voxels.VoxelGridMapper", diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 79cca3c834..3ed291b629 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -59,6 +59,51 @@ preview() # Preview in Meshcat execute() # Execute via coordinator ``` +### Viser Operator Panel (optional) + +The Viser manipulation panel is a companion operator UI for a running +`ManipulationModule`. It is optional and installed separately from the base +manipulation extra: + +```bash +uv sync --extra manipulation-viser +``` + +This extra installs Viser with URDF support, including `yourdfpy`, which is +required to render robot models with `ViserUrdf`. + +Start a manipulation stack first, then launch the panel in another terminal: + +```bash +python -m dimos.manipulation.viser_panel --host 127.0.0.1 --port 8095 +``` + +For a single-command mock test stack with no hardware, run: + +```bash +uv run --extra manipulation-viser dimos run xarm7-viser-panel-mock +``` + +Open `http://127.0.0.1:8095` unless `--open-browser` is passed. The panel shows +a disconnected state and keeps planning/execution controls disabled until it can +reach a compatible `ManipulationModule` over RPC. + +The panel keeps the live robot and target visible at the same time: + +- the current robot reflects live joint state; +- the target ghost follows the current target and turns red when infeasible; +- the end-effector transform gizmo is separate from both robot meshes; +- Cartesian target movement and joint sliders are both visible and stay + synchronized through IK/FK preview RPCs; +- Current, Init, and Home presets apply once, then the selector returns to its + neutral state. + +Planning, local Viser preview, execution, cancel, and clear plan use public +`ManipulationModule` RPCs. Hardware execution is opt-in: pass `--allow-execute` +and verify the target and plan are still fresh before Execute is enabled. Do not +execute on hardware until the same operation has been checked in mock or +simulation and Cancel remains visible. + ### Perception + Agent ```bash @@ -92,6 +137,7 @@ KeyboardTeleopModule ──→ ControlCoordinator ──→ ManipulationModule | `keyboard-teleop-xarm7` | XArm7 7-DOF keyboard teleop with Drake viz | | `xarm6-planner-only` | XArm6 standalone planner (no coordinator) | | `xarm7-planner-coordinator` | XArm7 planner with coordinator integration | +| `xarm7-viser-panel-mock` | Mock XArm7 planner + coordinator + Viser operator panel | | `dual-xarm6-planner` | Dual XArm6 planning | | `xarm-perception` | XArm7 + RealSense camera for perception | | `xarm-perception-agent` | XArm7 perception + LLM agent | @@ -114,6 +160,7 @@ KeyboardTeleopModule ──→ ControlCoordinator ──→ ManipulationModule | File | Description | |------|-------------| | [`manipulation_module.py`](/dimos/manipulation/manipulation_module.py) | Main module (RPC interface, state machine) | +| [`viser_panel/module.py`](/dimos/manipulation/viser_panel/module.py) | Optional Viser operator panel over the manipulation RPC interface | | [`manipulation/blueprints.py`](/dimos/manipulation/blueprints.py) | Planner and perception blueprints | | [`robot/manipulators/a750/blueprints.py`](/dimos/robot/manipulators/a750/blueprints.py) | A-750 keyboard teleop blueprint | | [`robot/manipulators/piper/blueprints.py`](/dimos/robot/manipulators/piper/blueprints.py) | Piper keyboard teleop blueprint | diff --git a/openspec/changes/add-viser-manipulation-panel/tasks.md b/openspec/changes/add-viser-manipulation-panel/tasks.md index 354d54856d..3d31349aae 100644 --- a/openspec/changes/add-viser-manipulation-panel/tasks.md +++ b/openspec/changes/add-viser-manipulation-panel/tasks.md @@ -1,40 +1,40 @@ ## 1. Implementation -- [ ] 1.1 Add optional Viser dependencies to the project dependency configuration without making existing manipulation installs require Viser. -- [ ] 1.2 Extend `ManipulationModule.get_robot_info()` with panel-needed metadata: model path, base pose, package paths, xacro args, and joint limits when available. -- [ ] 1.3 Add `ManipulationModule.get_planned_path(robot_name=None)` to return the current stored planned joint path through the public RPC surface. -- [ ] 1.4 Add `ManipulationModule.solve_ik_preview(pose, robot_name=None)` to run the configured `KinematicsSpec` solver without storing paths, previewing, executing, or publishing commands. -- [ ] 1.5 Add `ManipulationModule.solve_fk_preview(joints, robot_name=None)` to return the candidate end-effector pose and feasibility data for joint-slider targets without planning or executing. -- [ ] 1.6 Add focused unit coverage for the new manipulation RPCs, including no-plan side effects for IK/FK preview and enriched `get_robot_info()` response shape. -- [ ] 1.7 Create the optional Viser panel package/module under `dimos/manipulation/viser_panel/` with a dedicated worker module that hosts a `viser.ViserServer`. -- [ ] 1.8 Implement panel RPC connection handling to discover a running `ManipulationModule`, show a disconnected state when unavailable, and keep plan/execution controls disabled while disconnected. -- [ ] 1.9 Implement current robot rendering as a solid live-state `ViserUrdf` and target ghost rendering as a visually distinct translucent or color-coded target `ViserUrdf`. -- [ ] 1.10 Implement the Viser end-effector target transform control as a separate draggable gizmo, visually distinct from both current robot and target ghost. -- [ ] 1.11 Implement target preset controls for Current, Init, and Home targets, applying presets once and returning the selector to a neutral state. -- [ ] 1.12 Implement always-visible synchronized Cartesian and joint controls, including IK-driven slider updates and FK-driven target-gizmo updates. -- [ ] 1.13 Implement the non-blocking preview worker: debounce/coalesce IK/FK/collision preview requests, tag them with sequence IDs, and ignore stale results. -- [ ] 1.14 Implement automatic feasibility display and gating so infeasible targets turn target controls/ghost red and disable Plan, Preview, and Execute. -- [ ] 1.15 Implement Plan, Preview, Execute, Cancel, Reset Fault, and Clear Plan UI actions through public `ManipulationModule` RPCs with stale-plan gating. -- [ ] 1.16 Implement hardware execution confirmation and ensure Execute remains disabled unless the plan is fresh, target is feasible, selected robot matches, and current state matches the plan start constraints. -- [ ] 1.17 Add the supported launch surface: companion `python -m dimos.manipulation.viser_panel` entrypoint, optional blueprint, or both as finalized during implementation. -- [ ] 1.18 If a new runnable blueprint is added, regenerate and verify the blueprint registry with `pytest dimos/robot/test_all_blueprints_generation.py`. +- [x] 1.1 Add optional Viser dependencies to the project dependency configuration without making existing manipulation installs require Viser. +- [x] 1.2 Extend `ManipulationModule.get_robot_info()` with panel-needed metadata: model path, base pose, package paths, xacro args, and joint limits when available. +- [x] 1.3 Add `ManipulationModule.get_planned_path(robot_name=None)` to return the current stored planned joint path through the public RPC surface. +- [x] 1.4 Add `ManipulationModule.solve_ik_preview(pose, robot_name=None)` to run the configured `KinematicsSpec` solver without storing paths, previewing, executing, or publishing commands. +- [x] 1.5 Add `ManipulationModule.solve_fk_preview(joints, robot_name=None)` to return the candidate end-effector pose and feasibility data for joint-slider targets without planning or executing. +- [x] 1.6 Add focused unit coverage for the new manipulation RPCs, including no-plan side effects for IK/FK preview and enriched `get_robot_info()` response shape. +- [x] 1.7 Create the optional Viser panel package/module under `dimos/manipulation/viser_panel/` with a dedicated worker module that hosts a `viser.ViserServer`. +- [x] 1.8 Implement panel RPC connection handling to discover a running `ManipulationModule`, show a disconnected state when unavailable, and keep plan/execution controls disabled while disconnected. +- [x] 1.9 Implement current robot rendering as a solid live-state `ViserUrdf` and target ghost rendering as a visually distinct translucent or color-coded target `ViserUrdf`. +- [x] 1.10 Implement the Viser end-effector target transform control as a separate draggable gizmo, visually distinct from both current robot and target ghost. +- [x] 1.11 Implement target preset controls for Current, Init, and Home targets, applying presets once and returning the selector to a neutral state. +- [x] 1.12 Implement always-visible synchronized Cartesian and joint controls, including IK-driven slider updates and FK-driven target-gizmo updates. +- [x] 1.13 Implement the non-blocking preview worker: debounce/coalesce IK/FK/collision preview requests, tag them with sequence IDs, and ignore stale results. +- [x] 1.14 Implement automatic feasibility display and gating so infeasible targets turn target controls/ghost red and disable Plan, Preview, and Execute. +- [x] 1.15 Implement Plan, local Preview, Execute, Cancel, and Clear Plan UI actions through public `ManipulationModule` RPCs with stale-plan gating. +- [x] 1.16 Ensure Execute remains disabled unless the plan is fresh, target is feasible, selected robot matches, and current state matches the plan start constraints. +- [x] 1.17 Add the supported launch surface: companion `python -m dimos.manipulation.viser_panel` entrypoint, optional blueprint, or both as finalized during implementation. +- [x] 1.18 If a new runnable blueprint is added, regenerate and verify the blueprint registry with `pytest dimos/robot/test_all_blueprints_generation.py`. ## 2. Documentation -- [ ] 2.1 Update manipulation user docs under `docs/capabilities/manipulation/` or the canonical manipulation guide with install, launch, UI workflow, visual semantics, and safety notes. -- [ ] 2.2 Document the optional Viser dependency extra and supported launch command once implementation finalizes the command or blueprint name. -- [ ] 2.3 Add contributor notes only if implementation introduces reusable extension points, such as named target presets or preview RPC response schemas. -- [ ] 2.4 Add coding-agent guidance only if implementation establishes a reusable Viser module pattern; otherwise explicitly leave coding-agent docs unchanged. +- [x] 2.1 Update manipulation user docs under `docs/capabilities/manipulation/` or the canonical manipulation guide with install, launch, UI workflow, visual semantics, and safety notes. +- [x] 2.2 Document the optional Viser dependency extra and supported launch command once implementation finalizes the command or blueprint name. +- [x] 2.3 Add contributor notes only if implementation introduces reusable extension points, such as named target presets or preview RPC response schemas. No separate contributor notes were needed. +- [x] 2.4 Add coding-agent guidance only if implementation establishes a reusable Viser module pattern; otherwise explicitly leave coding-agent docs unchanged. ## 3. Verification -- [ ] 3.1 Run `openspec validate add-viser-manipulation-panel`. -- [ ] 3.2 Run focused tests for manipulation RPC changes, including existing manipulation unit tests and any new panel/RPC tests. -- [ ] 3.3 Run focused tests or a lightweight driver for the Viser panel state model, target preset application, async preview worker, stale-result dropping, and execution gating. -- [ ] 3.4 Run `pytest dimos/robot/test_all_blueprints_generation.py` if any runnable blueprint or generated blueprint input changes. -- [ ] 3.5 Run type/lint checks required for the touched Python modules. -- [ ] 3.6 Run documentation validation for changed docs, including link validation where available and `md-babel-py run ` for any changed docs with executable Python snippets. -- [ ] 3.7 Manually QA the static alignment mock or equivalent rendered panel surface to verify solid current robot, translucent target ghost, separate EEF gizmo, target presets, sliders, feasibility state, and plan controls are understandable. -- [ ] 3.8 Manually QA the running panel against a mock or simulation manipulation stack: open the panel, select a robot, apply Current/Init/Home presets, drag the EEF target, move joint sliders, observe async feasibility updates, plan, preview, execute when supported, cancel, reset, and verify stale-plan gating. -- [ ] 3.9 Manually QA the disconnected/faulted states: start the panel without a compatible manipulation stack and with a faulted manipulation state, then verify unavailable/fault messages and disabled planning/execution controls. -- [ ] 3.10 For hardware validation, follow the project hardware safety procedure: require explicit confirmation before Execute, verify Cancel remains visible, and do not execute on real hardware until mock/sim QA passes. +- [x] 3.1 Run `openspec validate add-viser-manipulation-panel`. +- [x] 3.2 Run focused tests for manipulation RPC changes, including existing manipulation unit tests and any new panel/RPC tests. +- [x] 3.3 Run focused tests or a lightweight driver for the Viser panel state model, target preset application, async preview worker, stale-result dropping, and execution gating. +- [x] 3.4 Run `pytest dimos/robot/test_all_blueprints_generation.py` if any runnable blueprint or generated blueprint input changes. +- [x] 3.5 Run type/lint checks required for the touched Python modules. +- [x] 3.6 Run documentation validation for changed docs, including link validation where available and `md-babel-py run ` for any changed docs with executable Python snippets. +- [x] 3.7 Manually QA the static alignment mock or equivalent rendered panel surface to verify solid current robot, translucent target ghost, separate EEF gizmo, target presets, sliders, feasibility state, and plan controls are understandable. +- [x] 3.8 Manually QA the running panel against a mock or simulation manipulation stack: open the panel, select a robot, apply Current/Init/Home presets, drag the EEF target, move joint sliders, observe async feasibility updates, plan, preview, execute when supported, cancel, reset, and verify stale-plan gating. +- [x] 3.9 Manually QA the disconnected/faulted states: start the panel without a compatible manipulation stack and with a faulted manipulation state, then verify unavailable/fault messages and disabled planning/execution controls. +- [x] 3.10 For hardware validation, follow the project hardware safety procedure: require explicit confirmation before Execute, verify Cancel remains visible, and do not execute on real hardware until mock/sim QA passes. diff --git a/pyproject.toml b/pyproject.toml index be01eb012d..5dab60c9bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -267,6 +267,11 @@ manipulation = [ "pyyaml>=6.0", ] +manipulation-viser = [ + "dimos[manipulation]", + "viser[urdf]>=1.0.29", +] + cpu = [ # CPU inference backends @@ -328,7 +333,7 @@ apriltag = [ ] all = [ - "dimos[agents,apriltag,base,cpu,cuda,drone,manipulation,misc,perception,psql,sim,unitree,visualization,web]", + "dimos[agents,apriltag,base,cpu,cuda,drone,manipulation,manipulation-viser,misc,perception,psql,sim,unitree,visualization,web]", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index e9e2d61c0f..18608b447a 100644 --- a/uv.lock +++ b/uv.lock @@ -1982,6 +1982,7 @@ all = [ { name = "ultralytics" }, { name = "unitree-webrtc-connect-leshy" }, { name = "uvicorn" }, + { name = "viser", extra = ["urdf"] }, { name = "xacro" }, { name = "xarm-python-sdk" }, { name = "xformers", marker = "platform_machine == 'x86_64'" }, @@ -2053,6 +2054,22 @@ manipulation = [ { name = "xacro" }, { name = "xarm-python-sdk" }, ] +manipulation-viser = [ + { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "drake", version = "1.45.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "drake", version = "1.49.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "kaleido" }, + { name = "matplotlib" }, + { name = "piper-sdk" }, + { name = "plotly" }, + { name = "pycollada" }, + { name = "pyrealsense2-extended", marker = "sys_platform != 'darwin'" }, + { name = "pyyaml" }, + { name = "trimesh" }, + { name = "viser", extra = ["urdf"] }, + { name = "xacro" }, + { name = "xarm-python-sdk" }, +] mapping = [ { name = "gtsam-extended" }, ] @@ -2343,9 +2360,10 @@ requires-dist = [ { name = "cupy-cuda12x", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = "==13.6.0" }, { name = "cyclonedds", marker = "extra == 'dds'", specifier = ">=0.10.5" }, { name = "cyclonedds", marker = "extra == 'unitree-dds'", specifier = ">=0.10.5" }, - { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "manipulation", "misc", "perception", "psql", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, + { name = "dimos", extras = ["agents", "apriltag", "base", "cpu", "cuda", "drone", "manipulation", "manipulation-viser", "misc", "perception", "psql", "sim", "unitree", "visualization", "web"], marker = "extra == 'all'" }, { name = "dimos", extras = ["agents", "web", "perception", "visualization"], marker = "extra == 'base'" }, { name = "dimos", extras = ["base", "mapping"], marker = "extra == 'unitree'" }, + { name = "dimos", extras = ["manipulation"], marker = "extra == 'manipulation-viser'" }, { name = "dimos", extras = ["unitree"], marker = "extra == 'unitree-dds'" }, { name = "dimos-lcm", specifier = ">=0.1.3" }, { name = "dimos-viewer", specifier = "==0.32.0a1" }, @@ -2447,13 +2465,14 @@ requires-dist = [ { name = "unitree-sdk2py-dimos", marker = "extra == 'unitree-dds'", specifier = ">=1.0.2" }, { name = "unitree-webrtc-connect-leshy", marker = "extra == 'unitree'", specifier = ">=2.0.7" }, { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.34.0" }, + { name = "viser", extras = ["urdf"], marker = "extra == 'manipulation-viser'", specifier = ">=1.0.29" }, { name = "xacro", marker = "extra == 'manipulation'" }, { name = "xarm-python-sdk", marker = "extra == 'manipulation'", specifier = ">=1.17.0" }, { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, { name = "xformers", marker = "platform_machine == 'x86_64' and extra == 'cuda'", specifier = ">=0.0.20" }, { name = "yapf", marker = "extra == 'misc'", specifier = "==0.40.2" }, ] -provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "cpu", "cuda", "psql", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] +provides-extras = ["misc", "visualization", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "manipulation-viser", "cpu", "cuda", "psql", "sim", "mapping", "drone", "dds", "base", "apriltag", "all"] [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] @@ -2845,6 +2864,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" }, ] +[[package]] +name = "embreex" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/89/692237d6a60f58f87f7bffdfe8c30008efd8811411e692f96d2cb1df0818/embreex-4.4.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:b685ed766ef86dbd302ae9e92ab20156007c31cbd2ea5465542b50eea99c6da5", size = 5098460, upload-time = "2026-04-22T19:46:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/14/fe/cedef264696768248f8139c569e10796ffb76627383adb5a60e1eaf28a20/embreex-4.4.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:daf8b1848798523d7d71cc22f2610401ab02ec93ea063f9cbb90dcb9abda2ccf", size = 13215487, upload-time = "2026-04-22T19:46:32.769Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/460b7f79689ac5e6ceb3ec2a1194176a0a66d6c4e010dae68ba899a1c927/embreex-4.4.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f548cbebc550624ef08530ed9dcd147eeddcd181fd8f32cf3378800b39b21034", size = 14438524, upload-time = "2026-04-22T19:46:35.533Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c2/aef3606d7ca2b4d2d18e57c8f65762b94d253e678a05c946649bb1913f5e/embreex-4.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:58921ef7ad488dbd514b3053e7c4a9fdcd2f7008426b3185b9f1bd394c608edd", size = 13119003, upload-time = "2026-04-22T19:46:38.442Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/84b02da29deac092349b12fae21a3cbf4b64104ef78e0d0c1bca5d268112/embreex-4.4.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b2e69b777c0b7878e13ad8c31f4fecaddb5cd8633a5814c1ac11da2efe1065dc", size = 5098210, upload-time = "2026-04-22T19:46:41.149Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/1bcdcb8cb09713d40c3cc6d303a4e456113df859dacb28a1b7af8a19a718/embreex-4.4.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:96a187753f578cb685051f8fd679dc7986f887fcb922bb81a9924f5b89e941d8", size = 13214875, upload-time = "2026-04-22T19:46:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/bed6f27a57b89ad028c8ec5adf6f1877a1ef92d983d703abb7a70717f0d9/embreex-4.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8415d14c8956afec7eb05749f1e0bf396bb51efd566054ca169e10e4089e7bb7", size = 14474358, upload-time = "2026-04-22T19:46:46.056Z" }, + { url = "https://files.pythonhosted.org/packages/21/f4/c3515fde7bacab245673988398ef40928ce0b9fb54e2b51e90a4a4535479/embreex-4.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfa54fb71cbb3a41c2366cabd09bb2dbbc8700843872f2c8655a3215a73459a7", size = 13119132, upload-time = "2026-04-22T19:46:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/b3413ba4f1c17f2374cc39b5b86404221aedc632c8b6cdb484697eeffcd8/embreex-4.4.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:3bb261a25c21d50bc7f046401e7256e59e43a500b229fb2a00b6393c61e5293d", size = 5097518, upload-time = "2026-04-22T19:46:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/8cc0960cc0f2d60d581869a66c8013e1bf1c73bf5bf9609bd8aa79e0f721/embreex-4.4.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:8e93bce7cf905365117dea2d0726d19262c88a010044d00631db6bb7dc145612", size = 13214768, upload-time = "2026-04-22T19:46:54.088Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/05a5b4d49749602b12e13d1871f8e6d1fe6db806eda75f6f57bb4f1acf6f/embreex-4.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb0872f5bc231f465b840b122847acbaf468ac48f49bfaff127c5347ec0db94f", size = 14529899, upload-time = "2026-04-22T19:46:56.824Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/625e035f3433071c91de07e66265a261be7bb708367f785000f93d7a992a/embreex-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6c092ee1adf5c48b7430c7ae3902943863745e54b4aef4327ecb3473e0a299d7", size = 13119305, upload-time = "2026-04-22T19:46:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/9b/58/56b0ef72a128da86892f89f61f0c91bf60c6ea20c2ab952cb88e05c4e89b/embreex-4.4.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:096cffa6be78966b0214b26f09fc22cb5ebcfbd4fe9fdc27caf6400869058a51", size = 5094712, upload-time = "2026-04-22T19:47:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/36/d2/9bbde8d9d520c2a7bcad892631165b9676d9dcbde381f25bfda06d0f6e42/embreex-4.4.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:078bae4dcebb2a64c8dde6b3c178f258f966c4514e265608620033a6c907e21b", size = 13212105, upload-time = "2026-04-22T19:47:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/3e5ca5dea1c2c5b4604f3bedb67ea4beaa465398d9c04d8124ca3d657b05/embreex-4.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd305875e2cd7c51004a423fe7da9881fa266fa4a50e61dd546978e10f020e66", size = 14486730, upload-time = "2026-04-22T19:47:07.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/00/26741306e557129d398744601cd9bca4069d52cebe146ba99e535f9f2c65/embreex-4.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:433de9063843804d70bb3c1680619bb28bc6403b79d3d94560ec9acc3df96eda", size = 13117246, upload-time = "2026-04-22T19:47:10.08Z" }, + { url = "https://files.pythonhosted.org/packages/62/70/4ed53f33ab0f0dca32f8017b903f5dcad25df32f7068854e529902aa97da/embreex-4.4.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:1ef2650f1ced083d433d46389a187fd57f7bf795106ff79a2671e95f2e6a4c4c", size = 5095722, upload-time = "2026-04-22T19:47:12.774Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ac/638a6b4a6cb67125a3c2676a108fb73d75e67b3ce3813f25b6056d0b77cd/embreex-4.4.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:ab3c50ecd3aa6c39491928d975b00c9f5eeaa1b39b74bab87170c17e2df1bfde", size = 13212439, upload-time = "2026-04-22T19:47:15.009Z" }, + { url = "https://files.pythonhosted.org/packages/39/1c/567194e9f5bdbb5099144dae3202452821319ff348e328b50c0fbacc3828/embreex-4.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b0bd41b10dc2f1ad4995faef08d924d5f33a5d47afb97ba5801c820f305db6d", size = 14480771, upload-time = "2026-04-22T19:47:17.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/8d/a46fb6ca4cc898e8d06449a4b46a8c22a28971f1e6d9bb6c99459bbb96d1/embreex-4.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:80f938291832ab11dc7a604d609bce2587d574b4fe862be91d117562629f1b94", size = 13373573, upload-time = "2026-04-22T19:47:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/d2/71/4e21eff598840060229cc502034737d04d2a42dee8118c7d5acf6ee6f28a/embreex-4.4.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:670470a53fdde1aeb52c4d8db600f17bc0dc7c34dc6f90233909db9c6fe1e88a", size = 5102482, upload-time = "2026-04-22T19:47:23.67Z" }, + { url = "https://files.pythonhosted.org/packages/61/5e/da7c1448c209f406a5b43a8feb07489e8652a64c48450b5642d3f34cf9e7/embreex-4.4.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ea332cfe60f3b242ecb557ef7b5fcbffec5edd0f3127241bed343d090ac735e2", size = 13218445, upload-time = "2026-04-22T19:47:25.847Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/8cb8a41b25138999a98286ae1562e5903c7579ec71becc05bfa1c10d571f/embreex-4.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e3481b76df80f23128173486ac0efe55e9199fc311bc74707452b4f19951eff", size = 14589303, upload-time = "2026-04-22T19:47:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/8b393b35016909143ecac7bf7bf418f79236e1f83faf3867b5c5cb50c97f/embreex-4.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a2fedc756a36729da6803425398aa4987b27d1d89e9fad403d8ef371fad5ca01", size = 13389585, upload-time = "2026-04-22T19:47:31.398Z" }, +] + [[package]] name = "empy" version = "3.3.4" @@ -3768,6 +3822,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, ] +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -5143,6 +5211,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, ] +[[package]] +name = "manifold3d" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/e5/b61f0ae60793e91c42f4b1e7b4fcba4aaf4b99c43552caff568f67a5e0e4/manifold3d-3.5.0.tar.gz", hash = "sha256:d43d8614dadab7db9ade5cd459cde0640f3f99a91466947aa17334b9e9369b82", size = 300919, upload-time = "2026-05-23T05:05:40.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/d2/a4dcb7312f1d90820477dafc0f687ea1180552d5290d35eed032704285c4/manifold3d-3.5.0-cp310-cp310-macosx_10_14_universal2.whl", hash = "sha256:3784b5576132b705d948a8d0f3d16bdaf1d9842cb347c31f0632e545d12309cb", size = 1834299, upload-time = "2026-05-23T05:04:19.321Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/01f360db13a6af559c6c6adb6e648a0efa313aac8f661c2cb0f64b5be2d4/manifold3d-3.5.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:60404989c3c583de5f97e88e31606a09f3e13a03b0b2b12973b0c883333412fb", size = 998233, upload-time = "2026-05-23T05:04:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d5/4e5a0e4c077cc10664f4634b1a246cec910ae55bb5d0c66281df1949aaae/manifold3d-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:560bdf6095feb7fb1fd251136e6ffb4287278593c1ef8326db8ffb72633ad12e", size = 870405, upload-time = "2026-05-23T05:04:23.152Z" }, + { url = "https://files.pythonhosted.org/packages/16/ef/eb1dea450a631e9fd2dcce659403a575941292f1c622d5abe715eaac5228/manifold3d-3.5.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbdab9398e7154091b7952ce8950c88aa74b01aba3090e68ba29217ba7ef9f90", size = 1313959, upload-time = "2026-05-23T05:04:25.191Z" }, + { url = "https://files.pythonhosted.org/packages/43/43/feae574b9ec7993163ef3321a010778c77d996d0bee68ce711060a040ea8/manifold3d-3.5.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a15947c260efd331f83decb1ee85e441797c7349129c60d091d788079704261", size = 1428654, upload-time = "2026-05-23T05:04:27.228Z" }, + { url = "https://files.pythonhosted.org/packages/ad/99/3bd8c4a11a336671fbac20181484e606a399cd1f7c9d30cc57af5fb32984/manifold3d-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:35a958ed15b96c72904ce1ed5d577ddebdf75f6bff5c92984b23c2d1a74a2c83", size = 1020210, upload-time = "2026-05-23T05:04:28.87Z" }, + { url = "https://files.pythonhosted.org/packages/26/a4/5578b24c3d45f2a4d1b63e973501d20e28e0381e5c5f1da8723c09a9adb1/manifold3d-3.5.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:4b7b4fae67ecdff22383af06609ae1d0b7d30c2d1b2efed0e749697ce6afccf1", size = 1849326, upload-time = "2026-05-23T05:04:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/6abced8a081d2d4e897799d896364ccaf6dcc964a9228e12f261261af937/manifold3d-3.5.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:d429c71109321348e82284de9786b24c5d1ad6f3331be7dcd341561dcf08ed51", size = 1012926, upload-time = "2026-05-23T05:04:32.233Z" }, + { url = "https://files.pythonhosted.org/packages/75/04/5af6df8f78f5d391f797985a716800dcdce7be86593d6bc2113febf3870f/manifold3d-3.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:486ce34f5ecb6850f87bcf5d01c466c052f972e98a34fe2b25fbbbbac41ef139", size = 885093, upload-time = "2026-05-23T05:04:33.942Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a4/5331f244b2b0b8daa33c1066b8e373200cad6cf3d59c3581d506340057e9/manifold3d-3.5.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e33ea03d966c629e151c2587363055ace78cb82fcd8e13459ec5f62cdd7fda9", size = 1328373, upload-time = "2026-05-23T05:04:36.157Z" }, + { url = "https://files.pythonhosted.org/packages/40/12/ef8fedd65123f729fb01711cc6cb02e053836bd52f11076d98925e12df68/manifold3d-3.5.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc75a58a5570a7ec8cc9987eb9ddb6eeddf4affd9b93f49445c0cca1f1c1ba24", size = 1443345, upload-time = "2026-05-23T05:04:37.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/a9/d5911b0d24fc407e8feabd1b0b6f41bdc895e6af58b7899459fc806afeae/manifold3d-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cf8272ee76c8541c183b2c8bea07be384efcbbc733f3edbef720e8bec3b95b45", size = 1034824, upload-time = "2026-05-23T05:04:39.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/97/2d222dc50cf331937836acfc61366215ee8a00fb2b4cce0bc8be91ce4bcd/manifold3d-3.5.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:79ffa285df6d5ad9250c064756fe50ae7a8c6bb9a21b72bb1692d4c37a620202", size = 1848791, upload-time = "2026-05-23T05:04:41.678Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8f/710b64ab447a8940368fb96ab5f592e6354328686f617ac9f0e76e03aa3d/manifold3d-3.5.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:01246fdf671baaa87a48c6006f985c0a3b75a33c9339214e1c512a937b3d471a", size = 1013105, upload-time = "2026-05-23T05:04:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/cf/5a/506be337bde6e8247c5757223bd0d4179d880c2b4a24bcc466420d9ac832/manifold3d-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a095c5ab0c82c3e269d1367aca13b30f4e8d3262e38129202dcf406ab5645cc", size = 884304, upload-time = "2026-05-23T05:04:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1a/83de823c8d10fb4902249004e4e50357fada7a941862409df1298f755417/manifold3d-3.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fed7bcbfb6b143448d5fac6b4d8b654fd185c0d0a1e18f2b1c4bacd839656c3b", size = 1329560, upload-time = "2026-05-23T05:04:47.149Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/63e9b1cc5261de75f885836688b14c4361c6752e7688dd04d37a1c88c0f0/manifold3d-3.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d12e817ca7751edd4978b399d21f00ac9e79a6d0fdbe8b2cd05d4f9a9ed81a08", size = 1445435, upload-time = "2026-05-23T05:04:48.868Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/760483c6533472349c3aba95fa5984bc16a8cb3b9cab23ac0079dbfb930d/manifold3d-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:684f0daaaab66ea305d0820fcee9ac440c5fe12105797839c4e9b89afe0af89f", size = 1033565, upload-time = "2026-05-23T05:04:50.651Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7e/21cc87332147a72ba80becfcab0db6fd0e4175d6a950471348cdd498e76c/manifold3d-3.5.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:4514646be7f3e8feb2186bd8a7ccd6519e45e547cfc63e16494f4e25f72fbfa7", size = 1848659, upload-time = "2026-05-23T05:04:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d2/43dd997c9ad5a6909ca5eef3b56b28894dae834e3792dd704789fa6869e4/manifold3d-3.5.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:6731f071b8f7abbe523f18051cbfd0fa62ffbfadcd73592c49f30ec3499f35ad", size = 1013009, upload-time = "2026-05-23T05:04:54.381Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/11ac40f395f486dc87df59bcfeded8d39ac5f7ab31a48bd044699f65deb1/manifold3d-3.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6f44cccdaf5d2166b08f9e152c69a284b96c1fd9fcac16799e83fee81fba1425", size = 884304, upload-time = "2026-05-23T05:04:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/4cedb280bd1331143964609b4a6f258988418770ca4af4f580f8b3b6c197/manifold3d-3.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d080887a67e80ad6edc8f5e49d936fd913cb8d93a3d56aff6341dfa88f17f2a", size = 1329215, upload-time = "2026-05-23T05:04:57.865Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6d/1a6caa477e84c8cf476786eb2009c9865456d8da040ee55ed7b5da103f41/manifold3d-3.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c8fb203f32525b9ef2a8badfea88b08c1d5170a864a0a23d09fe895088056fa4", size = 1445393, upload-time = "2026-05-23T05:04:59.608Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bf/50f3913b0f3902b112f44673b8c850afd9368c72365f438f2f4b0ad2cd58/manifold3d-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab2c5edfa5af8c5131753f764cf3054d309dd1c92a35f2055acab7cb24d8dd9a", size = 1033542, upload-time = "2026-05-23T05:05:02.135Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/8b3f37f9bc1f053c71b1fa2e4b6231f4918a34f35e6f68d78e2630482a80/manifold3d-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6b1631de1951f6f5b89a17445a59afc33f77edbabd732b8eb887f7822ceff966", size = 1848595, upload-time = "2026-05-23T05:05:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/2d/0c/0e077cdde1231e04c3b87dbb0605a99692f95948cc7a4b13f39d331413be/manifold3d-3.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e3a93f32729ac864ffe86964c1c371fa518af6e4cc53cf33719ff17ecff0d737", size = 1013023, upload-time = "2026-05-23T05:05:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/67/e9/de6d332f6ae9a951f03dd86a04f453dc6edad9739eefdea9cd059e1820d2/manifold3d-3.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:aeeb00088b756deeacf94a84c7d7363ba3387e82f3bf9d612ac2664edb52ef3d", size = 884183, upload-time = "2026-05-23T05:05:07.84Z" }, + { url = "https://files.pythonhosted.org/packages/69/63/9af35717a8e638304885dbc5b793e441f0ba0764168161f7548fdc6b72ac/manifold3d-3.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cae5483d2ec631991304a4a35ad7c091705c45b33eecb80b41a7e54c94316528", size = 1330108, upload-time = "2026-05-23T05:05:09.99Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/38bc18893c5369e81ea45433abc403e9534fbeb95d7f4e6aabd618269ba7/manifold3d-3.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a8d59e3f356823091034903bf62f1b8acf0d3c9b93c6b21a356f5a6050491e", size = 1445474, upload-time = "2026-05-23T05:05:12.013Z" }, + { url = "https://files.pythonhosted.org/packages/08/9d/a2c0ccefe5869819ddef73ddceea0e353d05ad2830dc0c5ce6655aa15583/manifold3d-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:8bd7de6a39bf430b6816a30df52461bcfb5e0427f53dcf157d7ddc07eae0b7a2", size = 1065036, upload-time = "2026-05-23T05:05:14.255Z" }, + { url = "https://files.pythonhosted.org/packages/05/05/980533da003278ea43a8cddeb8dabfec9f80a86bfb2991638e2334940231/manifold3d-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce6dbd4380c46831ba0d1958bbc6c5649b5a44528e6075ac8d7c302fa90b77fc", size = 1857612, upload-time = "2026-05-23T05:05:16.551Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/6b3738bf379786b23ae49222f44e58bc7a34455c50da08a1cf320a7a3b0f/manifold3d-3.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cf08a461bcc7ff83edaf8e64a6cb32b5f13b0bf63649d18db8fd5b85ed21d6f8", size = 1016497, upload-time = "2026-05-23T05:05:18.218Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/17219c86609407973822dfba0100f05b99fb2d04abe09516b7d1d5a868e8/manifold3d-3.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d303d5ab8991a5fdfaadd722aca970acb4f7b5e0069ddd291e92df11a03b2e69", size = 888473, upload-time = "2026-05-23T05:05:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/33c79a5774efb8c7edc64cad49ed6f2a0dbf4aed662ab5cad15119e6920e/manifold3d-3.5.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86701e3214283d14e760cbaca16c43a28cac1f45e0cad295bbfdd6ebd61bd85b", size = 1336788, upload-time = "2026-05-23T05:05:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1b/bbf7ca28806a5d81acd85d21ec223cb197f466a672184294870d2817732d/manifold3d-3.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6329c7034218efcae0fc7dfb789b8710d9b212dfd09eea1b8b845dd50fc71209", size = 1451571, upload-time = "2026-05-23T05:05:23.908Z" }, + { url = "https://files.pythonhosted.org/packages/e9/db/06ef9a4c34d0914d9ab27c9b5267338469a55a17e4ec2934c5080c947bd2/manifold3d-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:08b30f59e6af8b57f577bcb8caa5cae28d0ee5c5efe5d94c047f85deeaee0f31", size = 1076577, upload-time = "2026-05-23T05:05:25.844Z" }, +] + +[[package]] +name = "mapbox-earcut" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/7b/bbf6b00488662be5d2eb7a188222c264b6f713bac10dc4a77bf37a4cb4b6/mapbox_earcut-2.0.0.tar.gz", hash = "sha256:81eab6b86cf99551deb698b98e3f7502c57900e5c479df15e1bdaf1a57f0f9d6", size = 39934, upload-time = "2025-11-16T18:41:27.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/d3/5222339a8fad091bf64f2e3041e48606d69d69f0609a7632ca17a8a05d5a/mapbox_earcut-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:c9a1dab7529f8e54bdb377f908e56f1e2b9a7e27ed168c64d3c7c38ed04ac201", size = 55920, upload-time = "2025-11-16T18:40:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/19/e4/88d06e83ab75db2f4ae140a1e03ad8f84b02ac8af585dd61108aba73b8ed/mapbox_earcut-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e5953098ea198253c8a40e2f282ca5b04d50ec2b9661e20c4cd2b2be39f0bb0", size = 52557, upload-time = "2025-11-16T18:40:10.536Z" }, + { url = "https://files.pythonhosted.org/packages/22/88/abefd244ea049e42334c5f7a9e3b58f4ec3c84d063119ba3c8d27ff31932/mapbox_earcut-2.0.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe5fd5de409e3b6d13907e73f295c8f1d63bdb6b8ca155dde4c93865796eafe", size = 56950, upload-time = "2025-11-16T18:40:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e2/11122fddd086b930502eb4a954735da0f75e9d658fdab2d9e5914b9ebd2a/mapbox_earcut-2.0.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd04da6edbca1dd68ddbfac2398a95c763f35d7317fed227fde5b3aff1253b18", size = 59618, upload-time = "2025-11-16T18:40:13.017Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fd/e62195729daa3111fe95404a99c7a6b3aa174800373d10111b7e7278a789/mapbox_earcut-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bdb8881e857d6d9277df696e9cfb8749c00d6162021d9359cba9da58dfdd4f5", size = 153021, upload-time = "2025-11-16T18:40:14.294Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6a/d39ebaaa9010ea6c9f4d468f8812b1a1b31a40fba4f02ff29bc1bf321c30/mapbox_earcut-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6e2d1bf5af90d5857955775b4d8ea15b02e172f2a8f194bba50ff95f8ff3e80e", size = 157736, upload-time = "2025-11-16T18:40:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/20/00/6a59cdb8d8c1bf7e3cc92f0404f68fdb1a3cb0bbb0837af0dbb93d6290a6/mapbox_earcut-2.0.0-cp310-cp310-win32.whl", hash = "sha256:5b0aa63dd890d712343095b05eb7b60e071912ad3ced1fc4187d6a6a739677bc", size = 51564, upload-time = "2025-11-16T18:40:17.852Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/af69669c959d8f7fd1bd49c15deace2360bf6a79dad7bf9f7a7f1c137da6/mapbox_earcut-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b1355f13af89ea815b32f59a5455db295c965d51ab501bde0459cddc010a7149", size = 56793, upload-time = "2025-11-16T18:40:18.953Z" }, + { url = "https://files.pythonhosted.org/packages/07/9f/fbd15d9e348e75e986d6912c4eab99888106b7e5fb0a01e765422f7cd464/mapbox_earcut-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:9b5040e79e3783295e99c90277f31c1cbaddd3335297275331995ba5680e3649", size = 55773, upload-time = "2025-11-16T18:40:20.045Z" }, + { url = "https://files.pythonhosted.org/packages/72/40/be761298704fbbaa81c5618bb306f1510fb068e482f6a1c8b3b6c1b31479/mapbox_earcut-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf43baafec3ef1e967319d9b5da96bc6ddf3dbb204b6f3535275eda4b519a72", size = 52444, upload-time = "2025-11-16T18:40:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0b/0c0c08db9663238ffb82c48259582dc0047a3255d98c0ac83c48026b7544/mapbox_earcut-2.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a283531847f603dd9d69afb75b21bd009d385ca9485fcd3e5a7fa5db1ccd913", size = 56803, upload-time = "2025-11-16T18:40:22.891Z" }, + { url = "https://files.pythonhosted.org/packages/f0/4a/86796859383d7d11fa5d4bcf1983f94c6cbb9eeb60fb3bab527fec4b32fa/mapbox_earcut-2.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab697676f4cec4572d4e941b7a3429a6687bf2ac6e8db3f3781024e3239ae3a0", size = 59403, upload-time = "2025-11-16T18:40:24.021Z" }, + { url = "https://files.pythonhosted.org/packages/6c/db/adaf981ab3bcfcf993ef317636b1f27210d6834bb1e8d63db6ad7c08214a/mapbox_earcut-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1bdac76e048f4299accf4eaf797079ddfc330442e7231c15535ed198100d6c5", size = 152876, upload-time = "2025-11-16T18:40:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/d2/83/86417974039e7554c9e1e55c852a7e9c2a1390d64675eb85d70e5fa7eb37/mapbox_earcut-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a6945b23f859bef11ce3194303d17bd371c86b637e7029f81b1feaff3db3758", size = 157548, upload-time = "2025-11-16T18:40:27.202Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4c/c82a292bb21e5c651d81334123db2d654c5c9d19b2197080d3429dc1e49a/mapbox_earcut-2.0.0-cp311-cp311-win32.whl", hash = "sha256:8e119524c29406afb5eaa15e933f297d35679293a3ca62ced22f97a14c484cb5", size = 51424, upload-time = "2025-11-16T18:40:28.415Z" }, + { url = "https://files.pythonhosted.org/packages/30/57/6c39d7db81f72a3e4814ef152c8fb8dfe275dc4b03c9bfa073d251e3755f/mapbox_earcut-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:378bbbb3304e446023752db8f44ecd6e7ef965bcbda36541d2ae64442ba94254", size = 56662, upload-time = "2025-11-16T18:40:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d6/a1ef6e196b3d6968bf6546d4f7e54c559f9cff8991fdb880df0ba1618f52/mapbox_earcut-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:6d249a431abd6bbff36f1fd0493247a86de962244cc4081b4d5050b02ed48fb1", size = 50505, upload-time = "2025-11-16T18:40:30.992Z" }, + { url = "https://files.pythonhosted.org/packages/8d/93/846804029d955c3c841d8efff77c2b0e8d9aab057d3a077dc8e3f88b5ea4/mapbox_earcut-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db55ce18e698bc9d90914ee7d4f8c3e4d23827456ece7c5d7a1ec91e90c7122b", size = 55623, upload-time = "2025-11-16T18:40:32.113Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/cc9ece104bc3876b350dba6fef7f34fb7b20ecc028d2cdbdbecb436b1ed1/mapbox_earcut-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01dd6099d16123baf582a11b2bd1d59ce848498cf0cdca3812fd1f8b20ff33b7", size = 52028, upload-time = "2025-11-16T18:40:33.516Z" }, + { url = "https://files.pythonhosted.org/packages/88/6e/230da4aabcc56c99e9bddb4c43ce7d4ba3609c0caf2d316fb26535d7c60c/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5a098aae26a52282bc981a38e7bf6b889d2ea7442f2cd1903d2ba842f4ff07", size = 56351, upload-time = "2025-11-16T18:40:35.217Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/5cdd3752526e91d91336c7263af7767b291d21e63c89d7190a60051f0f87/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de35f241d0b9110ad9260f295acedd9d7cc0d7acfe30d36b1b3ee8419c2caba1", size = 59209, upload-time = "2025-11-16T18:40:36.634Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/b7781416cb93b37b95d0444e03f87184de8815e57ff202ce4105fa921325/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cb63ab85e2e430c350f93e75c13f8b91cb8c8a045f3cd714c390b69a720368a", size = 152316, upload-time = "2025-11-16T18:40:38.147Z" }, + { url = "https://files.pythonhosted.org/packages/c1/74/396338e3d345e4e36fb23a0380921098b6a95ce7fb19c4777f4185a5974e/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb3c9f069fc3795306db87f8139f70c4f047532f897a3de05f54dc1faebc97f6", size = 157268, upload-time = "2025-11-16T18:40:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/56/2c/66fd137ea86c508f6cd7247f7f6e2d1dabffc9f0e9ccf14c71406b197af1/mapbox_earcut-2.0.0-cp312-cp312-win32.whl", hash = "sha256:eb290e6676217707ed238dd55e07b0a8ca3ab928f6a27c4afefb2ff3af08d7cb", size = 51226, upload-time = "2025-11-16T18:40:41.018Z" }, + { url = "https://files.pythonhosted.org/packages/b8/84/7b78e37b0c2109243c0dad7d9ba9774b02fcee228bf61cf727a5aa1702e2/mapbox_earcut-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5ef5b3319a43375272ad2cad9333ed16e569b5102e32a4241451358897e6f6ee", size = 56417, upload-time = "2025-11-16T18:40:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/cd7195aa27c1c8f2b9d38025a5a8663f32cd01c07b648a54b1308ab26c15/mapbox_earcut-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:a4a3706feb5cc8c782d8f68bb0110c8d551304043f680a87a54b0651a2c208c3", size = 50111, upload-time = "2025-11-16T18:40:43.334Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/c5dd5b255b9828ba5df729e62fdd470a322c938f07ef392ca03c0592bb3a/mapbox_earcut-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:582329a81bd36cf0f82e443c395bcb8cfdb10caddafec76acaebac7c20bf1c31", size = 55619, upload-time = "2025-11-16T18:40:44.44Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3f/03f23eac9831e7d0d8da3d6993695a9a3724659c94e9997f6b7aaccc199d/mapbox_earcut-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d2ac5f610b3e44a3a0c4df06b5552d503b4f1c2c409eeca20dbe05112bd60955", size = 52023, upload-time = "2025-11-16T18:40:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/39/f3/a92ccee494b3e437e4bd81ecd358e39d231dc90af010d6c43930506c10ad/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58cc88513b87734b243d86f0d3fb87e96e0a78d9abd8fd615c55f766dd63f949", size = 56357, upload-time = "2025-11-16T18:40:47.27Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/e54ececd0403a5495c340b693075abec92a6d17dc44283b6cb059534f7ed/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40218d887798451932f3c335992834aa807c35cd497c6e0733470fdbd77f9521", size = 59215, upload-time = "2025-11-16T18:40:48.682Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/8fbff13a074c1fbf702b30ce7ec4d878bc664d659c1c2b1697831f4ea3a8/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:39fa5cfa0e855b028ec9b0200c88ebfa252448f343ce2f67b6fc07fe1f22a3ae", size = 152304, upload-time = "2025-11-16T18:40:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c757030b3cb3a9f2278ded6f7312d2b9d3761db6f3da8d395f7f7303dd66/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:476b558473b8a43f238d46e819bc0f830c427842ec5feb19e23b4dcac8ad2455", size = 157270, upload-time = "2025-11-16T18:40:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/96/63/589c6decb1f032d8811f1066da552f0a718830f592e6d6539fa4c3c766b8/mapbox_earcut-2.0.0-cp313-cp313-win32.whl", hash = "sha256:8c2d125c182acbc490b39503c0dec4f937bae180d0849a26bcea0ee4a76024bd", size = 51207, upload-time = "2025-11-16T18:40:52.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/75/a79a6020c46d4f07731e88ec5cc9324f6b43343aba835def1dc0bf59fecf/mapbox_earcut-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e049e6a37c228d7a9cb2f54ae405aa21d35c5175d849530fb32064ddb38ad5ab", size = 56416, upload-time = "2025-11-16T18:40:53.474Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/83e878c2b3e9e6db1f60b598a2cc5ed4c2b5bc8d281575c964869414a159/mapbox_earcut-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:8a833d73d63d4b6291bbd8b4d2f551e87f663282cdc547ecbbd9b423849ee996", size = 50103, upload-time = "2025-11-16T18:40:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/f1b74324c83f510213ff91eb8b1d2697ad5a12418c5fba966e80f1104a5f/mapbox_earcut-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad1dc141797037b7d4c9d8d2e52b9665b36294913a8ec31008b282d1a95b9bdc", size = 55728, upload-time = "2025-11-16T18:40:56.098Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/053c04e29c4bd22157d3b6255f1e5c19c46cb7a594c4314298bdcbca723f/mapbox_earcut-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0f0f5c6f5ed8ffdce8efe6a003ba598089d0ee07eabd41868db183be50484f9f", size = 52063, upload-time = "2025-11-16T18:40:57.227Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/acc2d553c3bb8c769535a280545bb7d9608141e90511a2e6215a54611776/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82cd92775f37fd1e4b8464c5e74a00e87130eecc55ee3df2492b8ca2bdf6ef3e", size = 56522, upload-time = "2025-11-16T18:40:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/627dd6defd3c1a2b3069e9e27482aa04d268c841735e576c1e22848a34f6/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:626ffc1310e0cc8910283e4ac3139e5fb0458f18f2c4874162f66159951933ff", size = 59204, upload-time = "2025-11-16T18:41:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3e/819185542ab095ba1244ad65ececb3edcde6fd0111248a0f9318d695bfcf/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea951d764a356cad95b23fef950d8aa3b44b933795ad09d977fea7d4dbe377c3", size = 152550, upload-time = "2025-11-16T18:41:01.233Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ad/85e0f815e4774b90ad6761bce55c80d13ee21b2a24014b0be0d5010b0049/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df1f217624abb5e02ecabcbd84369de970b8d8bc1e4e7c164c1cfcaddad76ca3", size = 157322, upload-time = "2025-11-16T18:41:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0f56369e7a000d2f3177d17baf34263559b206ae524fcd0c4c5d1d960dab/mapbox_earcut-2.0.0-cp314-cp314-win32.whl", hash = "sha256:6fa61307d38b50fc9bd5449c00dbae46d270a32b372c6fc3b8af4b85c85746e4", size = 52916, upload-time = "2025-11-16T18:41:04.122Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9d/8c557dd9b3d9fe2344f5bd5ff3bb0b2a42ed6addb7e43ca4358051743b04/mapbox_earcut-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:0da20ed3c81b240450118773bcedfac34e70a56998f66147222c46f4356fff67", size = 57713, upload-time = "2025-11-16T18:41:05.204Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ec/678c5553938d3a29d02dd41dd898672267f054afc4e2821958dee6ec86ce/mapbox_earcut-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:847e74bd5878e4c64793dc100f9288f5443f87c55c3fe391fd90509029136ff6", size = 51872, upload-time = "2025-11-16T18:41:06.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/94f2d973669cbfef811e536713fe56ec012ba74e5f8795a832337b1866a3/mapbox_earcut-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ddc9e7175fc903185c64afbbf91febee56b50787dd0962fce2bfb4f20cf80d27", size = 56447, upload-time = "2025-11-16T18:41:07.443Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1c/e0afcc82659cc1727a7e59c4f9e9880bbc3f048a4a5325772b44d4a91dfd/mapbox_earcut-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6dc8a7568066af9a858018d6d92b7e77e164578f9fcd79093f1cbe4ec203461b", size = 53154, upload-time = "2025-11-16T18:41:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/9845281c8c35da2bea733b8c2df5b9fe694e73e7b05fe8a1d4c3c439a1bc/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6abc5340edd9b433ab2dab2ee033082a199d5c51cce445124626c0040ec0d81b", size = 56285, upload-time = "2025-11-16T18:41:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/97/8e/eeea762a519490662b8f480e2b35bf03701b0bcc5a446b62a4c5a1500b06/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df7afdd8078a9aa28f469d9242531d304e09a4b14e514f048e021a949f3777b4", size = 58601, upload-time = "2025-11-16T18:41:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/b9/67/932f80aa6af9bc1a317b6119052c74f327d81e00b457003a049e324b810c/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a286f73e612a46cafd6d6c843365265090517af16823e2f37277c13cd8b6f09", size = 154924, upload-time = "2025-11-16T18:41:12.104Z" }, + { url = "https://files.pythonhosted.org/packages/87/38/5db4a91f9f90cbb447be61da5468a2955fad3a840ae4c7dbde789b09d45a/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8d081fe1d00dc553e3e68c02fc395324aad0d8ed955f3ff59289264c9b21ace4", size = 159194, upload-time = "2025-11-16T18:41:13.364Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/de3843b13fe854a010fb2f8b25551d4d5fe1c879ff2e7c8d7d8d7d735a8e/mapbox_earcut-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:13049ca96431bbc7ef7fd7780dd1872209ca11a5c1977f7aa91a1b574a8af863", size = 54143, upload-time = "2025-11-16T18:41:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/9a/89/fbdee5a56ba51df9be6098b5428636ad75aa994e98d8bec6113d5cba401e/mapbox_earcut-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ace78e4fdba3b8cbb7768d44d77a981698305862a07f94bbb6f5cc16659adb4", size = 60833, upload-time = "2025-11-16T18:41:15.694Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -5747,6 +5927,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, + { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, + { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, + { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, + { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + [[package]] name = "mujoco" version = "3.5.0" @@ -9419,6 +9655,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "rtree" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425, upload-time = "2025-08-13T19:32:01.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484, upload-time = "2025-08-13T19:31:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325, upload-time = "2025-08-13T19:31:52.367Z" }, + { url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789, upload-time = "2025-08-13T19:31:53.926Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644, upload-time = "2025-08-13T19:31:55.164Z" }, + { url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478, upload-time = "2025-08-13T19:31:56.808Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140, upload-time = "2025-08-13T19:31:58.031Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358, upload-time = "2025-08-13T19:31:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" }, +] + [[package]] name = "ruff" version = "0.14.3" @@ -9801,6 +10053,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" }, + { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -10028,6 +10348,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, ] +[[package]] +name = "svg-path" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b9/649abbe870842c185b12920e937e9b95d4c2b18de50af98d2c140df3e179/svg_path-7.0.tar.gz", hash = "sha256:9037486957cb1dcf4375ef42206499a47c111b8ffcbac6e3e55f9d079d875bb0", size = 23552, upload-time = "2025-07-06T15:20:40.823Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/83/4f5b250220e1a5acd31345a5ec1c95a7769725d0d8135276f399f44062f8/svg_path-7.0-py2.py3-none-any.whl", hash = "sha256:447cb1e16a95acea2dd867fe737fa99cb75d587b4fc64dbee709a8dd6891ad9c", size = 18208, upload-time = "2025-07-06T15:20:39.59Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -10642,6 +10971,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/98/716a473cfb24750858ddd5d14e6527539dd206583a46408d08eeb2844a75/trimesh-4.12.2-py3-none-any.whl", hash = "sha256:b5b5afa63c5272345f2858f7676bc8c217dc8a89f4fadf6193fe10a81b5ff2aa", size = 741043, upload-time = "2026-05-01T00:57:40.763Z" }, ] +[package.optional-dependencies] +easy = [ + { name = "charset-normalizer" }, + { name = "colorlog" }, + { name = "embreex" }, + { name = "httpx" }, + { name = "jsonschema" }, + { name = "lxml" }, + { name = "manifold3d", marker = "python_full_version < '3.14'" }, + { name = "mapbox-earcut" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pycollada" }, + { name = "rtree" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "shapely" }, + { name = "svg-path" }, + { name = "vhacdx" }, + { name = "xxhash" }, +] + [[package]] name = "triton" version = "3.6.0" @@ -11061,6 +11413,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] +[[package]] +name = "vhacdx" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/e3/d2abc3dc4c1cb216c2efdc70b36f80efeb1bdbd7d420a676ddc9d9d980e1/vhacdx-0.0.10.tar.gz", hash = "sha256:fcc23201e319d79fe25e064847efc254bd39ac30af28cc761409e1f9142dd033", size = 58125, upload-time = "2025-12-02T20:58:45.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f4/da308d86daaa9c636851357cbd928715d47963beecd525b3749d2d5c9537/vhacdx-0.0.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc7be82fab608cb7231e95a0a10700be1e9422a36b21e7d49c782a598c8d37c", size = 222760, upload-time = "2025-12-02T20:57:30.778Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8a/e3462a43ec6712b74d921e4af9d5a2998752378c5554bde9a594dbb0cf0c/vhacdx-0.0.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4b63d1c5ad0e64c300a3a9d9404f4778df367b8c545639dfb932db4b76704ff3", size = 208812, upload-time = "2025-12-02T20:57:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/b717275adb108431f1404193542fab7ecf4c5bae221f1552bbd570fe0e5d/vhacdx-0.0.10-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9bcf3fe1c555598e41348108b55a0fc67534e7fef2367452c301014518c1476", size = 236999, upload-time = "2025-12-02T20:57:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/bf/84/97e2305f6bd4a4de3d40bb234c38282cbcf2fa30653ff5ae4f7df9d8f3ec/vhacdx-0.0.10-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9506ca89289da63e5a3d1ac97aa7413aece47d65cbaa4b0c409469555add0e06", size = 250035, upload-time = "2025-12-02T20:57:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/9d/66/eb1d8d64742b9e73557e075cea6ee7e4976dd89b84c7d3197ca3621d5a85/vhacdx-0.0.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06faf9caa0abceddd5fa505e4299e2ebf14bc26c58a1e521013717cbf37bea61", size = 1224134, upload-time = "2025-12-02T20:57:37.217Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/e829b21b071db94f45079c4ace2f967c684f08b10ea285919a95e9d5fe21/vhacdx-0.0.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3a6b43b42290697e2bd04087977d1e3841d287c528e414c765581ecec62e66f6", size = 1284300, upload-time = "2025-12-02T20:57:38.78Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/b401565542b927ce3e0a6d5e72acef79343a449ee1a7ad94a5c7266bab26/vhacdx-0.0.10-cp310-cp310-win_amd64.whl", hash = "sha256:27eb3b293ccef1332d477346d564bb4c474bb451e9b753e3ce9cac01cbb90a0c", size = 193069, upload-time = "2025-12-02T20:57:40.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2c/d49df6fec3294cef3c8c88c54784162bd8350c427fecd9b16335772b760f/vhacdx-0.0.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8584f33ed020b6cce678b8febcf84af22bced617ef31c85bf31fd7e2b4bba9fe", size = 224113, upload-time = "2025-12-02T20:57:41.59Z" }, + { url = "https://files.pythonhosted.org/packages/68/1d/bd2456baa6b16977c106adc2386b6e7a34c3e57ade6aeeab68bb61ceb16f/vhacdx-0.0.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a9b63cdb5f34dfee386b64a01f7e1571ef0c2555244ea3d83a09d78273123bce", size = 210118, upload-time = "2025-12-02T20:57:42.749Z" }, + { url = "https://files.pythonhosted.org/packages/49/ab/15adb78489b51c2a898642755be727ecd7c3de37cac6e434ce420b8ce27c/vhacdx-0.0.10-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915eab6c19fdf63ab256855331db546575284786a480aa2d67437db0e86b0d17", size = 238276, upload-time = "2025-12-02T20:57:43.95Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f1/464c761dbe24f58d6fc354bf51729342981fb7a621e170e0d3512fadbec8/vhacdx-0.0.10-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e335bb9af540e6867ff051166a399075823fdd8fc1fc27e9530995cc1bda1eb", size = 251383, upload-time = "2025-12-02T20:57:45.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/22/c7b4117c5431189a6a019e8fc2cf590df3ab196c38b4b7c3622292205d9b/vhacdx-0.0.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c3ddbaa38eb65c3aec9b0e39a822223474c931c0e18d3e93a3a499870ffa45ad", size = 1225200, upload-time = "2025-12-02T20:57:46.639Z" }, + { url = "https://files.pythonhosted.org/packages/6c/62/c679ad28ce7854771913255e1abc588b3643c2147fb5c51a8553224aa1dd/vhacdx-0.0.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d398fcc13e330ed1fd2540a7d572aeca0be9621411def78e10c7ea4132f959ee", size = 1285935, upload-time = "2025-12-02T20:57:48.51Z" }, + { url = "https://files.pythonhosted.org/packages/de/c8/a8260b780e4578d7ef19b70343f9717f74ff48f9950138c96c78f209ec01/vhacdx-0.0.10-cp311-cp311-win_amd64.whl", hash = "sha256:c9665a3ef887babcac8b5822f01288e8f06b4a949fadbbe1861670b358f111ee", size = 194137, upload-time = "2025-12-02T20:57:50.207Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9c/66375e65634c80f6efb46e81915126bf3e55dc9d6615217590cbc8316d2e/vhacdx-0.0.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd17d697d6d4d7cf66f1e947e0530041913981e05f7025236bec28a350b1a33", size = 224998, upload-time = "2025-12-02T20:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e3/fc2644d3e7d0b2b52e2f681eb2878c0e1b9cafc53946f66736d0f01e237c/vhacdx-0.0.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:189ded39b709436cb732cdf694d4cf22e877aefb97e2ab2b55bf7ada9c030f93", size = 211130, upload-time = "2025-12-02T20:57:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/e3/93/0b0f1977f5b3c2e1bbea5ef85e37a808ff73f1b7daf42950c57090e90dc7/vhacdx-0.0.10-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b03d35ab56a93beee338175dbe0b87552353e5dfb3ff37467e88f56cedf7cc", size = 239661, upload-time = "2025-12-02T20:57:54.144Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/d2a6aeb1c6570a1fc1be29ee03db795f643ab03c6df7635522f23796b39d/vhacdx-0.0.10-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8c54ed193fa0db0248928fbf5d438b3872d615a506889d5b89fc6467d6411a", size = 252938, upload-time = "2025-12-02T20:57:55.275Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/1e678efc161a0d7fe1806f5e037ce11cc5964db7e08ccfc220ef63951863/vhacdx-0.0.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5c898104140c72e4dc789e6125812671eee5e412916e83eff24a6148248ff5e", size = 1226696, upload-time = "2025-12-02T20:57:56.438Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/b302a0420a241c4910f4870eb9f39e6ada59858db441cc35bda511c17982/vhacdx-0.0.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abdd0ba17786e578206594731df15c90e2751b6884220c8673124f47fd7ac620", size = 1287794, upload-time = "2025-12-02T20:57:57.694Z" }, + { url = "https://files.pythonhosted.org/packages/73/e9/f9729603ac75047a257f1b4ddac60cbde72b0abfd49ffed305751ba630a2/vhacdx-0.0.10-cp312-cp312-win_amd64.whl", hash = "sha256:79e7db59b4042295b21b79d55ba486a9a480550f696d466f158a30ed920dd0ec", size = 195033, upload-time = "2025-12-02T20:57:58.95Z" }, + { url = "https://files.pythonhosted.org/packages/0e/54/c2fc08d9324bbd92735caf9207cbabada3a8dd9d270d6e46ca69eb7f883d/vhacdx-0.0.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0599bc2a96de8fc9aeff460b3e88b8572e84ae95b8fc6c9888ef4b92023c22d5", size = 225014, upload-time = "2025-12-02T20:58:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9e/42adb642a12915acc9cb2bfab21710a6aabf045c26967ba0ff0e08a872d0/vhacdx-0.0.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dc648829a1e973f34ee11393a4f334ab55e3e0e9c4b9f6d6349af966fdf1895a", size = 211127, upload-time = "2025-12-02T20:58:02.107Z" }, + { url = "https://files.pythonhosted.org/packages/51/3d/63e090cd966817b89643d7e52e13df45043b22a42c7fbf702866bdd75bc0/vhacdx-0.0.10-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74c03f7315a434ec83cd0bff1e6bce6af4c01df61d677f48f3ffb36800606ee7", size = 239471, upload-time = "2025-12-02T20:58:03.173Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b4/07ab1c828bae0eb5c72cd9a4cbe8b0376d374509be3c7055e1a399bf85c3/vhacdx-0.0.10-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fcd02acc3733ec3a0a0d28ca7f526d4c56f14a3ceb4b12fce45acf72c09054a", size = 253019, upload-time = "2025-12-02T20:58:04.318Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/bc8a8858b300d2c092da11096ae0586ece446b4c41cb26620bf00d1d8232/vhacdx-0.0.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4b9f8a80ca4c54d7fa76419a2ebd9e9386cd177dc4d2b97f2208ac57c9a7e8aa", size = 1226933, upload-time = "2025-12-02T20:58:05.907Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/213230883874615f1661903bce1ace5013d03b34696efce8d53c662a3358/vhacdx-0.0.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847bd43e82afb439dd3fa972618d786d0b98d8ef04a8e8a6381f6945204d2505", size = 1288871, upload-time = "2025-12-02T20:58:07.432Z" }, + { url = "https://files.pythonhosted.org/packages/32/25/f0e6806824f88d47ab8bc1c9bf6f11634fd7b382d635d0696825f3b5672f/vhacdx-0.0.10-cp313-cp313-win_amd64.whl", hash = "sha256:ab300c5f3fe4e54f99af92f9ea27c977b09df5f59190b0a3e025161110f71ce7", size = 195091, upload-time = "2025-12-02T20:58:08.783Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/5137c048728fddd3315a79c94ba8663f3707f9268af9af15b15e1ef3cd85/vhacdx-0.0.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:147030c7683be4f21a3cdfb202b121c01716694b61ddad794345fcd9fa43d0ec", size = 225247, upload-time = "2025-12-02T20:58:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/5c731863db402e9878380f68be8722fabbcaf8cfe8d06237aaf15f116d95/vhacdx-0.0.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:069eb4381917b790921a33d4cc6ed07f7ed5362474232110baf8dd3dadcd768d", size = 211339, upload-time = "2025-12-02T20:58:10.951Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/e93ce9b653a9f435c530df8d5ad68a80b8bdc2b8518abc225fef9e7f349a/vhacdx-0.0.10-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7702892008b1150619c1f66a62ef88d1cb8f92b09d9c39a0bfb87d147f1c5ae2", size = 239974, upload-time = "2025-12-02T20:58:12.101Z" }, + { url = "https://files.pythonhosted.org/packages/77/dc/ef34f97a65385bc1f8ed4718fa5f7d96313e299e76761f1b69efaf597797/vhacdx-0.0.10-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4d550dfc377471b36f11065fc12cfbbd1750d63b10a336644bfdcbf27aa8382", size = 253245, upload-time = "2025-12-02T20:58:13.303Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/57051066bd0589b7fe68c32061821180f520b6a7ef4efa072b755dde63d3/vhacdx-0.0.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edce8f0ff516a111b6f1d644782a1d496b3e9e34ff4ce09849c9b8071627bca5", size = 1227432, upload-time = "2025-12-02T20:58:14.73Z" }, + { url = "https://files.pythonhosted.org/packages/1d/49/3488f2bd991027bd86f072cf776935c80b4e630bd3bc43c3289bc6eeeba0/vhacdx-0.0.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c463abbdce73d5d0b94eab2c9f43f2b55a4d0e788d87af659cc78029b960bf9", size = 1289126, upload-time = "2025-12-02T20:58:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/2f4506382a1133bf441cba2010017064e8f7af940d100141799d7e867e58/vhacdx-0.0.10-cp314-cp314-win_amd64.whl", hash = "sha256:b93c834f2bf1fa6630da5d3f77e94ea8e542fca31e385244a7ec905a32155549", size = 198706, upload-time = "2025-12-02T20:58:17.378Z" }, + { url = "https://files.pythonhosted.org/packages/db/f6/4fabfe65f3123abda09adc416a396caf8c2ad1b29c34a5178ec71754a163/vhacdx-0.0.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4c0f1bafc53e156472b0367533c2d3ec7a96b676b6d57aa92dd3e37519331b07", size = 228276, upload-time = "2025-12-02T20:58:18.545Z" }, + { url = "https://files.pythonhosted.org/packages/dc/70/bdc742628adcf9966cea81be7a651300bc399b492d10a763781af6d27041/vhacdx-0.0.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b0f8643dcb1f0774320fc1389ead0d0da4536e4c0fecfd4c8133baec0b6fa556", size = 214287, upload-time = "2025-12-02T20:58:19.696Z" }, + { url = "https://files.pythonhosted.org/packages/84/6a/f2e37ad333d3f671e1d59ba76bb61edc5520146539d52ee29e555becb4ac/vhacdx-0.0.10-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4547f3e55eb935d163d89c10ffdcadf8797c3b435a9dc82be4e0e27b1e3abff0", size = 240923, upload-time = "2025-12-02T20:58:21.105Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/f0325cd7ece95dbbc10d0c3f6d241d47beb3b99ae4dafe2e450082cd7bd9/vhacdx-0.0.10-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee09c4f2b6385546001b5e8609f428417fac147cfd3ea020fbc7dec0f11c489b", size = 254257, upload-time = "2025-12-02T20:58:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/ac/56/53347b910351eb4cf32a797e177f18b8d82b1ef4e4325607254cfe88ad2a/vhacdx-0.0.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b94d198e4716f9220985523f374617432ef5530910f3730051f3e7fcba71798", size = 1228434, upload-time = "2025-12-02T20:58:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/be/f5/f86c63da38b0446ef6652e8e72b84451e440418eaac0f554737e159ae36e/vhacdx-0.0.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:39c6d31ed27e3f33e9411927e1567ba37a18ba7ce9295efd1b24414b7313b503", size = 1288854, upload-time = "2025-12-02T20:58:24.46Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d1/b30dec954a24b41358297a3fbe7c30d8e2e818831f552cb34c904baa04e4/vhacdx-0.0.10-cp314-cp314t-win_amd64.whl", hash = "sha256:fc6a613082ec522a020e4f6a09f39ed42546de9aebe99548aa84938b1440871c", size = 204896, upload-time = "2025-12-02T20:58:25.825Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1" @@ -11077,6 +11483,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "viser" +version = "1.0.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "msgspec" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "requests" }, + { name = "rich" }, + { name = "tqdm" }, + { name = "trimesh" }, + { name = "typing-extensions" }, + { name = "websockets" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/ef/118c59772bad5409ab1ffa234f5c429eb823b76c5f9f086c40158833d00d/viser-1.0.29.tar.gz", hash = "sha256:6229a985720a5da3f427647f04bccfdd437c3b21aafa5d2818bcd5f73cc1ad2b", size = 4907233, upload-time = "2026-05-20T10:28:50.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/2b/b3461e44c999ad8e8819ac191e5437b0ed3444e9b1d6f2b22bc0bcc66b21/viser-1.0.29-py3-none-any.whl", hash = "sha256:807834e166c9156d08b2997cd992bef0c0892e780a1700ae5e1d65523978d780", size = 5009573, upload-time = "2026-05-20T10:28:48.548Z" }, +] + +[package.optional-dependencies] +urdf = [ + { name = "yourdfpy" }, +] + [[package]] name = "wadler-lindig" version = "0.1.7" @@ -11845,6 +12278,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/c9/d4b03b2490107f13ebd68fe9496d41ae41a7de6275ead56d0d4621b11ffd/yapf-0.40.2-py3-none-any.whl", hash = "sha256:adc8b5dd02c0143108878c499284205adb258aad6db6634e5b869e7ee2bd548b", size = 254707, upload-time = "2023-09-22T18:40:43.297Z" }, ] +[[package]] +name = "yourdfpy" +version = "0.0.60" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "lxml" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "six" }, + { name = "trimesh", extra = ["easy"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/19/20c50861f30aff7720f9a601f386d73760c2df9961de1f98d0dbf3b85e69/yourdfpy-0.0.60.tar.gz", hash = "sha256:2af2d8bdeea1b85b642590a3b4236fdb35746d7b3e38ce460a169c18d9c4f868", size = 538238, upload-time = "2026-01-23T07:32:47.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/60/4ea0d6df0b497d51bf2ef87eaab0eb26f8bc3b3313c012da5df3383cced9/yourdfpy-0.0.60-py3-none-any.whl", hash = "sha256:8a187a8b18c98db87c76e9a950581b3c875b761e00df83942526c17ea693166c", size = 22194, upload-time = "2026-01-23T07:32:46.481Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From ea8b0e44a2e89e0debefecad9f1491cd6c604671 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 14:12:22 -0700 Subject: [PATCH 4/8] style: format viser panel changes --- dimos/manipulation/manipulation_module.py | 12 ++--- dimos/manipulation/test_manipulation_unit.py | 2 +- dimos/manipulation/test_viser_panel.py | 20 +++---- dimos/manipulation/viser_panel/module.py | 57 +++++++++++++------- dimos/manipulation/viser_panel/state.py | 7 ++- 5 files changed, 61 insertions(+), 37 deletions(-) diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index be48a259d7..497587136b 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -650,7 +650,9 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] joint_limits = None if config.joint_limits_lower is not None and config.joint_limits_upper is not None: - joint_limits = list(zip(config.joint_limits_lower, config.joint_limits_upper, strict=False)) + joint_limits = list( + zip(config.joint_limits_lower, config.joint_limits_upper, strict=False) + ) elif self._world_monitor is not None: try: lower, upper = self._world_monitor.get_joint_limits(robot_id) @@ -670,9 +672,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] "model_path": str(config.model_path), "base_pose": config.base_pose, "joint_limits": joint_limits, - "package_paths": { - package: str(path) for package, path in config.package_paths.items() - }, + "package_paths": {package: str(path) for package, path in config.package_paths.items()}, "xacro_args": dict(config.xacro_args), "max_velocity": config.max_velocity, "max_acceleration": config.max_acceleration, @@ -686,9 +686,7 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] } @rpc - def solve_ik_preview( - self, pose: Pose, robot_name: RobotName | None = None - ) -> dict[str, Any]: + def solve_ik_preview(self, pose: Pose, robot_name: RobotName | None = None) -> dict[str, Any]: """Preview IK for a target pose without storing, previewing, executing, or moving. Args: diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 47d35117fd..8d39258c0a 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -26,9 +26,9 @@ ManipulationModule, ManipulationState, ) +from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.manipulation.planning.spec.enums import IKStatus from dimos.manipulation.planning.spec.models import IKResult -from dimos.manipulation.planning.spec.config import RobotModelConfig from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped from dimos.msgs.geometry_msgs.Quaternion import Quaternion diff --git a/dimos/manipulation/test_viser_panel.py b/dimos/manipulation/test_viser_panel.py index bc70a2d6cc..cf673c9fb3 100644 --- a/dimos/manipulation/test_viser_panel.py +++ b/dimos/manipulation/test_viser_panel.py @@ -14,9 +14,9 @@ from __future__ import annotations +from pathlib import Path import threading import time -from pathlib import Path from typing import cast from unittest.mock import MagicMock, patch @@ -186,7 +186,7 @@ def test_panel_ignores_stale_preview_result(): def test_panel_preview_request_times_out_without_blocking_worker_forever(): panel = _make_panel() - cast(ViserManipulationPanelConfig, panel.config).preview_request_timeout = 0.01 + cast("ViserManipulationPanelConfig", panel.config).preview_request_timeout = 0.01 release = threading.Event() def slow_preview() -> dict[str, object]: @@ -208,7 +208,9 @@ def test_panel_cartesian_preview_uses_timeout_wrapper(): panel._client.solve_ik_preview.return_value = {"success": True, "collision_free": True} request = PreviewRequest(1, "cartesian", "arm", pose=Pose.__new__(Pose)) - with patch.object(panel, "_call_preview_with_timeout", return_value={"success": False}) as timeout: + with patch.object( + panel, "_call_preview_with_timeout", return_value={"success": False} + ) as timeout: result = panel._handle_preview_request(request) assert result == {"success": False} @@ -330,7 +332,7 @@ def test_panel_execute_enabled_after_fresh_completed_plan(): panel.session.plan_state = PanelPlanState( status=PlanStatus.FRESH, robot="arm", - start_joints_snapshot=[0.0, 0.0], + start_joints_snapshot=[0.0, 0.0], ) panel._update_gui_state() @@ -340,7 +342,7 @@ def test_panel_execute_enabled_after_fresh_completed_plan(): def test_panel_execute_requires_operator_launch_opt_in(): panel = _make_panel() - cast(ViserManipulationPanelConfig, panel.config).allow_plan_execute = False + cast("ViserManipulationPanelConfig", panel.config).allow_plan_execute = False panel.session.runtime = PanelRuntime.RUNNING panel.session.backend_status = BackendConnectionStatus.READY panel.session.manipulation_state = "IDLE" @@ -466,7 +468,7 @@ def test_panel_state_axes_keep_action_separate_from_plan_state(): def test_panel_preview_animates_viser_ghost_path(): panel = _make_panel() - cast(ViserManipulationPanelConfig, panel.config).preview_duration = 0.0 + cast("ViserManipulationPanelConfig", panel.config).preview_duration = 0.0 panel.session.selected_robot = "arm" panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} ghost = FakeViserUrdf() @@ -493,8 +495,8 @@ def test_panel_interpolates_sparse_preview_path(): def test_panel_preview_animates_interpolated_frames(): panel = _make_panel() - cast(ViserManipulationPanelConfig, panel.config).preview_duration = 1.0 - cast(ViserManipulationPanelConfig, panel.config).preview_fps = 2.0 + cast("ViserManipulationPanelConfig", panel.config).preview_duration = 1.0 + cast("ViserManipulationPanelConfig", panel.config).preview_fps = 2.0 panel.session.selected_robot = "arm" panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2"]} ghost = FakeViserUrdf() @@ -508,7 +510,7 @@ def test_panel_preview_animates_interpolated_frames(): def test_panel_preview_does_not_trigger_backend_drake_preview(): panel = _make_panel() - cast(ViserManipulationPanelConfig, panel.config).preview_duration = 0.0 + cast("ViserManipulationPanelConfig", panel.config).preview_duration = 0.0 panel.session.selected_robot = "arm" panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} ghost = FakeViserUrdf() diff --git a/dimos/manipulation/viser_panel/module.py b/dimos/manipulation/viser_panel/module.py index 6ffa22d30e..5cbb90d476 100644 --- a/dimos/manipulation/viser_panel/module.py +++ b/dimos/manipulation/viser_panel/module.py @@ -16,6 +16,7 @@ from collections.abc import Sequence import importlib +import itertools from pathlib import Path import threading import time @@ -27,18 +28,18 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.rpc_client import RPCClient from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.manipulation.viser_panel.state import ( ActionStatus, BackendConnectionStatus, FeasibilityStatus, - PanelSession, PanelRuntime, + PanelSession, PlanStatus, PreviewRequest, PreviewWorker, TargetStatus, ) -from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 @@ -48,7 +49,9 @@ logger = setup_logger() -VISER_INSTALL_HINT = "Install the optional panel dependencies with: uv sync --extra manipulation-viser" +VISER_INSTALL_HINT = ( + "Install the optional panel dependencies with: uv sync --extra manipulation-viser" +) VISER_URDF_INSTALL_HINT = ( "Viser URDF support requires yourdfpy. Install it with: uv sync --extra manipulation-viser" ) @@ -97,7 +100,7 @@ def __init__(self, **kwargs: Any) -> None: @property def panel_config(self) -> ViserManipulationPanelConfig: - return cast(ViserManipulationPanelConfig, self.config) + return cast("ViserManipulationPanelConfig", self.config) @rpc def start(self) -> None: @@ -177,7 +180,9 @@ def _wire_static_callbacks(self) -> None: self._handles["preview"].on_click(lambda _: self._run_operation("Preview", self._preview)) self._handles["execute"].on_click(lambda _: self._run_operation("Execute", self._execute)) self._handles["cancel"].on_click(lambda _: self._run_operation("Cancel", self._cancel)) - self._handles["clear_plan"].on_click(lambda _: self._run_operation("Clear Plan", self._clear_plan)) + self._handles["clear_plan"].on_click( + lambda _: self._run_operation("Clear Plan", self._clear_plan) + ) def _poll_loop(self) -> None: interval = 1.0 / max(self.panel_config.poll_hz, 0.1) @@ -240,7 +245,9 @@ def refresh_panel_state(self) -> dict[str, Any]: return self._snapshot() try: self.session.current_joints = client.get_current_joints(robot) - self.session.current_ee_pose = self._pose_from_rpc(client.get_ee_pose(robot)) + self.session.current_ee_pose = self._pose_from_rpc( + client.get_ee_pose(robot) + ) self.session.manipulation_state = str(client.get_state()) self.session.error = str(client.get_error() or "") except TimeoutError as e: @@ -306,7 +313,9 @@ def _ensure_scene_nodes(self, robot_name: str, info: dict[str, Any]) -> None: self._handles["ee_control"] = self._server.scene.add_transform_controls( f"/targets/{robot_name}/ee_control", scale=0.25 ) - self._handles["ee_control"].on_update(lambda event: self._target_pose_changed(event.target)) + self._handles["ee_control"].on_update( + lambda event: self._target_pose_changed(event.target) + ) ViserUrdf = self._viser_urdf if ViserUrdf is None or not info.get("model_path"): return @@ -427,7 +436,9 @@ def _apply_preset(self, preset: str) -> None: sequence_id, "joints", self.session.selected_robot, - joints=self._make_joint_state(info.get("joint_names") or [], list(joints)), + joints=self._make_joint_state( + info.get("joint_names") or [], list(joints) + ), ) ) self._handles["preset"].value = "Select preset..." @@ -451,9 +462,7 @@ def _handle_preview_request(self, request: PreviewRequest) -> dict[str, Any]: except Exception as e: return {"success": False, "status": "ERROR", "message": str(e)} - def _call_preview_with_timeout( - self, call: Any, timeout_status: str - ) -> dict[str, Any]: + def _call_preview_with_timeout(self, call: Any, timeout_status: str) -> dict[str, Any]: result: dict[str, Any] | None = None error: Exception | None = None @@ -503,11 +512,15 @@ def _apply_preview_result(self, request: PreviewRequest, result: dict[str, Any]) else: status = str(result.get("status") or "invalid") self.session.feasibility.status = ( - FeasibilityStatus.COLLISION if status == "COLLISION" else FeasibilityStatus.IK_FAILED + FeasibilityStatus.COLLISION + if status == "COLLISION" + else FeasibilityStatus.IK_FAILED ) self.session.feasibility.message = str(result.get("message") or status) self.session.target_status = TargetStatus.INFEASIBLE - self._set_target_visual_state(self.session.feasibility.status == FeasibilityStatus.FEASIBLE) + self._set_target_visual_state( + self.session.feasibility.status == FeasibilityStatus.FEASIBLE + ) self._update_gui_state() def _sync_controls_from_targets(self) -> None: @@ -515,7 +528,9 @@ def _sync_controls_from_targets(self) -> None: self.session.sync_source = "cartesian" try: info = self.session.robot_info or {} - for name, value in zip(info.get("joint_names") or [], self.session.joint_target, strict=False): + for name, value in zip( + info.get("joint_names") or [], self.session.joint_target, strict=False + ): if name in self._joint_sliders: self._joint_sliders[name].value = float(value) if self.session.selected_robot: @@ -589,7 +604,9 @@ def _plan(self) -> None: self.session.plan_state.robot = robot self.session.plan_state.target_pose = self.session.cartesian_target self.session.plan_state.target_joints = target - self.session.plan_state.start_joints_snapshot = list(current) if current is not None else None + self.session.plan_state.start_joints_snapshot = ( + list(current) if current is not None else None + ) self.session.plan_state.planned_path = list(path or []) self._render_plan_path(self.session.plan_state.planned_path) else: @@ -646,7 +663,7 @@ def _render_plan_path(self, path: Sequence[JointState]) -> None: if len(positions) >= 2: self._handles["plan_path"] = self._server.scene.add_line_segments( "/plans/path", - points=[[start, end] for start, end in zip(positions[:-1], positions[1:], strict=False)], + points=[[start, end] for start, end in itertools.pairwise(positions)], colors=(80, 180, 255), ) @@ -751,7 +768,9 @@ def _prepared_urdf_path(self, info: dict[str, Any]) -> Path: prepare_urdf_for_drake( Path(str(info["model_path"])), package_paths=package_paths, - xacro_args={str(key): str(value) for key, value in (info.get("xacro_args") or {}).items()}, + xacro_args={ + str(key): str(value) for key, value in (info.get("xacro_args") or {}).items() + }, ) ) @@ -828,7 +847,9 @@ def _set_urdf_mesh_material( def _can_execute_from_ui(self, require_no_operation: bool = True) -> bool: if not self.panel_config.allow_plan_execute: return False - if require_no_operation and not self.session.can_execute(self.panel_config.current_match_tolerance): + if require_no_operation and not self.session.can_execute( + self.panel_config.current_match_tolerance + ): return False if not require_no_operation and not self._can_execute_for_operation(): return False diff --git a/dimos/manipulation/viser_panel/state.py b/dimos/manipulation/viser_panel/state.py index 4ead09ce18..00f60ac928 100644 --- a/dimos/manipulation/viser_panel/state.py +++ b/dimos/manipulation/viser_panel/state.py @@ -14,12 +14,13 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum import queue import threading import time -from typing import Any, Callable, Literal +from typing import Any, Literal from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.sensor_msgs.JointState import JointState @@ -165,7 +166,9 @@ def can_execute(self, current_tolerance: float) -> bool: return False return all( abs(expected - current) <= current_tolerance - for expected, current in zip(plan.start_joints_snapshot, self.current_joints, strict=False) + for expected, current in zip( + plan.start_joints_snapshot, self.current_joints, strict=False + ) ) @property From 9f5af563922f6a31ac40e6c8ce2c2190ab96bc94 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 14:57:41 -0700 Subject: [PATCH 5/8] refactor: split viser panel architecture --- dimos/manipulation/test_viser_panel.py | 37 +- dimos/manipulation/viser_panel/animation.py | 72 +++ dimos/manipulation/viser_panel/backend.py | 115 +++++ dimos/manipulation/viser_panel/controller.py | 52 +++ dimos/manipulation/viser_panel/gui.py | 164 +++++++ dimos/manipulation/viser_panel/module.py | 436 +++++------------- dimos/manipulation/viser_panel/scene.py | 204 ++++++++ docs/capabilities/manipulation/readme.md | 2 +- .../.openspec.yaml | 0 .../design.md | 0 .../docs.md | 0 .../proposal.md | 0 .../specs/manipulation-operator-panel/spec.md | 0 .../target-ui.html | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../design.md | 204 ++++++++ .../docs.md | 27 ++ .../proposal.md | 36 ++ .../specs/manipulation-operator-panel/spec.md | 22 + .../tasks.md | 24 + .../specs/manipulation-operator-panel/spec.md | 155 +++++++ 22 files changed, 1232 insertions(+), 320 deletions(-) create mode 100644 dimos/manipulation/viser_panel/animation.py create mode 100644 dimos/manipulation/viser_panel/backend.py create mode 100644 dimos/manipulation/viser_panel/controller.py create mode 100644 dimos/manipulation/viser_panel/gui.py create mode 100644 dimos/manipulation/viser_panel/scene.py rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/.openspec.yaml (100%) rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/design.md (100%) rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/docs.md (100%) rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/proposal.md (100%) rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/specs/manipulation-operator-panel/spec.md (100%) rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/target-ui.html (100%) rename openspec/changes/{add-viser-manipulation-panel => archive/2026-06-06-add-viser-manipulation-panel}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/design.md create mode 100644 openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/docs.md create mode 100644 openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/proposal.md create mode 100644 openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/specs/manipulation-operator-panel/spec.md create mode 100644 openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/tasks.md create mode 100644 openspec/specs/manipulation-operator-panel/spec.md diff --git a/dimos/manipulation/test_viser_panel.py b/dimos/manipulation/test_viser_panel.py index cf673c9fb3..44681bb883 100644 --- a/dimos/manipulation/test_viser_panel.py +++ b/dimos/manipulation/test_viser_panel.py @@ -22,14 +22,19 @@ import pytest +from dimos.manipulation.viser_panel.animation import interpolate_joint_path +from dimos.manipulation.viser_panel.backend import PanelBackend from dimos.manipulation.viser_panel.module import ( + ViserManipulationPanelConfig, + ViserManipulationPanelModule, +) +from dimos.manipulation.viser_panel.scene import ( GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY, GOAL_ROBOT_INFEASIBLE_COLOR, GOAL_ROBOT_INFEASIBLE_OPACITY, GOAL_ROBOT_MESH_COLOR, - ViserManipulationPanelConfig, - ViserManipulationPanelModule, + PanelScene, ) from dimos.manipulation.viser_panel.state import ( ActionStatus, @@ -81,6 +86,11 @@ def add_line_segments(self, _name: str, **kwargs: object) -> FakeHandle: return FakeHandle() +class FakeServer: + def __init__(self) -> None: + self.scene = FakeScene() + + class FakeViserUrdf: def __init__(self) -> None: self._urdf = type( @@ -185,15 +195,19 @@ def test_panel_ignores_stale_preview_result(): def test_panel_preview_request_times_out_without_blocking_worker_forever(): - panel = _make_panel() - cast("ViserManipulationPanelConfig", panel.config).preview_request_timeout = 0.01 + backend = PanelBackend( + module_ref=None, + module_rpc=None, + remote_factory=MagicMock(), + timeout_seconds=lambda: 0.01, + ) release = threading.Event() def slow_preview() -> dict[str, object]: release.wait(timeout=1.0) return {"success": True} - result = panel._call_preview_with_timeout(slow_preview, "IK_TIMEOUT") + result = backend.call_preview_with_timeout(slow_preview, "IK_TIMEOUT") release.set() time.sleep(0.02) @@ -361,13 +375,15 @@ def test_panel_execute_requires_operator_launch_opt_in(): def test_panel_renders_plan_path_with_viser_line_segment_shape(): - panel = _make_panel() + session = PanelSession(selected_robot="arm") + handles: dict[str, object] = {} + server = FakeServer() + scene = PanelScene(server, session, handles, {}, None) path = [_joint_state([0.0]), _joint_state([0.1]), _joint_state([0.2])] - panel._render_plan_path(path) + scene.render_plan_path(path) - assert panel._server is not None - assert panel._server.scene.line_segments[0]["points"] == [ + assert server.scene.line_segments[0]["points"] == [ [[0.0, 0.0, 0.02], [1.0, 0.1, 0.02]], [[1.0, 0.1, 0.02], [2.0, 0.2, 0.02]], ] @@ -485,10 +501,9 @@ def test_panel_preview_animates_viser_ghost_path(): def test_panel_interpolates_sparse_preview_path(): - panel = _make_panel() path = [_joint_state([0.0, 0.0]), _joint_state([1.0, 2.0])] - frames = panel._interpolate_joint_path(path, duration=1.0, fps=2.0) + frames = interpolate_joint_path(path, duration=1.0, fps=2.0) assert frames == [[0.0, 0.0], [0.5, 1.0], [1.0, 2.0]] diff --git a/dimos/manipulation/viser_panel/animation.py b/dimos/manipulation/viser_panel/animation.py new file mode 100644 index 0000000000..0ba9607e87 --- /dev/null +++ b/dimos/manipulation/viser_panel/animation.py @@ -0,0 +1,72 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from collections.abc import Callable, Sequence +import time + +from dimos.msgs.sensor_msgs.JointState import JointState + + +def interpolate_joint_path( + path: Sequence[JointState], duration: float, fps: float +) -> list[list[float]]: + waypoints = [list(waypoint.position) for waypoint in path if waypoint.position] + if not waypoints: + return [] + if len(waypoints) == 1 or duration <= 0.0: + return [waypoints[-1]] + frame_count = max(int(duration * max(fps, 1.0)) + 1, len(waypoints)) + segment_count = len(waypoints) - 1 + frames: list[list[float]] = [] + for frame_index in range(frame_count): + path_t = frame_index / max(frame_count - 1, 1) + scaled = path_t * segment_count + segment_index = min(int(scaled), segment_count - 1) + local_t = scaled - segment_index + start = waypoints[segment_index] + end = waypoints[segment_index + 1] + if len(start) != len(end): + continue + frames.append( + [ + start_value + (end_value - start_value) * local_t + for start_value, end_value in zip(start, end, strict=False) + ] + ) + if frames[-1] != waypoints[-1]: + frames.append(waypoints[-1]) + return frames + + +class PreviewAnimator: + def __init__( + self, + set_joints: Callable[[Sequence[float]], object], + *, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._set_joints = set_joints + self._sleep = sleep + + def animate(self, path: Sequence[JointState], duration: float, fps: float) -> bool: + frames = interpolate_joint_path(path, duration, fps) + if not frames: + return False + step_delay = duration / max(len(frames) - 1, 1) if duration > 0.0 else 0.0 + for joints in frames: + self._set_joints(joints) + self._sleep(step_delay) + return True diff --git a/dimos/manipulation/viser_panel/backend.py b/dimos/manipulation/viser_panel/backend.py new file mode 100644 index 0000000000..18c448e8df --- /dev/null +++ b/dimos/manipulation/viser_panel/backend.py @@ -0,0 +1,115 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from collections.abc import Callable +import threading +from typing import Any + +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.protocol.rpc.pubsubrpc import LCMRPC + + +class PanelBackend: + def __init__( + self, + *, + module_ref: Any | None, + module_rpc: Any | None, + remote_factory: Callable[..., Any], + timeout_seconds: Callable[[], float], + ) -> None: + self._module_ref = module_ref + self._module_rpc = module_rpc + self._remote_factory = remote_factory + self._timeout_seconds = timeout_seconds + self.client: Any | None = None + + def reset_client(self) -> Any | None: + if self.client is not None and self.client is not self._module_ref: + self.client.stop_rpc_client() + if self._module_ref is not None: + self.client = self._module_ref + return self.client + self.client = self._remote_factory( + ManipulationModule, + rpc=self._module_rpc if isinstance(self._module_rpc, LCMRPC) else None, + ) + return self.client + + def close(self) -> None: + if self.client is not None and self.client is not self._module_ref: + self.client.stop_rpc_client() + self.client = None + + def ensure_client(self) -> Any: + if self.client is None: + self.reset_client() + if self.client is None: + raise RuntimeError("Manipulation RPC client is not connected") + return self.client + + def list_robots(self) -> list[str]: + client = self.ensure_client() + try: + return list(client.list_robots()) + except TimeoutError: + self.reset_client() + return list(self.ensure_client().list_robots()) + + def call_preview_with_timeout( + self, call: Callable[[], dict[str, Any]], timeout_status: str + ) -> dict[str, Any]: + result: dict[str, Any] | None = None + error: Exception | None = None + + def run() -> None: + nonlocal result, error + try: + result = call() + except Exception as e: + error = e + + thread = threading.Thread(target=run, daemon=True) + thread.start() + timeout = max(self._timeout_seconds(), 0.0) + thread.join(timeout=timeout) + if thread.is_alive(): + return { + "success": False, + "status": timeout_status, + "message": f"Preview request timed out after {timeout:.1f}s", + "collision_free": False, + } + if error is not None: + raise error + return result or { + "success": False, + "status": "EMPTY_RESULT", + "message": "Preview request returned no result", + "collision_free": False, + } + + def solve_ik_preview(self, pose: Any, robot_name: str) -> dict[str, Any]: + client = self.ensure_client() + return self.call_preview_with_timeout( + lambda: dict(client.solve_ik_preview(pose, robot_name)), "IK_TIMEOUT" + ) + + def solve_fk_preview(self, joints: Any, robot_name: str) -> dict[str, Any]: + client = self.ensure_client() + return self.call_preview_with_timeout( + lambda: dict(client.solve_fk_preview(joints, robot_name)), "FK_TIMEOUT" + ) diff --git a/dimos/manipulation/viser_panel/controller.py b/dimos/manipulation/viser_panel/controller.py new file mode 100644 index 0000000000..aa9cf8c4c0 --- /dev/null +++ b/dimos/manipulation/viser_panel/controller.py @@ -0,0 +1,52 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from typing import Any + + +class PanelController: + def __init__(self, panel: Any) -> None: + self._panel = panel + + def refresh_panel_state(self) -> dict[str, Any]: + return self._panel._refresh_panel_state_impl() + + def select_robot(self, robot_name: str) -> None: + self._panel._select_robot_impl(robot_name) + + def apply_preset(self, preset: str) -> None: + self._panel._apply_preset_impl(preset) + + def target_pose_changed(self, target: Any) -> None: + self._panel._target_pose_changed_impl(target) + + def joint_slider_changed(self, joint_name: str) -> None: + self._panel._joint_slider_changed_impl(joint_name) + + def plan(self) -> None: + self._panel._plan_impl() + + def preview(self) -> None: + self._panel._preview_impl() + + def execute(self) -> None: + self._panel._execute_impl() + + def cancel(self) -> None: + self._panel._cancel_impl() + + def clear_plan(self) -> None: + self._panel._clear_plan_impl() diff --git a/dimos/manipulation/viser_panel/gui.py b/dimos/manipulation/viser_panel/gui.py new file mode 100644 index 0000000000..a9cb876bb2 --- /dev/null +++ b/dimos/manipulation/viser_panel/gui.py @@ -0,0 +1,164 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +from dimos.manipulation.viser_panel.state import ActionStatus, FeasibilityStatus, PanelSession + + +class PanelGui: + def __init__( + self, + server: Any | None, + session: PanelSession, + handles: dict[str, Any], + joint_sliders: dict[str, Any], + ) -> None: + self.server = server + self.session = session + self.handles = handles + self.joint_sliders = joint_sliders + + def build( + self, + *, + on_robot: Callable[[str], None], + on_preset: Callable[[str], None], + on_plan: Callable[[], None], + on_preview: Callable[[], None], + on_execute: Callable[[], None], + on_cancel: Callable[[], None], + on_clear_plan: Callable[[], None], + ) -> None: + if self.server is None: + return + gui = self.server.gui + self.handles["status"] = gui.add_text("Status", initial_value="Disconnected") + self.handles["error"] = gui.add_text("Error", initial_value="") + self.handles["feasibility"] = gui.add_text("Feasibility", initial_value="unknown") + self.handles["robot"] = gui.add_dropdown( + "Robot", options=["No robots"], initial_value="No robots" + ) + self.handles["preset"] = gui.add_dropdown( + "Target Preset", + options=["Select preset...", "Current"], + initial_value="Select preset...", + ) + self.handles["plan"] = gui.add_button("Plan", disabled=True) + self.handles["preview"] = gui.add_button("Preview", disabled=True) + self.handles["execute"] = gui.add_button("Execute", disabled=True) + self.handles["cancel"] = gui.add_button("Cancel") + self.handles["clear_plan"] = gui.add_button("Clear Plan") + self.handles["robot"].on_update(lambda event: on_robot(str(event.target.value))) + self.handles["preset"].on_update(lambda event: on_preset(str(event.target.value))) + self.handles["plan"].on_click(lambda _: on_plan()) + self.handles["preview"].on_click(lambda _: on_preview()) + self.handles["execute"].on_click(lambda _: on_execute()) + self.handles["cancel"].on_click(lambda _: on_cancel()) + self.handles["clear_plan"].on_click(lambda _: on_clear_plan()) + + def update_robot_dropdown(self, robots: Sequence[str]) -> None: + handle = self.handles.get("robot") + if handle is None: + return + options = list(robots) or ["No robots"] + handle.options = options + handle.value = self.session.selected_robot or options[0] + + def build_joint_sliders( + self, robot_name: str, info: dict[str, Any], on_change: Callable[[str], None] + ) -> None: + del robot_name + if self.server is None: + return + names = list(info.get("joint_names") or []) + limits = info.get("joint_limits") or [] + values = self.session.current_joints or [0.0] * len(names) + for index, name in enumerate(names): + lower, upper = (-3.14, 3.14) + if index < len(limits) and limits[index] is not None: + lower, upper = limits[index] + initial = values[index] if index < len(values) else 0.0 + slider = self.server.gui.add_slider( + name, + min=float(lower), + max=float(upper), + step=0.001, + initial_value=float(initial), + ) + slider.on_update(lambda _event, joint_name=name: on_change(joint_name)) + self.joint_sliders[name] = slider + + def update_preset_options(self, info: dict[str, Any]) -> None: + preset = self.handles.get("preset") + if preset is None: + return + options = ["Select preset...", "Current"] + if info.get("init_joints") is not None: + options.append("Init") + if info.get("home_joints") is not None: + options.append("Home") + preset.options = options + + def sync_controls_from_targets( + self, + *, + set_ghost_joints: Callable[[Sequence[float]], object], + ) -> None: + if self.session.joint_target is not None: + self.session.sync_source = "cartesian" + try: + info = self.session.robot_info or {} + for name, value in zip( + info.get("joint_names") or [], self.session.joint_target, strict=False + ): + if name in self.joint_sliders: + self.joint_sliders[name].value = float(value) + if self.session.action_status != ActionStatus.PREVIEWING: + set_ghost_joints(self.session.joint_target) + finally: + self.session.sync_source = None + pose = self.session.cartesian_target + ee_control = self.handles.get("ee_control") + if pose is not None and ee_control is not None: + self.session.sync_source = "joints" + try: + ee_control.position = (pose.position.x, pose.position.y, pose.position.z) + ee_control.wxyz = ( + pose.orientation.w, + pose.orientation.x, + pose.orientation.y, + pose.orientation.z, + ) + finally: + self.session.sync_source = None + + def update_gui_state( + self, + *, + can_execute: bool, + set_target_visual_state: Callable[[bool], None], + ) -> None: + if not self.handles: + return + self.handles["status"].value = self.session.module_state + self.handles["error"].value = self.session.error + self.handles["feasibility"].value = self.session.feasibility.status.value + set_target_visual_state(self.session.feasibility.status == FeasibilityStatus.FEASIBLE) + self.handles["plan"].disabled = not self.session.can_plan() + self.handles["preview"].disabled = not self.session.can_preview() + self.handles["execute"].disabled = not can_execute diff --git a/dimos/manipulation/viser_panel/module.py b/dimos/manipulation/viser_panel/module.py index 5cbb90d476..34afe7118e 100644 --- a/dimos/manipulation/viser_panel/module.py +++ b/dimos/manipulation/viser_panel/module.py @@ -16,8 +16,6 @@ from collections.abc import Sequence import importlib -import itertools -from pathlib import Path import threading import time import traceback @@ -28,7 +26,11 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.rpc_client import RPCClient from dimos.manipulation.manipulation_module import ManipulationModule -from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake +from dimos.manipulation.viser_panel.animation import interpolate_joint_path +from dimos.manipulation.viser_panel.backend import PanelBackend +from dimos.manipulation.viser_panel.controller import PanelController +from dimos.manipulation.viser_panel.gui import PanelGui +from dimos.manipulation.viser_panel.scene import PanelScene from dimos.manipulation.viser_panel.state import ( ActionStatus, BackendConnectionStatus, @@ -44,7 +46,6 @@ from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState -from dimos.protocol.rpc.pubsubrpc import LCMRPC from dimos.utils.logging_config import setup_logger logger = setup_logger() @@ -55,11 +56,6 @@ VISER_URDF_INSTALL_HINT = ( "Viser URDF support requires yourdfpy. Install it with: uv sync --extra manipulation-viser" ) -GOAL_ROBOT_FEASIBLE_COLOR = (255, 122, 0) -GOAL_ROBOT_INFEASIBLE_COLOR = (255, 30, 30) -GOAL_ROBOT_FEASIBLE_OPACITY = 0.7 -GOAL_ROBOT_INFEASIBLE_OPACITY = 0.75 -GOAL_ROBOT_MESH_COLOR = (*GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY) class ViserManipulationPanelConfig(ModuleConfig): @@ -89,6 +85,10 @@ def __init__(self, **kwargs: Any) -> None: self._urdfs: dict[str, Any] = {} self._handles: dict[str, Any] = {} self._joint_sliders: dict[str, Any] = {} + self._backend: PanelBackend | None = None + self._gui: PanelGui | None = None + self._scene: PanelScene | None = None + self._controller: PanelController | None = None self._stop_event = threading.Event() self._poll_thread: threading.Thread | None = None self._preview_worker = PreviewWorker( @@ -102,6 +102,52 @@ def __init__(self, **kwargs: Any) -> None: def panel_config(self) -> ViserManipulationPanelConfig: return cast("ViserManipulationPanelConfig", self.config) + def _ensure_backend(self) -> PanelBackend: + backend = getattr(self, "_backend", None) + if backend is None: + backend = PanelBackend( + module_ref=getattr(self, "manipulation", None), + module_rpc=getattr(self, "rpc", None), + remote_factory=RPCClient.remote, + timeout_seconds=lambda: self.panel_config.preview_request_timeout, + ) + self._backend = backend + if getattr(self, "_client", None) is not None and backend.client is None: + backend.client = self._client + return backend + + def _ensure_gui(self) -> PanelGui: + gui = getattr(self, "_gui", None) + if gui is None: + gui = PanelGui(self._server, self.session, self._handles, self._joint_sliders) + self._gui = gui + else: + gui.server = self._server + return gui + + def _ensure_scene(self) -> PanelScene: + scene = getattr(self, "_scene", None) + if scene is None: + scene = PanelScene( + self._server, + self.session, + self._handles, + self._urdfs, + self._viser_urdf, + ) + self._scene = scene + else: + scene.server = self._server + scene.viser_urdf = self._viser_urdf + return scene + + def _ensure_controller(self) -> PanelController: + controller = getattr(self, "_controller", None) + if controller is None: + controller = PanelController(self) + self._controller = controller + return controller + @rpc def start(self) -> None: super().start() @@ -130,7 +176,7 @@ def stop(self) -> None: if self._poll_thread and self._poll_thread.is_alive(): self._poll_thread.join(timeout=2.0) if self._client is not None: - self._client.stop_rpc_client() + self._ensure_backend().close() self._client = None if self._server is not None and hasattr(self._server, "stop"): self._server.stop() @@ -152,28 +198,19 @@ def _import_viser_urdf(self) -> Any: return viser_extras.ViserUrdf def _build_gui(self) -> None: - if self._server is None: - return - gui = self._server.gui - self._handles["status"] = gui.add_text("Status", initial_value="Disconnected") - self._handles["error"] = gui.add_text("Error", initial_value="") - self._handles["feasibility"] = gui.add_text("Feasibility", initial_value="unknown") - self._handles["robot"] = gui.add_dropdown( - "Robot", options=["No robots"], initial_value="No robots" + self._ensure_gui().build( + on_robot=self._select_robot, + on_preset=self._apply_preset, + on_plan=lambda: self._run_operation("Plan", self._plan), + on_preview=lambda: self._run_operation("Preview", self._preview), + on_execute=lambda: self._run_operation("Execute", self._execute), + on_cancel=lambda: self._run_operation("Cancel", self._cancel), + on_clear_plan=lambda: self._run_operation("Clear Plan", self._clear_plan), ) - self._handles["preset"] = gui.add_dropdown( - "Target Preset", - options=["Select preset...", "Current"], - initial_value="Select preset...", - ) - self._handles["plan"] = gui.add_button("Plan", disabled=True) - self._handles["preview"] = gui.add_button("Preview", disabled=True) - self._handles["execute"] = gui.add_button("Execute", disabled=True) - self._handles["cancel"] = gui.add_button("Cancel") - self._handles["clear_plan"] = gui.add_button("Clear Plan") - self._wire_static_callbacks() def _wire_static_callbacks(self) -> None: + if not self._handles: + return self._handles["robot"].on_update(lambda event: self._select_robot(str(event.target.value))) self._handles["preset"].on_update(lambda event: self._apply_preset(str(event.target.value))) self._handles["plan"].on_click(lambda _: self._run_operation("Plan", self._plan)) @@ -191,34 +228,19 @@ def _poll_loop(self) -> None: self._stop_event.wait(interval) def _reset_manipulation_client(self) -> None: - if self._client is not None: - self._client.stop_rpc_client() - if hasattr(self, "manipulation"): - self._client = self.manipulation - return - module_rpc = getattr(self, "rpc", None) - self._client = RPCClient.remote( - ManipulationModule, - rpc=module_rpc if isinstance(module_rpc, LCMRPC) else None, - ) + self._backend = None + self._client = self._ensure_backend().reset_client() def _list_robots(self) -> list[str]: - if self._client is None: - self._reset_manipulation_client() - client = self._client - if client is None: - raise RuntimeError("Manipulation RPC client is not connected") - try: - return list(client.list_robots()) - except TimeoutError: - self._reset_manipulation_client() - client = self._client - if client is None: - raise RuntimeError("Manipulation RPC client is not connected") - return list(client.list_robots()) + robots = self._ensure_backend().list_robots() + self._client = self._ensure_backend().client + return robots @rpc def refresh_panel_state(self) -> dict[str, Any]: + return self._ensure_controller().refresh_panel_state() + + def _refresh_panel_state_impl(self) -> dict[str, Any]: with self._lock: try: try: @@ -281,6 +303,9 @@ def _mark_waiting_for_robot(self, message: str) -> None: self.session.error = message def _select_robot(self, robot_name: str) -> None: + self._ensure_controller().select_robot(robot_name) + + def _select_robot_impl(self, robot_name: str) -> None: if robot_name in {"", "No robots"}: return with self._lock: @@ -291,12 +316,7 @@ def _select_robot(self, robot_name: str) -> None: self._joint_sliders.clear() def _update_robot_dropdown(self, robots: Sequence[str]) -> None: - handle = self._handles.get("robot") - if handle is None: - return - options = list(robots) or ["No robots"] - handle.options = options - handle.value = self.session.selected_robot or options[0] + self._ensure_gui().update_robot_dropdown(robots) def _ensure_robot_ui(self, robot_name: str) -> None: info = self.session.robot_info or {} @@ -307,76 +327,25 @@ def _ensure_robot_ui(self, robot_name: str) -> None: self._update_current_robot(robot_name) def _ensure_scene_nodes(self, robot_name: str, info: dict[str, Any]) -> None: - if self._server is None: - return - if "ee_control" not in self._handles: - self._handles["ee_control"] = self._server.scene.add_transform_controls( - f"/targets/{robot_name}/ee_control", scale=0.25 - ) - self._handles["ee_control"].on_update( - lambda event: self._target_pose_changed(event.target) - ) - ViserUrdf = self._viser_urdf - if ViserUrdf is None or not info.get("model_path"): - return - for kind in ("current", "ghost"): - key = f"{robot_name}:{kind}" - if key not in self._urdfs: - root_node_name = ( - f"/robots/{robot_name}/current" - if kind == "current" - else f"/targets/{robot_name}/ghost" - ) - mesh_color_override = GOAL_ROBOT_MESH_COLOR if kind == "ghost" else None - self._urdfs[key] = ViserUrdf( - self._server, - self._prepared_urdf_path(info), - root_node_name=root_node_name, - mesh_color_override=mesh_color_override, - ) - if kind == "ghost": - self._set_urdf_mesh_material( - self._urdfs[key], GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY - ) + scene = self._ensure_scene() + prepared_urdf_path = self._prepared_urdf_path + if getattr(prepared_urdf_path, "__func__", None) is not type(self)._prepared_urdf_path: + scene.prepared_urdf_path = prepared_urdf_path + scene.ensure_scene_nodes(robot_name, info, self._target_pose_changed) def _build_joint_sliders(self, robot_name: str, info: dict[str, Any]) -> None: - if self._server is None: - return - names = list(info.get("joint_names") or []) - limits = info.get("joint_limits") or [] - values = self.session.current_joints or [0.0] * len(names) - for index, name in enumerate(names): - lower, upper = (-3.14, 3.14) - if index < len(limits) and limits[index] is not None: - lower, upper = limits[index] - initial = values[index] if index < len(values) else 0.0 - slider = self._server.gui.add_slider( - name, - min=float(lower), - max=float(upper), - step=0.001, - initial_value=float(initial), - ) - slider.on_update(lambda _event, joint_name=name: self._joint_slider_changed(joint_name)) - self._joint_sliders[name] = slider + self._ensure_gui().build_joint_sliders(robot_name, info, self._joint_slider_changed) def _update_preset_options(self, info: dict[str, Any]) -> None: - preset = self._handles.get("preset") - if preset is None: - return - options = ["Select preset...", "Current"] - if info.get("init_joints") is not None: - options.append("Init") - if info.get("home_joints") is not None: - options.append("Home") - preset.options = options + self._ensure_gui().update_preset_options(info) def _update_current_robot(self, robot_name: str) -> None: - current = self._urdfs.get(f"{robot_name}:current") - if current is not None and self.session.current_joints is not None: - self._set_urdf_joints(current, self.session.current_joints) + self._ensure_scene().update_current_robot(robot_name) def _target_pose_changed(self, target: Any) -> None: + self._ensure_controller().target_pose_changed(target) + + def _target_pose_changed_impl(self, target: Any) -> None: with self._lock: if self.session.sync_source == "joints" or self.session.selected_robot is None: return @@ -389,6 +358,9 @@ def _target_pose_changed(self, target: Any) -> None: ) def _joint_slider_changed(self, _joint_name: str) -> None: + self._ensure_controller().joint_slider_changed(_joint_name) + + def _joint_slider_changed_impl(self, _joint_name: str) -> None: with self._lock: if self.session.sync_source == "cartesian" or self.session.selected_robot is None: return @@ -407,6 +379,9 @@ def _joint_slider_changed(self, _joint_name: str) -> None: ) def _apply_preset(self, preset: str) -> None: + self._ensure_controller().apply_preset(preset) + + def _apply_preset_impl(self, preset: str) -> None: if preset in {"", "Select preset..."} or self.session.selected_robot is None: return with self._lock: @@ -463,35 +438,7 @@ def _handle_preview_request(self, request: PreviewRequest) -> dict[str, Any]: return {"success": False, "status": "ERROR", "message": str(e)} def _call_preview_with_timeout(self, call: Any, timeout_status: str) -> dict[str, Any]: - result: dict[str, Any] | None = None - error: Exception | None = None - - def run() -> None: - nonlocal result, error - try: - result = call() - except Exception as e: - error = e - - thread = threading.Thread(target=run, daemon=True) - thread.start() - timeout = max(self.panel_config.preview_request_timeout, 0.0) - thread.join(timeout=timeout) - if thread.is_alive(): - return { - "success": False, - "status": timeout_status, - "message": f"Preview request timed out after {timeout:.1f}s", - "collision_free": False, - } - if error is not None: - raise error - return result or { - "success": False, - "status": "EMPTY_RESULT", - "message": "Preview request returned no result", - "collision_free": False, - } + return self._ensure_backend().call_preview_with_timeout(call, timeout_status) def _apply_preview_result(self, request: PreviewRequest, result: dict[str, Any]) -> None: with self._lock: @@ -524,35 +471,9 @@ def _apply_preview_result(self, request: PreviewRequest, result: dict[str, Any]) self._update_gui_state() def _sync_controls_from_targets(self) -> None: - if self.session.joint_target is not None: - self.session.sync_source = "cartesian" - try: - info = self.session.robot_info or {} - for name, value in zip( - info.get("joint_names") or [], self.session.joint_target, strict=False - ): - if name in self._joint_sliders: - self._joint_sliders[name].value = float(value) - if self.session.selected_robot: - ghost = self._urdfs.get(f"{self.session.selected_robot}:ghost") - if ghost is not None and self.session.action_status != ActionStatus.PREVIEWING: - self._set_urdf_joints(ghost, self.session.joint_target) - finally: - self.session.sync_source = None - pose = self.session.cartesian_target - ee_control = self._handles.get("ee_control") - if pose is not None and ee_control is not None: - self.session.sync_source = "joints" - try: - ee_control.position = (pose.position.x, pose.position.y, pose.position.z) - ee_control.wxyz = ( - pose.orientation.w, - pose.orientation.x, - pose.orientation.y, - pose.orientation.z, - ) - finally: - self.session.sync_source = None + self._ensure_gui().sync_controls_from_targets( + set_ghost_joints=lambda joints: self._ensure_scene().set_selected_ghost_joints(joints) + ) def _run_operation(self, name: str, operation: Any) -> None: action = { @@ -581,6 +502,9 @@ def run() -> None: threading.Thread(target=run, daemon=True).start() def _plan(self) -> None: + self._ensure_controller().plan() + + def _plan_impl(self) -> None: if ( self._client is None or not self._can_plan_for_operation() @@ -614,6 +538,9 @@ def _plan(self) -> None: self.session.error = str(self._client.get_error() or "Planning failed") def _preview(self) -> None: + self._ensure_controller().preview() + + def _preview_impl(self) -> None: if self._client is None or self.session.selected_robot is None: return path = list(self._client.get_planned_path(self.session.selected_robot) or []) @@ -626,6 +553,9 @@ def _preview(self) -> None: self.session.plan_state.status = PlanStatus.FAILED def _execute(self) -> None: + self._ensure_controller().execute() + + def _execute_impl(self) -> None: if ( self._client is None or self.session.selected_robot is None @@ -641,85 +571,37 @@ def _execute(self) -> None: self.session.error = str(self._client.get_error() or "Execution failed") def _cancel(self) -> None: + self._ensure_controller().cancel() + + def _cancel_impl(self) -> None: if self._client is not None: self._client.cancel() def _clear_plan(self) -> None: + self._ensure_controller().clear_plan() + + def _clear_plan_impl(self) -> None: if self._client is not None: self._client.clear_planned_path() self.session.plan_state = self.session.plan_state.__class__() self._render_plan_path([]) def _render_plan_path(self, path: Sequence[JointState]) -> None: - if self._server is None: - return - positions = [ - [float(index), waypoint.position[0] if waypoint.position else 0.0, 0.02] - for index, waypoint in enumerate(path) - ] - if "plan_path" in self._handles: - self._handles["plan_path"].remove() - self._handles.pop("plan_path", None) - if len(positions) >= 2: - self._handles["plan_path"] = self._server.scene.add_line_segments( - "/plans/path", - points=[[start, end] for start, end in itertools.pairwise(positions)], - colors=(80, 180, 255), - ) + self._ensure_scene().render_plan_path(path) def _animate_ghost_path(self, path: Sequence[JointState], duration: float) -> None: - robot = self.session.selected_robot - if robot is None or not path: - return - ghost = self._urdfs.get(f"{robot}:ghost") - if ghost is None: - return - frames = self._interpolate_joint_path(path, duration, self.panel_config.preview_fps) - step_delay = duration / max(len(frames) - 1, 1) if duration > 0.0 else 0.0 - for joints in frames: - self._set_urdf_joints(ghost, joints) - time.sleep(step_delay) + self._ensure_scene().animate_ghost_path(path, duration, self.panel_config.preview_fps) def _interpolate_joint_path( self, path: Sequence[JointState], duration: float, fps: float ) -> list[list[float]]: - waypoints = [list(waypoint.position) for waypoint in path if waypoint.position] - if not waypoints: - return [] - if len(waypoints) == 1 or duration <= 0.0: - return [waypoints[-1]] - frame_count = max(int(duration * max(fps, 1.0)) + 1, len(waypoints)) - segment_count = len(waypoints) - 1 - frames: list[list[float]] = [] - for frame_index in range(frame_count): - path_t = frame_index / max(frame_count - 1, 1) - scaled = path_t * segment_count - segment_index = min(int(scaled), segment_count - 1) - local_t = scaled - segment_index - start = waypoints[segment_index] - end = waypoints[segment_index + 1] - if len(start) != len(end): - continue - frames.append( - [ - start_value + (end_value - start_value) * local_t - for start_value, end_value in zip(start, end, strict=False) - ] - ) - if frames[-1] != waypoints[-1]: - frames.append(waypoints[-1]) - return frames + return interpolate_joint_path(path, duration, fps) def _update_gui_state(self) -> None: - if not self._handles: - return - self._handles["status"].value = self.session.module_state - self._handles["error"].value = self.session.error - self._handles["feasibility"].value = self.session.feasibility.status.value - self._set_target_visual_state(self.session.feasibility.status == FeasibilityStatus.FEASIBLE) - self._handles["plan"].disabled = not self.session.can_plan() - self._handles["preview"].disabled = not self.session.can_preview() - self._handles["execute"].disabled = not self._can_execute_from_ui() + self._ensure_gui().update_gui_state( + can_execute=self._can_execute_from_ui(), + set_target_visual_state=self._set_target_visual_state, + ) def _snapshot(self) -> dict[str, Any]: return { @@ -760,89 +642,27 @@ def _make_joint_state(self, names: Sequence[str], positions: Sequence[float]) -> joint_state.effort = [] return joint_state - def _prepared_urdf_path(self, info: dict[str, Any]) -> Path: - package_paths = { - package: Path(path) for package, path in (info.get("package_paths") or {}).items() - } - return Path( - prepare_urdf_for_drake( - Path(str(info["model_path"])), - package_paths=package_paths, - xacro_args={ - str(key): str(value) for key, value in (info.get("xacro_args") or {}).items() - }, - ) - ) + def _prepared_urdf_path(self, info: dict[str, Any]) -> Any: + return self._ensure_scene().prepared_urdf_path(info) def _set_urdf_joints(self, urdf: Any, joints: Sequence[float]) -> None: - joint_names = list((self.session.robot_info or {}).get("joint_names") or []) - named_joints = self._viser_joint_configuration(urdf, joint_names, joints) - if not named_joints: - return - if hasattr(urdf, "update_cfg"): - urdf.update_cfg(named_joints) - elif hasattr(urdf, "update_configuration"): - urdf.update_configuration(named_joints) + self._ensure_scene().set_urdf_joints(urdf, joints) def _viser_joint_configuration( self, urdf: Any, joint_names: Sequence[str], joints: Sequence[float] ) -> list[float]: - allowed_names = list(self._viser_actuated_joint_names(urdf)) - if not allowed_names: - return [] - values_by_name: dict[str, float] = {} - for name, value in zip(joint_names, joints, strict=False): - values_by_name[name] = float(value) - values_by_name[name.rsplit("/", 1)[-1]] = float(value) - return [values_by_name.get(name, 0.0) for name in allowed_names] + return self._ensure_scene().viser_joint_configuration(urdf, joint_names, joints) def _viser_actuated_joint_names(self, urdf: Any) -> tuple[str, ...]: - wrapped_urdf = getattr(urdf, "_urdf", None) - names = getattr(wrapped_urdf, "actuated_joint_names", None) - if names is not None: - return tuple(names) - joint_map = getattr(wrapped_urdf, "joint_map", None) - if isinstance(joint_map, dict): - return tuple(joint_map) - return () + return self._ensure_scene().viser_actuated_joint_names(urdf) def _set_target_visual_state(self, feasible: bool) -> None: - color = (0, 180, 255) if feasible else (255, 40, 40) - mesh_color = GOAL_ROBOT_FEASIBLE_COLOR if feasible else GOAL_ROBOT_INFEASIBLE_COLOR - mesh_opacity = GOAL_ROBOT_FEASIBLE_OPACITY if feasible else GOAL_ROBOT_INFEASIBLE_OPACITY - robot = self.session.selected_robot - handles = [self._handles.get("ee_control")] - if robot is not None: - ghost = self._urdfs.get(f"{robot}:ghost") - handles.append(ghost) - self._set_urdf_mesh_material(ghost, mesh_color, mesh_opacity) - for handle in handles: - if handle is None: - continue - for attr in ("color", "material_color"): - if hasattr(handle, attr): - try: - setattr(handle, attr, color) - except Exception: - pass + self._ensure_scene().set_target_visual_state(feasible) def _set_urdf_mesh_material( self, urdf: Any | None, color: tuple[int, int, int], opacity: float ) -> None: - if urdf is None: - return - for mesh in getattr(urdf, "_meshes", ()): - for attr in ("color", "material_color"): - if hasattr(mesh, attr): - try: - setattr(mesh, attr, color) - except Exception: - pass - if hasattr(mesh, "opacity"): - try: - mesh.opacity = opacity - except Exception: - pass + self._ensure_scene().set_urdf_mesh_material(urdf, color, opacity) def _can_execute_from_ui(self, require_no_operation: bool = True) -> bool: if not self.panel_config.allow_plan_execute: diff --git a/dimos/manipulation/viser_panel/scene.py b/dimos/manipulation/viser_panel/scene.py new file mode 100644 index 0000000000..ec17111086 --- /dev/null +++ b/dimos/manipulation/viser_panel/scene.py @@ -0,0 +1,204 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# 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. + +from __future__ import annotations + +from collections.abc import Callable, Sequence +import itertools +from pathlib import Path +from typing import Any + +from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake +from dimos.manipulation.viser_panel.animation import PreviewAnimator +from dimos.manipulation.viser_panel.state import PanelSession +from dimos.msgs.sensor_msgs.JointState import JointState + +GOAL_ROBOT_FEASIBLE_COLOR = (255, 122, 0) +GOAL_ROBOT_INFEASIBLE_COLOR = (255, 30, 30) +GOAL_ROBOT_FEASIBLE_OPACITY = 0.7 +GOAL_ROBOT_INFEASIBLE_OPACITY = 0.75 +GOAL_ROBOT_MESH_COLOR = (*GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY) + + +class PanelScene: + def __init__( + self, + server: Any | None, + session: PanelSession, + handles: dict[str, Any], + urdfs: dict[str, Any], + viser_urdf: Any | None, + ) -> None: + self.server = server + self.session = session + self.handles = handles + self.urdfs = urdfs + self.viser_urdf = viser_urdf + + def ensure_scene_nodes( + self, + robot_name: str, + info: dict[str, Any], + on_target_update: Callable[[Any], None], + ) -> None: + if self.server is None: + return + if "ee_control" not in self.handles: + self.handles["ee_control"] = self.server.scene.add_transform_controls( + f"/targets/{robot_name}/ee_control", scale=0.25 + ) + self.handles["ee_control"].on_update(lambda event: on_target_update(event.target)) + ViserUrdf = self.viser_urdf + if ViserUrdf is None or not info.get("model_path"): + return + for kind in ("current", "ghost"): + key = f"{robot_name}:{kind}" + if key not in self.urdfs: + root_node_name = ( + f"/robots/{robot_name}/current" + if kind == "current" + else f"/targets/{robot_name}/ghost" + ) + mesh_color_override = GOAL_ROBOT_MESH_COLOR if kind == "ghost" else None + self.urdfs[key] = ViserUrdf( + self.server, + self.prepared_urdf_path(info), + root_node_name=root_node_name, + mesh_color_override=mesh_color_override, + ) + if kind == "ghost": + self.set_urdf_mesh_material( + self.urdfs[key], GOAL_ROBOT_FEASIBLE_COLOR, GOAL_ROBOT_FEASIBLE_OPACITY + ) + + def update_current_robot(self, robot_name: str) -> None: + current = self.urdfs.get(f"{robot_name}:current") + if current is not None and self.session.current_joints is not None: + self.set_urdf_joints(current, self.session.current_joints) + + def set_selected_ghost_joints(self, joints: Sequence[float]) -> bool: + robot = self.session.selected_robot + if robot is None: + return False + ghost = self.urdfs.get(f"{robot}:ghost") + if ghost is None: + return False + self.set_urdf_joints(ghost, joints) + return True + + def render_plan_path(self, path: Sequence[JointState]) -> None: + if self.server is None: + return + positions = [ + [float(index), waypoint.position[0] if waypoint.position else 0.0, 0.02] + for index, waypoint in enumerate(path) + ] + if "plan_path" in self.handles: + self.handles["plan_path"].remove() + self.handles.pop("plan_path", None) + if len(positions) >= 2: + self.handles["plan_path"] = self.server.scene.add_line_segments( + "/plans/path", + points=[[start, end] for start, end in itertools.pairwise(positions)], + colors=(80, 180, 255), + ) + + def animate_ghost_path(self, path: Sequence[JointState], duration: float, fps: float) -> bool: + if self.session.selected_robot is None or not path: + return False + return PreviewAnimator(self.set_selected_ghost_joints).animate(path, duration, fps) + + def prepared_urdf_path(self, info: dict[str, Any]) -> Path: + package_paths = { + package: Path(path) for package, path in (info.get("package_paths") or {}).items() + } + return Path( + prepare_urdf_for_drake( + Path(str(info["model_path"])), + package_paths=package_paths, + xacro_args={ + str(key): str(value) for key, value in (info.get("xacro_args") or {}).items() + }, + ) + ) + + def set_urdf_joints(self, urdf: Any, joints: Sequence[float]) -> None: + joint_names = list((self.session.robot_info or {}).get("joint_names") or []) + named_joints = self.viser_joint_configuration(urdf, joint_names, joints) + if not named_joints: + return + if hasattr(urdf, "update_cfg"): + urdf.update_cfg(named_joints) + elif hasattr(urdf, "update_configuration"): + urdf.update_configuration(named_joints) + + def viser_joint_configuration( + self, urdf: Any, joint_names: Sequence[str], joints: Sequence[float] + ) -> list[float]: + allowed_names = list(self.viser_actuated_joint_names(urdf)) + if not allowed_names: + return [] + values_by_name: dict[str, float] = {} + for name, value in zip(joint_names, joints, strict=False): + values_by_name[name] = float(value) + values_by_name[name.rsplit("/", 1)[-1]] = float(value) + return [values_by_name.get(name, 0.0) for name in allowed_names] + + def viser_actuated_joint_names(self, urdf: Any) -> tuple[str, ...]: + wrapped_urdf = getattr(urdf, "_urdf", None) + names = getattr(wrapped_urdf, "actuated_joint_names", None) + if names is not None: + return tuple(names) + joint_map = getattr(wrapped_urdf, "joint_map", None) + if isinstance(joint_map, dict): + return tuple(joint_map) + return () + + def set_target_visual_state(self, feasible: bool) -> None: + color = (0, 180, 255) if feasible else (255, 40, 40) + mesh_color = GOAL_ROBOT_FEASIBLE_COLOR if feasible else GOAL_ROBOT_INFEASIBLE_COLOR + mesh_opacity = GOAL_ROBOT_FEASIBLE_OPACITY if feasible else GOAL_ROBOT_INFEASIBLE_OPACITY + robot = self.session.selected_robot + handles = [self.handles.get("ee_control")] + if robot is not None: + ghost = self.urdfs.get(f"{robot}:ghost") + handles.append(ghost) + self.set_urdf_mesh_material(ghost, mesh_color, mesh_opacity) + for handle in handles: + if handle is None: + continue + for attr in ("color", "material_color"): + if hasattr(handle, attr): + try: + setattr(handle, attr, color) + except Exception: + pass + + def set_urdf_mesh_material( + self, urdf: Any | None, color: tuple[int, int, int], opacity: float + ) -> None: + if urdf is None: + return + for mesh in getattr(urdf, "_meshes", ()): + for attr in ("color", "material_color"): + if hasattr(mesh, attr): + try: + setattr(mesh, attr, color) + except Exception: + pass + if hasattr(mesh, "opacity"): + try: + mesh.opacity = opacity + except Exception: + pass diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 3ed291b629..2edbc12c30 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -160,7 +160,7 @@ KeyboardTeleopModule ──→ ControlCoordinator ──→ ManipulationModule | File | Description | |------|-------------| | [`manipulation_module.py`](/dimos/manipulation/manipulation_module.py) | Main module (RPC interface, state machine) | -| [`viser_panel/module.py`](/dimos/manipulation/viser_panel/module.py) | Optional Viser operator panel over the manipulation RPC interface | +| [`viser_panel/__init__.py`](/dimos/manipulation/viser_panel/__init__.py) | Optional Viser operator panel package over the manipulation RPC interface | | [`manipulation/blueprints.py`](/dimos/manipulation/blueprints.py) | Planner and perception blueprints | | [`robot/manipulators/a750/blueprints.py`](/dimos/robot/manipulators/a750/blueprints.py) | A-750 keyboard teleop blueprint | | [`robot/manipulators/piper/blueprints.py`](/dimos/robot/manipulators/piper/blueprints.py) | Piper keyboard teleop blueprint | diff --git a/openspec/changes/add-viser-manipulation-panel/.openspec.yaml b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/.openspec.yaml similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/.openspec.yaml rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/.openspec.yaml diff --git a/openspec/changes/add-viser-manipulation-panel/design.md b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/design.md similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/design.md rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/design.md diff --git a/openspec/changes/add-viser-manipulation-panel/docs.md b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/docs.md similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/docs.md rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/docs.md diff --git a/openspec/changes/add-viser-manipulation-panel/proposal.md b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/proposal.md similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/proposal.md rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/proposal.md diff --git a/openspec/changes/add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/specs/manipulation-operator-panel/spec.md diff --git a/openspec/changes/add-viser-manipulation-panel/target-ui.html b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/target-ui.html similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/target-ui.html rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/target-ui.html diff --git a/openspec/changes/add-viser-manipulation-panel/tasks.md b/openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/tasks.md similarity index 100% rename from openspec/changes/add-viser-manipulation-panel/tasks.md rename to openspec/changes/archive/2026-06-06-add-viser-manipulation-panel/tasks.md diff --git a/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/.openspec.yaml b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/.openspec.yaml new file mode 100644 index 0000000000..b2c3a043ce --- /dev/null +++ b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/.openspec.yaml @@ -0,0 +1,2 @@ +schema: dimos-capability +created: 2026-06-06 diff --git a/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/design.md b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/design.md new file mode 100644 index 0000000000..30972bd739 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/design.md @@ -0,0 +1,204 @@ +## Context + +The optional Viser manipulation panel was added as a companion UI over `ManipulationModule` RPCs. Its current implementation lives mostly in `dimos/manipulation/viser_panel/module.py`, while `state.py` already holds the orthogonal lifecycle axes (`PanelRuntime`, backend readiness, target status, plan status, action status), `PanelSession`, and `PreviewWorker`. + +`module.py` currently owns all of these responsibilities at once: + +- DimOS `Module` lifecycle, config, RPC methods, and fallback RPC-client creation. +- Polling the manipulation backend and translating transient startup errors into panel state. +- Creating Viser GUI controls and wiring callbacks. +- Creating Viser scene handles, transform controls, line segments, and current/ghost `ViserUrdf` models. +- Preparing xacro/URDF model paths and mapping DimOS joint names to Viser/youdfpy actuated joint names. +- Handling target updates, preset application, IK/FK preview request dispatch, result application, and recursion guards. +- Running long operations (`Plan`, local Viser `Preview`, `Execute`, `Cancel`, `Clear Plan`) off the Viser callback path. +- Interpolating planned joint paths for local ghost animation. + +The file is cohesive at the feature level but too broad at the implementation level. Future additions such as more scene overlays, richer viewer controls, or more safety readouts would likely make the module harder to test and easier to regress. + +Viser's public examples and API docs are handle/callback oriented: controls are created with `server.gui.add_*`, callbacks are registered with `on_update`/`on_click`, layout uses folders/tabs/forms, and live state is applied by mutating handle fields such as `.value`, `.options`, `.visible`, `.disabled`, `.position`, and `.wxyz`. Public examples also use helper functions/dataclasses for grouped handles rather than a React-style declarative renderer. That should shape this refactor. + +## Goals / Non-Goals + +**Goals:** + +- Keep `ViserManipulationPanelModule` as the public DimOS module and blueprint surface. +- Keep `PanelSession` as the single mutable source of truth for panel state and gating. +- Split implementation concerns into small internal collaborators with explicit ownership of backend access, GUI handles, scene handles, workflow operations, and preview animation. +- Preserve existing public behavior, config, optional dependency behavior, CLI entrypoint, blueprint compatibility, and hardware execution opt-in. +- Make the tests describe component responsibilities instead of relying only on a partially initialized `ViserManipulationPanelModule`. +- Isolate Viser-specific quirks such as line-segment shape, transform-control quaternion order, `ViserUrdf` mesh material mutation, and actuated-joint-name mapping. + +**Non-Goals:** + +- Do not redesign the operator workflow or add new panel controls. +- Do not introduce a custom declarative UI framework. +- Do not change `ManipulationModule` RPC names, behavior, or return shapes. +- Do not change DimOS streams, blueprints, generated registry entries, or optional dependency extras unless the refactor reveals stale docs. +- Do not change execution safety gates, plan freshness logic, or the `allow_plan_execute` opt-in. + +## DimOS Architecture + +The refactor stays within `dimos/manipulation/viser_panel/` and should keep these public imports stable: + +- `ViserManipulationPanelConfig` +- `ViserManipulationPanelModule` +- `viser_manipulation_panel` + +No new typed streams, transports, DimOS Python `Spec` Protocols, skills/MCP tools, or generated blueprint registry entries are expected. + +Recommended package layout: + +```text +dimos/manipulation/viser_panel/ +├── __init__.py +├── __main__.py +├── module.py # DimOS Module shell and composition root +├── state.py # Existing PanelSession, state axes, PreviewWorker +├── backend.py # Manipulation backend client facade + retry/timeout helpers +├── gui.py # Viser GUI handle ownership and callback wiring +├── scene.py # Viser scene, URDF, path, target visual ownership +├── controller.py # Robot selection, target/preset sync, operations orchestration +└── animation.py # Local preview interpolation/ghost animation helpers +``` + +Suggested runtime composition: + +```text +ViserManipulationPanelModule + ├─ PanelSession + ├─ PanelBackend + │ ├─ module-ref client when composed in a blueprint + │ └─ RPCClient.remote fallback when launched as companion process + ├─ PanelScene + │ ├─ current robot URDF + │ ├─ target ghost URDF + │ ├─ EE transform controls + │ └─ planned-path line segments + ├─ PanelGui + │ ├─ status/error/feasibility handles + │ ├─ robot/preset controls + │ ├─ plan/preview/execute/cancel/clear buttons + │ └─ dynamic joint sliders + ├─ PanelController + │ ├─ refresh/select/apply preset + │ ├─ target changed from Cartesian or joints + │ └─ plan/preview/execute/cancel/clear + └─ PreviewAnimator + └─ interpolate sparse joint paths for local Viser preview +``` + +### Component responsibilities + +`module.py` should become the composition root: + +- Import Viser and Viser URDF support, preserving current fail-fast install hints. +- Create `ViserServer` and collaborators in `start()`. +- Start/stop `PreviewWorker` and the polling thread. +- Expose `refresh_panel_state()` and `get_panel_snapshot()` as RPCs. +- Own the shared lock and pass it to collaborators, or ensure collaborators are only called while the module holds the lock. + +`backend.py` should provide a small facade over either a composed `ManipulationModule` reference or `RPCClient.remote(ManipulationModule)`: + +- `reset_client()` / `close()` +- `list_robots()` with stale-client timeout retry +- state reads: robot info, joints, EE pose, manipulation state, error +- preview calls: IK/FK with explicit timeout result mapping +- operations: plan to joints/pose, get path, execute, cancel, clear plan + +The backend facade should not own Viser handles or mutate `PanelSession` directly, except through returned values. That keeps it testable with fake clients. + +`gui.py` should own GUI handles and expose intent callbacks instead of business logic: + +- Build static controls and dynamic joint sliders. +- Store handles in typed-ish dataclasses where practical (`StatusHandles`, `ActionHandles`, `JointSliderHandles`) rather than a single unstructured `_handles` dictionary. +- Use small declarative data only for repetitive controls, for example action-button metadata: label, handle key, action status, callback name. +- Provide methods such as `set_robot_options()`, `set_preset_options()`, `set_joint_slider_values()`, `set_target_pose_handle()`, and `apply_session_state()`. +- Convert Viser events into controller calls: selected robot, selected preset, transform-control changed, joint slider changed, action button clicked. + +`scene.py` should own Viser scene handles: + +- Ensure scene nodes for the selected robot. +- Prepare expanded URDFs through `prepare_urdf_for_drake()`. +- Create current and ghost `ViserUrdf` instances. +- Update current/ghost joints using Viser/youdfpy actuated joint names. +- Apply target feasibility colors/material opacity. +- Render/remove planned path line segments with the real Viser `(N, 2, 3)` segment shape. + +`controller.py` should be the workflow coordinator over `PanelSession`, `PanelBackend`, `PanelGui`, and `PanelScene`: + +- Refresh backend state and update GUI/scene. +- Select robot, invalidate plans, and rebuild robot-specific UI/scene as needed. +- Apply Current/Init/Home presets and submit preview requests. +- Handle target changes from Cartesian controls or joint sliders using `sync_source` recursion guards. +- Apply preview results only when sequence IDs match. +- Run plan, local preview, execute, cancel, and clear plan operations. +- Maintain the distinction between UI enablement gates and in-operation gates to avoid self-blocking actions. + +`animation.py` should hold pure or nearly pure preview helpers: + +- Interpolate sparse planned joint paths to visual frames. +- Animate a ghost target through `PanelScene.set_ghost_joints()`. +- Avoid backend preview calls; local preview remains Viser-only. + +## Decisions + +1. **Use composition as the primary structure.** + - Rationale: the current complexity is from several real domains, not repeated syntax alone. + - Alternative rejected: keep one module and add comments/regions. That would preserve coupling and not improve test seams. + +2. **Do not build a general declarative UI framework.** + - Rationale: Viser APIs and examples are imperative handle/callback APIs. A custom reconciler would add abstraction over mutable handles and make debugging harder. + - Limited declarative use is still useful for static metadata such as action buttons and status bindings. + +3. **Keep `PanelSession` as the state model.** + - Rationale: the current orthogonal state axes already address lifecycle complexity. The refactor should not replace them with distributed state across GUI/scene/backend objects. + - Collaborators may cache handles/resources, but business state stays in `PanelSession`. + +4. **Keep the module as the public compatibility boundary.** + - Rationale: imports, blueprints, and the companion entrypoint should not churn for users. + - Internal files can be added freely as long as `__init__.py` exports remain stable. + +5. **Split tests by responsibility as code moves.** + - State/gating tests stay against `PanelSession` and `PreviewWorker`. + - Backend retry/timeout tests should target `PanelBackend`. + - Scene tests should target URDF joint mapping, target colors, and line-segment shape. + - GUI/controller tests should target callback-to-intent wiring, target sync, stale preview results, plan/execute gating, and snapshots. + +## Safety / Simulation / Replay + +This is a behavior-preserving refactor. It must not broaden hardware execution availability. `Execute` remains disabled unless `allow_plan_execute` is enabled and the current joints still match the fresh plan start snapshot within tolerance. + +Manual QA should use the same surface as the existing panel: + +- Focused tests: `uv run pytest dimos/manipulation/test_viser_panel.py dimos/manipulation/test_manipulation_unit.py -q`. +- Lightweight launch/snapshot check against the mock panel when no conflicting DimOS coordinator is running: `uv run --extra manipulation-viser dimos run xarm7-viser-panel-mock` and inspect the Viser URL. +- If another DimOS run owns the default coordinator/LCM bus, do not stop it casually; either defer live launch or isolate the run if supported. + +Simulation/replay behavior is unchanged because no new robot-facing commands or streams are introduced. + +## Risks / Trade-offs + +- **Regression from moving coupled code.** Mitigate with small commits/tasks and existing tests before and after each extraction. +- **Over-abstraction.** Keep collaborators concrete and panel-specific. Avoid generic GUI frameworks, registries, or plugin systems. +- **Locking/thread assumptions.** Preserve current lock discipline. If collaborators are called from poll threads, preview workers, and Viser callbacks, document whether each method expects the caller to hold the lock. +- **Handle lifecycle leaks.** Scene/GUI owners should provide explicit remove/reset paths for robot switches and plan clearing. +- **Test churn.** Some tests currently patch partially initialized `ViserManipulationPanelModule`. The refactor should reduce that pattern by testing collaborators directly, but public module-level regression tests should remain. + +## Migration / Rollout + +Roll out in extraction order from lowest-risk to highest-risk: + +1. Move pure helpers and preview interpolation into `animation.py` without behavior changes. +2. Extract scene handle/URDF/path rendering into `scene.py` and update scene-focused tests. +3. Extract backend client facade and timeout/retry handling into `backend.py`. +4. Extract GUI handle creation and session-to-handle rendering into `gui.py`. +5. Extract workflow orchestration into `controller.py`, leaving `module.py` as lifecycle shell. +6. Run focused tests, LSP diagnostics for changed files, pre-commit, and manual QA where available. + +No data migration, generated registry update, or dependency lockfile change is expected. + +## Open Questions + +- Should `PanelGui` use Viser folders/tabs during extraction, or should layout remain visually unchanged until a later UI polish change? +- Should lock ownership be centralized in `module.py`, or should collaborators receive a small `locked()` helper/context to make thread expectations explicit? +- Should compatibility tests keep importing private helper methods from `ViserManipulationPanelModule`, or should they move immediately to component-level APIs as each component is extracted? diff --git a/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/docs.md b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/docs.md new file mode 100644 index 0000000000..4a8a132952 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/docs.md @@ -0,0 +1,27 @@ +## User-Facing Docs + +No user-facing behavior change is intended. `docs/capabilities/manipulation/readme.md` should remain accurate for launch commands, optional dependency installation, safety guidance, and operator workflow. + +If the implementation changes the key-file table or adds meaningful internal files worth documenting, update only the key-file row for `dimos/manipulation/viser_panel/` to describe the package as the optional Viser operator panel instead of implying a single-file implementation. + +## Contributor Docs + +No contributor docs are required unless the refactor establishes a reusable DimOS pattern for optional browser panels. If that happens, capture the pattern under `docs/development/` or `docs/coding-agents/` in a separate follow-up, not as part of this behavior-preserving refactor. + +## Coding-Agent Docs + +No `AGENTS.md` or `docs/coding-agents/` update is required for the initial refactor. If implementation uncovers a durable guidance point, such as a standard layout for Viser panel modules, propose it separately after the pattern is proven. + +## Doc Validation + +If no docs are changed, no doc-specific validation is needed beyond the normal pre-commit doclinks hook. + +If `docs/capabilities/manipulation/readme.md` is updated, run the repository doclinks validation through pre-commit: + +```bash +uv run pre-commit run doclinks --all-files +``` + +## No Docs Needed + +The change is primarily internal code organization. The operator-facing workflow, commands, dependencies, hardware execution opt-in, and safety guidance remain unchanged, so user documentation should not need updates unless file references are adjusted. diff --git a/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/proposal.md b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/proposal.md new file mode 100644 index 0000000000..c709363cab --- /dev/null +++ b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/proposal.md @@ -0,0 +1,36 @@ +## Why + +`dimos/manipulation/viser_panel/module.py` now contains the complete optional Viser manipulation panel implementation: DimOS module lifecycle, RPC client management, polling, GUI handle creation, scene/URDF rendering, target synchronization, preview debounce/timeout handling, and planning/execution operations. The behavior is tested and useful, but the single module has grown large enough that future target controls, scene overlays, or safety gates will be hard to add without accidental coupling. + +This change reorganizes the panel around explicit composition boundaries while preserving the existing operator behavior. The goal is a better design, not a new UI feature: keep `PanelSession` as the source of truth, keep `ViserManipulationPanelModule` as the public DimOS module/blueprint surface, and move Viser-specific handle ownership, backend RPC access, workflow operations, and preview animation into focused collaborators. + +## What Changes + +- Refactor the optional Viser manipulation panel implementation into small internal components with narrow responsibilities. +- Keep the public module, blueprint, CLI entrypoint, config fields, optional dependency extra, and documented operator workflow compatible. +- Preserve current behavior for robot discovery, target synchronization, automatic IK/FK feasibility preview, plan/preview/execute/cancel/clear controls, Viser URDF rendering, target coloring, and snapshots. +- Use composition as the primary organization mechanism; use only lightweight declarative data where it reduces repetitive GUI-control creation or status binding. +- Do not introduce a custom UI framework, change Viser dependencies, or alter `ManipulationModule` RPC behavior. +- No **BREAKING** public API/CLI or hardware-safety behavior changes are intended. + +## Affected DimOS Surfaces + +- Modules/streams: `ViserManipulationPanelModule` internals under `dimos/manipulation/viser_panel/`; no stream contract changes. +- Blueprints/CLI: existing Viser panel blueprint and `python -m dimos.manipulation.viser_panel` launch path remain compatible. +- Skills/MCP: none. +- Hardware/simulation/replay: no new hardware actions; existing execution opt-in and plan freshness gates remain unchanged. +- Docs/generated registries: docs may mention the internal package layout if key-file descriptions change; no generated registry change expected. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `manipulation-operator-panel`: clarify that the panel's public launch/import/behavior boundary remains stable while internal implementation is modularized. + +## Impact + +Developers get a panel implementation that is easier to test, extend, and reason about. Compatibility risk is primarily regression risk from moving code, especially around Viser handle lifecycle, target synchronization recursion guards, preview worker timing, and execute gating. The implementation should rely on existing tests as a safety net, split tests along the new component boundaries, and finish with the focused manipulation panel test suite plus a lightweight panel launch/snapshot check where available. diff --git a/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/specs/manipulation-operator-panel/spec.md b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/specs/manipulation-operator-panel/spec.md new file mode 100644 index 0000000000..6a4c916768 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/specs/manipulation-operator-panel/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Stable panel compatibility boundary during internal refactors +The system SHALL preserve the optional manipulation operator panel's public launch, import, configuration, and safety behavior while reorganizing internal implementation details. + +#### Scenario: Existing panel launch surfaces continue to work +- **GIVEN** a user has installed the optional Viser manipulation panel dependencies +- **WHEN** the user launches the panel through the existing companion entrypoint or an existing blueprint that includes the panel module +- **THEN** the launch surface accepts the same configuration fields as before +- **AND** the panel connects to the manipulation stack through the same public manipulation control surface + +#### Scenario: Existing developer imports remain stable +- **GIVEN** developer code imports the documented Viser panel module, config, or blueprint symbols +- **WHEN** the panel implementation is reorganized into internal collaborators +- **THEN** those documented imports continue to resolve +- **AND** callers do not need to import the internal backend, GUI, scene, controller, or animation helpers + +#### Scenario: Safety gates remain unchanged +- **GIVEN** the panel implementation has been reorganized internally +- **WHEN** an operator plans, previews, executes, cancels, or clears a plan +- **THEN** execution remains gated by the same operator opt-in and fresh-plan checks +- **AND** no new robot-facing action is enabled solely because of the internal refactor diff --git a/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/tasks.md b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/tasks.md new file mode 100644 index 0000000000..15086bba88 --- /dev/null +++ b/openspec/changes/archive/2026-06-06-refactor-viser-panel-architecture/tasks.md @@ -0,0 +1,24 @@ +## 1. Implementation + +- [x] 1.1 `dimos/manipulation/viser_panel/animation.py`: Extract joint-path interpolation from `ViserManipulationPanelModule` - expect pure helper tests to pass unchanged. +- [x] 1.2 `dimos/manipulation/viser_panel/scene.py`: Extract Viser scene ownership for transform controls, URDF creation, target colors, joint mapping, and path line segments - expect scene tests to cover Viser handle mutations and `(N, 2, 3)` path segments. +- [x] 1.3 `dimos/manipulation/viser_panel/backend.py`: Extract manipulation backend facade for module-reference/RPC-client access, stale-client retry, IK/FK preview timeout mapping, planning, execution, cancel, and clear - expect backend tests with fake clients. +- [x] 1.4 `dimos/manipulation/viser_panel/gui.py`: Extract Viser GUI handle creation, dynamic joint sliders, callback wiring, and session-to-handle rendering - expect GUI tests to cover button enablement, robot/preset options, and callback intent dispatch. +- [x] 1.5 `dimos/manipulation/viser_panel/controller.py`: Extract workflow orchestration for refresh, robot selection, target changes, preset application, preview result application, plan, local preview, execute, cancel, and clear - expect controller tests to preserve target sync and gating behavior. +- [x] 1.6 `dimos/manipulation/viser_panel/module.py`: Reduce the module to config, DimOS lifecycle, dependency import, server creation, collaborator composition, polling thread, preview worker startup/shutdown, and RPC snapshot methods - expect public imports and blueprint symbols to remain stable. +- [x] 1.7 `dimos/manipulation/test_viser_panel.py`: Reorganize tests around `state`, `animation`, `scene`, `backend`, `gui`, `controller`, and public module regression coverage - expect existing behavior cases to remain represented. + +## 2. Documentation + +- [x] 2.1 `docs/capabilities/manipulation/readme.md`: Confirm the existing Viser panel docs still match the refactored package - expect no user-facing workflow changes. +- [x] 2.2 `docs/capabilities/manipulation/readme.md`: If key-file descriptions become stale, update them to describe the Viser panel package rather than a single-file implementation - expect doclinks to remain valid. + +## 3. Verification + +- [x] 3.1 Run `openspec validate refactor-viser-panel-architecture` - expect the change to be valid. +- [x] 3.2 Run `uv run pytest dimos/manipulation/test_viser_panel.py dimos/manipulation/test_manipulation_unit.py -q` - expect focused manipulation panel tests to pass. +- [x] 3.3 Run LSP diagnostics on every changed Python file under `dimos/manipulation/viser_panel/` and the updated tests - expect no new diagnostics. +- [x] 3.4 Run `uv run pre-commit run --all` - expect formatting, lint, docs, and repository hooks to pass. +- [x] 3.5 If docs changed, run `uv run pre-commit run doclinks --all-files` - expect doclinks to pass. +- [x] 3.6 Manually QA through the panel surface by launching the mock stack when no conflicting DimOS coordinator is running: `uv run --extra manipulation-viser dimos run xarm7-viser-panel-mock`, opening the Viser URL, selecting the robot, applying Current/Home/Init, moving Cartesian and joint targets, planning, local-previewing, and confirming Execute gating remains unchanged. +- [x] 3.7 If a conflicting DimOS coordinator is already running on the default bus, do not stop it without user approval; record that live panel QA was blocked by the existing coordinator and rely on focused tests plus a later isolated run. diff --git a/openspec/specs/manipulation-operator-panel/spec.md b/openspec/specs/manipulation-operator-panel/spec.md new file mode 100644 index 0000000000..9b476db87f --- /dev/null +++ b/openspec/specs/manipulation-operator-panel/spec.md @@ -0,0 +1,155 @@ +# manipulation-operator-panel Specification + +## Purpose +TBD - created by archiving change add-viser-manipulation-panel. Update Purpose after archive. +## Requirements +### Requirement: Browser-based manipulation operator panel +The system SHALL provide a browser-based manipulation operator panel for supported manipulation stacks that lets an operator inspect robot state, set a motion-planning target, plan, preview, execute, cancel, and reset through a graphical interface. + +#### Scenario: Open panel for a running manipulation stack +- **GIVEN** a supported manipulation stack is running and exposes manipulation state through its public control surface +- **WHEN** an operator opens the manipulation operator panel +- **THEN** the panel displays the available robot choices and the selected robot's current manipulation state +- **AND** the panel provides controls for target selection, planning, previewing, execution, cancel, and reset + +#### Scenario: Missing manipulation service +- **GIVEN** the panel is opened when no compatible manipulation stack is reachable +- **WHEN** the panel attempts to load robot state +- **THEN** the panel displays a clear unavailable or disconnected state +- **AND** the panel keeps planning and execution controls disabled + +### Requirement: Current and target visualization +The system SHALL visually distinguish the live current robot state from the editable target state in the operator panel. + +#### Scenario: Display current robot and target ghost +- **GIVEN** a robot is selected and current state is available +- **WHEN** the panel renders the 3D scene +- **THEN** the live current robot is rendered as the solid authoritative state +- **AND** the target robot configuration is rendered as a visually distinct translucent or ghosted target +- **AND** the draggable end-effector target control is visually separate from both robot renderings + +#### Scenario: Target is infeasible +- **GIVEN** an operator has changed the target pose or joint target +- **WHEN** the target is infeasible because IK fails, collision checking fails, or the target is otherwise invalid +- **THEN** the panel marks the target controls and target ghost as infeasible using a red visual state +- **AND** the live current robot remains visually unchanged as the current robot state + +### Requirement: Target presets +The system SHALL provide one-shot target presets that initialize the editable target without creating an ongoing follow mode. + +#### Scenario: Apply current-position preset +- **GIVEN** current robot joints and end-effector pose are available +- **WHEN** the operator selects the Current target preset +- **THEN** the panel sets the target gizmo, target ghost, and joint controls to the current robot state +- **AND** the preset selection does not continue tracking future current-state changes after it is applied + +#### Scenario: Apply configured init or home preset +- **GIVEN** the selected robot exposes init or home target data +- **WHEN** the operator applies the Init or Home preset +- **THEN** the panel updates the target ghost, target gizmo, and joint controls to represent that preset target +- **AND** the panel evaluates target feasibility before enabling planning controls + +### Requirement: Synchronized Cartesian and joint target controls +The system SHALL keep Cartesian end-effector target controls and joint target controls synchronized for the selected robot. + +#### Scenario: Drag end-effector target +- **GIVEN** the operator is viewing a selected robot in the panel +- **WHEN** the operator drags or rotates the end-effector target control +- **THEN** the panel computes a candidate joint target for that end-effector target +- **AND** the joint controls update when the candidate target is feasible +- **AND** the existing plan is marked stale until the operator creates a new plan + +#### Scenario: Move joint slider +- **GIVEN** the operator is viewing a selected robot in the panel +- **WHEN** the operator changes a joint target control +- **THEN** the panel updates the target robot visualization and end-effector target pose to match the joint target +- **AND** the existing plan is marked stale until the operator creates a new plan + +### Requirement: Non-blocking target feasibility preview +The system SHALL keep the operator panel responsive while evaluating IK, FK, and collision feasibility for target edits. + +#### Scenario: High-frequency target edits +- **GIVEN** the operator is dragging an end-effector target control or moving joint controls repeatedly +- **WHEN** feasibility preview requests are generated faster than they can complete +- **THEN** the panel remains responsive to further UI input +- **AND** stale preview results do not overwrite a newer target state +- **AND** planning and execution controls remain disabled while the latest target feasibility is unknown + +#### Scenario: Preview result completes +- **GIVEN** the panel has requested feasibility for the latest target +- **WHEN** the latest feasibility result completes successfully +- **THEN** the panel updates the target visualization and controls from that latest result +- **AND** the panel enables planning controls only when the target is feasible and the manipulation state permits planning + +### Requirement: Planning, preview, and execution gating +The system SHALL require a fresh feasible target and a fresh plan before enabling execution from the operator panel. + +#### Scenario: Create and preview plan +- **GIVEN** a feasible synchronized target exists for the selected robot +- **WHEN** the operator requests a plan +- **THEN** the panel requests a motion plan for the selected robot +- **AND** the panel displays whether planning succeeded or failed +- **AND** a successful plan can be previewed before execution + +#### Scenario: Target changes after planning +- **GIVEN** a plan exists for the selected robot +- **WHEN** the operator changes the target pose, target joints, target preset, or selected robot +- **THEN** the panel marks the plan stale +- **AND** the panel disables execution until a new plan is created for the latest feasible target + +#### Scenario: Execute fresh plan +- **GIVEN** a fresh plan exists for the selected robot and the current robot state still matches the plan start constraints +- **WHEN** the operator confirms execution +- **THEN** the panel requests execution through the manipulation stack's public execution surface +- **AND** the panel displays trajectory status until execution completes, fails, or is canceled + +### Requirement: Safety controls remain available +The system SHALL keep safety-relevant controls visible and usable during planning and execution states. + +#### Scenario: Cancel during execution +- **GIVEN** the selected robot is executing a trajectory requested from the panel +- **WHEN** the operator activates Cancel +- **THEN** the panel requests cancellation through the manipulation stack's public cancel surface +- **AND** the panel refreshes and displays the resulting manipulation and trajectory status + +#### Scenario: Reset after fault +- **GIVEN** the manipulation stack reports a fault state +- **WHEN** the operator activates Reset Fault +- **THEN** the panel requests reset through the manipulation stack's public reset surface +- **AND** planning and execution controls remain disabled until the refreshed state and target feasibility allow them + +### Requirement: Optional dependency and compatibility boundary +The system SHALL keep the Viser manipulation panel optional and compatible with existing manipulation workflows. + +#### Scenario: Use manipulation without panel +- **GIVEN** a user installs or runs an existing manipulation workflow without the Viser panel dependencies +- **WHEN** the user runs existing manipulation planning, preview, or execution flows +- **THEN** those existing flows continue to work without requiring the panel +- **AND** existing Meshcat, Rerun, and Python/RPC workflows remain valid + +#### Scenario: Panel does not add agent tools +- **GIVEN** the manipulation operator panel is enabled +- **WHEN** agent or MCP tools are listed for the robot stack +- **THEN** the panel does not add or change agent/MCP tools as part of this phase +- **AND** operator UI actions remain separate from agent skill exposure + +### Requirement: Stable panel compatibility boundary during internal refactors +The system SHALL preserve the optional manipulation operator panel's public launch, import, configuration, and safety behavior while reorganizing internal implementation details. + +#### Scenario: Existing panel launch surfaces continue to work +- **GIVEN** a user has installed the optional Viser manipulation panel dependencies +- **WHEN** the user launches the panel through the existing companion entrypoint or an existing blueprint that includes the panel module +- **THEN** the launch surface accepts the same configuration fields as before +- **AND** the panel connects to the manipulation stack through the same public manipulation control surface + +#### Scenario: Existing developer imports remain stable +- **GIVEN** developer code imports the documented Viser panel module, config, or blueprint symbols +- **WHEN** the panel implementation is reorganized into internal collaborators +- **THEN** those documented imports continue to resolve +- **AND** callers do not need to import the internal backend, GUI, scene, controller, or animation helpers + +#### Scenario: Safety gates remain unchanged +- **GIVEN** the panel implementation has been reorganized internally +- **WHEN** an operator plans, previews, executes, cancels, or clears a plan +- **THEN** execution remains gated by the same operator opt-in and fresh-plan checks +- **AND** no new robot-facing action is enabled solely because of the internal refactor From b32c3514df4f9c3ff3597eff3dd11249ba30aa28 Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 15:41:55 -0700 Subject: [PATCH 6/8] fix: harden viser panel planning preview --- dimos/manipulation/blueprints.py | 13 ++- dimos/manipulation/manipulation_module.py | 38 ++++++-- dimos/manipulation/test_manipulation_unit.py | 46 +++++++++- dimos/manipulation/test_viser_panel.py | 95 +++++++++++++++++++- dimos/manipulation/viser_panel/__init__.py | 20 ----- dimos/manipulation/viser_panel/backend.py | 32 +++++-- dimos/manipulation/viser_panel/module.py | 43 ++++----- dimos/manipulation/viser_panel/scene.py | 7 +- dimos/robot/test_all_blueprints.py | 11 ++- docs/capabilities/manipulation/readme.md | 2 +- 10 files changed, 238 insertions(+), 69 deletions(-) delete mode 100644 dimos/manipulation/viser_panel/__init__.py diff --git a/dimos/manipulation/blueprints.py b/dimos/manipulation/blueprints.py index 4d45e4c3f7..4409929b4b 100644 --- a/dimos/manipulation/blueprints.py +++ b/dimos/manipulation/blueprints.py @@ -46,6 +46,16 @@ from dimos.perception.object_scene_registration import ObjectSceneRegistrationModule from dimos.robot.catalog.ufactory import xarm6 as _catalog_xarm6, xarm7 as _catalog_xarm7 + +def _quaternion(x: float, y: float, z: float, w: float) -> Quaternion: + quaternion = Quaternion.__new__(Quaternion) + quaternion.x = x + quaternion.y = y + quaternion.z = z + quaternion.w = w + return quaternion + + # Single XArm6 planner (standalone, no coordinator) _xarm6_planner_cfg = _catalog_xarm6( name="arm", @@ -197,7 +207,7 @@ # Usage: dimos run coordinator-mock, then dimos run xarm-perception _XARM_PERCEPTION_CAMERA_TRANSFORM = Transform( translation=Vector3(x=0.06693724, y=-0.0309563, z=0.00691482), - rotation=Quaternion(0.70513398, 0.00535696, 0.70897578, -0.01052180), # xyzw + rotation=_quaternion(0.70513398, 0.00535696, 0.70897578, -0.01052180), # xyzw ) _xarm7_perception_cfg = _catalog_xarm7( @@ -375,6 +385,7 @@ "xarm6_planner_only", "xarm7_planner_coordinator", "xarm7_planner_coordinator_agent", + "xarm7_viser_panel_mock", "xarm_perception", "xarm_perception_agent", "xarm_perception_sim", diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 497587136b..7278081ba1 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -596,6 +596,26 @@ def get_planned_path(self, robot_name: RobotName | None = None) -> JointPath | N return None return self._planned_paths.get(robot[0]) + @rpc + def get_planned_path_poses(self, robot_name: RobotName | None = None) -> list[Pose] | None: + """Get end-effector poses for the stored planned path. + + Args: + robot_name: Robot to query (required if multiple robots configured) + """ + if self._world_monitor is None: + return None + robot = self._get_robot(robot_name) + if robot is None: + return None + robot_name, robot_id, _, _ = robot + path = self._planned_paths.get(robot_name) + if path is None: + return None + return [ + self._world_monitor.get_ee_pose(robot_id, joint_state=waypoint) for waypoint in path + ] + @rpc def get_visualization_url(self) -> str | None: """Get the visualization URL. @@ -686,8 +706,10 @@ def get_robot_info(self, robot_name: RobotName | None = None) -> dict[str, Any] } @rpc - def solve_ik_preview(self, pose: Pose, robot_name: RobotName | None = None) -> dict[str, Any]: - """Preview IK for a target pose without storing, previewing, executing, or moving. + def evaluate_pose_target( + self, pose: Pose, robot_name: RobotName | None = None + ) -> dict[str, Any]: + """Evaluate a pose target without storing, previewing, executing, or moving. Args: pose: Target end-effector pose in world coordinates @@ -756,10 +778,10 @@ def solve_ik_preview(self, pose: Pose, robot_name: RobotName | None = None) -> d } @rpc - def solve_fk_preview( + def evaluate_joint_target( self, joints: JointState, robot_name: RobotName | None = None ) -> dict[str, Any]: - """Preview FK and feasibility for candidate joints without planning or moving. + """Evaluate candidate joints without planning or moving. Args: joints: Candidate joint state @@ -787,7 +809,13 @@ def solve_fk_preview( _, robot_id, config, _ = robot joint_state = joints if not joint_state.name: - joint_state = JointState(name=config.joint_names, position=list(joints.position)) + joint_state = JointState.__new__(JointState) + joint_state.ts = joints.ts + joint_state.frame_id = joints.frame_id + joint_state.name = config.joint_names + joint_state.position = list(joints.position) + joint_state.velocity = list(joints.velocity or []) + joint_state.effort = list(joints.effort or []) pose = self._world_monitor.get_ee_pose(robot_id, joint_state=joint_state) collision_free = self._world_monitor.is_state_valid(robot_id, joint_state) return { diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index 8d39258c0a..d551e79420 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -305,7 +305,7 @@ def test_get_planned_path_returns_stored_path(self, robot_config): assert module.get_planned_path("test_arm") is path - def test_solve_ik_preview_does_not_store_path_or_change_state(self, robot_config): + def test_evaluate_pose_target_does_not_store_path_or_change_state(self, robot_config): module = _make_module_with_monitor(robot_config) module._kinematics = MagicMock() current = JointState(name=robot_config.joint_names, position=[0.0, 0.0, 0.0]) @@ -320,7 +320,7 @@ def test_solve_ik_preview_does_not_store_path_or_change_state(self, robot_config message="ok", ) - result = module.solve_ik_preview(Pose(), "test_arm") + result = module.evaluate_pose_target(Pose(), "test_arm") assert result["success"] is True assert result["joint_state"] is solution @@ -329,14 +329,14 @@ def test_solve_ik_preview_does_not_store_path_or_change_state(self, robot_config assert module._planned_trajectories == {} assert module._state == ManipulationState.IDLE - def test_solve_fk_preview_returns_pose_and_feasibility(self, robot_config): + def test_evaluate_joint_target_returns_pose_and_feasibility(self, robot_config): module = _make_module_with_monitor(robot_config) target = JointState(name=[], position=[0.1, 0.2, 0.3]) pose = PoseStamped(position=Vector3(0.4, 0.5, 0.6), orientation=Quaternion()) module._world_monitor.get_ee_pose.return_value = pose module._world_monitor.is_state_valid.return_value = True - result = module.solve_fk_preview(target, "test_arm") + result = module.evaluate_joint_target(target, "test_arm") assert result["success"] is True assert result["pose"] is pose @@ -346,6 +346,24 @@ def test_solve_fk_preview_returns_pose_and_feasibility(self, robot_config): assert module._planned_paths == {} assert module._state == ManipulationState.IDLE + def test_get_planned_path_poses_returns_fk_for_each_waypoint(self, robot_config): + module = _make_module_with_monitor(robot_config) + path = [ + _joint_state([0.0, 0.0, 0.0], robot_config.joint_names), + _joint_state([0.1, 0.2, 0.3], robot_config.joint_names), + ] + poses = [ + PoseStamped(position=Vector3(0.1, 0.2, 0.3), orientation=_unit_quaternion()), + PoseStamped(position=Vector3(0.4, 0.5, 0.6), orientation=_unit_quaternion()), + ] + module._planned_paths = {"test_arm": path} + module._world_monitor.get_ee_pose.side_effect = poses + + result = module.get_planned_path_poses("test_arm") + + assert result == poses + assert module._world_monitor.get_ee_pose.call_count == 2 + class TestRobotModelConfigMapping: """Test RobotModelConfig joint name mapping helpers.""" @@ -374,6 +392,26 @@ def _make_module_with_monitor(*configs: RobotModelConfig) -> ManipulationModule: return module +def _unit_quaternion() -> Quaternion: + quaternion = Quaternion.__new__(Quaternion) + quaternion.x = 0.0 + quaternion.y = 0.0 + quaternion.z = 0.0 + quaternion.w = 1.0 + return quaternion + + +def _joint_state(position: list[float], name: list[str]) -> JointState: + joint_state = JointState.__new__(JointState) + joint_state.ts = 0.0 + joint_state.frame_id = "" + joint_state.name = name + joint_state.position = position + joint_state.velocity = [] + joint_state.effort = [] + return joint_state + + class TestOnJointState: """Test _on_joint_state routing, splitting, and init capture.""" diff --git a/dimos/manipulation/test_viser_panel.py b/dimos/manipulation/test_viser_panel.py index 44681bb883..342979c878 100644 --- a/dimos/manipulation/test_viser_panel.py +++ b/dimos/manipulation/test_viser_panel.py @@ -49,6 +49,9 @@ TargetStatus, ) from dimos.msgs.geometry_msgs.Pose import Pose +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.JointState import JointState @@ -63,6 +66,15 @@ def _joint_state(position: list[float], name: list[str] | None = None) -> JointS return joint_state +def _unit_quaternion() -> Quaternion: + quaternion = Quaternion.__new__(Quaternion) + quaternion.x = 0.0 + quaternion.y = 0.0 + quaternion.z = 0.0 + quaternion.w = 1.0 + return quaternion + + class FakeHandle: def __init__(self, value=None): self.value = value @@ -219,7 +231,7 @@ def slow_preview() -> dict[str, object]: def test_panel_cartesian_preview_uses_timeout_wrapper(): panel = _make_panel() panel._client = MagicMock() - panel._client.solve_ik_preview.return_value = {"success": True, "collision_free": True} + panel._client.evaluate_pose_target.return_value = {"success": True, "collision_free": True} request = PreviewRequest(1, "cartesian", "arm", pose=Pose.__new__(Pose)) with patch.object( @@ -229,6 +241,21 @@ def test_panel_cartesian_preview_uses_timeout_wrapper(): assert result == {"success": False} timeout.assert_called_once() + panel._client.evaluate_pose_target.assert_not_called() + panel._client.solve_ik_preview.assert_not_called() + + +def test_panel_cartesian_preview_routes_to_target_evaluation_api(): + panel = _make_panel() + panel._client = MagicMock() + panel._client.evaluate_pose_target.return_value = {"success": True, "collision_free": True} + request = PreviewRequest(1, "cartesian", "arm", pose=Pose.__new__(Pose)) + + result = panel._handle_preview_request(request) + + assert result == {"success": True, "collision_free": True} + panel._client.evaluate_pose_target.assert_called_once_with(request.pose, "arm") + panel._client.solve_ik_preview.assert_not_called() def test_panel_refresh_reports_disconnected_state_when_rpc_unavailable(): @@ -380,15 +407,57 @@ def test_panel_renders_plan_path_with_viser_line_segment_shape(): server = FakeServer() scene = PanelScene(server, session, handles, {}, None) path = [_joint_state([0.0]), _joint_state([0.1]), _joint_state([0.2])] + poses = [ + PoseStamped(position=Vector3(0.1, 0.2, 0.3), orientation=_unit_quaternion()), + PoseStamped(position=Vector3(0.4, 0.5, 0.6), orientation=_unit_quaternion()), + PoseStamped(position=Vector3(0.7, 0.8, 0.9), orientation=_unit_quaternion()), + ] - scene.render_plan_path(path) + scene.render_plan_path(path, poses) assert server.scene.line_segments[0]["points"] == [ - [[0.0, 0.0, 0.02], [1.0, 0.1, 0.02]], - [[1.0, 0.1, 0.02], [2.0, 0.2, 0.02]], + [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], + [[0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], ] +def test_panel_has_no_duplicate_static_callback_wiring_method(): + assert not hasattr(ViserManipulationPanelModule, "_wire_static_callbacks") + + +def test_panel_backend_rejects_new_preview_when_previous_timed_out(): + backend = PanelBackend( + module_ref=None, + module_rpc=None, + remote_factory=MagicMock(), + timeout_seconds=lambda: 0.01, + ) + release = threading.Event() + started = threading.Event() + active = 0 + + def slow_preview() -> dict[str, object]: + nonlocal active + active += 1 + started.set() + release.wait(timeout=1.0) + active -= 1 + return {"success": True} + + first = backend.call_preview_with_timeout(slow_preview, "IK_TIMEOUT") + assert first["status"] == "IK_TIMEOUT" + assert started.is_set() + + second = backend.call_preview_with_timeout(slow_preview, "IK_TIMEOUT") + + release.set() + assert backend._preview_thread is not None + backend._preview_thread.join(timeout=1.0) + assert second["success"] is False + assert second["status"] == "PREVIEW_BUSY" + assert active == 0 + + def test_panel_snapshot_does_not_force_refresh(): panel = _make_panel() panel.session.runtime = PanelRuntime.RUNNING @@ -555,6 +624,24 @@ def test_panel_target_sync_does_not_overwrite_ghost_during_preview(): assert ghost.cfg == [0.1, 0.2, 0.0] +def test_panel_initializes_target_from_current_robot_state(): + panel = _make_panel() + panel.session.selected_robot = "arm" + panel.session.robot_info = {"joint_names": ["arm/joint1", "arm/joint2", "arm/gripper"]} + panel.session.current_joints = [0.4, 0.5, 0.6] + panel.session.current_ee_pose = panel._make_pose((1.0, 2.0, 3.0), (0.1, 0.2, 0.3, 0.4)) + ghost = FakeViserUrdf() + panel._urdfs["arm:ghost"] = ghost + + panel._initialize_target_from_current_state() + + assert panel.session.cartesian_target is panel.session.current_ee_pose + assert panel.session.joint_target == [0.4, 0.5, 0.6] + assert panel._handles["ee_control"].position == (1.0, 2.0, 3.0) + assert panel._handles["ee_control"].wxyz == (0.4, 0.1, 0.2, 0.3) + assert ghost.cfg == [0.4, 0.5, 0.0] + + def test_panel_execute_runs_while_execute_operation_is_marked_in_flight(): panel = _make_panel() panel.session.runtime = PanelRuntime.RUNNING diff --git a/dimos/manipulation/viser_panel/__init__.py b/dimos/manipulation/viser_panel/__init__.py deleted file mode 100644 index 509522c6ab..0000000000 --- a/dimos/manipulation/viser_panel/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# 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. - -from dimos.manipulation.viser_panel.module import ( - ViserManipulationPanelConfig, - ViserManipulationPanelModule, -) - -__all__ = ["ViserManipulationPanelConfig", "ViserManipulationPanelModule"] diff --git a/dimos/manipulation/viser_panel/backend.py b/dimos/manipulation/viser_panel/backend.py index 18c448e8df..da2e2e678a 100644 --- a/dimos/manipulation/viser_panel/backend.py +++ b/dimos/manipulation/viser_panel/backend.py @@ -36,10 +36,14 @@ def __init__( self._remote_factory = remote_factory self._timeout_seconds = timeout_seconds self.client: Any | None = None + self._preview_lock = threading.Lock() + self._preview_thread: threading.Thread | None = None def reset_client(self) -> Any | None: - if self.client is not None and self.client is not self._module_ref: - self.client.stop_rpc_client() + with self._preview_lock: + if self.client is not None and self.client is not self._module_ref: + if not self._preview_thread or not self._preview_thread.is_alive(): + self.client.stop_rpc_client() if self._module_ref is not None: self.client = self._module_ref return self.client @@ -50,8 +54,10 @@ def reset_client(self) -> Any | None: return self.client def close(self) -> None: - if self.client is not None and self.client is not self._module_ref: - self.client.stop_rpc_client() + with self._preview_lock: + if self.client is not None and self.client is not self._module_ref: + if not self._preview_thread or not self._preview_thread.is_alive(): + self.client.stop_rpc_client() self.client = None def ensure_client(self) -> Any: @@ -72,6 +78,14 @@ def list_robots(self) -> list[str]: def call_preview_with_timeout( self, call: Callable[[], dict[str, Any]], timeout_status: str ) -> dict[str, Any]: + with self._preview_lock: + if self._preview_thread is not None and self._preview_thread.is_alive(): + return { + "success": False, + "status": "PREVIEW_BUSY", + "message": "Previous preview request is still running", + "collision_free": False, + } result: dict[str, Any] | None = None error: Exception | None = None @@ -83,6 +97,8 @@ def run() -> None: error = e thread = threading.Thread(target=run, daemon=True) + with self._preview_lock: + self._preview_thread = thread thread.start() timeout = max(self._timeout_seconds(), 0.0) thread.join(timeout=timeout) @@ -102,14 +118,14 @@ def run() -> None: "collision_free": False, } - def solve_ik_preview(self, pose: Any, robot_name: str) -> dict[str, Any]: + def evaluate_pose_target(self, pose: Any, robot_name: str) -> dict[str, Any]: client = self.ensure_client() return self.call_preview_with_timeout( - lambda: dict(client.solve_ik_preview(pose, robot_name)), "IK_TIMEOUT" + lambda: dict(client.evaluate_pose_target(pose, robot_name)), "IK_TIMEOUT" ) - def solve_fk_preview(self, joints: Any, robot_name: str) -> dict[str, Any]: + def evaluate_joint_target(self, joints: Any, robot_name: str) -> dict[str, Any]: client = self.ensure_client() return self.call_preview_with_timeout( - lambda: dict(client.solve_fk_preview(joints, robot_name)), "FK_TIMEOUT" + lambda: dict(client.evaluate_joint_target(joints, robot_name)), "FK_TIMEOUT" ) diff --git a/dimos/manipulation/viser_panel/module.py b/dimos/manipulation/viser_panel/module.py index 34afe7118e..c927351882 100644 --- a/dimos/manipulation/viser_panel/module.py +++ b/dimos/manipulation/viser_panel/module.py @@ -208,19 +208,6 @@ def _build_gui(self) -> None: on_clear_plan=lambda: self._run_operation("Clear Plan", self._clear_plan), ) - def _wire_static_callbacks(self) -> None: - if not self._handles: - return - self._handles["robot"].on_update(lambda event: self._select_robot(str(event.target.value))) - self._handles["preset"].on_update(lambda event: self._apply_preset(str(event.target.value))) - self._handles["plan"].on_click(lambda _: self._run_operation("Plan", self._plan)) - self._handles["preview"].on_click(lambda _: self._run_operation("Preview", self._preview)) - self._handles["execute"].on_click(lambda _: self._run_operation("Execute", self._execute)) - self._handles["cancel"].on_click(lambda _: self._run_operation("Cancel", self._cancel)) - self._handles["clear_plan"].on_click( - lambda _: self._run_operation("Clear Plan", self._clear_plan) - ) - def _poll_loop(self) -> None: interval = 1.0 / max(self.panel_config.poll_hz, 0.1) while not self._stop_event.is_set(): @@ -325,6 +312,20 @@ def _ensure_robot_ui(self, robot_name: str) -> None: self._build_joint_sliders(robot_name, info) self._update_preset_options(info) self._update_current_robot(robot_name) + self._initialize_target_from_current_state() + + def _initialize_target_from_current_state(self) -> None: + if ( + self.session.target_status != TargetStatus.EMPTY + or self.session.cartesian_target is not None + or self.session.joint_target is not None + or self.session.current_ee_pose is None + ): + return + self.session.cartesian_target = self.session.current_ee_pose + if self.session.current_joints is not None: + self.session.joint_target = list(self.session.current_joints) + self._sync_controls_from_targets() def _ensure_scene_nodes(self, robot_name: str, info: dict[str, Any]) -> None: scene = self._ensure_scene() @@ -425,12 +426,12 @@ def _handle_preview_request(self, request: PreviewRequest) -> dict[str, Any]: client = self._client if request.source == "cartesian" and request.pose is not None: return self._call_preview_with_timeout( - lambda: dict(client.solve_ik_preview(request.pose, request.robot_name)), + lambda: dict(client.evaluate_pose_target(request.pose, request.robot_name)), "IK_TIMEOUT", ) if request.source == "joints" and request.joints is not None: return self._call_preview_with_timeout( - lambda: dict(client.solve_fk_preview(request.joints, request.robot_name)), + lambda: dict(client.evaluate_joint_target(request.joints, request.robot_name)), "FK_TIMEOUT", ) return {"success": False, "status": "INVALID", "message": "Invalid preview request"} @@ -524,6 +525,7 @@ def _plan_impl(self) -> None: ok = False if ok: path = self._client.get_planned_path(robot) + poses = self._client.get_planned_path_poses(robot) self.session.plan_state.status = PlanStatus.FRESH self.session.plan_state.robot = robot self.session.plan_state.target_pose = self.session.cartesian_target @@ -532,7 +534,7 @@ def _plan_impl(self) -> None: list(current) if current is not None else None ) self.session.plan_state.planned_path = list(path or []) - self._render_plan_path(self.session.plan_state.planned_path) + self._render_plan_path(self.session.plan_state.planned_path, list(poses or [])) else: self.session.plan_state.status = PlanStatus.FAILED self.session.error = str(self._client.get_error() or "Planning failed") @@ -544,7 +546,8 @@ def _preview_impl(self) -> None: if self._client is None or self.session.selected_robot is None: return path = list(self._client.get_planned_path(self.session.selected_robot) or []) - self._render_plan_path(path) + poses = self._client.get_planned_path_poses(self.session.selected_robot) + self._render_plan_path(path, list(poses or [])) if path: self.session.error = "Previewing planned path in Viser" self._animate_ghost_path(path, self.panel_config.preview_duration) @@ -584,10 +587,10 @@ def _clear_plan_impl(self) -> None: if self._client is not None: self._client.clear_planned_path() self.session.plan_state = self.session.plan_state.__class__() - self._render_plan_path([]) + self._render_plan_path([], []) - def _render_plan_path(self, path: Sequence[JointState]) -> None: - self._ensure_scene().render_plan_path(path) + def _render_plan_path(self, path: Sequence[JointState], poses: Sequence[Pose]) -> None: + self._ensure_scene().render_plan_path(path, poses) def _animate_ghost_path(self, path: Sequence[JointState], duration: float) -> None: self._ensure_scene().animate_ghost_path(path, duration, self.panel_config.preview_fps) diff --git a/dimos/manipulation/viser_panel/scene.py b/dimos/manipulation/viser_panel/scene.py index ec17111086..260d018c98 100644 --- a/dimos/manipulation/viser_panel/scene.py +++ b/dimos/manipulation/viser_panel/scene.py @@ -22,6 +22,7 @@ from dimos.manipulation.planning.utils.mesh_utils import prepare_urdf_for_drake from dimos.manipulation.viser_panel.animation import PreviewAnimator from dimos.manipulation.viser_panel.state import PanelSession +from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.sensor_msgs.JointState import JointState GOAL_ROBOT_FEASIBLE_COLOR = (255, 122, 0) @@ -97,12 +98,12 @@ def set_selected_ghost_joints(self, joints: Sequence[float]) -> bool: self.set_urdf_joints(ghost, joints) return True - def render_plan_path(self, path: Sequence[JointState]) -> None: + def render_plan_path(self, path: Sequence[JointState], poses: Sequence[Pose]) -> None: if self.server is None: return positions = [ - [float(index), waypoint.position[0] if waypoint.position else 0.0, 0.02] - for index, waypoint in enumerate(path) + [float(pose.position.x), float(pose.position.y), float(pose.position.z)] + for pose in poses ] if "plan_path" in self.handles: self.handles["plan_path"].remove() diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index 0bb677ebc5..e815709d97 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -27,7 +27,7 @@ } # These need git LFS, so can't be run on the ubuntu runners. -SELF_HOSTED_BLUEPRINTS = frozenset( +_SELF_HOSTED_BLUEPRINTS = frozenset( { "alfred-nav", "coordinator-basic", @@ -65,11 +65,12 @@ "xarm6-planner-only", "xarm7-planner-coordinator", "xarm7-planner-coordinator-agent", + "xarm7-viser-panel-mock", } ) -UBUNTU_BLUEPRINTS = sorted(set(all_blueprints) - SELF_HOSTED_BLUEPRINTS) -SELF_HOSTED_BLUEPRINTS = sorted(SELF_HOSTED_BLUEPRINTS) +UBUNTU_BLUEPRINTS = sorted(set(all_blueprints) - _SELF_HOSTED_BLUEPRINTS) +SELF_HOSTED_BLUEPRINTS = sorted(_SELF_HOSTED_BLUEPRINTS) def _check_blueprint(blueprint_name: str) -> None: @@ -95,6 +96,10 @@ def test_old_self_hosted_blueprints() -> None: assert not unused_names +def test_xarm7_viser_panel_mock_requires_self_hosted_lfs() -> None: + assert "xarm7-viser-panel-mock" in SELF_HOSTED_BLUEPRINTS + + @pytest.mark.parametrize("blueprint_name", UBUNTU_BLUEPRINTS) def test_blueprint_is_valid(blueprint_name: str) -> None: """Validate blueprints that should import on the ubuntu-latest runner.""" diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 2edbc12c30..d1c3e0e284 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -160,7 +160,7 @@ KeyboardTeleopModule ──→ ControlCoordinator ──→ ManipulationModule | File | Description | |------|-------------| | [`manipulation_module.py`](/dimos/manipulation/manipulation_module.py) | Main module (RPC interface, state machine) | -| [`viser_panel/__init__.py`](/dimos/manipulation/viser_panel/__init__.py) | Optional Viser operator panel package over the manipulation RPC interface | +| [`viser_panel/module.py`](/dimos/manipulation/viser_panel/module.py) | Optional Viser operator panel module over the manipulation RPC interface | | [`manipulation/blueprints.py`](/dimos/manipulation/blueprints.py) | Planner and perception blueprints | | [`robot/manipulators/a750/blueprints.py`](/dimos/robot/manipulators/a750/blueprints.py) | A-750 keyboard teleop blueprint | | [`robot/manipulators/piper/blueprints.py`](/dimos/robot/manipulators/piper/blueprints.py) | Piper keyboard teleop blueprint | From 060ec5e28a0066bb7244e32fb44a848339b6854f Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 15:48:41 -0700 Subject: [PATCH 7/8] fix: satisfy viser panel mypy lint --- dimos/manipulation/viser_panel/controller.py | 3 ++- dimos/manipulation/viser_panel/module.py | 9 ++++++--- dimos/manipulation/viser_panel/scene.py | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dimos/manipulation/viser_panel/controller.py b/dimos/manipulation/viser_panel/controller.py index aa9cf8c4c0..45cad555bc 100644 --- a/dimos/manipulation/viser_panel/controller.py +++ b/dimos/manipulation/viser_panel/controller.py @@ -22,7 +22,8 @@ def __init__(self, panel: Any) -> None: self._panel = panel def refresh_panel_state(self) -> dict[str, Any]: - return self._panel._refresh_panel_state_impl() + snapshot: dict[str, Any] = self._panel._refresh_panel_state_impl() + return snapshot def select_robot(self, robot_name: str) -> None: self._panel._select_robot_impl(robot_name) diff --git a/dimos/manipulation/viser_panel/module.py b/dimos/manipulation/viser_panel/module.py index c927351882..cbab263f6c 100644 --- a/dimos/manipulation/viser_panel/module.py +++ b/dimos/manipulation/viser_panel/module.py @@ -330,9 +330,12 @@ def _initialize_target_from_current_state(self) -> None: def _ensure_scene_nodes(self, robot_name: str, info: dict[str, Any]) -> None: scene = self._ensure_scene() prepared_urdf_path = self._prepared_urdf_path - if getattr(prepared_urdf_path, "__func__", None) is not type(self)._prepared_urdf_path: - scene.prepared_urdf_path = prepared_urdf_path - scene.ensure_scene_nodes(robot_name, info, self._target_pose_changed) + path_factory = ( + prepared_urdf_path + if getattr(prepared_urdf_path, "__func__", None) is not type(self)._prepared_urdf_path + else None + ) + scene.ensure_scene_nodes(robot_name, info, self._target_pose_changed, path_factory) def _build_joint_sliders(self, robot_name: str, info: dict[str, Any]) -> None: self._ensure_gui().build_joint_sliders(robot_name, info, self._joint_slider_changed) diff --git a/dimos/manipulation/viser_panel/scene.py b/dimos/manipulation/viser_panel/scene.py index 260d018c98..3c0dbce72a 100644 --- a/dimos/manipulation/viser_panel/scene.py +++ b/dimos/manipulation/viser_panel/scene.py @@ -52,6 +52,7 @@ def ensure_scene_nodes( robot_name: str, info: dict[str, Any], on_target_update: Callable[[Any], None], + prepared_urdf_path: Callable[[dict[str, Any]], Path] | None = None, ) -> None: if self.server is None: return @@ -72,9 +73,10 @@ def ensure_scene_nodes( else f"/targets/{robot_name}/ghost" ) mesh_color_override = GOAL_ROBOT_MESH_COLOR if kind == "ghost" else None + path_factory = prepared_urdf_path or self.prepared_urdf_path self.urdfs[key] = ViserUrdf( self.server, - self.prepared_urdf_path(info), + path_factory(info), root_node_name=root_node_name, mesh_color_override=mesh_color_override, ) From ce87fb19a66ee4aa6bc36dc309b1fe9f02f69e9b Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 6 Jun 2026 15:50:10 -0700 Subject: [PATCH 8/8] fix: avoid execute gate action status race --- dimos/manipulation/test_viser_panel.py | 19 +++++++++++++++++++ dimos/manipulation/viser_panel/module.py | 10 ++++------ dimos/manipulation/viser_panel/state.py | 9 +++++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/dimos/manipulation/test_viser_panel.py b/dimos/manipulation/test_viser_panel.py index 342979c878..c88b6a8d41 100644 --- a/dimos/manipulation/test_viser_panel.py +++ b/dimos/manipulation/test_viser_panel.py @@ -401,6 +401,25 @@ def test_panel_execute_requires_operator_launch_opt_in(): assert panel._handles["execute"].disabled +def test_panel_execute_operation_gate_does_not_mutate_action_status(): + panel = _make_panel() + panel.session.runtime = PanelRuntime.RUNNING + panel.session.backend_status = BackendConnectionStatus.READY + panel.session.selected_robot = "arm" + panel.session.manipulation_state = "COMPLETED" + panel.session.current_joints = [0.0] + panel.session.target_status = TargetStatus.FEASIBLE + panel.session.action_status = ActionStatus.EXECUTING + panel.session.plan_state = PanelPlanState( + status=PlanStatus.FRESH, + robot="arm", + start_joints_snapshot=[0.0], + ) + + assert panel._can_execute_for_operation() + assert panel.session.action_status == ActionStatus.EXECUTING + + def test_panel_renders_plan_path_with_viser_line_segment_shape(): session = PanelSession(selected_robot="arm") handles: dict[str, object] = {} diff --git a/dimos/manipulation/viser_panel/module.py b/dimos/manipulation/viser_panel/module.py index cbab263f6c..95a8125347 100644 --- a/dimos/manipulation/viser_panel/module.py +++ b/dimos/manipulation/viser_panel/module.py @@ -691,12 +691,10 @@ def _can_plan_for_operation(self) -> bool: ) def _can_execute_for_operation(self) -> bool: - previous_action = self.session.action_status - try: - self.session.action_status = ActionStatus.IDLE - return self.session.can_execute(self.panel_config.current_match_tolerance) - finally: - self.session.action_status = previous_action + return self.session.can_execute( + self.panel_config.current_match_tolerance, + action_status=ActionStatus.IDLE, + ) ViserManipulationPanelModule.__annotations__["config"] = ViserManipulationPanelConfig diff --git a/dimos/manipulation/viser_panel/state.py b/dimos/manipulation/viser_panel/state.py index 00f60ac928..6f80f6028a 100644 --- a/dimos/manipulation/viser_panel/state.py +++ b/dimos/manipulation/viser_panel/state.py @@ -148,12 +148,17 @@ def can_cancel(self) -> bool: self.manipulation_state == "EXECUTING" ) - def can_execute(self, current_tolerance: float) -> bool: + def can_execute( + self, + current_tolerance: float, + action_status: ActionStatus | None = None, + ) -> bool: plan = self.plan_state + effective_action_status = action_status or self.action_status if not ( self.runtime == PanelRuntime.RUNNING and self.backend_status == BackendConnectionStatus.READY - and self.action_status == ActionStatus.IDLE + and effective_action_status == ActionStatus.IDLE and self.target_status == TargetStatus.FEASIBLE and self.manipulation_state in {"IDLE", "COMPLETED"} and plan.status == PlanStatus.FRESH