Geometry Classes

Obj

Bases: BaseMesh

OBJ mesh loader and exporter.

Inherits from BaseMesh and provides methods to parse, load, and save OBJ files, including support for vertices, normals, UVs, faces, and optional vertex colors.

Source code in ncca/ngl/obj.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
class Obj(BaseMesh):
    """OBJ mesh loader and exporter.

    Inherits from BaseMesh and provides methods to parse, load, and save OBJ files,
    including support for vertices, normals, UVs, faces, and optional vertex colors.
    """

    def __init__(self) -> None:
        """Initialize an empty OBJ mesh.

        Tracks current offsets for vertices, normals, and UVs to handle negative indices.
        """
        super().__init__()
        # as faces can use negative index values keep track of index
        self._current_vertex_offset: int = 0
        self._current_normal_offset: int = 0
        self._current_uv_offset: int = 0

    def _parse_vertex(self, tokens: list[str]) -> None:
        """Parse a vertex line from the OBJ file.

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseVertexError: If vertex parsing fails.
        """
        try:
            self.vertex.append(
                Vec3(float(tokens[1]), float(tokens[2]), float(tokens[3]))
            )
            self._current_vertex_offset += 1
            if len(tokens) == 7:  # we have the non standard colour
                if not hasattr(self, "colour"):
                    self.colour = []
                self.colour.append(
                    Vec3(float(tokens[4]), float(tokens[5]), float(tokens[6]))
                )
        except ValueError:
            raise ObjParseVertexError

    def _parse_normal(self, tokens: list[str]) -> None:
        """Parse a normal line from the OBJ file.

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseNormalError: If normal parsing fails.
        """
        try:
            self.normals.append(
                Vec3(float(tokens[1]), float(tokens[2]), float(tokens[3]))
            )
            self._current_normal_offset += 1
        except ValueError:
            raise ObjParseNormalError

    def _parse_uv(self, tokens: list[str]) -> None:
        """Parse a UV line from the OBJ file.

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseUVError: If UV parsing fails.
        """
        try:
            # some DCC's use vec3 for UV so may as well support
            z = 0.0
            if len(tokens) == 4:
                z = float(tokens[3])
            self.uv.append(Vec3(float(tokens[1]), float(tokens[2]), z))
            self._current_uv_offset += 1
        except ValueError:
            raise ObjParseUVError

    def _parse_face_vertex_normal_uv(self, tokens: list[str]) -> None:
        """Parse a face line with vertex/uv/normal indices (f v/vt/vn ...).

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseFaceError: If face parsing fails.
        """
        f = Face()
        for token in tokens[1:]:  # skip f
            # each one of these should be v/vt/vn
            vn = token.split("/")
            try:
                # note we need to subtract one from the list as obj index from 1
                idx = int(vn[0]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_vertex_offset + (idx + 1)
                f.vertex.append(idx)
                # same for UV
                idx = int(vn[1]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_uv_offset + (idx + 1)
                f.uv.append(idx)
                # same for normals
                idx = int(vn[2]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_normal_offset + (idx + 1)
                f.normal.append(idx)
            except ValueError:
                raise ObjParseFaceError
        self.faces.append(f)

    def _parse_face_vertex(self, tokens: list[str]) -> None:
        """Parse a face line with only vertex indices (f v v v ...).

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseFaceError: If face parsing fails.
        """
        f = Face()
        for token in tokens[1:]:  # skip f
            # each one of these should be v v
            try:
                # note we need to subtract one from the list as obj index from 1
                idx = int(token) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_vertex_offset + (idx + 1)
                f.vertex.append(idx)
            except ValueError:
                raise ObjParseFaceError
        self.faces.append(f)

    def _parse_face_vertex_normal(self, tokens: list[str]) -> None:
        """Parse a face line with vertex//normal indices (f v//vn ...).

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseFaceError: If face parsing fails.
        """
        f = Face()
        for token in tokens[1:]:  # skip f
            # each one of these should be v//vn
            vn = token.split("//")
            try:
                # note we need to subtract one from the list as obj index from 1
                idx = int(vn[0]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_vertex_offset + (idx + 1)
                f.vertex.append(idx)
                # same for normals
                idx = int(vn[1]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_normal_offset + (idx + 1)
                f.normal.append(idx)
            except ValueError:
                raise ObjParseFaceError
        self.faces.append(f)

    def _parse_face_vertex_uv(self, tokens: list[str]) -> None:
        """Parse a face line with vertex/uv indices (f v/vt ...).

        Args:
            tokens: List of string tokens from the line.

        Raises:
            ObjParseFaceError: If face parsing fails.
        """
        f = Face()
        for token in tokens[1:]:  # skip f
            # each one of these should be v/vt
            vn = token.split("/")
            try:
                # note we need to subtract one from the list as obj index from 1
                idx = int(vn[0]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_vertex_offset + (idx + 1)
                f.vertex.append(idx)
                # same for uv
                idx = int(vn[1]) - 1
                if idx < 0:  # negative index so grab the index
                    # note we index from 0 not 1 like obj so adjust
                    idx = self._current_uv_offset + (idx + 1)
                f.uv.append(idx)
            except ValueError:
                raise ObjParseFaceError
        self.faces.append(f)

    def _parse_face(self, tokens: list[str]) -> None:
        """Parse a face line, dispatching to the correct face parser based on format.

        Args:
            tokens: List of string tokens from the line.
        """
        # first let's find what sort of face we are dealing with I assume most likely case is all
        if tokens[1].count("/") == 2 and tokens[1].find("//") == -1:
            self._parse_face_vertex_normal_uv(tokens)
        elif tokens[1].find("/") == -1:
            self._parse_face_vertex(tokens)
        elif tokens[1].find("//") != -1:
            self._parse_face_vertex_normal(tokens)
        # if we have 1 / it is a VertUV format
        elif tokens[1].count("/") == 1:
            self._parse_face_vertex_uv(tokens)

    def load(self, file: str) -> bool:
        """Load an OBJ file and parse its contents into the mesh.

        Args:
            file: Path to the OBJ file.

        Returns:
            bool: True if loading was successful.
        """
        with open(file, "r") as obj_file:
            lines = obj_file.readlines()
        for line in lines:
            line = line.strip()  # strip whitespace
            if len(line) > 0:  # skip empty lines
                tokens = line.split()
                if tokens[0] == "v":
                    self._parse_vertex(tokens)
                elif tokens[0] == "vn":
                    self._parse_normal(tokens)
                elif tokens[0] == "vt":
                    self._parse_uv(tokens)
                elif tokens[0] == "f":
                    self._parse_face(tokens)
        return True

    @classmethod
    def from_file(cls, fname: str) -> "Obj":
        """Create an Obj instance from a file.

        Args:
            fname: Path to the OBJ file.

        Returns:
            Obj: The loaded Obj instance.
        """
        obj = Obj()
        obj.load(fname)
        return obj

    def add_vertex(self, vertex: Vec3) -> None:
        """Add a vertex to the mesh.

        Args:
            vertex: The vertex to add.
        """
        self.vertex.append(vertex)

    def add_vertex_colour(self, vertex: Vec3, colour: Vec3) -> None:
        """Add a vertex and its color to the mesh.

        Args:
            vertex: The vertex to add.
            colour: The color to associate with the vertex.
        """
        self.vertex.append(vertex)
        if not hasattr(self, "colour"):
            self.colour = []
        self.colour.append(colour)

    def add_normal(self, normal: Vec3) -> None:
        """Add a normal to the mesh.

        Args:
            normal: The normal to add.
        """
        self.normals.append(normal)

    def add_uv(self, uv: Vec3) -> None:
        """Add a UV coordinate to the mesh.

        Args:
            uv: The UV coordinate to add.
        """
        self.uv.append(uv)

    def add_face(self, face: Face) -> None:
        """Add a face to the mesh.

        Args:
            face: The face to add.
        """
        self.faces.append(face)

    def save(self, filename: str) -> None:
        """Save the mesh to an OBJ file.

        Args:
            filename: Path to the output OBJ file.
        """
        with open(filename, "w") as obj_file:
            obj_file.write("# This file was created by nccapy/Geo/Obj.py exporter\n")
            self._write_vertices(obj_file)
            self._write_uvs(obj_file)
            self._write_normals(obj_file)
            self._write_faces(obj_file)

    def _write_vertices(self, obj_file: TextIO) -> None:
        """Write vertices (and optional colors) to the OBJ file.

        Args:
            obj_file: Open file object for writing.
        """
        for i, v in enumerate(self.vertex):
            obj_file.write(f"v {v.x} {v.y} {v.z} ")
            if hasattr(self, "colour"):  # write colour if present
                obj_file.write(
                    f"{self.colour[i].x} {self.colour[i].y} {self.colour[i].z} "
                )
            obj_file.write("\n")

    def _write_uvs(self, obj_file: TextIO) -> None:
        """Write UV coordinates to the OBJ file.

        Args:
            obj_file: Open file object for writing.
        """
        for v in self.uv:
            obj_file.write(f"vt {v.x} {v.y} \n")

    def _write_normals(self, obj_file: TextIO) -> None:
        """Write normals to the OBJ file.

        Args:
            obj_file: Open file object for writing.
        """
        for v in self.normals:
            obj_file.write(f"vn {v.x} {v.y} {v.z} \n")

    def _write_faces(self, obj_file: TextIO) -> None:
        """Write faces to the OBJ file.

        Args:
            obj_file: Open file object for writing.
        """
        for face in self.faces:
            obj_file.write("f")
            for i in range(len(face.vertex)):
                obj_file.write(f" {face.vertex[i] + 1}")
                if len(face.uv) != 0:
                    obj_file.write(f"/{face.uv[i] + 1}")
                if len(face.normal) != 0:
                    if len(face.uv) == 0:
                        obj_file.write("//")
                    else:
                        obj_file.write("/")
                    obj_file.write(f"{face.normal[i] + 1}")
            obj_file.write("\n")

    @classmethod
    def obj_with_vao(cls, mesh_name: str, texture_name: str = None) -> "Obj":
        """Load an OBJ mesh and optionally a texture, then create a VAO.

        Args:
            mesh_name: Path to the OBJ mesh file.
            texture_name: Optional path to the texture file.

        Returns:
            Obj: The loaded and VAO-initialized mesh.
        """
        mesh = Obj()
        mesh.load(mesh_name)
        if texture_name:
            texture = Texture(texture_name)
            mesh.texture_id = texture.set_texture_gl()
            print(f"{mesh.texture_id=}")
        mesh.create_vao()
        return mesh

__init__()

Initialize an empty OBJ mesh.

Tracks current offsets for vertices, normals, and UVs to handle negative indices.

Source code in ncca/ngl/obj.py
33
34
35
36
37
38
39
40
41
42
def __init__(self) -> None:
    """Initialize an empty OBJ mesh.

    Tracks current offsets for vertices, normals, and UVs to handle negative indices.
    """
    super().__init__()
    # as faces can use negative index values keep track of index
    self._current_vertex_offset: int = 0
    self._current_normal_offset: int = 0
    self._current_uv_offset: int = 0

add_face(face)

Add a face to the mesh.

Parameters:
  • face (Face) –

    The face to add.

Source code in ncca/ngl/obj.py
314
315
316
317
318
319
320
def add_face(self, face: Face) -> None:
    """Add a face to the mesh.

    Args:
        face: The face to add.
    """
    self.faces.append(face)

add_normal(normal)

Add a normal to the mesh.

Parameters:
  • normal (Vec3) –

    The normal to add.

Source code in ncca/ngl/obj.py
298
299
300
301
302
303
304
def add_normal(self, normal: Vec3) -> None:
    """Add a normal to the mesh.

    Args:
        normal: The normal to add.
    """
    self.normals.append(normal)

add_uv(uv)

Add a UV coordinate to the mesh.

Parameters:
  • uv (Vec3) –

    The UV coordinate to add.

Source code in ncca/ngl/obj.py
306
307
308
309
310
311
312
def add_uv(self, uv: Vec3) -> None:
    """Add a UV coordinate to the mesh.

    Args:
        uv: The UV coordinate to add.
    """
    self.uv.append(uv)

add_vertex(vertex)

Add a vertex to the mesh.

Parameters:
  • vertex (Vec3) –

    The vertex to add.

Source code in ncca/ngl/obj.py
278
279
280
281
282
283
284
def add_vertex(self, vertex: Vec3) -> None:
    """Add a vertex to the mesh.

    Args:
        vertex: The vertex to add.
    """
    self.vertex.append(vertex)

add_vertex_colour(vertex, colour)

Add a vertex and its color to the mesh.

Parameters:
  • vertex (Vec3) –

    The vertex to add.

  • colour (Vec3) –

    The color to associate with the vertex.

Source code in ncca/ngl/obj.py
286
287
288
289
290
291
292
293
294
295
296
def add_vertex_colour(self, vertex: Vec3, colour: Vec3) -> None:
    """Add a vertex and its color to the mesh.

    Args:
        vertex: The vertex to add.
        colour: The color to associate with the vertex.
    """
    self.vertex.append(vertex)
    if not hasattr(self, "colour"):
        self.colour = []
    self.colour.append(colour)

from_file(fname) classmethod

Create an Obj instance from a file.

Parameters:
  • fname (str) –

    Path to the OBJ file.

Returns:
  • Obj( Obj ) –

    The loaded Obj instance.

Source code in ncca/ngl/obj.py
264
265
266
267
268
269
270
271
272
273
274
275
276
@classmethod
def from_file(cls, fname: str) -> "Obj":
    """Create an Obj instance from a file.

    Args:
        fname: Path to the OBJ file.

    Returns:
        Obj: The loaded Obj instance.
    """
    obj = Obj()
    obj.load(fname)
    return obj

load(file)

Load an OBJ file and parse its contents into the mesh.

Parameters:
  • file (str) –

    Path to the OBJ file.

Returns:
  • bool( bool ) –

    True if loading was successful.

Source code in ncca/ngl/obj.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def load(self, file: str) -> bool:
    """Load an OBJ file and parse its contents into the mesh.

    Args:
        file: Path to the OBJ file.

    Returns:
        bool: True if loading was successful.
    """
    with open(file, "r") as obj_file:
        lines = obj_file.readlines()
    for line in lines:
        line = line.strip()  # strip whitespace
        if len(line) > 0:  # skip empty lines
            tokens = line.split()
            if tokens[0] == "v":
                self._parse_vertex(tokens)
            elif tokens[0] == "vn":
                self._parse_normal(tokens)
            elif tokens[0] == "vt":
                self._parse_uv(tokens)
            elif tokens[0] == "f":
                self._parse_face(tokens)
    return True

obj_with_vao(mesh_name, texture_name=None) classmethod

Load an OBJ mesh and optionally a texture, then create a VAO.

Parameters:
  • mesh_name (str) –

    Path to the OBJ mesh file.

  • texture_name (str, default: None ) –

    Optional path to the texture file.

Returns:
  • Obj( Obj ) –

    The loaded and VAO-initialized mesh.

Source code in ncca/ngl/obj.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
@classmethod
def obj_with_vao(cls, mesh_name: str, texture_name: str = None) -> "Obj":
    """Load an OBJ mesh and optionally a texture, then create a VAO.

    Args:
        mesh_name: Path to the OBJ mesh file.
        texture_name: Optional path to the texture file.

    Returns:
        Obj: The loaded and VAO-initialized mesh.
    """
    mesh = Obj()
    mesh.load(mesh_name)
    if texture_name:
        texture = Texture(texture_name)
        mesh.texture_id = texture.set_texture_gl()
        print(f"{mesh.texture_id=}")
    mesh.create_vao()
    return mesh

save(filename)

Save the mesh to an OBJ file.

Parameters:
  • filename (str) –

    Path to the output OBJ file.

Source code in ncca/ngl/obj.py
322
323
324
325
326
327
328
329
330
331
332
333
def save(self, filename: str) -> None:
    """Save the mesh to an OBJ file.

    Args:
        filename: Path to the output OBJ file.
    """
    with open(filename, "w") as obj_file:
        obj_file.write("# This file was created by nccapy/Geo/Obj.py exporter\n")
        self._write_vertices(obj_file)
        self._write_uvs(obj_file)
        self._write_normals(obj_file)
        self._write_faces(obj_file)

Primitives

A static class for creating and drawing primitives.

Source code in ncca/ngl/opengl/primitives.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
class Primitives:
    """A static class for creating and drawing primitives."""

    # this is effectively a static class so we can use it to store data
    # and generate pipelines for drawing
    _primitives: Dict[str, _primitive] = {}
    _loaded: bool = False

    @classmethod
    def create(cls, type: str, name: str, *args: object, **kwargs: object) -> None:
        """Creates and stores a primitive object of the specified type.

        Prims.SPHERE : (radius: float, precision: int).
        Prims.TORUS : (radius: float, tube_radius: float, precision: int).
        Prims.LINE_GRID : (width: float, depth: float, steps: int).
        Prims.TRIANGLE_PLANE : ( width: float, depth: float, w_p: int, d_p: int, v_n: Vec3).
        Prims.CYLINDER : (radius: float, height: float, slices: int, stacks: int).
        Prims.CAPSULE : (radius: float, height: float, slices: int, stacks: int).
        Prims.CONE : (radius: float, height: float, slices: int, stacks: int).

        Args:
            type (str): The primitive type, typically from the Prims enum (e.g., Prims.SPHERE).
            name (str): The name to associate with the created primitive.
            *args: Positional arguments to pass to the primitive creation function (e.g., radius, precision).
            **kwargs: Keyword arguments to pass to the primitive creation function.

        Raises:
            ValueError: If the primitive type is not recognized.

        Example:
            Primitives.create(Prims.SPHERE, "sphere", 0.3, 32)
            Primitives.create(Prims.SPHERE, "sphere", radius=0.3, precision=32)
        """
        prim_methods = {
            Prims.SPHERE: PrimData.sphere,
            Prims.TORUS: PrimData.torus,
            Prims.LINE_GRID: PrimData.line_grid,
            Prims.TRIANGLE_PLANE: PrimData.triangle_plane,
            Prims.CYLINDER: PrimData.cylinder,
            Prims.DISK: PrimData.disk,
            Prims.CAPSULE: PrimData.capsule,
            Prims.CONE: PrimData.cone,
        }
        # line primitives are position-only GL_LINES data; everything else is
        # 8-float (position, normal, uv) triangle data
        prim_layouts = {
            Prims.LINE_GRID: (gl.GL_LINES, 3),
        }
        try:
            method = prim_methods[type]
        except KeyError:
            raise ValueError(f"Unknown primitive: {name}")

        draw_mode, floats_per_vertex = prim_layouts.get(type, (gl.GL_TRIANGLES, 8))
        cls._primitives[name] = _primitive(
            method(*args, **kwargs), draw_mode, floats_per_vertex
        )

    @classmethod
    def load_default_primitives(cls) -> None:
        """Loads the default primitives from the PrimData directory."""
        logger.info("Loading default primitives...")
        if not cls._loaded:
            for p in Prims:
                try:
                    prim_data = PrimData.primitive(p.value)
                    prim = _primitive(prim_data)
                    cls._primitives[p.value] = prim
                except Exception:
                    pass
            cls._loaded = True

    @classmethod
    def draw(cls, name: str | Prims) -> None:
        """Draws the specified primitive.

        Args:
            name: The name of the primitive to draw, either as a string or a Prims enum.
        """
        key = name.value if isinstance(name, Prims) else name
        try:
            prim = cls._primitives[key]
            with prim.vao:
                prim.vao.draw()
        except KeyError:
            logger.error(f"Failed to draw primitive {key}")
            return

create(type, name, *args, **kwargs) classmethod

Creates and stores a primitive object of the specified type.

Prims.SPHERE : (radius: float, precision: int). Prims.TORUS : (radius: float, tube_radius: float, precision: int). Prims.LINE_GRID : (width: float, depth: float, steps: int). Prims.TRIANGLE_PLANE : ( width: float, depth: float, w_p: int, d_p: int, v_n: Vec3). Prims.CYLINDER : (radius: float, height: float, slices: int, stacks: int). Prims.CAPSULE : (radius: float, height: float, slices: int, stacks: int). Prims.CONE : (radius: float, height: float, slices: int, stacks: int).

Parameters:
  • type (str) –

    The primitive type, typically from the Prims enum (e.g., Prims.SPHERE).

  • name (str) –

    The name to associate with the created primitive.

  • *args (object, default: () ) –

    Positional arguments to pass to the primitive creation function (e.g., radius, precision).

  • **kwargs (object, default: {} ) –

    Keyword arguments to pass to the primitive creation function.

Raises:
  • ValueError

    If the primitive type is not recognized.

Example

Primitives.create(Prims.SPHERE, "sphere", 0.3, 32) Primitives.create(Prims.SPHERE, "sphere", radius=0.3, precision=32)

Source code in ncca/ngl/opengl/primitives.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
@classmethod
def create(cls, type: str, name: str, *args: object, **kwargs: object) -> None:
    """Creates and stores a primitive object of the specified type.

    Prims.SPHERE : (radius: float, precision: int).
    Prims.TORUS : (radius: float, tube_radius: float, precision: int).
    Prims.LINE_GRID : (width: float, depth: float, steps: int).
    Prims.TRIANGLE_PLANE : ( width: float, depth: float, w_p: int, d_p: int, v_n: Vec3).
    Prims.CYLINDER : (radius: float, height: float, slices: int, stacks: int).
    Prims.CAPSULE : (radius: float, height: float, slices: int, stacks: int).
    Prims.CONE : (radius: float, height: float, slices: int, stacks: int).

    Args:
        type (str): The primitive type, typically from the Prims enum (e.g., Prims.SPHERE).
        name (str): The name to associate with the created primitive.
        *args: Positional arguments to pass to the primitive creation function (e.g., radius, precision).
        **kwargs: Keyword arguments to pass to the primitive creation function.

    Raises:
        ValueError: If the primitive type is not recognized.

    Example:
        Primitives.create(Prims.SPHERE, "sphere", 0.3, 32)
        Primitives.create(Prims.SPHERE, "sphere", radius=0.3, precision=32)
    """
    prim_methods = {
        Prims.SPHERE: PrimData.sphere,
        Prims.TORUS: PrimData.torus,
        Prims.LINE_GRID: PrimData.line_grid,
        Prims.TRIANGLE_PLANE: PrimData.triangle_plane,
        Prims.CYLINDER: PrimData.cylinder,
        Prims.DISK: PrimData.disk,
        Prims.CAPSULE: PrimData.capsule,
        Prims.CONE: PrimData.cone,
    }
    # line primitives are position-only GL_LINES data; everything else is
    # 8-float (position, normal, uv) triangle data
    prim_layouts = {
        Prims.LINE_GRID: (gl.GL_LINES, 3),
    }
    try:
        method = prim_methods[type]
    except KeyError:
        raise ValueError(f"Unknown primitive: {name}")

    draw_mode, floats_per_vertex = prim_layouts.get(type, (gl.GL_TRIANGLES, 8))
    cls._primitives[name] = _primitive(
        method(*args, **kwargs), draw_mode, floats_per_vertex
    )

draw(name) classmethod

Draws the specified primitive.

Parameters:
  • name (str | Prims) –

    The name of the primitive to draw, either as a string or a Prims enum.

Source code in ncca/ngl/opengl/primitives.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
@classmethod
def draw(cls, name: str | Prims) -> None:
    """Draws the specified primitive.

    Args:
        name: The name of the primitive to draw, either as a string or a Prims enum.
    """
    key = name.value if isinstance(name, Prims) else name
    try:
        prim = cls._primitives[key]
        with prim.vao:
            prim.vao.draw()
    except KeyError:
        logger.error(f"Failed to draw primitive {key}")
        return

load_default_primitives() classmethod

Loads the default primitives from the PrimData directory.

Source code in ncca/ngl/opengl/primitives.py
113
114
115
116
117
118
119
120
121
122
123
124
125
@classmethod
def load_default_primitives(cls) -> None:
    """Loads the default primitives from the PrimData directory."""
    logger.info("Loading default primitives...")
    if not cls._loaded:
        for p in Prims:
            try:
                prim_data = PrimData.primitive(p.value)
                prim = _primitive(prim_data)
                cls._primitives[p.value] = prim
            except Exception:
                pass
        cls._loaded = True

BaseMesh

Base class for mesh geometry.

Provides storage for vertices, normals, UVs, faces, and VAO management.

Source code in ncca/ngl/opengl/base_mesh.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class BaseMesh:
    """Base class for mesh geometry.

    Provides storage for vertices, normals, UVs, faces, and VAO management.
    """

    def __init__(self) -> None:
        """Create an empty mesh."""
        self.vertex: list = []
        self.normals: list = []
        self.uv: list = []
        self.faces: list[Face] = []
        self.vao = None
        self.bbox = None
        self.min_x: float = 0.0
        self.max_x: float = 0.0
        self.min_y: float = 0.0
        self.max_y: float = 0.0
        self.min_z: float = 0.0
        self.max_z: float = 0.0
        self.texture_id: int = 0
        self.texture: bool = False

    def is_triangular(self) -> bool:
        """Check if all faces in the mesh are triangles.

        Returns:
            bool: True if all faces are triangles, False otherwise.
        """
        return all(len(f.vertex) == 3 for f in self.faces)

    def _should_skip_vao_creation(self, reset_vao: bool) -> bool:
        """Check if VAO creation should be skipped."""
        if self.vao is None:
            return False

        if reset_vao:
            logger.warning("VAO exist so returning")
            return True

        logger.warning("Creating new VAO")
        return False

    def _validate_triangular_mesh(self) -> None:
        """Validate that the mesh is composed of triangles."""
        if not self.is_triangular():
            logger.error("Can only create VBO from all Triangle data at present")
            raise RuntimeError("Can only create VBO from all Triangle data at present")

    def create_vao(self, reset_vao: bool = False) -> None:
        """Create a Vertex Array Object (VAO) for the mesh.

        Only supports triangular meshes.

        Args:
            reset_vao: If True, will not create a new VAO if one already exists.

        Raises:
            RuntimeError: If the mesh is not composed entirely of triangles.
        """
        # Handle existing VAO based on reset_vao flag
        if self._should_skip_vao_creation(reset_vao):
            return
        # Validate mesh is triangular
        self._validate_triangular_mesh()

        data_pack_type = gl.GL_TRIANGLES

        @dataclass
        class VertData:
            """Structure for a single vertex's data, including position, normal, and UV."""

            x: float = 0.0
            y: float = 0.0
            z: float = 0.0
            nx: float = 0.0
            ny: float = 0.0
            nz: float = 0.0
            u: float = 0.0
            v: float = 0.0

            def as_array(self) -> np.ndarray:
                return np.array(
                    [self.x, self.y, self.z, self.nx, self.ny, self.nz, self.u, self.v],
                    dtype=np.float32,
                )

        vbo_mesh: list[VertData] = []
        for face in self.faces:
            for i in range(3):
                d = VertData()
                d.x = self.vertex[face.vertex[i]].x
                d.y = self.vertex[face.vertex[i]].y
                d.z = self.vertex[face.vertex[i]].z
                if self.normals and self.uv:
                    d.nx = self.normals[face.normal[i]].x
                    d.ny = self.normals[face.normal[i]].y
                    d.nz = self.normals[face.normal[i]].z
                    d.u = self.uv[face.uv[i]].x
                    d.v = 1 - self.uv[face.uv[i]].y  # Flip V for OpenGL
                elif self.normals and not self.uv:
                    d.nx = self.normals[face.normal[i]].x
                    d.ny = self.normals[face.normal[i]].y
                    d.nz = self.normals[face.normal[i]].z
                elif not self.normals and self.uv:
                    d.u = self.uv[face.uv[i]].x
                    d.v = 1 - self.uv[face.uv[i]].y
                vbo_mesh.append(d)

        mesh_data = np.concatenate([v.as_array() for v in vbo_mesh]).astype(np.float32)
        self.vao = vao_factory.VAOFactory.create_vao(
            vao_factory.VAOType.SIMPLE, data_pack_type
        )
        with self.vao as vao:
            mesh_size = len(mesh_data) // 8
            vao.set_data(VertexData(mesh_data, mesh_size))
            # vertex
            vao.set_vertex_attribute_pointer(0, 3, gl.GL_FLOAT, 8 * 4, 0)
            # normals
            vao.set_vertex_attribute_pointer(1, 3, gl.GL_FLOAT, 8 * 4, 3 * 4)
            # uvs
            vao.set_vertex_attribute_pointer(2, 2, gl.GL_FLOAT, 8 * 4, 6 * 4)
            vao.set_num_indices(mesh_size)
        self.calc_dimensions()
        self.bbox = BBox.from_extents(
            self.min_x, self.max_x, self.min_y, self.max_y, self.min_z, self.max_z
        )

    def calc_dimensions(self) -> None:
        """Calculate the bounding box extents for the mesh.

        Updates min_x, max_x, min_y, max_y, min_z, max_z.
        """
        if not self.vertex:
            return
        self.min_x = self.max_x = self.vertex[0].x
        self.min_y = self.max_y = self.vertex[0].y
        self.min_z = self.max_z = self.vertex[0].z
        for v in self.vertex:
            self.min_x = min(self.min_x, v.x)
            self.max_x = max(self.max_x, v.x)
            self.min_y = min(self.min_y, v.y)
            self.max_y = max(self.max_y, v.y)
            self.min_z = min(self.min_z, v.z)
            self.max_z = max(self.max_z, v.z)

    def draw(self) -> None:
        """Draw the mesh using its VAO and bound texture (if any)."""
        if self.vao:
            if self.texture_id:
                gl.glBindTexture(gl.GL_TEXTURE_2D, self.texture_id)
            with self.vao as vao:
                vao.draw()

__init__()

Create an empty mesh.

Source code in ncca/ngl/opengl/base_mesh.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def __init__(self) -> None:
    """Create an empty mesh."""
    self.vertex: list = []
    self.normals: list = []
    self.uv: list = []
    self.faces: list[Face] = []
    self.vao = None
    self.bbox = None
    self.min_x: float = 0.0
    self.max_x: float = 0.0
    self.min_y: float = 0.0
    self.max_y: float = 0.0
    self.min_z: float = 0.0
    self.max_z: float = 0.0
    self.texture_id: int = 0
    self.texture: bool = False

calc_dimensions()

Calculate the bounding box extents for the mesh.

Updates min_x, max_x, min_y, max_y, min_z, max_z.

Source code in ncca/ngl/opengl/base_mesh.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def calc_dimensions(self) -> None:
    """Calculate the bounding box extents for the mesh.

    Updates min_x, max_x, min_y, max_y, min_z, max_z.
    """
    if not self.vertex:
        return
    self.min_x = self.max_x = self.vertex[0].x
    self.min_y = self.max_y = self.vertex[0].y
    self.min_z = self.max_z = self.vertex[0].z
    for v in self.vertex:
        self.min_x = min(self.min_x, v.x)
        self.max_x = max(self.max_x, v.x)
        self.min_y = min(self.min_y, v.y)
        self.max_y = max(self.max_y, v.y)
        self.min_z = min(self.min_z, v.z)
        self.max_z = max(self.max_z, v.z)

create_vao(reset_vao=False)

Create a Vertex Array Object (VAO) for the mesh.

Only supports triangular meshes.

Parameters:
  • reset_vao (bool, default: False ) –

    If True, will not create a new VAO if one already exists.

Raises:
  • RuntimeError

    If the mesh is not composed entirely of triangles.

Source code in ncca/ngl/opengl/base_mesh.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def create_vao(self, reset_vao: bool = False) -> None:
    """Create a Vertex Array Object (VAO) for the mesh.

    Only supports triangular meshes.

    Args:
        reset_vao: If True, will not create a new VAO if one already exists.

    Raises:
        RuntimeError: If the mesh is not composed entirely of triangles.
    """
    # Handle existing VAO based on reset_vao flag
    if self._should_skip_vao_creation(reset_vao):
        return
    # Validate mesh is triangular
    self._validate_triangular_mesh()

    data_pack_type = gl.GL_TRIANGLES

    @dataclass
    class VertData:
        """Structure for a single vertex's data, including position, normal, and UV."""

        x: float = 0.0
        y: float = 0.0
        z: float = 0.0
        nx: float = 0.0
        ny: float = 0.0
        nz: float = 0.0
        u: float = 0.0
        v: float = 0.0

        def as_array(self) -> np.ndarray:
            return np.array(
                [self.x, self.y, self.z, self.nx, self.ny, self.nz, self.u, self.v],
                dtype=np.float32,
            )

    vbo_mesh: list[VertData] = []
    for face in self.faces:
        for i in range(3):
            d = VertData()
            d.x = self.vertex[face.vertex[i]].x
            d.y = self.vertex[face.vertex[i]].y
            d.z = self.vertex[face.vertex[i]].z
            if self.normals and self.uv:
                d.nx = self.normals[face.normal[i]].x
                d.ny = self.normals[face.normal[i]].y
                d.nz = self.normals[face.normal[i]].z
                d.u = self.uv[face.uv[i]].x
                d.v = 1 - self.uv[face.uv[i]].y  # Flip V for OpenGL
            elif self.normals and not self.uv:
                d.nx = self.normals[face.normal[i]].x
                d.ny = self.normals[face.normal[i]].y
                d.nz = self.normals[face.normal[i]].z
            elif not self.normals and self.uv:
                d.u = self.uv[face.uv[i]].x
                d.v = 1 - self.uv[face.uv[i]].y
            vbo_mesh.append(d)

    mesh_data = np.concatenate([v.as_array() for v in vbo_mesh]).astype(np.float32)
    self.vao = vao_factory.VAOFactory.create_vao(
        vao_factory.VAOType.SIMPLE, data_pack_type
    )
    with self.vao as vao:
        mesh_size = len(mesh_data) // 8
        vao.set_data(VertexData(mesh_data, mesh_size))
        # vertex
        vao.set_vertex_attribute_pointer(0, 3, gl.GL_FLOAT, 8 * 4, 0)
        # normals
        vao.set_vertex_attribute_pointer(1, 3, gl.GL_FLOAT, 8 * 4, 3 * 4)
        # uvs
        vao.set_vertex_attribute_pointer(2, 2, gl.GL_FLOAT, 8 * 4, 6 * 4)
        vao.set_num_indices(mesh_size)
    self.calc_dimensions()
    self.bbox = BBox.from_extents(
        self.min_x, self.max_x, self.min_y, self.max_y, self.min_z, self.max_z
    )

draw()

Draw the mesh using its VAO and bound texture (if any).

Source code in ncca/ngl/opengl/base_mesh.py
175
176
177
178
179
180
181
def draw(self) -> None:
    """Draw the mesh using its VAO and bound texture (if any)."""
    if self.vao:
        if self.texture_id:
            gl.glBindTexture(gl.GL_TEXTURE_2D, self.texture_id)
        with self.vao as vao:
            vao.draw()

is_triangular()

Check if all faces in the mesh are triangles.

Returns:
  • bool( bool ) –

    True if all faces are triangles, False otherwise.

Source code in ncca/ngl/opengl/base_mesh.py
52
53
54
55
56
57
58
def is_triangular(self) -> bool:
    """Check if all faces in the mesh are triangles.

    Returns:
        bool: True if all faces are triangles, False otherwise.
    """
    return all(len(f.vertex) == 3 for f in self.faces)

Face

Simple face structure for mesh geometry.

Holds indices for vertices, UVs, and normals.

Source code in ncca/ngl/opengl/base_mesh.py
14
15
16
17
18
19
20
21
22
23
24
25
26
class Face:
    """Simple face structure for mesh geometry.

    Holds indices for vertices, UVs, and normals.
    """

    __slots__ = ("vertex", "uv", "normal")

    def __init__(self) -> None:
        """Create an empty face."""
        self.vertex: list[int] = []
        self.uv: list[int] = []
        self.normal: list[int] = []

__init__()

Create an empty face.

Source code in ncca/ngl/opengl/base_mesh.py
22
23
24
25
26
def __init__(self) -> None:
    """Create an empty face."""
    self.vertex: list[int] = []
    self.uv: list[int] = []
    self.normal: list[int] = []

PrimData

Static methods generating packed vertex data for primitive shapes.

Source code in ncca/ngl/prim_data.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
class PrimData:
    """Static methods generating packed vertex data for primitive shapes."""

    @staticmethod
    def line_grid(width: float, depth: float, steps: int) -> np.ndarray:
        """Creates a line grid primitive.

        Args:
            width: The width of the grid.
            depth: The depth of the grid.
            steps: The number of steps in the grid.
        """
        # Calculate the step size for each grid value
        wstep = width / steps
        ws2 = width / 2.0
        v1 = -ws2

        dstep = depth / steps
        ds2 = depth / 2.0
        v2 = -ds2

        # Create a list to store the vertex data
        data = []

        for _ in range(steps + 1):
            # Vertex 1 x, y, z
            data.append([-ws2, 0.0, v1])
            # Vertex 2 x, y, z
            data.append([ws2, 0.0, v1])

            # Vertex 1 x, y, z
            data.append([v2, 0.0, ds2])
            # Vertex 2 x, y, z
            data.append([v2, 0.0, -ds2])

            # Now change our step value
            v1 += wstep
            v2 += dstep

        # Convert the list to a NumPy array
        return np.array(data, dtype=np.float32)

    @staticmethod
    def triangle_plane(
        width: float, depth: float, w_p: int, d_p: int, v_n: Vec3
    ) -> np.ndarray:
        """Creates a triangle plane primitive.

        Args:
            width: The width of the plane.
            depth: The depth of the plane.
            w_p: The number of width partitions.
            d_p: The number of depth partitions.
            v_n: The normal vector for the plane.
        """
        w2 = width / 2.0
        d2 = depth / 2.0
        w_step = width / w_p
        d_step = depth / d_p

        du = 0.9 / w_p
        dv = 0.9 / d_p

        data = []
        v = 0.0
        d = -d2
        for _ in range(d_p):
            u = 0.0
            w = -w2
            for _ in range(w_p):
                # tri 1
                # vert 1
                data.extend([w, 0.0, d + d_step, v_n.x, v_n.y, v_n.z, u, v + dv])
                # vert 2
                data.extend(
                    [w + w_step, 0.0, d + d_step, v_n.x, v_n.y, v_n.z, u + du, v + dv]
                )
                # vert 3
                data.extend([w, 0.0, d, v_n.x, v_n.y, v_n.z, u, v])

                # tri 2
                # vert 1
                data.extend(
                    [w + w_step, 0.0, d + d_step, v_n.x, v_n.y, v_n.z, u + du, v + dv]
                )
                # vert 2
                data.extend([w + w_step, 0.0, d, v_n.x, v_n.y, v_n.z, u + du, v])
                # vert 3
                data.extend([w, 0.0, d, v_n.x, v_n.y, v_n.z, u, v])
                u += du
                w += w_step
            v += dv
            d += d_step

        return np.array(data, dtype=np.float32)

    @staticmethod
    def sphere(radius: float, precision: int) -> np.ndarray:
        """Creates a sphere primitive.

        Args:
            radius: The radius of the sphere.
            precision: The precision of the sphere (number of slices).
        """
        # Sphere code based on a function Written by Paul Bourke.
        # http://astronomy.swin.edu.au/~pbourke/opengl/sphere/
        # the next part of the code calculates the P,N,UV of the sphere for triangles

        # Disallow a negative number for radius.
        if radius < 0.0:
            radius = -radius

        # Disallow a negative number for precision.
        if precision < 4:
            precision = 4

        # Create a numpy array to store our verts
        data = []

        for i in range(precision // 2):
            theta1 = i * 2.0 * np.pi / precision - np.pi / 2.0
            theta2 = (i + 1) * 2.0 * np.pi / precision - np.pi / 2.0

            for j in range(precision):
                theta3 = j * 2.0 * np.pi / precision
                theta4 = (j + 1) * 2.0 * np.pi / precision

                # First triangle
                nx1 = np.cos(theta2) * np.cos(theta3)
                ny1 = np.sin(theta2)
                nz1 = np.cos(theta2) * np.sin(theta3)
                x1 = radius * nx1
                y1 = radius * ny1
                z1 = radius * nz1
                u1 = j / precision
                v1 = 2.0 * (i + 1) / precision
                data.append([x1, y1, z1, nx1, ny1, nz1, u1, v1])

                nx2 = np.cos(theta1) * np.cos(theta3)
                ny2 = np.sin(theta1)
                nz2 = np.cos(theta1) * np.sin(theta3)
                x2 = radius * nx2
                y2 = radius * ny2
                z2 = radius * nz2
                u2 = j / precision
                v2 = 2.0 * i / precision
                data.append([x2, y2, z2, nx2, ny2, nz2, u2, v2])

                nx3 = np.cos(theta1) * np.cos(theta4)
                ny3 = np.sin(theta1)
                nz3 = np.cos(theta1) * np.sin(theta4)
                x3 = radius * nx3
                y3 = radius * ny3
                z3 = radius * nz3
                u3 = (j + 1) / precision
                v3 = 2.0 * i / precision
                data.append([x3, y3, z3, nx3, ny3, nz3, u3, v3])

                # Second triangle
                nx4 = np.cos(theta2) * np.cos(theta4)
                ny4 = np.sin(theta2)
                nz4 = np.cos(theta2) * np.sin(theta4)
                x4 = radius * nx4
                y4 = radius * ny4
                z4 = radius * nz4
                u4 = (j + 1) / precision
                v4 = 2.0 * (i + 1) / precision
                data.append([x4, y4, z4, nx4, ny4, nz4, u4, v4])

                data.append([x1, y1, z1, nx1, ny1, nz1, u1, v1])
                data.append([x3, y3, z3, nx3, ny3, nz3, u3, v3])

        return np.array(data, dtype=np.float32)

    @staticmethod
    def cone(base: float, height: float, slices: int, stacks: int) -> np.ndarray:
        """Creates a cone primitive.

        Args:
            base: The radius of the cone's base.
            height: The height of the cone.
            slices: The number of divisions around the cone.
            stacks: The number of divisions along the cone's height.
        """
        z_step = height / (stacks if stacks > 0 else 1)
        r_step = base / (stacks if stacks > 0 else 1)

        cosn = height / np.sqrt(height * height + base * base)
        sinn = base / np.sqrt(height * height + base * base)

        cs = _circle_table(slices)

        z0 = 0.0
        z1 = z_step

        r0 = base
        r1 = r0 - r_step

        du = 1.0 / stacks
        dv = 1.0 / slices

        u = 1.0
        v = 1.0

        data = []

        for _ in range(stacks):
            for j in range(slices):
                # First triangle
                d1 = [0] * 8
                d1[6] = u
                d1[7] = v
                d1[3] = cs[j, 0] * cosn  # nx
                d1[4] = cs[j, 1] * sinn  # ny
                d1[5] = sinn  # nz
                d1[0] = cs[j, 0] * r0  # x
                d1[1] = cs[j, 1] * r0  # y
                d1[2] = z0  # z
                data.append(d1)

                d2 = [0] * 8
                d2[6] = u
                d2[7] = v - dv
                d2[3] = cs[j, 0] * cosn  # nx
                d2[4] = cs[j, 1] * sinn  # ny
                d2[5] = sinn  # nz
                d2[0] = cs[j, 0] * r1  # x
                d2[1] = cs[j, 1] * r1  # y
                d2[2] = z1  # z
                data.append(d2)

                d3 = [0] * 8
                d3[6] = u - du
                d3[7] = v - dv
                d3[3] = cs[j + 1, 0] * cosn  # nx
                d3[4] = cs[j + 1, 1] * sinn  # ny
                d3[5] = sinn  # nz
                d3[0] = cs[j + 1, 0] * r1  # x
                d3[1] = cs[j + 1, 1] * r1  # y
                d3[2] = z1  # z
                data.append(d3)

                # Second triangle
                d4 = [0] * 8
                d4[6] = u
                d4[7] = v
                d4[3] = cs[j, 0] * cosn  # nx
                d4[4] = cs[j, 1] * sinn  # ny
                d4[5] = sinn  # nz
                d4[0] = cs[j, 0] * r0  # x
                d4[1] = cs[j, 1] * r0  # y
                d4[2] = z0  # z
                data.append(d4)

                d5 = [0] * 8
                d5[6] = u - du
                d5[7] = v - dv
                d5[3] = cs[j + 1, 0] * cosn  # nx
                d5[4] = cs[j + 1, 1] * sinn  # ny
                d5[5] = sinn  # nz
                d5[0] = cs[j + 1, 0] * r1  # x
                d5[1] = cs[j + 1, 1] * r1  # y
                d5[2] = z1  # z
                data.append(d5)

                d6 = [0] * 8
                d6[6] = u - du
                d6[7] = v
                d6[3] = cs[j + 1, 0] * cosn  # nx
                d6[4] = cs[j + 1, 1] * sinn  # ny
                d6[5] = sinn  # nz
                d6[0] = cs[j + 1, 0] * r0  # x
                d6[1] = cs[j + 1, 1] * r0  # y
                d6[2] = z0  # z
                data.append(d6)

                u -= du

            v -= dv
            u = 1.0
            z0 = z1
            z1 += z_step
            r0 = r1
            r1 -= r_step

        return np.array(data, dtype=np.float32)

    @staticmethod
    def _add_cylinder_sides(
        data: list, radius: float, h: float, ang: float, precision: int
    ) -> None:
        """Generates cylinder side geometry."""
        for i in range(2 * precision):
            c = radius * np.cos(ang * i)
            c1 = radius * np.cos(ang * (i + 1))
            s = radius * np.sin(ang * i)
            s1 = radius * np.sin(ang * (i + 1))
            # normals for cylinder sides
            nc = np.cos(ang * i)
            ns = np.sin(ang * i)
            nc1 = np.cos(ang * (i + 1))
            ns1 = np.sin(ang * (i + 1))
            # side top
            data.extend([c1, h, s1, nc1, 0.0, ns1, 0.0, 0.0])
            data.extend([c, h, s, nc, 0.0, ns, 0.0, 0.0])
            data.extend([c, -h, s, nc, 0.0, ns, 0.0, 0.0])
            # side bot
            data.extend([c, -h, s, nc, 0.0, ns, 0.0, 0.0])
            data.extend([c1, -h, s1, nc1, 0.0, ns1, 0.0, 0.0])
            data.extend([c1, h, s1, nc1, 0.0, ns1, 0.0, 0.0])

    @staticmethod
    def _add_hemispherical_caps(
        data: list, radius: float, h: float, ang: float, precision: int
    ) -> None:
        """Generates hemispherical cap geometry."""
        for i in range(2 * precision):
            # longitude
            s = -np.sin(ang * i)
            s1 = -np.sin(ang * (i + 1))
            c = np.cos(ang * i)
            c1 = np.cos(ang * (i + 1))
            for j in range(precision + 1):
                o = h if j < precision / 2 else -h
                # latitude
                sb = radius * np.sin(ang * j)
                sb1 = radius * np.sin(ang * (j + 1))
                cb = radius * np.cos(ang * j)
                cb1 = radius * np.cos(ang * (j + 1))
                if j != precision - 1:
                    nx, ny, nz = sb * c, cb, sb * s
                    data.extend([nx, ny + o, nz, nx, ny, nz, 0.0, 0.0])
                    nx, ny, nz = sb1 * c, cb1, sb1 * s
                    data.extend([nx, ny + o, nz, nx, ny, nz, 0.0, 0.0])
                    nx, ny, nz = sb1 * c1, cb1, sb1 * s1
                    data.extend([nx, ny + o, nz, nx, ny, nz, 0.0, 0.0])
                if j != 0:
                    nx, ny, nz = sb * c, cb, sb * s
                    data.extend([nx, ny + o, nz, nx, ny, nz, 0.0, 0.0])
                    nx, ny, nz = sb1 * c1, cb1, sb1 * s1
                    data.extend([nx, ny + o, nz, nx, ny, nz, 0.0, 0.0])
                    nx, ny, nz = sb * c1, cb, sb * s1
                    data.extend([nx, ny + o, nz, nx, ny, nz, 0.0, 0.0])

    @staticmethod
    def capsule(radius: float, height: float, precision: int) -> np.ndarray:
        """Creates a capsule primitive.

        The capsule is aligned along the y-axis and is composed of a cylinder
        and two hemispherical caps. Based on code from
        https://code.google.com/p/rgine/source/browse/trunk/RGine/opengl/src/RGLShapes.cpp
        and adapted.
        """
        if radius <= 0.0:
            raise ValueError(RAD_POS)
        if height < 0.0:
            raise ValueError(NON_NEG)
        if precision < 4:
            precision = 4

        data = []
        h = height / 2.0
        ang = np.pi / precision

        # Cylinder sides
        PrimData._add_cylinder_sides(data, radius, h, ang, precision)

        # Hemispherical caps
        PrimData._add_hemispherical_caps(data, radius, h, ang, precision)

        return np.array(data, dtype=np.float32)

    @staticmethod
    def cylinder(radius: float, height: float, slices: int, stacks: int) -> np.ndarray:
        """Creates a cylinder primitive.

        The cylinder is aligned along the y-axis.
        This method generates the cylinder walls, but not the top and bottom caps.
        """
        if radius <= 0.0:
            raise ValueError(RAD_POS)
        if height < 0.0:
            raise ValueError(NON_NEG)
        if slices < 3:
            slices = 3
        if stacks < 1:
            stacks = 1

        data = []
        h2 = height / 2.0
        y_step = height / stacks

        cs = _circle_table(slices)

        du = 1.0 / slices
        dv = 1.0 / stacks

        for i in range(stacks):
            y0 = -h2 + i * y_step
            y1 = -h2 + (i + 1) * y_step
            v = i * dv
            for j in range(slices):
                u = j * du

                nx1, nz1 = cs[j, 0], cs[j, 1]
                x1, z1 = radius * nx1, radius * nz1

                nx2, nz2 = cs[j + 1, 0], cs[j + 1, 1]
                x2, z2 = radius * nx2, radius * nz2

                p_bl = [x1, y0, z1, nx1, 0, nz1, u, v]
                p_br = [x2, y0, z2, nx2, 0, nz2, u + du, v]
                p_tl = [x1, y1, z1, nx1, 0, nz1, u, v + dv]
                p_tr = [x2, y1, z2, nx2, 0, nz2, u + du, v + dv]

                # Triangle 1
                data.extend(p_bl)
                data.extend(p_tl)
                data.extend(p_br)
                # Triangle 2
                data.extend(p_br)
                data.extend(p_tl)
                data.extend(p_tr)

        return np.array(data, dtype=np.float32)

    @staticmethod
    def disk(radius: float, slices: int) -> np.ndarray:
        """Creates a disk primitive.

        Args:
            radius: The radius of the disk.
            slices: The number of slices to divide the disk into.
        """
        if radius <= 0.0:
            raise ValueError(RAD_POS)
        if slices < 3:
            slices = 3

        data = []
        cs = _circle_table(slices)

        center = [0, 0, 0, 0, 1, 0, 0.5, 0.5]

        for i in range(slices):
            p1 = [
                radius * cs[i, 0],
                0,
                radius * cs[i, 1],
                0,
                1,
                0,
                cs[i, 0] * 0.5 + 0.5,
                cs[i, 1] * 0.5 + 0.5,
            ]
            p2 = [
                radius * cs[i + 1, 0],
                0,
                radius * cs[i + 1, 1],
                0,
                1,
                0,
                cs[i + 1, 0] * 0.5 + 0.5,
                cs[i + 1, 1] * 0.5 + 0.5,
            ]

            data.extend(center)
            data.extend(p2)
            data.extend(p1)

        return np.array(data, dtype=np.float32)

    @staticmethod
    def torus(
        minor_radius: float,
        major_radius: float,
        sides: int,
        rings: int,
    ) -> np.ndarray:
        """Creates a torus primitive.

        Args:
            minor_radius: The minor radius of the torus.
            major_radius: The major radius of the torus.
            sides: The number of sides for each ring.
            rings: The number of rings for the torus.
        """
        if minor_radius <= 0 or major_radius <= 0:
            raise ValueError(RAD_POS)
        if sides < 3 or rings < 3:
            raise ValueError("Sides and rings must be at least 3")

        d_psi = 2.0 * np.pi / rings
        d_phi = -2.0 * np.pi / sides

        psi = 0.0

        vertices = []
        normals = []
        uvs = []

        for j in range(rings + 1):
            c_psi = np.cos(psi)
            s_psi = np.sin(psi)
            phi = 0.0
            for i in range(sides + 1):
                c_phi = np.cos(phi)
                s_phi = np.sin(phi)

                x = c_psi * (major_radius + c_phi * minor_radius)
                z = s_psi * (major_radius + c_phi * minor_radius)
                y = s_phi * minor_radius
                vertices.append([x, y, z])

                nx = c_psi * c_phi
                nz = s_psi * c_phi
                ny = s_phi
                normals.append([nx, ny, nz])

                u = i / sides
                v = j / rings
                uvs.append([u, v])

                phi += d_phi
            psi += d_psi

        data = []
        for j in range(rings):
            for i in range(sides):
                idx1 = j * (sides + 1) + i
                idx2 = j * (sides + 1) + (i + 1)
                idx3 = (j + 1) * (sides + 1) + i
                idx4 = (j + 1) * (sides + 1) + (i + 1)

                p1 = vertices[idx1] + normals[idx1] + uvs[idx1]
                p2 = vertices[idx2] + normals[idx2] + uvs[idx2]
                p3 = vertices[idx3] + normals[idx3] + uvs[idx3]
                p4 = vertices[idx4] + normals[idx4] + uvs[idx4]

                data.extend(p1)
                data.extend(p3)
                data.extend(p2)

                data.extend(p2)
                data.extend(p3)
                data.extend(p4)

        return np.array(data, dtype=np.float32)

    @staticmethod
    def primitive(name: str | Enum) -> np.ndarray:
        """Load pre-generated vertex data for the named primitive."""
        prim_folder = Path(__file__).parent / "PrimData"
        prims = np.load(prim_folder / "Primitives.npz")
        if isinstance(name, Prims):
            name = name.value

        try:
            return prims[name]
        except KeyError:
            raise ValueError(f"Primitive '{name}' not found")

capsule(radius, height, precision) staticmethod

Creates a capsule primitive.

The capsule is aligned along the y-axis and is composed of a cylinder and two hemispherical caps. Based on code from https://code.google.com/p/rgine/source/browse/trunk/RGine/opengl/src/RGLShapes.cpp and adapted.

Source code in ncca/ngl/prim_data.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
@staticmethod
def capsule(radius: float, height: float, precision: int) -> np.ndarray:
    """Creates a capsule primitive.

    The capsule is aligned along the y-axis and is composed of a cylinder
    and two hemispherical caps. Based on code from
    https://code.google.com/p/rgine/source/browse/trunk/RGine/opengl/src/RGLShapes.cpp
    and adapted.
    """
    if radius <= 0.0:
        raise ValueError(RAD_POS)
    if height < 0.0:
        raise ValueError(NON_NEG)
    if precision < 4:
        precision = 4

    data = []
    h = height / 2.0
    ang = np.pi / precision

    # Cylinder sides
    PrimData._add_cylinder_sides(data, radius, h, ang, precision)

    # Hemispherical caps
    PrimData._add_hemispherical_caps(data, radius, h, ang, precision)

    return np.array(data, dtype=np.float32)

cone(base, height, slices, stacks) staticmethod

Creates a cone primitive.

Parameters:
  • base (float) –

    The radius of the cone's base.

  • height (float) –

    The height of the cone.

  • slices (int) –

    The number of divisions around the cone.

  • stacks (int) –

    The number of divisions along the cone's height.

Source code in ncca/ngl/prim_data.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
@staticmethod
def cone(base: float, height: float, slices: int, stacks: int) -> np.ndarray:
    """Creates a cone primitive.

    Args:
        base: The radius of the cone's base.
        height: The height of the cone.
        slices: The number of divisions around the cone.
        stacks: The number of divisions along the cone's height.
    """
    z_step = height / (stacks if stacks > 0 else 1)
    r_step = base / (stacks if stacks > 0 else 1)

    cosn = height / np.sqrt(height * height + base * base)
    sinn = base / np.sqrt(height * height + base * base)

    cs = _circle_table(slices)

    z0 = 0.0
    z1 = z_step

    r0 = base
    r1 = r0 - r_step

    du = 1.0 / stacks
    dv = 1.0 / slices

    u = 1.0
    v = 1.0

    data = []

    for _ in range(stacks):
        for j in range(slices):
            # First triangle
            d1 = [0] * 8
            d1[6] = u
            d1[7] = v
            d1[3] = cs[j, 0] * cosn  # nx
            d1[4] = cs[j, 1] * sinn  # ny
            d1[5] = sinn  # nz
            d1[0] = cs[j, 0] * r0  # x
            d1[1] = cs[j, 1] * r0  # y
            d1[2] = z0  # z
            data.append(d1)

            d2 = [0] * 8
            d2[6] = u
            d2[7] = v - dv
            d2[3] = cs[j, 0] * cosn  # nx
            d2[4] = cs[j, 1] * sinn  # ny
            d2[5] = sinn  # nz
            d2[0] = cs[j, 0] * r1  # x
            d2[1] = cs[j, 1] * r1  # y
            d2[2] = z1  # z
            data.append(d2)

            d3 = [0] * 8
            d3[6] = u - du
            d3[7] = v - dv
            d3[3] = cs[j + 1, 0] * cosn  # nx
            d3[4] = cs[j + 1, 1] * sinn  # ny
            d3[5] = sinn  # nz
            d3[0] = cs[j + 1, 0] * r1  # x
            d3[1] = cs[j + 1, 1] * r1  # y
            d3[2] = z1  # z
            data.append(d3)

            # Second triangle
            d4 = [0] * 8
            d4[6] = u
            d4[7] = v
            d4[3] = cs[j, 0] * cosn  # nx
            d4[4] = cs[j, 1] * sinn  # ny
            d4[5] = sinn  # nz
            d4[0] = cs[j, 0] * r0  # x
            d4[1] = cs[j, 1] * r0  # y
            d4[2] = z0  # z
            data.append(d4)

            d5 = [0] * 8
            d5[6] = u - du
            d5[7] = v - dv
            d5[3] = cs[j + 1, 0] * cosn  # nx
            d5[4] = cs[j + 1, 1] * sinn  # ny
            d5[5] = sinn  # nz
            d5[0] = cs[j + 1, 0] * r1  # x
            d5[1] = cs[j + 1, 1] * r1  # y
            d5[2] = z1  # z
            data.append(d5)

            d6 = [0] * 8
            d6[6] = u - du
            d6[7] = v
            d6[3] = cs[j + 1, 0] * cosn  # nx
            d6[4] = cs[j + 1, 1] * sinn  # ny
            d6[5] = sinn  # nz
            d6[0] = cs[j + 1, 0] * r0  # x
            d6[1] = cs[j + 1, 1] * r0  # y
            d6[2] = z0  # z
            data.append(d6)

            u -= du

        v -= dv
        u = 1.0
        z0 = z1
        z1 += z_step
        r0 = r1
        r1 -= r_step

    return np.array(data, dtype=np.float32)

cylinder(radius, height, slices, stacks) staticmethod

Creates a cylinder primitive.

The cylinder is aligned along the y-axis. This method generates the cylinder walls, but not the top and bottom caps.

Source code in ncca/ngl/prim_data.py
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
@staticmethod
def cylinder(radius: float, height: float, slices: int, stacks: int) -> np.ndarray:
    """Creates a cylinder primitive.

    The cylinder is aligned along the y-axis.
    This method generates the cylinder walls, but not the top and bottom caps.
    """
    if radius <= 0.0:
        raise ValueError(RAD_POS)
    if height < 0.0:
        raise ValueError(NON_NEG)
    if slices < 3:
        slices = 3
    if stacks < 1:
        stacks = 1

    data = []
    h2 = height / 2.0
    y_step = height / stacks

    cs = _circle_table(slices)

    du = 1.0 / slices
    dv = 1.0 / stacks

    for i in range(stacks):
        y0 = -h2 + i * y_step
        y1 = -h2 + (i + 1) * y_step
        v = i * dv
        for j in range(slices):
            u = j * du

            nx1, nz1 = cs[j, 0], cs[j, 1]
            x1, z1 = radius * nx1, radius * nz1

            nx2, nz2 = cs[j + 1, 0], cs[j + 1, 1]
            x2, z2 = radius * nx2, radius * nz2

            p_bl = [x1, y0, z1, nx1, 0, nz1, u, v]
            p_br = [x2, y0, z2, nx2, 0, nz2, u + du, v]
            p_tl = [x1, y1, z1, nx1, 0, nz1, u, v + dv]
            p_tr = [x2, y1, z2, nx2, 0, nz2, u + du, v + dv]

            # Triangle 1
            data.extend(p_bl)
            data.extend(p_tl)
            data.extend(p_br)
            # Triangle 2
            data.extend(p_br)
            data.extend(p_tl)
            data.extend(p_tr)

    return np.array(data, dtype=np.float32)

disk(radius, slices) staticmethod

Creates a disk primitive.

Parameters:
  • radius (float) –

    The radius of the disk.

  • slices (int) –

    The number of slices to divide the disk into.

Source code in ncca/ngl/prim_data.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
@staticmethod
def disk(radius: float, slices: int) -> np.ndarray:
    """Creates a disk primitive.

    Args:
        radius: The radius of the disk.
        slices: The number of slices to divide the disk into.
    """
    if radius <= 0.0:
        raise ValueError(RAD_POS)
    if slices < 3:
        slices = 3

    data = []
    cs = _circle_table(slices)

    center = [0, 0, 0, 0, 1, 0, 0.5, 0.5]

    for i in range(slices):
        p1 = [
            radius * cs[i, 0],
            0,
            radius * cs[i, 1],
            0,
            1,
            0,
            cs[i, 0] * 0.5 + 0.5,
            cs[i, 1] * 0.5 + 0.5,
        ]
        p2 = [
            radius * cs[i + 1, 0],
            0,
            radius * cs[i + 1, 1],
            0,
            1,
            0,
            cs[i + 1, 0] * 0.5 + 0.5,
            cs[i + 1, 1] * 0.5 + 0.5,
        ]

        data.extend(center)
        data.extend(p2)
        data.extend(p1)

    return np.array(data, dtype=np.float32)

line_grid(width, depth, steps) staticmethod

Creates a line grid primitive.

Parameters:
  • width (float) –

    The width of the grid.

  • depth (float) –

    The depth of the grid.

  • steps (int) –

    The number of steps in the grid.

Source code in ncca/ngl/prim_data.py
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
@staticmethod
def line_grid(width: float, depth: float, steps: int) -> np.ndarray:
    """Creates a line grid primitive.

    Args:
        width: The width of the grid.
        depth: The depth of the grid.
        steps: The number of steps in the grid.
    """
    # Calculate the step size for each grid value
    wstep = width / steps
    ws2 = width / 2.0
    v1 = -ws2

    dstep = depth / steps
    ds2 = depth / 2.0
    v2 = -ds2

    # Create a list to store the vertex data
    data = []

    for _ in range(steps + 1):
        # Vertex 1 x, y, z
        data.append([-ws2, 0.0, v1])
        # Vertex 2 x, y, z
        data.append([ws2, 0.0, v1])

        # Vertex 1 x, y, z
        data.append([v2, 0.0, ds2])
        # Vertex 2 x, y, z
        data.append([v2, 0.0, -ds2])

        # Now change our step value
        v1 += wstep
        v2 += dstep

    # Convert the list to a NumPy array
    return np.array(data, dtype=np.float32)

primitive(name) staticmethod

Load pre-generated vertex data for the named primitive.

Source code in ncca/ngl/prim_data.py
617
618
619
620
621
622
623
624
625
626
627
628
@staticmethod
def primitive(name: str | Enum) -> np.ndarray:
    """Load pre-generated vertex data for the named primitive."""
    prim_folder = Path(__file__).parent / "PrimData"
    prims = np.load(prim_folder / "Primitives.npz")
    if isinstance(name, Prims):
        name = name.value

    try:
        return prims[name]
    except KeyError:
        raise ValueError(f"Primitive '{name}' not found")

sphere(radius, precision) staticmethod

Creates a sphere primitive.

Parameters:
  • radius (float) –

    The radius of the sphere.

  • precision (int) –

    The precision of the sphere (number of slices).

Source code in ncca/ngl/prim_data.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
@staticmethod
def sphere(radius: float, precision: int) -> np.ndarray:
    """Creates a sphere primitive.

    Args:
        radius: The radius of the sphere.
        precision: The precision of the sphere (number of slices).
    """
    # Sphere code based on a function Written by Paul Bourke.
    # http://astronomy.swin.edu.au/~pbourke/opengl/sphere/
    # the next part of the code calculates the P,N,UV of the sphere for triangles

    # Disallow a negative number for radius.
    if radius < 0.0:
        radius = -radius

    # Disallow a negative number for precision.
    if precision < 4:
        precision = 4

    # Create a numpy array to store our verts
    data = []

    for i in range(precision // 2):
        theta1 = i * 2.0 * np.pi / precision - np.pi / 2.0
        theta2 = (i + 1) * 2.0 * np.pi / precision - np.pi / 2.0

        for j in range(precision):
            theta3 = j * 2.0 * np.pi / precision
            theta4 = (j + 1) * 2.0 * np.pi / precision

            # First triangle
            nx1 = np.cos(theta2) * np.cos(theta3)
            ny1 = np.sin(theta2)
            nz1 = np.cos(theta2) * np.sin(theta3)
            x1 = radius * nx1
            y1 = radius * ny1
            z1 = radius * nz1
            u1 = j / precision
            v1 = 2.0 * (i + 1) / precision
            data.append([x1, y1, z1, nx1, ny1, nz1, u1, v1])

            nx2 = np.cos(theta1) * np.cos(theta3)
            ny2 = np.sin(theta1)
            nz2 = np.cos(theta1) * np.sin(theta3)
            x2 = radius * nx2
            y2 = radius * ny2
            z2 = radius * nz2
            u2 = j / precision
            v2 = 2.0 * i / precision
            data.append([x2, y2, z2, nx2, ny2, nz2, u2, v2])

            nx3 = np.cos(theta1) * np.cos(theta4)
            ny3 = np.sin(theta1)
            nz3 = np.cos(theta1) * np.sin(theta4)
            x3 = radius * nx3
            y3 = radius * ny3
            z3 = radius * nz3
            u3 = (j + 1) / precision
            v3 = 2.0 * i / precision
            data.append([x3, y3, z3, nx3, ny3, nz3, u3, v3])

            # Second triangle
            nx4 = np.cos(theta2) * np.cos(theta4)
            ny4 = np.sin(theta2)
            nz4 = np.cos(theta2) * np.sin(theta4)
            x4 = radius * nx4
            y4 = radius * ny4
            z4 = radius * nz4
            u4 = (j + 1) / precision
            v4 = 2.0 * (i + 1) / precision
            data.append([x4, y4, z4, nx4, ny4, nz4, u4, v4])

            data.append([x1, y1, z1, nx1, ny1, nz1, u1, v1])
            data.append([x3, y3, z3, nx3, ny3, nz3, u3, v3])

    return np.array(data, dtype=np.float32)

torus(minor_radius, major_radius, sides, rings) staticmethod

Creates a torus primitive.

Parameters:
  • minor_radius (float) –

    The minor radius of the torus.

  • major_radius (float) –

    The major radius of the torus.

  • sides (int) –

    The number of sides for each ring.

  • rings (int) –

    The number of rings for the torus.

Source code in ncca/ngl/prim_data.py
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
@staticmethod
def torus(
    minor_radius: float,
    major_radius: float,
    sides: int,
    rings: int,
) -> np.ndarray:
    """Creates a torus primitive.

    Args:
        minor_radius: The minor radius of the torus.
        major_radius: The major radius of the torus.
        sides: The number of sides for each ring.
        rings: The number of rings for the torus.
    """
    if minor_radius <= 0 or major_radius <= 0:
        raise ValueError(RAD_POS)
    if sides < 3 or rings < 3:
        raise ValueError("Sides and rings must be at least 3")

    d_psi = 2.0 * np.pi / rings
    d_phi = -2.0 * np.pi / sides

    psi = 0.0

    vertices = []
    normals = []
    uvs = []

    for j in range(rings + 1):
        c_psi = np.cos(psi)
        s_psi = np.sin(psi)
        phi = 0.0
        for i in range(sides + 1):
            c_phi = np.cos(phi)
            s_phi = np.sin(phi)

            x = c_psi * (major_radius + c_phi * minor_radius)
            z = s_psi * (major_radius + c_phi * minor_radius)
            y = s_phi * minor_radius
            vertices.append([x, y, z])

            nx = c_psi * c_phi
            nz = s_psi * c_phi
            ny = s_phi
            normals.append([nx, ny, nz])

            u = i / sides
            v = j / rings
            uvs.append([u, v])

            phi += d_phi
        psi += d_psi

    data = []
    for j in range(rings):
        for i in range(sides):
            idx1 = j * (sides + 1) + i
            idx2 = j * (sides + 1) + (i + 1)
            idx3 = (j + 1) * (sides + 1) + i
            idx4 = (j + 1) * (sides + 1) + (i + 1)

            p1 = vertices[idx1] + normals[idx1] + uvs[idx1]
            p2 = vertices[idx2] + normals[idx2] + uvs[idx2]
            p3 = vertices[idx3] + normals[idx3] + uvs[idx3]
            p4 = vertices[idx4] + normals[idx4] + uvs[idx4]

            data.extend(p1)
            data.extend(p3)
            data.extend(p2)

            data.extend(p2)
            data.extend(p3)
            data.extend(p4)

    return np.array(data, dtype=np.float32)

triangle_plane(width, depth, w_p, d_p, v_n) staticmethod

Creates a triangle plane primitive.

Parameters:
  • width (float) –

    The width of the plane.

  • depth (float) –

    The depth of the plane.

  • w_p (int) –

    The number of width partitions.

  • d_p (int) –

    The number of depth partitions.

  • v_n (Vec3) –

    The normal vector for the plane.

Source code in ncca/ngl/prim_data.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
@staticmethod
def triangle_plane(
    width: float, depth: float, w_p: int, d_p: int, v_n: Vec3
) -> np.ndarray:
    """Creates a triangle plane primitive.

    Args:
        width: The width of the plane.
        depth: The depth of the plane.
        w_p: The number of width partitions.
        d_p: The number of depth partitions.
        v_n: The normal vector for the plane.
    """
    w2 = width / 2.0
    d2 = depth / 2.0
    w_step = width / w_p
    d_step = depth / d_p

    du = 0.9 / w_p
    dv = 0.9 / d_p

    data = []
    v = 0.0
    d = -d2
    for _ in range(d_p):
        u = 0.0
        w = -w2
        for _ in range(w_p):
            # tri 1
            # vert 1
            data.extend([w, 0.0, d + d_step, v_n.x, v_n.y, v_n.z, u, v + dv])
            # vert 2
            data.extend(
                [w + w_step, 0.0, d + d_step, v_n.x, v_n.y, v_n.z, u + du, v + dv]
            )
            # vert 3
            data.extend([w, 0.0, d, v_n.x, v_n.y, v_n.z, u, v])

            # tri 2
            # vert 1
            data.extend(
                [w + w_step, 0.0, d + d_step, v_n.x, v_n.y, v_n.z, u + du, v + dv]
            )
            # vert 2
            data.extend([w + w_step, 0.0, d, v_n.x, v_n.y, v_n.z, u + du, v])
            # vert 3
            data.extend([w, 0.0, d, v_n.x, v_n.y, v_n.z, u, v])
            u += du
            w += w_step
        v += dv
        d += d_step

    return np.array(data, dtype=np.float32)

Prims

Bases: Enum

Enum for the default primitives that can be loaded.

Source code in ncca/ngl/prim_data.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Prims(Enum):
    """Enum for the default primitives that can be loaded."""

    BUDDHA = "buddah"
    BUNNY = "bunny"
    CUBE = "cube"
    DODECAHEDRON = "dodecahedron"
    DRAGON = "dragon"
    FOOTBALL = "football"
    ICOSAHEDRON = "icosahedron"
    OCTAHEDRON = "octahedron"
    TEAPOT = "teapot"
    TETRAHEDRON = "tetrahedron"
    TROLL = "troll"
    SPHERE = "sphere"
    TORUS = "torus"
    LINE_GRID = "line_grid"
    TRIANGLE_PLANE = "triangle_plane"
    CONE = "cone"
    CAPSULE = "capsule"
    CYLINDER = "cylinder"
    DISK = "disk"

prim_data_to_ri_points_polygons

Convert a packed numpy array of triangles to RenderMan PointsPolygons format.

Designed to work with the PrimData class outputs.

Parameters:
  • triangles (ndarray) –

    Array of shape (n_vertices, 8) where each row is x, y, z, nx, ny, nz, u, v. n_vertices must be divisible by 3 (since we have triangles).

Returns:
  • (nvertices, vertices, parameterlist)
  • list[int]
    • nvertices: list of vertex counts per polygon (all 3 for triangles)
  • dict[str, list[float]]
    • vertices: flat list of vertex indices
  • tuple[list[int], list[int], dict[str, list[float]]]
    • parameterlist: dict with 'P', 'N', 'st' arrays for RenderMan
Source code in ncca/ngl/util.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def prim_data_to_ri_points_polygons(
    triangles: np.ndarray,
) -> tuple[list[int], list[int], dict[str, list[float]]]:
    """Convert a packed numpy array of triangles to RenderMan PointsPolygons format.

    Designed to work with the PrimData class outputs.

    Args:
        triangles: Array of shape (n_vertices, 8) where each row is
            x, y, z, nx, ny, nz, u, v. n_vertices must be divisible by 3
            (since we have triangles).

    Returns:
        (nvertices, vertices, parameterlist):
        - nvertices: list of vertex counts per polygon (all 3 for triangles)
        - vertices: flat list of vertex indices
        - parameterlist: dict with 'P', 'N', 'st' arrays for RenderMan
    """
    # Ensure it's a 2D array
    if triangles.ndim == 1:
        # If completely flat, reshape to (n_verts, 8)
        if len(triangles) % 8 != 0:
            raise ValueError("1D array length must be divisible by 8")
        triangles = triangles.reshape(-1, 8)

    n_verts = triangles.shape[0]
    if n_verts % 3 != 0:
        raise ValueError("Number of vertices must be divisible by 3")

    n_triangles = n_verts // 3

    # Extract components from each row
    positions = triangles[:, 0:3]  # (n_verts, 3) - x, y, z
    normals = triangles[:, 3:6]  # (n_verts, 3) - nx, ny, nz
    uvs = triangles[:, 6:8]  # (n_verts, 2) - u, v

    # RenderMan PointsPolygons format
    nvertices = [3] * n_triangles  # Each polygon has 3 vertices
    vertices = list(range(n_verts))  # Sequential vertex indices

    # Parameter list - flatten to 1D arrays as RenderMan expects
    parameterlist = {
        "P": positions.flatten().tolist(),  # Position
        "N": normals.flatten().tolist(),  # Normals
        "st": uvs.flatten().tolist(),  # Texture coordinates
    }

    return nvertices, vertices, parameterlist

Obj parse errors

Obj raises these rather than returning a sentinel value when a line of the file will not parse.

ObjParseVertexError

Bases: Exception

Raised when a vertex line in an OBJ file cannot be parsed.

Source code in ncca/ngl/obj.py
10
11
class ObjParseVertexError(Exception):
    """Raised when a vertex line in an OBJ file cannot be parsed."""

ObjParseNormalError

Bases: Exception

Raised when a normal line in an OBJ file cannot be parsed.

Source code in ncca/ngl/obj.py
14
15
class ObjParseNormalError(Exception):
    """Raised when a normal line in an OBJ file cannot be parsed."""

ObjParseUVError

Bases: Exception

Raised when a UV line in an OBJ file cannot be parsed.

Source code in ncca/ngl/obj.py
18
19
class ObjParseUVError(Exception):
    """Raised when a UV line in an OBJ file cannot be parsed."""

ObjParseFaceError

Bases: Exception

Raised when a face line in an OBJ file cannot be parsed.

Source code in ncca/ngl/obj.py
22
23
class ObjParseFaceError(Exception):
    """Raised when a face line in an OBJ file cannot be parsed."""