added mesh export preview
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user