mattzh72--articraft
1496 行
48 KiB
Python
1496 行
48 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from sdk import (
|
|
AllowedOverlap,
|
|
ArticulatedObject,
|
|
ArticulationType,
|
|
Box,
|
|
Mesh,
|
|
Mimic,
|
|
MotionLimits,
|
|
Origin,
|
|
Sphere,
|
|
ValidationError,
|
|
)
|
|
from sdk import (
|
|
TestContext as SDKTestContext,
|
|
)
|
|
from sdk._core.v0.geometry_qc import find_unsupported_parts
|
|
|
|
|
|
def _write_disconnected_boxes_obj(path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
centers = ((0.0, 0.0, 0.0), (0.45, 0.0, 0.0))
|
|
vertices: list[tuple[float, float, float]] = []
|
|
faces: list[tuple[int, int, int]] = []
|
|
cube_faces = (
|
|
(0, 1, 2),
|
|
(0, 2, 3),
|
|
(4, 6, 5),
|
|
(4, 7, 6),
|
|
(0, 4, 5),
|
|
(0, 5, 1),
|
|
(1, 5, 6),
|
|
(1, 6, 2),
|
|
(2, 6, 7),
|
|
(2, 7, 3),
|
|
(3, 7, 4),
|
|
(3, 4, 0),
|
|
)
|
|
for center_x, center_y, center_z in centers:
|
|
base_index = len(vertices) + 1
|
|
vertices.extend(
|
|
[
|
|
(center_x - 0.05, center_y - 0.05, center_z - 0.05),
|
|
(center_x + 0.05, center_y - 0.05, center_z - 0.05),
|
|
(center_x + 0.05, center_y + 0.05, center_z - 0.05),
|
|
(center_x - 0.05, center_y + 0.05, center_z - 0.05),
|
|
(center_x - 0.05, center_y - 0.05, center_z + 0.05),
|
|
(center_x + 0.05, center_y - 0.05, center_z + 0.05),
|
|
(center_x + 0.05, center_y + 0.05, center_z + 0.05),
|
|
(center_x - 0.05, center_y + 0.05, center_z + 0.05),
|
|
]
|
|
)
|
|
faces.extend(tuple(base_index + idx for idx in face) for face in cube_faces)
|
|
lines = [f"v {x} {y} {z}" for x, y, z in vertices]
|
|
lines.extend(f"f {a} {b} {c}" for a, b, c in faces)
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _build_joint_origin_model(*, joint_z: float) -> ArticulatedObject:
|
|
model = ArticulatedObject(name="joint_origin_tolerance")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)))
|
|
|
|
child = model.part("child")
|
|
child.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)))
|
|
|
|
model.articulation(
|
|
"base_to_child",
|
|
ArticulationType.REVOLUTE,
|
|
parent=base,
|
|
child=child,
|
|
origin=Origin(xyz=(0.0, 0.0, joint_z)),
|
|
axis=(0.0, 1.0, 0.0),
|
|
motion_limits=MotionLimits(effort=1.0, velocity=1.0, lower=0.0, upper=1.0),
|
|
)
|
|
return model
|
|
|
|
|
|
def _build_disconnected_part_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="disconnected_part")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)))
|
|
base.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.4, 0.0, 0.05)))
|
|
|
|
return model
|
|
|
|
|
|
def _build_diagonally_floating_spheres_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="diagonally_floating_spheres")
|
|
|
|
base = model.part("base")
|
|
base.visual(Sphere(0.05), origin=Origin(xyz=(0.0, 0.0, 0.05)), name="sphere_a")
|
|
base.visual(Sphere(0.05), origin=Origin(xyz=(0.08, 0.08, 0.05)), name="sphere_b")
|
|
|
|
return model
|
|
|
|
|
|
def _build_overlapping_parts_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="overlapping_parts")
|
|
root = model.part("root")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)), name="base_box")
|
|
|
|
child = model.part("child")
|
|
child.visual(
|
|
Box((0.2, 0.2, 0.2)),
|
|
origin=Origin(xyz=(0.0, 0.0, 0.1)),
|
|
name="child_box",
|
|
)
|
|
model.articulation(
|
|
"root_to_base",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=base,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_child",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=child,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_small_overlap_parts_model(
|
|
*, child_center_xyz: tuple[float, float, float]
|
|
) -> ArticulatedObject:
|
|
model = ArticulatedObject(name="small_overlap_parts")
|
|
root = model.part("root")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.02, 0.02, 0.02)), origin=Origin(xyz=(0.0, 0.0, 0.01)), name="base_box")
|
|
|
|
child = model.part("child")
|
|
child.visual(Box((0.02, 0.02, 0.02)), origin=Origin(xyz=child_center_xyz), name="child_box")
|
|
model.articulation(
|
|
"root_to_base",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=base,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_child",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=child,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_isolated_part_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="isolated_part")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)))
|
|
|
|
support = model.part("support")
|
|
support.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)))
|
|
|
|
antenna = model.part("antenna")
|
|
antenna.visual(Box((0.04, 0.04, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)))
|
|
|
|
model.articulation(
|
|
"base_to_support",
|
|
ArticulationType.FIXED,
|
|
parent=base,
|
|
child=support,
|
|
origin=Origin(xyz=(0.0, 0.0, 0.0)),
|
|
)
|
|
model.articulation(
|
|
"base_to_antenna",
|
|
ArticulationType.FIXED,
|
|
parent=base,
|
|
child=antenna,
|
|
origin=Origin(xyz=(0.6, 0.0, 0.0)),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_floating_group_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="floating_group")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.28, 0.2, 0.1)), origin=Origin(xyz=(0.14, 0.0, 0.05)))
|
|
|
|
lid = model.part("lid")
|
|
lid.visual(
|
|
Box((0.28, 0.2, 0.02)),
|
|
origin=Origin(xyz=(0.14, 0.0, -0.07)),
|
|
)
|
|
|
|
top_vent = model.part("top_vent")
|
|
top_vent.visual(
|
|
Box((0.06, 0.06, 0.04)),
|
|
origin=Origin(xyz=(0.0, 0.0, -0.08)),
|
|
)
|
|
|
|
model.articulation(
|
|
"lid_hinge",
|
|
ArticulationType.REVOLUTE,
|
|
parent=base,
|
|
child=lid,
|
|
origin=Origin(xyz=(0.0, 0.0, 0.18)),
|
|
axis=(0.0, 1.0, 0.0),
|
|
motion_limits=MotionLimits(effort=1.0, velocity=1.0, lower=0.0, upper=1.319468914507713),
|
|
meta={"qc_samples": [1.319468914507713]},
|
|
)
|
|
model.articulation(
|
|
"lid_to_top_vent",
|
|
ArticulationType.FIXED,
|
|
parent=lid,
|
|
child=top_vent,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_multi_element_overlapping_parts_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="multi_element_overlapping_parts")
|
|
root = model.part("root")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.14, 0.14, 0.14)), origin=Origin(xyz=(-0.04, 0.0, 0.07)), name="base_left")
|
|
base.visual(Box((0.14, 0.14, 0.14)), origin=Origin(xyz=(0.04, 0.0, 0.07)), name="base_right")
|
|
|
|
child = model.part("child")
|
|
child.visual(
|
|
Box((0.14, 0.14, 0.14)),
|
|
origin=Origin(xyz=(-0.04, 0.0, 0.07)),
|
|
name="child_left",
|
|
)
|
|
child.visual(
|
|
Box((0.14, 0.14, 0.14)),
|
|
origin=Origin(xyz=(0.04, 0.0, 0.07)),
|
|
name="child_right",
|
|
)
|
|
model.articulation(
|
|
"root_to_base",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=base,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_child",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=child,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_articulation_overlap_model(*, joint_type: ArticulationType) -> ArticulatedObject:
|
|
model = ArticulatedObject(name=f"articulation_overlap_{joint_type.value.lower()}")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.24, 0.24, 0.24)), origin=Origin(xyz=(0.0, 0.0, 0.12)))
|
|
|
|
child = model.part("child")
|
|
child.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)))
|
|
|
|
articulation_kwargs = {}
|
|
if joint_type == ArticulationType.REVOLUTE:
|
|
articulation_kwargs = {
|
|
"axis": (0.0, 1.0, 0.0),
|
|
"motion_limits": MotionLimits(effort=1.0, velocity=1.0, lower=-0.4, upper=0.4),
|
|
}
|
|
elif joint_type == ArticulationType.PRISMATIC:
|
|
articulation_kwargs = {
|
|
"axis": (1.0, 0.0, 0.0),
|
|
"motion_limits": MotionLimits(effort=1.0, velocity=1.0, lower=-0.02, upper=0.02),
|
|
}
|
|
elif joint_type == ArticulationType.CONTINUOUS:
|
|
articulation_kwargs = {
|
|
"axis": (0.0, 1.0, 0.0),
|
|
"motion_limits": MotionLimits(effort=1.0, velocity=1.0),
|
|
}
|
|
|
|
model.articulation(
|
|
"base_to_child",
|
|
joint_type,
|
|
parent=base,
|
|
child=child,
|
|
origin=Origin(xyz=(0.0, 0.0, 0.0)),
|
|
**articulation_kwargs,
|
|
)
|
|
return model
|
|
|
|
|
|
def _build_non_articulation_overlap_only_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="non_articulation_overlap_only")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)))
|
|
|
|
arm = model.part("arm")
|
|
arm.visual(Box((0.12, 0.12, 0.12)), origin=Origin(xyz=(0.22, 0.0, 0.06)))
|
|
|
|
rogue_a = model.part("rogue_a")
|
|
rogue_a.visual(Box((0.18, 0.18, 0.18)), origin=Origin(xyz=(0.8, 0.0, 0.09)))
|
|
|
|
rogue_b = model.part("rogue_b")
|
|
rogue_b.visual(Box((0.18, 0.18, 0.18)), origin=Origin(xyz=(0.8, 0.0, 0.09)))
|
|
|
|
model.articulation(
|
|
"base_to_arm",
|
|
ArticulationType.REVOLUTE,
|
|
parent=base,
|
|
child=arm,
|
|
origin=Origin(xyz=(0.22, 0.0, 0.0)),
|
|
axis=(0.0, 1.0, 0.0),
|
|
motion_limits=MotionLimits(effort=1.0, velocity=1.0, lower=-0.2, upper=0.2),
|
|
)
|
|
model.articulation(
|
|
"base_to_rogue_a",
|
|
ArticulationType.FIXED,
|
|
parent=base,
|
|
child=rogue_a,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"base_to_rogue_b",
|
|
ArticulationType.FIXED,
|
|
parent=base,
|
|
child=rogue_b,
|
|
origin=Origin(),
|
|
)
|
|
return model
|
|
|
|
|
|
def _build_pose_specific_part_overlap_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="pose_specific_part_overlap")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)), name="base_box")
|
|
|
|
slider = model.part("slider")
|
|
slider.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.25, 0.0, 0.1)), name="slider_box")
|
|
|
|
model.articulation(
|
|
"base_to_slider",
|
|
ArticulationType.PRISMATIC,
|
|
parent=base,
|
|
child=slider,
|
|
origin=Origin(),
|
|
axis=(1.0, 0.0, 0.0),
|
|
motion_limits=MotionLimits(effort=1.0, velocity=1.0, lower=-0.2, upper=0.0),
|
|
)
|
|
return model
|
|
|
|
|
|
def _build_coplanar_surface_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="coplanar_surfaces")
|
|
root = model.part("root")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)))
|
|
|
|
cap = model.part("cap")
|
|
cap.visual(Box((0.12, 0.12, 0.02)), origin=Origin(xyz=(0.0, 0.0, 0.19)))
|
|
model.articulation(
|
|
"root_to_base",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=base,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_cap",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=cap,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_adjacent_coplanar_surface_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="adjacent_coplanar_surfaces")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)))
|
|
|
|
door = model.part("door")
|
|
door.visual(Box((0.12, 0.12, 0.02)), origin=Origin(xyz=(0.0, 0.0, 0.19)))
|
|
|
|
model.articulation(
|
|
"base_to_door",
|
|
ArticulationType.REVOLUTE,
|
|
parent=base,
|
|
child=door,
|
|
origin=Origin(xyz=(0.0, 0.0, 0.19)),
|
|
axis=(0.0, 1.0, 0.0),
|
|
motion_limits=MotionLimits(effort=1.0, velocity=1.0, lower=0.0, upper=1.0),
|
|
)
|
|
return model
|
|
|
|
|
|
def _build_element_gap_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="element_gap")
|
|
root = model.part("root")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)), name="body")
|
|
|
|
arm = model.part("arm")
|
|
arm.visual(Box((0.04, 0.04, 0.04)), origin=Origin(xyz=(0.0, 0.0, 0.121)), name="hub")
|
|
arm.visual(Box((0.01, 0.01, 0.02)), origin=Origin(xyz=(0.0, 0.0, 0.11)), name="brace")
|
|
model.articulation(
|
|
"root_to_base",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=base,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_arm",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=arm,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_nested_parts_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="nested_parts")
|
|
root = model.part("root")
|
|
|
|
outer = model.part("outer")
|
|
outer.visual(Box((0.3, 0.3, 0.3)), origin=Origin(xyz=(0.0, 0.0, 0.15)), name="shell")
|
|
|
|
inner = model.part("inner")
|
|
inner.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.15)), name="insert")
|
|
model.articulation(
|
|
"root_to_outer",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=outer,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_inner",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=inner,
|
|
origin=Origin(),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_mesh_connectivity_model(tmp_path: Path) -> ArticulatedObject:
|
|
mesh_path = tmp_path / "assets" / "meshes" / "frame.obj"
|
|
_write_disconnected_boxes_obj(mesh_path)
|
|
|
|
model = ArticulatedObject(name="mesh_connectivity")
|
|
frame = model.part("frame")
|
|
frame.visual(Mesh(filename=mesh_path.as_posix()), origin=Origin(), name="frame_body")
|
|
return model
|
|
|
|
|
|
def _build_mesh_overlap_model(tmp_path: Path) -> ArticulatedObject:
|
|
mesh_path = tmp_path / "assets" / "meshes" / "frame.obj"
|
|
_write_disconnected_boxes_obj(mesh_path)
|
|
|
|
model = ArticulatedObject(name="mesh_overlap")
|
|
root = model.part("root")
|
|
base = model.part("base")
|
|
base.visual(Mesh(filename=mesh_path.as_posix()), origin=Origin(), name="frame_body")
|
|
|
|
child = model.part("child")
|
|
child.visual(
|
|
Box((0.12, 0.12, 0.12)),
|
|
origin=Origin(xyz=(0.45, 0.0, 0.0)),
|
|
name="child_box",
|
|
)
|
|
model.articulation(
|
|
"root_to_base",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=base,
|
|
origin=Origin(),
|
|
)
|
|
model.articulation(
|
|
"root_to_child",
|
|
ArticulationType.FIXED,
|
|
parent=root,
|
|
child=child,
|
|
origin=Origin(),
|
|
)
|
|
return model
|
|
|
|
|
|
def test_articulation_origin_tolerance_is_truncated_to_three_decimals() -> None:
|
|
ctx = SDKTestContext(_build_joint_origin_model(joint_z=0.115))
|
|
|
|
assert ctx.fail_if_articulation_origin_far_from_geometry(tol=0.0159)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.checks == ("fail_if_articulation_origin_far_from_geometry(tol=0.015)",)
|
|
|
|
|
|
def test_articulation_origin_tolerance_is_capped_at_point_one_five() -> None:
|
|
ctx = SDKTestContext(_build_joint_origin_model(joint_z=0.25))
|
|
|
|
assert ctx.fail_if_articulation_origin_far_from_geometry(tol=0.159)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.checks == ("fail_if_articulation_origin_far_from_geometry(tol=0.15)",)
|
|
|
|
|
|
def test_warn_if_articulation_origin_far_from_geometry_records_warning_only() -> None:
|
|
ctx = SDKTestContext(_build_joint_origin_model(joint_z=0.2))
|
|
|
|
assert not ctx.warn_if_articulation_origin_far_from_geometry(tol=0.015)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("warn_if_articulation_origin_far_from_geometry(tol=0.015)",)
|
|
assert len(report.warnings) == 2
|
|
assert report.warnings[0].startswith("DEPRECATED AS DEFAULT: ")
|
|
assert "warn_if_articulation_origin_far_from_geometry(...)" in report.warnings[0]
|
|
assert "warn_if_articulation_origin_far_from_geometry(tol=0.015)" in report.warnings[1]
|
|
assert "Articulation origin(s) far from geometry" in report.warnings[1]
|
|
|
|
|
|
def test_warn_if_part_contains_disconnected_geometry_islands_records_warning_only() -> None:
|
|
ctx = SDKTestContext(_build_disconnected_part_model())
|
|
|
|
assert not ctx.warn_if_part_contains_disconnected_geometry_islands()
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("warn_if_part_contains_disconnected_geometry_islands(tol=1e-06)",)
|
|
assert len(report.warnings) == 1
|
|
assert "warn_if_part_contains_disconnected_geometry_islands(tol=1e-06)" in report.warnings[0]
|
|
assert "Disconnected geometry islands detected" in report.warnings[0]
|
|
|
|
|
|
def test_fail_if_part_contains_disconnected_geometry_islands_records_failure() -> None:
|
|
ctx = SDKTestContext(_build_disconnected_part_model())
|
|
|
|
assert not ctx.fail_if_part_contains_disconnected_geometry_islands()
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert report.checks == ("fail_if_part_contains_disconnected_geometry_islands(tol=1e-06)",)
|
|
assert len(report.failures) == 1
|
|
assert (
|
|
report.failures[0].name == "fail_if_part_contains_disconnected_geometry_islands(tol=1e-06)"
|
|
)
|
|
assert "Disconnected geometry islands detected" in report.failures[0].details
|
|
|
|
|
|
def test_warn_if_part_contains_disconnected_geometry_islands_uses_exact_geometry_not_aabb_overlap() -> (
|
|
None
|
|
):
|
|
ctx = SDKTestContext(_build_diagonally_floating_spheres_model())
|
|
|
|
assert not ctx.warn_if_part_contains_disconnected_geometry_islands()
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("warn_if_part_contains_disconnected_geometry_islands(tol=1e-06)",)
|
|
assert len(report.warnings) == 1
|
|
assert "connected=1/2" in report.warnings[0]
|
|
|
|
|
|
def test_warn_if_part_contains_disconnected_geometry_islands_records_mesh_component_warning(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
ctx = SDKTestContext(_build_mesh_connectivity_model(tmp_path))
|
|
|
|
assert not ctx.warn_if_part_contains_disconnected_geometry_islands()
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert len(report.warnings) == 1
|
|
assert "frame_body__component_002:Mesh" in report.warnings[0]
|
|
|
|
|
|
def test_fail_if_part_contains_disconnected_geometry_islands_records_mesh_component_failure(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
ctx = SDKTestContext(_build_mesh_connectivity_model(tmp_path))
|
|
|
|
assert not ctx.fail_if_part_contains_disconnected_geometry_islands()
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert "frame_body__component_002:Mesh" in report.failures[0].details
|
|
|
|
|
|
def test_fail_if_isolated_parts_fails_for_isolated_part() -> None:
|
|
ctx = SDKTestContext(_build_isolated_part_model())
|
|
|
|
assert not ctx.fail_if_isolated_parts()
|
|
|
|
report = ctx.report()
|
|
assert report.checks == ("fail_if_isolated_parts()",)
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "fail_if_isolated_parts()"
|
|
assert "Isolated parts detected" in report.failures[0].details
|
|
assert (
|
|
"part 'antenna' is disconnected from the grounded body rooted at 'base'"
|
|
in report.failures[0].details
|
|
)
|
|
|
|
|
|
def test_find_unsupported_parts_reports_floating_connected_group() -> None:
|
|
findings = find_unsupported_parts(_build_floating_group_model(), max_pose_samples=8)
|
|
|
|
assert len(findings) == 1
|
|
finding = findings[0]
|
|
assert finding.parts == ("lid", "top_vent")
|
|
assert finding.root_parts == ("base",)
|
|
assert finding.nearest_part == "base"
|
|
assert finding.min_distance is not None
|
|
assert finding.min_distance > 0.0
|
|
assert finding.pose["lid_hinge"] == 1.319468914507713
|
|
|
|
|
|
def test_fail_if_isolated_parts_reports_floating_group_with_pose_context() -> None:
|
|
ctx = SDKTestContext(_build_floating_group_model())
|
|
|
|
assert not ctx.fail_if_isolated_parts(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert report.checks == ("fail_if_isolated_parts(samples=8,contact_tol=1e-06)",)
|
|
assert len(report.failures) == 1
|
|
details = report.failures[0].details
|
|
assert "Isolated parts detected" in details
|
|
assert (
|
|
"floating group ['lid', 'top_vent'] is disconnected from the grounded body rooted at 'base'"
|
|
in details
|
|
)
|
|
assert "lid_hinge=1.319" in details
|
|
|
|
|
|
def test_allow_isolated_part_suppresses_fail_if_isolated_parts() -> None:
|
|
model = _build_isolated_part_model()
|
|
ctx = SDKTestContext(model)
|
|
antenna = model.get_part("antenna")
|
|
ctx.allow_isolated_part(antenna, reason="intentionally freestanding accent")
|
|
|
|
assert ctx.fail_if_isolated_parts()
|
|
|
|
report = ctx.report()
|
|
assert report.failures == ()
|
|
assert report.checks == ("fail_if_isolated_parts()",)
|
|
assert report.allowed_isolated_parts == ("antenna",)
|
|
assert len(report.warnings) == 1
|
|
assert "Isolated parts detected but allowed by justification" in report.warnings[0]
|
|
|
|
|
|
def test_allow_isolated_part_requires_all_parts_in_floating_group() -> None:
|
|
model = _build_floating_group_model()
|
|
ctx = SDKTestContext(model)
|
|
ctx.allow_isolated_part("lid", reason="only part of the group is intentionally freestanding")
|
|
|
|
assert not ctx.fail_if_isolated_parts(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert len(report.failures) == 1
|
|
assert "floating group ['lid', 'top_vent']" in report.failures[0].details
|
|
|
|
ctx = SDKTestContext(model)
|
|
ctx.allow_isolated_part("lid", reason="intentional floating display assembly")
|
|
ctx.allow_isolated_part("top_vent", reason="intentional floating display assembly")
|
|
|
|
assert ctx.fail_if_isolated_parts(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert report.failures == ()
|
|
assert len(report.warnings) == 1
|
|
assert "Isolated parts detected but allowed by justification" in report.warnings[0]
|
|
assert "floating group ['lid', 'top_vent']" in report.warnings[0]
|
|
|
|
|
|
def test_warn_if_overlaps_records_warning_only() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
|
|
assert not ctx.warn_if_overlaps(max_pose_samples=8, overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("warn_if_overlaps(samples=8,ignore_adjacent=False,ignore_fixed=True)",)
|
|
assert len(report.warnings) == 2
|
|
assert report.warnings[0].startswith("DEPRECATED AS DEFAULT: ")
|
|
assert "warn_if_overlaps(...)" in report.warnings[0]
|
|
assert (
|
|
"warn_if_overlaps(samples=8,ignore_adjacent=False,ignore_fixed=True)" in report.warnings[1]
|
|
)
|
|
assert "Overlaps detected" in report.warnings[1]
|
|
assert "overlap_tol=0.001" in report.warnings[1]
|
|
assert "overlap_volume_tol=0" in report.warnings[1]
|
|
assert "relation=unrelated" in report.warnings[1]
|
|
assert "depth=(0.2,0.2,0.2)" in report.warnings[1]
|
|
assert "elem_a=#0 'base_box':Box" in report.warnings[1]
|
|
assert "elem_b=#0 'child_box':Box" in report.warnings[1]
|
|
|
|
|
|
def test_fail_if_parts_overlap_in_current_pose_fails_for_rest_pose_part_overlap() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
|
|
assert not ctx.fail_if_parts_overlap_in_current_pose(overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert report.checks == (
|
|
"fail_if_parts_overlap_in_current_pose(overlap_tol=0.001,overlap_volume_tol=0)",
|
|
)
|
|
assert len(report.failures) == 1
|
|
assert (
|
|
report.failures[0].name
|
|
== "fail_if_parts_overlap_in_current_pose(overlap_tol=0.001,overlap_volume_tol=0)"
|
|
)
|
|
assert "Part overlaps detected" in report.failures[0].details
|
|
assert "pair=('base','child')" in report.failures[0].details
|
|
assert "depth=(0.2,0.2,0.2)" in report.failures[0].details
|
|
assert "elem_a=#0 'base_box':Box" in report.failures[0].details
|
|
assert "elem_b=#0 'child_box':Box" in report.failures[0].details
|
|
assert "pose={}" in report.failures[0].details
|
|
|
|
|
|
def test_fail_if_parts_overlap_in_current_pose_uses_current_pose_only() -> None:
|
|
model = _build_pose_specific_part_overlap_model()
|
|
articulation = model.get_articulation("base_to_slider")
|
|
ctx = SDKTestContext(model)
|
|
|
|
assert ctx.fail_if_parts_overlap_in_current_pose(overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
with ctx.pose({articulation: -0.1}):
|
|
assert not ctx.fail_if_parts_overlap_in_current_pose(
|
|
overlap_tol=0.001, overlap_volume_tol=0.0
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert report.checks == (
|
|
"fail_if_parts_overlap_in_current_pose(overlap_tol=0.001,overlap_volume_tol=0)",
|
|
"fail_if_parts_overlap_in_current_pose(overlap_tol=0.001,overlap_volume_tol=0)",
|
|
)
|
|
assert len(report.failures) == 1
|
|
assert "pair=('base','slider')" in report.failures[0].details
|
|
assert "pose={'base_to_slider': -0.1}" in report.failures[0].details
|
|
|
|
|
|
def test_fail_if_parts_overlap_in_current_pose_aggregates_to_one_failure_per_part_pair() -> None:
|
|
ctx = SDKTestContext(_build_multi_element_overlapping_parts_model())
|
|
|
|
assert not ctx.fail_if_parts_overlap_in_current_pose(overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
|
|
report = ctx.report()
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].details.count("pair=('base','child')") == 1
|
|
|
|
|
|
def test_fail_if_parts_overlap_in_current_pose_uses_default_volume_tolerance_for_tiny_overlap(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("OBJECT_GEOMETRY_OVERLAP_TOL", raising=False)
|
|
monkeypatch.delenv("URDF_GEOMETRY_OVERLAP_TOL", raising=False)
|
|
monkeypatch.delenv("OBJECT_GEOMETRY_OVERLAP_VOLUME_TOL", raising=False)
|
|
monkeypatch.delenv("URDF_GEOMETRY_OVERLAP_VOLUME_TOL", raising=False)
|
|
|
|
ctx = SDKTestContext(
|
|
_build_small_overlap_parts_model(child_center_xyz=(0.014, 0.014, 0.024)),
|
|
)
|
|
|
|
assert ctx.fail_if_parts_overlap_in_current_pose()
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("fail_if_parts_overlap_in_current_pose()",)
|
|
|
|
|
|
def test_fail_if_parts_overlap_in_current_pose_still_fails_large_overlap_under_defaults(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("OBJECT_GEOMETRY_OVERLAP_TOL", raising=False)
|
|
monkeypatch.delenv("URDF_GEOMETRY_OVERLAP_TOL", raising=False)
|
|
monkeypatch.delenv("OBJECT_GEOMETRY_OVERLAP_VOLUME_TOL", raising=False)
|
|
monkeypatch.delenv("URDF_GEOMETRY_OVERLAP_VOLUME_TOL", raising=False)
|
|
|
|
ctx = SDKTestContext(
|
|
_build_small_overlap_parts_model(child_center_xyz=(0.01, 0.01, 0.02)),
|
|
)
|
|
|
|
assert not ctx.fail_if_parts_overlap_in_current_pose()
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert report.checks == ("fail_if_parts_overlap_in_current_pose()",)
|
|
assert len(report.failures) == 1
|
|
assert "Part overlaps detected" in report.failures[0].details
|
|
|
|
|
|
def test_allow_overlap_suppresses_fail_if_parts_overlap_in_current_pose() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
ctx.allow_overlap("base", "child", reason="bowl nests into the seated opening")
|
|
|
|
assert ctx.fail_if_parts_overlap_in_current_pose(overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == (
|
|
"fail_if_parts_overlap_in_current_pose(overlap_tol=0.001,overlap_volume_tol=0)",
|
|
)
|
|
assert report.allowances == (
|
|
"allow_overlap('base', 'child'): bowl nests into the seated opening",
|
|
)
|
|
assert len(report.warnings) == 1
|
|
assert "Overlaps detected but allowed by justification" in report.warnings[0]
|
|
|
|
|
|
def test_allow_overlap_accepts_original_mesh_element_name_after_component_split(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
ctx = SDKTestContext(_build_mesh_overlap_model(tmp_path))
|
|
ctx.allow_overlap(
|
|
"base", "child", elem_a="frame_body", elem_b="child_box", reason="mesh contact"
|
|
)
|
|
|
|
assert ctx.fail_if_parts_overlap_in_current_pose(overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.allowances == (
|
|
"allow_overlap('base', 'child', elem_a='frame_body', elem_b='child_box'): mesh contact",
|
|
)
|
|
|
|
|
|
def test_allow_overlap_accepts_mesh_component_specific_name_after_component_split(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
ctx = SDKTestContext(_build_mesh_overlap_model(tmp_path))
|
|
ctx.allow_overlap(
|
|
"base",
|
|
"child",
|
|
elem_a="frame_body__component_002",
|
|
elem_b="child_box",
|
|
reason="mesh contact",
|
|
)
|
|
|
|
assert ctx.fail_if_parts_overlap_in_current_pose(overlap_tol=0.001, overlap_volume_tol=0.0)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.allowances == (
|
|
"allow_overlap('base', 'child', elem_a='frame_body__component_002', elem_b='child_box'): mesh contact",
|
|
)
|
|
|
|
|
|
def test_fail_if_parts_overlap_in_sampled_poses_records_failure() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
|
|
assert not ctx.fail_if_parts_overlap_in_sampled_poses(
|
|
max_pose_samples=8,
|
|
overlap_tol=0.001,
|
|
overlap_volume_tol=0.0,
|
|
name="sampled_clearance",
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert report.checks == ("sampled_clearance",)
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "sampled_clearance"
|
|
assert "Overlaps detected" in report.failures[0].details
|
|
assert "relation=unrelated" in report.failures[0].details
|
|
|
|
|
|
def test_deprecated_default_helper_warning_emits_once_per_helper() -> None:
|
|
ctx = SDKTestContext(_build_joint_origin_model(joint_z=0.2))
|
|
|
|
assert not ctx.warn_if_articulation_origin_far_from_geometry(tol=0.015)
|
|
assert not ctx.warn_if_articulation_origin_far_from_geometry(tol=0.015)
|
|
|
|
report = ctx.report()
|
|
assert (
|
|
sum(
|
|
warning.startswith(
|
|
"DEPRECATED AS DEFAULT: warn_if_articulation_origin_far_from_geometry(...)"
|
|
)
|
|
for warning in report.warnings
|
|
)
|
|
== 1
|
|
)
|
|
|
|
|
|
def test_fail_if_articulation_overlaps_fails_for_revolute_pair() -> None:
|
|
ctx = SDKTestContext(
|
|
_build_articulation_overlap_model(joint_type=ArticulationType.REVOLUTE),
|
|
)
|
|
|
|
assert not ctx.fail_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert report.checks == ("fail_if_articulation_overlaps(samples=8)",)
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "fail_if_articulation_overlaps(samples=8)"
|
|
assert "pair=('base','child')" in report.failures[0].details
|
|
|
|
|
|
def test_warn_if_articulation_overlaps_records_warning_only() -> None:
|
|
ctx = SDKTestContext(
|
|
_build_articulation_overlap_model(joint_type=ArticulationType.REVOLUTE),
|
|
)
|
|
|
|
assert not ctx.warn_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("warn_if_articulation_overlaps(samples=8)",)
|
|
assert len(report.warnings) == 1
|
|
assert report.warnings[0].startswith("warn_if_articulation_overlaps(samples=8): ")
|
|
assert "pair=('base','child')" in report.warnings[0]
|
|
|
|
|
|
def test_fail_if_articulation_overlaps_fails_for_prismatic_pair() -> None:
|
|
ctx = SDKTestContext(
|
|
_build_articulation_overlap_model(joint_type=ArticulationType.PRISMATIC),
|
|
)
|
|
|
|
assert not ctx.fail_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "fail_if_articulation_overlaps(samples=8)"
|
|
assert "pair=('base','child')" in report.failures[0].details
|
|
|
|
|
|
def test_fail_if_articulation_overlaps_fails_for_continuous_pair() -> None:
|
|
ctx = SDKTestContext(
|
|
_build_articulation_overlap_model(joint_type=ArticulationType.CONTINUOUS),
|
|
)
|
|
|
|
assert not ctx.fail_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "fail_if_articulation_overlaps(samples=8)"
|
|
assert "pair=('base','child')" in report.failures[0].details
|
|
|
|
|
|
def test_fail_if_articulation_overlaps_ignores_fixed_pairs() -> None:
|
|
ctx = SDKTestContext(
|
|
_build_articulation_overlap_model(joint_type=ArticulationType.FIXED),
|
|
)
|
|
|
|
assert ctx.fail_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("fail_if_articulation_overlaps(samples=8)",)
|
|
|
|
|
|
def test_fail_if_articulation_overlaps_ignores_non_articulation_pairs() -> None:
|
|
ctx = SDKTestContext(_build_non_articulation_overlap_only_model())
|
|
|
|
assert ctx.fail_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("fail_if_articulation_overlaps(samples=8)",)
|
|
|
|
|
|
def test_allow_overlap_suppresses_reported_articulation_pair() -> None:
|
|
ctx = SDKTestContext(
|
|
_build_articulation_overlap_model(joint_type=ArticulationType.REVOLUTE),
|
|
)
|
|
ctx.allow_overlap("base", "child", reason="bearing sleeve nests around the hinge pin")
|
|
|
|
assert ctx.fail_if_articulation_overlaps(max_pose_samples=8)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("fail_if_articulation_overlaps(samples=8)",)
|
|
assert report.allowances == (
|
|
"allow_overlap('base', 'child'): bearing sleeve nests around the hinge pin",
|
|
)
|
|
assert len(report.warnings) == 1
|
|
assert "Overlaps detected but allowed by justification" in report.warnings[0]
|
|
|
|
|
|
def test_allow_isolated_part_records_allowance_and_structured_part_list() -> None:
|
|
model = _build_overlapping_parts_model()
|
|
child = model.get_part("child")
|
|
ctx = SDKTestContext(model)
|
|
|
|
ctx.allow_isolated_part(child, reason="intentionally freestanding accent")
|
|
|
|
report = ctx.report()
|
|
assert report.allowances == ("allow_isolated_part('child'): intentionally freestanding accent",)
|
|
assert report.allowed_isolated_parts == ("child",)
|
|
|
|
|
|
def test_allow_isolated_part_accepts_string_name() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
|
|
ctx.allow_isolated_part("child", reason="intentionally freestanding accent")
|
|
|
|
report = ctx.report()
|
|
assert report.allowed_isolated_parts == ("child",)
|
|
|
|
|
|
def test_allow_isolated_part_rejects_empty_reason() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
|
|
try:
|
|
ctx.allow_isolated_part("child", reason=" ")
|
|
except ValueError as exc:
|
|
assert "allow_isolated_part requires a non-empty reason" in str(exc)
|
|
else: # pragma: no cover - assertion helper
|
|
raise AssertionError("expected ValueError")
|
|
|
|
|
|
def test_warn_if_coplanar_surfaces_records_warning_only() -> None:
|
|
ctx = SDKTestContext(_build_coplanar_surface_model())
|
|
|
|
assert not ctx.warn_if_coplanar_surfaces(
|
|
max_pose_samples=1,
|
|
plane_tol=0.001,
|
|
min_overlap=0.05,
|
|
min_overlap_ratio=0.35,
|
|
ignore_adjacent=False,
|
|
ignore_fixed=False,
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == (
|
|
"warn_if_coplanar_surfaces(samples=1,plane_tol=0.001,min_overlap=0.05,min_overlap_ratio=0.35,ignore_adjacent=False,ignore_fixed=False)",
|
|
)
|
|
assert len(report.warnings) == 1
|
|
assert (
|
|
"warn_if_coplanar_surfaces(samples=1,plane_tol=0.001,min_overlap=0.05,min_overlap_ratio=0.35,ignore_adjacent=False,ignore_fixed=False)"
|
|
in report.warnings[0]
|
|
)
|
|
assert "Coplanar or nearly coplanar surfaces detected (max_risk=medium" in report.warnings[0]
|
|
assert "risk=medium relation=unrelated pair=('base','cap')" in report.warnings[0]
|
|
|
|
|
|
def test_warn_if_coplanar_surfaces_defaults_ignore_adjacent_pairs() -> None:
|
|
ctx = SDKTestContext(_build_adjacent_coplanar_surface_model())
|
|
|
|
assert ctx.warn_if_coplanar_surfaces(
|
|
max_pose_samples=1,
|
|
plane_tol=0.001,
|
|
min_overlap=0.05,
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.warnings == ()
|
|
assert report.checks == (
|
|
"warn_if_coplanar_surfaces(samples=1,plane_tol=0.001,min_overlap=0.05,min_overlap_ratio=0.35,ignore_adjacent=True,ignore_fixed=True)",
|
|
)
|
|
|
|
|
|
def test_allow_coplanar_surfaces_suppresses_reported_pair() -> None:
|
|
ctx = SDKTestContext(_build_coplanar_surface_model())
|
|
ctx.allow_coplanar_surfaces("base", "cap", reason="flush mounted cap")
|
|
|
|
assert ctx.warn_if_coplanar_surfaces(
|
|
max_pose_samples=1,
|
|
plane_tol=0.001,
|
|
min_overlap=0.05,
|
|
min_overlap_ratio=0.35,
|
|
ignore_adjacent=False,
|
|
ignore_fixed=False,
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.warnings == ()
|
|
assert report.allowances == ("allow_coplanar_surfaces('base', 'cap'): flush mounted cap",)
|
|
|
|
|
|
def test_allow_overlap_accepts_part_and_visual_objects() -> None:
|
|
model = _build_overlapping_parts_model()
|
|
base = model.get_part("base")
|
|
child = model.get_part("child")
|
|
base_box = base.visuals[0]
|
|
child_box = child.visuals[0]
|
|
|
|
ctx = SDKTestContext(model)
|
|
ctx.allow_overlap(base, child, reason="bearing sleeve nests around the hinge pin")
|
|
ctx.allow_overlap(
|
|
base, child, reason="explicit element allowance", elem_a=base_box, elem_b=child_box
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.allowances == (
|
|
"allow_overlap('base', 'child'): bearing sleeve nests around the hinge pin",
|
|
"allow_overlap('base', 'child', elem_a='base_box', elem_b='child_box'): explicit element allowance",
|
|
)
|
|
assert report.allowed_overlaps == (
|
|
AllowedOverlap("base", "child", "bearing sleeve nests around the hinge pin"),
|
|
AllowedOverlap(
|
|
"base",
|
|
"child",
|
|
"explicit element allowance",
|
|
elem_a="base_box",
|
|
elem_b="child_box",
|
|
),
|
|
)
|
|
|
|
|
|
def test_allow_coplanar_surfaces_accepts_part_objects() -> None:
|
|
model = _build_coplanar_surface_model()
|
|
base = model.get_part("base")
|
|
cap = model.get_part("cap")
|
|
|
|
ctx = SDKTestContext(model)
|
|
ctx.allow_coplanar_surfaces(base, cap, reason="flush mounted cap")
|
|
|
|
assert ctx.warn_if_coplanar_surfaces(
|
|
max_pose_samples=1,
|
|
plane_tol=0.001,
|
|
min_overlap=0.05,
|
|
min_overlap_ratio=0.35,
|
|
ignore_adjacent=False,
|
|
ignore_fixed=False,
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.warnings == ()
|
|
assert report.allowances == ("allow_coplanar_surfaces('base', 'cap'): flush mounted cap",)
|
|
|
|
|
|
def test_expect_aabb_gap_keeps_whole_link_behavior_by_default() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert ctx.expect_aabb_gap("arm", "base", axis="z", max_gap=0.0, max_penetration=0.0)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("expect_aabb_gap(arm,base,axis=z)",)
|
|
assert len(report.warnings) == 1
|
|
assert "DEPRECATED: expect_aabb_gap(...)" in report.warnings[0]
|
|
|
|
|
|
def test_expect_aabb_gap_can_target_named_elements() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert not ctx.expect_aabb_gap(
|
|
"arm",
|
|
"base",
|
|
axis="z",
|
|
max_gap=0.0,
|
|
max_penetration=0.0,
|
|
positive_elem="hub",
|
|
negative_elem="body",
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "expect_aabb_gap(arm,base,axis=z)"
|
|
assert "gap_z=0.001" in report.failures[0].details
|
|
assert "positive_elem='hub'" in report.failures[0].details
|
|
assert "negative_elem='body'" in report.failures[0].details
|
|
assert any("DEPRECATED: expect_aabb_gap(...)" in warning for warning in report.warnings)
|
|
|
|
|
|
def test_expect_aabb_gap_accepts_part_and_visual_objects() -> None:
|
|
model = _build_element_gap_model()
|
|
arm = model.get_part("arm")
|
|
base = model.get_part("base")
|
|
hub = arm.visuals[0]
|
|
body = base.visuals[0]
|
|
|
|
ctx = SDKTestContext(model)
|
|
|
|
assert not ctx.expect_aabb_gap(
|
|
arm,
|
|
base,
|
|
axis="z",
|
|
max_gap=0.0,
|
|
max_penetration=0.0,
|
|
positive_elem=hub,
|
|
negative_elem=body,
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "expect_aabb_gap(arm,base,axis=z)"
|
|
assert "gap_z=0.001" in report.failures[0].details
|
|
assert "positive_elem='hub'" in report.failures[0].details
|
|
assert "negative_elem='body'" in report.failures[0].details
|
|
assert any("DEPRECATED: expect_aabb_gap(...)" in warning for warning in report.warnings)
|
|
|
|
|
|
def test_pose_accepts_articulation_objects() -> None:
|
|
model = _build_joint_origin_model(joint_z=0.05)
|
|
child = model.get_part("child")
|
|
joint = model.get_articulation("base_to_child")
|
|
|
|
ctx = SDKTestContext(model)
|
|
rest_aabb = ctx.link_world_aabb(child)
|
|
assert rest_aabb is not None
|
|
|
|
with ctx.pose({joint: 0.5}):
|
|
posed_aabb = ctx.link_world_aabb(child)
|
|
|
|
assert posed_aabb is not None
|
|
assert posed_aabb != rest_aabb
|
|
|
|
|
|
def test_expect_aabb_gap_reports_missing_named_element() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert not ctx.expect_aabb_gap(
|
|
"arm",
|
|
"base",
|
|
axis="z",
|
|
max_gap=0.0,
|
|
positive_elem="missing_hub",
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert (
|
|
"missing element AABB for positive_elem='missing_hub' on 'arm'"
|
|
in report.failures[0].details
|
|
)
|
|
assert any("DEPRECATED: expect_aabb_gap(...)" in warning for warning in report.warnings)
|
|
|
|
|
|
def test_expect_contact_uses_exact_visual_geometry() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert ctx.expect_contact("arm", "base")
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("expect_contact(arm,base)",)
|
|
|
|
|
|
def test_expect_contact_can_target_exact_named_elements() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert not ctx.expect_contact("arm", "base", elem_a="hub", elem_b="body", contact_tol=1e-6)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "expect_contact(arm,base)"
|
|
assert "min_distance=0.001" in report.failures[0].details
|
|
assert "elem_a='hub'" in report.failures[0].details
|
|
assert "elem_b='body'" in report.failures[0].details
|
|
|
|
|
|
def test_expect_gap_uses_exact_visual_projection() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert ctx.expect_gap("arm", "base", axis="z", max_gap=0.0, max_penetration=0.0)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("expect_gap(arm,base,axis=z)",)
|
|
|
|
|
|
def test_expect_gap_can_target_named_elements() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert not ctx.expect_gap(
|
|
"arm",
|
|
"base",
|
|
axis="z",
|
|
max_gap=0.0,
|
|
max_penetration=0.0,
|
|
positive_elem="hub",
|
|
negative_elem="body",
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert len(report.failures) == 1
|
|
assert report.failures[0].name == "expect_gap(arm,base,axis=z)"
|
|
assert "gap_z=0.001" in report.failures[0].details
|
|
assert "positive_elem='hub'" in report.failures[0].details
|
|
assert "negative_elem='body'" in report.failures[0].details
|
|
|
|
|
|
def test_expect_gap_accepts_elem_aliases() -> None:
|
|
ctx = SDKTestContext(_build_element_gap_model())
|
|
|
|
assert not ctx.expect_gap(
|
|
"arm",
|
|
"base",
|
|
axis="z",
|
|
max_gap=0.0,
|
|
max_penetration=0.0,
|
|
elem_a="hub",
|
|
elem_b="body",
|
|
)
|
|
|
|
report = ctx.report()
|
|
assert not report.passed
|
|
assert "positive_elem='hub'" in report.failures[0].details
|
|
assert "negative_elem='body'" in report.failures[0].details
|
|
|
|
|
|
def test_expect_overlap_uses_exact_visual_geometry() -> None:
|
|
ctx = SDKTestContext(_build_overlapping_parts_model())
|
|
|
|
assert ctx.expect_overlap("base", "child", axes="xy", min_overlap=0.19)
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("expect_overlap(base,child,axes=xy)",)
|
|
|
|
|
|
def test_expect_within_uses_exact_visual_geometry() -> None:
|
|
ctx = SDKTestContext(_build_nested_parts_model())
|
|
|
|
assert ctx.expect_within("inner", "outer", axes="xyz")
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.failures == ()
|
|
assert report.checks == ("expect_within(inner,outer,axes=xyz)",)
|
|
|
|
|
|
def test_expect_within_accepts_elem_aliases() -> None:
|
|
ctx = SDKTestContext(_build_nested_parts_model())
|
|
|
|
assert ctx.expect_within("inner", "outer", axes="xyz", elem_a="insert", elem_b="shell")
|
|
|
|
report = ctx.report()
|
|
assert report.passed
|
|
assert report.checks == ("expect_within(inner,outer,axes=xyz)",)
|
|
|
|
|
|
def _build_floating_pose_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="floating_pose_checks")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)), name="base_box")
|
|
|
|
payload = model.part("payload")
|
|
payload.visual(
|
|
Box((0.1, 0.1, 0.1)),
|
|
origin=Origin(xyz=(0.0, 0.0, 0.05)),
|
|
name="payload_box",
|
|
)
|
|
|
|
model.articulation(
|
|
"base_to_payload",
|
|
ArticulationType.FLOATING,
|
|
parent=base,
|
|
child=payload,
|
|
origin=Origin(xyz=(0.4, 0.0, 0.0)),
|
|
)
|
|
|
|
return model
|
|
|
|
|
|
def _build_multi_root_pose_model() -> ArticulatedObject:
|
|
model = ArticulatedObject(name="multiple_roots_pose_checks")
|
|
|
|
base = model.part("base")
|
|
base.visual(Box((0.2, 0.2, 0.2)), origin=Origin(xyz=(0.0, 0.0, 0.1)), name="base_box")
|
|
|
|
rogue = model.part("rogue")
|
|
rogue.visual(Box((0.1, 0.1, 0.1)), origin=Origin(xyz=(0.0, 0.0, 0.05)), name="rogue_box")
|
|
|
|
return model
|
|
|
|
|
|
def test_pose_accepts_origin_for_floating_joint() -> None:
|
|
model = _build_floating_pose_model()
|
|
ctx = SDKTestContext(model)
|
|
joint = model.get_articulation("base_to_payload")
|
|
|
|
with ctx.pose({joint: Origin(xyz=(0.1, 0.2, 0.3))}):
|
|
pos = ctx.part_world_position("payload")
|
|
assert pos is not None
|
|
assert pos == pytest.approx((0.5, 0.2, 0.3))
|
|
|
|
|
|
def test_pose_rejects_scalar_for_floating_joint() -> None:
|
|
model = _build_floating_pose_model()
|
|
ctx = SDKTestContext(model)
|
|
joint = model.get_articulation("base_to_payload")
|
|
|
|
with pytest.raises(Exception, match="expects an Origin pose value"):
|
|
with ctx.pose({joint: 0.1}):
|
|
pass
|
|
|
|
|
|
def test_pose_rejects_origin_for_scalar_joint() -> None:
|
|
model = _build_joint_origin_model(joint_z=0.1)
|
|
ctx = SDKTestContext(model)
|
|
joint = model.get_articulation("base_to_child")
|
|
|
|
with pytest.raises(Exception, match="expects a scalar pose value"):
|
|
with ctx.pose({joint: Origin(xyz=(0.1, 0.0, 0.0))}):
|
|
pass
|
|
|
|
|
|
def test_pose_kwargs_accept_origin_for_floating_joint() -> None:
|
|
model = _build_floating_pose_model()
|
|
ctx = SDKTestContext(model)
|
|
|
|
with ctx.pose(base_to_payload=Origin(xyz=(0.0, -0.2, 0.15))):
|
|
pos = ctx.part_world_position("payload")
|
|
assert pos is not None
|
|
assert pos == pytest.approx((0.4, -0.2, 0.15))
|
|
|
|
|
|
def test_pose_rejects_direct_mimic_joint_override() -> None:
|
|
model = ArticulatedObject(name="mimic_pose_override")
|
|
base = model.part("base")
|
|
carriage = model.part("carriage")
|
|
tool = model.part("tool")
|
|
driver = model.articulation(
|
|
"base_to_carriage",
|
|
ArticulationType.PRISMATIC,
|
|
parent=base,
|
|
child=carriage,
|
|
axis=(1.0, 0.0, 0.0),
|
|
motion_limits=MotionLimits(effort=2.0, velocity=1.0, lower=0.0, upper=0.2),
|
|
)
|
|
follower = model.articulation(
|
|
"carriage_to_tool",
|
|
ArticulationType.PRISMATIC,
|
|
parent=carriage,
|
|
child=tool,
|
|
axis=(1.0, 0.0, 0.0),
|
|
motion_limits=MotionLimits(effort=2.0, velocity=1.0, lower=0.0, upper=0.2),
|
|
mimic=Mimic(joint=driver.name, multiplier=0.5),
|
|
)
|
|
ctx = SDKTestContext(model)
|
|
|
|
with pytest.raises(Exception, match="mimic-driven and cannot be posed directly"):
|
|
with ctx.pose({follower: 0.1}):
|
|
pass
|
|
|
|
|
|
def test_part_world_position_rejects_multiple_root_parts() -> None:
|
|
ctx = SDKTestContext(_build_multi_root_pose_model())
|
|
|
|
with pytest.raises(ValidationError, match="exactly one root part"):
|
|
ctx.part_world_position("base")
|