diff --git a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene index 4611e48..e9816b3 100644 Binary files a/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene and b/apps/studio/config/sensors/finger/scene/finger-display-1.0.0.oryscene differ diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 0613042..8a91e6a 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/adapters.py b/apps/studio/src/orisys_studio/scene3d/adapters.py index 2009003..aaf740b 100644 --- a/apps/studio/src/orisys_studio/scene3d/adapters.py +++ b/apps/studio/src/orisys_studio/scene3d/adapters.py @@ -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 diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index 97d0da5..bff30b3 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 2e468de..5a51493 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -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: diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 8ee5685..5391757 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -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. diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index e16212f..5897d5a 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -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.""" diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index c91e1d6..5cc70c8 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -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, diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 540e374..5c7f5c5 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -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 diff --git a/apps/studio/tests/test_scene3d_coordinator.py b/apps/studio/tests/test_scene3d_coordinator.py index 7e8155d..e96e76e 100644 --- a/apps/studio/tests/test_scene3d_coordinator.py +++ b/apps/studio/tests/test_scene3d_coordinator.py @@ -132,7 +132,7 @@ def test_activating_a_scene_bumps_the_generation_and_shows_the_reference_model(s assert coordinator.generation == 1 assert widget.scenes == [scene] 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") assert second == 2 and coordinator.generation == 2 # 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) assert len(widget.frames) >= 2 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" 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)) _drain(coordinator) assert coordinator.state == "no_data" - assert not np.asarray(widget.frames[-1].valid).any() - assert np.isnan(np.asarray(widget.frames[-1].scalar)).all() + # Absence is the *validity mask*, not a rewrite of the measurement: the field + # 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() @@ -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 before = len(widget.frames) _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 coordinator.stale_frames >= 0 coordinator.close() @@ -329,7 +335,7 @@ def test_a_new_session_is_refused_until_it_is_announced(scene, pool): before = coordinator.generation coordinator.begin_session() 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" ) 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")) _drain(coordinator, ticks=10) 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() last = widget.frames[-1] - assert not last.valid.any(), "the old measurement is still on screen" - assert last.scalar_descriptor.quantity == "unavailable" + assert not last.field.valid.any(), "the old measurement is still on screen" + assert last.field.descriptor.quantity == "unavailable" assert coordinator.state == "no_data" # 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.show_rest() _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" ) coordinator.close() @@ -497,14 +503,14 @@ def test_a_compose_running_when_the_pass_stopped_can_never_come_back(scene, pool coordinator.show_rest() 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() _drain(coordinator, ticks=40, sleep=0.01) assert coordinator.epoch == epoch + 1 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" ) 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) coordinator.set_visible(True) _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" ) 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): composer, widget, coordinator = _start_blocked_compose(scene, pool) coordinator.begin_session() - assert widget.frames[-1].scalar_descriptor.quantity == "unavailable" + assert widget.frames[-1].field.descriptor.quantity == "unavailable" composer.release.set() _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" ) coordinator.close() @@ -563,7 +569,7 @@ def test_a_compose_cancelled_before_it_started_is_not_an_error(scene): executor.run_next() coordinator.tick() 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() @@ -586,9 +592,9 @@ def test_repeated_start_and_stop_never_leaves_an_old_measurement(scene, pool): ) coordinator.tick() 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) - 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 composer.max_in_flight == 1 coordinator.close() @@ -616,7 +622,7 @@ def test_an_older_request_id_cannot_move_the_picture_backwards(scene): coordinator.tick() coordinator.tick() 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 coordinator.close() @@ -637,7 +643,7 @@ def test_a_result_from_an_older_request_is_dropped_on_arrival(scene): assert coordinator.submit(newer) == "accepted" executor.run_next() # request 1's compose finishes late _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" ) coordinator.close() @@ -688,7 +694,7 @@ def test_a_stale_array_is_never_presented_as_the_current_instant(scene): 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" ) 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() 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 pane must stop calling it live immediately. coordinator.note_field_failure(_stale_diagnostic()) assert coordinator.state == "stale" 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" ) assert coordinator.state == "stale" clock.advance(1.5) coordinator.tick() - assert not widget.frames[-1].valid.any(), "the expired measurement is still on screen" - assert widget.frames[-1].scalar_descriptor.quantity == "unavailable" + assert not widget.frames[-1].field.valid.any(), "the expired measurement is still on screen" + assert widget.frames[-1].field.descriptor.quantity == "unavailable" assert coordinator.state == "no_data" coordinator.close() @@ -746,7 +752,7 @@ def test_a_good_frame_after_a_failure_goes_back_to_live(scene): coordinator.tick() coordinator.tick() 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() diff --git a/apps/studio/tests/test_scene3d_gl_smoke.py b/apps/studio/tests/test_scene3d_gl_smoke.py index 7555d15..1a303cb 100644 --- a/apps/studio/tests/test_scene3d_gl_smoke.py +++ b/apps/studio/tests/test_scene3d_gl_smoke.py @@ -27,6 +27,7 @@ import pytest from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( ColorScale, + DisplayField, DisplaySettings, FieldDescriptor, FrameIdentity, @@ -44,6 +45,48 @@ pytestmark = pytest.mark.skipif( 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: """Let Qt paint until ``predicate`` holds. Returns whether it did.""" import time @@ -123,6 +166,13 @@ def _plate_is_grey(pixels: np.ndarray) -> float: 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): widget, _, _ = gl_widget 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): """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 - end is not. Counting "grey pixels" over the whole frame would just be counting - the background. + Asserted as a *colour identity*, not as a hue difference: the valid path must draw + the lookup table's own colour for the value it was given, and the neutral path must + 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 assets = synthetic.poc_scene() - count = int(assets.sensing_part().positions_sensor_m.shape[0]) 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( - identity=FrameIdentity("poc", 1, 5, 0.5, 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, - 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), - ) + value = 0.05 + frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32)) widget.set_settings( DisplaySettings( housing_visible=False, color_scale=ColorScale( - descriptor=frame.scalar_descriptor, + descriptor=frame.field.descriptor, lower=0.0, upper=1.0, 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) - 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})" - assert measured_grey > rest_grey + 10.0, ( - "the colour map made no difference: the invalid path is not being taken " - f"(rest r-g={rest_grey:.1f}, measured r-g={measured_grey:.1f})" + # Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture + # with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table. + table = lut_rgba("sequential")[:, 0, :3].astype(float) + 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)) @@ -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]) positions = np.asarray(assets.sensing_part().positions_sensor_m).copy() positions[:, 2] = 0.004 # a visible bump - frame = RenderFrame( - identity=FrameIdentity("poc", 1, 6, 0.6, generation=1), - asset_hash=assets.asset_hash, - geometry_id=assets.geometry_id, - sensing_part_id=assets.sensing_part().part_id, - positions_sensor_m=freeze(positions, dtype=np.float32), - 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), + frame = _frame( + assets, + np.linspace(0.0, 1.0, count).reshape(assets.shape_hw), + frame_id=6, + positions=freeze(positions, dtype=np.float32), + deformed=True, ) widget.set_settings( DisplaySettings( 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): """A bad frame is refused; the scene stays usable and stops showing it.""" widget, _, failures = gl_widget - count = 9 before = widget.rejected_frames - frame = RenderFrame( - identity=FrameIdentity("poc", 1, 2, 0.2, generation=1), - asset_hash="other", - 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), + frame = _frame( + synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32), + frame_id=2, ) + object.__setattr__(frame, "asset_hash", "other") + object.__setattr__(frame, "geometry_id", "some-other-geometry") widget.present(frame) assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame" 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 assets_frame = view.assets assert assets_frame is not None - count = int(assets_frame.sensing_part().positions_sensor_m.shape[0]) def frame_with(values: np.ndarray) -> 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, positions_sensor_m=assets_frame.sensing_part().positions_sensor_m, normals_sensor=assets_frame.sensing_part().normals_sensor, - scalar=freeze(np.asarray(values, 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), + field=_field(assets_frame, values), ) 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" ), ) - 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.present(frame_with(ramp)) diff --git a/apps/studio/tests/test_scene3d_region_agreement.py b/apps/studio/tests/test_scene3d_region_agreement.py new file mode 100644 index 0000000..973087c --- /dev/null +++ b/apps/studio/tests/test_scene3d_region_agreement.py @@ -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 diff --git a/apps/studio/tests/test_scene3d_stage1.py b/apps/studio/tests/test_scene3d_stage1.py index eccd97c..e44e052 100644 --- a/apps/studio/tests/test_scene3d_stage1.py +++ b/apps/studio/tests/test_scene3d_stage1.py @@ -24,8 +24,10 @@ from orisys_studio.scene3d.camera import ( OrbitCamera, ) from orisys_studio.scene3d.gl_backend import ( + FIELD_BYTES_PER_TEXEL, LUT_SIZE, STRIDE_FLOATS, + VALID_BYTES_PER_TEXEL, estimate_gpu_bytes, is_software_renderer, lut_rgba, @@ -259,7 +261,14 @@ def test_shader_sources_are_two_dialects_of_one_body(): len(shader_header("gles30")): ] 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(): @@ -301,35 +310,33 @@ def _plate(**kwargs): 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() count = int(part.positions_sensor_m.shape[0]) - scalar = np.linspace(0.0, 1.0, count, dtype=np.float32) - valid = np.ones(count, dtype=bool) - rows = pack_part(part, scalar=scalar, valid=valid) + uvs = synthetic.poc_scene().binding.uvs + rows = pack_part(part, uvs=uvs) assert rows.shape == (count, STRIDE_FLOATS) assert rows.dtype == np.float32 and rows.flags["C_CONTIGUOUS"] 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[:, 3:6], part.normals_sensor) - assert np.allclose(rows[:, 6], scalar) - assert np.allclose(rows[:, 7], 1.0) + assert np.allclose(rows[:, 6:8], uvs) + # 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()) - assert np.isnan(rows[:, 6]).all(), "an unmeasured scalar must be NaN, not zero" - assert np.allclose(rows[:, 7], 0.0), "an unmeasured vertex must be invalid" + assert np.allclose(rows[:, 6:8], 0.0) -def test_packing_refuses_a_mismatched_length(): - part = _plate() - with pytest.raises(Exception) as scalar_error: - pack_part(part, scalar=np.zeros(3, dtype=np.float32)) - 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_packing_refuses_a_mismatched_uv_count(): + with pytest.raises(Exception) as error_: + pack_part(_plate(), uvs=np.zeros((3, 2), dtype=np.float32)) + assert "UVs for" in str(error_.value) 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] -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() budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface") 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.triangles.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 < 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(): assets = synthetic.poc_scene() frame = rest_surface(assets, FrameIdentity("poc", 1, 0, 0.0, 1)) - count = int(assets.sensing_part().positions_sensor_m.shape[0]) - assert frame.scalar.shape == (count,) and frame.valid.shape == (count,) - assert not frame.valid.any(), "a reference frame measures nothing" - assert np.isnan(frame.scalar).all(), "an unavailable scalar is NaN, not zero" - assert frame.scalar_descriptor.quantity == "unavailable" - assert frame.scalar_descriptor.quality == "display_only" + shape = assets.shape_hw + assert frame.field.scalar.shape == shape and frame.field.valid.shape == shape + assert frame.field.shape_hw == shape + assert frame.field.domain_id == assets.field_domain_id + assert not frame.field.valid.any(), "a reference frame measures nothing" + 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.sensing_part_id == assets.sensing_part().part_id diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index 662e284..b0c1f2f 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -67,24 +67,71 @@ def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]: return stops -@lru_cache(maxsize=8) -def _lut_from_ramp(ramp: str) -> np.ndarray: - """Build a 256-entry user colour table from ``#RRGGBB`` ramp stops. +def ramp_for_theme(theme: str = "dark") -> str: + """The theme's own ``flow_ramp`` string, resolved and never empty. - The table is BGR, matching how :func:`cv2.applyColorMap` consumes user - colormaps. Invalid input never raises: it falls back to - :data:`FLOW_RAMP_FALLBACK`. + This is the **single** ramp both views build their lookup table from: the 2D + canvas through :func:`flow_colormap`, the 3D surface through the field it is + 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) if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS: stops = _parse_ramp(FLOW_RAMP_FALLBACK) positions = np.linspace(0.0, 1.0, _LUT_STEPS) 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 values = np.interp(positions, span, [stop[channel] for stop in stops]) - lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8) - return lut[:, :, ::-1].copy() + table[:, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8) + 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: @@ -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 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: diff --git a/shared/src/orisys_app_shared/scene3d/__init__.py b/shared/src/orisys_app_shared/scene3d/__init__.py index d99ba06..f001097 100644 --- a/shared/src/orisys_app_shared/scene3d/__init__.py +++ b/shared/src/orisys_app_shared/scene3d/__init__.py @@ -22,6 +22,7 @@ from orisys_app_shared.scene3d.contracts import ( CompileOptions, CompileReport, Diagnostic, + DisplayField, DisplaySettings, ExpectedGeometry, F32, @@ -45,7 +46,7 @@ from orisys_app_shared.scene3d.contracts import ( SurfaceMapping, ThreeDError, U32, - VertexFields, + diagnostic, error, freeze, @@ -72,6 +73,7 @@ __all__ = [ "CompileOptions", "CompileReport", "Diagnostic", + "DisplayField", "DisplaySettings", "ExpectedGeometry", "F32", @@ -95,7 +97,6 @@ __all__ = [ "SurfaceMapping", "ThreeDError", "U32", - "VertexFields", "diagnostic", "error", "freeze", diff --git a/shared/src/orisys_app_shared/scene3d/composer.py b/shared/src/orisys_app_shared/scene3d/composer.py index f549c37..11ad862 100644 --- a/shared/src/orisys_app_shared/scene3d/composer.py +++ b/shared/src/orisys_app_shared/scene3d/composer.py @@ -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``. -This module decides what a colour and a vertex position *mean*, so the rules from -§3.2 of the design document are its reason to exist: +The composer no longer *samples*. It used to gather the field onto every sensing +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 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. * without a trustworthy displacement the surface is coloured in place, and the total force is never turned into a per-vertex height. -* a reference frame measures nothing: every validity flag is false and the scalar is - absent, so the UI hides the colour bar rather than leaving the previous one on - screen. +* a reference frame measures nothing: the field arrives with every validity flag + false and no scalar. """ from __future__ import annotations @@ -27,8 +35,8 @@ import numpy as np from orisys_app_shared.scene3d.contracts import ( Diagnostic, + DisplayField, DisplaySettings, - FieldDescriptor, FrameIdentity, Quality, RenderFrame, @@ -47,7 +55,10 @@ from orisys_app_shared.scene3d.overlays import ( build_normal_overlay, 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 #: Reported when a display gain is applied to a displacement that is only @@ -74,8 +85,7 @@ class SceneComposer: looking at. """ - def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None: - self._sampler = sampler or SurfaceSampler() + def __init__(self, *, ramp: str = "") -> None: self.last_stats = ComposeStats() #: 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 @@ -83,6 +93,9 @@ class SceneComposer: self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {} #: Last UV grid of "arrow was on" samples for flow hysteresis. 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( self, @@ -96,23 +109,67 @@ class SceneComposer: self._check(assets, frame) for item in validate_surface_frame(frame): # 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": raise error(item.code, item.message, **dict(item.details)) 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() - 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 rest_positions, rest_normals = self._rest(assets) diagnostics: list[Diagnostic] = [] positions = rest_positions + normals = rest_normals 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: gain = float(settings.display_gain) - displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64) - positions = np.asarray(rest_positions, dtype=np.float64) + gain * displacement + # A tap that was invalid leaves ``NaN`` here, and a vertex with no measured + # 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": diagnostics.append( diagnostic( @@ -123,7 +180,7 @@ class SceneComposer: 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( diagnostic( "field_unavailable", @@ -133,11 +190,8 @@ class SceneComposer: normals_started = time.perf_counter() # 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. - normals = ( - vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) - if deformed - else rest_normals - ) + if deformed: + normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64)) 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 @@ -154,21 +208,18 @@ class SceneComposer: geometry_id=assets.geometry_id, sensing_part_id=part.part_id, positions_sensor_m=( - freeze(positions, dtype=np.float32) - if deformed - else rest_positions + freeze(positions, dtype=np.float32) if deformed else rest_positions ), normals_sensor=( freeze(normals, dtype=np.float32) if deformed else rest_normals ), # The measurement, untouched by the display gain. - scalar=fields.scalar, - valid=fields.valid, - scalar_descriptor=frame.scalar_descriptor, + field=field, diagnostics=tuple(diagnostics), # Carried through untouched: only the display side knows whether the frame # it is about to show is still the current one. computed_frame_id=frame.computed_frame_id, + deformed=deformed, # 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 # GUI thread would report another frame's cost. @@ -176,7 +227,11 @@ class SceneComposer: flow_arrows=flow_arrows, 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 def _build_overlays( @@ -275,7 +330,7 @@ class SceneComposer: def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame: """The static reference: nothing measured, so nothing coloured.""" - return build_rest_frame(assets, identity) + return build_rest_frame(assets, identity, ramp=self.ramp) @staticmethod 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. 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() - 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( identity=identity, asset_hash=assets.asset_hash, @@ -317,7 +381,14 @@ def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFram sensing_part_id=part.part_id, positions_sensor_m=part.positions_sensor_m, normals_sensor=part.normals_sensor, - scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32), - valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_), - scalar_descriptor=rest_descriptor(), + field=DisplayField( + domain_id=assets.field_domain_id, + 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), + ), ) diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 5c209ac..48b0ddc 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -268,12 +268,26 @@ class MeshPart: @dataclass(frozen=True) 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 geometry_id: str field_domain_id: str 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_weights: F32 # [N,4], non-negative, used weights sum to 1 @@ -627,12 +641,37 @@ class DisplaySettings: @dataclass(frozen=True) -class VertexFields: - """A UV field sampled onto the sensing vertices.""" +class DisplayField: + """The one field both views draw, on the domain's own grid. - scalar: F32 # [N] - valid: Mask # [N] - displacement_sensor_m: Optional[F32] = None # [N,3] + This object exists so that the two-dimensional canvas and the three-dimensional + surface cannot disagree about what was measured. Before it, the 3D pane had a + *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) @@ -645,13 +684,17 @@ class RenderFrame: sensing_part_id: str positions_sensor_m: F32 # [N,3], display displacement already applied normals_sensor: F32 # [N,3] - scalar: F32 # [N], before display gain - valid: Mask # [N] - scalar_descriptor: FieldDescriptor + #: The measurement, on the domain's grid. Not per vertex: see :class:`DisplayField`. + field: DisplayField diagnostics: tuple[Diagnostic, ...] = () #: The acquisition frame the coloured values belong to, carried through from the #: field frame so the *display* side can re-check it before presenting. 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 #: rather than read from the composer's shared counter, which the next task rewrites. 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, ...]: - """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 ( + ("uvs", binding.uvs), ("sample_indices", binding.sample_indices), ("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 # export), not a differently shaped UV field. for name, array in ( + ("uvs", binding.uvs), ("sample_indices", binding.sample_indices), ("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 " "vertices: this binding belongs to a different mesh", ) + _shape("uvs", binding.uvs, (vertices, 2)) _shape("sample_indices", binding.sample_indices, (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: raise error("field_domain_mismatch", "sample_indices must be int32") if binding.sample_weights.dtype != np.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 indices = binding.sample_indices weights = binding.sample_weights @@ -1156,20 +1221,47 @@ def validate_display_settings(settings: DisplaySettings) -> tuple[Diagnostic, .. 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 ( ("positions_sensor_m", frame.positions_sensor_m), ("normals_sensor", frame.normals_sensor), - ("scalar", frame.scalar), - ("valid", frame.valid), ): _readonly_c_contiguous(name, array) _shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3)) _shape("normals_sensor", frame.normals_sensor, (vertices, 3)) - _shape("scalar", frame.scalar, (vertices,)) - _shape("valid", frame.valid, (vertices,)) - if frame.scalar.dtype != np.float32 or frame.valid.dtype != np.bool_: - raise error("field_domain_mismatch", "render scalar is float32 and valid is bool") + field = frame.field + for name, array in (("field.scalar", field.scalar), ("field.valid", field.valid)): + _readonly_c_contiguous(name, array) + 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(): raise error("geometry_mismatch", "a render frame must name its asset and geometry") for label, arrows in ( @@ -1315,15 +1407,15 @@ class BoxFrameAdapter(Protocol): ) -> Optional[SurfaceFieldFrame]: ... -class SurfaceSampler(Protocol): - def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ... - - class SceneComposer(Protocol): def compose( self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings ) -> 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: @@ -1349,7 +1441,7 @@ class GLDispatch(Protocol): class GLRenderer(Protocol): def initialize(self, capabilities: GLCapabilities) -> None: ... def upload_scene(self, assets: SceneAssets) -> None: ... - def update_surface(self, frame: RenderFrame) -> None: ... + def update_field(self, frame: RenderFrame) -> None: ... def draw( self, camera: CameraMatrices, diff --git a/shared/src/orisys_app_shared/scene3d/digest.py b/shared/src/orisys_app_shared/scene3d/digest.py index 79fce38..43a1ea5 100644 --- a/shared/src/orisys_app_shared/scene3d/digest.py +++ b/shared/src/orisys_app_shared/scene3d/digest.py @@ -30,6 +30,38 @@ from orisys_app_shared.scene3d.contracts import MeshPart, SurfaceBinding, error #: else is worse than no digest at all. 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: """C-contiguous little-endian bytes: the same on every machine.""" diff --git a/shared/src/orisys_app_shared/scene3d/legacy.py b/shared/src/orisys_app_shared/scene3d/legacy.py index 6cb6adf..9958035 100644 --- a/shared/src/orisys_app_shared/scene3d/legacy.py +++ b/shared/src/orisys_app_shared/scene3d/legacy.py @@ -219,6 +219,28 @@ def uv_to_pixel(u: float, v: float, domain: LegacyFieldDomain) -> tuple[float, f 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( vertices: np.ndarray, faces: np.ndarray, @@ -540,16 +562,27 @@ def build_binding( part_id: str, geometry_id_value: str, ) -> 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 - 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. + ``uvs`` is the map the *shader* reads: one pixel coordinate per render vertex, in + the same order as the mesh's positions, already flipped and scaled, so the + fragment program only normalises by the domain size. Each row of + ``sample_indices``/``sample_weights`` holds up to four ``y*W+x`` indices and their + 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 count = len(render_uv) + pixels = uvs_to_pixels(render_uv, domain) indices = np.full((count, 4), -1, dtype=np.int32) weights = np.zeros((count, 4), dtype=np.float32) for row in range(count): @@ -584,6 +617,7 @@ def build_binding( geometry_id=geometry_id_value, field_domain_id=domain.domain_id, shape_hw=domain.shape_hw, + uvs=pixels, sample_indices=freeze(indices, dtype=np.int32), sample_weights=freeze(weights, dtype=np.float32), ) @@ -766,6 +800,7 @@ class LegacyFingerImporter: hash_arrays: dict[str, Any] = { "positions": sensing.positions_sensor_m, "triangles": sensing.triangles, + "uv": binding.uvs, "indices": binding.sample_indices, "weights": binding.sample_weights, } diff --git a/shared/src/orisys_app_shared/scene3d/package.py b/shared/src/orisys_app_shared/scene3d/package.py index 95c3186..05615d4 100644 --- a/shared/src/orisys_app_shared/scene3d/package.py +++ b/shared/src/orisys_app_shared/scene3d/package.py @@ -99,10 +99,18 @@ MAX_COMPRESSION_RATIO = 200 RATIO_FLOOR_BYTES = 4 << 20 #: 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/positions.npy": ("f4", 2), "sensing/normals.npy": ("f4", 2), "sensing/triangles.npy": ("u4", 2), + "sensing/uv.npy": ("f4", 2), "sensing/sample_indices.npy": ("i4", 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/normals.npy"] = np.ascontiguousarray(sensing.normals_sensor) 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_weights.npy"] = np.ascontiguousarray(assets.binding.sample_weights) if assets.source_vertex_of_render is not None: @@ -688,11 +697,37 @@ def scene_from_contents( ) if not 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( part_id="sensing_surface", geometry_id=str(manifest.get("geometry_id") or ""), field_domain_id=str(domain.get("id") or ""), 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_weights=freeze(arrays["sensing/sample_weights.npy"], dtype=np.float32), ) diff --git a/shared/src/orisys_app_shared/scene3d/sampling.py b/shared/src/orisys_app_shared/scene3d/sampling.py index f6dabe2..6d7fcfd 100644 --- a/shared/src/orisys_app_shared/scene3d/sampling.py +++ b/shared/src/orisys_app_shared/scene3d/sampling.py @@ -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``. -The sampling table is built once, at load time, from the field's own domain. It is -never rebuilt per frame, and a vertex is never repaired by searching for the nearest -triangle: if any tap that carries weight is invalid, the vertex is invalid. +**The per-vertex colour gather is gone.** This module used to hold +``SurfaceSampler.sample``: a vectorised bilinear gather of the field onto every +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 - 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 - neutral colour. -* **validity is a separate array from the value.** A vertex whose scalar happens to - be 0.0 is a measurement of zero; a vertex that is invalid has no value at all, and - the value is left as ``NaN`` so nobody can mistake one for the other. -* **the domain is checked, not assumed.** A frame whose domain or shape does not - match the binding is refused with ``field_domain_mismatch`` rather than sampled - into a plausible-looking but wrong picture. +Two things remain, and they are not the colour path: + +* :func:`remap_to_domain` — a gather, but of a *source* field onto the scene's domain, + done once in the acquisition adapter before any frame is published. It is how a + tracker field that arrives in the camera's grid reaches the UV domain. +* :func:`sample_vertex_displacement` — the display **deformation**, which moves + vertices and is therefore a vertex-stage quantity by definition. It runs only when + ``DisplaySettings.deform_enabled`` is set, and no adapter in this build publishes a + displacement, so the application never executes it. + +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 typing import Optional + import numpy as np from orisys_app_shared.scene3d.contracts import ( SurfaceBinding, SurfaceFieldFrame, - VertexFields, error, freeze, ) @@ -37,97 +47,87 @@ from orisys_app_shared.scene3d.contracts import ( WEIGHT_EPS = 1e-7 -class SurfaceSampler: - """Vectorised bilinear sampling through a precomputed binding.""" +def check_field_domain(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None: + """Refuse a frame that is not on this binding's geometry, domain and shape. - def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: - self._check_domains(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") - # ``-1`` -> index 0 for the gather; the weight there is 0 by contract and the - # ``used`` mask keeps it out of both the sum and the validity test. - safe = np.where(used, indices, 0) - height, width = frame.scalar.shape - flat_scalar = np.asarray(frame.scalar, dtype=np.float64).reshape(-1) - flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1) - if safe.size and int(safe.max()) >= flat_scalar.size: - raise error( - "field_domain_mismatch", - "a binding index falls outside the frame's field", - index=int(safe.max()), - size=int(flat_scalar.size), - ) - gathered_scalar = flat_scalar[safe] - gathered_valid = flat_valid[safe] - finite = np.isfinite(gathered_scalar) - # A tap counts only where it is used, in range, finite and valid. - contributed = used & gathered_valid & finite - valid = contributed | ~used - valid = valid.all(axis=1) - contribution = np.where(contributed, gathered_scalar, 0.0) * weights - scalar = contribution.sum(axis=1) - # 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, + Kept as a function rather than a method so the *texture* path can make exactly the + same check without importing a sampler: a frame from another domain would be + uploaded as a texture of the wrong size and quietly rescaled by the sampler, which + is the class of bug this check exists to prevent. + """ + 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" ) - @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: - """Convenience wrapper: one sampler, no state to keep.""" - return SurfaceSampler().sample(frame, binding) + ``None`` when the frame carries no displacement — the common case, and the only + case this build's adapters can produce. A tap that is unused or invalid makes the + 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( diff --git a/shared/src/orisys_app_shared/scene3d/synthetic.py b/shared/src/orisys_app_shared/scene3d/synthetic.py index 4bf9525..fa994e8 100644 --- a/shared/src/orisys_app_shared/scene3d/synthetic.py +++ b/shared/src/orisys_app_shared/scene3d/synthetic.py @@ -26,6 +26,7 @@ from orisys_app_shared.scene3d.contracts import ( freeze, ) 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 #: 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: - """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 + 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) weights = np.zeros((count, 4), dtype=np.float32) for index in range(count): @@ -129,6 +151,7 @@ def _binding() -> SurfaceBinding: geometry_id=POC_GEOMETRY_ID, field_domain_id=POC_DOMAIN_ID, shape_hw=(GRID, GRID), + uvs=uvs_to_pixels(uvs, domain), sample_indices=freeze(indices, dtype=np.int32), sample_weights=freeze(weights, dtype=np.float32), ) diff --git a/shared/tests/test_scene3d_contracts.py b/shared/tests/test_scene3d_contracts.py index e61f11b..a65d219 100644 --- a/shared/tests/test_scene3d_contracts.py +++ b/shared/tests/test_scene3d_contracts.py @@ -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 )[:vertices] 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( part_id="sensing_surface", geometry_id="geom-abc", field_domain_id="domain-xyz", shape_hw=(3, 4), + uvs=c.freeze(uvs, dtype=np.float32), sample_indices=c.freeze(indices, dtype=np.int32), 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, field_domain_id=binding.field_domain_id, shape_hw=binding.shape_hw, + uvs=binding.uvs, sample_indices=c.freeze(indices, dtype=np.int32), 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, field_domain_id=binding.field_domain_id, shape_hw=binding.shape_hw, + uvs=binding.uvs, sample_indices=c.freeze(indices, dtype=np.int32), sample_weights=binding.sample_weights, ) @@ -207,6 +214,7 @@ def test_weights_must_sum_to_one(): geometry_id=binding.geometry_id, field_domain_id=binding.field_domain_id, shape_hw=binding.shape_hw, + uvs=binding.uvs, sample_indices=binding.sample_indices, 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, field_domain_id=binding.field_domain_id, shape_hw=binding.shape_hw, + uvs=binding.uvs, sample_indices=binding.sample_indices, 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. 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 - is the whole reason the contracts layer is separate from the view. + usable here — the composer and the field domain checks run on a machine with no + GPU, which is the whole reason the contracts layer is separate from the view. """ from orisys_app_shared.scene3d import assets, composer, sampling, synthetic scene = synthetic.poc_scene() frame = _frame() - fields = sampling.sample_field(frame, _binding()) - assert np.asarray(fields.scalar).shape == (4,) + # The colour path makes no gather at all; the only sampling left is the field + # 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( scene, c.SurfaceFieldFrame( identity=frame.identity, @@ -511,7 +525,7 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu(): ), 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 # cannot honestly do: asking for encryption without key material, or without the # crypto backend, must not produce a plain package that looks protected. diff --git a/shared/tests/test_scene3d_live.py b/shared/tests/test_scene3d_live.py index e59b995..079618f 100644 --- a/shared/tests/test_scene3d_live.py +++ b/shared/tests/test_scene3d_live.py @@ -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 -impossible to notice on screen: an invalid tap must invalidate the *vertex* rather -than read as zero, the display gain must not touch the measurement, and a producer -must never be able to make the display queue grow. +impossible to notice on screen: the *colour* path must never resample the field onto +the vertices (that is what painted 30.15 % of the island grey), the display gain must +not touch the measurement, and a producer must never be able to make the display queue +grow. """ from __future__ import annotations @@ -30,9 +31,8 @@ from orisys_app_shared.scene3d.contracts import ( rest_descriptor, ) from orisys_app_shared.scene3d.sampling import ( - SurfaceSampler, 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 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( part_id="sensing_surface", geometry_id=scene.geometry_id, field_domain_id=scene.field_domain_id, shape_hw=shape, + uvs=freeze(uvs, dtype=np.float32), sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32), sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32), ) -# --------------------------------------------------------------------- sampling -def test_a_constant_field_samples_to_the_same_constant(): +# -------------------------------------------------------- the colour field path +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() - fields = sample_field(_frame(scene, np.full((3, 3), 7.5)), scene.binding) - assert np.allclose(np.asarray(fields.scalar), 7.5) - assert np.asarray(fields.valid).all() + frame = _frame(scene, np.arange(9, dtype=np.float32).reshape(3, 3)) + composed = SceneComposer().compose(scene, frame, DisplaySettings()) + 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(): - """Every vertex samples its own cell, so the sampled value is the cell value.""" +def test_a_hole_in_the_field_stays_a_hole_and_is_never_a_vertex_nan(): + """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() - 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[1, 1] = False # index 4 is a positive-weight tap - 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) + valid[1, 1] = False field = np.zeros((3, 3), dtype=np.float32) - field[1, 1] = 3.0 - valid = np.ones((3, 3), dtype=bool) - valid[0, 0] = False # unused, so it must not matter - fields = sample_field(_frame(scene, field, valid), binding) - assert bool(np.asarray(fields.valid)[0]) - assert np.asarray(fields.scalar)[0] == pytest.approx(3.0) - - -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]) + field[1, 1] = 99.0 + composed = SceneComposer().compose(scene, _frame(scene, field, valid), DisplaySettings()) + assert composed.field.valid.tolist() == valid.tolist() + assert int(composed.field.valid.sum()) == 8 + # The scalar is carried through untouched, `NaN` or not: nothing rewrites it into + # 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_zero_measurement_is_valid_and_stays_zero(): """Zero is a value. Only `valid` decides whether it means anything.""" scene = synthetic.poc_scene() - fields = sample_field(_frame(scene, np.zeros((3, 3), dtype=np.float32)), scene.binding) - assert np.asarray(fields.valid).all() - assert np.all(np.asarray(fields.scalar) == 0.0) - assert not np.isnan(np.asarray(fields.scalar)).any() + composed = SceneComposer().compose( + scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings() + ) + 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() frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain") with pytest.raises(ThreeDError) as excinfo: - sample_field(frame, scene.binding) + SceneComposer().compose(scene, frame, DisplaySettings()) 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() frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32)) with pytest.raises(ThreeDError) as excinfo: - sample_field(frame, scene.binding) - assert "shape does not match" in excinfo.value.diagnostic.message + SceneComposer().compose(scene, frame, DisplaySettings()) + assert excinfo.value.diagnostic.code == "field_domain_mismatch" -def test_sampling_is_vectorised_enough_for_a_large_mesh(): - """A full-resolution binding is ~10^5 vertices; sampling must not crawl.""" +def test_composing_a_frame_costs_no_per_vertex_work(): + """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() - count = 20_000 - rng = np.random.default_rng(7) - indices = rng.integers(0, 9, size=(count, 4)).astype(np.int32) - weights = rng.random((count, 4)).astype(np.float32) - weights /= weights.sum(axis=1, keepdims=True) - binding = _binding(indices=indices, weights=weights) - frame = _frame(scene, rng.random((3, 3)).astype(np.float32)) - started = time.perf_counter() - fields = sample_field(frame, binding) - elapsed = (time.perf_counter() - started) * 1000.0 - assert np.asarray(fields.scalar).shape == (count,) - assert elapsed < 150.0, f"sampling {count} vertices took {elapsed:.1f} ms" + composer = SceneComposer() + frame = _frame(scene, np.zeros((3, 3), dtype=np.float32)) + composer.compose(scene, frame, DisplaySettings()) + samples = [] + for _ in range(20): + composer.compose(scene, frame, DisplaySettings()) + samples.append(composer.last_stats.sample_ms) + assert max(samples) < 2.0, f"the colour path still does per-vertex work: {samples}" + + +# -------------------------------------------- the vertex displacement (geometry) +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 @@ -267,9 +289,9 @@ def test_the_gain_moves_positions_and_never_the_measurement(): scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0) ) # 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(small.scalar), np.asarray(large.scalar)) - assert np.array_equal(np.asarray(quiet.valid), np.asarray(large.valid)) + assert np.array_equal(np.asarray(quiet.field.scalar), np.asarray(small.field.scalar)) + assert np.array_equal(np.asarray(small.field.scalar), np.asarray(large.field.scalar)) + assert np.array_equal(np.asarray(quiet.field.valid), np.asarray(large.field.valid)) # The positions are not. rest = np.asarray(small.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(): scene = synthetic.poc_scene() rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1)) - assert not np.asarray(rest.valid).any() - assert np.isnan(np.asarray(rest.scalar)).all() - assert rest.scalar_descriptor.quantity == "unavailable" - assert rest.scalar_descriptor.quality == "display_only" + assert not np.asarray(rest.field.valid).any() + assert np.isnan(np.asarray(rest.field.scalar)).all() + assert rest.field.descriptor.quantity == "unavailable" + assert rest.field.descriptor.quality == "display_only" assert np.allclose( np.asarray(rest.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( 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) assert not value.flags["WRITEABLE"], f"{name} is writable" 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(): @@ -443,6 +469,7 @@ def test_a_calibrated_displacement_still_recomputes_positions_and_normals(): composed = SceneComposer().compose( 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.normals_sensor is not part.normals_sensor assert not np.allclose(