Files
cadSet/text-to-cad/viewer/moveit2_server/moveit2_server/srdf_source.py
T
2026-07-22 19:38:36 +08:00

262 lines
9.4 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from pathlib import Path
from typing import Literal
import xml.etree.ElementTree as ET
SRDF_SUFFIX = ".srdf"
SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf"
LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer"
class SrdfSourceError(ValueError):
pass
@dataclass(frozen=True)
class SrdfChain:
base_link: str
tip_link: str
@dataclass(frozen=True)
class SrdfPlanningGroup:
name: str
joint_names: tuple[str, ...]
link_names: tuple[str, ...]
chains: tuple[SrdfChain, ...]
subgroups: tuple[str, ...]
@dataclass(frozen=True)
class SrdfEndEffector:
name: str
parent_link: str
group: str
parent_group: str
@dataclass(frozen=True)
class SrdfGroupState:
name: str
group: str
joint_values_by_name: dict[str, float]
@property
def joint_values_by_name_rad(self) -> dict[str, float]:
return self.joint_values_by_name
@dataclass(frozen=True)
class SrdfDisabledCollisionPair:
link1: str
link2: str
reason: str
source: Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"] = "manual"
@dataclass(frozen=True)
class SrdfSource:
file_ref: str
source_path: Path
robot_name: str
urdf_ref: str
planning_groups: tuple[SrdfPlanningGroup, ...]
end_effectors: tuple[SrdfEndEffector, ...]
group_states: tuple[SrdfGroupState, ...]
disabled_collision_pairs: tuple[SrdfDisabledCollisionPair, ...]
def read_srdf_source(srdf_path: Path) -> SrdfSource:
resolved_path = srdf_path.resolve()
if resolved_path.suffix.lower() != SRDF_SUFFIX:
raise SrdfSourceError(f"{resolved_path} is not an SRDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise SrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SRDF XML") from exc
return parse_srdf_root(root, source_path=resolved_path)
def parse_srdf_root(root: ET.Element, *, source_path: Path) -> SrdfSource:
if root.tag != "robot":
raise SrdfSourceError(f"{_relative_to_repo(source_path)} root element must be <robot>")
robot_name = str(root.attrib.get("name") or "").strip()
if not robot_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} robot name is required")
planning_groups: list[SrdfPlanningGroup] = []
group_names: list[str] = []
for group_element in root.findall("group"):
name = str(group_element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group name is required")
group_names.append(name)
planning_groups.append(
SrdfPlanningGroup(
name=name,
joint_names=_child_names(group_element, "joint"),
link_names=_child_names(group_element, "link"),
chains=tuple(
SrdfChain(
base_link=str(chain.attrib.get("base_link") or "").strip(),
tip_link=str(chain.attrib.get("tip_link") or "").strip(),
)
for chain in group_element.findall("chain")
),
subgroups=_child_names(group_element, "group"),
)
)
_raise_on_duplicates(group_names, source_path=source_path, label="group")
end_effectors: list[SrdfEndEffector] = []
end_effector_names: list[str] = []
for element in root.findall("end_effector"):
name = str(element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} end_effector name is required")
end_effector_names.append(name)
end_effectors.append(
SrdfEndEffector(
name=name,
parent_link=str(element.attrib.get("parent_link") or "").strip(),
group=str(element.attrib.get("group") or "").strip(),
parent_group=str(element.attrib.get("parent_group") or "").strip(),
)
)
_raise_on_duplicates(end_effector_names, source_path=source_path, label="end_effector")
group_states: list[SrdfGroupState] = []
group_state_keys: list[str] = []
for element in root.findall("group_state"):
name = str(element.attrib.get("name") or "").strip()
group = str(element.attrib.get("group") or "").strip()
if not name or not group:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state requires name and group")
group_state_keys.append(f"{group}/{name}")
joint_values: dict[str, float] = {}
joint_names: list[str] = []
for joint_element in element.findall("joint"):
joint_name = str(joint_element.attrib.get("name") or "").strip()
if not joint_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state joint name is required")
joint_names.append(joint_name)
try:
joint_value = float(joint_element.attrib.get("value"))
except (TypeError, ValueError) as exc:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be numeric"
) from exc
if not isfinite(joint_value):
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be finite"
)
joint_values[joint_name] = joint_value
_raise_on_duplicates(joint_names, source_path=source_path, label=f"group_state {name!r} joint")
group_states.append(SrdfGroupState(name=name, group=group, joint_values_by_name=joint_values))
_raise_on_duplicates(group_state_keys, source_path=source_path, label="group_state")
disabled_pairs: list[SrdfDisabledCollisionPair] = []
pair_keys: list[str] = []
for element in root.findall("disable_collisions"):
link1 = str(element.attrib.get("link1") or "").strip()
link2 = str(element.attrib.get("link2") or "").strip()
if not link1 or not link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions requires link1 and link2")
if link1 == link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions cannot repeat the same link")
reason = str(element.attrib.get("reason") or "").strip()
if not reason:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} disable_collisions for {link1!r}/{link2!r} requires a reason"
)
pair_key = "/".join(sorted((link1, link2)))
pair_keys.append(pair_key)
disabled_pairs.append(
SrdfDisabledCollisionPair(
link1=link1,
link2=link2,
reason=reason,
source=_disabled_collision_source(reason),
)
)
_raise_on_duplicates(pair_keys, source_path=source_path, label="disable_collisions")
return SrdfSource(
file_ref=_relative_to_repo(source_path.resolve()),
source_path=source_path.resolve(),
robot_name=robot_name,
urdf_ref=_linked_urdf_ref(root),
planning_groups=tuple(planning_groups),
end_effectors=tuple(end_effectors),
group_states=tuple(group_states),
disabled_collision_pairs=tuple(disabled_pairs),
)
def _local_name(tag: str) -> str:
return str(tag or "").rsplit("}", 1)[-1].split(":", 1)[-1]
def _linked_urdf_ref(root: ET.Element) -> str:
for child in list(root):
if _local_name(child.tag) != "urdf":
continue
tag = str(child.tag or "")
if (
tag.startswith(f"{{{SRDF_METADATA_NAMESPACE}}}")
or tag.startswith(f"{{{LEGACY_EXPLORER_NAMESPACE}}}")
or tag.startswith("tcad:")
or tag.startswith("explorer:")
):
return str(child.attrib.get("path") or "").strip()
return ""
def _child_names(element: ET.Element, tag: str) -> tuple[str, ...]:
names: list[str] = []
for child in element.findall(tag):
name = str(child.attrib.get("name") or "").strip()
if name:
names.append(name)
return tuple(names)
def _raise_on_duplicates(names: list[str], *, source_path: Path, label: str) -> None:
seen = set()
duplicates = []
for name in names:
if name in seen:
duplicates.append(name)
seen.add(name)
if duplicates:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} has duplicate {label} name(s): {sorted(set(duplicates))!r}"
)
def _disabled_collision_source(
reason: str,
) -> Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"]:
normalized = str(reason or "").strip().lower()
if "adjacent" in normalized:
return "adjacent"
if any(token in normalized for token in ("never", "always", "sample", "default")):
return "sampled"
if "setup" in normalized or "assistant" in normalized:
return "setup_assistant"
if "assum" in normalized:
return "assumed"
return "manual"
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()