Files
orisys-app/apps/studio/tests/test_scene3d_frame_views.py
T
ykcheungab 9b1a3fb922 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.
2026-10-01 16:56:36 +08:00

395 lines
18 KiB
Python

"""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