first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,166 @@
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from tests.python.support.paths import REPO_ROOT
from tests.python.support.tmp_root import temporary_directory
CHECK_SCRIPT = REPO_ROOT / "scripts" / "release" / "check-publish-source.sh"
class ReleasePublishSourceTests(unittest.TestCase):
def run_git(self, repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=repo,
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return result.stdout.strip()
def write_file(self, repo: Path, name: str, text: str) -> None:
(repo / name).write_text(text, encoding="utf-8")
def commit(self, repo: Path, title: str, body: str | None = None) -> str:
self.run_git(repo, "add", "-A")
command = ["commit", "-m", title]
if body is not None:
command.extend(["-m", body])
self.run_git(repo, *command)
return self.run_git(repo, "rev-parse", "HEAD")
def init_repo(self, repo: Path) -> str:
self.run_git(repo, "init")
self.run_git(repo, "config", "user.name", "Release Test")
self.run_git(repo, "config", "user.email", "release-test@example.com")
self.write_file(repo, "source.txt", "previous source\n")
previous_source = self.commit(repo, "Previous source")
self.run_git(repo, "branch", "-M", "develop")
return previous_source
def run_check(self, repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["RELEASE_REPO_ROOT"] = os.fspath(repo)
return subprocess.run(
[os.fspath(CHECK_SCRIPT), *args],
cwd=repo,
env=env,
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
def create_publish_commit(self, repo: Path, previous_source: str, *, include_source_line: bool = True) -> str:
self.run_git(repo, "checkout", "-b", "main")
self.write_file(repo, "source.txt", "generated output\n")
body = f"Source commit: {previous_source}" if include_source_line else None
publish_commit = self.commit(repo, "Publish 0.1.0 from develop to main", body)
self.run_git(repo, "checkout", "develop")
return publish_commit
def create_generated_merge_publish_commit(self, repo: Path, target_parent: str, source_parent: str) -> str:
self.write_file(repo, "source.txt", "generated output\n")
self.run_git(repo, "add", "-A")
tree = self.run_git(repo, "write-tree")
message = repo / "message.txt"
message.write_text(
"\n".join(
[
"Publish 0.2.0 from develop to main",
"",
f"Source commit: {source_parent}",
"",
]
),
encoding="utf-8",
)
publish_commit = self.run_git(
repo,
"commit-tree",
tree,
"-p",
target_parent,
"-p",
source_parent,
"-F",
"message.txt",
)
self.run_git(repo, "branch", "-f", "main", publish_commit)
self.run_git(repo, "reset", "--hard", "develop")
return publish_commit
def test_accepts_source_that_contains_previous_publish_source(self) -> None:
with temporary_directory(prefix="release-source-ok-") as repo_text:
repo = Path(repo_text)
previous_source = self.init_repo(repo)
self.create_publish_commit(repo, previous_source)
self.write_file(repo, "source.txt", "next source\n")
source_commit = self.commit(repo, "Next source")
result = self.run_check(repo, "--source-ref", source_commit, "--target-ref", "main")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("Publish source is valid", result.stdout)
def test_prints_previous_source_from_publish_message(self) -> None:
with temporary_directory(prefix="release-source-print-") as repo_text:
repo = Path(repo_text)
previous_source = self.init_repo(repo)
self.create_publish_commit(repo, previous_source)
result = self.run_check(repo, "--target-ref", "main", "--print-previous-source")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), previous_source)
def test_prints_source_parent_from_generated_merge_publish_commit(self) -> None:
with temporary_directory(prefix="release-source-merge-") as repo_text:
repo = Path(repo_text)
previous_source = self.init_repo(repo)
target_parent = self.create_publish_commit(repo, previous_source)
self.write_file(repo, "source.txt", "next source\n")
source_parent = self.commit(repo, "Next source")
self.create_generated_merge_publish_commit(repo, target_parent, source_parent)
result = self.run_check(repo, "--target-ref", "main", "--print-previous-source")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), source_parent)
def test_rejects_source_that_drops_previous_publish_source(self) -> None:
with temporary_directory(prefix="release-source-bad-") as repo_text:
repo = Path(repo_text)
previous_source = self.init_repo(repo)
self.create_publish_commit(repo, previous_source)
self.run_git(repo, "checkout", "--orphan", "unrelated")
self.write_file(repo, "source.txt", "unrelated source\n")
unrelated_source = self.commit(repo, "Unrelated source")
result = self.run_check(repo, "--source-ref", unrelated_source, "--target-ref", "main")
self.assertNotEqual(result.returncode, 0)
self.assertIn("previous publish source is not an ancestor", result.stderr)
def test_uses_target_parent_as_legacy_previous_source_fallback(self) -> None:
with temporary_directory(prefix="release-source-legacy-") as repo_text:
repo = Path(repo_text)
previous_source = self.init_repo(repo)
self.create_publish_commit(repo, previous_source, include_source_line=False)
self.write_file(repo, "source.txt", "next source\n")
source_commit = self.commit(repo, "Next source")
result = self.run_check(repo, "--source-ref", source_commit, "--target-ref", "main")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(previous_source, result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,677 @@
from __future__ import annotations
import ast
import json
import os
import re
import shlex
import tomllib
import unittest
from pathlib import Path
from urllib.parse import unquote, urlparse
REPO_ROOT = Path(__file__).resolve().parents[3]
SKILLS_ROOT = REPO_ROOT / "skills"
SKIP_DIRS = {
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
}
PYTHON_SUFFIXES = {".py"}
JAVASCRIPT_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"}
PACKAGE_DEPENDENCY_FIELDS = (
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
)
FORBIDDEN_REPO_IMPORT_ROOTS = {"packages", "skills", "viewer"}
LOOKUP_PATH_ENV_VARS = {"NODE_PATH", "PYTHONPATH"}
JS_IMPORT_RE = re.compile(
r"(?:import|export)\s+(?:[^'\"\n]+?\s+from\s+)?['\"](?P<from>[^'\"]+)['\"]"
r"|(?:import|require)\(\s*['\"](?P<call>[^'\"]+)['\"]\s*\)"
)
PEP508_FILE_REF_RE = re.compile(r"@\s*(file:[^\s;]+)")
def _logical_abs(path: Path) -> Path:
return Path(os.path.abspath(os.fspath(path)))
def _is_inside(child: Path, parent: Path) -> bool:
child_text = os.fspath(_logical_abs(child))
parent_text = os.fspath(_logical_abs(parent))
try:
return os.path.commonpath([child_text, parent_text]) == parent_text
except ValueError:
return False
def _iter_skill_roots() -> list[Path]:
return sorted(
path
for path in SKILLS_ROOT.iterdir()
if path.is_dir() and (path / "SKILL.md").is_file()
)
def _walk_files(skill_root: Path, names: set[str] | None = None) -> list[Path]:
result: list[Path] = []
for dirpath, dirnames, filenames in os.walk(skill_root, followlinks=True):
dirnames[:] = sorted(name for name in dirnames if name not in SKIP_DIRS)
for filename in sorted(filenames):
if names is None or filename in names:
result.append(Path(dirpath) / filename)
return result
def _strip_requirement_line(line: str) -> str:
try:
parts = shlex.split(line, comments=True, posix=True)
except ValueError:
return line.split("#", 1)[0].strip()
return " ".join(parts).strip()
def _path_from_file_url(spec: str) -> str:
raw = spec[len("file:") :]
if raw.startswith("//"):
parsed = urlparse(spec)
return unquote(parsed.path)
return unquote(raw)
def _local_path_from_spec(spec: str, *, allow_bare_relative: bool = False) -> str | None:
stripped = spec.strip().strip("'\"")
if not stripped:
return None
file_ref = PEP508_FILE_REF_RE.search(stripped)
if file_ref:
return _path_from_file_url(file_ref.group(1))
parsed = urlparse(stripped)
if parsed.scheme and parsed.scheme != "file":
return None
if stripped.startswith("file:"):
return _path_from_file_url(stripped)
if stripped.startswith(("./", "../", "/", "~")) or stripped in {".", ".."}:
return stripped
if allow_bare_relative and "/" in stripped and not stripped.startswith("@"):
return stripped
return None
def _check_local_path(
errors: list[str],
*,
skill_root: Path,
manifest: Path,
local_path: str,
context: str,
) -> None:
expanded = os.path.expanduser(local_path)
candidate = Path(expanded)
if not candidate.is_absolute():
candidate = manifest.parent / candidate
if not _is_inside(candidate, skill_root):
errors.append(
f"{manifest.relative_to(REPO_ROOT)}: {context} points outside "
f"{skill_root.relative_to(REPO_ROOT)}: {local_path}"
)
def _requirement_path_specs(line: str) -> list[tuple[str, str]]:
stripped = _strip_requirement_line(line)
if not stripped:
return []
try:
tokens = shlex.split(stripped, comments=True, posix=True)
except ValueError:
tokens = stripped.split()
specs: list[tuple[str, str]] = []
index = 0
while index < len(tokens):
token = tokens[index]
if token in {"-e", "--editable", "-r", "--requirement", "-c", "--constraint"}:
if index + 1 < len(tokens):
specs.append((token, tokens[index + 1]))
index += 2
continue
for option in ("--editable=", "--requirement=", "--constraint="):
if token.startswith(option):
specs.append((option.rstrip("="), token[len(option) :]))
break
else:
local_path = _local_path_from_spec(token)
if local_path is not None:
specs.append(("requirement", token))
index += 1
pep508_ref = PEP508_FILE_REF_RE.search(stripped)
if pep508_ref:
specs.append(("requirement", pep508_ref.group(1)))
return specs
def _qualified_name(node: ast.AST) -> str | None:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
prefix = _qualified_name(node.value)
if prefix is None:
return None
return f"{prefix}.{node.attr}"
return None
def _literal_int(node: ast.AST) -> int | None:
if isinstance(node, ast.Constant) and isinstance(node.value, int):
return node.value
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
value = _literal_int(node.operand)
if value is not None:
return -value
return None
def _literal_env_key(node: ast.AST) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _is_os_environ_target(node: ast.AST) -> str | None:
if not isinstance(node, ast.Subscript):
return None
if _qualified_name(node.value) != "os.environ":
return None
key = _literal_env_key(node.slice)
if key in LOOKUP_PATH_ENV_VARS:
return key
return None
def _collect_python_path_bindings(tree: ast.AST, source: Path) -> dict[str, set[str]]:
bindings: dict[str, set[str]] = {}
assignments = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Assign | ast.AnnAssign)
]
for _ in range(8):
changed = False
for node in assignments:
value_node = node.value
if value_node is None:
continue
values = _eval_path_expr(value_node, source, bindings)
if not values:
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if isinstance(target, ast.Name) and bindings.get(target.id) != values:
bindings[target.id] = values
changed = True
if not changed:
break
return bindings
def _eval_path_expr(
node: ast.AST,
source: Path,
bindings: dict[str, set[str]],
) -> set[str]:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return {node.value}
if isinstance(node, ast.Name):
if node.id == "__file__":
return {os.fspath(source)}
return set(bindings.get(node.id, set()))
if isinstance(node, ast.Call):
name = _qualified_name(node.func)
if name in {"Path", "pathlib.Path"} and node.args:
return _eval_path_expr(node.args[0], source, bindings)
if name in {"str", "os.fspath"} and node.args:
return _eval_path_expr(node.args[0], source, bindings)
if name in {"os.path.abspath", "os.path.realpath"} and node.args:
return {
os.fspath(_logical_abs(Path(value)))
for value in _eval_path_expr(node.args[0], source, bindings)
}
if name == "os.path.join" and node.args:
values = [""]
for arg in node.args:
parts = _eval_path_expr(arg, source, bindings)
if not parts:
return set()
values = [os.path.join(prefix, part) for prefix in values for part in parts]
return set(values)
if isinstance(node.func, ast.Attribute) and node.func.attr in {
"absolute",
"expanduser",
"resolve",
}:
values = _eval_path_expr(node.func.value, source, bindings)
if node.func.attr == "expanduser":
return {os.path.expanduser(value) for value in values}
return {os.fspath(_logical_abs(Path(value))) for value in values}
return set()
if isinstance(node, ast.Attribute):
values = _eval_path_expr(node.value, source, bindings)
if node.attr == "parent":
return {os.fspath(Path(value).parent) for value in values}
return set()
if (
isinstance(node, ast.Subscript)
and isinstance(node.value, ast.Attribute)
and node.value.attr == "parents"
):
index = _literal_int(node.slice)
if index is None:
return set()
result: set[str] = set()
for value in _eval_path_expr(node.value.value, source, bindings):
parents = Path(value).parents
if -len(parents) <= index < len(parents):
result.add(os.fspath(parents[index]))
return result
if isinstance(node, ast.BinOp):
left_values = _eval_path_expr(node.left, source, bindings)
right_values = _eval_path_expr(node.right, source, bindings)
if not left_values or not right_values:
return set()
if isinstance(node.op, ast.Div):
return {
os.fspath(Path(left) / right)
for left in left_values
for right in right_values
}
if isinstance(node.op, ast.Add):
return {left + right for left in left_values for right in right_values}
if isinstance(node, ast.JoinedStr):
values = [""]
for part in node.values:
if isinstance(part, ast.Constant) and isinstance(part.value, str):
part_values = {part.value}
elif isinstance(part, ast.FormattedValue):
part_values = _eval_path_expr(part.value, source, bindings)
else:
return set()
if not part_values:
return set()
values = [prefix + suffix for prefix in values for suffix in part_values]
return set(values)
return set()
def _eval_path_sequence(
node: ast.AST,
source: Path,
bindings: dict[str, set[str]],
) -> set[str]:
if isinstance(node, ast.List | ast.Tuple | ast.Set):
result: set[str] = set()
for element in node.elts:
result.update(_eval_path_expr(element, source, bindings))
return result
return _eval_path_expr(node, source, bindings)
def _lookup_path_specs(values: set[str]) -> list[str]:
specs: list[str] = []
for value in values:
pieces = value.split(os.pathsep)
specs.extend(piece for piece in pieces if piece)
return specs
def _check_lookup_path(
errors: list[str],
*,
skill_root: Path,
source: Path,
lookup_path: str,
context: str,
) -> None:
expanded = os.path.expanduser(lookup_path)
candidate = Path(expanded)
if not candidate.is_absolute():
if candidate.parts and candidate.parts[0] == "skills":
errors.append(
f"{source.relative_to(REPO_ROOT)}: {context} adds relative root "
f"skills lookup path: {lookup_path}"
)
return
candidate = source.parent / candidate
if _logical_abs(candidate) == _logical_abs(SKILLS_ROOT):
errors.append(
f"{source.relative_to(REPO_ROOT)}: {context} adds repository skills "
f"lookup path: {lookup_path}"
)
elif _is_inside(candidate, SKILLS_ROOT) and not _is_inside(candidate, skill_root):
errors.append(
f"{source.relative_to(REPO_ROOT)}: {context} adds sibling skill lookup "
f"path: {lookup_path}"
)
elif not _is_inside(candidate, skill_root):
errors.append(
f"{source.relative_to(REPO_ROOT)}: {context} adds lookup path outside "
f"{skill_root.relative_to(REPO_ROOT)}: {lookup_path}"
)
class SkillSelfContainmentTest(unittest.TestCase):
def test_dependency_manifests_use_only_skill_local_path_dependencies(self) -> None:
errors: list[str] = []
manifest_names = {
"package-lock.json",
"package.json",
"pyproject.toml",
"requirements.txt",
}
for skill_root in _iter_skill_roots():
for manifest in _walk_files(skill_root, manifest_names):
if manifest.name == "requirements.txt":
self._check_requirements(errors, skill_root, manifest)
elif manifest.name == "package.json":
self._check_package_json(errors, skill_root, manifest)
elif manifest.name == "package-lock.json":
self._check_package_lock(errors, skill_root, manifest)
elif manifest.name == "pyproject.toml":
self._check_pyproject(errors, skill_root, manifest)
if errors:
self.fail("Skill manifests reference external local paths:\n" + "\n".join(errors))
def test_source_imports_and_lookup_paths_do_not_target_repo_roots_or_escape_skill(self) -> None:
errors: list[str] = []
source_suffixes = PYTHON_SUFFIXES | JAVASCRIPT_SUFFIXES
for skill_root in _iter_skill_roots():
for source in _walk_files(skill_root):
if source.suffix not in source_suffixes:
continue
if source.suffix in PYTHON_SUFFIXES:
self._check_python_imports(errors, skill_root, source)
self._check_python_lookup_paths(errors, skill_root, source)
else:
self._check_javascript_imports(errors, skill_root, source)
if errors:
self.fail("Skill source files reference repo-root or sibling paths:\n" + "\n".join(errors))
def _check_requirements(self, errors: list[str], skill_root: Path, manifest: Path) -> None:
for line_number, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), start=1):
for context, spec in _requirement_path_specs(line):
local_path = _local_path_from_spec(spec)
if local_path is not None:
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=local_path,
context=f"{context} on line {line_number}",
)
def _check_package_json(self, errors: list[str], skill_root: Path, manifest: Path) -> None:
data = json.loads(manifest.read_text(encoding="utf-8"))
for field in PACKAGE_DEPENDENCY_FIELDS:
dependencies = data.get(field, {})
if not isinstance(dependencies, dict):
continue
for name, spec in sorted(dependencies.items()):
if not isinstance(spec, str):
continue
local_path = _local_path_from_spec(spec)
if local_path is not None:
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=local_path,
context=f"{field}.{name}",
)
workspaces = data.get("workspaces")
workspace_specs: list[str] = []
if isinstance(workspaces, list):
workspace_specs.extend(spec for spec in workspaces if isinstance(spec, str))
elif isinstance(workspaces, dict):
packages = workspaces.get("packages", [])
if isinstance(packages, list):
workspace_specs.extend(spec for spec in packages if isinstance(spec, str))
for spec in workspace_specs:
local_path = _local_path_from_spec(spec, allow_bare_relative=True)
if local_path is not None:
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=local_path,
context="workspaces",
)
def _check_package_lock(self, errors: list[str], skill_root: Path, manifest: Path) -> None:
data = json.loads(manifest.read_text(encoding="utf-8"))
packages = data.get("packages", {})
if isinstance(packages, dict):
for package_path, package_data in sorted(packages.items()):
if package_path and not package_path.startswith("node_modules/"):
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=package_path,
context=f"lock package {package_path}",
)
if not isinstance(package_data, dict):
continue
for field in PACKAGE_DEPENDENCY_FIELDS:
dependencies = package_data.get(field, {})
if not isinstance(dependencies, dict):
continue
for name, spec in sorted(dependencies.items()):
if not isinstance(spec, str):
continue
local_path = _local_path_from_spec(spec)
if local_path is not None:
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=local_path,
context=f"lock {field}.{name}",
)
resolved = package_data.get("resolved")
if isinstance(resolved, str):
local_path = _local_path_from_spec(resolved, allow_bare_relative=True)
if local_path is not None:
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=local_path,
context=f"lock resolved {package_path or '<root>'}",
)
def _check_pyproject(self, errors: list[str], skill_root: Path, manifest: Path) -> None:
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
project = data.get("project", {})
if isinstance(project, dict):
self._check_python_dependency_list(
errors,
skill_root,
manifest,
project.get("dependencies", []),
"project.dependencies",
)
optional = project.get("optional-dependencies", {})
if isinstance(optional, dict):
for group, dependencies in sorted(optional.items()):
self._check_python_dependency_list(
errors,
skill_root,
manifest,
dependencies,
f"project.optional-dependencies.{group}",
)
tool = data.get("tool", {})
uv_sources = tool.get("uv", {}).get("sources", {}) if isinstance(tool, dict) else {}
if isinstance(uv_sources, dict):
for name, source in sorted(uv_sources.items()):
if isinstance(source, dict) and isinstance(source.get("path"), str):
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=source["path"],
context=f"tool.uv.sources.{name}.path",
)
def _check_python_dependency_list(
self,
errors: list[str],
skill_root: Path,
manifest: Path,
dependencies: object,
context: str,
) -> None:
if not isinstance(dependencies, list):
return
for dependency in dependencies:
if not isinstance(dependency, str):
continue
local_path = _local_path_from_spec(dependency)
if local_path is not None:
_check_local_path(
errors,
skill_root=skill_root,
manifest=manifest,
local_path=local_path,
context=context,
)
def _check_python_imports(self, errors: list[str], skill_root: Path, source: Path) -> None:
try:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
except (SyntaxError, UnicodeDecodeError):
return
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root_name = str(alias.name).split(".", 1)[0]
if root_name in FORBIDDEN_REPO_IMPORT_ROOTS:
errors.append(
f"{source.relative_to(REPO_ROOT)}:{node.lineno}: imports repo root "
f"module {root_name!r}"
)
elif isinstance(node, ast.ImportFrom):
if node.level:
base = source.parent
for _ in range(max(0, int(node.level) - 1)):
base = base.parent
if not _is_inside(base, skill_root):
errors.append(
f"{source.relative_to(REPO_ROOT)}:{node.lineno}: relative import escapes "
f"{skill_root.relative_to(REPO_ROOT)}"
)
elif node.module:
root_name = str(node.module).split(".", 1)[0]
if root_name in FORBIDDEN_REPO_IMPORT_ROOTS:
errors.append(
f"{source.relative_to(REPO_ROOT)}:{node.lineno}: imports repo root "
f"module {root_name!r}"
)
def _check_python_lookup_paths(self, errors: list[str], skill_root: Path, source: Path) -> None:
try:
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
except (SyntaxError, UnicodeDecodeError):
return
bindings = _collect_python_path_bindings(tree, source)
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = _qualified_name(node.func)
path_values: set[str] = set()
context = name or "lookup path mutation"
if name == "sys.path.insert" and len(node.args) >= 2:
path_values = _eval_path_expr(node.args[1], source, bindings)
elif name in {"sys.path.append", "site.addsitedir"} and node.args:
path_values = _eval_path_expr(node.args[0], source, bindings)
elif name == "sys.path.extend" and node.args:
path_values = _eval_path_sequence(node.args[0], source, bindings)
for lookup_path in _lookup_path_specs(path_values):
_check_lookup_path(
errors,
skill_root=skill_root,
source=source,
lookup_path=lookup_path,
context=f"{context} on line {node.lineno}",
)
elif isinstance(node, ast.Assign | ast.AnnAssign | ast.AugAssign):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
value = node.value
if value is None:
continue
for target in targets:
env_key = _is_os_environ_target(target)
if env_key is None:
continue
for lookup_path in _lookup_path_specs(_eval_path_expr(value, source, bindings)):
_check_lookup_path(
errors,
skill_root=skill_root,
source=source,
lookup_path=lookup_path,
context=f"os.environ[{env_key!r}] on line {node.lineno}",
)
def _check_javascript_imports(self, errors: list[str], skill_root: Path, source: Path) -> None:
try:
content = source.read_text(encoding="utf-8")
except UnicodeDecodeError:
return
for match in JS_IMPORT_RE.finditer(content):
spec = match.group("from") or match.group("call") or ""
if not spec:
continue
root_name = spec.split("/", 1)[0]
if root_name in FORBIDDEN_REPO_IMPORT_ROOTS:
errors.append(
f"{source.relative_to(REPO_ROOT)}: imports repo root module {root_name!r}"
)
continue
local_path = _local_path_from_spec(spec)
if local_path is not None:
candidate = Path(local_path)
if not candidate.is_absolute():
candidate = source.parent / candidate
if not _is_inside(candidate, skill_root):
errors.append(
f"{source.relative_to(REPO_ROOT)}: import {spec!r} escapes "
f"{skill_root.relative_to(REPO_ROOT)}"
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,234 @@
from __future__ import annotations
import sys
import types
import unittest
from contextlib import contextmanager
from cadpy.assembly import AssemblyHelper, MateTarget, label_shape, label_text, target
from cadpy.step_export import _collect_assembly_mates
class FakeLocation:
def __init__(self, value):
self.value = value
def __mul__(self, other):
return FakeLocation(("mul", self.value, other.value))
class FakePart:
def __init__(self):
self.joints = {}
self.label = None
class FakeJoint:
def __init__(self, *, label, to_part, joint_location=None, **options):
self.label = label
self.to_part = to_part
self.location = joint_location
self.options = options
self.connections = []
to_part.joints[label] = self
def connect_to(self, other, **options):
self.connections.append((other, options))
class FakeCompound:
def __init__(self, *, label, children):
self.label = label
self.children = tuple(children)
@contextmanager
def fake_build123d():
module = types.SimpleNamespace(
BallJoint=FakeJoint,
Compound=FakeCompound,
CylindricalJoint=FakeJoint,
LinearJoint=FakeJoint,
Location=FakeLocation,
RevoluteJoint=FakeJoint,
RigidJoint=FakeJoint,
)
original = sys.modules.get("build123d")
sys.modules["build123d"] = module
try:
yield module
finally:
if original is None:
sys.modules.pop("build123d", None)
else:
sys.modules["build123d"] = original
class AssemblyHelperTests(unittest.TestCase):
def test_label_text_normalizes_tokens(self) -> None:
self.assertEqual(
"base_plate:left_side",
label_text("base plate", "left:side"),
)
def test_label_shape_sets_native_label_and_color(self) -> None:
shape = types.SimpleNamespace()
color = object()
returned = label_shape(shape, "m3 standoff", "front left", color=color)
self.assertIs(returned, shape)
self.assertEqual("m3_standoff:front_left", shape.label)
self.assertIs(color, shape.color)
def test_helper_connects_fixed_joint_to_moving_joint(self) -> None:
with fake_build123d():
assembly = AssemblyHelper("enclosure")
base = assembly.add(FakePart(), "base")
lid = assembly.add(FakePart(), "lid")
base_frame = assembly.rigid_frame(base, "lid_seat", FakeLocation("base_frame"))
lid_frame = assembly.rigid_frame(lid, "underside", FakeLocation("lid_frame"))
relation = assembly.face_to_face(base_frame, lid_frame)
fixed_joint = base.joints["lid_seat"]
moving_joint = lid.joints["underside"]
self.assertEqual("face_to_face", relation.relation)
self.assertEqual("lid_seat", relation.fixed)
self.assertEqual("underside", relation.moving)
self.assertEqual({"part": "base", "frame": "lid_seat"}, relation.fixed_endpoint)
self.assertEqual({"part": "lid", "frame": "underside"}, relation.moving_endpoint)
self.assertEqual([(moving_joint, {})], fixed_joint.connections)
def test_build_records_mate_endpoint_payloads(self) -> None:
with fake_build123d():
assembly = AssemblyHelper("enclosure")
base = assembly.add(FakePart(), "base")
lid = assembly.add(FakePart(), "lid")
base_frame = assembly.rigid_frame(base, "lid_seat", FakeLocation("base_frame"))
lid_frame = assembly.rigid_frame(lid, "underside", FakeLocation("lid_frame"))
assembly.face_to_face(base_frame, lid_frame, label="lid_mate")
compound = assembly.build()
self.assertEqual(
[
{
"id": "m1",
"label": "m1",
"sourceLabel": "lid_mate",
"type": "face_to_face",
"relation": "face_to_face",
"fixed": "lid_seat",
"moving": "underside",
"parameters": {},
"fixedEndpoint": {"part": "base", "frame": "lid_seat"},
"movingEndpoint": {"part": "lid", "frame": "underside"},
}
],
compound.assembly_mates,
)
def test_export_collection_renumbers_mates_globally(self) -> None:
with fake_build123d():
first = AssemblyHelper("first")
first_base = first.add(FakePart(), "first_base")
first_lid = first.add(FakePart(), "first_lid")
first_base_frame = first.rigid_frame(first_base, "seat", FakeLocation("first_base"))
first_lid_frame = first.rigid_frame(first_lid, "underside", FakeLocation("first_lid"))
first.face_to_face(first_base_frame, first_lid_frame, label="first_mate")
first_compound = first.build()
second = AssemblyHelper("second")
second_base = second.add(FakePart(), "second_base")
second_lid = second.add(FakePart(), "second_lid")
second_base_frame = second.rigid_frame(second_base, "seat", FakeLocation("second_base"))
second_lid_frame = second.rigid_frame(second_lid, "underside", FakeLocation("second_lid"))
second.face_to_face(second_base_frame, second_lid_frame, label="second_mate")
second_compound = second.build()
root = FakeCompound(label="root", children=[first_compound, second_compound])
mates = _collect_assembly_mates(root)
self.assertEqual(["m1", "m2"], [mate["id"] for mate in mates])
self.assertEqual(["m1", "m2"], [mate["label"] for mate in mates])
self.assertEqual(["first_mate", "second_mate"], [mate["sourceLabel"] for mate in mates])
def test_helper_accepts_existing_native_joint_labels(self) -> None:
with fake_build123d():
assembly = AssemblyHelper("hinge")
frame = FakePart()
leaf = FakePart()
fixed_joint = FakeJoint(
label="hinge_axis",
to_part=frame,
joint_location=FakeLocation("frame_axis"),
)
moving_joint = FakeJoint(
label="leaf_axis",
to_part=leaf,
joint_location=FakeLocation("leaf_axis"),
)
relation = assembly.revolute(
(frame, "hinge_axis"),
(leaf, "leaf_axis"),
angle=45,
)
self.assertEqual("revolute", relation.relation)
self.assertEqual({"angle": 45}, relation.parameters)
self.assertEqual([(moving_joint, {"angle": 45})], fixed_joint.connections)
def test_axis_frames_use_native_joint_axis_argument(self) -> None:
with fake_build123d():
assembly = AssemblyHelper("hinge")
frame = FakePart()
frame_target = assembly.revolute_frame(
frame,
"hinge_axis",
"Axis.Z",
angular_range=(-90, 90),
)
self.assertEqual(MateTarget(frame, "hinge_axis"), frame_target)
joint = frame.joints["hinge_axis"]
self.assertIsNone(joint.location)
self.assertEqual({"axis": "Axis.Z", "angular_range": (-90, 90)}, joint.options)
def test_offset_target_creates_temporary_native_joint(self) -> None:
with fake_build123d():
assembly = AssemblyHelper("offset")
base = FakePart()
lid = FakePart()
base_frame = assembly.rigid_frame(base, "seat", FakeLocation("base"))
lid_frame = assembly.rigid_frame(lid, "underside", FakeLocation("lid"))
relation = assembly.face_to_face(base_frame, lid_frame, offset=0.5)
offset_joint = base.joints["seat:offset"]
self.assertIsInstance(offset_joint.location, FakeLocation)
self.assertEqual(("mul", "base", (0.0, 0.0, 0.5)), offset_joint.location.value)
self.assertEqual("seat:offset", relation.fixed)
def test_build_returns_labeled_compound(self) -> None:
with fake_build123d():
assembly = AssemblyHelper("robot arm")
base = assembly.add(FakePart(), "base")
arm = assembly.add(FakePart(), "arm")
compound = assembly.build()
self.assertEqual("robot_arm", compound.label)
self.assertEqual((base, arm), compound.children)
def test_target_tuple_is_available_for_call_sites(self) -> None:
part = object()
self.assertEqual(MateTarget(part, "axis"), target(part, "axis"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,460 @@
from __future__ import annotations
import contextlib
import io
import tempfile
import unittest
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from tests.python.support.paths import add_repo_path
add_repo_path("packages/cadpy/src")
from cadpy import generation
from cadpy.assembly_spec import (
IDENTITY_TRANSFORM,
AssemblyInstanceSpec,
AssemblyNodeSpec,
AssemblySpec,
)
from cadpy.catalog import CadSource
from cadpy.metadata import parse_generator_metadata
from cadpy.step_export import _create_bin_xcaf_doc, export_build123d_step_scene
from cadpy.step_scene import LoadedStepScene, _bbox_from_shape, scene_leaf_occurrences, scene_occurrence_shape
def _rounded_color(color: tuple[float, ...]) -> tuple[float, ...]:
return tuple(round(component, 3) for component in color)
def _srgb_to_linear(component: float) -> float:
if component <= 0.04045:
return component / 12.92
return ((component + 0.055) / 1.055) ** 2.4
class CompoundAssemblyGenerationTests(unittest.TestCase):
def test_step_payload_rejects_legacy_output_field(self) -> None:
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): step_output"):
generation._normalize_step_payload(
{"shape": object(), "step_output": "legacy.step"},
script_path=Path("part.py"),
)
def test_step_payload_preserves_instance_assembly_mates(self) -> None:
payload = generation._normalize_step_payload(
{
"instances": [
{
"path": "servo.step",
"name": "servo",
}
],
"assembly_mates": [
{
"sourceLabel": "servo_to_bracket",
"relation": "rigid",
"fixed": "servo_mount",
"moving": "bracket_foot",
}
],
},
script_path=Path("assembly.py"),
)
self.assertEqual("servo_to_bracket", payload["assembly_mates"][0]["sourceLabel"])
def test_attach_envelope_assembly_mates_renumbers_for_scene(self) -> None:
scene = LoadedStepScene(step_path=Path("assembly.step"), roots=[], prototype_shapes={})
generation._attach_envelope_assembly_mates(
scene,
{
"assembly_mates": [
{
"sourceLabel": "servo_to_bracket",
"relation": "rigid",
"fixed": "servo_mount",
"moving": "bracket_foot",
}
]
},
script_path=Path("assembly.py"),
)
self.assertEqual(
[
{
"id": "m1",
"label": "m1",
"sourceLabel": "servo_to_bracket",
"relation": "rigid",
"fixed": "servo_mount",
"moving": "bracket_foot",
}
],
scene.assembly_mates,
)
def test_dxf_payload_rejects_legacy_output_field(self) -> None:
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): dxf_output"):
generation._normalize_dxf_payload(
{"document": object(), "dxf_output": "legacy.dxf"},
script_path=Path("part.py"),
)
def test_metadata_rejects_legacy_output_fields(self) -> None:
cases = [
("gen_step", "return {'shape': object(), 'step_output': 'legacy.step'}", "step_output"),
("gen_dxf", "return {'document': object(), 'dxf_output': 'legacy.dxf'}", "dxf_output"),
("gen_urdf", "return {'xml': '<robot />', 'urdf_output': 'legacy.urdf'}", "urdf_output"),
("gen_sdf", "return {'xml': '<sdf version=\"1.12\" />', 'sdf_output': 'legacy.sdf'}", "sdf_output"),
]
for function_name, return_line, field_name in cases:
with self.subTest(function_name=function_name), tempfile.TemporaryDirectory(prefix="cadpy-output-field-") as tempdir:
script_path = Path(tempdir) / "part.py"
script_path.write_text(
"\n".join(
[
"def gen_step():",
" return {'shape': object()}",
"",
f"def {function_name}():",
f" {return_line}",
"",
]
),
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, f"unsupported field\\(s\\): {field_name}"):
parse_generator_metadata(script_path)
def test_run_selected_specs_preserves_action_stdout(self) -> None:
spec = SimpleNamespace(source_ref="part.py")
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
generation._run_selected_specs(
[spec],
action=lambda _spec: print("generator summary"),
logger=generation.CliLogger("test", stream=io.StringIO()),
success_message=None,
)
self.assertEqual("generator summary\n", stdout.getvalue())
def test_compound_with_explicit_children_is_discovered_as_assembly(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
script_path = Path(tempdir) / "robot_arm.py"
script_path.write_text(
"\n".join(
[
"from build123d import Compound",
"",
"def gen_step():",
" parts = []",
" assembly = Compound(",
" obj=parts,",
" children=parts,",
" label='robot_arm_static_display_pose',",
" )",
" return assembly",
"",
]
),
encoding="utf-8",
)
metadata = parse_generator_metadata(script_path)
self.assertIsNotNone(metadata)
self.assertEqual("assembly", metadata.kind)
def test_compound_with_literal_obj_sequence_is_discovered_as_assembly(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
script_path = Path(tempdir) / "compound_arm.py"
script_path.write_text(
"\n".join(
[
"from build123d import Box, Compound",
"",
"def gen_step():",
" left = Box(1, 1, 1)",
" right = Box(1, 1, 1)",
" return Compound(obj=[left, right], label='compound_arm')",
"",
]
),
encoding="utf-8",
)
metadata = parse_generator_metadata(script_path)
self.assertIsNotNone(metadata)
self.assertEqual("assembly", metadata.kind)
def test_childless_compound_obj_sequence_is_runtime_assembly(self) -> None:
import build123d
left = build123d.Box(1, 1, 1)
right = build123d.Box(1, 1, 1)
shape = build123d.Compound(obj=[left, right], label="compound_arm")
self.assertEqual("assembly", generation._shape_payload_entry_kind(shape, fallback="part"))
def test_labeled_childless_compound_does_not_warn_without_color(self) -> None:
import build123d
left = build123d.Box(1, 1, 1)
right = build123d.Box(1, 1, 1)
shape = build123d.Compound(obj=[left, right], label="compound_arm")
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_create_bin_xcaf_doc(shape)
messages = [str(item.message) for item in caught]
self.assertNotIn("Unknown Compound type, color not set", messages)
def test_colored_child_shapes_survive_compound_assembly_export(self) -> None:
import build123d
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
left = build123d.Box(1, 1, 1)
left.label = "red_child"
left.color = build123d.Color(1, 0, 0)
right = build123d.Pos(2, 0, 0) * build123d.Box(1, 1, 1)
right.label = "blue_child"
right.color = build123d.Color(0, 0, 1)
shape = build123d.Compound(children=[left, right], label="colored_assembly")
scene = export_build123d_step_scene(
shape,
Path(tempdir) / "colored_assembly.step",
text_to_cad_entry_kind="assembly",
)
colors = {
tuple(round(component, 3) for component in color)
for color in scene.prototype_colors.values()
}
colors.update(
tuple(round(component, 3) for component in node.color)
for root in scene.roots
for node in root.children
if node.color is not None
)
self.assertEqual(1, len(scene.roots))
self.assertEqual(2, len(scene.roots[0].children))
self.assertIn((1.0, 0.0, 0.0, 1.0), colors)
self.assertIn((0.0, 0.0, 1.0, 1.0), colors)
def test_nested_colored_compound_keeps_parent_transform(self) -> None:
import build123d
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
child = build123d.Box(1, 1, 1)
child.label = "motor_body"
child.color = build123d.Color(0.1, 0.2, 0.3)
expected_color = _rounded_color(child.color)
expected_linear_color = _rounded_color(
(
*(_srgb_to_linear(component) for component in expected_color[:3]),
expected_color[3],
)
)
nested = build123d.Compound(children=[child], label="imported_motor")
placed = build123d.Pos(20, 0, 0) * nested
placed.label = "placed_motor"
root = build123d.Compound(children=[placed], label="arm")
scene = export_build123d_step_scene(
root,
Path(tempdir) / "arm.step",
text_to_cad_entry_kind="assembly",
)
leaves = scene_leaf_occurrences(scene)
self.assertEqual(1, len(leaves))
bbox = _bbox_from_shape(scene_occurrence_shape(scene, leaves[0]))
self.assertGreater(bbox["min"][0], 19.0)
self.assertLess(bbox["max"][0], 21.0)
self.assertIn(
_rounded_color(leaves[0].color),
{expected_color, expected_linear_color},
)
def test_shape_payload_can_export_with_assembly_entry_kind(self) -> None:
import build123d
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
script_path = Path(tempdir) / "robot_arm.py"
script_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
output_path = script_path.with_suffix(".step")
scene = LoadedStepScene(step_path=output_path.resolve(), roots=[], prototype_shapes={})
left = build123d.Box(1, 1, 1)
right = build123d.Box(1, 1, 1)
shape = build123d.Compound(children=[left, right], label="robot_arm")
with (
mock.patch.object(
generation,
"python_source_hash",
return_value=SimpleNamespace(
source_path="robot_arm.py",
source_hash="hash-123",
),
),
mock.patch.object(generation, "export_build123d_step_scene", return_value=scene) as export_scene,
):
result = generation._write_shape_step_payload(
{"shape": shape},
output_path=output_path,
script_path=script_path,
logger=generation.CliLogger("test"),
entry_kind="assembly",
)
self.assertIs(result, scene)
self.assertEqual("assembly", export_scene.call_args.kwargs["text_to_cad_entry_kind"])
self.assertEqual("assembly", getattr(scene, "text_to_cad_entry_kind", None))
self.assertEqual("shape", getattr(scene, "step_payload_kind", None))
def test_effective_spec_follows_runtime_shape_entry_kind(self) -> None:
step_path = Path("/tmp/compound.step")
scene = LoadedStepScene(step_path=step_path, roots=[], prototype_shapes={})
scene.text_to_cad_entry_kind = "assembly"
spec = generation.EntrySpec(
source_ref="compound.py",
cad_ref="compound",
kind="part",
source_path=Path("/tmp/compound.py"),
display_name="compound",
source="generated",
step_path=step_path,
script_path=Path("/tmp/compound.py"),
)
effective = generation._effective_step_spec_for_scene(spec, scene)
self.assertEqual("assembly", effective.kind)
self.assertEqual("part", spec.kind)
def test_artifact_outputs_use_runtime_shape_entry_kind(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-compound-") as tempdir:
step_path = Path(tempdir) / "compound.step"
script_path = Path(tempdir) / "compound.py"
scene = LoadedStepScene(step_path=step_path.resolve(), roots=[], prototype_shapes={})
scene.text_to_cad_entry_kind = "assembly"
scene.source_kind = "python"
scene.source_path = "compound.py"
scene.source_hash = "source-hash"
spec = generation.EntrySpec(
source_ref="compound.py",
cad_ref="compound",
kind="part",
source_path=script_path,
display_name="compound",
source="generated",
step_path=step_path,
script_path=script_path,
)
selector_bundle = generation.SelectorBundle(manifest={"stats": {}})
with (
mock.patch.object(generation, "_existing_topology_artifact_matches_spec_without_scene", return_value=False),
mock.patch.object(generation, "_existing_topology_artifact_matches_options", return_value=False),
mock.patch.object(generation, "_selector_options_for_part", return_value=generation.SelectorOptions()),
mock.patch.object(generation, "mesh_step_scene"),
mock.patch.object(generation, "scene_export_shape"),
mock.patch.object(generation, "_reset_step_artifact_dir"),
mock.patch.object(generation, "_run_artifact_jobs", return_value={"GLB/topology": selector_bundle}),
):
result = generation._generate_part_outputs(
spec,
entries_by_step_path={step_path.resolve(): spec},
preloaded_scene=scene,
require_step_file=False,
force=True,
)
self.assertEqual("assembly", result.spec.kind)
self.assertIs(result.selector_bundle, selector_bundle)
def test_dependency_expansion_walks_flattened_grouping_node_leaves(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-dependencies-") as tempdir:
root = Path(tempdir)
assembly_path = root / "grouped_assembly.py"
assembly_step = root / "grouped_assembly.step"
leaf_step = root / "nested_part.step"
assembly_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
leaf_step.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
identity = tuple(IDENTITY_TRANSFORM)
leaf_instance = AssemblyInstanceSpec(
instance_id="nested_part",
source_path=leaf_step.resolve(),
path="nested_part.step",
name="nested_part",
transform=identity,
)
leaf_node = AssemblyNodeSpec(
instance_id="nested_part",
name="nested_part",
transform=identity,
source_path=leaf_step.resolve(),
path="nested_part.step",
children=(),
)
grouping_node = AssemblyNodeSpec(
instance_id="front_group",
name="front_group",
transform=identity,
source_path=None,
path=None,
children=(leaf_node,),
)
assembly_spec = AssemblySpec(
assembly_path=assembly_path.resolve(),
instances=(leaf_instance,),
children=(grouping_node,),
)
entry = generation.EntrySpec(
source_ref="grouped_assembly.py",
cad_ref="grouped_assembly",
kind="assembly",
source_path=assembly_path.resolve(),
display_name="grouped_assembly",
source="generated",
step_path=assembly_step.resolve(),
script_path=assembly_path.resolve(),
)
leaf_source = CadSource(
source_ref="nested_part.step",
cad_ref="nested_part",
kind="part",
source_path=leaf_step.resolve(),
source="imported",
origin_path=leaf_step.resolve(),
step_path=leaf_step.resolve(),
)
with (
mock.patch.object(generation, "read_assembly_spec", return_value=assembly_spec),
mock.patch.object(generation, "_source_lookup_by_path", return_value={leaf_step.resolve(): leaf_source}),
):
expanded = generation._expand_specs_with_file_dependencies([entry])
self.assertEqual(
[assembly_path.resolve(), leaf_step.resolve()],
[spec.source_path for spec in expanded],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,71 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from tests.python.support.paths import add_repo_path
add_repo_path("packages/cadpy/src")
from cadpy.generation_status import GenerationOutput, track_generation_run
class GenerationStatusTests(unittest.TestCase):
def test_generation_status_uses_hidden_lock_next_to_output_and_cleans_up(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-generation-status-") as tempdir:
workspace = Path(tempdir) / "workspace"
model_dir = workspace / "models"
skill_dir = Path(tempdir) / "installed-skill"
source_path = model_dir / "part.py"
step_path = model_dir / "part.step"
model_dir.mkdir(parents=True)
source_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
(skill_dir / "scripts").mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# skill\n", encoding="utf-8")
with track_generation_run(
source_path=source_path,
generator="gen_step",
outputs=[GenerationOutput(step_path, "step")],
repo_root=skill_dir,
):
lock_paths = tuple(model_dir.glob(".part.step.*.generation.lock.json"))
self.assertEqual(1, len(lock_paths))
payload = json.loads(lock_paths[0].read_text(encoding="utf-8"))
self.assertEqual("running", payload["status"])
self.assertEqual("gen_step", payload["generator"])
self.assertEqual("part.py", payload["sourcePath"])
self.assertEqual("part.step", payload["outputs"][0]["path"])
self.assertFalse(tuple(skill_dir.rglob("*.generation.lock.json")))
self.assertFalse(lock_paths[0].exists())
def test_generation_status_writes_one_lock_for_each_output_without_blocking(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-generation-status-") as tempdir:
model_dir = Path(tempdir) / "models"
step_path = model_dir / "part.step"
glb_path = model_dir / ".part.step.glb"
model_dir.mkdir(parents=True)
with track_generation_run(
source_path=None,
generator="gen_step",
outputs=[
GenerationOutput(step_path, "step"),
GenerationOutput(glb_path, "glb"),
],
repo_root=Path(tempdir),
):
step_locks = tuple(model_dir.glob(".part.step.*.generation.lock.json"))
glb_locks = tuple(model_dir.glob("..part.step.glb.*.generation.lock.json"))
self.assertEqual(1, len(step_locks))
self.assertEqual(1, len(glb_locks))
self.assertNotEqual(step_locks[0], glb_locks[0])
self.assertFalse(tuple(model_dir.glob("*.generation.lock.json")))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,25 @@
from __future__ import annotations
import unittest
from tests.python.support.paths import add_repo_path
add_repo_path("packages/cadpy/src")
from cadpy.glb import _GlbBuilder
class GlbMaterialTests(unittest.TestCase):
def test_materials_record_source_color_hint(self) -> None:
builder = _GlbBuilder()
source_index = builder.add_material((0.1, 0.1, 0.1, 1.0), source_color=True)
fallback_index = builder.add_material((0.72, 0.72, 0.72, 1.0), source_color=False)
materials = builder.json["materials"]
self.assertEqual({"cadSourceColor": True}, materials[source_index]["extras"])
self.assertEqual({"cadSourceColor": False}, materials[fallback_index]["extras"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from tests.python.support.paths import add_repo_path
add_repo_path("packages/cadpy_metadata/src")
from cadpy_metadata.generator import (
GenerationOutput,
PythonSourceIdentity,
track_generation_run,
xml_with_text_to_cad_metadata,
)
class MetadataGenerationStatusTests(unittest.TestCase):
def test_generation_status_uses_hidden_lock_next_to_output_and_cleans_up(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-metadata-generation-status-") as tempdir:
workspace = Path(tempdir) / "workspace"
model_dir = workspace / "models"
skill_dir = Path(tempdir) / "installed-skill"
source_path = model_dir / "robot_urdf.py"
urdf_path = model_dir / "robot.urdf"
model_dir.mkdir(parents=True)
source_path.write_text("def gen_urdf():\n return '<robot />'\n", encoding="utf-8")
(skill_dir / "scripts").mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# skill\n", encoding="utf-8")
with track_generation_run(
source_path=source_path,
generator="gen_urdf",
outputs=[GenerationOutput(urdf_path, "urdf")],
repo_root=skill_dir,
):
lock_paths = tuple(model_dir.glob(".robot.urdf.*.generation.lock.json"))
self.assertEqual(1, len(lock_paths))
payload = json.loads(lock_paths[0].read_text(encoding="utf-8"))
self.assertEqual("running", payload["status"])
self.assertEqual("gen_urdf", payload["generator"])
self.assertEqual("robot_urdf.py", payload["sourcePath"])
self.assertEqual("robot.urdf", payload["outputs"][0]["path"])
self.assertFalse(tuple(skill_dir.rglob("*.generation.lock.json")))
self.assertFalse(lock_paths[0].exists())
def test_generation_status_writes_one_lock_for_each_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-metadata-generation-status-") as tempdir:
model_dir = Path(tempdir) / "models"
urdf_path = model_dir / "robot.urdf"
srdf_path = model_dir / "robot.srdf"
model_dir.mkdir(parents=True)
with track_generation_run(
source_path=None,
generator="gen_srdf",
outputs=[
GenerationOutput(urdf_path, "urdf"),
GenerationOutput(srdf_path, "srdf"),
],
repo_root=Path(tempdir),
):
urdf_locks = tuple(model_dir.glob(".robot.urdf.*.generation.lock.json"))
srdf_locks = tuple(model_dir.glob(".robot.srdf.*.generation.lock.json"))
self.assertEqual(1, len(urdf_locks))
self.assertEqual(1, len(srdf_locks))
self.assertNotEqual(urdf_locks[0], srdf_locks[0])
self.assertFalse(tuple(model_dir.glob("*.generation.lock.json")))
def test_xml_metadata_source_path_is_relative_to_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="cadpy-metadata-xml-") as tempdir:
workspace = Path(tempdir)
source_path = workspace / "src" / "robot.py"
output_path = workspace / "out" / "robot.urdf"
identity = PythonSourceIdentity(
source_path="src/robot.py",
source_hash="source-hash",
)
xml = xml_with_text_to_cad_metadata(
"<robot name=\"sample\" />",
identity,
output_path=output_path,
source_path=source_path,
)
self.assertIn("<!-- cadpy:sourcePath=../src/robot.py -->", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,982 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import hashlib
import os
import stat
import tempfile
import textwrap
import unittest
import zipfile
from pathlib import Path
from unittest import mock
from tests.python.support.paths import add_repo_path, repo_path
add_repo_path("skills/bambu-labs/scripts")
import bambu_lan_print as bambu
def write_gcode(path: Path, body: str = "M104 S220\nM140 S65\nG1 X1 Y1 Z0.2 E0.1\n") -> None:
path.write_text(body, encoding="utf-8")
def write_sliced_3mf(path: Path) -> None:
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("[Content_Types].xml", "<Types/>")
archive.writestr("Metadata/plate_1.gcode", "; plate 1\nG1 X1\n")
def write_fake_bambox(path: Path) -> None:
script = textwrap.dedent(
"""\
#!/usr/bin/env python3
import json
import sys
import zipfile
from pathlib import Path
args = sys.argv[1:]
if args[0] == "pack":
output = Path(args[args.index("-o") + 1])
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("[Content_Types].xml", "<Types/>")
archive.writestr("Metadata/plate_1.gcode", "G1 X1 E0.1\\n")
print(json.dumps({"packed": str(output)}))
raise SystemExit(0)
if args[0] == "validate":
print(json.dumps({"ok": True}))
raise SystemExit(0)
raise SystemExit(2)
"""
)
path.write_text(script, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
class BambuLanPrintTests(unittest.TestCase):
def test_new_printer_onboarding_reference_is_linked_and_specific(self) -> None:
skill_root = repo_path("skills/bambu-labs")
skill_text = (skill_root / "SKILL.md").read_text(encoding="utf-8")
onboarding_path = skill_root / "references" / "new-printer-onboarding.md"
onboarding = onboarding_path.read_text(encoding="utf-8")
self.assertIn("references/new-printer-onboarding.md", skill_text)
for required in [
"Enable LAN Only",
"Enable Developer Mode",
"A1 / A1 Mini",
"P1P / P1S",
"X1 / X1C / X1E",
"H2D / Newer Bambu Printers",
"printer IP address and LAN access code",
]:
with self.subTest(required=required):
self.assertIn(required, onboarding)
def test_explicit_print_request_policy_is_documented(self) -> None:
skill_root = repo_path("skills/bambu-labs")
skill_text = (skill_root / "SKILL.md").read_text(encoding="utf-8")
checklist = (skill_root / "references" / "real-printer-checklist.md").read_text(encoding="utf-8")
self.assertIn("explicit user request to print or start", skill_text)
self.assertIn("do not pause for a second confirmation", skill_text)
self.assertIn("live-start authorization for this checklist", checklist)
def test_default_config_uses_workspace_root_not_skill_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
workspace_root = Path(tmp) / "workspace"
workspace_root.mkdir()
with mock.patch.dict(os.environ, {"INIT_CWD": str(workspace_root)}):
self.assertEqual(workspace_root.resolve(), bambu.default_workspace_root())
skill_root = repo_path("skills/bambu-labs")
self.assertNotEqual(skill_root.resolve(), bambu.DEFAULT_CONFIG_PATH.parent)
def test_inspects_plain_gcode(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job.gcode"
write_gcode(job)
inspection = bambu.inspect_gcode_file(job)
self.assertEqual(inspection.size_bytes, job.stat().st_size)
self.assertRegex(inspection.md5, r"^[0-9a-f]{32}$")
self.assertEqual(inspection.path, str(job))
def test_rejects_non_plain_gcode_inputs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
missing = root / "missing.gcode"
empty = root / "empty.gcode"
empty.write_text("", encoding="utf-8")
sliced = root / "job.gcode.3mf"
write_sliced_3mf(sliced)
mesh = root / "part.stl"
mesh.write_text("solid part\nendsolid part\n", encoding="utf-8")
cases = [
(missing, "does not exist"),
(empty, "empty"),
(sliced, "Expected plain .gcode"),
(mesh, "Expected a plain .gcode"),
]
for path, message in cases:
with self.subTest(path=path):
with self.assertRaisesRegex(bambu.BambuPrintError, message):
bambu.inspect_gcode_file(path)
def test_plain_send_plan_uses_gcode_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job 01.gcode"
write_gcode(job)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--action",
"upload-start",
"--sequence-id",
"seq-1",
"--remote-name",
"job 01.gcode",
]
)
plan = bambu.build_send_plan(args)
self.assertTrue(plan["dry_run"])
self.assertEqual(plan["handoff"], "plain")
self.assertTrue(plan["ftps"]["will_upload"])
self.assertTrue(plan["mqtt"]["will_publish"])
self.assertEqual(plan["ftps"]["remote_path"], "cache/job_01.gcode")
self.assertEqual(plan["mqtt"]["topic"], "device/00M00A000000000/request")
payload = plan["mqtt"]["payload"]["print"]
self.assertEqual(payload["command"], "gcode_file")
self.assertEqual(payload["param"], "cache/job_01.gcode")
self.assertEqual(payload["sequence_id"], "seq-1")
def test_gcode_param_override_is_allowed_for_plain_handoff(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job.gcode"
write_gcode(job)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--gcode-param",
"/cache/job.gcode",
]
)
plan = bambu.build_send_plan(args)
self.assertEqual(plan["mqtt"]["payload"]["print"]["param"], "/cache/job.gcode")
def test_config_set_writes_printer_json_without_echoing_access_code(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = root / "printers.json"
args = bambu.parse_args(
[
"config",
"--config",
str(config),
"set",
"--printer",
"a1-mini",
"--host",
"192.168.1.34",
"--access-code",
"12345678",
"--model",
"a1-mini",
"--serial",
"0309CA4C0901107",
]
)
with mock.patch("builtins.print") as mocked_print:
code = bambu.config_set_main(args)
self.assertEqual(code, 0)
data = json.loads(config.read_text(encoding="utf-8"))
self.assertEqual(data["printers"]["a1-mini"]["access_code"], "12345678")
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["entry"]["host"], "192.168.1.34")
self.assertNotIn("access_code", payload["entry"])
def test_send_parser_loads_printer_defaults_from_json_config(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
config = root / "printers.json"
write_gcode(job)
config.write_text(
json.dumps(
{
"printers": {
"a1-mini": {
"host": "192.168.1.34",
"access_code": "12345678",
"serial": "0309CA4C0901107",
"model": "a1-mini",
}
}
}
),
encoding="utf-8",
)
args = bambu.parse_args(["send", "--config", str(config), "--printer", "a1-mini", "--gcode", str(job)])
plan = bambu.build_send_plan(args)
self.assertEqual(args.host, "192.168.1.34")
self.assertEqual(args.serial, "0309CA4C0901107")
self.assertEqual(bambu.access_code_from_args(args), "12345678")
self.assertEqual(plan["mqtt"]["topic"], "device/0309CA4C0901107/request")
def test_dry_run_send_plan_does_not_discover_serial_when_omitted(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
config = root / "printers.json"
write_gcode(job)
config.write_text(
json.dumps({"printers": {"a1-mini": {"host": "192.168.1.34", "access_code": "12345678"}}}),
encoding="utf-8",
)
args = bambu.parse_args(
[
"send",
"--config",
str(config),
"--printer",
"a1-mini",
"--gcode",
str(job),
"--action",
"upload-start",
]
)
with mock.patch.object(
bambu,
"discover_printer_serial",
side_effect=AssertionError("dry-run plans must not perform network discovery"),
) as mocked_discover:
plan = bambu.build_send_plan(args)
mocked_discover.assert_not_called()
self.assertEqual(args.serial, "")
self.assertIsNone(plan["mqtt"]["topic"])
cached = json.loads(config.read_text(encoding="utf-8"))
self.assertNotIn("serial", cached["printers"]["a1-mini"])
def test_execute_send_discovers_serial_from_printer_certificate_when_omitted(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
config = root / "printers.json"
write_gcode(job)
config.write_text(
json.dumps({"printers": {"a1-mini": {"host": "192.168.1.34", "access_code": "12345678"}}}),
encoding="utf-8",
)
args = bambu.parse_args(
[
"send",
"--config",
str(config),
"--printer",
"a1-mini",
"--gcode",
str(job),
"--action",
"upload-start",
"--execute",
"--confirm-start-print",
]
)
with (
mock.patch.object(bambu, "discover_printer_serial", return_value="0309CA4C0901107") as mocked_discover,
mock.patch.object(bambu, "upload_ftps") as mocked_upload,
mock.patch.object(bambu, "publish_mqtt") as mocked_publish,
mock.patch("builtins.print") as mocked_print,
):
code = bambu.send_main(args)
self.assertEqual(code, 0)
mocked_discover.assert_called_once()
mocked_upload.assert_called_once()
mocked_publish.assert_called_once()
self.assertEqual(args.serial, "0309CA4C0901107")
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["mqtt"]["topic"], "device/0309CA4C0901107/request")
cached = json.loads(config.read_text(encoding="utf-8"))
self.assertEqual(cached["printers"]["a1-mini"]["serial"], "0309CA4C0901107")
def test_serial_command_reports_tls_certificate_serial(self) -> None:
args = bambu.parse_args(["serial", "--host", "192.168.1.34", "--json"])
with mock.patch.object(bambu, "discover_printer_serial", return_value="0309CA4C0901107"), mock.patch(
"builtins.print"
) as mocked_print:
code = bambu.serial_main(args)
self.assertEqual(code, 0)
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["serial"], "0309CA4C0901107")
self.assertEqual(payload["source"], "printer_tls_certificate_common_name")
def test_bambox_project_refuses_unsupported_a1_mini_profile(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job.gcode"
write_gcode(job)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--handoff",
"bambox-project",
"--bambox-profile",
"a1-mini-0.4",
"--filament",
"PLA",
]
)
with self.assertRaisesRegex(bambu.BambuPrintError, "a1-mini-0.4 is not enabled"):
bambu.build_send_plan(args)
def test_bambox_project_builds_pack_command_without_running_bambox(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job.gcode"
write_gcode(job)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--handoff",
"bambox-project",
"--bambox-profile",
"p1s-0.4",
"--filament",
"PLA",
"--bambox-bin",
"/usr/local/bin/bambox",
"--action",
"upload-start",
]
)
plan = bambu.build_send_plan(args)
command = plan["input"]["bambox"]["pack_command"]
self.assertEqual(command[:3], ["/usr/local/bin/bambox", "pack", str(job.resolve())])
self.assertIn("-m", command)
self.assertIn("p1s", command)
self.assertIn("--nozzle-diameter", command)
self.assertIn("0.4", command)
self.assertIn("-f", command)
self.assertIn("PLA", command)
self.assertEqual(plan["mqtt"]["payload"]["print"]["command"], "project_file")
self.assertIsNone(plan["mqtt"]["payload"]["print"]["md5"])
self.assertEqual(plan["mqtt"]["payload"]["print"]["subtask_name"], "job")
self.assertTrue(plan["mqtt"]["payload"]["print"]["bed_levelling"])
self.assertFalse(plan["mqtt"]["payload"]["print"]["use_ams"])
self.assertEqual(plan["ftps"]["remote_path"], "job.gcode.3mf")
self.assertEqual(plan["ftps"]["url"], "ftp:///job.gcode.3mf")
def test_package_execute_uses_fake_bambox_and_validates_archive(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
output = root / "job.gcode.3mf"
fake_bambox = root / "bambox"
write_gcode(job)
write_fake_bambox(fake_bambox)
args = bambu.parse_args(
[
"package",
"--gcode",
str(job),
"--output",
str(output),
"--bambox-profile",
"p1s-0.4",
"--filament",
"PLA",
"--bambox-bin",
str(fake_bambox),
"--execute",
]
)
with mock.patch("builtins.print") as mocked_print:
code = bambu.package_main(args)
self.assertEqual(code, 0)
self.assertTrue(output.exists())
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["executed"], ["bambox_pack", "bambox_validate"])
self.assertEqual(payload["result"]["project"]["plates"][0]["path"], "Metadata/plate_1.gcode")
def test_template_project_pack_replaces_plate_gcode_and_md5(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
template = root / "template.gcode.3mf"
output = root / "job.gcode.3mf"
write_gcode(job, "M104 S220\nM140 S65\nG1 X2 Y2 Z0.2 E0.2\n")
write_sliced_3mf(template)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--handoff",
"template-project",
"--template-project",
str(template),
]
)
result = bambu.package_with_template_project(args, output)
self.assertEqual(result["pack"]["replaced_plate"], "Metadata/plate_1.gcode")
with zipfile.ZipFile(output) as archive:
self.assertEqual(archive.read("Metadata/plate_1.gcode"), job.read_bytes())
self.assertEqual(
archive.read("Metadata/plate_1.gcode.md5").decode("ascii"),
hashlib.md5(job.read_bytes()).hexdigest().upper(),
)
def test_template_project_upload_start_execute_uses_root_project_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
template = root / "template.gcode.3mf"
write_gcode(job)
write_sliced_3mf(template)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--handoff",
"template-project",
"--template-project",
str(template),
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--access-code",
"12345678",
"--action",
"upload-start",
"--execute",
"--confirm-start-print",
]
)
with (
mock.patch.object(bambu, "upload_ftps") as mocked_upload,
mock.patch.object(bambu, "publish_mqtt") as mocked_publish,
mock.patch("builtins.print") as mocked_print,
):
code = bambu.send_main(args)
self.assertEqual(code, 0)
mocked_upload.assert_called_once()
self.assertEqual(mocked_upload.call_args.args[2], "job.gcode.3mf")
mocked_publish.assert_called_once()
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["handoff"], "template-project")
self.assertEqual(payload["ftps"]["remote_path"], "job.gcode.3mf")
self.assertEqual(payload["ftps"]["url"], "ftp:///job.gcode.3mf")
self.assertEqual(payload["mqtt"]["payload"]["print"]["command"], "project_file")
self.assertEqual(payload["mqtt"]["payload"]["print"]["url"], "ftp:///job.gcode.3mf")
self.assertEqual(payload["mqtt"]["payload"]["print"]["subtask_name"], "job")
self.assertTrue(payload["mqtt"]["payload"]["print"]["flow_cali"])
self.assertRegex(payload["mqtt"]["payload"]["print"]["md5"], r"^[0-9A-F]{32}$")
def test_bambox_upload_start_execute_packages_then_uses_project_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
job = root / "job.gcode"
fake_bambox = root / "bambox"
write_gcode(job)
write_fake_bambox(fake_bambox)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--handoff",
"bambox-project",
"--bambox-profile",
"p1s-0.4",
"--filament",
"PLA",
"--bambox-bin",
str(fake_bambox),
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--access-code",
"12345678",
"--action",
"upload-start",
"--execute",
"--confirm-start-print",
]
)
with (
mock.patch.object(bambu, "upload_ftps") as mocked_upload,
mock.patch.object(bambu, "publish_mqtt") as mocked_publish,
mock.patch("builtins.print") as mocked_print,
):
code = bambu.send_main(args)
self.assertEqual(code, 0)
mocked_upload.assert_called_once()
mocked_publish.assert_called_once()
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["executed"], ["upload", "publish_start_request"])
self.assertEqual(payload["mqtt"]["payload"]["print"]["command"], "project_file")
self.assertEqual(payload["mqtt"]["payload"]["print"]["param"], "Metadata/plate_1.gcode")
self.assertEqual(payload["mqtt"]["payload"]["print"]["url"], "ftp:///job.gcode.3mf")
self.assertEqual(payload["mqtt"]["payload"]["print"]["subtask_name"], "job")
self.assertRegex(payload["mqtt"]["payload"]["print"]["md5"], r"^[0-9A-F]{32}$")
def test_implicit_ftps_reuses_control_tls_session_for_data_connection(self) -> None:
context = mock.Mock()
control_socket = mock.Mock()
control_socket.session = "session-token"
data_socket = mock.Mock()
wrapped_data_socket = mock.Mock()
context.wrap_socket.return_value = wrapped_data_socket
ftp = bambu.ImplicitFTP_TLS(context=context)
ftp.sock = control_socket
ftp.host = "printer.local"
ftp._prot_p = True
with mock.patch.object(bambu.ftplib.FTP, "ntransfercmd", return_value=(data_socket, 123)):
conn, size = ftp.ntransfercmd("STOR job.gcode")
self.assertIs(conn, wrapped_data_socket)
self.assertEqual(size, 123)
context.wrap_socket.assert_called_once_with(
data_socket,
server_hostname="printer.local",
session="session-token",
)
def test_ftps_upload_accepts_timeout_when_control_channel_confirms_completion(self) -> None:
ftp = mock.Mock()
ftp.storbinary.side_effect = bambu.socket.timeout("data channel shutdown timed out")
ftp.voidresp.return_value = "226 Transfer complete"
bambu.storbinary_with_bambu_timeout_tolerance(ftp, "STOR job.gcode", mock.Mock())
ftp.storbinary.assert_called_once()
ftp.voidresp.assert_called_once()
def test_start_execute_requires_confirmation_before_network_use(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job.gcode"
write_gcode(job)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--action",
"start",
"--execute",
]
)
with self.assertRaisesRegex(bambu.BambuPrintError, "confirm-start-print"):
bambu.send_main(args)
def test_validate_local_host_accepts_hostname_resolving_to_private_address(self) -> None:
with mock.patch.object(
bambu.socket,
"getaddrinfo",
return_value=[
(
bambu.socket.AF_INET,
bambu.socket.SOCK_STREAM,
0,
"",
("192.168.1.50", 0),
)
],
):
bambu.validate_local_host("printer.local", allow_nonprivate=False)
def test_validate_local_host_rejects_hostname_resolving_to_public_address(self) -> None:
with mock.patch.object(
bambu.socket,
"getaddrinfo",
return_value=[
(
bambu.socket.AF_INET,
bambu.socket.SOCK_STREAM,
0,
"",
("8.8.8.8", 0),
)
],
):
with self.assertRaisesRegex(bambu.BambuPrintError, "non-private"):
bambu.validate_local_host("printer.example.com", allow_nonprivate=False)
def test_validate_local_host_does_not_resolve_when_nonprivate_hosts_are_allowed(self) -> None:
with mock.patch.object(bambu.socket, "getaddrinfo") as mocked_getaddrinfo:
bambu.validate_local_host("printer.example.com", allow_nonprivate=True)
mocked_getaddrinfo.assert_not_called()
def test_validate_local_host_rejects_literal_public_address(self) -> None:
with self.assertRaisesRegex(bambu.BambuPrintError, "non-private"):
bambu.validate_local_host("8.8.8.8", allow_nonprivate=False)
def test_upload_start_reports_published_start_request_not_confirmed_start(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
job = Path(tmp) / "job.gcode"
write_gcode(job)
args = bambu.parse_args(
[
"send",
"--gcode",
str(job),
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--access-code",
"12345678",
"--action",
"upload-start",
"--execute",
"--confirm-start-print",
]
)
with (
mock.patch.object(bambu, "upload_ftps") as mocked_upload,
mock.patch.object(bambu, "publish_mqtt") as mocked_publish,
mock.patch("builtins.print") as mocked_print,
):
code = bambu.send_main(args)
self.assertEqual(code, 0)
mocked_upload.assert_called_once()
mocked_publish.assert_called_once()
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["executed"], ["upload", "publish_start_request"])
self.assertEqual(payload["mqtt"]["payload"]["print"]["command"], "gcode_file")
self.assertIn("does not confirm printer acceptance", payload["execution_notes"][0])
def test_mqtt_client_id_defaults_to_serial(self) -> None:
args = mock.Mock()
args.access_code = "12345678"
args.host = "192.168.1.50"
args.mqtt_port = 8883
args.timeout = 20.0
args.tls_verify = False
args.allow_nonprivate_host = True
args.client_id = ""
args.serial = "00M00A000000000"
sent = bytearray()
connack = bytearray(b"\x20\x02\x00\x00")
class FakeSocket:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def sendall(self, data):
sent.extend(data)
def recv(self, size):
chunk = bytes(connack[:size])
del connack[:size]
return chunk
fake_context = mock.Mock()
fake_context.wrap_socket.return_value = FakeSocket()
with (
mock.patch.object(bambu.ssl, "create_default_context", return_value=fake_context),
mock.patch.object(bambu.socket, "create_connection", return_value=FakeSocket()),
):
bambu.publish_mqtt(args, "device/00M00A000000000/request", {"print": {}})
self.assertIn(b"00M00A000000000", bytes(sent))
def test_mqtt_qos1_publish_waits_for_puback(self) -> None:
args = mock.Mock()
args.access_code = "12345678"
args.host = "192.168.1.50"
args.mqtt_port = 8883
args.timeout = 20.0
args.tls_verify = False
args.allow_nonprivate_host = True
args.client_id = ""
args.serial = "00M00A000000000"
args.mqtt_qos = 1
args.wait_after_publish = 0.0
sent = bytearray()
incoming = bytearray(b"\x20\x02\x00\x00\x40\x02\x00\x02")
class FakeSocket:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def sendall(self, data):
sent.extend(data)
def recv(self, size):
chunk = bytes(incoming[:size])
del incoming[:size]
return chunk
def settimeout(self, timeout):
self.timeout = timeout
fake_context = mock.Mock()
fake_context.wrap_socket.return_value = FakeSocket()
with (
mock.patch.object(bambu.ssl, "create_default_context", return_value=fake_context),
mock.patch.object(bambu.socket, "create_connection", return_value=FakeSocket()),
):
result = bambu.publish_mqtt(args, "device/00M00A000000000/request", {"print": {}})
self.assertTrue(result["puback_received"])
self.assertIn(b"\x32", bytes(sent))
def test_mqtt_packet_encoding(self) -> None:
self.assertEqual(bambu.encode_remaining_length(0), b"\x00")
self.assertEqual(bambu.encode_remaining_length(321), b"\xc1\x02")
packet = bambu.mqtt_publish_packet("device/abc/request", json.dumps({"print": {}}).encode())
self.assertEqual(packet[0], 0x30)
self.assertIn(b"device/abc/request", packet)
qos_packet = bambu.mqtt_publish_packet(
"device/abc/request",
json.dumps({"print": {}}).encode(),
qos=1,
packet_id=7,
)
self.assertEqual(qos_packet[0], 0x32)
self.assertIn(b"\x00\x07", qos_packet)
subscribe = bambu.mqtt_subscribe_packet(1, "device/abc/report")
self.assertEqual(subscribe[0], 0x82)
self.assertIn(b"device/abc/report", subscribe)
def test_status_command_uses_configured_printer_report_topic(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = root / "printers.json"
config.write_text(
json.dumps(
{
"printers": {
"a1-mini": {
"host": "192.168.1.34",
"access_code": "12345678",
"serial": "0309CA4C0901107",
}
}
}
),
encoding="utf-8",
)
args = bambu.parse_args(["status", "--config", str(config), "--printer", "a1-mini", "--wait-seconds", "0.1"])
with mock.patch.object(
bambu,
"subscribe_mqtt_reports",
return_value=[{"topic": "device/0309CA4C0901107/report", "json": {"print": {"gcode_state": "RUNNING"}}}],
) as mocked_status, mock.patch("builtins.print") as mocked_print:
code = bambu.status_main(args)
self.assertEqual(code, 0)
mocked_status.assert_called_once()
self.assertEqual(mocked_status.call_args.args[1], "device/0309CA4C0901107/report")
payload = json.loads(mocked_print.call_args.args[0])
self.assertTrue(payload["ok"])
def test_clear_error_dry_run_builds_clean_print_error_payload(self) -> None:
args = bambu.parse_args(
[
"clear-error",
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--sequence-id",
"seq-1",
]
)
plan = bambu.build_clear_error_plan(args)
self.assertTrue(plan["dry_run"])
self.assertEqual(plan["mqtt"]["topic"], "device/00M00A000000000/request")
self.assertEqual(plan["mqtt"]["payload"]["print"]["command"], "clean_print_error")
self.assertEqual(plan["mqtt"]["payload"]["print"]["sequence_id"], "seq-1")
def test_clear_error_execute_publishes_without_start_confirmation(self) -> None:
args = bambu.parse_args(
[
"clear-error",
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--access-code",
"12345678",
"--execute",
]
)
with mock.patch.object(bambu, "publish_mqtt", return_value={"puback_received": True}) as mocked_publish, mock.patch(
"builtins.print"
) as mocked_print:
code = bambu.clear_error_main(args)
self.assertEqual(code, 0)
mocked_publish.assert_called_once()
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["executed"], ["publish_clean_print_error"])
self.assertEqual(payload["mqtt"]["payload"]["print"]["command"], "clean_print_error")
def test_pause_dry_run_builds_pause_payload(self) -> None:
args = bambu.parse_args(
[
"pause",
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--sequence-id",
"seq-1",
]
)
plan = bambu.build_print_control_plan(args)
self.assertTrue(plan["dry_run"])
self.assertEqual(plan["action"], "pause")
self.assertEqual(plan["mqtt"]["topic"], "device/00M00A000000000/request")
self.assertFalse(plan["mqtt"]["will_publish"])
payload = plan["mqtt"]["payload"]["print"]
self.assertEqual(payload["command"], "pause")
self.assertEqual(payload["sequence_id"], "seq-1")
def test_cancel_dry_run_maps_to_bambu_stop_payload(self) -> None:
args = bambu.parse_args(
[
"cancel",
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--sequence-id",
"seq-2",
]
)
plan = bambu.build_print_control_plan(args)
self.assertTrue(plan["dry_run"])
self.assertEqual(plan["action"], "cancel")
payload = plan["mqtt"]["payload"]["print"]
self.assertEqual(payload["command"], "stop")
self.assertEqual(payload["param"], "")
self.assertEqual(payload["sequence_id"], "seq-2")
def test_cancel_execute_requires_cancel_confirmation(self) -> None:
args = bambu.parse_args(
[
"cancel",
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--access-code",
"12345678",
"--execute",
]
)
with self.assertRaisesRegex(bambu.BambuPrintError, "--confirm-cancel-print"):
bambu.print_control_main(args)
def test_cancel_execute_publishes_stop_after_confirmation(self) -> None:
args = bambu.parse_args(
[
"cancel",
"--host",
"192.168.1.50",
"--serial",
"00M00A000000000",
"--access-code",
"12345678",
"--execute",
"--confirm-cancel-print",
]
)
with mock.patch.object(bambu, "publish_mqtt", return_value={"puback_received": True}) as mocked_publish, mock.patch(
"builtins.print"
) as mocked_print:
code = bambu.print_control_main(args)
self.assertEqual(code, 0)
mocked_publish.assert_called_once()
payload = json.loads(mocked_print.call_args.args[0])
self.assertEqual(payload["executed"], ["publish_cancel_request"])
self.assertEqual(payload["mqtt"]["payload"]["print"]["command"], "stop")
def test_formats_hms_codes_from_status_payload(self) -> None:
message = {"json": {"print": {"hms": [{"attr": 83887360, "code": 65543, "action": 0}]}}}
bambu.annotate_hms_codes(message)
self.assertEqual(message["hms"][0]["hms_code"], "0500-0500-0001-0007")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,4 @@
from tests.python.support.paths import add_repo_path
add_repo_path("skills/cad/scripts")
add_repo_path("skills/cad/scripts/packages/cadpy/src")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
import json
import shutil
import struct
import unittest
from pathlib import Path
from unittest import mock
import build123d
from cadpy import assembly_export as assembly_export_module
from cadpy.assembly_export import build_assembly_compound, export_assembly_step_scene
from cadpy.assembly_spec import AssemblyInstanceSpec, AssemblyNodeSpec, AssemblySpec
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.glb_mesh_payload import DEFAULT_MATERIAL, scene_glb_mesh_payload
from cadpy.render import part_glb_path
from cadpy.step_scene import (
SelectorProfile,
_apply_transform_point,
_bbox_from_shape,
extract_selectors_from_scene,
mesh_step_scene,
scene_leaf_occurrences,
scene_occurrence_shape,
_shape_hash,
)
from tests.python.support.cad_test_roots import IsolatedCadRoots
IDENTITY_TRANSFORM = (1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)
TRANSLATED_TRANSFORM = (1.0, 0.0, 0.0, 4.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0)
def _pad4(payload: bytes, *, byte: bytes = b"\0") -> bytes:
padding = (4 - (len(payload) % 4)) % 4
return payload + (byte * padding)
def _write_topology_glb(path: Path, manifest: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
manifest_payload = json.dumps({"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, "profile": "index", **manifest}, separators=(",", ":")).encode("utf-8")
display_manifest_payload = json.dumps(
{
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "surface-edges",
"stepHash": "",
"halfEdgesView": "surfaceHalfEdges",
"buffers": {"views": {"surfaceHalfEdges": {"dtype": "uint32", "bufferView": 1, "byteOffset": 0, "byteLength": 0, "count": 0, "itemSize": 4}}},
},
separators=(",", ":"),
).encode("utf-8")
display_offset = len(_pad4(manifest_payload))
binary = _pad4(manifest_payload) + _pad4(display_manifest_payload)
gltf = {
"asset": {"version": "2.0"},
"buffers": [{"byteLength": len(binary)}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": len(manifest_payload)},
{"buffer": 0, "byteOffset": display_offset, "byteLength": len(display_manifest_payload)},
],
"extensionsUsed": ["STEP_topology"],
"extensions": {
"STEP_topology": {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"indexView": 0,
"edgeView": 1,
"encoding": "utf-8",
}
},
}
json_chunk = _pad4(json.dumps(gltf, separators=(",", ":")).encode("utf-8"), byte=b" ")
path.write_bytes(
b"glTF"
+ struct.pack("<II", 2, 12 + 8 + len(json_chunk) + 8 + len(binary))
+ struct.pack("<I4s", len(json_chunk), b"JSON")
+ json_chunk
+ struct.pack("<I4s", len(binary), b"BIN\0")
+ binary
)
class AssemblyExportTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="assembly-export-")
self.cad_root = self._isolated_roots.cad_root
def tearDown(self) -> None:
shutil.rmtree(self._isolated_roots.root, ignore_errors=True)
def _write_part(self) -> Path:
step_path = self.cad_root / "STEP" / "leaf.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
build123d.export_step(build123d.Box(1, 1, 1), step_path)
return step_path
def _write_colored_part(self) -> Path:
step_path = self.cad_root / "STEP" / "leaf.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
box = build123d.Box(1, 1, 1)
box.color = build123d.Color(1, 0, 0, 1)
build123d.export_step(box, step_path)
return step_path
def _write_native_assembly_part(self, *, write_topology: bool = False) -> Path:
step_path = self.cad_root / "STEP" / "native_module.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
left = build123d.Box(1, 1, 1)
left.label = "left"
right = build123d.Box(1, 1, 1).moved(build123d.Location((10, 0, 0)))
right.label = "right"
module = build123d.Compound(children=[left, right], label="native_module")
build123d.export_step(module, step_path)
if write_topology:
_write_topology_glb(part_glb_path(step_path), {"assembly": {"root": {"children": [{}]}}})
return step_path
def _assembly_spec(self, *instances: AssemblyInstanceSpec) -> AssemblySpec:
assembly_path = self.cad_root / "STEP" / "assembly.py"
assembly_path.parent.mkdir(parents=True, exist_ok=True)
return AssemblySpec(
assembly_path=assembly_path,
instances=instances,
)
def _leaf_instance(
self,
*,
instance_id: str = "leaf",
transform: tuple[float, ...] = IDENTITY_TRANSFORM,
use_source_colors: bool = True,
) -> AssemblyInstanceSpec:
return AssemblyInstanceSpec(
instance_id=instance_id,
source_path=(self.cad_root / "STEP" / "leaf.step").resolve(),
path="leaf.step",
name=instance_id,
transform=transform,
use_source_colors=use_source_colors,
)
def _transformed_payload_bbox(self, scene, node) -> dict[str, list[float]]:
payload = scene_glb_mesh_payload(
scene,
node.prototype_key,
default_color=DEFAULT_MATERIAL,
suppress_face_colors=True,
)
points = []
for x in (payload.minimum[0] * 1000.0, payload.maximum[0] * 1000.0):
for y in (payload.minimum[1] * 1000.0, payload.maximum[1] * 1000.0):
for z in (payload.minimum[2] * 1000.0, payload.maximum[2] * 1000.0):
points.append(_apply_transform_point(node.transform, [x, y, z]))
return {
"min": [min(point[index] for point in points) for index in range(3)],
"max": [max(point[index] for point in points) for index in range(3)],
}
def _assert_bbox_close(self, actual: dict[str, list[float]], expected: dict[str, list[float]]) -> None:
for key in ("min", "max"):
for actual_value, expected_value in zip(actual[key], expected[key], strict=True):
self.assertAlmostEqual(actual_value, expected_value, places=3)
def test_imported_part_does_not_read_persistent_source_color(self) -> None:
self._write_part()
assembly_spec = self._assembly_spec(self._leaf_instance())
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertIsNone(assembly.children[0].color)
def test_instance_can_suppress_embedded_source_color(self) -> None:
self._write_colored_part()
assembly_spec = self._assembly_spec(self._leaf_instance(use_source_colors=False))
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertIsNone(assembly.children[0].color)
def test_repeated_part_instances_keep_distinct_occurrence_names(self) -> None:
self._write_part()
assembly_spec = self._assembly_spec(
self._leaf_instance(instance_id="leaf_a"),
self._leaf_instance(instance_id="leaf_b", transform=TRANSLATED_TRANSFORM),
)
assembly_path = assembly_spec.assembly_path
output_path = assembly_path.with_suffix(".step")
scene = export_assembly_step_scene(assembly_spec, output_path)
bundle = extract_selectors_from_scene(
scene,
cad_ref="assemblies/assembly",
profile=SelectorProfile.SUMMARY,
)
columns = bundle.manifest["tables"]["occurrenceColumns"]
source_names = {
dict(zip(columns, row))["sourceName"]
for row in bundle.manifest["occurrences"]
}
self.assertIn("leaf_a", source_names)
self.assertIn("leaf_b", source_names)
self.assertEqual(output_path.resolve(), scene.step_path)
self.assertIsNotNone(scene.doc)
def test_recursive_children_keep_subassembly_labels(self) -> None:
self._write_part()
assembly_spec = AssemblySpec(
assembly_path=self.cad_root / "STEP" / "assembly.py",
instances=(),
children=(
AssemblyNodeSpec(
instance_id="module",
name="module",
transform=IDENTITY_TRANSFORM,
children=(
AssemblyNodeSpec(
instance_id="leaf",
name="leaf",
source_path=(self.cad_root / "STEP" / "leaf.step").resolve(),
path="leaf.step",
transform=IDENTITY_TRANSFORM,
),
),
),
),
)
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertEqual("module", assembly.children[0].label)
self.assertEqual("module__leaf", assembly.children[0].children[0].label)
def test_direct_export_recursive_children_keep_occurrence_names(self) -> None:
self._write_part()
assembly_spec = AssemblySpec(
assembly_path=self.cad_root / "STEP" / "assembly.py",
instances=(),
children=(
AssemblyNodeSpec(
instance_id="module",
name="module",
transform=IDENTITY_TRANSFORM,
children=(
AssemblyNodeSpec(
instance_id="leaf",
name="leaf",
source_path=(self.cad_root / "STEP" / "leaf.step").resolve(),
path="leaf.step",
transform=IDENTITY_TRANSFORM,
),
),
),
),
)
scene = export_assembly_step_scene(assembly_spec, assembly_spec.assembly_path.with_suffix(".step"))
bundle = extract_selectors_from_scene(
scene,
cad_ref="assemblies/assembly",
profile=SelectorProfile.SUMMARY,
)
columns = bundle.manifest["tables"]["occurrenceColumns"]
source_names = {
dict(zip(columns, row))["sourceName"]
for row in bundle.manifest["occurrences"]
}
self.assertIn("module", source_names)
self.assertIn("module__leaf", source_names)
def test_direct_export_preserves_imported_step_assembly_child_locations(self) -> None:
native_step = self._write_native_assembly_part()
assembly_spec = self._assembly_spec(
AssemblyInstanceSpec(
instance_id="native",
source_path=native_step.resolve(),
path="native_module.step",
name="native",
transform=IDENTITY_TRANSFORM,
),
)
scene = export_assembly_step_scene(assembly_spec, assembly_spec.assembly_path.with_suffix(".step"))
native_root = scene.roots[0].children[0].children[0]
child_x_offsets = sorted(round(float(child.local_transform[3]), 3) for child in native_root.children)
self.assertEqual([0.0, 10.0], child_x_offsets)
def test_imported_step_assembly_glb_payload_does_not_double_apply_child_location(self) -> None:
native_step = self._write_native_assembly_part()
assembly_spec = self._assembly_spec(
AssemblyInstanceSpec(
instance_id="native",
source_path=native_step.resolve(),
path="native_module.step",
name="native",
transform=IDENTITY_TRANSFORM,
),
)
scene = export_assembly_step_scene(assembly_spec, assembly_spec.assembly_path.with_suffix(".step"))
mesh_step_scene(scene, linear_deflection=0.006, angular_deflection=0.6, relative=True)
leaf_bboxes = [
(node, _bbox_from_shape(scene_occurrence_shape(scene, node)))
for node in scene_leaf_occurrences(scene)
if node.prototype_key is not None
]
self.assertEqual(2, len(leaf_bboxes))
right_box_leaf, expected_bbox = max(
leaf_bboxes,
key=lambda item: item[1]["max"][0],
)
payload_bbox = self._transformed_payload_bbox(scene, right_box_leaf)
self.assertGreater(expected_bbox["min"][0], 9.0)
self.assertLess(expected_bbox["max"][0], 11.0)
self._assert_bbox_close(payload_bbox, {"min": expected_bbox["min"], "max": expected_bbox["max"]})
def test_cached_compound_copy_preserves_parent_transform(self) -> None:
leaf = build123d.Box(1, 1, 1)
leaf.label = "leaf"
compound = build123d.Compound(children=[leaf], label="module")
moved = compound.moved(build123d.Location((10, 0, 0)))
copied = assembly_export_module._copy_cached_shape_tree(moved)
self.assertAlmostEqual(10.0, copied.center().X, places=6)
def test_catalog_index_is_reused_for_repeated_instances(self) -> None:
self._write_part()
assembly_spec = self._assembly_spec(
self._leaf_instance(instance_id="leaf_a"),
self._leaf_instance(instance_id="leaf_b", transform=TRANSLATED_TRANSFORM),
)
with mock.patch.object(
assembly_export_module,
"iter_cad_sources",
wraps=assembly_export_module.iter_cad_sources,
) as iter_cad_sources:
assembly = build_assembly_compound(assembly_spec, label="assembly")
self.assertEqual(1, iter_cad_sources.call_count)
self.assertEqual(["leaf_a", "leaf_b"], [child.label for child in assembly.children])
def test_direct_export_propagates_leaf_shape_label_to_step_product(self) -> None:
"""Regression: leaf-Part definitions in the assembly STEP must carry
the shape label, not the generic 'SOLID' OCCT fallback. Without
``_set_label_name`` on the leaf branch of ``_shape_definition_for_tree``,
a CAD UI such as Fusion shows every single-Part component as ``solid``.
"""
leaf_label = "leaf_box_product"
step_path = self.cad_root / "STEP" / f"{leaf_label}.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
box = build123d.Box(1, 1, 1)
box.label = leaf_label
build123d.export_step(box, step_path)
assembly_spec = self._assembly_spec(
AssemblyInstanceSpec(
instance_id="leaf",
source_path=step_path.resolve(),
path=f"{leaf_label}.step",
name="leaf",
transform=IDENTITY_TRANSFORM,
),
)
output_path = assembly_spec.assembly_path.with_suffix(".step")
export_assembly_step_scene(assembly_spec, output_path)
contents = output_path.read_bytes()
self.assertIn(f"PRODUCT('{leaf_label}'".encode("ascii"), contents)
self.assertNotIn(b"PRODUCT('SOLID'", contents)
def test_direct_writer_reuses_quantity_colors_for_tuple_rgba(self) -> None:
assembly_spec = self._assembly_spec()
writer = assembly_export_module._DirectXcafAssemblyWriter(assembly_spec, label="assembly")
captured_colors: list[object] = []
class FakeColorTool:
def SetColor(self, label, wrapped, color_type):
captured_colors.append(wrapped)
writer.color_tool = FakeColorTool()
writer._set_label_color(object(), (0.1, 0.2, 0.3, 1.0))
writer._set_label_color(object(), (0.1, 0.2, 0.3, 1.0))
self.assertEqual(2, len(captured_colors))
self.assertIs(captured_colors[0], captured_colors[1])
def test_direct_writer_applies_step_scene_face_colors_to_prototype_shapes(self) -> None:
assembly_spec = self._assembly_spec()
writer = assembly_export_module._DirectXcafAssemblyWriter(assembly_spec, label="assembly")
captured_colors: list[object] = []
class FakeColorTool:
def SetColor(self, label, wrapped, color_type):
captured_colors.append(wrapped)
writer.color_tool = FakeColorTool()
shape = build123d.Box(1, 1, 1).wrapped
shape_label = writer.shape_tool.AddShape(shape, False)
face_hashes = []
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current())))
explorer.Next()
writer._set_shape_face_colors(shape_label, shape, {face_hashes[0]: (0.0, 0.0, 1.0, 1.0)})
self.assertEqual(1, len(captured_colors))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,173 @@
import shutil
import unittest
from pathlib import Path
from cadpy import assembly_flatten
from cadpy import assembly_spec
from cadpy.render import part_glb_path
from tests.python.support.cad_test_roots import IsolatedCadRoots
def _translation(x: float, y: float, z: float) -> list[float]:
return [
1.0,
0.0,
0.0,
x,
0.0,
1.0,
0.0,
y,
0.0,
0.0,
1.0,
z,
0.0,
0.0,
0.0,
1.0,
]
IDENTITY_TRANSFORM = _translation(0.0, 0.0, 0.0)
class AssemblyFlattenTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="assembly_flatten-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-assembly_flatten-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(assembly_spec.CAD_ROOT).as_posix()
self.render_paths: list[Path] = []
def tearDown(self) -> None:
for render_path in self.render_paths:
render_path.unlink(missing_ok=True)
shutil.rmtree(self.temp_root, ignore_errors=True)
self._tempdir.cleanup()
def _cad_ref(self, name: str) -> str:
return f"{self.relative_dir}/{name}"
def _write_part(self, name: str) -> None:
step_path = self.temp_root / f"{name}.step"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
glb_path = part_glb_path(step_path)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
self.render_paths.append(glb_path)
def _write_assembly(self, name: str, *, instances: list[dict[str, object]]) -> None:
(self.temp_root / f"{name}.py").write_text(
"\n".join(
[
"def gen_step():",
f" return {{'instances': {instances!r}}}",
"",
]
)
)
def test_flatten_source_path_resolves_python_assembly_parts(self) -> None:
self._write_part("part-a")
self._write_part("part-b")
self._write_assembly(
"root-assembly",
instances=[
{
"path": "part-a.step",
"name": "left-leaf",
"transform": _translation(1.0, 2.0, 3.0),
},
{
"path": "part-b.step",
"name": "right-leaf",
"transform": _translation(0.0, 5.0, 0.0),
},
],
)
resolved = assembly_flatten.flatten_source_path(self.temp_root / "root-assembly.py")
self.assertEqual(2, len(resolved))
self.assertEqual(
[
("left-leaf",),
("right-leaf",),
],
[part.instance_path for part in resolved],
)
self.assertEqual(
[
self._cad_ref("part-a"),
self._cad_ref("part-b"),
],
[part.cad_ref for part in resolved],
)
self.assertEqual("left-leaf", resolved[0].name)
self.assertEqual("right-leaf", resolved[1].name)
self.assertEqual(
tuple(_translation(1.0, 2.0, 3.0)),
resolved[0].transform,
)
self.assertEqual(
tuple(_translation(0.0, 5.0, 0.0)),
resolved[1].transform,
)
def test_flatten_source_path_resolves_paths_relative_to_assembly_source(self) -> None:
step_dir = self.temp_root / "parts"
step_dir.mkdir(parents=True)
step_path = step_dir / "part.step"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
glb_path = part_glb_path(step_path)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
self.render_paths.append(glb_path)
(step_dir / "sample_assembly.py").write_text(
"\n".join(
[
"def gen_step():",
" return {'instances': [",
" {'path': 'part.step', 'name': 'leaf', 'transform': "
f"{IDENTITY_TRANSFORM!r}}}",
" ]}",
"",
]
),
encoding="utf-8",
)
resolved = assembly_flatten.flatten_source_path(step_dir / "sample_assembly.py")
self.assertEqual(1, len(resolved))
self.assertEqual(f"{self.relative_dir}/parts/part", resolved[0].cad_ref)
self.assertEqual(step_path.resolve(), resolved[0].step_path.resolve())
self.assertEqual(part_glb_path(step_path).resolve(), resolved[0].glb_path.resolve())
def test_flatten_source_path_rejects_duplicate_instance_names(self) -> None:
self._write_part("part")
self._write_assembly(
"assembly",
instances=[
{
"path": "part.step",
"name": "duplicate",
"transform": IDENTITY_TRANSFORM,
},
{
"path": "part.step",
"name": "duplicate",
"transform": IDENTITY_TRANSFORM,
},
],
)
with self.assertRaisesRegex(assembly_flatten.AssemblyResolutionError, "duplicates"):
assembly_flatten.flatten_source_path(self.temp_root / "assembly.py")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,120 @@
import shutil
import unittest
from cadpy.assembly_spec import (
IDENTITY_TRANSFORM,
AssemblySpecError,
assembly_spec_from_payload,
)
from tests.python.support.cad_test_roots import IsolatedCadRoots
class AssemblySpecTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="assembly-spec-")
self.cad_root = self._isolated_roots.cad_root
self.assembly_path = self.cad_root / "STEP" / "assembly.py"
self.assembly_path.parent.mkdir(parents=True, exist_ok=True)
self.leaf_step = self.assembly_path.parent / "leaf.step"
self.leaf_step.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
def tearDown(self) -> None:
shutil.rmtree(self._isolated_roots.root, ignore_errors=True)
def test_flat_instances_are_normalized_to_root_children(self) -> None:
spec = assembly_spec_from_payload(
self.assembly_path,
{
"instances": [
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
}
]
},
)
self.assertEqual(1, len(spec.instances))
self.assertEqual(1, len(spec.children))
self.assertEqual("leaf", spec.children[0].instance_id)
self.assertEqual(self.leaf_step.resolve(), spec.children[0].source_path)
def test_nested_children_parse_to_leaf_instances(self) -> None:
spec = assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"name": "module",
"transform": list(IDENTITY_TRANSFORM),
"children": [
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
}
],
}
]
},
)
self.assertEqual(1, len(spec.children))
self.assertEqual("module", spec.children[0].instance_id)
self.assertEqual(1, len(spec.children[0].children))
self.assertEqual(("leaf",), tuple(instance.instance_id for instance in spec.instances))
def test_rejects_duplicate_sibling_names(self) -> None:
with self.assertRaisesRegex(AssemblySpecError, "duplicates 'leaf'"):
assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
},
{
"path": "leaf.step",
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
},
]
},
)
def test_rejects_empty_subassembly_children(self) -> None:
with self.assertRaisesRegex(AssemblySpecError, "children\\[1\\]\\.children must be a non-empty array"):
assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"name": "module",
"transform": list(IDENTITY_TRANSFORM),
"children": [],
}
]
},
)
def test_rejects_absolute_paths(self) -> None:
with self.assertRaisesRegex(AssemblySpecError, "must be a relative STEP path"):
assembly_spec_from_payload(
self.assembly_path,
{
"children": [
{
"path": str(self.leaf_step.resolve()),
"name": "leaf",
"transform": list(IDENTITY_TRANSFORM),
}
]
},
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,423 @@
import json
import struct
import unittest
from array import array
from pathlib import Path
import build123d
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from cadpy.glb import (
export_assembly_glb_from_scene,
export_native_glb_from_scene,
export_part_glb_from_scene,
read_step_display_edge_manifest_from_glb,
read_step_topology_bundle_from_glb,
read_step_topology_manifest_from_glb,
)
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION, glb_surface_edge_class_has_nonzero_values
from cadpy.step_scene import (
LoadedStepScene,
OccurrenceNode,
SelectorBundle,
SelectorOptions,
SelectorProfile,
_shape_hash,
extract_selectors_from_scene,
load_step_scene,
)
from tests.python.support.tmp_root import temporary_directory
def _identity_transform() -> tuple[float, ...]:
return (
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
)
def _meshed_box(width: float = 10, depth: float = 20, height: float = 30) -> object:
shape = build123d.Box(width, depth, height).wrapped
BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform()
return shape
def _single_leaf_scene(shape: object, **kwargs) -> tuple[LoadedStepScene, int]:
prototype_key = _shape_hash(shape)
scene = LoadedStepScene(
step_path=Path("fixture.step"),
roots=[
OccurrenceNode(
path=(1,),
name="fixture",
source_name="fixture",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=prototype_key,
)
],
prototype_shapes={prototype_key: shape},
prototype_names={prototype_key: "fixture"},
**kwargs,
)
return scene, prototype_key
def _read_glb_json(path: Path) -> dict[str, object]:
payload = path.read_bytes()
magic, version, _length = struct.unpack_from("<III", payload, 0)
if magic != 0x46546C67 or version != 2:
raise AssertionError("Not a GLB v2 file")
chunk_length, chunk_type = struct.unpack_from("<I4s", payload, 12)
if chunk_type != b"JSON":
raise AssertionError("First GLB chunk is not JSON")
return json.loads(payload[20:20 + chunk_length].decode("utf-8"))
class GlbExportTests(unittest.TestCase):
def test_part_glb_embeds_step_topology_extension(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
scene, _prototype_key = _single_leaf_scene(_meshed_box())
bundle = SelectorBundle(
manifest={
"schemaVersion": 1,
"profile": "artifact",
"stepPath": "fixture.step",
"stepHash": "step-hash-123",
"tables": {},
"occurrences": [],
"shapes": [],
"faces": [],
"edges": [],
},
buffers={"edgeIds": array("I", [7, 11]), "surfaceHalfEdges": array("I")},
)
glb_path = export_part_glb_from_scene(
step_path,
scene,
linear_deflection=0.1,
angular_deflection=0.1,
selector_bundle=bundle,
)
gltf = _read_glb_json(glb_path)
self.assertIn("STEP_topology", gltf.get("extensionsUsed", []))
self.assertIn("STEP_topology", gltf.get("extensions", {}))
extension = gltf.get("extensions", {}).get("STEP_topology", {})
self.assertIn("indexView", extension)
self.assertIn("edgeView", extension)
self.assertIn("selectorView", extension)
index = read_step_topology_manifest_from_glb(glb_path)
self.assertIsNotNone(index)
assert index is not None
self.assertEqual("index", index.get("profile"))
self.assertEqual("part", index.get("entryKind"))
self.assertNotIn("cadRef", index)
self.assertNotIn("cadPath", index)
surface_edges = read_step_display_edge_manifest_from_glb(glb_path)
self.assertIsNotNone(surface_edges)
assert surface_edges is not None
self.assertEqual("surface-edges", surface_edges.get("profile"))
self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, surface_edges.get("schemaVersion"))
self.assertEqual("surfaceHalfEdges", surface_edges.get("halfEdgesView"))
self.assertIn("surfaceHalfEdges", surface_edges.get("buffers", {}).get("views", {}))
self.assertNotIn("faces", surface_edges)
self.assertNotIn("relations", surface_edges)
embedded = read_step_topology_bundle_from_glb(glb_path)
self.assertIsNotNone(embedded)
assert embedded is not None
self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, embedded.manifest["schemaVersion"])
self.assertNotIn("cadRef", embedded.manifest)
self.assertNotIn("cadPath", embedded.manifest)
self.assertEqual([7, 11], list(embedded.buffers["edgeIds"]))
def test_step_glb_surface_edge_class_attribute_is_populated_from_selector_topology(self) -> None:
with temporary_directory(prefix="cad-glb-surface-edges-") as temp_dir:
step_path = Path(temp_dir) / "cylinder.step"
build123d.export_step(build123d.Cylinder(5, 10), step_path)
scene = load_step_scene(step_path)
bundle = extract_selectors_from_scene(
scene,
profile=SelectorProfile.ARTIFACT,
options=SelectorOptions(linear_deflection=0.2, angular_deflection=0.2),
)
glb_path = export_part_glb_from_scene(
step_path,
scene,
linear_deflection=0.2,
angular_deflection=0.2,
selector_bundle=bundle,
)
self.assertTrue(glb_surface_edge_class_has_nonzero_values(glb_path))
def test_legacy_topology_json_and_bin_bundle_is_read_when_glb_lacks_extension(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
artifact_dir = Path(temp_dir) / ".fixture.step"
artifact_dir.mkdir()
glb_path = artifact_dir / "model.glb"
glb_path.write_bytes(b"legacy visual glb without embedded topology")
edge_ids = array("I", [19, 23])
edge_bytes = edge_ids.tobytes()
(artifact_dir / "topology.bin").write_bytes(edge_bytes)
(artifact_dir / "topology.json").write_text(
json.dumps(
{
"schemaVersion": 1,
"profile": "artifact",
"cadRef": "fixtures/legacy",
"buffers": {
"uri": "topology.bin",
"littleEndian": True,
"views": {
"edgeIds": {
"dtype": "uint32",
"byteOffset": 0,
"byteLength": len(edge_bytes),
"count": 2,
"itemSize": 4,
}
},
},
}
),
encoding="utf-8",
)
index = read_step_topology_manifest_from_glb(glb_path)
self.assertIsNotNone(index)
assert index is not None
self.assertEqual("fixtures/legacy", index.get("cadRef"))
bundle = read_step_topology_bundle_from_glb(glb_path)
self.assertIsNotNone(bundle)
assert bundle is not None
self.assertEqual("fixtures/legacy", bundle.manifest.get("cadRef"))
self.assertEqual([19, 23], list(bundle.buffers["edgeIds"]))
def test_assembly_glb_preserves_face_colors_as_material_primitives(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
shape = _meshed_box()
face_hashes: list[int] = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More() and len(face_hashes) < 2:
face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current())))
explorer.Next()
scene, prototype_key = _single_leaf_scene(
shape,
prototype_face_colors={
_shape_hash(shape): {
face_hashes[0]: (1.0, 0.0, 0.0, 1.0),
face_hashes[1]: (0.0, 0.0, 1.0, 1.0),
}
},
)
glb_path = export_assembly_glb_from_scene(
step_path,
scene,
linear_deflection=0.1,
angular_deflection=0.1,
)
gltf = _read_glb_json(glb_path)
colors = [
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
for material in gltf.get("materials", [])
]
primitives = gltf.get("meshes", [])[0].get("primitives", [])
primitive_materials = {primitive.get("material") for primitive in primitives}
primitive_attributes = [primitive.get("attributes", {}) for primitive in primitives]
self.assertEqual(prototype_key, _shape_hash(shape))
self.assertIn([1.0, 0.0, 0.0, 1.0], colors)
self.assertIn([0.0, 0.0, 1.0, 1.0], colors)
self.assertGreaterEqual(len(primitive_materials), 2)
self.assertTrue(all("NORMAL" in attributes for attributes in primitive_attributes))
def test_assembly_glb_applies_occurrence_color_to_descendant_meshes(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
shape = _meshed_box()
prototype_key = _shape_hash(shape)
scene = LoadedStepScene(
step_path=step_path,
roots=[
OccurrenceNode(
path=(1,),
name="assembly",
source_name="assembly",
transform=_identity_transform(),
prototype_key=None,
local_transform=_identity_transform(),
children=(
OccurrenceNode(
path=(1, 1),
name="part",
source_name="part",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=prototype_key,
),
),
)
],
prototype_shapes={prototype_key: shape},
prototype_names={prototype_key: "fixture"},
)
glb_path = export_assembly_glb_from_scene(
step_path,
scene,
linear_deflection=0.1,
angular_deflection=0.1,
occurrence_colors={"o1": (0.1, 0.2, 0.3, 1.0)},
)
gltf = _read_glb_json(glb_path)
colors = [
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
for material in gltf.get("materials", [])
]
self.assertIn([0.1, 0.2, 0.3, 1.0], colors)
def test_part_glb_uses_scene_material_colors(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
shape = _meshed_box()
scene, prototype_key = _single_leaf_scene(
shape,
prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)},
)
glb_path = export_part_glb_from_scene(
step_path,
scene,
linear_deflection=0.1,
angular_deflection=0.1,
)
gltf = _read_glb_json(glb_path)
colors = [
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
for material in gltf.get("materials", [])
]
self.assertEqual(prototype_key, _shape_hash(shape))
self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors)
def test_native_glb_is_y_up_without_step_topology_or_cad_extras(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
shape = _meshed_box(width=10, depth=20, height=30)
scene, _prototype_key = _single_leaf_scene(
shape,
prototype_colors={_shape_hash(shape): (0.168627, 0.184314, 0.2, 1.0)},
)
glb_path = export_native_glb_from_scene(
step_path,
scene,
target_path=Path(temp_dir) / "fixture.glb",
linear_deflection=0.1,
angular_deflection=0.1,
)
gltf = _read_glb_json(glb_path)
self.assertNotIn("extensionsUsed", gltf)
self.assertNotIn("extensions", gltf)
self.assertTrue(all("extras" not in node for node in gltf.get("nodes", [])))
position_accessor = gltf["accessors"][0]
self.assertEqual([-0.005, -0.015, -0.01], [round(value, 6) for value in position_accessor["min"]])
self.assertEqual([0.005, 0.015, 0.01], [round(value, 6) for value in position_accessor["max"]])
colors = [
material.get("pbrMetallicRoughness", {}).get("baseColorFactor")
for material in gltf.get("materials", [])
]
self.assertIn([0.168627, 0.184314, 0.2, 1.0], colors)
def test_native_glb_converts_assembly_transforms_to_y_up(self) -> None:
with temporary_directory(prefix="cad-glb-test-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
shape = _meshed_box()
prototype_key = _shape_hash(shape)
translated = (
1.0,
0.0,
0.0,
10.0,
0.0,
1.0,
0.0,
20.0,
0.0,
0.0,
1.0,
30.0,
0.0,
0.0,
0.0,
1.0,
)
scene = LoadedStepScene(
step_path=step_path,
roots=[
OccurrenceNode(
path=(1,),
name="assembly",
source_name="assembly",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=None,
children=(
OccurrenceNode(
path=(1, 1),
name="part",
source_name="part",
transform=translated,
local_transform=translated,
prototype_key=prototype_key,
),
),
)
],
prototype_shapes={prototype_key: shape},
prototype_names={prototype_key: "fixture"},
)
glb_path = export_native_glb_from_scene(
step_path,
scene,
target_path=Path(temp_dir) / "assembly.glb",
linear_deflection=0.1,
angular_deflection=0.1,
)
gltf = _read_glb_json(glb_path)
child = next(node for node in gltf["nodes"] if node.get("mesh") == 0)
self.assertEqual("part", child.get("name"))
self.assertEqual([0.01, 0.03, -0.02], [round(value, 6) for value in child["matrix"][12:15]])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
"""Selector-manifest entryKind stamping in the GLB STEP_topology writer."""
from __future__ import annotations
import json
import unittest
from cadpy.glb import _GlbBuilder
from cadpy.glb_topology import STEP_TOPOLOGY_EXTENSION
from cadpy.selector_types import SelectorBundle
def _decode_view(builder: _GlbBuilder, view_index: int) -> dict[str, object]:
views = builder.json["bufferViews"]
assert isinstance(views, list)
view = views[view_index]
start = int(view["byteOffset"])
length = int(view["byteLength"])
return json.loads(bytes(builder.binary[start : start + length]).decode("utf-8"))
def _bundle_manifest(*, assembly: bool) -> dict[str, object]:
manifest: dict[str, object] = {
"cadPath": "tmp/sample",
"stepPath": "tmp/sample.step",
"stepHash": "hash-123",
"sourceKind": "step",
"sourcePath": "tmp/sample.step",
"bbox": {"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 1.0]},
"stats": {"occurrenceCount": 3, "leafOccurrenceCount": 2},
}
if assembly:
manifest["assembly"] = {"id": "o1"}
return manifest
class GlbSelectorManifestEntryKindTests(unittest.TestCase):
def _write_step_topology(self, manifest: dict[str, object]) -> tuple[dict[str, object], dict[str, object]]:
builder = _GlbBuilder()
builder.add_step_topology(SelectorBundle(manifest=manifest), include_selector_topology=True)
extensions = builder.json["extensions"]
assert isinstance(extensions, dict)
extension = extensions[STEP_TOPOLOGY_EXTENSION]
selector_manifest = _decode_view(builder, extension["selectorView"])
return extension, selector_manifest
def test_assembly_manifest_stamps_selector_entry_kind(self) -> None:
extension, selector_manifest = self._write_step_topology(_bundle_manifest(assembly=True))
self.assertEqual("assembly", extension["entryKind"])
self.assertEqual("assembly", selector_manifest["entryKind"])
self.assertNotIn("assembly", selector_manifest)
def test_part_manifest_stamps_selector_entry_kind(self) -> None:
extension, selector_manifest = self._write_step_topology(_bundle_manifest(assembly=False))
self.assertEqual("part", extension["entryKind"])
self.assertEqual("part", selector_manifest["entryKind"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,69 @@
import tempfile
import unittest
from pathlib import Path
from cadpy import generation, step_artifacts
from cadpy.step_targets import ResolvedStepTarget
class StepArtifactsTests(unittest.TestCase):
def test_existing_step_target_ignores_python_source_for_glb_regeneration(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
step_path = root / "part.step"
source_path = root / "part.py"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
source_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
target = ResolvedStepTarget(
cad_path="part",
kind="part",
source_path=source_path,
step_path=step_path,
)
self.assertIsNone(step_artifacts._python_source_for_target(target))
def test_missing_logical_step_can_still_use_python_source(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
step_path = root / "part.step"
source_path = root / "part.py"
source_path.write_text("def gen_step():\n return None\n", encoding="utf-8")
target = ResolvedStepTarget(
cad_path="part",
kind="part",
source_path=step_path,
step_path=step_path,
)
self.assertEqual(step_artifacts._python_source_for_target(target), source_path)
def test_existing_step_spec_can_reuse_python_backed_glb_when_step_hash_matches(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
step_path = root / "part.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
spec = generation.EntrySpec(
source_ref="part.step",
cad_ref="part",
kind="part",
source_path=step_path,
display_name="part",
source="imported",
step_path=step_path,
)
self.assertFalse(generation._artifact_source_kind_matches_spec(spec, {"sourceKind": "python"}))
self.assertTrue(
generation._artifact_source_kind_matches_spec(
spec,
{
"sourceKind": "python",
"stepHash": generation.step_file_hash(step_path),
},
)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,80 @@
import unittest
from pathlib import Path
from cadpy.step_metadata import (
TEXT_TO_CAD_GENERATOR,
inject_text_to_cad_step_metadata,
read_text_to_cad_step_metadata,
)
from tests.python.support.tmp_root import temporary_directory
MINIMAL_STEP = """ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Open CASCADE Model'),'2;1');
ENDSEC;
DATA;
#1=PRODUCT_DEFINITION('design','',#2,#3);
#4=PRODUCT_DEFINITION_SHAPE('','',#1);
#5=SHAPE_REPRESENTATION('',(#6),#7);
#7=(GEOMETRIC_REPRESENTATION_CONTEXT(3) REPRESENTATION_CONTEXT('Context #1','3D'));
ENDSEC;
END-ISO-10303-21;
"""
class TextToCadStepMetadataTests(unittest.TestCase):
def test_injects_and_reads_text_to_cad_metadata(self) -> None:
with temporary_directory(prefix="cad-step-metadata-") as temp_dir:
step_path = Path(temp_dir) / "fixture.step"
step_path.write_text(MINIMAL_STEP, encoding="utf-8")
inject_text_to_cad_step_metadata(
step_path,
entry_kind="assembly",
source_hash="source-hash-123",
)
metadata = read_text_to_cad_step_metadata(step_path)
self.assertEqual(TEXT_TO_CAD_GENERATOR, metadata.get("generator"))
self.assertEqual("assembly", metadata.get("entryKind"))
self.assertEqual("source-hash-123", metadata.get("sourceHash"))
step_text = step_path.read_text(encoding="utf-8")
self.assertIn("PROPERTY_DEFINITION('cadpy metadata','cadpy:entryKind'", step_text)
def test_reads_tail_metadata_without_full_file_scan(self) -> None:
with temporary_directory(prefix="cad-step-metadata-tail-") as temp_dir:
step_path = Path(temp_dir) / "large-fixture.step"
metadata_block = "\n".join(
[
"#100=DESCRIPTIVE_REPRESENTATION_ITEM('cadpy:generator','cadpy');",
"#101=REPRESENTATION('cadpy:generator',(#100),#7);",
"#102=PROPERTY_DEFINITION('cadpy metadata','cadpy:generator',#1);",
"#103=PROPERTY_DEFINITION_REPRESENTATION(#102,#101);",
"#104=DESCRIPTIVE_REPRESENTATION_ITEM('cadpy:entryKind','assembly');",
"#105=REPRESENTATION('cadpy:entryKind',(#104),#7);",
"#106=PROPERTY_DEFINITION('cadpy metadata','cadpy:entryKind',#1);",
"#107=PROPERTY_DEFINITION_REPRESENTATION(#106,#105);",
"#112=DESCRIPTIVE_REPRESENTATION_ITEM('cadpy:sourceHash','source-hash-tail');",
"#113=REPRESENTATION('cadpy:sourceHash',(#112),#7);",
"#114=PROPERTY_DEFINITION('cadpy metadata','cadpy:sourceHash',#1);",
"#115=PROPERTY_DEFINITION_REPRESENTATION(#114,#113);",
]
)
step_path.write_text(
"ISO-10303-21;\nDATA;\n"
+ ("#9=PRODUCT('padding','padding','',(#7));\n" * 40000)
+ metadata_block
+ "\nENDSEC;\nEND-ISO-10303-21;\n",
encoding="utf-8",
)
metadata = read_text_to_cad_step_metadata(step_path)
self.assertEqual(TEXT_TO_CAD_GENERATOR, metadata.get("generator"))
self.assertEqual("assembly", metadata.get("entryKind"))
self.assertEqual("source-hash-tail", metadata.get("sourceHash"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,296 @@
import os
import unittest
from pathlib import Path
from unittest import mock
import build123d
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from cadpy import step_scene
from cadpy.step_scene import (
LoadedStepScene,
OccurrenceNode,
SelectorOptions,
SelectorProfile,
adaptive_mesh_resolution_for_scene,
extract_selectors_from_scene,
load_step_scene,
scene_occurrence_shape,
)
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE
from tests.python.support.tmp_root import temporary_directory
class StepSceneSelectorArtifactTests(unittest.TestCase):
def test_load_step_scene_cached_reuses_brep_scene_cache(self) -> None:
with temporary_directory(prefix="cad-step-scene-cache-") as temp_dir:
temp_root = Path(temp_dir)
step_path = temp_root / "box.step"
cache_dir = temp_root / "cache"
build123d.export_step(build123d.Box(1, 1, 1), step_path)
with mock.patch.dict(os.environ, {"TEXT_TO_CAD_STEP_SCENE_CACHE_DIR": str(cache_dir)}):
first = step_scene.load_step_scene_cached(step_path)
self.assertEqual(1, len(first.prototype_shapes))
with mock.patch.object(step_scene, "load_step_scene", side_effect=AssertionError("cache miss")):
cached = step_scene.load_step_scene_cached(step_path)
self.assertEqual(first.step_hash, cached.step_hash)
self.assertEqual(1, len(cached.roots))
self.assertEqual(1, len(cached.prototype_shapes))
self.assertFalse(scene_occurrence_shape(cached, cached.roots[0]).IsNull())
def test_step_scene_cache_restores_locations_and_face_color_hashes(self) -> None:
with temporary_directory(prefix="cad-step-scene-cache-private-") as temp_dir:
temp_root = Path(temp_dir)
shape = build123d.Box(1, 1, 1).wrapped
explorer = TopExp_Explorer(shape, TopAbs_FACE)
face_hash = step_scene._shape_hash(TopoDS.Face_s(explorer.Current()))
transform = (
1.0,
0.0,
0.0,
5.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
)
step_path = temp_root / "synthetic.step"
step_path.write_text("synthetic cache key only", encoding="utf-8")
scene = LoadedStepScene(
step_path=step_path,
roots=[
OccurrenceNode(
path=(1,),
name="moved_box",
source_name="box",
transform=transform,
local_transform=transform,
prototype_key=7,
)
],
prototype_shapes={7: shape},
prototype_face_colors={7: {face_hash: (1.0, 0.0, 0.0, 1.0)}},
)
cache_root = temp_root / "cache"
step_hash = "a" * 64
step_scene._write_step_scene_cache(scene, step_hash=step_hash, root=cache_root)
cached = step_scene._read_step_scene_cache(step_path, step_hash=step_hash, root=cache_root)
self.assertIsNotNone(cached)
assert cached is not None
self.assertEqual(1, sum(len(colors) for colors in cached.prototype_face_colors.values()))
located = scene_occurrence_shape(cached, cached.roots[0])
bounds = Bnd_Box()
BRepBndLib.Add_s(located, bounds)
x_min, _y_min, _z_min, x_max, _y_max, _z_max = bounds.Get()
self.assertGreater(x_min, 4.0)
self.assertGreater(x_max, 5.0)
def test_artifact_topology_uses_glb_face_runs_without_duplicate_face_buffers(self) -> None:
with temporary_directory(prefix="cad-topology-v2-") as temp_dir:
step_path = Path(temp_dir) / "box.step"
build123d.export_step(build123d.Box(1, 1, 1), step_path)
scene = load_step_scene(step_path)
bundle = extract_selectors_from_scene(
scene,
cad_ref="fixtures/box",
profile=SelectorProfile.ARTIFACT,
options=SelectorOptions(linear_deflection=0.1, angular_deflection=0.1),
)
self.assertEqual(STEP_TOPOLOGY_SCHEMA_VERSION, bundle.manifest["schemaVersion"])
self.assertTrue(bundle.manifest["capabilities"]["surfaceEdgeRendering"])
self.assertEqual(".box.step.glb", bundle.manifest["faceProxy"]["source"])
self.assertIn("faceRuns", bundle.buffers)
self.assertIn("surfaceHalfEdges", bundle.buffers)
self.assertTrue(scene.glb_mesh_payloads)
self.assertNotIn("facePositions", bundle.buffers)
self.assertNotIn("faceIndices", bundle.buffers)
self.assertNotIn("faceIds", bundle.buffers)
face_columns = bundle.manifest["tables"]["faceColumns"]
triangle_count_column = face_columns.index("triangleCount")
row_triangle_count = sum(int(row[triangle_count_column]) for row in bundle.manifest["faces"])
run_triangle_count = sum(int(bundle.buffers["faceRuns"][index + 3]) for index in range(0, len(bundle.buffers["faceRuns"]), 5))
self.assertEqual(row_triangle_count, run_triangle_count)
def test_shape_rows_include_occurrence_and_prototype_names(self) -> None:
transform = (
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
)
scene = LoadedStepScene(
step_path=Path("labeled.step"),
roots=[
OccurrenceNode(
path=(1,),
name="base:front_left",
source_name="base",
transform=transform,
prototype_key=7,
)
],
prototype_shapes={7: build123d.Box(1, 1, 1).wrapped},
prototype_names={7: "base"},
)
bundle = extract_selectors_from_scene(
scene,
cad_ref="fixtures/labeled",
profile=SelectorProfile.REFS,
options=SelectorOptions(linear_deflection=0.1, angular_deflection=0.1),
)
shape_columns = bundle.manifest["tables"]["shapeColumns"]
shape = dict(zip(shape_columns, bundle.manifest["shapes"][0]))
self.assertEqual("base:front_left", shape["name"])
self.assertEqual("base", shape["sourceName"])
def test_adaptive_mesh_resolution_prefers_finer_defaults_for_small_simple_parts(self) -> None:
with temporary_directory(prefix="cad-adaptive-mesh-") as temp_dir:
step_path = Path(temp_dir) / "box.step"
build123d.export_step(build123d.Box(10, 8, 4), step_path)
scene = load_step_scene(step_path)
resolution = adaptive_mesh_resolution_for_scene(scene)
self.assertEqual("extra-fine", resolution.profile)
self.assertLess(resolution.settings.tolerance, DEFAULT_MESH_TOLERANCE)
self.assertLess(resolution.settings.angular_tolerance, DEFAULT_MESH_ANGULAR_TOLERANCE)
self.assertEqual(1, resolution.hints["leafOccurrenceCount"])
def test_adaptive_mesh_resolution_does_not_coarsen_simple_repeated_assemblies_by_leaf_count_alone(self) -> None:
box_shape = build123d.Box(10, 8, 4).wrapped
identity = (
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
)
scene = LoadedStepScene(
step_path=Path("synthetic-repeated-box.step"),
roots=[
OccurrenceNode(
path=(index + 1,),
name=f"box_{index}",
source_name=f"box_{index}",
transform=identity,
prototype_key=1,
)
for index in range(100)
],
prototype_shapes={1: box_shape},
)
resolution = adaptive_mesh_resolution_for_scene(scene)
self.assertEqual("medium", resolution.profile)
self.assertLess(resolution.settings.tolerance, DEFAULT_MESH_TOLERANCE)
self.assertEqual(100, resolution.hints["leafOccurrenceCount"])
def test_adaptive_mesh_resolution_keeps_many_low_curvature_occurrences_balanced(self) -> None:
with mock.patch.object(
step_scene,
"_scene_mesh_resolution_hints",
return_value={
"bboxDiag": 190.0,
"prototypeFaceCount": 420,
"prototypeEdgeCount": 860,
"prototypeCurvedFaceCount": 30,
"prototypeCurvedEdgeCount": 70,
"occurrenceFaceCount": 2957,
"occurrenceEdgeCount": 6012,
"occurrenceCurvedFaceCount": 80,
"occurrenceCurvedEdgeCount": 120,
"leafOccurrenceCount": 481,
"complexityScore": 18083.7,
"effectiveComplexityScore": 18083.7,
"curvaturePressureScore": 280.0,
},
):
resolution = adaptive_mesh_resolution_for_scene(
LoadedStepScene(step_path=Path("repeated-low-curvature.step"), roots=[], prototype_shapes={})
)
self.assertEqual("balanced-assembly", resolution.profile)
self.assertEqual(0.016, resolution.settings.tolerance)
self.assertEqual(0.5, resolution.settings.angular_tolerance)
def test_adaptive_mesh_resolution_uses_large_topology_profile_for_extreme_imports(self) -> None:
with mock.patch.object(
step_scene,
"_scene_mesh_resolution_hints",
return_value={
"bboxDiag": None,
"prototypeFaceCount": 12000,
"prototypeEdgeCount": 30000,
"prototypeCurvedFaceCount": 4000,
"prototypeCurvedEdgeCount": 12000,
"occurrenceFaceCount": 23000,
"occurrenceEdgeCount": 59000,
"occurrenceCurvedFaceCount": 8000,
"occurrenceCurvedEdgeCount": 24000,
"leafOccurrenceCount": 120,
"complexityScore": 60000.0,
"effectiveComplexityScore": 60000.0,
"curvaturePressureScore": 38000.0,
},
):
resolution = adaptive_mesh_resolution_for_scene(
LoadedStepScene(step_path=Path("huge.step"), roots=[], prototype_shapes={})
)
self.assertEqual("large-topology", resolution.profile)
self.assertGreater(resolution.settings.tolerance, DEFAULT_MESH_TOLERANCE)
self.assertGreater(resolution.settings.angular_tolerance, DEFAULT_MESH_ANGULAR_TOLERANCE)
def test_adaptive_mesh_resolution_uses_curvature_pressure_before_raw_counts_explode(self) -> None:
with mock.patch.object(
step_scene,
"_scene_mesh_resolution_hints",
return_value={
"bboxDiag": 120.0,
"prototypeFaceCount": 700,
"prototypeEdgeCount": 1600,
"prototypeCurvedFaceCount": 550,
"prototypeCurvedEdgeCount": 1500,
"occurrenceFaceCount": 700,
"occurrenceEdgeCount": 1600,
"occurrenceCurvedFaceCount": 550,
"occurrenceCurvedEdgeCount": 1500,
"leafOccurrenceCount": 8,
"complexityScore": 2100.0,
"effectiveComplexityScore": 2100.0,
"curvaturePressureScore": 3600.0,
},
):
resolution = adaptive_mesh_resolution_for_scene(
LoadedStepScene(step_path=Path("curvy.step"), roots=[], prototype_shapes={})
)
self.assertEqual("medium", resolution.profile)
self.assertEqual(0.014, resolution.settings.tolerance)
self.assertEqual(0.45, resolution.settings.angular_tolerance)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
import shutil
import unittest
from pathlib import Path
from cadpy import render as cad_render
from tests.python.support.cad_test_roots import IsolatedCadRoots
class CadpyRenderTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="cadjs-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-cadjs-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(cad_render.CAD_ROOT).as_posix()
self.cleanup_paths: set[Path] = set()
def tearDown(self) -> None:
for path in self.cleanup_paths:
path.unlink(missing_ok=True)
shutil.rmtree(self.temp_root, ignore_errors=True)
self._tempdir.cleanup()
def _write_step(self, name: str, *, extension: str = ".step") -> Path:
step_path = self.temp_root / f"{name}{extension}"
step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
self.cleanup_paths.update(
(
cad_render.part_glb_path(step_path),
)
)
return step_path
def test_direct_step_has_no_persistent_stl_path(self) -> None:
step_path = self._write_step("part")
with self.assertRaisesRegex(ValueError, "no configured STL output"):
cad_render.part_stl_path(step_path)
def test_glb_path_uses_adjacent_hidden_step_glb(self) -> None:
step_path = self._write_step("part")
glb_path = cad_render.part_glb_path(step_path)
self.assertEqual(self.temp_root / ".part.step.glb", glb_path)
self.assertEqual(self.temp_root / ".part.step" / "model.glb", cad_render.legacy_part_glb_path(step_path))
def test_glb_path_preserves_stp_extension(self) -> None:
step_path = self._write_step("part-stp", extension=".stp")
glb_path = cad_render.part_glb_path(step_path)
self.assertEqual(self.temp_root / ".part-stp.stp.glb", glb_path)
self.assertEqual(self.temp_root / ".part-stp.stp" / "model.glb", cad_render.legacy_part_glb_path(step_path))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,433 @@
import os
import unittest
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
import build123d
import trimesh
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from cadpy.step_scene import LoadedStepScene, OccurrenceNode, _shape_hash
from tests.python.support.tmp_root import temporary_directory
from cadpy.threemf import export_scene_3mf, export_shape_3mf
NS = {"m": "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"}
def _identity_transform() -> tuple[float, ...]:
return (
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
)
def _translate_transform(x: float, y: float, z: float) -> tuple[float, ...]:
return (
1.0,
0.0,
0.0,
x,
0.0,
1.0,
0.0,
y,
0.0,
0.0,
1.0,
z,
0.0,
0.0,
0.0,
1.0,
)
def _meshed_box(width: float = 10, depth: float = 20, height: float = 30) -> object:
shape = build123d.Box(width, depth, height).wrapped
BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform()
return shape
def _single_leaf_scene(shape: object, **kwargs) -> tuple[LoadedStepScene, int]:
prototype_key = _shape_hash(shape)
scene = LoadedStepScene(
step_path=Path("fixture.step"),
roots=[
OccurrenceNode(
path=(1,),
name="fixture",
source_name="fixture",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=prototype_key,
)
],
prototype_shapes={prototype_key: shape},
prototype_names={prototype_key: "fixture"},
**kwargs,
)
return scene, prototype_key
def _read_model(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as package:
return ET.fromstring(package.read("3D/3dmodel.model"))
class ThreeMfExportTests(unittest.TestCase):
def test_export_shape_3mf_writes_loadable_package(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "box.3mf"
shape = build123d.Box(10, 20, 30).wrapped
BRepMesh_IncrementalMesh(shape, 0.1, True, 0.1, True).Perform()
export_shape_3mf(shape, output_path)
self.assertTrue(output_path.exists())
self.assertTrue(zipfile.is_zipfile(output_path))
with zipfile.ZipFile(output_path) as package:
self.assertIn("[Content_Types].xml", package.namelist())
self.assertIn("_rels/.rels", package.namelist())
self.assertIn("3D/3dmodel.model", package.namelist())
self.assertNotIn("mesh.stl", package.namelist())
model_xml = package.read("3D/3dmodel.model")
root = ET.fromstring(model_xml)
self.assertEqual("millimeter", root.attrib["unit"])
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
self.assertTrue(bases)
self.assertIn("#B6C4CEFF", [base.attrib.get("displaycolor") for base in bases])
vertices = root.findall(".//m:vertex", NS)
triangles = root.findall(".//m:triangle", NS)
self.assertTrue(vertices)
self.assertTrue(triangles)
self.assertLess(len(vertices), len(triangles) * 3)
loaded = trimesh.load(output_path, file_type="3mf")
geometry = getattr(loaded, "geometry", None)
self.assertTrue(len(geometry) if geometry is not None else len(loaded.faces))
def test_scene_export_emits_component_objects_and_transforms(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "assembly.3mf"
shape = _meshed_box()
prototype_key = _shape_hash(shape)
scene = LoadedStepScene(
step_path=Path("assembly.step"),
roots=[
OccurrenceNode(
path=(1,),
name="root",
source_name="root",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=None,
children=[
OccurrenceNode(
path=(1, 1),
name="left",
source_name="block",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=prototype_key,
),
OccurrenceNode(
path=(1, 2),
name="right",
source_name="block",
transform=_translate_transform(25, 0, 0),
local_transform=_translate_transform(25, 0, 0),
prototype_key=prototype_key,
),
],
)
],
prototype_shapes={prototype_key: shape},
prototype_names={prototype_key: "block"},
)
export_scene_3mf(scene, output_path)
root = _read_model(output_path)
objects = root.findall("./m:resources/m:object", NS)
mesh_objects = [obj for obj in objects if obj.find("m:mesh", NS) is not None]
component_objects = [obj for obj in objects if obj.find("m:components", NS) is not None]
components = root.findall(".//m:component", NS)
transforms = [component.attrib.get("transform") for component in components if component.attrib.get("transform")]
self.assertEqual(1, len(mesh_objects))
self.assertEqual(1, len(component_objects))
self.assertEqual(2, len(components))
self.assertTrue(any("25" in transform.split() for transform in transforms))
def test_uniform_color_uses_object_level_material(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "red.3mf"
shape = _meshed_box()
scene, prototype_key = _single_leaf_scene(
shape,
prototype_colors={_shape_hash(shape): (1.0, 0.0, 0.0, 1.0)},
)
export_scene_3mf(scene, output_path)
root = _read_model(output_path)
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None)
triangles = root.findall(".//m:triangle", NS)
self.assertEqual(prototype_key, _shape_hash(shape))
self.assertIn("#FF0000FF", [base.attrib.get("displaycolor") for base in bases])
self.assertNotIn("#B6C4CEFF", [base.attrib.get("displaycolor") for base in bases])
self.assertEqual("1", mesh_object.attrib.get("pid"))
self.assertIsNotNone(mesh_object.attrib.get("pindex"))
self.assertFalse(any("p1" in triangle.attrib for triangle in triangles))
def test_display_colors_are_srgb_encoded_from_scene_colors(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "rail.3mf"
shape = _meshed_box()
scene, _prototype_key = _single_leaf_scene(shape)
export_scene_3mf(scene, output_path, occurrence_colors={"o1": (0.22, 0.28, 0.34, 1.0)})
root = _read_model(output_path)
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
self.assertIn("#81909EFF", [base.attrib.get("displaycolor") for base in bases])
def test_occurrence_color_overrides_default_material(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "dark.3mf"
shape = _meshed_box()
scene, _prototype_key = _single_leaf_scene(shape)
export_scene_3mf(scene, output_path, occurrence_colors={"o1": (0.168627, 0.184314, 0.2, 1.0)})
root = _read_model(output_path)
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None)
dark_index = next(
str(index)
for index, base in enumerate(bases)
if base.attrib.get("displaycolor") == "#72777CFF"
)
self.assertEqual(dark_index, mesh_object.attrib.get("pindex"))
def test_parent_occurrence_color_applies_to_descendant_meshes(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "child.3mf"
shape = _meshed_box()
prototype_key = _shape_hash(shape)
scene = LoadedStepScene(
step_path=Path("assembly.step"),
roots=[
OccurrenceNode(
path=(1,),
name="colored-parent",
source_name="colored-parent",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=None,
children=[
OccurrenceNode(
path=(1, 1),
name="child",
source_name="child",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=prototype_key,
)
],
)
],
prototype_shapes={prototype_key: shape},
)
export_scene_3mf(scene, output_path, occurrence_colors={"o1": (0.168627, 0.184314, 0.2, 1.0)})
root = _read_model(output_path)
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None)
dark_index = next(
str(index)
for index, base in enumerate(bases)
if base.attrib.get("displaycolor") == "#72777CFF"
)
self.assertEqual(dark_index, mesh_object.attrib.get("pindex"))
def test_mixed_face_colors_use_triangle_material_indices(self) -> None:
with temporary_directory(prefix="cad-3mf-test-") as temp_dir:
output_path = Path(temp_dir) / "mixed.3mf"
shape = _meshed_box()
face_hashes: list[int] = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face_hashes.append(_shape_hash(TopoDS.Face_s(explorer.Current())))
explorer.Next()
scene, prototype_key = _single_leaf_scene(
shape,
prototype_face_colors={
_shape_hash(shape): {
face_hash: (
(1.0, 0.0, 0.0, 1.0)
if index % 2 == 0
else (0.0, 0.0, 1.0, 1.0)
)
for index, face_hash in enumerate(face_hashes)
}
},
)
export_scene_3mf(scene, output_path)
root = _read_model(output_path)
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
triangles = root.findall(".//m:triangle", NS)
triangle_materials = {triangle.attrib.get("p1") for triangle in triangles if "p1" in triangle.attrib}
mesh_object = next(obj for obj in root.findall("./m:resources/m:object", NS) if obj.find("m:mesh", NS) is not None)
self.assertEqual(prototype_key, _shape_hash(shape))
self.assertIn("#FF0000FF", [base.attrib.get("displaycolor") for base in bases])
self.assertIn("#0000FFFF", [base.attrib.get("displaycolor") for base in bases])
self.assertNotIn("#B6C4CEFF", [base.attrib.get("displaycolor") for base in bases])
self.assertIsNone(mesh_object.attrib.get("pindex"))
self.assertGreaterEqual(len(triangle_materials), 2)
@unittest.skipUnless(os.environ.get("CAD_3MF_HEAVY_TESTS") == "1", "set CAD_3MF_HEAVY_TESTS=1 to run fixture exports")
class ThreeMfFixtureExportTests(unittest.TestCase):
def test_sample_assembly_exports_native_components(self) -> None:
with temporary_directory(prefix="cad-3mf-fixture-") as temp_dir:
output_path = Path(temp_dir) / "sample_assembly.3mf"
shape_a = _meshed_box()
shape_b = _meshed_box(6, 8, 10)
key_a = _shape_hash(shape_a)
key_b = _shape_hash(shape_b)
scene = LoadedStepScene(
step_path=Path("sample_assembly.step"),
roots=[
OccurrenceNode(
path=(1,),
name="sample_assembly",
source_name="sample_assembly",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=None,
children=[
OccurrenceNode(
path=(1, 1),
name="sample_module",
source_name="sample_module",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=None,
children=[
OccurrenceNode(
path=(1, 1, 1),
name="sample_component_a",
source_name="sample_component_a",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=key_a,
),
OccurrenceNode(
path=(1, 1, 2),
name="sample_component_b",
source_name="sample_component_b",
transform=_translate_transform(25, 0, 0),
local_transform=_translate_transform(25, 0, 0),
prototype_key=key_b,
),
],
),
OccurrenceNode(
path=(1, 2),
name="sample_component_c",
source_name="sample_component_c",
transform=_translate_transform(0, 25, 0),
local_transform=_translate_transform(0, 25, 0),
prototype_key=key_a,
),
],
)
],
prototype_shapes={key_a: shape_a, key_b: shape_b},
prototype_names={key_a: "sample_component_a", key_b: "sample_component_b"},
)
export_scene_3mf(scene, output_path)
root = _read_model(output_path)
objects = root.findall("./m:resources/m:object", NS)
components = root.findall(".//m:component", NS)
transforms = [component.attrib.get("transform") for component in components if component.attrib.get("transform")]
self.assertGreater(sum(1 for obj in objects if obj.find("m:mesh", NS) is not None), 1)
self.assertGreater(sum(1 for obj in objects if obj.find("m:components", NS) is not None), 1)
self.assertGreater(len(transforms), 0)
loaded = trimesh.load(output_path, file_type="3mf")
self.assertGreater(len(getattr(loaded, "geometry", {}) or []), 1)
def test_sample_components_export_material_assignments(self) -> None:
with temporary_directory(prefix="cad-3mf-fixture-") as temp_dir:
output_path = Path(temp_dir) / "sample_components.3mf"
shape_a = _meshed_box()
shape_b = _meshed_box(6, 8, 10)
key_a = _shape_hash(shape_a)
key_b = _shape_hash(shape_b)
scene = LoadedStepScene(
step_path=Path("sample_components.step"),
roots=[
OccurrenceNode(
path=(1,),
name="sample_component_a",
source_name="sample_component_a",
transform=_identity_transform(),
local_transform=_identity_transform(),
prototype_key=key_a,
),
OccurrenceNode(
path=(2,),
name="sample_component_b",
source_name="sample_component_b",
transform=_translate_transform(25, 0, 0),
local_transform=_translate_transform(25, 0, 0),
prototype_key=key_b,
),
],
prototype_shapes={key_a: shape_a, key_b: shape_b},
prototype_names={key_a: "sample_component_a", key_b: "sample_component_b"},
prototype_colors={key_a: (1.0, 0.0, 0.0, 1.0), key_b: (0.0, 0.0, 1.0, 1.0)},
)
export_scene_3mf(scene, output_path)
root = _read_model(output_path)
bases = root.findall("./m:resources/m:basematerials/m:base", NS)
objects = root.findall("./m:resources/m:object", NS)
triangles = root.findall(".//m:triangle", NS)
material_colors = {base.attrib.get("displaycolor") for base in bases}
object_materials = {(obj.attrib.get("pid"), obj.attrib.get("pindex")) for obj in objects if obj.attrib.get("pindex")}
triangle_materials = {triangle.attrib.get("p1") for triangle in triangles if "p1" in triangle.attrib}
self.assertIn("#FF0000FF", material_colors)
self.assertIn("#0000FFFF", material_colors)
self.assertTrue(object_materials or triangle_materials)
loaded = trimesh.load(output_path, file_type="3mf")
self.assertGreater(len(getattr(loaded, "geometry", {}) or []), 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,77 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from tests.python.support.paths import repo_path
class InspectCliWrapperTests(unittest.TestCase):
def test_inspect_directory_invokes_cli(self) -> None:
skill_root = repo_path("skills/cad")
result = subprocess.run(
[sys.executable, "scripts/inspect", "--help"],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertIn("usage: inspect", result.stdout)
def test_inspect_help_does_not_import_heavy_cad_modules(self) -> None:
skill_root = repo_path("skills/cad")
code = (
"import sys; "
"sys.path.insert(0, 'scripts/inspect'); "
"import inspect_refs.cli; "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False"], result.stdout.strip().splitlines())
def test_scripts_inspect_rejects_render_subcommand(self) -> None:
skill_root = repo_path("skills/cad")
result = subprocess.run(
[sys.executable, "scripts/inspect", "render", "--help"],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual(2, result.returncode)
self.assertIn("invalid choice", result.stderr)
def test_scripts_inspect_worker_reads_jsonl(self) -> None:
skill_root = repo_path("skills/cad")
result = subprocess.run(
[sys.executable, "scripts/inspect", "worker"],
cwd=skill_root,
input='{"id":"bad","argv":["refs"]}\n',
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertIn('"id":"bad"', result.stdout)
self.assertIn('"exitCode":2', result.stdout)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,933 @@
import contextlib
import io
import shutil
import unittest
from array import array
from pathlib import Path
from unittest import mock
from tests.python.support.paths import add_repo_path
add_repo_path("skills/cad/scripts/inspect")
from inspect_refs import cli as inspect_cli
from inspect_refs import inspect as refs_inspect
from cadpy import cad_ref_syntax as refs_syntax
from cadpy import assembly_spec
from cadpy import generation as cad_generation
from cadpy import step_targets
from cadpy.glb_topology import STEP_TOPOLOGY_SCHEMA_VERSION
from cadpy.render import part_glb_path
from cadpy.selector_types import SelectorBundle, SelectorProfile
from cadpy.source_hash import python_source_hash
from tests.python.support.cad_test_roots import IsolatedCadRoots
def _refs_manifest(cad_ref: str) -> dict[str, object]:
return {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "refs",
"cadPath": cad_ref,
"stepPath": f"{cad_ref}.step",
"stepHash": "step-hash-123",
"bbox": {"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
"stats": {
"occurrenceCount": 2,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 2,
"edgeCount": 2,
"vertexCount": 1,
},
"tables": {
"occurrenceColumns": [
"id",
"path",
"name",
"sourceName",
"parentId",
"transform",
"bbox",
"shapeStart",
"shapeCount",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
"vertexStart",
"vertexCount",
],
"shapeColumns": [
"id",
"occurrenceId",
"ordinal",
"kind",
"bbox",
"center",
"area",
"volume",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
"vertexStart",
"vertexCount",
],
"faceColumns": [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"surfaceType",
"area",
"center",
"normal",
"bbox",
"edgeStart",
"edgeCount",
"relevance",
"flags",
"params",
"triangleStart",
"triangleCount",
],
"edgeColumns": [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"curveType",
"length",
"center",
"bbox",
"faceStart",
"faceCount",
"vertexStart",
"vertexCount",
"relevance",
"flags",
"params",
"segmentStart",
"segmentCount",
],
"vertexColumns": [
"id",
"occurrenceId",
"shapeId",
"ordinal",
"center",
"bbox",
"edgeStart",
"edgeCount",
"relevance",
"flags",
],
},
"occurrences": [
[
"o1",
"1",
"Root",
"Root",
None,
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
{"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
0,
1,
0,
2,
0,
2,
0,
1,
],
[
"o1.2",
"1.2",
"Bracket",
"Bracket",
"o1",
[1, 0, 0, 5, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
{"min": [5.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
0,
1,
0,
2,
0,
2,
0,
1,
],
],
"shapes": [
[
"o1.2.s1",
"o1.2",
1,
"solid",
{"min": [5.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
[7.5, 5.0, 5.0],
100.0,
250.0,
0,
2,
0,
2,
0,
1,
]
],
"faces": [
[
"o1.2.f1",
"o1.2",
"o1.2.s1",
1,
"plane",
20.0,
[6.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
{"min": [5.0, 0.0, 0.0], "max": [7.0, 2.0, 0.0]},
0,
2,
80,
0,
{"origin": [5.0, 0.0, 0.0], "axis": [0.0, 0.0, 1.0]},
0,
0,
],
[
"o1.2.f2",
"o1.2",
"o1.2.s1",
2,
"cylinder",
12.0,
[7.0, 2.0, 1.0],
[1.0, 0.0, 0.0],
{"min": [6.0, 1.0, 0.0], "max": [8.0, 3.0, 2.0]},
1,
0,
60,
0,
{"center": [7.0, 2.0, 1.0], "axis": [1.0, 0.0, 0.0], "radius": 1.0},
0,
0,
],
],
"edges": [
[
"o1.2.e1",
"o1.2",
"o1.2.s1",
1,
"line",
4.0,
[6.0, 1.0, 0.0],
{"min": [5.0, 0.0, 0.0], "max": [7.0, 2.0, 0.0]},
0,
2,
0,
1,
90,
0,
{"origin": [5.0, 0.0, 0.0], "direction": [1.0, 0.0, 0.0]},
0,
0,
],
[
"o1.2.e2",
"o1.2",
"o1.2.s1",
2,
"line",
3.0,
[5.5, 0.5, 0.0],
{"min": [5.0, 0.0, 0.0], "max": [6.0, 1.0, 0.0]},
2,
1,
1,
1,
75,
0,
{"origin": [5.0, 0.0, 0.0], "direction": [0.0, 1.0, 0.0]},
0,
0,
],
],
"vertices": [
[
"o1.2.v1",
"o1.2",
"o1.2.s1",
1,
[5.0, 0.0, 0.0],
{"min": [5.0, 0.0, 0.0], "max": [5.0, 0.0, 0.0]},
0,
2,
95,
0,
]
],
"assemblyMates": [
{
"id": "m1",
"label": "m1",
"sourceLabel": "face_to_face:block_pocket_floor_offset:bottom_center",
"type": "face_to_face",
"fixed": "block_pocket_floor:offset",
"moving": "bottom_center",
"parameters": {"offset": 0.2},
"fixedEndpoint": {
"position": [6.0, 1.0, 0.0],
"axes": {
"z": [0.0, 0.0, 1.0],
},
},
"movingEndpoint": {
"position": [7.0, 2.0, 1.0],
"axes": {
"z": [0.0, 0.0, 1.0],
},
},
}
],
"relations": {
"faceEdgeRows": [0, 1, 0],
"edgeFaceRows": [0, 1, 0],
"edgeVertexRows": [0, 0],
"vertexEdgeRows": [0, 1],
},
}
def _summary_manifest(cad_ref: str) -> dict[str, object]:
return {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "summary",
"cadPath": cad_ref,
"stepPath": f"{cad_ref}.step",
"stepHash": "step-hash-123",
"bbox": {"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
"stats": {
"occurrenceCount": 1,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 2,
"edgeCount": 2,
"vertexCount": 1,
},
"tables": {
"occurrenceColumns": [
"id",
"path",
"name",
"sourceName",
"parentId",
"transform",
"bbox",
"shapeStart",
"shapeCount",
"faceStart",
"faceCount",
"edgeStart",
"edgeCount",
"vertexStart",
"vertexCount",
],
"shapeColumns": [],
"faceColumns": [],
"edgeColumns": [],
"vertexColumns": [],
},
"occurrences": [
[
"o1",
"1",
"Part",
"Part",
None,
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
{"min": [0.0, 0.0, 0.0], "max": [10.0, 10.0, 10.0]},
0,
1,
0,
2,
0,
2,
0,
1,
]
],
"shapes": [],
"faces": [],
"edges": [],
"vertices": [],
}
class InspectRefsSyntaxTests(unittest.TestCase):
def test_normalize_selector_list_inherits_occurrence_prefix(self) -> None:
selectors = refs_syntax.normalize_selector_list("o1.2.f12,f13,e7,v2,s3")
self.assertEqual(
["o1.2.f12", "o1.2.f13", "o1.2.e7", "o1.2.v2", "o1.2.s3"],
selectors,
)
class InspectRefsTests(unittest.TestCase):
def setUp(self) -> None:
self._isolated_roots = IsolatedCadRoots(self, prefix="refs-inspect-")
tempdir = self._isolated_roots.temporary_cad_directory(prefix="tmp-refs-inspect-")
self._tempdir = tempdir
self.temp_root = Path(tempdir.name)
self.relative_dir = self.temp_root.relative_to(assembly_spec.CAD_ROOT).as_posix()
self.lookup_ref = f"{self.relative_dir}/sample"
self.cad_ref = self.lookup_ref
self.step_path = self.temp_root / "sample.step"
self.step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n")
self.addCleanup(self._tempdir.cleanup)
self.addCleanup(lambda: shutil.rmtree(self.temp_root, ignore_errors=True))
def _touch_glb(self, step_path: Path | None = None) -> Path:
glb_path = part_glb_path(step_path or self.step_path)
glb_path.parent.mkdir(parents=True, exist_ok=True)
glb_path.write_bytes(b"glb")
return glb_path
@contextlib.contextmanager
def _mock_glb_topology(
self,
manifest: dict[str, object],
*,
step_path: Path | None = None,
buffers: dict[str, array] | None = None,
include_selector: bool = True,
include_index: bool = True,
current_hash: str | None = None,
strip_selector_keys: tuple[str, ...] = (),
):
resolved_step_path = step_path or self.step_path
edge_rendering = {
"visibilityClasses": ["feature", "tangent", "seam", "degenerate"],
"generatedVisibilityClasses": ["feature"],
"visibilityClassCounts": {"feature": 1},
"generatedVisibilityClassCounts": {"feature": 1},
}
mesh = {
"linearDeflection": 0.006,
"angularDeflection": 0.2,
"relative": True,
"resolution": {
"profile": "extra-fine",
"hints": {
"effectiveComplexityScore": 2,
"curvaturePressureScore": 2,
"leafOccurrenceCount": 1,
"occurrenceFaceCount": 2,
"occurrenceEdgeCount": 2,
},
},
}
topology_manifest = {"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION, **manifest}
source_kind = str(topology_manifest.get("sourceKind") or "step").strip().lower()
source_path = resolved_step_path.with_suffix(".py") if source_kind == "python" else resolved_step_path
topology_manifest.setdefault("sourceKind", source_kind)
topology_manifest.setdefault("sourcePath", self._manifest_path(source_path))
topology_manifest.setdefault("stepPath", self._manifest_path(resolved_step_path))
topology_manifest.setdefault("edgeRendering", edge_rendering)
topology_manifest.setdefault("mesh", mesh)
edge_manifest = {
"schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION,
"profile": "surface-edges",
"edgeRendering": edge_rendering,
"primitiveAttributes": {
"barycentric": "_CAD_EDGE_BARYCENTRIC",
"class": "_CAD_EDGE_CLASS",
},
"buffers": {"views": {"surfaceHalfEdges": {}}},
}
if source_kind == "python":
edge_manifest["sourceKind"] = "python"
edge_manifest["sourcePath"] = topology_manifest.get("sourcePath")
edge_manifest["sourceHash"] = topology_manifest.get("sourceHash")
if topology_manifest.get("stepHash"):
edge_manifest["stepHash"] = topology_manifest.get("stepHash")
else:
edge_manifest["sourceKind"] = "step"
edge_manifest["sourcePath"] = topology_manifest.get("sourcePath")
edge_manifest["stepHash"] = topology_manifest.get("stepHash")
self._touch_glb(resolved_step_path)
stack = contextlib.ExitStack()
with stack:
stack.enter_context(mock.patch.object(step_targets, "find_step_path", return_value=resolved_step_path))
expected_step_hash = str(topology_manifest.get("stepHash") or "") if current_hash is None else current_hash
stack.enter_context(mock.patch.object(step_targets, "step_file_hash", return_value=expected_step_hash))
stack.enter_context(mock.patch.object(cad_generation, "step_file_hash", return_value=expected_step_hash))
stack.enter_context(
mock.patch.object(
step_targets,
"read_step_topology_manifest_from_glb",
return_value=topology_manifest if include_index else None,
)
)
stack.enter_context(
mock.patch.object(
step_targets,
"read_step_display_edge_manifest_from_glb",
return_value=edge_manifest,
)
)
stack.enter_context(mock.patch.object(step_targets, "glb_primitives_have_surface_edge_attributes", return_value=True))
stack.enter_context(mock.patch.object(step_targets, "glb_surface_edge_class_has_nonzero_values", return_value=True))
selector_topology_manifest = {
key: value for key, value in topology_manifest.items() if key not in strip_selector_keys
}
stack.enter_context(
mock.patch.object(
step_targets,
"read_step_topology_bundle_from_glb",
return_value=(
SelectorBundle(manifest=selector_topology_manifest, buffers=buffers or {})
if include_selector
else None
),
)
)
yield
def _manifest_path(self, path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(assembly_spec.REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def test_whole_entry_summary_uses_glb_index(self) -> None:
with self._mock_glb_topology(_summary_manifest(self.cad_ref), include_selector=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertTrue(result["ok"])
token = result["tokens"][0]
self.assertEqual(1, token["summary"]["occurrenceCount"])
self.assertEqual(2, token["summary"]["faceCount"])
self.assertEqual([], token["selections"])
def test_facts_kind_falls_back_to_index_manifest_for_assembly(self) -> None:
manifest = {**_refs_manifest(self.cad_ref), "entryKind": "assembly"}
with self._mock_glb_topology(manifest, strip_selector_keys=("entryKind", "assembly")):
result = refs_inspect.inspect_cad_refs(self.cad_ref, facts=True)
self.assertTrue(result["ok"])
token = result["tokens"][0]
self.assertEqual("assembly", token["summary"]["kind"])
self.assertEqual("assembly", token["entryFacts"]["kind"])
def test_python_backed_glb_only_entry_inspects_without_step_file(self) -> None:
self.step_path.unlink()
script_path = self.step_path.with_suffix(".py")
script_path.write_text("def gen_step():\n return object()\n", encoding="utf-8")
source_identity = python_source_hash(script_path)
manifest = {
**_summary_manifest(self.cad_ref),
"sourceKind": "python",
"sourceHash": source_identity.source_hash,
}
with self._mock_glb_topology(manifest, include_selector=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertTrue(result["ok"])
token = result["tokens"][0]
self.assertEqual(refs_inspect._relative_to_repo(self.step_path), token["stepPath"])
self.assertEqual(1, token["summary"]["occurrenceCount"])
def test_context_provider_can_supply_in_memory_entry_context(self) -> None:
requested_profiles = []
def provider(cad_path, profile):
requested_profiles.append(profile)
if cad_path != self.cad_ref:
return None
manifest = _summary_manifest(cad_path)
return refs_inspect.EntryContext(
cad_path=cad_path,
kind="part",
source_path=self.step_path,
step_path=self.step_path,
manifest=manifest,
selector_index=refs_inspect.lookup.build_selector_index(manifest),
)
result = refs_inspect.inspect_cad_refs(self.cad_ref, context_provider=provider)
self.assertTrue(result["ok"])
self.assertEqual([SelectorProfile.SUMMARY], requested_profiles)
self.assertEqual(2, result["tokens"][0]["summary"]["faceCount"])
def test_face_lookup_resolves_single_occurrence_alias_and_detail(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1.2.f1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("face", selection["selectorType"])
self.assertEqual("o1.2.f1", selection["normalizedSelector"])
self.assertEqual("plane area=20.0", selection["summary"])
self.assertEqual(["e1", "e2"], selection["detail"]["adjacentEdgeSelectors"])
def test_assembly_mate_lookup_resolves_numbered_ref(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#m1", detail=True, positioning=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("mate", selection["selectorType"])
self.assertEqual("m1", selection["normalizedSelector"])
self.assertEqual("#m1", selection["copyText"])
self.assertEqual("Mate face_to_face:block_pocket_floor_offset:bottom_center", selection["label"])
self.assertEqual("face_to_face block_pocket_floor:offset -> bottom_center", selection["summary"])
self.assertEqual("face_to_face:block_pocket_floor_offset:bottom_center", selection["detail"]["sourceLabel"])
self.assertEqual({"offset": 0.2}, selection["detail"]["parameters"])
self.assertEqual("mate", selection["positioning"]["selectorType"])
self.assertEqual([6.0, 1.0, 0.0], selection["positioning"]["fixedEndpoint"]["position"])
def test_vertex_lookup_resolves_corner_detail(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1.2.v1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("vertex", selection["selectorType"])
self.assertEqual("o1.2.v1", selection["normalizedSelector"])
self.assertEqual("corner edges=2", selection["summary"])
self.assertEqual(["e1", "e2"], selection["detail"]["adjacentEdgeSelectors"])
self.assertEqual(["f1", "f2"], selection["detail"]["adjacentFaceSelectors"])
def test_single_occurrence_alias_is_compacted_in_copy_text(self) -> None:
with self._mock_glb_topology(_summary_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#f2", detail=True)
self.assertFalse(result["ok"])
with self._mock_glb_topology(
{
**_refs_manifest(self.cad_ref),
"stats": {
"occurrenceCount": 1,
"leafOccurrenceCount": 1,
"shapeCount": 1,
"faceCount": 2,
"edgeCount": 2,
"vertexCount": 1,
},
"occurrences": [_refs_manifest(self.cad_ref)["occurrences"][1]],
},
):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#v1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("v1", selection["displaySelector"])
self.assertEqual("#v1", selection["copyText"])
def test_old_part_selector_syntax_is_rejected(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#p:legacy.f1")
self.assertFalse(result["ok"])
self.assertEqual("selector", result["errors"][0]["kind"])
def test_topology_flag_returns_full_selector_lists(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, include_topology=True)
self.assertTrue(result["ok"])
topology = result["tokens"][0]["topology"]
self.assertIn("f1", topology["faces"])
self.assertIn("e1", topology["edges"])
self.assertIn("v1", topology["vertices"])
def test_detail_uses_glb_buffer_backed_relation_rows(self) -> None:
manifest = {
**_refs_manifest(self.cad_ref),
"relations": {
"faceEdgeRowsView": "faceEdgeRows",
"edgeFaceRowsView": "edgeFaceRows",
"edgeVertexRowsView": "edgeVertexRows",
"vertexEdgeRowsView": "vertexEdgeRows",
},
}
buffers = {
"faceEdgeRows": array("I", [0, 1, 0]),
"edgeFaceRows": array("I", [0, 1, 0]),
"edgeVertexRows": array("I", [0, 0]),
"vertexEdgeRows": array("I", [0, 1]),
}
with self._mock_glb_topology(manifest, buffers=buffers):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1.2.f1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual(["e1", "e2"], selection["detail"]["adjacentEdgeSelectors"])
def test_missing_glb_topology_is_an_inspect_error(self) -> None:
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
self.assertNotIn("scripts.step", error["message"])
self.assertIn("regenerateCommand", error)
self.assertEqual("python scripts/step", error["regenerateCommand"])
def test_missing_selector_topology_is_an_inspect_error(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref), include_selector=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#f1")
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_missing_step_topology_is_an_inspect_error(self) -> None:
with self._mock_glb_topology(_summary_manifest(self.cad_ref), include_index=False):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_unsupported_step_topology_is_an_inspect_error(self) -> None:
self._touch_glb()
manifest = {**_summary_manifest(self.cad_ref), "schemaVersion": STEP_TOPOLOGY_SCHEMA_VERSION + 1}
with mock.patch.object(step_targets, "find_step_path", return_value=self.step_path), mock.patch.object(
step_targets,
"read_step_topology_manifest_from_glb",
return_value=manifest,
):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_stale_glb_topology_is_an_inspect_error(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref), current_hash="new-step-hash"):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertFalse(result["ok"])
error = result["errors"][0]
self.assertEqual("glb_regeneration_failed", error["code"])
self.assertIn("\nRegenerate STEP artifacts with the following command using the CAD skill:", error["message"])
def test_legacy_cad_ref_mismatch_is_accepted_when_hash_matches(self) -> None:
with self._mock_glb_topology({**_refs_manifest("other/ref"), "stepHash": "step-hash-123"}):
result = refs_inspect.inspect_cad_refs(self.cad_ref)
self.assertTrue(result["ok"])
self.assertEqual(self.cad_ref, result["tokens"][0]["cadPath"])
def test_non_leaf_occurrence_detail_reports_children(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(self.cad_ref, "#o1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("occurrence", selection["selectorType"])
self.assertEqual("o1", selection["normalizedSelector"])
self.assertEqual(1, selection["detail"]["childCount"])
self.assertEqual(["o1.2"], selection["detail"]["descendantOccurrenceIds"])
def test_assembly_topology_lookup_resolves_from_generated_step(self) -> None:
assembly_cad_ref = f"{self.relative_dir}/sample-assembly"
assembly_path = self.temp_root / "sample-assembly.py"
assembly_step_path = self.temp_root / "sample-assembly.step"
assembly_path.write_text(
"def gen_step():\n"
" return {'instances': []}\n",
encoding="utf-8",
)
assembly_step_path.write_text("ISO-10303-21; END-ISO-10303-21;\n", encoding="utf-8")
source_identity = python_source_hash(assembly_path)
with mock.patch.object(
step_targets,
"resolve_cad_source_path",
return_value=("assembly", assembly_path),
), self._mock_glb_topology(
{
**_refs_manifest(assembly_cad_ref),
"sourceKind": "python",
"sourceHash": source_identity.source_hash,
"stepHash": cad_generation.step_file_hash(assembly_step_path),
},
step_path=assembly_step_path,
):
result = refs_inspect.inspect_cad_refs(assembly_cad_ref, "#o1.2.f1", detail=True)
self.assertTrue(result["ok"])
selection = result["tokens"][0]["selections"][0]
self.assertEqual("assembly", result["tokens"][0]["summary"]["kind"])
self.assertEqual("face", selection["selectorType"])
self.assertEqual("o1.2.f1", selection["normalizedSelector"])
def test_positioning_flag_returns_plane_facts(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(
self.cad_ref,
"#o1.2.f1",
positioning=True,
)
self.assertTrue(result["ok"])
positioning = result["tokens"][0]["selections"][0]["positioning"]
self.assertEqual("plane", positioning["kind"])
self.assertEqual("z", positioning["axis"])
self.assertEqual(0.0, positioning["coordinate"])
self.assertEqual([0.0, 0.0, 1.0], positioning["normal"])
def test_planes_flag_returns_entry_planes(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_cad_refs(
self.cad_ref,
planes=True,
plane_coordinate_tolerance=0.01,
plane_min_area_ratio=0.0,
plane_limit=1,
)
self.assertTrue(result["ok"])
planes = result["tokens"][0]["planes"]
self.assertEqual(1, len(planes))
self.assertEqual("z", planes[0]["axis"])
def test_refs_text_format_includes_entry_reports(self) -> None:
result = {
"ok": True,
"tokens": [
{
"cadPath": self.cad_ref,
"summary": {"faceCount": 2, "edgeCount": 2},
"entryFacts": {
"size": [10.0, 10.0, 10.0],
"center": [5.0, 5.0, 5.0],
"extentAxis": "x",
"diag": 17.320508,
"kind": "part",
},
"planes": [
{
"axis": "z",
"coordinate": 0.0,
"normalSign": 1,
"faceCount": 1,
"totalArea": 100.0,
}
],
"selections": [],
}
],
"errors": [],
}
text = inspect_cli._format_refs_text(result, quiet=False, verbose=False)
self.assertIn("facts: size=[10, 10, 10]", text)
self.assertIn("planes: 1 major groups", text)
self.assertIn("z=0", text)
def test_diff_planes_returns_entry_planes(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.diff_entry_targets(
self.cad_ref,
self.cad_ref,
planes=True,
plane_limit=1,
)
self.assertTrue(result["ok"])
self.assertEqual(1, len(result["diff"]["leftMajorPlanes"]))
self.assertEqual(1, len(result["diff"]["rightMajorPlanes"]))
def test_cli_parses_current_agentic_commands(self) -> None:
parser = inspect_cli.build_parser()
refs_args = parser.parse_args(["refs", "entry.step", "#f1", "--detail", "--facts"])
self.assertEqual("refs", refs_args.command)
self.assertTrue(refs_args.detail)
self.assertTrue(refs_args.facts)
diff_args = parser.parse_args(
["diff", "left", "right", "--planes", "--plane-coordinate-tolerance", "0.02", "--plane-limit", "3"]
)
self.assertTrue(diff_args.planes)
self.assertEqual(0.02, diff_args.plane_coordinate_tolerance)
self.assertEqual(3, diff_args.plane_limit)
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as render_exit:
parser.parse_args(["render", "list", "part.step", "--format", "text"])
self.assertEqual(2, render_exit.exception.code)
worker_args = parser.parse_args(["worker"])
self.assertEqual("worker", worker_args.command)
top_level_verbose_args = parser.parse_args(["--verbose", "refs", "entry.step", "#f1"])
self.assertTrue(top_level_verbose_args.verbose)
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as verbose_render_exit:
parser.parse_args(["--verbose", "render", "view", "part.step", "--output", "part.png"])
self.assertEqual(2, verbose_render_exit.exception.code)
def test_worker_response_wraps_inspect_result(self) -> None:
response = inspect_cli._worker_response('{"id":"missing-input","argv":["refs"]}')
self.assertFalse(response["ok"])
self.assertEqual("missing-input", response["id"])
self.assertEqual(2, response["exitCode"])
self.assertIn("No STEP/CAD entry target provided", response["result"]["errors"][0]["message"])
def test_frame_command_returns_occurrence_axes(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.inspect_target_frame(self.cad_ref, "#o1.2")
self.assertTrue(result["ok"])
self.assertEqual([5.0, 0.0, 0.0], result["frame"]["translation"])
self.assertEqual([1.0, 0.0, 0.0], result["frame"]["localAxes"]["x"])
def test_measure_targets_returns_signed_axis_distance(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.measure_targets(
self.cad_ref,
"#o1.2.f1",
"#o1.2.f2",
axis="x",
)
self.assertTrue(result["ok"])
self.assertEqual("x", result["axis"])
self.assertEqual(5.0, result["from"]["coordinate"])
self.assertEqual(7.0, result["to"]["coordinate"])
self.assertEqual(2.0, result["measurement"]["signedDistance"])
def test_align_targets_returns_flush_translation_delta(self) -> None:
with self._mock_glb_topology(_refs_manifest(self.cad_ref)):
result = refs_inspect.align_targets(
self.cad_ref,
"#o1.2.f1",
"#o1.2.f2",
axis="x",
)
self.assertTrue(result["ok"])
self.assertEqual([2.0, 0.0, 0.0], result["alignment"]["translationVector"])
self.assertEqual(2.0, result["alignment"]["transformTranslationDelta"]["3"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,655 @@
from __future__ import annotations
import asyncio
import io
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from tests.python.support.paths import add_repo_path, repo_path
add_repo_path("skills/cad/scripts")
import snapshot.__main__ as snapshot_main
from snapshot.__main__ import (
RENDER_HTML_PATH,
RUNTIME_DIR,
SnapshotError,
load_job_from_options,
parse_snapshot_args,
resolve_render_job_packet,
resolve_snapshot_route_file,
timestamp_output_path,
)
class _TtyStringIO(io.StringIO):
def isatty(self) -> bool:
return True
def _selector_artifact(*occurrence_ids: str) -> SimpleNamespace:
return SimpleNamespace(
selector_bundle=SimpleNamespace(
manifest={
"tables": {
"occurrenceColumns": ["id"],
"shapeColumns": ["id", "occurrenceId"],
},
"occurrences": [[occurrence_id] for occurrence_id in occurrence_ids],
"shapes": [],
},
buffers={},
)
)
class SnapshotCliTests(unittest.TestCase):
def test_cli_import_does_not_import_heavy_cad_modules(self) -> None:
skill_root = repo_path("skills/cad")
code = (
"import sys; sys.path.insert(0, 'scripts'); import snapshot.__main__; "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False"], result.stdout.strip().splitlines())
def test_shortcut_job_shape_stays_owned_by_python_cli(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
"wireframe",
"--size-profile",
"simple",
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(job["input"], "models/simple/cylindrical_cap.step")
self.assertNotIn("workspaceRoot", job)
self.assertNotIn("rootDir", job)
self.assertEqual(job["outputs"][0]["path"], "tmp/cap.png")
self.assertEqual(job["display"], {"mode": "wireframe"})
self.assertEqual(job["render"]["sizeProfile"], "simple")
def test_shortcut_focus_and_hide_flags_are_mutually_exclusive(self) -> None:
with self.assertRaisesRegex(SnapshotError, "--focus and --hide cannot be used"):
parse_snapshot_args(
[
"--input",
"models/assembly.step",
"--output",
"tmp/assembly.png",
"--focus",
"#o1.2",
"--hide=#o1.3.1",
]
)
def test_display_shortcut_accepts_cad_display_modes(self) -> None:
for raw_mode, expected_display in [
("edges", {"mode": "solid"}),
("x-ray", {"mode": "transparent"}),
("hidden edges visible", {"mode": "hidden_edges"}),
("hidden-lines-removed", {"mode": "hidden_lines_removed"}),
("flat", {"mode": "unshaded"}),
("appearance", {"mode": "rendered"}),
("wire", {"mode": "wireframe"}),
]:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
raw_mode,
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(job["display"], expected_display)
def test_display_json_accepts_exploded_settings(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
'{"projection":"perspective","mode":"rendered","exploded":{"enabled":true,"axis":"radial","spacing":1.6}}',
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(
job["display"],
{
"projection": "perspective",
"mode": "rendered",
"exploded": {"enabled": True, "axis": "radial", "spacing": 1.6},
},
)
def test_edge_settings_belong_to_display_json(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
'{"edges":{"enabled":false,"color":"#123456"}}',
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(job["display"], {"edges": {"enabled": False, "color": "#123456"}})
appearance_options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--appearance",
'{"edges":{"enabled":false}}',
]
)
with self.assertRaisesRegex(SnapshotError, "unsupported keys: edges"):
load_job_from_options(appearance_options, stdin=_TtyStringIO(), cwd=Path.cwd())
def test_display_shortcut_rejects_unknown_modes(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
"mist",
]
)
with self.assertRaisesRegex(SnapshotError, "Unsupported display mode"):
load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
def test_display_shortcut_rejects_exploded_mode_alias(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/simple/cylindrical_cap.step",
"--output",
"tmp/cap.png",
"--display",
"exploded",
]
)
with self.assertRaisesRegex(SnapshotError, "Unsupported display mode"):
load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
def test_shortcut_focus_flags_apply_selection(self) -> None:
options = parse_snapshot_args(
[
"--input",
"models/assembly.step",
"--output",
"tmp/assembly.png",
"--focus",
"#o1.2",
"#o1.3",
]
)
job = load_job_from_options(options, stdin=_TtyStringIO(), cwd=Path.cwd())
self.assertEqual(
job["selection"],
{
"focus": ["#o1.2", "#o1.3"],
},
)
def test_output_paths_are_timestamped_when_jobs_are_resolved(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "part.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".part.step.glb").write_bytes(b"glb")
original_timestamp = snapshot_main.snapshot_timestamp
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.snapshot_timestamp = lambda: "20260527T163012Z"
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: None
packet = resolve_render_job_packet(
{
"jobs": [
{
"input": "models/part.step",
"outputs": [
{"path": "tmp/iso.png", "camera": "iso"},
{"path": "tmp/front.png", "camera": "front"},
],
},
{
"input": "models/part.step",
"mode": "orbit",
"outputs": [{"path": "tmp/orbit.gif"}],
},
]
},
cwd=root,
)
finally:
snapshot_main.snapshot_timestamp = original_timestamp
snapshot_main.ensure_step_topology_artifact = original_ensure
output_paths = [
Path(output["path"]).relative_to(root).as_posix()
for job in packet["jobs"]
for output in job["outputs"]
]
self.assertEqual(
output_paths,
[
"tmp/iso_20260527T163012Z.png",
"tmp/front_20260527T163012Z.png",
"tmp/orbit_20260527T163012Z.gif",
],
)
def test_render_job_derives_asset_root_from_input_path(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "part.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".part.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: None
packet = resolve_render_job_packet(
{
"input": "models/part.step",
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
job = packet["jobs"][0]
self.assertNotIn("workspaceRoot", job)
self.assertNotIn("rootDir", job)
self.assertEqual(job["resolved"]["rootPath"], str(models))
self.assertEqual(job["resolved"]["inputUrl"], "/__render_asset/part.step")
self.assertEqual(job["resolved"]["glbUrl"], "/__render_asset/.part.step.glb")
def test_render_job_ensures_step_artifact_for_step_input(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
step_path = models / "part.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".part.step.glb").write_bytes(b"glb")
calls = []
def fake_ensure(target, **kwargs):
calls.append((target, kwargs))
return None
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = fake_ensure
resolve_render_job_packet(
{
"input": "models/part.step",
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
self.assertEqual(len(calls), 1)
target, kwargs = calls[0]
self.assertEqual(target.step_path, step_path)
self.assertEqual(target.source_path, step_path)
self.assertEqual(kwargs["owner"], "cad-snapshot")
self.assertFalse(kwargs["require_selector"])
def test_render_job_rejects_non_step_input_without_artifact_generation(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "robot.urdf").write_text("<robot name=\"r\" />\n", encoding="utf-8")
calls = []
def fake_ensure(target, **kwargs):
calls.append((target, kwargs))
return None
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = fake_ensure
with self.assertRaisesRegex(
SnapshotError,
"Snapshot supports only STEP/STP inputs or same-stem Python generators",
):
resolve_render_job_packet(
{
"input": "models/robot.urdf",
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
self.assertEqual(calls, [])
def test_render_job_requires_selector_topology_for_cad_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
step_path = models / "assembly.step"
step_path.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
calls = []
def fake_ensure(target, **kwargs):
calls.append((target, kwargs))
return _selector_artifact("o1", "o1.2")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = fake_ensure
resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {"focus": ["#o1.2"]},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
self.assertEqual(len(calls), 1)
target, kwargs = calls[0]
self.assertEqual(target.step_path, step_path)
self.assertTrue(kwargs["require_selector"])
def test_render_job_normalizes_focus_selector_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact(
"o1",
"o1.2",
"o1.2.1",
"o1.3",
)
packet = resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {
"focus": ["#o1.2", "#o1.3"],
},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
selection = packet["jobs"][0]["selection"]
self.assertEqual(selection["focus"], ["o1.2", "o1.3"])
def test_render_job_normalizes_hide_selector_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact(
"o1",
"o1.2",
"o1.2.1",
"o1.3",
)
packet = resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {"hide": ["#o1.2.1"]},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
selection = packet["jobs"][0]["selection"]
self.assertEqual(selection["hide"], ["o1.2.1"])
def test_render_job_rejects_face_focus_refs(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact("o1", "o1.2")
with self.assertRaisesRegex(SnapshotError, "part/subassembly occurrence refs"):
resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {"focus": ["#o1.2.f1"]},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
def test_render_job_rejects_mixed_focus_and_hide_selection(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory).resolve()
models = root / "models"
models.mkdir()
(models / "assembly.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
(models / ".assembly.step.glb").write_bytes(b"glb")
original_ensure = snapshot_main.ensure_step_topology_artifact
try:
snapshot_main.ensure_step_topology_artifact = lambda *args, **kwargs: _selector_artifact(
"o1",
"o1.2",
"o1.3",
)
with self.assertRaisesRegex(SnapshotError, "selection.focus/refs and selection.hide cannot be used"):
resolve_render_job_packet(
{
"input": "models/assembly.step",
"selection": {
"focus": ["#o1.2"],
"hide": ["#o1.3"],
},
"outputs": [{"path": "tmp/iso.png", "camera": "iso"}],
},
cwd=root,
)
finally:
snapshot_main.ensure_step_topology_artifact = original_ensure
def test_snapshot_root_flags_and_job_fields_are_removed(self) -> None:
with self.assertRaisesRegex(SnapshotError, "Unknown argument: --workspace-root"):
parse_snapshot_args(["--workspace-root", "/tmp"])
with self.assertRaisesRegex(SnapshotError, "Unknown argument: --root-dir"):
parse_snapshot_args(["--root-dir", "models"])
with self.assertRaisesRegex(SnapshotError, "no longer accept workspaceRoot or rootDir"):
resolve_render_job_packet(
{
"input": "part.step",
"workspaceRoot": "/tmp",
"outputs": [{"path": "tmp/iso.png"}],
},
cwd=Path.cwd(),
)
def test_timestamp_output_path_preserves_extension(self) -> None:
self.assertEqual(
timestamp_output_path("snapshots/review.png", "20260527T163012Z"),
"snapshots/review_20260527T163012Z.png",
)
def test_removed_daemon_flags_stay_removed(self) -> None:
with self.assertRaisesRegex(SnapshotError, "daemon commands have been removed"):
parse_snapshot_args(["daemon"])
with self.assertRaisesRegex(SnapshotError, "--socket has been removed"):
parse_snapshot_args(["--socket", "snapshot.sock"])
def test_runtime_routes_are_self_contained(self) -> None:
self.assertEqual(
resolve_snapshot_route_file("http://snapshot.local/render.html"),
RENDER_HTML_PATH,
)
self.assertEqual(
resolve_snapshot_route_file("http://snapshot.local/snapshot-render.js"),
RUNTIME_DIR / "snapshot-render.js",
)
def test_snapshot_renderer_does_not_force_chromium_single_process(self) -> None:
captured_launch_options = {}
class FakePage:
async def route(self, *args, **kwargs):
pass
async def goto(self, *args, **kwargs):
pass
async def wait_for_function(self, *args, **kwargs):
pass
class FakeContext:
async def new_page(self):
return FakePage()
async def close(self):
pass
class FakeBrowser:
async def new_context(self, *args, **kwargs):
return FakeContext()
async def close(self):
pass
class FakeChromium:
async def launch(self, **kwargs):
captured_launch_options.update(kwargs)
return FakeBrowser()
class FakePlaywright:
def __init__(self) -> None:
self.chromium = FakeChromium()
async def stop(self):
pass
fake_playwright = FakePlaywright()
class FakeAsyncPlaywright:
async def start(self):
return fake_playwright
async_api_module = ModuleType("playwright.async_api")
async_api_module.async_playwright = FakeAsyncPlaywright
playwright_module = ModuleType("playwright")
playwright_module.__path__ = []
original_playwright = sys.modules.get("playwright")
original_async_api = sys.modules.get("playwright.async_api")
try:
sys.modules["playwright"] = playwright_module
sys.modules["playwright.async_api"] = async_api_module
async def start_renderer() -> None:
renderer = snapshot_main.BatchSnapshotRenderer()
try:
await renderer.start()
finally:
await renderer.close()
asyncio.run(start_renderer())
finally:
if original_playwright is None:
sys.modules.pop("playwright", None)
else:
sys.modules["playwright"] = original_playwright
if original_async_api is None:
sys.modules.pop("playwright.async_api", None)
else:
sys.modules["playwright.async_api"] = original_async_api
self.assertNotIn("--single-process", captured_launch_options.get("args") or [])
def test_snapshot_tool_has_no_sideways_runtime_dependencies(self) -> None:
snapshot_root = repo_path("skills/cad/scripts/snapshot")
checked_files = [
snapshot_root / "__main__.py",
snapshot_root / "runtime" / "render.html",
snapshot_root / "runtime" / "snapshot-render.js",
]
forbidden = (
"packages/cadjs",
"skills/cad-viewer",
"/node_modules/",
"\\node_modules\\",
"CADJS_NODE_MODULES_ROOT",
)
for checked_file in checked_files:
text = checked_file.read_text(encoding="utf-8")
for token in forbidden:
self.assertNotIn(token, text, f"{checked_file} should not reference {token}")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,147 @@
import contextlib
import io
import subprocess
import sys
import unittest
from unittest import mock
from tests.python.support.paths import add_repo_path, repo_path
add_repo_path("skills/cad/scripts")
from step import cli
class StepCliTests(unittest.TestCase):
def test_requires_explicit_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main([])
self.assertEqual(2, cm.exception.code)
def test_passes_targets_in_order_without_kind_for_generated_sources(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/second.py", "parts/first.py"]))
generate.assert_called_once()
self.assertEqual(["parts/second.py", "parts/first.py"], generate.call_args.args[0])
self.assertIsNone(generate.call_args.kwargs["direct_step_kind"])
self.assertFalse(generate.call_args.kwargs["step_options"].has_metadata)
self.assertIsNone(generate.call_args.kwargs["output"])
self.assertFalse(generate.call_args.kwargs["skip_step_write"])
self.assertFalse(generate.call_args.kwargs["verbose"])
def test_passes_direct_step_kind(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["--kind", "assembly", "assemblies/second.step", "assemblies/first.step"]))
generate.assert_called_once()
self.assertEqual(["assemblies/second.step", "assemblies/first.step"], generate.call_args.args[0])
self.assertEqual("assembly", generate.call_args.kwargs["direct_step_kind"])
self.assertFalse(generate.call_args.kwargs["step_options"].has_metadata)
self.assertIsNone(generate.call_args.kwargs["output"])
self.assertFalse(generate.call_args.kwargs["verbose"])
def test_passes_verbose_flag(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/sample.py", "--verbose"]))
self.assertTrue(generate.call_args.kwargs["verbose"])
def test_passes_skip_step_write_flag(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/sample.py", "--skip-step-write"]))
self.assertTrue(generate.call_args.kwargs["skip_step_write"])
def test_passes_output_flag(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["parts/sample.py", "-o", "STEP/sample.step"]))
self.assertEqual("STEP/sample.step", generate.call_args.kwargs["output"])
def test_output_flag_rejects_multiple_targets(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["parts/first.py", "parts/second.py", "-o", "STEP/first.step"])
self.assertEqual(2, cm.exception.code)
def test_passes_import_metadata_flags(self) -> None:
with mock.patch.object(cli, "generate_step_targets", return_value=0) as generate:
self.assertEqual(
0,
cli.main(
[
"--kind",
"part",
"imports/sample_part.step",
"--stl",
"../meshes/sample_part.stl",
"--3mf",
"../meshes/sample_part.3mf",
"--glb",
"../meshes/sample_part.glb",
"--mesh-tolerance",
"0.2",
"--mesh-angular-tolerance",
"0.25",
]
),
)
generate.assert_called_once()
self.assertEqual(["imports/sample_part.step"], generate.call_args.args[0])
self.assertEqual("part", generate.call_args.kwargs["direct_step_kind"])
options = generate.call_args.kwargs["step_options"]
self.assertEqual("../meshes/sample_part.stl", options.stl)
self.assertEqual("../meshes/sample_part.3mf", options.three_mf)
self.assertEqual("../meshes/sample_part.glb", options.glb)
self.assertEqual(0.2, options.mesh_tolerance)
self.assertEqual(0.25, options.mesh_angular_tolerance)
def test_rejects_invalid_numeric_flag(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["imports/sample_part.step", "--mesh-tolerance", "nan"])
self.assertEqual(2, cm.exception.code)
def test_rejects_removed_inspect_flag(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["parts/sample.step", "--inspect", "refs {cad_token} --facts"])
self.assertEqual(2, cm.exception.code)
def test_help_has_current_step_flags_only(self) -> None:
stream = io.StringIO()
with self.assertRaises(SystemExit) as cm, contextlib.redirect_stdout(stream):
cli.main(["--help"])
self.assertEqual(0, cm.exception.code)
help_text = stream.getvalue()
self.assertIn("--kind", help_text)
self.assertIn("--stl", help_text)
self.assertIn("--3mf", help_text)
self.assertIn("--glb", help_text)
self.assertIn("--skip-step-write", help_text)
self.assertIn("--output", help_text)
self.assertIn("--mesh-tolerance", help_text)
self.assertIn("--verbose", help_text)
def test_cli_does_not_reserve_common_module_name(self) -> None:
skill_root = repo_path("skills/cad")
code = (
"import sys; sys.path.insert(0, 'scripts'); import step.cli; "
"print('common' in sys.modules); "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False", "False"], result.stdout.strip().splitlines())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,78 @@
import subprocess
import sys
import unittest
from unittest import mock
from tests.python.support.paths import add_repo_path, repo_path
add_repo_path("skills/dxf/scripts")
from dxf import cli as dxf
class DxfCliTests(unittest.TestCase):
def test_requires_explicit_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
dxf.main([])
self.assertEqual(2, cm.exception.code)
def test_passes_targets_in_order(self) -> None:
with mock.patch.object(dxf, "generate_dxf_targets", return_value=0) as generate:
self.assertEqual(0, dxf.main(["drawings/second.py", "drawings/first.py"]))
generate.assert_called_once_with(["drawings/second.py", "drawings/first.py"], output=None, verbose=False)
def test_passes_verbose_flag(self) -> None:
with mock.patch.object(dxf, "generate_dxf_targets", return_value=0) as generate:
self.assertEqual(0, dxf.main(["drawings/part.py", "--verbose"]))
generate.assert_called_once_with(["drawings/part.py"], output=None, verbose=True)
def test_passes_output_flag(self) -> None:
with mock.patch.object(dxf, "generate_dxf_targets", return_value=0) as generate:
self.assertEqual(0, dxf.main(["drawings/part.py", "-o", "DXF/part.dxf"]))
generate.assert_called_once_with(["drawings/part.py"], output="DXF/part.dxf", verbose=False)
def test_output_flag_rejects_multiple_targets(self) -> None:
with self.assertRaises(SystemExit) as cm:
dxf.main(["drawings/first.py", "drawings/second.py", "-o", "DXF/first.dxf"])
self.assertEqual(2, cm.exception.code)
def test_scripts_dxf_directory_invokes_cli(self) -> None:
skill_root = repo_path("skills/dxf")
result = subprocess.run(
[sys.executable, "scripts/dxf", "--help"],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertIn("usage: dxf", result.stdout)
self.assertIn("--output", result.stdout)
def test_cli_import_does_not_import_heavy_cad_modules(self) -> None:
skill_root = repo_path("skills/dxf")
code = (
"import sys; sys.path.insert(0, 'scripts'); import dxf.cli; "
"print('OCP.OCP' in sys.modules); "
"print('cadpy.step_scene' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
cwd=skill_root,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertEqual(["False", "False"], result.stdout.strip().splitlines())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,37 @@
import unittest
from pathlib import Path
import ezdxf
from tests.python.support.paths import add_repo_path
from tests.python.support.tmp_root import temporary_directory
add_repo_path("skills/dxf/scripts")
from dxf.render_payload import build_dxf_render_payload
class CadpyDxfTests(unittest.TestCase):
def test_build_dxf_render_payload_supports_straight_lwpolyline(self) -> None:
with temporary_directory(prefix="tmp-cad-dxf-") as tmpdir:
dxf_path = Path(tmpdir) / "outline.dxf"
doc = ezdxf.new("R2010")
modelspace = doc.modelspace()
modelspace.add_lwpolyline(
[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)],
close=True,
dxfattribs={"layer": "CUT"},
)
doc.saveas(dxf_path)
payload = build_dxf_render_payload(dxf_path, file_ref="test/outline.dxf")
self.assertEqual(4, payload["counts"]["paths"])
self.assertEqual(0, payload["counts"]["circles"])
self.assertEqual(4, len(payload["geometry"]["lines"]))
self.assertEqual(10.0, payload["bounds"]["width"])
self.assertEqual(5.0, payload["bounds"]["height"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,77 @@
import textwrap
import unittest
from pathlib import Path
from cadpy import catalog as cad_catalog
from cadpy import generation as cad_generation
from cadpy.metadata import parse_generator_metadata
from tests.python.support.tmp_root import temporary_directory
STANDALONE_DXF_SOURCE = textwrap.dedent(
'''
"""Standalone DXF drafting source."""
import ezdxf
def gen_dxf():
doc = ezdxf.new()
msp = doc.modelspace()
msp.add_lwpolyline([(0, 0), (40, 0), (40, 20), (0, 20)], close=True)
return doc
'''
).strip()
def _write_standalone_source(root: Path, stem: str = "outline") -> Path:
script_path = root / f"{stem}.py"
script_path.write_text(STANDALONE_DXF_SOURCE + "\n")
return script_path
class StandaloneDxfSourceTests(unittest.TestCase):
def test_metadata_allows_gen_dxf_without_gen_step(self) -> None:
with temporary_directory(prefix="dxf-skill") as root:
script_path = _write_standalone_source(Path(root))
metadata = parse_generator_metadata(script_path)
assert metadata is not None
self.assertTrue(metadata.has_gen_dxf)
self.assertFalse(metadata.has_gen_step)
self.assertIsNone(metadata.kind)
def test_metadata_still_requires_gen_step_for_urdf_and_sdf(self) -> None:
with temporary_directory(prefix="dxf-skill") as root:
script_path = Path(root) / "robot.py"
script_path.write_text("def gen_urdf():\n return '<robot/>'\n")
with self.assertRaisesRegex(ValueError, "require gen_step"):
parse_generator_metadata(script_path)
def test_explicit_target_resolves_dxf_only_source(self) -> None:
with temporary_directory(prefix="dxf-skill") as root:
script_path = _write_standalone_source(Path(root))
source = cad_catalog.source_from_path(script_path)
assert source is not None
self.assertEqual("dxf", source.kind)
self.assertIsNone(source.step_path)
self.assertEqual(script_path.with_suffix(".dxf"), source.dxf_path)
def test_directory_catalog_skips_dxf_only_source(self) -> None:
with temporary_directory(prefix="dxf-skill") as root:
_write_standalone_source(Path(root))
self.assertEqual((), cad_catalog.iter_cad_sources(Path(root)))
def test_generate_dxf_targets_writes_sibling_output(self) -> None:
with temporary_directory(prefix="dxf-skill") as root:
script_path = _write_standalone_source(Path(root))
self.assertEqual(0, cad_generation.generate_dxf_targets([str(script_path)]))
output_path = script_path.with_suffix(".dxf")
self.assertTrue(output_path.exists())
self.assertGreater(output_path.stat().st_size, 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import tempfile
import unittest
import zipfile
from pathlib import Path
from tests.python.support.paths import add_repo_path
add_repo_path("skills/gcode/scripts")
import gcode_tool as gcode
def make_executable(path: Path) -> None:
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
path.chmod(0o755)
def write_profile(tmp: Path, backend: str = "orcaslicer") -> Path:
native_config = tmp / f"{backend}.ini"
native_config.write_text("# slicer profile\n", encoding="utf-8")
profile = tmp / "profile.json"
profile.write_text(
json.dumps(
{
"backend": backend,
"native_config": str(native_config),
"machine": {
"name": "Test Printer",
"bed_size_mm": [180, 180],
"z_height_mm": 180,
},
"filament": {
"type": "PLA",
"nozzle_temp_c": 220,
"bed_temp_c": 65,
},
}
),
encoding="utf-8",
)
return profile
def write_profile_with_motion_bounds(tmp: Path) -> Path:
native_config = tmp / "orcaslicer.ini"
native_config.write_text("# slicer profile\n", encoding="utf-8")
profile = tmp / "profile_with_motion_bounds.json"
profile.write_text(
json.dumps(
{
"backend": "orcaslicer",
"native_config": str(native_config),
"machine": {
"name": "Test Printer",
"bed_size_mm": [180, 180],
"z_height_mm": 180,
"motion_bounds_mm": {
"x": [-14, 181],
"y": [-4, 185],
"z": [-1.1, 180],
},
},
"filament": {
"type": "PLA",
"nozzle_temp_c": 220,
"bed_temp_c": 65,
},
}
),
encoding="utf-8",
)
return profile
class GCodeToolTests(unittest.TestCase):
def test_discovers_fake_preferred_backend_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
bin_dir = Path(tmp) / "bin"
bin_dir.mkdir()
make_executable(bin_dir / "OrcaSlicer")
make_executable(bin_dir / "prusa-slicer")
report = gcode.discovery_report(search_path=str(bin_dir))
backends = {item["id"]: item for item in report["backends"]}
self.assertTrue(backends["orcaslicer"]["available"])
self.assertTrue(backends["prusa-slicer"]["available"])
self.assertFalse(backends["curaengine"]["available"])
self.assertEqual(report["preferred_order"], ["orcaslicer", "prusa-slicer", "curaengine"])
def test_profile_validation_requires_backend_native_config_and_bed_limits(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
missing_backend = root / "missing_backend.json"
missing_backend.write_text("{}", encoding="utf-8")
with self.assertRaisesRegex(gcode.GCodeToolError, "backend"):
gcode.load_profile(missing_backend)
missing_native = root / "missing_native.json"
missing_native.write_text(
json.dumps(
{
"backend": "orcaslicer",
"machine": {"name": "Printer", "bed_size_mm": [180, 180], "z_height_mm": 180},
"filament": {"type": "PLA", "nozzle_temp_c": 220, "bed_temp_c": 65},
}
),
encoding="utf-8",
)
with self.assertRaisesRegex(gcode.GCodeToolError, "native_config"):
gcode.load_profile(missing_native)
bad_bed = root / "bad_bed.json"
native_config = root / "profile.ini"
native_config.write_text("# config\n", encoding="utf-8")
bad_bed.write_text(
json.dumps(
{
"backend": "orcaslicer",
"native_config": str(native_config),
"machine": {"name": "Printer", "bed_size_mm": [180], "z_height_mm": 180},
"filament": {"type": "PLA", "nozzle_temp_c": 220, "bed_temp_c": 65},
}
),
encoding="utf-8",
)
with self.assertRaisesRegex(gcode.GCodeToolError, "bed_size_mm"):
gcode.load_profile(bad_bed)
def test_input_classification_for_supported_rejected_and_sliced_bambu_files(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
stl = root / "part.stl"
stl.write_text("solid part\nendsolid part\n", encoding="utf-8")
stl_info = gcode.inspect_input(stl)
self.assertTrue(stl_info.direct_to_slicer)
self.assertFalse(stl_info.needs_stl_conversion)
glb = root / "part.glb"
glb.write_bytes(b"glTF")
glb_info = gcode.inspect_input(glb)
self.assertTrue(glb_info.needs_stl_conversion)
sliced = root / "job.gcode.3mf"
with zipfile.ZipFile(sliced, "w") as archive:
archive.writestr("[Content_Types].xml", "<Types/>")
archive.writestr("Metadata/plate_1.gcode", "G1 X1\n")
sliced_info = gcode.inspect_input(sliced)
self.assertTrue(sliced_info.already_sliced_bambu)
self.assertEqual(sliced_info.status, "already_sliced_bambu_3mf")
step = root / "part.step"
step.write_text("ISO-10303-21;", encoding="utf-8")
with self.assertRaisesRegex(gcode.GCodeToolError, "out of scope"):
gcode.inspect_input(step)
def test_dry_run_command_construction_for_each_backend(self) -> None:
cases = [
("orcaslicer", "OrcaSlicer", ["--load-settings", "--outputdir", "--slice"]),
("prusa-slicer", "prusa-slicer", ["--load", "--export-gcode", "--output"]),
("curaengine", "CuraEngine", ["slice", "-j", "-l", "-o"]),
]
for backend, executable, expected_parts in cases:
with self.subTest(backend=backend), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bin_dir = root / "bin"
bin_dir.mkdir()
make_executable(bin_dir / executable)
profile = write_profile(root, backend)
model = root / "part.obj"
model.write_text("o part\n", encoding="utf-8")
output = root / "part.gcode"
args = gcode.build_parser().parse_args(
[
"slice",
"--input",
str(model),
"--output",
str(output),
"--profile",
str(profile),
"--backend",
"auto",
"--dry-run",
]
)
plan = gcode.build_slice_plan(args, search_path=str(bin_dir))
command = plan["command"]
self.assertEqual(Path(command[0]).name, executable)
for part in expected_parts:
self.assertIn(part, command)
self.assertEqual(plan["backend"], backend)
self.assertFalse(plan["conversion"]["required"])
def test_refuses_to_slice_already_sliced_bambu_3mf(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bin_dir = root / "bin"
bin_dir.mkdir()
make_executable(bin_dir / "OrcaSlicer")
profile = write_profile(root)
sliced = root / "job.gcode.3mf"
with zipfile.ZipFile(sliced, "w") as archive:
archive.writestr("Metadata/plate_1.gcode", "G1 X1\n")
args = gcode.build_parser().parse_args(
[
"slice",
"--input",
str(sliced),
"--output",
str(root / "job.gcode"),
"--profile",
str(profile),
"--dry-run",
]
)
with self.assertRaisesRegex(gcode.GCodeToolError, "already a sliced Bambu"):
gcode.build_slice_plan(args, search_path=str(bin_dir))
def test_gcode_validation_passes_valid_simple_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile = gcode.load_profile(write_profile(root))
toolpath = root / "valid.gcode"
toolpath.write_text(
"\n".join(
[
"M104 S220",
"M140 S65",
"G90",
"G1 X10 Y10 Z0.2 F1800",
"G1 X20 Y10 E0.4 F1200",
]
),
encoding="utf-8",
)
result = gcode.validate_gcode_file(toolpath, profile)
self.assertTrue(result["ok"])
self.assertEqual(result["errors"], [])
self.assertGreaterEqual(result["stats"]["extrusion_moves"], 1)
def test_gcode_validation_reports_empty_no_extrusion_and_out_of_bounds(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile = gcode.load_profile(write_profile(root))
empty = root / "empty.gcode"
empty.write_text("", encoding="utf-8")
empty_result = gcode.validate_gcode_file(empty, profile)
self.assertFalse(empty_result["ok"])
self.assertIn("G-code file is empty.", empty_result["errors"])
no_extrusion = root / "no_extrusion.gcode"
no_extrusion.write_text("M104 S220\nG1 X10 Y10 Z0.2\n", encoding="utf-8")
no_extrusion_result = gcode.validate_gcode_file(no_extrusion, profile)
self.assertFalse(no_extrusion_result["ok"])
self.assertIn("No extrusion moves found.", no_extrusion_result["errors"])
out_of_bounds = root / "out_of_bounds.gcode"
out_of_bounds.write_text("M104 S220\nG1 X999 Y10 Z0.2 E0.1\n", encoding="utf-8")
out_of_bounds_result = gcode.validate_gcode_file(out_of_bounds, profile)
self.assertFalse(out_of_bounds_result["ok"])
self.assertTrue(any("X=999.0" in error for error in out_of_bounds_result["errors"]))
def test_gcode_validation_uses_optional_motion_bounds(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile = gcode.load_profile(write_profile_with_motion_bounds(root))
toolpath = root / "native_start_positions.gcode"
toolpath.write_text("M104 S220\nM140 S65\nG90\nG1 X-13.5 Y-4 Z-1 E0.1\n", encoding="utf-8")
result = gcode.validate_gcode_file(toolpath, profile)
self.assertTrue(result["ok"])
def test_gcode_validation_warns_for_unknown_commands(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
profile = gcode.load_profile(write_profile(root))
toolpath = root / "unknown.gcode"
toolpath.write_text("M104 S220\nM999\nG1 X10 Y10 Z0.2 E0.1\n", encoding="utf-8")
result = gcode.validate_gcode_file(toolpath, profile)
self.assertTrue(result["ok"])
self.assertTrue(any("M999" in warning for warning in result["warnings"]))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,51 @@
import unittest
import xml.etree.ElementTree as ET
from tests.python.support.paths import add_repo_path
add_repo_path("skills/sdf/scripts")
from sdf import builder
class SdfBuilderTests(unittest.TestCase):
def test_builds_minimal_model_with_box_geometry(self) -> None:
root = builder.sdf_root()
model = builder.model(root, "fixture", static=False)
base = builder.link(model, "base_link")
visual = builder.visual(base, "base_visual")
builder.box(visual, (0.4, 0.3, 0.1))
collision = builder.collision(base, "base_collision")
builder.box(collision, (0.4, 0.3, 0.1))
xml = ET.tostring(root, encoding="unicode")
self.assertIn('<sdf version="1.12"', xml)
self.assertIn('<model name="fixture"', xml)
self.assertIn("<size>0.4 0.3 0.1</size>", xml)
def test_rejects_invalid_numeric_values(self) -> None:
root = builder.sdf_root()
model = builder.model(root, "fixture")
base = builder.link(model, "base_link")
visual = builder.visual(base, "base_visual")
with self.assertRaisesRegex(ValueError, "positive"):
builder.box(visual, (1, 0, 1))
with self.assertRaisesRegex(ValueError, "nonzero"):
builder.axis(model, (0, 0, 0))
def test_builds_static_plugin_metadata(self) -> None:
root = builder.sdf_root()
model = builder.model(root, "fixture")
builder.plugin(model, "controller", "gz-sim-joint-controller-system", topic="/cmd")
xml = ET.tostring(root, encoding="unicode")
self.assertIn('name="controller"', xml)
self.assertIn('filename="gz-sim-joint-controller-system"', xml)
self.assertIn("<topic>/cmd</topic>", xml)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,297 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from tests.python.support.paths import add_repo_path
add_repo_path("skills/sdf/scripts")
from sdf import cli
from sdf.source import SdfSourceError
def _write_sdf_source(path: Path, body: str) -> None:
path.write_text(
"\n".join(
[
"def gen_sdf():",
*[f" {line}" for line in body.splitlines()],
"",
]
),
encoding="utf-8",
)
def _strip_cadpy_metadata_comments(text: str) -> str:
return "\n".join(
line for line in text.splitlines() if not line.startswith("<!-- cadpy:")
) + "\n"
class SdfCliTests(unittest.TestCase):
def test_requires_explicit_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main([])
self.assertEqual(2, cm.exception.code)
def test_rejects_summary_option(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["sample_robot.py", "--summary"])
self.assertEqual(2, cm.exception.code)
def test_passes_targets_and_output(self) -> None:
with mock.patch.object(cli, "generate_sdf_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["sample_robot.py", "-o", "sample_robot.sdf"]))
generate.assert_called_once_with(["sample_robot.py"], output="sample_robot.sdf", gz_check="auto", strict=False)
def test_passes_gz_check_and_strict_options(self) -> None:
with mock.patch.object(cli, "generate_sdf_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["sample_robot.py", "--gz-check", "required", "--strict"]))
generate.assert_called_once_with(["sample_robot.py"], output=None, gz_check="required", strict=True)
def test_rejects_output_with_pair_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["sample_robot.py=sample_robot.sdf", "-o", "other.sdf"])
self.assertEqual(2, cm.exception.code)
def test_generates_default_sibling_output_from_xml_string(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(
source_path,
"return '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>'",
)
self.assertEqual(0, cli.generate_sdf_targets([str(source_path)]))
output_text = source_path.with_suffix(".sdf").read_text(encoding="utf-8")
self.assertIn("<!-- cadpy:sourcePath=", output_text)
self.assertEqual(
'<sdf version="1.12"><model name="sample"><link name="base_link" /></model></sdf>\n',
_strip_cadpy_metadata_comments(output_text),
)
def test_validates_generated_sdf_before_writing_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
output_path = source_path.with_suffix(".sdf")
output_path.write_text("original output\n", encoding="utf-8")
_write_sdf_source(
source_path,
"return '<robot name=\"not_sdf\" />'",
)
with self.assertRaisesRegex(SdfSourceError, "root element must be <sdf>"):
cli.generate_sdf_targets([str(source_path)])
self.assertEqual("original output\n", output_path.read_text(encoding="utf-8"))
def test_generates_default_sibling_output_from_element_root(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
source_path.write_text(
"\n".join(
[
"import xml.etree.ElementTree as ET",
"",
"def gen_sdf():",
" sdf = ET.Element('sdf', {'version': '1.12'})",
" model = ET.SubElement(sdf, 'model', {'name': 'sample'})",
" ET.SubElement(model, 'link', {'name': 'base_link'})",
" return sdf",
"",
]
),
encoding="utf-8",
)
self.assertEqual(0, cli.generate_sdf_targets([str(source_path)]))
output_text = source_path.with_suffix(".sdf").read_text(encoding="utf-8")
self.assertIn("<!-- cadpy:sourcePath=", output_text)
self.assertEqual(
'<?xml version="1.0"?>\n'
'<sdf version="1.12">\n'
' <model name="sample">\n'
' <link name="base_link" />\n'
' </model>\n'
'</sdf>\n',
_strip_cadpy_metadata_comments(output_text),
)
def test_generates_envelope_output_from_element_root(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
source_path.write_text(
"\n".join(
[
"import xml.etree.ElementTree as ET",
"",
"def gen_sdf():",
" sdf = ET.Element('sdf', {'version': '1.12'})",
" model = ET.SubElement(sdf, 'model', {'name': 'sample'})",
" ET.SubElement(model, 'link', {'name': 'base_link'})",
" return {'xml': sdf}",
"",
]
),
encoding="utf-8",
)
self.assertEqual(0, cli.generate_sdf_targets([str(source_path)]))
self.assertIn(
'<link name="base_link" />',
source_path.with_suffix(".sdf").read_text(encoding="utf-8"),
)
def test_generates_output_override_for_single_target(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
output_path = Path(tempdir) / "custom" / "robot.sdf"
_write_sdf_source(
source_path,
"return '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>'",
)
self.assertEqual(0, cli.generate_sdf_targets([str(source_path)], output=str(output_path)))
self.assertTrue(output_path.exists())
self.assertFalse(source_path.with_suffix(".sdf").exists())
def test_generates_mixed_plain_and_paired_targets(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
root = Path(tempdir)
first_path = root / "first.py"
second_path = root / "second.py"
second_output = root / "custom" / "second.sdf"
_write_sdf_source(first_path, "return '<sdf version=\"1.12\"><model name=\"first\"><link name=\"base\" /></model></sdf>'")
_write_sdf_source(second_path, "return '<sdf version=\"1.12\"><model name=\"second\"><link name=\"base\" /></model></sdf>'")
self.assertEqual(0, cli.generate_sdf_targets([str(first_path), f"{second_path}={second_output}"]))
self.assertTrue(first_path.with_suffix(".sdf").exists())
self.assertTrue(second_output.exists())
self.assertFalse(second_path.with_suffix(".sdf").exists())
def test_rejects_legacy_sdf_output_field(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>',",
" 'sdf_output': 'legacy/ignored.sdf',",
"}",
]
),
)
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): sdf_output"):
cli.generate_sdf_targets([str(source_path)])
self.assertFalse(source_path.with_suffix(".sdf").exists())
self.assertFalse((Path(tempdir) / "legacy" / "ignored.sdf").exists())
def test_envelope_prints_assumptions_and_warnings(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>',",
" 'metadata': {'target_consumer': 'CAD Viewer'},",
" 'assumptions': [{'code': 'mesh_units', 'message': 'Assumed mesh units are meters'}],",
" 'warnings': ['Plugin startup was not smoke-tested'],",
"}",
]
),
)
with mock.patch("builtins.print") as print_mock:
self.assertEqual(0, cli.generate_sdf_targets([str(source_path)], gz_check="never"))
printed = "\n".join(" ".join(str(arg) for arg in call.args) for call in print_mock.call_args_list)
self.assertIn("Assumption [mesh_units]", printed)
self.assertIn("generator_warning", printed)
def test_rejects_unknown_envelope_fields(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>',",
" 'unexpected': True,",
"}",
]
),
)
with self.assertRaisesRegex(TypeError, "unsupported field"):
cli.generate_sdf_targets([str(source_path)], gz_check="never")
def test_strict_rejects_generator_warnings_before_writing(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>',",
" 'warnings': ['Unverified plugin'],",
"}",
]
),
)
with self.assertRaisesRegex(SdfSourceError, "generator_warning"):
cli.generate_sdf_targets([str(source_path)], gz_check="never", strict=True)
self.assertFalse(source_path.with_suffix(".sdf").exists())
def test_gz_check_required_failure_prevents_write(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(
source_path,
"return '<sdf version=\"1.12\"><model name=\"sample\"><link name=\"base_link\" /></model></sdf>'",
)
with mock.patch("sdf.external.shutil.which", return_value=None):
with self.assertRaisesRegex(SdfSourceError, "gz_check_unavailable"):
cli.generate_sdf_targets([str(source_path)], gz_check="required")
self.assertFalse(source_path.with_suffix(".sdf").exists())
def test_rejects_invalid_output_suffix(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_sdf_source(source_path, "return '<sdf version=\"1.12\"><model name=\"sample\" /></sdf>'")
with self.assertRaisesRegex(ValueError, "must end in .sdf"):
cli.generate_sdf_targets([f"{source_path}={Path(tempdir) / 'sample.xml'}"])
def test_rejects_duplicate_output_paths(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-sdf-") as tempdir:
first_path = Path(tempdir) / "first.py"
second_path = Path(tempdir) / "second.py"
output_path = Path(tempdir) / "same.sdf"
_write_sdf_source(first_path, "return '<sdf version=\"1.12\"><model name=\"first\"><link name=\"base\" /></model></sdf>'")
_write_sdf_source(second_path, "return '<sdf version=\"1.12\"><model name=\"second\"><link name=\"base\" /></model></sdf>'")
with self.assertRaisesRegex(ValueError, "used more than once"):
cli.generate_sdf_targets([f"{first_path}={output_path}", f"{second_path}={output_path}"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,294 @@
import tempfile
import unittest
from pathlib import Path
from sdf.source import SdfSourceError, parse_sdf_xml, read_sdf_source
class SdfSourceTests(unittest.TestCase):
def setUp(self) -> None:
self._tempdir = tempfile.TemporaryDirectory(prefix="tmp-sdf-source-")
self.temp_root = Path(self._tempdir.name)
def tearDown(self) -> None:
self._tempdir.cleanup()
def _file_ref(self, name: str) -> str:
return (self.temp_root / f"{name}.sdf").resolve().as_posix()
def _write_mesh(self, name: str) -> Path:
mesh_path = self.temp_root / "meshes" / name
mesh_path.parent.mkdir(parents=True, exist_ok=True)
mesh_path.write_text("solid empty\nendsolid empty\n", encoding="utf-8")
return mesh_path
def _write_sdf(self, name: str, body: str) -> Path:
sdf_path = self.temp_root / f"{name}.sdf"
sdf_path.write_text(body.strip() + "\n", encoding="utf-8")
return sdf_path
def test_read_sdf_source_accepts_minimal_model(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
</model>
</sdf>
""",
)
source = read_sdf_source(source_path)
self.assertEqual(self._file_ref("robot"), source.file_ref)
self.assertEqual("1.12", source.version)
self.assertEqual(("sample",), source.model_names)
self.assertEqual((), source.world_names)
self.assertEqual(("base_link",), source.links)
self.assertEqual(0, len(source.joints))
def test_parse_sdf_xml_validates_without_existing_output_file(self) -> None:
output_path = self.temp_root / "generated" / "robot.sdf"
source = parse_sdf_xml(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
</model>
</sdf>
""",
source_path=output_path,
base_dir=output_path.parent,
)
self.assertEqual(output_path.resolve().as_posix(), source.file_ref)
self.assertEqual(("sample",), source.model_names)
self.assertFalse(output_path.exists())
def test_read_sdf_source_accepts_local_mesh_references(self) -> None:
mesh_path = self._write_mesh("base.stl")
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual">
<geometry>
<mesh>
<uri>meshes/base.stl</uri>
</mesh>
</geometry>
</visual>
<collision name="base_collision">
<geometry>
<mesh>
<uri>meshes/base.stl</uri>
</mesh>
</geometry>
</collision>
</link>
</model>
</sdf>
""",
)
source = read_sdf_source(source_path)
self.assertEqual((mesh_path.resolve(), mesh_path.resolve()), source.mesh_paths)
self.assertEqual((mesh_path.resolve(),), source.visual_mesh_paths)
self.assertEqual((mesh_path.resolve(),), source.collision_mesh_paths)
def test_read_sdf_source_accepts_external_mesh_uri_without_resolution(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual">
<geometry>
<mesh>
<uri>model://sample/meshes/base.dae</uri>
</mesh>
</geometry>
</visual>
</link>
</model>
</sdf>
""",
)
source = read_sdf_source(source_path)
self.assertEqual((), source.mesh_paths)
def test_read_sdf_source_accepts_world_models(self) -> None:
source_path = self._write_sdf(
"world",
"""
<sdf version="1.12">
<world name="sample_world">
<model name="sample">
<link name="base_link" />
</model>
</world>
</sdf>
""",
)
source = read_sdf_source(source_path)
self.assertEqual(("sample_world",), source.world_names)
self.assertEqual(("sample",), source.model_names)
def test_read_sdf_source_accepts_world_only_scene(self) -> None:
source_path = self._write_sdf(
"world_only",
"""
<sdf version="1.12">
<world name="sample_world">
<include>
<uri>model://sun</uri>
</include>
</world>
</sdf>
""",
)
source = read_sdf_source(source_path)
self.assertEqual(("sample_world",), source.world_names)
self.assertEqual((), source.model_names)
def test_read_sdf_source_rejects_missing_root(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<model name="sample">
<link name="base_link" />
</model>
""",
)
with self.assertRaisesRegex(SdfSourceError, "root element must be <sdf>"):
read_sdf_source(source_path)
def test_read_sdf_source_rejects_missing_version(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf>
<model name="sample">
<link name="base_link" />
</model>
</sdf>
""",
)
with self.assertRaisesRegex(SdfSourceError, "version is required"):
read_sdf_source(source_path)
def test_read_sdf_source_rejects_missing_model_name(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model>
<link name="base_link" />
</model>
</sdf>
""",
)
with self.assertRaisesRegex(SdfSourceError, "model name is required"):
read_sdf_source(source_path)
def test_read_sdf_source_rejects_duplicate_links(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="base_link" />
</model>
</sdf>
""",
)
with self.assertRaisesRegex(SdfSourceError, "duplicates"):
read_sdf_source(source_path)
def test_read_sdf_source_rejects_missing_joint_child_link(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="arm_link" />
<joint name="base_to_arm" type="revolute">
<parent>base_link</parent>
<child>missing_link</child>
</joint>
</model>
</sdf>
""",
)
with self.assertRaisesRegex(SdfSourceError, "missing link"):
read_sdf_source(source_path)
def test_read_sdf_source_rejects_duplicate_joints(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="arm_link" />
<joint name="base_to_arm" type="fixed">
<parent>base_link</parent>
<child>arm_link</child>
</joint>
<joint name="base_to_arm" type="fixed">
<parent>base_link</parent>
<child>arm_link</child>
</joint>
</model>
</sdf>
""",
)
with self.assertRaisesRegex(SdfSourceError, "duplicates"):
read_sdf_source(source_path)
def test_read_sdf_source_rejects_missing_local_mesh(self) -> None:
source_path = self._write_sdf(
"robot",
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual">
<geometry>
<mesh>
<uri>meshes/missing.stl</uri>
</mesh>
</geometry>
</visual>
</link>
</model>
</sdf>
""",
)
with self.assertRaisesRegex(SdfSourceError, "missing mesh file"):
read_sdf_source(source_path)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,338 @@
import tempfile
import unittest
from pathlib import Path
from sdf.validation import validate_sdf_xml
class SdfValidationTests(unittest.TestCase):
def setUp(self) -> None:
self._tempdir = tempfile.TemporaryDirectory(prefix="tmp-sdf-validation-")
self.temp_root = Path(self._tempdir.name)
def tearDown(self) -> None:
self._tempdir.cleanup()
def _validate(self, body: str):
return validate_sdf_xml(
body.strip(),
source_path=self.temp_root / "generated.sdf",
base_dir=self.temp_root,
)
def _error_codes(self, body: str) -> set[str]:
return {finding.code for finding in self._validate(body).errors}
def _warning_codes(self, body: str) -> set[str]:
return {finding.code for finding in self._validate(body).warnings}
def test_valid_minimal_model_passes(self) -> None:
result = self._validate(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
</model>
</sdf>
"""
)
self.assertEqual([], result.errors)
def test_valid_world_only_scene_passes(self) -> None:
result = self._validate(
"""
<sdf version="1.12">
<world name="sample_world">
<include><uri>model://sun</uri></include>
<light name="key" type="directional" />
</world>
</sdf>
"""
)
self.assertEqual([], result.errors)
def test_malformed_pose_length_fails(self) -> None:
self.assertIn(
"invalid_numeric_vector",
self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<pose>1 2 3</pose>
<link name="base_link" />
</model>
</sdf>
"""
),
)
def test_quaternion_pose_zero_norm_fails_and_non_unit_warns(self) -> None:
self.assertIn(
"zero_quaternion",
self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<pose rotation_format="quat_xyzw">0 0 0 0 0 0 0</pose>
<link name="base_link" />
</model>
</sdf>
"""
),
)
self.assertIn(
"non_unit_quaternion",
self._warning_codes(
"""
<sdf version="1.12">
<model name="sample">
<pose rotation_format="quat_xyzw">0 0 0 0 0 0 2</pose>
<link name="base_link" />
</model>
</sdf>
"""
),
)
def test_nontrivial_pose_without_relative_to_warns(self) -> None:
self.assertIn(
"pose_missing_relative_to",
self._warning_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<pose>1 0 0 0 0 0</pose>
</link>
</model>
</sdf>
"""
),
)
def test_frame_cycle_fails(self) -> None:
self.assertIn(
"frame_cycle",
self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<frame name="a" attached_to="b" />
<frame name="b" attached_to="a" />
<link name="base_link" />
</model>
</sdf>
"""
),
)
def test_joint_type_and_world_child_are_checked(self) -> None:
illegal_type_errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="arm_link" />
<joint name="bad" type="hinge">
<parent>base_link</parent>
<child>arm_link</child>
</joint>
</model>
</sdf>
"""
)
self.assertIn("unknown_joint_type", illegal_type_errors)
world_child_errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<joint name="bad" type="fixed">
<parent>base_link</parent>
<child>world</child>
</joint>
</model>
</sdf>
"""
)
self.assertIn("invalid_joint_child", world_child_errors)
def test_axis_zero_fails_and_non_unit_warns(self) -> None:
zero_errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="arm_link" />
<joint name="bad" type="revolute">
<parent>base_link</parent>
<child>arm_link</child>
<axis><xyz>0 0 0</xyz></axis>
</joint>
</model>
</sdf>
"""
)
self.assertIn("zero_axis", zero_errors)
warnings = self._warning_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="arm_link" />
<joint name="warns" type="revolute">
<parent>base_link</parent>
<child>arm_link</child>
<axis><xyz>0 0 2</xyz></axis>
</joint>
</model>
</sdf>
"""
)
self.assertIn("non_unit_axis", warnings)
def test_invalid_primitive_dimensions_fail(self) -> None:
errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual">
<geometry><box><size>1 0 1</size></box></geometry>
</visual>
</link>
</model>
</sdf>
"""
)
self.assertIn("invalid_dimension", errors)
def test_visual_without_geometry_fails(self) -> None:
self.assertIn(
"invalid_geometry_count",
self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual" />
</link>
</model>
</sdf>
"""
),
)
def test_mesh_uri_resolution_distinguishes_local_and_external(self) -> None:
local_errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual">
<geometry><mesh><uri>meshes/missing.stl</uri></mesh></geometry>
</visual>
</link>
</model>
</sdf>
"""
)
self.assertIn("missing_mesh_file", local_errors)
external_result = self._validate(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<visual name="base_visual">
<geometry><mesh><uri>package://sample/meshes/base.dae</uri></mesh></geometry>
</visual>
</link>
</model>
</sdf>
"""
)
self.assertEqual([], external_result.errors)
def test_invalid_inertial_values_fail(self) -> None:
mass_errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<inertial><mass>0</mass></inertial>
</link>
</model>
</sdf>
"""
)
self.assertIn("invalid_mass", mass_errors)
inertia_errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<inertial>
<mass>1</mass>
<inertia>
<ixx>1</ixx><iyy>1</iyy><izz>-1</izz>
<ixy>0</ixy><ixz>0</ixz><iyz>0</iyz>
</inertia>
</inertial>
</link>
</model>
</sdf>
"""
)
self.assertIn("invalid_inertia_matrix", inertia_errors)
def test_sensor_plugin_and_include_required_fields(self) -> None:
errors = self._error_codes(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link">
<sensor name="camera" />
</link>
<plugin name="controller" />
<include />
</model>
</sdf>
"""
)
self.assertIn("missing_sensor_type", errors)
self.assertIn("missing_plugin_filename", errors)
self.assertIn("missing_child_text", errors)
def test_plugin_contents_are_static_metadata_not_motion_contracts(self) -> None:
result = self._validate(
"""
<sdf version="1.12">
<model name="sample">
<link name="base_link" />
<link name="door_link" />
<joint name="door_joint" type="fixed">
<parent>base_link</parent>
<child>door_link</child>
</joint>
<plugin name="controller" filename="gz-sim-custom-controller-system">
<custom_parameter>opaque</custom_parameter>
<nested_block invalid_for_validator="but_plugin_owned" />
</plugin>
</model>
</sdf>
"""
)
self.assertEqual([], result.errors)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,269 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from tests.python.support.paths import add_repo_path
add_repo_path("skills/srdf/scripts")
from srdf import cli
SAMPLE_URDF = """\
<robot name="sample">
<link name="base"/>
<link name="shoulder_link"/>
<link name="wrist"/>
<link name="tool"/>
<joint name="shoulder" type="revolute">
<parent link="base"/>
<child link="shoulder_link"/>
<limit lower="-1" upper="1" effort="1" velocity="1"/>
</joint>
<joint name="elbow" type="revolute">
<parent link="shoulder_link"/>
<child link="wrist"/>
<limit lower="-1" upper="1" effort="1" velocity="1"/>
</joint>
<joint name="wrist_tool" type="fixed">
<parent link="wrist"/>
<child link="tool"/>
</joint>
</robot>
"""
SAMPLE_SRDF = """\
<robot name="sample" xmlns:tcad="https://text-to-cad.dev/srdf">
<tcad:urdf path="robot.urdf"/>
<group name="arm">
<joint name="shoulder"/>
<joint name="elbow"/>
</group>
<group name="gripper">
<link name="tool"/>
</group>
<end_effector name="tcp" parent_link="wrist" group="gripper" parent_group="arm"/>
<group_state name="home" group="arm">
<joint name="shoulder" value="0"/>
<joint name="elbow" value="0"/>
</group_state>
<disable_collisions link1="wrist" link2="tool" reason="Adjacent"/>
</robot>
"""
def write_source(path: Path, payload: object | None = None, *, function_name: str = "gen_srdf") -> None:
if payload is None:
payload = {"xml": SAMPLE_SRDF, "urdf": "robot.urdf"}
path.write_text(
f"def {function_name}():\n"
f" return {payload!r}\n",
encoding="utf-8",
)
class SrdfCliTests(unittest.TestCase):
def test_writes_sibling_srdf_without_hidden_artifact(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "robot_srdf.py"
write_source(source_path)
self.assertEqual(0, cli.generate_srdf_targets([str(source_path)]))
output_path = root / "robot_srdf.srdf"
self.assertTrue(output_path.is_file())
output_text = output_path.read_text(encoding="utf-8")
self.assertIn("tcad:urdf", output_text)
self.assertIn("path=\"robot.urdf\"", output_text)
self.assertIn("<group name=\"arm\">", output_text)
self.assertFalse((root / ".robot.urdf").exists())
def test_injects_tcad_urdf_link(self) -> None:
srdf = """\
<robot name="sample">
<group name="arm">
<chain base_link="base" tip_link="tool"/>
</group>
</robot>
"""
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "robot_srdf.py"
write_source(source_path, {"xml": srdf, "urdf": "robot.urdf"})
self.assertEqual(0, cli.generate_srdf_targets([str(source_path)]))
output_text = (root / "robot_srdf.srdf").read_text(encoding="utf-8")
self.assertIn("tcad:urdf", output_text)
self.assertIn("path=\"robot.urdf\"", output_text)
def test_updates_legacy_explorer_urdf_link(self) -> None:
legacy_srdf = SAMPLE_SRDF.replace(
'xmlns:tcad="https://text-to-cad.dev/srdf"',
'xmlns:explorer="https://text-to-cad.dev/explorer"',
).replace("tcad:urdf", "explorer:urdf")
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "robot_srdf.py"
write_source(source_path, {"xml": legacy_srdf, "urdf": "robot.urdf"})
self.assertEqual(0, cli.generate_srdf_targets([str(source_path)]))
output_text = (root / "robot_srdf.srdf").read_text(encoding="utf-8")
self.assertIn("tcad:urdf", output_text)
self.assertNotIn("explorer:urdf", output_text)
self.assertIn("path=\"robot.urdf\"", output_text)
def test_writes_srdf_from_element_root(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "robot_srdf.py"
source_path.write_text(
"\n".join(
[
"import xml.etree.ElementTree as ET",
"",
"def gen_srdf():",
" robot = ET.Element('robot', {'name': 'sample'})",
" group = ET.SubElement(robot, 'group', {'name': 'arm'})",
" ET.SubElement(group, 'joint', {'name': 'shoulder'})",
" ET.SubElement(group, 'joint', {'name': 'elbow'})",
" return {'xml': robot, 'urdf': 'robot.urdf'}",
"",
]
),
encoding="utf-8",
)
self.assertEqual(0, cli.generate_srdf_targets([str(source_path)]))
output_text = (root / "robot_srdf.srdf").read_text(encoding="utf-8")
self.assertTrue(output_text.startswith('<?xml version="1.0"?>\n'))
self.assertIn("tcad:urdf", output_text)
self.assertIn("<group name=\"arm\">", output_text)
self.assertIn("<joint name=\"shoulder\" />", output_text)
def test_supports_output_option(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
output_path = root / "out" / "robot.srdf"
write_source(source_path)
self.assertEqual(0, cli.generate_srdf_targets([str(source_path)], output=str(output_path)))
self.assertTrue(output_path.is_file())
def test_supports_source_output_pairs(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
output_path = root / "out" / "robot.srdf"
write_source(source_path)
self.assertEqual(0, cli.generate_srdf_targets([f"{source_path}={output_path}"]))
self.assertTrue(output_path.is_file())
def test_rejects_missing_gen_srdf(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
write_source(source_path, function_name="gen_urdf")
with self.assertRaisesRegex(RuntimeError, "gen_srdf"):
cli.generate_srdf_targets([str(source_path)])
def test_rejects_missing_xml_or_urdf(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
source_path = root / "source.py"
write_source(source_path, {"xml": SAMPLE_SRDF})
with self.assertRaisesRegex(TypeError, "urdf"):
cli.generate_srdf_targets([str(source_path)])
write_source(source_path, {"urdf": "robot.urdf"})
with self.assertRaisesRegex(TypeError, "xml"):
cli.generate_srdf_targets([str(source_path)])
def test_rejects_invalid_xml_or_urdf_reference(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
write_source(source_path, {"xml": "<sdf/>", "urdf": "robot.urdf"})
with self.assertRaisesRegex(Exception, "root element must be <robot>"):
cli.generate_srdf_targets([str(source_path)])
write_source(source_path, {"xml": SAMPLE_SRDF, "urdf": "missing.urdf"})
with self.assertRaisesRegex(FileNotFoundError, "urdf file does not exist"):
cli.generate_srdf_targets([str(source_path)])
def test_rejects_group_state_values_outside_group_or_limits(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
write_source(
source_path,
{
"xml": SAMPLE_SRDF.replace('<joint name="shoulder" value="0"/>', '<joint name="shoulder" value="2"/>'),
"urdf": "robot.urdf",
},
)
with self.assertRaisesRegex(Exception, "above its URDF upper limit"):
cli.generate_srdf_targets([str(source_path)])
write_source(
source_path,
{
"xml": SAMPLE_SRDF.replace('<joint name="shoulder" value="0"/>', '<joint name="wrist_tool" value="0"/>'),
"urdf": "robot.urdf",
},
)
with self.assertRaisesRegex(Exception, "not in group"):
cli.generate_srdf_targets([str(source_path)])
def test_rejects_overlapping_end_effector_group(self) -> None:
srdf = SAMPLE_SRDF.replace('<link name="tool"/>', '<link name="wrist"/>')
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
write_source(source_path, {"xml": srdf, "urdf": "robot.urdf"})
with self.assertRaisesRegex(Exception, "shares link"):
cli.generate_srdf_targets([str(source_path)])
def test_rejects_disabled_collision_without_reason(self) -> None:
srdf = SAMPLE_SRDF.replace(' reason="Adjacent"', "")
with tempfile.TemporaryDirectory(prefix="tmp-gen-srdf-") as tempdir:
root = Path(tempdir)
(root / "robot.urdf").write_text(SAMPLE_URDF, encoding="utf-8")
source_path = root / "source.py"
write_source(source_path, {"xml": srdf, "urdf": "robot.urdf"})
with self.assertRaisesRegex(Exception, "requires a reason"):
cli.generate_srdf_targets([str(source_path)])
def test_output_must_be_srdf(self) -> None:
with self.assertRaisesRegex(ValueError, "must end in .srdf"):
cli.generate_srdf_targets(["source.py=out.xml"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from srdf.source import SrdfSourceError, read_srdf_source
SAMPLE_SRDF = """\
<robot name="sample" xmlns:tcad="https://text-to-cad.dev/srdf">
<tcad:urdf path="robot.urdf"/>
<group name="arm">
<joint name="shoulder"/>
<joint name="elbow"/>
</group>
<group name="gripper">
<link name="tool"/>
</group>
<end_effector name="tcp" parent_link="wrist" group="gripper" parent_group="arm"/>
<group_state name="home" group="arm">
<joint name="shoulder" value="0"/>
<joint name="elbow" value="1.57"/>
</group_state>
<disable_collisions link1="base" link2="shoulder_link" reason="Adjacent"/>
</robot>
"""
LEGACY_SAMPLE_SRDF = SAMPLE_SRDF.replace(
'xmlns:tcad="https://text-to-cad.dev/srdf"',
'xmlns:explorer="https://text-to-cad.dev/explorer"',
).replace("tcad:urdf", "explorer:urdf")
class SrdfSourceTests(unittest.TestCase):
def test_reads_moveit2_srdf_inventory(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-srdf-source-") as tempdir:
srdf_path = Path(tempdir) / "robot.srdf"
srdf_path.write_text(SAMPLE_SRDF, encoding="utf-8")
source = read_srdf_source(srdf_path)
self.assertEqual(source.robot_name, "sample")
self.assertEqual(source.urdf_ref, "robot.urdf")
self.assertEqual(source.planning_groups[0].name, "arm")
self.assertEqual(source.planning_groups[0].joint_names, ("shoulder", "elbow"))
self.assertEqual(source.end_effectors[0].name, "tcp")
self.assertEqual(source.group_states[0].joint_values_by_name_rad["elbow"], 1.57)
self.assertEqual(source.disabled_collision_pairs[0].reason, "Adjacent")
def test_reads_legacy_explorer_urdf_metadata(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-srdf-source-") as tempdir:
srdf_path = Path(tempdir) / "robot.srdf"
srdf_path.write_text(LEGACY_SAMPLE_SRDF, encoding="utf-8")
source = read_srdf_source(srdf_path)
self.assertEqual(source.urdf_ref, "robot.urdf")
def test_rejects_non_robot_root(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-srdf-source-") as tempdir:
srdf_path = Path(tempdir) / "robot.srdf"
srdf_path.write_text("<sdf/>", encoding="utf-8")
with self.assertRaisesRegex(SrdfSourceError, "root element must be <robot>"):
read_srdf_source(srdf_path)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,264 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from tests.python.support.paths import add_repo_path
add_repo_path("skills/urdf/scripts")
from urdf import cli
from urdf.source import UrdfSourceError
def _write_urdf_source(path: Path, body: str) -> None:
path.write_text(
"\n".join(
[
"def gen_urdf():",
*[f" {line}" for line in body.splitlines()],
"",
]
),
encoding="utf-8",
)
def _strip_cadpy_metadata_comments(text: str) -> str:
return "\n".join(
line for line in text.splitlines() if not line.startswith("<!-- cadpy:")
) + "\n"
class UrdfCliTests(unittest.TestCase):
def test_requires_explicit_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main([])
self.assertEqual(2, cm.exception.code)
def test_rejects_summary_option(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["sample_robot.py", "--summary"])
self.assertEqual(2, cm.exception.code)
def test_rejects_validate_options(self) -> None:
for option in ("--validate", "--no-validate"):
with self.subTest(option=option):
with self.assertRaises(SystemExit) as cm:
cli.main(["sample_robot.py", option])
self.assertEqual(2, cm.exception.code)
def test_passes_targets_and_output(self) -> None:
with mock.patch.object(cli, "generate_urdf_targets", return_value=0) as generate:
self.assertEqual(0, cli.main(["sample_robot.py", "-o", "sample_robot.urdf"]))
generate.assert_called_once_with(["sample_robot.py"], output="sample_robot.urdf")
def test_rejects_output_with_pair_target(self) -> None:
with self.assertRaises(SystemExit) as cm:
cli.main(["sample_robot.py=sample_robot.urdf", "-o", "other.urdf"])
self.assertEqual(2, cm.exception.code)
def test_generates_default_sibling_output_from_xml_string(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_urdf_source(
source_path,
"return '<robot name=\"sample\"><link name=\"base_link\" /></robot>'",
)
self.assertEqual(0, cli.generate_urdf_targets([str(source_path)]))
output_text = source_path.with_suffix(".urdf").read_text(encoding="utf-8")
self.assertIn("<!-- cadpy:sourcePath=", output_text)
self.assertEqual(
'<robot name="sample"><link name="base_link" /></robot>\n',
_strip_cadpy_metadata_comments(output_text),
)
def test_generates_default_sibling_output_from_element_root(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
source_path.write_text(
"\n".join(
[
"import xml.etree.ElementTree as ET",
"",
"def gen_urdf():",
" robot = ET.Element('robot', {'name': 'sample'})",
" ET.SubElement(robot, 'link', {'name': 'base_link'})",
" return robot",
"",
]
),
encoding="utf-8",
)
self.assertEqual(0, cli.generate_urdf_targets([str(source_path)]))
output_text = source_path.with_suffix(".urdf").read_text(encoding="utf-8")
self.assertIn("<!-- cadpy:sourcePath=", output_text)
self.assertEqual(
'<?xml version="1.0"?>\n<robot name="sample">\n <link name="base_link" />\n</robot>\n',
_strip_cadpy_metadata_comments(output_text),
)
def test_generates_envelope_output_from_element_root(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
source_path.write_text(
"\n".join(
[
"import xml.etree.ElementTree as ET",
"",
"def gen_urdf():",
" robot = ET.Element('robot', {'name': 'sample'})",
" ET.SubElement(robot, 'link', {'name': 'base_link'})",
" return {'xml': robot}",
"",
]
),
encoding="utf-8",
)
self.assertEqual(0, cli.generate_urdf_targets([str(source_path)]))
self.assertIn(
'<link name="base_link" />',
source_path.with_suffix(".urdf").read_text(encoding="utf-8"),
)
def test_generates_output_override_for_single_target(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
output_path = Path(tempdir) / "custom" / "robot.urdf"
_write_urdf_source(
source_path,
"return '<robot name=\"sample\"><link name=\"base_link\" /></robot>'",
)
self.assertEqual(0, cli.generate_urdf_targets([str(source_path)], output=str(output_path)))
self.assertTrue(output_path.exists())
self.assertFalse(source_path.with_suffix(".urdf").exists())
def test_validates_generated_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
output_path = Path(tempdir) / "custom" / "robot.urdf"
_write_urdf_source(
source_path,
"return '<robot name=\"sample\"><link name=\"base_link\" /></robot>'",
)
with mock.patch.object(cli, "read_urdf_source") as read_urdf_source:
self.assertEqual(0, cli.generate_urdf_targets([str(source_path)], output=str(output_path)))
read_urdf_source.assert_called_once_with(output_path.resolve())
def test_rejects_invalid_generated_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
output_path = Path(tempdir) / "custom" / "robot.urdf"
_write_urdf_source(source_path, "return '<not-robot />'")
with self.assertRaisesRegex(UrdfSourceError, "root element must be <robot>"):
cli.generate_urdf_targets([str(source_path)], output=str(output_path))
def test_generates_mixed_plain_and_paired_targets(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
root = Path(tempdir)
first_path = root / "first.py"
second_path = root / "second.py"
second_output = root / "custom" / "second.urdf"
_write_urdf_source(first_path, "return '<robot name=\"first\"><link name=\"base\" /></robot>'")
_write_urdf_source(second_path, "return '<robot name=\"second\"><link name=\"base\" /></robot>'")
self.assertEqual(0, cli.generate_urdf_targets([str(first_path), f"{second_path}={second_output}"]))
self.assertTrue(first_path.with_suffix(".urdf").exists())
self.assertTrue(second_output.exists())
self.assertFalse(second_path.with_suffix(".urdf").exists())
def test_rejects_legacy_urdf_output_field(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_urdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<robot name=\"sample\"><link name=\"base_link\" /></robot>',",
" 'urdf_output': 'legacy/ignored.urdf',",
"}",
]
),
)
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): urdf_output"):
cli.generate_urdf_targets([str(source_path)])
def test_rejects_legacy_validate_field(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_urdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<robot name=\"sample\"><link name=\"base_link\" /></robot>',",
" 'validate': False,",
"}",
]
),
)
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): validate"):
cli.generate_urdf_targets([str(source_path)])
def test_rejects_explorer_metadata_field(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
output_path = Path(tempdir) / "custom" / "sample_robot.urdf"
_write_urdf_source(
source_path,
"\n".join(
[
"return {",
" 'xml': '<robot name=\"sample\"><link name=\"base_link\" /></robot>',",
" 'explorer_metadata': {",
" 'schemaVersion': 1,",
" 'kind': 'example-urdf-consumer',",
" 'defaultJoints': {'joint_1': 90},",
" },",
"}",
]
),
)
with self.assertRaisesRegex(TypeError, "unsupported field\\(s\\): explorer_metadata"):
cli.generate_urdf_targets([f"{source_path}={output_path}"])
self.assertFalse(output_path.exists())
def test_rejects_invalid_output_suffix(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
source_path = Path(tempdir) / "sample_robot.py"
_write_urdf_source(source_path, "return '<robot name=\"sample\"><link name=\"base\" /></robot>'")
with self.assertRaisesRegex(ValueError, "must end in .urdf"):
cli.generate_urdf_targets([f"{source_path}={Path(tempdir) / 'sample.xml'}"])
def test_rejects_duplicate_output_paths(self) -> None:
with tempfile.TemporaryDirectory(prefix="tmp-urdf-") as tempdir:
first_path = Path(tempdir) / "first.py"
second_path = Path(tempdir) / "second.py"
output_path = Path(tempdir) / "same.urdf"
_write_urdf_source(first_path, "return '<robot name=\"first\"><link name=\"base\" /></robot>'")
_write_urdf_source(second_path, "return '<robot name=\"second\"><link name=\"base\" /></robot>'")
with self.assertRaisesRegex(ValueError, "used more than once"):
cli.generate_urdf_targets([f"{first_path}={output_path}", f"{second_path}={output_path}"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,593 @@
import tempfile
import unittest
import warnings
from pathlib import Path
from urdf.source import (
MeshUriKind,
UrdfSourceError,
UrdfSourceWarning,
classify_mesh_uri,
read_urdf_source,
resolve_mesh_uri,
)
class UrdfSourceTests(unittest.TestCase):
def setUp(self) -> None:
self._tempdir = tempfile.TemporaryDirectory(prefix="tmp-urdf-source-")
self.temp_root = Path(self._tempdir.name)
def tearDown(self) -> None:
self._tempdir.cleanup()
def _file_ref(self, name: str) -> str:
return (self.temp_root / f"{name}.urdf").resolve().as_posix()
def _write_mesh(self, name: str) -> Path:
mesh_path = self.temp_root / name if Path(name).suffix else self.temp_root / f"{name}.stl"
mesh_path.parent.mkdir(parents=True, exist_ok=True)
mesh_path.write_text("solid empty\nendsolid empty\n", encoding="utf-8")
return mesh_path
def _write_urdf(self, name: str, body: str) -> Path:
urdf_path = self.temp_root / f"{name}.urdf"
urdf_path.write_text(body.strip() + "\n", encoding="utf-8")
script_path = self.temp_root / f"{name}.py"
if not script_path.exists():
script_path.write_text(
"\n".join(
[
"def gen_step():",
" return {'instances': []}",
"",
"def gen_urdf():",
" return {'xml': ''}",
"",
]
),
encoding="utf-8",
)
return urdf_path
def test_read_urdf_source_accepts_valid_mesh_robot(self) -> None:
mesh_path = self._write_mesh("base")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{mesh_path.name}" scale="0.001 0.001 0.001" />
</geometry>
</visual>
</link>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual(self._file_ref("robot"), source.file_ref)
self.assertEqual("sample-robot", source.robot_name)
self.assertEqual("base_link", source.root_link)
self.assertEqual(("base_link",), source.links)
self.assertEqual(0, len(source.joints))
self.assertEqual((mesh_path.resolve(),), source.mesh_paths)
self.assertEqual((mesh_path.resolve(),), source.visual_mesh_paths)
self.assertEqual((), source.collision_mesh_paths)
def test_read_urdf_source_accepts_collision_meshes(self) -> None:
visual_mesh_path = self._write_mesh("visual")
collision_mesh_path = self._write_mesh("collision")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{visual_mesh_path.name}" />
</geometry>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0" />
<geometry>
<mesh filename="{collision_mesh_path.name}" />
</geometry>
</collision>
</link>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual(
(visual_mesh_path.resolve(), collision_mesh_path.resolve()),
source.mesh_paths,
)
self.assertEqual((visual_mesh_path.resolve(),), source.visual_mesh_paths)
self.assertEqual((collision_mesh_path.resolve(),), source.collision_mesh_paths)
def test_read_urdf_source_accepts_primitive_collision_geometry(self) -> None:
visual_mesh_path = self._write_mesh("visual")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{visual_mesh_path.name}" />
</geometry>
</visual>
<collision>
<geometry>
<box size="0.1 0.2 0.3" />
</geometry>
</collision>
</link>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual((visual_mesh_path.resolve(),), source.mesh_paths)
self.assertEqual((), source.collision_mesh_paths)
def test_read_urdf_source_accepts_primitive_visual_geometry(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<box size="0.1 0.2 0.3" />
</geometry>
</visual>
</link>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual(("base_link",), source.links)
self.assertEqual((), source.mesh_paths)
def test_read_urdf_source_accepts_non_stl_mesh_formats(self) -> None:
visual_mesh_path = self._write_mesh("meshes/base.dae")
collision_mesh_path = self._write_mesh("meshes/base_collision.obj")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{visual_mesh_path.relative_to(self.temp_root).as_posix()}" />
</geometry>
</visual>
<collision>
<geometry>
<mesh filename="{collision_mesh_path.relative_to(self.temp_root).as_posix()}" />
</geometry>
</collision>
</link>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual((visual_mesh_path.resolve(), collision_mesh_path.resolve()), source.mesh_paths)
def test_classifies_mesh_uris(self) -> None:
package_ref = classify_mesh_uri("package://robot_description/meshes/base.stl")
self.assertEqual(MeshUriKind.PACKAGE, package_ref.kind)
self.assertEqual("robot_description", package_ref.package_name)
self.assertEqual("meshes/base.stl", package_ref.package_path.as_posix())
self.assertEqual(MeshUriKind.REMOTE, classify_mesh_uri("https://example.com/base.stl").kind)
self.assertEqual(MeshUriKind.LOCAL_ABSOLUTE, classify_mesh_uri("/tmp/base.stl").kind)
self.assertEqual(MeshUriKind.LOCAL_RELATIVE, classify_mesh_uri("meshes/base.stl").kind)
def test_resolves_package_mesh_uri_with_package_map(self) -> None:
package_root = self.temp_root / "robot_description"
mesh_path = package_root / "meshes" / "base.stl"
mesh_path.parent.mkdir(parents=True)
mesh_path.write_text("solid empty\nendsolid empty\n", encoding="utf-8")
self.assertEqual(
mesh_path.resolve(),
resolve_mesh_uri(
"package://robot_description/meshes/base.stl",
package_map={"robot_description": package_root},
),
)
def test_read_urdf_source_accepts_unresolved_package_mesh_with_warning(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="package://robot_description/meshes/base.stl" />
</geometry>
</visual>
</link>
</robot>
""",
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UrdfSourceWarning)
source = read_urdf_source(source_path)
self.assertEqual((), source.mesh_paths)
warning_messages = [str(warning.message) for warning in caught]
self.assertTrue(any("syntax is valid but was not resolved" in message for message in warning_messages))
def test_read_urdf_source_accepts_unresolved_remote_mesh_with_warning(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="https://example.com/meshes/base.glb" />
</geometry>
</visual>
</link>
</robot>
""",
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UrdfSourceWarning)
source = read_urdf_source(source_path)
self.assertEqual((), source.mesh_paths)
warning_messages = [str(warning.message) for warning in caught]
self.assertTrue(any("is not a local mesh URI and was not resolved" in message for message in warning_messages))
def test_read_urdf_source_resolves_package_mesh_with_package_map(self) -> None:
package_root = self.temp_root / "robot_description"
mesh_path = package_root / "meshes" / "base.stl"
mesh_path.parent.mkdir(parents=True)
mesh_path.write_text("solid empty\nendsolid empty\n", encoding="utf-8")
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="package://robot_description/meshes/base.stl" />
</geometry>
</visual>
</link>
</robot>
""",
)
source = read_urdf_source(source_path, package_map={"robot_description": package_root})
self.assertEqual((mesh_path.resolve(),), source.mesh_paths)
def test_read_urdf_source_accepts_valid_inertial(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<inertial>
<origin xyz="0 0 0" rpy="0 0 0" />
<mass value="1.2" />
<inertia ixx="0.1" ixy="0" ixz="0" iyy="0.1" iyz="0" izz="0.1" />
</inertial>
</link>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual(("base_link",), source.links)
def test_read_urdf_source_rejects_nonpositive_inertial_mass(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<inertial>
<mass value="0" />
<inertia ixx="0.1" ixy="0" ixz="0" iyy="0.1" iyz="0" izz="0.1" />
</inertial>
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "mass must be positive"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_invalid_inertia_triangle(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<inertial>
<mass value="1" />
<inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="1" />
</inertial>
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "triangle"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_invalid_origin_vector(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link" />
<link name="arm_link" />
<joint name="base_to_arm" type="fixed">
<origin xyz="0 0" />
<parent link="base_link" />
<child link="arm_link" />
</joint>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "origin xyz must have 3 values"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_zero_joint_axis(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link" />
<link name="arm_link" />
<joint name="base_to_arm" type="revolute">
<parent link="base_link" />
<child link="arm_link" />
<axis xyz="0 0 0" />
<limit lower="-1" upper="1" />
</joint>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "axis must be nonzero"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_invalid_mesh_scale(self) -> None:
mesh_path = self._write_mesh("base.obj")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{mesh_path.name}" scale="1 0 1" />
</geometry>
</visual>
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "scale values must be positive"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_invalid_primitive_dimensions(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<collision>
<geometry>
<sphere radius="0" />
</geometry>
</collision>
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "sphere radius must be positive"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_missing_geometry_element(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<visual />
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "visual requires <geometry>"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_duplicate_links(self) -> None:
mesh_path = self._write_mesh("base")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{mesh_path.name}" />
</geometry>
</visual>
</link>
<link name="base_link" />
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "duplicates"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_missing_mesh(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="does-not-exist.stl" />
</geometry>
</visual>
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "missing mesh file"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_missing_collision_mesh(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link">
<collision>
<geometry>
<mesh filename="does-not-exist.stl" />
</geometry>
</collision>
</link>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "missing mesh file"):
read_urdf_source(source_path)
def test_read_urdf_source_accepts_prismatic_joint_with_limits(self) -> None:
mesh_path = self._write_mesh("base")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{mesh_path.name}" />
</geometry>
</visual>
</link>
<link name="arm_link" />
<joint name="base_to_arm" type="prismatic">
<parent link="base_link" />
<child link="arm_link" />
<limit lower="0" upper="0.05" effort="1" velocity="1" />
</joint>
</robot>
""",
)
source = read_urdf_source(source_path)
self.assertEqual("prismatic", source.joints[0].joint_type)
self.assertEqual(0.0, source.joints[0].min_value_deg)
self.assertEqual(0.05, source.joints[0].max_value_deg)
def test_read_urdf_source_rejects_reversed_joint_limits(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link" />
<link name="arm_link" />
<joint name="base_to_arm" type="prismatic">
<parent link="base_link" />
<child link="arm_link" />
<limit lower="1" upper="0" />
</joint>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "lower limit exceeds upper limit"):
read_urdf_source(source_path)
def test_read_urdf_source_rejects_unsupported_joint_type(self) -> None:
mesh_path = self._write_mesh("base")
source_path = self._write_urdf(
"robot",
f"""
<robot name="sample-robot">
<link name="base_link">
<visual>
<geometry>
<mesh filename="{mesh_path.name}" />
</geometry>
</visual>
</link>
<link name="arm_link" />
<joint name="base_to_arm" type="planar">
<parent link="base_link" />
<child link="arm_link" />
</joint>
</robot>
""",
)
with self.assertRaisesRegex(UrdfSourceError, "unsupported type"):
read_urdf_source(source_path)
def test_file_ref_ignores_neighbor_step_toml(self) -> None:
source_path = self._write_urdf(
"robot",
"""
<robot name="sample-robot">
<link name="base_link" />
</robot>
""",
)
stale_path = self.temp_root / "robot.step.toml"
stale_path.write_text(
"\n".join(
[
'kind = "part"',
'source = "robot.urdf"',
]
)
+ "\n",
encoding="utf-8",
)
source = read_urdf_source(source_path)
self.assertEqual(source_path.resolve().as_posix(), source.file_ref)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1 @@
"""Shared helpers for repository-owned Python tests."""
@@ -0,0 +1,48 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from tests.python.support.paths import add_repo_path
add_repo_path("skills/cad/scripts")
from cadpy import assembly_spec, catalog, generation, glb_topology, metadata, render, step_scene, step_targets
from tests.python.support.tmp_root import CAD_TEST_TMP_ROOT, temporary_directory
IGNORED_TEST_ROOT = CAD_TEST_TMP_ROOT
class IsolatedCadRoots:
def __init__(self, testcase: unittest.TestCase, *, prefix: str) -> None:
self._tempdir = temporary_directory(prefix=prefix)
testcase.addCleanup(self._tempdir.cleanup)
self.root = Path(self._tempdir.name)
self.cad_root = self.root / "workspace"
self.cad_root.mkdir(parents=True, exist_ok=True)
patches = []
for module in (
assembly_spec,
catalog,
render,
generation,
glb_topology,
metadata,
step_scene,
step_targets,
):
if hasattr(module, "CAD_ROOT"):
patches.append(mock.patch.object(module, "CAD_ROOT", self.cad_root))
if hasattr(module, "REPO_ROOT"):
patches.append(mock.patch.object(module, "REPO_ROOT", self.cad_root))
for patcher in patches:
patcher.start()
testcase.addCleanup(patcher.stop)
def temporary_cad_directory(self, *, prefix: str) -> tempfile.TemporaryDirectory[str]:
return tempfile.TemporaryDirectory(prefix=prefix, dir=self.cad_root)
@@ -0,0 +1,19 @@
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
def repo_path(*parts: str) -> Path:
return REPO_ROOT.joinpath(*parts)
def add_repo_path(*parts: str) -> Path:
path = repo_path(*parts)
path_text = str(path)
if path_text not in sys.path:
sys.path.insert(0, path_text)
return path
@@ -0,0 +1,20 @@
from __future__ import annotations
import tempfile
from pathlib import Path
from tests.python.support.paths import REPO_ROOT
TMP_ROOT = REPO_ROOT / "tmp"
CAD_TEST_TMP_ROOT = TMP_ROOT / "cad-skill-tests"
def temporary_directory(*, prefix: str) -> tempfile.TemporaryDirectory[str]:
CAD_TEST_TMP_ROOT.mkdir(parents=True, exist_ok=True)
return tempfile.TemporaryDirectory(prefix=prefix, dir=CAD_TEST_TMP_ROOT)
def named_tmp_root(name: str) -> Path:
tmp_root = TMP_ROOT / name
tmp_root.mkdir(parents=True, exist_ok=True)
return tmp_root
@@ -0,0 +1 @@
"""MoveIt2 server tests."""
@@ -0,0 +1,281 @@
from __future__ import annotations
import asyncio
import json
import os
import tempfile
from pathlib import Path
import unittest
from unittest.mock import patch
from tests.python.support.paths import add_repo_path
add_repo_path("viewer/moveit2_server")
from moveit2_server.context import build_moveit2_context
from moveit2_server.protocol import MotionProtocolError
from moveit2_server.server import handle_message
def write_file(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def write_sample_robot(repo_root: Path, urdf_ref: str = "robot.urdf", srdf_ref: str = "robot.srdf") -> None:
urdf_path = repo_root / urdf_ref
srdf_path = repo_root / srdf_ref
write_file(
urdf_path,
"""
<robot name="robot">
<link name="base_link"/>
<link name="wrist_link"/>
<link name="tool_link"/>
<joint name="shoulder" type="revolute">
<parent link="base_link"/>
<child link="wrist_link"/>
<limit lower="-1" upper="1" effort="1" velocity="1"/>
</joint>
<joint name="wrist_tool" type="fixed">
<parent link="wrist_link"/>
<child link="tool_link"/>
</joint>
</robot>
""",
)
relative_urdf_ref = Path(os.path.relpath(urdf_path, srdf_path.parent)).as_posix()
write_file(
srdf_path,
f"""
<robot name="robot" xmlns:tcad="https://text-to-cad.dev/srdf">
<tcad:urdf path="{relative_urdf_ref}"/>
<group name="arm">
<joint name="shoulder"/>
</group>
<group name="tool_group">
<link name="tool_link"/>
</group>
<end_effector name="tool" parent_link="wrist_link" group="tool_group" parent_group="arm"/>
<group_state name="home" group="arm">
<joint name="shoulder" value="0"/>
</group_state>
</robot>
""",
)
def sample_payload() -> dict[str, object]:
return {
"dir": "",
"file": "robot.srdf",
"startJointValuesByNameDeg": {"shoulder": 10},
"target": {
"endEffector": "tool",
"frame": "base_link",
"xyz": [0.1, 0.0, 0.2],
},
"moveit2": {
"planningGroup": "arm",
"endEffector": "tool",
"targetFrame": "base_link",
"ik": {"timeout": 0.2, "attempts": 3, "tolerance": 0.01},
"planning": {
"pipeline": "ompl",
"plannerId": "RRTConnectkConfigDefault",
"planningTime": 2.0,
"maxVelocityScalingFactor": 0.5,
"maxAccelerationScalingFactor": 0.25,
},
},
}
class MoveIt2ContextTests(unittest.TestCase):
def test_builds_context_from_srdf_file_and_request_settings(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_sample_robot(repo_root)
context = build_moveit2_context(
repo_root=repo_root,
dir="",
file="robot.srdf",
type="srdf.planToPose",
payload=sample_payload(),
)
self.assertEqual(context["file"], "robot.srdf")
self.assertEqual(context["command"]["planningGroup"], "arm")
self.assertEqual(context["command"]["endEffectors"][0]["link"], "tool_link")
self.assertTrue(context["command"]["ik"]["positionOnly"])
self.assertEqual(context["command"]["endEffectors"][0]["positionTolerance"], 0.01)
self.assertEqual(context["command"]["ik"]["attempts"], 3)
self.assertEqual(context["command"]["planner"]["planningTime"], 2.0)
self.assertIn("robot.srdf", context["modelAssetHash"])
self.assertEqual(Path(str(context["urdfPath"])).resolve(), (repo_root / "robot.urdf").resolve())
def test_chain_planning_group_uses_chain_joints_not_all_active_joints(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_file(
repo_root / "robot.urdf",
"""
<robot name="robot">
<link name="base_link"/>
<link name="shoulder_link"/>
<link name="wrist_link"/>
<link name="tool_link"/>
<link name="finger_link"/>
<joint name="shoulder" type="revolute">
<parent link="base_link"/>
<child link="shoulder_link"/>
</joint>
<joint name="elbow" type="revolute">
<parent link="shoulder_link"/>
<child link="wrist_link"/>
</joint>
<joint name="wrist_tool" type="fixed">
<parent link="wrist_link"/>
<child link="tool_link"/>
</joint>
<joint name="gripper" type="revolute">
<parent link="tool_link"/>
<child link="finger_link"/>
</joint>
</robot>
""",
)
write_file(
repo_root / "robot.srdf",
"""
<robot name="robot" xmlns:tcad="https://text-to-cad.dev/srdf">
<tcad:urdf path="robot.urdf"/>
<group name="arm">
<chain base_link="base_link" tip_link="tool_link"/>
</group>
<group name="gripper">
<joint name="gripper"/>
<link name="finger_link"/>
</group>
<end_effector name="tool" parent_link="tool_link" group="gripper" parent_group="arm"/>
</robot>
""",
)
context = build_moveit2_context(
repo_root=repo_root,
dir="",
file="robot.srdf",
type="srdf.solvePose",
payload=sample_payload(),
)
self.assertEqual(context["command"]["jointNames"], ["shoulder", "elbow"])
self.assertEqual(context["command"]["endEffectors"][0]["jointNames"], ["shoulder", "elbow"])
def test_accepts_orientation_and_explicit_target_link(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_sample_robot(repo_root)
payload = sample_payload()
payload["target"]["quat_xyzw"] = [0, 0, 0, 2]
payload["moveit2"]["targetLink"] = "tool_link"
payload["moveit2"]["ik"]["positionOnly"] = False
context = build_moveit2_context(
repo_root=repo_root,
dir="",
file="robot.srdf",
type="srdf.solvePose",
payload=payload,
)
self.assertFalse(context["command"]["ik"]["positionOnly"])
self.assertEqual(context["command"]["endEffectors"][0]["link"], "tool_link")
def test_rejects_group_state_joint_outside_group_or_limits(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_sample_robot(repo_root)
srdf_path = repo_root / "robot.srdf"
srdf_path.write_text(
srdf_path.read_text(encoding="utf-8").replace(
'<joint name="shoulder" value="0"/>',
'<joint name="shoulder" value="2"/>',
),
encoding="utf-8",
)
with self.assertRaisesRegex(MotionProtocolError, "above its URDF upper limit"):
build_moveit2_context(
repo_root=repo_root,
dir="",
file="robot.srdf",
type="srdf.solvePose",
payload=sample_payload(),
)
def test_accepts_repo_relative_file_ref_with_catalog_dir(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_sample_robot(repo_root, "workspace/robot.urdf", "workspace/robot.srdf")
payload = sample_payload()
payload["dir"] = "workspace"
payload["file"] = "workspace/robot.srdf"
context = build_moveit2_context(
repo_root=repo_root,
dir="workspace",
file="workspace/robot.srdf",
type="srdf.solvePose",
payload=payload,
)
self.assertEqual(context["dir"], "workspace")
self.assertEqual(context["file"], "robot.srdf")
self.assertEqual(Path(str(context["srdfPath"])).resolve(), (repo_root / "workspace/robot.srdf").resolve())
def test_handle_message_uses_moveit2_context(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_sample_robot(repo_root)
message = {"id": "req-1", "type": "srdf.solvePose", "payload": sample_payload()}
with patch("moveit2_server.server.dispatch", return_value={"jointValuesByNameDeg": {"shoulder": 11}}):
response = asyncio.run(handle_message(json.dumps(message), repo_root=repo_root))
payload = json.loads(response)
self.assertTrue(payload["ok"])
self.assertEqual(payload["result"]["jointValuesByNameDeg"]["shoulder"], 11)
def test_rejects_traversal(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
with self.assertRaisesRegex(MotionProtocolError, "file must stay inside"):
build_moveit2_context(
repo_root=Path(tempdir),
dir="",
file="../robot.srdf",
type="srdf.solvePose",
payload=sample_payload(),
)
def test_rejects_unknown_planning_group_from_request(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
repo_root = Path(tempdir)
write_sample_robot(repo_root)
payload = sample_payload()
payload["moveit2"]["planningGroup"] = "missing"
with self.assertRaisesRegex(MotionProtocolError, "Selected planning group"):
build_moveit2_context(
repo_root=repo_root,
dir="",
file="robot.srdf",
type="srdf.planToPose",
payload=payload,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,171 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from tests.python.support.paths import add_repo_path
add_repo_path("viewer/moveit2_server")
from moveit2_server.moveit_py import (
MoveItPyAdapter,
_joint_state_seed,
_native_start_joint_values,
_robot_description_for_moveit,
)
class MoveItPyAdapterHelperTests(unittest.TestCase):
def test_robot_description_rewrites_relative_meshes_to_file_uris(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
urdf_path = root / "robot.urdf"
urdf_path.write_text(
"""
<robot name="robot">
<link name="base">
<visual>
<geometry>
<mesh filename="STL/base.stl" />
</geometry>
</visual>
</link>
</robot>
""",
encoding="utf-8",
)
description = _robot_description_for_moveit(urdf_path)
self.assertIn((root / "STL/base.stl").resolve().as_uri(), description)
self.assertNotIn('filename="STL/base.stl"', description)
def test_config_dict_builds_moveit2_config_from_srdf_and_request_settings(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
urdf_path = root / "robot.urdf"
urdf_path.write_text('<robot name="robot"><link name="base" /></robot>', encoding="utf-8")
srdf_path = root / "robot.srdf"
srdf_path.write_text('<robot name="robot" />', encoding="utf-8")
request = SimpleNamespace(
context={
"urdfPath": str(urdf_path),
"srdfPath": str(srdf_path),
"srdf": {
"planningGroups": [
{"name": "arm", "jointNames": ["shoulder"]},
{"name": "arm_with_gripper", "subgroups": ["arm", "gripper"]},
]
},
},
command={
"ik": {"timeout": 0.2, "attempts": 3},
"planner": {
"pipeline": "ompl",
"plannerId": "RRTConnectkConfigDefault",
"planningTime": 2.0,
"maxVelocityScalingFactor": 0.5,
"maxAccelerationScalingFactor": 0.25,
},
},
)
config = MoveItPyAdapter()._config_dict(request)
self.assertEqual(config["robot_description_semantic"], '<robot name="robot" />')
self.assertEqual(config["robot_description_kinematics"]["arm"]["kinematics_solver_timeout"], 0.2)
self.assertTrue(config["robot_description_kinematics"]["arm"]["position_only_ik"])
self.assertNotIn("arm_with_gripper", config["robot_description_kinematics"])
self.assertEqual(config["planning_pipelines"]["pipeline_names"], ["ompl"])
self.assertEqual(config["plan_request_params"]["planning_attempts"], 3)
self.assertEqual(config["plan_request_params"]["max_velocity_scaling_factor"], 0.5)
self.assertIsNot(config["plan_request_params"], config["ompl_rrtc"]["plan_request_params"])
self.assertEqual(config["ompl_rrtc"]["plan_request_params"]["planning_attempts"], 3)
self.assertEqual(config["planning_scene_monitor_options"]["wait_for_initial_state_timeout"], 5.0)
def test_joint_state_seed_includes_all_active_urdf_joints(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
urdf_path = Path(tempdir) / "robot.urdf"
urdf_path.write_text(
"""
<robot name="robot">
<joint name="fixed_base" type="fixed" />
<joint name="shoulder" type="revolute" />
<joint name="slide" type="prismatic" />
</robot>
""",
encoding="utf-8",
)
request = SimpleNamespace(
context={"urdfPath": str(urdf_path)},
payload={"startJointValuesByNameDeg": {"shoulder": 90, "slide": 0.25}},
command={
"planningGroup": "arm",
"jointNames": ["shoulder"],
},
)
names, positions = _joint_state_seed(request)
self.assertEqual(names, ["shoulder", "slide"])
self.assertAlmostEqual(positions[0], 1.5707963267948966)
self.assertEqual(positions[1], 0.25)
def test_legacy_start_joint_values_only_convert_angular_joints(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
urdf_path = Path(tempdir) / "robot.urdf"
urdf_path.write_text(
"""
<robot name="robot">
<joint name="shoulder" type="revolute" />
<joint name="slide" type="prismatic" />
</robot>
""",
encoding="utf-8",
)
request = SimpleNamespace(
context={"urdfPath": str(urdf_path)},
payload={"startJointValuesByNameDeg": {"shoulder": 90, "slide": 0.25}},
command={"jointNames": ["shoulder", "slide"]},
)
values = _native_start_joint_values(request)
self.assertAlmostEqual(values["shoulder"], 1.5707963267948966)
self.assertEqual(values["slide"], 0.25)
def test_serializes_robot_trajectory_messages_with_fallback_timing(self) -> None:
class Duration:
sec = 0
nanosec = 0
class Point:
def __init__(self, positions: list[float]) -> None:
self.time_from_start = Duration()
self.positions = positions
class JointTrajectory:
joint_names = ["shoulder", "elbow"]
points = [Point([0.0, 0.0]), Point([1.5707963267948966, 0.0])]
class RobotTrajectoryMsg:
joint_trajectory = JointTrajectory()
class RobotTrajectory:
def get_robot_trajectory_msg(self) -> RobotTrajectoryMsg:
return RobotTrajectoryMsg()
request = SimpleNamespace(context={}, payload={}, command={"jointNames": ["shoulder", "elbow"]})
serialized = MoveItPyAdapter()._serialize_trajectory(RobotTrajectory(), ["shoulder", "elbow"], request)
self.assertEqual(serialized["jointNames"], ["shoulder", "elbow"])
self.assertEqual(serialized["points"][0]["timeFromStartSec"], 0.0)
self.assertGreater(serialized["points"][1]["timeFromStartSec"], 0.0)
self.assertAlmostEqual(serialized["points"][1]["positions"][0], 1.5707963267948966)
self.assertAlmostEqual(serialized["points"][1]["positionsDeg"][0], 90.0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,133 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from tests.python.support.paths import add_repo_path
add_repo_path("viewer/moveit2_server")
from moveit2_server.dispatcher import dispatch
from moveit2_server.protocol import (
MotionProtocolError,
normalize_joint_values,
normalize_motion_target,
normalize_request,
)
def moveit2_context() -> dict[str, object]:
return {
"command": {
"planningGroup": "arm",
"jointNames": ["shoulder", "elbow"],
"endEffectors": [
{
"name": "tool",
"link": "tool_link",
"frame": "base_link",
"planningGroup": "arm",
"jointNames": ["shoulder", "elbow"],
"positionTolerance": 0.002,
}
],
"ik": {"timeout": 0.05, "attempts": 1, "tolerance": 0.002},
"planner": {
"pipeline": "ompl",
"plannerId": "RRTConnectkConfigDefault",
"planningTime": 1.0,
"maxVelocityScalingFactor": 1.0,
"maxAccelerationScalingFactor": 1.0,
},
},
}
class FakeMoveIt2Adapter:
def solve_pose(self, request: object) -> dict[str, object]:
return {"jointValuesByNameDeg": {"shoulder": 11}}
def plan_to_pose(self, request: object) -> dict[str, object]:
return {
"jointValuesByNameDeg": {"shoulder": 11},
"trajectory": {"jointNames": ["shoulder", "elbow"], "points": []},
}
class MoveIt2ProtocolTests(unittest.TestCase):
def test_normalizes_joint_values(self) -> None:
self.assertEqual(normalize_joint_values({"shoulder": "12.5"}), {"shoulder": 12.5})
with self.assertRaisesRegex(MotionProtocolError, "empty joint names"):
normalize_joint_values({"": 1})
def test_normalizes_request_with_moveit2_context(self) -> None:
request = normalize_request(
{
"id": "abc",
"type": "srdf.solvePose",
"payload": {
"file": "robot.srdf",
"target": {
"endEffector": "tool",
"frame": "base_link",
"xyz": [0.1, 0.0, 0.2],
},
},
},
context=moveit2_context(),
)
self.assertEqual(request.id, "abc")
self.assertEqual(request.protocol_version, 1)
self.assertEqual(request.command["planningGroup"], "arm")
def test_normalizes_target_orientation(self) -> None:
target = normalize_motion_target({
"target": {
"endEffector": "tool",
"frame": "base_link",
"xyz": [0, 0, 0],
"quat_xyzw": [0, 0, 0, 2],
}
})
self.assertEqual(target["orientationMode"], "quat_xyzw")
self.assertEqual(target["quat_xyzw"], (0.0, 0.0, 0.0, 1.0))
with self.assertRaisesRegex(MotionProtocolError, "exactly one"):
normalize_motion_target({
"target": {
"endEffector": "tool",
"frame": "base_link",
"xyz": [0, 0, 0],
"quat_xyzw": [0, 0, 0, 1],
"rpy": [0, 0, 0],
}
})
def test_dispatch_uses_moveit2_adapter_for_solve_and_plan(self) -> None:
request = normalize_request(
{
"id": "abc",
"type": "srdf.planToPose",
"payload": {
"startJointValuesByNameDeg": {"shoulder": 10},
"target": {
"endEffector": "tool",
"frame": "base_link",
"xyz": [0.1, 0.0, 0.2],
},
},
},
context=moveit2_context(),
)
with patch("moveit2_server.dispatcher._adapter", return_value=FakeMoveIt2Adapter()):
result = dispatch(request)
self.assertEqual(result["jointValuesByNameDeg"]["shoulder"], 11)
self.assertEqual(result["trajectory"]["jointNames"], ["shoulder", "elbow"])
if __name__ == "__main__":
unittest.main()