rednoise 0.1.2__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
@@ -0,0 +1,35 @@
1
+ # CMake driver for the RedNoise Python wheel (scikit-build-core).
2
+ #
3
+ # This does NOT build a Python extension module. The binding is pure ctypes; all
4
+ # we need is the native shared library. So we pull in the parent C++ project and
5
+ # build ONLY the installable `rednoise` library (its stable C ABI), then install
6
+ # the resulting .so / .dll / .dylib INTO the importable `rednoise/` package dir
7
+ # so the wheel carries it and __init__.py finds it next to the package.
8
+
9
+ cmake_minimum_required(VERSION 3.24)
10
+ project(rednoise_python LANGUAGES C CXX)
11
+
12
+ # Build only the library from the parent project. Force these into the cache
13
+ # BEFORE add_subdirectory so the parent's option() calls pick them up. We keep
14
+ # the app (SDL3), tests, headless tools, GPU and Vulkan paths off, and disable
15
+ # fetching so the build is offline (glm is vendored in third_party/).
16
+ set(BUILD_APP OFF CACHE BOOL "" FORCE)
17
+ set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
18
+ set(BUILD_HEADLESS OFF CACHE BOOL "" FORCE)
19
+ set(BUILD_GPU OFF CACHE BOOL "" FORCE)
20
+ set(BUILD_VULKAN OFF CACHE BOOL "" FORCE)
21
+ set(BUILD_LIB ON CACHE BOOL "" FORCE)
22
+ set(FETCH_DEPENDENCIES OFF CACHE BOOL "" FORCE)
23
+ # ctypes loads a SHARED library, so build rednoise as a .dll/.so/.dylib (with the
24
+ # C ABI exported - see WINDOWS_EXPORT_ALL_SYMBOLS on the target), not a static lib.
25
+ set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE)
26
+
27
+ # The C++ project root is two levels up (bindings/python -> repo root).
28
+ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../.. rednoise_root)
29
+
30
+ # Install the built native library into the importable package directory so it
31
+ # ships inside the wheel right next to rednoise/__init__.py.
32
+ install(TARGETS rednoise
33
+ RUNTIME DESTINATION rednoise
34
+ LIBRARY DESTINATION rednoise
35
+ ARCHIVE DESTINATION rednoise)
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.1
2
+ Name: rednoise
3
+ Version: 0.1.2
4
+ Summary: Python ctypes bindings for the RedNoise CPU software renderer, with the native library bundled.
5
+ Keywords: renderer,raytracer,pathtracer,graphics,ctypes
6
+ Author: RedNoise contributors
7
+ License: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
14
+ Project-URL: Homepage, https://github.com/ReverseZoom2151/rednoise
15
+ Project-URL: Repository, https://github.com/ReverseZoom2151/rednoise
16
+ Project-URL: Issues, https://github.com/ReverseZoom2151/rednoise/issues
17
+ Requires-Python: >=3.8
18
+ Provides-Extra: pillow
19
+ Requires-Dist: Pillow>=8.0; extra == "pillow"
20
+ Description-Content-Type: text/markdown
21
+
22
+ # RedNoise Python bindings
23
+
24
+ Python `ctypes` bindings for the RedNoise CPU software renderer. They call the
25
+ stable C ABI declared in `include/rednoise/rednoise.h`. There is no compiled
26
+ extension module: the bindings load the RedNoise shared library at runtime. The
27
+ native library is built and bundled into the package, so a normal install is
28
+ self-contained.
29
+
30
+ ## Install
31
+
32
+ ```
33
+ pip install rednoise
34
+ ```
35
+
36
+ Prebuilt wheels ship the native library, so on a supported platform this is all
37
+ you need. Installing from an sdist compiles the native library on your machine
38
+ via CMake, so you need a C++23-capable compiler (GCC 12+, Clang 15+, or MSVC
39
+ 2022+) and CMake 3.24 or newer. The build uses the vendored `glm` in
40
+ `third_party/` and runs fully offline; it does not fetch SDL3 or any other
41
+ optional dependency.
42
+
43
+ Under the hood, `pip install` drives scikit-build-core and CMake to build only
44
+ the installable `rednoise` library (`-DBUILD_LIB=ON`, everything else off) and
45
+ installs the resulting shared library into the importable `rednoise/` package:
46
+
47
+ - Linux: `librednoise.so`
48
+ - macOS: `librednoise.dylib`
49
+ - Windows: `rednoise.dll`
50
+
51
+ Pillow is optional and only needed for `Image.to_pillow()` or saving non-PNG
52
+ formats:
53
+
54
+ ```
55
+ pip install "rednoise[pillow]"
56
+ ```
57
+
58
+ ## Library discovery
59
+
60
+ The bundled library is found automatically. For development against a
61
+ separately built library, the bindings locate it in this order:
62
+
63
+ 1. The library bundled next to the package (what a wheel installs).
64
+ 2. The `REDNOISE_LIBRARY` environment variable (an explicit path to the file).
65
+ 3. The system search path (`ctypes.util.find_library("rednoise")`).
66
+ 4. Common local build paths (`build/`, next to the package, and similar).
67
+
68
+ To override with a locally built library, set `REDNOISE_LIBRARY`:
69
+
70
+ ```
71
+ # Linux
72
+ export REDNOISE_LIBRARY="$PWD/build/librednoise.so"
73
+
74
+ # macOS
75
+ export REDNOISE_LIBRARY="$PWD/build/librednoise.dylib"
76
+
77
+ # Windows (cmd)
78
+ set REDNOISE_LIBRARY=%CD%\build\rednoise.dll
79
+ ```
80
+
81
+ ## Develop from a local checkout
82
+
83
+ Install the bindings straight from this directory:
84
+
85
+ ```
86
+ pip install ./bindings/python
87
+ ```
88
+
89
+ Add the Pillow extra with:
90
+
91
+ ```
92
+ pip install "./bindings/python[pillow]"
93
+ ```
94
+
95
+ You can still build the shared library by hand from the repository root and
96
+ point `REDNOISE_LIBRARY` at it:
97
+
98
+ ```
99
+ cmake -B build -S . -DBUILD_LIB=ON -DBUILD_APP=OFF
100
+ cmake --build build
101
+ ```
102
+
103
+ ## Usage
104
+
105
+ ```python
106
+ import rednoise
107
+
108
+ print(rednoise.version())
109
+
110
+ with rednoise.Scene.load_obj("assets/cornell-box.obj") as scene:
111
+ print("triangles:", scene.triangle_count)
112
+ image = scene.render(mode="pathtraced", width=400, height=300, samples=64)
113
+ image.save("cornell.png")
114
+ ```
115
+
116
+ `Scene.render` accepts a render mode as a `RenderMode` enum, an integer, or a
117
+ name string: `"wireframe"`, `"rasterised"`, `"raytraced"`, or `"pathtraced"`.
118
+ The returned `Image` exposes `.rgba` (raw RGBA8 bytes), `.size`, `.save(path)`,
119
+ and `.to_pillow()` when Pillow is installed.
120
+
121
+ A complete runnable example lives in `example.py`. Run it from the repository
122
+ root (after building the library and setting `REDNOISE_LIBRARY`):
123
+
124
+ ```
125
+ python bindings/python/example.py
126
+ ```
@@ -0,0 +1,105 @@
1
+ # RedNoise Python bindings
2
+
3
+ Python `ctypes` bindings for the RedNoise CPU software renderer. They call the
4
+ stable C ABI declared in `include/rednoise/rednoise.h`. There is no compiled
5
+ extension module: the bindings load the RedNoise shared library at runtime. The
6
+ native library is built and bundled into the package, so a normal install is
7
+ self-contained.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install rednoise
13
+ ```
14
+
15
+ Prebuilt wheels ship the native library, so on a supported platform this is all
16
+ you need. Installing from an sdist compiles the native library on your machine
17
+ via CMake, so you need a C++23-capable compiler (GCC 12+, Clang 15+, or MSVC
18
+ 2022+) and CMake 3.24 or newer. The build uses the vendored `glm` in
19
+ `third_party/` and runs fully offline; it does not fetch SDL3 or any other
20
+ optional dependency.
21
+
22
+ Under the hood, `pip install` drives scikit-build-core and CMake to build only
23
+ the installable `rednoise` library (`-DBUILD_LIB=ON`, everything else off) and
24
+ installs the resulting shared library into the importable `rednoise/` package:
25
+
26
+ - Linux: `librednoise.so`
27
+ - macOS: `librednoise.dylib`
28
+ - Windows: `rednoise.dll`
29
+
30
+ Pillow is optional and only needed for `Image.to_pillow()` or saving non-PNG
31
+ formats:
32
+
33
+ ```
34
+ pip install "rednoise[pillow]"
35
+ ```
36
+
37
+ ## Library discovery
38
+
39
+ The bundled library is found automatically. For development against a
40
+ separately built library, the bindings locate it in this order:
41
+
42
+ 1. The library bundled next to the package (what a wheel installs).
43
+ 2. The `REDNOISE_LIBRARY` environment variable (an explicit path to the file).
44
+ 3. The system search path (`ctypes.util.find_library("rednoise")`).
45
+ 4. Common local build paths (`build/`, next to the package, and similar).
46
+
47
+ To override with a locally built library, set `REDNOISE_LIBRARY`:
48
+
49
+ ```
50
+ # Linux
51
+ export REDNOISE_LIBRARY="$PWD/build/librednoise.so"
52
+
53
+ # macOS
54
+ export REDNOISE_LIBRARY="$PWD/build/librednoise.dylib"
55
+
56
+ # Windows (cmd)
57
+ set REDNOISE_LIBRARY=%CD%\build\rednoise.dll
58
+ ```
59
+
60
+ ## Develop from a local checkout
61
+
62
+ Install the bindings straight from this directory:
63
+
64
+ ```
65
+ pip install ./bindings/python
66
+ ```
67
+
68
+ Add the Pillow extra with:
69
+
70
+ ```
71
+ pip install "./bindings/python[pillow]"
72
+ ```
73
+
74
+ You can still build the shared library by hand from the repository root and
75
+ point `REDNOISE_LIBRARY` at it:
76
+
77
+ ```
78
+ cmake -B build -S . -DBUILD_LIB=ON -DBUILD_APP=OFF
79
+ cmake --build build
80
+ ```
81
+
82
+ ## Usage
83
+
84
+ ```python
85
+ import rednoise
86
+
87
+ print(rednoise.version())
88
+
89
+ with rednoise.Scene.load_obj("assets/cornell-box.obj") as scene:
90
+ print("triangles:", scene.triangle_count)
91
+ image = scene.render(mode="pathtraced", width=400, height=300, samples=64)
92
+ image.save("cornell.png")
93
+ ```
94
+
95
+ `Scene.render` accepts a render mode as a `RenderMode` enum, an integer, or a
96
+ name string: `"wireframe"`, `"rasterised"`, `"raytraced"`, or `"pathtraced"`.
97
+ The returned `Image` exposes `.rgba` (raw RGBA8 bytes), `.size`, `.save(path)`,
98
+ and `.to_pillow()` when Pillow is installed.
99
+
100
+ A complete runnable example lives in `example.py`. Run it from the repository
101
+ root (after building the library and setting `REDNOISE_LIBRARY`):
102
+
103
+ ```
104
+ python bindings/python/example.py
105
+ ```
@@ -0,0 +1,53 @@
1
+ """Runnable example for the RedNoise Python bindings.
2
+
3
+ This loads the Cornell box scene, path-traces it, saves a PNG, and prints the
4
+ triangle count and native library version.
5
+
6
+ Prerequisites:
7
+ 1. Build the shared library from the project root:
8
+ cmake -B build -S . -DBUILD_LIB=ON -DBUILD_APP=OFF
9
+ cmake --build build
10
+ 2. Point the binding at the built library (adjust the extension for your OS):
11
+ export REDNOISE_LIBRARY="$PWD/build/librednoise.so" # Linux
12
+ export REDNOISE_LIBRARY="$PWD/build/librednoise.dylib" # macOS
13
+ set REDNOISE_LIBRARY=%CD%\\build\\rednoise.dll # Windows
14
+
15
+ Run from the repository root so the asset path resolves:
16
+ python bindings/python/example.py
17
+
18
+ Or install the package first (pip install ./bindings/python) and run from
19
+ anywhere; this script locates assets relative to the repo layout.
20
+ """
21
+
22
+ import os
23
+
24
+ import rednoise
25
+
26
+
27
+ def main() -> None:
28
+ # Resolve assets/cornell-box.obj relative to this file's location in the
29
+ # repo (bindings/python/example.py -> <repo>/assets/cornell-box.obj),
30
+ # falling back to a path relative to the current working directory.
31
+ here = os.path.dirname(os.path.abspath(__file__))
32
+ repo_root = os.path.dirname(os.path.dirname(here))
33
+ obj_path = os.path.join(repo_root, "assets", "cornell-box.obj")
34
+ if not os.path.isfile(obj_path):
35
+ obj_path = os.path.join("assets", "cornell-box.obj")
36
+
37
+ print(f"RedNoise version: {rednoise.version()}")
38
+
39
+ with rednoise.Scene.load_obj(obj_path) as scene:
40
+ print(f"Triangle count: {scene.triangle_count}")
41
+ image = scene.render(
42
+ mode="pathtraced",
43
+ width=400,
44
+ height=300,
45
+ samples=64,
46
+ )
47
+ out_path = "cornell.png"
48
+ image.save(out_path)
49
+ print(f"Saved {image.width}x{image.height} render to {out_path}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,70 @@
1
+ # Packaging metadata for the RedNoise Python bindings.
2
+ #
3
+ # This package builds and bundles the RedNoise shared library. scikit-build-core
4
+ # drives CMake to compile the installable `rednoise` library (C ABI) from the
5
+ # parent C++ project and installs the resulting .so / .dll / .dylib into the
6
+ # importable `rednoise/` package directory. Prebuilt wheels ship the library;
7
+ # building from an sdist requires a C++23-capable compiler and CMake >= 3.24.
8
+
9
+ [build-system]
10
+ requires = ["scikit-build-core>=0.8"]
11
+ build-backend = "scikit_build_core.build"
12
+
13
+ [project]
14
+ name = "rednoise"
15
+ version = "0.1.2"
16
+ description = "Python ctypes bindings for the RedNoise CPU software renderer, with the native library bundled."
17
+ readme = "README.md"
18
+ requires-python = ">=3.8"
19
+ license = { text = "MIT" }
20
+ authors = [
21
+ { name = "RedNoise contributors" },
22
+ ]
23
+ keywords = ["renderer", "raytracer", "pathtracer", "graphics", "ctypes"]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.8",
30
+ "Topic :: Multimedia :: Graphics :: 3D Rendering",
31
+ ]
32
+
33
+ # No hard runtime dependencies: the binding is pure ctypes over the C ABI.
34
+ dependencies = []
35
+
36
+ [project.optional-dependencies]
37
+ # Pillow enables Image.to_pillow() and saving non-PNG formats.
38
+ pillow = ["Pillow>=8.0"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/ReverseZoom2151/rednoise"
42
+ Repository = "https://github.com/ReverseZoom2151/rednoise"
43
+ Issues = "https://github.com/ReverseZoom2151/rednoise/issues"
44
+
45
+ [tool.scikit-build]
46
+ minimum-version = "0.8"
47
+ cmake.version = ">=3.24"
48
+ wheel.packages = ["rednoise"]
49
+ # The binding is pure ctypes loading a bundled shared library - there is no
50
+ # Python C extension, so the wheel is platform-specific but Python-version
51
+ # independent. Tag it py3-none-<platform> so ONE wheel per OS serves all of
52
+ # Python 3 (and the build runs once per OS instead of once per version).
53
+ wheel.py-api = "py3"
54
+
55
+ # cibuildwheel configuration (read from this file when building wheels). Build one
56
+ # wheel per OS for CPython 3.12 (the lib is py-version independent, tagged py3),
57
+ # and use manylinux_2_34 on Linux - its default toolchain is GCC 14, which the
58
+ # C++23 engine needs (the older manylinux images ship GCC 10-12).
59
+ [tool.cibuildwheel]
60
+ build = "cp312-*"
61
+ skip = "*musllinux*"
62
+
63
+ [tool.cibuildwheel.linux]
64
+ # Build x86_64 only: the 32-bit i686 build uses the legacy manylinux2014 image
65
+ # (GCC 10, no C++23), and nobody needs 32-bit x86 for this renderer.
66
+ archs = ["x86_64"]
67
+ manylinux-x86_64-image = "quay.io/pypa/manylinux_2_34_x86_64"
68
+ # manylinux_2_34 ships gcc-toolset-14 (GCC 14) but defaults to a legacy gcc; put
69
+ # the toolset first on PATH so gcc/g++ are C++23-capable.
70
+ environment = { PATH = "/opt/rh/gcc-toolset-14/root/usr/bin:$PATH", CC = "gcc", CXX = "g++" }
@@ -0,0 +1,456 @@
1
+ """Python bindings for the RedNoise CPU software renderer.
2
+
3
+ This is a pure-Python binding built on :mod:`ctypes` over the stable C ABI
4
+ declared in ``include/rednoise/rednoise.h``. There is no compiled extension;
5
+ the binding loads the shared library (``librednoise.so`` on Linux,
6
+ ``librednoise.dylib`` on macOS, ``rednoise.dll`` on Windows) at runtime.
7
+
8
+ The shared library is *not* bundled with this package. Build it from the C++
9
+ project::
10
+
11
+ cmake -B build -S . -DBUILD_LIB=ON -DBUILD_APP=OFF
12
+ cmake --build build
13
+
14
+ Then either place the library on a path the loader searches, or point the
15
+ ``REDNOISE_LIBRARY`` environment variable at the built file.
16
+
17
+ Typical usage::
18
+
19
+ import rednoise
20
+
21
+ scene = rednoise.Scene.load_obj("assets/cornell-box.obj")
22
+ print(scene.triangle_count, rednoise.version())
23
+ image = scene.render(mode="pathtraced", width=400, height=300, samples=64)
24
+ image.save("cornell.png")
25
+ scene.close()
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import ctypes
31
+ import ctypes.util
32
+ import os
33
+ from enum import IntEnum
34
+ from typing import Optional, Tuple, Union
35
+
36
+ __all__ = ["RenderMode", "Scene", "Image", "version"]
37
+
38
+ __version__ = "0.1.2"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Render modes
43
+ # ---------------------------------------------------------------------------
44
+ class RenderMode(IntEnum):
45
+ """Which renderer to run, mirroring the C ``rn_render_mode`` enum."""
46
+
47
+ WIREFRAME = 0
48
+ RASTERISED = 1
49
+ RAYTRACED = 2
50
+ PATHTRACED = 3
51
+
52
+ @classmethod
53
+ def coerce(cls, value: Union["RenderMode", int, str]) -> "RenderMode":
54
+ """Coerce an int, name string, or :class:`RenderMode` into a member.
55
+
56
+ Strings are matched case-insensitively, so ``"pathtraced"``,
57
+ ``"PATHTRACED"`` and ``"path_traced"`` all resolve to
58
+ :attr:`RenderMode.PATHTRACED`.
59
+ """
60
+ if isinstance(value, cls):
61
+ return value
62
+ if isinstance(value, bool):
63
+ # bool is a subclass of int; reject it to avoid surprises.
64
+ raise TypeError("render mode must not be a bool")
65
+ if isinstance(value, int):
66
+ return cls(value)
67
+ if isinstance(value, str):
68
+ key = value.strip().upper().replace("-", "").replace("_", "").replace(" ", "")
69
+ for member in cls:
70
+ if member.name.replace("_", "") == key:
71
+ return member
72
+ valid = ", ".join(m.name.lower() for m in cls)
73
+ raise ValueError(f"unknown render mode {value!r}; expected one of: {valid}")
74
+ raise TypeError(f"render mode must be RenderMode, int or str, not {type(value).__name__}")
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Library loading
79
+ # ---------------------------------------------------------------------------
80
+ def _candidate_paths() -> list:
81
+ """Return candidate local build locations for the shared library."""
82
+ here = os.path.dirname(os.path.abspath(__file__))
83
+ # Walk up from this package to plausible project roots.
84
+ roots = [
85
+ here, # next to the package itself
86
+ os.path.dirname(here), # bindings/python
87
+ os.path.dirname(os.path.dirname(here)), # bindings
88
+ os.path.dirname(os.path.dirname(os.path.dirname(here))), # project root
89
+ os.getcwd(), # wherever the process was launched from
90
+ ]
91
+ names = [
92
+ "librednoise.so",
93
+ "librednoise.dylib",
94
+ "rednoise.dll",
95
+ "librednoise.dll",
96
+ ]
97
+ subdirs = ["", "build", os.path.join("build", "Release"), os.path.join("build", "Debug")]
98
+
99
+ seen = set()
100
+ candidates = []
101
+ for root in roots:
102
+ for sub in subdirs:
103
+ for name in names:
104
+ path = os.path.normpath(os.path.join(root, sub, name))
105
+ if path not in seen:
106
+ seen.add(path)
107
+ candidates.append(path)
108
+ return candidates
109
+
110
+
111
+ def _bundled_library_path() -> Optional[str]:
112
+ """Return the path to the library bundled inside this package, if present.
113
+
114
+ A pip-installed wheel ships the native library right next to this module
115
+ (installed there by the scikit-build-core / CMake build). Look for it first
116
+ so an installed package is self-contained and needs no environment setup.
117
+ """
118
+ here = os.path.dirname(os.path.abspath(__file__))
119
+ names = [
120
+ "librednoise.so",
121
+ "rednoise.dll",
122
+ "librednoise.dylib",
123
+ "librednoise.dll",
124
+ ]
125
+ for name in names:
126
+ path = os.path.join(here, name)
127
+ if os.path.isfile(path):
128
+ return path
129
+ return None
130
+
131
+
132
+ def _find_library_path() -> Optional[str]:
133
+ """Locate the shared library path, or return ``None`` if not found."""
134
+ # 1. The library bundled next to this package (pip-installed wheel).
135
+ bundled = _bundled_library_path()
136
+ if bundled:
137
+ return bundled
138
+
139
+ # 2. Explicit override via environment variable.
140
+ env = os.environ.get("REDNOISE_LIBRARY")
141
+ if env:
142
+ if os.path.isfile(env):
143
+ return env
144
+ raise OSError(
145
+ f"REDNOISE_LIBRARY is set to {env!r} but that file does not exist."
146
+ )
147
+
148
+ # 3. System search path.
149
+ found = ctypes.util.find_library("rednoise")
150
+ if found:
151
+ return found
152
+
153
+ # 4. Common local build locations.
154
+ for path in _candidate_paths():
155
+ if os.path.isfile(path):
156
+ return path
157
+
158
+ return None
159
+
160
+
161
+ _LIB = None # cached CDLL handle
162
+
163
+
164
+ def _load_library() -> ctypes.CDLL:
165
+ """Load (once) and return the RedNoise shared library.
166
+
167
+ Loading is lazy so that ``import rednoise`` never fails merely because the
168
+ library has not been built yet. The first call that needs native code
169
+ triggers the load and raises a helpful error if the library is missing.
170
+ """
171
+ global _LIB
172
+ if _LIB is not None:
173
+ return _LIB
174
+
175
+ path = _find_library_path()
176
+ if path is None:
177
+ raise OSError(
178
+ "Could not locate the RedNoise shared library "
179
+ "(librednoise.so / librednoise.dylib / rednoise.dll).\n"
180
+ "Build it with:\n"
181
+ " cmake -B build -S . -DBUILD_LIB=ON -DBUILD_APP=OFF\n"
182
+ " cmake --build build\n"
183
+ "then set the REDNOISE_LIBRARY environment variable to the built "
184
+ "library file, or place it on the loader search path."
185
+ )
186
+
187
+ try:
188
+ lib = ctypes.CDLL(path)
189
+ except OSError as exc: # pragma: no cover - platform dependent
190
+ raise OSError(f"Failed to load RedNoise library at {path!r}: {exc}") from exc
191
+
192
+ _configure_signatures(lib)
193
+ _LIB = lib
194
+ return lib
195
+
196
+
197
+ def _configure_signatures(lib: ctypes.CDLL) -> None:
198
+ """Declare argtypes/restype for every exported function."""
199
+ # rn_scene *rn_scene_load_obj(const char *path, float scale);
200
+ lib.rn_scene_load_obj.argtypes = [ctypes.c_char_p, ctypes.c_float]
201
+ lib.rn_scene_load_obj.restype = ctypes.c_void_p
202
+
203
+ # void rn_scene_free(rn_scene *scene);
204
+ lib.rn_scene_free.argtypes = [ctypes.c_void_p]
205
+ lib.rn_scene_free.restype = None
206
+
207
+ # int rn_scene_triangle_count(const rn_scene *scene);
208
+ lib.rn_scene_triangle_count.argtypes = [ctypes.c_void_p]
209
+ lib.rn_scene_triangle_count.restype = ctypes.c_int
210
+
211
+ # int rn_render(const rn_scene *, rn_render_mode, int, int, float, int, unsigned char *);
212
+ lib.rn_render.argtypes = [
213
+ ctypes.c_void_p,
214
+ ctypes.c_int,
215
+ ctypes.c_int,
216
+ ctypes.c_int,
217
+ ctypes.c_float,
218
+ ctypes.c_int,
219
+ ctypes.c_char_p,
220
+ ]
221
+ lib.rn_render.restype = ctypes.c_int
222
+
223
+ # int rn_save_png(const char *path, int width, int height, const unsigned char *rgba);
224
+ lib.rn_save_png.argtypes = [
225
+ ctypes.c_char_p,
226
+ ctypes.c_int,
227
+ ctypes.c_int,
228
+ ctypes.c_char_p,
229
+ ]
230
+ lib.rn_save_png.restype = ctypes.c_int
231
+
232
+ # const char *rn_version(void);
233
+ lib.rn_version.argtypes = []
234
+ lib.rn_version.restype = ctypes.c_char_p
235
+
236
+
237
+ def _encode_path(path: Union[str, os.PathLike]) -> bytes:
238
+ """Encode a filesystem path for the C ABI (``const char *``)."""
239
+ return os.fsencode(os.fspath(path))
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Image
244
+ # ---------------------------------------------------------------------------
245
+ class Image:
246
+ """An RGBA8 image returned by :meth:`Scene.render`.
247
+
248
+ Holds the raw pixel bytes together with the image dimensions. Pixels are
249
+ laid out row-major as ``width * height`` RGBA quadruplets.
250
+ """
251
+
252
+ __slots__ = ("_rgba", "_width", "_height")
253
+
254
+ def __init__(self, rgba: bytes, width: int, height: int):
255
+ expected = width * height * 4
256
+ if len(rgba) != expected:
257
+ raise ValueError(
258
+ f"rgba buffer has {len(rgba)} bytes, expected {expected} "
259
+ f"for a {width}x{height} RGBA8 image"
260
+ )
261
+ self._rgba = bytes(rgba)
262
+ self._width = int(width)
263
+ self._height = int(height)
264
+
265
+ @property
266
+ def rgba(self) -> bytes:
267
+ """The raw RGBA8 pixel bytes (``width * height * 4`` bytes)."""
268
+ return self._rgba
269
+
270
+ @property
271
+ def width(self) -> int:
272
+ """Image width in pixels."""
273
+ return self._width
274
+
275
+ @property
276
+ def height(self) -> int:
277
+ """Image height in pixels."""
278
+ return self._height
279
+
280
+ @property
281
+ def size(self) -> Tuple[int, int]:
282
+ """The ``(width, height)`` image dimensions."""
283
+ return (self._width, self._height)
284
+
285
+ def save(self, path: Union[str, os.PathLike]) -> None:
286
+ """Write the image to ``path`` as a PNG via the native ``rn_save_png``.
287
+
288
+ Only the ``.png`` format is supported by the native writer. If Pillow
289
+ is installed and a non-PNG extension is requested, the save is delegated
290
+ to :meth:`to_pillow` instead.
291
+ """
292
+ path = os.fspath(path)
293
+ ext = os.path.splitext(path)[1].lower()
294
+ if ext and ext != ".png":
295
+ # Fall back to Pillow for other formats if it is available.
296
+ try:
297
+ self.to_pillow().save(path)
298
+ return
299
+ except ImportError:
300
+ raise ValueError(
301
+ f"native saving only supports .png; got {ext!r}. "
302
+ "Install Pillow (pip install rednoise[pillow]) to save other formats."
303
+ )
304
+ lib = _load_library()
305
+ ok = lib.rn_save_png(_encode_path(path), self._width, self._height, self._rgba)
306
+ if ok != 1:
307
+ raise RuntimeError(f"rn_save_png failed to write {path!r}")
308
+
309
+ def to_pillow(self):
310
+ """Build and return a :class:`PIL.Image.Image` (requires Pillow).
311
+
312
+ Pillow is imported lazily and is an optional dependency; install it via
313
+ ``pip install rednoise[pillow]``.
314
+ """
315
+ try:
316
+ from PIL import Image as PILImage
317
+ except ImportError as exc: # pragma: no cover - optional dependency
318
+ raise ImportError(
319
+ "Pillow is required for to_pillow(); install it with "
320
+ "'pip install rednoise[pillow]'"
321
+ ) from exc
322
+ return PILImage.frombytes("RGBA", (self._width, self._height), self._rgba)
323
+
324
+ def __repr__(self) -> str:
325
+ return f"<rednoise.Image {self._width}x{self._height} RGBA8>"
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Scene
330
+ # ---------------------------------------------------------------------------
331
+ class Scene:
332
+ """A loaded RedNoise scene backed by a native ``rn_scene`` handle.
333
+
334
+ Create one with :meth:`load_obj`. The underlying native scene is released
335
+ exactly once via :meth:`close`, ``__del__``, or context-manager exit.
336
+ """
337
+
338
+ __slots__ = ("_handle",)
339
+
340
+ def __init__(self, handle: int):
341
+ # handle is a non-NULL c_void_p value (an int address).
342
+ if not handle:
343
+ raise ValueError("Scene requires a non-NULL native handle")
344
+ self._handle = handle
345
+
346
+ @classmethod
347
+ def load_obj(cls, path: Union[str, os.PathLike], scale: float = 0.35) -> "Scene":
348
+ """Load a Wavefront OBJ (and its .mtl) as a scene.
349
+
350
+ :param path: Path to the ``.obj`` file.
351
+ :param scale: Uniform scale applied to vertices (default ``0.35``).
352
+ :raises FileNotFoundError: if the OBJ file does not exist.
353
+ :raises RuntimeError: if the native loader returns NULL.
354
+ """
355
+ path = os.fspath(path)
356
+ if not os.path.isfile(path):
357
+ raise FileNotFoundError(f"OBJ file not found: {path!r}")
358
+ lib = _load_library()
359
+ handle = lib.rn_scene_load_obj(_encode_path(path), ctypes.c_float(scale))
360
+ if not handle:
361
+ raise RuntimeError(f"rn_scene_load_obj failed to load {path!r}")
362
+ return cls(handle)
363
+
364
+ @property
365
+ def triangle_count(self) -> int:
366
+ """Number of triangles in the scene."""
367
+ self._check_open()
368
+ lib = _load_library()
369
+ return int(lib.rn_scene_triangle_count(self._handle))
370
+
371
+ def render(
372
+ self,
373
+ mode: Union[RenderMode, int, str] = "raytraced",
374
+ width: int = 640,
375
+ height: int = 480,
376
+ cam_z: float = 4.0,
377
+ samples: int = 64,
378
+ ) -> Image:
379
+ """Render the scene and return an :class:`Image`.
380
+
381
+ :param mode: A :class:`RenderMode`, its integer value, or a name string
382
+ such as ``"wireframe"``, ``"rasterised"``, ``"raytraced"`` or
383
+ ``"pathtraced"``.
384
+ :param width: Output width in pixels.
385
+ :param height: Output height in pixels.
386
+ :param cam_z: Camera Z position; the camera at ``(0, 0, cam_z)`` looks
387
+ at the origin.
388
+ :param samples: Per-pixel sample count (only used by ``PATHTRACED``).
389
+ :raises RuntimeError: if the native renderer reports failure.
390
+ """
391
+ self._check_open()
392
+ render_mode = RenderMode.coerce(mode)
393
+ width = int(width)
394
+ height = int(height)
395
+ if width <= 0 or height <= 0:
396
+ raise ValueError(f"width and height must be positive, got {width}x{height}")
397
+
398
+ buf = ctypes.create_string_buffer(width * height * 4)
399
+ lib = _load_library()
400
+ ok = lib.rn_render(
401
+ self._handle,
402
+ int(render_mode),
403
+ width,
404
+ height,
405
+ ctypes.c_float(cam_z),
406
+ int(samples),
407
+ buf,
408
+ )
409
+ if ok != 1:
410
+ raise RuntimeError(
411
+ f"rn_render failed (mode={render_mode.name}, {width}x{height})"
412
+ )
413
+ return Image(buf.raw, width, height)
414
+
415
+ def close(self) -> None:
416
+ """Release the native scene. Safe to call multiple times."""
417
+ handle = self._handle
418
+ if handle:
419
+ self._handle = 0
420
+ # Guard against interpreter-shutdown ordering issues.
421
+ if _LIB is not None:
422
+ _LIB.rn_scene_free(handle)
423
+
424
+ def _check_open(self) -> None:
425
+ if not self._handle:
426
+ raise RuntimeError("operation on a closed Scene")
427
+
428
+ def __enter__(self) -> "Scene":
429
+ return self
430
+
431
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
432
+ self.close()
433
+
434
+ def __del__(self):
435
+ # Best-effort cleanup; swallow everything during interpreter shutdown.
436
+ try:
437
+ self.close()
438
+ except Exception:
439
+ pass
440
+
441
+ def __repr__(self) -> str:
442
+ if not self._handle:
443
+ return "<rednoise.Scene closed>"
444
+ return f"<rednoise.Scene handle=0x{self._handle:x}>"
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # Module-level helpers
449
+ # ---------------------------------------------------------------------------
450
+ def version() -> str:
451
+ """Return the native library version string, e.g. ``"0.1.0"``."""
452
+ lib = _load_library()
453
+ raw = lib.rn_version()
454
+ if raw is None:
455
+ return ""
456
+ return raw.decode("utf-8", "replace")