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
+62
View File
@@ -299,6 +299,68 @@ every object separately, such as cutting or weeding. A failing `min_feature_mm`
alongside a passing `nominal_cell.dots` means the artwork is fragmented, not that alongside a passing `nominal_cell.dots` means the artwork is fragmented, not that
the pattern itself is unprintable. the pattern itself is unprintable.
## Preview the export
`orisys-design preview` opens the built export in a minimal PyOpenGL window so a
revision can be checked before it reaches Studio: the sensing surface highlighted,
the exported housing around it, and the exported camera frustum.
```powershell
python -m pip install -e .[preview] # numpy, PyQt5, PyOpenGL
orisys-design preview sensors/finger-xynova/r01
```
Controls: left-drag orbit, middle/right-drag pan, wheel zoom, `H` toggle housing,
`W` wireframe, `C` frustum. `--self-test` renders one frame, reports how many
mesh pixels were drawn, and exits without waiting for a window — use it to check
a machine's GL setup. `--screenshot PATH` writes that frame to a PNG so a view
can be reviewed or shared without describing it. `--no-housing` and
`--no-frustum` simplify the view.
Normals are crease-aware: faces are grouped per vertex and split where the
dihedral angle exceeds `--crease-angle` (default 35°), so machined edges read as
creases instead of smooth roll-offs. `--smooth` disables splitting. This matters
because the housing is sharp and the sensing surface is not — 13.5% of `frame_L`
edges exceed 30° while the sensing surface has none, and the default leaves its
2508 vertices untouched.
Exit codes: `2` for a missing optional dependency, `3` for a payload that has not
been exported, `4` when no OpenGL context can be created (the offscreen/minimal
Qt platform cannot create one at all, so run on a desktop session).
### Housing is a separate artifact
`export` also writes **`housing.npz`** for the objects in the `ASSEMBLY`
collection, transformed into the same sensor frame as the sensing surface. It is
deliberately *not* merged into `mesh.npz`: `orisys-sdk`'s unroll consumes
`mesh.npz` vertices as the sensing surface, so housing vertices there would
corrupt it. Declare the collections with `--housing-collections` or via
`design.yaml`'s `collections.assembly`.
### Lighting mirrors Studio, by value
This window reimplements a small renderer rather than importing Studio's, so the
look is copied rather than shared. Every mirrored value — clear colour, light
direction, the `0.35 + 0.65 * lambert` shading, housing graphite — lives in
`preview/appearance.py` with the `gl_backend.py` line it came from, and
`tests/test_preview.py` re-reads Studio's source and fails if it changes. That
keeps the duplication honest and makes adopting real reuse a one-module swap.
The sensing surface is drawn in a preview-only highlight colour; Studio colours
it from the measurement LUT instead. Treat this as a design-time check, not the
authoritative renderer.
### One caveat about export units
The exported sensor frame is **not metres**. `origin_sensor` carries a uniform
scale, so a ~21 mm assembly exports with bounds around ±574 units and
`world_to_cam` is a scaled (non-rigid) pose: its rotation column norms are
`0.003937` on Xynova, not `1`. Projection is still self-consistent — all 2508
sensing vertices land inside the 1000×1000 frame — but any consumer that assumes
metres will be wrong. The preview therefore frames the model from its own
bounding sphere rather than Studio's fixed metre limits, and prints a warning
when the pose is not rigid.
Collection check: Collection check:
```powershell ```powershell
+129 -7
View File
@@ -53,8 +53,8 @@ def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Ob
return obj return obj
def _load_job(path: Path) -> tuple[str | None, dict]: def _load_job(path: Path) -> tuple[str | None, dict, list[str] | None]:
"""Load output path and objects map from a JSON export job.""" """Load output path, objects map, and optional housing collections."""
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError(f"Export job must be a JSON object: {path}") raise ValueError(f"Export job must be a JSON object: {path}")
@@ -62,7 +62,15 @@ def _load_job(path: Path) -> tuple[str | None, dict]:
if not isinstance(objects, dict): if not isinstance(objects, dict):
raise ValueError(f"Export job 'objects' must be a mapping: {path}") raise ValueError(f"Export job 'objects' must be a mapping: {path}")
output = data.get("output") output = data.get("output")
return (str(output) if output else None), objects housing = data.get("housing_collections")
if housing is not None:
if not isinstance(housing, list) or not all(
isinstance(name, str) for name in housing
):
raise ValueError(
f"Export job 'housing_collections' must be a list of names: {path}"
)
return (str(output) if output else None), objects, housing
def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene): def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene):
@@ -158,6 +166,95 @@ def export_mesh_in_sensor_frame(
cleanup() cleanup()
def _housing_objects(collection_names: list[str]) -> list[bpy.types.Object]:
"""Unique mesh objects in the configured collections, in a stable order."""
found: dict[str, bpy.types.Object] = {}
for name in collection_names:
collection = bpy.data.collections.get(name)
if collection is None:
print(
f"WARNING: housing collection not found: {name!r}",
file=sys.stderr,
)
continue
for obj in collection.all_objects:
if obj.type == "MESH" and obj.name not in found:
found[obj.name] = obj
return list(found.values())
def export_housing_in_sensor_frame(
objects: list[bpy.types.Object],
t_sensor_from_world: Matrix,
filepath: Path,
) -> tuple[int, int, int]:
"""Export the housing meshes in the sensor frame, keeping part identity.
Parts are concatenated into one buffer -- the same schema family as
``mesh.npz`` -- with ``part_index`` linking every face to a ``part_names``
entry. Housing never goes into ``mesh.npz``: the SDK unroll reads that file's
vertices as the sensing surface.
"""
vertex_blocks: list[np.ndarray] = []
face_blocks: list[np.ndarray] = []
part_index: list[int] = []
part_names: list[str] = []
offset = 0
for obj in objects:
mesh, world, cleanup = _triangulated_mesh(obj)
try:
transform = t_sensor_from_world @ world
faces_list = [tuple(poly.vertices) for poly in mesh.polygons]
if not faces_list:
print(f"WARNING: housing object {obj.name!r} has no faces; skipped")
continue
if any(len(face) != 3 for face in faces_list):
raise RuntimeError(
f"Housing object {obj.name!r} is not fully triangulated after export"
)
vertex_blocks.append(
np.array([transform @ v.co for v in mesh.vertices], dtype=np.float32)
)
faces = np.asarray(faces_list, dtype=np.int32) + offset
face_blocks.append(faces)
part_index.extend([len(part_names)] * len(faces))
part_names.append(obj.name)
offset += len(mesh.vertices)
finally:
cleanup()
if vertex_blocks:
vertices = np.concatenate(vertex_blocks)
faces = np.concatenate(face_blocks)
else:
vertices = np.zeros((0, 3), dtype=np.float32)
faces = np.zeros((0, 3), dtype=np.int32)
names = (
np.array(part_names, dtype=np.str_)
if part_names
else np.array([""], dtype=np.str_)[:0]
)
np.savez_compressed(
filepath,
vertices=vertices,
faces=faces,
part_index=np.asarray(part_index, dtype=np.int32),
part_names=names,
)
print(
f"Housing exported to {filepath} "
f"({len(part_names)} part(s), {len(vertices)} verts, {len(faces)} tris)"
)
if not part_names:
print(
"WARNING: no housing meshes found; housing.npz is empty",
file=sys.stderr,
)
return len(part_names), int(len(vertices)), int(len(faces))
def export_camera_pose_sensor_frame( def export_camera_pose_sensor_frame(
camera_obj: bpy.types.Object, camera_obj: bpy.types.Object,
t_sensor_from_world: Matrix, t_sensor_from_world: Matrix,
@@ -199,18 +296,27 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
default=None, default=None,
help="Sensor-frame empty object name (overrides job)", help="Sensor-frame empty object name (overrides job)",
) )
parser.add_argument(
"--housing-collections",
dest="housing_collections",
default=None,
help="Comma-separated collections exported as housing (overrides job)",
)
return parser.parse_args(argv) return parser.parse_args(argv)
def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]: def _resolve_config(
args: argparse.Namespace,
) -> tuple[Path, str, str, str, list[str]]:
"""Merge --job JSON with explicit flag overrides. Explicit flags win.""" """Merge --job JSON with explicit flag overrides. Explicit flags win."""
job_output: str | None = None job_output: str | None = None
job_objects: dict = {} job_objects: dict = {}
job_housing: list[str] | None = None
if args.job is not None: if args.job is not None:
job_path = args.job.resolve() job_path = args.job.resolve()
if not job_path.is_file(): if not job_path.is_file():
raise RuntimeError(f"Export job not found: {job_path}") raise RuntimeError(f"Export job not found: {job_path}")
job_output, job_objects = _load_job(job_path) job_output, job_objects, job_housing = _load_job(job_path)
output_raw = args.output or job_output output_raw = args.output or job_output
if not output_raw: if not output_raw:
@@ -227,13 +333,24 @@ def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]:
or job_objects.get("origin_sensor") or job_objects.get("origin_sensor")
or _DEFAULT_OBJECTS["origin_sensor"] or _DEFAULT_OBJECTS["origin_sensor"]
) )
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin) housing: list[str] = []
if args.housing_collections:
housing = [
name.strip()
for name in str(args.housing_collections).split(",")
if name.strip()
]
elif job_housing is not None:
housing = list(job_housing)
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin), housing
def main() -> int: def main() -> int:
args = parse_args(_argv_after_double_dash()) args = parse_args(_argv_after_double_dash())
try: try:
output_dir, mesh_name, cam_name, origin_name = _resolve_config(args) output_dir, mesh_name, cam_name, origin_name, housing_collections = (
_resolve_config(args)
)
except Exception as exc: except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
return 2 return 2
@@ -252,11 +369,15 @@ def main() -> int:
scene = bpy.context.scene scene = bpy.context.scene
mesh_path = output_dir / "mesh.npz" mesh_path = output_dir / "mesh.npz"
housing_path = output_dir / "housing.npz"
intrinsics_path = output_dir / "camera_intrinsics.npz" intrinsics_path = output_dir / "camera_intrinsics.npz"
pose_path = output_dir / "world_to_cam.npy" pose_path = output_dir / "world_to_cam.npy"
try: try:
export_mesh_in_sensor_frame(mesh_obj, t_sensor_from_world, mesh_path) export_mesh_in_sensor_frame(mesh_obj, t_sensor_from_world, mesh_path)
export_housing_in_sensor_frame(
_housing_objects(housing_collections), t_sensor_from_world, housing_path
)
K, width, height = export_camera_intrinsics(cam_obj, scene) K, width, height = export_camera_intrinsics(cam_obj, scene)
np.savez( np.savez(
intrinsics_path, intrinsics_path,
@@ -272,6 +393,7 @@ def main() -> int:
print(f"Export complete: {output_dir}") print(f"Export complete: {output_dir}")
print(f" {mesh_path.name}") print(f" {mesh_path.name}")
print(f" {housing_path.name}")
print(f" {intrinsics_path.name}") print(f" {intrinsics_path.name}")
print(f" {pose_path.name}") print(f" {pose_path.name}")
return 0 return 0
+7
View File
@@ -16,6 +16,13 @@ dependencies = [
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
] ]
# The export preview only. numpy stays optional because the host CLI is
# otherwise stdlib + PyYAML; PyOpenGL matches the backend Studio uses.
preview = [
"numpy>=1.24",
"PyQt5>=5.15",
"PyOpenGL>=3.1",
]
[project.scripts] [project.scripts]
orisys-design = "orisys_sensor_design.cli:main" orisys-design = "orisys_sensor_design.cli:main"
@@ -6,6 +6,9 @@ output_dir: build/export/finger-xynova/r01/compute
# Files must match orisys-sdk / Studio runtime layout. # Files must match orisys-sdk / Studio runtime layout.
artifacts: artifacts:
- mesh.npz - mesh.npz
# Housing is a separate artifact on purpose: mesh.npz stays sensing-surface-only
# because the SDK unroll consumes its vertices as the sensing surface.
- housing.npz
- camera_intrinsics.npz - camera_intrinsics.npz
- world_to_cam.npy - world_to_cam.npy
Binary file not shown.
+3
View File
@@ -6,6 +6,9 @@ output_dir: build/export/finger/r01/compute
# Files must match orisys-sdk / Studio runtime layout. # Files must match orisys-sdk / Studio runtime layout.
artifacts: artifacts:
- mesh.npz - mesh.npz
# Housing is a separate artifact on purpose: mesh.npz stays sensing-surface-only
# because the SDK unroll consumes its vertices as the sensing surface.
- housing.npz
- camera_intrinsics.npz - camera_intrinsics.npz
- world_to_cam.npy - world_to_cam.npy
+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())]) 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) root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir)
errors = validate_revision_metadata(paths) errors = validate_revision_metadata(paths)
@@ -205,7 +207,12 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
load_profile(paths, profile) load_profile(paths, profile)
output_dir = export_output_dir(root, paths, profile) output_dir = export_output_dir(root, paths, profile)
output_dir.mkdir(parents=True, exist_ok=True) 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)) job_path = write_export_job(job, export_revision_dir(root, paths, profile))
print(f"Wrote export job: {job_path}") print(f"Wrote export job: {job_path}")
@@ -457,6 +464,84 @@ def cmd_inspect(revision_dir: Path, args: argparse.Namespace) -> int:
return 0 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: def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
root = repo_root(revision_dir) root = repo_root(revision_dir)
paths = RevisionPaths.from_revision_dir(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 = sub.add_parser("export", help="Export nominal geometry for a profile")
export_p.add_argument("revision_dir", type=Path) export_p.add_argument("revision_dir", type=Path)
export_p.add_argument("--profile", default="compute", choices=["compute"]) 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_p = sub.add_parser(
"texture", "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)", 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) args = parser.parse_args(argv)
if args.command == "validate": if args.command == "validate":
code = cmd_validate(args.revision_dir, args.blender) code = cmd_validate(args.revision_dir, args.blender)
elif args.command == "export": 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": elif args.command == "texture":
code = cmd_texture(args.revision_dir, args) code = cmd_texture(args.revision_dir, args)
elif args.command == "inspect": elif args.command == "inspect":
code = cmd_inspect(args.revision_dir, args) code = cmd_inspect(args.revision_dir, args)
elif args.command == "preview":
code = cmd_preview(args.revision_dir, args)
elif args.command == "manifest": elif args.command == "manifest":
code = cmd_manifest(args.revision_dir, args.out, args.profile) code = cmd_manifest(args.revision_dir, args.out, args.profile)
else: 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" EXPORT_JOB_FILENAME = "_export_job.json"
MANIFEST_FILENAME = "manifest.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( def build_export_job(
paths: RevisionPaths, paths: RevisionPaths,
output_dir: Path, output_dir: Path,
profile: str, profile: str,
housing_collections: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the JSON job dict that Blender's export.py consumes (stdlib JSON).""" """Build the JSON job dict that Blender's export.py consumes (stdlib JSON)."""
return { return {
@@ -159,6 +177,11 @@ def build_export_job(
"output": str(output_dir.resolve()), "output": str(output_dir.resolve()),
"profile": profile, "profile": profile,
"objects": export_object_names(paths), "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
+1
View File
@@ -120,6 +120,7 @@ def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> N
assert loaded["profile"] == "compute" assert loaded["profile"] == "compute"
assert loaded["expected_artifacts"] == [ assert loaded["expected_artifacts"] == [
"mesh.npz", "mesh.npz",
"housing.npz",
"camera_intrinsics.npz", "camera_intrinsics.npz",
"world_to_cam.npy", "world_to_cam.npy",
] ]
+509
View File
@@ -0,0 +1,509 @@
"""Tests for the export preview: geometry, camera, appearance and the GL smoke.
The GL test is skipped when no real platform GL context can be created, copying
Studio's ``test_scene3d_gl_smoke.py``: the offscreen/minimal plugin cannot create
a platform GL context at all, so an offscreen run cannot stand in for a GPU.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
np = pytest.importorskip("numpy")
from orisys_sensor_design.manifest import (
DEFAULT_HOUSING_COLLECTIONS,
RevisionPaths,
build_export_job,
export_housing_collections,
repo_root,
)
from orisys_sensor_design.preview import appearance
from orisys_sensor_design.preview.camera import (
OrbitCamera,
frustum_segments,
pose_is_rigid,
)
from orisys_sensor_design.preview.geometry import (
DEFAULT_CREASE_ANGLE_DEG,
SMOOTH_CREASE_ANGLE_DEG,
PreviewGeometryError,
compute_normals,
load_housing,
load_parts,
load_sensing,
split_vertices,
)
REPO = repo_root()
STUDIO_BACKEND = (
REPO.parent
/ "orisys-app"
/ "apps"
/ "studio"
/ "src"
/ "orisys_studio"
/ "scene3d"
/ "gl_backend.py"
)
XYNOVA_EXPORT = REPO / "build" / "export" / "finger-xynova" / "r01" / "compute"
def _write_npz(path: Path, **arrays) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(path, **arrays)
return path
def _housing_npz(path: Path) -> Path:
vertices = np.array(
[[0, 0, 0], [1, 0, 0], [0, 1, 0], [5, 5, 5], [6, 5, 5], [5, 6, 5]],
dtype=np.float32,
)
faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int32)
return _write_npz(
path,
vertices=vertices,
faces=faces,
part_index=np.array([0, 1], dtype=np.int32),
part_names=np.array(["frame_L", "nail"]),
)
# ------------------------------------------------------------- geometry
def test_compute_normals_of_a_flat_quad_points_along_plus_z() -> None:
positions = np.array(
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32
)
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
normals = compute_normals(positions, triangles)
assert normals.shape == positions.shape
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6)
def test_compute_normals_falls_back_on_degenerate_faces() -> None:
positions = np.zeros((3, 3), dtype=np.float32)
triangles = np.array([[0, 1, 2]], dtype=np.int32)
normals = compute_normals(positions, triangles)
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32))
def test_load_sensing_reads_the_payload_mesh(tmp_path: Path) -> None:
_write_npz(
tmp_path / "mesh.npz",
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
)
part = load_sensing(tmp_path)
assert part.role == "sensing"
assert part.name == "sensing_surface"
assert part.triangle_count == 1
def test_load_sensing_rejects_a_missing_file(tmp_path: Path) -> None:
with pytest.raises(PreviewGeometryError, match="missing geometry file"):
load_sensing(tmp_path)
def test_load_housing_splits_parts_by_index(tmp_path: Path) -> None:
_housing_npz(tmp_path / "housing.npz")
parts = load_housing(tmp_path)
assert [part.name for part in parts] == ["frame_L", "nail"]
assert all(part.role == "housing" for part in parts)
assert parts[0].vertex_count == 3
assert parts[1].triangle_count == 1
def test_load_housing_is_empty_when_absent(tmp_path: Path) -> None:
assert load_housing(tmp_path) == []
def test_load_housing_is_empty_when_it_has_no_faces(tmp_path: Path) -> None:
_write_npz(
tmp_path / "housing.npz",
vertices=np.zeros((0, 3), dtype=np.float32),
faces=np.zeros((0, 3), dtype=np.int32),
part_index=np.zeros((0,), dtype=np.int32),
part_names=np.array([""], dtype=np.str_)[:0],
)
assert load_housing(tmp_path) == []
def test_load_housing_rejects_a_part_index_length_mismatch(tmp_path: Path) -> None:
_write_npz(
tmp_path / "housing.npz",
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
part_index=np.array([0, 1], dtype=np.int32),
part_names=np.array(["a", "b"]),
)
with pytest.raises(PreviewGeometryError, match="part_index"):
load_housing(tmp_path)
def test_load_parts_can_skip_housing(tmp_path: Path) -> None:
_write_npz(
tmp_path / "mesh.npz",
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 2]], dtype=np.int32),
)
_housing_npz(tmp_path / "housing.npz")
assert len(load_parts(tmp_path)) == 3
assert len(load_parts(tmp_path, include_housing=False)) == 1
# --------------------------------------------------------------- camera
def test_fit_frames_the_bounds_and_scales_with_the_frame() -> None:
camera = OrbitCamera()
low = np.array([-1.0, -1.0, -1.0])
high = np.array([1.0, 1.0, 1.0])
camera.fit(low, high, 1.0)
assert np.allclose(camera.target, 0.0)
radius = np.linalg.norm(high - low) * 0.5
assert camera.fitted_radius == pytest.approx(radius)
# The distance must exceed the radius, and stay within its own ratios.
assert radius * 0.05 <= camera.distance <= radius * 50.0
assert camera.distance > radius
def test_fit_is_unit_agnostic_for_a_scaled_export() -> None:
"""The exported sensor frame is not metres, so framing must not assume it."""
camera = OrbitCamera()
camera.fit(np.array([-574.0, -576.0, -299.0]), np.array([574.0, 556.0, 1691.0]), 1.6)
assert camera.distance > 1000.0 # would be clamped to 5 m by Studio's limits
def test_zoom_is_clamped_relative_to_the_fitted_radius() -> None:
camera = OrbitCamera()
camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0)
for _ in range(200):
camera.zoom(1.0)
assert camera.distance >= camera.fitted_radius * 0.05
for _ in range(400):
camera.zoom(-1.0)
assert camera.distance <= camera.fitted_radius * 50.0
def test_orbit_clamps_pitch() -> None:
camera = OrbitCamera()
camera.orbit(0.0, -100000.0)
assert abs(camera.pitch) <= 1.5534 + 1e-3
def test_view_and_projection_are_well_formed() -> None:
camera = OrbitCamera()
camera.fit(np.array([-1.0, -1.0, -1.0]), np.array([1.0, 1.0, 1.0]), 1.0)
view = camera.view()
projection = camera.projection()
assert view.shape == (4, 4) and projection.shape == (4, 4)
# An orthonormal view: rotation rows are unit length.
assert np.allclose(np.linalg.norm(view[:3, :3], axis=1), 1.0, atol=1e-9)
# The target maps onto the camera's -Z axis.
eye = camera.eye()
target_in_camera = view @ np.array([*camera.target, 1.0])
assert target_in_camera[2] == pytest.approx(-camera.distance, rel=1e-6)
assert eye.shape == (3,)
def test_pose_is_rigid_detects_a_scaled_pose() -> None:
rigid = np.eye(4)
assert pose_is_rigid(rigid) is True
scaled = np.diag([0.003937, 0.003937, 0.003937, 1.0])
assert pose_is_rigid(scaled) is False
assert pose_is_rigid(np.zeros((3, 3))) is False
def test_frustum_segments_apex_is_the_camera_position() -> None:
K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]])
world_to_cam = np.eye(4)
world_to_cam[2, 3] = 2.0 # camera sits 2 units in front of the origin
segments = frustum_segments(K, world_to_cam, image_size=(100, 100))
assert segments.shape == (8, 2, 3)
apex = segments[0, 0]
assert np.allclose(apex, [0.0, 0.0, -2.0], atol=1e-9)
def test_frustum_segments_rejects_a_bad_shape() -> None:
with pytest.raises(ValueError, match="world_to_cam"):
frustum_segments(np.eye(3), np.eye(3))
with pytest.raises(ValueError, match="K must be"):
frustum_segments(np.eye(2), np.eye(4))
# ------------------------------------------------------------- appearance
def test_shader_sources_are_the_core_profile() -> None:
vertex, fragment = appearance.shader_source("diffuse")
assert vertex.startswith(appearance.SHADER_HEADER)
assert fragment.startswith(appearance.SHADER_HEADER)
assert "u_light_direction" in fragment
line_vertex, line_fragment = appearance.shader_source("line")
assert "u_color" in line_fragment
with pytest.raises(ValueError, match="unknown shader"):
appearance.shader_source("nope")
def test_base_color_matches_studio_for_housing() -> None:
assert appearance.base_color("housing") == appearance.HOUSING_COLOR
assert appearance.base_color("anything") == appearance.PART_FALLBACK_COLOR
@pytest.mark.skipif(
not STUDIO_BACKEND.is_file(), reason="orisys-app checkout is not present"
)
def test_appearance_mirror_matches_studio() -> None:
"""Drift guard: the mirror must not silently diverge from Studio's backend."""
source = STUDIO_BACKEND.read_text(encoding="utf-8")
assert f"glClearColor({appearance.CLEAR_COLOR[0]}, {appearance.CLEAR_COLOR[1]}, " in source
assert "(0.3, -0.5, 0.8)" in source
assert "0.35 + 0.65 * lambert" in source
assert "0.32, 0.33, 0.35, 1.0" in source
assert "0.45, 0.45, 0.47, 1.0" in source
for fragment_line in (
"vec3 n = normalize(v_normal);",
"if (!gl_FrontFacing) {",
"n = -n;",
):
assert fragment_line in source, fragment_line
assert fragment_line in appearance.DIFFUSE_FRAGMENT_BODY, fragment_line
# ------------------------------------------------- export contract guards
def test_export_job_requests_housing_from_the_assembly_collection() -> None:
paths = RevisionPaths.from_revision_dir(REPO / "sensors" / "finger-xynova" / "r01")
job = build_export_job(paths, Path("out"), "compute")
assert job["schema"] == "orisys-sensor-design/export-job-v2"
assert job["housing_collections"] == export_housing_collections(paths)
assert job["housing_collections"] == ["ASSEMBLY"]
def test_housing_collections_default_when_design_yaml_is_silent(tmp_path: Path) -> None:
revision = tmp_path / "sensors" / "finger" / "r99"
(revision / "profiles").mkdir(parents=True)
(revision / "design.yaml").write_text("sensor: finger\nrevision: r99\n", encoding="utf-8")
paths = RevisionPaths.from_revision_dir(revision)
assert export_housing_collections(paths) == list(DEFAULT_HOUSING_COLLECTIONS)
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "housing.npz").is_file(),
reason="run orisys-design export first",
)
def test_exported_housing_keeps_mesh_npz_sensing_only() -> None:
"""The SDK unroll reads mesh.npz vertices as the sensing surface."""
with np.load(XYNOVA_EXPORT / "mesh.npz", allow_pickle=False) as mesh:
assert set(mesh.files) == {"vertices", "faces", "uv_data", "loop_to_vertex"}
assert mesh["vertices"].shape[0] == 2508
# SMOOTH reads the artifact as stored: 21426 is what housing.npz contains, and
# crease splitting only ever enlarges the preview's in-memory copy.
parts = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
assert [part.name for part in parts] == ["frame_L", "nail", "frame_R"]
assert sum(part.vertex_count for part in parts) == 21426
# ------------------------------------------------------------- GL smoke
pytestmark_gl = pytest.mark.skipif(
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
reason="the offscreen/minimal plugin cannot create a platform GL context",
)
@pytest.mark.skipif(
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
reason="the offscreen/minimal plugin cannot create a platform GL context",
)
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_preview_draws_the_mesh_and_the_housing_toggle_changes_it() -> None:
pytest.importorskip("PyQt5")
from PyQt5.QtWidgets import QApplication
from orisys_sensor_design.preview.gl_view import (
PreviewGLWidget,
configure_surface_format,
)
from orisys_sensor_design.preview.window import prepare
state = prepare(XYNOVA_EXPORT)
configure_surface_format()
app = QApplication.instance() or QApplication([])
widget = PreviewGLWidget(
state["parts"], state["camera"], frustum=state["frustum"]
)
widget.show_frustum = False
widget.resize(480, 320)
widget.show()
app.processEvents()
background = np.array([15, 18, 23])
def drawn() -> int:
widget.repaint()
app.processEvents()
frame = widget.grab_rgb()
return int(
np.count_nonzero(np.any(frame[:, :, :3] != background, axis=2))
)
widget.show_housing = True
with_housing = drawn()
widget.show_housing = False
without_housing = drawn()
assert widget.gl_ok, widget.gl_error
assert with_housing > 0, "nothing was drawn at all"
assert 0 < without_housing < with_housing, (
"hiding the housing did not reduce what is on screen "
f"({without_housing} vs {with_housing})"
)
# --------------------------------------------------------- crease normals
def _cube() -> tuple[np.ndarray, np.ndarray]:
positions = np.array(
[
[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1],
],
dtype=np.float32,
)
triangles = np.array(
[
[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6],
[0, 4, 5], [0, 5, 1], [1, 5, 6], [1, 6, 2],
[2, 6, 7], [2, 7, 3], [3, 7, 4], [3, 4, 0],
],
dtype=np.int32,
)
return positions, triangles
def _two_faces(angle_deg: float) -> tuple[np.ndarray, np.ndarray]:
"""Two triangles sharing an edge, tilted from each other by ``angle_deg``."""
radians = np.radians(angle_deg)
positions = np.array(
[
[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0],
[1.0 + np.cos(radians), 0.0, np.sin(radians)],
],
dtype=np.float32,
)
triangles = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int32)
return positions, triangles
def test_split_vertices_makes_a_cube_corner_three_clusters() -> None:
positions, triangles = _cube()
split_positions, normals, _ = split_vertices(positions, triangles, 35.0)
assert len(split_positions) == 24 # 8 corners x 3 axis-aligned faces
axes = {tuple(np.round(row, 6)) for row in normals}
assert axes == {
(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0),
(0.0, 1.0, 0.0), (0.0, -1.0, 0.0),
(0.0, 0.0, 1.0), (0.0, 0.0, -1.0),
}
def test_split_vertices_smooth_sentinel_keeps_every_vertex() -> None:
positions, triangles = _cube()
split_positions, normals, split_triangles = split_vertices(
positions, triangles, SMOOTH_CREASE_ANGLE_DEG
)
assert len(split_positions) == 8
assert np.allclose(split_triangles, triangles)
assert np.allclose(normals, compute_normals(positions, triangles))
def test_split_vertices_respects_the_threshold() -> None:
shallow_positions, shallow_triangles = _two_faces(20.0)
_, _, shallow = split_vertices(shallow_positions, shallow_triangles, 35.0)
# The shared edge vertices keep one normal each, so the quad stays 4 vertices.
assert len(shallow) == 2
assert len(set(map(tuple, shallow.reshape(-1, 3)))) <= 4
steep_positions, steep_triangles = _two_faces(40.0)
steep_positions_out, _, _ = split_vertices(
steep_positions, steep_triangles, 35.0
)
assert len(steep_positions_out) > len(steep_positions)
def test_split_vertices_leaves_a_flat_quad_flat() -> None:
positions = np.array(
[[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=np.float32
)
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
split_positions, normals, _ = split_vertices(positions, triangles, 35.0)
assert len(split_positions) == 4
assert np.allclose(normals, np.array([0, 0, 1], dtype=np.float32), atol=1e-6)
def test_split_vertices_handles_an_empty_mesh() -> None:
positions = np.zeros((0, 3), dtype=np.float32)
triangles = np.zeros((0, 3), dtype=np.int32)
split_positions, normals, split_triangles = split_vertices(
positions, triangles, 35.0
)
assert len(split_positions) == 0 and len(split_triangles) == 0
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_default_crease_leaves_the_smooth_sensing_surface_untouched() -> None:
"""The sensing surface has 0% sharp edges, so it must not be re-vertexed."""
smooth = load_sensing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
creased = load_sensing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG)
assert creased.vertex_count == smooth.vertex_count
assert np.allclose(creased.normals, smooth.normals, atol=1e-6)
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "housing.npz").is_file(), reason="export not built"
)
def test_default_crease_splits_the_sharp_housing_edges() -> None:
smooth = load_housing(XYNOVA_EXPORT, crease_angle_deg=SMOOTH_CREASE_ANGLE_DEG)
creased = load_housing(XYNOVA_EXPORT, crease_angle_deg=DEFAULT_CREASE_ANGLE_DEG)
assert [p.name for p in smooth] == [p.name for p in creased]
for before, after in zip(smooth, creased):
assert after.triangle_count == before.triangle_count
assert after.vertex_count > before.vertex_count, before.name
# ------------------------------------------------------------ depth range
def test_projection_brackets_the_fitted_model() -> None:
camera = OrbitCamera()
low = np.array([-574.0, -576.0, -299.0])
high = np.array([574.0, 556.0, 1691.0])
camera.fit(low, high, 1.6)
view = camera.view()
projection = camera.projection()
eye = camera.eye()
for corner in (
np.array([x, y, z, 1.0])
for x in (low[0], high[0])
for y in (low[1], high[1])
for z in (low[2], high[2])
):
clip = projection @ view @ corner
ndc_z = clip[2] / clip[3]
assert -1.0 < ndc_z < 1.0, ndc_z
# The whole depth buffer is spent on the model, not a 1% slice of it.
depth = np.linalg.norm(view[:3, :3] @ (high - low))
assert depth > 0 and eye.shape == (3,)