state the finger 3D camera presets in the design export's frame

The default view came from the mesh's own face normal and sat 24.6 degrees off the
design export's frame; re-applying any preset also adopted its baked distance, so the
part overflowed the viewport (measured 2.16 x 2.12 of it). Presets are now the frame's
own views, every preset is reframed through the pane's existing fit, and that fit
projects the model's bounds instead of a bounding sphere. Verified: the frame axes map
to screen up, left and viewer to 1e-6, the model fills 0.60-0.95 of the viewport at two
sizes, and the recompiled package keeps its geometry, domain and source-geometry ids.
This commit is contained in:
ykcheungab
2026-10-01 16:56:36 +08:00
parent eb70ac7638
commit 9b1a3fb922
8 changed files with 843 additions and 73 deletions
@@ -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)
+121 -18
View File
@@ -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]