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:
@@ -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``.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -23,13 +32,16 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
CameraPreset,
|
||||
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
|
||||
#: 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.
|
||||
MIN_DISTANCE_M = 0.005
|
||||
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
|
||||
MAX_PITCH_RAD = math.radians(89.0)
|
||||
#: Zoom per wheel step.
|
||||
@@ -53,30 +65,50 @@ class OrbitCamera:
|
||||
aspect: float = 1.0
|
||||
_fitted_radius_m: float = 0.05
|
||||
_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
|
||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
||||
"""Frame the whole scene from the bounds, without changing the angles.
|
||||
|
||||
The distance is derived from the bounding sphere and the *narrower* of the
|
||||
two field-of-view axes, so a wide window does not clip the model
|
||||
vertically while a tall one does not clip it horizontally.
|
||||
The distance is the one ``framing.fit_distance_m`` solves for: the box's own
|
||||
corners projected into this camera's frame, so the binding axis lands on
|
||||
``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)
|
||||
if bounds.shape != (2, 3):
|
||||
raise ValueError(f"bounds must be [2,3], got {bounds.shape}")
|
||||
low, high = bounds[0], bounds[1]
|
||||
self.target_sensor_m = (low + high) * 0.5
|
||||
radius = float(np.linalg.norm(high - low) * 0.5)
|
||||
self._fitted_radius_m = max(radius, 1e-6)
|
||||
self._fitted_radius_m = max(float(np.linalg.norm(high - low) * 0.5), 1e-6)
|
||||
self.aspect = max(float(aspect), 1e-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._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,
|
||||
MAX_DISTANCE_M,
|
||||
)
|
||||
@@ -84,8 +116,11 @@ class OrbitCamera:
|
||||
def set_preset(self, preset: CameraPreset) -> None:
|
||||
"""Point the camera where the asset says its default view is.
|
||||
|
||||
The preset's ``eye`` is turned into yaw/pitch/distance around its target,
|
||||
so a later drag continues from the preset instead of jumping.
|
||||
The preset's ``eye`` is turned into yaw/pitch/distance in the preset's own
|
||||
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)
|
||||
target = np.asarray(preset.target_sensor_m, dtype=np.float64)
|
||||
@@ -94,13 +129,19 @@ class OrbitCamera:
|
||||
distance = float(np.linalg.norm(offset))
|
||||
if distance <= 0.0:
|
||||
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.distance_m = _clamp(distance, MIN_DISTANCE_M, MAX_DISTANCE_M)
|
||||
self.up_sensor = up / max(float(np.linalg.norm(up)), 1e-9)
|
||||
direction = offset / distance
|
||||
self.pitch_rad = _clamp(math.asin(_clamp(direction[2], -1.0, 1.0)),
|
||||
-MAX_PITCH_RAD, MAX_PITCH_RAD)
|
||||
self.yaw_rad = math.atan2(direction[0], -direction[1])
|
||||
self.up_sensor = pole
|
||||
self.pitch_rad = _clamp(
|
||||
math.asin(_clamp(float(direction @ pole), -1.0, 1.0)),
|
||||
-MAX_PITCH_RAD,
|
||||
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._preset_name = preset.name
|
||||
|
||||
@@ -115,7 +156,6 @@ class OrbitCamera:
|
||||
|
||||
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."""
|
||||
width = max(int(viewport[0]), 1)
|
||||
height = max(int(viewport[1]), 1)
|
||||
dx, dy = float(delta_px[0]), float(delta_px[1])
|
||||
world_per_px = (
|
||||
@@ -156,15 +196,7 @@ class OrbitCamera:
|
||||
# ``cross(backward, up)`` yields a left-handed basis (det = -1), which
|
||||
# mirrors the whole model.
|
||||
backward = self._direction()
|
||||
up = np.asarray(self.up_sensor, dtype=np.float64)
|
||||
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)
|
||||
right, true_up = screen_basis(backward, self.up_sensor)
|
||||
view = np.eye(4, dtype=np.float64)
|
||||
view[0, :3], view[1, :3], view[2, :3] = right, true_up, backward
|
||||
view[0, 3] = -float(np.dot(right, eye))
|
||||
@@ -194,30 +226,67 @@ class OrbitCamera:
|
||||
return (near, far)
|
||||
|
||||
# ------------------------------------------------------------------- helpers
|
||||
def _aim_turntable(self, pole: np.ndarray, backward: np.ndarray) -> None:
|
||||
"""Aim the turntable frame: ``pole`` is its axis, ``backward`` fixes the yaw.
|
||||
|
||||
``e2`` is the in-plane direction opposite the given view direction, so
|
||||
``yaw = pitch = 0`` reproduces it; ``e1`` completes a right-handed basis.
|
||||
"""
|
||||
pole = _unit(pole, np.array([0.0, 0.0, 1.0], dtype=np.float64))
|
||||
candidate = np.asarray(backward, dtype=np.float64)
|
||||
candidate = candidate - pole * float(candidate @ pole)
|
||||
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."""
|
||||
"""Unit vector from the target towards the eye, in the turntable frame."""
|
||||
pole, e1, e2 = self._turntable()
|
||||
cos_pitch = math.cos(self.pitch_rad)
|
||||
return np.array(
|
||||
[
|
||||
cos_pitch * math.sin(self.yaw_rad),
|
||||
-cos_pitch * math.cos(self.yaw_rad),
|
||||
math.sin(self.pitch_rad),
|
||||
],
|
||||
dtype=np.float64,
|
||||
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]:
|
||||
"""``(right, up)`` of the camera, in the same handedness as ``matrices``."""
|
||||
backward = self._direction()
|
||||
up = np.asarray(self.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])
|
||||
norm = 1.0
|
||||
right = right / norm
|
||||
true_up = np.cross(backward, right)
|
||||
return right, true_up / max(float(np.linalg.norm(true_up)), 1e-9)
|
||||
return screen_basis(self._direction(), self.up_sensor)
|
||||
|
||||
|
||||
def _unit(value: np.ndarray, fallback: np.ndarray) -> np.ndarray:
|
||||
"""``value`` as a unit vector, or ``fallback`` when it has no direction."""
|
||||
array = np.asarray(value, dtype=np.float64)
|
||||
norm = float(np.linalg.norm(array))
|
||||
if norm < 1e-12:
|
||||
return np.array(fallback, dtype=np.float64)
|
||||
return array / norm
|
||||
|
||||
|
||||
def _clamp(value: float, low: float, high: float) -> float:
|
||||
|
||||
@@ -422,10 +422,34 @@ class Scene3DView(QWidget):
|
||||
except ValueError as exc:
|
||||
logger.warning("ignoring preset %s: %s", name, exc)
|
||||
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:
|
||||
self._widget.update()
|
||||
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:
|
||||
"""Go back to the view the asset itself calls its own.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
@@ -28,6 +29,7 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
SceneAssets,
|
||||
diagnostic,
|
||||
)
|
||||
from orisys_app_shared.scene3d.framing import drawn_bounds
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||
|
||||
@@ -146,6 +148,23 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
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
|
||||
def initializeGL(self) -> None: # pragma: no cover - needs a real context
|
||||
try:
|
||||
@@ -200,7 +219,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
self._camera.set_preset(presets[0])
|
||||
except ValueError:
|
||||
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:
|
||||
frame, self._frame = self._frame, None
|
||||
self._needs_surface_upload = False
|
||||
@@ -293,12 +312,12 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
try:
|
||||
self.makeCurrent()
|
||||
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
|
||||
finally:
|
||||
try:
|
||||
self.doneCurrent()
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001, S110 - as above: nothing left to report to
|
||||
pass
|
||||
|
||||
def hideEvent(self, event: QEvent) -> None: # pragma: no cover - Qt event
|
||||
|
||||
Reference in New Issue
Block a user