diff --git a/README.md b/README.md index bd9aba6..2e1e04a 100644 --- a/README.md +++ b/README.md @@ -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 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: ```powershell diff --git a/blender/scripts/export.py b/blender/scripts/export.py index 9c74ead..13a886c 100644 --- a/blender/scripts/export.py +++ b/blender/scripts/export.py @@ -53,8 +53,8 @@ def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Ob return obj -def _load_job(path: Path) -> tuple[str | None, dict]: - """Load output path and objects map from a JSON export job.""" +def _load_job(path: Path) -> tuple[str | None, dict, list[str] | None]: + """Load output path, objects map, and optional housing collections.""" data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): 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): raise ValueError(f"Export job 'objects' must be a mapping: {path}") 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): @@ -158,6 +166,95 @@ def export_mesh_in_sensor_frame( 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( camera_obj: bpy.types.Object, t_sensor_from_world: Matrix, @@ -199,18 +296,27 @@ def parse_args(argv: list[str]) -> argparse.Namespace: default=None, 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) -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.""" job_output: str | None = None job_objects: dict = {} + job_housing: list[str] | None = None if args.job is not None: job_path = args.job.resolve() if not job_path.is_file(): 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 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 _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: args = parse_args(_argv_after_double_dash()) 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: print(f"ERROR: {exc}", file=sys.stderr) return 2 @@ -252,11 +369,15 @@ def main() -> int: scene = bpy.context.scene mesh_path = output_dir / "mesh.npz" + housing_path = output_dir / "housing.npz" intrinsics_path = output_dir / "camera_intrinsics.npz" pose_path = output_dir / "world_to_cam.npy" try: 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) np.savez( intrinsics_path, @@ -272,6 +393,7 @@ def main() -> int: print(f"Export complete: {output_dir}") print(f" {mesh_path.name}") + print(f" {housing_path.name}") print(f" {intrinsics_path.name}") print(f" {pose_path.name}") return 0 diff --git a/pyproject.toml b/pyproject.toml index b1d1d7d..8161232 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,13 @@ dependencies = [ dev = [ "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] orisys-design = "orisys_sensor_design.cli:main" diff --git a/sensors/finger-xynova/r01/profiles/compute.yaml b/sensors/finger-xynova/r01/profiles/compute.yaml index 8b1ed88..3e1c3f0 100644 --- a/sensors/finger-xynova/r01/profiles/compute.yaml +++ b/sensors/finger-xynova/r01/profiles/compute.yaml @@ -6,6 +6,9 @@ output_dir: build/export/finger-xynova/r01/compute # Files must match orisys-sdk / Studio runtime layout. artifacts: - 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 - world_to_cam.npy diff --git a/sensors/finger-xynova/r01/source/geometry.blend b/sensors/finger-xynova/r01/source/geometry.blend index 5b1bad1..85cab04 100644 Binary files a/sensors/finger-xynova/r01/source/geometry.blend and b/sensors/finger-xynova/r01/source/geometry.blend differ diff --git a/sensors/finger/r01/profiles/compute.yaml b/sensors/finger/r01/profiles/compute.yaml index 5ecc875..e8eee80 100644 --- a/sensors/finger/r01/profiles/compute.yaml +++ b/sensors/finger/r01/profiles/compute.yaml @@ -6,6 +6,9 @@ output_dir: build/export/finger/r01/compute # Files must match orisys-sdk / Studio runtime layout. artifacts: - 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 - world_to_cam.npy diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index 27d5067..8abab8c 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -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: diff --git a/src/orisys_sensor_design/manifest.py b/src/orisys_sensor_design/manifest.py index e6c1c9e..9667b10 100644 --- a/src/orisys_sensor_design/manifest.py +++ b/src/orisys_sensor_design/manifest.py @@ -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) + ), } diff --git a/src/orisys_sensor_design/preview/__init__.py b/src/orisys_sensor_design/preview/__init__.py new file mode 100644 index 0000000..172c424 --- /dev/null +++ b/src/orisys_sensor_design/preview/__init__.py @@ -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", +] diff --git a/src/orisys_sensor_design/preview/appearance.py b/src/orisys_sensor_design/preview/appearance.py new file mode 100644 index 0000000..04f6527 --- /dev/null +++ b/src/orisys_sensor_design/preview/appearance.py @@ -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}") diff --git a/src/orisys_sensor_design/preview/camera.py b/src/orisys_sensor_design/preview/camera.py new file mode 100644 index 0000000..b05df72 --- /dev/null +++ b/src/orisys_sensor_design/preview/camera.py @@ -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) diff --git a/src/orisys_sensor_design/preview/geometry.py b/src/orisys_sensor_design/preview/geometry.py new file mode 100644 index 0000000..bbef353 --- /dev/null +++ b/src/orisys_sensor_design/preview/geometry.py @@ -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))) diff --git a/src/orisys_sensor_design/preview/gl_view.py b/src/orisys_sensor_design/preview/gl_view.py new file mode 100644 index 0000000..d8525e5 --- /dev/null +++ b/src/orisys_sensor_design/preview/gl_view.py @@ -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) diff --git a/src/orisys_sensor_design/preview/window.py b/src/orisys_sensor_design/preview/window.py new file mode 100644 index 0000000..ae2cd28 --- /dev/null +++ b/src/orisys_sensor_design/preview/window.py @@ -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 " + ) + 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 diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d3fbe5d..cf156e1 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -120,6 +120,7 @@ def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> N assert loaded["profile"] == "compute" assert loaded["expected_artifacts"] == [ "mesh.npz", + "housing.npz", "camera_intrinsics.npz", "world_to_cam.npy", ] diff --git a/tests/test_preview.py b/tests/test_preview.py new file mode 100644 index 0000000..8b55379 --- /dev/null +++ b/tests/test_preview.py @@ -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,)