
Cad Viewer
- 4.5k installs
- 11k repo stars
- Updated July 11, 2026
- earthtojake/text-to-cad
CAD Viewer is an agent skill that starts CAD Viewer and returns review links when users supply explicit CAD, G-code, or robot-description files to inspect.
About
CAD Viewer is a minimal agent skill interface for opening engineering files in a dedicated viewer from an agent session. The documented interface exposes display name CAD Viewer, a short description to open CAD, G-code, and robot files in Viewer, and a default prompt instructing the agent to start CAD Viewer and return review links for explicit CAD, G-code, or robot-description files named by the user. The skill does not describe conversion pipelines or modeling steps; it focuses on launching the viewer and surfacing review URLs once the user points at concrete file paths or filenames. Teams use it when an agent should hand off visual inspection of mechanical CAD exports, CNC G-code programs, or robot description assets without embedding a full CAD editor in chat. Invocation assumes the user supplies explicit file references rather than inferring paths. Output is review links suitable for quick human verification of geometry, toolpaths, or robot models before downstream manufacturing or simulation work.
- Opens CAD, G-code, and robot-description files in CAD Viewer on explicit user file references.
- Default prompt returns review links after starting the viewer for named engineering assets.
- Targets quick visual inspection rather than in-chat CAD editing or conversion.
- Skill interface documents display name, short description, and default invocation prompt only.
- Use when users name concrete CAD, G-code, or robot files needing a review URL.
Cad Viewer by the numbers
- 4,526 all-time installs (skills.sh)
- +782 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #76 of 1,896 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
cad-viewer capabilities & compatibility
- Capabilities
- launch cad viewer on explicit file references · return review links for named assets
- Use cases
- ui design
What cad-viewer says it does
Open CAD, G-code, and robot files in Viewer.
return review links for explicit CAD, G-code, or robot-description files
npx skills add https://github.com/earthtojake/text-to-cad --skill cad-viewerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4.5k |
|---|---|
| repo stars | ★ 11k |
| Last updated | July 11, 2026 |
| Repository | earthtojake/text-to-cad ↗ |
How do I get a review link for a CAD, G-code, or robot file without manually launching the viewer myself?
Launch CAD Viewer and return review links when the user names explicit CAD, G-code, or robot-description files to inspect.
Who is it for?
Developers or mechanical engineers who want an agent to open CAD Viewer on explicit file paths and return shareable review URLs.
Skip if: Skip when you need parametric CAD modeling, mesh repair, or G-code generation rather than viewing existing files.
When should I use this skill?
User names CAD, G-code, or robot-description files and asks to open or review them in CAD Viewer.
What you get
CAD Viewer started with review links returned for the named engineering files.
- Review links for named CAD, G-code, or robot files
Files
interface: display_name: "CAD Viewer" short_description: "Open CAD, G-code, and robot files in Viewer." default_prompt: "Use $cad-viewer to start CAD Viewer and return review links for explicit CAD, G-code, or robot-description files."
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
CAD Viewer Development
Use this reference only when the user asks to modify, debug, or iterate on CAD Viewer source.
CAD Viewer source development happens in the root source workspace, not inside this generated skill runtime.
If you are not currently working directly in the editable source workspace, ask the user for the correct checkout and run the agent from that repository.
Once inside the repository, read root AGENTS.md for guidance on modifying and running the Viewer.
MoveIt2 Server
Load this only when the user specifically needs CAD Viewer's optional SRDF IK or path-planning controls. Plain SRDF review and normal Viewer links do not require MoveIt2.
Start
Run from the cad-viewer skill directory:
npm --prefix scripts/viewer run moveit2:setup
npm --prefix scripts/viewer run moveit2:check
npm --prefix scripts/viewer run moveit2:serveThe server defaults to ws://127.0.0.1:8765/ws. CAD Viewer connects to that URL when VIEWER_MOVEIT2_WS_URL or the browser ?moveit2Ws= query points at it.
Use the configured ROS 2 / MoveIt2 environment. Do not install ROS 2 or MoveIt2 packages into the repository CAD .venv.
Viewer Controls
Open an .srdf file, expand the right-side MoveIt2 sheet, and use:
- Status: linked SRDF and MoveIt2 server status.
- Target: planning group, end effector, target frame, and X/Y/Z target coordinates.
- Solver: IK timeout, attempts, and tolerance.
- Planning: pipeline, planner ID, plan time, velocity scale, and acceleration scale.
- Actions: select pose from the model, reset to the current pose, solve pose, or plan to pose.
Report whether the environment check passed, whether pose solving/planning worked, and any viewer/server error text.
CAD Viewer Features
Load this only when a task needs Viewer file-support details or UI control guidance.
Supported Files
.step,.stp: STEP/STP review through hidden GLB sidecars; supports assembly trees, part hide/show, inspect/focus, face/edge/vertex/part selection, copied#...CAD references, display modes, clip planes, and optional STEP module parameters/animations when a sidecar module exists..stl,.3mf,.glb: mesh viewing with orbit/pan/zoom, screenshots, appearance controls, and solid/wireframe display where available..dxf: read-only flat-pattern viewing, plus plate thickness and bend direction/angle controls when bend preview data is available..gcode: diagnostic toolpath preview; shows layer-colored extrusion ribbons, optional travel moves, visible-layer and detail controls, feature markers, movement stats, bounds, and parser warnings. It does not reslice, simulate firmware, or replace G-code v
interface:
display_name: "CAD Viewer"
short_description: "Open CAD, G-code, and robot files in Viewer."
default_prompt: "Use $cad-viewer to start CAD Viewer and return review links for explicit CAD, G-code, or robot-description files."
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
CAD Viewer Development
Use this reference only when the user asks to modify, debug, or iterate on CAD Viewer source.
CAD Viewer source development happens in the root source workspace, not inside this generated skill runtime.
If you are not currently working directly in the editable source workspace, ask the user for the correct checkout and run the agent from that repository.
Once inside the repository, read root AGENTS.md for guidance on modifying and running the Viewer.
MoveIt2 Server
Load this only when the user specifically needs CAD Viewer's optional SRDF IK or path-planning controls. Plain SRDF review and normal Viewer links do not require MoveIt2.
Start
Run from the cad-viewer skill directory:
npm --prefix scripts/viewer run moveit2:setup
npm --prefix scripts/viewer run moveit2:check
npm --prefix scripts/viewer run moveit2:serveThe server defaults to ws://127.0.0.1:8765/ws. CAD Viewer connects to that URL when VIEWER_MOVEIT2_WS_URL or the browser ?moveit2Ws= query points at it.
Use the configured ROS 2 / MoveIt2 environment. Do not install ROS 2 or MoveIt2 packages into the repository CAD .venv.
Viewer Controls
Open an .srdf file, expand the right-side MoveIt2 sheet, and use:
- Status: linked SRDF and MoveIt2 server status.
- Target: planning group, end effector, target frame, and X/Y/Z target coordinates.
- Solver: IK timeout, attempts, and tolerance.
- Planning: pipeline, planner ID, plan time, velocity scale, and acceleration scale.
- Actions: select pose from the model, reset to the current pose, solve pose, or plan to pose.
Report whether the environment check passed, whether pose solving/planning worked, and any viewer/server error text.
CAD Viewer Features
Load this only when a task needs Viewer file-support details or UI control guidance.
Supported Files
.step,.stp: STEP/STP review through hidden GLB sidecars; supports assembly trees, part hide/show, inspect/focus, face/edge/vertex/part selection, copied#...CAD references, display modes, clip planes, and optional STEP module parameters/animations when a sidecar module exists..stl,.3mf,.glb: mesh viewing with orbit/pan/zoom, screenshots, appearance controls, and solid/wireframe display where available..dxf: read-only flat-pattern viewing, plus plate thickness and bend direction/angle controls when bend preview data is available..gcode: diagnostic toolpath preview; shows layer-colored extrusion ribbons, optional travel moves, visible-layer and detail controls, feature markers, movement stats, bounds, and parser warnings. It does not reslice, simulate firmware, or replace G-code validation..urdf: robot link/mesh viewing with movable joint sliders, reset pose, and copied joint values..srdf: linked-URDF viewing with planning groups, group-state presets, joint controls, and optional MoveIt2 IK/planning controls..sdf: SDF model/world viewing with metadata, counts, warnings, and joint controls when available.
Controls
- Navigation: left-drag to orbit, right/middle-drag to pan, wheel or pinch to zoom, and Arrow/WASD keys to orbit. Use the view sphere for top/bottom/front/back/left/right views; click its center for the default isometric view.
- File browser: toggle the left CAD Viewer sidebar, search files/ids/paths, expand folders, select entries, or switch files from the breadcrumb menus.
- Floating toolbar:
Selectcopies STEP topology references,Drawopens annotation tools,Select Poseappears for robot target picking when available,Open orbit previewstarts an auto-rotating preview, and the copy/download buttons capture screenshots. - Drawing tools: freehand, line, arrow, expand, rectangle, circle, fill, erase, undo, redo, and clear.
- File sheet: open the right sheet for file-specific controls such as STEP tree/parameters, G-code layers/detail/travel, DXF thickness/bends, URDF/SRDF/SDF joints and metadata, plus display and appearance controls.
- Display/appearance: use the file sheet and theme menu for solid/wireframe/x-ray modes, STEP clipping, an independent Explode toggle for animated vertical STEP disassembly, surface colors, edge visibility/detail, highlight styling, backdrop, floor/grid, lighting, theme presets, and color mode.
--editable ./scripts/viewer/packages/cadpy
node_modules
.env
.env.*
!.env.example
!.env.*.example
__pycache__
*.py[cod]
.pytest_cache
tmp
!dist
!dist/**
from pathlib import Path
__path__.append(str(Path(__file__).resolve().parent / "moveit2_server"))
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
#!/usr/bin/env bash
MOVEIT2_SERVER_CONDA_ENV_NAME="${MOVEIT2_SERVER_CONDA_ENV_NAME:-moveit2-server}"
find_conda() {
if [[ -n "${MOVEIT2_SERVER_CONDA_EXE:-}" && -x "${MOVEIT2_SERVER_CONDA_EXE}" ]]; then
printf '%s\n' "${MOVEIT2_SERVER_CONDA_EXE}"
return 0
fi
if [[ -n "${CONDA_EXE:-}" && -x "${CONDA_EXE}" ]]; then
printf '%s\n' "${CONDA_EXE}"
return 0
fi
if command -v conda >/dev/null 2>&1; then
command -v conda
return 0
fi
local candidate
for candidate in \
"${HOME}/miniforge3/bin/conda" \
"${HOME}/mambaforge/bin/conda" \
"${HOME}/miniconda3/bin/conda" \
"${HOME}/anaconda3/bin/conda" \
"/opt/conda/bin/conda"
do
if [[ -x "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
printf 'Could not find conda. Install Miniforge, put conda on PATH, or set MOVEIT2_SERVER_CONDA_EXE/CONDA_EXE.\n' >&2
return 1
}
find_repo_root() {
if [[ -n "${MOVEIT2_SERVER_REPO_ROOT:-}" ]]; then
if [[ ! -d "${MOVEIT2_SERVER_REPO_ROOT}" ]]; then
printf 'MOVEIT2_SERVER_REPO_ROOT does not exist: %s\n' "${MOVEIT2_SERVER_REPO_ROOT}" >&2
return 1
fi
(cd "${MOVEIT2_SERVER_REPO_ROOT}" && pwd -P)
return 0
fi
local root
if root="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "${root}"
return 0
fi
printf 'Could not find a repository root from the current directory. Run from the robot project repo or set MOVEIT2_SERVER_REPO_ROOT.\n' >&2
return 1
}
name: moveit2-server
channels:
- conda-forge
- robostack-jazzy
dependencies:
- python=3.12
- pip
- websockets
- ros-jazzy-ros-base
- ros-jazzy-rclpy
- ros-jazzy-geometry-msgs
- ros-jazzy-moveit-py
- ros-jazzy-moveit-planners-ompl
- ros-jazzy-rmw-cyclonedds-cpp
"""MoveIt2 websocket server for SRDF workflows."""
from __future__ import annotations
from math import isfinite
import posixpath
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from moveit2_server.protocol import MotionProtocolError, SUPPORTED_REQUEST_TYPES
from moveit2_server.srdf_source import SrdfSource, read_srdf_source
DEFAULT_CAD_DIRECTORY = ""
DEFAULT_IK_TIMEOUT = 0.05
DEFAULT_IK_ATTEMPTS = 1
DEFAULT_POSITION_TOLERANCE = 0.002
DEFAULT_PLANNING_PIPELINE = "ompl"
DEFAULT_PLANNER_ID = "RRTConnectkConfigDefault"
DEFAULT_PLANNING_TIME = 1.0
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _path_is_inside(child_path: Path, parent_path: Path) -> bool:
try:
child_path.resolve().relative_to(parent_path.resolve())
return True
except ValueError:
return False
def _file_version(file_path: Path) -> str:
try:
stats = file_path.stat()
except FileNotFoundError:
return ""
if not stats.st_mode:
return ""
return f"{stats.st_size:x}-{stats.st_mtime_ns:x}"
def _combined_version(paths: list[Path]) -> str:
return "|".join(f"{path.name}:{_file_version(path)}" for path in paths)
def _normalize_relative_path(value: Any, *, label: str, suffix: str | None = None) -> str:
raw_value = str(value or "").strip().replace("\\", "/").lstrip("/")
normalized = posixpath.normpath(raw_value)
if not raw_value or normalized in {"", ".", ".."} or normalized.startswith("../"):
raise MotionProtocolError(f"{label} must stay inside the repository: {raw_value or '(missing)'}")
if suffix and not normalized.lower().endswith(suffix):
raise MotionProtocolError(f"{label} must end in {suffix}: {normalized}")
return normalized
def normalize_cad_directory(value: Any = DEFAULT_CAD_DIRECTORY) -> str:
if value is None:
value = DEFAULT_CAD_DIRECTORY
raw_value = str(value).strip()
if not raw_value:
return ""
return _normalize_relative_path(raw_value, label="dir")
def normalize_file_ref(value: Any) -> str:
return _normalize_relative_path(value, label="file", suffix=".srdf")
def _file_ref_relative_to_cad_dir(file_ref: str, *, cad_dir: str, cad_root: Path) -> str:
if not cad_dir:
return file_ref
prefix = f"{cad_dir.rstrip('/')}/"
if not file_ref.startswith(prefix):
return file_ref
scan_relative_ref = file_ref[len(prefix):]
if scan_relative_ref and not (cad_root / file_ref).is_file() and (cad_root / scan_relative_ref).is_file():
return scan_relative_ref
return file_ref
def _resolve_srdf_urdf_path(srdf_source: SrdfSource, *, srdf_path: Path, repo_root: Path) -> Path:
raw_value = str(srdf_source.urdf_ref or "").strip()
if not raw_value:
raise MotionProtocolError("SRDF must include tcad:urdf metadata")
if "\\" in raw_value:
raise MotionProtocolError("SRDF tcad:urdf path must use POSIX '/' separators")
raw_path = Path(raw_value)
if raw_path.is_absolute():
raise MotionProtocolError("SRDF tcad:urdf path must be relative")
candidate = (srdf_path.parent / raw_path).resolve()
if not _path_is_inside(candidate, repo_root):
raise MotionProtocolError(f"SRDF tcad:urdf path must stay inside the repository: {raw_value}")
if candidate.suffix.lower() != ".urdf":
raise MotionProtocolError(f"SRDF tcad:urdf path must end in .urdf: {raw_value}")
if not candidate.is_file():
raise MotionProtocolError(f"SRDF tcad:urdf file does not exist: {raw_value}")
return candidate
def _urdf_robot(urdf_path: Path) -> dict[str, Any]:
try:
root = ET.parse(urdf_path).getroot()
except FileNotFoundError as exc:
raise MotionProtocolError(f"URDF file does not exist: {urdf_path}") from exc
except ET.ParseError as exc:
raise MotionProtocolError(f"URDF file is invalid XML: {urdf_path}") from exc
if root.tag != "robot":
raise MotionProtocolError("URDF root must be <robot>")
links = {
str(link.get("name") or "").strip()
for link in root.findall("link")
if str(link.get("name") or "").strip()
}
joints: dict[str, dict[str, str]] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
if not name:
continue
parent_element = joint.find("parent")
child_element = joint.find("child")
joint_type = str(joint.get("type") or "").strip()
limit_element = joint.find("limit")
lower = _optional_finite_float(limit_element.get("lower") if limit_element is not None else None)
upper = _optional_finite_float(limit_element.get("upper") if limit_element is not None else None)
joints[name] = {
"type": joint_type,
"parent": str(parent_element.get("link") if parent_element is not None else "").strip(),
"child": str(child_element.get("link") if child_element is not None else "").strip(),
"lower": lower if joint_type in {"revolute", "prismatic"} else None,
"upper": upper if joint_type in {"revolute", "prismatic"} else None,
"mimic": joint.find("mimic") is not None,
}
return {
"name": str(root.get("name") or "").strip(),
"links": links,
"joints": joints,
}
def _optional_finite_float(value: Any) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if isfinite(parsed) else None
def _active_joint_names(robot: dict[str, Any]) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name, joint in joints.items()
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic"))
]
def _all_joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
if not base_link or not tip_link:
return []
if base_link == tip_link:
return []
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = {}
for raw_name, raw_joint in joints.items():
if not isinstance(raw_joint, dict):
continue
parent = str(raw_joint.get("parent") or "").strip()
child = str(raw_joint.get("child") or "").strip()
if parent and child:
by_parent.setdefault(parent, []).append((str(raw_name), raw_joint))
stack: list[tuple[str, list[tuple[str, dict[str, Any]]]]] = [(base_link, [])]
visited: set[str] = set()
while stack:
link_name, path = stack.pop()
if link_name == tip_link:
return [joint_name for joint_name, _joint in path]
if link_name in visited:
continue
visited.add(link_name)
for joint_name, joint in reversed(by_parent.get(link_name, [])):
child = str(joint.get("child") or "").strip()
if child:
stack.append((child, [*path, (joint_name, joint)]))
return []
def _joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
names: list[str] = []
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
joint = joints.get(joint_name)
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic")):
names.append(joint_name)
return names
def _append_unique(target: list[str], values: list[str]) -> None:
seen = set(target)
for value in values:
if value not in seen:
target.append(value)
seen.add(value)
def _joint_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> list[str]:
explicit_joint_names = list(group.get("jointNames") or [])
if explicit_joint_names:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name in explicit_joint_names
if isinstance(joints.get(str(name)), dict)
and str(joints[str(name)].get("type") or "").strip() != "fixed"
and not bool(joints[str(name)].get("mimic"))
]
names: list[str] = []
for chain in group.get("chains") or []:
if not isinstance(chain, dict):
continue
_append_unique(
names,
_joint_names_for_chain(
robot,
base_link=str(chain.get("baseLink") or "").strip(),
tip_link=str(chain.get("tipLink") or "").strip(),
),
)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups") or []:
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
_append_unique(
names,
_joint_names_for_group(
subgroup,
robot=robot,
groups_by_name=groups_by_name,
visited=visited,
),
)
return names
def _positive_float(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be positive") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be positive")
return numeric
def _positive_int(value: Any, *, label: str, default: int) -> int:
if value is None:
value = default
try:
numeric = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a positive integer") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be a positive integer")
return numeric
def _scaling(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be between 0 and 1") from exc
if numeric <= 0 or numeric > 1:
raise MotionProtocolError(f"{label} must be between 0 and 1")
return numeric
def _boolean(value: Any, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _string(value: Any, *, label: str, default: str = "") -> str:
normalized = str(value if value is not None else default).strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _optional_object(value: Any, *, label: str) -> dict[str, Any]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
return value
def _validate_srdf_inventory(metadata: dict[str, Any], *, robot: dict[str, Any]) -> dict[str, Any]:
if str(metadata.get("robotName") or "").strip() != str(robot.get("name") or "").strip():
raise MotionProtocolError("SRDF robotName must match the linked URDF robot name")
link_names = robot.get("links")
joint_payload = robot.get("joints")
if not isinstance(link_names, set) or not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
joint_names = set(joint_payload)
groups = _validate_planning_groups(metadata.get("planningGroups"), link_names=link_names, joint_names=joint_names)
group_by_name = {group["name"]: group for group in groups}
end_effectors = _validate_end_effectors(
metadata.get("endEffectors"),
link_names=link_names,
groups=group_by_name,
robot=robot,
)
group_states = _validate_group_states(metadata.get("groupStates"), groups=group_by_name, robot=robot)
disabled_pairs = _validate_disabled_pairs(metadata.get("disabledCollisionPairs"), link_names=link_names)
return {
**metadata,
"planningGroups": groups,
"endEffectors": end_effectors,
"groupStates": group_states,
"disabledCollisionPairs": disabled_pairs,
}
def _group_tip_link(group: Any) -> str:
if isinstance(group, dict):
link_names = group.get("linkNames")
if isinstance(link_names, list) and link_names:
return str(link_names[-1])
chains = group.get("chains")
if isinstance(chains, list) and chains:
chain = chains[-1]
if isinstance(chain, dict):
return str(chain.get("tipLink") or "")
return ""
link_names = getattr(group, "link_names", ())
if link_names:
return str(link_names[-1])
chains = getattr(group, "chains", ())
if chains:
return str(getattr(chains[-1], "tip_link", "") or "")
return ""
def _end_effector_link(end_effector: Any, groups_by_name: dict[str, Any]) -> str:
group_name = str(getattr(end_effector, "group", "") or "")
group = groups_by_name.get(group_name)
if group is not None:
group_tip = _group_tip_link(group)
if group_tip:
return group_tip
return ""
def _srdf_inventory_from_source(srdf_source: SrdfSource, *, srdf_path: Path, urdf_path: Path) -> dict[str, Any]:
groups_by_name = {group.name: group for group in srdf_source.planning_groups}
return {
"urdf": str(urdf_path),
"srdf": str(srdf_path),
"robotName": srdf_source.robot_name,
"planningGroups": [
{
"name": group.name,
"jointNames": list(group.joint_names),
"linkNames": list(group.link_names),
"chains": [
{"baseLink": chain.base_link, "tipLink": chain.tip_link}
for chain in group.chains
],
"subgroups": list(group.subgroups),
}
for group in srdf_source.planning_groups
],
"endEffectors": [
{
"name": end_effector.name,
"link": _end_effector_link(end_effector, groups_by_name) or end_effector.parent_link,
"parentLink": end_effector.parent_link,
"group": end_effector.group,
"parentGroup": end_effector.parent_group,
}
for end_effector in srdf_source.end_effectors
],
"groupStates": [
{
"name": group_state.name,
"group": group_state.group,
"jointValuesByName": group_state.joint_values_by_name,
"jointValuesByNameRad": group_state.joint_values_by_name,
}
for group_state in srdf_source.group_states
],
"disabledCollisionPairs": [
{
"link1": pair.link1,
"link2": pair.link2,
"reason": pair.reason,
"source": pair.source,
}
for pair in srdf_source.disabled_collision_pairs
],
}
def _validate_planning_groups(value: Any, *, link_names: set[str], joint_names: set[str]) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value:
raise MotionProtocolError("SRDF planningGroups must be a non-empty array")
groups: list[dict[str, Any]] = []
seen = set()
for raw_group in value:
if not _plain_object(raw_group):
raise MotionProtocolError("SRDF planning group must be an object")
name = _string(raw_group.get("name"), label="SRDF planning group name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF planning group {name}")
seen.add(name)
group_joint_names = _validate_names(raw_group.get("jointNames"), allowed=joint_names, label=f"SRDF planning group {name} jointNames")
group_link_names = _validate_names(raw_group.get("linkNames"), allowed=link_names, label=f"SRDF planning group {name} linkNames")
chains = raw_group.get("chains") if isinstance(raw_group.get("chains"), list) else []
normalized_chains: list[dict[str, str]] = []
for raw_chain in chains:
if not _plain_object(raw_chain):
raise MotionProtocolError(f"SRDF planning group {name} chains must contain objects")
base_link = _string(raw_chain.get("baseLink"), label=f"SRDF planning group {name} chain baseLink")
tip_link = _string(raw_chain.get("tipLink"), label=f"SRDF planning group {name} chain tipLink")
if base_link not in link_names or tip_link not in link_names:
raise MotionProtocolError(f"SRDF planning group {name} chain references missing URDF link")
normalized_chains.append({"baseLink": base_link, "tipLink": tip_link})
subgroups = [str(group).strip() for group in raw_group.get("subgroups", []) if str(group).strip()]
groups.append({
"name": name,
"jointNames": group_joint_names,
"linkNames": group_link_names,
"chains": normalized_chains,
"subgroups": subgroups,
})
group_names = {group["name"] for group in groups}
for group in groups:
for subgroup in group["subgroups"]:
if subgroup not in group_names:
raise MotionProtocolError(f"SRDF planning group {group['name']} references missing subgroup {subgroup}")
return groups
def _validate_names(value: Any, *, allowed: set[str], label: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError(f"{label} must be an array")
names: list[str] = []
seen = set()
for raw_name in value:
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty names")
if name in seen:
raise MotionProtocolError(f"{label} includes duplicate {name}")
if name not in allowed:
raise MotionProtocolError(f"{label} references missing name {name}")
seen.add(name)
names.append(name)
return names
def _link_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> set[str]:
links = set(str(link) for link in group.get("linkNames", []) if str(link))
joints = robot.get("joints")
if not isinstance(joints, dict):
return links
def add_joint_links(joint_name: str) -> None:
joint = joints.get(joint_name)
if not isinstance(joint, dict):
return
child = str(joint.get("child") or "").strip()
if child:
links.add(child)
for joint_name in group.get("jointNames", []):
add_joint_links(str(joint_name))
for chain in group.get("chains", []):
if not isinstance(chain, dict):
continue
base_link = str(chain.get("baseLink") or "").strip()
tip_link = str(chain.get("tipLink") or "").strip()
if tip_link:
links.add(tip_link)
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
add_joint_links(joint_name)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups", []):
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
links.update(_link_names_for_group(subgroup, robot=robot, groups_by_name=groups_by_name, visited=visited))
links.discard("")
return links
def _joint_adjacent_to_any_link(robot: dict[str, Any], parent_link: str, child_links: set[str]) -> bool:
joints = robot.get("joints")
if not isinstance(joints, dict):
return False
for joint in joints.values():
if not isinstance(joint, dict):
continue
parent = str(joint.get("parent") or "").strip()
child = str(joint.get("child") or "").strip()
if (parent == parent_link and child in child_links) or (child == parent_link and parent in child_links):
return True
return False
def _validate_end_effectors(
value: Any,
*,
link_names: set[str],
groups: dict[str, dict[str, Any]],
robot: dict[str, Any],
) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF endEffectors must be an array")
end_effectors: list[dict[str, Any]] = []
seen = set()
for raw_end_effector in value:
if not _plain_object(raw_end_effector):
raise MotionProtocolError("SRDF end effector must be an object")
name = _string(raw_end_effector.get("name"), label="SRDF end effector name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF end effector {name}")
seen.add(name)
parent_link = _string(raw_end_effector.get("parentLink"), label=f"SRDF end effector {name} parentLink")
if parent_link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentLink {parent_link}")
group_name = _string(raw_end_effector.get("group"), label=f"SRDF end effector {name} group")
if group_name not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing group {group_name}")
parent_group = str(raw_end_effector.get("parentGroup") or "").strip()
if parent_group and parent_group not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentGroup {parent_group}")
link = str(raw_end_effector.get("link") or _group_tip_link(groups[group_name]) or parent_link).strip()
if link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing link {link}")
end_effector_links = _link_names_for_group(groups[group_name], robot=robot, groups_by_name=groups)
if parent_group:
parent_group_links = _link_names_for_group(groups[parent_group], robot=robot, groups_by_name=groups)
overlap = sorted(end_effector_links & parent_group_links)
if overlap:
raise MotionProtocolError(f"SRDF end effector {name} group shares link(s) with parentGroup: {overlap}")
if parent_link not in parent_group_links:
raise MotionProtocolError(
f"SRDF end effector {name} parentLink {parent_link} is not in parentGroup {parent_group}"
)
if end_effector_links and parent_link not in end_effector_links and not _joint_adjacent_to_any_link(
robot,
parent_link,
end_effector_links,
):
raise MotionProtocolError(f"SRDF end effector {name} parentLink is not adjacent to its group")
end_effectors.append({
"name": name,
"link": link,
"parentLink": parent_link,
"group": group_name,
"parentGroup": parent_group,
})
return end_effectors
def _validate_group_state_joint_value(state_name: str, joint_name: str, value: float, joint: dict[str, Any]) -> None:
joint_type = str(joint.get("type") or "").strip()
if joint_type == "fixed":
raise MotionProtocolError(f"SRDF group state {state_name} cannot set fixed joint {joint_name}")
if bool(joint.get("mimic")):
raise MotionProtocolError(f"SRDF group state {state_name} cannot set mimic joint {joint_name}")
if joint_type == "continuous":
return
lower = joint.get("lower")
upper = joint.get("upper")
if isinstance(lower, float) and value < lower:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is below its URDF lower limit")
if isinstance(upper, float) and value > upper:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is above its URDF upper limit")
def _validate_group_states(value: Any, *, groups: dict[str, dict[str, Any]], robot: dict[str, Any]) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF groupStates must be an array")
states: list[dict[str, Any]] = []
seen_keys: set[str] = set()
joint_payload = robot.get("joints")
if not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
for raw_state in value:
if not _plain_object(raw_state):
raise MotionProtocolError("SRDF group state must be an object")
name = _string(raw_state.get("name"), label="SRDF group state name")
group = _string(raw_state.get("group"), label=f"SRDF group state {name} group")
state_key = f"{group}/{name}"
if state_key in seen_keys:
raise MotionProtocolError(f"Duplicate SRDF group state {state_key}")
seen_keys.add(state_key)
if group not in groups:
raise MotionProtocolError(f"SRDF group state {name} references missing group {group}")
group_joint_names = set(_joint_names_for_group(groups[group], robot=robot, groups_by_name=groups))
joint_values = raw_state.get("jointValuesByName")
legacy_joint_values = raw_state.get("jointValuesByNameRad")
if joint_values is None and legacy_joint_values is not None:
joint_values = legacy_joint_values
if not _plain_object(joint_values):
raise MotionProtocolError(f"SRDF group state {name} jointValuesByName must be an object")
parsed_values: dict[str, float] = {}
for raw_joint_name, raw_value in joint_values.items():
joint_name = str(raw_joint_name or "").strip()
joint = joint_payload.get(joint_name)
if not isinstance(joint, dict):
raise MotionProtocolError(f"SRDF group state {name} references missing joint {joint_name}")
if joint_name not in group_joint_names:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} is not in group {group}")
try:
parsed_value = float(raw_value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be numeric") from exc
if not isfinite(parsed_value):
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be finite")
_validate_group_state_joint_value(name, joint_name, parsed_value, joint)
parsed_values[joint_name] = parsed_value
states.append({"name": name, "group": group, "jointValuesByName": parsed_values, "jointValuesByNameRad": parsed_values})
return states
def _validate_disabled_pairs(value: Any, *, link_names: set[str]) -> list[dict[str, str]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF disabledCollisionPairs must be an array")
pairs: list[dict[str, str]] = []
seen: set[str] = set()
for raw_pair in value:
if not _plain_object(raw_pair):
raise MotionProtocolError("SRDF disabled collision pair must be an object")
link1 = _string(raw_pair.get("link1"), label="SRDF disabled collision link1")
link2 = _string(raw_pair.get("link2"), label="SRDF disabled collision link2")
if link1 == link2:
raise MotionProtocolError("SRDF disabled collision pair cannot repeat the same link")
if link1 not in link_names or link2 not in link_names:
raise MotionProtocolError("SRDF disabled collision pair references missing URDF link")
pair_key = "/".join(sorted((link1, link2)))
if pair_key in seen:
raise MotionProtocolError(f"Duplicate SRDF disabled collision pair {pair_key}")
seen.add(pair_key)
reason = str(raw_pair.get("reason") or "").strip()
if not reason:
raise MotionProtocolError(f"SRDF disabled collision pair {pair_key} requires a reason")
pairs.append({
"link1": link1,
"link2": link2,
"reason": reason,
"source": str(raw_pair.get("source") or "manual").strip(),
})
return pairs
def _moveit_settings(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("moveit2"), label="moveit2")
def _target_payload(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("target"), label="target")
def _find_group(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for group in metadata.get("planningGroups", []):
if str(group.get("name") or "") == name:
return group
raise MotionProtocolError(f"Selected planning group {name} is not defined by the SRDF")
def _find_end_effector(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for end_effector in metadata.get("endEffectors", []):
if str(end_effector.get("name") or "") == name:
return end_effector
raise MotionProtocolError(f"Selected end effector {name} is not defined by the SRDF")
def _default_end_effector(metadata: dict[str, Any], planning_group_name: str) -> dict[str, Any] | None:
end_effectors = list(metadata.get("endEffectors", []))
for end_effector in end_effectors:
if str(end_effector.get("parentGroup") or "") == planning_group_name:
return end_effector
return end_effectors[0] if end_effectors else None
def _build_command(
*,
command_name: str,
payload: Any,
metadata: dict[str, Any],
robot: dict[str, Any],
) -> dict[str, Any]:
moveit2 = _moveit_settings(payload)
target = _target_payload(payload)
default_group = metadata["planningGroups"][0]
planning_group_name = _string(
moveit2.get("planningGroup"),
label="moveit2.planningGroup",
default=str(default_group.get("name") or ""),
)
planning_group = _find_group(metadata, planning_group_name)
default_end_effector = _default_end_effector(metadata, planning_group_name)
selected_end_effector_name = str(moveit2.get("endEffector") or target.get("endEffector") or "").strip()
selected_end_effector = _find_end_effector(metadata, selected_end_effector_name) if selected_end_effector_name else default_end_effector
if selected_end_effector is None:
raise MotionProtocolError("SRDF must define an end effector for MoveIt2 pose requests")
robot_links = robot.get("links")
if not isinstance(robot_links, set):
raise MotionProtocolError("URDF robot inventory is invalid")
target_frame = _string(
moveit2.get("targetFrame") or target.get("frame"),
label="moveit2.targetFrame",
default=next(iter(sorted(robot_links)), ""),
)
if target_frame not in robot_links:
raise MotionProtocolError(f"moveit2.targetFrame references missing URDF link {target_frame}")
ik = _optional_object(moveit2.get("ik"), label="moveit2.ik")
planning = _optional_object(moveit2.get("planning"), label="moveit2.planning")
target_has_orientation = "quat_xyzw" in target or "rpy" in target
position_only_ik = _boolean(ik.get("positionOnly"), default=not target_has_orientation)
if not target_has_orientation and not position_only_ik:
raise MotionProtocolError("target orientation is required when moveit2.ik.positionOnly is false")
groups_by_name = {
str(group.get("name") or ""): group
for group in metadata.get("planningGroups", [])
if isinstance(group, dict) and str(group.get("name") or "")
}
joint_names = _joint_names_for_group(planning_group, robot=robot, groups_by_name=groups_by_name)
target_link = _string(
moveit2.get("targetLink") or target.get("targetLink") or target.get("link"),
label="moveit2.targetLink",
default=str(selected_end_effector.get("link") or ""),
)
if target_link not in robot_links:
raise MotionProtocolError(f"moveit2.targetLink references missing URDF link {target_link}")
command = {
"planningGroup": planning_group_name,
"jointNames": joint_names,
"endEffectors": [
{
"name": selected_end_effector["name"],
"link": target_link,
"inferredLink": selected_end_effector["link"],
"frame": target_frame,
"planningGroup": planning_group_name,
"jointNames": joint_names,
"positionTolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
}
],
"ik": {
"timeout": _positive_float(ik.get("timeout"), label="moveit2.ik.timeout", default=DEFAULT_IK_TIMEOUT),
"attempts": _positive_int(ik.get("attempts"), label="moveit2.ik.attempts", default=DEFAULT_IK_ATTEMPTS),
"positionOnly": position_only_ik,
"tolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
},
"planner": {
"pipeline": _string(planning.get("pipeline"), label="moveit2.planning.pipeline", default=DEFAULT_PLANNING_PIPELINE),
"plannerId": _string(planning.get("plannerId"), label="moveit2.planning.plannerId", default=DEFAULT_PLANNER_ID),
"planningTime": _positive_float(planning.get("planningTime"), label="moveit2.planning.planningTime", default=DEFAULT_PLANNING_TIME),
"maxVelocityScalingFactor": _scaling(
planning.get("maxVelocityScalingFactor"),
label="moveit2.planning.maxVelocityScalingFactor",
default=1.0,
),
"maxAccelerationScalingFactor": _scaling(
planning.get("maxAccelerationScalingFactor"),
label="moveit2.planning.maxAccelerationScalingFactor",
default=1.0,
),
},
}
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name}")
return command
def build_moveit2_context(
*,
repo_root: str | Path,
dir: Any = DEFAULT_CAD_DIRECTORY,
file: Any,
type: Any,
payload: Any = None,
) -> dict[str, Any]:
command_name = str(type or "").strip()
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name or '(missing)'}")
resolved_repo_root = Path(repo_root).resolve()
cad_dir = normalize_cad_directory(dir)
file_ref = normalize_file_ref(file)
cad_root = (resolved_repo_root / cad_dir).resolve()
if not _path_is_inside(cad_root, resolved_repo_root):
raise MotionProtocolError(f"dir must stay inside the repository: {cad_dir}")
file_ref = _file_ref_relative_to_cad_dir(file_ref, cad_dir=cad_dir, cad_root=cad_root)
srdf_path = (cad_root / file_ref).resolve()
if not _path_is_inside(srdf_path, cad_root):
root_label = cad_dir or "the repository"
raise MotionProtocolError(f"file must stay inside {root_label}: {file_ref}")
if not srdf_path.is_file():
raise MotionProtocolError(f"SRDF file does not exist: {file_ref}")
try:
srdf_source = read_srdf_source(srdf_path)
except Exception as exc:
raise MotionProtocolError(str(exc)) from exc
urdf_path = _resolve_srdf_urdf_path(srdf_source, srdf_path=srdf_path, repo_root=resolved_repo_root)
robot = _urdf_robot(urdf_path)
inventory_payload = _srdf_inventory_from_source(srdf_source, srdf_path=srdf_path, urdf_path=urdf_path)
srdf_inventory = _validate_srdf_inventory(inventory_payload, robot=robot)
command = _build_command(command_name=command_name, payload=payload, metadata=srdf_inventory, robot=robot)
version_paths = [urdf_path, srdf_path]
return {
"repoRoot": str(resolved_repo_root),
"dir": cad_dir,
"file": file_ref,
"urdfPath": str(urdf_path),
"srdfPath": str(srdf_path),
"modelAssetHash": _combined_version(version_paths),
"commandName": command_name,
"command": command,
"srdf": srdf_inventory,
}
from __future__ import annotations
from functools import lru_cache
from typing import Any
from moveit2_server.protocol import MotionProtocolError, MotionRequest
from moveit2_server.moveit_py import MoveItPyAdapter
@lru_cache(maxsize=1)
def _adapter() -> MoveItPyAdapter:
return MoveItPyAdapter()
def dispatch(request: MotionRequest) -> dict[str, Any]:
adapter = _adapter()
if request.type == "srdf.solvePose":
result = adapter.solve_pose(request)
elif request.type == "srdf.planToPose":
result = adapter.plan_to_pose(request)
else:
raise MotionProtocolError(f"Unsupported request type {request.type}")
if not isinstance(result, dict):
raise MotionProtocolError("MoveIt2 adapter result must be an object")
if result.get("ok") is False:
raise RuntimeError(str(result.get("message") or "MoveIt2 request failed"))
result.pop("ok", None)
return result
from __future__ import annotations
import argparse
import json
import time
from typing import Any
def _json_list(value: str, label: str) -> list[Any]:
payload = json.loads(value)
if not isinstance(payload, list):
raise ValueError(f"{label} must be a JSON array")
return payload
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m moveit2_server.joint_state_seed")
parser.add_argument("--names", required=True, help="JSON array of joint names")
parser.add_argument("--positions", required=True, help="JSON array of joint positions in ROS units")
parser.add_argument("--duration", type=float, default=8.0)
parser.add_argument("--rate", type=float, default=30.0)
args = parser.parse_args(argv)
names = [str(name) for name in _json_list(args.names, "names")]
positions = [float(position) for position in _json_list(args.positions, "positions")]
if len(names) != len(positions):
raise ValueError("names and positions must have the same length")
if args.duration <= 0 or args.rate <= 0:
raise ValueError("duration and rate must be positive")
import rclpy
from sensor_msgs.msg import JointState
rclpy.init(args=None)
node = rclpy.create_node("moveit2_server_joint_state_seed")
publisher = node.create_publisher(JointState, "/joint_states", 10)
deadline = time.monotonic() + args.duration
period = 1.0 / args.rate
try:
while time.monotonic() < deadline and rclpy.ok():
message = JointState()
message.header.stamp = node.get_clock().now().to_msg()
message.name = names
message.position = positions
publisher.publish(message)
rclpy.spin_once(node, timeout_sec=0.0)
time.sleep(period)
finally:
try:
node.destroy_node()
except Exception:
pass
try:
rclpy.shutdown()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
import subprocess
import sys
from typing import Any
import xml.etree.ElementTree as ET
from moveit2_server.protocol import (
MotionProtocolError,
normalize_joint_values,
normalize_motion_target,
)
def _deg_to_rad(value: float) -> float:
return (float(value) * math.pi) / 180.0
def _rad_to_deg(value: float) -> float:
return (float(value) * 180.0) / math.pi
def _solve_command(request: Any) -> dict[str, Any]:
return request.command
def _target_end_effector_name(request: Any) -> str:
target = request.payload.get("target") if isinstance(request.payload, dict) else None
if not isinstance(target, dict):
return ""
return str(target.get("endEffector") or "").strip()
def _selected_end_effector(request: Any) -> dict[str, Any]:
command = _solve_command(request)
end_effectors = command.get("endEffectors", [])
target_name = _target_end_effector_name(request)
if target_name:
for end_effector in end_effectors:
if str(end_effector.get("name", "")).strip() == target_name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {target_name}")
for end_effector in end_effectors:
return end_effector
raise MotionProtocolError("moveit_py command endEffectors must be non-empty")
def _joint_names(request: Any) -> list[str]:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
joint_names = [
str(name).strip()
for name in end_effector.get("jointNames", command.get("jointNames", []))
if str(name).strip()
]
if not joint_names:
raise MotionProtocolError("moveit_py command jointNames must be non-empty")
return joint_names
def _end_effector(request: Any, name: str) -> dict[str, Any]:
end_effector = _selected_end_effector(request)
if str(end_effector.get("name", "")).strip() == name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {name}")
def _planning_group(request: Any) -> str:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
planning_group = str(
end_effector.get("planningGroup")
or command.get("planningGroup")
or request.command.get("planningGroup")
or ""
).strip()
if not planning_group:
raise MotionProtocolError("moveit_py command planningGroup must be non-empty")
return planning_group
def _robot_description_for_moveit(urdf_path: Path) -> str:
"""Return URDF XML with relative mesh paths rewritten as file URIs."""
try:
tree = ET.parse(urdf_path)
except ET.ParseError:
return urdf_path.read_text(encoding="utf-8")
root = tree.getroot()
changed = False
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename") or "").strip()
if not filename or "://" in filename or filename.startswith("package:"):
continue
path = Path(filename)
if not path.is_absolute():
path = urdf_path.parent / path
mesh.set("filename", path.resolve().as_uri())
changed = True
if not changed:
return urdf_path.read_text(encoding="utf-8")
return ET.tostring(root, encoding="unicode")
def _active_urdf_joints(urdf_path: Path) -> list[tuple[str, str]]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return []
joints: list[tuple[str, str]] = []
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name and joint_type != "fixed":
joints.append((name, joint_type))
return joints
def _urdf_joint_types(urdf_path: Path) -> dict[str, str]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return {}
joint_types: dict[str, str] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name:
joint_types[name] = joint_type
return joint_types
def _joint_types_for_request(request: Any) -> dict[str, str]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _urdf_joint_types(urdf_path)
if joint_types:
return joint_types
command = _solve_command(request)
fallback_names = [str(name).strip() for name in command.get("jointNames", []) if str(name).strip()]
if not fallback_names:
fallback_names = _joint_names(request)
return {name: "revolute" for name in fallback_names}
def _native_start_joint_values(request: Any, joint_types: dict[str, str] | None = None) -> dict[str, float]:
joint_types = joint_types or _joint_types_for_request(request)
payload = request.payload if isinstance(request.payload, dict) else {}
if "startJointValuesByName" in payload:
return normalize_joint_values(payload.get("startJointValuesByName"), label="startJointValuesByName")
legacy_values = normalize_joint_values(payload.get("startJointValuesByNameDeg"), label="startJointValuesByNameDeg")
return {
name: float(value) if str(joint_types.get(name) or "").strip() == "prismatic" else _deg_to_rad(value)
for name, value in legacy_values.items()
}
def _native_to_legacy_joint_value(value: float, joint_type: str) -> float:
return float(value) if str(joint_type or "").strip() == "prismatic" else _rad_to_deg(value)
def _native_to_legacy_joint_values(values: dict[str, float], joint_types: dict[str, str]) -> dict[str, float]:
return {
name: _native_to_legacy_joint_value(value, str(joint_types.get(name) or "revolute"))
for name, value in values.items()
}
def _moveit2_groups(request: Any) -> list[dict[str, Any]]:
srdf = request.context.get("srdf", {}) if isinstance(request.context, dict) else {}
groups = srdf.get("planningGroups", []) if isinstance(srdf, dict) else []
return [group for group in groups if isinstance(group, dict)]
def _supports_kdl_kinematics(group: dict[str, Any]) -> bool:
if group.get("subgroups"):
return False
return bool(group.get("jointNames") or group.get("chains"))
def _kinematics_config(request: Any) -> dict[str, Any]:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return {
str(group.get("name")): {
"kinematics_solver": "kdl_kinematics_plugin/KDLKinematicsPlugin",
"kinematics_solver_search_resolution": 0.005,
"kinematics_solver_timeout": float(ik.get("timeout") or 0.05),
"position_only_ik": bool(ik.get("positionOnly", True)),
}
for group in _moveit2_groups(request)
if str(group.get("name") or "").strip() and _supports_kdl_kinematics(group)
}
def _planning_pipeline_config(request: Any) -> dict[str, Any]:
planner = request.command.get("planner", {}) if isinstance(request.command, dict) else {}
pipeline = str(planner.get("pipeline") or "ompl")
planner_id = str(planner.get("plannerId") or "RRTConnectkConfigDefault")
planning_time = float(planner.get("planningTime") or 1.0)
def request_params() -> dict[str, Any]:
return {
"planning_attempts": int(request.command.get("ik", {}).get("attempts") or 1),
"planning_pipeline": pipeline,
"planner_id": planner_id,
"planning_time": planning_time,
"max_velocity_scaling_factor": float(planner.get("maxVelocityScalingFactor") or 1.0),
"max_acceleration_scaling_factor": float(planner.get("maxAccelerationScalingFactor") or 1.0),
}
return {
"planning_pipelines": {
"pipeline_names": [pipeline],
},
pipeline: {
"planning_plugin": "ompl_interface/OMPLPlanner",
"planning_plugins": ["ompl_interface/OMPLPlanner"],
"request_adapters": [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
],
"start_state_max_bounds_error": 0.1,
},
"plan_request_params": request_params(),
"ompl_rrtc": {
"plan_request_params": request_params(),
},
}
def _joint_state_seed(request: Any) -> tuple[list[str], list[float]]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _joint_types_for_request(request)
joint_values = _native_start_joint_values(request, joint_types)
joints = _active_urdf_joints(urdf_path)
if not joints:
joints = [(name, "revolute") for name in _joint_names(request)]
names: list[str] = []
positions: list[float] = []
for name, joint_type in joints:
names.append(name)
del joint_type
positions.append(float(joint_values.get(name, 0.0)))
return names, positions
class _JointStateSeeder:
def __init__(self, request: Any, *, duration_sec: float = 8.0, rate_hz: float = 30.0) -> None:
self._request = request
self._duration_sec = duration_sec
self._rate_hz = rate_hz
self._process: subprocess.Popen[bytes] | None = None
def start(self) -> None:
names, positions = _joint_state_seed(self._request)
if not names:
return
self._process = subprocess.Popen(
[
sys.executable,
"-m",
"moveit2_server.joint_state_seed",
"--names",
json.dumps(names),
"--positions",
json.dumps(positions),
"--duration",
str(self._duration_sec),
"--rate",
str(self._rate_hz),
],
stdout=subprocess.DEVNULL,
)
def stop(self) -> None:
if not self._process or self._process.poll() is not None:
return
self._process.terminate()
try:
self._process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=1.0)
class MoveItPyAdapter:
"""MoveIt 2 adapter used by the local ROS 2 MoveIt2 server."""
def __init__(self) -> None:
self._moveit_by_key: dict[str, Any] = {}
def _config_dict(self, request: Any) -> dict[str, Any]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
srdf_path = Path(str(request.context.get("srdfPath") or ""))
if not urdf_path.is_file():
raise RuntimeError(f"URDF file does not exist: {urdf_path}")
config: dict[str, Any] = {
"robot_description": _robot_description_for_moveit(urdf_path),
"robot_description_kinematics": _kinematics_config(request),
}
if srdf_path.is_file():
config["robot_description_semantic"] = srdf_path.read_text(encoding="utf-8")
config.update(_planning_pipeline_config(request))
config["planning_scene_monitor_options"] = {
"name": "planning_scene_monitor",
"robot_description": "robot_description",
"joint_state_topic": "/joint_states",
"attached_collision_object_topic": "/moveit_cpp/planning_scene_monitor",
"publish_planning_scene_topic": "/moveit_cpp/publish_planning_scene",
"monitored_planning_scene_topic": "/moveit_cpp/monitored_planning_scene",
"wait_for_initial_state_timeout": 5.0,
}
ompl_config = config.setdefault("ompl", {})
if isinstance(ompl_config, dict):
ompl_config.setdefault("planning_plugin", "ompl_interface/OMPLPlanner")
ompl_config.setdefault("planning_plugins", ["ompl_interface/OMPLPlanner"])
ompl_config.setdefault("request_adapters", [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
])
ompl_config.setdefault("start_state_max_bounds_error", 0.1)
planning_scene_monitor_options = config.setdefault("planning_scene_monitor_options", {})
if isinstance(planning_scene_monitor_options, dict):
current_timeout = float(planning_scene_monitor_options.get("wait_for_initial_state_timeout") or 0.0)
planning_scene_monitor_options["wait_for_initial_state_timeout"] = max(current_timeout, 5.0)
return config
def _moveit(self, request: Any) -> Any:
try:
import rclpy
from moveit.planning import MoveItPy
except ImportError as exc:
raise RuntimeError(
"moveit_py adapter requires a ROS 2 environment with rclpy and moveit_py. "
f"Current interpreter: {sys.executable}. Start moveit2_server from a RoboStack/ROS shell."
) from exc
cache_key = str(request.context.get("modelAssetHash") or request.context.get("urdfPath") or "")
if cache_key in self._moveit_by_key:
return self._moveit_by_key[cache_key]
if not rclpy.ok():
rclpy.init(args=None)
node_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:10] if cache_key else "default"
config = self._config_dict(request)
joint_state_seeder = _JointStateSeeder(request)
joint_state_seeder.start()
try:
try:
moveit = MoveItPy(
node_name=f"moveit2_server_{node_hash}",
config_dict=config,
)
except TypeError:
# Older bindings do not expose config_dict; they require equivalent
# ROS parameters to be supplied by the launch environment.
moveit = MoveItPy(node_name=f"moveit2_server_{node_hash}")
finally:
joint_state_seeder.stop()
self._moveit_by_key[cache_key] = moveit
return moveit
def _planning_component(self, request: Any, planning_group: str) -> Any:
return self._moveit(request).get_planning_component(planning_group)
def _robot_state(self, request: Any) -> Any:
try:
from moveit.core.robot_state import RobotState
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import RobotState for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
robot_state = RobotState(self._moveit(request).get_robot_model())
joint_values = _native_start_joint_values(request)
joint_names = _joint_names(request)
positions = [float(joint_values.get(name, 0.0)) for name in joint_names]
planning_group = _planning_group(request)
if hasattr(robot_state, "set_joint_group_positions"):
robot_state.set_joint_group_positions(planning_group, positions)
elif hasattr(robot_state, "set_joint_positions"):
robot_state.set_joint_positions(joint_names, positions)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint setter")
return robot_state
def _pose_stamped(self, request: Any) -> tuple[Any, dict[str, Any]]:
try:
from geometry_msgs.msg import PoseStamped
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import geometry_msgs.msg.PoseStamped for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
target = normalize_motion_target(request.payload)
end_effector = _end_effector(request, target["endEffector"])
pose = PoseStamped()
pose.header.frame_id = target["frame"]
pose.pose.position.x = target["xyz"][0]
pose.pose.position.y = target["xyz"][1]
pose.pose.position.z = target["xyz"][2]
quat_xyzw = target.get("quat_xyzw") or (0.0, 0.0, 0.0, 1.0)
pose.pose.orientation.x = quat_xyzw[0]
pose.pose.orientation.y = quat_xyzw[1]
pose.pose.orientation.z = quat_xyzw[2]
pose.pose.orientation.w = quat_xyzw[3]
return pose, end_effector
def _position_only_ik(self, request: Any) -> bool:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return bool(ik.get("positionOnly", True))
def _position_goal_constraints(self, target_pose: Any, end_effector: dict[str, Any]) -> list[Any]:
try:
from geometry_msgs.msg import Pose
from moveit_msgs.msg import Constraints, PositionConstraint
from shape_msgs.msg import SolidPrimitive
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import MoveIt position constraint messages for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
sphere = SolidPrimitive()
sphere.type = SolidPrimitive.SPHERE
sphere.dimensions = [float(end_effector.get("positionTolerance") or 0.002)]
center = Pose()
center.orientation.w = 1.0
center.position.x = target_pose.pose.position.x
center.position.y = target_pose.pose.position.y
center.position.z = target_pose.pose.position.z
position_constraint = PositionConstraint()
position_constraint.header.frame_id = target_pose.header.frame_id
position_constraint.link_name = str(end_effector.get("link") or "")
position_constraint.constraint_region.primitives.append(sphere)
position_constraint.constraint_region.primitive_poses.append(center)
position_constraint.weight = 1.0
constraints = Constraints()
constraints.name = f"{position_constraint.link_name}_position_goal"
constraints.position_constraints.append(position_constraint)
return [constraints]
def _joint_values_from_state(self, robot_state: Any, request: Any) -> dict[str, float]:
joint_names = _joint_names(request)
planning_group = _planning_group(request)
if hasattr(robot_state, "get_joint_group_positions"):
values = robot_state.get_joint_group_positions(planning_group)
elif hasattr(robot_state, "get_joint_positions"):
values = robot_state.get_joint_positions(joint_names)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint getter")
return {
joint_name: float(values[index])
for index, joint_name in enumerate(joint_names)
if index < len(values)
}
def solve_pose(self, request: Any) -> dict[str, Any]:
robot_state = self._robot_state(request)
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
if not hasattr(robot_state, "set_from_ik"):
raise RuntimeError("moveit_py RobotState does not expose set_from_ik")
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
timeout = float(ik.get("timeout") or 0.05)
attempts = int(ik.get("attempts") or 1)
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout, attempts)
except TypeError:
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout)
except TypeError:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"))
if not ok:
return {
"ok": False,
"message": "MoveIt did not find a pose solution",
}
joint_values = self._joint_values_from_state(robot_state, request)
joint_types = _joint_types_for_request(request)
return {
"jointValuesByName": joint_values,
"jointValuesByNameDeg": _native_to_legacy_joint_values(joint_values, joint_types),
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
def _serialize_trajectory(self, trajectory: Any, joint_names: list[str], request: Any) -> dict[str, Any]:
if hasattr(trajectory, "get_robot_trajectory_msg"):
trajectory = trajectory.get_robot_trajectory_msg()
joint_trajectory = getattr(trajectory, "joint_trajectory", trajectory)
source_joint_names = list(getattr(joint_trajectory, "joint_names", []) or joint_names)
joint_types = _joint_types_for_request(request)
points = []
for point in getattr(joint_trajectory, "points", []) or []:
duration = getattr(point, "time_from_start", None)
seconds = float(getattr(duration, "sec", 0)) + (float(getattr(duration, "nanosec", 0)) / 1_000_000_000)
positions_by_name = {
name: float(point.positions[index])
for index, name in enumerate(source_joint_names)
if index < len(getattr(point, "positions", []))
}
native_positions = [positions_by_name.get(name, 0.0) for name in joint_names]
legacy_positions = [
_native_to_legacy_joint_value(positions_by_name.get(name, 0.0), str(joint_types.get(name) or "revolute"))
for name in joint_names
]
points.append({
"timeFromStartSec": seconds,
"positions": native_positions,
"positionsDeg": legacy_positions,
})
if len(points) > 1 and all(point["timeFromStartSec"] <= 0.0 for point in points):
total_joint_delta = 0.0
for previous, current in zip(points, points[1:], strict=False):
total_joint_delta += sum(
abs(current["positionsDeg"][index] - previous["positionsDeg"][index])
for index in range(min(len(previous["positionsDeg"]), len(current["positionsDeg"])))
)
total_duration = min(max(total_joint_delta / 240.0, 0.75), 4.0)
step_duration = total_duration / float(len(points) - 1)
for index, point in enumerate(points):
point["timeFromStartSec"] = index * step_duration
return {
"jointNames": joint_names,
"points": points,
}
def plan_to_pose(self, request: Any) -> dict[str, Any]:
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
planning_component = self._planning_component(request, planning_group)
start_state = self._robot_state(request)
planning_component.set_start_state(robot_state=start_state)
if self._position_only_ik(request):
try:
planning_component.set_goal_state(
motion_plan_constraints=self._position_goal_constraints(target_pose, end_effector),
)
except TypeError:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
else:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
plan_result = planning_component.plan()
if not plan_result:
return {
"ok": False,
"message": "MoveIt planning did not find a trajectory",
}
joint_names = _joint_names(request)
trajectory = self._serialize_trajectory(plan_result.trajectory, joint_names, request)
final_joint_values: dict[str, float] = {}
final_joint_values_legacy: dict[str, float] = {}
if trajectory["points"]:
final_joint_values = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positions"],
strict=False,
))
final_joint_values_legacy = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positionsDeg"],
strict=False,
))
return {
"jointValuesByName": final_joint_values,
"jointValuesByNameDeg": final_joint_values_legacy,
"trajectory": trajectory,
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Any
SUPPORTED_REQUEST_TYPES = {"srdf.solvePose", "srdf.planToPose"}
SUPPORTED_PROTOCOL_VERSION = 1
_ABSOLUTE_PATH_RE = re.compile(r"(?<![\w.])/(?:[^\s'\"<>:,;(){}[\]]+/)+[^\s'\"<>:,;(){}[\]]+")
class MotionProtocolError(ValueError):
"""Raised when a MoveIt2 server request is malformed."""
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _string(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _number(value: Any, label: str) -> float:
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a finite number") from exc
if numeric != numeric or numeric in (float("inf"), float("-inf")):
raise MotionProtocolError(f"{label} must be a finite number")
return numeric
def normalize_xyz(value: Any, label: str = "target.xyz") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def normalize_quat_xyzw(value: Any, label: str = "target.quat_xyzw") -> tuple[float, float, float, float]:
if not isinstance(value, list) or len(value) != 4:
raise MotionProtocolError(f"{label} must be a 4-number array")
quat = (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
_number(value[3], f"{label}[3]"),
)
norm = math.sqrt(sum(component * component for component in quat))
if norm <= 1e-12:
raise MotionProtocolError(f"{label} must not be zero length")
return tuple(component / norm for component in quat)
def normalize_rpy(value: Any, label: str = "target.rpy") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def _quat_xyzw_from_rpy(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
return normalize_quat_xyzw([
(sr * cp * cy) - (cr * sp * sy),
(cr * sp * cy) + (sr * cp * sy),
(cr * cp * sy) - (sr * sp * cy),
(cr * cp * cy) + (sr * sp * sy),
])
def normalize_joint_values(value: Any, label: str = "startJointValuesByNameDeg") -> dict[str, float]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
normalized: dict[str, float] = {}
for raw_name, raw_value in value.items():
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty joint names")
normalized[name] = _number(raw_value, f"{label}.{name}")
return normalized
def normalize_protocol_version(value: Any) -> int:
if value is None:
return SUPPORTED_PROTOCOL_VERSION
try:
version = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError("protocolVersion must be an integer") from exc
if version != SUPPORTED_PROTOCOL_VERSION:
raise MotionProtocolError(f"Unsupported protocolVersion {version}")
return version
@dataclass(frozen=True)
class WireMessage:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
@dataclass(frozen=True)
class MotionRequest:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
context: dict[str, Any]
command: dict[str, Any]
def normalize_wire_message(message: Any) -> WireMessage:
if not _plain_object(message):
raise MotionProtocolError("MoveIt2 request must be an object")
request_id = _string(message.get("id"), "id")
request_type = _string(message.get("type"), "type")
protocol_version = normalize_protocol_version(message.get("protocolVersion"))
if request_type not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {request_type}")
payload = message.get("payload")
if not _plain_object(payload):
raise MotionProtocolError("payload must be an object")
return WireMessage(
id=request_id,
type=request_type,
protocol_version=protocol_version,
payload=payload,
)
def normalize_request(message: Any, *, context: dict[str, Any]) -> MotionRequest:
wire = normalize_wire_message(message)
if not _plain_object(context):
raise MotionProtocolError("request context must be an object")
command = context.get("command")
if not _plain_object(command):
raise MotionProtocolError("request context command must be an object")
return MotionRequest(
id=wire.id,
type=wire.type,
protocol_version=wire.protocol_version,
payload=wire.payload,
context=context,
command=command,
)
def normalize_motion_target(payload: dict[str, Any]) -> dict[str, Any]:
target = payload.get("target")
if not _plain_object(target):
raise MotionProtocolError("target must be an object")
has_quat = "quat_xyzw" in target
has_rpy = "rpy" in target
if has_quat and has_rpy:
raise MotionProtocolError("target must include exactly one of quat_xyzw or rpy")
quat_xyzw: tuple[float, float, float, float] | None = None
orientation_mode = "position_only"
if has_quat:
quat_xyzw = normalize_quat_xyzw(target.get("quat_xyzw"))
orientation_mode = "quat_xyzw"
elif has_rpy:
quat_xyzw = _quat_xyzw_from_rpy(*normalize_rpy(target.get("rpy")))
orientation_mode = "rpy"
return {
"endEffector": _string(target.get("endEffector"), "target.endEffector"),
"frame": _string(target.get("frame"), "target.frame"),
"xyz": normalize_xyz(target.get("xyz")),
"quat_xyzw": quat_xyzw,
"orientationMode": orientation_mode,
}
def success_response(request_id: str, result: dict[str, Any]) -> dict[str, Any]:
return {
"id": request_id,
"ok": True,
"result": result,
}
def _sanitize_error_message(message: str) -> str:
def replace(match: re.Match[str]) -> str:
path = match.group(0)
basename = path.rstrip("/").rsplit("/", 1)[-1]
return f".../{basename}" if basename else "<path>"
return _ABSOLUTE_PATH_RE.sub(replace, message)
def error_response(request_id: str, error: BaseException, *, debug: bool = False) -> dict[str, Any]:
message = str(error)
if not debug:
message = _sanitize_error_message(message)
return {
"id": request_id,
"ok": False,
"error": {
"code": error.__class__.__name__,
"message": message,
},
}
from __future__ import annotations
import argparse
import asyncio
import importlib
import json
import os
from pathlib import Path
from typing import Any
from moveit2_server.context import DEFAULT_CAD_DIRECTORY, build_moveit2_context
from moveit2_server.dispatcher import dispatch
from moveit2_server.protocol import error_response, normalize_request, normalize_wire_message, success_response
def _json_dumps(message: dict[str, Any]) -> str:
return json.dumps(message, separators=(",", ":"))
async def handle_message(raw_message: str, *, repo_root: Path) -> str:
request_id = ""
debug_errors = os.environ.get("MOVEIT2_SERVER_DEBUG_ERRORS", "").strip().lower() in {"1", "true", "yes", "on"}
try:
message = json.loads(raw_message)
wire = normalize_wire_message(message)
request_id = wire.id
payload_dir = wire.payload.get("dir", DEFAULT_CAD_DIRECTORY)
context = build_moveit2_context(
repo_root=repo_root,
dir=payload_dir,
file=wire.payload.get("file"),
type=wire.type,
payload=wire.payload,
)
request = normalize_request(message, context=context)
return _json_dumps(success_response(request.id, dispatch(request)))
except Exception as exc:
return _json_dumps(error_response(request_id, exc, debug=debug_errors))
async def handle_client(websocket: Any, path: str | None = None, *, repo_root: Path) -> None:
async for raw_message in websocket:
await websocket.send(await handle_message(str(raw_message), repo_root=repo_root))
async def run_server(*, repo_root: Path, host: str, port: int) -> None:
try:
from websockets.asyncio.server import serve
except ImportError:
from websockets import serve
async with serve(lambda websocket, path=None: handle_client(websocket, path, repo_root=repo_root), host, port):
print(f"moveit2_server listening on ws://{host}:{port}/ws", flush=True)
await asyncio.Future()
def _check_import(module_name: str, attribute_path: str | None = None) -> str:
module = importlib.import_module(module_name)
current: Any = module
if attribute_path:
for attribute in attribute_path.split("."):
current = getattr(current, attribute)
return str(getattr(module, "__file__", "built-in"))
def check_environment(*, repo_root: Path) -> dict[str, Any]:
checks = {
"repoRoot": str(repo_root),
"rmwImplementation": os.environ.get("RMW_IMPLEMENTATION", ""),
"imports": {
"websockets": _check_import("websockets"),
"rclpy": _check_import("rclpy"),
"geometry_msgs.msg.PoseStamped": _check_import("geometry_msgs.msg", "PoseStamped"),
"moveit.planning.MoveItPy": _check_import("moveit.planning", "MoveItPy"),
},
}
return checks
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="moveit2_server")
parser.add_argument(
"--repo-root",
default=str(Path.cwd()),
help="Repository root that contains the CAD scan directory.",
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--check",
action="store_true",
help="Verify required Python, ROS 2, MoveIt, and websocket imports, then exit.",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
repo_root = Path(args.repo_root).resolve()
if args.check:
print(json.dumps(check_environment(repo_root=repo_root), indent=2, sort_keys=True))
return 0
asyncio.run(run_server(repo_root=repo_root, host=args.host, port=args.port))
return 0
if __name__ == "__main__":
raise SystemExit(main())
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()
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "moveit2_server"
version = "0.3.6"
description = "Local MoveIt2 websocket server for SRDF solve and planning requests."
requires-python = ">=3.12"
dependencies = [
"websockets>=12",
]
[project.scripts]
moveit2_server = "moveit2_server.server:main"
[tool.setuptools]
packages = ["moveit2_server"]
[tool.setuptools.package-dir]
moveit2_server = "moveit2_server"
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
exec "${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" \
python -m moveit2_server.server \
--repo-root "${REPO_ROOT}" \
--host "${MOVEIT2_SERVER_HOST:-127.0.0.1}" \
--port "${MOVEIT2_SERVER_PORT:-8765}" \
"$@"
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
"${CONDA}" env update --file "${SCRIPT_DIR}/environment.yml" --prune
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m pip install -e "${SCRIPT_DIR}"
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
{
"name": "cad-viewer-runtime",
"private": true,
"type": "module",
"version": "0.3.6",
"scripts": {
"serve": "node backend/server.mjs",
"start": "node backend/server.mjs",
"moveit2:setup": "moveit2_server/setup.sh",
"moveit2:check": "moveit2_server/check-moveit2-server.sh",
"moveit2:serve": "moveit2_server/run-moveit2-server.sh"
}
}
{
"name": "cadjs",
"version": "0.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cadjs",
"version": "0.3.6",
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
}
}
}
{
"name": "cadjs",
"version": "0.3.6",
"private": true,
"type": "module",
"description": "Reusable CAD render/viewer runtime for docs, CAD Viewer, and generated snapshot browser assets.",
"exports": {
".": "./src/index.js",
"./common/*": "./src/common/*",
"./lib/*": "./src/lib/*"
},
"scripts": {
"test": "node scripts/run-tests.mjs"
},
"sideEffects": [
"./src/common/headlessRenderEntry.js",
"./src/common/render.html"
],
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
}
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(packageRoot, "..", "..");
const sourceRoot = path.join(packageRoot, "src");
function collectTests(dir, tests = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectTests(entryPath, tests);
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
tests.push(entryPath);
}
}
return tests;
}
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
const tests = (requestedTests.length ? requestedTests : collectTests(sourceRoot)).sort();
if (!tests.length) {
console.error("No cadjs tests found.");
process.exit(1);
}
const result = spawnSync(process.execPath, [
"--test",
"--experimental-default-type=module",
...tests,
], {
cwd: packageRoot,
env: {
...process.env,
...(fs.existsSync(path.join(repoRoot, ".venv", "bin", "python"))
? { CAD_PYTHON: path.join(repoRoot, ".venv", "bin", "python") }
: {}),
...(fs.existsSync(path.join(repoRoot, "packages", "cadpy", "src"))
? { CAD_PYTHONPATH: path.join(repoRoot, "packages", "cadpy", "src") }
: {}),
},
stdio: "inherit",
});
process.exit(result.status ?? 1);
import * as THREE from "three";
export const WORLD_UP = Object.freeze([0, 0, 1]);
export const TOP_VIEW_UP = Object.freeze([0, 1, 0]);
export const RENDER_CAMERA_PRESETS = Object.freeze({
front: Object.freeze({ name: "front", direction: Object.freeze([0, -1, 0]), up: WORLD_UP }),
back: Object.freeze({ name: "back", direction: Object.freeze([0, 1, 0]), up: WORLD_UP }),
right: Object.freeze({ name: "right", direction: Object.freeze([1, 0, 0]), up: WORLD_UP }),
left: Object.freeze({ name: "left", direction: Object.freeze([-1, 0, 0]), up: WORLD_UP }),
top: Object.freeze({ name: "top", direction: Object.freeze([0, 0, 1]), up: TOP_VIEW_UP }),
bottom: Object.freeze({ name: "bottom", direction: Object.freeze([0, 0, -1]), up: TOP_VIEW_UP }),
iso: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
isometric: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
side: Object.freeze({ name: "side", direction: Object.freeze([1, 0, 0]), up: WORLD_UP })
});
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
const DEFAULT_CAMERA_PRESET = "iso";
const CAMERA_SPEC_KEYS = Object.freeze(["preset", "position", "target", "up", "zoom", "direction", "name"]);
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function normalizeVector(value, {
fieldName = "camera vector",
required = false,
nonZero = false,
strict = true
} = {}) {
if (value === undefined || value === null) {
if (required && strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
if (!Array.isArray(value) || value.length < 3) {
if (strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
const vector = [
Number(value[0]),
Number(value[1]),
Number(value[2])
];
if (!vector.every(Number.isFinite)) {
if (strict) {
throw new Error(`${fieldName} must contain only finite numbers`);
}
return null;
}
if (nonZero && vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 <= 1e-12) {
if (strict) {
throw new Error(`${fieldName} must not be the zero vector`);
}
return null;
}
return vector;
}
export function cloneCameraVector(vector) {
return normalizeVector(vector, { strict: false });
}
export function normalizeCameraZoom(value, fallback = 1, {
fieldName = "camera.zoom",
strict = false
} = {}) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue;
}
if (strict) {
throw new Error(`${fieldName} must be a positive finite number`);
}
return fallback;
}
function clonePreset(preset) {
return {
name: preset.name,
direction: [...preset.direction],
up: [...preset.up]
};
}
function cameraPresetByName(name, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const raw = String(name || DEFAULT_CAMERA_PRESET).trim().toLowerCase();
if (presets[raw]) {
return clonePreset(presets[raw]);
}
const parts = raw.split(":").map((entry) => Number(entry));
if (parts.length >= 2 && parts.every(Number.isFinite)) {
const azimuth = (parts[0] * Math.PI) / 180;
const elevation = (parts[1] * Math.PI) / 180;
const cosElevation = Math.cos(elevation);
return {
name: `${parts[0]}:${parts[1]}`,
direction: [
Math.sin(azimuth) * cosElevation,
-Math.cos(azimuth) * cosElevation,
Math.sin(elevation)
],
up: [...WORLD_UP]
};
}
if (strict) {
throw new Error(`Unknown camera preset: ${name}`);
}
return clonePreset(presets[DEFAULT_CAMERA_PRESET]);
}
function parseCameraJsonString(raw, { strict = true } = {}) {
const text = String(raw || "").trim();
if (!text.startsWith("{")) {
return null;
}
try {
const parsed = JSON.parse(text);
if (!isPlainObject(parsed)) {
throw new Error("camera JSON must be an object");
}
return parsed;
} catch (error) {
if (strict) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid camera JSON: ${message}`);
}
return null;
}
}
function validateCameraSpecKeys(spec) {
const unknownKeys = Object.keys(spec).filter((key) => !CAMERA_SPEC_KEYS.includes(key));
if (unknownKeys.length) {
throw new Error(`Unsupported camera fields: ${unknownKeys.join(", ")}`);
}
}
export function normalizeCameraSpec(rawCamera = DEFAULT_CAMERA_PRESET, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const parsedJson = typeof rawCamera === "string"
? parseCameraJsonString(rawCamera, { strict })
: null;
const source = parsedJson || rawCamera;
if (!isPlainObject(source)) {
const preset = cameraPresetByName(source || DEFAULT_CAMERA_PRESET, { presets, strict });
return {
sourceKind: "string",
name: preset.name,
preset: preset.name,
direction: preset.direction,
position: null,
target: null,
up: preset.up,
zoom: 1,
hasExplicitPosition: false,
hasExplicitTarget: false,
hasExplicitUp: false,
hasExplicitZoom: false
};
}
validateCameraSpecKeys(source);
const rawPreset = source.preset ?? DEFAULT_CAMERA_PRESET;
const preset = cameraPresetByName(rawPreset, { presets, strict });
const direction = normalizeVector(source.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || preset.direction;
const position = normalizeVector(source.position, {
fieldName: "camera.position",
strict
});
const target = normalizeVector(source.target, {
fieldName: "camera.target",
strict
});
const up = normalizeVector(source.up, {
fieldName: "camera.up",
nonZero: true,
strict
}) || preset.up || [...WORLD_UP];
const hasExplicitZoom = Object.prototype.hasOwnProperty.call(source, "zoom");
return {
sourceKind: "object",
name: String(source.name || preset.name || "custom"),
preset: preset.name,
direction,
position,
target,
up,
zoom: normalizeCameraZoom(source.zoom, 1, { strict, fieldName: "camera.zoom" }),
hasExplicitPosition: Object.prototype.hasOwnProperty.call(source, "position"),
hasExplicitTarget: Object.prototype.hasOwnProperty.call(source, "target"),
hasExplicitUp: Object.prototype.hasOwnProperty.call(source, "up"),
hasExplicitZoom
};
}
function settingsForCamera(sceneScale, settingsByScale) {
if (settingsByScale?.[sceneScale]) {
return settingsByScale[sceneScale];
}
if (settingsByScale?.cad) {
return settingsByScale.cad;
}
return {
minBoundsSpan: 1,
minModelRadius: 1,
minCameraDistance: 10,
minCameraFar: 1000
};
}
function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = settingsForCamera(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
function vectorFromArray(vector) {
return new THREE.Vector3(vector[0], vector[1], vector[2]);
}
export function resolveCameraView(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
const target = spec.target ? vectorFromArray(spec.target) : null;
const position = spec.position ? vectorFromArray(spec.position) : null;
const direction = position && target
? position.clone().sub(target).normalize().toArray()
: normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict: false
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
return {
name: spec.name,
direction,
up: [...(spec.up || WORLD_UP)],
zoom: spec.zoom
};
}
export function resolveCameraSnapshot(rawCamera = DEFAULT_CAMERA_PRESET, bounds = null, {
sceneScale = "cad",
settingsByScale = null,
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const spec = normalizeCameraSpec(rawCamera, { presets, strict });
const settings = settingsForCamera(sceneScale, settingsByScale);
const { center, radius, size } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const target = spec.target ? vectorFromArray(spec.target) : center.clone();
const direction = normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
const position = spec.position
? vectorFromArray(spec.position)
: target.clone().add(vectorFromArray(direction).normalize().multiplyScalar(
Math.max(radius * 3.2, settings.minCameraDistance || settings.minModelRadius * 10 || 10)
));
const resolvedDirection = position.clone().sub(target);
if (resolvedDirection.lengthSq() <= 1e-12) {
throw new Error("camera.position must differ from camera.target");
}
const up = vectorFromArray(spec.up || WORLD_UP);
if (up.lengthSq() <= 1e-12) {
throw new Error("camera.up must not be the zero vector");
}
return {
name: spec.name,
preset: spec.preset,
position: position.toArray(),
target: target.toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom,
direction: resolvedDirection.normalize().toArray(),
view: {
name: spec.name,
direction: resolvedDirection.normalize().toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom
},
sourceKind: spec.sourceKind,
hasExplicitPosition: spec.hasExplicitPosition,
hasExplicitTarget: spec.hasExplicitTarget,
hasExplicitUp: spec.hasExplicitUp,
hasExplicitZoom: spec.hasExplicitZoom,
radius,
size: size.toArray()
};
}
export function cameraSpecUsesPerspectiveProjection(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
return spec.sourceKind === "object" && (
spec.hasExplicitPosition ||
spec.hasExplicitTarget ||
spec.hasExplicitUp
);
}
import {
buildPartTransformMatrix
} from "./stepModuleEffects.js";
function applyObjectMatrix(THREE, object3d, matrix) {
if (!object3d || !(matrix instanceof THREE.Matrix4)) {
return;
}
object3d.matrixAutoUpdate = false;
const targetMatrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
targetMatrix.copy(matrix);
object3d.matrix = targetMatrix;
object3d.matrixWorldNeedsUpdate = true;
}
export function composeDisplayRecordEffectMatrix(THREE, record) {
if (!record || !THREE?.Matrix4) {
return null;
}
const matrices = [
record.effectMatrix,
record.explodedViewMatrix
].filter((matrix) => matrix instanceof THREE.Matrix4);
if (!matrices.length) {
return null;
}
const combined = new THREE.Matrix4();
for (const matrix of matrices) {
combined.premultiply(matrix);
}
return combined;
}
export function composeDisplayRecordObjectMatrix(THREE, record) {
const baseMatrix = buildPartTransformMatrix(THREE, record?.baseTransform);
const effectMatrix = composeDisplayRecordEffectMatrix(THREE, record);
return effectMatrix ? effectMatrix.multiply(baseMatrix) : baseMatrix;
}
export function applyDisplayRecordTransform(THREE, record) {
if (!record) {
return;
}
const combinedMatrix = composeDisplayRecordObjectMatrix(THREE, record);
applyObjectMatrix(THREE, record.mesh, combinedMatrix);
applyObjectMatrix(THREE, record.edges, combinedMatrix);
applyObjectMatrix(THREE, record.silhouette, combinedMatrix);
}
import {
DEFAULT_STEP_CLIP_SETTINGS,
normalizeStepClipSettings,
stepClipSettingsEqual
} from "../lib/viewer/clipPlane.js";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "../lib/perspective.js";
export { CAMERA_PROJECTION, normalizeCameraProjection };
export const CAD_DISPLAY_MODE = Object.freeze({
HIDDEN_EDGES: "hidden_edges",
HIDDEN_LINES_REMOVED: "hidden_lines_removed",
RENDERED: "rendered",
SOLID: "solid",
TRANSPARENT: "transparent",
UNSHADED: "unshaded",
WIREFRAME: "wireframe"
});
export const CAD_DISPLAY_MODE_VALUES = Object.freeze(Object.values(CAD_DISPLAY_MODE));
export const EXPLODED_VIEW_AXES = Object.freeze(["x", "y", "z", "radial"]);
export const EXPLODED_VIEW_DIRECTIONS = Object.freeze(["positive", "negative"]);
export const MAX_EXPLODED_VIEW_DEPTH = 8;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const CAD_EDGE_COLOR = "#132232";
export const CAD_EDGE_HIGHLIGHT_COLOR = "#8dc5ff";
export const CAD_EDGE_CLASS_IDS = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
export const DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 1.15
}),
tangent: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.5,
thickness: 1.15
}),
seam: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.85,
thickness: 1.15
}),
degenerate: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 0
})
});
export const DEFAULT_DISPLAY_EDGE_SETTINGS = Object.freeze({
enabled: true,
contrastMode: "manual",
color: CAD_EDGE_COLOR,
thickness: 1,
classes: DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
highlightColor: CAD_EDGE_HIGHLIGHT_COLOR,
highlightOpacity: 1,
highlightThickness: 3,
silhouette: false,
silhouetteScale: 0
});
export const DISABLED_DISPLAY_EDGE_SETTINGS = Object.freeze({
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false
});
export const DEFAULT_EXPLODED_VIEW_SETTINGS = Object.freeze({
enabled: false,
axis: "z",
direction: "positive",
spacing: 1.45,
depth: 1,
keepBaseGrounded: true,
mergeCoplanar: false,
autoFrame: true
});
export const DEFAULT_DISPLAY_SETTINGS = Object.freeze({
projection: CAMERA_PROJECTION.ORTHOGRAPHIC,
mode: CAD_DISPLAY_MODE.SOLID,
clip: DEFAULT_STEP_CLIP_SETTINGS,
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeNumber(value, fallback, min = -Infinity, max = Infinity) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return fallback;
}
return clamp(numericValue, min, max);
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDisplayEdgeContrastMode(value, fallback = "manual") {
const normalized = String(value || "").trim().toLowerCase();
return ["auto", "manual"].includes(normalized)
? normalized
: fallback;
}
export function normalizeDisplayEdgeClassSettings(
value = {},
fallback = DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
colorFallback = CAD_EDGE_COLOR
) {
const source = isObject(value) ? value : {};
const fallbackColor = normalizeColor(colorFallback, CAD_EDGE_COLOR);
return Object.fromEntries(CAD_EDGE_CLASS_IDS.map((classId) => {
const classSource = isObject(source[classId]) ? source[classId] : {};
const classFallback = fallback?.[classId] || DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS[classId];
const legacyDisabled = classSource.enabled === false;
return [classId, {
color: normalizeColor(classSource.color, fallbackColor),
opacity: normalizeNumber(classSource.opacity, classFallback.opacity, 0, 1),
thickness: legacyDisabled
? 0
: normalizeNumber(classSource.thickness, classFallback.thickness, 0, 6)
}];
}));
}
export function normalizeDisplayEdgeSettings(value = null, fallback = DEFAULT_DISPLAY_EDGE_SETTINGS) {
const source = isObject(value) ? value : {};
const color = normalizeColor(source.color, fallback.color);
const normalized = {
enabled: normalizeBoolean(source.enabled, fallback.enabled),
contrastMode: normalizeDisplayEdgeContrastMode(source.contrastMode, fallback.contrastMode),
color,
thickness: normalizeNumber(source.thickness, fallback.thickness, 0.5, 6),
classes: normalizeDisplayEdgeClassSettings(source.classes, fallback.classes, color),
highlightColor: normalizeColor(source.highlightColor, fallback.highlightColor || CAD_EDGE_HIGHLIGHT_COLOR),
highlightOpacity: normalizeNumber(source.highlightOpacity, fallback.highlightOpacity || 1, 0, 1),
highlightThickness: normalizeNumber(source.highlightThickness, fallback.highlightThickness || 3, 0.5, 6),
silhouette: normalizeBoolean(source.silhouette, fallback.silhouette || false),
silhouetteScale: normalizeNumber(source.silhouetteScale, fallback.silhouetteScale || 0, 0, 0.04)
};
if (typeof source.depthTest === "boolean") {
normalized.depthTest = source.depthTest;
}
return normalized;
}
function normalizeModeText(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_");
}
export function normalizeDisplayMode(value) {
const normalized = normalizeModeText(value);
if (!normalized) {
return CAD_DISPLAY_MODE.SOLID;
}
if (normalized === "wire" || normalized === "wire_frame") {
return CAD_DISPLAY_MODE.WIREFRAME;
}
if (
normalized === "edges" ||
normalized === "edge" ||
normalized === "shaded_edges" ||
normalized === "shaded_with_edges" ||
normalized === "with_edges"
) {
return CAD_DISPLAY_MODE.SOLID;
}
if (
normalized === "shaded" ||
normalized === "shaded_without_edges" ||
normalized === "without_edges"
) {
return CAD_DISPLAY_MODE.RENDERED;
}
if (
normalized === "translucent" ||
normalized === "xray" ||
normalized === "x_ray" ||
normalized === "see_through"
) {
return CAD_DISPLAY_MODE.TRANSPARENT;
}
if (
normalized === "hidden_edge" ||
normalized === "hidden_edges_visible" ||
normalized === "hidden_edge_display" ||
normalized === "shaded_hidden_edges"
) {
return CAD_DISPLAY_MODE.HIDDEN_EDGES;
}
if (
normalized === "visible_edges" ||
normalized === "visible_edges_only" ||
normalized === "hidden_lines" ||
normalized === "hidden_line_removed" ||
normalized === "hidden_lines_removed" ||
normalized === "hidden_edges_removed"
) {
return CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED;
}
if (normalized === "flat") {
return CAD_DISPLAY_MODE.UNSHADED;
}
if (normalized === "appearance" || normalized === "material" || normalized === "materials") {
return CAD_DISPLAY_MODE.RENDERED;
}
return CAD_DISPLAY_MODE_VALUES.includes(normalized)
? normalized
: CAD_DISPLAY_MODE.SOLID;
}
export function normalizeExplodedViewAxis(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.axis) {
const normalized = String(value || "").trim().toLowerCase();
const axis = normalized.startsWith("-") ? normalized.slice(1) : normalized;
return EXPLODED_VIEW_AXES.includes(axis) ? axis : fallback;
}
export function normalizeExplodedViewDirection(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.direction) {
const normalized = String(value || "").trim().toLowerCase();
if (["negative", "reverse", "down", "backward", "-", "-1"].includes(normalized)) {
return "negative";
}
if (["positive", "forward", "up", "+", "+1", "1"].includes(normalized)) {
return "positive";
}
return fallback;
}
export function normalizeExplodedViewDepth(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.depth) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["all", "full", "parts", "leaves", "leaf"].includes(normalized)) {
return MAX_EXPLODED_VIEW_DEPTH;
}
const numericValue = Math.round(Number(value));
return Number.isFinite(numericValue)
? clamp(numericValue, 1, MAX_EXPLODED_VIEW_DEPTH)
: fallback;
}
export function normalizeExplodedViewSettings(value = null, overrides = {}) {
const source = isObject(value) ? value : {};
const merged = isObject(overrides) ? { ...source, ...overrides } : source;
const axisText = String(merged.axis || "").trim().toLowerCase();
return {
enabled: normalizeBoolean(merged.enabled, DEFAULT_EXPLODED_VIEW_SETTINGS.enabled),
axis: normalizeExplodedViewAxis(merged.axis),
direction: normalizeExplodedViewDirection(merged.direction || (axisText.startsWith("-") ? "negative" : "positive")),
spacing: normalizeNumber(merged.spacing ?? merged.distance ?? merged.distanceScale, DEFAULT_EXPLODED_VIEW_SETTINGS.spacing, 0.25, 4),
depth: normalizeExplodedViewDepth(merged.depth ?? merged.levels ?? merged.scopeDepth),
keepBaseGrounded: normalizeBoolean(merged.keepBaseGrounded ?? merged.groundBase, DEFAULT_EXPLODED_VIEW_SETTINGS.keepBaseGrounded),
mergeCoplanar: normalizeBoolean(merged.mergeCoplanar ?? merged.mergeLayers ?? merged.coalesceLayers, DEFAULT_EXPLODED_VIEW_SETTINGS.mergeCoplanar),
autoFrame: normalizeBoolean(merged.autoFrame, DEFAULT_EXPLODED_VIEW_SETTINGS.autoFrame)
};
}
export function normalizeDisplaySettings(value = null) {
const source = isObject(value) ? value : {};
const explodedOverrides = {};
if (isObject(source.exploded) && source.exploded.enabled !== undefined) {
explodedOverrides.enabled = source.exploded.enabled;
}
return {
projection: normalizeCameraProjection(source.projection, DEFAULT_DISPLAY_SETTINGS.projection),
mode: normalizeDisplayMode(source.mode),
clip: normalizeStepClipSettings(source.clip),
exploded: normalizeExplodedViewSettings(source.exploded, explodedOverrides),
edges: normalizeDisplayEdgeSettings(source.edges)
};
}
export function cloneDisplaySettings(value = DEFAULT_DISPLAY_SETTINGS) {
return normalizeDisplaySettings(value);
}
export function displaySettingsEqual(left, right) {
const a = normalizeDisplaySettings(left);
const b = normalizeDisplaySettings(right);
return a.projection === b.projection &&
a.mode === b.mode &&
stepClipSettingsEqual(a.clip, b.clip) &&
JSON.stringify(a.exploded) === JSON.stringify(b.exploded) &&
JSON.stringify(a.edges) === JSON.stringify(b.edges);
}
export function resolveDisplayMode(displaySettings) {
return normalizeDisplaySettings(displaySettings).mode;
}
export function resolveDisplayEdgeSettings(displaySettings) {
return normalizeDisplaySettings(displaySettings).edges;
}
export function displayModeIsWireframe(value) {
return normalizeDisplayMode(value) === CAD_DISPLAY_MODE.WIREFRAME;
}
export function displayModeForcesEdges(value) {
return [
CAD_DISPLAY_MODE.SOLID,
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED
].includes(normalizeDisplayMode(value));
}
export function displayModeAllowsEdges(value) {
return ![
CAD_DISPLAY_MODE.RENDERED,
CAD_DISPLAY_MODE.UNSHADED
].includes(normalizeDisplayMode(value));
}
export function displayModeShowsEdges(value, edgeSettings = null) {
const mode = normalizeDisplayMode(value);
return mode === CAD_DISPLAY_MODE.WIREFRAME ||
displayModeForcesEdges(mode);
}
export function displayModeShowsThroughEdges(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesTransparentSurfaces(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesUnlitSurfaces(value) {
return [
CAD_DISPLAY_MODE.UNSHADED,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeSurfaceOpacity(value, fallback = 1) {
const mode = normalizeDisplayMode(value);
if (mode === CAD_DISPLAY_MODE.WIREFRAME) {
return 0.035;
}
if (mode === CAD_DISPLAY_MODE.TRANSPARENT) {
return 0.22;
}
if (mode === CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED) {
return 0.045;
}
const numericFallback = Number(fallback);
return Number.isFinite(numericFallback) ? numericFallback : 1;
}
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>CAD Viewer headless render</title>
<style>
html,
body {
margin: 0;
min-width: 100%;
min-height: 100%;
overflow: hidden;
background: transparent;
}
</style>
<script type="importmap">
{
"imports": {
"three": "/node_modules/three/build/three.module.js",
"three/examples/": "/node_modules/three/examples/",
"gifenc": "/node_modules/gifenc/dist/gifenc.esm.js"
}
}
</script>
</head>
<body>
<script type="module" src="/common/headlessRenderEntry.js"></script>
</body>
</html>
export * from "./common/cadScene.js";
export * from "./common/renderModel.js";
export * from "./common/source.js";
export * from "./common/themeSettings.js";
export async function mapWithConcurrency(items, concurrency, mapper) {
const list = Array.isArray(items) ? items : [];
const workerCount = Math.min(
list.length,
Math.max(1, Math.floor(Number(concurrency) || 1))
);
const results = new Array(list.length);
let nextIndex = 0;
async function runWorker() {
while (nextIndex < list.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(list[currentIndex], currentIndex);
}
}
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
return results;
}
export * from "../common/displaySettings.js";
export * from "../../common/stepModule.js";
Related skills
How it compares
Pick cad-viewer when you need quick in-session visual review links rather than exporting files to a desktop CAD suite manually.
FAQ
Which file types does CAD Viewer handle?
CAD files, G-code programs, and robot-description files named explicitly by the user.
What does the skill return after launching the viewer?
Review links for the specified files after CAD Viewer starts.