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:
ykcheungab
2026-10-01 16:56:36 +08:00
parent eb70ac7638
commit 9b1a3fb922
8 changed files with 843 additions and 73 deletions
+117 -48
View File
@@ -9,6 +9,15 @@ Conventions (design document §3.1): sensor frame, right-handed, metres; column
vectors, so ``p_camera = view @ p_sensor`` and ``p_clip = projection @ p_camera``. vectors, so ``p_camera = view @ p_sensor`` and ``p_clip = projection @ p_camera``.
The view matrix is built from a target, a distance and two angles, which is what The view matrix is built from a target, a distance and two angles, which is what
makes orbit/pan/zoom trivial and keeps "reset" exact rather than approximate. makes orbit/pan/zoom trivial and keeps "reset" exact rather than approximate.
The two angles are measured in a **turntable frame**: a pole (the axis the current
view calls up) and two axes spanning the plane perpendicular to it. The pole is the
applied preset's own ``up``, so the design export's +Z is the pole for every view
that has it as its up — a drag then spins the part about the axis the picture calls
vertical, instead of about the sensor's own Z, which is *not* vertical for this
export. Keeping the pole out of the sensor's axes is also what lets a preset looking
straight down the frame's up (``top``) be reproduced exactly: with the sensor's Z as
the pole it would land on the pole and a drag would have nowhere to go.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,13 +32,16 @@ from orisys_app_shared.scene3d.contracts import (
CameraPreset, CameraPreset,
freeze, freeze,
) )
from orisys_app_shared.scene3d.framing import fit_distance_m, screen_basis
#: Distance limits, in metres: close enough to inspect a 20 mm part, far enough #: Distance limits, in metres: close enough to inspect a 20 mm part, far enough
#: not to lose a 10 cm one. The far plane follows the distance, so precision stays #: not to lose a 10 cm one. The far plane follows the distance, so precision stays
#: usable at both ends instead of being decided once. #: usable at both ends instead of being decided once.
MIN_DISTANCE_M = 0.005 MIN_DISTANCE_M = 0.005
MAX_DISTANCE_M = 5.0 MAX_DISTANCE_M = 5.0
#: Radians per pixel of drag, and the pitch limit that keeps the up vector sane. #: Radians per pixel of drag, and the limit that keeps the pole out of the picture
#: plane: at exactly 90° off the turntable's equator the reference up is parallel to
#: the view direction, so the up vector would have to be invented.
ORBIT_RAD_PER_PX = 0.008 ORBIT_RAD_PER_PX = 0.008
MAX_PITCH_RAD = math.radians(89.0) MAX_PITCH_RAD = math.radians(89.0)
#: Zoom per wheel step. #: Zoom per wheel step.
@@ -53,30 +65,50 @@ class OrbitCamera:
aspect: float = 1.0 aspect: float = 1.0
_fitted_radius_m: float = 0.05 _fitted_radius_m: float = 0.05
_preset_name: str | None = None _preset_name: str | None = None
#: The turntable frame: ``_pole_sensor`` is the axis the angles are measured
#: against (the current view's up) and the other two span the plane across it.
#: They are aimed by :meth:`set_preset`; a camera nobody has given a preset keeps
#: the sensor's own basis, which is what ``reset`` returns to.
_pole_sensor: np.ndarray | None = None
_e1_sensor: np.ndarray | None = None
_e2_sensor: np.ndarray | None = None
def __post_init__(self) -> None:
self.up_sensor = _unit(self.up_sensor, np.array([0.0, 0.0, 1.0]))
if self._pole_sensor is None:
# The sensor basis, with yaw = pitch = 0 looking along -Y: the direction a
# camera starts in when no asset has said otherwise.
self._aim_turntable(
np.array([0.0, 0.0, 1.0], dtype=np.float64),
np.array([0.0, -1.0, 0.0], dtype=np.float64),
)
# ------------------------------------------------------------------- setup # ------------------------------------------------------------------- setup
def fit(self, bounds_sensor_m: F32, aspect: float) -> None: def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
"""Frame the whole scene from the bounds, without changing the angles. """Frame the whole scene from the bounds, without changing the angles.
The distance is derived from the bounding sphere and the *narrower* of the The distance is the one ``framing.fit_distance_m`` solves for: the box's own
two field-of-view axes, so a wide window does not clip the model corners projected into this camera's frame, so the binding axis lands on
vertically while a tall one does not clip it horizontally. ``FILL_FRACTION`` of the viewport and nothing is clipped. A bounding *sphere*
would leave a square-ish part filling barely half of a wide window, which is
not what "zoom to extent" means.
""" """
bounds = np.asarray(bounds_sensor_m, dtype=np.float64) bounds = np.asarray(bounds_sensor_m, dtype=np.float64)
if bounds.shape != (2, 3): if bounds.shape != (2, 3):
raise ValueError(f"bounds must be [2,3], got {bounds.shape}") raise ValueError(f"bounds must be [2,3], got {bounds.shape}")
low, high = bounds[0], bounds[1] low, high = bounds[0], bounds[1]
self.target_sensor_m = (low + high) * 0.5 self.target_sensor_m = (low + high) * 0.5
radius = float(np.linalg.norm(high - low) * 0.5) self._fitted_radius_m = max(float(np.linalg.norm(high - low) * 0.5), 1e-6)
self._fitted_radius_m = max(radius, 1e-6)
self.aspect = max(float(aspect), 1e-6) self.aspect = max(float(aspect), 1e-6)
self.vertical_fov_rad = _clamp(self.vertical_fov_rad, 0.1, 2.6) self.vertical_fov_rad = _clamp(self.vertical_fov_rad, 0.1, 2.6)
vertical_half = 0.5 * self.vertical_fov_rad
horizontal_half = math.atan(math.tan(vertical_half) * self.aspect)
half = min(vertical_half, horizontal_half)
# A little margin, so the silhouette is not flush with the edge.
self.distance_m = _clamp( self.distance_m = _clamp(
self._fitted_radius_m / max(math.sin(half), 1e-6) * 1.15, fit_distance_m(
bounds,
self._direction(),
self.up_sensor,
self.aspect,
self.vertical_fov_rad,
),
MIN_DISTANCE_M, MIN_DISTANCE_M,
MAX_DISTANCE_M, MAX_DISTANCE_M,
) )
@@ -84,8 +116,11 @@ class OrbitCamera:
def set_preset(self, preset: CameraPreset) -> None: def set_preset(self, preset: CameraPreset) -> None:
"""Point the camera where the asset says its default view is. """Point the camera where the asset says its default view is.
The preset's ``eye`` is turned into yaw/pitch/distance around its target, The preset's ``eye`` is turned into yaw/pitch/distance in the preset's own
so a later drag continues from the preset instead of jumping. frame, so a later drag continues from the preset instead of jumping. The
preset's ``up`` becomes the turntable's pole *and* the camera's up, which is
why the view is reproduced exactly rather than to within the pole's reach:
every preset here has its up perpendicular to its view direction.
""" """
eye = np.asarray(preset.eye_sensor_m, dtype=np.float64) eye = np.asarray(preset.eye_sensor_m, dtype=np.float64)
target = np.asarray(preset.target_sensor_m, dtype=np.float64) target = np.asarray(preset.target_sensor_m, dtype=np.float64)
@@ -94,13 +129,19 @@ class OrbitCamera:
distance = float(np.linalg.norm(offset)) distance = float(np.linalg.norm(offset))
if distance <= 0.0: if distance <= 0.0:
raise ValueError(f"preset {preset.name!r} puts the eye on the target") raise ValueError(f"preset {preset.name!r} puts the eye on the target")
pole = _unit(up, np.array([0.0, 0.0, 1.0]))
direction = offset / distance
self._aim_turntable(pole, direction)
pole, e1, e2 = self._turntable()
self.target_sensor_m = target self.target_sensor_m = target
self.distance_m = _clamp(distance, MIN_DISTANCE_M, MAX_DISTANCE_M) self.distance_m = _clamp(distance, MIN_DISTANCE_M, MAX_DISTANCE_M)
self.up_sensor = up / max(float(np.linalg.norm(up)), 1e-9) self.up_sensor = pole
direction = offset / distance self.pitch_rad = _clamp(
self.pitch_rad = _clamp(math.asin(_clamp(direction[2], -1.0, 1.0)), math.asin(_clamp(float(direction @ pole), -1.0, 1.0)),
-MAX_PITCH_RAD, MAX_PITCH_RAD) -MAX_PITCH_RAD,
self.yaw_rad = math.atan2(direction[0], -direction[1]) MAX_PITCH_RAD,
)
self.yaw_rad = math.atan2(float(direction @ e1), -float(direction @ e2))
self.vertical_fov_rad = _clamp(float(preset.vertical_fov_rad), 0.1, 2.6) self.vertical_fov_rad = _clamp(float(preset.vertical_fov_rad), 0.1, 2.6)
self._preset_name = preset.name self._preset_name = preset.name
@@ -115,7 +156,6 @@ class OrbitCamera:
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
"""Slide the target in the view plane, scaled so it tracks the cursor.""" """Slide the target in the view plane, scaled so it tracks the cursor."""
width = max(int(viewport[0]), 1)
height = max(int(viewport[1]), 1) height = max(int(viewport[1]), 1)
dx, dy = float(delta_px[0]), float(delta_px[1]) dx, dy = float(delta_px[0]), float(delta_px[1])
world_per_px = ( world_per_px = (
@@ -156,15 +196,7 @@ class OrbitCamera:
# ``cross(backward, up)`` yields a left-handed basis (det = -1), which # ``cross(backward, up)`` yields a left-handed basis (det = -1), which
# mirrors the whole model. # mirrors the whole model.
backward = self._direction() backward = self._direction()
up = np.asarray(self.up_sensor, dtype=np.float64) right, true_up = screen_basis(backward, self.up_sensor)
right = np.cross(up, backward)
norm = float(np.linalg.norm(right))
if norm < 1e-8:
# Looking straight down the up axis: pick any stable right vector.
right = np.array([1.0, 0.0, 0.0])
norm = 1.0
right = right / norm
true_up = np.cross(backward, right)
view = np.eye(4, dtype=np.float64) view = np.eye(4, dtype=np.float64)
view[0, :3], view[1, :3], view[2, :3] = right, true_up, backward view[0, :3], view[1, :3], view[2, :3] = right, true_up, backward
view[0, 3] = -float(np.dot(right, eye)) view[0, 3] = -float(np.dot(right, eye))
@@ -194,30 +226,67 @@ class OrbitCamera:
return (near, far) return (near, far)
# ------------------------------------------------------------------- helpers # ------------------------------------------------------------------- helpers
def _direction(self) -> np.ndarray: def _aim_turntable(self, pole: np.ndarray, backward: np.ndarray) -> None:
"""Unit vector from the target towards the eye.""" """Aim the turntable frame: ``pole`` is its axis, ``backward`` fixes the yaw.
cos_pitch = math.cos(self.pitch_rad)
return np.array( ``e2`` is the in-plane direction opposite the given view direction, so
[ ``yaw = pitch = 0`` reproduces it; ``e1`` completes a right-handed basis.
cos_pitch * math.sin(self.yaw_rad), """
-cos_pitch * math.cos(self.yaw_rad), pole = _unit(pole, np.array([0.0, 0.0, 1.0], dtype=np.float64))
math.sin(self.pitch_rad), candidate = np.asarray(backward, dtype=np.float64)
], candidate = candidate - pole * float(candidate @ pole)
dtype=np.float64, if float(np.linalg.norm(candidate)) < 1e-9:
# The view direction is along the pole: no view is rolled by the choice, so
# keep the turntable's current in-plane axis if there is one.
if self._e2_sensor is not None:
candidate = np.asarray(self._e2_sensor, dtype=np.float64)
candidate = candidate - pole * float(candidate @ pole)
if float(np.linalg.norm(candidate)) < 1e-9:
fallback = (
np.array([0.0, 0.0, 1.0])
if abs(float(pole[2])) < 0.9
else np.array([0.0, 1.0, 0.0])
) )
candidate = np.cross(pole, fallback)
e2 = -candidate / float(np.linalg.norm(candidate))
e1 = np.cross(e2, pole)
e1 = e1 / max(float(np.linalg.norm(e1)), 1e-15)
self._pole_sensor = pole
self._e1_sensor = e1
self._e2_sensor = e2
def _turntable(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""The aimed ``(pole, e1, e2)``; ``__post_init__`` guarantees they exist."""
if (
self._pole_sensor is None
or self._e1_sensor is None
or self._e2_sensor is None
):
raise RuntimeError("the camera's turntable frame was never aimed")
return self._pole_sensor, self._e1_sensor, self._e2_sensor
def _direction(self) -> np.ndarray:
"""Unit vector from the target towards the eye, in the turntable frame."""
pole, e1, e2 = self._turntable()
cos_pitch = math.cos(self.pitch_rad)
direction = (
cos_pitch * (math.sin(self.yaw_rad) * e1 - math.cos(self.yaw_rad) * e2)
+ math.sin(self.pitch_rad) * pole
)
return direction / max(float(np.linalg.norm(direction)), 1e-15)
def _basis(self) -> tuple[np.ndarray, np.ndarray]: def _basis(self) -> tuple[np.ndarray, np.ndarray]:
"""``(right, up)`` of the camera, in the same handedness as ``matrices``.""" """``(right, up)`` of the camera, in the same handedness as ``matrices``."""
backward = self._direction() return screen_basis(self._direction(), self.up_sensor)
up = np.asarray(self.up_sensor, dtype=np.float64)
right = np.cross(up, backward)
norm = float(np.linalg.norm(right)) def _unit(value: np.ndarray, fallback: np.ndarray) -> np.ndarray:
if norm < 1e-8: """``value`` as a unit vector, or ``fallback`` when it has no direction."""
right = np.array([1.0, 0.0, 0.0]) array = np.asarray(value, dtype=np.float64)
norm = 1.0 norm = float(np.linalg.norm(array))
right = right / norm if norm < 1e-12:
true_up = np.cross(backward, right) return np.array(fallback, dtype=np.float64)
return right, true_up / max(float(np.linalg.norm(true_up)), 1e-9) return array / norm
def _clamp(value: float, low: float, high: float) -> float: def _clamp(value: float, low: float, high: float) -> float:
@@ -422,10 +422,34 @@ class Scene3DView(QWidget):
except ValueError as exc: except ValueError as exc:
logger.warning("ignoring preset %s: %s", name, exc) logger.warning("ignoring preset %s: %s", name, exc)
return return
# Every preset is framed, not only the first picture. A preset carries
# the distance the compiler could state without knowing this pane's
# shape, and adopting it blindly is what used to zoom the part until it
# overflowed the viewport the moment an operator picked a view.
self.fit_view()
if self._widget is not None: if self._widget is not None:
self._widget.update() self._widget.update()
return return
def fit_view(self) -> None:
"""Zoom to extent: frame what the pane draws, at this pane's own aspect.
The same call the first paint makes, so "the default view" and "reset" and
"pick a preset" all mean one thing. ``OrbitCamera.fit`` keeps the angles, so
this changes the distance and the target only.
"""
if self._assets is None:
return
aspect = float(self._camera.aspect)
bounds = None
if self._widget is not None:
width, height = self._widget.viewport_pixels()
aspect = width / max(height, 1)
bounds = self._widget.fit_bounds()
self._camera.fit(
self._assets.bounds_sensor_m if bounds is None else bounds, aspect
)
def reset_view(self) -> None: def reset_view(self) -> None:
"""Go back to the view the asset itself calls its own. """Go back to the view the asset itself calls its own.
@@ -18,6 +18,7 @@ from __future__ import annotations
import time import time
import numpy as np
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
DisplaySettings, DisplaySettings,
@@ -28,6 +29,7 @@ from orisys_app_shared.scene3d.contracts import (
SceneAssets, SceneAssets,
diagnostic, diagnostic,
) )
from orisys_app_shared.scene3d.framing import drawn_bounds
from PySide6.QtCore import QEvent, Qt, Signal from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QMouseEvent, QWheelEvent from PySide6.QtGui import QMouseEvent, QWheelEvent
@@ -146,6 +148,23 @@ class Sensor3DWidget(QOpenGLWidget):
max(int(round(self.height() * ratio)), 1), max(int(round(self.height() * ratio)), 1),
) )
def fit_bounds(self) -> np.ndarray:
"""The bounds the camera should frame: what this pane will actually draw.
The sensing surface always; the housing only while it is shown, because
framing a 15.8 mm surface while a 21 mm frame is on screen crops the part the
operator just asked to inspect. Falls back to the asset's own statement when
there are no parts to measure.
"""
if self._assets is None:
return np.zeros((2, 3), dtype=np.float32)
bounds = drawn_bounds(
self._assets.parts, housing_visible=bool(self._settings.housing_visible)
)
if bounds is None:
return np.asarray(self._assets.bounds_sensor_m, dtype=np.float32)
return bounds.astype(np.float32)
# ------------------------------------------------------------ Qt overrides # ------------------------------------------------------------ Qt overrides
def initializeGL(self) -> None: # pragma: no cover - needs a real context def initializeGL(self) -> None: # pragma: no cover - needs a real context
try: try:
@@ -200,7 +219,7 @@ class Sensor3DWidget(QOpenGLWidget):
self._camera.set_preset(presets[0]) self._camera.set_preset(presets[0])
except ValueError: except ValueError:
pass pass
self._camera.fit(self._assets.bounds_sensor_m, self._aspect()) self._camera.fit(self.fit_bounds(), self._aspect())
if self._needs_surface_upload and self._frame is not None: if self._needs_surface_upload and self._frame is not None:
frame, self._frame = self._frame, None frame, self._frame = self._frame, None
self._needs_surface_upload = False self._needs_surface_upload = False
@@ -293,12 +312,12 @@ class Sensor3DWidget(QOpenGLWidget):
try: try:
self.makeCurrent() self.makeCurrent()
self._renderer.release() self._renderer.release()
except Exception: # noqa: BLE001 - teardown must not raise on the way out except Exception: # noqa: BLE001, S110 - teardown must not raise on the way out
pass pass
finally: finally:
try: try:
self.doneCurrent() self.doneCurrent()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001, S110 - as above: nothing left to report to
pass pass
def hideEvent(self, event: QEvent) -> None: # pragma: no cover - Qt event def hideEvent(self, event: QEvent) -> None: # pragma: no cover - Qt event
@@ -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
@@ -0,0 +1,162 @@
"""Where to put a camera so a box fills the viewport.
One implementation, two callers: the compiler bakes every ``CameraPreset`` with
:func:`fit_distance_m`, and the Studio's orbit camera re-frames a preset with the same
call. Two implementations would let the picture the asset states and the picture the
pane draws disagree, which is the defect class this module exists to remove.
The camera *basis* lives here too. ``right = cross(up, backward)`` is easy to get
backwards — that yields a left-handed basis and mirrors the model — so it is written
once, with the one fallback for the degenerate case.
Units are the asset's: metres in the sensor frame, column vectors.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
import numpy as np
#: Fraction of the viewport the box's projected bounds are made to occupy, on the
#: axis that binds. Under 1.0 so the silhouette is never flush with the edge, high
#: enough that "zoom to extent" means what it says. A square-ish model in a wide
#: viewport cannot reach this on both axes at once: the other axis gets
#: ``fill · (model aspect) / (viewport aspect)``, and cropping the model to do
#: better is not framing.
FILL_FRACTION = 0.92
#: Metres. A degenerate box (one point) still needs the camera outside it.
MIN_FIT_DISTANCE_M = 1e-4
#: The eight corners of a box, in the order (x, y, z) = (low/high).
_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,
)
def screen_basis(backward_sensor: Any, up_sensor: Any) -> tuple[np.ndarray, np.ndarray]:
"""``(right, true_up)`` for a camera looking along ``-backward`` with ``up``.
``backward`` points from the target towards the eye. ``right`` is
``cross(up, backward)`` — the *negated* form of ``cross(backward, up)`` — because
that combination is the right-handed one; the other mirrors the model. When the
two inputs are parallel the camera is at its own pole and no ``right`` is defined,
so a stable axis is used rather than a zero vector.
"""
backward = np.asarray(backward_sensor, dtype=np.float64)
backward = backward / max(float(np.linalg.norm(backward)), 1e-15)
up = np.asarray(up_sensor, dtype=np.float64)
right = np.cross(up, backward)
norm = float(np.linalg.norm(right))
if norm < 1e-8:
right = np.array([1.0, 0.0, 0.0], dtype=np.float64)
norm = 1.0
right = right / norm
true_up = np.cross(backward, right)
return right, true_up / max(float(np.linalg.norm(true_up)), 1e-15)
def fit_distance_m(
bounds_sensor_m: Any,
backward_sensor: Any,
up_sensor: Any,
aspect: float,
vertical_fov_rad: float,
*,
fill: float = FILL_FRACTION,
) -> float:
"""Distance from the target that makes a box occupy ``fill`` of the viewport.
The box's eight corners are put in the camera's own frame and each one states how
far the eye must be for it to land inside the ``fill`` envelope:
``d >= n + |across| / (fill · tan(half_angle))``
``n`` is the corner's depth along ``backward``, so a corner nearer the eye pushes
the camera back further — which is what makes this a fit for a perspective camera
rather than for a bounding sphere. The maximum over the corners and over the two
axes is the answer; the binding axis lands exactly on ``fill``.
"""
bounds = np.asarray(bounds_sensor_m, dtype=np.float64)
if bounds.shape != (2, 3):
raise ValueError(f"bounds must be [2,3], got {bounds.shape}")
low, high = bounds[0], bounds[1]
centre = (low + high) * 0.5
corners = low + _CORNERS * (high - low)
right, true_up = screen_basis(backward_sensor, up_sensor)
backward = np.asarray(backward_sensor, dtype=np.float64)
backward = backward / max(float(np.linalg.norm(backward)), 1e-15)
aspect = max(float(aspect), 1e-6)
vertical = min(max(float(vertical_fov_rad), 0.1), 2.6)
tan_v = float(np.tan(0.5 * vertical))
# The projection's own relation (``OrbitCamera.matrices`` builds it this way), so
# the fit and the frustum cannot disagree about how wide the viewport is.
tan_h = tan_v * aspect
fill = float(fill)
relative = corners - centre
across_right = relative @ right
across_up = relative @ true_up
depth = relative @ backward
needed = np.maximum(
depth + np.abs(across_right) / max(fill * tan_h, 1e-9),
depth + np.abs(across_up) / max(fill * tan_v, 1e-9),
)
return max(float(needed.max()), MIN_FIT_DISTANCE_M)
def projected_fractions(
bounds_sensor_m: Any,
backward_sensor: Any,
up_sensor: Any,
distance_m: float,
aspect: float,
vertical_fov_rad: float,
) -> tuple[float, float]:
"""``(horizontal, vertical)`` fraction of the viewport the box's bounds occupy.
The same statement :func:`fit_distance_m` solves for, evaluated rather than
solved: 1.0 means the bounds touch both edges of that axis.
"""
bounds = np.asarray(bounds_sensor_m, dtype=np.float64)
low, high = bounds[0], bounds[1]
centre = (low + high) * 0.5
corners = low + _CORNERS * (high - low)
right, true_up = screen_basis(backward_sensor, up_sensor)
backward = np.asarray(backward_sensor, dtype=np.float64)
backward = backward / max(float(np.linalg.norm(backward)), 1e-15)
vertical = min(max(float(vertical_fov_rad), 0.1), 2.6)
tan_h = np.tan(0.5 * vertical) * max(float(aspect), 1e-6)
relative = corners - centre
depth = float(distance_m) - relative @ backward
horizontal = np.abs(relative @ right) / np.maximum(depth * tan_h, 1e-12)
upright = np.abs(relative @ true_up) / np.maximum(
depth * np.tan(0.5 * vertical), 1e-12
)
return float(horizontal.max()), float(upright.max())
def drawn_bounds(
parts: Iterable[Any], *, housing_visible: bool = False
) -> np.ndarray | None:
"""Bounds of the geometry a pane will draw, or ``None`` when it draws nothing.
The sensing surface is always drawn; the housing only when it is shown. Framing
the sensing bounds while the frame is on screen would crop the very part the
operator just asked to inspect — on the finger the frame is ~33 % taller than the
surface it surrounds.
"""
blocks = [
np.asarray(part.positions_sensor_m, dtype=np.float64)
for part in parts
if part.role == "sensing" or housing_visible
]
blocks = [block for block in blocks if block.size]
if not blocks:
return None
points = np.concatenate(blocks, axis=0)
return np.stack([points.min(axis=0), points.max(axis=0)]).astype(np.float64)
+120 -17
View File
@@ -54,11 +54,15 @@ from orisys_app_shared.scene3d.digest import (
geometry_id, geometry_id,
source_geometry_digest, source_geometry_digest,
) )
from orisys_app_shared.scene3d.framing import fit_distance_m
from orisys_app_shared.scene3d.overlays import build_surface_mapping from orisys_app_shared.scene3d.overlays import build_surface_mapping
#: Keys the importer needs, and what it does when they are missing. #: Keys the importer needs, and what it does when they are missing.
MESH_KEYS = ("vertices", "faces", "uv_data", "loop_to_vertex") MESH_KEYS = ("vertices", "faces", "uv_data", "loop_to_vertex")
#: Vertical field of view every derived preset states, in radians (~34°).
DEFAULT_PRESET_FOV_RAD = 0.6
#: The mesh's material: the sensing surface is colour-mapped, never shaded into #: The mesh's material: the sensing surface is colour-mapped, never shaded into
#: looking like a different value. #: looking like a different value.
SENSING_MATERIAL = Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True) SENSING_MATERIAL = Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True)
@@ -829,6 +833,13 @@ class LegacyFingerImporter:
bounds, bounds,
positions_sensor_m=positions_sensor, positions_sensor_m=positions_sensor,
triangles=triangles, triangles=triangles,
# The export's own axes, which are the transform's rotation block:
# ``transform_sensor_from_source`` maps the frame the mesh was
# written in (the ``origin_sensor`` empty) onto the sensor frame,
# so its columns *are* that frame's X, Y and Z. Stating the views
# in it is what stops the presets from being a guess about which
# side of the surface the camera belongs on.
source_frame_sensor=transform[:3, :3],
) )
), ),
bounds_sensor_m=bounds, bounds_sensor_m=bounds,
@@ -938,10 +949,12 @@ def sensing_surface_normal(
Area-weighted over the triangles (so a dense region does not outvote a large Area-weighted over the triangles (so a dense region does not outvote a large
face) and oriented away from the mesh's own centroid. This is the direction a face) and oriented away from the mesh's own centroid. This is the direction a
camera has to sit on: the sensor's *own* geometry says which side is the sensing camera has to sit on when nothing states the export's frame: the sensor's *own*
side, and a preset that assumes an axis instead is wrong the moment the export's geometry says which side is the sensing side, so a package an older compiler baked
frame rotates (measured: the shipped ``default`` preset sits 122.7° from this without a frame can be repaired instead of shown from behind. It is **not** how the
normal, i.e. behind the surface). presets are derived once the frame is known (:func:`default_finger_presets`): on the
finger the pad's normal is 24.6° off the frame's +Y, and the picture belongs in the
artist's frame, not tilted to follow a facet.
""" """
positions = np.asarray(positions_sensor_m, dtype=np.float64) positions = np.asarray(positions_sensor_m, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64) triangles = np.asarray(triangles, dtype=np.int64)
@@ -965,26 +978,47 @@ def default_finger_presets(
triangles: np.ndarray | None = None, triangles: np.ndarray | None = None,
*, *,
surface_normal_sensor: np.ndarray | None = None, surface_normal_sensor: np.ndarray | None = None,
source_frame_sensor: np.ndarray | None = None,
) -> tuple[CameraPreset, ...]: ) -> tuple[CameraPreset, ...]:
"""The default views for a flat sensing surface, in sensor metres. """The default views of a sensing part, in sensor metres.
Derived from the bounds and from the surface's **own** normal. The absolute axes Two derivations, and which one applies is the whole point:
are not the sensor's: the design export's frame is rotated relative to the SDK
asset's, so a preset hard-coded to look down ``+Z`` ends up behind the surface * **The stated frame** (``source_frame_sensor``, the compile path). The three
(~123° from its normal), where 96.5% of it is hidden by the frame when the columns are the design export's own axes — the ``origin_sensor`` empty's X, Y
housing is shown. The three-quarter view therefore comes from the mesh: the eye and Z — expressed in the sensor frame, which is exactly the rotation block of
is placed along the outward normal, raised off-axis so the relief is visible. the ``--transform`` the compiler is given. The views are then stated in the
frame the artist worked in: ``default``/``front`` look along the frame's +Y with
+Z up, ``side`` looks along +X, ``top`` looks along +Z. The sensing surface's
own normal never enters it, so a tilted pad (the finger's is 24.6° off +Y) does
not tilt the picture.
* **The mesh's own normal** (no frame stated). A package baked by an older
compiler, or the load-time repair path, has no frame to trust, so the eye is put
on the outward side of the surface the geometry actually shows. This is the
fallback, not the design.
""" """
low, high = np.asarray(bounds_sensor_m, dtype=np.float64) low, high = np.asarray(bounds_sensor_m, dtype=np.float64)
centre = (low + high) * 0.5 centre = (low + high) * 0.5
span = float(np.max(high - low)) span = float(np.max(high - low))
distance = max(span, 1e-4) distance = max(span, 1e-4)
normal = surface_normal_sensor
if normal is None: if source_frame_sensor is not None:
return _frame_presets(
np.asarray(bounds_sensor_m, dtype=np.float64),
source_frame_sensor,
centre=centre,
)
if surface_normal_sensor is None:
if positions_sensor_m is None or triangles is None: if positions_sensor_m is None or triangles is None:
normal = np.array([0.0, 0.0, 1.0]) raise error(
else: "geometry_mismatch",
"the default views need either the export's source frame or the mesh's "
"own surface: without both, the derivation would be a guess",
)
normal = sensing_surface_normal(positions_sensor_m, triangles) normal = sensing_surface_normal(positions_sensor_m, triangles)
else:
normal = surface_normal_sensor
normal = np.asarray(normal, dtype=np.float64) normal = np.asarray(normal, dtype=np.float64)
normal = normal / max(float(np.linalg.norm(normal)), 1e-15) normal = normal / max(float(np.linalg.norm(normal)), 1e-15)
# In-plane basis for this surface: the principal axes of the vertex cloud with # In-plane basis for this surface: the principal axes of the vertex cloud with
@@ -1014,8 +1048,7 @@ def default_finger_presets(
return ( return (
# Three-quarter: 31.0° off the normal, so the surface is seen almost head-on # Three-quarter: 31.0° off the normal, so the surface is seen almost head-on
# with enough tilt to show its relief. (The old fixed vector put this 122.7° # with enough tilt to show its relief.
# away — behind the surface — for the design frame.)
CameraPreset("default", eye((1.0 * normal + 0.6 * tangent) / np.sqrt(1.36)), CameraPreset("default", eye((1.0 * normal + 0.6 * tangent) / np.sqrt(1.36)),
tuple(centre), tuple(normal), 0.6), tuple(centre), tuple(normal), 0.6),
# Straight down the outward normal. # Straight down the outward normal.
@@ -1027,5 +1060,75 @@ def default_finger_presets(
) )
#: The aspect each baked preset is framed for. The pane re-frames at its own shape
#: (``OrbitCamera.fit``), so this only has to be a sane statement for a consumer that
#: draws the preset with no viewport of its own — 4:3 is the conservative one.
PRESET_REFERENCE_ASPECT = 4.0 / 3.0
def _frame_presets(
bounds_sensor_m: np.ndarray, source_frame_sensor: np.ndarray, *, centre: np.ndarray
) -> tuple[CameraPreset, ...]:
"""The four engineering views of the frame the design export states.
``source_frame_sensor`` is a 3x3 whose columns are the export's X, Y and Z axes in
the sensor frame. Each view is named for what it looks along, and each ``up`` is
perpendicular to its own view direction — which is what "not rolled" means, and
what lets the pane reproduce the preset exactly.
"""
frame = np.asarray(source_frame_sensor, dtype=np.float64)
if frame.shape != (3, 3):
raise error(
"geometry_mismatch",
f"source_frame_sensor must be 3x3, got {frame.shape}",
)
axes = []
for column in range(3):
axis = frame[:, column]
norm = float(np.linalg.norm(axis))
if norm <= 1e-12:
raise error("geometry_mismatch", "the source frame has a zero axis")
axes.append(axis / norm)
frame_x, frame_y, frame_z = axes
if float(np.linalg.det(np.stack(axes, axis=1))) < 0.0:
raise error(
"geometry_mismatch",
"the source frame is left-handed: it is not a rotation of the sensor frame",
)
# (name, eye direction, up). ``front`` is the default by the operator's own
# definition: the view the finger is presented in.
table = (
("default", frame_y, frame_z),
("front", frame_y, frame_z),
("side", frame_x, frame_z),
# Looking straight down the frame's up. ``up`` is the frame's -Y so that the
# view is the default one tilted 90° about its own horizontal axis: +X stays
# on the left, as it is in every other view here, and nothing is rolled.
("top", frame_z, -frame_y),
)
presets = []
for name, direction, up in table:
# The baked distance is a statement for a consumer that draws the preset with no
# viewport of its own; the pane re-frames at its own shape on every application.
distance = fit_distance_m(
bounds_sensor_m,
direction,
up,
PRESET_REFERENCE_ASPECT,
DEFAULT_PRESET_FOV_RAD,
)
presets.append(
CameraPreset(
name,
tuple(centre + direction * distance),
tuple(centre),
tuple(up),
DEFAULT_PRESET_FOV_RAD,
)
)
return tuple(presets)
def identity_transform() -> np.ndarray: def identity_transform() -> np.ndarray:
return np.eye(4, dtype=np.float64) return np.eye(4, dtype=np.float64)
@@ -787,12 +787,11 @@ def scene_from_contents(
for item in manifest.get("camera_presets", []) for item in manifest.get("camera_presets", [])
if isinstance(item, dict) and item.get("name") if isinstance(item, dict) and item.get("name")
) )
# A preset baked by an older compiler points down a fixed axis, which is only # A preset is trusted only if its eye is in front of the surface. A package whose
# correct if the export's frame happens to match the sensor frame. The design # presets were baked without the export's frame — an older compiler, or a compilers'
# export does not: its shipped ``default`` sits ~123° from the sensing surface's # axis guess that happened to be wrong — can point at the back of the plate, where
# own normal, where the housing hides ~100% of the surface. If the package's # the housing hides all of it; the mesh's own normal then says which side the sensing
# default preset is on the far side of the surface, replace the set with one # side is, and the set is replaced rather than presented.
# derived from the mesh rather than presenting a view of the back of the sensor.
sensing_only_for_presets = tuple(part for part in parts if part.role == "sensing") sensing_only_for_presets = tuple(part for part in parts if part.role == "sensing")
if sensing_only_for_presets: if sensing_only_for_presets:
sensing_part = sensing_only_for_presets[0] sensing_part = sensing_only_for_presets[0]