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:
Binary file not shown.
@@ -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)
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user