c377c14474
2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
513 lines
18 KiB
Python
513 lines
18 KiB
Python
"""Tests for the export preview: geometry, camera, appearance and the GL smoke.
|
|
|
|
The GL test is skipped when no real platform GL context can be created, copying
|
|
Studio's ``test_scene3d_gl_smoke.py``: the offscreen/minimal plugin cannot create
|
|
a platform GL context at all, so an offscreen run cannot stand in for a GPU.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
np = pytest.importorskip("numpy")
|
|
|
|
from orisys_sensor_design.manifest import (
|
|
DEFAULT_HOUSING_COLLECTIONS,
|
|
RevisionPaths,
|
|
build_export_job,
|
|
export_housing_collections,
|
|
repo_root,
|
|
)
|
|
from orisys_sensor_design.preview import appearance
|
|
from orisys_sensor_design.preview.camera import (
|
|
OrbitCamera,
|
|
frustum_segments,
|
|
pose_is_rigid,
|
|
)
|
|
from orisys_sensor_design.preview.geometry import (
|
|
DEFAULT_CREASE_ANGLE_DEG,
|
|
SMOOTH_CREASE_ANGLE_DEG,
|
|
PreviewGeometryError,
|
|
compute_normals,
|
|
load_housing,
|
|
load_parts,
|
|
load_sensing,
|
|
split_vertices,
|
|
)
|
|
|
|
REPO = repo_root()
|
|
STUDIO_BACKEND = (
|
|
REPO.parent
|
|
/ "orisys-app"
|
|
/ "apps"
|
|
/ "studio"
|
|
/ "src"
|
|
/ "orisys_studio"
|
|
/ "scene3d"
|
|
/ "gl_backend.py"
|
|
)
|
|
XYNOVA_EXPORT = REPO / "build" / "export" / "finger-xynova" / "r01" / "compute"
|
|
|
|
|
|
def _write_npz(path: Path, **arrays) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
np.savez_compressed(path, **arrays)
|
|
return path
|
|
|
|
|
|
def _housing_npz(path: Path) -> Path:
|
|
vertices = np.array(
|
|
[[0, 0, 0], [1, 0, 0], [0, 1, 0], [5, 5, 5], [6, 5, 5], [5, 6, 5]],
|
|
dtype=np.float32,
|
|
)
|
|
faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32)
|
|
return _write_npz(
|
|
path,
|
|
vertices=vertices,
|
|
faces=faces,
|
|
part_index=np.array([0, 1], dtype=np.int32),
|
|
part_names=np.array(["frame_L", "nail"]),
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------- geometry
|
|
|
|
|
|
def test_compute_normals_of_a_flat_quad_points_along_plus_z() -> None:
|
|
positions = np.array(
|
|
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32
|
|
)
|
|
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
|
|
normals = compute_normals(positions, triangles)
|
|
assert normals.shape == positions.shape
|
|
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6)
|
|
|
|
|
|
def test_compute_normals_falls_back_on_degenerate_faces() -> None:
|
|
positions = np.zeros((3, 3), dtype=np.float32)
|
|
triangles = np.array([[0, 1, 2]], dtype=np.int32)
|
|
normals = compute_normals(positions, triangles)
|
|
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32))
|
|
|
|
|
|
def test_load_sensing_reads_the_payload_mesh(tmp_path: Path) -> None:
|
|
_write_npz(
|
|
tmp_path / "mesh.npz",
|
|
vertices=np.zeros((3, 3), dtype=np.float32),
|
|
faces=np.array([[0, 1, 2]], dtype=np.int32),
|
|
)
|
|
part = load_sensing(tmp_path)
|
|
assert part.role == "sensing"
|
|
assert part.name == "sensing_surface"
|
|
assert part.triangle_count == 1
|
|
|
|
|
|
def test_load_sensing_rejects_a_missing_file(tmp_path: Path) -> None:
|
|
with pytest.raises(PreviewGeometryError, match="missing geometry file"):
|
|
load_sensing(tmp_path)
|
|
|
|
|
|
def test_load_housing_splits_parts_by_index(tmp_path: Path) -> None:
|
|
_housing_npz(tmp_path / "housing.npz")
|
|
parts = load_housing(tmp_path)
|
|
assert [part.name for part in parts] == ["frame_L", "nail"]
|
|
assert all(part.role == "housing" for part in parts)
|
|
assert parts[0].vertex_count == 3
|
|
assert parts[1].triangle_count == 1
|
|
|
|
|
|
def test_load_housing_is_empty_when_absent(tmp_path: Path) -> None:
|
|
assert load_housing(tmp_path) == []
|
|
|
|
|
|
def test_load_housing_is_empty_when_it_has_no_faces(tmp_path: Path) -> None:
|
|
_write_npz(
|
|
tmp_path / "housing.npz",
|
|
vertices=np.zeros((0, 3), dtype=np.float32),
|
|
faces=np.zeros((0, 3), dtype=np.int32),
|
|
part_index=np.zeros((0,), dtype=np.int32),
|
|
part_names=np.array([""], dtype=np.str_)[:0],
|
|
)
|
|
assert load_housing(tmp_path) == []
|
|
|
|
|
|
def test_load_housing_rejects_a_part_index_length_mismatch(tmp_path: Path) -> None:
|
|
_write_npz(
|
|
tmp_path / "housing.npz",
|
|
vertices=np.zeros((3, 3), dtype=np.float32),
|
|
faces=np.array([[0, 1, 2]], dtype=np.int32),
|
|
part_index=np.array([0, 1], dtype=np.int32),
|
|
part_names=np.array(["a", "b"]),
|
|
)
|
|
with pytest.raises(PreviewGeometryError, match="part_index"):
|
|
load_housing(tmp_path)
|
|
|
|
|
|
def test_load_parts_can_skip_housing(tmp_path: Path) -> None:
|
|
_write_npz(
|
|
tmp_path / "mesh.npz",
|
|
vertices=np.zeros((3, 3), dtype=np.float32),
|
|
faces=np.array([[0, 1, 2]], dtype=np.int32),
|
|
)
|
|
_housing_npz(tmp_path / "housing.npz")
|
|
assert len(load_parts(tmp_path)) == 3
|
|
assert len(load_parts(tmp_path, include_housing=False)) == 1
|
|
|
|
|
|
# --------------------------------------------------------------- camera
|
|
|
|
|
|
def test_fit_frames_the_bounds_and_scales_with_the_frame() -> None:
|
|
camera = OrbitCamera()
|
|
low = np.array([-1.0, -1.0, -1.0])
|
|
high = np.array([1.0, 1.0, 1.0])
|
|
camera.fit(low, high, 1.0)
|
|
assert np.allclose(camera.target, 0.0)
|
|
radius = np.linalg.norm(high - low) * 0.5
|
|
assert camera.fitted_radius == pytest.approx(radius)
|
|
# The distance must exceed the radius, and stay within its own ratios.
|
|
assert radius * 0.05 <= camera.distance <= radius * 50.0
|
|
assert camera.distance > radius
|
|
|
|
|
|
def test_fit_is_unit_agnostic_for_a_scaled_export() -> None:
|
|
"""The exported sensor frame is not metres, so framing must not assume it."""
|
|
camera = OrbitCamera()
|
|
camera.fit(np.array([-574.0, -576.0, -299.0]), np.array([574.0, 556.0, 1691.0]), 1.6)
|
|
assert camera.distance > 1000.0 # would be clamped to 5 m by Studio's limits
|
|
|
|
|
|
def test_zoom_is_clamped_relative_to_the_fitted_radius() -> None:
|
|
camera = OrbitCamera()
|
|
camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0)
|
|
for _ in range(200):
|
|
camera.zoom(1.0)
|
|
assert camera.distance >= camera.fitted_radius * 0.05
|
|
for _ in range(400):
|
|
camera.zoom(-1.0)
|
|
assert camera.distance <= camera.fitted_radius * 50.0
|
|
|
|
|
|
def test_orbit_clamps_pitch() -> None:
|
|
camera = OrbitCamera()
|
|
camera.orbit(0.0, -100000.0)
|
|
assert abs(camera.pitch) <= 1.5534 + 1e-3
|
|
|
|
|
|
def test_view_and_projection_are_well_formed() -> None:
|
|
camera = OrbitCamera()
|
|
camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0)
|
|
view = camera.view()
|
|
projection = camera.projection()
|
|
assert view.shape == (4, 4) and projection.shape == (4, 4)
|
|
# An orthonormal view: rotation rows are unit length.
|
|
assert np.allclose(np.linalg.norm(view[:3, :3], axis=1), 1.0, atol=1e-9)
|
|
# The target maps onto the camera's -Z axis.
|
|
eye = camera.eye()
|
|
target_in_camera = view @ np.array([*camera.target, 1.0])
|
|
assert target_in_camera[2] == pytest.approx(-camera.distance, rel=1e-6)
|
|
assert eye.shape == (3,)
|
|
|
|
|
|
def test_pose_is_rigid_detects_a_scaled_pose() -> None:
|
|
rigid = np.eye(4)
|
|
assert pose_is_rigid(rigid) is True
|
|
scaled = np.diag([0.003937, 0.003937, 0.003937, 1.0])
|
|
assert pose_is_rigid(scaled) is False
|
|
assert pose_is_rigid(np.zeros((3, 3))) is False
|
|
|
|
|
|
def test_frustum_segments_apex_is_the_camera_position() -> None:
|
|
K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]])
|
|
world_to_cam = np.eye(4)
|
|
world_to_cam[2, 3] = 2.0 # camera sits 2 units in front of the origin
|
|
segments = frustum_segments(K, world_to_cam, image_size=(100, 100))
|
|
assert segments.shape == (8, 2, 3)
|
|
apex = segments[0, 0]
|
|
assert np.allclose(apex, [0.0, 0.0, -2.0], atol=1e-9)
|
|
|
|
|
|
def test_frustum_segments_rejects_a_bad_shape() -> None:
|
|
with pytest.raises(ValueError, match="world_to_cam"):
|
|
frustum_segments(np.eye(3), np.eye(3))
|
|
with pytest.raises(ValueError, match="K must be"):
|
|
frustum_segments(np.eye(2), np.eye(4))
|
|
|
|
|
|
# ------------------------------------------------------------- appearance
|
|
|
|
|
|
def test_shader_sources_are_the_core_profile() -> None:
|
|
vertex, fragment = appearance.shader_source("diffuse")
|
|
assert vertex.startswith(appearance.SHADER_HEADER)
|
|
assert fragment.startswith(appearance.SHADER_HEADER)
|
|
assert "u_light_direction" in fragment
|
|
line_vertex, line_fragment = appearance.shader_source("line")
|
|
assert "u_color" in line_fragment
|
|
with pytest.raises(ValueError, match="unknown shader"):
|
|
appearance.shader_source("nope")
|
|
|
|
|
|
def test_base_color_matches_studio_for_housing() -> None:
|
|
assert appearance.base_color("housing") == appearance.HOUSING_COLOR
|
|
assert appearance.base_color("anything") == appearance.PART_FALLBACK_COLOR
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not STUDIO_BACKEND.is_file(), reason="orisys-app checkout is not present"
|
|
)
|
|
def test_appearance_mirror_matches_studio() -> None:
|
|
"""Drift guard: the mirror must not silently diverge from Studio's backend."""
|
|
source = STUDIO_BACKEND.read_text(encoding="utf-8")
|
|
assert f"glClearColor({appearance.CLEAR_COLOR[0]}, {appearance.CLEAR_COLOR[1]}, " in source
|
|
assert "(0.3, -0.5, 0.8)" in source
|
|
assert "0.35 + 0.65 * lambert" in source
|
|
assert "0.32, 0.33, 0.35, 1.0" in source
|
|
assert "0.45, 0.45, 0.47, 1.0" in source
|
|
for fragment_line in (
|
|
"vec3 n = normalize(v_normal);",
|
|
"if (!gl_FrontFacing) {",
|
|
"n = -n;",
|
|
):
|
|
assert fragment_line in source, fragment_line
|
|
assert fragment_line in appearance.DIFFUSE_FRAGMENT_BODY, fragment_line
|
|
|
|
|
|
# ------------------------------------------------- export contract guards
|
|
|
|
|
|
def test_export_job_requests_housing_from_the_assembly_collection() -> None:
|
|
paths = RevisionPaths.from_revision_dir(REPO / "sensors" / "finger-xynova" / "r01")
|
|
job = build_export_job(paths, Path("out"), "compute")
|
|
assert job["schema"] == "orisys-sensor-design/export-job-v2"
|
|
assert job["housing_collections"] == export_housing_collections(paths)
|
|
assert job["housing_collections"] == ["ASSEMBLY"]
|
|
|
|
|
|
def test_housing_collections_default_when_design_yaml_is_silent(tmp_path: Path) -> None:
|
|
revision = tmp_path / "sensors" / "finger" / "r99"
|
|
(revision / "profiles").mkdir(parents=True)
|
|
(revision / "design.yaml").write_text("sensor: finger\nrevision: r99\n", encoding="utf-8")
|
|
paths = RevisionPaths.from_revision_dir(revision)
|
|
assert export_housing_collections(paths) == list(DEFAULT_HOUSING_COLLECTIONS)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not (XYNOVA_EXPORT / "housing.npz").is_file(),
|
|
reason="run orisys-design export first",
|
|
)
|
|
def test_exported_housing_keeps_mesh_npz_sensing_only() -> None:
|
|
"""The SDK unroll reads mesh.npz vertices as the sensing surface."""
|
|
with np.load(XYNOVA_EXPORT / "mesh.npz", allow_pickle=False) as mesh:
|
|
assert set(mesh.files) == {"vertices", "faces", "uv_data", "loop_to_vertex"}
|
|
assert mesh["vertices"].shape[0] == 2508
|
|
# SMOOTH reads the artifact as stored: 21426 is what housing.npz contains, and
|
|
# crease splitting only ever enlarges the preview's in-memory copy.
|
|
parts = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
|
|
assert [part.name for part in parts] == ["frame_L", "nail", "frame_R"]
|
|
assert sum(part.vertex_count for part in parts) == 21426
|
|
|
|
|
|
# ------------------------------------------------------------- GL smoke
|
|
|
|
|
|
pytestmark_gl = pytest.mark.skipif(
|
|
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
|
|
reason="the offscreen/minimal plugin cannot create a platform GL context",
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
|
|
reason="the offscreen/minimal plugin cannot create a platform GL context",
|
|
)
|
|
@pytest.mark.skipif(
|
|
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
|
|
)
|
|
def test_preview_draws_the_mesh_and_the_housing_toggle_changes_it() -> None:
|
|
# Guard the submodule, not the top-level package: a partially installed
|
|
# PyQt5 can expose `PyQt5` (with `PyQt5.__file__ is None`) while every
|
|
# binding submodule is missing, which would otherwise fail instead of skip.
|
|
pytest.importorskip("PyQt5.QtWidgets")
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
from orisys_sensor_design.preview.gl_view import (
|
|
PreviewGLWidget,
|
|
configure_surface_format,
|
|
)
|
|
from orisys_sensor_design.preview.window import prepare
|
|
|
|
state = prepare(XYNOVA_EXPORT)
|
|
configure_surface_format()
|
|
app = QApplication.instance() or QApplication([])
|
|
widget = PreviewGLWidget(
|
|
state["parts"], state["camera"], frustum=state["frustum"]
|
|
)
|
|
widget.show_frustum = False
|
|
widget.resize(480, 320)
|
|
widget.show()
|
|
app.processEvents()
|
|
|
|
background = np.array([15, 18, 23])
|
|
|
|
def drawn() -> int:
|
|
widget.repaint()
|
|
app.processEvents()
|
|
frame = widget.grab_rgb()
|
|
return int(
|
|
np.count_nonzero(np.any(frame[:, :, :3] != background, axis=2))
|
|
)
|
|
|
|
widget.show_housing = True
|
|
with_housing = drawn()
|
|
widget.show_housing = False
|
|
without_housing = drawn()
|
|
assert widget.gl_ok, widget.gl_error
|
|
assert with_housing > 0, "nothing was drawn at all"
|
|
assert 0 < without_housing < with_housing, (
|
|
"hiding the housing did not reduce what is on screen "
|
|
f"({without_housing} vs {with_housing})"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------- crease normals
|
|
|
|
|
|
def _cube() -> tuple[np.ndarray, np.ndarray]:
|
|
positions = np.array(
|
|
[
|
|
[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
|
|
[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1],
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
triangles = np.array(
|
|
[
|
|
[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],
|
|
],
|
|
dtype=np.int32,
|
|
)
|
|
return positions, triangles
|
|
|
|
|
|
def _two_faces(angle_deg: float) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Two triangles sharing an edge, tilted from each other by ``angle_deg``."""
|
|
radians = np.radians(angle_deg)
|
|
positions = np.array(
|
|
[
|
|
[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0],
|
|
[1.0 + np.cos(radians), 0.0, np.sin(radians)],
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
triangles = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32)
|
|
return positions, triangles
|
|
|
|
|
|
def test_split_vertices_makes_a_cube_corner_three_clusters() -> None:
|
|
positions, triangles = _cube()
|
|
split_positions, normals, _ = split_vertices(positions, triangles, 35.0)
|
|
assert len(split_positions) == 24 # 8 corners x 3 axis-aligned faces
|
|
axes = {tuple(np.round(row, 6)) for row in normals}
|
|
assert axes == {
|
|
(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0),
|
|
(0.0, 1.0, 0.0), (0.0, -1.0, 0.0),
|
|
(0.0, 0.0, 1.0), (0.0, 0.0, -1.0),
|
|
}
|
|
|
|
|
|
def test_split_vertices_smooth_sentinel_keeps_every_vertex() -> None:
|
|
positions, triangles = _cube()
|
|
split_positions, normals, split_triangles = split_vertices(
|
|
positions, triangles, SMOOTH_CREASE_ANGLE_DEG
|
|
)
|
|
assert len(split_positions) == 8
|
|
assert np.allclose(split_triangles, triangles)
|
|
assert np.allclose(normals, compute_normals(positions, triangles))
|
|
|
|
|
|
def test_split_vertices_respects_the_threshold() -> None:
|
|
shallow_positions, shallow_triangles = _two_faces(20.0)
|
|
_, _, shallow = split_vertices(shallow_positions, shallow_triangles, 35.0)
|
|
# The shared edge vertices keep one normal each, so the quad stays 4 vertices.
|
|
assert len(shallow) == 2
|
|
assert len(set(map(tuple, shallow.reshape(-1, 3)))) <= 4
|
|
|
|
steep_positions, steep_triangles = _two_faces(40.0)
|
|
steep_positions_out, _, _ = split_vertices(
|
|
steep_positions, steep_triangles, 35.0
|
|
)
|
|
assert len(steep_positions_out) > len(steep_positions)
|
|
|
|
|
|
def test_split_vertices_leaves_a_flat_quad_flat() -> None:
|
|
positions = np.array(
|
|
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32
|
|
)
|
|
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
|
|
split_positions, normals, _ = split_vertices(positions, triangles, 35.0)
|
|
assert len(split_positions) == 4
|
|
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6)
|
|
|
|
|
|
def test_split_vertices_handles_an_empty_mesh() -> None:
|
|
positions = np.zeros((0, 3), dtype=np.float32)
|
|
triangles = np.zeros((0, 3), dtype=np.int32)
|
|
split_positions, normals, split_triangles = split_vertices(
|
|
positions, triangles, 35.0
|
|
)
|
|
assert len(split_positions) == 0 and len(split_triangles) == 0
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
|
|
)
|
|
def test_default_crease_leaves_the_smooth_sensing_surface_untouched() -> None:
|
|
"""The sensing surface has 0% sharp edges, so it must not be re-vertexed."""
|
|
smooth = load_sensing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
|
|
creased = load_sensing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG)
|
|
assert creased.vertex_count == smooth.vertex_count
|
|
assert np.allclose(creased.normals, smooth.normals, atol=1e-6)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not (XYNOVA_EXPORT / "housing.npz").is_file(), reason="export not built"
|
|
)
|
|
def test_default_crease_splits_the_sharp_housing_edges() -> None:
|
|
smooth = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
|
|
creased = load_housing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG)
|
|
assert [p.name for p in smooth] == [p.name for p in creased]
|
|
for before, after in zip(smooth, creased):
|
|
assert after.triangle_count == before.triangle_count
|
|
assert after.vertex_count > before.vertex_count, before.name
|
|
|
|
|
|
# ------------------------------------------------------------ depth range
|
|
|
|
|
|
def test_projection_brackets_the_fitted_model() -> None:
|
|
camera = OrbitCamera()
|
|
low = np.array([-574.0, -576.0, -299.0])
|
|
high = np.array([574.0, 556.0, 1691.0])
|
|
camera.fit(low, high, 1.6)
|
|
view = camera.view()
|
|
projection = camera.projection()
|
|
eye = camera.eye()
|
|
for corner in (
|
|
np.array([x, y, z, 1.0])
|
|
for x in (low[0], high[0])
|
|
for y in (low[1], high[1])
|
|
for z in (low[2], high[2])
|
|
):
|
|
clip = projection @ view @ corner
|
|
ndc_z = clip[2] / clip[3]
|
|
assert -1.0 < ndc_z < 1.0, ndc_z
|
|
# The whole depth buffer is spent on the model, not a 1% slice of it.
|
|
depth = np.linalg.norm(view[:3, :3] @ (high - low))
|
|
assert depth > 0 and eye.shape == (3,)
|