From 9b1a3fb922a499c7aafc43ff47293f7416832f2a Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Thu, 1 Oct 2026 16:56:36 +0800 Subject: [PATCH] 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. --- .../scene/finger-display-1.0.0.oryscene | Bin 727289 -> 727228 bytes .../src/orisys_studio/scene3d/camera.py | 161 +++++-- apps/studio/src/orisys_studio/scene3d/view.py | 24 ++ .../src/orisys_studio/scene3d/widget.py | 25 +- apps/studio/tests/test_scene3d_frame_views.py | 394 ++++++++++++++++++ .../src/orisys_app_shared/scene3d/framing.py | 162 +++++++ .../src/orisys_app_shared/scene3d/legacy.py | 139 +++++- .../src/orisys_app_shared/scene3d/package.py | 11 +- 8 files changed, 843 insertions(+), 73 deletions(-) create mode 100644 apps/studio/tests/test_scene3d_frame_views.py create mode 100644 shared/src/orisys_app_shared/scene3d/framing.py diff --git a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene index e9816b3e5b3d7bd5ee1466abb2962d112ac5562b..3efa30032359caf04ca00bde7e8985b26805d6be 100644 GIT binary patch delta 2388 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 JI&m1=`wL`IR#N}~ delta 2429 zcmZXWc{J2*AI5)U8O&sjF?Lad$(BONLqZtBV`LhPH(OaFOY~?=(VMb0mW=GORAY%4 z8e*iEAvCSl8Ec}3Y{?qWm_ItF^Zw4c&i%Q6_kCUG``>-NsMXx4)f9HL<>iwE06-96 zpiZU)#!y|IQ(NzvikchUGhIYS`5 zzKOCBsCAdtigUM%hWqHZOix^0p6Rf7Rz8^+SbASHha7;kDhWVqplnVM379OsyNq%a zEJZZTh56#F$!7QTWd>FuDyXg_OP39svfXsWRjj%eXdz98uH(Gy z7t6kiMTE{(KDDc&%A##8Yc-m5F^`;WKL&{@WhWCIYg69$6tI3h6!~BVGaqnRs97>y zTK8I6nVf|5Q|=)T1W4Nuu+vl1qd5?d6ao)6@l9Y0?%05jxwW?Clze}8&wUq0#h8w0 z@ND4wmtb$_`a!A_oqe zVB30X$>(Ip7?_Ot_QWkY?61jUh|sFzsIts!KiUeAB(VGU)qLdzifTml?x*~%Y4$IL zfXk;2C6qa@_}0KM|A7`W&a{ zr)g)qjF2A;MX!mB(SOrek|bnQQp zJ+zSFDEYT6t!w%{D@VRW${-q<16JRChkITH`HXlnYP(GlTXy=_Cg|0#uUoOEPMR8m zn-rP7haWWTl$90Iupff*SzR?bBGWG4quu8=hZc{y3tPg%C?3DepN`${AQyJm%=kx{ zwv8$!RTF4a8=)=dO@gKAEf|L${eP%9jRZGvbh5(R*m12F>M(ty>5Gt<}m zUJ52SWdL{ER^i?EnP>Eawto805Ef*+Ye7id@ zH39G1Co6w#t-=3XPH5N=9|&A8L9WVHyp*r%P;5jRtZBFlu|FAsGPa6eM-G@-!_nzl zX_=?Ik0HWdyL@oIHP$C%7hc^FPq21E_l}>kam>;(Z=#lfW+j=Il0tVwz0`a?<=vcm zi8(_^2@K_@s_7}$0J>38(%ayIwm67tylT2$RkW1uPJgUoo=4xhi)m`kf`3#0O(h{( zxJFY{*%0;EK=b5Rck--4WVrUwa`pCcQuXm_(KnXcbfInc-s_p349k2aLd^u#@4I=T zk@TAUbV|5*d-L;H?Y14M=-ns_mWp?7_Y3>LD0*Foh|BAS;uES&;TwjraR z&Q7nO)_*cs7GHSOcrS)XpjlCLEVl4+eJK%9dZ&LG-EhAdJ36SMt=}o1irFly0r?%| ziTfWh_zC<$7ggQIicdFRm`*g$$u-g*> zqvt5zPbui!0DHr~sBWD-ax_s>b~AMje|$)7+Hg+$`^*XkVQ8^LxQ5nI3i6M@GwSkI z$RLUGmFn#n#z!Cg((<=8B-=Q6@mrZ*v*J|i%%gbQVtTce&QA0uV`BR4@ZT4EA7hx6 zE0d*}w_G1?$Pw+uPlidJT}%fxz~8`cyoFc8@JLAA>U$?0U-zw5W5Kc59qB(FW*l(% zXx$4hXD#k)yq_3&mgdG9Px0(%Mo8=sv^JP3GPW3 zhCiVT1LjD8BSDUY8oMwM8v-{$asZOY&!svhL_v;AAJ!oA@_R&jB?&#wg&j$#2^UPI zpr^PH#{uC#Is%Cy1=Zd=%Ys4WK(i!y7*vIO)ogEXPQt;U2bK05!@-}5)edm#gc$&E zipB5m2B?-)1cPdDO}!iwj~FXsfhm93(H6ub3HUHZh(SF zy3$Z}Zh)IKRF1b*5kx9b1pR+Rx0Y{^IDsDkyhH(oKO)viZPHLpZo~p70%Spg!lCNi s!B;YTsPmkO1#ti%^@nMS 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: diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index ba759d2..a981d7c 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index 258e8b2..1ed7a30 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -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 diff --git a/apps/studio/tests/test_scene3d_frame_views.py b/apps/studio/tests/test_scene3d_frame_views.py new file mode 100644 index 0000000..dc6f08a --- /dev/null +++ b/apps/studio/tests/test_scene3d_frame_views.py @@ -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 diff --git a/shared/src/orisys_app_shared/scene3d/framing.py b/shared/src/orisys_app_shared/scene3d/framing.py new file mode 100644 index 0000000..0a9a96d --- /dev/null +++ b/shared/src/orisys_app_shared/scene3d/framing.py @@ -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) diff --git a/shared/src/orisys_app_shared/scene3d/legacy.py b/shared/src/orisys_app_shared/scene3d/legacy.py index 5c906e2..84556a2 100644 --- a/shared/src/orisys_app_shared/scene3d/legacy.py +++ b/shared/src/orisys_app_shared/scene3d/legacy.py @@ -54,11 +54,15 @@ from orisys_app_shared.scene3d.digest import ( geometry_id, source_geometry_digest, ) +from orisys_app_shared.scene3d.framing import fit_distance_m from orisys_app_shared.scene3d.overlays import build_surface_mapping #: Keys the importer needs, and what it does when they are missing. 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 #: looking like a different value. SENSING_MATERIAL = Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True) @@ -829,6 +833,13 @@ class LegacyFingerImporter: bounds, positions_sensor_m=positions_sensor, 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, @@ -938,10 +949,12 @@ def sensing_surface_normal( 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 - camera has to sit on: the sensor's *own* geometry says which side is the sensing - side, and a preset that assumes an axis instead is wrong the moment the export's - frame rotates (measured: the shipped ``default`` preset sits 122.7° from this - normal, i.e. behind the surface). + camera has to sit on when nothing states the export's frame: the sensor's *own* + geometry says which side is the sensing side, so a package an older compiler baked + without a frame can be repaired instead of shown from behind. It is **not** how the + 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) triangles = np.asarray(triangles, dtype=np.int64) @@ -965,26 +978,47 @@ def default_finger_presets( triangles: np.ndarray | None = None, *, surface_normal_sensor: np.ndarray | None = None, + source_frame_sensor: np.ndarray | None = None, ) -> 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 - 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 - (~123° from its normal), where 96.5% of it is hidden by the frame when the - housing is shown. The three-quarter view therefore comes from the mesh: the eye - is placed along the outward normal, raised off-axis so the relief is visible. + Two derivations, and which one applies is the whole point: + + * **The stated frame** (``source_frame_sensor``, the compile path). The three + columns are the design export's own axes — the ``origin_sensor`` empty's X, Y + and Z — expressed in the sensor frame, which is exactly the rotation block of + 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) centre = (low + high) * 0.5 span = float(np.max(high - low)) 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: - normal = np.array([0.0, 0.0, 1.0]) - else: - normal = sensing_surface_normal(positions_sensor_m, triangles) + raise error( + "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) + else: + normal = surface_normal_sensor normal = np.asarray(normal, dtype=np.float64) normal = normal / max(float(np.linalg.norm(normal)), 1e-15) # In-plane basis for this surface: the principal axes of the vertex cloud with @@ -1014,8 +1048,7 @@ def default_finger_presets( return ( # 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° - # away — behind the surface — for the design frame.) + # with enough tilt to show its relief. CameraPreset("default", eye((1.0 * normal + 0.6 * tangent) / np.sqrt(1.36)), tuple(centre), tuple(normal), 0.6), # 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: return np.eye(4, dtype=np.float64) diff --git a/shared/src/orisys_app_shared/scene3d/package.py b/shared/src/orisys_app_shared/scene3d/package.py index 6d7c4a5..6ce4145 100644 --- a/shared/src/orisys_app_shared/scene3d/package.py +++ b/shared/src/orisys_app_shared/scene3d/package.py @@ -787,12 +787,11 @@ def scene_from_contents( for item in manifest.get("camera_presets", []) if isinstance(item, dict) and item.get("name") ) - # A preset baked by an older compiler points down a fixed axis, which is only - # correct if the export's frame happens to match the sensor frame. The design - # export does not: its shipped ``default`` sits ~123° from the sensing surface's - # own normal, where the housing hides ~100% of the surface. If the package's - # default preset is on the far side of the surface, replace the set with one - # derived from the mesh rather than presenting a view of the back of the sensor. + # A preset is trusted only if its eye is in front of the surface. A package whose + # presets were baked without the export's frame — an older compiler, or a compilers' + # axis guess that happened to be wrong — can point at the back of the plate, where + # the housing hides all of it; the mesh's own normal then says which side the sensing + # side is, and the set is replaced rather than presented. sensing_only_for_presets = tuple(part for part in parts if part.role == "sensing") if sensing_only_for_presets: sensing_part = sensing_only_for_presets[0]