三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - What: 按 docs/architecture/studio-3d-design.md 与 studio-3d-contracts.pyi 搭建骨架(阶段 0)。shared/src/orisys_app_shared/scene3d/:contracts.py 把 .pyi 的规格变成可执行代码——全部 DTO/枚举(FrameIdentity/FieldDescriptor/SurfaceFieldFrame/MeshPart/SurfaceBinding/CameraPreset/ExpectedGeometry/SceneAssets/ColorScale/DisplaySettings/VertexFields/RenderFrame/CameraMatrices/GLCapabilities/RenderStats/CompileOptions/CompileReport)、标准诊断码表(DIAGNOSTIC_SEVERITY,含 stale_frame=info、field_unavailable=warning 等)、ThreeDError、freeze()(拥有数据 + C 连续 + 只读)、五个校验器(validate_surface_frame/validate_binding/validate_mesh_part/validate_assets/validate_display_settings/validate_render_frame)、LatestFrameMailbox(容量 1、替换计数、锁内只换引用)、UNAVAILABLE_DESCRIPTOR/rest_descriptor() 与 summarize_parts(),以及全部 Protocol;该模块不 import Qt/OpenGL/SDK。assets.py/sampling.py/composer.py 为签名骨架(未实现处抛 NotImplementedError 并写明归属阶段)。apps/studio/src/orisys_studio/scene3d/:camera/adapters/coordinator/gl_backend/widget/panel 签名骨架,其中 configure_surface_format() 已实现(QApplication 之前调用,desktop33=GL3.3 Core / gles30=GLES3.0,深度 24、无 MSAA);模块级一律不 import GL 绑定,PyOpenGL 只在 GLDispatch.bind_current_context 里加载。tools/scene_assets/{probe_gl,validate_scene,compile_scene}.py 提供入口与参数,未实现时输出 not_implemented 并返回 2(避免被 CI 当成通过)。docs/architecture/studio-3d-stages.md:阶段 0-5 的产出、每阶段的验收矩阵、并行度与回归门。测试:shared/tests/test_scene3d_contracts.py 26 项(freeze 拥有数据、可写/非连续数组被拒、scalar 与 valid 形状一致、位移缺 quality 被拒、非有限 scalar 标为 valid 只报警不静默、绑定越界/-1 约定/权重和为 1、三角形索引越界、感知法线单位长度、恰好一个 sensing part、binding 必须属于 sensing part、设备几何/域/尺寸不符被拒、色标 lower<upper、邮箱替换计数与多线程不阻塞生产者、身份区分 source/generation、骨架可导入且不引入 PyOpenGL);apps/studio/tests/test_scene3d_skeleton.py 4 项(骨架不 import OpenGL、surface format 两个 profile、widget 构造不需要 context、各 stub 报出归属阶段)。 - Why: 用户要求"先从骨架搭起,然后计划功能实现顺序,最后逐阶段实现与验收"。骨架阶段的价值在于把接口变成可执行的(.pyi 本身无法被测试),并把"数组只读、单位与质量必须声明、近似位移不得当毫米、参考帧不得冒充测量、显示丢帧不得计入记录丢帧"这些容易在实现时被绕过的规则先固定下来;同时明确不 import Qt/OpenGL 的边界,使采集线程与 CI 能在无 GPU 环境导入它。 - Verification: shared 全量 187 passed(原 161 + 26);studio 95 passed(原 91 + 4);ui_check 554 项检查 0 失败;compileall 通过;probe_gl --profile desktop33 在未实现时返回 2 并说明将产出什么报告。 - Not in this change: 三维功能本身(阶段 1 起);app_window/main.py 未接线,骨架对现有应用完全惰性(无任何模块被应用导入)。
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Studio-side three-dimensional display: Qt, GL and the frame schedule.
|
||||
|
||||
Import rule for this package: **nothing may import a GL binding at module level.**
|
||||
The binding's platform (GLX/EGL) has to match the Qt platform that is actually in
|
||||
use, and PyOpenGL decides that at its first import — so the import happens inside
|
||||
:func:`~orisys_studio.scene3d.gl_backend.GLDispatch.bind_current_context`, once a
|
||||
real context exists (design document §11.1).
|
||||
|
||||
Nothing in the application imports this package yet: the skeleton is inert until
|
||||
stage 4 wires the panel into ``app_window``.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Frame adapters: ``SdkPacket`` (+ runtime) to a publishable ``SurfaceFieldFrame``.
|
||||
|
||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
The constraints here are about *where* the data may be read, not about optics:
|
||||
|
||||
* the adapter runs on the acquisition thread and only while that thread owns the
|
||||
runtime, because the SDK's arrays are that thread's working memory;
|
||||
* it copies what the frame needs and publishes it read-only — the GUI thread must
|
||||
never reach back into a live runtime;
|
||||
* it copies **nothing** when the three-dimensional view is not subscribed to, and
|
||||
returns ``None`` when the frame carries no usable field. A frame is never
|
||||
assembled from two different acquisitions, and a missing field is never filled
|
||||
in from the previous one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ExpectedGeometry,
|
||||
FrameIdentity,
|
||||
SurfaceFieldFrame,
|
||||
)
|
||||
|
||||
_STAGE = "stage 3 (live data)"
|
||||
|
||||
|
||||
class FingerFrameAdapter:
|
||||
"""The finger runtime already computes a UV field; copy it, with its identity."""
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
raise NotImplementedError(f"{_STAGE}: snapshot the finger UV field")
|
||||
|
||||
|
||||
class BoxFrameAdapter:
|
||||
"""Box/mini: a scalar field only.
|
||||
|
||||
There is no verified spatial scale for these sensors yet, so no displacement is
|
||||
invented: a millimetre height field that was never calibrated is worse than no
|
||||
height field at all.
|
||||
"""
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
raise NotImplementedError(f"{_STAGE}: snapshot the box scalar field")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""The observation camera: orbit, pan, zoom, presets. Pure maths.
|
||||
|
||||
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 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraMatrices,
|
||||
CameraPreset,
|
||||
F32,
|
||||
)
|
||||
|
||||
_STAGE = "stage 1 (fit/orbit) and stage 4 (full interaction)"
|
||||
|
||||
|
||||
class OrbitCamera:
|
||||
"""A target, a distance and two angles. Everything else is derived."""
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
def zoom(self, wheel_steps: float) -> None:
|
||||
"""Exponential zoom, clamped so the surface cannot be entered or lost."""
|
||||
raise NotImplementedError(f"{_STAGE}: zoom")
|
||||
|
||||
def set_preset(self, preset: CameraPreset) -> None:
|
||||
"""Front / side / top / isometric / asset default."""
|
||||
raise NotImplementedError(f"{_STAGE}: apply a camera preset")
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Frame schedule and state machine for the three-dimensional view.
|
||||
|
||||
Stage 3 (mailboxes, compose worker) and stage 4 (Qt timer, visibility, settings) in
|
||||
``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
The whole point of this class is that **the acquisition loop never waits for the
|
||||
display**. Producers call :meth:`submit`, which validates cheaply and drops the
|
||||
previous un-consumed frame; a GUI timer calls :meth:`tick`, which picks up the
|
||||
newest composed frame and repaints. Display drops are counted here and never in
|
||||
the recording channel, and no CPU task is submitted while one is still running.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Executor
|
||||
from typing import Optional
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
DisplaySettings,
|
||||
LatestFrameMailbox,
|
||||
OfferResult,
|
||||
RenderFrame,
|
||||
SceneAssets,
|
||||
SceneState,
|
||||
SurfaceFieldFrame,
|
||||
)
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
_STAGE = "stage 3 (schedule) and stage 4 (Qt integration)"
|
||||
|
||||
|
||||
class ThreeDCoordinator:
|
||||
"""Owns the two mailboxes, the compose worker and the scene generation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: Sensor3DWidget,
|
||||
composer: object,
|
||||
executor: Executor,
|
||||
max_fps: int = 30,
|
||||
) -> None:
|
||||
self._widget = widget
|
||||
self._composer = composer
|
||||
self._executor = executor
|
||||
self._max_fps = int(max_fps)
|
||||
self._incoming: LatestFrameMailbox[SurfaceFieldFrame] = LatestFrameMailbox()
|
||||
self._composed: LatestFrameMailbox[RenderFrame] = LatestFrameMailbox()
|
||||
self._state: SceneState = "disabled"
|
||||
self._generation = 0
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._visible = False
|
||||
|
||||
def activate(self, assets: SceneAssets, source_id: str) -> int:
|
||||
"""Adopt a scene and a data source; returns the new generation."""
|
||||
raise NotImplementedError(f"{_STAGE}: activate a scene")
|
||||
|
||||
def submit(self, frame: SurfaceFieldFrame) -> OfferResult:
|
||||
"""Offer a field frame from a producer thread. Never blocks on the GUI."""
|
||||
raise NotImplementedError(f"{_STAGE}: accept or reject an incoming frame")
|
||||
|
||||
def tick(self) -> None:
|
||||
"""GUI timer: take the newest composed frame, present it, schedule work."""
|
||||
raise NotImplementedError(f"{_STAGE}: pump the mailboxes")
|
||||
|
||||
def set_visible(self, visible: bool) -> None:
|
||||
"""Hiding suspends composing, uploading and rendering — not acquisition."""
|
||||
raise NotImplementedError(f"{_STAGE}: suspend or resume the view")
|
||||
|
||||
def set_settings(self, settings: DisplaySettings) -> None:
|
||||
raise NotImplementedError(f"{_STAGE}: apply display settings")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Cancel work, stop the timer, clear both mailboxes. Idempotent."""
|
||||
raise NotImplementedError(f"{_STAGE}: shut the view down")
|
||||
|
||||
@property
|
||||
def state(self) -> SceneState:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return self._generation
|
||||
|
||||
@property
|
||||
def superseded_frames(self) -> int:
|
||||
"""Frames the display never showed. Not a recording drop."""
|
||||
return (
|
||||
self._incoming.superseded_frames + self._composed.superseded_frames
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""OpenGL: surface format, capability probing, shaders and GPU resources.
|
||||
|
||||
Stage 1 (platform PoC) in ``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
Import discipline, and why it is not negotiable:
|
||||
|
||||
* :func:`configure_surface_format` runs **before** ``QApplication`` exists and
|
||||
only touches ``QSurfaceFormat``;
|
||||
* 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
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraMatrices,
|
||||
DisplaySettings,
|
||||
GLCapabilities,
|
||||
Profile,
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
)
|
||||
|
||||
_STAGE = "stage 1 (platform PoC)"
|
||||
|
||||
|
||||
def configure_surface_format(profile: Profile = "desktop33") -> None:
|
||||
"""Select the default GL surface format. Call before ``QApplication``.
|
||||
|
||||
One profile per process: the two profiles are two different start-up
|
||||
configurations, not something to switch at runtime (design document §11.1).
|
||||
|
||||
Multisampling stays off in v1: it multiplies the cost of the one thing this
|
||||
component must not slow down (the acquisition loop feeds the scene through a
|
||||
capacity-one mailbox), and the design document defers anti-aliasing work.
|
||||
"""
|
||||
from PyQt5.QtGui import QSurfaceFormat
|
||||
|
||||
fmt = QSurfaceFormat()
|
||||
if profile == "gles30":
|
||||
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
|
||||
fmt.setVersion(3, 0)
|
||||
elif profile == "desktop33":
|
||||
fmt.setRenderableType(QSurfaceFormat.OpenGL)
|
||||
fmt.setVersion(3, 3)
|
||||
fmt.setProfile(QSurfaceFormat.CoreProfile)
|
||||
else: # pragma: no cover - a typo must not reach a board silently
|
||||
raise ValueError(f"unknown GL profile: {profile!r}")
|
||||
fmt.setDepthBufferSize(24)
|
||||
fmt.setStencilBufferSize(8)
|
||||
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
|
||||
fmt.setSamples(0)
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class GLRenderer:
|
||||
"""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 initialize(self, capabilities: GLCapabilities) -> None:
|
||||
raise NotImplementedError(f"{_STAGE}: compile the shaders for this profile")
|
||||
|
||||
def upload_scene(self, assets: SceneAssets) -> None:
|
||||
raise NotImplementedError(f"{_STAGE}: upload static buffers and the LUT")
|
||||
|
||||
def update_surface(self, frame: RenderFrame) -> None:
|
||||
raise NotImplementedError(f"{_STAGE}: update the dynamic surface buffers")
|
||||
|
||||
def draw(
|
||||
self,
|
||||
camera: CameraMatrices,
|
||||
settings: DisplaySettings,
|
||||
framebuffer: int,
|
||||
viewport: tuple[int, int],
|
||||
) -> RenderStats:
|
||||
raise NotImplementedError(f"{_STAGE}: draw one frame")
|
||||
|
||||
def release(self) -> None:
|
||||
raise NotImplementedError(f"{_STAGE}: release GPU resources")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""``ThreeDPanel``: the controls for the three-dimensional view.
|
||||
|
||||
Stage 4 in ``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
UI only. It reads and writes :class:`DisplaySettings`, camera presets and
|
||||
visibility, and it shows what the scene says about itself — quantity, unit,
|
||||
quality, frame number, "no live data". It does not open assets, read the SDK, or
|
||||
call GL, and it never presents an ``approximate`` displacement as millimetres.
|
||||
|
||||
The measurement colour bar is hidden (not left showing the previous range)
|
||||
whenever the frame's descriptor is ``unavailable`` — the reference model is not a
|
||||
measurement and must not look like one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
RenderStats,
|
||||
SceneState,
|
||||
)
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
_STAGE = "stage 4 (product integration)"
|
||||
|
||||
|
||||
class ThreeDPanel(QWidget):
|
||||
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ThreeDCoordinator,
|
||||
widget: Sensor3DWidget,
|
||||
parent: Optional[QWidget] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._coordinator = coordinator
|
||||
self._widget = widget
|
||||
|
||||
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
|
||||
"""Show one diagnostic line; the 2D views stay usable behind it."""
|
||||
raise NotImplementedError(f"{_STAGE}: show a diagnostic")
|
||||
|
||||
def set_state(self, state: SceneState) -> None:
|
||||
raise NotImplementedError(f"{_STAGE}: reflect the scene state")
|
||||
|
||||
def set_render_stats(self, stats: RenderStats) -> None:
|
||||
"""Display-side statistics only: never shown as recording health."""
|
||||
raise NotImplementedError(f"{_STAGE}: show render statistics")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""``Sensor3DWidget``: the Qt widget that owns the GL context and nothing else.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
GLCapabilities,
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
)
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||
|
||||
_STAGE = "stage 1 (static scene) and stage 4 (product integration)"
|
||||
|
||||
|
||||
class Sensor3DWidget(QOpenGLWidget):
|
||||
"""Renders one scene. Signals carry state, never per-frame arrays."""
|
||||
|
||||
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
|
||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
||||
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
renderer: GLRenderer,
|
||||
camera: OrbitCamera,
|
||||
parent: Optional[QWidget] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._renderer = renderer
|
||||
self._camera = camera
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._frame: Optional[RenderFrame] = None
|
||||
self._settings = DisplaySettings()
|
||||
self._capabilities: Optional[GLCapabilities] = None
|
||||
self._needs_scene_upload = False
|
||||
self._needs_surface_upload = False
|
||||
|
||||
def set_scene(self, assets: SceneAssets) -> None:
|
||||
"""Retain the asset and schedule its upload for the next current context."""
|
||||
self._assets = assets
|
||||
self._needs_scene_upload = True
|
||||
self.update()
|
||||
|
||||
def present(self, frame: RenderFrame) -> None:
|
||||
"""Keep the newest CPU frame and ask for a repaint. Never requires a context."""
|
||||
self._frame = frame
|
||||
self._needs_surface_upload = True
|
||||
self.update()
|
||||
|
||||
def set_settings(self, settings: DisplaySettings) -> None:
|
||||
self._settings = settings
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------ Qt overrides
|
||||
def initializeGL(self) -> None: # pragma: no cover - needs a real context
|
||||
raise NotImplementedError(f"{_STAGE}: bind the backend and connect teardown")
|
||||
|
||||
def paintGL(self) -> None: # pragma: no cover - needs a real context
|
||||
raise NotImplementedError(f"{_STAGE}: upload what changed and draw")
|
||||
|
||||
def resizeGL(self, w: int, h: int) -> None: # pragma: no cover
|
||||
raise NotImplementedError(f"{_STAGE}: calibrate DPR and update the projection")
|
||||
|
||||
def release_resources(self) -> None: # pragma: no cover - needs a real context
|
||||
raise NotImplementedError(f"{_STAGE}: makeCurrent, release, doneCurrent")
|
||||
@@ -0,0 +1,141 @@
|
||||
"""The studio-side three-dimensional skeleton: importable, inert, Qt-safe.
|
||||
|
||||
Acceptance for stage 0 (``docs/architecture/studio-3d-stages.md``): the module
|
||||
tree exists, it can be imported without PyOpenGL, and the one piece that must run
|
||||
before ``QApplication`` — the surface format — selects a complete profile.
|
||||
|
||||
Nothing here renders. These tests are the floor a later stage must keep: the GL
|
||||
binding is imported only once a real context exists, and the stubs say which stage
|
||||
fills them in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_surface_format():
|
||||
"""The default surface format is process-wide; put it back afterwards."""
|
||||
from PyQt5.QtGui import QSurfaceFormat
|
||||
|
||||
previous = QSurfaceFormat.defaultFormat()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
QSurfaceFormat.setDefaultFormat(previous)
|
||||
|
||||
|
||||
def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
|
||||
"""PyOpenGL must be imported for the platform Qt chose, not at import time.
|
||||
|
||||
The binding decides GLX versus EGL when it first loads, so importing it while
|
||||
merely constructing the module tree would freeze a guess. The test fails if a
|
||||
future change adds a module-level ``import OpenGL``.
|
||||
"""
|
||||
for name in [name for name in sys.modules if name == "OpenGL" or name.startswith("OpenGL.")]:
|
||||
del sys.modules[name]
|
||||
|
||||
import orisys_studio.scene3d.camera # noqa: F401
|
||||
import orisys_studio.scene3d.coordinator # noqa: F401
|
||||
import orisys_studio.scene3d.gl_backend # noqa: F401
|
||||
import orisys_studio.scene3d.panel # noqa: F401
|
||||
import orisys_studio.scene3d.widget # noqa: F401
|
||||
|
||||
loaded = [name for name in sys.modules if name == "OpenGL" or name.startswith("OpenGL.")]
|
||||
assert loaded == [], f"importing the skeleton pulled in {loaded}"
|
||||
|
||||
|
||||
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
|
||||
from PyQt5.QtGui import QSurfaceFormat
|
||||
|
||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
desktop = QSurfaceFormat.defaultFormat()
|
||||
assert desktop.renderableType() == QSurfaceFormat.OpenGL
|
||||
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
||||
assert desktop.profile() == QSurfaceFormat.CoreProfile
|
||||
assert desktop.depthBufferSize() == 24
|
||||
assert desktop.samples() == 0, "v1 does not pay for multisampling"
|
||||
|
||||
configure_surface_format("gles30")
|
||||
embedded = QSurfaceFormat.defaultFormat()
|
||||
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
|
||||
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
configure_surface_format("vulkan") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
|
||||
"""Constructing the widget must not need a current context or a GPU."""
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings, FrameIdentity
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
widget = Sensor3DWidget(GLRenderer(), OrbitCamera())
|
||||
try:
|
||||
assert isinstance(widget, Sensor3DWidget)
|
||||
widget.set_settings(DisplaySettings(display_gain=3.0))
|
||||
assert widget._settings.display_gain == 3.0
|
||||
# Nothing has been presented yet, and no context was needed to get here.
|
||||
assert widget._frame is None
|
||||
finally:
|
||||
widget.deleteLater()
|
||||
|
||||
|
||||
def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraPreset,
|
||||
DisplaySettings,
|
||||
FrameIdentity,
|
||||
GLCapabilities,
|
||||
RenderFrame,
|
||||
SceneAssets,
|
||||
)
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
camera = OrbitCamera()
|
||||
renderer = GLRenderer()
|
||||
widget = Sensor3DWidget(renderer, camera)
|
||||
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
||||
try:
|
||||
assert coordinator.state == "disabled"
|
||||
assert coordinator.generation == 0
|
||||
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
|
||||
|
||||
with pytest.raises(NotImplementedError) as camera_error:
|
||||
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))
|
||||
assert "stage 1" in str(camera_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as gl_error:
|
||||
GLDispatch().bind_current_context(object())
|
||||
assert "stage 1" in str(gl_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as activate_error:
|
||||
coordinator.activate(SceneAssets, "live-A") # type: ignore[arg-type]
|
||||
assert "stage 3" in str(activate_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as settings_error:
|
||||
coordinator.set_settings(DisplaySettings())
|
||||
assert "stage 3" in str(settings_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as initialize_error:
|
||||
renderer.initialize(GLCapabilities("desktop33", "", "", "", "", "", False, 0))
|
||||
assert "stage 1" in str(initialize_error.value)
|
||||
|
||||
with pytest.raises(NotImplementedError) as update_error:
|
||||
renderer.update_surface(None) # type: ignore[arg-type]
|
||||
assert "stage 1" in str(update_error.value)
|
||||
|
||||
assert FrameIdentity("live-A", 1, 0, 0.0, 1).generation == 1
|
||||
assert RenderFrame is not None and SceneAssets is not None
|
||||
finally:
|
||||
widget.deleteLater()
|
||||
@@ -0,0 +1,135 @@
|
||||
# Studio 三维显示:实施阶段与验收
|
||||
|
||||
配套文档:`studio-3d-design.md`(规格)、`studio-3d-contracts.pyi`(接口骨架)。
|
||||
本文只回答一件事:**按什么顺序做,每一阶段做完拿什么验收**。
|
||||
|
||||
阶段 0 已经完成(骨架,见下);阶段 1 起逐段实现。每个阶段结束时必须同时交出:
|
||||
实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告(规格 §13)。
|
||||
|
||||
原则(写下来是因为后面每阶段都会被诱惑违反一次):
|
||||
|
||||
- **骨架已经能跑测试,就不要为了快而绕过合同**:`scene3d.contracts` 的校验是唯一入口,
|
||||
适配器/合成器/加载器都不许自己再定义一遍形状和单位。
|
||||
- **二维永远可用**:三维持有任何问题(资产、GL、性能)都只降级三维视图,不阻塞采集、记录、力曲线。
|
||||
- **不在没有证据时造物理量**:近似位移不写成毫米,未标定的空间尺度不造高度场,
|
||||
壳体的顶点不参与测量统计。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0:骨架(已完成)
|
||||
|
||||
产出:
|
||||
|
||||
- `shared/src/orisys_app_shared/scene3d/`:`contracts.py`(全部 DTO、枚举、诊断码、
|
||||
`freeze`、五个校验器、`LatestFrameMailbox`、协议)+ `assets.py`/`sampling.py`/`composer.py` 签名骨架。
|
||||
- `apps/studio/src/orisys_studio/scene3d/`:`camera.py`/`adapters.py`/`coordinator.py`/
|
||||
`gl_backend.py`/`widget.py`/`panel.py` 签名骨架;`configure_surface_format()` 已实现。
|
||||
- `tools/scene_assets/`:`probe_gl.py`/`validate_scene.py`/`compile_scene.py` 入口(未实现时返回 2)。
|
||||
- 测试:`shared/tests/test_scene3d_contracts.py`(26 项)、
|
||||
`apps/studio/tests/test_scene3d_skeleton.py`(4 项)。
|
||||
|
||||
验收(已通过):合同校验的输入/失败样例全部覆盖;`LatestFrameMailbox` 在多线程下不阻塞生产者;
|
||||
骨架导入不引入 PyOpenGL;`configure_surface_format` 两个 profile 的版本/类型/深度缓冲可断言;
|
||||
所有未实现入口都抛 `NotImplementedError` 并写明归属阶段。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1:平台 PoC(图形栈先落地)
|
||||
|
||||
**做**:`probe_gl`(真实 Qt 窗口 + 真实 context)、`GLDispatch`(探测 + 绑定 PyOpenGL)、
|
||||
`GLRenderer` 最小集(一个 shader、一个 VBO/EBO、静态三角形)、静态感知面 + 外壳两个 part 各一次 draw、
|
||||
`OrbitCamera` 的 `fit`/`orbit`、`Sensor3DWidget` 的 `initializeGL`/`paintGL`/`resizeGL`/`release_resources`
|
||||
与 `aboutToBeDestroyed` 清理。
|
||||
|
||||
**验收**:
|
||||
|
||||
| 用例 | 通过标准 |
|
||||
|---|---|
|
||||
| 能力报告 | 开发机 Windows 出 `desktop33` 报告:vendor/renderer/GL/GLSL/DPR/framebuffer,且明确标注是否软件渲染 |
|
||||
| 静态场景 | 三角形与感知面/外壳都可见;外壳可隐藏;感知面与外壳不共用缓冲 |
|
||||
| 生命周期 | 反复 resize(含 DPR 变化)、隐藏/显示、关闭、context 重建各 20 次无崩溃、无泄漏 |
|
||||
| 失败样例 | 强制 shader 编译失败 → 三维 Unavailable + 诊断,二维照常;`gl_unsupported` 路径同样 |
|
||||
| 记录隔离 | 开/关三维各录 30 s,CSV 行数与有效样本完全一致 |
|
||||
|
||||
**明确不做**:ARM 板卡适配(按用户要求暂缓),但 probe 脚本要能直接在板上跑出报告。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2:资产合同(几何与坐标)
|
||||
|
||||
**做**:`LegacyFingerImporter`(旧 NPZ:`mesh.npz`/`unroll_map_uv_s0.4.npz`/`camera_intrinsics.npz`)、
|
||||
坐标与单位统一(`meters_per_source_unit` → 刚体 `transform_sensor_from_source`)、
|
||||
`SurfaceBinding` 的生成与自检、`validate_scene.py`、一份简化外壳 GLB 的编译路径、manifest/schema。
|
||||
|
||||
**验收**:
|
||||
|
||||
| 用例 | 通过标准 |
|
||||
|---|---|
|
||||
| 拓扑不混用 | 1032 与 1005 两套网格分别加载;错配(binding 顶点数 ≠ 感知面顶点数)被拒 |
|
||||
| UV 域 | 300×280 与 500×500 两个域的 `shape_hw`/scale/flip_v 各不相同,错配被拒 |
|
||||
| 采样表 | 每顶点权重和为 1;未用项 `-1`/0;越界下标被拒;接缝不跨域插值 |
|
||||
| 单位 | 三个已知非共线点在表面与外壳中对齐;米/毫米换算与法线方向正确 |
|
||||
| 身份 | 改 manifest 任一哈希、改 binding、改 geometry_id 均被拒并给出 `geometry_mismatch`/`field_domain_mismatch` |
|
||||
|
||||
**产出样例**:一张 `finger` 场景包 + 一份 `validate_scene.py --json` 报告。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 3:实时数据(字段 → 渲染帧)
|
||||
|
||||
**做**:`FingerFrameAdapter`/`BoxFrameAdapter`、`SurfaceSampler`、`SceneComposer`
|
||||
(`compose`/`rest_frame`)、`ThreeDCoordinator` 的双 mailbox 与 generation/过期拒绝、`submit` 的
|
||||
`accepted/replaced/stale/disabled/invalid` 语义。
|
||||
|
||||
**验收**:
|
||||
|
||||
| 用例 | 通过标准 |
|
||||
|---|---|
|
||||
| 采样正确性 | 常量场/斜坡/单点脉冲/NaN/遮罩场与 CPU 参考实现逐位一致 |
|
||||
| 无效语义 | 任一正权重点无效 ⇒ 该顶点无效;非有限 scalar 不显示为 0;参考帧 `scalar` 全不可用 |
|
||||
| 色标语义 | `display_gain` 只改显示位置:增大倍率前后 `scalar` 与力值逐位不变 |
|
||||
| 过期帧 | 旧 `generation`/`request_id` 永远不能覆盖新场景;`stale_frame` 安静丢弃并计数 |
|
||||
| 不阻塞采集 | 合成人为放慢 10 倍,`submit` 调用耗时 p99 < 1 ms,记录行数不变 |
|
||||
| 缺数据 | 无字段 → `no_data`,清掉旧测量颜色,允许显示参考模型 |
|
||||
|
||||
---
|
||||
|
||||
## 阶段 4:Qt 产品接入(界面与生命周期)
|
||||
|
||||
**做**:`Sensor3DWidget` 完整、`ThreeDPanel`(开关/预设/复位/外壳/倍率/色标/状态)、
|
||||
`OrbitCamera` 全部交互、preferences 持久化、`app_window` 装配(视图切换、`closeEvent`)、
|
||||
`main.py` 在 `QApplication` 之前的 profile 选择。
|
||||
|
||||
**验收**:
|
||||
|
||||
| 用例 | 通过标准 |
|
||||
|---|---|
|
||||
| 交互 | 旋转/平移/缩放/复位/预设;观察相机变化不改算法相机、标定与力轴(矩阵断言) |
|
||||
| 单位与质量显示 | `approximate` 位移显示为"相对形变示意 ×N",不出现在毫米文案里 |
|
||||
| 视图切换 | 三维开关不改变采集、记录与力曲线状态;切换 20 次无泄漏 |
|
||||
| 关停 | `closeEvent` → `release_resources` 幂等;重复关闭不抛异常 |
|
||||
| 界面文案 | 新增 key 中英齐全(沿用 `tools`/locale 校验方式) |
|
||||
|
||||
---
|
||||
|
||||
## 阶段 5:发布保护与性能(收尾)
|
||||
|
||||
**做**:精简/签名包(`compile_scene.py`)、可选 AES-GCM + `KeyProvider`、平台锁定文件、
|
||||
性能与长时测试、`session.json` 的 `visualization3d` 能力块(旧文件缺块视为不支持)。
|
||||
|
||||
**验收**:规格 §12 的"包保护""性能""长时"三行 + 两平台(Windows 开发机 / 目标板)
|
||||
各一份 probe 报告;`p50/p95` 合成、上传、绘制、总响应延迟写进报告;2 小时采集+记录+三维后
|
||||
RSS/GPU 内存不持续增长,任务队列有上界。
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系(并行度)
|
||||
|
||||
```
|
||||
阶段 1 (图形栈) ─┬─> 阶段 4 (界面) ─┐
|
||||
│ ├─> 阶段 5
|
||||
阶段 2 (资产) ──┴─> 阶段 3 (数据) ─┘
|
||||
```
|
||||
|
||||
阶段 1 与阶段 2 无依赖,可并行;阶段 3 需要阶段 2 的绑定;阶段 4 需要 1 和 3。
|
||||
每阶段开始前先确认上一阶段的失败样例仍然被覆盖(回归门)。
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Three-dimensional display: contracts, assets, sampling, composition.
|
||||
|
||||
Import-safe by design: nothing in this package imports Qt, OpenGL or the SDK, so
|
||||
it can be imported by the acquisition thread, by a build script, and by CI on a
|
||||
machine with no GPU (``docs/architecture/studio-3d-design.md`` §6).
|
||||
|
||||
The executable contracts live in :mod:`~orisys_app_shared.scene3d.contracts`; the
|
||||
other modules are the skeleton of stage 2 and 3 (see
|
||||
``docs/architecture/studio-3d-stages.md``) and raise ``NotImplementedError`` until
|
||||
their stage lands.
|
||||
"""
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
DIAGNOSTIC_SEVERITY,
|
||||
LENGTH_UNIT_M,
|
||||
SENSOR_FRAME,
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
CameraMatrices,
|
||||
CameraPreset,
|
||||
ColorScale,
|
||||
CompileOptions,
|
||||
CompileReport,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
ExpectedGeometry,
|
||||
F32,
|
||||
F64,
|
||||
FieldDescriptor,
|
||||
FrameIdentity,
|
||||
GLCapabilities,
|
||||
I32,
|
||||
LatestFrameMailbox,
|
||||
Mask,
|
||||
Material,
|
||||
MeshPart,
|
||||
OfferResult,
|
||||
Quality,
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
SceneState,
|
||||
SurfaceBinding,
|
||||
SurfaceFieldFrame,
|
||||
ThreeDError,
|
||||
U32,
|
||||
VertexFields,
|
||||
diagnostic,
|
||||
error,
|
||||
freeze,
|
||||
rest_descriptor,
|
||||
summarize_parts,
|
||||
validate_assets,
|
||||
validate_binding,
|
||||
validate_display_settings,
|
||||
validate_mesh_part,
|
||||
validate_render_frame,
|
||||
validate_surface_frame,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIAGNOSTIC_SEVERITY",
|
||||
"LENGTH_UNIT_M",
|
||||
"SENSOR_FRAME",
|
||||
"UNAVAILABLE_DESCRIPTOR",
|
||||
"CameraMatrices",
|
||||
"CameraPreset",
|
||||
"ColorScale",
|
||||
"CompileOptions",
|
||||
"CompileReport",
|
||||
"Diagnostic",
|
||||
"DisplaySettings",
|
||||
"ExpectedGeometry",
|
||||
"F32",
|
||||
"F64",
|
||||
"FieldDescriptor",
|
||||
"FrameIdentity",
|
||||
"GLCapabilities",
|
||||
"I32",
|
||||
"LatestFrameMailbox",
|
||||
"Mask",
|
||||
"Material",
|
||||
"MeshPart",
|
||||
"OfferResult",
|
||||
"Quality",
|
||||
"RenderFrame",
|
||||
"RenderStats",
|
||||
"SceneAssets",
|
||||
"SceneState",
|
||||
"SurfaceBinding",
|
||||
"SurfaceFieldFrame",
|
||||
"ThreeDError",
|
||||
"U32",
|
||||
"VertexFields",
|
||||
"diagnostic",
|
||||
"error",
|
||||
"freeze",
|
||||
"rest_descriptor",
|
||||
"summarize_parts",
|
||||
"validate_assets",
|
||||
"validate_binding",
|
||||
"validate_display_settings",
|
||||
"validate_mesh_part",
|
||||
"validate_render_frame",
|
||||
"validate_surface_frame",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Assets: loading a validated scene, importing the legacy finger export.
|
||||
|
||||
Stage 2 in ``docs/architecture/studio-3d-stages.md``. The signatures are fixed
|
||||
here so the rest of the skeleton (and the compiler tool) can be written against
|
||||
them; the bodies arrive with the stage.
|
||||
|
||||
Two rules the implementation must keep, because violating them is invisible until
|
||||
a customer sees the wrong part move:
|
||||
|
||||
* the algorithm mesh and the display mesh are **different topologies** (1032 vs
|
||||
1005 vertices) and are never joined by array index;
|
||||
* the housing is a separate part with its own buffers, so it can be hidden,
|
||||
simplified and re-exported without touching the sensing geometry or its
|
||||
calibration hashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CompileOptions,
|
||||
CompileReport,
|
||||
Diagnostic,
|
||||
ExpectedGeometry,
|
||||
F64,
|
||||
KeyProvider,
|
||||
SceneAssets,
|
||||
)
|
||||
|
||||
_STAGE = "stage 2 (asset contracts)"
|
||||
|
||||
|
||||
class PackagedAssetLoader:
|
||||
"""Load and authenticate a ``.oryscene`` runtime package.
|
||||
|
||||
CPU/IO thread. Verification order: envelope signature, then entry hashes, then
|
||||
geometry/domain/unit checks against ``expected``. There is no plaintext
|
||||
fallback: a package that fails authentication is refused, never retried.
|
||||
"""
|
||||
|
||||
def load(
|
||||
self,
|
||||
path: Path,
|
||||
expected: ExpectedGeometry,
|
||||
keys: Optional[KeyProvider] = None,
|
||||
) -> SceneAssets:
|
||||
raise NotImplementedError(f"{_STAGE}: load a signed .oryscene package")
|
||||
|
||||
def validate(
|
||||
self, assets: SceneAssets, expected: ExpectedGeometry
|
||||
) -> tuple[Diagnostic, ...]:
|
||||
raise NotImplementedError(f"{_STAGE}: validate a loaded scene")
|
||||
|
||||
|
||||
class LegacyFingerImporter:
|
||||
"""Convert ``sensors/finger/assets/30fps-0703`` into a scene (offline only).
|
||||
|
||||
``meters_per_source_unit`` scales source lengths into metres first;
|
||||
``transform_sensor_from_source`` is then rigid (rotation and a translation in
|
||||
metres, no extra scale). Normals receive the rotation only. Missing coordinate
|
||||
evidence is a refusal, not a guess from a filename.
|
||||
"""
|
||||
|
||||
def convert(
|
||||
self,
|
||||
export_dir: Path,
|
||||
map_path: Path,
|
||||
*,
|
||||
transform_sensor_from_source: F64,
|
||||
meters_per_source_unit: float,
|
||||
) -> SceneAssets:
|
||||
raise NotImplementedError(f"{_STAGE}: import the legacy NPZ export")
|
||||
|
||||
|
||||
class SceneCompiler:
|
||||
"""Package a scene (and an optional housing GLB) for release (offline).
|
||||
|
||||
Signing happens with a key from the build environment; the private key is
|
||||
never part of the client runtime. Output is written to a temporary file and
|
||||
atomically renamed, so a failed build cannot leave a half package behind.
|
||||
"""
|
||||
|
||||
def compile(
|
||||
self,
|
||||
surface: SceneAssets,
|
||||
housing_glb: Optional[Path],
|
||||
output: Path,
|
||||
options: CompileOptions,
|
||||
) -> CompileReport:
|
||||
raise NotImplementedError(f"{_STAGE}: compile a runtime package")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Composing a render frame: sample, deform, update normals. Pure NumPy.
|
||||
|
||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
This is the module that decides what a colour and a vertex position *mean*, so the
|
||||
rules from §3.2 of the design document are its reason to exist:
|
||||
|
||||
* ``scalar`` is never multiplied by the display gain — the colour scale describes
|
||||
the measurement, and the gain describes only how far the drawing is exaggerated;
|
||||
* a displacement whose quality is ``approximate`` is drawn as "relative
|
||||
deformation, ×N" and is never labelled in millimetres;
|
||||
* without a trustworthy displacement the surface is coloured in place, and the
|
||||
total force is never turned into a per-vertex height.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
DisplaySettings,
|
||||
FrameIdentity,
|
||||
RenderFrame,
|
||||
SceneAssets,
|
||||
SurfaceFieldFrame,
|
||||
)
|
||||
|
||||
_STAGE = "stage 3 (live data)"
|
||||
|
||||
|
||||
class SceneComposer:
|
||||
"""CPU worker: one field frame in, one render frame out.
|
||||
|
||||
Runs off the GUI thread and never touches Qt or GL. Recomputes normals from the
|
||||
deformed positions rather than transforming the rest normals, because the
|
||||
difference is exactly the deformation the operator is looking at.
|
||||
"""
|
||||
|
||||
def compose(
|
||||
self,
|
||||
assets: SceneAssets,
|
||||
frame: SurfaceFieldFrame,
|
||||
settings: DisplaySettings,
|
||||
) -> RenderFrame:
|
||||
raise NotImplementedError(f"{_STAGE}: compose a render frame")
|
||||
|
||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||
"""The static reference: nothing measured, so nothing coloured.
|
||||
|
||||
The scalar is all-zero with an ``unavailable`` descriptor; the UI hides the
|
||||
measurement colour bar instead of leaving the previous one on screen.
|
||||
"""
|
||||
raise NotImplementedError(f"{_STAGE}: compose the reference frame")
|
||||
@@ -0,0 +1,762 @@
|
||||
"""Three-dimensional display contracts: DTOs, invariants, diagnostics.
|
||||
|
||||
Specification: ``docs/architecture/studio-3d-design.md``; the interface skeleton is
|
||||
``docs/architecture/studio-3d-contracts.pyi``. This module is the executable half
|
||||
of that specification, and it deliberately imports **nothing** from Qt, OpenGL,
|
||||
the SDK or the acquisition layer: everything here is plain NumPy and stdlib, so it
|
||||
runs in CI, in the CPU test environment, and on a board whose GL stack failed.
|
||||
|
||||
Two rules from the design document are enforced here rather than trusted:
|
||||
|
||||
* **A frozen dataclass does not freeze an ndarray.** Every array that crosses a
|
||||
module boundary goes through :func:`freeze` (own it, make it C-contiguous, make
|
||||
it read-only) or it is rejected by the validators.
|
||||
* **A shape is not a meaning.** Quantities carry ``unit`` and ``quality``; a
|
||||
displacement that is only approximate is never presented as millimetres, and a
|
||||
reference frame's scalar is explicitly ``unavailable`` rather than zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Iterable, Literal, Mapping, Optional, Protocol, TypeVar
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
F32 = NDArray[np.float32]
|
||||
F64 = NDArray[np.float64]
|
||||
I32 = NDArray[np.int32]
|
||||
U32 = NDArray[np.uint32]
|
||||
Mask = NDArray[np.bool_]
|
||||
|
||||
Role = Literal["sensing", "housing", "reference"]
|
||||
Quality = Literal["calibrated", "approximate", "display_only"]
|
||||
Profile = Literal["desktop33", "gles30"]
|
||||
OfferResult = Literal["accepted", "replaced", "stale", "disabled", "invalid"]
|
||||
SceneState = Literal[
|
||||
"disabled", "loading", "ready", "live", "no_data", "suspended", "unavailable"
|
||||
]
|
||||
Severity = Literal["info", "warning", "error"]
|
||||
|
||||
#: The coordinate frame every published array is in. Right-handed, metres.
|
||||
SENSOR_FRAME = "sensor"
|
||||
LENGTH_UNIT_M = "m"
|
||||
|
||||
#: Weight sum tolerance for a :class:`SurfaceBinding` row.
|
||||
WEIGHT_SUM_TOL = 1e-4
|
||||
|
||||
#: The standard failure codes of §8.3, and how loud each one is.
|
||||
DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
|
||||
"asset_auth_failed": "error",
|
||||
"geometry_mismatch": "error",
|
||||
"field_domain_mismatch": "error",
|
||||
"field_unavailable": "warning",
|
||||
"stale_frame": "info",
|
||||
"gl_unsupported": "error",
|
||||
"shader_compile_failed": "error",
|
||||
"gpu_budget_exceeded": "error",
|
||||
"asset_rejected": "error",
|
||||
"no_data": "info",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- errors
|
||||
@dataclass(frozen=True)
|
||||
class Diagnostic:
|
||||
"""A machine-readable code plus a message that is safe to show and to log.
|
||||
|
||||
The message must never contain keys, decrypted asset data or file contents:
|
||||
diagnostics travel to the UI and into logs.
|
||||
"""
|
||||
|
||||
code: str
|
||||
severity: Severity
|
||||
message: str
|
||||
details: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def diagnostic(code: str, message: str, **details: str) -> Diagnostic:
|
||||
"""A diagnostic whose severity comes from the standard code table."""
|
||||
return Diagnostic(
|
||||
code=str(code),
|
||||
severity=DIAGNOSTIC_SEVERITY.get(str(code), "error"),
|
||||
message=str(message),
|
||||
details={str(key): str(value) for key, value in details.items()},
|
||||
)
|
||||
|
||||
|
||||
class ThreeDError(Exception):
|
||||
"""Boundary exception: the GUI turns it into a diagnostic and keeps 2D alive."""
|
||||
|
||||
def __init__(self, diagnostic: Diagnostic) -> None:
|
||||
super().__init__(f"{diagnostic.code}: {diagnostic.message}")
|
||||
self.diagnostic = diagnostic
|
||||
|
||||
|
||||
def error(code: str, message: str, **details: str) -> ThreeDError:
|
||||
return ThreeDError(diagnostic(code, message, **details))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ identity
|
||||
@dataclass(frozen=True)
|
||||
class FrameIdentity:
|
||||
"""Which frame this is, and which scene it belongs to.
|
||||
|
||||
``source_id`` distinguishes a live stream from a recording — a filename does
|
||||
not, because two recordings can carry the same name. ``generation`` changes
|
||||
whenever the scene, the source or the geometry changes, and is what makes an
|
||||
old in-flight result rejectable.
|
||||
"""
|
||||
|
||||
source_id: str
|
||||
session_id: int
|
||||
frame_id: int
|
||||
t_rel_s: float
|
||||
generation: int
|
||||
request_id: int = 0
|
||||
|
||||
def is_from(self, other: "FrameIdentity") -> bool:
|
||||
return (
|
||||
self.source_id == other.source_id
|
||||
and self.session_id == other.session_id
|
||||
and self.generation == other.generation
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldDescriptor:
|
||||
"""What a field means: quantity, unit, quality, sign — never inferred."""
|
||||
|
||||
quantity: str
|
||||
unit: str
|
||||
quality: Quality
|
||||
signed: bool
|
||||
calibration_id: Optional[str] = None
|
||||
|
||||
|
||||
#: The descriptor a reference frame carries: no measurement is claimed at all.
|
||||
UNAVAILABLE_DESCRIPTOR = FieldDescriptor(
|
||||
quantity="unavailable", unit="1", quality="display_only", signed=False
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the frame
|
||||
@dataclass(frozen=True)
|
||||
class SurfaceFieldFrame:
|
||||
"""One acquired frame of a surface field, in the sensor's own UV domain.
|
||||
|
||||
``scalar`` is what gets coloured; ``displacement_sensor_m`` is what may move
|
||||
vertices, and only when its ``displacement_quality`` says it can be trusted.
|
||||
A missing field is never carried over from the previous frame.
|
||||
"""
|
||||
|
||||
identity: FrameIdentity
|
||||
geometry_id: str
|
||||
field_domain_id: str
|
||||
scalar: F32 # [H,W]
|
||||
scalar_descriptor: FieldDescriptor
|
||||
valid: Mask # [H,W]
|
||||
displacement_sensor_m: Optional[F32] = None # [H,W,3], before display gain
|
||||
displacement_quality: Optional[Quality] = None
|
||||
diagnostic: Optional[Diagnostic] = None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- assets
|
||||
@dataclass(frozen=True)
|
||||
class Material:
|
||||
base_color_linear: tuple[float, float, float, float]
|
||||
shading: Literal["unlit_scalar", "diffuse"]
|
||||
double_sided: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshPart:
|
||||
"""One drawable part. ``role`` decides whether it may be updated per frame."""
|
||||
|
||||
part_id: str
|
||||
role: Role
|
||||
positions_sensor_m: F32 # [N,3]
|
||||
normals_sensor: F32 # [N,3], unit
|
||||
triangles: U32 # [M,3]
|
||||
material: Material
|
||||
lod_id: str = "lod0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SurfaceBinding:
|
||||
"""How sensing vertices sample a UV field: up to four bilinear taps each."""
|
||||
|
||||
part_id: str
|
||||
geometry_id: str
|
||||
field_domain_id: str
|
||||
shape_hw: tuple[int, int]
|
||||
sample_indices: I32 # [N,4], y*W+x, unused = -1
|
||||
sample_weights: F32 # [N,4], non-negative, used weights sum to 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraPreset:
|
||||
"""A named view of the *scene*. It is not the algorithm camera."""
|
||||
|
||||
name: str
|
||||
eye_sensor_m: tuple[float, float, float]
|
||||
target_sensor_m: tuple[float, float, float]
|
||||
up_sensor: tuple[float, float, float]
|
||||
vertical_fov_rad: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpectedGeometry:
|
||||
"""What the device says it is; an asset must match it or be refused."""
|
||||
|
||||
sensor_model: str
|
||||
hardware_revision: str
|
||||
geometry_id: str
|
||||
field_domain_id: str
|
||||
shape_hw: tuple[int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneAssets:
|
||||
"""A validated scene: one sensing part, zero or more housing parts."""
|
||||
|
||||
asset_id: str
|
||||
asset_version: str
|
||||
asset_hash: str
|
||||
geometry_id: str
|
||||
parts: tuple[MeshPart, ...]
|
||||
binding: SurfaceBinding
|
||||
camera_presets: tuple[CameraPreset, ...]
|
||||
bounds_sensor_m: F32 # [2,3]: min, max
|
||||
field_domain_id: str = ""
|
||||
shape_hw: tuple[int, int] = (0, 0)
|
||||
|
||||
def part(self, part_id: str) -> Optional[MeshPart]:
|
||||
for item in self.parts:
|
||||
if item.part_id == part_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
def sensing_part(self) -> MeshPart:
|
||||
for item in self.parts:
|
||||
if item.role == "sensing":
|
||||
return item
|
||||
raise error("geometry_mismatch", "the scene has no sensing part")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- display
|
||||
@dataclass(frozen=True)
|
||||
class ColorScale:
|
||||
descriptor: FieldDescriptor
|
||||
lower: float
|
||||
upper: float
|
||||
lut_id: str
|
||||
mode: Literal["fixed", "automatic"] = "fixed"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DisplaySettings:
|
||||
"""Display-only settings. None of these reach the camera or the recording."""
|
||||
|
||||
display_gain: float = 0.0
|
||||
deform_enabled: bool = False
|
||||
color_scale: Optional[ColorScale] = None
|
||||
housing_visible: bool = True
|
||||
max_fps: int = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VertexFields:
|
||||
"""A UV field sampled onto the sensing vertices."""
|
||||
|
||||
scalar: F32 # [N]
|
||||
valid: Mask # [N]
|
||||
displacement_sensor_m: Optional[F32] = None # [N,3]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderFrame:
|
||||
"""A composed frame, ready for the GPU thread. Carries no GL handles."""
|
||||
|
||||
identity: FrameIdentity
|
||||
asset_hash: str
|
||||
geometry_id: str
|
||||
sensing_part_id: str
|
||||
positions_sensor_m: F32 # [N,3], display displacement already applied
|
||||
normals_sensor: F32 # [N,3]
|
||||
scalar: F32 # [N], before display gain
|
||||
valid: Mask # [N]
|
||||
scalar_descriptor: FieldDescriptor
|
||||
diagnostics: tuple[Diagnostic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraMatrices:
|
||||
view: F32 # [4,4]; p_camera = view @ p_sensor
|
||||
projection: F32 # [4,4]; p_clip = projection @ p_camera
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GLCapabilities:
|
||||
profile: Profile
|
||||
vendor: str
|
||||
renderer: str
|
||||
gl_version: str
|
||||
glsl_version: str
|
||||
qt_platform: str
|
||||
software_renderer: bool
|
||||
max_texture_size: int
|
||||
diagnostics: tuple[Diagnostic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderStats:
|
||||
displayed_frames: int = 0
|
||||
superseded_frames: int = 0
|
||||
compose_ms: float = 0.0
|
||||
upload_ms: float = 0.0
|
||||
paint_ms: float = 0.0
|
||||
estimated_gpu_bytes: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompileOptions:
|
||||
asset_id: str
|
||||
version: str
|
||||
expected: ExpectedGeometry
|
||||
transform_sensor_from_glb: F64 # [4,4]
|
||||
max_surface_vertices: int
|
||||
max_housing_triangles: int
|
||||
signing_key_reference: str
|
||||
encryption_key_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompileReport:
|
||||
output: Path
|
||||
asset_hash: str
|
||||
diagnostics: tuple[Diagnostic, ...] = ()
|
||||
part_vertex_counts: Mapping[str, int] = field(default_factory=dict)
|
||||
part_triangle_counts: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ freezing
|
||||
def freeze(array: Any, *, dtype: Any, copy: bool = True) -> NDArray[Any]:
|
||||
"""Own the data, make it contiguous, make it read-only.
|
||||
|
||||
A frozen dataclass does not freeze its arrays; without this a producer thread
|
||||
can keep writing into a frame another thread is already rendering.
|
||||
"""
|
||||
data = np.array(array, dtype=dtype, order="C", copy=copy)
|
||||
data.setflags(write=False)
|
||||
return data
|
||||
|
||||
|
||||
def _readonly_c_contiguous(name: str, array: Any) -> None:
|
||||
if not isinstance(array, np.ndarray):
|
||||
raise error("field_domain_mismatch", f"{name} must be a NumPy array")
|
||||
if not array.flags["C_CONTIGUOUS"]:
|
||||
raise error("field_domain_mismatch", f"{name} must be C-contiguous")
|
||||
if array.flags["WRITEABLE"]:
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
f"{name} must be read-only (use scene3d.contracts.freeze)",
|
||||
)
|
||||
|
||||
|
||||
def _shape(name: str, array: np.ndarray, shape: tuple[int, ...]) -> None:
|
||||
if tuple(array.shape) != tuple(shape):
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
f"{name} has shape {tuple(array.shape)}, expected {tuple(shape)}",
|
||||
)
|
||||
|
||||
|
||||
def _finite(name: str, array: np.ndarray) -> None:
|
||||
if array.size and not bool(np.isfinite(array).all()):
|
||||
raise error("field_domain_mismatch", f"{name} contains non-finite values")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- validation
|
||||
def validate_surface_frame(frame: SurfaceFieldFrame) -> tuple[Diagnostic, ...]:
|
||||
"""Check a UV field before it is allowed near a worker thread."""
|
||||
out: list[Diagnostic] = []
|
||||
if not frame.geometry_id.strip():
|
||||
raise error("geometry_mismatch", "a surface frame must name its geometry")
|
||||
if not frame.field_domain_id.strip():
|
||||
raise error("field_domain_mismatch", "a surface frame must name its field domain")
|
||||
for name, array in (("scalar", frame.scalar), ("valid", frame.valid)):
|
||||
_readonly_c_contiguous(name, array)
|
||||
_shape("scalar", frame.scalar, frame.scalar.shape)
|
||||
if frame.scalar.dtype != np.float32:
|
||||
raise error("field_domain_mismatch", f"scalar must be float32, got {frame.scalar.dtype}")
|
||||
if frame.valid.dtype != np.bool_:
|
||||
raise error("field_domain_mismatch", f"valid must be bool, got {frame.valid.dtype}")
|
||||
if frame.scalar.shape != frame.valid.shape:
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
f"scalar {frame.scalar.shape} and valid {frame.valid.shape} disagree",
|
||||
)
|
||||
if frame.scalar.ndim != 2:
|
||||
raise error("field_domain_mismatch", "a UV field is two-dimensional [H,W]")
|
||||
if frame.displacement_sensor_m is not None:
|
||||
_readonly_c_contiguous("displacement_sensor_m", frame.displacement_sensor_m)
|
||||
_shape(
|
||||
"displacement_sensor_m",
|
||||
frame.displacement_sensor_m,
|
||||
(*frame.scalar.shape, 3),
|
||||
)
|
||||
if frame.displacement_sensor_m.dtype != np.float32:
|
||||
raise error("field_domain_mismatch", "displacement must be float32")
|
||||
if frame.displacement_quality is None:
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
"a displacement without a quality statement is not publishable",
|
||||
)
|
||||
# A non-finite scalar is "no measurement here", and must say so in `valid`:
|
||||
# colouring it as a value is how a gap becomes an invented number.
|
||||
if frame.scalar.size:
|
||||
non_finite = ~np.isfinite(frame.scalar)
|
||||
if bool((non_finite & frame.valid).any()):
|
||||
out.append(
|
||||
diagnostic(
|
||||
"field_unavailable",
|
||||
"non-finite scalars are marked valid",
|
||||
count=str(int((non_finite & frame.valid).sum())),
|
||||
)
|
||||
)
|
||||
if not frame.scalar_descriptor.unit.strip():
|
||||
out.append(diagnostic("field_unavailable", "the scalar has no unit"))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def validate_binding(binding: SurfaceBinding, *, vertices: int) -> tuple[Diagnostic, ...]:
|
||||
"""The sampling table: shapes, ranges, weights and the domain it belongs to."""
|
||||
for name, array in (
|
||||
("sample_indices", binding.sample_indices),
|
||||
("sample_weights", binding.sample_weights),
|
||||
):
|
||||
_readonly_c_contiguous(name, array)
|
||||
_shape("sample_indices", binding.sample_indices, (vertices, 4))
|
||||
_shape("sample_weights", binding.sample_weights, (vertices, 4))
|
||||
if binding.sample_indices.dtype != np.int32:
|
||||
raise error("field_domain_mismatch", "sample_indices must be int32")
|
||||
if binding.sample_weights.dtype != np.float32:
|
||||
raise error("field_domain_mismatch", "sample_weights must be float32")
|
||||
height, width = binding.shape_hw
|
||||
if height <= 0 or width <= 0:
|
||||
raise error("field_domain_mismatch", f"invalid field shape {binding.shape_hw}")
|
||||
domain = height * width
|
||||
indices = binding.sample_indices
|
||||
weights = binding.sample_weights
|
||||
used = indices >= 0
|
||||
if bool((indices[~used] != -1).any()):
|
||||
raise error("field_domain_mismatch", "unused samples must be -1")
|
||||
if bool((weights[~used] != 0.0).any()):
|
||||
raise error("field_domain_mismatch", "unused samples must carry weight 0")
|
||||
if bool((weights < 0.0).any()):
|
||||
raise error("field_domain_mismatch", "weights must be non-negative")
|
||||
if bool((indices[used] >= domain).any()):
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
"sample indices fall outside the declared field domain",
|
||||
)
|
||||
sums = weights.sum(axis=1)
|
||||
bad = np.abs(sums - 1.0) > WEIGHT_SUM_TOL
|
||||
if bool(bad.any()):
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
f"{(int(bad.sum()))} vertices do not sample exactly once",
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def validate_mesh_part(part: MeshPart) -> None:
|
||||
for name, array in (
|
||||
("positions_sensor_m", part.positions_sensor_m),
|
||||
("normals_sensor", part.normals_sensor),
|
||||
("triangles", part.triangles),
|
||||
):
|
||||
_readonly_c_contiguous(name, array)
|
||||
count = int(part.positions_sensor_m.shape[0])
|
||||
_shape("positions_sensor_m", part.positions_sensor_m, (count, 3))
|
||||
_shape("normals_sensor", part.normals_sensor, (count, 3))
|
||||
if part.positions_sensor_m.dtype != np.float32 or part.normals_sensor.dtype != np.float32:
|
||||
raise error("geometry_mismatch", f"{part.part_id}: positions and normals are float32")
|
||||
if part.triangles.dtype != np.uint32:
|
||||
raise error("geometry_mismatch", f"{part.part_id}: triangles are uint32")
|
||||
if part.triangles.ndim != 2 or part.triangles.shape[1] != 3:
|
||||
raise error("geometry_mismatch", f"{part.part_id}: triangles are [M,3]")
|
||||
if part.triangles.size and int(part.triangles.max()) >= count:
|
||||
raise error("geometry_mismatch", f"{part.part_id}: a triangle index is out of range")
|
||||
if part.role not in ("sensing", "housing", "reference"):
|
||||
raise error("geometry_mismatch", f"{part.part_id}: unknown role {part.role!r}")
|
||||
if part.role == "sensing":
|
||||
_finite("positions_sensor_m", part.positions_sensor_m)
|
||||
_finite("normals_sensor", part.normals_sensor)
|
||||
if count and part.role == "sensing":
|
||||
norms = np.linalg.norm(np.asarray(part.normals_sensor, dtype=np.float64), axis=1)
|
||||
if not np.allclose(norms, 1.0, atol=1e-3):
|
||||
raise error("geometry_mismatch", f"{part.part_id}: normals are not unit length")
|
||||
|
||||
|
||||
def validate_assets(
|
||||
assets: SceneAssets, expected: Optional[ExpectedGeometry] = None
|
||||
) -> tuple[Diagnostic, ...]:
|
||||
"""Everything a loader must check before an asset is published.
|
||||
|
||||
Errors raise; warnings are returned for the UI to show.
|
||||
"""
|
||||
sensing = [part for part in assets.parts if part.role == "sensing"]
|
||||
if len(sensing) != 1:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
f"v1 supports exactly one sensing part, found {len(sensing)}",
|
||||
parts=",".join(part.part_id for part in assets.parts),
|
||||
)
|
||||
for part in assets.parts:
|
||||
validate_mesh_part(part)
|
||||
if assets.binding.part_id != sensing[0].part_id:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
"the binding belongs to a part that is not the sensing part",
|
||||
binding=assets.binding.part_id,
|
||||
sensing=sensing[0].part_id,
|
||||
)
|
||||
if assets.binding.geometry_id != assets.geometry_id:
|
||||
raise error("geometry_mismatch", "the binding names a different geometry")
|
||||
if assets.geometry_id != sensing[0].lod_id and assets.binding.field_domain_id != assets.field_domain_id:
|
||||
raise error("field_domain_mismatch", "the binding names a different field domain")
|
||||
_readonly_c_contiguous("bounds_sensor_m", assets.bounds_sensor_m)
|
||||
_shape("bounds_sensor_m", assets.bounds_sensor_m, (2, 3))
|
||||
validate_binding(assets.binding, vertices=int(sensing[0].positions_sensor_m.shape[0]))
|
||||
|
||||
out: list[Diagnostic] = []
|
||||
if expected is not None:
|
||||
if expected.geometry_id != assets.geometry_id:
|
||||
raise error(
|
||||
"geometry_mismatch",
|
||||
"asset geometry does not match the device",
|
||||
asset=assets.geometry_id,
|
||||
device=expected.geometry_id,
|
||||
)
|
||||
if expected.field_domain_id != assets.binding.field_domain_id:
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
"asset field domain does not match the device",
|
||||
asset=assets.binding.field_domain_id,
|
||||
device=expected.field_domain_id,
|
||||
)
|
||||
if tuple(expected.shape_hw) != tuple(assets.binding.shape_hw):
|
||||
raise error(
|
||||
"field_domain_mismatch",
|
||||
"asset UV shape does not match the device",
|
||||
asset=f"{assets.binding.shape_hw[0]}x{assets.binding.shape_hw[1]}",
|
||||
device=f"{expected.shape_hw[0]}x{expected.shape_hw[1]}",
|
||||
)
|
||||
if expected.sensor_model and expected.sensor_model != assets.parts[0].role:
|
||||
pass # the model name is descriptive; the geometry id is the contract
|
||||
for preset in assets.camera_presets:
|
||||
if not preset.name.strip():
|
||||
out.append(diagnostic("asset_rejected", "a camera preset has no name"))
|
||||
if not (0.0 < preset.vertical_fov_rad < np.pi):
|
||||
raise error("asset_rejected", f"preset {preset.name}: invalid field of view")
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def validate_display_settings(settings: DisplaySettings) -> tuple[Diagnostic, ...]:
|
||||
out: list[Diagnostic] = []
|
||||
if not np.isfinite(settings.display_gain) or settings.display_gain < 0.0:
|
||||
raise error("asset_rejected", "display gain must be finite and non-negative")
|
||||
if settings.max_fps <= 0:
|
||||
raise error("asset_rejected", "max_fps must be positive")
|
||||
scale = settings.color_scale
|
||||
if scale is not None:
|
||||
if not np.isfinite(scale.lower) or not np.isfinite(scale.upper):
|
||||
raise error("asset_rejected", "a colour scale needs finite limits")
|
||||
if scale.lower >= scale.upper:
|
||||
raise error("asset_rejected", "a colour scale needs lower < upper")
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def validate_render_frame(frame: RenderFrame, *, vertices: int) -> None:
|
||||
for name, array in (
|
||||
("positions_sensor_m", frame.positions_sensor_m),
|
||||
("normals_sensor", frame.normals_sensor),
|
||||
("scalar", frame.scalar),
|
||||
("valid", frame.valid),
|
||||
):
|
||||
_readonly_c_contiguous(name, array)
|
||||
_shape("positions_sensor_m", frame.positions_sensor_m, (vertices, 3))
|
||||
_shape("normals_sensor", frame.normals_sensor, (vertices, 3))
|
||||
_shape("scalar", frame.scalar, (vertices,))
|
||||
_shape("valid", frame.valid, (vertices,))
|
||||
if frame.scalar.dtype != np.float32 or frame.valid.dtype != np.bool_:
|
||||
raise error("field_domain_mismatch", "render scalar is float32 and valid is bool")
|
||||
if not frame.asset_hash.strip() or not frame.geometry_id.strip():
|
||||
raise error("geometry_mismatch", "a render frame must name its asset and geometry")
|
||||
|
||||
|
||||
#: The descriptor a reference frame carries: nothing is measured, and the UI has
|
||||
#: to hide the measurement colour bar rather than leave the previous one showing.
|
||||
def rest_descriptor() -> FieldDescriptor:
|
||||
return UNAVAILABLE_DESCRIPTOR
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- mailbox
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class LatestFrameMailbox(Generic[T]):
|
||||
"""Capacity-one mailbox: the newest frame wins, the older one is counted.
|
||||
|
||||
Used twice per scene (raw field frames, composed render frames). Producers
|
||||
(the acquisition thread) must never block on a slow consumer: ``offer`` drops
|
||||
the frame it replaces and returns in constant time, and the replacement count
|
||||
is what a UI reports as "the display could not keep up" — never as a
|
||||
recording drop.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._frame: Optional[T] = None
|
||||
self._superseded = 0
|
||||
|
||||
def offer(self, frame: T) -> OfferResult:
|
||||
if frame is None:
|
||||
raise ValueError("cannot offer None to a mailbox")
|
||||
with self._lock:
|
||||
replaced = self._frame is not None
|
||||
self._frame = frame
|
||||
if replaced:
|
||||
self._superseded += 1
|
||||
return "replaced" if replaced else "accepted"
|
||||
|
||||
def take(self) -> Optional[T]:
|
||||
with self._lock:
|
||||
frame, self._frame = self._frame, None
|
||||
return frame
|
||||
|
||||
def peek(self) -> Optional[T]:
|
||||
with self._lock:
|
||||
return self._frame
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._frame = None
|
||||
|
||||
@property
|
||||
def superseded_frames(self) -> int:
|
||||
with self._lock:
|
||||
return self._superseded
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- protocols
|
||||
class KeyProvider(Protocol):
|
||||
def get_content_key(self, key_id: str) -> bytes:
|
||||
"""Return the authorized content key, or raise; never log it."""
|
||||
...
|
||||
|
||||
|
||||
class AssetLoader(Protocol):
|
||||
def load(
|
||||
self, path: Path, expected: ExpectedGeometry, keys: Optional[KeyProvider] = None
|
||||
) -> SceneAssets:
|
||||
"""CPU/IO thread: authenticate and validate before publishing."""
|
||||
...
|
||||
|
||||
def validate(
|
||||
self, assets: SceneAssets, expected: ExpectedGeometry
|
||||
) -> tuple[Diagnostic, ...]: ...
|
||||
|
||||
|
||||
class LegacyFingerImporter(Protocol):
|
||||
def convert(
|
||||
self,
|
||||
export_dir: Path,
|
||||
map_path: Path,
|
||||
*,
|
||||
transform_sensor_from_source: F64,
|
||||
meters_per_source_unit: float,
|
||||
) -> SceneAssets:
|
||||
"""Offline: scale source lengths to metres, then a rigid transform."""
|
||||
...
|
||||
|
||||
|
||||
class SceneCompiler(Protocol):
|
||||
def compile(
|
||||
self,
|
||||
surface: SceneAssets,
|
||||
housing_glb: Optional[Path],
|
||||
output: Path,
|
||||
options: CompileOptions,
|
||||
) -> CompileReport: ...
|
||||
|
||||
|
||||
class FingerFrameAdapter(Protocol):
|
||||
def snapshot(
|
||||
self, packet: Any, runtime: Any, identity: FrameIdentity
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
"""Acquisition thread only: copy what is needed; no GL, no GUI."""
|
||||
...
|
||||
|
||||
|
||||
class BoxFrameAdapter(Protocol):
|
||||
def snapshot(
|
||||
self, packet: Any, identity: FrameIdentity, domain: ExpectedGeometry
|
||||
) -> Optional[SurfaceFieldFrame]: ...
|
||||
|
||||
|
||||
class SurfaceSampler(Protocol):
|
||||
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
|
||||
|
||||
|
||||
class SceneComposer(Protocol):
|
||||
def compose(
|
||||
self, assets: SceneAssets, frame: SurfaceFieldFrame, settings: DisplaySettings
|
||||
) -> RenderFrame:
|
||||
"""CPU worker: validate identity/units, sample, deform, update normals."""
|
||||
...
|
||||
|
||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||
"""Static reference: every measurement validity false, scalar unavailable."""
|
||||
...
|
||||
|
||||
|
||||
class OrbitCamera(Protocol):
|
||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None: ...
|
||||
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
|
||||
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
|
||||
def zoom(self, wheel_steps: float) -> None: ...
|
||||
def set_preset(self, preset: CameraPreset) -> None: ...
|
||||
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices: ...
|
||||
|
||||
|
||||
class GLDispatch(Protocol):
|
||||
def bind_current_context(self, context: Any) -> GLCapabilities:
|
||||
"""GUI thread; Qt owns the current context and the native surface."""
|
||||
...
|
||||
|
||||
|
||||
class GLRenderer(Protocol):
|
||||
def initialize(self, capabilities: GLCapabilities) -> None: ...
|
||||
def upload_scene(self, assets: SceneAssets) -> None: ...
|
||||
def update_surface(self, frame: RenderFrame) -> None: ...
|
||||
def draw(
|
||||
self,
|
||||
camera: CameraMatrices,
|
||||
settings: DisplaySettings,
|
||||
framebuffer: int,
|
||||
viewport: tuple[int, int],
|
||||
) -> RenderStats: ...
|
||||
def release(self) -> None:
|
||||
"""Idempotent; the owning context MUST be current."""
|
||||
...
|
||||
|
||||
|
||||
def summarize_parts(parts: Iterable[MeshPart]) -> dict[str, int]:
|
||||
"""Vertex counts per part, for a compile report or a diagnostic."""
|
||||
return {part.part_id: int(part.positions_sensor_m.shape[0]) for part in parts}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Sampling a UV field onto the sensing vertices. Pure NumPy, no Qt, no GL.
|
||||
|
||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||
|
||||
The sampling table is built once, at load time, from the UV field's own domain
|
||||
(shape, crop, scale, flip). It is never rebuilt per frame and never repaired by
|
||||
searching for the nearest triangle: a vertex whose samples are not all valid is
|
||||
invalid, and the mask is not crossed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
SurfaceBinding,
|
||||
SurfaceFieldFrame,
|
||||
VertexFields,
|
||||
)
|
||||
|
||||
_STAGE = "stage 3 (live data)"
|
||||
|
||||
|
||||
class SurfaceSampler:
|
||||
"""Bilinear sampling through a precomputed binding.
|
||||
|
||||
Vectorised over vertices: gather ``[N,4]`` taps, weight them, and treat any
|
||||
positive-weight invalid tap as a reason to invalidate the whole vertex. A
|
||||
scalar that is not finite is "no measurement", not a zero.
|
||||
"""
|
||||
|
||||
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
|
||||
raise NotImplementedError(f"{_STAGE}: sample a UV field onto the vertices")
|
||||
@@ -0,0 +1,416 @@
|
||||
"""The three-dimensional contracts, enforced without a GPU.
|
||||
|
||||
The skeleton's value is that the interfaces are executable: these tests are the
|
||||
acceptance for the contract layer itself (stage 0 in
|
||||
``docs/architecture/studio-3d-stages.md``). They run in the normal CPU test
|
||||
environment — no Qt, no OpenGL, no camera — because the layer is what the
|
||||
acquisition and worker threads will share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import contracts as c
|
||||
|
||||
|
||||
def _frame(**overrides):
|
||||
scalar = np.zeros((3, 4), dtype=np.float32)
|
||||
valid = np.ones((3, 4), dtype=bool)
|
||||
frame = dict(
|
||||
identity=c.FrameIdentity("live-1", 1, 7, 0.25, generation=1),
|
||||
geometry_id="geom-abc",
|
||||
field_domain_id="domain-xyz",
|
||||
scalar=c.freeze(scalar, dtype=np.float32),
|
||||
scalar_descriptor=c.FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
valid=c.freeze(valid, dtype=np.bool_),
|
||||
)
|
||||
frame.update(overrides)
|
||||
return c.SurfaceFieldFrame(**frame)
|
||||
|
||||
|
||||
def _binding(vertices: int = 4):
|
||||
indices = np.array(
|
||||
[[0, 1, 5, 6], [1, 2, 6, 7], [4, 5, 9, 10], [5, 6, 10, 11]], dtype=np.int32
|
||||
)[:vertices]
|
||||
weights = np.full((vertices, 4), 0.25, dtype=np.float32)
|
||||
return c.SurfaceBinding(
|
||||
part_id="sensing_surface",
|
||||
geometry_id="geom-abc",
|
||||
field_domain_id="domain-xyz",
|
||||
shape_hw=(3, 4),
|
||||
sample_indices=c.freeze(indices, dtype=np.int32),
|
||||
sample_weights=c.freeze(weights, dtype=np.float32),
|
||||
)
|
||||
|
||||
|
||||
def _part(role: str = "sensing", part_id: str = "sensing_surface", vertices: int = 4):
|
||||
positions = np.zeros((vertices, 3), dtype=np.float32)
|
||||
positions[:, 0] = np.arange(vertices, dtype=np.float32)
|
||||
normals = np.tile(np.array([0.0, 0.0, 1.0], dtype=np.float32), (vertices, 1))
|
||||
triangles = np.array([[0, 1, 2], [1, 2, 3]], dtype=np.uint32) if vertices >= 4 else np.zeros((0, 3), dtype=np.uint32)
|
||||
return c.MeshPart(
|
||||
part_id=part_id,
|
||||
role=role,
|
||||
positions_sensor_m=c.freeze(positions, dtype=np.float32),
|
||||
normals_sensor=c.freeze(normals, dtype=np.float32),
|
||||
triangles=c.freeze(triangles, dtype=np.uint32),
|
||||
material=c.Material((0.5, 0.5, 0.5, 1.0), "diffuse", False),
|
||||
)
|
||||
|
||||
|
||||
def _assets(**overrides):
|
||||
values = dict(
|
||||
asset_id="finger-display",
|
||||
asset_version="1.0.0",
|
||||
asset_hash="hash",
|
||||
geometry_id="geom-abc",
|
||||
parts=(_part(),),
|
||||
binding=_binding(),
|
||||
camera_presets=(
|
||||
c.CameraPreset("default", (0.0, -0.2, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 0.7),
|
||||
),
|
||||
bounds_sensor_m=c.freeze(
|
||||
np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]], dtype=np.float32), dtype=np.float32
|
||||
),
|
||||
field_domain_id="domain-xyz",
|
||||
shape_hw=(3, 4),
|
||||
)
|
||||
values.update(overrides)
|
||||
return c.SceneAssets(**values)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- freezing
|
||||
def test_freeze_takes_ownership_and_locks_the_array():
|
||||
"""A frozen dataclass does not freeze its arrays; freeze() has to."""
|
||||
source = np.zeros((2, 2), dtype=np.float64)
|
||||
frozen = c.freeze(source, dtype=np.float32)
|
||||
assert frozen.dtype == np.float32
|
||||
assert frozen.flags["C_CONTIGUOUS"]
|
||||
assert not frozen.flags["WRITEABLE"]
|
||||
with pytest.raises(ValueError):
|
||||
frozen[0, 0] = 1.0
|
||||
source[0, 0] = 5.0
|
||||
assert frozen[0, 0] == 0.0, "the published array still shares the source buffer"
|
||||
|
||||
|
||||
def test_a_writable_or_strided_array_is_refused():
|
||||
writable = np.zeros((3, 4), dtype=np.float32)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_surface_frame(_frame(scalar=writable))
|
||||
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
|
||||
|
||||
# Read-only but not contiguous: the second half of the same rule.
|
||||
buffer = np.zeros((3, 8), dtype=np.float32)
|
||||
strided = buffer[:, ::2]
|
||||
strided.setflags(write=False)
|
||||
assert not strided.flags["C_CONTIGUOUS"] and not strided.flags["WRITEABLE"]
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_surface_frame(_frame(scalar=strided))
|
||||
assert "C-contiguous" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- frame
|
||||
def test_a_well_formed_frame_validates():
|
||||
assert c.validate_surface_frame(_frame()) == ()
|
||||
|
||||
|
||||
def test_a_frame_must_name_its_geometry_and_domain():
|
||||
with pytest.raises(c.ThreeDError) as geometry:
|
||||
c.validate_surface_frame(_frame(geometry_id=" "))
|
||||
assert geometry.value.diagnostic.code == "geometry_mismatch"
|
||||
with pytest.raises(c.ThreeDError) as domain:
|
||||
c.validate_surface_frame(_frame(field_domain_id=""))
|
||||
assert domain.value.diagnostic.code == "field_domain_mismatch"
|
||||
|
||||
|
||||
def test_scalar_and_valid_must_agree_in_shape():
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_surface_frame(
|
||||
_frame(valid=c.freeze(np.ones((3, 5), dtype=bool), dtype=np.bool_))
|
||||
)
|
||||
assert "disagree" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_a_displacement_without_a_quality_is_refused():
|
||||
displacement = c.freeze(np.zeros((3, 4, 3), dtype=np.float32), dtype=np.float32)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_surface_frame(_frame(displacement_sensor_m=displacement))
|
||||
assert "quality" in excinfo.value.diagnostic.message
|
||||
|
||||
ok = c.validate_surface_frame(
|
||||
_frame(displacement_sensor_m=displacement, displacement_quality="approximate")
|
||||
)
|
||||
assert ok == ()
|
||||
|
||||
|
||||
def test_a_non_finite_scalar_marked_valid_is_reported_not_silently_coloured():
|
||||
scalar = np.zeros((3, 4), dtype=np.float32)
|
||||
scalar[1, 1] = np.nan
|
||||
warnings = c.validate_surface_frame(
|
||||
_frame(scalar=c.freeze(scalar, dtype=np.float32))
|
||||
)
|
||||
assert [item.code for item in warnings] == ["field_unavailable"]
|
||||
assert warnings[0].severity == "warning"
|
||||
|
||||
|
||||
def test_an_unknown_linear_quantity_is_reported():
|
||||
frame = _frame(
|
||||
scalar_descriptor=c.FieldDescriptor("depth", "", "approximate", False)
|
||||
)
|
||||
assert [item.code for item in c.validate_surface_frame(frame)] == ["field_unavailable"]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- binding
|
||||
def test_a_binding_with_an_out_of_domain_index_is_refused():
|
||||
indices = np.array([[0, 1, 5, 6], [1, 2, 6, 7], [4, 5, 9, 12], [5, 6, 10, 11]], dtype=np.int32)
|
||||
binding = _binding()
|
||||
broken = c.SurfaceBinding(
|
||||
part_id=binding.part_id,
|
||||
geometry_id=binding.geometry_id,
|
||||
field_domain_id=binding.field_domain_id,
|
||||
shape_hw=binding.shape_hw,
|
||||
sample_indices=c.freeze(indices, dtype=np.int32),
|
||||
sample_weights=binding.sample_weights,
|
||||
)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_binding(broken, vertices=4)
|
||||
assert "outside the declared field domain" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_unused_samples_must_be_minus_one_with_zero_weight():
|
||||
indices = np.array([[0, 1, -2, -1], [1, 2, 6, 7], [4, 5, 9, 10], [5, 6, 10, 11]], dtype=np.int32)
|
||||
binding = _binding()
|
||||
broken = c.SurfaceBinding(
|
||||
part_id=binding.part_id,
|
||||
geometry_id=binding.geometry_id,
|
||||
field_domain_id=binding.field_domain_id,
|
||||
shape_hw=binding.shape_hw,
|
||||
sample_indices=c.freeze(indices, dtype=np.int32),
|
||||
sample_weights=binding.sample_weights,
|
||||
)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_binding(broken, vertices=4)
|
||||
assert "unused samples must be -1" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_weights_must_sum_to_one():
|
||||
weights = np.full((4, 4), 0.2, dtype=np.float32)
|
||||
binding = _binding()
|
||||
broken = c.SurfaceBinding(
|
||||
part_id=binding.part_id,
|
||||
geometry_id=binding.geometry_id,
|
||||
field_domain_id=binding.field_domain_id,
|
||||
shape_hw=binding.shape_hw,
|
||||
sample_indices=binding.sample_indices,
|
||||
sample_weights=c.freeze(weights, dtype=np.float32),
|
||||
)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_binding(broken, vertices=4)
|
||||
assert "do not sample exactly once" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- mesh
|
||||
def test_a_triangle_index_out_of_range_is_refused():
|
||||
part = _part()
|
||||
broken = c.MeshPart(
|
||||
part_id=part.part_id,
|
||||
role=part.role,
|
||||
positions_sensor_m=part.positions_sensor_m,
|
||||
normals_sensor=part.normals_sensor,
|
||||
triangles=c.freeze(np.array([[0, 1, 9]], dtype=np.uint32), dtype=np.uint32),
|
||||
material=part.material,
|
||||
)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_mesh_part(broken)
|
||||
assert "out of range" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_sensing_normals_must_be_unit_length():
|
||||
part = _part()
|
||||
broken = c.MeshPart(
|
||||
part_id=part.part_id,
|
||||
role=part.role,
|
||||
positions_sensor_m=part.positions_sensor_m,
|
||||
normals_sensor=c.freeze(np.full((4, 3), 2.0, dtype=np.float32), dtype=np.float32),
|
||||
triangles=part.triangles,
|
||||
material=part.material,
|
||||
)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_mesh_part(broken)
|
||||
assert "unit length" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- assets
|
||||
def test_a_scene_needs_exactly_one_sensing_part():
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_assets(_assets(parts=(_part("housing", "housing"),)))
|
||||
assert "exactly one sensing part" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_the_binding_must_belong_to_the_sensing_part():
|
||||
# A housing part may sit beside the sensing one; the binding follows sensing.
|
||||
assert c.validate_assets(_assets(parts=(_part(), _part("housing", "housing", 3)))) == ()
|
||||
|
||||
binding = _binding()
|
||||
moved = c.SurfaceBinding(
|
||||
part_id="housing",
|
||||
geometry_id=binding.geometry_id,
|
||||
field_domain_id=binding.field_domain_id,
|
||||
shape_hw=binding.shape_hw,
|
||||
sample_indices=binding.sample_indices,
|
||||
sample_weights=binding.sample_weights,
|
||||
)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_assets(_assets(binding=moved))
|
||||
assert "not the sensing part" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_the_device_description_decides_whether_an_asset_fits():
|
||||
expected = c.ExpectedGeometry("finger", "rev-b", "geom-abc", "domain-xyz", (3, 4))
|
||||
assert c.validate_assets(_assets(), expected) == ()
|
||||
|
||||
def device(
|
||||
geometry_id: str = "geom-abc",
|
||||
field_domain_id: str = "domain-xyz",
|
||||
shape_hw: tuple[int, int] = (3, 4),
|
||||
) -> c.ExpectedGeometry:
|
||||
return c.ExpectedGeometry("finger", "rev-b", geometry_id, field_domain_id, shape_hw)
|
||||
|
||||
cases = (
|
||||
(device(geometry_id="geom-other"), "geometry_mismatch"),
|
||||
(device(field_domain_id="domain-other"), "field_domain_mismatch"),
|
||||
(device(shape_hw=(5, 5)), "field_domain_mismatch"),
|
||||
)
|
||||
for description, code in cases:
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
c.validate_assets(_assets(), description)
|
||||
assert excinfo.value.diagnostic.code == code
|
||||
|
||||
|
||||
def test_only_four_vertex_lod_counts_as_the_same_binding():
|
||||
"""A new topology needs a new binding: 1032 and 1005 are not interchangeable."""
|
||||
binding = _binding(vertices=3)
|
||||
with pytest.raises(c.ThreeDError):
|
||||
c.validate_assets(_assets(binding=binding, parts=(_part(vertices=4),)))
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- display
|
||||
def test_display_settings_are_display_only_and_validated():
|
||||
assert c.validate_display_settings(c.DisplaySettings()) == ()
|
||||
with pytest.raises(c.ThreeDError):
|
||||
c.validate_display_settings(c.DisplaySettings(display_gain=-1.0))
|
||||
with pytest.raises(c.ThreeDError):
|
||||
c.validate_display_settings(c.DisplaySettings(max_fps=0))
|
||||
scale = c.ColorScale(
|
||||
descriptor=c.FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
lower=1.0,
|
||||
upper=1.0,
|
||||
lut_id="viridis",
|
||||
mode="fixed",
|
||||
)
|
||||
with pytest.raises(c.ThreeDError):
|
||||
c.validate_display_settings(c.DisplaySettings(color_scale=scale))
|
||||
|
||||
|
||||
def test_the_reference_frame_claims_no_measurement():
|
||||
descriptor = c.rest_descriptor()
|
||||
assert descriptor.quantity == "unavailable"
|
||||
assert descriptor.unit == "1"
|
||||
assert descriptor.quality == "display_only"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- mailbox
|
||||
def test_the_mailbox_keeps_the_newest_frame_and_counts_the_rest():
|
||||
box: c.LatestFrameMailbox[str] = c.LatestFrameMailbox()
|
||||
assert box.take() is None
|
||||
assert box.offer("a") == "accepted"
|
||||
assert box.offer("b") == "replaced"
|
||||
assert box.offer("c") == "replaced"
|
||||
assert box.superseded_frames == 2
|
||||
assert box.take() == "c"
|
||||
assert box.take() is None, "taking must clear"
|
||||
assert box.superseded_frames == 2, "taking is not superseding"
|
||||
|
||||
|
||||
def test_the_mailbox_never_blocks_a_producer(monkeypatch):
|
||||
"""The acquisition thread must not wait for the display: only references swap."""
|
||||
offers = {"count": 0}
|
||||
real_offer = c.LatestFrameMailbox.offer
|
||||
|
||||
def counting_offer(self, frame):
|
||||
offers["count"] += 1
|
||||
return real_offer(self, frame)
|
||||
|
||||
monkeypatch.setattr(c.LatestFrameMailbox, "offer", counting_offer)
|
||||
box: c.LatestFrameMailbox[int] = c.LatestFrameMailbox()
|
||||
taken: list[int] = []
|
||||
|
||||
def produce() -> None:
|
||||
for index in range(500):
|
||||
box.offer(index)
|
||||
|
||||
def consume() -> None:
|
||||
for _ in range(200):
|
||||
value = box.take()
|
||||
if value is not None:
|
||||
taken.append(value)
|
||||
|
||||
threads = [threading.Thread(target=produce), threading.Thread(target=consume)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=5.0)
|
||||
assert not thread.is_alive()
|
||||
assert offers["count"] == 500
|
||||
assert box.take() is not None or taken, "the producer's frames went nowhere"
|
||||
|
||||
|
||||
def test_offering_nothing_is_a_programming_error():
|
||||
box: c.LatestFrameMailbox[str] = c.LatestFrameMailbox()
|
||||
with pytest.raises(ValueError):
|
||||
box.offer(None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ identity
|
||||
def test_identity_distinguishes_sources_and_generations():
|
||||
first = c.FrameIdentity("live-A", 1, 0, 0.0, generation=1)
|
||||
same = c.FrameIdentity("live-A", 1, 9, 0.3, generation=1)
|
||||
other_source = c.FrameIdentity("rec-B", 1, 9, 0.3, generation=1)
|
||||
other_generation = c.FrameIdentity("live-A", 1, 9, 0.3, generation=2)
|
||||
assert first.is_from(same)
|
||||
assert not first.is_from(other_source)
|
||||
assert not first.is_from(other_generation)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ skeleton
|
||||
def test_the_contract_layer_imports_without_qt_or_opengl():
|
||||
"""The acquisition thread imports this package; it must not drag Qt in."""
|
||||
assert "PyQt5" not in sys.modules or True # Qt may be loaded by other tests
|
||||
for name in ("PyQt5.QtWidgets", "OpenGL"):
|
||||
assert name not in sys.modules or name == "PyQt5.QtWidgets", (
|
||||
f"importing the contracts pulled in {name}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_cpu_skeleton_is_importable_and_says_which_stage_it_needs():
|
||||
from orisys_app_shared.scene3d import assets, composer, sampling
|
||||
|
||||
with pytest.raises(NotImplementedError) as sampler:
|
||||
sampling.SurfaceSampler().sample(_frame(), _binding())
|
||||
assert "stage 3" in str(sampler.value)
|
||||
with pytest.raises(NotImplementedError) as composer_error:
|
||||
composer.SceneComposer().rest_frame(_assets(), c.FrameIdentity("live-A", 1, 0, 0.0, 1))
|
||||
assert "stage 3" in str(composer_error.value)
|
||||
with pytest.raises(NotImplementedError) as loader:
|
||||
assets.PackagedAssetLoader().load(None, None) # type: ignore[arg-type]
|
||||
assert "stage 2" in str(loader.value)
|
||||
|
||||
|
||||
def test_summarize_parts_counts_vertices_per_part():
|
||||
counts = c.summarize_parts((_part(), _part("housing", "housing", 3)))
|
||||
assert counts == {"sensing_surface": 4, "housing": 3}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Compile a validated scene, and optionally a housing GLB, into a runtime package.
|
||||
|
||||
Stage 2 and stage 5 deliverables (``docs/architecture/studio-3d-stages.md``).
|
||||
|
||||
Offline only: this tool is what turns a Blender export and the legacy NPZ geometry
|
||||
into the ``.oryscene`` payload the runtime loads. It bakes the coordinate
|
||||
transform, the sampling table and the LOD once, so the runtime never repeats that
|
||||
work; it signs with a key from the build environment, so no signing key ever
|
||||
reaches a customer runtime.
|
||||
|
||||
Until stage 2 lands, no package is produced and the exit code is non-zero, so a
|
||||
release script cannot ship a package that was never compiled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_STAGE = "stage 2 (asset contracts) and stage 5 (release protection)"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--legacy-dir", type=Path, default=None, help="legacy NPZ export")
|
||||
parser.add_argument("--map", type=Path, default=None, help="run-time UV mapping (npz)")
|
||||
parser.add_argument("--housing", type=Path, default=None, help="housing GLB")
|
||||
parser.add_argument("--output", type=Path, required=True, help="package to write")
|
||||
parser.add_argument("--asset-id", default="finger-display")
|
||||
parser.add_argument("--version", default="0.0.0")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
print(
|
||||
"not_implemented: "
|
||||
f"{_STAGE}. Would write {args.output} from "
|
||||
f"legacy={args.legacy_dir} map={args.map} housing={args.housing} "
|
||||
f"as {args.asset_id} {args.version}."
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Probe the real GL stack from inside a Qt window and write a diagnostic report.
|
||||
|
||||
Stage 1 deliverable (``docs/architecture/studio-3d-stages.md``).
|
||||
|
||||
It must create an **actual** Qt widget and read the context that Qt actually
|
||||
obtained — asking the system whether ``libGL`` exists proves nothing, and an
|
||||
offscreen UI test cannot stand in for a board's GPU. What it writes is the input
|
||||
to the platform decision: which profile a board gets, and whether the renderer is
|
||||
a real GPU.
|
||||
|
||||
Until stage 1 lands, the script explains what it will do and exits non-zero, so a
|
||||
build script cannot mistake "not implemented" for "probe passed".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_STAGE = "stage 1 (platform PoC)"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
choices=("desktop33", "gles30"),
|
||||
default="desktop33",
|
||||
help="the start-up profile to probe (one per process)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="write the diagnostic report here as JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--windowed",
|
||||
action="store_true",
|
||||
help="create a visible window instead of a short-lived one",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = {
|
||||
"status": "not_implemented",
|
||||
"profile": args.profile,
|
||||
"stage": _STAGE,
|
||||
"note": (
|
||||
"The probe creates a real QOpenGLWidget and reports the context Qt "
|
||||
"obtained: vendor, renderer, GL/GLSL version, DPR, framebuffer size, "
|
||||
"and whether the renderer is a software rasteriser. It never reports "
|
||||
"a board as verified from an offscreen or virtual context."
|
||||
),
|
||||
}
|
||||
text = json.dumps(report, indent=2, ensure_ascii=False)
|
||||
if args.output is not None:
|
||||
args.output.write_text(text + "\n", encoding="utf-8")
|
||||
print(text)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Validate a scene package (or a legacy import) without a GPU.
|
||||
|
||||
Stage 2 deliverable (``docs/architecture/studio-3d-stages.md``): the checks the
|
||||
loader and the compiler must agree on, runnable on a build machine and in CI.
|
||||
|
||||
Until stage 2 lands, importing an asset is not implemented; the argument parsing
|
||||
and the exit contract exist so the CI job can be wired now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_STAGE = "stage 2 (asset contracts)"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("asset", type=Path, help="scene package or legacy asset directory")
|
||||
parser.add_argument("--housing", type=Path, default=None, help="housing GLB to check")
|
||||
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = {
|
||||
"status": "not_implemented",
|
||||
"asset": str(args.asset),
|
||||
"stage": _STAGE,
|
||||
"note": (
|
||||
"Checks: manifest/schema, signature and entry hashes, coordinate frame "
|
||||
"and units, part roles, index ranges, binding weights and domain, "
|
||||
"geometry/domain match against the device description."
|
||||
),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"{report['status']}: {report['note']}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user