first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,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()