mattzh72--articraft
681 行
20 KiB
Python
681 行
20 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from sdk import (
|
|
Box,
|
|
BoxGeometry,
|
|
Cylinder,
|
|
Mesh,
|
|
MeshGeometry,
|
|
Origin,
|
|
Part,
|
|
Sphere,
|
|
SurfaceFrame,
|
|
ValidationError,
|
|
Visual,
|
|
align_centers,
|
|
mesh_from_geometry,
|
|
part_local_aabb,
|
|
place_on_face,
|
|
place_on_surface,
|
|
sample_catmull_rom_spline_2d,
|
|
surface_frame,
|
|
wrap_mesh_onto_surface,
|
|
wrap_profile_onto_surface,
|
|
)
|
|
from sdk._core.v0 import placement as placement_module
|
|
|
|
|
|
def _rpy_matrix(origin: Origin) -> tuple[tuple[float, float, float], ...]:
|
|
roll, pitch, yaw = origin.rpy
|
|
cr = math.cos(roll)
|
|
sr = math.sin(roll)
|
|
cp = math.cos(pitch)
|
|
sp = math.sin(pitch)
|
|
cy = math.cos(yaw)
|
|
sy = math.sin(yaw)
|
|
return (
|
|
(cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr),
|
|
(sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr),
|
|
(-sp, cp * sr, cp * cr),
|
|
)
|
|
|
|
|
|
def _rotate(
|
|
mat: tuple[tuple[float, float, float], ...], vec: tuple[float, float, float]
|
|
) -> tuple[float, float, float]:
|
|
x, y, z = vec
|
|
return (
|
|
mat[0][0] * x + mat[0][1] * y + mat[0][2] * z,
|
|
mat[1][0] * x + mat[1][1] * y + mat[1][2] * z,
|
|
mat[2][0] * x + mat[2][1] * y + mat[2][2] * z,
|
|
)
|
|
|
|
|
|
def _assert_vec_close(
|
|
actual: tuple[float, float, float], expected: tuple[float, float, float], *, tol: float = 1e-6
|
|
) -> None:
|
|
for a, b in zip(actual, expected):
|
|
assert abs(a - b) <= tol
|
|
|
|
|
|
def _radius(vec: tuple[float, float, float]) -> float:
|
|
return math.sqrt(vec[0] ** 2 + vec[1] ** 2 + vec[2] ** 2)
|
|
|
|
|
|
def _spherical_log_map(
|
|
point: tuple[float, float, float],
|
|
*,
|
|
center: tuple[float, float, float],
|
|
radius: float,
|
|
frame: SurfaceFrame,
|
|
) -> tuple[float, float]:
|
|
normal = (
|
|
(point[0] - center[0]) / radius,
|
|
(point[1] - center[1]) / radius,
|
|
(point[2] - center[2]) / radius,
|
|
)
|
|
cos_angle = max(-1.0, min(1.0, sum(a * b for a, b in zip(frame.normal, normal))))
|
|
angle = math.acos(cos_angle)
|
|
if angle <= 1e-12:
|
|
return (0.0, 0.0)
|
|
tangent = (
|
|
normal[0] - cos_angle * frame.normal[0],
|
|
normal[1] - cos_angle * frame.normal[1],
|
|
normal[2] - cos_angle * frame.normal[2],
|
|
)
|
|
tangent_norm = math.sqrt(sum(v * v for v in tangent))
|
|
tangent_dir = (
|
|
tangent[0] / tangent_norm,
|
|
tangent[1] / tangent_norm,
|
|
tangent[2] / tangent_norm,
|
|
)
|
|
distance = radius * angle
|
|
return (
|
|
distance * sum(a * b for a, b in zip(tangent_dir, frame.tangent_u)),
|
|
distance * sum(a * b for a, b in zip(tangent_dir, frame.tangent_v)),
|
|
)
|
|
|
|
|
|
def _cylindrical_recover_xy(
|
|
point: tuple[float, float, float],
|
|
*,
|
|
radius: float,
|
|
frame: SurfaceFrame,
|
|
) -> tuple[float, float]:
|
|
radial = (point[0], point[1], 0.0)
|
|
radial_norm = math.sqrt(radial[0] ** 2 + radial[1] ** 2)
|
|
radial_dir = (radial[0] / radial_norm, radial[1] / radial_norm, 0.0)
|
|
anchor_radial = (frame.normal[0], frame.normal[1], 0.0)
|
|
anchor_radial_norm = math.sqrt(anchor_radial[0] ** 2 + anchor_radial[1] ** 2)
|
|
anchor_radial_dir = (
|
|
anchor_radial[0] / anchor_radial_norm,
|
|
anchor_radial[1] / anchor_radial_norm,
|
|
0.0,
|
|
)
|
|
sin_angle = anchor_radial_dir[0] * radial_dir[1] - anchor_radial_dir[1] * radial_dir[0]
|
|
cos_angle = anchor_radial_dir[0] * radial_dir[0] + anchor_radial_dir[1] * radial_dir[1]
|
|
circum = radius * math.atan2(sin_angle, cos_angle)
|
|
axial = point[2] - frame.point[2]
|
|
return (axial, -circum)
|
|
|
|
|
|
def test_place_on_surface_mounts_centered_child_flush_to_sphere() -> None:
|
|
origin = place_on_surface(
|
|
child=Box((0.020, 0.030, 0.004)),
|
|
target=Sphere(radius=1.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
child_axis="+z",
|
|
)
|
|
|
|
_assert_vec_close(origin.xyz, (1.002, 0.0, 0.0))
|
|
rotated = _rotate(_rpy_matrix(origin), (0.0, 0.0, 1.0))
|
|
_assert_vec_close(rotated, (1.0, 0.0, 0.0))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("face", "expected_x", "expected_y", "expected_z"),
|
|
[
|
|
("+x", (0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
|
|
("-x", (0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 0.0)),
|
|
("+y", (1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
|
|
("-y", (1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
|
|
("+z", (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
|
("-z", (1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
|
|
],
|
|
)
|
|
def test_place_on_face_uses_documented_face_basis(
|
|
face: str,
|
|
expected_x: tuple[float, float, float],
|
|
expected_y: tuple[float, float, float],
|
|
expected_z: tuple[float, float, float],
|
|
) -> None:
|
|
parent = Part("parent")
|
|
parent.visual(Box((2.0, 4.0, 6.0)))
|
|
|
|
origin = place_on_face(parent, face, prefer_collisions=False)
|
|
rot = _rpy_matrix(origin)
|
|
|
|
_assert_vec_close(_rotate(rot, (1.0, 0.0, 0.0)), expected_x)
|
|
_assert_vec_close(_rotate(rot, (0.0, 1.0, 0.0)), expected_y)
|
|
_assert_vec_close(_rotate(rot, (0.0, 0.0, 1.0)), expected_z)
|
|
|
|
|
|
def test_align_centers_respects_selected_axes() -> None:
|
|
origin = align_centers(
|
|
((1.0, 2.0, 3.0), (5.0, 8.0, 9.0)),
|
|
((10.0, 20.0, 30.0), (16.0, 28.0, 40.0)),
|
|
axes=("x", "z"),
|
|
)
|
|
|
|
assert origin.xyz == (10.0, 0.0, 29.0)
|
|
|
|
|
|
def test_legacy_aabb_placement_helpers_warn() -> None:
|
|
with pytest.deprecated_call():
|
|
placement_module.align_centers_xy(
|
|
((0.0, 0.0, 0.0), (2.0, 4.0, 6.0)),
|
|
((10.0, 20.0, 30.0), (14.0, 28.0, 42.0)),
|
|
)
|
|
|
|
with pytest.deprecated_call():
|
|
placement_module.place_on_top(
|
|
((0.0, 0.0, 0.0), (2.0, 4.0, 6.0)),
|
|
((10.0, 20.0, 30.0), (14.0, 28.0, 42.0)),
|
|
)
|
|
|
|
with pytest.deprecated_call():
|
|
placement_module.place_in_front_of(
|
|
((0.0, 0.0, 0.0), (2.0, 4.0, 6.0)),
|
|
((10.0, 20.0, 30.0), (14.0, 28.0, 42.0)),
|
|
)
|
|
|
|
|
|
def test_surface_frame_respects_visual_origin_offset() -> None:
|
|
target = Visual(
|
|
geometry=Sphere(radius=1.0),
|
|
origin=Origin(xyz=(0.5, -0.25, 0.1)),
|
|
)
|
|
|
|
frame = surface_frame(
|
|
target,
|
|
point_hint=(2.5, -0.25, 0.1),
|
|
)
|
|
|
|
assert isinstance(frame, SurfaceFrame)
|
|
_assert_vec_close(frame.point, (1.5, -0.25, 0.1))
|
|
_assert_vec_close(frame.normal, (1.0, 0.0, 0.0))
|
|
|
|
|
|
def test_surface_frame_uses_mesh_proximity_backend(tmp_path: Path) -> None:
|
|
mesh: Mesh = mesh_from_geometry(BoxGeometry((2.0, 2.0, 2.0)), tmp_path / "box.obj")
|
|
|
|
frame = surface_frame(
|
|
mesh,
|
|
point_hint=(1.8, 0.2, -0.1),
|
|
)
|
|
|
|
_assert_vec_close(frame.point, (1.0, 0.2, -0.1), tol=1e-4)
|
|
_assert_vec_close(frame.normal, (1.0, 0.0, 0.0), tol=1e-4)
|
|
|
|
|
|
def test_surface_frame_uses_scaled_source_geometry_provenance() -> None:
|
|
target = Mesh(
|
|
filename="dummy.obj",
|
|
scale=(2.0, 2.0, 2.0),
|
|
source_geometry=Box((1.0, 1.0, 1.0)),
|
|
)
|
|
|
|
frame = surface_frame(
|
|
target,
|
|
direction=(0.0, 0.0, 1.0),
|
|
)
|
|
|
|
_assert_vec_close(frame.point, (0.0, 0.0, 1.0))
|
|
|
|
origin = place_on_surface(
|
|
child=Box((1.0, 1.0, 1.0)),
|
|
target=target,
|
|
direction=(0.0, 0.0, 1.0),
|
|
child_axis="+z",
|
|
)
|
|
|
|
_assert_vec_close(origin.xyz, (0.0, 0.0, 1.5))
|
|
|
|
|
|
def test_mesh_helpers_resolve_legacy_mesh_prefix(tmp_path: Path) -> None:
|
|
mesh_from_geometry(
|
|
BoxGeometry((2.0, 4.0, 6.0)),
|
|
tmp_path / "assets" / "meshes" / "box.obj",
|
|
)
|
|
mesh = Mesh(filename="meshes/box.obj")
|
|
|
|
aabb = part_local_aabb(
|
|
Part("body", visuals=[Visual(mesh)]),
|
|
asset_root=tmp_path,
|
|
prefer_collisions=False,
|
|
)
|
|
|
|
assert aabb == ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0))
|
|
|
|
loaded = placement_module._load_trimesh_mesh(mesh, asset_root=tmp_path)
|
|
assert tuple(float(v) for v in loaded.extents) == (2.0, 4.0, 6.0)
|
|
|
|
|
|
def test_place_on_surface_uses_mesh_target_for_flush_offset(tmp_path: Path) -> None:
|
|
target_mesh: Mesh = mesh_from_geometry(BoxGeometry((2.0, 2.0, 2.0)), tmp_path / "target.obj")
|
|
|
|
origin = place_on_surface(
|
|
child=Box((0.100, 0.100, 0.100)),
|
|
target=target_mesh,
|
|
point_hint=(1.8, 0.0, 0.0),
|
|
child_axis="+z",
|
|
clearance=0.010,
|
|
)
|
|
|
|
_assert_vec_close(origin.xyz, (1.06, 0.0, 0.0), tol=1e-4)
|
|
|
|
|
|
def test_surface_mesh_cache_refreshes_when_obj_changes(tmp_path: Path) -> None:
|
|
placement_module._TRIMESH_CACHE.clear()
|
|
|
|
mesh_path = tmp_path / "assets" / "meshes" / "box.obj"
|
|
mesh_from_geometry(BoxGeometry((1.0, 1.0, 1.0)), mesh_path)
|
|
mesh = Mesh(filename="assets/meshes/box.obj")
|
|
|
|
loaded1 = placement_module._load_trimesh_mesh(mesh, asset_root=tmp_path)
|
|
mesh_from_geometry(BoxGeometry((2.0, 2.0, 2.0)), mesh_path)
|
|
loaded2 = placement_module._load_trimesh_mesh(mesh, asset_root=tmp_path)
|
|
|
|
assert tuple(float(v) for v in loaded1.extents) == (1.0, 1.0, 1.0)
|
|
assert tuple(float(v) for v in loaded2.extents) == (2.0, 2.0, 2.0)
|
|
assert loaded1 is not loaded2
|
|
|
|
placement_module._TRIMESH_CACHE.clear()
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_conforms_visible_face_to_sphere() -> None:
|
|
geom = MeshGeometry(
|
|
vertices=[
|
|
(-0.10, 0.0, 0.0),
|
|
(0.10, 0.0, 0.0),
|
|
(-0.10, 0.0, -0.02),
|
|
(0.10, 0.0, -0.02),
|
|
],
|
|
faces=[(0, 1, 2), (1, 3, 2)],
|
|
)
|
|
|
|
wrapped = wrap_mesh_onto_surface(
|
|
geom,
|
|
Sphere(radius=1.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
child_axis="+z",
|
|
visible_relief=0.0,
|
|
)
|
|
|
|
assert isinstance(wrapped, MeshGeometry)
|
|
outer = wrapped.vertices[:2]
|
|
inner = wrapped.vertices[2:]
|
|
for vertex in outer:
|
|
radius = math.sqrt(vertex[0] ** 2 + vertex[1] ** 2 + vertex[2] ** 2)
|
|
assert abs(radius - 1.0) <= 1e-6
|
|
for outer_vertex, inner_vertex in zip(outer, inner):
|
|
outer_radius = math.sqrt(outer_vertex[0] ** 2 + outer_vertex[1] ** 2 + outer_vertex[2] ** 2)
|
|
inner_radius = math.sqrt(inner_vertex[0] ** 2 + inner_vertex[1] ** 2 + inner_vertex[2] ** 2)
|
|
assert inner_radius < outer_radius
|
|
assert outer[0][2] < 0.0
|
|
assert outer[1][2] > 0.0
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_uses_mesh_target(tmp_path: Path) -> None:
|
|
target_mesh: Mesh = mesh_from_geometry(BoxGeometry((2.0, 2.0, 2.0)), tmp_path / "target.obj")
|
|
geom = MeshGeometry(
|
|
vertices=[
|
|
(-0.05, -0.05, 0.01),
|
|
(0.05, -0.05, 0.01),
|
|
(0.05, 0.05, 0.01),
|
|
(-0.05, 0.05, 0.01),
|
|
(-0.05, -0.05, -0.01),
|
|
(0.05, -0.05, -0.01),
|
|
(0.05, 0.05, -0.01),
|
|
(-0.05, 0.05, -0.01),
|
|
],
|
|
faces=[(0, 1, 2), (0, 2, 3), (4, 6, 5), (4, 7, 6)],
|
|
)
|
|
|
|
wrapped = wrap_mesh_onto_surface(
|
|
geom,
|
|
target_mesh,
|
|
point_hint=(1.2, 0.0, 0.0),
|
|
child_axis="+z",
|
|
visible_relief=0.0,
|
|
)
|
|
|
|
outer = wrapped.vertices[:4]
|
|
for vertex in outer:
|
|
assert abs(vertex[0] - 1.0) <= 1e-4
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_subdivides_large_patch_with_max_edge() -> None:
|
|
geom = MeshGeometry(
|
|
vertices=[
|
|
(-0.25, -0.20, 0.0),
|
|
(0.25, -0.20, 0.0),
|
|
(0.00, 0.28, 0.0),
|
|
],
|
|
faces=[(0, 1, 2)],
|
|
)
|
|
|
|
wrapped = wrap_mesh_onto_surface(
|
|
geom,
|
|
Sphere(radius=1.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
child_axis="+z",
|
|
visible_relief=0.0,
|
|
max_edge=0.05,
|
|
)
|
|
|
|
assert len(wrapped.faces) > 1
|
|
for a, b, c in wrapped.faces:
|
|
centroid = (
|
|
(wrapped.vertices[a][0] + wrapped.vertices[b][0] + wrapped.vertices[c][0]) / 3.0,
|
|
(wrapped.vertices[a][1] + wrapped.vertices[b][1] + wrapped.vertices[c][1]) / 3.0,
|
|
(wrapped.vertices[a][2] + wrapped.vertices[b][2] + wrapped.vertices[c][2]) / 3.0,
|
|
)
|
|
assert _radius(centroid) >= 0.998
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_preserves_shape_on_sphere() -> None:
|
|
geom = MeshGeometry(
|
|
vertices=[
|
|
(-0.03, -0.01, 0.0),
|
|
(0.02, -0.015, 0.0),
|
|
(0.015, 0.012, 0.0),
|
|
(-0.025, 0.008, 0.0),
|
|
],
|
|
faces=[(0, 1, 2), (0, 2, 3)],
|
|
)
|
|
direction = (0.12, -0.97, 0.21)
|
|
frame = surface_frame(
|
|
Sphere(radius=1.0),
|
|
direction=direction,
|
|
)
|
|
|
|
wrapped = wrap_mesh_onto_surface(
|
|
geom,
|
|
Sphere(radius=1.0),
|
|
direction=direction,
|
|
child_axis="+z",
|
|
visible_relief=0.0,
|
|
)
|
|
|
|
recovered = [
|
|
_spherical_log_map(vertex, center=(0.0, 0.0, 0.0), radius=1.0, frame=frame)
|
|
for vertex in wrapped.vertices
|
|
]
|
|
expected = [(vertex[0], vertex[1]) for vertex in geom.vertices]
|
|
for actual, target in zip(recovered, expected):
|
|
assert abs(actual[0] - target[0]) <= 1e-6
|
|
assert abs(actual[1] - target[1]) <= 1e-6
|
|
|
|
|
|
def test_wrap_profile_onto_surface_preserves_visible_profile_on_sphere() -> None:
|
|
profile = [
|
|
(-0.03, -0.01),
|
|
(0.02, -0.015),
|
|
(0.015, 0.012),
|
|
(-0.025, 0.008),
|
|
]
|
|
direction = (0.12, -0.97, 0.21)
|
|
frame = surface_frame(
|
|
Sphere(radius=1.0),
|
|
direction=direction,
|
|
)
|
|
|
|
wrapped = wrap_profile_onto_surface(
|
|
profile,
|
|
Sphere(radius=1.0),
|
|
thickness=0.02,
|
|
direction=direction,
|
|
mapping="intrinsic",
|
|
visible_relief=0.0,
|
|
)
|
|
|
|
recovered = [
|
|
_spherical_log_map(vertex, center=(0.0, 0.0, 0.0), radius=1.0, frame=frame)
|
|
for vertex in wrapped.vertices[: len(profile)]
|
|
]
|
|
for actual, target in zip(recovered, profile):
|
|
assert abs(actual[0] - target[0]) <= 1e-6
|
|
assert abs(actual[1] - target[1]) <= 1e-6
|
|
|
|
outer = wrapped.vertices[: len(wrapped.vertices) // 2]
|
|
inner = wrapped.vertices[len(wrapped.vertices) // 2 :]
|
|
assert len(outer) == len(inner)
|
|
for outer_vertex, inner_vertex in zip(outer, inner):
|
|
assert abs(_radius(outer_vertex) - 1.0) <= 1e-6
|
|
assert _radius(inner_vertex) < _radius(outer_vertex)
|
|
|
|
|
|
def test_wrap_profile_onto_surface_subdivides_only_profile_cap() -> None:
|
|
profile = [
|
|
(-0.25, -0.20),
|
|
(0.25, -0.20),
|
|
(0.0, 0.28),
|
|
]
|
|
|
|
wrapped = wrap_profile_onto_surface(
|
|
profile,
|
|
Sphere(radius=1.0),
|
|
thickness=0.02,
|
|
direction=(1.0, 0.0, 0.0),
|
|
mapping="intrinsic",
|
|
visible_relief=0.0,
|
|
surface_max_edge=0.05,
|
|
)
|
|
|
|
assert len(wrapped.faces) > 8
|
|
outer_vertex_count = len(wrapped.vertices) // 2
|
|
outer_faces = [
|
|
(a, b, c)
|
|
for a, b, c in wrapped.faces
|
|
if a < outer_vertex_count and b < outer_vertex_count and c < outer_vertex_count
|
|
]
|
|
assert outer_faces
|
|
for a, b, c in outer_faces:
|
|
centroid = (
|
|
(wrapped.vertices[a][0] + wrapped.vertices[b][0] + wrapped.vertices[c][0]) / 3.0,
|
|
(wrapped.vertices[a][1] + wrapped.vertices[b][1] + wrapped.vertices[c][1]) / 3.0,
|
|
(wrapped.vertices[a][2] + wrapped.vertices[b][2] + wrapped.vertices[c][2]) / 3.0,
|
|
)
|
|
assert _radius(centroid) >= 0.998
|
|
|
|
|
|
def test_wrap_profile_onto_surface_supports_hole_profiles(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
outer = [
|
|
(-0.04, -0.04),
|
|
(0.04, -0.04),
|
|
(0.04, 0.04),
|
|
(-0.04, 0.04),
|
|
]
|
|
hole = [
|
|
(-0.012, -0.012),
|
|
(0.012, -0.012),
|
|
(0.012, 0.012),
|
|
(-0.012, 0.012),
|
|
]
|
|
frame = surface_frame(
|
|
Sphere(radius=1.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
wrapped = wrap_profile_onto_surface(
|
|
outer,
|
|
Sphere(radius=1.0),
|
|
thickness=0.015,
|
|
hole_profiles=[hole],
|
|
direction=(1.0, 0.0, 0.0),
|
|
mapping="intrinsic",
|
|
visible_relief=0.0,
|
|
surface_max_edge=0.03,
|
|
)
|
|
|
|
visible_points = [
|
|
_spherical_log_map(vertex, center=(0.0, 0.0, 0.0), radius=1.0, frame=frame)
|
|
for vertex in wrapped.vertices
|
|
if abs(_radius(vertex) - 1.0) <= 1e-6
|
|
]
|
|
assert visible_points
|
|
|
|
expected_points = outer + hole
|
|
for expected in expected_points:
|
|
assert (
|
|
min(
|
|
math.hypot(actual[0] - expected[0], actual[1] - expected[1])
|
|
for actual in visible_points
|
|
)
|
|
<= 1e-6
|
|
)
|
|
assert "hole_profiles were provided" in caplog.text
|
|
|
|
|
|
def test_wrap_profile_onto_surface_logs_boundary_fallback(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
def fail_planar_cap(*args, **kwargs):
|
|
raise ValidationError("profile mesh boundary could not be reconstructed")
|
|
|
|
monkeypatch.setattr(placement_module, "_solid_from_planar_profile_cap", fail_planar_cap)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
wrapped = wrap_profile_onto_surface(
|
|
[
|
|
(-0.04, -0.04),
|
|
(0.04, -0.04),
|
|
(0.04, 0.04),
|
|
(-0.04, 0.04),
|
|
],
|
|
Sphere(radius=1.0),
|
|
thickness=0.015,
|
|
direction=(1.0, 0.0, 0.0),
|
|
mapping="intrinsic",
|
|
visible_relief=0.0,
|
|
surface_max_edge=0.03,
|
|
)
|
|
|
|
assert wrapped.vertices
|
|
assert "after planar cap reconstruction failed" in caplog.text
|
|
|
|
|
|
def test_wrap_profile_onto_surface_handles_complex_profile_robustly() -> None:
|
|
points = [
|
|
(-0.040, 0.010),
|
|
(-0.030, 0.025),
|
|
(-0.010, 0.036),
|
|
(0.014, 0.034),
|
|
(0.035, 0.026),
|
|
(0.045, 0.010),
|
|
(0.040, -0.004),
|
|
(0.020, -0.012),
|
|
(0.012, -0.032),
|
|
(-0.004, -0.040),
|
|
(-0.018, -0.024),
|
|
(-0.032, -0.012),
|
|
]
|
|
profile = sample_catmull_rom_spline_2d(
|
|
points,
|
|
samples_per_segment=10,
|
|
closed=True,
|
|
)
|
|
|
|
wrapped = wrap_profile_onto_surface(
|
|
profile,
|
|
Sphere(radius=1.0),
|
|
thickness=0.016,
|
|
direction=(0.8, 0.3, 0.52),
|
|
mapping="intrinsic",
|
|
visible_relief=0.0,
|
|
surface_max_edge=0.006,
|
|
)
|
|
|
|
radii = [_radius(vertex) for vertex in wrapped.vertices]
|
|
assert len(wrapped.faces) > 1000
|
|
assert max(radii) >= 0.999999
|
|
assert min(radii) < 0.999
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_preserves_shape_on_cylinder_sidewall() -> None:
|
|
geom = MeshGeometry(
|
|
vertices=[
|
|
(-0.03, -0.01, 0.0),
|
|
(0.02, -0.015, 0.0),
|
|
(0.015, 0.012, 0.0),
|
|
(-0.025, 0.008, 0.0),
|
|
],
|
|
faces=[(0, 1, 2), (0, 2, 3)],
|
|
)
|
|
frame = surface_frame(
|
|
Cylinder(radius=1.0, length=4.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
)
|
|
|
|
wrapped = wrap_mesh_onto_surface(
|
|
geom,
|
|
Cylinder(radius=1.0, length=4.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
child_axis="+z",
|
|
mapping="intrinsic",
|
|
visible_relief=0.0,
|
|
)
|
|
|
|
recovered = [
|
|
_cylindrical_recover_xy(vertex, radius=1.0, frame=frame) for vertex in wrapped.vertices
|
|
]
|
|
expected = [(vertex[0], vertex[1]) for vertex in geom.vertices]
|
|
for actual, target in zip(recovered, expected):
|
|
assert abs(actual[0] - target[0]) <= 1e-6
|
|
assert abs(actual[1] - target[1]) <= 1e-6
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_intrinsic_mapping_rejects_unsupported_target(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
target_mesh: Mesh = mesh_from_geometry(BoxGeometry((2.0, 2.0, 2.0)), tmp_path / "target.obj")
|
|
geom = MeshGeometry(
|
|
vertices=[(-0.05, -0.05, 0.0), (0.05, -0.05, 0.0), (0.0, 0.05, 0.0)],
|
|
faces=[(0, 1, 2)],
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="Intrinsic surface wrapping"):
|
|
wrap_mesh_onto_surface(
|
|
geom,
|
|
target_mesh,
|
|
point_hint=(1.2, 0.0, 0.0),
|
|
mapping="intrinsic",
|
|
)
|
|
|
|
|
|
def test_wrap_mesh_onto_surface_rejects_conflicting_edge_settings() -> None:
|
|
geom = MeshGeometry(
|
|
vertices=[(-0.05, -0.05, 0.0), (0.05, -0.05, 0.0), (0.0, 0.05, 0.0)],
|
|
faces=[(0, 1, 2)],
|
|
)
|
|
|
|
with pytest.raises(ValidationError, match="surface_max_edge or max_edge"):
|
|
wrap_mesh_onto_surface(
|
|
geom,
|
|
Sphere(radius=1.0),
|
|
direction=(1.0, 0.0, 0.0),
|
|
surface_max_edge=0.05,
|
|
max_edge=0.10,
|
|
)
|