bf643de93d
- What:
1. `docs/architecture/studio-3d-stages.md` 新增「阶段 6:运行正确性与失效状态(批次 A)—— 已完成」:
做(epoch/字段级来源/设备几何身份/加载请求号/工具与重编包)、验收表格(含两份探针的
before/after 与全量计数)、"这一阶段抓到的" 7 条、兼容说明 6 条;文首进度改成阶段 6 已完成、
7/8/9 未开始;依赖关系图扩展为 6→7/8→9。
2. `docs/architecture/studio-3d-contracts.pyi` 与本阶段的契约改动同步:`SceneState` 加 `'stale'`;
`SurfaceFieldFrame`/`RenderFrame` 加 `computed_frame_id`;`ExpectedGeometry` 加 `source_geometry_id`
并注明米制 id 何时为空;`SceneAssets` 加 `source_geometry_id`/`compatible_sensor_model`/
`compatible_hardware_revisions`;`ThreeDCoordinator.__init__` 加 `clock`/`stale_grace_s`,新增
`note_field_failure()`/`show_rest()`/`epoch`/`last_presented()`。
- Why: 规格 §13 要求每阶段同时交出实现、测试、输入样例、失败样例、兼容说明与平台诊断报告;阶段 6 的
契约改动(`SceneState`、两个 `computed_frame_id`、`ExpectedGeometry`/`SceneAssets` 的新身份字段、
协调器新参数)必须先落到接口骨架里,否则下一阶段会照着过期文档实现。审查报告本身不修改
(`_review_scene3d_20260914/` 保持原样),结论与反号证据写进本仓文档。
- Verification:
· 阶段末全量复核(与本提交同一工作树):apps/studio 234 项 0 失败(14 跳过 = offscreen 下的真 GL 用例,
用 `QT_QPA_PLATFORM=windows` 单独跑为 14 项 0 失败 0 跳过);shared 316 项 0 失败 1 跳过;
`ui_check` 587 项 0 失败;`locale_check` 0 缺失(zh/en 各 398 键,仅有键集合相同);
`validate_scene.py` 对重编发布包 `source geometry recorded 46a03a5fbac1eace` 等全项 ok。
· 复现证据:`.repro/probe_stage6_r1.py`(R1/R7-b 反号)、`.repro/probe_stage6_device.py`
(R3:同尺寸不同映射被拒、表面不同被拒、设备不声明 → unverified),输出 JSON 与扫描件留在 `.repro/`。
- Not in this change: 不改代码(本提交只动文档与接口骨架);阶段 7/8/9 的计划条目(二维后端统一、
回放入口收口、能力控件、限速与真实统计、保护包密钥注入、ARM 与 2 小时门禁)尚未开始;
真机上"算法链失败时 `VRAW` 是否也过期"这一条仍待实测(风险项已写进阶段 6 小节)。
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""Studio 3D v1 design contracts, 2026-09-14.
|
|
|
|
Specification only: split into the proposed modules when implementing.
|
|
No rendering, asset loading, decryption or SDK changes are implemented here.
|
|
All published NumPy arrays must be C-contiguous and read-only (frozen dataclasses
|
|
alone do not freeze ndarray contents). Shapes are contractual, not type-checked
|
|
by NDArray. See studio-3d-design.md for threading and validation requirements.
|
|
"""
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar
|
|
from concurrent.futures import Executor
|
|
|
|
import numpy as np
|
|
from numpy.typing import NDArray
|
|
from PyQt5.QtCore import QEvent, pyqtBoundSignal
|
|
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
|
|
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
|
from orisys_app_shared.acquisition.models import SdkPacket
|
|
from orisys.finger.runtime import FingerRuntime
|
|
|
|
F32: TypeAlias = NDArray[np.float32]
|
|
F64: TypeAlias = NDArray[np.float64]
|
|
I32: TypeAlias = NDArray[np.int32]
|
|
U32: TypeAlias = NDArray[np.uint32]
|
|
Mask: TypeAlias = NDArray[np.bool_]
|
|
Role: TypeAlias = Literal['sensing', 'housing', 'reference']
|
|
Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only']
|
|
Profile: TypeAlias = Literal['desktop33', 'gles30']
|
|
OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid']
|
|
SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable', 'stale']
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Diagnostic:
|
|
code: str # Stable machine-readable code, e.g. geometry_mismatch.
|
|
severity: Literal['info', 'warning', 'error']
|
|
message: str # Human-readable, never contains keys or decrypted asset data.
|
|
details: Mapping[str, str]
|
|
|
|
|
|
class ThreeDError(Exception):
|
|
"""Boundary exception; GUI converts to diagnostic and keeps 2D operational."""
|
|
diagnostic: Diagnostic
|
|
def __init__(self, diagnostic: Diagnostic) -> None: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FrameIdentity:
|
|
source_id: str # Live stream UUID or recording UUID; not just a filename.
|
|
session_id: int
|
|
frame_id: int
|
|
t_rel_s: float # Acquisition-relative monotonic time, never wall-clock for ordering.
|
|
generation: int # Increment on activation/source/geometry change.
|
|
request_id: int # Replay cursor request; 0 for ordinary live frames.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FieldDescriptor:
|
|
quantity: str # Examples: flow_magnitude, signed_normal_displacement, depth_response.
|
|
unit: str # px, m, or explicit algorithm-response unit; never inferred from shape.
|
|
quality: Quality
|
|
signed: bool
|
|
calibration_id: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SurfaceFieldFrame:
|
|
identity: FrameIdentity
|
|
geometry_id: str
|
|
field_domain_id: str # Includes geometry, UV shape, crop/scale/flip convention.
|
|
scalar: F32 # [H,W]; measured/displayed quantity, not RGB.
|
|
scalar_descriptor: FieldDescriptor
|
|
valid: Mask # [H,W]; false or non-finite scalar means unavailable, not zero.
|
|
displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain.
|
|
displacement_quality: Quality | None
|
|
diagnostic: Diagnostic | None
|
|
computed_frame_id: int | None # Frame the array was computed for; None = unstated.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Material:
|
|
base_color_linear: tuple[float, float, float, float]
|
|
shading: Literal['unlit_scalar', 'diffuse']
|
|
double_sided: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeshPart:
|
|
part_id: str
|
|
role: Role
|
|
positions_sensor_m: F32 # [N,3], baked coordinates.
|
|
normals_sensor: F32 # [N,3], finite unit normals.
|
|
triangles: U32 # [M,3], in [0,N).
|
|
material: Material
|
|
lod_id: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SurfaceBinding:
|
|
part_id: str # The sensing part only.
|
|
geometry_id: str
|
|
field_domain_id: str
|
|
shape_hw: tuple[int, int]
|
|
sample_indices: I32 # [N,4], row-major y*W+x; unused index=-1 with weight=0.
|
|
sample_weights: F32 # [N,4], nonnegative, used weights sum to one.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CameraPreset:
|
|
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:
|
|
sensor_model: str
|
|
hardware_revision: str
|
|
geometry_id: str # Metre-scaled shape identity; '' when the device cannot state it.
|
|
field_domain_id: str
|
|
shape_hw: tuple[int, int]
|
|
source_geometry_id: str # Unit-free surface identity a device CAN reproduce.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SceneAssets:
|
|
asset_id: str
|
|
asset_version: str
|
|
asset_hash: str # Digest of canonical manifest/content, not encryption nonce.
|
|
geometry_id: str
|
|
parts: tuple[MeshPart, ...]
|
|
binding: SurfaceBinding # v1 supports exactly one sensing part.
|
|
camera_presets: tuple[CameraPreset, ...]
|
|
bounds_sensor_m: F32 # [2,3]: min, max.
|
|
source_geometry_id: str # Unit-free source geometry this asset was built from.
|
|
compatible_sensor_model: str
|
|
compatible_hardware_revisions: tuple[str, ...] # '*' means any revision.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ColorScale:
|
|
descriptor: FieldDescriptor
|
|
lower: float # Finite; lower < upper, in descriptor.unit.
|
|
upper: float
|
|
lut_id: str # Approved sequential/diverging LUT.
|
|
mode: Literal['fixed', 'automatic']
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DisplaySettings:
|
|
display_gain: float # Finite positive; affects only dynamic positions.
|
|
deform_enabled: bool
|
|
color_scale: ColorScale
|
|
housing_visible: bool
|
|
max_fps: int # Display limit; never forwarded as camera/recording FPS.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VertexFields:
|
|
scalar: F32 # [N], unscaled scientific value.
|
|
valid: Mask # [N]; any positive-weight invalid sample invalidates the vertex.
|
|
displacement_sensor_m: F32 | None # [N,3].
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RenderFrame:
|
|
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], valid even at vertices excluded by mask.
|
|
scalar: F32 # [N], BEFORE display gain.
|
|
valid: Mask # [N].
|
|
scalar_descriptor: FieldDescriptor
|
|
diagnostics: tuple[Diagnostic, ...]
|
|
computed_frame_id: int | None # Carried through so the display side re-checks it.
|
|
|
|
|
|
@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
|
|
superseded_frames: int
|
|
compose_ms: float
|
|
upload_ms: float # CPU submission time unless a separate GPU timer is used.
|
|
paint_ms: float
|
|
estimated_gpu_bytes: int
|
|
|
|
|
|
class KeyProvider(Protocol):
|
|
def get_content_key(self, key_id: str) -> bytes:
|
|
"""Return authorized content key, or raise ThreeDError; never log it."""
|
|
...
|
|
|
|
|
|
class AssetLoader(Protocol):
|
|
def load(self, path: Path, expected: ExpectedGeometry, keys: KeyProvider | None = None) -> SceneAssets:
|
|
"""CPU/IO thread. Authenticate and validate before publishing assets."""
|
|
...
|
|
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 meters, then apply a rigid transform.
|
|
|
|
transform translation is meters; its rotation contains no extra scale.
|
|
"""
|
|
...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CompileOptions:
|
|
asset_id: str
|
|
version: str
|
|
expected: ExpectedGeometry
|
|
transform_sensor_from_glb: F64 # [4,4]; GLB lengths are already meters.
|
|
max_surface_vertices: int
|
|
max_housing_triangles: int
|
|
signing_key_reference: str # Build environment reference, not private key bytes.
|
|
encryption_key_id: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CompileReport:
|
|
output: Path
|
|
asset_hash: str
|
|
diagnostics: tuple[Diagnostic, ...]
|
|
part_vertex_counts: Mapping[str, int]
|
|
part_triangle_counts: Mapping[str, int]
|
|
|
|
|
|
class SceneCompiler(Protocol):
|
|
def compile(self, surface: SceneAssets, housing_glb: Path | None, output: Path,
|
|
options: CompileOptions) -> CompileReport: ...
|
|
|
|
|
|
class FingerFrameAdapter(Protocol):
|
|
def snapshot(self, packet: SdkPacket, runtime: FingerRuntime,
|
|
identity: FrameIdentity) -> SurfaceFieldFrame | None:
|
|
"""Acquisition thread only. Copy/own one frame; no GL and no GUI calls."""
|
|
...
|
|
|
|
|
|
class BoxFrameAdapter(Protocol):
|
|
def snapshot(self, packet: SdkPacket, identity: FrameIdentity,
|
|
domain: ExpectedGeometry) -> SurfaceFieldFrame | None: ...
|
|
|
|
|
|
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; all measurement validity false, scalar unavailable."""
|
|
...
|
|
|
|
|
|
T = TypeVar('T')
|
|
class LatestFrameMailbox(Generic[T]):
|
|
def __init__(self) -> None: ...
|
|
def offer(self, frame: T) -> OfferResult:
|
|
"""Thread-safe capacity-one replacement, constant-time under the lock."""
|
|
...
|
|
def take(self) -> T | None: ...
|
|
def clear(self) -> None: ...
|
|
@property
|
|
def superseded_frames(self) -> int: ...
|
|
|
|
|
|
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: ...
|
|
|
|
|
|
def configure_surface_format(profile: Profile) -> None:
|
|
"""Before QApplication and before importing a platform-specific GL binding."""
|
|
...
|
|
|
|
|
|
class GLDispatch(Protocol):
|
|
def bind_current_context(self, context: QOpenGLContext) -> GLCapabilities:
|
|
"""GUI thread; Qt owns the current context and 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; correct owning context MUST be current."""
|
|
...
|
|
|
|
|
|
class Sensor3DWidget(QOpenGLWidget):
|
|
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities.
|
|
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic.
|
|
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited.
|
|
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
|
|
parent: QWidget | None = None) -> None: ...
|
|
def set_scene(self, assets: SceneAssets) -> None:
|
|
"""GUI: retain CPU data, schedule upload when context is current."""
|
|
...
|
|
def present(self, frame: RenderFrame) -> None: ...
|
|
def set_settings(self, settings: DisplaySettings) -> None: ...
|
|
def initializeGL(self) -> None: ...
|
|
def paintGL(self) -> None: ...
|
|
def resizeGL(self, w: int, h: int) -> None: ...
|
|
def release_resources(self) -> None: ...
|
|
def mousePressEvent(self, event: QMouseEvent) -> None: ...
|
|
def mouseMoveEvent(self, event: QMouseEvent) -> None: ...
|
|
def wheelEvent(self, event: QWheelEvent) -> None: ...
|
|
def hideEvent(self, event: QEvent) -> None: ...
|
|
def showEvent(self, event: QEvent) -> None: ...
|
|
|
|
|
|
class ThreeDCoordinator:
|
|
def __init__(self, widget: Sensor3DWidget, composer: SceneComposer,
|
|
executor: Executor, max_fps: int = 30, *,
|
|
clock: Callable[[], float] = ..., stale_grace_s: float = 1.0) -> None: ...
|
|
def activate(self, assets: SceneAssets, source_id: str) -> int: ...
|
|
def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ...
|
|
def note_field_failure(self, item: Diagnostic | None = None) -> None: ...
|
|
def tick(self) -> None: ...
|
|
def set_visible(self, visible: bool) -> None: ...
|
|
def set_settings(self, settings: DisplaySettings) -> None: ...
|
|
def show_rest(self) -> None: ...
|
|
def close(self) -> None: ...
|
|
@property
|
|
def state(self) -> SceneState: ...
|
|
@property
|
|
def generation(self) -> int: ...
|
|
@property
|
|
def epoch(self) -> int: ...
|
|
def last_presented(self) -> tuple[FrameIdentity, float] | None: ...
|
|
|
|
|
|
class ThreeDPanel(QWidget):
|
|
"""UI-only controls; no asset IO, SDK reads, or direct GL calls."""
|
|
def __init__(self, coordinator: ThreeDCoordinator, widget: Sensor3DWidget,
|
|
parent: QWidget | None = None) -> None: ...
|
|
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None: ...
|
|
def set_state(self, state: SceneState) -> None: ...
|
|
def set_render_stats(self, stats: RenderStats) -> None: ...
|