阶段 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:
@@ -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
|
||||
full interaction (pan, zoom limits, presets, reset) is stage 4 in
|
||||
``docs/architecture/studio-3d-stages.md``.
|
||||
It changes ``view`` and ``projection`` and nothing else: the pixel→surface
|
||||
mapping, the calibration and the force axes are not reachable from here. The
|
||||
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
|
||||
``projection`` only: ``window_to_world`` for the pixel→surface mapping, the
|
||||
calibration and the force axes are untouched by anything a user does here. The
|
||||
default view comes from the asset's ``CameraPreset``, so it is versioned with the
|
||||
geometry rather than with a user's preference file.
|
||||
Conventions (design document §3.1): sensor frame, right-handed, metres; column
|
||||
vectors, so ``p_camera = view @ p_sensor`` and ``p_clip = projection @ p_camera``.
|
||||
The view matrix is built from a target, a distance and two angles, which is what
|
||||
makes orbit/pan/zoom trivial and keeps "reset" exact rather than approximate.
|
||||
"""
|
||||
|
||||
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 (
|
||||
CameraMatrices,
|
||||
CameraPreset,
|
||||
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:
|
||||
"""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:
|
||||
"""Frame the whole scene: set the target, distance, near and far planes."""
|
||||
raise NotImplementedError(f"{_STAGE}: fit the camera to the scene bounds")
|
||||
"""Frame the whole scene from the bounds, without changing the angles.
|
||||
|
||||
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:
|
||||
"""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:
|
||||
"""Slide the target in the view plane, scaled by distance."""
|
||||
raise NotImplementedError(f"{_STAGE}: pan")
|
||||
"""Slide the target in the view plane, scaled so it tracks the cursor."""
|
||||
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:
|
||||
"""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:
|
||||
"""Front / side / top / isometric / asset default."""
|
||||
raise NotImplementedError(f"{_STAGE}: apply a camera preset")
|
||||
def reset(self, bounds_sensor_m: F32) -> None:
|
||||
"""Back to the framed default view."""
|
||||
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:
|
||||
"""``p_camera = view @ p_sensor``, ``p_clip = projection @ p_camera``."""
|
||||
raise NotImplementedError(f"{_STAGE}: build the view and projection matrices")
|
||||
"""``view`` and ``projection`` for the given physical viewport size."""
|
||||
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`,
|
||||
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;
|
||||
* ``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.
|
||||
|
||||
The probe is what decides the profile on a board. A missing GL, a software
|
||||
renderer, or a failed shader compile means the three-dimensional view reports
|
||||
itself unavailable and the two-dimensional application carries on unchanged.
|
||||
Two parts, two programs, one draw call per part. The sensing surface is re-uploaded
|
||||
per frame and its colour comes from ``scalar`` through a LUT texture with the range
|
||||
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
|
||||
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
|
||||
in the neutral colour rather than in colour zero — the reference model is not a
|
||||
measurement and must not look like one.
|
||||
"""
|
||||
|
||||
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 (
|
||||
CameraMatrices,
|
||||
DisplaySettings,
|
||||
GLCapabilities,
|
||||
MeshPart,
|
||||
Profile,
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
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:
|
||||
@@ -63,28 +91,474 @@ def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||
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:
|
||||
"""Bind the GL entry points for the context Qt actually created."""
|
||||
|
||||
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:
|
||||
"""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
|
||||
re-uploaded per frame, the housing is uploaded once and then left alone.
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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(
|
||||
self,
|
||||
@@ -93,7 +567,240 @@ class GLRenderer:
|
||||
framebuffer: int,
|
||||
viewport: tuple[int, int],
|
||||
) -> 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:
|
||||
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
|
||||
``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
Qt owns the context and the framebuffer, so the widget must never bind target 0:
|
||||
it draws into the FBO Qt has already set up for the current ``paintGL`` call. The
|
||||
widget holds CPU-side data (the latest :class:`RenderFrame`) and schedules the
|
||||
upload; it does no composing, no asset IO and no SDK access, so a slow render can
|
||||
only drop *display* frames.
|
||||
Qt owns the context and the framebuffer, so the widget never binds target 0: it
|
||||
draws into ``defaultFramebufferObject()``, which is the FBO of the current
|
||||
``paintGL`` call. The widget holds CPU-side data (the latest ``RenderFrame``) and
|
||||
schedules the upload; it does no composing, no asset IO and no SDK access, so a
|
||||
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
|
||||
|
||||
import time
|
||||
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 orisys_app_shared.scene3d.contracts import (
|
||||
@@ -24,11 +30,19 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
diagnostic,
|
||||
)
|
||||
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):
|
||||
@@ -43,21 +57,36 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
renderer: GLRenderer,
|
||||
camera: OrbitCamera,
|
||||
parent: Optional[QWidget] = None,
|
||||
*,
|
||||
dispatch: Optional[GLDispatch] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._renderer = renderer
|
||||
self._camera = camera
|
||||
self._dispatch = dispatch or GLDispatch()
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._frame: Optional[RenderFrame] = None
|
||||
self._settings = DisplaySettings()
|
||||
self._capabilities: Optional[GLCapabilities] = None
|
||||
self._diagnostic: Optional[Diagnostic] = None
|
||||
self._needs_scene_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:
|
||||
"""Retain the asset and schedule its upload for the next current context."""
|
||||
self._assets = assets
|
||||
self._frame = None
|
||||
self._needs_scene_upload = True
|
||||
self._unavailable = False
|
||||
self.update()
|
||||
|
||||
def present(self, frame: RenderFrame) -> None:
|
||||
@@ -70,15 +99,217 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
self._settings = settings
|
||||
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
|
||||
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
|
||||
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
|
||||
raise NotImplementedError(f"{_STAGE}: calibrate DPR and update the projection")
|
||||
def resizeGL(self, w: int, h: int) -> None: # pragma: no cover - needs a context
|
||||
"""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
|
||||
raise NotImplementedError(f"{_STAGE}: makeCurrent, release, doneCurrent")
|
||||
def release_resources(self) -> None: # pragma: no cover - needs a context
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user