first commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user