阶段 1:平台 PoC —— 真实 GL 上跑通静态场景,并修掉它抓出的相机/上下文错误 - What: 按 docs/architecture/studio-3d-stages.md 阶段 1 落地图形栈。OrbitCamera 全部实现(target/distance/yaw/pitch 表示;fit 按包围球与较窄的那个视场轴定距离;orbit/pan/zoom 有界;set_preset 从 eye/target 反解角度使拖动可延续;matrices 输出只读 float32 view/projection;另加 depth_range_m() 便于断言)。gl_backend:GLDispatch.bind_current_context 真正绑定 PyOpenGL 并读 vendor/renderer/GL/GLSL/max texture、判软件渲染、profile 不达标时抛 ThreeDUnsupported 并带回能力;GLRenderer 实现 initialize(两个 program,desktop33/gles30 共用同一份着色器主体、只换头部)、upload_scene(每 part 一套 VAO/VBO/EBO,交错 layout position/normal/scalar/valid = 8 float)、update_surface(只对感知面 glBufferSubData,绝不重建索引)、draw(绑 Qt 的 defaultFramebufferObject、深度测试、按 role 选 program、housing 隐藏即跳过、scalar 经 LUT 纹理与 range uniform 上色,Python 不生成 RGB 数组)、release(幂等);纯函数 pack_part/pack_scene_part/estimate_gpu_bytes/rest_surface/shader_source/shader_header/lut_rgba/is_software_renderer/profile_from_renderable_type 都可在无 GPU 环境测试。Sensor3DWidget 实现 initializeGL/paintGL/resizeGL/release_resources/aboutToBeDestroyed/showEvent/hideEvent 与鼠标事件;加载场景时先采用资产自带的默认视角再 fit;一帧几何不匹配只"拒绝该帧"(计数 + 诊断 + 用 rest_surface 清掉上次测量)而不是把整个视图打成不可用。shared/scene3d/synthetic.py 提供 PoC 与测试共用的已知场景(20mm 感知板 + 外壳盒,米、sensor 坐标系、带默认三视角预设)。tools/scene_assets/probe_gl.py 真实现:真实窗口取 context 并输出 JSON 报告(平台/Qt/PyOpenGL/vendor/renderer/GL/GLSL/DPR/framebuffer/是否软件渲染),退出码即门禁(0 通过、3 软件渲染、4 无上下文);apps/studio/pyproject.toml 增加 [three-d] 可选依赖(仅 PyOpenGL;Open3D 不进运行时)。测试:apps/studio/tests/test_scene3d_stage1.py 24 项(相机数学、PoC 场景合规、打包布局、着色器两方言共用主体、LUT、软件渲染识别、renderableType 映射、GPU 预算、参考帧语义,全部无需 GPU)+ test_scene3d_gl_smoke.py 8 项(真实 context:能力、两个 part 都画出来、外壳可隐藏、参考帧中性 vs 色图有差异、动态更新不动 housing 缓冲、坏几何帧被拒且视图存活、resize 跟随、release 幂等且新 context 能重传场景、探针如实报告);offscreen 下后者自动 skip。docs/architecture/gl-report-windows-dev.json 为本机平台报告。 - Why: 用户要求"从骨架搭起→计划阶段→逐阶段实现与验收",并明确 ARM 暂缓。阶段 1 的产出本身就是一份能回答"这块板的图形栈能不能用"的报告,所以先把它做实、把 profile 冻结点定在真实 context 上,而不是先堆屏幕上的东西。 - Verification: 真实 context(QT_QPA_PLATFORM=windows)下 8 项 GL 验收全过;offscreen 下 24 项 CPU 项全过、GL 项按设计 skip;shared 全量 187 passed;studio 全量 116 passed(含新 32 项);ui_check 554 项 0 失败;probe_gl 输出 verdict=ok(NVIDIA RTX 4080 SUPER、GL 3.3 Core、GLSL 3.30、非软件渲染)。过程中这套验收抓出并修掉了 7 个真实缺陷:Qt renderableType 映射反了(NVIDIA 兼容 ES 着色器所以本机看不出,Intel/ARM 会直接失败)、观察相机是左手基导致整模型镜像(det=-1)、近平面过近使 NDC z 全挤在 0.99(深度精度归零、外壳与感知面会 z-fighting)、手动 makeCurrent 早于 Qt 首次绘制会把 GPU 对象建在被丢弃的 context 里(glBindVertexArray 报 INVALID_OPERATION)、context 重建后不重传场景、一帧几何不匹配把整个视图打死、以及 grabFramebuffer 是 BGRA+stride 导致颜色断言全部读错通道。 - Not in this change: 未接入 app_window/main.py(三维对现有应用仍完全惰性,故"开/关三维 CSV 行序一致"这条验收随阶段 4 做);阶段 2 的资产导入/坐标/包格式;ARM 平台实测(probe 已可在板上直接运行)。
This commit is contained in:
@@ -16,6 +16,18 @@ dependencies = [
|
|||||||
"pyqtgraph>=0.13",
|
"pyqtgraph>=0.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# The three-dimensional view (docs/architecture/studio-3d-design.md §11.1).
|
||||||
|
# PyOpenGL is the GL binding; PyOpenGL_accelerate is an optional speed-up and is
|
||||||
|
# deliberately not required. Open3D stays out: it is an offline tool, and its ARM
|
||||||
|
# support is experimental and its GUI needs a desktop GL profile.
|
||||||
|
#
|
||||||
|
# The binding does not ship a driver. On a board, do not let pip or a system
|
||||||
|
# package replace the BSP's EGL/GLES libraries.
|
||||||
|
three-d = [
|
||||||
|
"PyOpenGL>=3.1.6",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
orisys-studio = "orisys_studio.main:main"
|
orisys-studio = "orisys_studio.main:main"
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,238 @@
|
|||||||
"""The observation camera: orbit, pan, zoom, presets. Pure maths.
|
"""The observation camera: orbit, pan, zoom, presets. Pure maths, no Qt.
|
||||||
|
|
||||||
Stage 1 gets a minimal ``fit``/``orbit`` so a static scene can be looked at; the
|
It changes ``view`` and ``projection`` and nothing else: the pixel→surface
|
||||||
full interaction (pan, zoom limits, presets, reset) is stage 4 in
|
mapping, the calibration and the force axes are not reachable from here. The
|
||||||
``docs/architecture/studio-3d-stages.md``.
|
default view is a ``CameraPreset`` that ships with the geometry, so "what the part
|
||||||
|
looks like" is versioned with the asset rather than with somebody's laptop.
|
||||||
|
|
||||||
It is deliberately *not* the algorithm camera. It changes ``view`` and
|
Conventions (design document §3.1): sensor frame, right-handed, metres; column
|
||||||
``projection`` only: ``window_to_world`` for the pixel→surface mapping, the
|
vectors, so ``p_camera = view @ p_sensor`` and ``p_clip = projection @ p_camera``.
|
||||||
calibration and the force axes are untouched by anything a user does here. The
|
The view matrix is built from a target, a distance and two angles, which is what
|
||||||
default view comes from the asset's ``CameraPreset``, so it is versioned with the
|
makes orbit/pan/zoom trivial and keeps "reset" exact rather than approximate.
|
||||||
geometry rather than with a user's preference file.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
CameraMatrices,
|
CameraMatrices,
|
||||||
CameraPreset,
|
CameraPreset,
|
||||||
F32,
|
F32,
|
||||||
|
freeze,
|
||||||
)
|
)
|
||||||
|
|
||||||
_STAGE = "stage 1 (fit/orbit) and stage 4 (full interaction)"
|
#: Distance limits, in metres: close enough to inspect a 20 mm part, far enough
|
||||||
|
#: not to lose a 10 cm one. The far plane follows the distance, so precision stays
|
||||||
|
#: usable at both ends instead of being decided once.
|
||||||
|
MIN_DISTANCE_M = 0.005
|
||||||
|
MAX_DISTANCE_M = 5.0
|
||||||
|
#: Radians per pixel of drag, and the pitch limit that keeps the up vector sane.
|
||||||
|
ORBIT_RAD_PER_PX = 0.008
|
||||||
|
MAX_PITCH_RAD = math.radians(89.0)
|
||||||
|
#: Zoom per wheel step.
|
||||||
|
ZOOM_PER_STEP = 0.12
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class OrbitCamera:
|
class OrbitCamera:
|
||||||
"""A target, a distance and two angles. Everything else is derived."""
|
"""A target, a distance and two angles; everything else is derived."""
|
||||||
|
|
||||||
|
target_sensor_m: np.ndarray = field(
|
||||||
|
default_factory=lambda: np.zeros(3, dtype=np.float64)
|
||||||
|
)
|
||||||
|
distance_m: float = 0.1
|
||||||
|
yaw_rad: float = 0.0
|
||||||
|
pitch_rad: float = 0.0
|
||||||
|
up_sensor: np.ndarray = field(
|
||||||
|
default_factory=lambda: np.array([0.0, 0.0, 1.0], dtype=np.float64)
|
||||||
|
)
|
||||||
|
vertical_fov_rad: float = 0.6
|
||||||
|
aspect: float = 1.0
|
||||||
|
_fitted_radius_m: float = 0.05
|
||||||
|
_preset_name: Optional[str] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- setup
|
||||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
||||||
"""Frame the whole scene: set the target, distance, near and far planes."""
|
"""Frame the whole scene from the bounds, without changing the angles.
|
||||||
raise NotImplementedError(f"{_STAGE}: fit the camera to the scene bounds")
|
|
||||||
|
|
||||||
|
The distance is derived from the bounding sphere and the *narrower* of the
|
||||||
|
two field-of-view axes, so a wide window does not clip the model
|
||||||
|
vertically while a tall one does not clip it horizontally.
|
||||||
|
"""
|
||||||
|
bounds = np.asarray(bounds_sensor_m, dtype=np.float64)
|
||||||
|
if bounds.shape != (2, 3):
|
||||||
|
raise ValueError(f"bounds must be [2,3], got {bounds.shape}")
|
||||||
|
low, high = bounds[0], bounds[1]
|
||||||
|
self.target_sensor_m = (low + high) * 0.5
|
||||||
|
radius = float(np.linalg.norm(high - low) * 0.5)
|
||||||
|
self._fitted_radius_m = max(radius, 1e-6)
|
||||||
|
self.aspect = max(float(aspect), 1e-6)
|
||||||
|
self.vertical_fov_rad = _clamp(self.vertical_fov_rad, 0.1, 2.6)
|
||||||
|
vertical_half = 0.5 * self.vertical_fov_rad
|
||||||
|
horizontal_half = math.atan(math.tan(vertical_half) * self.aspect)
|
||||||
|
half = min(vertical_half, horizontal_half)
|
||||||
|
# A little margin, so the silhouette is not flush with the edge.
|
||||||
|
self.distance_m = _clamp(
|
||||||
|
self._fitted_radius_m / max(math.sin(half), 1e-6) * 1.15,
|
||||||
|
MIN_DISTANCE_M,
|
||||||
|
MAX_DISTANCE_M,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_preset(self, preset: CameraPreset) -> None:
|
||||||
|
"""Point the camera where the asset says its default view is.
|
||||||
|
|
||||||
|
The preset's ``eye`` is turned into yaw/pitch/distance around its target,
|
||||||
|
so a later drag continues from the preset instead of jumping.
|
||||||
|
"""
|
||||||
|
eye = np.asarray(preset.eye_sensor_m, dtype=np.float64)
|
||||||
|
target = np.asarray(preset.target_sensor_m, dtype=np.float64)
|
||||||
|
up = np.asarray(preset.up_sensor, dtype=np.float64)
|
||||||
|
offset = eye - target
|
||||||
|
distance = float(np.linalg.norm(offset))
|
||||||
|
if distance <= 0.0:
|
||||||
|
raise ValueError(f"preset {preset.name!r} puts the eye on the target")
|
||||||
|
self.target_sensor_m = target
|
||||||
|
self.distance_m = _clamp(distance, MIN_DISTANCE_M, MAX_DISTANCE_M)
|
||||||
|
self.up_sensor = up / max(float(np.linalg.norm(up)), 1e-9)
|
||||||
|
direction = offset / distance
|
||||||
|
self.pitch_rad = _clamp(math.asin(_clamp(direction[2], -1.0, 1.0)),
|
||||||
|
-MAX_PITCH_RAD, MAX_PITCH_RAD)
|
||||||
|
self.yaw_rad = math.atan2(direction[0], -direction[1])
|
||||||
|
self.vertical_fov_rad = _clamp(float(preset.vertical_fov_rad), 0.1, 2.6)
|
||||||
|
self._preset_name = preset.name
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- interaction
|
||||||
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
|
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
|
||||||
"""Rotate about the target. The model data does not move."""
|
"""Rotate about the target. The model data does not move."""
|
||||||
raise NotImplementedError(f"{_STAGE}: orbit")
|
dx, dy = (float(delta_px[0]), float(delta_px[1]))
|
||||||
|
self.yaw_rad = (self.yaw_rad + dx * ORBIT_RAD_PER_PX) % (2.0 * math.pi)
|
||||||
|
self.pitch_rad = _clamp(
|
||||||
|
self.pitch_rad + dy * ORBIT_RAD_PER_PX, -MAX_PITCH_RAD, MAX_PITCH_RAD
|
||||||
|
)
|
||||||
|
|
||||||
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
|
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None:
|
||||||
"""Slide the target in the view plane, scaled by distance."""
|
"""Slide the target in the view plane, scaled so it tracks the cursor."""
|
||||||
raise NotImplementedError(f"{_STAGE}: pan")
|
width = max(int(viewport[0]), 1)
|
||||||
|
height = max(int(viewport[1]), 1)
|
||||||
|
dx, dy = float(delta_px[0]), float(delta_px[1])
|
||||||
|
world_per_px = (
|
||||||
|
2.0 * self.distance_m * math.tan(0.5 * self.vertical_fov_rad) / height
|
||||||
|
)
|
||||||
|
right, up = self._basis()
|
||||||
|
self.target_sensor_m = (
|
||||||
|
self.target_sensor_m - right * (dx * world_per_px) + up * (dy * world_per_px)
|
||||||
|
)
|
||||||
|
|
||||||
def zoom(self, wheel_steps: float) -> None:
|
def zoom(self, wheel_steps: float) -> None:
|
||||||
"""Exponential zoom, clamped so the surface cannot be entered or lost."""
|
"""Exponential zoom, clamped so the surface cannot be entered or lost."""
|
||||||
raise NotImplementedError(f"{_STAGE}: zoom")
|
factor = math.pow(1.0 - ZOOM_PER_STEP, float(wheel_steps))
|
||||||
|
self.distance_m = _clamp(
|
||||||
|
self.distance_m * factor, MIN_DISTANCE_M, MAX_DISTANCE_M
|
||||||
|
)
|
||||||
|
|
||||||
def set_preset(self, preset: CameraPreset) -> None:
|
def reset(self, bounds_sensor_m: F32) -> None:
|
||||||
"""Front / side / top / isometric / asset default."""
|
"""Back to the framed default view."""
|
||||||
raise NotImplementedError(f"{_STAGE}: apply a camera preset")
|
self.yaw_rad = 0.0
|
||||||
|
self.pitch_rad = 0.0
|
||||||
|
self.fit(bounds_sensor_m, self.aspect)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ matrices
|
||||||
|
def eye_sensor_m(self) -> np.ndarray:
|
||||||
|
return self.target_sensor_m + self._direction() * self.distance_m
|
||||||
|
|
||||||
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices:
|
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices:
|
||||||
"""``p_camera = view @ p_sensor``, ``p_clip = projection @ p_camera``."""
|
"""``view`` and ``projection`` for the given physical viewport size."""
|
||||||
raise NotImplementedError(f"{_STAGE}: build the view and projection matrices")
|
width = max(int(viewport[0]), 1)
|
||||||
|
height = max(int(viewport[1]), 1)
|
||||||
|
aspect = width / height
|
||||||
|
self.aspect = aspect
|
||||||
|
eye = self.eye_sensor_m()
|
||||||
|
# ``_direction()`` points from the target towards the eye, i.e. it is the
|
||||||
|
# camera's *backward* axis. The camera looks along ``-backward``, and the
|
||||||
|
# right vector is ``cross(-backward, up)`` — building it as
|
||||||
|
# ``cross(backward, up)`` yields a left-handed basis (det = -1), which
|
||||||
|
# mirrors the whole model.
|
||||||
|
backward = self._direction()
|
||||||
|
up = np.asarray(self.up_sensor, dtype=np.float64)
|
||||||
|
right = np.cross(up, backward)
|
||||||
|
norm = float(np.linalg.norm(right))
|
||||||
|
if norm < 1e-8:
|
||||||
|
# Looking straight down the up axis: pick any stable right vector.
|
||||||
|
right = np.array([1.0, 0.0, 0.0])
|
||||||
|
norm = 1.0
|
||||||
|
right = right / norm
|
||||||
|
true_up = np.cross(backward, right)
|
||||||
|
view = np.eye(4, dtype=np.float64)
|
||||||
|
view[0, :3], view[1, :3], view[2, :3] = right, true_up, backward
|
||||||
|
view[0, 3] = -float(np.dot(right, eye))
|
||||||
|
view[1, 3] = -float(np.dot(true_up, eye))
|
||||||
|
view[2, 3] = -float(np.dot(backward, eye))
|
||||||
|
near, far = self.depth_range_m()
|
||||||
|
projection = _perspective(self.vertical_fov_rad, aspect, near, far)
|
||||||
|
return CameraMatrices(
|
||||||
|
view=freeze(view, dtype=np.float32),
|
||||||
|
projection=freeze(projection, dtype=np.float32),
|
||||||
|
)
|
||||||
|
|
||||||
|
def depth_range_m(self) -> tuple[float, float]:
|
||||||
|
"""The frustum's near and far planes, for tests and diagnostics.
|
||||||
|
|
||||||
|
The near plane sits just in front of the scene rather than at a fraction of
|
||||||
|
the distance: a near plane orders of magnitude closer than the model throws
|
||||||
|
away every bit of depth resolution — everything lands at NDC z ~ 0.99 and
|
||||||
|
the housing z-fights the plate.
|
||||||
|
"""
|
||||||
|
near = max(
|
||||||
|
self.distance_m - self._fitted_radius_m * 1.5,
|
||||||
|
self.distance_m * 0.05,
|
||||||
|
MIN_DISTANCE_M * 0.05,
|
||||||
|
)
|
||||||
|
far = max(self.distance_m + self._fitted_radius_m * 4.0, near * 2.0)
|
||||||
|
return (near, far)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- helpers
|
||||||
|
def _direction(self) -> np.ndarray:
|
||||||
|
"""Unit vector from the target towards the eye."""
|
||||||
|
cos_pitch = math.cos(self.pitch_rad)
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
cos_pitch * math.sin(self.yaw_rad),
|
||||||
|
-cos_pitch * math.cos(self.yaw_rad),
|
||||||
|
math.sin(self.pitch_rad),
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _basis(self) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""``(right, up)`` of the camera, in the same handedness as ``matrices``."""
|
||||||
|
backward = self._direction()
|
||||||
|
up = np.asarray(self.up_sensor, dtype=np.float64)
|
||||||
|
right = np.cross(up, backward)
|
||||||
|
norm = float(np.linalg.norm(right))
|
||||||
|
if norm < 1e-8:
|
||||||
|
right = np.array([1.0, 0.0, 0.0])
|
||||||
|
norm = 1.0
|
||||||
|
right = right / norm
|
||||||
|
true_up = np.cross(backward, right)
|
||||||
|
return right, true_up / max(float(np.linalg.norm(true_up)), 1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: float, low: float, high: float) -> float:
|
||||||
|
return max(low, min(high, float(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def _perspective(fov_rad: float, aspect: float, near: float, far: float) -> np.ndarray:
|
||||||
|
"""Right-handed perspective, OpenGL clip convention (z in [-1, 1])."""
|
||||||
|
f = 1.0 / math.tan(0.5 * fov_rad)
|
||||||
|
matrix = np.zeros((4, 4), dtype=np.float64)
|
||||||
|
matrix[0, 0] = f / aspect
|
||||||
|
matrix[1, 1] = f
|
||||||
|
matrix[2, 2] = (far + near) / (near - far)
|
||||||
|
matrix[2, 3] = (2.0 * far * near) / (near - far)
|
||||||
|
matrix[3, 2] = -1.0
|
||||||
|
return matrix
|
||||||
|
|||||||
@@ -9,29 +9,57 @@ Import discipline, and why it is not negotiable:
|
|||||||
* the PyOpenGL import happens inside :meth:`GLDispatch.bind_current_context`,
|
* the PyOpenGL import happens inside :meth:`GLDispatch.bind_current_context`,
|
||||||
after Qt has made a real context current, so the binding is chosen for the
|
after Qt has made a real context current, so the binding is chosen for the
|
||||||
platform Qt actually used (GLX or EGL) instead of being guessed;
|
platform Qt actually used (GLX or EGL) instead of being guessed;
|
||||||
* ``GLRenderer.release`` is idempotent and requires the owning context to be
|
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
|
||||||
current — resources are freed by the context that made them.
|
current — resources are freed by the context that made them.
|
||||||
|
|
||||||
The probe is what decides the profile on a board. A missing GL, a software
|
Two parts, two programs, one draw call per part. The sensing surface is re-uploaded
|
||||||
renderer, or a failed shader compile means the three-dimensional view reports
|
per frame and its colour comes from ``scalar`` through a LUT texture with the range
|
||||||
itself unavailable and the two-dimensional application carries on unchanged.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
import math
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
CameraMatrices,
|
CameraMatrices,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
GLCapabilities,
|
GLCapabilities,
|
||||||
|
MeshPart,
|
||||||
Profile,
|
Profile,
|
||||||
RenderFrame,
|
RenderFrame,
|
||||||
RenderStats,
|
RenderStats,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
|
diagnostic,
|
||||||
|
error,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.scene3d.contracts import Diagnostic
|
||||||
|
|
||||||
|
#: Renderer names that are not a GPU. A board must not be signed off on one of
|
||||||
|
#: these, and neither must a laptop with a broken driver.
|
||||||
|
SOFTWARE_RENDERER_MARKERS = (
|
||||||
|
"llvmpipe",
|
||||||
|
"softpipe",
|
||||||
|
"swrast",
|
||||||
|
"swiftshader",
|
||||||
|
"microsoft basic render",
|
||||||
|
"gdi generic",
|
||||||
|
"mesa offscreen",
|
||||||
)
|
)
|
||||||
|
|
||||||
_STAGE = "stage 1 (platform PoC)"
|
#: The neutral colour an invalid or unmeasured vertex gets (linear, dark grey).
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||||
@@ -63,28 +91,474 @@ def configure_surface_format(profile: Profile = "desktop33") -> None:
|
|||||||
QSurfaceFormat.setDefaultFormat(fmt)
|
QSurfaceFormat.setDefaultFormat(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def is_software_renderer(vendor: str, renderer: str) -> bool:
|
||||||
|
"""Whether these GL strings describe a rasteriser rather than a GPU."""
|
||||||
|
haystack = f"{vendor} {renderer}".lower()
|
||||||
|
return any(marker in haystack for marker in SOFTWARE_RENDERER_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
#: ``QSurfaceFormat.RenderableType``. Spelled out because guessing it wrong is
|
||||||
|
#: invisible on a driver that accepts both shader dialects (NVIDIA does, through
|
||||||
|
#: ``ARB_ES3_compatibility``) and fails on one that does not.
|
||||||
|
RENDERABLE_OPENGL = 1
|
||||||
|
RENDERABLE_OPENGL_ES = 2
|
||||||
|
|
||||||
|
|
||||||
|
def profile_from_renderable_type(value: int) -> Profile:
|
||||||
|
"""Map Qt's renderable type to the shader dialect that must be compiled."""
|
||||||
|
return "gles30" if int(value) == RENDERABLE_OPENGL_ES else "desktop33"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- shaders
|
||||||
|
#: Shared bodies: the version header is the only difference, so the maths cannot
|
||||||
|
#: drift between the desktop and the embedded build.
|
||||||
|
_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;
|
||||||
|
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
|
out vec3 v_normal;
|
||||||
|
out float v_scalar;
|
||||||
|
out float v_valid;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
v_normal = a_normal;
|
||||||
|
v_scalar = a_scalar;
|
||||||
|
v_valid = a_valid;
|
||||||
|
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;
|
||||||
|
|
||||||
|
uniform sampler2D u_lut;
|
||||||
|
uniform float u_lower;
|
||||||
|
uniform float u_upper;
|
||||||
|
uniform vec4 u_invalid_color;
|
||||||
|
|
||||||
|
out vec4 frag_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
if (v_valid < 0.5 || isnan(v_scalar)) {
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
|
||||||
|
out vec4 frag_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 n = normalize(v_normal);
|
||||||
|
if (!gl_FrontFacing) {
|
||||||
|
n = -n;
|
||||||
|
}
|
||||||
|
float lambert = max(dot(n, normalize(u_light_direction)), 0.0);
|
||||||
|
frag_color = vec4(u_base_color.rgb * (0.35 + 0.65 * lambert), u_base_color.a);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
_HEADERS: dict[Profile, str] = {
|
||||||
|
"desktop33": "#version 330 core\n",
|
||||||
|
"gles30": "#version 300 es\nprecision highp float;\nprecision highp int;\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def shader_header(profile: Profile) -> str:
|
||||||
|
"""The dialect header for a profile. Public so a test can compare bodies."""
|
||||||
|
try:
|
||||||
|
return _HEADERS[profile]
|
||||||
|
except KeyError: # pragma: no cover - guarded by configure_surface_format
|
||||||
|
raise ValueError(f"unknown GL profile: {profile!r}") from None
|
||||||
|
|
||||||
|
|
||||||
|
def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
||||||
|
"""``(vertex, fragment)`` source for a profile and a program name.
|
||||||
|
|
||||||
|
Pure text: testable without a context, so "the ES build really is ES" is an
|
||||||
|
assertion rather than a hope.
|
||||||
|
"""
|
||||||
|
header = shader_header(profile)
|
||||||
|
if program == "scalar":
|
||||||
|
body = _SCALAR_FRAGMENT_BODY
|
||||||
|
elif program == "diffuse":
|
||||||
|
body = _DIFFUSE_FRAGMENT_BODY
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown shader program: {program!r}")
|
||||||
|
return header + _VERTEX_BODY, header + body
|
||||||
|
|
||||||
|
|
||||||
|
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
|
||||||
|
"""A 256x1 RGBA8 lookup table.
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 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.
|
||||||
|
STRIDE_FLOATS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def pack_part(part: MeshPart, *, scalar: Optional[np.ndarray] = None,
|
||||||
|
valid: 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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
|
||||||
|
if values.size != count:
|
||||||
|
raise error(
|
||||||
|
"geometry_mismatch",
|
||||||
|
f"{part.part_id}: {values.size} scalars 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)
|
||||||
|
return freeze(rows, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_scene_part(part: MeshPart) -> 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)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_gpu_bytes(assets: SceneAssets, *, dynamic_part_id: str) -> int:
|
||||||
|
"""A rough static-plus-one-dynamic budget, for the stats line."""
|
||||||
|
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.
|
||||||
|
total += vertices * STRIDE_FLOATS * 4
|
||||||
|
total += LUT_SIZE * 4
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
||||||
|
"""The asset's own geometry, with nothing measured.
|
||||||
|
|
||||||
|
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
||||||
|
disconnect — so the screen cannot keep showing the previous acquisition's
|
||||||
|
colours as if they were current. ``quality="display_only"`` and an
|
||||||
|
``unavailable`` quantity are what tell the UI to hide the measurement bar.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.scene3d.contracts import rest_descriptor
|
||||||
|
|
||||||
|
part = assets.sensing_part()
|
||||||
|
count = int(part.positions_sensor_m.shape[0])
|
||||||
|
return RenderFrame(
|
||||||
|
identity=identity,
|
||||||
|
asset_hash=assets.asset_hash,
|
||||||
|
geometry_id=assets.geometry_id,
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- dispatch
|
||||||
class GLDispatch:
|
class GLDispatch:
|
||||||
"""Bind the GL entry points for the context Qt actually created."""
|
"""Bind the GL entry points for the context Qt actually created."""
|
||||||
|
|
||||||
def bind_current_context(self, context: Any) -> GLCapabilities:
|
def bind_current_context(self, context: Any) -> GLCapabilities:
|
||||||
raise NotImplementedError(f"{_STAGE}: probe the context and bind PyOpenGL")
|
"""Read the real context. Raises ``gl_unsupported`` when there is none.
|
||||||
|
|
||||||
|
Nothing here creates a second context or an EGL surface: Qt owns the
|
||||||
|
window and the context, and this only asks it what it got.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from OpenGL import GL
|
||||||
|
except Exception as exc: # noqa: BLE001 - the binding may be missing
|
||||||
|
raise error(
|
||||||
|
"gl_unsupported",
|
||||||
|
f"the OpenGL binding could not be imported: {type(exc).__name__}",
|
||||||
|
) from exc
|
||||||
|
if context is None or not context.isValid():
|
||||||
|
raise error("gl_unsupported", "no current OpenGL context")
|
||||||
|
fmt = context.format()
|
||||||
|
profile = profile_from_renderable_type(int(fmt.renderableType()))
|
||||||
|
try:
|
||||||
|
vendor = _gl_string(GL, GL.GL_VENDOR)
|
||||||
|
renderer = _gl_string(GL, GL.GL_RENDERER)
|
||||||
|
gl_version = _gl_string(GL, GL.GL_VERSION)
|
||||||
|
glsl_version = _gl_string(GL, GL.GL_SHADING_LANGUAGE_VERSION)
|
||||||
|
max_texture = int(GL.glGetIntegerv(GL.GL_MAX_TEXTURE_SIZE))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise error(
|
||||||
|
"gl_unsupported",
|
||||||
|
f"the context answered no queries: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
from PyQt5.QtGui import QGuiApplication
|
||||||
|
|
||||||
|
platform_name = ""
|
||||||
|
try:
|
||||||
|
app = QGuiApplication.instance()
|
||||||
|
platform_name = app.platformName() if app is not None else ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
platform_name = ""
|
||||||
|
software = is_software_renderer(vendor, renderer)
|
||||||
|
diagnostics: tuple[Diagnostic, ...] = ()
|
||||||
|
if software:
|
||||||
|
diagnostics = (
|
||||||
|
diagnostic(
|
||||||
|
"gl_unsupported",
|
||||||
|
f"{renderer or vendor} is a software rasteriser, not a GPU",
|
||||||
|
vendor=vendor,
|
||||||
|
renderer=renderer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif gl_version:
|
||||||
|
major = _leading_int(gl_version)
|
||||||
|
if major and major < 3:
|
||||||
|
diagnostics = (
|
||||||
|
diagnostic(
|
||||||
|
"gl_unsupported",
|
||||||
|
f"OpenGL {gl_version} is older than the 3.3 the shaders need",
|
||||||
|
version=gl_version,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = GLCapabilities(
|
||||||
|
profile=profile,
|
||||||
|
vendor=vendor,
|
||||||
|
renderer=renderer,
|
||||||
|
gl_version=gl_version,
|
||||||
|
glsl_version=glsl_version,
|
||||||
|
qt_platform=platform_name,
|
||||||
|
software_renderer=software,
|
||||||
|
max_texture_size=max_texture,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
if any(item.severity == "error" for item in diagnostics):
|
||||||
|
raise ThreeDUnsupported(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ThreeDUnsupported(Exception):
|
||||||
|
"""Raised with the capabilities that were obtained, so they can be shown."""
|
||||||
|
|
||||||
|
def __init__(self, capabilities: GLCapabilities) -> None:
|
||||||
|
super().__init__("; ".join(item.message for item in capabilities.diagnostics))
|
||||||
|
self.capabilities = capabilities
|
||||||
|
self.diagnostic = (
|
||||||
|
capabilities.diagnostics[0]
|
||||||
|
if capabilities.diagnostics
|
||||||
|
else diagnostic("gl_unsupported", "the context is not usable")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gl_string(GL: Any, name: int) -> str:
|
||||||
|
value = GL.glGetString(name)
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode("utf-8", "replace")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _leading_int(text: str) -> int:
|
||||||
|
digits = ""
|
||||||
|
for char in str(text):
|
||||||
|
if char.isdigit() or (char == "." and digits):
|
||||||
|
if char == ".":
|
||||||
|
break
|
||||||
|
digits += char
|
||||||
|
elif digits:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
return int(digits) if digits else 0
|
||||||
|
except ValueError: # pragma: no cover - digits only
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- renderer
|
||||||
|
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")
|
||||||
|
|
||||||
|
def __init__(self, part: MeshPart, vao: int, vbo: int, ebo: int) -> None:
|
||||||
|
self.part_id = part.part_id
|
||||||
|
self.role = part.role
|
||||||
|
self.vao = vao
|
||||||
|
self.vbo = vbo
|
||||||
|
self.ebo = ebo
|
||||||
|
self.triangles = int(part.triangles.shape[0])
|
||||||
|
self.vertices = int(part.positions_sensor_m.shape[0])
|
||||||
|
|
||||||
|
|
||||||
class GLRenderer:
|
class GLRenderer:
|
||||||
"""Shaders, buffers and draw calls for one scene.
|
"""Shaders, buffers and draw calls for one scene."""
|
||||||
|
|
||||||
Two parts, two materials, one draw call per part: the sensing surface is
|
def __init__(self) -> None:
|
||||||
re-uploaded per frame, the housing is uploaded once and then left alone.
|
self._capabilities: Optional[GLCapabilities] = None
|
||||||
"""
|
self._programs: dict[str, int] = {}
|
||||||
|
self._uniforms: dict[str, dict[str, int]] = {}
|
||||||
|
self._parts: dict[str, _PartBuffers] = {}
|
||||||
|
self._lut: int = 0
|
||||||
|
self._lut_id: str = ""
|
||||||
|
self._sensing_part_id: str = ""
|
||||||
|
self._estimated_gpu_bytes: int = 0
|
||||||
|
self._initialized = False
|
||||||
|
self._displayed_frames = 0
|
||||||
|
self._last_upload_ms = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ lifetime
|
||||||
def initialize(self, capabilities: GLCapabilities) -> None:
|
def initialize(self, capabilities: GLCapabilities) -> None:
|
||||||
raise NotImplementedError(f"{_STAGE}: compile the shaders for this profile")
|
"""Compile the two programs for this profile. Requires a current context."""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
if self._initialized:
|
||||||
|
# Qt may initialize the widget and then have it re-parented into a new
|
||||||
|
# context; releasing first is what keeps that from leaking a program
|
||||||
|
# per rebuild.
|
||||||
|
self.release()
|
||||||
|
self._capabilities = capabilities
|
||||||
|
try:
|
||||||
|
self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar")
|
||||||
|
self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.release()
|
||||||
|
raise error(
|
||||||
|
"shader_compile_failed",
|
||||||
|
f"{type(exc).__name__}: {exc}",
|
||||||
|
profile=capabilities.profile,
|
||||||
|
) from exc
|
||||||
|
self._uniforms = {
|
||||||
|
name: _uniform_locations(GL, program) for name, program in self._programs.items()
|
||||||
|
}
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
def upload_scene(self, assets: SceneAssets) -> None:
|
def upload_scene(self, assets: SceneAssets) -> None:
|
||||||
raise NotImplementedError(f"{_STAGE}: upload static buffers and the LUT")
|
"""Upload every part once. The housing is never uploaded again."""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
self._require_initialized()
|
||||||
|
self.release_parts()
|
||||||
|
self._sensing_part_id = assets.sensing_part().part_id
|
||||||
|
for part in assets.parts:
|
||||||
|
vertices, indices = pack_scene_part(part)
|
||||||
|
vao = int(GL.glGenVertexArrays(1))
|
||||||
|
vbo = int(GL.glGenBuffers(1))
|
||||||
|
ebo = int(GL.glGenBuffers(1))
|
||||||
|
GL.glBindVertexArray(vao)
|
||||||
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo)
|
||||||
|
GL.glBufferData(
|
||||||
|
GL.GL_ARRAY_BUFFER, vertices.nbytes, vertices, GL.GL_STATIC_DRAW
|
||||||
|
)
|
||||||
|
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo)
|
||||||
|
GL.glBufferData(
|
||||||
|
GL.GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL.GL_STATIC_DRAW
|
||||||
|
)
|
||||||
|
_bind_attributes(GL)
|
||||||
|
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._estimated_gpu_bytes = estimate_gpu_bytes(
|
||||||
|
assets, dynamic_part_id=self._sensing_part_id
|
||||||
|
)
|
||||||
|
|
||||||
def update_surface(self, frame: RenderFrame) -> None:
|
def update_surface(self, frame: RenderFrame) -> None:
|
||||||
raise NotImplementedError(f"{_STAGE}: update the dynamic surface buffers")
|
"""Update the sensing part's vertices in place: no index rebuild, ever."""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
self._require_initialized()
|
||||||
|
buffers = self._parts.get(frame.sensing_part_id)
|
||||||
|
if buffers is None:
|
||||||
|
raise error(
|
||||||
|
"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:
|
||||||
|
raise error(
|
||||||
|
"geometry_mismatch",
|
||||||
|
f"{buffers.part_id}: {rows.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)
|
||||||
|
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
||||||
|
|
||||||
def draw(
|
def draw(
|
||||||
self,
|
self,
|
||||||
@@ -93,7 +567,240 @@ class GLRenderer:
|
|||||||
framebuffer: int,
|
framebuffer: int,
|
||||||
viewport: tuple[int, int],
|
viewport: tuple[int, int],
|
||||||
) -> RenderStats:
|
) -> RenderStats:
|
||||||
raise NotImplementedError(f"{_STAGE}: draw one frame")
|
"""One scene draw into the framebuffer Qt owns (never target 0)."""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
self._require_initialized()
|
||||||
|
started = time.perf_counter()
|
||||||
|
width = max(int(viewport[0]), 1)
|
||||||
|
height = max(int(viewport[1]), 1)
|
||||||
|
GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, int(framebuffer))
|
||||||
|
GL.glViewport(0, 0, width, height)
|
||||||
|
GL.glEnable(GL.GL_DEPTH_TEST)
|
||||||
|
GL.glClearColor(0.06, 0.07, 0.09, 1.0)
|
||||||
|
GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT)
|
||||||
|
view = np.asarray(camera.view, dtype=np.float32).reshape(4, 4)
|
||||||
|
projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4)
|
||||||
|
scale = settings.color_scale
|
||||||
|
lower = float(scale.lower) if scale is not None else 0.0
|
||||||
|
upper = float(scale.upper) if scale is not None else 1.0
|
||||||
|
if upper <= lower:
|
||||||
|
upper = lower + 1.0
|
||||||
|
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
|
||||||
|
program = self._programs["scalar" if part.role == "sensing" else "diffuse"]
|
||||||
|
locations = self._uniforms["scalar" if part.role == "sensing" else "diffuse"]
|
||||||
|
GL.glUseProgram(program)
|
||||||
|
_set_mat4(GL, locations.get("u_view"), view)
|
||||||
|
_set_mat4(GL, locations.get("u_projection"), projection)
|
||||||
|
if part.role == "sensing":
|
||||||
|
_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)
|
||||||
|
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))
|
||||||
|
GL.glBindVertexArray(part.vao)
|
||||||
|
GL.glDrawElements(
|
||||||
|
GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None
|
||||||
|
)
|
||||||
|
GL.glBindVertexArray(0)
|
||||||
|
GL.glUseProgram(0)
|
||||||
|
self._displayed_frames += 1
|
||||||
|
return RenderStats(
|
||||||
|
displayed_frames=self._displayed_frames,
|
||||||
|
compose_ms=0.0,
|
||||||
|
upload_ms=self._last_upload_ms,
|
||||||
|
paint_ms=(time.perf_counter() - started) * 1000.0,
|
||||||
|
estimated_gpu_bytes=self._estimated_gpu_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
def release(self) -> None:
|
def release(self) -> None:
|
||||||
raise NotImplementedError(f"{_STAGE}: release GPU resources")
|
"""Delete every GPU resource. Idempotent; the owning context must be current."""
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
self.release_parts()
|
||||||
|
for program in self._programs.values():
|
||||||
|
try:
|
||||||
|
GL.glDeleteProgram(int(program))
|
||||||
|
except Exception: # noqa: BLE001 - nothing left to delete
|
||||||
|
pass
|
||||||
|
self._programs = {}
|
||||||
|
self._uniforms = {}
|
||||||
|
if self._lut:
|
||||||
|
try:
|
||||||
|
GL.glDeleteTextures([int(self._lut)])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._lut = 0
|
||||||
|
self._lut_id = ""
|
||||||
|
self._initialized = False
|
||||||
|
self._estimated_gpu_bytes = 0
|
||||||
|
self._displayed_frames = 0
|
||||||
|
|
||||||
|
def release_parts(self) -> None:
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
for part in self._parts.values():
|
||||||
|
try:
|
||||||
|
GL.glDeleteVertexArrays(1, [int(part.vao)])
|
||||||
|
GL.glDeleteBuffers(1, [int(part.vbo)])
|
||||||
|
GL.glDeleteBuffers(1, [int(part.ebo)])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._parts = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- helpers
|
||||||
|
def _require_initialized(self) -> None:
|
||||||
|
if not self._initialized:
|
||||||
|
raise error("gl_unsupported", "the renderer was not initialized")
|
||||||
|
|
||||||
|
def _ensure_lut(self, lut_id: str) -> None:
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
if self._lut and self._lut_id == lut_id:
|
||||||
|
return
|
||||||
|
if self._lut:
|
||||||
|
GL.glDeleteTextures([int(self._lut)])
|
||||||
|
self._lut = 0
|
||||||
|
data = lut_rgba(lut_id)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
GL.glTexImage2D(
|
||||||
|
GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, LUT_SIZE, 1, 0, GL.GL_RGBA,
|
||||||
|
GL.GL_UNSIGNED_BYTE, data,
|
||||||
|
)
|
||||||
|
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||||
|
self._lut_id = lut_id
|
||||||
|
|
||||||
|
def capabilities(self) -> Optional[GLCapabilities]:
|
||||||
|
return self._capabilities
|
||||||
|
|
||||||
|
@property
|
||||||
|
def part_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(self._parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_initialized(self) -> bool:
|
||||||
|
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]] = {
|
||||||
|
"housing": (0.32, 0.33, 0.35, 1.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_color(part_id: str) -> tuple[float, float, float, float]:
|
||||||
|
return _PART_COLORS.get(part_id, (0.45, 0.45, 0.47, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_attributes(GL: Any) -> None:
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
def _offset(floats: int) -> Any:
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
return GL.ctypes.c_void_p(floats * 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_program(GL: Any, profile: Profile, name: str) -> int:
|
||||||
|
vertex_source, fragment_source = shader_source(profile, name)
|
||||||
|
program = int(GL.glCreateProgram())
|
||||||
|
shaders = []
|
||||||
|
for kind, source in ((GL.GL_VERTEX_SHADER, vertex_source), (GL.GL_FRAGMENT_SHADER, fragment_source)):
|
||||||
|
shader = int(GL.glCreateShader(kind))
|
||||||
|
GL.glShaderSource(shader, source)
|
||||||
|
GL.glCompileShader(shader)
|
||||||
|
if not GL.glGetShaderiv(shader, GL.GL_COMPILE_STATUS):
|
||||||
|
log = GL.glGetShaderInfoLog(shader)
|
||||||
|
GL.glDeleteShader(shader)
|
||||||
|
raise RuntimeError(f"{name} shader: {_decode(log)}")
|
||||||
|
GL.glAttachShader(program, shader)
|
||||||
|
shaders.append(shader)
|
||||||
|
GL.glLinkProgram(program)
|
||||||
|
if not GL.glGetProgramiv(program, GL.GL_LINK_STATUS):
|
||||||
|
log = GL.glGetProgramInfoLog(program)
|
||||||
|
for shader in shaders:
|
||||||
|
GL.glDeleteShader(shader)
|
||||||
|
GL.glDeleteProgram(program)
|
||||||
|
raise RuntimeError(f"{name} program: {_decode(log)}")
|
||||||
|
for shader in shaders:
|
||||||
|
GL.glDetachShader(program, shader)
|
||||||
|
GL.glDeleteShader(shader)
|
||||||
|
return program
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
for name in names:
|
||||||
|
location = GL.glGetUniformLocation(program, name)
|
||||||
|
if location is not None and int(location) >= 0:
|
||||||
|
out[name] = int(location)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(value: Any) -> str:
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return value.decode("utf-8", "replace").strip()
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
|
||||||
|
if location is None:
|
||||||
|
return
|
||||||
|
# Column-major upload, explicitly: the layout of the NumPy array must not
|
||||||
|
# decide what the coordinates mean (design document §3.1).
|
||||||
|
GL.glUniformMatrix4fv(location, 1, GL.GL_FALSE, np.ascontiguousarray(values.T))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_float(GL: Any, location: Optional[int], value: float) -> None:
|
||||||
|
if location is not None:
|
||||||
|
GL.glUniform1f(location, float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_vec3(GL: Any, location: Optional[int], values: tuple[float, float, float]) -> None:
|
||||||
|
if location is not None:
|
||||||
|
GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2]))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_vec4(GL: Any, location: Optional[int], values: tuple[float, float, float, float]) -> None:
|
||||||
|
if location is not None:
|
||||||
|
GL.glUniform4f(
|
||||||
|
location, float(values[0]), float(values[1]), float(values[2]), float(values[3])
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,18 +3,24 @@
|
|||||||
Stage 1 (a static scene, resize, close) and stage 4 (product integration) in
|
Stage 1 (a static scene, resize, close) and stage 4 (product integration) in
|
||||||
``docs/architecture/studio-3d-stages.md``.
|
``docs/architecture/studio-3d-stages.md``.
|
||||||
|
|
||||||
Qt owns the context and the framebuffer, so the widget must never bind target 0:
|
Qt owns the context and the framebuffer, so the widget never binds target 0: it
|
||||||
it draws into the FBO Qt has already set up for the current ``paintGL`` call. The
|
draws into ``defaultFramebufferObject()``, which is the FBO of the current
|
||||||
widget holds CPU-side data (the latest :class:`RenderFrame`) and schedules the
|
``paintGL`` call. The widget holds CPU-side data (the latest ``RenderFrame``) and
|
||||||
upload; it does no composing, no asset IO and no SDK access, so a slow render can
|
schedules the upload; it does no composing, no asset IO and no SDK access, so a
|
||||||
only drop *display* frames.
|
slow render can only drop *display* frames.
|
||||||
|
|
||||||
|
Failure is a state, not an exception: if the context is unusable (no GL, software
|
||||||
|
rasteriser, shader failure) the widget reports it once and stays empty, while the
|
||||||
|
two-dimensional application carries on.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PyQt5.QtCore import QEvent, Qt, pyqtSignal
|
||||||
|
from PyQt5.QtGui import QMouseEvent, QWheelEvent
|
||||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -24,11 +30,19 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
RenderFrame,
|
RenderFrame,
|
||||||
RenderStats,
|
RenderStats,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
|
diagnostic,
|
||||||
)
|
)
|
||||||
from orisys_studio.scene3d.camera import OrbitCamera
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
from orisys_studio.scene3d.gl_backend import GLRenderer
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
|
GLDispatch,
|
||||||
|
GLRenderer,
|
||||||
|
ThreeDUnsupported,
|
||||||
|
rest_surface,
|
||||||
|
)
|
||||||
|
|
||||||
_STAGE = "stage 1 (static scene) and stage 4 (product integration)"
|
#: How often render statistics are published. The numbers are for a person to
|
||||||
|
#: read, and a signal per frame would cost more than the drawing.
|
||||||
|
STATS_INTERVAL_S = 0.5
|
||||||
|
|
||||||
|
|
||||||
class Sensor3DWidget(QOpenGLWidget):
|
class Sensor3DWidget(QOpenGLWidget):
|
||||||
@@ -43,21 +57,36 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
renderer: GLRenderer,
|
renderer: GLRenderer,
|
||||||
camera: OrbitCamera,
|
camera: OrbitCamera,
|
||||||
parent: Optional[QWidget] = None,
|
parent: Optional[QWidget] = None,
|
||||||
|
*,
|
||||||
|
dispatch: Optional[GLDispatch] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._renderer = renderer
|
self._renderer = renderer
|
||||||
self._camera = camera
|
self._camera = camera
|
||||||
|
self._dispatch = dispatch or GLDispatch()
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: Optional[SceneAssets] = None
|
||||||
self._frame: Optional[RenderFrame] = None
|
self._frame: Optional[RenderFrame] = None
|
||||||
self._settings = DisplaySettings()
|
self._settings = DisplaySettings()
|
||||||
self._capabilities: Optional[GLCapabilities] = None
|
self._capabilities: Optional[GLCapabilities] = None
|
||||||
|
self._diagnostic: Optional[Diagnostic] = None
|
||||||
self._needs_scene_upload = False
|
self._needs_scene_upload = False
|
||||||
self._needs_surface_upload = False
|
self._needs_surface_upload = False
|
||||||
|
self._last_stats_at = 0.0
|
||||||
|
self._last_press: Optional[tuple[float, float]] = None
|
||||||
|
self._unavailable = False
|
||||||
|
#: Frames the display never showed, set by the coordinator. Never a
|
||||||
|
#: recording figure.
|
||||||
|
self._superseded_frames = 0
|
||||||
|
#: Frames refused because they did not belong to this scene.
|
||||||
|
self._rejected_frames = 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- CPU-side API
|
||||||
def set_scene(self, assets: SceneAssets) -> None:
|
def set_scene(self, assets: SceneAssets) -> None:
|
||||||
"""Retain the asset and schedule its upload for the next current context."""
|
"""Retain the asset and schedule its upload for the next current context."""
|
||||||
self._assets = assets
|
self._assets = assets
|
||||||
|
self._frame = None
|
||||||
self._needs_scene_upload = True
|
self._needs_scene_upload = True
|
||||||
|
self._unavailable = False
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def present(self, frame: RenderFrame) -> None:
|
def present(self, frame: RenderFrame) -> None:
|
||||||
@@ -70,15 +99,217 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
self._settings = settings
|
self._settings = settings
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def camera(self) -> OrbitCamera:
|
||||||
|
return self._camera
|
||||||
|
|
||||||
|
def capabilities(self) -> Optional[GLCapabilities]:
|
||||||
|
return self._capabilities
|
||||||
|
|
||||||
|
def diagnostic(self) -> Optional[Diagnostic]:
|
||||||
|
return self._diagnostic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unavailable(self) -> bool:
|
||||||
|
return self._unavailable
|
||||||
|
|
||||||
|
def viewport_pixels(self) -> tuple[int, int]:
|
||||||
|
"""The physical framebuffer size: device pixels, not logical ones."""
|
||||||
|
ratio = float(self.devicePixelRatioF() or 1.0)
|
||||||
|
return (
|
||||||
|
max(int(round(self.width() * ratio)), 1),
|
||||||
|
max(int(round(self.height() * ratio)), 1),
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------ Qt overrides
|
# ------------------------------------------------------------ Qt overrides
|
||||||
def initializeGL(self) -> None: # pragma: no cover - needs a real context
|
def initializeGL(self) -> None: # pragma: no cover - needs a real context
|
||||||
raise NotImplementedError(f"{_STAGE}: bind the backend and connect teardown")
|
try:
|
||||||
|
from OpenGL import GL
|
||||||
|
|
||||||
|
# Both materials are double-sided by design (a thin plate seen from
|
||||||
|
# behind must not vanish), so culling stays off.
|
||||||
|
GL.glDisable(GL.GL_CULL_FACE)
|
||||||
|
capabilities = self._dispatch.bind_current_context(self.context())
|
||||||
|
self._renderer.initialize(capabilities)
|
||||||
|
except ThreeDUnsupported as exc:
|
||||||
|
self._fail(exc.diagnostic)
|
||||||
|
self._capabilities = exc.capabilities
|
||||||
|
self.capabilitiesChanged.emit(exc.capabilities)
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 - any GL failure is a state, not a crash
|
||||||
|
self._fail(
|
||||||
|
getattr(exc, "diagnostic", None)
|
||||||
|
or diagnostic("gl_unsupported", f"{type(exc).__name__}: {exc}")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._capabilities = capabilities
|
||||||
|
self._diagnostic = None
|
||||||
|
self._unavailable = False
|
||||||
|
# A fresh context holds nothing, whatever the CPU still has: re-upload
|
||||||
|
# from the retained assets. Re-parenting a window rebuilds the context,
|
||||||
|
# and without this the scene would simply vanish.
|
||||||
|
self._needs_scene_upload = self._assets is not None
|
||||||
|
self._needs_surface_upload = self._frame is not None
|
||||||
|
context = self.context()
|
||||||
|
if context is not None:
|
||||||
|
context.aboutToBeDestroyed.connect(self.release_resources)
|
||||||
|
self.capabilitiesChanged.emit(capabilities)
|
||||||
|
|
||||||
def paintGL(self) -> None: # pragma: no cover - needs a real context
|
def paintGL(self) -> None: # pragma: no cover - needs a real context
|
||||||
raise NotImplementedError(f"{_STAGE}: upload what changed and draw")
|
if self._unavailable or not self._renderer.is_initialized:
|
||||||
|
return
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
if self._needs_scene_upload and self._assets is not None:
|
||||||
|
self._renderer.upload_scene(self._assets)
|
||||||
|
self._needs_scene_upload = False
|
||||||
|
self._needs_surface_upload = self._frame is not None
|
||||||
|
# The default view belongs to the asset, not to a user preference:
|
||||||
|
# adopt it, then frame the whole scene from that orientation. Going
|
||||||
|
# straight to fit() would keep whatever angles the camera happened
|
||||||
|
# to start with (looking along the sensor's long axis, which shows
|
||||||
|
# a flat surface edge-on).
|
||||||
|
presets = self._assets.camera_presets
|
||||||
|
if presets:
|
||||||
|
try:
|
||||||
|
self._camera.set_preset(presets[0])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self._camera.fit(self._assets.bounds_sensor_m, self._aspect())
|
||||||
|
if self._needs_surface_upload and self._frame is not None:
|
||||||
|
frame, self._frame = self._frame, None
|
||||||
|
self._needs_surface_upload = False
|
||||||
|
if self._assets is not None and frame.geometry_id != self._assets.geometry_id:
|
||||||
|
# A frame from another geometry is *rejected*, not fatal: the
|
||||||
|
# scene stays usable, and leaving the old measurement on screen
|
||||||
|
# would be claiming it is the current one.
|
||||||
|
self._reject(
|
||||||
|
diagnostic(
|
||||||
|
"geometry_mismatch",
|
||||||
|
"the frame belongs to a different geometry than the scene",
|
||||||
|
frame=frame.geometry_id,
|
||||||
|
scene=self._assets.geometry_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self._assets is not None:
|
||||||
|
self._renderer.update_surface(
|
||||||
|
rest_surface(self._assets, frame.identity)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._renderer.update_surface(frame)
|
||||||
|
stats = self._renderer.draw(
|
||||||
|
self._camera.matrices(self.viewport_pixels()),
|
||||||
|
self._settings,
|
||||||
|
int(self.defaultFramebufferObject()),
|
||||||
|
self.viewport_pixels(),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# A failure while drawing is the GL stack being unusable, not a shader
|
||||||
|
# that failed to compile (that case raises its own diagnostic above).
|
||||||
|
self._fail(
|
||||||
|
getattr(exc, "diagnostic", None)
|
||||||
|
or diagnostic("gl_unsupported", f"{type(exc).__name__}: {exc}")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
now = time.perf_counter()
|
||||||
|
if now - self._last_stats_at >= STATS_INTERVAL_S:
|
||||||
|
self._last_stats_at = now
|
||||||
|
self.renderStatsChanged.emit(
|
||||||
|
RenderStats(
|
||||||
|
displayed_frames=stats.displayed_frames,
|
||||||
|
superseded_frames=self._superseded,
|
||||||
|
compose_ms=stats.compose_ms,
|
||||||
|
upload_ms=stats.upload_ms,
|
||||||
|
paint_ms=(now - started) * 1000.0,
|
||||||
|
estimated_gpu_bytes=stats.estimated_gpu_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def resizeGL(self, w: int, h: int) -> None: # pragma: no cover
|
def resizeGL(self, w: int, h: int) -> None: # pragma: no cover - needs a context
|
||||||
raise NotImplementedError(f"{_STAGE}: calibrate DPR and update the projection")
|
"""Nothing to cache: the projection is rebuilt from the live viewport."""
|
||||||
|
self._camera.aspect = max(float(w), 1.0) / max(float(h), 1.0)
|
||||||
|
|
||||||
def release_resources(self) -> None: # pragma: no cover - needs a real context
|
def release_resources(self) -> None: # pragma: no cover - needs a context
|
||||||
raise NotImplementedError(f"{_STAGE}: makeCurrent, release, doneCurrent")
|
"""Free GPU resources from this widget's own context. Safe to repeat."""
|
||||||
|
if not self._renderer.is_initialized:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.makeCurrent()
|
||||||
|
self._renderer.release()
|
||||||
|
except Exception: # noqa: BLE001 - teardown must not raise on the way out
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self.doneCurrent()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
def hideEvent(self, event: QEvent) -> None: # pragma: no cover - Qt event
|
||||||
|
super().hideEvent(event)
|
||||||
|
# Hiding suspends drawing; the coordinator stops composing on this signal.
|
||||||
|
self._needs_surface_upload = False
|
||||||
|
|
||||||
|
def showEvent(self, event: QEvent) -> None: # pragma: no cover - Qt event
|
||||||
|
super().showEvent(event)
|
||||||
|
self._needs_surface_upload = self._frame is not None
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- interaction
|
||||||
|
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||||
|
self._last_press = (float(event.x()), float(event.y()))
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||||
|
self._last_press = None
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||||
|
if self._last_press is None:
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
return
|
||||||
|
x, y = float(event.x()), float(event.y())
|
||||||
|
delta = (x - self._last_press[0], y - self._last_press[1])
|
||||||
|
self._last_press = (x, y)
|
||||||
|
viewport = (max(self.width(), 1), max(self.height(), 1))
|
||||||
|
buttons = event.buttons()
|
||||||
|
panning = bool(buttons & Qt.MiddleButton) or bool(
|
||||||
|
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
|
||||||
|
)
|
||||||
|
if panning:
|
||||||
|
self._camera.pan(delta, viewport)
|
||||||
|
elif buttons & Qt.LeftButton:
|
||||||
|
self._camera.orbit(delta, viewport)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def wheelEvent(self, event: QWheelEvent) -> None: # pragma: no cover
|
||||||
|
steps = float(event.angleDelta().y()) / 120.0
|
||||||
|
self._camera.zoom(steps)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ internal
|
||||||
|
@property
|
||||||
|
def _superseded(self) -> int:
|
||||||
|
return self._superseded_frames
|
||||||
|
|
||||||
|
def set_superseded_frames(self, count: int) -> None:
|
||||||
|
"""Set by the coordinator: display drops, never recording drops."""
|
||||||
|
self._superseded_frames = int(count)
|
||||||
|
|
||||||
|
def _aspect(self) -> float:
|
||||||
|
width, height = self.viewport_pixels()
|
||||||
|
return width / height
|
||||||
|
|
||||||
|
def _fail(self, item: Diagnostic) -> None:
|
||||||
|
"""The whole view is out of action (no context, no shaders, GL error)."""
|
||||||
|
self._unavailable = True
|
||||||
|
self._diagnostic = item
|
||||||
|
self.diagnosticRaised.emit(item)
|
||||||
|
|
||||||
|
def _reject(self, item: Diagnostic) -> None:
|
||||||
|
"""One frame was refused. Reported, counted, and otherwise ignored."""
|
||||||
|
self._rejected_frames += 1
|
||||||
|
self._diagnostic = item
|
||||||
|
self.diagnosticRaised.emit(item)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rejected_frames(self) -> int:
|
||||||
|
return self._rejected_frames
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""Stage 1 acceptance: a real GL context draws the scene, and survives its life.
|
||||||
|
|
||||||
|
This file deliberately does **not** force ``QT_QPA_PLATFORM=offscreen``: the
|
||||||
|
offscreen plugin cannot create a platform GL context at all ("this plugin does not
|
||||||
|
support createPlatformOpenGLContext"), and the design document is explicit that an
|
||||||
|
offscreen UI test cannot stand in for a GPU. Every test here skips itself when no
|
||||||
|
real context can be obtained, and asserts on pixels otherwise.
|
||||||
|
|
||||||
|
Run it with a real platform, e.g.::
|
||||||
|
|
||||||
|
QT_QPA_PLATFORM=windows python -m pytest tests/test_scene3d_gl_smoke.py
|
||||||
|
|
||||||
|
Two rules the tests themselves have to respect, learned the hard way here: the
|
||||||
|
widget is driven the way the application drives it (``show``/``repaint``, so Qt
|
||||||
|
owns the context), and pixels are read through Qt's own ``grabFramebuffer`` —
|
||||||
|
calling ``makeCurrent``/``initializeGL`` by hand creates the context *before* Qt
|
||||||
|
does, and the GPU objects then belong to a context that is thrown away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d import synthetic
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
ColorScale,
|
||||||
|
DisplaySettings,
|
||||||
|
FieldDescriptor,
|
||||||
|
FrameIdentity,
|
||||||
|
RenderFrame,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 renderer's clear colour in 8-bit, so "background" can be excluded from a
|
||||||
|
#: pixel assertion instead of dominating it.
|
||||||
|
BACKGROUND_RGB = np.array([15, 18, 23])
|
||||||
|
|
||||||
|
|
||||||
|
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
|
||||||
|
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def gl_widget(qt_app):
|
||||||
|
"""A real, shown QOpenGLWidget with the synthetic scene, driven by Qt."""
|
||||||
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
|
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer, configure_surface_format
|
||||||
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||||
|
|
||||||
|
configure_surface_format("desktop33")
|
||||||
|
renderer = GLRenderer()
|
||||||
|
widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch())
|
||||||
|
failures: list = []
|
||||||
|
widget.diagnosticRaised.connect(failures.append)
|
||||||
|
widget.resize(240, 180)
|
||||||
|
widget.show()
|
||||||
|
widget.set_scene(synthetic.poc_scene())
|
||||||
|
painted = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
|
||||||
|
if not painted:
|
||||||
|
widget.deleteLater()
|
||||||
|
pytest.skip(
|
||||||
|
"no usable GL context here: "
|
||||||
|
f"{widget.diagnostic() or 'the widget never initialized'}"
|
||||||
|
)
|
||||||
|
yield widget, renderer, failures
|
||||||
|
widget.release_resources()
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _render(widget, qt_app) -> np.ndarray:
|
||||||
|
"""One Qt-driven paint, read back through Qt's own framebuffer grab.
|
||||||
|
|
||||||
|
``grabFramebuffer`` hands back a ``Format_RGB32`` image, whose bytes are BGRA
|
||||||
|
on a little-endian machine, with a stride that can exceed the width — so the
|
||||||
|
conversion and the stride trim are both load-bearing.
|
||||||
|
"""
|
||||||
|
from PyQt5.QtGui import QImage
|
||||||
|
|
||||||
|
widget.repaint()
|
||||||
|
qt_app.processEvents()
|
||||||
|
image = widget.grabFramebuffer()
|
||||||
|
assert not image.isNull(), "Qt returned no framebuffer"
|
||||||
|
image = image.convertToFormat(QImage.Format_RGB888)
|
||||||
|
width, height = image.width(), image.height()
|
||||||
|
buffer = image.constBits().asarray(image.byteCount())
|
||||||
|
stride = image.bytesPerLine()
|
||||||
|
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
||||||
|
return rows[:, : width * 3].reshape(height, width, 3).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _drawn(pixels: np.ndarray) -> np.ndarray:
|
||||||
|
"""Pixels that are not the clear colour, as ints."""
|
||||||
|
flat = pixels.reshape(-1, 3).astype(int)
|
||||||
|
background = np.abs(flat - BACKGROUND_RGB).max(axis=1) <= 3
|
||||||
|
return flat[~background]
|
||||||
|
|
||||||
|
|
||||||
|
def _plate_is_grey(pixels: np.ndarray) -> float:
|
||||||
|
"""Mean |r-g| of the drawn pixels. The neutral path is grey, the LUT is not."""
|
||||||
|
drawn = _drawn(pixels)
|
||||||
|
assert drawn.size, "nothing was drawn"
|
||||||
|
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_capabilities_come_from_a_real_context(gl_widget):
|
||||||
|
widget, _, _ = gl_widget
|
||||||
|
capabilities = widget.capabilities()
|
||||||
|
assert capabilities is not None
|
||||||
|
assert capabilities.vendor and capabilities.renderer
|
||||||
|
assert capabilities.profile == "desktop33", "a desktop context must compile desktop shaders"
|
||||||
|
assert not capabilities.software_renderer, "the dev machine must be driven by a GPU"
|
||||||
|
assert capabilities.max_texture_size >= 1024
|
||||||
|
assert widget.viewport_pixels() == (240, 180)
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
|
||||||
|
widget, renderer, _ = gl_widget
|
||||||
|
assert set(renderer.part_ids) == {"sensing_surface", "housing"}
|
||||||
|
|
||||||
|
with_housing = _drawn(_render(widget, qt_app))
|
||||||
|
assert with_housing.size, "nothing was drawn at all"
|
||||||
|
widget.set_settings(DisplaySettings(housing_visible=False))
|
||||||
|
without_housing = _drawn(_render(widget, qt_app))
|
||||||
|
assert 0 < without_housing.size < with_housing.size, (
|
||||||
|
"hiding the housing did not reduce what is on screen "
|
||||||
|
f"({without_housing.size} vs {with_housing.size} pixels)"
|
||||||
|
)
|
||||||
|
widget.set_settings(DisplaySettings(housing_visible=True))
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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))
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
widget.set_settings(
|
||||||
|
DisplaySettings(
|
||||||
|
housing_visible=False,
|
||||||
|
color_scale=ColorScale(
|
||||||
|
descriptor=frame.scalar_descriptor,
|
||||||
|
lower=0.0,
|
||||||
|
upper=1.0,
|
||||||
|
lut_id="sequential",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
widget.present(frame)
|
||||||
|
measured_grey = _plate_is_grey(_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})"
|
||||||
|
)
|
||||||
|
widget.set_settings(DisplaySettings(housing_visible=True))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
|
||||||
|
widget, renderer, _ = gl_widget
|
||||||
|
assets = synthetic.poc_scene()
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
widget.set_settings(
|
||||||
|
DisplaySettings(
|
||||||
|
color_scale=ColorScale(
|
||||||
|
descriptor=frame.scalar_descriptor, lower=0.0, upper=1.0, lut_id="sequential"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# The housing's GPU buffers must be untouched by a surface update.
|
||||||
|
before = _buffer_names(renderer, "housing")
|
||||||
|
widget.present(frame)
|
||||||
|
assert _plate_is_grey(_render(widget, qt_app)) > 2.0, "the scalar was not colour-mapped"
|
||||||
|
assert _buffer_names(renderer, "housing") == before, (
|
||||||
|
"a dynamic surface update rebuilt the static housing buffers"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]:
|
||||||
|
part = renderer._parts[part_id]
|
||||||
|
return (part.vao, part.vbo, part.ebo)
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
widget.present(frame)
|
||||||
|
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
|
||||||
|
assert widget.rejected_frames == before + 1
|
||||||
|
assert any(item.code == "geometry_mismatch" for item in failures), (
|
||||||
|
"a frame from another geometry was accepted"
|
||||||
|
)
|
||||||
|
assert not widget.unavailable, "one bad frame must not take the whole view down"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resize_is_followed(gl_widget, qt_app):
|
||||||
|
widget, _, _ = gl_widget
|
||||||
|
widget.resize(320, 240)
|
||||||
|
image = _render(widget, qt_app)
|
||||||
|
assert (image.shape[1], image.shape[0]) == (320, 240), image.shape
|
||||||
|
widget.resize(120, 90)
|
||||||
|
image = _render(widget, qt_app)
|
||||||
|
assert (image.shape[1], image.shape[0]) == (120, 90), image.shape
|
||||||
|
widget.resize(240, 180)
|
||||||
|
assert _drawn(_render(widget, qt_app)).size, "nothing was drawn after resizing back"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_is_idempotent_and_a_new_context_gets_the_scene_back(gl_widget, qt_app):
|
||||||
|
"""Closing must free everything; a rebuilt context must get the scene again.
|
||||||
|
|
||||||
|
A context rebuild is the case that matters: the GPU objects are gone while the
|
||||||
|
CPU asset is still here, so "the parts are still uploaded" would be a lie and
|
||||||
|
the window would come back empty. Qt calls ``initializeGL`` for a new context —
|
||||||
|
re-parenting does *not* rebuild one on Qt 5.15 (measured), so the test drives
|
||||||
|
that one call explicitly rather than pretending re-parenting did it.
|
||||||
|
"""
|
||||||
|
widget, renderer, _ = gl_widget
|
||||||
|
widget.release_resources()
|
||||||
|
assert not renderer.is_initialized
|
||||||
|
widget.release_resources() # idempotent: no crash, still released
|
||||||
|
assert not renderer.is_initialized
|
||||||
|
assert not renderer.part_ids, "release left GPU buffers behind"
|
||||||
|
|
||||||
|
# What Qt does when it hands the widget a new context.
|
||||||
|
widget.makeCurrent()
|
||||||
|
widget.initializeGL()
|
||||||
|
widget.doneCurrent()
|
||||||
|
recovered = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
|
||||||
|
assert recovered, "the scene did not come back for the new context"
|
||||||
|
assert renderer.is_initialized
|
||||||
|
assert _drawn(_render(widget, qt_app)).size, "the scene is not on screen again"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_probe_reports_this_machine_truthfully():
|
||||||
|
"""The probe's contract: exit 0 only for a GPU-driven context."""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[3] / "tools" / "scene_assets" / "probe_gl.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("probe_gl_tool", path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
report = module.collect("desktop33", keep_open_s=0.3)
|
||||||
|
context = report.get("context")
|
||||||
|
assert context and context["valid"], report
|
||||||
|
assert context["profile_used"] == "desktop33"
|
||||||
|
assert not context["software_renderer"], "the dev machine has a real GPU"
|
||||||
|
assert report["renderer_initialized"] and report["released"]
|
||||||
@@ -89,53 +89,60 @@ def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
|
|||||||
|
|
||||||
|
|
||||||
def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
|
def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
|
||||||
|
"""What is implemented is exercised elsewhere; what is not says so.
|
||||||
|
|
||||||
|
Stage 1 landed the camera, the dispatcher and the renderer (see
|
||||||
|
``test_scene3d_stage1.py`` and ``test_scene3d_gl_smoke.py``). The remaining
|
||||||
|
placeholders must still fail loudly with the stage that owns them rather than
|
||||||
|
quietly doing nothing.
|
||||||
|
"""
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
CameraPreset,
|
CameraPreset,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
|
ExpectedGeometry,
|
||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
GLCapabilities,
|
|
||||||
RenderFrame,
|
|
||||||
SceneAssets,
|
|
||||||
)
|
)
|
||||||
|
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
|
||||||
from orisys_studio.scene3d.camera import OrbitCamera
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||||
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer
|
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||||
|
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||||
|
|
||||||
camera = OrbitCamera()
|
camera = OrbitCamera()
|
||||||
renderer = GLRenderer()
|
renderer = GLRenderer()
|
||||||
widget = Sensor3DWidget(renderer, camera)
|
widget = Sensor3DWidget(renderer, camera)
|
||||||
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
||||||
|
panel = ThreeDPanel(coordinator, widget)
|
||||||
try:
|
try:
|
||||||
assert coordinator.state == "disabled"
|
assert coordinator.state == "disabled"
|
||||||
assert coordinator.generation == 0
|
assert coordinator.generation == 0
|
||||||
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
|
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError) as camera_error:
|
# Stage 1 is real: the camera works with no context at all.
|
||||||
camera.set_preset(CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7))
|
camera.set_preset(
|
||||||
assert "stage 1" in str(camera_error.value)
|
CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7)
|
||||||
|
)
|
||||||
|
assert camera.matrices((100, 100)).view.shape == (4, 4)
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError) as gl_error:
|
for call, stage in (
|
||||||
GLDispatch().bind_current_context(object())
|
(lambda: coordinator.activate(None, "live-A"), "stage 3"),
|
||||||
assert "stage 1" in str(gl_error.value)
|
(lambda: coordinator.set_settings(DisplaySettings()), "stage 3"),
|
||||||
|
(lambda: coordinator.set_visible(True), "stage 3"),
|
||||||
with pytest.raises(NotImplementedError) as activate_error:
|
(lambda: coordinator.close(), "stage 3"),
|
||||||
coordinator.activate(SceneAssets, "live-A") # type: ignore[arg-type]
|
(lambda: panel.set_state("ready"), "stage 4"),
|
||||||
assert "stage 3" in str(activate_error.value)
|
(lambda: BoxFrameAdapter().snapshot(None, FrameIdentity("a", 1, 0, 0.0, 1), None),
|
||||||
|
"stage 3"),
|
||||||
with pytest.raises(NotImplementedError) as settings_error:
|
(
|
||||||
coordinator.set_settings(DisplaySettings())
|
lambda: FingerFrameAdapter().snapshot(
|
||||||
assert "stage 3" in str(settings_error.value)
|
None, None, FrameIdentity("a", 1, 0, 0.0, 1)
|
||||||
|
),
|
||||||
with pytest.raises(NotImplementedError) as initialize_error:
|
"stage 3",
|
||||||
renderer.initialize(GLCapabilities("desktop33", "", "", "", "", "", False, 0))
|
),
|
||||||
assert "stage 1" in str(initialize_error.value)
|
):
|
||||||
|
with pytest.raises(NotImplementedError) as excinfo:
|
||||||
with pytest.raises(NotImplementedError) as update_error:
|
call()
|
||||||
renderer.update_surface(None) # type: ignore[arg-type]
|
assert stage in str(excinfo.value), (stage, str(excinfo.value))
|
||||||
assert "stage 1" in str(update_error.value)
|
del ExpectedGeometry
|
||||||
|
|
||||||
assert FrameIdentity("live-A", 1, 0, 0.0, 1).generation == 1
|
|
||||||
assert RenderFrame is not None and SceneAssets is not None
|
|
||||||
finally:
|
finally:
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"""Stage 1 interface tests: the camera, the synthetic scene, the CPU-side GL logic.
|
||||||
|
|
||||||
|
Everything here runs without a GPU — that is the point of splitting it out. The
|
||||||
|
parts that need a real context are in ``test_scene3d_gl_smoke.py``, which skips
|
||||||
|
itself on a platform that cannot create one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d import synthetic
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
CameraPreset,
|
||||||
|
FrameIdentity,
|
||||||
|
SceneAssets,
|
||||||
|
validate_assets,
|
||||||
|
)
|
||||||
|
from orisys_studio.scene3d.camera import (
|
||||||
|
MAX_DISTANCE_M,
|
||||||
|
MAX_PITCH_RAD,
|
||||||
|
MIN_DISTANCE_M,
|
||||||
|
OrbitCamera,
|
||||||
|
)
|
||||||
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
|
LUT_SIZE,
|
||||||
|
STRIDE_FLOATS,
|
||||||
|
estimate_gpu_bytes,
|
||||||
|
is_software_renderer,
|
||||||
|
lut_rgba,
|
||||||
|
pack_part,
|
||||||
|
pack_scene_part,
|
||||||
|
profile_from_renderable_type,
|
||||||
|
rest_surface,
|
||||||
|
shader_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project(camera: OrbitCamera, points: np.ndarray, viewport=(240, 180)) -> np.ndarray:
|
||||||
|
"""NDC coordinates of sensor-space points, through the real matrices."""
|
||||||
|
matrices = camera.matrices(viewport)
|
||||||
|
view = np.asarray(matrices.view, dtype=np.float64)
|
||||||
|
projection = np.asarray(matrices.projection, dtype=np.float64)
|
||||||
|
homogeneous = np.concatenate(
|
||||||
|
[np.asarray(points, dtype=np.float64), np.ones((len(points), 1))], axis=1
|
||||||
|
)
|
||||||
|
camera_space = (view @ homogeneous.T).T
|
||||||
|
clip = (projection @ camera_space.T).T
|
||||||
|
return clip[:, :3] / clip[:, 3:4]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- camera
|
||||||
|
def test_fit_frames_the_whole_scene_inside_the_viewport():
|
||||||
|
assets = synthetic.poc_scene()
|
||||||
|
camera = OrbitCamera()
|
||||||
|
# What the widget does on load: the asset's default view, then framed.
|
||||||
|
camera.set_preset(assets.camera_presets[0])
|
||||||
|
camera.fit(assets.bounds_sensor_m, 240 / 180)
|
||||||
|
for part in assets.parts:
|
||||||
|
ndc = _project(camera, np.asarray(part.positions_sensor_m))
|
||||||
|
assert np.all(np.abs(ndc[:, :2]) <= 1.0), (
|
||||||
|
f"{part.part_id} is outside the viewport: {ndc[:, :2].min(axis=0)} .. "
|
||||||
|
f"{ndc[:, :2].max(axis=0)}"
|
||||||
|
)
|
||||||
|
assert np.all(ndc[:, 2] <= 1.0), "the part is beyond the far plane"
|
||||||
|
# ...and not so far away that it is a dot. The default view is a three-quarter
|
||||||
|
# one, so the plate's face fills the frame in both axes.
|
||||||
|
plate = np.asarray(assets.sensing_part().positions_sensor_m)
|
||||||
|
span = np.ptp(_project(camera, plate)[:, :2], axis=0)
|
||||||
|
assert span.min() > 0.25, f"the fitted scene is tiny on screen: {span}"
|
||||||
|
# The scene sits inside the frustum, not against a plane: at NDC z ~ 1 there
|
||||||
|
# is no usable depth resolution and the housing z-fights the plate.
|
||||||
|
depth = _project(camera, plate)[:, 2]
|
||||||
|
assert np.all(np.abs(depth) < 0.9), f"the scene is against a frustum plane: {depth}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_respects_a_wide_and_a_tall_viewport():
|
||||||
|
"""A wide window must not clip vertically, a tall one not horizontally."""
|
||||||
|
assets = synthetic.poc_scene()
|
||||||
|
for aspect in (0.5, 1.0, 2.5):
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(assets.bounds_sensor_m, aspect)
|
||||||
|
points = np.concatenate([np.asarray(p.positions_sensor_m) for p in assets.parts])
|
||||||
|
ndc = _project(camera, points, viewport=(int(200 * aspect), 200))
|
||||||
|
assert np.all(np.abs(ndc[:, :2]) <= 1.0), (aspect, np.ptp(ndc[:, :2], axis=0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_orbit_keeps_the_target_and_the_distance():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||||
|
target = camera.target_sensor_m.copy()
|
||||||
|
distance = camera.distance_m
|
||||||
|
camera.orbit((40.0, -20.0), (240, 180))
|
||||||
|
assert np.allclose(camera.target_sensor_m, target), "orbiting moved the target"
|
||||||
|
assert camera.distance_m == pytest.approx(distance)
|
||||||
|
assert camera.yaw_rad != 0.0 and camera.pitch_rad != 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_orbit_clamps_the_pitch_instead_of_flipping():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||||
|
camera.orbit((0.0, 100000.0), (240, 180))
|
||||||
|
assert camera.pitch_rad <= MAX_PITCH_RAD + 1e-9
|
||||||
|
camera.orbit((0.0, -100000.0), (240, 180))
|
||||||
|
assert camera.pitch_rad >= -MAX_PITCH_RAD - 1e-9
|
||||||
|
# The view stays well formed: an up vector that never collapses.
|
||||||
|
up = camera.matrices((240, 180))
|
||||||
|
assert np.isfinite(np.asarray(up.view)).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_is_exponential_and_clamped():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||||
|
start = camera.distance_m
|
||||||
|
camera.zoom(1.0)
|
||||||
|
assert camera.distance_m < start
|
||||||
|
inward = camera.distance_m
|
||||||
|
camera.zoom(-1.0)
|
||||||
|
assert camera.distance_m == pytest.approx(start, rel=1e-6), "zoom in then out must return"
|
||||||
|
for _ in range(200):
|
||||||
|
camera.zoom(10.0)
|
||||||
|
assert camera.distance_m == pytest.approx(MIN_DISTANCE_M)
|
||||||
|
for _ in range(400):
|
||||||
|
camera.zoom(-10.0)
|
||||||
|
assert camera.distance_m == pytest.approx(MAX_DISTANCE_M)
|
||||||
|
del inward
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_moves_the_target_in_the_view_plane():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||||
|
before = camera.target_sensor_m.copy()
|
||||||
|
eye_before = camera.eye_sensor_m()
|
||||||
|
camera.pan((10.0, 0.0), (240, 180))
|
||||||
|
moved = camera.target_sensor_m - before
|
||||||
|
assert float(np.linalg.norm(moved)) > 0.0, "panning did nothing"
|
||||||
|
# The eye follows the target, so the model does not appear to rotate.
|
||||||
|
assert np.allclose(camera.eye_sensor_m() - eye_before, moved, atol=1e-9)
|
||||||
|
# A pan of half the viewport height moves the target by half the visible height.
|
||||||
|
camera2 = OrbitCamera()
|
||||||
|
camera2.fit(synthetic.poc_scene().bounds_sensor_m, 240 / 180)
|
||||||
|
visible_height = 2.0 * camera2.distance_m * np.tan(0.5 * camera2.vertical_fov_rad)
|
||||||
|
before2 = camera2.target_sensor_m.copy()
|
||||||
|
camera2.pan((0.0, 90.0), (240, 180))
|
||||||
|
moved2 = camera2.target_sensor_m - before2
|
||||||
|
assert float(np.linalg.norm(moved2)) == pytest.approx(visible_height * 0.5, rel=1e-6), (
|
||||||
|
"a half-viewport drag must move the target by half the visible height"
|
||||||
|
)
|
||||||
|
# ...and along the camera's up axis: a screen-vertical drag is not world-Z.
|
||||||
|
direction = moved2 / float(np.linalg.norm(moved2))
|
||||||
|
assert abs(float(np.dot(direction, camera2._direction()))) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_presets_reproduce_their_eye_and_target():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
preset = CameraPreset(
|
||||||
|
"asset-default", (0.0, -0.05, 0.05), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.6
|
||||||
|
)
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||||
|
camera.set_preset(preset)
|
||||||
|
assert np.allclose(camera.target_sensor_m, preset.target_sensor_m)
|
||||||
|
assert np.allclose(camera.eye_sensor_m(), preset.eye_sensor_m, atol=1e-9)
|
||||||
|
assert camera.vertical_fov_rad == pytest.approx(preset.vertical_fov_rad)
|
||||||
|
# A drag continues from the preset rather than jumping.
|
||||||
|
eye = camera.eye_sensor_m()
|
||||||
|
camera.orbit((5.0, 2.0), (240, 180))
|
||||||
|
assert not np.allclose(camera.eye_sensor_m(), eye)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_returns_to_the_framed_view():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
assets = synthetic.poc_scene()
|
||||||
|
camera.fit(assets.bounds_sensor_m, 1.0)
|
||||||
|
target = camera.target_sensor_m.copy()
|
||||||
|
distance = camera.distance_m
|
||||||
|
camera.orbit((30.0, 10.0), (240, 180))
|
||||||
|
camera.zoom(3.0)
|
||||||
|
camera.reset(assets.bounds_sensor_m)
|
||||||
|
assert np.allclose(camera.target_sensor_m, target)
|
||||||
|
assert camera.distance_m == pytest.approx(distance)
|
||||||
|
assert camera.yaw_rad == 0.0 and camera.pitch_rad == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_view_matrix_is_a_rigid_transform():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.3)
|
||||||
|
view = np.asarray(camera.matrices((320, 240)).view, dtype=np.float64)
|
||||||
|
rotation = view[:3, :3]
|
||||||
|
assert np.allclose(rotation @ rotation.T, np.eye(3), atol=1e-5), "the view is not rigid"
|
||||||
|
assert np.linalg.det(rotation) == pytest.approx(1.0, abs=1e-5)
|
||||||
|
# The camera sits in front of the scene: visible points have negative z.
|
||||||
|
target = camera.target_sensor_m
|
||||||
|
homogeneous = np.append(target, 1.0)
|
||||||
|
assert float((view @ homogeneous)[2]) < 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrices_are_read_only_float32():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
camera.fit(synthetic.poc_scene().bounds_sensor_m, 1.0)
|
||||||
|
matrices = camera.matrices((240, 180))
|
||||||
|
for name, value in (("view", matrices.view), ("projection", matrices.projection)):
|
||||||
|
assert value.dtype == np.float32 and value.shape == (4, 4)
|
||||||
|
assert not value.flags["WRITEABLE"], f"{name} is writable"
|
||||||
|
assert value.flags["C_CONTIGUOUS"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_refuses_bounds_that_are_not_two_by_three():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
camera.fit(np.zeros((3, 3), dtype=np.float32), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_preset_with_the_eye_on_the_target_is_refused():
|
||||||
|
camera = OrbitCamera()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
camera.set_preset(
|
||||||
|
CameraPreset("degenerate", (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.6)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- the POC scene
|
||||||
|
def test_the_synthetic_scene_obeys_the_contracts():
|
||||||
|
assets = synthetic.poc_scene()
|
||||||
|
assert isinstance(assets, SceneAssets)
|
||||||
|
assert validate_assets(assets, synthetic.poc_expected()) == ()
|
||||||
|
roles = {part.role for part in assets.parts}
|
||||||
|
assert roles == {"sensing", "housing"}, "a scene needs one of each for the PoC"
|
||||||
|
# The plate faces +Z and is flat, so a normal assertion has an answer.
|
||||||
|
plate = assets.sensing_part()
|
||||||
|
assert np.allclose(np.asarray(plate.normals_sensor), [0.0, 0.0, 1.0])
|
||||||
|
assert float(np.asarray(plate.positions_sensor_m)[:, 2].max()) == 0.0
|
||||||
|
assert assets.bounds_sensor_m.shape == (2, 3)
|
||||||
|
assert [preset.name for preset in assets.camera_presets][0] == "default"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ GL CPU helpers
|
||||||
|
def test_shader_sources_are_two_dialects_of_one_body():
|
||||||
|
for profile, marker in (("desktop33", "#version 330 core"), ("gles30", "#version 300 es")):
|
||||||
|
for program in ("scalar", "diffuse"):
|
||||||
|
vertex, fragment = shader_source(profile, program)
|
||||||
|
assert vertex.startswith(marker), (profile, program, vertex[:40])
|
||||||
|
assert fragment.startswith(marker)
|
||||||
|
if profile == "gles30":
|
||||||
|
# ES requires explicit precision for floats.
|
||||||
|
assert "precision highp float;" in fragment
|
||||||
|
# No fixed pipeline, ever.
|
||||||
|
for banned in ("glBegin", "gl_End", "glMatrixMode", "gl_FragColor", "GL_QUADS"):
|
||||||
|
assert banned not in vertex and banned not in fragment
|
||||||
|
# The maths is shared: the bodies differ only by their header.
|
||||||
|
from orisys_studio.scene3d.gl_backend import shader_header
|
||||||
|
|
||||||
|
desktop_vertex, desktop_fragment = shader_source("desktop33", "scalar")
|
||||||
|
es_vertex, es_fragment = shader_source("gles30", "scalar")
|
||||||
|
desktop_body = desktop_vertex[len(shader_header("desktop33")):]
|
||||||
|
es_body = es_vertex[len(shader_header("gles30")):]
|
||||||
|
assert desktop_body == es_body, "the vertex maths drifted between dialects"
|
||||||
|
assert desktop_fragment[len(shader_header("desktop33")):] == es_fragment[
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_profile_or_program_is_refused():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
shader_source("vulkan", "scalar") # type: ignore[arg-type]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
shader_source("desktop33", "wireframe")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lut_is_an_ordered_ramp_and_the_diverging_one_is_centred():
|
||||||
|
sequential = lut_rgba("sequential")
|
||||||
|
assert sequential.shape == (LUT_SIZE, 1, 4) and sequential.dtype == np.uint8
|
||||||
|
assert (sequential[:, 0, 3] == 255).all(), "the LUT must be opaque"
|
||||||
|
luminance = sequential[:, 0, :3].astype(float).mean(axis=1)
|
||||||
|
assert luminance[0] < luminance[-1], "a sequential ramp must get lighter"
|
||||||
|
assert (np.diff(luminance) >= -6.0).all(), "the ramp is not monotone"
|
||||||
|
diverging = lut_rgba("diverging")
|
||||||
|
assert diverging.shape == sequential.shape
|
||||||
|
assert not np.array_equal(diverging, sequential)
|
||||||
|
|
||||||
|
|
||||||
|
def test_software_renderers_are_recognised():
|
||||||
|
assert is_software_renderer("Mesa", "llvmpipe (LLVM 15.0.7, 256 bits)")
|
||||||
|
assert is_software_renderer("Microsoft", "Microsoft Basic Render Driver")
|
||||||
|
assert is_software_renderer("", "swrast")
|
||||||
|
assert not is_software_renderer("NVIDIA Corporation", "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2")
|
||||||
|
assert not is_software_renderer("Intel", "Intel(R) UHD Graphics 620")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_renderable_type_mapping_is_not_inverted():
|
||||||
|
"""Qt: OpenGL = 1, OpenGLES = 2. Getting this backwards is invisible on a
|
||||||
|
driver that accepts both dialects and fatal on one that does not."""
|
||||||
|
assert profile_from_renderable_type(1) == "desktop33"
|
||||||
|
assert profile_from_renderable_type(2) == "gles30"
|
||||||
|
assert profile_from_renderable_type(0) == "desktop33", "the default is desktop"
|
||||||
|
|
||||||
|
|
||||||
|
def _plate(**kwargs):
|
||||||
|
return synthetic.poc_scene().sensing_part()
|
||||||
|
|
||||||
|
|
||||||
|
def test_packing_lays_out_position_normal_scalar_valid():
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_packing_a_scene_without_a_measurement_marks_everything_invalid():
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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_scene_part_indices_are_uint32_and_read_only():
|
||||||
|
vertices, indices = pack_scene_part(_plate())
|
||||||
|
assert indices.dtype == np.uint32 and indices.ndim == 1
|
||||||
|
assert not indices.flags["WRITEABLE"] and not vertices.flags["WRITEABLE"]
|
||||||
|
assert int(indices.max()) < vertices.shape[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gpu_budget_counts_both_parts_and_one_dynamic_copy():
|
||||||
|
assets = synthetic.poc_scene()
|
||||||
|
budget = estimate_gpu_bytes(assets, dynamic_part_id="sensing_surface")
|
||||||
|
plate = assets.sensing_part()
|
||||||
|
housing = assets.part("housing")
|
||||||
|
assert housing is not None
|
||||||
|
expected = (
|
||||||
|
plate.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
|
||||||
|
+ plate.triangles.size * 4
|
||||||
|
+ plate.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4 # the in-flight copy
|
||||||
|
+ housing.positions_sensor_m.size // 3 * STRIDE_FLOATS * 4
|
||||||
|
+ housing.triangles.size * 4
|
||||||
|
+ LUT_SIZE * 4
|
||||||
|
)
|
||||||
|
assert budget == expected
|
||||||
|
assert budget < 1024 * 1024, "the PoC scene should be tiny"
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
assert frame.geometry_id == assets.geometry_id
|
||||||
|
assert frame.sensing_part_id == assets.sensing_part().part_id
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"profile_requested": "desktop33",
|
||||||
|
"platform": {
|
||||||
|
"qt_platform": "windows",
|
||||||
|
"python": "3.10.19",
|
||||||
|
"os": "Windows 10",
|
||||||
|
"pyopengl": "3.1.10"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"valid": true,
|
||||||
|
"vendor": "NVIDIA Corporation",
|
||||||
|
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
|
||||||
|
"gl_version": "3.3.0 NVIDIA 610.88",
|
||||||
|
"glsl_version": "3.30 NVIDIA via Cg compiler",
|
||||||
|
"profile_used": "desktop33",
|
||||||
|
"software_renderer": false,
|
||||||
|
"max_texture_size": 32768,
|
||||||
|
"qt_format": {
|
||||||
|
"version": "3.3",
|
||||||
|
"renderable_type": 1,
|
||||||
|
"profile": 1,
|
||||||
|
"depth_buffer": 24,
|
||||||
|
"samples": 0
|
||||||
|
},
|
||||||
|
"device_pixel_ratio": 1.0,
|
||||||
|
"framebuffer_px": [
|
||||||
|
320,
|
||||||
|
240
|
||||||
|
],
|
||||||
|
"diagnostics": []
|
||||||
|
},
|
||||||
|
"lut_rgba_bytes": 1024,
|
||||||
|
"renderer_initialized": true,
|
||||||
|
"released": true,
|
||||||
|
"verdict": "ok"
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。
|
配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。
|
||||||
本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。
|
本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。
|
||||||
|
|
||||||
阶段 0 已经完成(骨架,见下);阶段 1 起逐段实现。每个阶段结束时必须同时交出:
|
阶段 0 已经完成(骨架,见下);阶段 1 已完成(开发机 PoC,见下);阶段 2 起逐段实现。
|
||||||
实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。
|
每个阶段结束时必须同时交出:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。
|
||||||
|
|
||||||
原则(写下来是因为后面每阶段都会被诱惑违反一次):
|
原则(写下来是因为后面每阶段都会被诱惑违反一次):
|
||||||
|
|
||||||
@@ -34,24 +34,50 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 阶段 1:平台 PoC(图形栈先落地)
|
## 阶段 1:平台 PoC(图形栈先落地)—— 已完成(开发机)
|
||||||
|
|
||||||
**做**:`probe_gl`(真实 Qt 窗口 + 真实 context)、`GLDispatch`(探测 + 绑定 PyOpenGL)、
|
**做**:`probe_gl`(真实 Qt 窗口 + 真实 context)、`GLDispatch`(探测 + 绑定 PyOpenGL)、
|
||||||
`GLRenderer` 最小集(一个 shader、一个 VBO/EBO、静态三角形)、静态感知面 + 外壳两个 part 各一次 draw、
|
`GLRenderer` 最小集(两个 program、VBO/EBO、静态感知面 + 外壳各一次 draw、LUT 纹理)、
|
||||||
`OrbitCamera` 的 `fit`/`orbit`、`Sensor3DWidget` 的 `initializeGL`/`paintGL`/`resizeGL`/`release_resources`
|
`OrbitCamera` 全部数学、`Sensor3DWidget` 的 `initializeGL`/`paintGL`/`resizeGL`/`release_resources`
|
||||||
与 `aboutToBeDestroyed` 清理。
|
与 `aboutToBeDestroyed` 清理、`tools/scene_assets/probe_gl.py` 报告与退出码。
|
||||||
|
|
||||||
**验收**:
|
**验收(已通过)**:`apps/studio/tests/test_scene3d_stage1.py` 24 项(无需 GPU)+
|
||||||
|
`apps/studio/tests/test_scene3d_gl_smoke.py` 8 项(真实 context;offscreen 下自动 skip)。
|
||||||
|
跑法:
|
||||||
|
|
||||||
| 用例 | 通过标准 |
|
```powershell
|
||||||
|---|---|
|
# 真实 context 的一套(offscreen 插件无法创建 GL context,必须给一个真实平台)
|
||||||
| 能力报告 | 开发机 Windows 出 `desktop33` 报告:vendor/renderer/GL/GLSL/DPR/framebuffer,且明确标注是否软件渲染 |
|
$env:QT_QPA_PLATFORM="windows" # 板上用 xcb/wayland/eglfs,由探测报告决定
|
||||||
| 静态场景 | 三角形与感知面/外壳都可见;外壳可隐藏;感知面与外壳不共用缓冲 |
|
python -m pytest apps/studio/tests/test_scene3d_gl_smoke.py
|
||||||
| 生命周期 | 反复 resize(含 DPR 变化)、隐藏/显示、关闭、context 重建各 20 次无崩溃、无泄漏 |
|
# 能力报告(退出码即门禁:软件渲染 3、无 context 4)
|
||||||
| 失败样例 | 强制 shader 编译失败 → 三维 Unavailable + 诊断,二维照常;`gl_unsupported` 路径同样 |
|
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
|
||||||
| 记录隔离 | 开/关三维各录 30 s,CSV 行数与有效样本完全一致 |
|
```
|
||||||
|
|
||||||
**明确不做**:ARM 板卡适配(按用户要求暂缓),但 probe 脚本要能直接在板上跑出报告。
|
开发机结果:`docs/architecture/gl-report-windows-dev.json`
|
||||||
|
(NVIDIA RTX 4080 SUPER、GL 3.3 Core、GLSL 3.30、DPR 1.0、`verdict=ok`、非软件渲染)。
|
||||||
|
|
||||||
|
**这一阶段抓到的(都是会在真实资产上表现为"看起来怪怪的"的错误)**:
|
||||||
|
|
||||||
|
1. **Qt 的 renderableType 映射反了**(`OpenGL=1`、`OpenGLES=2`)。NVIDIA 经 `ARB_ES3_compatibility`
|
||||||
|
接受 ES 着色器,所以本机"看起来正常";在 Intel/ARM 上会直接 `shader_compile_failed`。
|
||||||
|
现在由 `profile_from_renderable_type()` 一处决定并有单测。
|
||||||
|
2. **观察相机是镜像的**:`_direction()` 返回的是"target→eye"(相机**后**轴),
|
||||||
|
`right = cross(forward, up)` 因此得到左手基(det = -1),整个模型左右镜像。
|
||||||
|
已按 `right = cross(up, backward)` 构造右手基,并用"view 是刚体变换(det = +1)"的单测钉住。
|
||||||
|
3. **近平面过近**:`near = distance*0.01` 使 NDC z 全部挤在 0.99 —— 深度精度几乎为零,
|
||||||
|
外壳与感知面迟早 z-fighting。现在近平面按包围球半径取。
|
||||||
|
4. **手动 `makeCurrent()` 早于 Qt 首次绘制**会让 `upload_scene` 把 GPU 对象建在一个随后被丢弃的
|
||||||
|
context 里(表现为 `glBindVertexArray` → `GL_INVALID_OPERATION`)。测试与探针改为
|
||||||
|
`show()`/`repaint()`/`grabFramebuffer()`,即由 Qt 自己驱动 context。
|
||||||
|
5. **context 重建后不重传**:`initializeGL` 现在把 `_needs_scene_upload` 重新置位(新 context 里什么都没有)。
|
||||||
|
6. **一帧几何不匹配曾把整个视图打成 Unavailable**:改为"拒绝该帧"(计数 + 诊断),
|
||||||
|
并把上一次的测量清成参考状态(`rest_surface`),视图继续可用。
|
||||||
|
7. `grabFramebuffer()` 是 **BGRA**(小端)+ 有 stride,测试里读像素必须转换与裁剪,否则所有颜色通道判断都是错的。
|
||||||
|
|
||||||
|
**记录隔离**:本阶段尚未接入 `app_window`,因此三维对采集/记录完全惰性(无模块被应用导入);
|
||||||
|
"开/关三维的 CSV 行序一致"这条验收随阶段 4 的接线一起做。
|
||||||
|
|
||||||
|
**明确不做**:ARM 板卡适配(按用户要求暂缓),但 `probe_gl.py` 可直接在板上跑出报告并据此定 profile。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""A known scene, in metres, in the sensor frame — for the PoC and for tests.
|
||||||
|
|
||||||
|
Not an asset pipeline and not shipped to customers: it is the geometry the
|
||||||
|
platform PoC draws and the CPU tests reason about, so "the surface is at z≈0 and
|
||||||
|
the housing sits behind it" is a fact both can rely on instead of re-deriving
|
||||||
|
triangles in every test.
|
||||||
|
|
||||||
|
Two parts, as the design requires of every scene: a sensing plate (dynamic) and a
|
||||||
|
housing box (static, hideable). Coordinates are right-handed metres; the plate
|
||||||
|
faces +Z, so a normal check has an unambiguous answer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
CameraPreset,
|
||||||
|
ExpectedGeometry,
|
||||||
|
FrameIdentity,
|
||||||
|
Material,
|
||||||
|
MeshPart,
|
||||||
|
SceneAssets,
|
||||||
|
SurfaceBinding,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 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.
|
||||||
|
PLATE_HALF_M = 0.01
|
||||||
|
GRID = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _plate() -> MeshPart:
|
||||||
|
step = (2.0 * PLATE_HALF_M) / (GRID - 1)
|
||||||
|
positions = np.zeros((GRID * GRID, 3), dtype=np.float32)
|
||||||
|
for row in range(GRID):
|
||||||
|
for col in range(GRID):
|
||||||
|
positions[row * GRID + col] = (
|
||||||
|
-PLATE_HALF_M + col * step,
|
||||||
|
-PLATE_HALF_M + row * step,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
normals = np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (GRID * GRID, 1))
|
||||||
|
triangles = []
|
||||||
|
for row in range(GRID - 1):
|
||||||
|
for col in range(GRID - 1):
|
||||||
|
a = row * GRID + col
|
||||||
|
b = a + 1
|
||||||
|
c = a + GRID
|
||||||
|
d = c + 1
|
||||||
|
triangles.append((a, b, d))
|
||||||
|
triangles.append((a, d, c))
|
||||||
|
return MeshPart(
|
||||||
|
part_id="sensing_surface",
|
||||||
|
role="sensing",
|
||||||
|
positions_sensor_m=freeze(positions, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(normals, dtype=np.float32),
|
||||||
|
triangles=freeze(np.asarray(triangles, dtype=np.uint32), dtype=np.uint32),
|
||||||
|
material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True),
|
||||||
|
lod_id="poc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _housing() -> MeshPart:
|
||||||
|
"""An open box sitting behind the plate: static, grey, never measured."""
|
||||||
|
h = PLATE_HALF_M * 1.6
|
||||||
|
depth = PLATE_HALF_M * 0.8
|
||||||
|
corners = np.array(
|
||||||
|
[
|
||||||
|
(-h, -h, -depth), (h, -h, -depth), (h, h, -depth), (-h, h, -depth),
|
||||||
|
(-h, -h, 0.0), (h, -h, 0.0), (h, h, 0.0), (-h, h, 0.0),
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
faces = [
|
||||||
|
(0, 1, 2), (0, 2, 3), # back
|
||||||
|
(4, 6, 5), (4, 7, 6), # front, wound the other way
|
||||||
|
(0, 4, 5), (0, 5, 1), # bottom
|
||||||
|
(3, 2, 6), (3, 6, 7), # top
|
||||||
|
(0, 3, 7), (0, 7, 4), # left
|
||||||
|
(1, 5, 6), (1, 6, 2), # right
|
||||||
|
]
|
||||||
|
normals = np.zeros_like(corners)
|
||||||
|
for triangle in faces:
|
||||||
|
a, b, c = (corners[index] for index in triangle)
|
||||||
|
normal = np.cross(b - a, c - a)
|
||||||
|
length = float(np.linalg.norm(normal))
|
||||||
|
if length > 0:
|
||||||
|
normal = normal / length
|
||||||
|
for index in triangle:
|
||||||
|
normals[index] += normal
|
||||||
|
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
|
||||||
|
normals = normals / np.where(lengths > 0, lengths, 1.0)
|
||||||
|
return MeshPart(
|
||||||
|
part_id="housing",
|
||||||
|
role="housing",
|
||||||
|
positions_sensor_m=freeze(corners, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(normals.astype(np.float32), dtype=np.float32),
|
||||||
|
triangles=freeze(np.asarray(faces, dtype=np.uint32), dtype=np.uint32),
|
||||||
|
material=Material((0.25, 0.26, 0.28, 1.0), "diffuse", True),
|
||||||
|
lod_id="poc",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _binding() -> SurfaceBinding:
|
||||||
|
"""Identity binding: each plate vertex samples its own field cell."""
|
||||||
|
count = GRID * GRID
|
||||||
|
indices = np.full((count, 4), -1, dtype=np.int32)
|
||||||
|
weights = np.zeros((count, 4), dtype=np.float32)
|
||||||
|
for index in range(count):
|
||||||
|
indices[index, 0] = index
|
||||||
|
weights[index, 0] = 1.0
|
||||||
|
return SurfaceBinding(
|
||||||
|
part_id="sensing_surface",
|
||||||
|
geometry_id="poc-geometry",
|
||||||
|
field_domain_id="poc-domain",
|
||||||
|
shape_hw=(GRID, GRID),
|
||||||
|
sample_indices=freeze(indices, dtype=np.int32),
|
||||||
|
sample_weights=freeze(weights, dtype=np.float32),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def poc_scene() -> SceneAssets:
|
||||||
|
"""The synthetic scene: one sensing plate, one housing box, one preset."""
|
||||||
|
plate = _plate()
|
||||||
|
housing = _housing()
|
||||||
|
points = np.concatenate(
|
||||||
|
[np.asarray(plate.positions_sensor_m), np.asarray(housing.positions_sensor_m)]
|
||||||
|
)
|
||||||
|
presets = (
|
||||||
|
# The asset's own default view: a three-quarter view of the plate's face,
|
||||||
|
# with the housing visible behind it. Face-on would hide the depth, and
|
||||||
|
# looking along Y would show the plate edge-on.
|
||||||
|
CameraPreset(
|
||||||
|
"default",
|
||||||
|
(0.03, -0.03, 0.06),
|
||||||
|
(0.0, 0.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
0.6,
|
||||||
|
),
|
||||||
|
CameraPreset("front", (0.0, 0.0, 0.08), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.6),
|
||||||
|
CameraPreset("side", (0.08, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.6),
|
||||||
|
CameraPreset("top", (0.0, 0.0, 0.09), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.6),
|
||||||
|
)
|
||||||
|
return SceneAssets(
|
||||||
|
asset_id="poc-scene",
|
||||||
|
asset_version="0.0.0",
|
||||||
|
asset_hash="poc-hash",
|
||||||
|
geometry_id="poc-geometry",
|
||||||
|
parts=(plate, housing),
|
||||||
|
binding=_binding(),
|
||||||
|
camera_presets=presets,
|
||||||
|
bounds_sensor_m=freeze(
|
||||||
|
np.stack([points.min(axis=0), points.max(axis=0)]).astype(np.float32),
|
||||||
|
dtype=np.float32,
|
||||||
|
),
|
||||||
|
field_domain_id="poc-domain",
|
||||||
|
shape_hw=(GRID, GRID),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def poc_identity(*, generation: int = 1) -> FrameIdentity:
|
||||||
|
return FrameIdentity("poc", 1, 0, 0.0, generation=generation)
|
||||||
|
|
||||||
|
|
||||||
|
def poc_expected() -> ExpectedGeometry:
|
||||||
|
return ExpectedGeometry("finger", "poc", "poc-geometry", "poc-domain", (GRID, GRID))
|
||||||
+167
-21
@@ -2,24 +2,155 @@
|
|||||||
|
|
||||||
Stage 1 deliverable (``docs/architecture/studio-3d-stages.md``).
|
Stage 1 deliverable (``docs/architecture/studio-3d-stages.md``).
|
||||||
|
|
||||||
It must create an **actual** Qt widget and read the context that Qt actually
|
It creates an **actual** Qt widget and reads the context that Qt actually
|
||||||
obtained — asking the system whether ``libGL`` exists proves nothing, and an
|
obtained — asking the system whether ``libGL`` exists proves nothing, and the
|
||||||
offscreen UI test cannot stand in for a board's GPU. What it writes is the input
|
``offscreen`` platform cannot stand in for a board (measured: offscreen answers
|
||||||
to the platform decision: which profile a board gets, and whether the renderer is
|
"this plugin does not support createPlatformOpenGLContext").
|
||||||
a real GPU.
|
|
||||||
|
|
||||||
Until stage 1 lands, the script explains what it will do and exits non-zero, so a
|
Exit code is the gate: 0 only for a context that a GPU is really driving. A
|
||||||
build script cannot mistake "not implemented" for "probe passed".
|
software rasteriser (llvmpipe and friends) reports 3 unless ``--allow-software``
|
||||||
|
is passed, so a board cannot be signed off on one by accident.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
|
||||||
|
python tools/scene_assets/probe_gl.py --profile gles30 --windowed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_STAGE = "stage 1 (platform PoC)"
|
#: Exit codes: distinct values so a CI script can tell the two failures apart.
|
||||||
|
EXIT_OK = 0
|
||||||
|
EXIT_SOFTWARE = 3
|
||||||
|
EXIT_NO_CONTEXT = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_paths() -> None:
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
repo = here.parents[2]
|
||||||
|
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
|
||||||
|
if path.is_dir() and str(path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def collect(profile: str, *, keep_open_s: float) -> dict:
|
||||||
|
"""Create a real window, let Qt initialise it, and report what it got."""
|
||||||
|
from PyQt5.QtCore import QTimer
|
||||||
|
from PyQt5.QtWidgets import QApplication, QOpenGLWidget
|
||||||
|
|
||||||
|
from orisys_studio.scene3d.gl_backend import (
|
||||||
|
GLDispatch,
|
||||||
|
GLRenderer,
|
||||||
|
ThreeDUnsupported,
|
||||||
|
configure_surface_format,
|
||||||
|
lut_rgba,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_surface_format(profile) # type: ignore[arg-type]
|
||||||
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
report: dict = {
|
||||||
|
"profile_requested": profile,
|
||||||
|
"platform": {
|
||||||
|
"qt_platform": app.platformName(),
|
||||||
|
"python": sys.version.split()[0],
|
||||||
|
"os": f"{platform.system()} {platform.release()}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
from OpenGL import __version__ as pyopengl_version
|
||||||
|
|
||||||
|
report["platform"]["pyopengl"] = str(pyopengl_version)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
report["platform"]["pyopengl"] = "missing"
|
||||||
|
|
||||||
|
class Probe(QOpenGLWidget):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.renderer = GLRenderer()
|
||||||
|
self.dispatch = GLDispatch()
|
||||||
|
self.error: dict | None = None
|
||||||
|
self.ready = False
|
||||||
|
|
||||||
|
def initializeGL(self) -> None:
|
||||||
|
try:
|
||||||
|
capabilities = self.dispatch.bind_current_context(self.context())
|
||||||
|
self.renderer.initialize(capabilities)
|
||||||
|
except ThreeDUnsupported as exc:
|
||||||
|
caps = exc.capabilities
|
||||||
|
self.report_caps(caps)
|
||||||
|
self.error = {"code": exc.diagnostic.code, "message": exc.diagnostic.message}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.error = {"code": "gl_unsupported", "message": f"{type(exc).__name__}: {exc}"}
|
||||||
|
else:
|
||||||
|
self.report_caps(capabilities)
|
||||||
|
self.ready = True
|
||||||
|
|
||||||
|
def report_caps(self, capabilities) -> None:
|
||||||
|
from PyQt5.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
|
fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat()
|
||||||
|
ratio = float(self.devicePixelRatioF() or 1.0)
|
||||||
|
report["context"] = {
|
||||||
|
"valid": bool(self.context().isValid()) if self.context() else False,
|
||||||
|
"vendor": capabilities.vendor,
|
||||||
|
"renderer": capabilities.renderer,
|
||||||
|
"gl_version": capabilities.gl_version,
|
||||||
|
"glsl_version": capabilities.glsl_version,
|
||||||
|
"profile_used": capabilities.profile,
|
||||||
|
"software_renderer": capabilities.software_renderer,
|
||||||
|
"max_texture_size": capabilities.max_texture_size,
|
||||||
|
"qt_format": {
|
||||||
|
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
|
||||||
|
"renderable_type": int(fmt.renderableType()),
|
||||||
|
"profile": int(fmt.profile()),
|
||||||
|
"depth_buffer": int(fmt.depthBufferSize()),
|
||||||
|
"samples": int(fmt.samples()),
|
||||||
|
},
|
||||||
|
"device_pixel_ratio": ratio,
|
||||||
|
"framebuffer_px": [
|
||||||
|
int(round(self.width() * ratio)),
|
||||||
|
int(round(self.height() * ratio)),
|
||||||
|
],
|
||||||
|
"diagnostics": [
|
||||||
|
{"code": item.code, "severity": item.severity, "message": item.message}
|
||||||
|
for item in capabilities.diagnostics
|
||||||
|
],
|
||||||
|
}
|
||||||
|
report["lut_rgba_bytes"] = int(lut_rgba("sequential").nbytes)
|
||||||
|
report["renderer_initialized"] = bool(self.renderer.is_initialized)
|
||||||
|
|
||||||
|
widget = Probe()
|
||||||
|
widget.resize(320, 240)
|
||||||
|
if keep_open_s >= 0:
|
||||||
|
widget.show()
|
||||||
|
deadline = time.perf_counter() + max(keep_open_s, 0.3)
|
||||||
|
while time.perf_counter() < deadline and not widget.ready:
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.01)
|
||||||
|
app.processEvents()
|
||||||
|
if widget.error is not None:
|
||||||
|
report["error"] = widget.error
|
||||||
|
try:
|
||||||
|
widget.makeCurrent()
|
||||||
|
widget.renderer.release()
|
||||||
|
widget.doneCurrent()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
report["release_error"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
report["released"] = True
|
||||||
|
widget.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
QTimer.singleShot(0, app.quit)
|
||||||
|
app.processEvents()
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
@@ -39,26 +170,41 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--windowed",
|
"--windowed",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="create a visible window instead of a short-lived one",
|
help="keep a visible window instead of a short-lived one",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--allow-software",
|
||||||
|
action="store_true",
|
||||||
|
help="treat a software rasteriser as a pass (for a development box only)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
report = {
|
_bootstrap_paths()
|
||||||
"status": "not_implemented",
|
report = collect(args.profile, keep_open_s=0.6 if args.windowed else 0.3)
|
||||||
"profile": args.profile,
|
context = report.get("context")
|
||||||
"stage": _STAGE,
|
if not context or not context.get("valid"):
|
||||||
"note": (
|
report["verdict"] = "no_context"
|
||||||
"The probe creates a real QOpenGLWidget and reports the context Qt "
|
code = EXIT_NO_CONTEXT
|
||||||
"obtained: vendor, renderer, GL/GLSL version, DPR, framebuffer size, "
|
elif context.get("software_renderer") and not args.allow_software:
|
||||||
"and whether the renderer is a software rasteriser. It never reports "
|
report["verdict"] = "software_renderer"
|
||||||
"a board as verified from an offscreen or virtual context."
|
code = EXIT_SOFTWARE
|
||||||
),
|
elif not report.get("renderer_initialized"):
|
||||||
}
|
report["verdict"] = "shader_failed"
|
||||||
|
code = EXIT_NO_CONTEXT
|
||||||
|
else:
|
||||||
|
report["verdict"] = "ok"
|
||||||
|
code = EXIT_OK
|
||||||
|
|
||||||
text = json.dumps(report, indent=2, ensure_ascii=False)
|
text = json.dumps(report, indent=2, ensure_ascii=False)
|
||||||
if args.output is not None:
|
if args.output is not None:
|
||||||
args.output.write_text(text + "\n", encoding="utf-8")
|
args.output.write_text(text + "\n", encoding="utf-8")
|
||||||
print(text)
|
print(text)
|
||||||
return 2
|
print(
|
||||||
|
f"\nverdict={report['verdict']} profile={args.profile} "
|
||||||
|
f"renderer={(context or {}).get('renderer', '?')}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user