added mesh export preview

This commit is contained in:
ykcheungab
2026-09-20 13:46:58 +08:00
parent 39caecbfda
commit f9662df08c
16 changed files with 2025 additions and 11 deletions
+136 -3
View File
@@ -193,7 +193,9 @@ def cmd_validate(revision_dir: Path, blender: bool) -> int:
return _run_blender_script("validate.py", paths.source_blend, [str(revision_dir.resolve())])
def cmd_export(revision_dir: Path, profile: str) -> int:
def cmd_export(
revision_dir: Path, profile: str, housing_collections: str | None = None
) -> int:
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
@@ -205,7 +207,12 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
load_profile(paths, profile)
output_dir = export_output_dir(root, paths, profile)
output_dir.mkdir(parents=True, exist_ok=True)
job = build_export_job(paths, output_dir, profile)
collections = (
[name.strip() for name in housing_collections.split(",") if name.strip()]
if housing_collections
else None
)
job = build_export_job(paths, output_dir, profile, collections)
job_path = write_export_job(job, export_revision_dir(root, paths, profile))
print(f"Wrote export job: {job_path}")
@@ -457,6 +464,84 @@ def cmd_inspect(revision_dir: Path, args: argparse.Namespace) -> int:
return 0
def cmd_preview(revision_dir: Path, args: argparse.Namespace) -> int:
"""Open the export preview, or render one frame with --self-test."""
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths)
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
return 1
export_dir = (
args.output_dir.resolve()
if args.output_dir is not None
else export_output_dir(root, paths, args.profile)
)
try:
import numpy # noqa: F401
except ImportError:
print(
"ERROR: the preview needs its optional dependencies.\n"
"Install them with: python -m pip install -e .[preview]",
file=sys.stderr,
)
return 2
from orisys_sensor_design.preview.geometry import SMOOTH_CREASE_ANGLE_DEG
from orisys_sensor_design.preview.window import (
PreviewSetupError,
describe,
prepare,
run_preview,
)
crease = SMOOTH_CREASE_ANGLE_DEG if args.smooth else args.crease_angle
try:
state = prepare(
export_dir,
include_housing=not args.no_housing,
crease_angle_deg=crease,
)
except PreviewSetupError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 3
except ImportError:
print(
"ERROR: the preview needs its optional dependencies.\n"
"Install them with: python -m pip install -e .[preview]",
file=sys.stderr,
)
return 2
if args.no_frustum:
state["frustum"] = None
for line in describe(state):
print(line)
self_test = None
if args.self_test:
self_test = export_dir
try:
code = run_preview(state, self_test=self_test, screenshot=args.screenshot)
except ImportError as exc:
print(
f"ERROR: {exc}\n"
"Install the preview extras with: python -m pip install -e .[preview]",
file=sys.stderr,
)
return 2
if code == 4:
print(
"ERROR: no usable OpenGL context. The offscreen/minimal Qt platform "
"cannot create a platform GL context; run on a desktop session.",
file=sys.stderr,
)
return code
def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir)
@@ -494,6 +579,12 @@ def main(argv: list[str] | None = None) -> None:
export_p = sub.add_parser("export", help="Export nominal geometry for a profile")
export_p.add_argument("revision_dir", type=Path)
export_p.add_argument("--profile", default="compute", choices=["compute"])
export_p.add_argument(
"--housing-collections",
dest="housing_collections",
default=None,
help="Collections exported as housing (default: design.yaml collections.assembly)",
)
texture_p = sub.add_parser(
"texture",
@@ -623,15 +714,57 @@ def main(argv: list[str] | None = None) -> None:
help="Area above which a polygon counts as a material feature (default: 1e-4)",
)
preview_p = sub.add_parser(
"preview",
help="Preview an exported payload in a minimal PyOpenGL window",
)
preview_p.add_argument("revision_dir", type=Path)
preview_p.add_argument("--profile", default="compute", choices=["compute"])
preview_p.add_argument(
"--output-dir",
type=Path,
default=None,
help="Export payload directory (default: the profile's output_dir)",
)
preview_p.add_argument(
"--no-housing", dest="no_housing", action="store_true",
help="Draw the sensing surface only",
)
preview_p.add_argument(
"--no-frustum", dest="no_frustum", action="store_true",
help="Do not draw the exported camera frustum",
)
preview_p.add_argument(
"--wireframe", action="store_true", help="Start with the wireframe overlay on",
)
preview_p.add_argument(
"--self-test", dest="self_test", action="store_true",
help="Render one frame, report whether anything was drawn, then exit",
)
preview_p.add_argument(
"--screenshot", type=Path, default=None,
help="Render one frame to this PNG and exit (for review)",
)
preview_p.add_argument(
"--crease-angle", dest="crease_angle", type=float, default=35.0,
help="Split vertex normals above this dihedral angle (default: 35)",
)
preview_p.add_argument(
"--smooth", action="store_true",
help="Never split normals; average every face at a vertex",
)
args = parser.parse_args(argv)
if args.command == "validate":
code = cmd_validate(args.revision_dir, args.blender)
elif args.command == "export":
code = cmd_export(args.revision_dir, args.profile)
code = cmd_export(args.revision_dir, args.profile, args.housing_collections)
elif args.command == "texture":
code = cmd_texture(args.revision_dir, args)
elif args.command == "inspect":
code = cmd_inspect(args.revision_dir, args)
elif args.command == "preview":
code = cmd_preview(args.revision_dir, args)
elif args.command == "manifest":
code = cmd_manifest(args.revision_dir, args.out, args.profile)
else:
+24 -1
View File
@@ -143,15 +143,33 @@ def export_object_names(paths: RevisionPaths) -> dict[str, str]:
}
EXPORT_JOB_SCHEMA = "orisys-sensor-design/export-job-v1"
EXPORT_JOB_SCHEMA = "orisys-sensor-design/export-job-v2"
EXPORT_JOB_FILENAME = "_export_job.json"
MANIFEST_FILENAME = "manifest.json"
#: Collections exported as housing when design.yaml names none. The housing is a
#: separate artifact: mesh.npz stays sensing-surface-only because the SDK unroll
#: consumes its vertices as the sensing surface.
DEFAULT_HOUSING_COLLECTIONS = ("ASSEMBLY",)
def export_housing_collections(paths: RevisionPaths) -> list[str]:
"""Resolve the housing collections to export (design.yaml ``collections.assembly``)."""
design = load_yaml(paths.design_yaml)
collections = design.get("collections") or {}
if not isinstance(collections, dict):
raise ValueError("design.yaml 'collections' must be a mapping")
name = collections.get("assembly")
if name is None:
return list(DEFAULT_HOUSING_COLLECTIONS)
return [str(name)]
def build_export_job(
paths: RevisionPaths,
output_dir: Path,
profile: str,
housing_collections: list[str] | None = None,
) -> dict[str, Any]:
"""Build the JSON job dict that Blender's export.py consumes (stdlib JSON)."""
return {
@@ -159,6 +177,11 @@ def build_export_job(
"output": str(output_dir.resolve()),
"profile": profile,
"objects": export_object_names(paths),
"housing_collections": (
list(housing_collections)
if housing_collections is not None
else export_housing_collections(paths)
),
}
@@ -0,0 +1,31 @@
"""Minimal design-time preview of an exported sensor payload.
The preview exists so a revision can be looked at before it reaches Studio. It
renders the exported sensing surface, highlighted, inside the housing exported
beside it, with the exported camera frustum.
Its lighting and colours mirror Studio's OpenGL backend by value; see
:mod:`orisys_sensor_design.preview.appearance` for the recorded references and
the drift guard that keeps them honest. It is a design-time check, not the
authoritative renderer.
"""
from __future__ import annotations
from orisys_sensor_design.preview.geometry import (
PreviewGeometryError,
PreviewPart,
bounds,
load_housing,
load_parts,
load_sensing,
)
__all__ = [
"PreviewGeometryError",
"PreviewPart",
"bounds",
"load_housing",
"load_parts",
"load_sensing",
]
@@ -0,0 +1,130 @@
"""Appearance constants mirrored from Studio's OpenGL backend.
This preview reimplements a small renderer instead of importing Studio's, so
this module is the **single** place where Studio's look is copied. Nothing else
in the preview may hardcode a colour or a lighting term.
Every value below is taken from::
orisys-app/apps/studio/src/orisys_studio/scene3d/gl_backend.py
with the reference recorded next to it. ``tests/test_preview_appearance.py``
re-reads that file when the checkout is available and fails when Studio's values
change, so this mirror cannot drift silently.
One deliberate divergence: Studio colours the sensing surface from the
measurement LUT (or the neutral ``INVALID_COLOR`` when a frame carries no
measurement). The preview has no measurement, so it paints the surface with
:data:`SENSING_HIGHLIGHT` so the sensing surface is obvious at a glance. That
colour has no Studio counterpart and is not drift-checked.
"""
from __future__ import annotations
#: Studio asks for a 3.3 core context by default (``configure_surface_format``).
PROFILE = "desktop33"
#: gl_backend.shader_header("desktop33") / ``_HEADERS``.
SHADER_HEADER = "#version 330 core\n"
#: gl_backend.py:757 -- ``glClearColor(0.06, 0.07, 0.09, 1.0)``.
CLEAR_COLOR = (0.06, 0.07, 0.09, 1.0)
#: gl_backend.py:786 -- ``u_light_direction`` = ``(0.3, -0.5, 0.8)``.
LIGHT_DIRECTION = (0.3, -0.5, 0.8)
#: gl_backend.py:175 -- ``base * (AMBIENT + DIFFUSE * lambert)``.
AMBIENT = 0.35
DIFFUSE = 0.65
#: gl_backend.py:930 -- ``_PART_COLORS["housing"]``.
HOUSING_COLOR = (0.32, 0.33, 0.35, 1.0)
#: gl_backend.py:935 -- ``_base_color`` fallback for a part with no entry.
PART_FALLBACK_COLOR = (0.45, 0.45, 0.47, 1.0)
#: Preview-only highlight for the sensing surface; see the module docstring.
SENSING_HIGHLIGHT = (0.95, 0.55, 0.15, 1.0)
#: Preview-only overlay colours, with no Studio counterpart.
WIREFRAME_COLOR = (0.82, 0.85, 0.90, 1.0)
FRUSTUM_COLOR = (0.35, 0.75, 1.0, 1.0)
#: Mirrors the shared body of ``_VERTEX_BODY`` for the two attributes the
#: preview uploads (position and normal); the scalar/valid varyings Studio also
#: carries feed its LUT and are not used here.
DIFFUSE_VERTEX_BODY = """
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec3 v_normal;
void main() {
v_normal = a_normal;
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
#: Mirrors ``_DIFFUSE_FRAGMENT_BODY`` (gl_backend.py:159-177) exactly, so the
#: preview and Studio shade identically.
DIFFUSE_FRAGMENT_BODY = """
in vec3 v_normal;
uniform vec3 u_light_direction;
uniform vec4 u_base_color;
out vec4 frag_color;
void main() {
vec3 n = normalize(v_normal);
if (!gl_FrontFacing) {
n = -n;
}
float lambert = max(dot(n, normalize(u_light_direction)), 0.0);
frag_color = vec4(u_base_color.rgb * (0.35 + 0.65 * lambert), u_base_color.a);
}
"""
#: Flat colour lines for the wireframe overlay and the camera frustum.
LINE_VERTEX_BODY = """
layout(location = 0) in vec3 a_position;
uniform mat4 u_view;
uniform mat4 u_projection;
void main() {
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
LINE_FRAGMENT_BODY = """
uniform vec4 u_color;
out vec4 frag_color;
void main() {
frag_color = u_color;
}
"""
def base_color(part_id: str) -> tuple[float, float, float, float]:
"""Studio's base colour for a part; housing graphite, otherwise the fallback."""
if part_id == "housing":
return HOUSING_COLOR
return PART_FALLBACK_COLOR
def shader_source(name: str) -> tuple[str, str]:
"""Return ``(vertex, fragment)`` sources for a program name."""
if name == "diffuse":
return (
SHADER_HEADER + DIFFUSE_VERTEX_BODY,
SHADER_HEADER + DIFFUSE_FRAGMENT_BODY,
)
if name == "line":
return SHADER_HEADER + LINE_VERTEX_BODY, SHADER_HEADER + LINE_FRAGMENT_BODY
raise ValueError(f"unknown shader program: {name!r}")
+229
View File
@@ -0,0 +1,229 @@
"""Orbit camera and camera-frustum maths. Pure numpy, no Qt, no OpenGL.
Conventions follow Studio (``scene3d/camera.py``): sensor frame, right-handed,
metres, column vectors, so ``p_camera = view @ p_sensor`` and
``p_clip = projection @ p_camera``. The view matrix is built from a target, a
distance and two angles, which keeps orbit/pan/zoom trivial and "reset" exact
rather than approximate.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
#: Studio's distance limits are absolute because it works in metres. The exported
#: sensor frame is *not* metres: ``origin_sensor`` carries a uniform scale, so a
#: ~21 mm part exports with bounds around +/-574 units. The preview therefore
#: keeps Studio's behaviour but expresses the limits as ratios of the fitted
#: radius, which is unit-agnostic.
MIN_DISTANCE_RATIO = 0.05
MAX_DISTANCE_RATIO = 50.0
#: Radians per pixel of drag, and the pitch limit that keeps the up vector sane.
ORBIT_RAD_PER_PX = 0.008
MAX_PITCH_RAD = math.radians(89.0)
#: Zoom per wheel step.
ZOOM_PER_STEP = 0.12
#: Studio's default vertical field of view.
DEFAULT_FOV_Y_RAD = 0.6
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
@dataclass
class OrbitCamera:
"""A target, a distance and two angles; everything else is derived."""
target: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float64))
distance: float = 0.1
yaw: float = math.radians(35.0)
pitch: float = math.radians(25.0)
up: np.ndarray = field(
default_factory=lambda: np.array([0.0, 0.0, 1.0], dtype=np.float64)
)
fov_y: float = DEFAULT_FOV_Y_RAD
aspect: float = 1.0
fitted_radius: float = 1.0
def _distance_limits(self) -> tuple[float, float]:
radius = max(self.fitted_radius, 1e-12)
return radius * MIN_DISTANCE_RATIO, radius * MAX_DISTANCE_RATIO
# ------------------------------------------------------------------- setup
def fit(self, low: np.ndarray, high: np.ndarray, aspect: float) -> None:
"""Frame the whole scene from its bounds, keeping the angles.
The distance comes from the bounding sphere and the *narrower* of the two
field-of-view axes, so a wide window does not clip the model vertically
while a tall one clips it horizontally.
"""
low = np.asarray(low, dtype=np.float64).reshape(3)
high = np.asarray(high, dtype=np.float64).reshape(3)
self.target = (low + high) * 0.5
radius = float(np.linalg.norm(high - low) * 0.5)
self.fitted_radius = max(radius, 1e-9)
self.aspect = max(float(aspect), 1e-6)
self.fov_y = _clamp(self.fov_y, 0.1, 2.6)
vertical_half = 0.5 * self.fov_y
horizontal_half = math.atan(math.tan(vertical_half) * self.aspect)
half = max(min(vertical_half, horizontal_half), 1e-6)
low_limit, high_limit = self._distance_limits()
self.distance = _clamp(
self.fitted_radius / math.sin(half) * 1.25, low_limit, high_limit
)
def reset_angles(self) -> None:
self.yaw = math.radians(35.0)
self.pitch = math.radians(25.0)
# -------------------------------------------------------------- interaction
def orbit(self, dx_px: float, dy_px: float) -> None:
self.yaw -= float(dx_px) * ORBIT_RAD_PER_PX
self.pitch = _clamp(
self.pitch + float(dy_px) * ORBIT_RAD_PER_PX, -MAX_PITCH_RAD, MAX_PITCH_RAD
)
def pan(self, dx_px: float, dy_px: float, viewport_height_px: int) -> None:
"""Slide the target in the camera plane, scaled so the drag tracks the model."""
height = max(int(viewport_height_px), 1)
world_per_px = (
2.0 * self.distance * math.tan(0.5 * self.fov_y) / height
)
view = self.view()
right = view[0, :3]
up = view[1, :3]
self.target = self.target - right * (float(dx_px) * world_per_px)
self.target = self.target + up * (float(dy_px) * world_per_px)
def zoom(self, steps: float) -> None:
factor = math.exp(-float(steps) * ZOOM_PER_STEP)
low_limit, high_limit = self._distance_limits()
self.distance = _clamp(self.distance * factor, low_limit, high_limit)
# ------------------------------------------------------------- derivation
def eye(self) -> np.ndarray:
direction = np.array(
[
math.cos(self.pitch) * math.cos(self.yaw),
math.cos(self.pitch) * math.sin(self.yaw),
math.sin(self.pitch),
],
dtype=np.float64,
)
return self.target + direction * self.distance
def view(self) -> np.ndarray:
eye = self.eye()
forward = self.target - eye
norm = float(np.linalg.norm(forward))
forward = forward / norm if norm > 1e-12 else np.array([0.0, 0.0, -1.0])
up = np.asarray(self.up, dtype=np.float64)
right = np.cross(forward, up)
right_norm = float(np.linalg.norm(right))
if right_norm <= 1e-9:
# Looking straight down the up axis: pick any perpendicular.
right = np.array([1.0, 0.0, 0.0])
else:
right = right / right_norm
true_up = np.cross(right, forward)
matrix = np.eye(4, dtype=np.float64)
matrix[0, :3] = right
matrix[1, :3] = true_up
matrix[2, :3] = -forward
matrix[0, 3] = -float(np.dot(right, eye))
matrix[1, 3] = -float(np.dot(true_up, eye))
matrix[2, 3] = float(np.dot(forward, eye))
return matrix
def projection(self) -> np.ndarray:
"""Perspective projection bracketing the model, not an arbitrary range.
Studio derives near/far from the distance, which spends only ~1% of the
depth buffer on the model (a 10,000:1 ratio) and invites z-fighting where
exported parts genuinely touch. Bracketing the fitted sphere instead keeps
the whole buffer on the geometry; the near clamp stays valid when the
camera ends up inside the bounding sphere.
"""
radius = max(self.fitted_radius, 1e-12)
near = max(self.distance - 1.5 * radius, self.distance * 0.02, 1e-9)
far = max(self.distance + 1.5 * radius, near * 1.001)
f = 1.0 / math.tan(0.5 * self.fov_y)
aspect = max(self.aspect, 1e-6)
matrix = np.zeros((4, 4), dtype=np.float64)
matrix[0, 0] = f / aspect
matrix[1, 1] = f
matrix[2, 2] = (far + near) / (near - far)
matrix[2, 3] = (2.0 * far * near) / (near - far)
matrix[3, 2] = -1.0
return matrix
def pose_is_rigid(world_to_cam: np.ndarray, tolerance: float = 1e-3) -> bool:
"""Whether a sensor->camera matrix is a rigid transform.
A scaled pose (observed on ``finger/r01`` earlier, where ``origin_sensor``
carried a 0.018388 uniform scale) still draws, but a frustum built from it is
distorted, so the window warns instead of pretending otherwise.
"""
matrix = np.asarray(world_to_cam, dtype=np.float64)
if matrix.shape != (4, 4):
return False
norms = np.linalg.norm(matrix[:3, :3], axis=0)
return bool(np.all(np.abs(norms - 1.0) <= tolerance))
def frustum_segments(
K: np.ndarray,
world_to_cam: np.ndarray,
*,
depth_m: float | None = None,
image_size: tuple[int, int] | None = None,
) -> np.ndarray:
"""Camera frustum as line segments in the sensor frame.
``K`` is the OpenCV-style intrinsic matrix and ``world_to_cam`` maps sensor to
camera. Blender's camera looks down local ``-Z`` and the SDK projects with
``z = -p_camera.z``, so the image plane sits at camera-space ``z = -depth``.
Image rows run downwards while camera ``+Y`` points up, hence the negated
vertical term.
Returns an ``(N, 2, 3)`` array of segment endpoints.
"""
matrix = np.asarray(world_to_cam, dtype=np.float64)
if matrix.shape != (4, 4):
raise ValueError(f"world_to_cam must be (4, 4), got {matrix.shape}")
K = np.asarray(K, dtype=np.float64)
if K.shape != (3, 3):
raise ValueError(f"K must be (3, 3), got {K.shape}")
if image_size is None:
cx, cy = float(K[0, 2]), float(K[1, 2])
width, height = 2.0 * cx, 2.0 * cy
else:
width, height = (float(image_size[0]), float(image_size[1]))
cx, cy = float(K[0, 2]), float(K[1, 2])
if depth_m is None:
# Depth of the sensor-frame origin *in camera space*: the pose may carry a
# scale, so a distance measured in sensor units would be wrong here.
depth_m = -float(matrix[2, 3])
if not np.isfinite(depth_m) or depth_m <= 1e-9:
depth_m = 1.0
sensor_from_cam = np.linalg.inv(matrix)
corners = []
for u, v in ((0.0, 0.0), (width, 0.0), (width, height), (0.0, height)):
x = (u - cx) / float(K[0, 0]) * depth_m
y = -(v - cy) / float(K[1, 1]) * depth_m
point = sensor_from_cam @ np.array([x, y, -depth_m, 1.0])
corners.append(point[:3])
apex = (sensor_from_cam @ np.array([0.0, 0.0, 0.0, 1.0]))[:3]
segments = [[apex, corner] for corner in corners]
segments.extend(
[corners[index], corners[(index + 1) % 4]] for index in range(4)
)
return np.asarray(segments, dtype=np.float64)
@@ -0,0 +1,294 @@
"""Preview geometry: the sensing surface from the payload, housing beside it.
Coordinates are the sensor frame, metres, matching the export and Studio. The
sensing surface always comes from ``mesh.npz`` -- never from ``housing.npz`` --
because that file is the sensing mesh the SDK unroll consumes.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
import numpy as np
MESH_FILENAME = "mesh.npz"
HOUSING_FILENAME = "housing.npz"
ROLE_SENSING = "sensing"
ROLE_HOUSING = "housing"
#: Dihedral angle above which a vertex normal is split, so a machined edge reads
#: as a crease instead of a smooth roll-off. Measured on the Xynova housing,
#: 13.5% of frame edges are sharper than 30 degrees (p90 = 86 degrees) while the
#: sensing surface has none, which is why the two parts want different treatment.
DEFAULT_CREASE_ANGLE_DEG = 35.0
#: Sentinel meaning "never split": keep one averaged normal per vertex.
SMOOTH_CREASE_ANGLE_DEG = 180.0
class PreviewGeometryError(RuntimeError):
"""Raised when a payload cannot be read as preview geometry."""
@dataclass(frozen=True)
class PreviewPart:
"""One drawable part, in the sensor frame."""
name: str
role: str
positions: np.ndarray
normals: np.ndarray
triangles: np.ndarray
@property
def vertex_count(self) -> int:
return int(self.positions.shape[0])
@property
def triangle_count(self) -> int:
return int(self.triangles.shape[0])
def compute_normals(positions: np.ndarray, triangles: np.ndarray) -> np.ndarray:
"""Area-weighted vertex normals; zero-length results fall back to +Z.
Neither ``mesh.npz`` nor ``housing.npz`` stores normals, so they are derived
here. The un-normalised cross product already carries the triangle area, which
is what makes the result area-weighted without an extra pass.
"""
positions = np.asarray(positions, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
normals = np.zeros_like(positions)
if triangles.size:
a = positions[triangles[:, 0]]
b = positions[triangles[:, 1]]
c = positions[triangles[:, 2]]
face = np.cross(b - a, c - a)
for corner in range(3):
np.add.at(normals, triangles[:, corner], face)
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
safe = np.where(lengths > 1e-20, lengths, 1.0)
unit = normals / safe
degenerate = (lengths <= 1e-20).ravel()
if np.any(degenerate):
unit[degenerate] = np.array([0.0, 0.0, 1.0])
return unit.astype(np.float32)
def split_vertices(
positions: np.ndarray,
triangles: np.ndarray,
crease_angle_deg: float = DEFAULT_CREASE_ANGLE_DEG,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Crease-aware normals: one vertex per (vertex, face cluster).
Averaging every incident face at a shared vertex rounds off sharp edges, so a
90 degree machined corner is shaded as if it were a fillet. Faces are grouped
per vertex by normal similarity and each group gets its own averaged normal,
which leaves flat faces flat, keeps genuinely smooth regions smooth, and makes
an edge above ``crease_angle_deg`` a hard crease.
``crease_angle_deg = 180`` reproduces the plain averaged result, so callers can
opt out. Output vertex count only grows where edges actually are sharp.
"""
positions = np.asarray(positions, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
if triangles.size == 0:
return (
positions.astype(np.float32),
compute_normals(positions, triangles),
triangles.astype(np.int32),
)
if crease_angle_deg >= SMOOTH_CREASE_ANGLE_DEG - 1e-9:
return (
positions.astype(np.float32),
compute_normals(positions, triangles),
triangles.astype(np.int32),
)
a = positions[triangles[:, 0]]
b = positions[triangles[:, 1]]
c = positions[triangles[:, 2]]
face_vector = np.cross(b - a, c - a) # length carries the area
lengths = np.linalg.norm(face_vector, axis=1, keepdims=True)
face_unit = face_vector / np.where(lengths > 0.0, lengths, 1.0)
incident: list[list[int]] = [[] for _ in range(len(positions))]
for face_index, triangle in enumerate(triangles):
for vertex_index in triangle:
incident[int(vertex_index)].append(face_index)
threshold = math.cos(math.radians(max(0.0, float(crease_angle_deg))))
out_positions: list[np.ndarray] = []
out_normals: list[np.ndarray] = []
face_cluster: dict[tuple[int, int], int] = {}
cluster_output: dict[tuple[int, int], int] = {}
for vertex_index in range(len(positions)):
faces = incident[vertex_index]
if not faces:
continue
assigned: dict[int, int] = {}
clusters: list[list[int]] = []
for face_index in faces:
if face_index in assigned:
continue
cluster_id = len(clusters)
seed = face_unit[face_index]
members = [face_index]
assigned[face_index] = cluster_id
for other in faces:
if other in assigned:
continue
if float(np.dot(seed, face_unit[other])) >= threshold:
assigned[other] = cluster_id
members.append(other)
clusters.append(members)
for cluster_id, members in enumerate(clusters):
accumulated = np.zeros(3, dtype=np.float64)
for face_index in members:
accumulated += face_vector[face_index]
norm = float(np.linalg.norm(accumulated))
normal = (
accumulated / norm
if norm > 1e-20
else np.array([0.0, 0.0, 1.0], dtype=np.float64)
)
cluster_output[(vertex_index, cluster_id)] = len(out_positions)
out_positions.append(positions[vertex_index])
out_normals.append(normal)
for face_index, cluster_id in assigned.items():
face_cluster[(vertex_index, face_index)] = cluster_id
remapped = np.empty_like(triangles)
for face_index in range(len(triangles)):
for corner in range(triangles.shape[1]):
vertex_index = int(triangles[face_index, corner])
cluster_id = face_cluster[(vertex_index, face_index)]
remapped[face_index, corner] = cluster_output[(vertex_index, cluster_id)]
return (
np.asarray(out_positions, dtype=np.float32).reshape(-1, 3),
np.asarray(out_normals, dtype=np.float32).reshape(-1, 3),
remapped.astype(np.int32),
)
def _open_npz(path: Path) -> dict[str, np.ndarray]:
if not Path(path).is_file():
raise PreviewGeometryError(f"missing geometry file: {path}")
try:
with np.load(path, allow_pickle=False) as handle:
return {key: handle[key] for key in handle.files}
except (OSError, ValueError) as exc:
raise PreviewGeometryError(f"cannot read {path}: {exc}") from exc
def _require(data: dict[str, np.ndarray], keys: tuple[str, ...], path: Path) -> None:
missing = [key for key in keys if key not in data]
if missing:
raise PreviewGeometryError(
f"{path} is missing {', '.join(missing)} (found: {', '.join(sorted(data))})"
)
def load_sensing(
export_dir: Path, *, crease_angle_deg: float = DEFAULT_CREASE_ANGLE_DEG
) -> PreviewPart:
"""The sensing surface, from the SDK payload."""
path = Path(export_dir) / MESH_FILENAME
data = _open_npz(path)
_require(data, ("vertices", "faces"), path)
positions = np.asarray(data["vertices"], dtype=np.float32)
triangles = np.asarray(data["faces"], dtype=np.int32)
if positions.ndim != 2 or positions.shape[1] != 3:
raise PreviewGeometryError(f"{path}: vertices must be (N, 3)")
if triangles.ndim != 2 or triangles.shape[1] != 3:
raise PreviewGeometryError(f"{path}: faces must be (M, 3) triangles")
split_positions, normals, split_triangles = split_vertices(
positions, triangles, crease_angle_deg
)
return PreviewPart(
name="sensing_surface",
role=ROLE_SENSING,
positions=split_positions,
normals=normals,
triangles=split_triangles,
)
def load_housing(
export_dir: Path, *, crease_angle_deg: float = DEFAULT_CREASE_ANGLE_DEG
) -> list[PreviewPart]:
"""Housing parts, split by ``part_index``/``part_names``.
A missing or empty ``housing.npz`` yields no parts rather than an error: the
preview then shows the sensing surface alone, which is what an export without
an ``ASSEMBLY`` collection legitimately produces.
"""
path = Path(export_dir) / HOUSING_FILENAME
if not path.is_file():
return []
data = _open_npz(path)
_require(data, ("vertices", "faces"), path)
positions = np.asarray(data["vertices"], dtype=np.float32)
triangles = np.asarray(data["faces"], dtype=np.int32)
if triangles.shape[0] == 0:
return []
names = [str(name) for name in data.get("part_names", [])]
part_index = np.asarray(data.get("part_index", np.zeros(len(triangles))), dtype=np.int64)
if part_index.shape[0] != triangles.shape[0]:
raise PreviewGeometryError(
f"{path}: part_index has {part_index.shape[0]} entries for "
f"{triangles.shape[0]} faces"
)
parts: list[PreviewPart] = []
for index in sorted(set(int(value) for value in part_index)):
selected = triangles[part_index == index]
if selected.shape[0] == 0:
continue
used = np.unique(selected)
remap = np.full(int(positions.shape[0]), -1, dtype=np.int64)
remap[used] = np.arange(used.size)
local_triangles = remap[selected].astype(np.int32)
local_positions = positions[used].astype(np.float32)
name = names[index] if 0 <= index < len(names) else f"housing_{index}"
split_positions, normals, split_triangles = split_vertices(
local_positions, local_triangles, crease_angle_deg
)
parts.append(
PreviewPart(
name=name,
role=ROLE_HOUSING,
positions=split_positions,
normals=normals,
triangles=split_triangles,
)
)
return parts
def load_parts(
export_dir: Path,
*,
include_housing: bool = True,
crease_angle_deg: float = DEFAULT_CREASE_ANGLE_DEG,
) -> list[PreviewPart]:
"""Every drawable part: the sensing surface first, then housing."""
parts = [load_sensing(export_dir, crease_angle_deg=crease_angle_deg)]
if include_housing:
parts.extend(load_housing(export_dir, crease_angle_deg=crease_angle_deg))
return parts
def bounds(parts: list[PreviewPart]) -> np.ndarray:
"""Axis-aligned ``[low, high]`` bounds over every part, in the sensor frame."""
arrays = [part.positions for part in parts if part.vertex_count]
if not arrays:
return np.zeros((2, 3), dtype=np.float64)
stacked = np.concatenate(arrays, axis=0).astype(np.float64)
return np.stack((stacked.min(axis=0), stacked.max(axis=0)))
+285
View File
@@ -0,0 +1,285 @@
"""The preview viewport: a QOpenGLWidget drawing with PyOpenGL.
Shading mirrors Studio's backend by value through
:mod:`orisys_sensor_design.preview.appearance`. PyOpenGL is imported *inside*
``initializeGL``, after Qt has made a real context current, copying Studio's
import discipline: importing it at module scope picks a binding before the
platform Qt actually used is known.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QSurfaceFormat
from PyQt5.QtWidgets import QOpenGLWidget
from orisys_sensor_design.preview import appearance
from orisys_sensor_design.preview.camera import OrbitCamera
from orisys_sensor_design.preview.geometry import ROLE_SENSING, PreviewPart
FLOATS_PER_VERTEX = 6 # position (3) + normal (3)
def configure_surface_format() -> None:
"""Select a 3.3 core surface format. Call before ``QApplication`` exists."""
fmt = QSurfaceFormat()
fmt.setRenderableType(QSurfaceFormat.OpenGL)
fmt.setVersion(3, 3)
fmt.setProfile(QSurfaceFormat.CoreProfile)
fmt.setDepthBufferSize(24)
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
fmt.setSamples(0)
QSurfaceFormat.setDefaultFormat(fmt)
def _compile_program(GL: Any, vertex_source: str, fragment_source: str) -> int:
program = int(GL.glCreateProgram())
shaders = []
for kind, source in (
(GL.GL_VERTEX_SHADER, vertex_source),
(GL.GL_FRAGMENT_SHADER, fragment_source),
):
shader = int(GL.glCreateShader(kind))
GL.glShaderSource(shader, source)
GL.glCompileShader(shader)
if not GL.glGetShaderiv(shader, GL.GL_COMPILE_STATUS):
log = GL.glGetShaderInfoLog(shader)
raise RuntimeError(f"shader compile failed: {log!r}")
GL.glAttachShader(program, shader)
shaders.append(shader)
GL.glLinkProgram(program)
if not GL.glGetProgramiv(program, GL.GL_LINK_STATUS):
raise RuntimeError(f"program link failed: {GL.glGetProgramInfoLog(program)!r}")
for shader in shaders:
GL.glDeleteShader(shader)
return program
class PreviewGLWidget(QOpenGLWidget):
"""Draws the sensing surface, its housing and the camera frustum."""
def __init__(
self,
parts: list[PreviewPart],
camera: OrbitCamera,
*,
frustum: np.ndarray | None = None,
parent: Any = None,
) -> None:
super().__init__(parent)
self.parts = list(parts)
self.camera = camera
self.frustum = frustum
self.show_housing = True
self.show_wireframe = False
self.show_frustum = True
self.gl_ok = False
self.gl_error: str | None = None
self._buffers: list[dict[str, Any]] = []
self._frustum_buffer: dict[str, Any] | None = None
self._programs: dict[str, int] = {}
self._uniforms: dict[str, dict[str, int]] = {}
self.setFocusPolicy(Qt.StrongFocus)
# ------------------------------------------------------------------ GL life
def initializeGL(self) -> None: # noqa: N802 - Qt naming
try:
from OpenGL import GL
self._GL = GL
self._programs["diffuse"] = _compile_program(
GL, *appearance.shader_source("diffuse")
)
self._programs["line"] = _compile_program(
GL, *appearance.shader_source("line")
)
self._uniforms["diffuse"] = {
name: GL.glGetUniformLocation(self._programs["diffuse"], name)
for name in ("u_view", "u_projection", "u_light_direction", "u_base_color")
}
self._uniforms["line"] = {
name: GL.glGetUniformLocation(self._programs["line"], name)
for name in ("u_view", "u_projection", "u_color")
}
for part in self.parts:
self._buffers.append(self._upload_part(part))
if self.frustum is not None and len(self.frustum):
self._frustum_buffer = self._upload_lines(self.frustum)
GL.glEnable(GL.GL_DEPTH_TEST)
GL.glDisable(GL.GL_CULL_FACE)
self.gl_ok = True
except Exception as exc: # a missing driver must not crash the app
self.gl_error = f"{type(exc).__name__}: {exc}"
self.gl_ok = False
def _upload_part(self, part: PreviewPart) -> dict[str, Any]:
GL = self._GL
positions = np.ascontiguousarray(part.positions, dtype=np.float32)
normals = np.ascontiguousarray(part.normals, dtype=np.float32)
indices = np.ascontiguousarray(
part.triangles.astype(np.uint32).ravel(), dtype=np.uint32
)
vao = int(GL.glGenVertexArrays(1))
GL.glBindVertexArray(vao)
position_buffer = int(GL.glGenBuffers(1))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, position_buffer)
GL.glBufferData(
GL.GL_ARRAY_BUFFER, positions.nbytes, positions, GL.GL_STATIC_DRAW
)
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
normal_buffer = int(GL.glGenBuffers(1))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, normal_buffer)
GL.glBufferData(GL.GL_ARRAY_BUFFER, normals.nbytes, normals, GL.GL_STATIC_DRAW)
GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
element_buffer = int(GL.glGenBuffers(1))
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, element_buffer)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER, indices.nbytes, indices, GL.GL_STATIC_DRAW
)
GL.glBindVertexArray(0)
return {"part": part, "vao": vao, "index_count": int(indices.size)}
def _upload_lines(self, segments: np.ndarray) -> dict[str, Any]:
GL = self._GL
points = np.asarray(segments, dtype=np.float32).reshape(-1, 3)
vao = int(GL.glGenVertexArrays(1))
GL.glBindVertexArray(vao)
vbo = int(GL.glGenBuffers(1))
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo)
GL.glBufferData(GL.GL_ARRAY_BUFFER, points.nbytes, points, GL.GL_STATIC_DRAW)
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, 12, GL.ctypes.c_void_p(0))
GL.glBindVertexArray(0)
return {"vao": vao, "count": int(points.shape[0])}
# ------------------------------------------------------------------- drawing
def _set_matrices(self, program_key: str) -> None:
GL = self._GL
# The camera builds row-major matrices (``p_camera = view @ p_sensor``);
# OpenGL reads uniform matrix data column-major, so transpose on upload.
view = np.ascontiguousarray(self.camera.view(), dtype=np.float32)
projection = np.ascontiguousarray(self.camera.projection(), dtype=np.float32)
uniforms = self._uniforms[program_key]
GL.glUniformMatrix4fv(uniforms["u_view"], 1, GL.GL_TRUE, view)
GL.glUniformMatrix4fv(uniforms["u_projection"], 1, GL.GL_TRUE, projection)
def paintGL(self) -> None: # noqa: N802 - Qt naming
GL = self._GL
GL.glClearColor(*appearance.CLEAR_COLOR)
GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT)
GL.glUseProgram(self._programs["diffuse"])
self._set_matrices("diffuse")
uniforms = self._uniforms["diffuse"]
GL.glUniform3f(uniforms["u_light_direction"], *appearance.LIGHT_DIRECTION)
for entry in self._buffers:
part = entry["part"]
if part.role != ROLE_SENSING and not self.show_housing:
continue
color = (
appearance.SENSING_HIGHLIGHT
if part.role == ROLE_SENSING
else appearance.base_color("housing")
)
GL.glUniform4f(uniforms["u_base_color"], *color)
GL.glBindVertexArray(entry["vao"])
GL.glDrawElements(
GL.GL_TRIANGLES, entry["index_count"], GL.GL_UNSIGNED_INT, None
)
if self.show_wireframe:
GL.glUseProgram(self._programs["line"])
self._set_matrices("line")
line_uniforms = self._uniforms["line"]
GL.glUniform4f(line_uniforms["u_color"], *appearance.WIREFRAME_COLOR)
GL.glPolygonMode(GL.GL_FRONT_AND_BACK, GL.GL_LINE)
for entry in self._buffers:
part = entry["part"]
if part.role != ROLE_SENSING and not self.show_housing:
continue
GL.glBindVertexArray(entry["vao"])
GL.glDrawElements(
GL.GL_TRIANGLES, entry["index_count"], GL.GL_UNSIGNED_INT, None
)
GL.glPolygonMode(GL.GL_FRONT_AND_BACK, GL.GL_FILL)
if self.show_frustum and self._frustum_buffer is not None:
GL.glUseProgram(self._programs["line"])
self._set_matrices("line")
GL.glUniform4f(
self._uniforms["line"]["u_color"], *appearance.FRUSTUM_COLOR
)
GL.glBindVertexArray(self._frustum_buffer["vao"])
GL.glDrawArrays(GL.GL_LINES, 0, self._frustum_buffer["count"])
GL.glBindVertexArray(0)
GL.glUseProgram(0)
def resizeGL(self, width: int, height: int) -> None: # noqa: N802 - Qt naming
from OpenGL import GL
GL.glViewport(0, 0, max(width, 1), max(height, 1))
self.camera.aspect = max(width, 1) / max(height, 1)
# -------------------------------------------------------------- interaction
def mousePressEvent(self, event: Any) -> None: # noqa: N802
self._last = event.pos()
def mouseMoveEvent(self, event: Any) -> None: # noqa: N802
last = getattr(self, "_last", None)
if last is None:
return
delta = event.pos() - last
self._last = event.pos()
if event.buttons() & Qt.LeftButton:
self.camera.orbit(delta.x(), delta.y())
elif event.buttons() & (Qt.MiddleButton | Qt.RightButton):
self.camera.pan(delta.x(), delta.y(), self.height())
self.update()
def wheelEvent(self, event: Any) -> None: # noqa: N802
self.camera.zoom(event.angleDelta().y() / 120.0)
self.update()
def keyPressEvent(self, event: Any) -> None: # noqa: N802
key = event.key()
if key == Qt.Key_H:
self.show_housing = not self.show_housing
elif key == Qt.Key_W:
self.show_wireframe = not self.show_wireframe
elif key == Qt.Key_C:
self.show_frustum = not self.show_frustum
else:
return super().keyPressEvent(event)
self.update()
# ------------------------------------------------------------- diagnostics
def grab_rgb(self) -> np.ndarray:
"""The current frame as ``(H, W, 4)`` uint8, for self-tests.
Uses ``bytesPerLine`` and an explicit ``setsize``: reading a QImage
through its deprecated size accessors segfaults, and the row stride is
not guaranteed to equal ``width * 4``.
"""
from PyQt5.QtGui import QImage
image = self.grabFramebuffer()
if image.isNull():
return np.zeros((0, 0, 4), dtype=np.uint8)
image = image.convertToFormat(QImage.Format_RGBA8888)
width, height = int(image.width()), int(image.height())
stride = int(image.bytesPerLine())
pointer = image.constBits()
pointer.setsize(stride * height)
raw = np.frombuffer(pointer, np.uint8).reshape(height, stride)
# Copy: the slice is contiguous, so ascontiguousarray would return a view
# onto the QImage's buffer and dangle once the QImage is collected.
return np.array(raw[:, : width * 4], dtype=np.uint8).reshape(height, width, 4)
+182
View File
@@ -0,0 +1,182 @@
"""The preview window and the standalone entry point.
The viewport widget is kept separate from the chrome so it can be embedded later;
:func:`run_preview` is the standalone window the CLI opens.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
from orisys_sensor_design.preview.camera import (
OrbitCamera,
frustum_segments,
pose_is_rigid,
)
from orisys_sensor_design.preview.geometry import (
ROLE_SENSING,
PreviewPart,
bounds,
load_parts,
)
EXPORT_FILES = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
class PreviewSetupError(RuntimeError):
"""Raised when the payload cannot be prepared for preview."""
def prepare(
export_dir: Path,
*,
include_housing: bool = True,
width: int = 1280,
height: int = 800,
crease_angle_deg: float | None = None,
) -> dict[str, Any]:
"""Load the payload and build the camera and frustum the window needs."""
from orisys_sensor_design.preview.geometry import DEFAULT_CREASE_ANGLE_DEG
if crease_angle_deg is None:
crease_angle_deg = DEFAULT_CREASE_ANGLE_DEG
export_dir = Path(export_dir)
missing = [name for name in EXPORT_FILES if not (export_dir / name).is_file()]
if missing:
raise PreviewSetupError(
f"{export_dir} is missing {', '.join(missing)}; "
"run: orisys-design export <revision_dir>"
)
parts = load_parts(
export_dir,
include_housing=include_housing,
crease_angle_deg=crease_angle_deg,
)
if not parts:
raise PreviewSetupError(f"{export_dir} contains no drawable geometry")
low, high = bounds(parts)
camera = OrbitCamera()
camera.fit(low, high, max(width, 1) / max(height, 1))
with np.load(export_dir / "camera_intrinsics.npz", allow_pickle=False) as handle:
K = np.asarray(handle["K"], dtype=np.float64)
image_size = (
int(handle["width"]) if "width" in handle.files else None,
int(handle["height"]) if "height" in handle.files else None,
)
world_to_cam = np.load(export_dir / "world_to_cam.npy").astype(np.float64)
# Place the frustum's image plane at the model centre, measured in camera
# space: the pose may carry a scale, so sensor-unit distances would be wrong.
centre = (low + high) * 0.5
depth = float(-(world_to_cam @ np.array([*centre, 1.0]))[2])
if not np.isfinite(depth) or depth <= 1e-9:
depth = 1.0
frustum = frustum_segments(
K, world_to_cam, depth_m=depth, image_size=image_size
)
return {
"parts": parts,
"camera": camera,
"frustum": frustum,
"bounds": (low, high),
"K": K,
"world_to_cam": world_to_cam,
"pose_is_rigid": pose_is_rigid(world_to_cam),
"export_dir": export_dir,
}
def describe(state: dict[str, Any]) -> list[str]:
"""Human-readable legend lines for the window and the CLI banner."""
parts: list[PreviewPart] = state["parts"]
lines = [
f"export: {state['export_dir']}",
f"sensing surface: {parts[0].name} "
f"({parts[0].vertex_count} verts, {parts[0].triangle_count} tris) -- highlighted",
]
housing = [part for part in parts if part.role != ROLE_SENSING]
if housing:
total = sum(part.triangle_count for part in housing)
names = ", ".join(part.name for part in housing)
lines.append(f"housing: {names} ({total} tris total)")
else:
lines.append("housing: none exported")
low, high = state["bounds"]
extent = np.asarray(high) - np.asarray(low)
lines.append(
"sensor-frame extent: "
+ " x ".join(f"{value:.1f}" for value in extent)
+ " (export units, not metres)"
)
if not state["pose_is_rigid"]:
lines.append(
"WARNING: world_to_cam is not rigid (scaled pose); the frustum is drawn "
"as exported and its size is only indicative"
)
return lines
def run_preview(
state: dict[str, Any],
*,
self_test: Path | None = None,
screenshot: Path | None = None,
) -> int:
"""Open the preview window, or render one frame when capturing.
Returns 0 on success, 4 when no usable GL context could be created.
"""
from PyQt5.QtWidgets import QApplication
from orisys_sensor_design.preview.gl_view import PreviewGLWidget, configure_surface_format
app = QApplication.instance()
created = app is None
if created:
configure_surface_format()
app = QApplication([])
widget = PreviewGLWidget(
state["parts"], state["camera"], frustum=state["frustum"]
)
widget.resize(1280, 800)
widget.show()
app.processEvents()
widget.repaint()
app.processEvents()
if self_test is not None or screenshot is not None:
# Judge the meshes, not the frustum: those overlay lines would otherwise
# make a broken mesh path look like a pass.
widget.show_frustum = False
widget.repaint()
app.processEvents()
frame = widget.grab_rgb()
background = np.array(
[round(channel * 255) for channel in (0.06, 0.07, 0.09)]
)
drawn = int(np.count_nonzero(np.any(frame[:, :, :3] != background, axis=2)))
print(
f"self-test: gl_ok={widget.gl_ok} mesh_pixels={drawn} "
f"of {frame.shape[0] * frame.shape[1]} (frustum excluded)"
)
if widget.gl_error:
print(f"self-test: GL error: {widget.gl_error}")
if screenshot is not None:
target = Path(screenshot)
target.parent.mkdir(parents=True, exist_ok=True)
image = widget.grabFramebuffer()
if image.isNull() or not image.save(str(target)):
print(f"ERROR: could not write {target}", file=sys.stderr)
return 4
print(f"wrote {target} ({image.width()}x{image.height()})")
if not widget.gl_ok:
return 4
return 0 if drawn > 0 else 4
app.exec_()
return 0 if widget.gl_ok else 4