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:
@@ -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,
|
||||
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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user