meshshader-engine 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. meshshader_engine/__init__.py +12 -0
  2. meshshader_engine/__main__.py +6 -0
  3. meshshader_engine/application.py +278 -0
  4. meshshader_engine/assets/__init__.py +107 -0
  5. meshshader_engine/assets/bunny.py +46 -0
  6. meshshader_engine/assets/data/bunny.npz +0 -0
  7. meshshader_engine/assets/data/teapot.npz +0 -0
  8. meshshader_engine/assets/mesh_data.py +188 -0
  9. meshshader_engine/assets/meshlets.py +344 -0
  10. meshshader_engine/assets/primitives.py +189 -0
  11. meshshader_engine/assets/serialization.py +171 -0
  12. meshshader_engine/assets/teapot.py +38 -0
  13. meshshader_engine/assets/texture_image.py +221 -0
  14. meshshader_engine/camera.py +218 -0
  15. meshshader_engine/cli.py +261 -0
  16. meshshader_engine/config.py +1058 -0
  17. meshshader_engine/errors.py +97 -0
  18. meshshader_engine/gl/__init__.py +10 -0
  19. meshshader_engine/gl/backends/__init__.py +1 -0
  20. meshshader_engine/gl/backends/base.py +39 -0
  21. meshshader_engine/gl/backends/classic.py +121 -0
  22. meshshader_engine/gl/backends/mesh_nv.py +141 -0
  23. meshshader_engine/gl/buffers.py +276 -0
  24. meshshader_engine/gl/capabilities.py +253 -0
  25. meshshader_engine/gl/context.py +178 -0
  26. meshshader_engine/gl/debug.py +145 -0
  27. meshshader_engine/gl/hot_reload.py +91 -0
  28. meshshader_engine/gl/program.py +264 -0
  29. meshshader_engine/gl/reflection.py +181 -0
  30. meshshader_engine/gl/shader_abi.py +143 -0
  31. meshshader_engine/gl/shader_compiler.py +356 -0
  32. meshshader_engine/gl/textures.py +456 -0
  33. meshshader_engine/math3d.py +310 -0
  34. meshshader_engine/scene_state.py +128 -0
  35. meshshader_engine-0.2.0.dist-info/METADATA +1723 -0
  36. meshshader_engine-0.2.0.dist-info/RECORD +40 -0
  37. meshshader_engine-0.2.0.dist-info/WHEEL +5 -0
  38. meshshader_engine-0.2.0.dist-info/entry_points.txt +2 -0
  39. meshshader_engine-0.2.0.dist-info/licenses/LICENSE +674 -0
  40. meshshader_engine-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,12 @@
1
+ """MeshShaderEngine: a didactic engine for classical and
2
+ NVIDIA mesh-shader render paths.
3
+
4
+ The package wraps PyOpenGL boilerplate and gives students a small,
5
+ explicit interface made of TOML configuration, shader files, and
6
+ optional model animation. Importing this package must not trigger any
7
+ OpenGL or display side effects.
8
+ """
9
+
10
+ __version__ = "0.2.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Allows invocation via ``python -m meshshader_engine``."""
2
+
3
+ from meshshader_engine.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,278 @@
1
+ """Application loop: connects window, mouse input, scene state, and
2
+ render path.
3
+
4
+ This module is the only place that registers ``glfw`` input callbacks
5
+ and thereby binds the pure, window-system-independent
6
+ :class:`~meshshader_engine.camera.OrbitCamera` to actual mouse events.
7
+ The interaction sensitivities
8
+ (``_ORBIT_SENSITIVITY_RAD_PER_PIXEL``, ``_SCROLL_ZOOM_SENSITIVITY``,
9
+ ``_DRAG_ZOOM_SENSITIVITY_PER_PIXEL``) are engine constants, not
10
+ configuration values -- only *whether* orbit/zoom are active is
11
+ configurable via ``interaction.orbit_enabled``/``zoom_enabled`` (see
12
+ README, section "TOML reference").
13
+
14
+ ``run_experiment`` blocks until the window is closed and is therefore
15
+ deliberately not covered by automated tests (a test would either have
16
+ to run forever or artificially interrupt the event loop, without
17
+ still being meaningful). The individual building blocks (camera,
18
+ SceneState, ``ClassicBackend``, ``MeshNVBackend``, ``gl.context.window``)
19
+ are already tested independently; the backend choice itself is a
20
+ simple dictionary lookup via ``_RENDER_BACKENDS``, whose keys exactly
21
+ match the ``pipeline.backend`` values already validated in
22
+ ``config.py``. The effect-applying part of the mouse callbacks
23
+ (``_apply_orbit_delta``, ``_apply_zoom_delta``) is factored out as a
24
+ pure function so that the acceptance criterion "disabled interaction
25
+ produces no unexpected input effects" (PLAN_SHADER_ENGINE.md, Phase 13)
26
+ is testable without a real context -- only the glfw callback
27
+ registration itself remains untested and is kept correspondingly
28
+ small.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import time
34
+ from typing import Any
35
+
36
+ from meshshader_engine.camera import OrbitCamera
37
+ from meshshader_engine.config import ExperimentConfig
38
+ from meshshader_engine.gl.backends.classic import ClassicBackend
39
+ from meshshader_engine.gl.backends.mesh_nv import MeshNVBackend
40
+ from meshshader_engine.gl.context import window
41
+ from meshshader_engine.scene_state import SceneState
42
+
43
+ __all__ = ["run_experiment"]
44
+
45
+ # Backend selection via pipeline.backend (see config.py,
46
+ # _ALLOWED_BACKENDS -- already validated there against exactly these
47
+ # two values, so a KeyError here would be an internal consistency bug,
48
+ # not a user error).
49
+ _RENDER_BACKENDS = {"classic": ClassicBackend, "mesh_nv": MeshNVBackend}
50
+
51
+ _ORBIT_SENSITIVITY_RAD_PER_PIXEL = 0.005
52
+ _SCROLL_ZOOM_SENSITIVITY = 0.5
53
+ _DRAG_ZOOM_SENSITIVITY_PER_PIXEL = 0.02
54
+
55
+
56
+ class _MouseState:
57
+ """Pure state of the last known mouse position and button status."""
58
+
59
+ def __init__(self) -> None:
60
+ self.orbit_dragging = False
61
+ self.zoom_dragging = False
62
+ self.last_x = 0.0
63
+ self.last_y = 0.0
64
+
65
+
66
+ def _apply_orbit_delta(
67
+ camera: OrbitCamera, dx: float, dy: float, *, enabled: bool
68
+ ) -> None:
69
+ """Apply a mouse-movement delta as an orbit, if ``enabled``.
70
+
71
+ Pure function with no glfw/GL dependency: with ``enabled=False``,
72
+ ``camera`` is not touched at all, instead of merely being called
73
+ with a delta of 0 -- this makes "no unexpected input effects" when
74
+ interaction is disabled directly testable.
75
+
76
+ ``dy`` is a GLFW screen-coordinate delta (Y grows downward), while
77
+ ``OrbitCamera.orbit`` expects a world-space elevation delta (Y
78
+ grows upward, see ``math3d.py``). The sign of ``dy`` is therefore
79
+ NOT flipped: dragging upward (``dy`` negative in screen
80
+ coordinates) should lower the camera (negative elevation change)
81
+ -- the familiar "trackball" feel, where the front edge of the
82
+ object follows the mouse movement instead of running opposite to
83
+ it.
84
+ """
85
+ if enabled:
86
+ camera.orbit(
87
+ -dx * _ORBIT_SENSITIVITY_RAD_PER_PIXEL,
88
+ dy * _ORBIT_SENSITIVITY_RAD_PER_PIXEL,
89
+ )
90
+
91
+
92
+ def _apply_zoom_delta(
93
+ camera: OrbitCamera, delta: float, *, enabled: bool, sensitivity: float
94
+ ) -> None:
95
+ """Apply a zoom delta, if ``enabled``.
96
+
97
+ Pure function, shared by scroll-wheel and right-click-drag zoom
98
+ (see :func:`run_experiment`); the two input sources have very
99
+ different magnitudes (scroll "notches" vs. screen pixels), hence
100
+ the explicit ``sensitivity`` parameter instead of a shared
101
+ constant.
102
+ """
103
+ if enabled:
104
+ camera.zoom(-delta * sensitivity)
105
+
106
+
107
+ def _apply_resize(
108
+ camera: OrbitCamera, viewport_size: list[float], width: int, height: int
109
+ ) -> None:
110
+ """Update the camera aspect ratio and viewport size on a resize
111
+ event.
112
+
113
+ Pure function with no GL call (the actual ``glViewport()`` call
114
+ stays in the callback in :func:`run_experiment`). Non-positive
115
+ sizes (e.g. when minimizing the window under some window managers)
116
+ are silently ignored instead of raising an error --
117
+ ``OrbitCamera.set_viewport`` itself would raise a ``ValueError``
118
+ for that, which would crash a window handler.
119
+
120
+ Parameters
121
+ ----------
122
+ camera : meshshader_engine.camera.OrbitCamera
123
+ viewport_size : list[float]
124
+ Two-element list ``[width, height]``, updated in place (not a
125
+ tuple, since the caller must keep the current value across
126
+ frames).
127
+ width, height : int
128
+ New framebuffer size in pixels.
129
+ """
130
+ if width <= 0 or height <= 0:
131
+ return
132
+ camera.set_viewport(width, height)
133
+ viewport_size[0] = float(width)
134
+ viewport_size[1] = float(height)
135
+
136
+
137
+ def run_experiment(config: ExperimentConfig, *, visible: bool = True) -> None:
138
+ """Run an experiment interactively in a window (blocking).
139
+
140
+ Left-click-drag orbits the camera (if ``interaction.orbit_enabled``).
141
+ Zoom (if ``interaction.zoom_enabled``) is possible in two ways, so
142
+ it also works on notebooks without a scroll wheel: scrolling, or
143
+ right-click-drag up/down (up = move closer, down = move farther --
144
+ symmetric to scrolling up/forward). Disabled interaction is already
145
+ checked in the respective callbacks before ``OrbitCamera.orbit``/
146
+ ``zoom`` is called -- no mouse events are then forwarded to the
147
+ camera at all.
148
+
149
+ Parameters
150
+ ----------
151
+ config : meshshader_engine.config.ExperimentConfig
152
+ visible : bool
153
+ Whether the window is created visibly. Default ``True`` for
154
+ real use via ``mse run``; Level B tests (see
155
+ ``tests/integration``) set ``False`` to check the success path
156
+ without a flashing window.
157
+
158
+ Raises
159
+ ------
160
+ GraphicsContextError
161
+ If no OpenGL context can be created, or (only for
162
+ ``pipeline.backend = "mesh_nv"``) if ``GL_NV_mesh_shader`` is
163
+ not available.
164
+ ShaderCompilationError
165
+ If the configured shaders fail to compile/link, or violate the
166
+ shader ABI.
167
+ """
168
+ import glfw
169
+
170
+ camera = OrbitCamera.from_config(config.camera, config.interaction)
171
+ scene = SceneState.from_config(config.light, config.animation, camera)
172
+ mouse = _MouseState()
173
+
174
+ with window(
175
+ width=config.window.width,
176
+ height=config.window.height,
177
+ title=config.window.title,
178
+ debug=True,
179
+ visible=visible,
180
+ ) as handle:
181
+ camera.set_viewport(handle.width, handle.height)
182
+ viewport_size = [float(handle.width), float(handle.height)]
183
+
184
+ backend_factory = _RENDER_BACKENDS[config.pipeline.backend]
185
+ backend = backend_factory(config)
186
+ messenger = None
187
+ if handle.debug_context:
188
+ from meshshader_engine.gl.debug import DebugMessenger
189
+
190
+ messenger = DebugMessenger()
191
+
192
+ def _on_cursor_pos(_glfw_window: Any, x: float, y: float) -> None:
193
+ dx = x - mouse.last_x
194
+ dy = y - mouse.last_y
195
+ if mouse.orbit_dragging:
196
+ _apply_orbit_delta(
197
+ camera, dx, dy, enabled=config.interaction.orbit_enabled
198
+ )
199
+ if mouse.zoom_dragging:
200
+ # Dragging upward (dy < 0 in screen coordinates) should
201
+ # zoom in, see the run_experiment docstring; hence -dy.
202
+ _apply_zoom_delta(
203
+ camera,
204
+ -dy,
205
+ enabled=config.interaction.zoom_enabled,
206
+ sensitivity=_DRAG_ZOOM_SENSITIVITY_PER_PIXEL,
207
+ )
208
+ mouse.last_x = x
209
+ mouse.last_y = y
210
+
211
+ def _on_mouse_button(
212
+ _glfw_window: Any, button: int, action: int, _mods: int
213
+ ) -> None:
214
+ if button == glfw.MOUSE_BUTTON_LEFT:
215
+ mouse.orbit_dragging = action == glfw.PRESS
216
+ elif button == glfw.MOUSE_BUTTON_RIGHT:
217
+ mouse.zoom_dragging = action == glfw.PRESS
218
+
219
+ def _on_scroll(
220
+ _glfw_window: Any, _x_offset: float, y_offset: float
221
+ ) -> None:
222
+ _apply_zoom_delta(
223
+ camera,
224
+ y_offset,
225
+ enabled=config.interaction.zoom_enabled,
226
+ sensitivity=_SCROLL_ZOOM_SENSITIVITY,
227
+ )
228
+
229
+ def _on_framebuffer_size(
230
+ _glfw_window: Any, fb_width: int, fb_height: int
231
+ ) -> None:
232
+ _apply_resize(camera, viewport_size, fb_width, fb_height)
233
+ if fb_width > 0 and fb_height > 0:
234
+ from OpenGL.GL import glViewport
235
+
236
+ glViewport(0, 0, fb_width, fb_height)
237
+
238
+ glfw.set_cursor_pos_callback(handle.glfw_window, _on_cursor_pos)
239
+ glfw.set_mouse_button_callback(handle.glfw_window, _on_mouse_button)
240
+ glfw.set_scroll_callback(handle.glfw_window, _on_scroll)
241
+ glfw.set_framebuffer_size_callback(
242
+ handle.glfw_window, _on_framebuffer_size
243
+ )
244
+
245
+ from OpenGL.GL import (
246
+ GL_COLOR_BUFFER_BIT,
247
+ GL_DEPTH_BUFFER_BIT,
248
+ GL_DEPTH_TEST,
249
+ glClear,
250
+ glClearColor,
251
+ glEnable,
252
+ )
253
+
254
+ # Depth testing (z-buffering): each fragment's depth is compared
255
+ # against the per-pixel depth buffer so that nearer geometry
256
+ # correctly occludes farther geometry, regardless of draw order.
257
+ glEnable(GL_DEPTH_TEST)
258
+ glClearColor(0.1, 0.1, 0.12, 1.0)
259
+
260
+ start_time = time.perf_counter()
261
+ try:
262
+ while not glfw.window_should_close(handle.glfw_window):
263
+ scene.time_seconds = time.perf_counter() - start_time
264
+ # Polling instead of an additional file watcher (see
265
+ # PLAN_SHADER_ENGINE.md, Phase 21, task 1); a failed
266
+ # reload logs itself and leaves the current program
267
+ # untouched (task 3).
268
+ backend.reload_shaders_if_changed()
269
+ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
270
+ backend.draw(
271
+ scene, viewport_size=(viewport_size[0], viewport_size[1])
272
+ )
273
+ glfw.swap_buffers(handle.glfw_window)
274
+ glfw.poll_events()
275
+ finally:
276
+ if messenger is not None:
277
+ messenger.close()
278
+ backend.close()
@@ -0,0 +1,107 @@
1
+ """Geometry generation and vertex data model.
2
+
3
+ Covers the shared ``MeshData`` contract, procedural primitives
4
+ (plane/cube), pre-built assets loaded from disk (teapot/bunny/a
5
+ student's custom mesh), and meshlet partitioning for the mesh-shader
6
+ render path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Final
14
+
15
+ from meshshader_engine.assets.bunny import load_bunny
16
+ from meshshader_engine.assets.mesh_data import MeshData
17
+ from meshshader_engine.assets.primitives import build_cube, build_plane
18
+ from meshshader_engine.assets.serialization import load_mesh_data
19
+ from meshshader_engine.assets.teapot import load_teapot
20
+ from meshshader_engine.errors import MeshDataError
21
+
22
+ __all__ = ["OBJECT_BUILDERS", "build_object", "load_custom_mesh"]
23
+
24
+ #: ``object.type`` (see :mod:`meshshader_engine.config`) -> a
25
+ #: zero-argument builder producing its ``MeshData``. Covers every
26
+ #: built-in object type; ``"custom"`` is handled separately by
27
+ #: :func:`build_object`, since it additionally needs a path. Single
28
+ #: source of truth shared by both render-path backends
29
+ #: (:mod:`meshshader_engine.gl.backends.classic`,
30
+ #: :mod:`meshshader_engine.gl.backends.mesh_nv`), which both need to
31
+ #: build the same object regardless of which backend renders it --
32
+ #: keeping one copy here instead of one per backend avoids the two
33
+ #: falling out of sync if a new object type is ever added.
34
+ OBJECT_BUILDERS: Final[dict[str, Callable[[], MeshData]]] = {
35
+ "plane": build_plane,
36
+ "cube": build_cube,
37
+ "teapot": load_teapot,
38
+ "bunny": load_bunny,
39
+ }
40
+
41
+
42
+ def load_custom_mesh(path: Path) -> MeshData:
43
+ """Load a student-provided custom mesh.
44
+
45
+ The file must already be in the engine's versioned ``MeshData``
46
+ ``.npz`` format (see :mod:`meshshader_engine.assets.serialization`)
47
+ -- produced ahead of time from a student's OBJ file by
48
+ ``tools/import_mesh.py`` (see README, section "Custom Student
49
+ Meshes"). This function does no format conversion itself, exactly
50
+ like :func:`meshshader_engine.assets.teapot.load_teapot`/
51
+ :func:`meshshader_engine.assets.bunny.load_bunny`.
52
+
53
+ Parameters
54
+ ----------
55
+ path : pathlib.Path
56
+ Already-resolved path to the ``.npz`` file (see
57
+ :mod:`meshshader_engine.config`, "Path safety").
58
+
59
+ Returns
60
+ -------
61
+ MeshData
62
+
63
+ Raises
64
+ ------
65
+ MeshDataError
66
+ If the file is missing (names the exact ``tools/import_mesh.py``
67
+ invocation to fix it) or invalid (see
68
+ :func:`meshshader_engine.assets.serialization.load_mesh_data`).
69
+ """
70
+ if not path.exists():
71
+ raise MeshDataError(
72
+ f"Custom mesh not found: {path}. Convert your OBJ file first "
73
+ f"with 'python tools/import_mesh.py <model.obj> {path}' (see "
74
+ "README, section 'Custom Student Meshes')."
75
+ )
76
+ return load_mesh_data(path)
77
+
78
+
79
+ def build_object(object_type: str, mesh_path: Path | None = None) -> MeshData:
80
+ """Build or load the ``MeshData`` for one ``[object]`` entry,
81
+ regardless of whether it is procedural, a pre-built engine asset,
82
+ or a student's custom mesh.
83
+
84
+ Deliberately takes plain parameters rather than a
85
+ :class:`meshshader_engine.config.ObjectConfig`, so this module
86
+ stays importable and testable without depending on
87
+ :mod:`meshshader_engine.config` (same reasoning as
88
+ :func:`meshshader_engine.assets.texture_image.decode_texture_image`).
89
+
90
+ Parameters
91
+ ----------
92
+ object_type : str
93
+ One of the keys in :data:`OBJECT_BUILDERS`, or ``"custom"``.
94
+ mesh_path : pathlib.Path or None
95
+ Required if and only if ``object_type == "custom"`` (already
96
+ enforced by :mod:`meshshader_engine.config`, so a violation
97
+ here would be an internal consistency bug, not a user error --
98
+ see the ``assert`` below).
99
+
100
+ Returns
101
+ -------
102
+ MeshData
103
+ """
104
+ if object_type == "custom":
105
+ assert mesh_path is not None # already guaranteed by config.py
106
+ return load_custom_mesh(mesh_path)
107
+ return OBJECT_BUILDERS[object_type]()
@@ -0,0 +1,46 @@
1
+ """Loading the Stanford Bunny from the pre-built asset.
2
+
3
+ Like the teapot (see :mod:`meshshader_engine.assets.teapot`), the
4
+ bunny is not generated procedurally but loaded from a file that
5
+ ``tools/build_assets.py`` generates once from the official Stanford
6
+ source -- see README, section "Assets", for provenance and license
7
+ terms (notably: research/educational use and free redistribution are
8
+ permitted, commercial use is not, without Stanford's permission --
9
+ unlike this engine's own GPLv3 license).
10
+
11
+ At 34,834 vertices / 69,451 triangles (after dropping the source's
12
+ 1,113 unreferenced vertices, see ``tools/build_assets.py``), this is
13
+ by far the highest-resolution object the engine ships, meant to make
14
+ meshlet partitioning (:mod:`meshshader_engine.assets.meshlets`)
15
+ visibly more interesting than the teapot's ~40 meshlets.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Final
22
+
23
+ from meshshader_engine.assets.mesh_data import MeshData
24
+ from meshshader_engine.assets.serialization import _load_prebuilt_asset
25
+
26
+ __all__ = ["BUNNY_ASSET_PATH", "load_bunny"]
27
+
28
+ #: Location of the pre-built bunny asset (see ``tools/build_assets.py``).
29
+ BUNNY_ASSET_PATH: Final[Path] = Path(__file__).parent / "data" / "bunny.npz"
30
+
31
+
32
+ def load_bunny() -> MeshData:
33
+ """Load the pre-built Stanford Bunny geometry.
34
+
35
+ Returns
36
+ -------
37
+ MeshData
38
+
39
+ Raises
40
+ ------
41
+ MeshDataError
42
+ If the asset is missing (generate it with
43
+ ``python tools/build_assets.py``, see README, section
44
+ "Assets") or invalid.
45
+ """
46
+ return _load_prebuilt_asset(BUNNY_ASSET_PATH, "Bunny")
Binary file
Binary file
@@ -0,0 +1,188 @@
1
+ """Immutable, validated vertex data model for triangulated meshes.
2
+
3
+ Data conventions (see ``PLAN_SHADER_ENGINE.md``, section 4.4, and
4
+ README, section "Vertex layout"):
5
+
6
+ - ``positions``: ``float32[N, 3]``, object-space (local/model-space)
7
+ position -- i.e. before the ``mse_Model`` transform; only becomes a
8
+ world-space position once multiplied by it in the shader (see
9
+ README, section "Coordinate Spaces for Lighting").
10
+ - ``normals``: ``float32[N, 3]``, row-wise unit vectors.
11
+ - ``uvs``: ``float32[N, 2]``, texture coordinates (no value range is
12
+ enforced, since repeating/tiled UVs outside [0, 1] are allowed).
13
+ - ``tangents``: ``float32[N, 4]``; ``tangents[:, :3]`` is row-wise
14
+ normalized, ``tangents[:, 3]`` holds the handedness (exactly +1 or
15
+ -1). The bitangent is NOT stored, but reconstructed in the shader:
16
+ ``bitangent = cross(normal, tangent.xyz) * tangent.w``.
17
+ - ``indices``: ``uint32[M]`` with ``M % 3 == 0`` (a plain triangle
18
+ list, no fans/strips); every value lies in ``[0, N)``.
19
+ - Right-handed coordinate system, consistent with
20
+ :mod:`meshshader_engine.math3d`; triangles are wound
21
+ counter-clockwise (CCW) when viewed from the front (i.e. from the
22
+ direction the normal points).
23
+
24
+ ``MeshData`` fully validates this contract in ``__post_init__`` and
25
+ then locks all referenced NumPy arrays against writes
26
+ (``flags.writeable = False``), so that a once-constructed instance
27
+ can no longer be accidentally mutated even via a shared array
28
+ reference.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+
35
+ import numpy as np
36
+
37
+ from meshshader_engine.errors import MeshDataError
38
+
39
+ __all__ = ["MeshData"]
40
+
41
+
42
+ @dataclass(frozen=True, eq=False, slots=True)
43
+ class MeshData:
44
+ """Triangulated, fully validated vertex data model.
45
+
46
+ ``eq=False`` because the default comparison generated by
47
+ ``@dataclass`` would compare NumPy arrays element-wise, raising a
48
+ ``ValueError`` ("truth value of an array is ambiguous"); instance
49
+ identity is sufficient for this model.
50
+
51
+ Attributes
52
+ ----------
53
+ positions : numpy.ndarray
54
+ Vertex positions, shape (N, 3), dtype float32.
55
+ normals : numpy.ndarray
56
+ Unit normals, shape (N, 3), dtype float32.
57
+ uvs : numpy.ndarray
58
+ Texture coordinates, shape (N, 2), dtype float32.
59
+ tangents : numpy.ndarray
60
+ Tangents, shape (N, 4), dtype float32; see the module
61
+ docstring for the convention used for ``tangents[:, 3]``
62
+ (handedness).
63
+ indices : numpy.ndarray
64
+ Triangle index list, shape (M,) with M % 3 == 0, dtype
65
+ uint32.
66
+
67
+ Raises
68
+ ------
69
+ MeshDataError
70
+ If any array violates shape, dtype, value range (finite
71
+ values, normalized normals/tangents, valid handedness), or
72
+ index bounds.
73
+ """
74
+
75
+ positions: np.ndarray
76
+ normals: np.ndarray
77
+ uvs: np.ndarray
78
+ tangents: np.ndarray
79
+ indices: np.ndarray
80
+
81
+ def __post_init__(self) -> None:
82
+ _validate(self)
83
+ for array in (
84
+ self.positions,
85
+ self.normals,
86
+ self.uvs,
87
+ self.tangents,
88
+ self.indices,
89
+ ):
90
+ array.flags.writeable = False
91
+
92
+ @property
93
+ def vertex_count(self) -> int:
94
+ """Number of vertices (rows in ``positions``)."""
95
+ return self.positions.shape[0]
96
+
97
+ @property
98
+ def triangle_count(self) -> int:
99
+ """Number of triangles (``len(indices) // 3``)."""
100
+ return self.indices.shape[0] // 3
101
+
102
+
103
+ # --- Internal validation -----------------------------------------------------
104
+
105
+
106
+ def _validate(mesh: MeshData) -> None:
107
+ _require_float_array(mesh.positions, "positions", ncols=3)
108
+ _require_float_array(mesh.normals, "normals", ncols=3)
109
+ _require_float_array(mesh.uvs, "uvs", ncols=2)
110
+ _require_float_array(mesh.tangents, "tangents", ncols=4)
111
+
112
+ n = mesh.positions.shape[0]
113
+ if n == 0:
114
+ raise MeshDataError("positions: must not be empty")
115
+ for name, array in (
116
+ ("normals", mesh.normals),
117
+ ("uvs", mesh.uvs),
118
+ ("tangents", mesh.tangents),
119
+ ):
120
+ if array.shape[0] != n:
121
+ raise MeshDataError(
122
+ f"{name}: expected {n} rows (like positions), got: "
123
+ f"{array.shape[0]}"
124
+ )
125
+
126
+ _require_unit_rows(mesh.normals, "normals")
127
+ _require_unit_rows(mesh.tangents[:, :3], "tangents[:, :3]")
128
+
129
+ handedness = mesh.tangents[:, 3]
130
+ if not np.all(np.isclose(np.abs(handedness), 1.0, atol=1e-6)):
131
+ raise MeshDataError("tangents[:, 3] (handedness) must be +1 or -1")
132
+
133
+ _validate_indices(mesh.indices, vertex_count=n)
134
+
135
+
136
+ def _require_float_array(array: np.ndarray, name: str, *, ncols: int) -> None:
137
+ if not isinstance(array, np.ndarray):
138
+ raise MeshDataError(
139
+ f"{name}: must be a numpy.ndarray, got: {type(array).__name__}"
140
+ )
141
+ if array.dtype != np.float32:
142
+ raise MeshDataError(
143
+ f"{name}: expected dtype float32, got: {array.dtype}"
144
+ )
145
+ if array.ndim != 2 or array.shape[1] != ncols:
146
+ raise MeshDataError(
147
+ f"{name}: expected shape (N, {ncols}), got: shape={array.shape}"
148
+ )
149
+ if not np.all(np.isfinite(array)):
150
+ raise MeshDataError(f"{name}: contains non-finite values (NaN/Inf)")
151
+
152
+
153
+ def _require_unit_rows(
154
+ array: np.ndarray, name: str, *, atol: float = 1e-4
155
+ ) -> None:
156
+ norms = np.linalg.norm(array, axis=1)
157
+ if not np.all(np.isclose(norms, 1.0, atol=atol)):
158
+ bad_row = int(np.argmax(np.abs(norms - 1.0)))
159
+ raise MeshDataError(
160
+ f"{name}: must contain row-wise unit vectors; "
161
+ f"row {bad_row} has length {norms[bad_row]:.6f}"
162
+ )
163
+
164
+
165
+ def _validate_indices(indices: np.ndarray, *, vertex_count: int) -> None:
166
+ if not isinstance(indices, np.ndarray):
167
+ raise MeshDataError(
168
+ f"indices: must be a numpy.ndarray, got: {type(indices).__name__}"
169
+ )
170
+ if indices.dtype != np.uint32:
171
+ raise MeshDataError(
172
+ f"indices: expected dtype uint32, got: {indices.dtype}"
173
+ )
174
+ if indices.ndim != 1:
175
+ raise MeshDataError(
176
+ f"indices: expected a 1D array, got: shape={indices.shape}"
177
+ )
178
+ if indices.shape[0] == 0 or indices.shape[0] % 3 != 0:
179
+ raise MeshDataError(
180
+ f"indices: length must be a positive multiple of 3, "
181
+ f"got: {indices.shape[0]}"
182
+ )
183
+ max_index = int(indices.max())
184
+ if max_index >= vertex_count:
185
+ raise MeshDataError(
186
+ f"indices: value {max_index} is outside the valid range "
187
+ f"[0, {vertex_count - 1}]"
188
+ )