state the finger 3D camera presets in the design export's frame
The default view came from the mesh's own face normal and sat 24.6 degrees off the design export's frame; re-applying any preset also adopted its baked distance, so the part overflowed the viewport (measured 2.16 x 2.12 of it). Presets are now the frame's own views, every preset is reframed through the pane's existing fit, and that fit projects the model's bounds instead of a bounding sphere. Verified: the frame axes map to screen up, left and viewer to 1e-6, the model fills 0.60-0.95 of the viewport at two sizes, and the recompiled package keeps its geometry, domain and source-geometry ids.
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
"""The camera presets, stated in the design export's own frame.
|
||||
|
||||
The frame the finger is drawn in is not the sensor's: the Blender export writes the mesh
|
||||
into the ``origin_sensor`` empty's frame, and the compile bridges the two with a fitted
|
||||
rigid transform. The columns of that transform's rotation block *are* the empty's X, Y and
|
||||
Z expressed in the sensor frame, so those three vectors are the whole definition of "which
|
||||
way is up" for this asset — and every preset here is stated in them rather than in the
|
||||
sensing surface's own face normal, which is tilted 24.6° off the frame's +Y.
|
||||
|
||||
The assertions are numeric on purpose. "The picture looks right" is what let a preset sit
|
||||
122.7° from the surface it was pointing at; what is checked here is that the frame's +Z
|
||||
lands on the screen's up axis, that +X lands on the left, and that the model's own bounds
|
||||
occupy a stated fraction of the viewport — through the same matrices the GPU is handed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from orisys_app_shared.scene3d.legacy import (
|
||||
default_finger_presets,
|
||||
sensing_surface_normal,
|
||||
)
|
||||
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
|
||||
from orisys_studio.scene3d.camera import MAX_DISTANCE_M, MIN_DISTANCE_M, OrbitCamera
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
PACKAGE = REPO / "apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene"
|
||||
RIGID = REPO / "tools/scene_assets/housing/finger_r01_design_to_sensor_rigid_m.npy"
|
||||
|
||||
#: The frame's axes are recovered to float noise, and the matrices the camera hands out
|
||||
#: are float32, so 1e-6 is the honest tolerance for a direction that must be exact.
|
||||
EXACT = 1e-6
|
||||
#: A step of a millimetre across a ~32 mm view is about 0.1 in NDC; 0.02 is a 5x margin.
|
||||
VISIBLE_STEP = 0.02
|
||||
#: How much of the viewport the model's bounds must occupy on the axis that binds.
|
||||
FILL_MIN = 0.60
|
||||
FILL_MAX = 0.95
|
||||
|
||||
|
||||
def _scene():
|
||||
if not PACKAGE.is_file():
|
||||
pytest.skip(f"the shipped finger package is missing: {PACKAGE}")
|
||||
return scene_from_contents(read_package(PACKAGE), None)
|
||||
|
||||
|
||||
def _transform() -> np.ndarray:
|
||||
if not RIGID.is_file():
|
||||
pytest.skip(f"the fitted design frame is missing: {RIGID}")
|
||||
return np.load(RIGID, allow_pickle=False)
|
||||
|
||||
|
||||
def _frame() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""The empty's X, Y and Z in the sensor frame: the transform's columns."""
|
||||
rotation = _transform()[:3, :3]
|
||||
return rotation[:, 0], rotation[:, 1], rotation[:, 2]
|
||||
|
||||
|
||||
def _presets():
|
||||
"""What the compiler derives for the shipped package, from the stated frame."""
|
||||
assets = _scene()
|
||||
sensing = assets.sensing_part()
|
||||
return assets, default_finger_presets(
|
||||
assets.bounds_sensor_m,
|
||||
positions_sensor_m=np.asarray(sensing.positions_sensor_m, dtype=np.float64),
|
||||
triangles=np.asarray(sensing.triangles, dtype=np.int64),
|
||||
source_frame_sensor=_transform()[:3, :3],
|
||||
)
|
||||
|
||||
|
||||
def _project(
|
||||
camera: OrbitCamera, points: np.ndarray, viewport=(640, 480)
|
||||
) -> np.ndarray:
|
||||
"""NDC coordinates of sensor-space points, through the real matrices."""
|
||||
matrices = camera.matrices(viewport)
|
||||
view = np.asarray(matrices.view, dtype=np.float64)
|
||||
projection = np.asarray(matrices.projection, dtype=np.float64)
|
||||
homogeneous = np.concatenate(
|
||||
[np.asarray(points, dtype=np.float64), np.ones((len(points), 1))], axis=1
|
||||
)
|
||||
clip = (projection @ (view @ homogeneous.T)).T
|
||||
return clip[:, :3] / clip[:, 3:4]
|
||||
|
||||
|
||||
def _camera_for(preset, assets, aspect=4 / 3, viewport=(640, 480)) -> OrbitCamera:
|
||||
camera = OrbitCamera()
|
||||
camera.set_preset(preset)
|
||||
camera.fit(assets.bounds_sensor_m, aspect)
|
||||
return camera
|
||||
|
||||
|
||||
def _screen_axes(camera: OrbitCamera, viewport=(640, 480)) -> tuple[np.ndarray, ...]:
|
||||
"""``(right, up, backward)`` of the camera, taken from the view matrix it hands out."""
|
||||
view = np.asarray(camera.matrices(viewport).view, dtype=np.float64)
|
||||
return view[0, :3], view[1, :3], view[2, :3]
|
||||
|
||||
|
||||
def _bounds_fractions(
|
||||
assets, camera: OrbitCamera, viewport
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""``(span_x, span_y, reach_x, reach_y)`` of the model's bounds, in NDC.
|
||||
|
||||
``span`` is how much of the axis the bounds cover; ``reach`` is how near the edge
|
||||
they come. They differ under perspective — the corner towards the eye projects wider
|
||||
than the one behind it — so a fit is stated as a ``reach`` and "it fills the
|
||||
viewport" is the weaker ``span``.
|
||||
"""
|
||||
bounds = np.asarray(assets.bounds_sensor_m, dtype=np.float64)
|
||||
corners = np.array(
|
||||
[[i, j, k] for i in (0.0, 1.0) for j in (0.0, 1.0) for k in (0.0, 1.0)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
ndc = _project(camera, bounds[0] + corners * (bounds[1] - bounds[0]), viewport)
|
||||
span = np.ptp(ndc[:, :2], axis=0) / 2.0
|
||||
reach = np.abs(ndc[:, :2]).max(axis=0)
|
||||
return float(span[0]), float(span[1]), float(reach[0]), float(reach[1])
|
||||
|
||||
|
||||
def _step_towards(camera: OrbitCamera, axis: np.ndarray, viewport) -> np.ndarray:
|
||||
"""The NDC displacement produced by a 1 mm step along a world axis, at the target."""
|
||||
points = np.stack([camera.target_sensor_m, camera.target_sensor_m + axis * 1e-3])
|
||||
ndc = _project(camera, points, viewport)
|
||||
return ndc[1, :2] - ndc[0, :2]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the stated frame
|
||||
def test_the_fitted_transform_columns_are_the_empty_axes() -> None:
|
||||
"""The frame is read, not assumed: the transform's columns are orthonormal."""
|
||||
rotation = _transform()[:3, :3]
|
||||
assert np.abs(rotation @ rotation.T - np.eye(3)).max() < 1e-12
|
||||
assert float(np.linalg.det(rotation)) == pytest.approx(1.0, abs=1e-12)
|
||||
frame_x, frame_y, frame_z = _frame()
|
||||
# Recorded here because every preset below is a statement about these three: the
|
||||
# export's +Y is the sensor's -Z, and its +Z (up) is the sensor's -X.
|
||||
assert np.allclose(frame_x, [0.0, 1.0, 0.0], atol=1e-6)
|
||||
assert np.allclose(frame_y, [0.0, 0.0, -1.0], atol=1e-6)
|
||||
assert np.allclose(frame_z, [-1.0, 0.0, 0.0], atol=1e-6)
|
||||
|
||||
|
||||
def test_the_default_view_is_the_frames_own_view() -> None:
|
||||
"""The default: an eye on the empty's +Y, up along its +Z, +X to the left."""
|
||||
assets, presets = _presets()
|
||||
frame_x, frame_y, frame_z = _frame()
|
||||
assert [preset.name for preset in presets] == ["default", "front", "side", "top"]
|
||||
preset = presets[0]
|
||||
camera = _camera_for(preset, assets)
|
||||
right, true_up, backward = _screen_axes(camera)
|
||||
|
||||
# The view direction and the up are the frame's, exactly.
|
||||
assert float(backward @ frame_y) == pytest.approx(1.0, abs=EXACT), (
|
||||
"+Y is the view axis"
|
||||
)
|
||||
assert float(true_up @ frame_z) == pytest.approx(1.0, abs=EXACT), "+Z is screen up"
|
||||
# ...and screen-right is -X, which is what "+X appears to the left" means.
|
||||
assert float(right @ frame_x) == pytest.approx(-1.0, abs=EXACT), "+X is on the left"
|
||||
# The camera is on the +Y axis: the eye's offset from the target is the frame's +Y.
|
||||
eye = np.asarray(preset.eye_sensor_m, dtype=np.float64) - np.asarray(
|
||||
preset.target_sensor_m, dtype=np.float64
|
||||
)
|
||||
assert float((eye / np.linalg.norm(eye)) @ frame_y) == pytest.approx(1.0, abs=EXACT)
|
||||
# ...and reproduced by the camera, not merely stated by the preset.
|
||||
assert np.allclose(camera.eye_sensor_m(), preset.eye_sensor_m, atol=1e-9)
|
||||
|
||||
# The same statement through the projection: a step along the frame's axes moves
|
||||
# where it must on the screen.
|
||||
towards_x = _step_towards(camera, frame_x, (640, 480))
|
||||
towards_z = _step_towards(camera, frame_z, (640, 480))
|
||||
assert towards_x[0] < -VISIBLE_STEP, "+X must move left, not right"
|
||||
assert abs(float(towards_x[1])) < VISIBLE_STEP / 2.0, "+X must not move up or down"
|
||||
assert towards_z[1] > VISIBLE_STEP, "+Z must move up"
|
||||
assert abs(float(towards_z[0])) < VISIBLE_STEP / 2.0, "+Z must not move sideways"
|
||||
|
||||
|
||||
def test_every_preset_states_the_frame_the_same_way() -> None:
|
||||
"""The view table: what each preset puts on the screen, axis by axis.
|
||||
|
||||
``+X`` on the left of ``default``/``front`` is not an accident to be repeated: the
|
||||
``top`` view is the default one tilted 90° about its own horizontal axis, so it keeps
|
||||
the same left/right; ``side`` looks along ``+X`` and therefore puts ``+Y`` on the
|
||||
right. A view that was rolled would fail the two "no drift" assertions.
|
||||
"""
|
||||
assets, presets = _presets()
|
||||
frame_x, frame_y, frame_z = _frame()
|
||||
by_name = {preset.name: preset for preset in presets}
|
||||
|
||||
# (name, eye axis, screen-right, screen-up)
|
||||
table = (
|
||||
("default", frame_y, -frame_x, frame_z),
|
||||
("front", frame_y, -frame_x, frame_z),
|
||||
("side", frame_x, frame_y, frame_z),
|
||||
("top", frame_z, -frame_x, -frame_y),
|
||||
)
|
||||
for name, eye_axis, expected_right, expected_up in table:
|
||||
camera = _camera_for(by_name[name], assets)
|
||||
right, true_up, backward = _screen_axes(camera)
|
||||
assert float(backward @ eye_axis) == pytest.approx(1.0, abs=EXACT), name
|
||||
assert float(right @ expected_right) == pytest.approx(1.0, abs=EXACT), name
|
||||
assert float(true_up @ expected_up) == pytest.approx(1.0, abs=EXACT), name
|
||||
# The frame's up is screen-up wherever it is not the view axis (which is what
|
||||
# "the view is not rolled" means, stated in the frame's own terms).
|
||||
if name != "top":
|
||||
assert float(true_up @ frame_z) == pytest.approx(1.0, abs=EXACT), name
|
||||
else:
|
||||
assert float(backward @ frame_z) == pytest.approx(1.0, abs=EXACT), name
|
||||
# A left-handed view would mirror the whole model.
|
||||
assert float(
|
||||
np.linalg.det(np.stack([right, true_up, backward]))
|
||||
) == pytest.approx(1.0, abs=1e-6), name
|
||||
|
||||
|
||||
def test_the_presets_are_not_the_surface_normal() -> None:
|
||||
"""The superseded heuristic, pinned: the default is not the face normal.
|
||||
|
||||
The pad's mean normal is 24.6° off the frame's +Y, so a preset derived from it cannot
|
||||
sit on the +Y axis. With no frame stated the fallback still uses the normal — that is
|
||||
the repair path for a package an older compiler baked — and that is why this test
|
||||
asserts the difference rather than its absence.
|
||||
"""
|
||||
assets, presets = _presets()
|
||||
sensing = assets.sensing_part()
|
||||
normal = sensing_surface_normal(
|
||||
np.asarray(sensing.positions_sensor_m, dtype=np.float64),
|
||||
np.asarray(sensing.triangles, dtype=np.int64),
|
||||
)
|
||||
frame_x, frame_y, frame_z = _frame()
|
||||
assert float(normal @ frame_y) < 0.99, "the fixture no longer distinguishes the two"
|
||||
|
||||
eye = np.asarray(presets[0].eye_sensor_m, dtype=np.float64) - np.asarray(
|
||||
presets[0].target_sensor_m, dtype=np.float64
|
||||
)
|
||||
direction = eye / np.linalg.norm(eye)
|
||||
angle = float(np.degrees(np.arccos(np.clip(direction @ normal, -1.0, 1.0))))
|
||||
assert angle == pytest.approx(24.57, abs=0.1), (
|
||||
f"the default view is {angle:.2f}° off"
|
||||
)
|
||||
|
||||
fallback = default_finger_presets(
|
||||
assets.bounds_sensor_m,
|
||||
positions_sensor_m=np.asarray(sensing.positions_sensor_m, dtype=np.float64),
|
||||
triangles=np.asarray(sensing.triangles, dtype=np.int64),
|
||||
surface_normal_sensor=normal,
|
||||
)
|
||||
fallback_front = np.asarray(
|
||||
fallback[1].eye_sensor_m, dtype=np.float64
|
||||
) - np.asarray(fallback[1].target_sensor_m, dtype=np.float64)
|
||||
fallback_front = fallback_front / np.linalg.norm(fallback_front)
|
||||
assert float(fallback_front @ normal) > 0.99, (
|
||||
"the no-frame fallback must still look straight at the sensing side of the surface"
|
||||
)
|
||||
fallback_default = np.asarray(
|
||||
fallback[0].eye_sensor_m, dtype=np.float64
|
||||
) - np.asarray(fallback[0].target_sensor_m, dtype=np.float64)
|
||||
fallback_default = fallback_default / np.linalg.norm(fallback_default)
|
||||
assert float(fallback_default @ normal) == pytest.approx(
|
||||
1.0 / np.sqrt(1.36), abs=1e-6
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- the fit
|
||||
def test_the_default_view_fits_the_model_at_two_viewport_sizes() -> None:
|
||||
"""Zoom to extent, measured: the bounds fill the viewport, and never clip.
|
||||
|
||||
Both sizes are the same shape as the pane at its usual size, so both axes are inside
|
||||
the band. A wide viewport cannot do that for a square-ish part — the binding axis is
|
||||
asserted on its own below, because "fill 90 % of the height and stay uncropped" is the
|
||||
most a 16:9 window can be given without cropping the model.
|
||||
"""
|
||||
assets, presets = _presets()
|
||||
preset = presets[0]
|
||||
bounds = np.asarray(assets.bounds_sensor_m, dtype=np.float64)
|
||||
|
||||
for viewport in ((640, 480), (700, 530)):
|
||||
aspect = viewport[0] / viewport[1]
|
||||
camera = _camera_for(preset, assets, aspect, viewport)
|
||||
assert MIN_DISTANCE_M < camera.distance_m < MAX_DISTANCE_M
|
||||
span_x, span_y, reach_x, reach_y = _bounds_fractions(assets, camera, viewport)
|
||||
# Nothing touches an edge...
|
||||
assert max(reach_x, reach_y) <= FILL_MAX, (viewport, reach_x, reach_y)
|
||||
# ...both axes are filled sensibly...
|
||||
assert span_x >= FILL_MIN, (viewport, span_x)
|
||||
assert span_y >= FILL_MIN, (viewport, span_y)
|
||||
# ...and the axis that binds is tight, which is what "zoom to extent" means.
|
||||
assert max(reach_x, reach_y) >= 0.85, (viewport, reach_x, reach_y)
|
||||
# Nothing is outside the frame, and nothing sits on a frustum plane.
|
||||
corners = np.array(
|
||||
[[i, j, k] for i in (0.0, 1.0) for j in (0.0, 1.0) for k in (0.0, 1.0)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
ndc = _project(camera, bounds[0] + corners * (bounds[1] - bounds[0]), viewport)
|
||||
assert np.all(np.abs(ndc[:, :2]) <= 1.0), (viewport, np.abs(ndc[:, :2]).max())
|
||||
assert np.all(np.abs(ndc[:, 2]) < 0.9), viewport
|
||||
|
||||
|
||||
def test_a_wide_viewport_fills_what_the_geometry_allows() -> None:
|
||||
"""At 16:9 the vertical binds: no cropping, and the height is still filled."""
|
||||
assets, presets = _presets()
|
||||
viewport = (1280, 720)
|
||||
camera = _camera_for(presets[0], assets, viewport[0] / viewport[1], viewport)
|
||||
span_x, span_y, reach_x, reach_y = _bounds_fractions(assets, camera, viewport)
|
||||
assert reach_y == pytest.approx(0.92, abs=0.01)
|
||||
assert span_y == pytest.approx(0.92, abs=0.02)
|
||||
assert span_x < FILL_MIN, "a square part in a 16:9 window cannot fill both axes"
|
||||
assert span_x > 0.4, span_x
|
||||
assert reach_x <= FILL_MAX, reach_x
|
||||
|
||||
|
||||
def test_applying_any_preset_reframes_the_pane(qt_app) -> None:
|
||||
"""Every preset is framed, through the pane's own path, at two window sizes.
|
||||
|
||||
The defect this replaces: the first picture was framed, but choosing a view from the
|
||||
combo adopted the preset's baked distance and zoomed the part until it overflowed
|
||||
(measured on the shipped package: 2.16 x 2.12 of the viewport on ``default``).
|
||||
"""
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
assets = _scene()
|
||||
view = Scene3DView()
|
||||
if not view.available:
|
||||
view.close()
|
||||
pytest.skip(f"no GL view here: {view.reason}")
|
||||
try:
|
||||
assert view.use_scene(assets, source_id="frame-views")
|
||||
seen = set()
|
||||
for size in ((560, 500), (700, 560)):
|
||||
view.resize(*size)
|
||||
view.show()
|
||||
qt_app.processEvents()
|
||||
widget = view.gl_widget
|
||||
viewport = widget.viewport_pixels()
|
||||
seen.add(viewport)
|
||||
for preset in assets.camera_presets:
|
||||
view.apply_preset(preset.name)
|
||||
qt_app.processEvents()
|
||||
span_x, span_y, reach_x, reach_y = _bounds_fractions(
|
||||
assets, view._camera, viewport
|
||||
)
|
||||
assert max(reach_x, reach_y) <= FILL_MAX, (
|
||||
size,
|
||||
preset.name,
|
||||
reach_x,
|
||||
reach_y,
|
||||
)
|
||||
assert max(reach_x, reach_y) >= 0.85, (
|
||||
size,
|
||||
preset.name,
|
||||
reach_x,
|
||||
reach_y,
|
||||
)
|
||||
# Both axes cannot be filled when the view is narrow (``side`` sees a
|
||||
# 7.5 x 15.8 mm profile in a 1.19:1 viewport), so what is asserted is that
|
||||
# the axis the fit binds on is filled and the other is not cropped.
|
||||
assert max(span_x, span_y) >= FILL_MIN, (
|
||||
size,
|
||||
preset.name,
|
||||
span_x,
|
||||
span_y,
|
||||
)
|
||||
assert len(seen) == 2, f"the two window sizes gave one viewport: {seen}"
|
||||
finally:
|
||||
view.close()
|
||||
|
||||
|
||||
def test_a_drag_in_the_default_view_spins_about_the_frames_up() -> None:
|
||||
"""The turntable's pole is the frame's +Z, so a horizontal drag is not a roll.
|
||||
|
||||
With the sensor's own Z as the pole the default view would sit *on* the pole: a
|
||||
horizontal drag would do nothing at all and a vertical one would tilt the part out of
|
||||
its frame. Measured here as the world axis the target-to-eye direction turns about.
|
||||
"""
|
||||
assets, presets = _presets()
|
||||
frame_x, frame_y, frame_z = _frame()
|
||||
camera = _camera_for(presets[0], assets)
|
||||
before = camera.eye_sensor_m() - camera.target_sensor_m
|
||||
camera.orbit((30.0, 0.0), (640, 480))
|
||||
after = camera.eye_sensor_m() - camera.target_sensor_m
|
||||
assert np.linalg.norm(after - before) > 1e-4, "a horizontal drag did nothing"
|
||||
# A rotation about the frame's up keeps the vector perpendicular to it, and keeps
|
||||
# the frame's up fixed on the screen's up axis.
|
||||
assert float(after @ frame_z) == pytest.approx(0.0, abs=1e-9)
|
||||
_right, true_up, _backward = _screen_axes(camera)
|
||||
assert float(true_up @ frame_z) == pytest.approx(1.0, abs=EXACT), (
|
||||
"the drag rolled it"
|
||||
)
|
||||
|
||||
# A rightward drag swings the eye towards the frame's -X (the screen's right is -X),
|
||||
# so the part itself appears to turn to the right: a turntable, not a roll.
|
||||
camera = _camera_for(presets[0], assets)
|
||||
camera.orbit((30.0, 0.0), (640, 480))
|
||||
moved = camera.eye_sensor_m() - camera.target_sensor_m
|
||||
moved = moved / np.linalg.norm(moved)
|
||||
assert float(moved @ frame_x) < 0.0
|
||||
assert float(moved @ frame_y) > 0.0
|
||||
Reference in New Issue
Block a user