三维显示骨架:可执行合同 + 模块签名 + 分阶段计划 - 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