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