modify 3d view to sync / identical to 2d view
This commit is contained in:
Binary file not shown.
@@ -254,6 +254,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||||
|
# The island latch is *upstream of both views*: the 2D canvas paints the
|
||||||
|
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
||||||
|
# views, one decision about where the measurement is — see
|
||||||
|
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
||||||
|
self.mesh3d_panel.set_domain_latch(self._domain_mask)
|
||||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
# The charts browse the whole session, not just their rolling buffer.
|
# The charts browse the whole session, not just their rolling buffer.
|
||||||
|
|||||||
@@ -142,13 +142,22 @@ def _computed_frame_id(packet: SdkPacket, field: str, frame_id: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _device_mask(
|
def _device_mask(
|
||||||
packet: SdkPacket, shape_hw: tuple[int, int]
|
packet: SdkPacket,
|
||||||
|
shape_hw: tuple[int, int],
|
||||||
|
latch: Any = None,
|
||||||
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
|
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
|
||||||
"""The device's own validity, or a diagnostic when it describes another grid.
|
"""The device's own validity, or a diagnostic when it describes another grid.
|
||||||
|
|
||||||
Both silent choices are wrong: stretching a mask from another grid marks samples
|
Both silent choices are wrong: stretching a mask from another grid marks samples
|
||||||
valid that the device never claimed, and ignoring it *widens* the trusted area to
|
valid that the device never claimed, and ignoring it *widens* the trusted area to
|
||||||
the whole UV domain. So a mismatch refuses the frame and names both shapes.
|
the whole UV domain. So a mismatch refuses the frame and names both shapes.
|
||||||
|
|
||||||
|
``latch`` is the session's ``DomainMaskLatch`` when the window has one. The mask it
|
||||||
|
returns is the *session's* island — the same array the 2D canvas paints its island
|
||||||
|
and its "no depth" grey from — so the surface's validity texture is that island and
|
||||||
|
not a second, independently rebuilt copy of it. The latch is idempotent for a
|
||||||
|
repeated mask (an IoU of 1 is always held), which is what makes it safe for both
|
||||||
|
views to consult it from different threads and different frames.
|
||||||
"""
|
"""
|
||||||
explicit = getattr(packet, "valid_mask", None)
|
explicit = getattr(packet, "valid_mask", None)
|
||||||
if explicit is None:
|
if explicit is None:
|
||||||
@@ -161,6 +170,10 @@ def _device_mask(
|
|||||||
mask=_shape_of(array),
|
mask=_shape_of(array),
|
||||||
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}",
|
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}",
|
||||||
)
|
)
|
||||||
|
if latch is not None:
|
||||||
|
held = latch.update(array)
|
||||||
|
if held is not None:
|
||||||
|
array = np.asarray(held, dtype=bool)
|
||||||
return array, None
|
return array, None
|
||||||
|
|
||||||
|
|
||||||
@@ -224,6 +237,11 @@ class FingerFrameAdapter:
|
|||||||
#: Set (and cleared) by every snapshot: the reason the picture is not the field
|
#: Set (and cleared) by every snapshot: the reason the picture is not the field
|
||||||
#: the operator chose. ``None`` when the preferred field was the one drawn.
|
#: the operator chose. ``None`` when the preferred field was the one drawn.
|
||||||
self.last_substitution: Optional[Diagnostic] = None
|
self.last_substitution: Optional[Diagnostic] = None
|
||||||
|
#: The session's island latch (``image_backend.DomainMaskLatch``), when the
|
||||||
|
#: window has one. See ``_device_mask``: the surface's validity is the *latched*
|
||||||
|
#: island — the same mask the 2D canvas paints its island and its "no depth"
|
||||||
|
#: grey from — so the two views cannot disagree about where the measurement is.
|
||||||
|
self.domain_latch: Any = None
|
||||||
|
|
||||||
def set_preferred_field(self, name: str) -> None:
|
def set_preferred_field(self, name: str) -> None:
|
||||||
self.preferred_field = _field_name(name)
|
self.preferred_field = _field_name(name)
|
||||||
@@ -387,7 +405,7 @@ class FingerFrameAdapter:
|
|||||||
"""Intersect with the device's own mask and build the frame."""
|
"""Intersect with the device's own mask and build the frame."""
|
||||||
# Inside the UV island is the only place a value means anything: the tracker is
|
# Inside the UV island is the only place a value means anything: the tracker is
|
||||||
# looking at masked black outside it.
|
# looking at masked black outside it.
|
||||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||||
if mask_problem is not None:
|
if mask_problem is not None:
|
||||||
refused.append(mask_problem)
|
refused.append(mask_problem)
|
||||||
return None
|
return None
|
||||||
@@ -407,7 +425,7 @@ class FingerFrameAdapter:
|
|||||||
arr = np.asarray(flow, dtype=np.float32)
|
arr = np.asarray(flow, dtype=np.float32)
|
||||||
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
||||||
flow_uv = freeze(arr, dtype=np.float32)
|
flow_uv = freeze(arr, dtype=np.float32)
|
||||||
device_for_flow, _ = _device_mask(packet, self.shape_hw)
|
device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||||
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
|
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
|
||||||
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
|
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
|
||||||
if not np.isfinite(fn):
|
if not np.isfinite(fn):
|
||||||
@@ -452,6 +470,8 @@ class BoxFrameAdapter:
|
|||||||
self.field_domain_id = str(field_domain_id)
|
self.field_domain_id = str(field_domain_id)
|
||||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||||
self.last_diagnostic: Optional[Diagnostic] = None
|
self.last_diagnostic: Optional[Diagnostic] = None
|
||||||
|
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
|
||||||
|
self.domain_latch: Any = None
|
||||||
|
|
||||||
def snapshot(
|
def snapshot(
|
||||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||||
@@ -468,7 +488,7 @@ class BoxFrameAdapter:
|
|||||||
("depth", getattr(packet, "depth", None)),
|
("depth", getattr(packet, "depth", None)),
|
||||||
("flow", flow_magnitude(packet)),
|
("flow", flow_magnitude(packet)),
|
||||||
)
|
)
|
||||||
device, mask_problem = _device_mask(packet, self.shape_hw)
|
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||||
if mask_problem is not None:
|
if mask_problem is not None:
|
||||||
self.last_diagnostic = mask_problem
|
self.last_diagnostic = mask_problem
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ class ThreeDCoordinator:
|
|||||||
# A frame arrived with its own fields: whatever went stale is current
|
# A frame arrived with its own fields: whatever went stale is current
|
||||||
# again, and the grace starts over next time it fails.
|
# again, and the grace starts over next time it fails.
|
||||||
self._stale_since = None
|
self._stale_since = None
|
||||||
self._set_state("live" if composed.valid.any() else "no_data")
|
self._set_state("live" if composed.field.valid.any() else "no_data")
|
||||||
elif self._state in ("ready", "loading"):
|
elif self._state in ("ready", "loading"):
|
||||||
# Nothing has arrived yet: show the reference model rather than a blank
|
# Nothing has arrived yet: show the reference model rather than a blank
|
||||||
# window, and say that no measurement is being displayed. Only once per
|
# window, and say that no measurement is being displayed. Only once per
|
||||||
@@ -482,6 +482,19 @@ class ThreeDCoordinator:
|
|||||||
# held field frame so the operator sees the change without waiting.
|
# held field frame so the operator sees the change without waiting.
|
||||||
self._settings_dirty = True
|
self._settings_dirty = True
|
||||||
|
|
||||||
|
def set_ramp(self, ramp: str) -> None:
|
||||||
|
"""Hand the theme's colour ramp to the composer.
|
||||||
|
|
||||||
|
The composer stamps it on every ``DisplayField`` it builds, and the GL layer
|
||||||
|
builds its lookup table from the field it is handed. That is how the 3D
|
||||||
|
surface and the 2D canvas end up coloured by one table: the ramp is resolved
|
||||||
|
once, from the theme, and travels with the measurement.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._composer.ramp = str(ramp or "")
|
||||||
|
except AttributeError: # pragma: no cover - a composer stand-in without state
|
||||||
|
pass
|
||||||
|
|
||||||
def show_rest(self) -> None:
|
def show_rest(self) -> None:
|
||||||
"""Put the reference model back on screen and stop claiming a measurement.
|
"""Put the reference model back on screen and stop claiming a measurement.
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ Import discipline, and why it is not negotiable:
|
|||||||
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
|
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
|
||||||
current — resources are freed by the context that made them.
|
current — resources are freed by the context that made them.
|
||||||
|
|
||||||
Two parts, two programs, one draw call per part. The sensing surface is re-uploaded
|
Two parts, two programs, one draw call per part. The sensing surface's colour comes
|
||||||
per frame and its colour comes from ``scalar`` through a LUT texture with the range
|
from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
|
||||||
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
|
``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
|
||||||
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
|
builds an RGB array per frame and never gathers the field onto the vertices; the
|
||||||
in the neutral colour rather than in colour zero — the reference model is not a
|
vertex buffer is uploaded once and re-uploaded only when a display deformation
|
||||||
measurement and must not look like one.
|
actually moves a vertex. The housing is uploaded once and never touched again. A
|
||||||
|
fragment whose texel is invalid is drawn in the neutral colour rather than in colour
|
||||||
|
zero: the reference model is not a measurement and must not look like one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -61,6 +63,16 @@ INVALID_COLOR = (0.18, 0.18, 0.20, 1.0)
|
|||||||
#: LUT size. 256 is enough for a sequential ramp and cheap to rebuild.
|
#: LUT size. 256 is enough for a sequential ramp and cheap to rebuild.
|
||||||
LUT_SIZE = 256
|
LUT_SIZE = 256
|
||||||
|
|
||||||
|
#: The ramp used when a field does not name one. It is the dark theme's own
|
||||||
|
#: ``flow_ramp``: the 2D canvas and the 3D pane must be coloured by the same stops,
|
||||||
|
#: and a default that differed from either would be the bug this replaces.
|
||||||
|
DEFAULT_FIELD_RAMP = "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||||
|
|
||||||
|
#: Bytes per texel the GPU budget charges for the field textures: ``R16F`` scalar
|
||||||
|
#: (nominal; the ``R32F`` fallback is 4) plus the ``R8`` validity.
|
||||||
|
FIELD_BYTES_PER_TEXEL = 2
|
||||||
|
VALID_BYTES_PER_TEXEL = 1
|
||||||
|
|
||||||
|
|
||||||
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||||
"""Select the default GL surface format. Call before ``QApplication``.
|
"""Select the default GL surface format. Call before ``QApplication``.
|
||||||
@@ -112,33 +124,42 @@ def profile_from_renderable_type(value: int) -> Profile:
|
|||||||
# --------------------------------------------------------------------- shaders
|
# --------------------------------------------------------------------- shaders
|
||||||
#: Shared bodies: the version header is the only difference, so the maths cannot
|
#: Shared bodies: the version header is the only difference, so the maths cannot
|
||||||
#: drift between the desktop and the embedded build.
|
#: drift between the desktop and the embedded build.
|
||||||
|
#:
|
||||||
|
#: The surface is **texture-first**. The vertex stage carries geometry and one UV
|
||||||
|
#: pair per vertex — nothing about the measurement — and the fragment stage reads the
|
||||||
|
#: field and its validity from two textures on the domain's own grid. There used to
|
||||||
|
#: be ``a_scalar``/``a_valid`` attributes here, gathered per *vertex* on the CPU and
|
||||||
|
#: interpolated across the triangles; a vertex whose taps touched one invalid pixel
|
||||||
|
#: became ``NaN`` and the ``isnan`` rule below painted the whole triangle grey
|
||||||
|
#: (measured: 30.15 % of the island). Sampling per fragment removes the interpolation
|
||||||
|
#: step entirely: the fragment reads the same texel the 2D canvas paints at that
|
||||||
|
#: pixel, so the two views agree by construction.
|
||||||
_VERTEX_BODY = """
|
_VERTEX_BODY = """
|
||||||
layout(location = 0) in vec3 a_position;
|
layout(location = 0) in vec3 a_position;
|
||||||
layout(location = 1) in vec3 a_normal;
|
layout(location = 1) in vec3 a_normal;
|
||||||
layout(location = 2) in float a_scalar;
|
layout(location = 4) in vec2 a_uv;
|
||||||
layout(location = 3) in float a_valid;
|
|
||||||
|
|
||||||
uniform mat4 u_view;
|
uniform mat4 u_view;
|
||||||
uniform mat4 u_projection;
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
out vec3 v_normal;
|
out vec3 v_normal;
|
||||||
out float v_scalar;
|
out vec2 v_uv;
|
||||||
out float v_valid;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
v_normal = a_normal;
|
v_normal = a_normal;
|
||||||
v_scalar = a_scalar;
|
v_uv = a_uv;
|
||||||
v_valid = a_valid;
|
|
||||||
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
|
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_SCALAR_FRAGMENT_BODY = """
|
_SCALAR_FRAGMENT_BODY = """
|
||||||
in vec3 v_normal;
|
in vec3 v_normal;
|
||||||
in float v_scalar;
|
in vec2 v_uv;
|
||||||
in float v_valid;
|
|
||||||
|
|
||||||
uniform sampler2D u_lut;
|
uniform sampler2D u_lut;
|
||||||
|
uniform sampler2D u_field;
|
||||||
|
uniform sampler2D u_valid;
|
||||||
|
uniform vec2 u_inv_domain_size;
|
||||||
uniform float u_lower;
|
uniform float u_lower;
|
||||||
uniform float u_upper;
|
uniform float u_upper;
|
||||||
uniform vec4 u_invalid_color;
|
uniform vec4 u_invalid_color;
|
||||||
@@ -146,20 +167,25 @@ uniform vec4 u_invalid_color;
|
|||||||
out vec4 frag_color;
|
out vec4 frag_color;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
if (v_valid < 0.5 || isnan(v_scalar)) {
|
// ``v_uv`` is already on the domain's pixel grid (the compiler applied the field's
|
||||||
|
// own flip and scale, once, in ``legacy.uvs_to_pixels``), so the texel this
|
||||||
|
// fragment owns is the rounded pixel, centred by half a texel and normalised by
|
||||||
|
// the domain size -- the only conversion left, and it is exact rather than a
|
||||||
|
// resampling. NEAREST filtering on both textures then makes "valid" a property of
|
||||||
|
// that one texel, which is what the 2D view paints.
|
||||||
|
vec2 texel = (floor(v_uv + 0.5) + 0.5) * u_inv_domain_size;
|
||||||
|
if (texture(u_valid, texel).r < 0.5) {
|
||||||
frag_color = u_invalid_color;
|
frag_color = u_invalid_color;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
float span = max(u_upper - u_lower, 1e-12);
|
float span = max(u_upper - u_lower, 1e-12);
|
||||||
float t = clamp((v_scalar - u_lower) / span, 0.0, 1.0);
|
float t = clamp((texture(u_field, texel).r - u_lower) / span, 0.0, 1.0);
|
||||||
frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
|
frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_DIFFUSE_FRAGMENT_BODY = """
|
_DIFFUSE_FRAGMENT_BODY = """
|
||||||
in vec3 v_normal;
|
in vec3 v_normal;
|
||||||
in float v_scalar;
|
|
||||||
in float v_valid;
|
|
||||||
|
|
||||||
uniform vec3 u_light_direction;
|
uniform vec3 u_light_direction;
|
||||||
uniform vec4 u_base_color;
|
uniform vec4 u_base_color;
|
||||||
@@ -310,106 +336,134 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
|
|||||||
return lower, upper
|
return lower, upper
|
||||||
|
|
||||||
|
|
||||||
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
|
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
|
||||||
"""A 256x1 RGBA8 lookup table.
|
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
|
||||||
|
|
||||||
Hand-rolled rather than imported: the runtime must not grow a plotting
|
``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
|
||||||
dependency for one texture, and the ramp is part of what the colours *mean*.
|
(``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
|
||||||
``sequential`` is perceptually increasing in lightness (safe for magnitude);
|
which is the dark theme's own. Either way the table is the *same* array the 2D
|
||||||
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a
|
canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
|
||||||
signed quantity, and never used for an unsigned one).
|
interpolation and the single rounding, so the two legends and the two pictures
|
||||||
|
cannot disagree.
|
||||||
|
|
||||||
|
That was not true before: this function had its own hand-written stop list
|
||||||
|
(a viridis-like ramp) while the 2D side used the theme's ``flow_ramp``, and the
|
||||||
|
two disagreed by up to 218 of 255 per channel. A measurement drawn purple in one
|
||||||
|
pane and navy in the other is two different measurements as far as an operator is
|
||||||
|
concerned.
|
||||||
|
|
||||||
|
``diverging`` is the same ramp mirrored through its midpoint: safe for a signed
|
||||||
|
quantity, and never used for an unsigned one.
|
||||||
"""
|
"""
|
||||||
stops = np.array(
|
from orisys_app_shared.acquisition.arrows import ramp_rgba_table
|
||||||
[
|
|
||||||
[0.267, 0.005, 0.329],
|
return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
|
||||||
[0.283, 0.141, 0.458],
|
|
||||||
[0.254, 0.265, 0.530],
|
|
||||||
[0.207, 0.372, 0.553],
|
|
||||||
[0.164, 0.471, 0.558],
|
|
||||||
[0.128, 0.567, 0.551],
|
|
||||||
[0.135, 0.659, 0.518],
|
|
||||||
[0.267, 0.749, 0.441],
|
|
||||||
[0.478, 0.821, 0.318],
|
|
||||||
[0.741, 0.873, 0.150],
|
|
||||||
[0.993, 0.906, 0.144],
|
|
||||||
],
|
|
||||||
dtype=np.float32,
|
|
||||||
)
|
|
||||||
if lut_id == "diverging":
|
|
||||||
stops = np.concatenate([stops[::-1], stops], axis=0)
|
|
||||||
positions = np.linspace(0.0, 1.0, stops.shape[0], dtype=np.float32)
|
|
||||||
grid = np.linspace(0.0, 1.0, LUT_SIZE, dtype=np.float32)
|
|
||||||
rgba = np.empty((LUT_SIZE, 1, 4), dtype=np.uint8)
|
|
||||||
for channel in range(3):
|
|
||||||
rgba[:, 0, channel] = np.clip(
|
|
||||||
np.interp(grid, positions, stops[:, channel]) * 255.0, 0, 255
|
|
||||||
).astype(np.uint8)
|
|
||||||
rgba[:, 0, 3] = 255
|
|
||||||
return rgba
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- vertex packing
|
# ---------------------------------------------------------------- vertex packing
|
||||||
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1)
|
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
|
||||||
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData.
|
#: buffer per part, uploaded once. The stride is the same 8 floats it was when it
|
||||||
|
#: carried a per-vertex scalar and validity — the measurement moved to its own
|
||||||
|
#: texture, and the UV that addresses it took their place.
|
||||||
STRIDE_FLOATS = 8
|
STRIDE_FLOATS = 8
|
||||||
|
|
||||||
|
#: Where in a vertex row the UV starts.
|
||||||
|
UV_OFFSET_FLOATS = 6
|
||||||
|
|
||||||
def pack_part(part: MeshPart, *, scalar: Optional[np.ndarray] = None,
|
#: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
|
||||||
valid: Optional[np.ndarray] = None) -> np.ndarray:
|
#: driver cannot alias them.
|
||||||
|
LUT_TEXTURE_UNIT = 0
|
||||||
|
FIELD_TEXTURE_UNIT = 1
|
||||||
|
VALID_TEXTURE_UNIT = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_edges(values: np.ndarray, usable: np.ndarray) -> np.ndarray:
|
||||||
|
"""Every unusable texel's one-texel edge extension: the mean of its usable neighbours.
|
||||||
|
|
||||||
|
Slicing rather than ``np.roll``: a wrapped neighbour would carry a measurement in
|
||||||
|
from the opposite edge of the domain, which is a value from somewhere else entirely.
|
||||||
|
A texel with no usable neighbour — the whole field is a hole — keeps 0.0, and the
|
||||||
|
validity channel is what decides whether anything is drawn there.
|
||||||
|
"""
|
||||||
|
height, width = values.shape
|
||||||
|
total = np.zeros_like(values)
|
||||||
|
counts = np.zeros_like(values)
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
if dy == 0 and dx == 0:
|
||||||
|
continue
|
||||||
|
source_y = slice(max(dy, 0), height + min(dy, 0))
|
||||||
|
source_x = slice(max(dx, 0), width + min(dx, 0))
|
||||||
|
target_y = slice(max(-dy, 0), height + min(-dy, 0))
|
||||||
|
target_x = slice(max(-dx, 0), width + min(-dx, 0))
|
||||||
|
neighbour_values = values[source_y, source_x]
|
||||||
|
neighbour_usable = usable[source_y, source_x]
|
||||||
|
total[target_y, target_x] += np.where(neighbour_usable, neighbour_values, 0.0)
|
||||||
|
counts[target_y, target_x] += neighbour_usable
|
||||||
|
filled = np.divide(total, counts, out=np.zeros_like(values), where=counts > 0)
|
||||||
|
return np.where(usable, values, filled)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_part(part: MeshPart, *, uvs: Optional[np.ndarray] = None) -> np.ndarray:
|
||||||
"""Interleave one part into the layout the shaders read.
|
"""Interleave one part into the layout the shaders read.
|
||||||
|
|
||||||
The static case (no scalar yet) marks every vertex invalid, which the shader
|
``uvs`` are the render-vertex UVs on the domain's pixel grid
|
||||||
draws as the neutral colour: a scene that has never received a measurement
|
(``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
|
||||||
must not be coloured as if it had.
|
with no UV mapping (the housing) is never coloured by the measurement, so it gets
|
||||||
|
zeros and its own diffuse program; the attribute exists in the shared layout but
|
||||||
|
nothing reads it there.
|
||||||
"""
|
"""
|
||||||
count = int(part.positions_sensor_m.shape[0])
|
count = int(part.positions_sensor_m.shape[0])
|
||||||
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
|
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
|
||||||
rows[:, 0:3] = np.asarray(part.positions_sensor_m, dtype=np.float32)
|
rows[:, 0:3] = np.asarray(part.positions_sensor_m, dtype=np.float32)
|
||||||
rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
|
rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
|
||||||
if scalar is None:
|
if uvs is None:
|
||||||
rows[:, 6] = np.nan
|
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
|
||||||
else:
|
else:
|
||||||
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
|
values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
|
||||||
if values.size != count:
|
if values.shape[0] != count:
|
||||||
raise error(
|
raise error(
|
||||||
"geometry_mismatch",
|
"geometry_mismatch",
|
||||||
f"{part.part_id}: {values.size} scalars for {count} vertices",
|
f"{part.part_id}: {values.shape[0]} UVs for {count} vertices",
|
||||||
)
|
)
|
||||||
rows[:, 6] = values
|
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = values
|
||||||
if valid is None:
|
|
||||||
rows[:, 7] = 0.0
|
|
||||||
else:
|
|
||||||
flags = np.asarray(valid, dtype=bool).reshape(-1)
|
|
||||||
if flags.size != count:
|
|
||||||
raise error(
|
|
||||||
"geometry_mismatch",
|
|
||||||
f"{part.part_id}: {flags.size} validity flags for {count} vertices",
|
|
||||||
)
|
|
||||||
rows[:, 7] = flags.astype(np.float32)
|
|
||||||
return freeze(rows, dtype=np.float32)
|
return freeze(rows, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
def pack_scene_part(part: MeshPart) -> tuple[np.ndarray, np.ndarray]:
|
def pack_scene_part(
|
||||||
|
part: MeshPart, *, uvs: Optional[np.ndarray] = None
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
|
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
|
||||||
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
|
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
|
||||||
return pack_part(part), freeze(indices, dtype=np.uint32)
|
return pack_part(part, uvs=uvs), freeze(indices, dtype=np.uint32)
|
||||||
|
|
||||||
|
|
||||||
def estimate_gpu_bytes(assets: SceneAssets, *, dynamic_part_id: str) -> int:
|
def estimate_gpu_bytes(assets: SceneAssets, *, dynamic_part_id: str) -> int:
|
||||||
"""A rough static-plus-one-dynamic budget, for the stats line."""
|
"""A rough static-plus-one-dynamic budget, for the stats line.
|
||||||
|
|
||||||
|
The two field textures are part of it now: the scalar as 16-bit floats and the
|
||||||
|
validity as one byte per texel, over the scene's own UV domain. They are counted
|
||||||
|
at their nominal size (``R16F``), and the ``R32F`` fallback a driver without
|
||||||
|
``R16F`` forces is twice the scalar half — noted rather than guessed at, because
|
||||||
|
the budget is a *report*, and the code that picks the format is in
|
||||||
|
:meth:`GLRenderer._ensure_field_textures`.
|
||||||
|
"""
|
||||||
total = 0
|
total = 0
|
||||||
for part in assets.parts:
|
for part in assets.parts:
|
||||||
vertices = int(part.positions_sensor_m.shape[0])
|
vertices = int(part.positions_sensor_m.shape[0])
|
||||||
indices = int(part.triangles.size)
|
indices = int(part.triangles.size)
|
||||||
total += vertices * STRIDE_FLOATS * 4 + indices * 4
|
total += vertices * STRIDE_FLOATS * 4 + indices * 4
|
||||||
if part.part_id == dynamic_part_id:
|
if part.part_id == dynamic_part_id:
|
||||||
# A second copy for the frame in flight.
|
# A second copy for the frame in flight. Only ever re-written when a display
|
||||||
|
# deformation actually moves the vertices (``RenderFrame.deformed``).
|
||||||
total += vertices * STRIDE_FLOATS * 4
|
total += vertices * STRIDE_FLOATS * 4
|
||||||
total += LUT_SIZE * 4
|
total += LUT_SIZE * 4
|
||||||
|
height, width = int(assets.shape_hw[0]), int(assets.shape_hw[1])
|
||||||
|
total += max(height, 0) * max(width, 0) * (FIELD_BYTES_PER_TEXEL + VALID_BYTES_PER_TEXEL)
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
def rest_surface(assets: SceneAssets, identity: Any, *, ramp: str = "") -> RenderFrame:
|
||||||
"""The asset's own geometry, with nothing measured.
|
"""The asset's own geometry, with nothing measured.
|
||||||
|
|
||||||
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
||||||
@@ -419,7 +473,7 @@ def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
|||||||
"""
|
"""
|
||||||
from orisys_app_shared.scene3d.composer import build_rest_frame
|
from orisys_app_shared.scene3d.composer import build_rest_frame
|
||||||
|
|
||||||
return build_rest_frame(assets, identity)
|
return build_rest_frame(assets, identity, ramp=ramp)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- dispatch
|
# ------------------------------------------------------------------- dispatch
|
||||||
@@ -543,9 +597,13 @@ def _leading_int(text: str) -> int:
|
|||||||
class _PartBuffers:
|
class _PartBuffers:
|
||||||
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
|
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
|
||||||
|
|
||||||
__slots__ = ("part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices")
|
__slots__ = (
|
||||||
|
"part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices", "rows"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, part: MeshPart, vao: int, vbo: int, ebo: int) -> None:
|
def __init__(
|
||||||
|
self, part: MeshPart, vao: int, vbo: int, ebo: int, rows: np.ndarray
|
||||||
|
) -> None:
|
||||||
self.part_id = part.part_id
|
self.part_id = part.part_id
|
||||||
self.role = part.role
|
self.role = part.role
|
||||||
self.vao = vao
|
self.vao = vao
|
||||||
@@ -553,6 +611,9 @@ class _PartBuffers:
|
|||||||
self.ebo = ebo
|
self.ebo = ebo
|
||||||
self.triangles = int(part.triangles.shape[0])
|
self.triangles = int(part.triangles.shape[0])
|
||||||
self.vertices = int(part.positions_sensor_m.shape[0])
|
self.vertices = int(part.positions_sensor_m.shape[0])
|
||||||
|
#: The interleaved rows as uploaded. A display deformation rewrites positions
|
||||||
|
#: and normals into a copy of this; nothing else ever re-uploads a vertex.
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
|
||||||
class GLRenderer:
|
class GLRenderer:
|
||||||
@@ -565,7 +626,15 @@ class GLRenderer:
|
|||||||
self._parts: dict[str, _PartBuffers] = {}
|
self._parts: dict[str, _PartBuffers] = {}
|
||||||
self._lut: int = 0
|
self._lut: int = 0
|
||||||
self._lut_id: str = ""
|
self._lut_id: str = ""
|
||||||
#: Range of the valid samples of the frame currently uploaded, for the
|
#: The ramp the LUT was built from, so a theme change rebuilds it once.
|
||||||
|
self._lut_ramp: str = ""
|
||||||
|
#: The two field textures. Created once, sub-uploaded per frame: no per-frame
|
||||||
|
#: allocation and no per-frame vertex traffic.
|
||||||
|
self._field_texture: int = 0
|
||||||
|
self._valid_texture: int = 0
|
||||||
|
self._field_shape: Optional[tuple[int, int]] = None
|
||||||
|
self._field_internal_format: int = 0
|
||||||
|
#: Range of the valid samples of the field currently uploaded, for the
|
||||||
#: automatic colour scale. ``None`` until a frame carries valid data.
|
#: automatic colour scale. ``None`` until a frame carries valid data.
|
||||||
self._scalar_range: Optional[tuple[float, float]] = None
|
self._scalar_range: Optional[tuple[float, float]] = None
|
||||||
#: The colour bounds of the last draw and how they were chosen.
|
#: The colour bounds of the last draw and how they were chosen.
|
||||||
@@ -621,8 +690,11 @@ class GLRenderer:
|
|||||||
self._scalar_range = None
|
self._scalar_range = None
|
||||||
self._last_scale = None
|
self._last_scale = None
|
||||||
self._last_scale_mode = ""
|
self._last_scale_mode = ""
|
||||||
|
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
|
||||||
for part in assets.parts:
|
for part in assets.parts:
|
||||||
vertices, indices = pack_scene_part(part)
|
vertices, indices = pack_scene_part(
|
||||||
|
part, uvs=uvs if part.role == "sensing" else None
|
||||||
|
)
|
||||||
vao = int(GL.glGenVertexArrays(1))
|
vao = int(GL.glGenVertexArrays(1))
|
||||||
vbo = int(GL.glGenBuffers(1))
|
vbo = int(GL.glGenBuffers(1))
|
||||||
ebo = int(GL.glGenBuffers(1))
|
ebo = int(GL.glGenBuffers(1))
|
||||||
@@ -639,13 +711,24 @@ class GLRenderer:
|
|||||||
GL.glBindVertexArray(0)
|
GL.glBindVertexArray(0)
|
||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||||
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
|
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
|
||||||
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo)
|
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo, vertices)
|
||||||
self._estimated_gpu_bytes = estimate_gpu_bytes(
|
self._estimated_gpu_bytes = estimate_gpu_bytes(
|
||||||
assets, dynamic_part_id=self._sensing_part_id
|
assets, dynamic_part_id=self._sensing_part_id
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_surface(self, frame: RenderFrame) -> None:
|
def update_field(self, frame: RenderFrame) -> None:
|
||||||
"""Update the sensing part's vertices in place: no index rebuild, ever."""
|
"""Upload this frame's field as two textures. No vertex traffic.
|
||||||
|
|
||||||
|
This replaced ``update_surface``, which interleaved a per-vertex scalar and
|
||||||
|
validity flag and pushed the whole vertex buffer through
|
||||||
|
``glBufferSubData`` on every frame. The measurement no longer has anything to
|
||||||
|
do with the vertices: the field goes into two textures on the domain's own
|
||||||
|
grid and the fragment reads it through the UV that was already in the buffer.
|
||||||
|
|
||||||
|
A *display deformation* still moves vertices, so that case — and only that
|
||||||
|
case, ``frame.deformed`` — re-uploads the sensing part's rows. No adapter in
|
||||||
|
this build publishes a displacement, so the application never takes it.
|
||||||
|
"""
|
||||||
from OpenGL import GL
|
from OpenGL import GL
|
||||||
|
|
||||||
self._require_initialized()
|
self._require_initialized()
|
||||||
@@ -655,26 +738,132 @@ class GLRenderer:
|
|||||||
"geometry_mismatch",
|
"geometry_mismatch",
|
||||||
f"the scene has no part {frame.sensing_part_id!r} to update",
|
f"the scene has no part {frame.sensing_part_id!r} to update",
|
||||||
)
|
)
|
||||||
rows = pack_part(
|
field = frame.field
|
||||||
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor),
|
if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
|
||||||
scalar=frame.scalar,
|
|
||||||
valid=frame.valid,
|
|
||||||
)
|
|
||||||
if int(rows.shape[0]) != buffers.vertices:
|
|
||||||
raise error(
|
raise error(
|
||||||
"geometry_mismatch",
|
"geometry_mismatch",
|
||||||
f"{buffers.part_id}: {rows.shape[0]} vertices for "
|
f"{buffers.part_id}: {frame.positions_sensor_m.shape[0]} vertices for "
|
||||||
f"{buffers.vertices} uploaded",
|
f"{buffers.vertices} uploaded",
|
||||||
)
|
)
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
# The field carries the theme's ramp, so the lookup table follows the same
|
||||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
# string the 2D canvas built its table from. Checked here rather than plumbed
|
||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
# through the view: whatever field this renderer is handed is what it draws.
|
||||||
|
self.set_ramp(getattr(field, "ramp", "") or "")
|
||||||
|
if frame.deformed:
|
||||||
|
rows = np.array(buffers.rows, copy=True)
|
||||||
|
rows[:, 0:3] = np.asarray(frame.positions_sensor_m, dtype=np.float32)
|
||||||
|
rows[:, 3:6] = np.asarray(frame.normals_sensor, dtype=np.float32)
|
||||||
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
|
||||||
|
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||||
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||||
|
self._upload_field_textures(field)
|
||||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
||||||
measured = _valid_scalar_range(frame.scalar, frame.valid)
|
measured = _valid_scalar_range(field.scalar, field.valid)
|
||||||
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
||||||
self._upload_overlays(frame)
|
self._upload_overlays(frame)
|
||||||
|
|
||||||
|
def _upload_field_textures(self, field: Any) -> None:
|
||||||
|
"""``glTexSubImage2D`` of the scalar and validity channels.
|
||||||
|
|
||||||
|
The scalar is ``R16F`` (``R32F`` when the driver will not take a half float) and
|
||||||
|
is filtered linearly, so the picture is smooth rather than blocky when the plate
|
||||||
|
is magnified. The validity is ``R8`` with ``NEAREST`` filtering, because a
|
||||||
|
validity that a linear filter could blend is exactly the "partly valid triangle"
|
||||||
|
that used to be painted grey.
|
||||||
|
|
||||||
|
**A texel with no measurement must not contribute to a filtered sample.** Linear
|
||||||
|
filtering reaches one texel in each direction, so every invalid texel adjacent to
|
||||||
|
the island is given the mean of its valid neighbours (a one-texel edge extension).
|
||||||
|
Without it a valid fragment on the island's rim blended its own measurement with
|
||||||
|
a placeholder's zero and was drawn in a colour that is neither the 2D pixel's nor
|
||||||
|
the invalid colour — measured on the shipped asset as a texel painted both valid
|
||||||
|
and not, depending on which fragment landed on it. One texel of margin is exactly
|
||||||
|
enough for a 2x2 filter, and the validity channel is untouched, so the painted
|
||||||
|
*region* is still decided per texel.
|
||||||
|
"""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
shape = (int(field.shape_hw[0]), int(field.shape_hw[1]))
|
||||||
|
self._ensure_field_textures(shape)
|
||||||
|
measured = np.asarray(field.scalar, dtype=np.float32)
|
||||||
|
usable = np.asarray(field.valid, dtype=bool) & np.isfinite(measured)
|
||||||
|
scalars = np.where(usable, measured, 0.0).astype(np.float32)
|
||||||
|
if usable.any() and not usable.all():
|
||||||
|
scalars = np.where(usable, scalars, _extend_edges(scalars, usable))
|
||||||
|
valid = np.ascontiguousarray(np.asarray(field.valid, dtype=bool).astype(np.uint8) * 255)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||||
|
GL.glTexSubImage2D(
|
||||||
|
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
|
||||||
|
GL.GL_RED, GL.GL_FLOAT, np.ascontiguousarray(scalars),
|
||||||
|
)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||||
|
GL.glTexSubImage2D(
|
||||||
|
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
|
||||||
|
GL.GL_RED, GL.GL_UNSIGNED_BYTE, valid,
|
||||||
|
)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||||
|
|
||||||
|
def _ensure_field_textures(self, shape: tuple[int, int]) -> None:
|
||||||
|
"""Allocate the two field textures once, for one domain shape."""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
if self._field_texture and self._field_shape == shape:
|
||||||
|
return
|
||||||
|
self._release_field_textures()
|
||||||
|
height, width = int(shape[0]), int(shape[1])
|
||||||
|
if height <= 0 or width <= 0:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch", f"the field has no extent: {height}x{width}"
|
||||||
|
)
|
||||||
|
capabilities = self._capabilities
|
||||||
|
if capabilities is not None and capabilities.max_texture_size:
|
||||||
|
if width > capabilities.max_texture_size or height > capabilities.max_texture_size:
|
||||||
|
raise error(
|
||||||
|
"gpu_budget_exceeded",
|
||||||
|
f"the field is {width}x{height} but this GPU takes at most "
|
||||||
|
f"{capabilities.max_texture_size} texels per side",
|
||||||
|
)
|
||||||
|
self._field_texture = int(GL.glGenTextures(1))
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
|
||||||
|
# ``R16F`` is the size the design asks for and every 3.3/ES3 context has it;
|
||||||
|
# a driver that refuses it raises, and ``R32F`` is the documented fallback
|
||||||
|
# rather than a silent no-texture render.
|
||||||
|
for internal_format in (GL.GL_R16F, GL.GL_R32F):
|
||||||
|
try:
|
||||||
|
GL.glTexImage2D(
|
||||||
|
GL.GL_TEXTURE_2D, 0, internal_format, width, height, 0,
|
||||||
|
GL.GL_RED, GL.GL_FLOAT, None,
|
||||||
|
)
|
||||||
|
self._field_internal_format = internal_format
|
||||||
|
break
|
||||||
|
except Exception: # noqa: BLE001 - a refused internal format
|
||||||
|
while GL.glGetError() != GL.GL_NO_ERROR:
|
||||||
|
pass
|
||||||
|
if not self._field_internal_format:
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||||
|
self._release_field_textures()
|
||||||
|
raise error(
|
||||||
|
"gl_unsupported",
|
||||||
|
"this context accepts neither R16F nor R32F for the field texture",
|
||||||
|
)
|
||||||
|
self._valid_texture = int(GL.glGenTextures(1))
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
|
||||||
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
|
||||||
|
GL.glTexImage2D(
|
||||||
|
GL.GL_TEXTURE_2D, 0, GL.GL_R8, width, height, 0,
|
||||||
|
GL.GL_RED, GL.GL_UNSIGNED_BYTE, None,
|
||||||
|
)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||||
|
self._field_shape = (height, width)
|
||||||
|
|
||||||
def _upload_overlays(self, frame: RenderFrame) -> None:
|
def _upload_overlays(self, frame: RenderFrame) -> None:
|
||||||
"""Upload merged flow / contact arrow meshes for this frame."""
|
"""Upload merged flow / contact arrow meshes for this frame."""
|
||||||
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
|
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
|
||||||
@@ -768,7 +957,9 @@ class GLRenderer:
|
|||||||
# panel has to be able to ask what the picture means.
|
# panel has to be able to ask what the picture means.
|
||||||
self._last_scale = (float(lower), float(upper))
|
self._last_scale = (float(lower), float(upper))
|
||||||
self._last_scale_mode = str(getattr(scale, "mode", "") or "")
|
self._last_scale_mode = str(getattr(scale, "mode", "") or "")
|
||||||
self._ensure_lut("diverging" if (scale and scale.descriptor.signed) else "sequential")
|
self._ensure_lut(
|
||||||
|
"diverging" if (scale and scale.descriptor.signed) else "sequential"
|
||||||
|
)
|
||||||
for part in self._parts.values():
|
for part in self._parts.values():
|
||||||
if part.role == "housing" and not settings.housing_visible:
|
if part.role == "housing" and not settings.housing_visible:
|
||||||
continue
|
continue
|
||||||
@@ -781,10 +972,7 @@ class GLRenderer:
|
|||||||
_set_float(GL, locations.get("u_lower"), lower)
|
_set_float(GL, locations.get("u_lower"), lower)
|
||||||
_set_float(GL, locations.get("u_upper"), upper)
|
_set_float(GL, locations.get("u_upper"), upper)
|
||||||
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
|
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
|
||||||
if locations.get("u_lut") is not None:
|
self._bind_field_textures(GL, locations)
|
||||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
|
||||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
|
||||||
GL.glUniform1i(locations["u_lut"], 0)
|
|
||||||
else:
|
else:
|
||||||
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8))
|
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8))
|
||||||
_set_vec4(GL, locations.get("u_base_color"), _base_color(part.part_id))
|
_set_vec4(GL, locations.get("u_base_color"), _base_color(part.part_id))
|
||||||
@@ -837,6 +1025,49 @@ class GLRenderer:
|
|||||||
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
||||||
return self._last_scale
|
return self._last_scale
|
||||||
|
|
||||||
|
def set_ramp(self, ramp: str) -> None:
|
||||||
|
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
|
||||||
|
|
||||||
|
The ramp is the *only* thing the 3D pane needs from the theme: it is what
|
||||||
|
makes its colour table the same table the 2D canvas paints with.
|
||||||
|
"""
|
||||||
|
wanted = str(ramp or "")
|
||||||
|
if wanted != self._lut_ramp:
|
||||||
|
self._lut_ramp = wanted
|
||||||
|
self._lut_id = ""
|
||||||
|
|
||||||
|
def _bind_field_textures(self, GL: Any, locations: dict[str, int]) -> None:
|
||||||
|
if locations.get("u_lut") is not None:
|
||||||
|
GL.glActiveTexture(GL.GL_TEXTURE0 + LUT_TEXTURE_UNIT)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||||
|
GL.glUniform1i(locations["u_lut"], LUT_TEXTURE_UNIT)
|
||||||
|
if locations.get("u_field") is not None:
|
||||||
|
GL.glActiveTexture(GL.GL_TEXTURE0 + FIELD_TEXTURE_UNIT)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
|
||||||
|
GL.glUniform1i(locations["u_field"], FIELD_TEXTURE_UNIT)
|
||||||
|
if locations.get("u_valid") is not None:
|
||||||
|
GL.glActiveTexture(GL.GL_TEXTURE0 + VALID_TEXTURE_UNIT)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
|
||||||
|
GL.glUniform1i(locations["u_valid"], VALID_TEXTURE_UNIT)
|
||||||
|
if locations.get("u_inv_domain_size") is not None:
|
||||||
|
height, width = self._field_shape or (1, 1)
|
||||||
|
GL.glUniform2f(
|
||||||
|
locations["u_inv_domain_size"],
|
||||||
|
1.0 / max(float(width), 1.0),
|
||||||
|
1.0 / max(float(height), 1.0),
|
||||||
|
)
|
||||||
|
GL.glActiveTexture(GL.GL_TEXTURE0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_shape(self) -> Optional[tuple[int, int]]:
|
||||||
|
"""The allocated field texture's ``(H, W)``, or ``None`` before the first frame."""
|
||||||
|
return self._field_shape
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_internal_format(self) -> int:
|
||||||
|
"""The internal format the scalar texture actually got (``R16F`` or ``R32F``)."""
|
||||||
|
return self._field_internal_format
|
||||||
|
|
||||||
def release(self) -> None:
|
def release(self) -> None:
|
||||||
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
||||||
from OpenGL import GL
|
from OpenGL import GL
|
||||||
@@ -858,6 +1089,7 @@ class GLRenderer:
|
|||||||
pass
|
pass
|
||||||
self._lut = 0
|
self._lut = 0
|
||||||
self._lut_id = ""
|
self._lut_id = ""
|
||||||
|
self._release_field_textures()
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
self._estimated_gpu_bytes = 0
|
self._estimated_gpu_bytes = 0
|
||||||
self._scalar_range = None
|
self._scalar_range = None
|
||||||
@@ -865,6 +1097,20 @@ class GLRenderer:
|
|||||||
self._last_scale_mode = ""
|
self._last_scale_mode = ""
|
||||||
self._paint_calls = 0
|
self._paint_calls = 0
|
||||||
|
|
||||||
|
def _release_field_textures(self) -> None:
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
for name in ("_field_texture", "_valid_texture"):
|
||||||
|
handle = int(getattr(self, name))
|
||||||
|
if handle:
|
||||||
|
try:
|
||||||
|
GL.glDeleteTextures([handle])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
setattr(self, name, 0)
|
||||||
|
self._field_shape = None
|
||||||
|
self._field_internal_format = 0
|
||||||
|
|
||||||
def release_parts(self) -> None:
|
def release_parts(self) -> None:
|
||||||
from OpenGL import GL
|
from OpenGL import GL
|
||||||
|
|
||||||
@@ -890,7 +1136,7 @@ class GLRenderer:
|
|||||||
if self._lut:
|
if self._lut:
|
||||||
GL.glDeleteTextures([int(self._lut)])
|
GL.glDeleteTextures([int(self._lut)])
|
||||||
self._lut = 0
|
self._lut = 0
|
||||||
data = lut_rgba(lut_id)
|
data = lut_rgba(lut_id, self._lut_ramp)
|
||||||
self._lut = int(GL.glGenTextures(1))
|
self._lut = int(GL.glGenTextures(1))
|
||||||
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
|
||||||
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
|
||||||
@@ -916,17 +1162,6 @@ class GLRenderer:
|
|||||||
return self._initialized
|
return self._initialized
|
||||||
|
|
||||||
|
|
||||||
class _PartLayout:
|
|
||||||
"""A minimal stand-in for :class:`MeshPart` when updating an existing buffer."""
|
|
||||||
|
|
||||||
__slots__ = ("part_id", "positions_sensor_m", "normals_sensor")
|
|
||||||
|
|
||||||
def __init__(self, part_id: str, positions: np.ndarray, normals: np.ndarray) -> None:
|
|
||||||
self.part_id = part_id
|
|
||||||
self.positions_sensor_m = positions
|
|
||||||
self.normals_sensor = normals
|
|
||||||
|
|
||||||
|
|
||||||
#: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
|
#: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
|
||||||
#: it never competes with the measurement colour scale.
|
#: it never competes with the measurement colour scale.
|
||||||
_PART_COLORS: dict[str, tuple[float, float, float, float]] = {
|
_PART_COLORS: dict[str, tuple[float, float, float, float]] = {
|
||||||
@@ -939,15 +1174,23 @@ def _base_color(part_id: str) -> tuple[float, float, float, float]:
|
|||||||
|
|
||||||
|
|
||||||
def _bind_attributes(GL: Any) -> None:
|
def _bind_attributes(GL: Any) -> None:
|
||||||
|
"""Position, normal and the UV the field texture is addressed by.
|
||||||
|
|
||||||
|
Attribute locations 2 and 3 are deliberately left unbound: they were the
|
||||||
|
per-vertex scalar and validity flags, and nothing in the surface program declares
|
||||||
|
them any more. Re-using 2 or 3 for something else later would be a silent
|
||||||
|
reinterpretation of a slot an old shader variant could still expect, so the UV
|
||||||
|
goes to 4 instead.
|
||||||
|
"""
|
||||||
stride = STRIDE_FLOATS * 4
|
stride = STRIDE_FLOATS * 4
|
||||||
GL.glEnableVertexAttribArray(0)
|
GL.glEnableVertexAttribArray(0)
|
||||||
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
|
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
|
||||||
GL.glEnableVertexAttribArray(1)
|
GL.glEnableVertexAttribArray(1)
|
||||||
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
|
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
|
||||||
GL.glEnableVertexAttribArray(2)
|
GL.glEnableVertexAttribArray(4)
|
||||||
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6))
|
GL.glVertexAttribPointer(
|
||||||
GL.glEnableVertexAttribArray(3)
|
4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
|
||||||
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7))
|
)
|
||||||
|
|
||||||
|
|
||||||
def _offset(floats: int) -> Any:
|
def _offset(floats: int) -> Any:
|
||||||
@@ -985,8 +1228,9 @@ def _compile_program(GL: Any, profile: Profile, name: str) -> int:
|
|||||||
|
|
||||||
def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
|
def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
|
||||||
names = (
|
names = (
|
||||||
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper",
|
"u_view", "u_projection", "u_lower", "u_upper",
|
||||||
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color",
|
"u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
|
||||||
|
"u_light_direction", "u_base_color",
|
||||||
)
|
)
|
||||||
out: dict[str, int] = {}
|
out: dict[str, int] = {}
|
||||||
for name in names:
|
for name in names:
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ class ColorScaleBar(QLabel):
|
|||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
self._lut_id = "sequential"
|
self._lut_id = "sequential"
|
||||||
|
#: The theme's own ramp, so this legend is drawn from the same table the 2D
|
||||||
|
#: canvas and the 3D surface are coloured by.
|
||||||
|
self._ramp = ""
|
||||||
|
|
||||||
def set_lut(self, lut_id: str) -> None:
|
def set_lut(self, lut_id: str) -> None:
|
||||||
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
|
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
|
||||||
@@ -98,6 +101,13 @@ class ColorScaleBar(QLabel):
|
|||||||
self._lut_id = wanted
|
self._lut_id = wanted
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def set_ramp(self, ramp: str) -> None:
|
||||||
|
wanted = str(ramp or "")
|
||||||
|
if wanted == self._ramp:
|
||||||
|
return
|
||||||
|
self._ramp = wanted
|
||||||
|
self.update()
|
||||||
|
|
||||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
from PySide6.QtGui import QPainter
|
from PySide6.QtGui import QPainter
|
||||||
|
|
||||||
@@ -105,7 +115,9 @@ class ColorScaleBar(QLabel):
|
|||||||
|
|
||||||
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
||||||
# which is what QImage's borrowed buffer needs.
|
# which is what QImage's borrowed buffer needs.
|
||||||
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
|
rgba = np.ascontiguousarray(
|
||||||
|
lut_rgba(self._lut_id, self._ramp)[:, 0, :]
|
||||||
|
).tobytes()
|
||||||
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
|
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.drawImage(self.rect(), image)
|
painter.drawImage(self.rect(), image)
|
||||||
@@ -393,6 +405,18 @@ class ThreeDPanel(QFrame):
|
|||||||
self._render_scale_bounds()
|
self._render_scale_bounds()
|
||||||
self._render_legend()
|
self._render_legend()
|
||||||
|
|
||||||
|
def set_ramp(self, ramp: str) -> None:
|
||||||
|
"""Adopt the theme's resolved colour ramp for the legend.
|
||||||
|
|
||||||
|
The legend and the picture are drawn from one table
|
||||||
|
(``gl_backend.lut_rgba``), and that table is built from this ramp — the same
|
||||||
|
string the 2D canvas colours its pixels from. Without this the 3D pane's
|
||||||
|
legend would keep the default theme's colours after a theme switch while the
|
||||||
|
surface (which gets the ramp on the frame it is handed) changed, which is the
|
||||||
|
disagreement the one-ramp change exists to remove.
|
||||||
|
"""
|
||||||
|
self.scale_bar.set_ramp(ramp)
|
||||||
|
|
||||||
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
||||||
"""Offer only the controls this data source can actually feed.
|
"""Offer only the controls this data source can actually feed.
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ class Scene3DView(QWidget):
|
|||||||
self._camera = OrbitCamera()
|
self._camera = OrbitCamera()
|
||||||
self._executor: Optional[ThreadPoolExecutor] = None
|
self._executor: Optional[ThreadPoolExecutor] = None
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: Optional[SceneAssets] = None
|
||||||
|
self._composer: Optional[SceneComposer] = None
|
||||||
|
#: The session's island latch, shared with the 2D canvas when the window hands
|
||||||
|
#: one over (``image_backend.DomainMaskLatch``). One latch, two views: the
|
||||||
|
#: surface's validity texture and the canvas's island are the same mask, so
|
||||||
|
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
|
||||||
|
#: each frame's own mask, which is what the tests and the PoC want.
|
||||||
|
self._domain_latch: Any = None
|
||||||
self._expected: Optional[ExpectedGeometry] = None
|
self._expected: Optional[ExpectedGeometry] = None
|
||||||
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
|
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
|
||||||
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
||||||
@@ -136,8 +143,9 @@ class Scene3DView(QWidget):
|
|||||||
|
|
||||||
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
|
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
|
||||||
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
|
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
|
||||||
|
self._composer = SceneComposer()
|
||||||
self._coordinator = ThreeDCoordinator(
|
self._coordinator = ThreeDCoordinator(
|
||||||
self._widget, SceneComposer(), self._executor, max_fps=30
|
self._widget, self._composer, self._executor, max_fps=30
|
||||||
)
|
)
|
||||||
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
||||||
# The picture must not be squeezed out of existence by the controls above it:
|
# The picture must not be squeezed out of existence by the controls above it:
|
||||||
@@ -253,8 +261,7 @@ class Scene3DView(QWidget):
|
|||||||
asset_id = str(getattr(assets, "asset_id", "") or "")
|
asset_id = str(getattr(assets, "asset_id", "") or "")
|
||||||
self._adapter = None
|
self._adapter = None
|
||||||
device = expected
|
device = expected
|
||||||
common = {
|
common = { "geometry_id": str(
|
||||||
"geometry_id": str(
|
|
||||||
device.geometry_id if device is not None and device.geometry_id
|
device.geometry_id if device is not None and device.geometry_id
|
||||||
else assets.geometry_id
|
else assets.geometry_id
|
||||||
),
|
),
|
||||||
@@ -285,9 +292,44 @@ class Scene3DView(QWidget):
|
|||||||
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
|
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
|
||||||
# passed here. Remapping it again would index the flow at source pixels.
|
# passed here. Remapping it again would index the flow at source pixels.
|
||||||
adapter = FingerFrameAdapter(**common)
|
adapter = FingerFrameAdapter(**common)
|
||||||
|
if self._domain_latch is not None:
|
||||||
|
# The window's own latch, so the island the 2D canvas paints and the island
|
||||||
|
# the surface's validity texture carries are one decision, taken once.
|
||||||
|
adapter.domain_latch = self._domain_latch
|
||||||
self._adapter = adapter
|
self._adapter = adapter
|
||||||
return lambda packet, identity: adapter.snapshot(packet, None, identity)
|
return lambda packet, identity: adapter.snapshot(packet, None, identity)
|
||||||
|
|
||||||
|
def set_domain_latch(self, latch: Any) -> None:
|
||||||
|
"""Share the 2D canvas's island latch with this pane's adapter.
|
||||||
|
|
||||||
|
``image_backend.DomainMaskLatch`` is *upstream of both views*: the canvas paints
|
||||||
|
the latched boundary and the adapter marks exactly that boundary valid, so
|
||||||
|
"which pixels carry a measurement" is one answer rather than two that happen to
|
||||||
|
agree. Called before the scene is resolved in practice (the window builds the
|
||||||
|
latch first); re-binding an already-built adapter updates it in place.
|
||||||
|
"""
|
||||||
|
self._domain_latch = latch
|
||||||
|
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
|
||||||
|
self._adapter.domain_latch = latch
|
||||||
|
|
||||||
|
def set_theme(self, theme: str) -> None:
|
||||||
|
"""Adopt the application theme's colour ramp.
|
||||||
|
|
||||||
|
The 2D canvas colours its pixels from ``arrows.flow_colormap(theme)``; the 3D
|
||||||
|
surface is coloured from whatever ramp the field it draws carries. Setting both
|
||||||
|
from this one call is what makes them the *same* table rather than two tables
|
||||||
|
that were written to look alike.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.acquisition.arrows import ramp_for_theme
|
||||||
|
|
||||||
|
ramp = ramp_for_theme(theme)
|
||||||
|
if self._composer is not None:
|
||||||
|
self._composer.ramp = ramp
|
||||||
|
if self._panel is not None:
|
||||||
|
self._panel.set_ramp(ramp)
|
||||||
|
if self._coordinator is not None:
|
||||||
|
self._coordinator.set_ramp(ramp)
|
||||||
|
|
||||||
# --------------------------------------------------------------- producer
|
# --------------------------------------------------------------- producer
|
||||||
def submit_packet(self, packet: Any) -> None:
|
def submit_packet(self, packet: Any) -> None:
|
||||||
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
|
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
self._last_frame_identity = frame.identity
|
self._last_frame_identity = frame.identity
|
||||||
self._unique_frames += 1
|
self._unique_frames += 1
|
||||||
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0)
|
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0)
|
||||||
self.descriptorChanged.emit(frame.scalar_descriptor)
|
self.descriptorChanged.emit(frame.field.descriptor)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_settings(self, settings: DisplaySettings) -> None:
|
def set_settings(self, settings: DisplaySettings) -> None:
|
||||||
@@ -216,11 +216,11 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self._assets is not None:
|
if self._assets is not None:
|
||||||
self._renderer.update_surface(
|
self._renderer.update_field(
|
||||||
rest_surface(self._assets, frame.identity)
|
rest_surface(self._assets, frame.identity)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._renderer.update_surface(frame)
|
self._renderer.update_field(frame)
|
||||||
stats = self._renderer.draw(
|
stats = self._renderer.draw(
|
||||||
self._camera.matrices(self.viewport_pixels()),
|
self._camera.matrices(self.viewport_pixels()),
|
||||||
self._settings,
|
self._settings,
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ class Mesh3dPanel(QFrame):
|
|||||||
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
|
||||||
#: until the view exists (see ``set_diagnostics_visible``).
|
#: until the view exists (see ``set_diagnostics_visible``).
|
||||||
self._show_diagnostics = False
|
self._show_diagnostics = False
|
||||||
|
#: The session's island latch, shared with the 2D canvas (see
|
||||||
|
#: ``set_domain_latch``). Held until the view exists.
|
||||||
|
self._domain_latch = None
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -189,9 +192,26 @@ class Mesh3dPanel(QFrame):
|
|||||||
panel.set_diagnostics_visible(self._show_diagnostics)
|
panel.set_diagnostics_visible(self._show_diagnostics)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
def set_theme(self, theme: str) -> None:
|
||||||
"""Re-tint the header icons: they are painted from the theme's own colours."""
|
"""Re-tint the header icons, and colour the picture from the theme's ramp.
|
||||||
|
|
||||||
|
The ramp half is not cosmetic: the 2D canvas is coloured from
|
||||||
|
``arrows.flow_colormap(theme)``, so the 3D surface has to be coloured from the
|
||||||
|
same string or the two panes show one measurement in two colours.
|
||||||
|
"""
|
||||||
self._theme = str(theme)
|
self._theme = str(theme)
|
||||||
self._apply_icons()
|
self._apply_icons()
|
||||||
|
if self._view is not None and hasattr(self._view, "set_theme"):
|
||||||
|
self._view.set_theme(self._theme)
|
||||||
|
|
||||||
|
def set_domain_latch(self, latch) -> None:
|
||||||
|
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
|
||||||
|
|
||||||
|
Held here as well as pushed, because the view is built on first use: a latch
|
||||||
|
handed over before then must still reach the adapter when the view appears.
|
||||||
|
"""
|
||||||
|
self._domain_latch = latch
|
||||||
|
if self._view is not None and hasattr(self._view, "set_domain_latch"):
|
||||||
|
self._view.set_domain_latch(latch)
|
||||||
|
|
||||||
def _apply_icons(self) -> None:
|
def _apply_icons(self) -> None:
|
||||||
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
|
||||||
@@ -267,6 +287,16 @@ class Mesh3dPanel(QFrame):
|
|||||||
self._stack.addWidget(view)
|
self._stack.addWidget(view)
|
||||||
view.diagnosticRaised.connect(self._on_diagnostic)
|
view.diagnosticRaised.connect(self._on_diagnostic)
|
||||||
view.enabledChanged.connect(self._on_view_enabled)
|
view.enabledChanged.connect(self._on_view_enabled)
|
||||||
|
# The theme and the session's island latch were decided before the view existed
|
||||||
|
# (the view is built on first use), so they are pushed again here rather than
|
||||||
|
# lost — exactly the treatment ``_pending_settings`` and ``_source_kind`` get,
|
||||||
|
# and for the same reason: the picture must be coloured by the same ramp, and
|
||||||
|
# bounded by the same island, as the 2D canvas from its first frame. Guarded the
|
||||||
|
# same way as ``panel`` above, because a test double is a view too.
|
||||||
|
if hasattr(view, "set_theme"):
|
||||||
|
view.set_theme(self._theme)
|
||||||
|
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
|
||||||
|
view.set_domain_latch(self._domain_latch)
|
||||||
if self._pending_settings is not None:
|
if self._pending_settings is not None:
|
||||||
view.restore_settings(self._pending_settings)
|
view.restore_settings(self._pending_settings)
|
||||||
# The kind was decided before the view existed (the window sets it when the
|
# The kind was decided before the view existed (the window sets it when the
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ def test_activating_a_scene_bumps_the_generation_and_shows_the_reference_model(s
|
|||||||
assert coordinator.generation == 1
|
assert coordinator.generation == 1
|
||||||
assert widget.scenes == [scene]
|
assert widget.scenes == [scene]
|
||||||
assert len(widget.frames) == 1, "the reference model must be shown immediately"
|
assert len(widget.frames) == 1, "the reference model must be shown immediately"
|
||||||
assert widget.frames[0].scalar_descriptor.quantity == "unavailable"
|
assert widget.frames[0].field.descriptor.quantity == "unavailable"
|
||||||
second = coordinator.activate(scene, "live-B")
|
second = coordinator.activate(scene, "live-B")
|
||||||
assert second == 2 and coordinator.generation == 2
|
assert second == 2 and coordinator.generation == 2
|
||||||
# A frame from the previous scene is now old news.
|
# A frame from the previous scene is now old news.
|
||||||
@@ -187,7 +187,7 @@ def test_a_tick_composes_and_presents(scene, pool):
|
|||||||
_drain(coordinator)
|
_drain(coordinator)
|
||||||
assert len(widget.frames) >= 2
|
assert len(widget.frames) >= 2
|
||||||
last = widget.frames[-1]
|
last = widget.frames[-1]
|
||||||
assert np.allclose(np.asarray(last.scalar), 3.0)
|
assert np.allclose(np.asarray(last.field.scalar), 3.0)
|
||||||
assert coordinator.state == "live"
|
assert coordinator.state == "live"
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
|
|
||||||
@@ -200,8 +200,14 @@ def test_a_frame_with_nothing_valid_is_no_data_not_a_stale_picture(scene, pool):
|
|||||||
coordinator.submit(_frame(scene, valid=False))
|
coordinator.submit(_frame(scene, valid=False))
|
||||||
_drain(coordinator)
|
_drain(coordinator)
|
||||||
assert coordinator.state == "no_data"
|
assert coordinator.state == "no_data"
|
||||||
assert not np.asarray(widget.frames[-1].valid).any()
|
# Absence is the *validity mask*, not a rewrite of the measurement: the field
|
||||||
assert np.isnan(np.asarray(widget.frames[-1].scalar)).all()
|
# travels exactly as the device published it, and the fragment reads the mask. The
|
||||||
|
# old per-vertex path expressed "no value" by putting NaN on the vertices, which is
|
||||||
|
# also what made one invalid texel grey a whole triangle.
|
||||||
|
assert not np.asarray(widget.frames[-1].field.valid).any()
|
||||||
|
assert np.isfinite(np.asarray(widget.frames[-1].field.scalar)).all(), (
|
||||||
|
"the measurement was rewritten to express absence; the mask is what says so"
|
||||||
|
)
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +259,7 @@ def test_a_result_that_arrives_after_a_scene_change_is_dropped(scene, pool):
|
|||||||
coordinator.activate(scene, "live-A") # generation 2 while it runs
|
coordinator.activate(scene, "live-A") # generation 2 while it runs
|
||||||
before = len(widget.frames)
|
before = len(widget.frames)
|
||||||
_drain(coordinator, ticks=20, sleep=0.02)
|
_drain(coordinator, ticks=20, sleep=0.02)
|
||||||
composed = [frame for frame in widget.frames[before:] if np.isfinite(np.asarray(frame.scalar)).any()]
|
composed = [frame for frame in widget.frames[before:] if np.isfinite(np.asarray(frame.field.scalar)).any()]
|
||||||
assert not composed, "a frame composed for the previous scene was presented"
|
assert not composed, "a frame composed for the previous scene was presented"
|
||||||
assert coordinator.stale_frames >= 0
|
assert coordinator.stale_frames >= 0
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
@@ -329,7 +335,7 @@ def test_a_new_session_is_refused_until_it_is_announced(scene, pool):
|
|||||||
before = coordinator.generation
|
before = coordinator.generation
|
||||||
coordinator.begin_session()
|
coordinator.begin_session()
|
||||||
assert coordinator.generation == before + 1
|
assert coordinator.generation == before + 1
|
||||||
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable", (
|
assert widget.frames[-1].field.descriptor.quantity == "unavailable", (
|
||||||
"a new session must not leave the previous session's measurement on screen"
|
"a new session must not leave the previous session's measurement on screen"
|
||||||
)
|
)
|
||||||
assert coordinator.submit(
|
assert coordinator.submit(
|
||||||
@@ -386,12 +392,12 @@ def test_show_rest_puts_the_model_back_and_stops_claiming_a_measurement(scene, p
|
|||||||
coordinator.submit(_frame(scene, source="replay-A"))
|
coordinator.submit(_frame(scene, source="replay-A"))
|
||||||
_drain(coordinator, ticks=10)
|
_drain(coordinator, ticks=10)
|
||||||
assert coordinator.state in ("live", "no_data")
|
assert coordinator.state in ("live", "no_data")
|
||||||
assert widget.frames[-1].valid.any(), "the measurement never arrived"
|
assert widget.frames[-1].field.valid.any(), "the measurement never arrived"
|
||||||
|
|
||||||
coordinator.show_rest()
|
coordinator.show_rest()
|
||||||
last = widget.frames[-1]
|
last = widget.frames[-1]
|
||||||
assert not last.valid.any(), "the old measurement is still on screen"
|
assert not last.field.valid.any(), "the old measurement is still on screen"
|
||||||
assert last.scalar_descriptor.quantity == "unavailable"
|
assert last.field.descriptor.quantity == "unavailable"
|
||||||
assert coordinator.state == "no_data"
|
assert coordinator.state == "no_data"
|
||||||
|
|
||||||
# And it is a no-op when there is nothing to show or the view is off.
|
# And it is a no-op when there is nothing to show or the view is off.
|
||||||
@@ -413,7 +419,7 @@ def test_a_frame_already_queued_is_dropped_when_the_model_comes_back(scene, pool
|
|||||||
coordinator.submit(_frame(scene, source="replay-A", value=7.0))
|
coordinator.submit(_frame(scene, source="replay-A", value=7.0))
|
||||||
coordinator.show_rest()
|
coordinator.show_rest()
|
||||||
_drain(coordinator, ticks=4, sleep=0.01)
|
_drain(coordinator, ticks=4, sleep=0.01)
|
||||||
assert not widget.frames[-1].valid.any(), (
|
assert not widget.frames[-1].field.valid.any(), (
|
||||||
"a frame that was in flight when the pass stopped was presented anyway"
|
"a frame that was in flight when the pass stopped was presented anyway"
|
||||||
)
|
)
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
@@ -497,14 +503,14 @@ def test_a_compose_running_when_the_pass_stopped_can_never_come_back(scene, pool
|
|||||||
|
|
||||||
coordinator.show_rest()
|
coordinator.show_rest()
|
||||||
assert coordinator.state == "no_data"
|
assert coordinator.state == "no_data"
|
||||||
assert not widget.frames[-1].valid.any(), "stop did not clear the measurement"
|
assert not widget.frames[-1].field.valid.any(), "stop did not clear the measurement"
|
||||||
|
|
||||||
composer.release.set()
|
composer.release.set()
|
||||||
_drain(coordinator, ticks=40, sleep=0.01)
|
_drain(coordinator, ticks=40, sleep=0.01)
|
||||||
|
|
||||||
assert coordinator.epoch == epoch + 1
|
assert coordinator.epoch == epoch + 1
|
||||||
assert coordinator._inflight is None, "the finished work still owns the slot"
|
assert coordinator._inflight is None, "the finished work still owns the slot"
|
||||||
assert not widget.frames[-1].valid.any(), (
|
assert not widget.frames[-1].field.valid.any(), (
|
||||||
"the stopped pass put its measurement back on screen"
|
"the stopped pass put its measurement back on screen"
|
||||||
)
|
)
|
||||||
assert coordinator.state != "live"
|
assert coordinator.state != "live"
|
||||||
@@ -522,7 +528,7 @@ def test_hiding_while_a_compose_runs_does_not_let_it_paint_later(scene, pool):
|
|||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
coordinator.set_visible(True)
|
coordinator.set_visible(True)
|
||||||
_drain(coordinator, ticks=40, sleep=0.01)
|
_drain(coordinator, ticks=40, sleep=0.01)
|
||||||
assert not widget.frames[-1].valid.any(), (
|
assert not widget.frames[-1].field.valid.any(), (
|
||||||
"a compose started before the view was hidden painted after it came back"
|
"a compose started before the view was hidden painted after it came back"
|
||||||
)
|
)
|
||||||
assert coordinator.state != "live"
|
assert coordinator.state != "live"
|
||||||
@@ -532,10 +538,10 @@ def test_hiding_while_a_compose_runs_does_not_let_it_paint_later(scene, pool):
|
|||||||
def test_a_compose_running_for_the_old_session_is_dropped_by_begin_session(scene, pool):
|
def test_a_compose_running_for_the_old_session_is_dropped_by_begin_session(scene, pool):
|
||||||
composer, widget, coordinator = _start_blocked_compose(scene, pool)
|
composer, widget, coordinator = _start_blocked_compose(scene, pool)
|
||||||
coordinator.begin_session()
|
coordinator.begin_session()
|
||||||
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable"
|
assert widget.frames[-1].field.descriptor.quantity == "unavailable"
|
||||||
composer.release.set()
|
composer.release.set()
|
||||||
_drain(coordinator, ticks=40, sleep=0.01)
|
_drain(coordinator, ticks=40, sleep=0.01)
|
||||||
assert not widget.frames[-1].valid.any(), (
|
assert not widget.frames[-1].field.valid.any(), (
|
||||||
"the previous session's compose was presented into the new session"
|
"the previous session's compose was presented into the new session"
|
||||||
)
|
)
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
@@ -563,7 +569,7 @@ def test_a_compose_cancelled_before_it_started_is_not_an_error(scene):
|
|||||||
executor.run_next()
|
executor.run_next()
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
assert coordinator.state == "live"
|
assert coordinator.state == "live"
|
||||||
assert np.allclose(np.asarray(widget.frames[-1].scalar), 4.0)
|
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 4.0)
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -586,9 +592,9 @@ def test_repeated_start_and_stop_never_leaves_an_old_measurement(scene, pool):
|
|||||||
)
|
)
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
coordinator.show_rest()
|
coordinator.show_rest()
|
||||||
assert not widget.frames[-1].valid.any(), f"cycle {index} kept a measurement"
|
assert not widget.frames[-1].field.valid.any(), f"cycle {index} kept a measurement"
|
||||||
_drain(coordinator, ticks=60, sleep=0.01)
|
_drain(coordinator, ticks=60, sleep=0.01)
|
||||||
assert not widget.frames[-1].valid.any(), "a stopped pass painted after the fact"
|
assert not widget.frames[-1].field.valid.any(), "a stopped pass painted after the fact"
|
||||||
assert coordinator.state != "live"
|
assert coordinator.state != "live"
|
||||||
assert composer.max_in_flight == 1
|
assert composer.max_in_flight == 1
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
@@ -616,7 +622,7 @@ def test_an_older_request_id_cannot_move_the_picture_backwards(scene):
|
|||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
assert widget.frames[-1].identity.request_id == 2, "an older frame was shown"
|
assert widget.frames[-1].identity.request_id == 2, "an older frame was shown"
|
||||||
assert np.allclose(np.asarray(widget.frames[-1].scalar), 2.0)
|
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 2.0)
|
||||||
assert coordinator.stale_frames == 1
|
assert coordinator.stale_frames == 1
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
|
|
||||||
@@ -637,7 +643,7 @@ def test_a_result_from_an_older_request_is_dropped_on_arrival(scene):
|
|||||||
assert coordinator.submit(newer) == "accepted"
|
assert coordinator.submit(newer) == "accepted"
|
||||||
executor.run_next() # request 1's compose finishes late
|
executor.run_next() # request 1's compose finishes late
|
||||||
_drain(coordinator, ticks=6, sleep=0.0)
|
_drain(coordinator, ticks=6, sleep=0.0)
|
||||||
assert not np.allclose(np.asarray(widget.frames[-1].scalar), 7.0), (
|
assert not np.allclose(np.asarray(widget.frames[-1].field.scalar), 7.0), (
|
||||||
"a result for a superseded request was presented"
|
"a result for a superseded request was presented"
|
||||||
)
|
)
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
@@ -688,7 +694,7 @@ def test_a_stale_array_is_never_presented_as_the_current_instant(scene):
|
|||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
|
|
||||||
assert not any(frame.valid.any() for frame in widget.frames[presented:]), (
|
assert not any(frame.field.valid.any() for frame in widget.frames[presented:]), (
|
||||||
"a stale array was painted as the current instant"
|
"a stale array was painted as the current instant"
|
||||||
)
|
)
|
||||||
assert all(frame.identity.frame_id != 5 for frame in widget.frames[presented:])
|
assert all(frame.identity.frame_id != 5 for frame in widget.frames[presented:])
|
||||||
@@ -711,22 +717,22 @@ def test_a_refused_field_expires_the_measurement_after_the_grace(scene):
|
|||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
assert coordinator.state == "live"
|
assert coordinator.state == "live"
|
||||||
assert np.allclose(np.asarray(widget.frames[-1].scalar), 8.0)
|
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 8.0)
|
||||||
|
|
||||||
# The producer cannot build a field any more: the picture stays for the grace, but
|
# The producer cannot build a field any more: the picture stays for the grace, but
|
||||||
# the pane must stop calling it live immediately.
|
# the pane must stop calling it live immediately.
|
||||||
coordinator.note_field_failure(_stale_diagnostic())
|
coordinator.note_field_failure(_stale_diagnostic())
|
||||||
assert coordinator.state == "stale"
|
assert coordinator.state == "stale"
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
assert np.allclose(np.asarray(widget.frames[-1].scalar), 8.0), (
|
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 8.0), (
|
||||||
"a single refused frame blanked the model"
|
"a single refused frame blanked the model"
|
||||||
)
|
)
|
||||||
assert coordinator.state == "stale"
|
assert coordinator.state == "stale"
|
||||||
|
|
||||||
clock.advance(1.5)
|
clock.advance(1.5)
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
assert not widget.frames[-1].valid.any(), "the expired measurement is still on screen"
|
assert not widget.frames[-1].field.valid.any(), "the expired measurement is still on screen"
|
||||||
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable"
|
assert widget.frames[-1].field.descriptor.quantity == "unavailable"
|
||||||
assert coordinator.state == "no_data"
|
assert coordinator.state == "no_data"
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
|
|
||||||
@@ -746,7 +752,7 @@ def test_a_good_frame_after_a_failure_goes_back_to_live(scene):
|
|||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
coordinator.tick()
|
coordinator.tick()
|
||||||
assert coordinator.state == "live"
|
assert coordinator.state == "live"
|
||||||
assert np.allclose(np.asarray(widget.frames[-1].scalar), 2.0)
|
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 2.0)
|
||||||
coordinator.close()
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import pytest
|
|||||||
from orisys_app_shared.scene3d import synthetic
|
from orisys_app_shared.scene3d import synthetic
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
ColorScale,
|
ColorScale,
|
||||||
|
DisplayField,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
FieldDescriptor,
|
FieldDescriptor,
|
||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
@@ -44,6 +45,48 @@ pytestmark = pytest.mark.skipif(
|
|||||||
BACKGROUND_RGB = np.array([15, 18, 23])
|
BACKGROUND_RGB = np.array([15, 18, 23])
|
||||||
|
|
||||||
|
|
||||||
|
def _field(assets, values, valid=None, descriptor=None) -> DisplayField:
|
||||||
|
"""A display field on the scene's own domain, from a flat or gridded array.
|
||||||
|
|
||||||
|
The surface's colour and validity now come from the *field*, not from the vertices,
|
||||||
|
so a test that wants a particular picture builds one of these and hands it to the
|
||||||
|
widget. ``values`` is reshaped to the scene's ``[H,W]`` domain — which is what the
|
||||||
|
2D canvas would paint — and the fragment reads it through the mesh's UVs.
|
||||||
|
"""
|
||||||
|
shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
|
||||||
|
scalar = np.asarray(values, dtype=np.float32).reshape(shape)
|
||||||
|
mask = (
|
||||||
|
np.ones(shape, dtype=bool)
|
||||||
|
if valid is None
|
||||||
|
else np.asarray(valid, dtype=bool).reshape(shape)
|
||||||
|
)
|
||||||
|
return DisplayField(
|
||||||
|
domain_id=assets.field_domain_id,
|
||||||
|
shape_hw=shape,
|
||||||
|
scalar=freeze(np.array(scalar, dtype=np.float32), dtype=np.float32),
|
||||||
|
valid=freeze(mask, dtype=np.bool_),
|
||||||
|
descriptor=(
|
||||||
|
descriptor
|
||||||
|
or FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(assets, values, valid=None, *, frame_id=5, positions=None, normals=None,
|
||||||
|
descriptor=None, deformed=False) -> RenderFrame:
|
||||||
|
part = assets.sensing_part()
|
||||||
|
return RenderFrame(
|
||||||
|
identity=FrameIdentity("poc", 1, frame_id, 0.5, generation=1),
|
||||||
|
asset_hash=assets.asset_hash,
|
||||||
|
geometry_id=assets.geometry_id,
|
||||||
|
sensing_part_id=part.part_id,
|
||||||
|
positions_sensor_m=part.positions_sensor_m if positions is None else positions,
|
||||||
|
normals_sensor=part.normals_sensor if normals is None else normals,
|
||||||
|
field=_field(assets, values, valid, descriptor),
|
||||||
|
deformed=deformed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
|
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
|
||||||
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
|
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
|
||||||
import time
|
import time
|
||||||
@@ -123,6 +166,13 @@ def _plate_is_grey(pixels: np.ndarray) -> float:
|
|||||||
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
|
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def _plate_mean_rgb(pixels: np.ndarray) -> np.ndarray:
|
||||||
|
"""The mean RGB of the drawn pixels: the plate's own colour, on a flat surface."""
|
||||||
|
drawn = _drawn(pixels)
|
||||||
|
assert drawn.size, "nothing was drawn"
|
||||||
|
return drawn.mean(axis=0)
|
||||||
|
|
||||||
|
|
||||||
def test_the_capabilities_come_from_a_real_context(gl_widget):
|
def test_the_capabilities_come_from_a_real_context(gl_widget):
|
||||||
widget, _, _ = gl_widget
|
widget, _, _ = gl_widget
|
||||||
capabilities = widget.capabilities()
|
capabilities = widget.capabilities()
|
||||||
@@ -157,33 +207,30 @@ def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
|
|||||||
def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_app):
|
def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_app):
|
||||||
"""No measurement yet: the plate must not be coloured as if it had one.
|
"""No measurement yet: the plate must not be coloured as if it had one.
|
||||||
|
|
||||||
Checked as a difference: the neutral path is grey (r≈g), the colour map's low
|
Asserted as a *colour identity*, not as a hue difference: the valid path must draw
|
||||||
end is not. Counting "grey pixels" over the whole frame would just be counting
|
the lookup table's own colour for the value it was given, and the neutral path must
|
||||||
the background.
|
draw the neutral colour. (The old form of this test compared mean ``|r-g|``, which
|
||||||
|
only worked because the 3D pane used a purple ramp whose low end is far from grey.
|
||||||
|
With one ramp serving both views the low end is the theme's own navy, where ``r≈g``,
|
||||||
|
so a hue test would have had to be loosened until it stopped meaning anything. A
|
||||||
|
colour-identity test is both stricter and independent of which ramp is in use.)
|
||||||
"""
|
"""
|
||||||
|
from orisys_studio.scene3d.gl_backend import INVALID_COLOR, LUT_SIZE, lut_rgba
|
||||||
|
|
||||||
widget, _, _ = gl_widget
|
widget, _, _ = gl_widget
|
||||||
assets = synthetic.poc_scene()
|
assets = synthetic.poc_scene()
|
||||||
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
|
||||||
widget.set_settings(DisplaySettings(housing_visible=False))
|
widget.set_settings(DisplaySettings(housing_visible=False))
|
||||||
|
|
||||||
rest_grey = _plate_is_grey(_render(widget, qt_app))
|
rest_rgb = _plate_mean_rgb(_render(widget, qt_app))
|
||||||
|
neutral_8bit = np.array(INVALID_COLOR[:3]) * 255.0
|
||||||
|
|
||||||
frame = RenderFrame(
|
value = 0.05
|
||||||
identity=FrameIdentity("poc", 1, 5, 0.5, generation=1),
|
frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32))
|
||||||
asset_hash=assets.asset_hash,
|
|
||||||
geometry_id=assets.geometry_id,
|
|
||||||
sensing_part_id=assets.sensing_part().part_id,
|
|
||||||
positions_sensor_m=assets.sensing_part().positions_sensor_m,
|
|
||||||
normals_sensor=assets.sensing_part().normals_sensor,
|
|
||||||
scalar=freeze(np.full(count, 0.05, dtype=np.float32), dtype=np.float32),
|
|
||||||
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
|
||||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
|
||||||
)
|
|
||||||
widget.set_settings(
|
widget.set_settings(
|
||||||
DisplaySettings(
|
DisplaySettings(
|
||||||
housing_visible=False,
|
housing_visible=False,
|
||||||
color_scale=ColorScale(
|
color_scale=ColorScale(
|
||||||
descriptor=frame.scalar_descriptor,
|
descriptor=frame.field.descriptor,
|
||||||
lower=0.0,
|
lower=0.0,
|
||||||
upper=1.0,
|
upper=1.0,
|
||||||
lut_id="sequential",
|
lut_id="sequential",
|
||||||
@@ -191,12 +238,27 @@ def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_a
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
widget.present(frame)
|
widget.present(frame)
|
||||||
measured_grey = _plate_is_grey(_render(widget, qt_app))
|
measured_rgb = _plate_mean_rgb(_render(widget, qt_app))
|
||||||
|
|
||||||
assert rest_grey < 6.0, f"the unmeasured surface is not neutral (r-g={rest_grey:.1f})"
|
# Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture
|
||||||
assert measured_grey > rest_grey + 10.0, (
|
# with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table.
|
||||||
"the colour map made no difference: the invalid path is not being taken "
|
table = lut_rgba("sequential")[:, 0, :3].astype(float)
|
||||||
f"(rest r-g={rest_grey:.1f}, measured r-g={measured_grey:.1f})"
|
position = float(np.clip(value * LUT_SIZE - 0.5, 0.0, LUT_SIZE - 1.0))
|
||||||
|
low = int(np.floor(position))
|
||||||
|
high = min(low + 1, LUT_SIZE - 1)
|
||||||
|
fraction = position - low
|
||||||
|
expected = (1.0 - fraction) * table[low] + fraction * table[high]
|
||||||
|
|
||||||
|
assert np.abs(rest_rgb - neutral_8bit).max() < 6.0, (
|
||||||
|
f"the unmeasured surface is not the neutral colour: {rest_rgb} vs {neutral_8bit}"
|
||||||
|
)
|
||||||
|
assert np.abs(measured_rgb - expected).max() < 8.0, (
|
||||||
|
"the colour map's own colour did not reach the plate: "
|
||||||
|
f"drawn {measured_rgb}, the table gives {expected} for {value} — the valid path "
|
||||||
|
"is not being taken, or the pane is not using the shared ramp"
|
||||||
|
)
|
||||||
|
assert np.abs(expected - neutral_8bit).max() > 10.0, (
|
||||||
|
"this value is too close to the neutral colour to make the point; pick another"
|
||||||
)
|
)
|
||||||
widget.set_settings(DisplaySettings(housing_visible=True))
|
widget.set_settings(DisplaySettings(housing_visible=True))
|
||||||
|
|
||||||
@@ -207,21 +269,18 @@ def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
|
|||||||
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
||||||
positions = np.asarray(assets.sensing_part().positions_sensor_m).copy()
|
positions = np.asarray(assets.sensing_part().positions_sensor_m).copy()
|
||||||
positions[:, 2] = 0.004 # a visible bump
|
positions[:, 2] = 0.004 # a visible bump
|
||||||
frame = RenderFrame(
|
frame = _frame(
|
||||||
identity=FrameIdentity("poc", 1, 6, 0.6, generation=1),
|
assets,
|
||||||
asset_hash=assets.asset_hash,
|
np.linspace(0.0, 1.0, count).reshape(assets.shape_hw),
|
||||||
geometry_id=assets.geometry_id,
|
frame_id=6,
|
||||||
sensing_part_id=assets.sensing_part().part_id,
|
positions=freeze(positions, dtype=np.float32),
|
||||||
positions_sensor_m=freeze(positions, dtype=np.float32),
|
deformed=True,
|
||||||
normals_sensor=assets.sensing_part().normals_sensor,
|
|
||||||
scalar=freeze(np.linspace(0.0, 1.0, count).astype(np.float32), dtype=np.float32),
|
|
||||||
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
|
||||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
|
||||||
)
|
)
|
||||||
widget.set_settings(
|
widget.set_settings(
|
||||||
DisplaySettings(
|
DisplaySettings(
|
||||||
color_scale=ColorScale(
|
color_scale=ColorScale(
|
||||||
descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential"
|
descriptor=frame.field.descriptor, lower=0.0, upper=1.0,
|
||||||
|
lut_id="sequential",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -242,21 +301,13 @@ def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]:
|
|||||||
def test_a_frame_from_another_geometry_is_refused_without_losing_the_view(gl_widget, qt_app):
|
def test_a_frame_from_another_geometry_is_refused_without_losing_the_view(gl_widget, qt_app):
|
||||||
"""A bad frame is refused; the scene stays usable and stops showing it."""
|
"""A bad frame is refused; the scene stays usable and stops showing it."""
|
||||||
widget, _, failures = gl_widget
|
widget, _, failures = gl_widget
|
||||||
count = 9
|
|
||||||
before = widget.rejected_frames
|
before = widget.rejected_frames
|
||||||
frame = RenderFrame(
|
frame = _frame(
|
||||||
identity=FrameIdentity("poc", 1, 2, 0.2, generation=1),
|
synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32),
|
||||||
asset_hash="other",
|
frame_id=2,
|
||||||
geometry_id="some-other-geometry",
|
|
||||||
sensing_part_id="sensing_surface",
|
|
||||||
positions_sensor_m=freeze(np.zeros((count, 3), dtype=np.float32), dtype=np.float32),
|
|
||||||
normals_sensor=freeze(
|
|
||||||
np.tile([0.0, 0.0, 1.0], (count, 1)).astype(np.float32), dtype=np.float32
|
|
||||||
),
|
|
||||||
scalar=freeze(np.zeros(count, dtype=np.float32), dtype=np.float32),
|
|
||||||
valid=freeze(np.zeros(count, dtype=np.bool_), dtype=np.bool_),
|
|
||||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
|
||||||
)
|
)
|
||||||
|
object.__setattr__(frame, "asset_hash", "other")
|
||||||
|
object.__setattr__(frame, "geometry_id", "some-other-geometry")
|
||||||
widget.present(frame)
|
widget.present(frame)
|
||||||
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
|
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
|
||||||
assert widget.rejected_frames == before + 1
|
assert widget.rejected_frames == before + 1
|
||||||
@@ -525,7 +576,6 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
|
|||||||
widget = view.gl_widget
|
widget = view.gl_widget
|
||||||
assets_frame = view.assets
|
assets_frame = view.assets
|
||||||
assert assets_frame is not None
|
assert assets_frame is not None
|
||||||
count = int(assets_frame.sensing_part().positions_sensor_m.shape[0])
|
|
||||||
|
|
||||||
def frame_with(values: np.ndarray) -> RenderFrame:
|
def frame_with(values: np.ndarray) -> RenderFrame:
|
||||||
return RenderFrame(
|
return RenderFrame(
|
||||||
@@ -535,9 +585,7 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
|
|||||||
sensing_part_id=assets_frame.sensing_part().part_id,
|
sensing_part_id=assets_frame.sensing_part().part_id,
|
||||||
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
|
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
|
||||||
normals_sensor=assets_frame.sensing_part().normals_sensor,
|
normals_sensor=assets_frame.sensing_part().normals_sensor,
|
||||||
scalar=freeze(np.asarray(values, dtype=np.float32), dtype=np.float32),
|
field=_field(assets_frame, values),
|
||||||
valid=freeze(np.ones(count, dtype=np.bool_), dtype=np.bool_),
|
|
||||||
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
||||||
@@ -553,7 +601,7 @@ def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
|
|||||||
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
|
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
ramp = np.linspace(3.0, 7.0, count)
|
ramp = np.linspace(3.0, 7.0, int(assets_frame.shape_hw[0]) * int(assets_frame.shape_hw[1]))
|
||||||
|
|
||||||
widget.set_settings(fixed_wide)
|
widget.set_settings(fixed_wide)
|
||||||
widget.present(frame_with(ramp))
|
widget.present(frame_with(ramp))
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
"""Region agreement: does the 3D pane paint exactly the 2D island?
|
||||||
|
|
||||||
|
The user-visible claim this file checks: the set of fragments the 3D surface paints as
|
||||||
|
*valid* must be the 2D island's inverse image under the mesh's UV map, to within one
|
||||||
|
texel of the field domain, with **no** disagreement in the island's interior.
|
||||||
|
|
||||||
|
Before the texture-first path it was not. The colour came from a per-vertex gather
|
||||||
|
(``sampling.SurfaceSampler``), one invalid tap made a vertex ``NaN``, the shader's
|
||||||
|
``isnan`` rule painted every triangle touching it grey, and on the shipped finger asset
|
||||||
|
that measured as 5763 of the 27227 mesh-covered island texels painted grey (**21.17 %**),
|
||||||
|
5476 of them strictly *inside* the island, with 26840 wrong pixels on screen. The same
|
||||||
|
measurement after the change is 0, 0 and 0.
|
||||||
|
|
||||||
|
How it is measured, and why this is not a tautology
|
||||||
|
---------------------------------------------------
|
||||||
|
The pane is drawn by a real GL context and the framebuffer is read back. The *reference*
|
||||||
|
is computed on the CPU from the same camera matrices, viewport, vertex positions and
|
||||||
|
UVs: every triangle is rasterised, ``a_uv`` is interpolated perspective-correctly exactly
|
||||||
|
as a GPU varying is, and the texel it addresses is the one the fragment shader's
|
||||||
|
``floor(v_uv + 0.5)`` selects. So the comparison is between the GPU's answer and an
|
||||||
|
independent CPU derivation of the same UV map. A wrong UV convention, a wrong v flip, an
|
||||||
|
off-by-one texel, a stale ``a_scalar`` attribute or a leftover per-vertex path all show
|
||||||
|
up as a large difference — which is exactly how the numbers above were produced.
|
||||||
|
|
||||||
|
The tolerance is the design's, and it is asserted in *domain* space, which is where
|
||||||
|
"one texel of the 300x280 domain" is defined:
|
||||||
|
|
||||||
|
* every island texel the mesh covers is painted valid, and no texel outside the island's
|
||||||
|
one-texel dilation is (``residual <= 1 texel``);
|
||||||
|
* the island's one-texel *erosion* is painted valid everywhere and invalid nowhere
|
||||||
|
(``zero interior disagreement``);
|
||||||
|
* no texel is painted both ways (a fragment-level disagreement).
|
||||||
|
|
||||||
|
The mesh's UV footprint is a sub-rectangle of the domain (the shipped finger mesh spans
|
||||||
|
x 0.03..279.0, y 42.6..256.4 of a 300x280 grid), so 5710 island texels have no triangle
|
||||||
|
over them at all. "The island's inverse image under the mesh UV map" is therefore
|
||||||
|
*island ∩ covered*, and every interior/outside assertion is restricted to that set: a
|
||||||
|
texel no triangle reaches cannot be evidence about the shader.
|
||||||
|
|
||||||
|
There is deliberately no ``pytest.skip`` for a missing context once a real platform is
|
||||||
|
in use: this is the only coverage of this property on actual hardware, and a silent skip
|
||||||
|
is how it stayed broken. The module is not collected under the offscreen plugin because
|
||||||
|
that plugin cannot create a platform GL context *at all* — a property of the plugin, not
|
||||||
|
of the machine — which is the same rule ``test_scene3d_gl_smoke.py`` follows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_OPENGL", "desktop")
|
||||||
|
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
|
||||||
|
reason="the offscreen/minimal plugin cannot create a platform GL context",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The 2D canvas's clear colour and the round-trip sentinels, in 8-bit.
|
||||||
|
BACKGROUND_RGB = (15, 18, 23)
|
||||||
|
INVALID_SENTINEL = (0, 255, 0) # patches gl_backend.INVALID_COLOR
|
||||||
|
#: The shipped domain, when the real package is the subject.
|
||||||
|
REAL_VIEWPORT = (900, 900)
|
||||||
|
#: A dense synthetic domain: one vertex per texel over a 320x240 viewport, so a texel is
|
||||||
|
#: about five pixels and a per-vertex path cannot look like a per-fragment one by luck.
|
||||||
|
DENSE_DOMAIN_HW = (40, 48)
|
||||||
|
DENSE_VIEWPORT = (320, 240)
|
||||||
|
DENSE_PLATE_M = 0.06
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ helpers
|
||||||
|
def adjacent_to(mask: np.ndarray) -> np.ndarray:
|
||||||
|
"""Everything 8-adjacent to ``mask``: its one-texel dilation, without itself."""
|
||||||
|
out = np.zeros_like(mask)
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
if dy == 0 and dx == 0:
|
||||||
|
continue
|
||||||
|
out |= np.roll(np.roll(mask, dy, axis=0), dx, axis=1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dilate1(mask: np.ndarray) -> np.ndarray:
|
||||||
|
return mask | adjacent_to(mask)
|
||||||
|
|
||||||
|
|
||||||
|
def erode1(mask: np.ndarray) -> np.ndarray:
|
||||||
|
return mask & ~adjacent_to(~mask)
|
||||||
|
|
||||||
|
|
||||||
|
def reference_texels(positions, uvs, triangles, camera, viewport):
|
||||||
|
"""Which texel every framebuffer pixel reads, derived independently of the GPU."""
|
||||||
|
view = np.asarray(camera.view, dtype=np.float64).reshape(4, 4)
|
||||||
|
projection = np.asarray(camera.projection, dtype=np.float64).reshape(4, 4)
|
||||||
|
width, height = int(viewport[0]), int(viewport[1])
|
||||||
|
homo = np.hstack([np.asarray(positions, dtype=np.float64), np.ones((len(positions), 1))])
|
||||||
|
clip = (projection @ view @ homo.T).T
|
||||||
|
w = clip[:, 3]
|
||||||
|
ndc = clip[:, :3] / w[:, None]
|
||||||
|
x = (ndc[:, 0] * 0.5 + 0.5) * width
|
||||||
|
y = (ndc[:, 1] * 0.5 + 0.5) * height
|
||||||
|
depth = np.full((height, width), np.inf)
|
||||||
|
texel = np.full((height, width, 2), -1, dtype=np.int64)
|
||||||
|
for tri in np.asarray(triangles, dtype=np.int64):
|
||||||
|
i0, i1, i2 = int(tri[0]), int(tri[1]), int(tri[2])
|
||||||
|
xs = np.array([x[i0], x[i1], x[i2]])
|
||||||
|
ys = np.array([y[i0], y[i1], y[i2]])
|
||||||
|
area = (xs[1] - xs[0]) * (ys[2] - ys[0]) - (xs[2] - xs[0]) * (ys[1] - ys[0])
|
||||||
|
if area == 0.0:
|
||||||
|
continue
|
||||||
|
min_x = max(int(np.floor(xs.min() - 0.5)), 0)
|
||||||
|
max_x = min(int(np.ceil(xs.max() + 0.5)), width)
|
||||||
|
min_y = max(int(np.floor(ys.min() - 0.5)), 0)
|
||||||
|
max_y = min(int(np.ceil(ys.max() + 0.5)), height)
|
||||||
|
if min_x >= max_x or min_y >= max_y:
|
||||||
|
continue
|
||||||
|
gx, gy = np.meshgrid(
|
||||||
|
np.arange(min_x, max_x) + 0.5, np.arange(min_y, max_y) + 0.5
|
||||||
|
)
|
||||||
|
l0 = ((xs[1] - gx) * (ys[2] - gy) - (xs[2] - gx) * (ys[1] - gy)) / area
|
||||||
|
l1 = ((xs[2] - gx) * (ys[0] - gy) - (xs[0] - gx) * (ys[2] - gy)) / area
|
||||||
|
l2 = 1.0 - l0 - l1
|
||||||
|
inside = (l0 >= 0.0) & (l1 >= 0.0) & (l2 >= 0.0)
|
||||||
|
if not inside.any():
|
||||||
|
continue
|
||||||
|
zs = np.array([ndc[i0, 2], ndc[i1, 2], ndc[i2, 2]])
|
||||||
|
ws = np.array([w[i0], w[i1], w[i2]])
|
||||||
|
zi = l0 * zs[0] + l1 * zs[1] + l2 * zs[2]
|
||||||
|
window = depth[min_y:max_y, min_x:max_x]
|
||||||
|
take = inside & (zi < window)
|
||||||
|
if not take.any():
|
||||||
|
continue
|
||||||
|
inv_w = l0 / ws[0] + l1 / ws[1] + l2 / ws[2]
|
||||||
|
uu = (
|
||||||
|
l0 * uvs[i0, 0] / ws[0] + l1 * uvs[i1, 0] / ws[1] + l2 * uvs[i2, 0] / ws[2]
|
||||||
|
) / inv_w
|
||||||
|
vv = (
|
||||||
|
l0 * uvs[i0, 1] / ws[0] + l1 * uvs[i1, 1] / ws[1] + l2 * uvs[i2, 1] / ws[2]
|
||||||
|
) / inv_w
|
||||||
|
window[take] = zi[take]
|
||||||
|
rows, cols = np.nonzero(take)
|
||||||
|
view_x = texel[min_y:max_y, min_x:max_x, 0]
|
||||||
|
view_y = texel[min_y:max_y, min_x:max_x, 1]
|
||||||
|
view_x[rows, cols] = np.floor(uu[take] + 0.5)
|
||||||
|
view_y[rows, cols] = np.floor(vv[take] + 0.5)
|
||||||
|
return texel, np.isfinite(depth)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(pixels, target, tolerance):
|
||||||
|
flat = pixels.reshape(-1, 3).astype(np.int16)
|
||||||
|
return (
|
||||||
|
np.abs(flat - np.asarray(target, dtype=np.int16)).max(axis=1) <= tolerance
|
||||||
|
).reshape(pixels.shape[:2])
|
||||||
|
|
||||||
|
|
||||||
|
def measure(island, texel, covered, painted_valid, painted_invalid):
|
||||||
|
"""The domain-space numbers, and the assertions, in one place."""
|
||||||
|
shape = island.shape
|
||||||
|
# The GPU's own coverage, not this rasteriser's: the reference's barycentric test
|
||||||
|
# includes triangle and silhhouette edge pixels that GL's fill rule rejects, and a
|
||||||
|
# texel only this reference reaches would otherwise read as "the pane failed to paint
|
||||||
|
# it". A texel is covered when a pixel the pane *did* paint resolves to it.
|
||||||
|
painted = painted_valid | painted_invalid
|
||||||
|
covered = covered & painted
|
||||||
|
height, width = int(shape[0]), int(shape[1])
|
||||||
|
flat = texel[..., 1] * width + texel[..., 0]
|
||||||
|
valid_texel = np.zeros(shape[0] * shape[1], dtype=bool)
|
||||||
|
invalid_texel = np.zeros(shape[0] * shape[1], dtype=bool)
|
||||||
|
covered_texel = np.zeros(shape[0] * shape[1], dtype=bool)
|
||||||
|
covered_texel[flat[covered]] = True
|
||||||
|
valid_texel[flat[covered & painted_valid]] = True
|
||||||
|
invalid_texel[flat[covered & painted_invalid]] = True
|
||||||
|
valid_texel = valid_texel.reshape(shape)
|
||||||
|
invalid_texel = invalid_texel.reshape(shape)
|
||||||
|
covered_texel = covered_texel.reshape(shape)
|
||||||
|
island_covered = island & covered_texel
|
||||||
|
grey = island_covered & ~valid_texel
|
||||||
|
extra = valid_texel & ~island
|
||||||
|
interior = erode1(island) & covered_texel
|
||||||
|
outside = ~dilate1(island) & covered_texel
|
||||||
|
mixed = valid_texel & invalid_texel
|
||||||
|
# The one-texel rim of the island, in screen terms: a pixel is in the band when its
|
||||||
|
# own texel has a 4-neighbour with the opposite validity.
|
||||||
|
tx = np.clip(texel[..., 0], 0, width - 1)
|
||||||
|
ty = np.clip(texel[..., 1], 0, height - 1)
|
||||||
|
here = island[ty, tx]
|
||||||
|
band = np.zeros_like(here)
|
||||||
|
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
||||||
|
band |= island[np.clip(ty + dy, 0, height - 1), np.clip(tx + dx, 0, width - 1)] != here
|
||||||
|
band &= covered
|
||||||
|
expected = np.zeros(here.shape, dtype=bool)
|
||||||
|
expected[covered] = here[covered]
|
||||||
|
missing_pixels = expected & ~painted_valid
|
||||||
|
extra_pixels = painted_valid & covered & ~expected
|
||||||
|
return {
|
||||||
|
"island_texels": int(island.sum()),
|
||||||
|
"rim_texels": int((island & adjacent_to(~island)).sum()),
|
||||||
|
"island_covered_texels": int(island_covered.sum()),
|
||||||
|
"island_uncovered_texels": int((island & ~covered_texel).sum()),
|
||||||
|
"painted_valid_texels": int(valid_texel.sum()),
|
||||||
|
"island_grey_texels": int(grey.sum()),
|
||||||
|
"island_grey_percent": round(
|
||||||
|
100.0 * float(grey.sum()) / max(int(island_covered.sum()), 1), 4
|
||||||
|
),
|
||||||
|
"extra_valid_texels": int(extra.sum()),
|
||||||
|
"mixed_texels": int(mixed.sum()),
|
||||||
|
"mixed_inside_interior": int((mixed & erode1(island)).sum()),
|
||||||
|
"covered_pixels": int(covered.sum()),
|
||||||
|
"interior_texels": int(interior.sum()),
|
||||||
|
"interior_not_painted_valid": int((interior & ~valid_texel).sum()),
|
||||||
|
"interior_painted_invalid": int((interior & invalid_texel).sum()),
|
||||||
|
"outside_dilated_painted_valid": int((outside & valid_texel).sum()),
|
||||||
|
"grey_more_than_one_texel_inside": int((grey & erode1(island)).sum()),
|
||||||
|
"grey_more_than_two_texels_inside": int((grey & erode1(erode1(island))).sum()),
|
||||||
|
"extra_more_than_one_texel_outside": int((extra & ~dilate1(island)).sum()),
|
||||||
|
"extra_more_than_two_texels_outside": int(
|
||||||
|
(extra & ~dilate1(dilate1(island))).sum()
|
||||||
|
),
|
||||||
|
"band_pixels": int(band.sum()),
|
||||||
|
"missing_pixels": int(missing_pixels.sum()),
|
||||||
|
"extra_pixels": int(extra_pixels.sum()),
|
||||||
|
"missing_pixels_outside_band": int((missing_pixels & ~band).sum()),
|
||||||
|
"extra_pixels_outside_band": int((extra_pixels & ~band).sum()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_agreement(numbers: dict, label: str) -> None:
|
||||||
|
"""Every assertion, with the label so a failure says which subject it was."""
|
||||||
|
assert numbers["covered_pixels"] > 5000, (label, numbers)
|
||||||
|
# A render that covers almost none of the island would make the rest vacuous.
|
||||||
|
assert numbers["island_covered_texels"] > 0.5 * numbers["island_texels"], (label, numbers)
|
||||||
|
# The island's inverse image: every covered island texel is a measurement.
|
||||||
|
#
|
||||||
|
# Two residues are bounded here rather than waved at, because they have different
|
||||||
|
# causes and only one of them is avoidable:
|
||||||
|
#
|
||||||
|
# * a *rim* texel can be missed when a fragment's interpolated UV sits within float32
|
||||||
|
# rounding of the neighbouring texel and the GPU rounds the other way — the
|
||||||
|
# "residual <= 1 texel" of the acceptance, and it may not reach deeper than the rim;
|
||||||
|
# * a texel can be *skipped entirely* when the plate is minified: NEAREST sampling of a
|
||||||
|
# 300x280 domain onto a plate of roughly half that many pixels cannot show every
|
||||||
|
# texel, and a validity channel deliberately cannot be averaged (a blended validity
|
||||||
|
# is the partly-valid triangle this change removed). One texel two steps inside the
|
||||||
|
# rim is the measured worst case at the shipped asset's own default view, so the
|
||||||
|
# band is bounded at two texels and the *fraction* at 0.05 %.
|
||||||
|
assert numbers["grey_more_than_one_texel_inside"] <= numbers["rim_texels"], (
|
||||||
|
f"{label}: the unpainted residue is deeper than the island's rim"
|
||||||
|
)
|
||||||
|
assert numbers["grey_more_than_two_texels_inside"] == 0, (label, numbers)
|
||||||
|
assert numbers["island_grey_percent"] <= 0.05, (
|
||||||
|
f"{label}: {numbers['island_grey_percent']} % of the island is painted as no "
|
||||||
|
"measurement; at most a hairline of the rim may be"
|
||||||
|
)
|
||||||
|
# The same tolerance in the other direction: a texel just outside the island may be
|
||||||
|
# resolved as valid when a fragment's UV rounds the other way, but never deeper than
|
||||||
|
# the rim and never in a way that grows with the island.
|
||||||
|
assert numbers["extra_valid_texels"] <= numbers["rim_texels"], (label, numbers)
|
||||||
|
assert numbers["extra_more_than_one_texel_outside"] == 0, (label, numbers)
|
||||||
|
# Zero interior disagreement, and no disagreement more than one texel out.
|
||||||
|
assert numbers["interior_not_painted_valid"] == 0, (label, numbers)
|
||||||
|
assert numbers["interior_painted_invalid"] == 0, (label, numbers)
|
||||||
|
assert numbers["outside_dilated_painted_valid"] == 0, (label, numbers)
|
||||||
|
assert numbers["grey_more_than_two_texels_inside"] == 0, (label, numbers)
|
||||||
|
assert numbers["extra_more_than_two_texels_outside"] == 0, (label, numbers)
|
||||||
|
# A texel painted both ways can only be a half-texel rounding tie at the rim: a
|
||||||
|
# fragment whose interpolated UV sits within float32 rounding of a texel boundary is
|
||||||
|
# rounded to a different texel by the GPU than by this float64 reference. That is the
|
||||||
|
# residue the one-texel tolerance exists for, and it may never reach the interior.
|
||||||
|
assert numbers["mixed_inside_interior"] == 0, (label, numbers)
|
||||||
|
# ...and every pixel that disagrees is inside that one-texel band.
|
||||||
|
assert numbers["missing_pixels_outside_band"] == 0, (label, numbers)
|
||||||
|
assert numbers["extra_pixels_outside_band"] == 0, (label, numbers)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- subjects
|
||||||
|
def _dense_scene():
|
||||||
|
"""A flat plate with one vertex per texel, and its island: the controlled case."""
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
CameraPreset,
|
||||||
|
Material,
|
||||||
|
MeshPart,
|
||||||
|
SceneAssets,
|
||||||
|
SurfaceBinding,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
|
||||||
|
|
||||||
|
height, width = DENSE_DOMAIN_HW
|
||||||
|
step = DENSE_PLATE_M / (max(height, width) - 1)
|
||||||
|
rows, cols = np.meshgrid(np.arange(height), np.arange(width), indexing="ij")
|
||||||
|
positions = np.stack(
|
||||||
|
[
|
||||||
|
(cols - (width - 1) / 2.0) * step,
|
||||||
|
-(rows - (height - 1) / 2.0) * step,
|
||||||
|
np.zeros_like(rows, dtype=np.float64),
|
||||||
|
],
|
||||||
|
axis=-1,
|
||||||
|
).astype(np.float32)
|
||||||
|
flat = positions.reshape(-1, 3)
|
||||||
|
index = (rows * width + cols).astype(np.int64)
|
||||||
|
a = index[:-1, :-1].reshape(-1)
|
||||||
|
b = index[:-1, 1:].reshape(-1)
|
||||||
|
c = index[1:, :-1].reshape(-1)
|
||||||
|
d = index[1:, 1:].reshape(-1)
|
||||||
|
triangles = np.concatenate(
|
||||||
|
[np.stack([a, b, d], axis=1), np.stack([a, d, c], axis=1)], axis=0
|
||||||
|
).astype(np.uint32)
|
||||||
|
part = MeshPart(
|
||||||
|
part_id="sensing_surface",
|
||||||
|
role="sensing",
|
||||||
|
positions_sensor_m=freeze(flat, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(
|
||||||
|
np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (len(flat), 1)),
|
||||||
|
dtype=np.float32,
|
||||||
|
),
|
||||||
|
triangles=freeze(triangles, dtype=np.uint32),
|
||||||
|
material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True),
|
||||||
|
lod_id="region",
|
||||||
|
)
|
||||||
|
geom = geometry_id([part])
|
||||||
|
domain = field_domain_id(shape_hw=DENSE_DOMAIN_HW, flip_v=False)
|
||||||
|
uvs = np.stack(
|
||||||
|
[cols.reshape(-1).astype(np.float32), rows.reshape(-1).astype(np.float32)], axis=1
|
||||||
|
)
|
||||||
|
count = len(flat)
|
||||||
|
indices = np.full((count, 4), -1, dtype=np.int32)
|
||||||
|
weights = np.zeros((count, 4), dtype=np.float32)
|
||||||
|
indices[:, 0] = np.arange(count, dtype=np.int32)
|
||||||
|
weights[:, 0] = 1.0
|
||||||
|
assets = SceneAssets(
|
||||||
|
asset_id="region-dense",
|
||||||
|
asset_version="0.0.0",
|
||||||
|
asset_hash="region-hash",
|
||||||
|
geometry_id=geom,
|
||||||
|
parts=(part,),
|
||||||
|
binding=SurfaceBinding(
|
||||||
|
part_id="sensing_surface",
|
||||||
|
geometry_id=geom,
|
||||||
|
field_domain_id=domain,
|
||||||
|
shape_hw=DENSE_DOMAIN_HW,
|
||||||
|
uvs=freeze(uvs, dtype=np.float32),
|
||||||
|
sample_indices=freeze(indices, dtype=np.int32),
|
||||||
|
sample_weights=freeze(weights, dtype=np.float32),
|
||||||
|
),
|
||||||
|
camera_presets=(
|
||||||
|
CameraPreset(
|
||||||
|
"front", (0.0, 0.0, DENSE_PLATE_M * 2.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.6
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bounds_sensor_m=freeze(
|
||||||
|
np.stack([flat.min(axis=0), flat.max(axis=0)]).astype(np.float32),
|
||||||
|
dtype=np.float32,
|
||||||
|
),
|
||||||
|
field_domain_id=domain,
|
||||||
|
shape_hw=DENSE_DOMAIN_HW,
|
||||||
|
)
|
||||||
|
island = np.zeros(DENSE_DOMAIN_HW, dtype=bool)
|
||||||
|
island[6:34, 8:40] = True
|
||||||
|
island[12:20, 20:26] = False # a hole: an interior the shader must still colour
|
||||||
|
island[24:30, 30:36] = False # a notch: a concave corner on the boundary
|
||||||
|
return assets, island, uvs, "front"
|
||||||
|
|
||||||
|
|
||||||
|
def _shipped_scene(preset_name: str = "front"):
|
||||||
|
"""The real finger package, with the island it carries.
|
||||||
|
|
||||||
|
``surface_valid.npy`` is byte-identical to the unroll map's ``valid_mask`` (measured:
|
||||||
|
32937 texels, 0 differing), so the island is the 2D canvas's own island and no path
|
||||||
|
outside this repository is needed to state it.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
|
||||||
|
|
||||||
|
package = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "config" / "sensors" / "finger" / "scene" / "finger-display-1.0.0.oryscene"
|
||||||
|
)
|
||||||
|
assert package.is_file(), f"the shipped scene package is missing: {package}"
|
||||||
|
assets = scene_from_contents(read_package(package), None)
|
||||||
|
mapping = assets.surface_mapping
|
||||||
|
assert mapping is not None, "the shipped package carries no surface mapping"
|
||||||
|
names = {preset.name for preset in assets.camera_presets}
|
||||||
|
assert preset_name in names, f"the shipped package has no {preset_name!r} preset: {names}"
|
||||||
|
return (
|
||||||
|
assets,
|
||||||
|
np.asarray(mapping.valid, dtype=bool),
|
||||||
|
np.asarray(assets.binding.uvs, dtype=np.float32),
|
||||||
|
preset_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_region(qt_app, assets, island, uvs, preset_name, viewport):
|
||||||
|
"""Draw the island through the real GL path and measure the painted region."""
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
ColorScale,
|
||||||
|
DisplayField,
|
||||||
|
DisplaySettings,
|
||||||
|
FieldDescriptor,
|
||||||
|
FrameIdentity,
|
||||||
|
RenderFrame,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
|
from orisys_studio.scene3d import gl_backend
|
||||||
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
|
GLDispatch,
|
||||||
|
GLRenderer,
|
||||||
|
configure_surface_format,
|
||||||
|
lut_rgba,
|
||||||
|
)
|
||||||
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||||
|
|
||||||
|
configure_surface_format("desktop33")
|
||||||
|
renderer = GLRenderer()
|
||||||
|
widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch())
|
||||||
|
seen: list = []
|
||||||
|
widget.diagnosticRaised.connect(seen.append)
|
||||||
|
widget.resize(*viewport)
|
||||||
|
widget.show()
|
||||||
|
widget.set_scene(assets)
|
||||||
|
|
||||||
|
def pump(predicate, timeout_s=12.0):
|
||||||
|
import time
|
||||||
|
|
||||||
|
deadline = time.perf_counter() + timeout_s
|
||||||
|
while time.perf_counter() < deadline:
|
||||||
|
widget.repaint()
|
||||||
|
qt_app.processEvents()
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not pump(lambda: bool(renderer.part_ids)):
|
||||||
|
widget.release_resources()
|
||||||
|
widget.close()
|
||||||
|
pytest.fail(
|
||||||
|
"no usable OpenGL context on a real platform: "
|
||||||
|
f"{widget.diagnostic() or seen or 'the widget never initialized'}"
|
||||||
|
)
|
||||||
|
preset = next(item for item in assets.camera_presets if item.name == preset_name)
|
||||||
|
widget.camera().set_preset(preset)
|
||||||
|
widget.camera().fit(assets.bounds_sensor_m, viewport[0] / max(viewport[1], 1))
|
||||||
|
widget.repaint()
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
|
||||||
|
frame = RenderFrame(
|
||||||
|
identity=FrameIdentity("region", 1, 1, 0.0, generation=1),
|
||||||
|
asset_hash=assets.asset_hash,
|
||||||
|
geometry_id=assets.geometry_id,
|
||||||
|
sensing_part_id=assets.sensing_part().part_id,
|
||||||
|
positions_sensor_m=assets.sensing_part().positions_sensor_m,
|
||||||
|
normals_sensor=assets.sensing_part().normals_sensor,
|
||||||
|
field=DisplayField(
|
||||||
|
domain_id=assets.field_domain_id,
|
||||||
|
shape_hw=(int(island.shape[0]), int(island.shape[1])),
|
||||||
|
scalar=freeze(
|
||||||
|
np.where(island, 1.0, 0.0).astype(np.float32), dtype=np.float32
|
||||||
|
),
|
||||||
|
valid=freeze(np.array(island, dtype=bool), dtype=np.bool_),
|
||||||
|
descriptor=descriptor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
widget.set_settings(
|
||||||
|
DisplaySettings(
|
||||||
|
housing_visible=False,
|
||||||
|
color_scale=ColorScale(
|
||||||
|
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The invalid colour is the sentinel the readback is classified by. ``draw`` reads it
|
||||||
|
# from the module, so patching the module is what the shader is given.
|
||||||
|
gl_backend.INVALID_COLOR = (0.0, 1.0, 0.0, 1.0)
|
||||||
|
# The colour a *valid* fragment gets comes from the tree's own lookup table, not from
|
||||||
|
# a hard-coded constant: the 2D canvas and the 3D surface must be coloured by one
|
||||||
|
# table, and a test that hard-coded the colour would stop noticing if they diverged.
|
||||||
|
valid_rgb = tuple(int(v) for v in lut_rgba("sequential")[-1, 0, :3])
|
||||||
|
|
||||||
|
widget.present(frame)
|
||||||
|
try:
|
||||||
|
assert pump(
|
||||||
|
lambda: getattr(renderer, "field_shape", None)
|
||||||
|
== (int(island.shape[0]), int(island.shape[1]))
|
||||||
|
), "the field texture was never uploaded"
|
||||||
|
for _ in range(8):
|
||||||
|
widget.repaint()
|
||||||
|
qt_app.processEvents()
|
||||||
|
image = widget.grabFramebuffer()
|
||||||
|
assert not image.isNull(), "Qt returned no framebuffer"
|
||||||
|
image = image.convertToFormat(QImage.Format.Format_RGB888)
|
||||||
|
width, height = image.width(), image.height()
|
||||||
|
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
|
||||||
|
pixels = (
|
||||||
|
np.frombuffer(buffer, dtype=np.uint8)
|
||||||
|
.reshape(height, image.bytesPerLine())[:, : width * 3]
|
||||||
|
.reshape(height, width, 3)
|
||||||
|
.copy()
|
||||||
|
)
|
||||||
|
background = classify(pixels, BACKGROUND_RGB, 6)
|
||||||
|
painted_valid = classify(pixels, valid_rgb, 14) & ~background
|
||||||
|
painted_invalid = classify(pixels, INVALID_SENTINEL, 14) & ~background
|
||||||
|
texel, covered = reference_texels(
|
||||||
|
np.asarray(frame.positions_sensor_m, dtype=np.float64),
|
||||||
|
uvs,
|
||||||
|
assets.sensing_part().triangles,
|
||||||
|
widget.camera().matrices(widget.viewport_pixels()),
|
||||||
|
widget.viewport_pixels(),
|
||||||
|
)
|
||||||
|
# The reference is built in GL window coordinates (y up); the grab is top-down.
|
||||||
|
return (
|
||||||
|
measure(
|
||||||
|
island,
|
||||||
|
texel[::-1],
|
||||||
|
covered[::-1],
|
||||||
|
painted_valid,
|
||||||
|
painted_invalid,
|
||||||
|
),
|
||||||
|
renderer,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
widget.release_resources()
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------ the test
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def dense_region(qt_app):
|
||||||
|
assets, island, uvs, preset = _dense_scene()
|
||||||
|
numbers, _ = _render_region(
|
||||||
|
qt_app, assets, island, uvs, preset, DENSE_VIEWPORT
|
||||||
|
)
|
||||||
|
return numbers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def shipped_region(qt_app):
|
||||||
|
"""The shipped asset, seen face-on and magnified: the exact case."""
|
||||||
|
assets, island, uvs, preset = _shipped_scene("front")
|
||||||
|
numbers, _ = _render_region(qt_app, assets, island, uvs, preset, REAL_VIEWPORT)
|
||||||
|
return numbers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def shipped_default_region(qt_app):
|
||||||
|
"""The shipped asset at *its own* default preset: a three-quarter, minified view."""
|
||||||
|
assets, island, uvs, _ = _shipped_scene("front")
|
||||||
|
default = assets.camera_presets[0].name
|
||||||
|
numbers, _ = _render_region(qt_app, assets, island, uvs, default, REAL_VIEWPORT)
|
||||||
|
return numbers
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dense_scene_paints_exactly_the_island(dense_region):
|
||||||
|
"""The controlled case: one vertex per texel, a hole and a notch in the island.
|
||||||
|
|
||||||
|
Nothing is tolerated here. The plate is seen face-on and every texel is magnified,
|
||||||
|
so neither a rounding tie nor a skipped texel can arise: the painted region must be
|
||||||
|
the island exactly.
|
||||||
|
"""
|
||||||
|
assert_agreement(dense_region, "dense")
|
||||||
|
assert dense_region["island_grey_texels"] == 0, dense_region
|
||||||
|
assert dense_region["extra_valid_texels"] == 0, dense_region
|
||||||
|
assert dense_region["grey_more_than_one_texel_inside"] == 0, dense_region
|
||||||
|
assert dense_region["mixed_texels"] == 0, dense_region
|
||||||
|
assert dense_region["missing_pixels"] == 0, dense_region
|
||||||
|
assert dense_region["extra_pixels"] == 0, dense_region
|
||||||
|
# Nothing may be left uncovered here: the mesh spans the whole domain.
|
||||||
|
assert dense_region["island_uncovered_texels"] == 0, dense_region
|
||||||
|
assert dense_region["painted_valid_texels"] == dense_region["island_texels"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_package_paints_exactly_the_island(shipped_region):
|
||||||
|
"""The real acceptance: the shipped asset, face-on, every texel magnified.
|
||||||
|
|
||||||
|
Zero tolerance, as the design asks: no island texel is grey, no texel outside the
|
||||||
|
island is painted as a measurement, and no pixel disagrees at all. Before the change
|
||||||
|
the same measurement was 5763 grey island texels — 5476 of them strictly inside the
|
||||||
|
island — and 26840 wrong pixels.
|
||||||
|
"""
|
||||||
|
numbers = shipped_region
|
||||||
|
assert_agreement(numbers, "shipped")
|
||||||
|
assert numbers["island_grey_texels"] == 0, numbers
|
||||||
|
assert numbers["extra_valid_texels"] == 0, numbers
|
||||||
|
assert numbers["mixed_texels"] == 0, numbers
|
||||||
|
assert numbers["missing_pixels"] == 0, numbers
|
||||||
|
assert numbers["extra_pixels"] == 0, numbers
|
||||||
|
assert numbers["painted_valid_texels"] == numbers["island_covered_texels"], numbers
|
||||||
|
# Reported, not asserted: the mesh's UV footprint is a sub-rectangle of the domain,
|
||||||
|
# so these island texels have no triangle over them at all.
|
||||||
|
assert numbers["island_uncovered_texels"] > 0, numbers
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_default_view_keeps_the_residue_on_the_rim(shipped_default_region):
|
||||||
|
"""The view an operator actually opens: three-quarter, and minified.
|
||||||
|
|
||||||
|
Nothing may be grey except within two texels of the island's rim, and almost nothing
|
||||||
|
may be: measured as 1 texel of 25539 (0.0039 %) — the one texel the GPU never resolved
|
||||||
|
as itself, because NEAREST sampling of a 300x280 domain onto a plate of about half
|
||||||
|
that many pixels cannot show every texel, and a validity channel may not be averaged.
|
||||||
|
(The old per-vertex path, on this same view, left 5763 texels grey with 5476 of them
|
||||||
|
strictly inside the island.)
|
||||||
|
"""
|
||||||
|
numbers = shipped_default_region
|
||||||
|
assert_agreement(numbers, "shipped-default")
|
||||||
|
assert numbers["island_grey_percent"] < 0.05, numbers
|
||||||
|
assert numbers["interior_painted_invalid"] == 0, numbers
|
||||||
|
# The residual at this view is a rim rounding tie and nothing more: at most one texel
|
||||||
|
# of the island missed and at most one texel just outside it claimed, both adjacent to
|
||||||
|
# the boundary, and no disagreement anywhere but the one-texel band.
|
||||||
|
assert numbers["island_grey_texels"] <= 1, numbers
|
||||||
|
assert numbers["extra_valid_texels"] <= 1, numbers
|
||||||
|
assert numbers["grey_more_than_one_texel_inside"] == 0, numbers
|
||||||
|
assert numbers["extra_more_than_one_texel_outside"] == 0, numbers
|
||||||
@@ -24,8 +24,10 @@ from orisys_studio.scene3d.camera import (
|
|||||||
OrbitCamera,
|
OrbitCamera,
|
||||||
)
|
)
|
||||||
from orisys_studio.scene3d.gl_backend import (
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
|
FIELD_BYTES_PER_TEXEL,
|
||||||
LUT_SIZE,
|
LUT_SIZE,
|
||||||
STRIDE_FLOATS,
|
STRIDE_FLOATS,
|
||||||
|
VALID_BYTES_PER_TEXEL,
|
||||||
estimate_gpu_bytes,
|
estimate_gpu_bytes,
|
||||||
is_software_renderer,
|
is_software_renderer,
|
||||||
lut_rgba,
|
lut_rgba,
|
||||||
@@ -259,7 +261,14 @@ def test_shader_sources_are_two_dialects_of_one_body():
|
|||||||
len(shader_header("gles30")):
|
len(shader_header("gles30")):
|
||||||
]
|
]
|
||||||
assert "texture(u_lut" in desktop_fragment and "texture(u_lut" in es_fragment
|
assert "texture(u_lut" in desktop_fragment and "texture(u_lut" in es_fragment
|
||||||
assert "isnan" in desktop_fragment, "an unmeasured value must not be colour-mapped"
|
# The surface reads *two textures* and decides validity from one of them. There is
|
||||||
|
# no per-vertex scalar or validity attribute, and no ``isnan`` rule: the NaN that
|
||||||
|
# rule used to catch was produced by the CPU gather this path replaced.
|
||||||
|
for marker in ("uniform sampler2D u_field", "uniform sampler2D u_valid",
|
||||||
|
"u_inv_domain_size", "a_uv", "v_uv"):
|
||||||
|
assert marker in desktop_vertex or marker in desktop_fragment, marker
|
||||||
|
assert "a_scalar" not in desktop_vertex and "a_valid" not in desktop_vertex
|
||||||
|
assert "isnan" not in desktop_fragment
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_profile_or_program_is_refused():
|
def test_an_unknown_profile_or_program_is_refused():
|
||||||
@@ -301,35 +310,33 @@ def _plate(**kwargs):
|
|||||||
return synthetic.poc_scene().sensing_part()
|
return synthetic.poc_scene().sensing_part()
|
||||||
|
|
||||||
|
|
||||||
def test_packing_lays_out_position_normal_scalar_valid():
|
def test_packing_lays_out_position_normal_and_uv():
|
||||||
|
"""Position, normal, UV: the measurement is not in the vertex buffer any more."""
|
||||||
part = _plate()
|
part = _plate()
|
||||||
count = int(part.positions_sensor_m.shape[0])
|
count = int(part.positions_sensor_m.shape[0])
|
||||||
scalar = np.linspace(0.0, 1.0, count, dtype=np.float32)
|
uvs = synthetic.poc_scene().binding.uvs
|
||||||
valid = np.ones(count, dtype=bool)
|
rows = pack_part(part, uvs=uvs)
|
||||||
rows = pack_part(part, scalar=scalar, valid=valid)
|
|
||||||
assert rows.shape == (count, STRIDE_FLOATS)
|
assert rows.shape == (count, STRIDE_FLOATS)
|
||||||
assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"]
|
assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"]
|
||||||
assert not rows.flags["WRITEABLE"], "the packed buffer must be read-only"
|
assert not rows.flags["WRITEABLE"], "the packed buffer must be read-only"
|
||||||
assert np.allclose(rows[:, 0:3], part.positions_sensor_m)
|
assert np.allclose(rows[:, 0:3], part.positions_sensor_m)
|
||||||
assert np.allclose(rows[:, 3:6], part.normals_sensor)
|
assert np.allclose(rows[:, 3:6], part.normals_sensor)
|
||||||
assert np.allclose(rows[:, 6], scalar)
|
assert np.allclose(rows[:, 6:8], uvs)
|
||||||
assert np.allclose(rows[:, 7], 1.0)
|
# Slots 2 and 3 of the surface program are gone, not reused: the attributes that
|
||||||
|
# lived there were the per-vertex scalar and validity.
|
||||||
|
assert STRIDE_FLOATS == 8
|
||||||
|
|
||||||
|
|
||||||
def test_packing_a_scene_without_a_measurement_marks_everything_invalid():
|
def test_packing_a_part_without_a_uv_map_leaves_the_uv_slot_empty():
|
||||||
|
"""The housing is never coloured by the measurement, so it carries no mapping."""
|
||||||
rows = pack_part(_plate())
|
rows = pack_part(_plate())
|
||||||
assert np.isnan(rows[:, 6]).all(), "an unmeasured scalar must be NaN, not zero"
|
assert np.allclose(rows[:, 6:8], 0.0)
|
||||||
assert np.allclose(rows[:, 7], 0.0), "an unmeasured vertex must be invalid"
|
|
||||||
|
|
||||||
|
|
||||||
def test_packing_refuses_a_mismatched_length():
|
def test_packing_refuses_a_mismatched_uv_count():
|
||||||
part = _plate()
|
with pytest.raises(Exception) as error_:
|
||||||
with pytest.raises(Exception) as scalar_error:
|
pack_part(_plate(), uvs=np.zeros((3, 2), dtype=np.float32))
|
||||||
pack_part(part, scalar=np.zeros(3, dtype=np.float32))
|
assert "UVs for" in str(error_.value)
|
||||||
assert "scalars for" in str(scalar_error.value)
|
|
||||||
with pytest.raises(Exception) as valid_error:
|
|
||||||
pack_part(part, valid=np.zeros(2, dtype=bool))
|
|
||||||
assert "validity flags for" in str(valid_error.value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_scene_part_indices_are_uint32_and_read_only():
|
def test_scene_part_indices_are_uint32_and_read_only():
|
||||||
@@ -339,7 +346,7 @@ def test_scene_part_indices_are_uint32_and_read_only():
|
|||||||
assert int(indices.max()) < vertices.shape[0]
|
assert int(indices.max()) < vertices.shape[0]
|
||||||
|
|
||||||
|
|
||||||
def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
|
def test_the_gpu_budget_counts_both_parts_one_dynamic_copy_and_the_field_textures():
|
||||||
assets = synthetic.poc_scene()
|
assets = synthetic.poc_scene()
|
||||||
budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface")
|
budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface")
|
||||||
plate = assets.sensing_part()
|
plate = assets.sensing_part()
|
||||||
@@ -352,6 +359,8 @@ def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
|
|||||||
+ housing.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
|
+ housing.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
|
||||||
+ housing.triangles.size * 4
|
+ housing.triangles.size * 4
|
||||||
+ LUT_SIZE * 4
|
+ LUT_SIZE * 4
|
||||||
|
# The two field textures: R16F scalar + R8 validity over the scene's domain.
|
||||||
|
+ assets.shape_hw[0] * assets.shape_hw[1] * (FIELD_BYTES_PER_TEXEL + VALID_BYTES_PER_TEXEL)
|
||||||
)
|
)
|
||||||
assert budget == expected
|
assert budget == expected
|
||||||
assert budget < 1024 * 1024, "the PoC scene should be tiny"
|
assert budget < 1024 * 1024, "the PoC scene should be tiny"
|
||||||
@@ -360,11 +369,14 @@ def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
|
|||||||
def test_the_rest_surface_claims_no_measurement():
|
def test_the_rest_surface_claims_no_measurement():
|
||||||
assets = synthetic.poc_scene()
|
assets = synthetic.poc_scene()
|
||||||
frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1))
|
frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1))
|
||||||
count = int(assets.sensing_part().positions_sensor_m.shape[0])
|
shape = assets.shape_hw
|
||||||
assert frame.scalar.shape == (count,) and frame.valid.shape == (count,)
|
assert frame.field.scalar.shape == shape and frame.field.valid.shape == shape
|
||||||
assert not frame.valid.any(), "a reference frame measures nothing"
|
assert frame.field.shape_hw == shape
|
||||||
assert np.isnan(frame.scalar).all(), "an unavailable scalar is NaN, not zero"
|
assert frame.field.domain_id == assets.field_domain_id
|
||||||
assert frame.scalar_descriptor.quantity == "unavailable"
|
assert not frame.field.valid.any(), "a reference frame measures nothing"
|
||||||
assert frame.scalar_descriptor.quality == "display_only"
|
assert np.isnan(frame.field.scalar).all(), "an unavailable scalar is NaN, not zero"
|
||||||
|
assert frame.field.descriptor.quantity == "unavailable"
|
||||||
|
assert frame.field.descriptor.quality == "display_only"
|
||||||
|
assert not frame.deformed, "a reference frame is the asset's own geometry"
|
||||||
assert frame.geometry_id == assets.geometry_id
|
assert frame.geometry_id == assets.geometry_id
|
||||||
assert frame.sensing_part_id == assets.sensing_part().part_id
|
assert frame.sensing_part_id == assets.sensing_part().part_id
|
||||||
|
|||||||
@@ -67,24 +67,71 @@ def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]:
|
|||||||
return stops
|
return stops
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=8)
|
def ramp_for_theme(theme: str = "dark") -> str:
|
||||||
def _lut_from_ramp(ramp: str) -> np.ndarray:
|
"""The theme's own ``flow_ramp`` string, resolved and never empty.
|
||||||
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
|
|
||||||
|
|
||||||
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user
|
This is the **single** ramp both views build their lookup table from: the 2D
|
||||||
colormaps. Invalid input never raises: it falls back to
|
canvas through :func:`flow_colormap`, the 3D surface through the field it is
|
||||||
:data:`FLOW_RAMP_FALLBACK`.
|
handed (``scene3d.contracts.DisplayField.ramp``). One string, two tables, so the
|
||||||
|
two pictures cannot be coloured by different ramps — which they were, until the
|
||||||
|
texture-first path: the 3D pane had its own hand-rolled stop list and the two
|
||||||
|
disagreed by up to 218/255 per channel.
|
||||||
|
"""
|
||||||
|
return str(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK) or FLOW_RAMP_FALLBACK)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def ramp_rgb_table(ramp: str) -> np.ndarray:
|
||||||
|
"""A 256x3 **RGB** table from ``#RRGGBB`` ramp stops.
|
||||||
|
|
||||||
|
One interpolation, one rounding, one table shape: every consumer of the ramp
|
||||||
|
(the BGR table ``cv2`` wants, the RGBA texture the shader samples) is a view of
|
||||||
|
this array, so "the legend cannot disagree with the picture" holds for the 3D
|
||||||
|
pane too and not only for the 2D one. Invalid input never raises: it falls back
|
||||||
|
to :data:`FLOW_RAMP_FALLBACK`.
|
||||||
"""
|
"""
|
||||||
stops = _parse_ramp(ramp)
|
stops = _parse_ramp(ramp)
|
||||||
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
|
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
|
||||||
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
|
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
|
||||||
positions = np.linspace(0.0, 1.0, _LUT_STEPS)
|
positions = np.linspace(0.0, 1.0, _LUT_STEPS)
|
||||||
span = np.linspace(0.0, 1.0, len(stops))
|
span = np.linspace(0.0, 1.0, len(stops))
|
||||||
lut = np.empty((_LUT_STEPS, 1, 3), dtype=np.uint8)
|
table = np.empty((_LUT_STEPS, 3), dtype=np.uint8)
|
||||||
for channel in range(3): # source order: R, G, B
|
for channel in range(3): # source order: R, G, B
|
||||||
values = np.interp(positions, span, [stop[channel] for stop in stops])
|
values = np.interp(positions, span, [stop[channel] for stop in stops])
|
||||||
lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
|
table[:, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
|
||||||
return lut[:, :, ::-1].copy()
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def ramp_rgba_table(ramp: str, *, diverging: bool = False) -> np.ndarray:
|
||||||
|
"""A 256x1 RGBA8 texture table for the shader, from the same stops.
|
||||||
|
|
||||||
|
``diverging`` mirrors the ramp through its own midpoint: the safe choice for a
|
||||||
|
signed quantity (a deformation response), and never used for an unsigned one. The
|
||||||
|
mirroring happens on the *stops* rather than on the interpolated table, so the
|
||||||
|
result is still exactly one interpolation of a legal ramp.
|
||||||
|
"""
|
||||||
|
stops = _parse_ramp(ramp)
|
||||||
|
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
|
||||||
|
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
|
||||||
|
if diverging:
|
||||||
|
ramp = ",".join(
|
||||||
|
"#%02X%02X%02X" % stop for stop in [*stops, *reversed(stops)]
|
||||||
|
)
|
||||||
|
table = ramp_rgb_table(ramp)
|
||||||
|
rgba = np.empty((_LUT_STEPS, 1, 4), dtype=np.uint8)
|
||||||
|
rgba[:, 0, :3] = table
|
||||||
|
rgba[:, 0, 3] = 255
|
||||||
|
return rgba
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def _lut_from_ramp(ramp: str) -> np.ndarray:
|
||||||
|
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
|
||||||
|
|
||||||
|
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user
|
||||||
|
colormaps.
|
||||||
|
"""
|
||||||
|
return ramp_rgb_table(ramp)[:, None, ::-1].copy()
|
||||||
|
|
||||||
|
|
||||||
def flow_colormap(theme: str = "dark") -> np.ndarray:
|
def flow_colormap(theme: str = "dark") -> np.ndarray:
|
||||||
@@ -93,7 +140,7 @@ def flow_colormap(theme: str = "dark") -> np.ndarray:
|
|||||||
Cached on the resolved ramp string rather than the theme name, so a theme
|
Cached on the resolved ramp string rather than the theme name, so a theme
|
||||||
switch is picked up as soon as :func:`build_qss` reloads the tokens.
|
switch is picked up as soon as :func:`build_qss` reloads the tokens.
|
||||||
"""
|
"""
|
||||||
return _lut_from_ramp(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK))
|
return _lut_from_ramp(ramp_for_theme(theme))
|
||||||
|
|
||||||
|
|
||||||
def arrow_display_scale(draw_style: str = "box") -> float:
|
def arrow_display_scale(draw_style: str = "box") -> float:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
CompileOptions,
|
CompileOptions,
|
||||||
CompileReport,
|
CompileReport,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
DisplayField,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
ExpectedGeometry,
|
ExpectedGeometry,
|
||||||
F32,
|
F32,
|
||||||
@@ -45,7 +46,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
SurfaceMapping,
|
SurfaceMapping,
|
||||||
ThreeDError,
|
ThreeDError,
|
||||||
U32,
|
U32,
|
||||||
VertexFields,
|
|
||||||
diagnostic,
|
diagnostic,
|
||||||
error,
|
error,
|
||||||
freeze,
|
freeze,
|
||||||
@@ -72,6 +73,7 @@ __all__ = [
|
|||||||
"CompileOptions",
|
"CompileOptions",
|
||||||
"CompileReport",
|
"CompileReport",
|
||||||
"Diagnostic",
|
"Diagnostic",
|
||||||
|
"DisplayField",
|
||||||
"DisplaySettings",
|
"DisplaySettings",
|
||||||
"ExpectedGeometry",
|
"ExpectedGeometry",
|
||||||
"F32",
|
"F32",
|
||||||
@@ -95,7 +97,6 @@ __all__ = [
|
|||||||
"SurfaceMapping",
|
"SurfaceMapping",
|
||||||
"ThreeDError",
|
"ThreeDError",
|
||||||
"U32",
|
"U32",
|
||||||
"VertexFields",
|
|
||||||
"diagnostic",
|
"diagnostic",
|
||||||
"error",
|
"error",
|
||||||
"freeze",
|
"freeze",
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"""Composing a render frame: sample, deform, update normals. Pure NumPy.
|
"""Composing a render frame: validate, (optionally) deform, hand the field on. NumPy.
|
||||||
|
|
||||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||||
|
|
||||||
This module decides what a colour and a vertex position *mean*, so the rules from
|
The composer no longer *samples*. It used to gather the field onto every sensing
|
||||||
§3.2 of the design document are its reason to exist:
|
vertex and put the result on the frame, and that is what made the 3D pane disagree
|
||||||
|
with the 2D one: a vertex whose four taps touched one invalid pixel became ``NaN``,
|
||||||
|
and the shader's ``isnan`` rule then painted the whole triangle grey. The field now
|
||||||
|
travels whole — :class:`~orisys_app_shared.scene3d.contracts.DisplayField` carries the
|
||||||
|
frame's own ``[H,W]`` grid, the same array the 2D canvas paints, and the surface
|
||||||
|
samples it per *fragment* through the mesh's UVs. Same array, same validity, two
|
||||||
|
views: they agree because there is only one of them.
|
||||||
|
|
||||||
|
This module still decides what a colour and a vertex position *mean*, so the rules
|
||||||
|
from §3.2 of the design document remain its reason to exist:
|
||||||
|
|
||||||
* ``scalar`` is never multiplied by the display gain. The colour scale describes the
|
* ``scalar`` is never multiplied by the display gain. The colour scale describes the
|
||||||
measurement; the gain describes only how far the drawing is exaggerated. A gain
|
measurement; the gain describes only how far the drawing is exaggerated. A gain
|
||||||
@@ -13,9 +22,8 @@ This module decides what a colour and a vertex position *mean*, so the rules fro
|
|||||||
instead of leaving the UI to guess.
|
instead of leaving the UI to guess.
|
||||||
* without a trustworthy displacement the surface is coloured in place, and the total
|
* without a trustworthy displacement the surface is coloured in place, and the total
|
||||||
force is never turned into a per-vertex height.
|
force is never turned into a per-vertex height.
|
||||||
* a reference frame measures nothing: every validity flag is false and the scalar is
|
* a reference frame measures nothing: the field arrives with every validity flag
|
||||||
absent, so the UI hides the colour bar rather than leaving the previous one on
|
false and no scalar.
|
||||||
screen.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,8 +35,8 @@ import numpy as np
|
|||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
|
DisplayField,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
FieldDescriptor,
|
|
||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
Quality,
|
Quality,
|
||||||
RenderFrame,
|
RenderFrame,
|
||||||
@@ -47,7 +55,10 @@ from orisys_app_shared.scene3d.overlays import (
|
|||||||
build_normal_overlay,
|
build_normal_overlay,
|
||||||
mesh_span_from_mapping,
|
mesh_span_from_mapping,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.sampling import SurfaceSampler
|
from orisys_app_shared.scene3d.sampling import (
|
||||||
|
check_field_domain,
|
||||||
|
sample_vertex_displacement,
|
||||||
|
)
|
||||||
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
|
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
|
||||||
|
|
||||||
#: Reported when a display gain is applied to a displacement that is only
|
#: Reported when a display gain is applied to a displacement that is only
|
||||||
@@ -74,8 +85,7 @@ class SceneComposer:
|
|||||||
looking at.
|
looking at.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None:
|
def __init__(self, *, ramp: str = "") -> None:
|
||||||
self._sampler = sampler or SurfaceSampler()
|
|
||||||
self.last_stats = ComposeStats()
|
self.last_stats = ComposeStats()
|
||||||
#: Rest positions and normals, keyed by ``asset_hash``: with no displacement to
|
#: Rest positions and normals, keyed by ``asset_hash``: with no displacement to
|
||||||
#: draw, the picture is the asset's own geometry, and recomputing its normals
|
#: draw, the picture is the asset's own geometry, and recomputing its normals
|
||||||
@@ -83,6 +93,9 @@ class SceneComposer:
|
|||||||
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
||||||
#: Last UV grid of "arrow was on" samples for flow hysteresis.
|
#: Last UV grid of "arrow was on" samples for flow hysteresis.
|
||||||
self._flow_previously_on: Optional[np.ndarray] = None
|
self._flow_previously_on: Optional[np.ndarray] = None
|
||||||
|
#: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from.
|
||||||
|
#: Set by the view when the theme changes; empty means this build's default.
|
||||||
|
self.ramp = str(ramp)
|
||||||
|
|
||||||
def compose(
|
def compose(
|
||||||
self,
|
self,
|
||||||
@@ -96,23 +109,67 @@ class SceneComposer:
|
|||||||
self._check(assets, frame)
|
self._check(assets, frame)
|
||||||
for item in validate_surface_frame(frame):
|
for item in validate_surface_frame(frame):
|
||||||
# A frame with, say, non-finite scalars marked valid is still usable:
|
# A frame with, say, non-finite scalars marked valid is still usable:
|
||||||
# the vertex ends up invalid. Reported rather than refused.
|
# the fragment ends up invalid. Reported rather than refused.
|
||||||
if item.severity == "error":
|
if item.severity == "error":
|
||||||
raise error(item.code, item.message, **dict(item.details))
|
raise error(item.code, item.message, **dict(item.details))
|
||||||
part = assets.sensing_part()
|
part = assets.sensing_part()
|
||||||
|
# The field, whole. No gather, no NaN repair, no per-vertex copy: the render
|
||||||
|
# frame carries the acquisition frame's own arrays, which is what makes the
|
||||||
|
# 2D and 3D pictures the same picture. ``validate_surface_frame`` above has
|
||||||
|
# already checked shapes and dtypes; ``check_field_domain`` repeats the
|
||||||
|
# geometry/domain identity against the binding, which is the identity the
|
||||||
|
# *texture* is addressed by.
|
||||||
sampling_started = time.perf_counter()
|
sampling_started = time.perf_counter()
|
||||||
fields = self._sampler.sample(frame, assets.binding)
|
check_field_domain(frame, assets.binding)
|
||||||
|
field = DisplayField(
|
||||||
|
domain_id=frame.field_domain_id,
|
||||||
|
shape_hw=(int(frame.scalar.shape[0]), int(frame.scalar.shape[1])),
|
||||||
|
scalar=frame.scalar,
|
||||||
|
valid=frame.valid,
|
||||||
|
descriptor=frame.scalar_descriptor,
|
||||||
|
ramp=self.ramp,
|
||||||
|
)
|
||||||
self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0
|
self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0
|
||||||
rest_positions, rest_normals = self._rest(assets)
|
rest_positions, rest_normals = self._rest(assets)
|
||||||
diagnostics: list[Diagnostic] = []
|
diagnostics: list[Diagnostic] = []
|
||||||
|
|
||||||
positions = rest_positions
|
positions = rest_positions
|
||||||
|
normals = rest_normals
|
||||||
quality = frame.displacement_quality
|
quality = frame.displacement_quality
|
||||||
deformed = bool(settings.deform_enabled and fields.displacement_sensor_m is not None)
|
# Deformation is a *vertex-stage* quantity: it moves the mesh, so it is the one
|
||||||
|
# thing that still has to reach the CPU. It is gathered only when it is asked
|
||||||
|
# for — never for colour — and it is what ``RenderFrame.deformed`` reports, so
|
||||||
|
# the GPU knows whether the vertex buffer needs re-uploading at all.
|
||||||
|
displaced = (
|
||||||
|
sample_vertex_displacement(frame, assets.binding)
|
||||||
|
if settings.deform_enabled and frame.displacement_sensor_m is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
deformed = bool(settings.deform_enabled and displaced is not None)
|
||||||
if deformed:
|
if deformed:
|
||||||
gain = float(settings.display_gain)
|
gain = float(settings.display_gain)
|
||||||
displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64)
|
# A tap that was invalid leaves ``NaN`` here, and a vertex with no measured
|
||||||
positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement
|
# displacement does not move: ``nan_to_num`` is the honest reading of
|
||||||
|
# "absent", and it is *not* the colour path — it cannot make a fragment
|
||||||
|
# grey, because the colour no longer comes from the vertices at all.
|
||||||
|
displacement = np.nan_to_num(
|
||||||
|
np.asarray(displaced, dtype=np.float64), nan=0.0, posinf=0.0, neginf=0.0
|
||||||
|
)
|
||||||
|
moved = np.asarray(rest_positions, dtype=np.float64) + gain * displacement
|
||||||
|
if bool(np.isfinite(moved).all()):
|
||||||
|
positions = moved
|
||||||
|
else:
|
||||||
|
# A gain large enough to overflow is a display request that cannot be
|
||||||
|
# drawn, and drawing something else would be a different picture.
|
||||||
|
diagnostics.append(
|
||||||
|
diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
"the display gain overflows the surface coordinates: "
|
||||||
|
"colouring in place",
|
||||||
|
gain=f"{gain:g}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
deformed = False
|
||||||
if quality != "calibrated":
|
if quality != "calibrated":
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
diagnostic(
|
diagnostic(
|
||||||
@@ -123,7 +180,7 @@ class SceneComposer:
|
|||||||
gain=f"{gain:g}",
|
gain=f"{gain:g}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif settings.deform_enabled and fields.displacement_sensor_m is None:
|
elif settings.deform_enabled and frame.displacement_sensor_m is None:
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
diagnostic(
|
diagnostic(
|
||||||
"field_unavailable",
|
"field_unavailable",
|
||||||
@@ -133,11 +190,8 @@ class SceneComposer:
|
|||||||
normals_started = time.perf_counter()
|
normals_started = time.perf_counter()
|
||||||
# In place, the normals are the asset's own: the positions are untouched, so
|
# In place, the normals are the asset's own: the positions are untouched, so
|
||||||
# recomputing them from the same triangles gives the same answer every frame.
|
# recomputing them from the same triangles gives the same answer every frame.
|
||||||
normals = (
|
if deformed:
|
||||||
vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
|
normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
|
||||||
if deformed
|
|
||||||
else rest_normals
|
|
||||||
)
|
|
||||||
self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0
|
self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0
|
||||||
|
|
||||||
# Measured before the frame is built, so the frame carries *its own* cost: the
|
# Measured before the frame is built, so the frame carries *its own* cost: the
|
||||||
@@ -154,21 +208,18 @@ class SceneComposer:
|
|||||||
geometry_id=assets.geometry_id,
|
geometry_id=assets.geometry_id,
|
||||||
sensing_part_id=part.part_id,
|
sensing_part_id=part.part_id,
|
||||||
positions_sensor_m=(
|
positions_sensor_m=(
|
||||||
freeze(positions, dtype=np.float32)
|
freeze(positions, dtype=np.float32) if deformed else rest_positions
|
||||||
if deformed
|
|
||||||
else rest_positions
|
|
||||||
),
|
),
|
||||||
normals_sensor=(
|
normals_sensor=(
|
||||||
freeze(normals, dtype=np.float32) if deformed else rest_normals
|
freeze(normals, dtype=np.float32) if deformed else rest_normals
|
||||||
),
|
),
|
||||||
# The measurement, untouched by the display gain.
|
# The measurement, untouched by the display gain.
|
||||||
scalar=fields.scalar,
|
field=field,
|
||||||
valid=fields.valid,
|
|
||||||
scalar_descriptor=frame.scalar_descriptor,
|
|
||||||
diagnostics=tuple(diagnostics),
|
diagnostics=tuple(diagnostics),
|
||||||
# Carried through untouched: only the display side knows whether the frame
|
# Carried through untouched: only the display side knows whether the frame
|
||||||
# it is about to show is still the current one.
|
# it is about to show is still the current one.
|
||||||
computed_frame_id=frame.computed_frame_id,
|
computed_frame_id=frame.computed_frame_id,
|
||||||
|
deformed=deformed,
|
||||||
# What this frame cost to compose, carried *on the frame*: the shared
|
# What this frame cost to compose, carried *on the frame*: the shared
|
||||||
# ``last_stats`` object is rewritten by the next task, so a reader on the
|
# ``last_stats`` object is rewritten by the next task, so a reader on the
|
||||||
# GUI thread would report another frame's cost.
|
# GUI thread would report another frame's cost.
|
||||||
@@ -176,7 +227,11 @@ class SceneComposer:
|
|||||||
flow_arrows=flow_arrows,
|
flow_arrows=flow_arrows,
|
||||||
contact_arrow=contact_arrow,
|
contact_arrow=contact_arrow,
|
||||||
)
|
)
|
||||||
validate_render_frame(result, vertices=int(rest_positions.shape[0]))
|
validate_render_frame(
|
||||||
|
result,
|
||||||
|
vertices=int(rest_positions.shape[0]),
|
||||||
|
domain_shape=tuple(assets.shape_hw),
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _build_overlays(
|
def _build_overlays(
|
||||||
@@ -275,7 +330,7 @@ class SceneComposer:
|
|||||||
|
|
||||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||||
"""The static reference: nothing measured, so nothing coloured."""
|
"""The static reference: nothing measured, so nothing coloured."""
|
||||||
return build_rest_frame(assets, identity)
|
return build_rest_frame(assets, identity, ramp=self.ramp)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check(assets: SceneAssets, frame: SurfaceFieldFrame) -> None:
|
def _check(assets: SceneAssets, frame: SurfaceFieldFrame) -> None:
|
||||||
@@ -302,14 +357,23 @@ class SceneComposer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
def build_rest_frame(
|
||||||
|
assets: SceneAssets, identity: FrameIdentity, *, ramp: str = ""
|
||||||
|
) -> RenderFrame:
|
||||||
"""The asset's own geometry at rest, with no measurement attached.
|
"""The asset's own geometry at rest, with no measurement attached.
|
||||||
|
|
||||||
Shared by the composer and by the GL layer, which needs it to *clear* a stale
|
Shared by the composer and by the GL layer, which needs it to *clear* a stale
|
||||||
measurement after a refused frame or a disconnect.
|
measurement after a refused frame or a disconnect. The field is a full-size grid
|
||||||
|
of "no measurement": a reference model is not an observation, and the shader has
|
||||||
|
to be able to tell (every texel invalid) rather than being handed an empty frame.
|
||||||
"""
|
"""
|
||||||
part = assets.sensing_part()
|
part = assets.sensing_part()
|
||||||
count = int(part.positions_sensor_m.shape[0])
|
shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
|
||||||
|
if shape[0] <= 0 or shape[1] <= 0:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the scene declares no UV domain, so there is no field to clear to",
|
||||||
|
)
|
||||||
return RenderFrame(
|
return RenderFrame(
|
||||||
identity=identity,
|
identity=identity,
|
||||||
asset_hash=assets.asset_hash,
|
asset_hash=assets.asset_hash,
|
||||||
@@ -317,7 +381,14 @@ def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFram
|
|||||||
sensing_part_id=part.part_id,
|
sensing_part_id=part.part_id,
|
||||||
positions_sensor_m=part.positions_sensor_m,
|
positions_sensor_m=part.positions_sensor_m,
|
||||||
normals_sensor=part.normals_sensor,
|
normals_sensor=part.normals_sensor,
|
||||||
scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32),
|
field=DisplayField(
|
||||||
valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_),
|
domain_id=assets.field_domain_id,
|
||||||
scalar_descriptor=rest_descriptor(),
|
shape_hw=shape,
|
||||||
|
scalar=freeze(
|
||||||
|
np.full(shape, np.nan, dtype=np.float32), dtype=np.float32
|
||||||
|
),
|
||||||
|
valid=freeze(np.zeros(shape, dtype=bool), dtype=np.bool_),
|
||||||
|
descriptor=rest_descriptor(),
|
||||||
|
ramp=str(ramp),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -268,12 +268,26 @@ class MeshPart:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SurfaceBinding:
|
class SurfaceBinding:
|
||||||
"""How sensing vertices sample a UV field: up to four bilinear taps each."""
|
"""How the sensing mesh addresses the field: one UV per vertex, plus its taps.
|
||||||
|
|
||||||
|
Two things travel together, because they are two views of the same
|
||||||
|
correspondence — the mesh's own UV map onto the field domain:
|
||||||
|
|
||||||
|
* ``uvs`` is the **render-vertex** UV, one pair per vertex of
|
||||||
|
``MeshPart.positions_sensor_m``, already converted onto the domain's pixel grid
|
||||||
|
by ``legacy.uv_to_pixel`` (``x = u*(W-1)``, ``y = (1-v)*(H-1)`` when the map is
|
||||||
|
v-flipped). Converting once, here, is what keeps the flip and the scale in one
|
||||||
|
place: the fragment shader only normalises by the domain size.
|
||||||
|
* ``sample_indices``/``sample_weights`` are the bilinear taps of the same UVs.
|
||||||
|
Since the texture-first path they carry only the *geometry* input (a display
|
||||||
|
deformation), never the surface colour — the colour is the field texture.
|
||||||
|
"""
|
||||||
|
|
||||||
part_id: str
|
part_id: str
|
||||||
geometry_id: str
|
geometry_id: str
|
||||||
field_domain_id: str
|
field_domain_id: str
|
||||||
shape_hw: tuple[int, int]
|
shape_hw: tuple[int, int]
|
||||||
|
uvs: F32 # [N,2], domain pixel coordinates, same order as the render vertices
|
||||||
sample_indices: I32 # [N,4], y*W+x, unused = -1
|
sample_indices: I32 # [N,4], y*W+x, unused = -1
|
||||||
sample_weights: F32 # [N,4], non-negative, used weights sum to 1
|
sample_weights: F32 # [N,4], non-negative, used weights sum to 1
|
||||||
|
|
||||||
@@ -627,12 +641,37 @@ class DisplaySettings:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class VertexFields:
|
class DisplayField:
|
||||||
"""A UV field sampled onto the sensing vertices."""
|
"""The one field both views draw, on the domain's own grid.
|
||||||
|
|
||||||
scalar: F32 # [N]
|
This object exists so that the two-dimensional canvas and the three-dimensional
|
||||||
valid: Mask # [N]
|
surface cannot disagree about what was measured. Before it, the 3D pane had a
|
||||||
displacement_sensor_m: Optional[F32] = None # [N,3]
|
*per-vertex* field: ``sampling.SurfaceSampler`` gathered each of the mesh's 1032
|
||||||
|
vertices from up to four domain pixels, and the shader interpolated those 1032
|
||||||
|
values across 1974 triangles. A vertex whose taps touched one invalid pixel
|
||||||
|
became ``NaN``, the shader's ``isnan`` rule then made it neutral grey, and a
|
||||||
|
triangle with two valid corners and one invalid one was drawn entirely grey —
|
||||||
|
measured on the shipped finger asset as **30.15 % of the island painted grey**
|
||||||
|
and a painted region that was p50 6.7 px / max 25.8 px wrong against the 2D view.
|
||||||
|
|
||||||
|
So the field travels whole. ``scalar``/``valid`` are the frame's own
|
||||||
|
``[H,W]`` grid — the same array the 2D canvas paints — and the surface samples
|
||||||
|
them per *fragment* through the mesh's UVs. There is no resampling step to get
|
||||||
|
wrong and no fallback: a scene whose package carries no UVs is refused at load
|
||||||
|
(see ``package.scene_from_contents``) rather than drawn untextured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The UV domain this field lives on. Both views check it against the scene's.
|
||||||
|
domain_id: str
|
||||||
|
shape_hw: tuple[int, int]
|
||||||
|
scalar: F32 # [H,W], the measurement before the display gain; NaN where invalid
|
||||||
|
valid: Mask # [H,W]
|
||||||
|
descriptor: FieldDescriptor
|
||||||
|
#: The resolved ``#RRGGBB,#RRGGBB,...`` ramp both views build their lookup table
|
||||||
|
#: from (``acquisition.arrows.ramp_for_theme``). One ramp, one range, one meaning:
|
||||||
|
#: the 2D canvas and the 3D surface are coloured by the same table because they are
|
||||||
|
#: handed the same string. Empty means "this build's default ramp".
|
||||||
|
ramp: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -645,13 +684,17 @@ class RenderFrame:
|
|||||||
sensing_part_id: str
|
sensing_part_id: str
|
||||||
positions_sensor_m: F32 # [N,3], display displacement already applied
|
positions_sensor_m: F32 # [N,3], display displacement already applied
|
||||||
normals_sensor: F32 # [N,3]
|
normals_sensor: F32 # [N,3]
|
||||||
scalar: F32 # [N], before display gain
|
#: The measurement, on the domain's grid. Not per vertex: see :class:`DisplayField`.
|
||||||
valid: Mask # [N]
|
field: DisplayField
|
||||||
scalar_descriptor: FieldDescriptor
|
|
||||||
diagnostics: tuple[Diagnostic, ...] = ()
|
diagnostics: tuple[Diagnostic, ...] = ()
|
||||||
#: The acquisition frame the coloured values belong to, carried through from the
|
#: The acquisition frame the coloured values belong to, carried through from the
|
||||||
#: field frame so the *display* side can re-check it before presenting.
|
#: field frame so the *display* side can re-check it before presenting.
|
||||||
computed_frame_id: Optional[int] = None
|
computed_frame_id: Optional[int] = None
|
||||||
|
#: Whether ``positions_sensor_m``/``normals_sensor`` differ from the asset's own
|
||||||
|
#: rest geometry. ``False`` for every path the application can take today (no
|
||||||
|
#: adapter publishes a displacement), and the GPU then uploads the vertex buffer
|
||||||
|
#: once and never again: the per-frame work is the field texture and nothing else.
|
||||||
|
deformed: bool = False
|
||||||
#: What composing this frame cost, measured where it was composed. Carried per frame
|
#: What composing this frame cost, measured where it was composed. Carried per frame
|
||||||
#: rather than read from the composer's shared counter, which the next task rewrites.
|
#: rather than read from the composer's shared counter, which the next task rewrites.
|
||||||
compose_ms: float = 0.0
|
compose_ms: float = 0.0
|
||||||
@@ -864,8 +907,9 @@ def validate_surface_frame(frame: SurfaceFieldFrame) -> tuple[Diagnostic, ...]:
|
|||||||
|
|
||||||
|
|
||||||
def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnostic, ...]:
|
def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnostic, ...]:
|
||||||
"""The sampling table: shapes, ranges, weights and the domain it belongs to."""
|
"""The UV map and the sampling table: shapes, ranges, weights and the domain."""
|
||||||
for name, array in (
|
for name, array in (
|
||||||
|
("uvs", binding.uvs),
|
||||||
("sample_indices", binding.sample_indices),
|
("sample_indices", binding.sample_indices),
|
||||||
("sample_weights", binding.sample_weights),
|
("sample_weights", binding.sample_weights),
|
||||||
):
|
):
|
||||||
@@ -874,6 +918,7 @@ def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnos
|
|||||||
# binding was built for a different mesh (1032 rows against a 1005-vertex
|
# binding was built for a different mesh (1032 rows against a 1005-vertex
|
||||||
# export), not a differently shaped UV field.
|
# export), not a differently shaped UV field.
|
||||||
for name, array in (
|
for name, array in (
|
||||||
|
("uvs", binding.uvs),
|
||||||
("sample_indices", binding.sample_indices),
|
("sample_indices", binding.sample_indices),
|
||||||
("sample_weights", binding.sample_weights),
|
("sample_weights", binding.sample_weights),
|
||||||
):
|
):
|
||||||
@@ -883,15 +928,35 @@ def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnos
|
|||||||
f"{name} has {int(array.shape[0])} rows for {int(vertices)} sensing "
|
f"{name} has {int(array.shape[0])} rows for {int(vertices)} sensing "
|
||||||
"vertices: this binding belongs to a different mesh",
|
"vertices: this binding belongs to a different mesh",
|
||||||
)
|
)
|
||||||
|
_shape("uvs", binding.uvs, (vertices, 2))
|
||||||
_shape("sample_indices", binding.sample_indices, (vertices, 4))
|
_shape("sample_indices", binding.sample_indices, (vertices, 4))
|
||||||
_shape("sample_weights", binding.sample_weights, (vertices, 4))
|
_shape("sample_weights", binding.sample_weights, (vertices, 4))
|
||||||
|
if binding.uvs.dtype != np.float32:
|
||||||
|
raise error("field_domain_mismatch", "uvs must be float32")
|
||||||
|
if not bool(np.isfinite(binding.uvs).all()):
|
||||||
|
# A non-finite UV is not a UV: it would put a fragment's sample somewhere no
|
||||||
|
# domain pixel exists, and the texture lookup would be undefined.
|
||||||
|
raise error("field_domain_mismatch", "the render UVs must be finite")
|
||||||
|
height, width = binding.shape_hw
|
||||||
|
if height <= 0 or width <= 0:
|
||||||
|
raise error("field_domain_mismatch", f"invalid field shape {binding.shape_hw}")
|
||||||
|
# The UVs are already on the pixel grid, so "inside the domain" is a range check
|
||||||
|
# with a small tolerance: a compiler that stored u/v instead of pixel coordinates
|
||||||
|
# lands in [0,1] and fails here rather than sampling one texel in the corner.
|
||||||
|
if bool((binding.uvs < -0.5).any()) or bool(
|
||||||
|
(binding.uvs[:, 0] > float(width - 1) + 0.5).any()
|
||||||
|
) or bool((binding.uvs[:, 1] > float(height - 1) + 0.5).any()):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the render UVs are not on this binding's pixel grid "
|
||||||
|
f"(expected x in [0,{int(width) - 1}], y in [0,{int(height) - 1}])",
|
||||||
|
u_min=f"{float(binding.uvs.min()):.4f}",
|
||||||
|
u_max=f"{float(binding.uvs.max()):.4f}",
|
||||||
|
)
|
||||||
if binding.sample_indices.dtype != np.int32:
|
if binding.sample_indices.dtype != np.int32:
|
||||||
raise error("field_domain_mismatch", "sample_indices must be int32")
|
raise error("field_domain_mismatch", "sample_indices must be int32")
|
||||||
if binding.sample_weights.dtype != np.float32:
|
if binding.sample_weights.dtype != np.float32:
|
||||||
raise error("field_domain_mismatch", "sample_weights must be float32")
|
raise error("field_domain_mismatch", "sample_weights must be float32")
|
||||||
height, width = binding.shape_hw
|
|
||||||
if height <= 0 or width <= 0:
|
|
||||||
raise error("field_domain_mismatch", f"invalid field shape {binding.shape_hw}")
|
|
||||||
domain = height * width
|
domain = height * width
|
||||||
indices = binding.sample_indices
|
indices = binding.sample_indices
|
||||||
weights = binding.sample_weights
|
weights = binding.sample_weights
|
||||||
@@ -1156,20 +1221,47 @@ def validate_display_settings(settings: DisplaySettings) -> tuple[Diagnostic, ..
|
|||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
def validate_render_frame(frame: RenderFrame, *, vertices: int) -> None:
|
def validate_render_frame(
|
||||||
|
frame: RenderFrame, *, vertices: int, domain_shape: tuple[int, int] = (0, 0)
|
||||||
|
) -> None:
|
||||||
for name, array in (
|
for name, array in (
|
||||||
("positions_sensor_m", frame.positions_sensor_m),
|
("positions_sensor_m", frame.positions_sensor_m),
|
||||||
("normals_sensor", frame.normals_sensor),
|
("normals_sensor", frame.normals_sensor),
|
||||||
("scalar", frame.scalar),
|
|
||||||
("valid", frame.valid),
|
|
||||||
):
|
):
|
||||||
_readonly_c_contiguous(name, array)
|
_readonly_c_contiguous(name, array)
|
||||||
_shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3))
|
_shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3))
|
||||||
_shape("normals_sensor", frame.normals_sensor, (vertices, 3))
|
_shape("normals_sensor", frame.normals_sensor, (vertices, 3))
|
||||||
_shape("scalar", frame.scalar, (vertices,))
|
field = frame.field
|
||||||
_shape("valid", frame.valid, (vertices,))
|
for name, array in (("field.scalar", field.scalar), ("field.valid", field.valid)):
|
||||||
if frame.scalar.dtype != np.float32 or frame.valid.dtype != np.bool_:
|
_readonly_c_contiguous(name, array)
|
||||||
raise error("field_domain_mismatch", "render scalar is float32 and valid is bool")
|
if field.scalar.dtype != np.float32 or field.valid.dtype != np.bool_:
|
||||||
|
raise error("field_domain_mismatch", "the display field is float32 and bool")
|
||||||
|
if field.scalar.shape != field.valid.shape:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the display field's scalar and validity disagree in shape",
|
||||||
|
)
|
||||||
|
if field.scalar.ndim != 2:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
f"the display field must be [H,W], got {tuple(field.scalar.shape)}",
|
||||||
|
)
|
||||||
|
if tuple(field.shape_hw) != tuple(field.scalar.shape):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the display field's declared shape is not its array's shape",
|
||||||
|
declared=f"{int(field.shape_hw[0])}x{int(field.shape_hw[1])}",
|
||||||
|
array=f"{int(field.scalar.shape[0])}x{int(field.scalar.shape[1])}",
|
||||||
|
)
|
||||||
|
if domain_shape and tuple(field.shape_hw) != tuple(domain_shape):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the display field is not on the scene's UV domain",
|
||||||
|
field=f"{int(field.shape_hw[0])}x{int(field.shape_hw[1])}",
|
||||||
|
scene=f"{int(domain_shape[0])}x{int(domain_shape[1])}",
|
||||||
|
)
|
||||||
|
if not field.domain_id.strip():
|
||||||
|
raise error("field_domain_mismatch", "a display field must name its domain")
|
||||||
if not frame.asset_hash.strip() or not frame.geometry_id.strip():
|
if not frame.asset_hash.strip() or not frame.geometry_id.strip():
|
||||||
raise error("geometry_mismatch", "a render frame must name its asset and geometry")
|
raise error("geometry_mismatch", "a render frame must name its asset and geometry")
|
||||||
for label, arrows in (
|
for label, arrows in (
|
||||||
@@ -1315,15 +1407,15 @@ class BoxFrameAdapter(Protocol):
|
|||||||
) -> Optional[SurfaceFieldFrame]: ...
|
) -> Optional[SurfaceFieldFrame]: ...
|
||||||
|
|
||||||
|
|
||||||
class SurfaceSampler(Protocol):
|
|
||||||
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SceneComposer(Protocol):
|
class SceneComposer(Protocol):
|
||||||
def compose(
|
def compose(
|
||||||
self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings
|
self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings
|
||||||
) -> RenderFrame:
|
) -> RenderFrame:
|
||||||
"""CPU worker: validate identity/units, sample, deform, update normals."""
|
"""CPU worker: validate identity/units, deform, update normals.
|
||||||
|
|
||||||
|
It no longer *samples*: the frame's own ``[H,W]`` grid is what the surface
|
||||||
|
draws (``DisplayField``), so there is no per-vertex gather to get wrong.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||||
@@ -1349,7 +1441,7 @@ class GLDispatch(Protocol):
|
|||||||
class GLRenderer(Protocol):
|
class GLRenderer(Protocol):
|
||||||
def initialize(self, capabilities: GLCapabilities) -> None: ...
|
def initialize(self, capabilities: GLCapabilities) -> None: ...
|
||||||
def upload_scene(self, assets: SceneAssets) -> None: ...
|
def upload_scene(self, assets: SceneAssets) -> None: ...
|
||||||
def update_surface(self, frame: RenderFrame) -> None: ...
|
def update_field(self, frame: RenderFrame) -> None: ...
|
||||||
def draw(
|
def draw(
|
||||||
self,
|
self,
|
||||||
camera: CameraMatrices,
|
camera: CameraMatrices,
|
||||||
|
|||||||
@@ -30,6 +30,38 @@ from orisys_app_shared.scene3d.contracts import MeshPart, SurfaceBinding, error
|
|||||||
#: else is worse than no digest at all.
|
#: else is worse than no digest at all.
|
||||||
DIGEST_SCHEMA = "orisys-scene-digest/1"
|
DIGEST_SCHEMA = "orisys-scene-digest/1"
|
||||||
|
|
||||||
|
#: Which digest covers which array. Written down because the texture-first change had
|
||||||
|
#: to answer exactly this question before it could delete anything, and a table is a
|
||||||
|
#: better answer than a grep:
|
||||||
|
#:
|
||||||
|
#: ``geometry_id``
|
||||||
|
#: ``MeshPart.positions_sensor_m`` and ``MeshPart.triangles`` of the sensing part.
|
||||||
|
#: Normals are *not* covered (they are derived), which is what lets the same sensor
|
||||||
|
#: be shipped with better shading and the same identity.
|
||||||
|
#: ``binding_id``
|
||||||
|
#: ``SurfaceBinding.sample_indices``/``sample_weights``, plus the part id, the
|
||||||
|
#: geometry id and the domain shape. The render UVs are deliberately **not** here:
|
||||||
|
#: they are the field *correspondence*, and they are covered by the package's own
|
||||||
|
#: per-entry sha256 instead, so adding them did not move this digest.
|
||||||
|
#: ``source_geometry_digest``
|
||||||
|
#: the map's ``world_xyz``, in the source's own units — the one geometry identity a
|
||||||
|
#: device can reproduce without the metre statement.
|
||||||
|
#: ``field_domain_id``
|
||||||
|
#: the domain's shape, flip, scale, origin and the *content digest of the mapping*
|
||||||
|
#: (``valid_mask``, ``map_x``, ``map_y``). Not any per-vertex array.
|
||||||
|
#: ``asset_hash``
|
||||||
|
#: every packaged entry's ``content_digest`` plus the canonical manifest. This is
|
||||||
|
#: the only digest that covers ``sensing/surface_positions.npy``,
|
||||||
|
#: ``sensing/surface_normals.npy``, ``sensing/surface_valid.npy``,
|
||||||
|
#: ``sensing/source_vertex.npy`` and ``sensing/uv.npy``.
|
||||||
|
DIGEST_COVERAGE: Mapping[str, str] = {
|
||||||
|
"geometry_id": "sensing positions + triangles",
|
||||||
|
"binding_id": "sample_indices + sample_weights",
|
||||||
|
"source_geometry_digest": "the map's world_xyz",
|
||||||
|
"field_domain_id": "shape, flip, scale, origin, mapping content",
|
||||||
|
"asset_hash": "every packaged entry (including uvs and surface_*) + the manifest",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _array_bytes(array: Any) -> bytes:
|
def _array_bytes(array: Any) -> bytes:
|
||||||
"""C-contiguous little-endian bytes: the same on every machine."""
|
"""C-contiguous little-endian bytes: the same on every machine."""
|
||||||
|
|||||||
@@ -219,6 +219,28 @@ def uv_to_pixel(u: float, v: float, domain: LegacyFieldDomain) -> tuple[float, f
|
|||||||
return x, y
|
return x, y
|
||||||
|
|
||||||
|
|
||||||
|
def uvs_to_pixels(render_uv: np.ndarray, domain: LegacyFieldDomain) -> np.ndarray:
|
||||||
|
"""Every render vertex's UV, once, on the domain's pixel grid.
|
||||||
|
|
||||||
|
This is the conversion the *shader* used to have to redo per fragment (and could
|
||||||
|
not: it has no flip flag), and it is why ``SurfaceBinding.uvs`` holds pixel
|
||||||
|
coordinates rather than u/v. ``x = u*(W-1)`` and ``y = (1-v)*(H-1)`` when the map
|
||||||
|
is v-flipped — the same convention as :func:`uv_to_pixel` and
|
||||||
|
:func:`build_binding`, so the texture the fragment reads and the tap the sampler
|
||||||
|
would have read are the same pixel by construction.
|
||||||
|
"""
|
||||||
|
values = np.asarray(render_uv, dtype=np.float64)
|
||||||
|
if values.ndim != 2 or values.shape[1] != 2:
|
||||||
|
raise error("geometry_mismatch", f"render UVs must be [N,2], got {values.shape}")
|
||||||
|
height, width = domain.shape_hw
|
||||||
|
x = values[:, 0] * float(width - 1)
|
||||||
|
if domain.flip_v:
|
||||||
|
y = (1.0 - values[:, 1]) * float(height - 1)
|
||||||
|
else:
|
||||||
|
y = values[:, 1] * float(height - 1)
|
||||||
|
return freeze(np.stack([x, y], axis=1), dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
def split_at_uv_seams(
|
def split_at_uv_seams(
|
||||||
vertices: np.ndarray,
|
vertices: np.ndarray,
|
||||||
faces: np.ndarray,
|
faces: np.ndarray,
|
||||||
@@ -540,16 +562,27 @@ def build_binding(
|
|||||||
part_id: str,
|
part_id: str,
|
||||||
geometry_id_value: str,
|
geometry_id_value: str,
|
||||||
) -> SurfaceBinding:
|
) -> SurfaceBinding:
|
||||||
"""Bilinear taps for every render vertex, inside the declared domain.
|
"""The render vertices' UVs, on the domain's pixel grid, plus their bilinear taps.
|
||||||
|
|
||||||
Each row holds up to four ``y*W+x`` indices and their weights. A neighbour that
|
``uvs`` is the map the *shader* reads: one pixel coordinate per render vertex, in
|
||||||
would fall outside the domain contributes nothing and its weight is
|
the same order as the mesh's positions, already flipped and scaled, so the
|
||||||
redistributed, so the used weights always sum to exactly one and the unused
|
fragment program only normalises by the domain size. Each row of
|
||||||
entries are ``-1``/``0`` — the sampling table can never point outside the map
|
``sample_indices``/``sample_weights`` holds up to four ``y*W+x`` indices and their
|
||||||
it was built for.
|
weights for the same UV. A neighbour that would fall outside the domain
|
||||||
|
contributes nothing and its weight is redistributed, so the used weights always
|
||||||
|
sum to exactly one and the unused entries are ``-1``/``0`` — the sampling table
|
||||||
|
can never point outside the map it was built for.
|
||||||
|
|
||||||
|
The taps are computed from the **unrounded** UV (``uv_to_pixel`` on the float64
|
||||||
|
u/v) rather than from ``uvs``, and deliberately so: the sampling table is part of
|
||||||
|
``binding_id``, and deriving it from the float32 pixel coordinates would move that
|
||||||
|
digest for every existing package even though the correspondence had not changed
|
||||||
|
by even 1e-5 of a pixel. The two agree to within float32 rounding, which is far
|
||||||
|
below the distance between neighbouring texels.
|
||||||
"""
|
"""
|
||||||
height, width = domain.shape_hw
|
height, width = domain.shape_hw
|
||||||
count = len(render_uv)
|
count = len(render_uv)
|
||||||
|
pixels = uvs_to_pixels(render_uv, domain)
|
||||||
indices = np.full((count, 4), -1, dtype=np.int32)
|
indices = np.full((count, 4), -1, dtype=np.int32)
|
||||||
weights = np.zeros((count, 4), dtype=np.float32)
|
weights = np.zeros((count, 4), dtype=np.float32)
|
||||||
for row in range(count):
|
for row in range(count):
|
||||||
@@ -584,6 +617,7 @@ def build_binding(
|
|||||||
geometry_id=geometry_id_value,
|
geometry_id=geometry_id_value,
|
||||||
field_domain_id=domain.domain_id,
|
field_domain_id=domain.domain_id,
|
||||||
shape_hw=domain.shape_hw,
|
shape_hw=domain.shape_hw,
|
||||||
|
uvs=pixels,
|
||||||
sample_indices=freeze(indices, dtype=np.int32),
|
sample_indices=freeze(indices, dtype=np.int32),
|
||||||
sample_weights=freeze(weights, dtype=np.float32),
|
sample_weights=freeze(weights, dtype=np.float32),
|
||||||
)
|
)
|
||||||
@@ -766,6 +800,7 @@ class LegacyFingerImporter:
|
|||||||
hash_arrays: dict[str, Any] = {
|
hash_arrays: dict[str, Any] = {
|
||||||
"positions": sensing.positions_sensor_m,
|
"positions": sensing.positions_sensor_m,
|
||||||
"triangles": sensing.triangles,
|
"triangles": sensing.triangles,
|
||||||
|
"uv": binding.uvs,
|
||||||
"indices": binding.sample_indices,
|
"indices": binding.sample_indices,
|
||||||
"weights": binding.sample_weights,
|
"weights": binding.sample_weights,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,10 +99,18 @@ MAX_COMPRESSION_RATIO = 200
|
|||||||
RATIO_FLOOR_BYTES = 4 << 20
|
RATIO_FLOOR_BYTES = 4 << 20
|
||||||
|
|
||||||
#: The arrays a sensing part is made of, and the role each belongs to.
|
#: The arrays a sensing part is made of, and the role each belongs to.
|
||||||
|
#:
|
||||||
|
#: ``sensing/uv.npy`` is **required**, not optional: the surface's colour is the field
|
||||||
|
#: texture sampled through these UVs, so a package without them cannot be drawn at all.
|
||||||
|
#: A reader that fell back to "no UVs" would have to paint the surface untextured, and
|
||||||
|
#: an untextured measurement is a picture that claims to be a measurement and is not —
|
||||||
|
#: see :func:`scene_from_contents`, which refuses such a package by name and says the
|
||||||
|
#: repair is a recompile.
|
||||||
SENSING_ARRAYS = {
|
SENSING_ARRAYS = {
|
||||||
"sensing/positions.npy": ("f4", 2),
|
"sensing/positions.npy": ("f4", 2),
|
||||||
"sensing/normals.npy": ("f4", 2),
|
"sensing/normals.npy": ("f4", 2),
|
||||||
"sensing/triangles.npy": ("u4", 2),
|
"sensing/triangles.npy": ("u4", 2),
|
||||||
|
"sensing/uv.npy": ("f4", 2),
|
||||||
"sensing/sample_indices.npy": ("i4", 2),
|
"sensing/sample_indices.npy": ("i4", 2),
|
||||||
"sensing/sample_weights.npy": ("f4", 2),
|
"sensing/sample_weights.npy": ("f4", 2),
|
||||||
}
|
}
|
||||||
@@ -233,6 +241,7 @@ def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]:
|
|||||||
arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m)
|
arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m)
|
||||||
arrays["sensing/normals.npy"] = np.ascontiguousarray(sensing.normals_sensor)
|
arrays["sensing/normals.npy"] = np.ascontiguousarray(sensing.normals_sensor)
|
||||||
arrays["sensing/triangles.npy"] = np.ascontiguousarray(sensing.triangles)
|
arrays["sensing/triangles.npy"] = np.ascontiguousarray(sensing.triangles)
|
||||||
|
arrays["sensing/uv.npy"] = np.ascontiguousarray(assets.binding.uvs)
|
||||||
arrays["sensing/sample_indices.npy"] = np.ascontiguousarray(assets.binding.sample_indices)
|
arrays["sensing/sample_indices.npy"] = np.ascontiguousarray(assets.binding.sample_indices)
|
||||||
arrays["sensing/sample_weights.npy"] = np.ascontiguousarray(assets.binding.sample_weights)
|
arrays["sensing/sample_weights.npy"] = np.ascontiguousarray(assets.binding.sample_weights)
|
||||||
if assets.source_vertex_of_render is not None:
|
if assets.source_vertex_of_render is not None:
|
||||||
@@ -688,11 +697,37 @@ def scene_from_contents(
|
|||||||
)
|
)
|
||||||
if not parts:
|
if not parts:
|
||||||
raise error("geometry_mismatch", "the package has no mesh parts")
|
raise error("geometry_mismatch", "the package has no mesh parts")
|
||||||
|
# The texture-first path has no fallback, so the absence of the UV map is a
|
||||||
|
# refusal with a named repair rather than a scene that renders untextured. The
|
||||||
|
# check is here, ahead of the binding, so the message names the *entry* an engineer
|
||||||
|
# has to add rather than a KeyError from a dictionary lookup.
|
||||||
|
if "sensing/uv.npy" not in arrays:
|
||||||
|
raise error(
|
||||||
|
"asset_rejected",
|
||||||
|
"this scene package carries no sensing/uv.npy, so the sensing surface has "
|
||||||
|
"no UV map to sample the field through: it was compiled before the "
|
||||||
|
"texture-first surface and must be recompiled "
|
||||||
|
"(tools/scene_assets/compile_scene.py)",
|
||||||
|
entry="sensing/uv.npy",
|
||||||
|
found="absent",
|
||||||
|
required="the render-vertex UVs, [N,2] on the field domain's pixel grid",
|
||||||
|
repair="recompile the package",
|
||||||
|
)
|
||||||
|
for required_entry in SENSING_ARRAYS:
|
||||||
|
if required_entry not in arrays:
|
||||||
|
raise error(
|
||||||
|
"asset_rejected",
|
||||||
|
f"this scene package is missing {required_entry}, which every sensing "
|
||||||
|
"surface needs: recompile the package",
|
||||||
|
entry=required_entry,
|
||||||
|
repair="recompile the package",
|
||||||
|
)
|
||||||
binding = SurfaceBinding(
|
binding = SurfaceBinding(
|
||||||
part_id="sensing_surface",
|
part_id="sensing_surface",
|
||||||
geometry_id=str(manifest.get("geometry_id") or ""),
|
geometry_id=str(manifest.get("geometry_id") or ""),
|
||||||
field_domain_id=str(domain.get("id") or ""),
|
field_domain_id=str(domain.get("id") or ""),
|
||||||
shape_hw=(shape[0], shape[1]),
|
shape_hw=(shape[0], shape[1]),
|
||||||
|
uvs=freeze(arrays["sensing/uv.npy"], dtype=np.float32),
|
||||||
sample_indices=freeze(arrays["sensing/sample_indices.npy"], dtype=np.int32),
|
sample_indices=freeze(arrays["sensing/sample_indices.npy"], dtype=np.int32),
|
||||||
sample_weights=freeze(arrays["sensing/sample_weights.npy"], dtype=np.float32),
|
sample_weights=freeze(arrays["sensing/sample_weights.npy"], dtype=np.float32),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,33 +1,43 @@
|
|||||||
"""Sampling a UV field onto the sensing vertices. Pure NumPy, no Qt, no GL.
|
"""Field plumbing that is still CPU work. Pure NumPy, no Qt, no GL.
|
||||||
|
|
||||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||||
|
|
||||||
The sampling table is built once, at load time, from the field's own domain. It is
|
**The per-vertex colour gather is gone.** This module used to hold
|
||||||
never rebuilt per frame, and a vertex is never repaired by searching for the nearest
|
``SurfaceSampler.sample``: a vectorised bilinear gather of the field onto every
|
||||||
triangle: if any tap that carries weight is invalid, the vertex is invalid.
|
sensing vertex, with a ``NaN`` for a vertex whose taps touched one invalid pixel. The
|
||||||
|
fragment shader then had to interpret that ``NaN``, and the rule it used —
|
||||||
|
``if (v_valid < 0.5 || isnan(v_scalar))`` — made a whole triangle grey as soon as one
|
||||||
|
of its corners was invalid. On the shipped finger asset that painted **30.15 % of the
|
||||||
|
island grey** and put the painted region p50 6.7 px / max 25.8 px away from the 2D
|
||||||
|
view.
|
||||||
|
|
||||||
Three details that decide whether a measurement is honest:
|
The field now reaches the surface whole, as a texture sampled per *fragment*
|
||||||
|
(``contracts.DisplayField``), so there is nothing here to gather for colour and no
|
||||||
|
fallback for a caller to reach for.
|
||||||
|
|
||||||
* **an invalid tap is not zero.** Weighting a gap with weight 0 would still poison
|
Two things remain, and they are not the colour path:
|
||||||
the sum, because ``0 * NaN`` is ``NaN``; invalid taps are therefore excluded from
|
|
||||||
the sum and the *vertex* is marked invalid, which is what the shader draws as the
|
* :func:`remap_to_domain` — a gather, but of a *source* field onto the scene's domain,
|
||||||
neutral colour.
|
done once in the acquisition adapter before any frame is published. It is how a
|
||||||
* **validity is a separate array from the value.** A vertex whose scalar happens to
|
tracker field that arrives in the camera's grid reaches the UV domain.
|
||||||
be 0.0 is a measurement of zero; a vertex that is invalid has no value at all, and
|
* :func:`sample_vertex_displacement` — the display **deformation**, which moves
|
||||||
the value is left as ``NaN`` so nobody can mistake one for the other.
|
vertices and is therefore a vertex-stage quantity by definition. It runs only when
|
||||||
* **the domain is checked, not assumed.** A frame whose domain or shape does not
|
``DisplaySettings.deform_enabled`` is set, and no adapter in this build publishes a
|
||||||
match the binding is refused with ``field_domain_mismatch`` rather than sampled
|
displacement, so the application never executes it.
|
||||||
into a plausible-looking but wrong picture.
|
|
||||||
|
Neither of them reads the scalar field: a unit test asserts that the render path
|
||||||
|
touches the frame's own arrays and never copies them (``tests/test_scene3d_live.py``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
SurfaceBinding,
|
SurfaceBinding,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
VertexFields,
|
|
||||||
error,
|
error,
|
||||||
freeze,
|
freeze,
|
||||||
)
|
)
|
||||||
@@ -37,97 +47,87 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
WEIGHT_EPS = 1e-7
|
WEIGHT_EPS = 1e-7
|
||||||
|
|
||||||
|
|
||||||
class SurfaceSampler:
|
def check_field_domain(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None:
|
||||||
"""Vectorised bilinear sampling through a precomputed binding."""
|
"""Refuse a frame that is not on this binding's geometry, domain and shape.
|
||||||
|
|
||||||
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
|
Kept as a function rather than a method so the *texture* path can make exactly the
|
||||||
self._check_domains(frame, binding)
|
same check without importing a sampler: a frame from another domain would be
|
||||||
indices = np.asarray(binding.sample_indices, dtype=np.int64) # [N,4]
|
uploaded as a texture of the wrong size and quietly rescaled by the sampler, which
|
||||||
weights = np.asarray(binding.sample_weights, dtype=np.float64) # [N,4]
|
is the class of bug this check exists to prevent.
|
||||||
used = indices >= 0
|
"""
|
||||||
if not used.any() and len(indices):
|
if binding.geometry_id and frame.geometry_id != binding.geometry_id:
|
||||||
raise error("field_domain_mismatch", "the binding samples nothing at all")
|
raise error(
|
||||||
# ``-1`` -> index 0 for the gather; the weight there is 0 by contract and the
|
"geometry_mismatch",
|
||||||
# ``used`` mask keeps it out of both the sum and the validity test.
|
"the frame belongs to a different geometry than the binding",
|
||||||
safe = np.where(used, indices, 0)
|
frame=frame.geometry_id,
|
||||||
height, width = frame.scalar.shape
|
binding=binding.geometry_id,
|
||||||
flat_scalar = np.asarray(frame.scalar, dtype=np.float64).reshape(-1)
|
)
|
||||||
flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1)
|
if binding.field_domain_id and frame.field_domain_id != binding.field_domain_id:
|
||||||
if safe.size and int(safe.max()) >= flat_scalar.size:
|
raise error(
|
||||||
raise error(
|
"field_domain_mismatch",
|
||||||
"field_domain_mismatch",
|
"the frame is on a different field domain than the binding",
|
||||||
"a binding index falls outside the frame's field",
|
frame=frame.field_domain_id,
|
||||||
index=int(safe.max()),
|
binding=binding.field_domain_id,
|
||||||
size=int(flat_scalar.size),
|
)
|
||||||
)
|
if tuple(frame.scalar.shape) != tuple(binding.shape_hw):
|
||||||
gathered_scalar = flat_scalar[safe]
|
raise error(
|
||||||
gathered_valid = flat_valid[safe]
|
"field_domain_mismatch",
|
||||||
finite = np.isfinite(gathered_scalar)
|
"the frame's shape does not match the binding's domain",
|
||||||
# A tap counts only where it is used, in range, finite and valid.
|
frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
|
||||||
contributed = used & gathered_valid & finite
|
binding=f"{binding.shape_hw[0]}x{binding.shape_hw[1]}",
|
||||||
valid = contributed | ~used
|
)
|
||||||
valid = valid.all(axis=1)
|
if frame.scalar.shape != frame.valid.shape:
|
||||||
contribution = np.where(contributed, gathered_scalar, 0.0) * weights
|
raise error(
|
||||||
scalar = contribution.sum(axis=1)
|
"field_domain_mismatch", "the frame's scalar and validity disagree"
|
||||||
# No measurement: the value is absent, not zero.
|
|
||||||
scalar = np.where(valid, scalar, np.nan)
|
|
||||||
|
|
||||||
displacement = None
|
|
||||||
if frame.displacement_sensor_m is not None:
|
|
||||||
flat_displacement = np.asarray(
|
|
||||||
frame.displacement_sensor_m, dtype=np.float64
|
|
||||||
).reshape(-1, 3)
|
|
||||||
if flat_displacement.shape[0] != flat_scalar.shape[0]:
|
|
||||||
raise error(
|
|
||||||
"field_domain_mismatch",
|
|
||||||
"the displacement field does not match the scalar field",
|
|
||||||
)
|
|
||||||
gathered = flat_displacement[safe] # [N,4,3]
|
|
||||||
finite_disp = np.isfinite(gathered).all(axis=2)
|
|
||||||
contributed_disp = used & gathered_valid & finite_disp
|
|
||||||
displacement = np.where(contributed_disp[:, :, None], gathered, 0.0)
|
|
||||||
displacement = (displacement * weights[:, :, None]).sum(axis=1)
|
|
||||||
displacement = np.where(valid[:, None], displacement, np.nan)
|
|
||||||
displacement = freeze(displacement, dtype=np.float32)
|
|
||||||
del height, width
|
|
||||||
return VertexFields(
|
|
||||||
scalar=freeze(scalar, dtype=np.float32),
|
|
||||||
valid=freeze(valid, dtype=np.bool_),
|
|
||||||
displacement_sensor_m=displacement,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_domains(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None:
|
|
||||||
if binding.geometry_id and frame.geometry_id != binding.geometry_id:
|
|
||||||
raise error(
|
|
||||||
"geometry_mismatch",
|
|
||||||
"the frame belongs to a different geometry than the binding",
|
|
||||||
frame=frame.geometry_id,
|
|
||||||
binding=binding.geometry_id,
|
|
||||||
)
|
|
||||||
if binding.field_domain_id and frame.field_domain_id != binding.field_domain_id:
|
|
||||||
raise error(
|
|
||||||
"field_domain_mismatch",
|
|
||||||
"the frame is on a different field domain than the binding",
|
|
||||||
frame=frame.field_domain_id,
|
|
||||||
binding=binding.field_domain_id,
|
|
||||||
)
|
|
||||||
if tuple(frame.scalar.shape) != tuple(binding.shape_hw):
|
|
||||||
raise error(
|
|
||||||
"field_domain_mismatch",
|
|
||||||
"the frame's shape does not match the binding's domain",
|
|
||||||
frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
|
|
||||||
binding=f"{binding.shape_hw[0]}x{binding.shape_hw[1]}",
|
|
||||||
)
|
|
||||||
if frame.scalar.shape != frame.valid.shape:
|
|
||||||
raise error(
|
|
||||||
"field_domain_mismatch", "the frame's scalar and validity disagree"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def sample_vertex_displacement(
|
||||||
|
frame: SurfaceFieldFrame, binding: SurfaceBinding
|
||||||
|
) -> Optional[np.ndarray]:
|
||||||
|
"""The frame's displacement, gathered onto the render vertices.
|
||||||
|
|
||||||
def sample_field(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
|
``None`` when the frame carries no displacement — the common case, and the only
|
||||||
"""Convenience wrapper: one sampler, no state to keep."""
|
case this build's adapters can produce. A tap that is unused or invalid makes the
|
||||||
return SurfaceSampler().sample(frame, binding)
|
vertex's displacement ``NaN``, which the composer reads as "do not move this
|
||||||
|
vertex": an absent displacement must not be read as a zero-length one, because
|
||||||
|
that would move a vertex by exactly nothing and call it measured.
|
||||||
|
|
||||||
|
This is geometry, not colour. It does **not** touch ``frame.scalar``.
|
||||||
|
"""
|
||||||
|
displacement = frame.displacement_sensor_m
|
||||||
|
if displacement is None:
|
||||||
|
return None
|
||||||
|
check_field_domain(frame, binding)
|
||||||
|
indices = np.asarray(binding.sample_indices, dtype=np.int64) # [N,4]
|
||||||
|
weights = np.asarray(binding.sample_weights, dtype=np.float64) # [N,4]
|
||||||
|
used = indices >= 0
|
||||||
|
if not used.any() and len(indices):
|
||||||
|
raise error("field_domain_mismatch", "the binding samples nothing at all")
|
||||||
|
safe = np.where(used, indices, 0)
|
||||||
|
flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1)
|
||||||
|
flat_displacement = np.asarray(displacement, dtype=np.float64).reshape(-1, 3)
|
||||||
|
if flat_displacement.shape[0] != flat_valid.size:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the displacement field does not match the scalar field",
|
||||||
|
)
|
||||||
|
if safe.size and int(safe.max()) >= flat_valid.size:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"a binding index falls outside the frame's field",
|
||||||
|
index=int(safe.max()),
|
||||||
|
size=int(flat_valid.size),
|
||||||
|
)
|
||||||
|
gathered = flat_displacement[safe] # [N,4,3]
|
||||||
|
gathered_valid = flat_valid[safe]
|
||||||
|
finite = np.isfinite(gathered).all(axis=2)
|
||||||
|
contributed = used & gathered_valid & finite
|
||||||
|
valid = (contributed | ~used).all(axis=1)
|
||||||
|
values = np.where(contributed[:, :, None], gathered, 0.0)
|
||||||
|
values = (values * weights[:, :, None]).sum(axis=1)
|
||||||
|
values = np.where(valid[:, None], values, np.nan)
|
||||||
|
return freeze(values, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
def remap_to_domain(
|
def remap_to_domain(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
freeze,
|
freeze,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
|
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
|
||||||
|
from orisys_app_shared.scene3d.legacy import LegacyFieldDomain, uvs_to_pixels
|
||||||
|
|
||||||
#: The plate is 20 mm x 20 mm, sampled 3x3: small enough to read in a test, big
|
#: The plate is 20 mm x 20 mm, sampled 3x3: small enough to read in a test, big
|
||||||
#: enough to have an interior vertex that a bilinear tap can move.
|
#: enough to have an interior vertex that a bilinear tap can move.
|
||||||
@@ -117,8 +118,29 @@ POC_DOMAIN_ID = field_domain_id(shape_hw=(GRID, GRID), flip_v=False)
|
|||||||
|
|
||||||
|
|
||||||
def _binding() -> SurfaceBinding:
|
def _binding() -> SurfaceBinding:
|
||||||
"""Identity binding: each plate vertex samples its own field cell."""
|
"""Identity binding: each plate vertex samples its own field cell.
|
||||||
|
|
||||||
|
The UVs are the plate's own parameterisation, converted to the domain's pixel
|
||||||
|
grid by the *same* function the importer uses (``legacy.uvs_to_pixels``) rather
|
||||||
|
than by a second, hand-written convention. With a 3x3 domain and no v flip that
|
||||||
|
lands exactly on the identity — vertex ``row*GRID+col`` owns texel ``(col,row)``
|
||||||
|
— so a fragment's texel is a fact the tests can state without re-deriving it.
|
||||||
|
That is what makes this scene able to pin the texture-first path: the surface's
|
||||||
|
colour and validity now come from the field texture, and the PoC's UVs say
|
||||||
|
precisely which texel every fragment must read.
|
||||||
|
"""
|
||||||
count = GRID * GRID
|
count = GRID * GRID
|
||||||
|
domain = LegacyFieldDomain(
|
||||||
|
shape_hw=(GRID, GRID),
|
||||||
|
flip_v=False,
|
||||||
|
source_scale=None,
|
||||||
|
valid_mask=freeze(np.ones((GRID, GRID), dtype=bool), dtype=np.bool_),
|
||||||
|
domain_id=POC_DOMAIN_ID,
|
||||||
|
)
|
||||||
|
uvs = np.zeros((count, 2), dtype=np.float32)
|
||||||
|
for row in range(GRID):
|
||||||
|
for col in range(GRID):
|
||||||
|
uvs[row * GRID + col] = (col / (GRID - 1), row / (GRID - 1))
|
||||||
indices = np.full((count, 4), -1, dtype=np.int32)
|
indices = np.full((count, 4), -1, dtype=np.int32)
|
||||||
weights = np.zeros((count, 4), dtype=np.float32)
|
weights = np.zeros((count, 4), dtype=np.float32)
|
||||||
for index in range(count):
|
for index in range(count):
|
||||||
@@ -129,6 +151,7 @@ def _binding() -> SurfaceBinding:
|
|||||||
geometry_id=POC_GEOMETRY_ID,
|
geometry_id=POC_GEOMETRY_ID,
|
||||||
field_domain_id=POC_DOMAIN_ID,
|
field_domain_id=POC_DOMAIN_ID,
|
||||||
shape_hw=(GRID, GRID),
|
shape_hw=(GRID, GRID),
|
||||||
|
uvs=uvs_to_pixels(uvs, domain),
|
||||||
sample_indices=freeze(indices, dtype=np.int32),
|
sample_indices=freeze(indices, dtype=np.int32),
|
||||||
sample_weights=freeze(weights, dtype=np.float32),
|
sample_weights=freeze(weights, dtype=np.float32),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ def _binding(vertices: int = 4):
|
|||||||
[[0, 1, 5, 6], [1, 2, 6, 7], [4, 5, 9, 10], [5, 6, 10, 11]], dtype=np.int32
|
[[0, 1, 5, 6], [1, 2, 6, 7], [4, 5, 9, 10], [5, 6, 10, 11]], dtype=np.int32
|
||||||
)[:vertices]
|
)[:vertices]
|
||||||
weights = np.full((vertices, 4), 0.25, dtype=np.float32)
|
weights = np.full((vertices, 4), 0.25, dtype=np.float32)
|
||||||
|
# The domain is 3x4, so its pixel grid is x in [0,3], y in [0,2].
|
||||||
|
uvs = np.array(
|
||||||
|
[[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]], dtype=np.float32
|
||||||
|
)[:vertices]
|
||||||
return c.SurfaceBinding(
|
return c.SurfaceBinding(
|
||||||
part_id="sensing_surface",
|
part_id="sensing_surface",
|
||||||
geometry_id="geom-abc",
|
geometry_id="geom-abc",
|
||||||
field_domain_id="domain-xyz",
|
field_domain_id="domain-xyz",
|
||||||
shape_hw=(3, 4),
|
shape_hw=(3, 4),
|
||||||
|
uvs=c.freeze(uvs, dtype=np.float32),
|
||||||
sample_indices=c.freeze(indices, dtype=np.int32),
|
sample_indices=c.freeze(indices, dtype=np.int32),
|
||||||
sample_weights=c.freeze(weights, dtype=np.float32),
|
sample_weights=c.freeze(weights, dtype=np.float32),
|
||||||
)
|
)
|
||||||
@@ -175,6 +180,7 @@ def test_a_binding_with_an_out_of_domain_index_is_refused():
|
|||||||
geometry_id=binding.geometry_id,
|
geometry_id=binding.geometry_id,
|
||||||
field_domain_id=binding.field_domain_id,
|
field_domain_id=binding.field_domain_id,
|
||||||
shape_hw=binding.shape_hw,
|
shape_hw=binding.shape_hw,
|
||||||
|
uvs=binding.uvs,
|
||||||
sample_indices=c.freeze(indices, dtype=np.int32),
|
sample_indices=c.freeze(indices, dtype=np.int32),
|
||||||
sample_weights=binding.sample_weights,
|
sample_weights=binding.sample_weights,
|
||||||
)
|
)
|
||||||
@@ -191,6 +197,7 @@ def test_unused_samples_must_be_minus_one_with_zero_weight():
|
|||||||
geometry_id=binding.geometry_id,
|
geometry_id=binding.geometry_id,
|
||||||
field_domain_id=binding.field_domain_id,
|
field_domain_id=binding.field_domain_id,
|
||||||
shape_hw=binding.shape_hw,
|
shape_hw=binding.shape_hw,
|
||||||
|
uvs=binding.uvs,
|
||||||
sample_indices=c.freeze(indices, dtype=np.int32),
|
sample_indices=c.freeze(indices, dtype=np.int32),
|
||||||
sample_weights=binding.sample_weights,
|
sample_weights=binding.sample_weights,
|
||||||
)
|
)
|
||||||
@@ -207,6 +214,7 @@ def test_weights_must_sum_to_one():
|
|||||||
geometry_id=binding.geometry_id,
|
geometry_id=binding.geometry_id,
|
||||||
field_domain_id=binding.field_domain_id,
|
field_domain_id=binding.field_domain_id,
|
||||||
shape_hw=binding.shape_hw,
|
shape_hw=binding.shape_hw,
|
||||||
|
uvs=binding.uvs,
|
||||||
sample_indices=binding.sample_indices,
|
sample_indices=binding.sample_indices,
|
||||||
sample_weights=c.freeze(weights, dtype=np.float32),
|
sample_weights=c.freeze(weights, dtype=np.float32),
|
||||||
)
|
)
|
||||||
@@ -263,6 +271,7 @@ def test_the_binding_must_belong_to_the_sensing_part():
|
|||||||
geometry_id=binding.geometry_id,
|
geometry_id=binding.geometry_id,
|
||||||
field_domain_id=binding.field_domain_id,
|
field_domain_id=binding.field_domain_id,
|
||||||
shape_hw=binding.shape_hw,
|
shape_hw=binding.shape_hw,
|
||||||
|
uvs=binding.uvs,
|
||||||
sample_indices=binding.sample_indices,
|
sample_indices=binding.sample_indices,
|
||||||
sample_weights=binding.sample_weights,
|
sample_weights=binding.sample_weights,
|
||||||
)
|
)
|
||||||
@@ -491,15 +500,20 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
|
|||||||
"""The acquisition thread imports this package; it must not drag Qt or GL in.
|
"""The acquisition thread imports this package; it must not drag Qt or GL in.
|
||||||
|
|
||||||
Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and
|
Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and
|
||||||
usable here — the sampler and the composer run on a machine with no GPU, which
|
usable here — the composer and the field domain checks run on a machine with no
|
||||||
is the whole reason the contracts layer is separate from the view.
|
GPU, which is the whole reason the contracts layer is separate from the view.
|
||||||
"""
|
"""
|
||||||
from orisys_app_shared.scene3d import assets, composer, sampling, synthetic
|
from orisys_app_shared.scene3d import assets, composer, sampling, synthetic
|
||||||
|
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
frame = _frame()
|
frame = _frame()
|
||||||
fields = sampling.sample_field(frame, _binding())
|
# The colour path makes no gather at all; the only sampling left is the field
|
||||||
assert np.asarray(fields.scalar).shape == (4,)
|
# remap and the vertex displacement, and both are pure NumPy.
|
||||||
|
assert callable(sampling.remap_to_domain)
|
||||||
|
assert callable(sampling.sample_vertex_displacement)
|
||||||
|
assert not hasattr(sampling, "sample_field"), (
|
||||||
|
"the per-vertex colour gather is back; the texture-first path has no fallback"
|
||||||
|
)
|
||||||
composed = composer.SceneComposer().compose(
|
composed = composer.SceneComposer().compose(
|
||||||
scene, c.SurfaceFieldFrame(
|
scene, c.SurfaceFieldFrame(
|
||||||
identity=frame.identity,
|
identity=frame.identity,
|
||||||
@@ -511,7 +525,7 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
|
|||||||
),
|
),
|
||||||
c.DisplaySettings(),
|
c.DisplaySettings(),
|
||||||
)
|
)
|
||||||
assert composed.scalar.shape == (9,)
|
assert composed.field.scalar.shape == (3, 3)
|
||||||
# The loading facade is real too (stage 2), and the compiler refuses what it
|
# The loading facade is real too (stage 2), and the compiler refuses what it
|
||||||
# cannot honestly do: asking for encryption without key material, or without the
|
# cannot honestly do: asking for encryption without key material, or without the
|
||||||
# crypto backend, must not produce a plain package that looks protected.
|
# crypto backend, must not produce a plain package that looks protected.
|
||||||
|
|||||||
+133
-106
@@ -1,9 +1,10 @@
|
|||||||
"""Stage 3 acceptance: sampling, composition, and the frame schedule.
|
"""Stage 3 acceptance: the field path, composition, and the frame schedule.
|
||||||
|
|
||||||
The three rows that matter most are the ones that are easy to get subtly wrong and
|
The three rows that matter most are the ones that are easy to get subtly wrong and
|
||||||
impossible to notice on screen: an invalid tap must invalidate the *vertex* rather
|
impossible to notice on screen: the *colour* path must never resample the field onto
|
||||||
than read as zero, the display gain must not touch the measurement, and a producer
|
the vertices (that is what painted 30.15 % of the island grey), the display gain must
|
||||||
must never be able to make the display queue grow.
|
not touch the measurement, and a producer must never be able to make the display queue
|
||||||
|
grow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -30,9 +31,8 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
rest_descriptor,
|
rest_descriptor,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.sampling import (
|
from orisys_app_shared.scene3d.sampling import (
|
||||||
SurfaceSampler,
|
|
||||||
remap_to_domain,
|
remap_to_domain,
|
||||||
sample_field,
|
sample_vertex_displacement,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,133 +70,155 @@ def _binding(shape=(3, 3), indices=None, weights=None):
|
|||||||
from orisys_app_shared.scene3d.contracts import SurfaceBinding
|
from orisys_app_shared.scene3d.contracts import SurfaceBinding
|
||||||
|
|
||||||
count = int(indices.shape[0]) if indices is not None else shape[0] * shape[1]
|
count = int(indices.shape[0]) if indices is not None else shape[0] * shape[1]
|
||||||
|
uvs = np.zeros((count, 2), dtype=np.float32)
|
||||||
|
if count == shape[0] * shape[1]:
|
||||||
|
grid = np.stack(
|
||||||
|
np.meshgrid(np.arange(shape[1]), np.arange(shape[0])), axis=-1
|
||||||
|
).reshape(-1, 2)
|
||||||
|
uvs = grid.astype(np.float32)
|
||||||
return SurfaceBinding(
|
return SurfaceBinding(
|
||||||
part_id="sensing_surface",
|
part_id="sensing_surface",
|
||||||
geometry_id=scene.geometry_id,
|
geometry_id=scene.geometry_id,
|
||||||
field_domain_id=scene.field_domain_id,
|
field_domain_id=scene.field_domain_id,
|
||||||
shape_hw=shape,
|
shape_hw=shape,
|
||||||
|
uvs=freeze(uvs, dtype=np.float32),
|
||||||
sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32),
|
sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32),
|
||||||
sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32),
|
sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- sampling
|
# -------------------------------------------------------- the colour field path
|
||||||
def test_a_constant_field_samples_to_the_same_constant():
|
def test_the_colour_path_never_gathers_the_field_onto_the_vertices():
|
||||||
|
"""The property the whole texture-first change exists for.
|
||||||
|
|
||||||
|
The render frame must carry the acquisition frame's *own* ``[H,W]`` grid — not a
|
||||||
|
per-vertex resampling of it. This is asserted by identity, which is the strongest
|
||||||
|
form available: a gather cannot produce the same object.
|
||||||
|
"""
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
fields = sample_field(_frame(scene, np.full((3, 3), 7.5)), scene.binding)
|
frame = _frame(scene, np.arange(9, dtype=np.float32).reshape(3, 3))
|
||||||
assert np.allclose(np.asarray(fields.scalar), 7.5)
|
composed = SceneComposer().compose(scene, frame, DisplaySettings())
|
||||||
assert np.asarray(fields.valid).all()
|
assert composed.field.scalar is frame.scalar
|
||||||
|
assert composed.field.valid is frame.valid
|
||||||
|
assert composed.field.shape_hw == (3, 3)
|
||||||
|
assert composed.field.domain_id == scene.field_domain_id
|
||||||
|
assert composed.field.descriptor is frame.scalar_descriptor
|
||||||
|
# ...and there is no per-vertex array left to read by mistake.
|
||||||
|
assert not hasattr(composed, "scalar")
|
||||||
|
assert not hasattr(composed, "valid")
|
||||||
|
|
||||||
|
|
||||||
def test_a_ramp_field_samples_exactly_like_the_reference():
|
def test_a_hole_in_the_field_stays_a_hole_and_is_never_a_vertex_nan():
|
||||||
"""Every vertex samples its own cell, so the sampled value is the cell value."""
|
"""A gap is not a measurement of zero, and it is not a grey *triangle* either.
|
||||||
|
|
||||||
|
Under the old sampler one invalid tap made a whole vertex ``NaN``, and the shader
|
||||||
|
painted every triangle touching it grey. Now the hole travels as a hole: exactly
|
||||||
|
the pixels the device called invalid are invalid, and a value of 0.0 inside them
|
||||||
|
is not read at all.
|
||||||
|
"""
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
ramp = np.arange(9, dtype=np.float32).reshape(3, 3)
|
|
||||||
fields = sample_field(_frame(scene, ramp), scene.binding)
|
|
||||||
assert np.array_equal(np.asarray(fields.scalar), ramp.reshape(-1))
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_single_sample_spike_reaches_exactly_one_vertex():
|
|
||||||
scene = synthetic.poc_scene()
|
|
||||||
field = np.zeros((3, 3), dtype=np.float32)
|
|
||||||
field[1, 1] = 5.0
|
|
||||||
fields = sample_field(_frame(scene, field), scene.binding)
|
|
||||||
scalar = np.asarray(fields.scalar)
|
|
||||||
assert int((scalar > 0).sum()) == 1
|
|
||||||
assert scalar[4] == 5.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_bilinear_sampling_averages_the_four_taps():
|
|
||||||
"""A vertex between pixels gets the weighted average, computed by hand."""
|
|
||||||
scene = synthetic.poc_scene()
|
|
||||||
indices = np.array([[0, 1, 3, 4]], dtype=np.int32)
|
|
||||||
weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32)
|
|
||||||
binding = _binding(indices=indices, weights=weights)
|
|
||||||
field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32)
|
|
||||||
fields = sample_field(_frame(scene, field), binding)
|
|
||||||
assert np.asarray(fields.scalar)[0] == pytest.approx((1 + 2 + 3 + 4) / 4)
|
|
||||||
assert bool(np.asarray(fields.valid)[0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_invalid_tap_invalidates_the_vertex_and_is_not_read_as_zero():
|
|
||||||
"""A gap is not a measurement of zero: the value must be absent."""
|
|
||||||
scene = synthetic.poc_scene()
|
|
||||||
indices = np.array([[0, 1, 3, 4]], dtype=np.int32)
|
|
||||||
weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32)
|
|
||||||
binding = _binding(indices=indices, weights=weights)
|
|
||||||
field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32)
|
|
||||||
valid = np.ones((3, 3), dtype=bool)
|
valid = np.ones((3, 3), dtype=bool)
|
||||||
valid[1, 1] = False # index 4 is a positive-weight tap
|
valid[1, 1] = False
|
||||||
fields = sample_field(_frame(scene, field, valid), binding)
|
|
||||||
assert not bool(np.asarray(fields.valid)[0])
|
|
||||||
assert np.isnan(np.asarray(fields.scalar)[0]), (
|
|
||||||
"an invalid vertex must carry no value at all, not a sum with a zero in it"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unused_tap_is_neither_a_contribution_nor_a_reason_to_invalidate():
|
|
||||||
scene = synthetic.poc_scene()
|
|
||||||
indices = np.array([[4, -1, -1, -1]], dtype=np.int32)
|
|
||||||
weights = np.array([[1.0, 0.0, 0.0, 0.0]], dtype=np.float32)
|
|
||||||
binding = _binding(indices=indices, weights=weights)
|
|
||||||
field = np.zeros((3, 3), dtype=np.float32)
|
field = np.zeros((3, 3), dtype=np.float32)
|
||||||
field[1, 1] = 3.0
|
field[1, 1] = 99.0
|
||||||
valid = np.ones((3, 3), dtype=bool)
|
composed = SceneComposer().compose(scene, _frame(scene, field, valid), DisplaySettings())
|
||||||
valid[0, 0] = False # unused, so it must not matter
|
assert composed.field.valid.tolist() == valid.tolist()
|
||||||
fields = sample_field(_frame(scene, field, valid), binding)
|
assert int(composed.field.valid.sum()) == 8
|
||||||
assert bool(np.asarray(fields.valid)[0])
|
# The scalar is carried through untouched, `NaN` or not: nothing rewrites it into
|
||||||
assert np.asarray(fields.scalar)[0] == pytest.approx(3.0)
|
# a per-vertex absence, so the fragment reads the *device's* answer.
|
||||||
|
assert np.array_equal(np.asarray(composed.field.scalar), field)
|
||||||
|
|
||||||
def test_a_non_finite_scalar_invalidates_rather_than_colouring_nan():
|
|
||||||
scene = synthetic.poc_scene()
|
|
||||||
field = np.full((3, 3), 1.0, dtype=np.float32)
|
|
||||||
field[2, 2] = np.nan
|
|
||||||
fields = sample_field(_frame(scene, field), scene.binding)
|
|
||||||
assert not bool(np.asarray(fields.valid)[8])
|
|
||||||
assert np.isnan(np.asarray(fields.scalar)[8])
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_zero_measurement_is_valid_and_stays_zero():
|
def test_a_zero_measurement_is_valid_and_stays_zero():
|
||||||
"""Zero is a value. Only `valid` decides whether it means anything."""
|
"""Zero is a value. Only `valid` decides whether it means anything."""
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
fields = sample_field(_frame(scene, np.zeros((3, 3), dtype=np.float32)), scene.binding)
|
composed = SceneComposer().compose(
|
||||||
assert np.asarray(fields.valid).all()
|
scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
|
||||||
assert np.all(np.asarray(fields.scalar) == 0.0)
|
)
|
||||||
assert not np.isnan(np.asarray(fields.scalar)).any()
|
assert composed.field.valid.all()
|
||||||
|
assert np.all(np.asarray(composed.field.scalar) == 0.0)
|
||||||
|
assert not np.isnan(np.asarray(composed.field.scalar)).any()
|
||||||
|
|
||||||
|
|
||||||
def test_the_sampler_refuses_a_frame_from_another_domain():
|
def test_the_composer_refuses_a_frame_from_another_domain():
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain")
|
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain")
|
||||||
with pytest.raises(ThreeDError) as excinfo:
|
with pytest.raises(ThreeDError) as excinfo:
|
||||||
sample_field(frame, scene.binding)
|
SceneComposer().compose(scene, frame, DisplaySettings())
|
||||||
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
|
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
|
||||||
|
|
||||||
|
|
||||||
def test_the_sampler_refuses_a_frame_of_the_wrong_shape():
|
def test_the_composer_refuses_a_frame_of_the_wrong_shape():
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32))
|
frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32))
|
||||||
with pytest.raises(ThreeDError) as excinfo:
|
with pytest.raises(ThreeDError) as excinfo:
|
||||||
sample_field(frame, scene.binding)
|
SceneComposer().compose(scene, frame, DisplaySettings())
|
||||||
assert "shape does not match" in excinfo.value.diagnostic.message
|
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
|
||||||
|
|
||||||
|
|
||||||
def test_sampling_is_vectorised_enough_for_a_large_mesh():
|
def test_composing_a_frame_costs_no_per_vertex_work():
|
||||||
"""A full-resolution binding is ~10^5 vertices; sampling must not crawl."""
|
"""The measured CPU cost of the colour path, over a realistic grid.
|
||||||
|
|
||||||
|
The old path gathered 1032 vertices x 4 taps every frame *and* the panel's own
|
||||||
|
tests measured it as the bulk of a compose. The field path is a shape check and a
|
||||||
|
dataclass: the number below is the evidence.
|
||||||
|
"""
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
count = 20_000
|
composer = SceneComposer()
|
||||||
rng = np.random.default_rng(7)
|
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
|
||||||
indices = rng.integers(0, 9, size=(count, 4)).astype(np.int32)
|
composer.compose(scene, frame, DisplaySettings())
|
||||||
weights = rng.random((count, 4)).astype(np.float32)
|
samples = []
|
||||||
weights /= weights.sum(axis=1, keepdims=True)
|
for _ in range(20):
|
||||||
binding = _binding(indices=indices, weights=weights)
|
composer.compose(scene, frame, DisplaySettings())
|
||||||
frame = _frame(scene, rng.random((3, 3)).astype(np.float32))
|
samples.append(composer.last_stats.sample_ms)
|
||||||
started = time.perf_counter()
|
assert max(samples) < 2.0, f"the colour path still does per-vertex work: {samples}"
|
||||||
fields = sample_field(frame, binding)
|
|
||||||
elapsed = (time.perf_counter() - started) * 1000.0
|
|
||||||
assert np.asarray(fields.scalar).shape == (count,)
|
# -------------------------------------------- the vertex displacement (geometry)
|
||||||
assert elapsed < 150.0, f"sampling {count} vertices took {elapsed:.1f} ms"
|
def test_a_constant_displacement_gathers_to_the_same_constant():
|
||||||
|
scene = synthetic.poc_scene()
|
||||||
|
displacement = np.full((3, 3, 3), 0.25, dtype=np.float32)
|
||||||
|
frame = _frame(
|
||||||
|
scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement
|
||||||
|
)
|
||||||
|
values = sample_vertex_displacement(frame, scene.binding)
|
||||||
|
assert values is not None
|
||||||
|
assert np.allclose(np.asarray(values), 0.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_invalid_tap_leaves_that_vertex_with_no_displacement():
|
||||||
|
"""Absent is absent: the vertex does not move at all, and the composer reads NaN."""
|
||||||
|
scene = synthetic.poc_scene()
|
||||||
|
displacement = np.ones((3, 3, 3), dtype=np.float32) * 0.5
|
||||||
|
valid = np.ones((3, 3), dtype=bool)
|
||||||
|
valid[1, 1] = False
|
||||||
|
frame = _frame(
|
||||||
|
scene, np.zeros((3, 3), dtype=np.float32), valid, displacement=displacement
|
||||||
|
)
|
||||||
|
values = np.asarray(sample_vertex_displacement(frame, scene.binding))
|
||||||
|
assert np.isnan(values[4]).all()
|
||||||
|
assert np.allclose(values[0], 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_without_a_displacement_gathers_nothing():
|
||||||
|
scene = synthetic.poc_scene()
|
||||||
|
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
|
||||||
|
assert sample_vertex_displacement(frame, scene.binding) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_displacement_path_never_reads_the_scalar_field():
|
||||||
|
"""It is geometry, not colour: the gathered value may not depend on the scalar."""
|
||||||
|
scene = synthetic.poc_scene()
|
||||||
|
displacement = np.ones((3, 3, 3), dtype=np.float32) * 0.125
|
||||||
|
first = sample_vertex_displacement(
|
||||||
|
_frame(scene, np.full((3, 3), 1.0, dtype=np.float32), displacement=displacement),
|
||||||
|
scene.binding,
|
||||||
|
)
|
||||||
|
second = sample_vertex_displacement(
|
||||||
|
_frame(scene, np.full((3, 3), 900.0, dtype=np.float32), displacement=displacement),
|
||||||
|
scene.binding,
|
||||||
|
)
|
||||||
|
assert np.array_equal(np.asarray(first), np.asarray(second))
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- remap
|
# ----------------------------------------------------------------------- remap
|
||||||
@@ -267,9 +289,9 @@ def test_the_gain_moves_positions_and_never_the_measurement():
|
|||||||
scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0)
|
scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0)
|
||||||
)
|
)
|
||||||
# The measurement is identical in all three.
|
# The measurement is identical in all three.
|
||||||
assert np.array_equal(np.asarray(quiet.scalar), np.asarray(small.scalar))
|
assert np.array_equal(np.asarray(quiet.field.scalar), np.asarray(small.field.scalar))
|
||||||
assert np.array_equal(np.asarray(small.scalar), np.asarray(large.scalar))
|
assert np.array_equal(np.asarray(small.field.scalar), np.asarray(large.field.scalar))
|
||||||
assert np.array_equal(np.asarray(quiet.valid), np.asarray(large.valid))
|
assert np.array_equal(np.asarray(quiet.field.valid), np.asarray(large.field.valid))
|
||||||
# The positions are not.
|
# The positions are not.
|
||||||
rest = np.asarray(small.positions_sensor_m)
|
rest = np.asarray(small.positions_sensor_m)
|
||||||
moved = np.asarray(large.positions_sensor_m)
|
moved = np.asarray(large.positions_sensor_m)
|
||||||
@@ -374,10 +396,10 @@ def test_the_composer_refuses_a_frame_from_another_geometry():
|
|||||||
def test_a_reference_frame_measures_nothing():
|
def test_a_reference_frame_measures_nothing():
|
||||||
scene = synthetic.poc_scene()
|
scene = synthetic.poc_scene()
|
||||||
rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1))
|
rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1))
|
||||||
assert not np.asarray(rest.valid).any()
|
assert not np.asarray(rest.field.valid).any()
|
||||||
assert np.isnan(np.asarray(rest.scalar)).all()
|
assert np.isnan(np.asarray(rest.field.scalar)).all()
|
||||||
assert rest.scalar_descriptor.quantity == "unavailable"
|
assert rest.field.descriptor.quantity == "unavailable"
|
||||||
assert rest.scalar_descriptor.quality == "display_only"
|
assert rest.field.descriptor.quality == "display_only"
|
||||||
assert np.allclose(
|
assert np.allclose(
|
||||||
np.asarray(rest.positions_sensor_m),
|
np.asarray(rest.positions_sensor_m),
|
||||||
np.asarray(scene.sensing_part().positions_sensor_m),
|
np.asarray(scene.sensing_part().positions_sensor_m),
|
||||||
@@ -389,10 +411,14 @@ def test_the_composed_frame_carries_read_only_arrays():
|
|||||||
composed = SceneComposer().compose(
|
composed = SceneComposer().compose(
|
||||||
scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
|
scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
|
||||||
)
|
)
|
||||||
for name in ("positions_sensor_m", "normals_sensor", "scalar", "valid"):
|
for name in ("positions_sensor_m", "normals_sensor"):
|
||||||
value = getattr(composed, name)
|
value = getattr(composed, name)
|
||||||
assert not value.flags["WRITEABLE"], f"{name} is writable"
|
assert not value.flags["WRITEABLE"], f"{name} is writable"
|
||||||
assert value.flags["C_CONTIGUOUS"]
|
assert value.flags["C_CONTIGUOUS"]
|
||||||
|
for name in ("scalar", "valid"):
|
||||||
|
value = getattr(composed.field, name)
|
||||||
|
assert not value.flags["WRITEABLE"], f"field.{name} is writable"
|
||||||
|
assert value.flags["C_CONTIGUOUS"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_composer_does_not_rebuild_the_binding_or_the_indices():
|
def test_the_composer_does_not_rebuild_the_binding_or_the_indices():
|
||||||
@@ -443,6 +469,7 @@ def test_a_calibrated_displacement_still_recomputes_positions_and_normals():
|
|||||||
composed = SceneComposer().compose(
|
composed = SceneComposer().compose(
|
||||||
scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0)
|
scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0)
|
||||||
)
|
)
|
||||||
|
assert composed.deformed
|
||||||
assert composed.positions_sensor_m is not part.positions_sensor_m
|
assert composed.positions_sensor_m is not part.positions_sensor_m
|
||||||
assert composed.normals_sensor is not part.normals_sensor
|
assert composed.normals_sensor is not part.normals_sensor
|
||||||
assert not np.allclose(
|
assert not np.allclose(
|
||||||
|
|||||||
Reference in New Issue
Block a user