modify 3d view to sync / identical to 2d view

This commit is contained in:
ykcheungab
2026-09-27 10:42:38 +08:00
parent 3daee6d407
commit bcab2790bf
24 changed files with 1971 additions and 530 deletions
@@ -254,6 +254,11 @@ class StudioMainWindow(QMainWindow):
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
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.force_plots = ForcePlotsPanel(theme=self._theme)
# 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(
packet: SdkPacket, shape_hw: tuple[int, int]
packet: SdkPacket,
shape_hw: tuple[int, int],
latch: Any = None,
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
"""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
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.
``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)
if explicit is None:
@@ -161,6 +170,10 @@ def _device_mask(
mask=_shape_of(array),
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
@@ -224,6 +237,11 @@ class FingerFrameAdapter:
#: 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.
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:
self.preferred_field = _field_name(name)
@@ -387,7 +405,7 @@ class FingerFrameAdapter:
"""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
# 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:
refused.append(mask_problem)
return None
@@ -407,7 +425,7 @@ class FingerFrameAdapter:
arr = np.asarray(flow, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
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)
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn):
@@ -452,6 +470,8 @@ class BoxFrameAdapter:
self.field_domain_id = str(field_domain_id)
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
self.last_diagnostic: Optional[Diagnostic] = None
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
self.domain_latch: Any = None
def snapshot(
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
@@ -468,7 +488,7 @@ class BoxFrameAdapter:
("depth", getattr(packet, "depth", None)),
("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:
self.last_diagnostic = mask_problem
return None
@@ -452,7 +452,7 @@ class ThreeDCoordinator:
# A frame arrived with its own fields: whatever went stale is current
# again, and the grace starts over next time it fails.
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"):
# Nothing has arrived yet: show the reference model rather than a blank
# 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.
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:
"""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
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
per frame and its colour comes from ``scalar`` through a LUT texture with the range
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
in the neutral colour rather than in colour zero — the reference model is not a
measurement and must not look like one.
Two parts, two programs, one draw call per part. The sensing surface's colour comes
from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
builds an RGB array per frame and never gathers the field onto the vertices; the
vertex buffer is uploaded once and re-uploaded only when a display deformation
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
@@ -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
#: 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:
"""Select the default GL surface format. Call before ``QApplication``.
@@ -112,33 +124,42 @@ def profile_from_renderable_type(value: int) -> Profile:
# --------------------------------------------------------------------- shaders
#: Shared bodies: the version header is the only difference, so the maths cannot
#: 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 = """
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in float a_scalar;
layout(location = 3) in float a_valid;
layout(location = 4) in vec2 a_uv;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec3 v_normal;
out float v_scalar;
out float v_valid;
out vec2 v_uv;
void main() {
v_normal = a_normal;
v_scalar = a_scalar;
v_valid = a_valid;
v_uv = a_uv;
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
_SCALAR_FRAGMENT_BODY = """
in vec3 v_normal;
in float v_scalar;
in float v_valid;
in vec2 v_uv;
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_upper;
uniform vec4 u_invalid_color;
@@ -146,20 +167,25 @@ uniform vec4 u_invalid_color;
out vec4 frag_color;
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;
return;
}
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);
}
"""
_DIFFUSE_FRAGMENT_BODY = """
in vec3 v_normal;
in float v_scalar;
in float v_valid;
uniform vec3 u_light_direction;
uniform vec4 u_base_color;
@@ -310,106 +336,134 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
return lower, upper
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
"""A 256x1 RGBA8 lookup table.
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
"""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
dependency for one texture, and the ramp is part of what the colours *mean*.
``sequential`` is perceptually increasing in lightness (safe for magnitude);
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a
signed quantity, and never used for an unsigned one).
``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
(``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
which is the dark theme's own. Either way the table is the *same* array the 2D
canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
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(
[
[0.267, 0.005, 0.329],
[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
from orisys_app_shared.acquisition.arrows import ramp_rgba_table
return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
# ---------------------------------------------------------------- vertex packing
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1)
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData.
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
#: 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
#: Where in a vertex row the UV starts.
UV_OFFSET_FLOATS = 6
def pack_part(part: MeshPart, *, scalar: Optional[np.ndarray] = None,
valid: Optional[np.ndarray] = None) -> np.ndarray:
#: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
#: 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.
The static case (no scalar yet) marks every vertex invalid, which the shader
draws as the neutral colour: a scene that has never received a measurement
must not be coloured as if it had.
``uvs`` are the render-vertex UVs on the domain's pixel grid
(``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
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])
rows = np.empty((count, STRIDE_FLOATS), 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)
if scalar is None:
rows[:, 6] = np.nan
if uvs is None:
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
else:
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
if values.size != count:
values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
if values.shape[0] != count:
raise error(
"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
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)
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = values
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."""
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:
"""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
for part in assets.parts:
vertices = int(part.positions_sensor_m.shape[0])
indices = int(part.triangles.size)
total += vertices * STRIDE_FLOATS * 4 + indices * 4
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 += 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
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.
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
return build_rest_frame(assets, identity)
return build_rest_frame(assets, identity, ramp=ramp)
# ------------------------------------------------------------------- dispatch
@@ -543,9 +597,13 @@ def _leading_int(text: str) -> int:
class _PartBuffers:
"""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.role = part.role
self.vao = vao
@@ -553,6 +611,9 @@ class _PartBuffers:
self.ebo = ebo
self.triangles = int(part.triangles.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:
@@ -565,7 +626,15 @@ class GLRenderer:
self._parts: dict[str, _PartBuffers] = {}
self._lut: int = 0
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.
self._scalar_range: Optional[tuple[float, float]] = None
#: The colour bounds of the last draw and how they were chosen.
@@ -621,8 +690,11 @@ class GLRenderer:
self._scalar_range = None
self._last_scale = None
self._last_scale_mode = ""
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
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))
vbo = int(GL.glGenBuffers(1))
ebo = int(GL.glGenBuffers(1))
@@ -639,13 +711,24 @@ class GLRenderer:
GL.glBindVertexArray(0)
GL.glBindBuffer(GL.GL_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(
assets, dynamic_part_id=self._sensing_part_id
)
def update_surface(self, frame: RenderFrame) -> None:
"""Update the sensing part's vertices in place: no index rebuild, ever."""
def update_field(self, frame: RenderFrame) -> None:
"""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
self._require_initialized()
@@ -655,26 +738,132 @@ class GLRenderer:
"geometry_mismatch",
f"the scene has no part {frame.sensing_part_id!r} to update",
)
rows = pack_part(
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor),
scalar=frame.scalar,
valid=frame.valid,
)
if int(rows.shape[0]) != buffers.vertices:
field = frame.field
if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
raise error(
"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",
)
started = time.perf_counter()
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)
# The field carries the theme's ramp, so the lookup table follows the same
# string the 2D canvas built its table from. Checked here rather than plumbed
# 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
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._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:
"""Upload merged flow / contact arrow meshes for this frame."""
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.
self._last_scale = (float(lower), float(upper))
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():
if part.role == "housing" and not settings.housing_visible:
continue
@@ -781,10 +972,7 @@ class GLRenderer:
_set_float(GL, locations.get("u_lower"), lower)
_set_float(GL, locations.get("u_upper"), upper)
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
if locations.get("u_lut") is not None:
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glUniform1i(locations["u_lut"], 0)
self._bind_field_textures(GL, locations)
else:
_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))
@@ -837,6 +1025,49 @@ class GLRenderer:
"""The colour bounds the last draw actually used (``None`` before any draw)."""
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:
"""Delete every GPU resource. Idempotent; the owning context must be current."""
from OpenGL import GL
@@ -858,6 +1089,7 @@ class GLRenderer:
pass
self._lut = 0
self._lut_id = ""
self._release_field_textures()
self._initialized = False
self._estimated_gpu_bytes = 0
self._scalar_range = None
@@ -865,6 +1097,20 @@ class GLRenderer:
self._last_scale_mode = ""
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:
from OpenGL import GL
@@ -890,7 +1136,7 @@ class GLRenderer:
if self._lut:
GL.glDeleteTextures([int(self._lut)])
self._lut = 0
data = lut_rgba(lut_id)
data = lut_rgba(lut_id, self._lut_ramp)
self._lut = int(GL.glGenTextures(1))
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
@@ -916,17 +1162,6 @@ class GLRenderer:
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
#: it never competes with the measurement colour scale.
_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:
"""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
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
GL.glEnableVertexAttribArray(2)
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6))
GL.glEnableVertexAttribArray(3)
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7))
GL.glEnableVertexAttribArray(4)
GL.glVertexAttribPointer(
4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
)
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]:
names = (
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper",
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color",
"u_view", "u_projection", "u_lower", "u_upper",
"u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
"u_light_direction", "u_base_color",
)
out: dict[str, int] = {}
for name in names:
+25 -1
View File
@@ -90,6 +90,9 @@ class ColorScaleBar(QLabel):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setVisible(False)
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:
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
@@ -98,6 +101,13 @@ class ColorScaleBar(QLabel):
self._lut_id = wanted
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
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,
# 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)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
@@ -393,6 +405,18 @@ class ThreeDPanel(QFrame):
self._render_scale_bounds()
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:
"""Offer only the controls this data source can actually feed.
+45 -3
View File
@@ -99,6 +99,13 @@ class Scene3DView(QWidget):
self._camera = OrbitCamera()
self._executor: Optional[ThreadPoolExecutor] = 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._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
#: 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._widget = Sensor3DWidget(GLRenderer(), self._camera)
self._composer = SceneComposer()
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)
# 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 "")
self._adapter = None
device = expected
common = {
"geometry_id": str(
common = { "geometry_id": str(
device.geometry_id if device is not None and device.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
# passed here. Remapping it again would index the flow at source pixels.
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
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
def submit_packet(self, packet: Any) -> None:
"""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._unique_frames += 1
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()
def set_settings(self, settings: DisplaySettings) -> None:
@@ -216,11 +216,11 @@ class Sensor3DWidget(QOpenGLWidget):
)
)
if self._assets is not None:
self._renderer.update_surface(
self._renderer.update_field(
rest_surface(self._assets, frame.identity)
)
else:
self._renderer.update_surface(frame)
self._renderer.update_field(frame)
stats = self._renderer.draw(
self._camera.matrices(self.viewport_pixels()),
self._settings,
@@ -111,6 +111,9 @@ class Mesh3dPanel(QFrame):
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
#: until the view exists (see ``set_diagnostics_visible``).
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.setContentsMargins(0, 0, 0, 0)
@@ -189,9 +192,26 @@ class Mesh3dPanel(QFrame):
panel.set_diagnostics_visible(self._show_diagnostics)
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._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:
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
@@ -267,6 +287,16 @@ class Mesh3dPanel(QFrame):
self._stack.addWidget(view)
view.diagnosticRaised.connect(self._on_diagnostic)
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:
view.restore_settings(self._pending_settings)
# The kind was decided before the view existed (the window sets it when the