OpenRCT2-ObjectCommon 0.1.1__tar.gz → 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.
Files changed (33) hide show
  1. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/PKG-INFO +2 -2
  2. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/__init__.py +19 -0
  3. openrct2_objectcommon-0.1.2/openrct2_object_common/blender/mesh_extract.py +183 -0
  4. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/blender/modal.py +8 -1
  5. openrct2_objectcommon-0.1.2/openrct2_object_common/blender/props.py +76 -0
  6. openrct2_objectcommon-0.1.2/openrct2_object_common/cli.py +19 -0
  7. openrct2_objectcommon-0.1.2/openrct2_object_common/config.py +35 -0
  8. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/parkobj.py +50 -4
  9. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/placement.py +2 -1
  10. openrct2_objectcommon-0.1.2/openrct2_object_common/testing.py +82 -0
  11. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/pyproject.toml +4 -7
  12. openrct2_objectcommon-0.1.2/tests/conftest.py +125 -0
  13. openrct2_objectcommon-0.1.2/tests/test_blender.py +355 -0
  14. openrct2_objectcommon-0.1.2/tests/test_blender_shared.py +307 -0
  15. openrct2_objectcommon-0.1.2/tests/test_cli.py +151 -0
  16. openrct2_objectcommon-0.1.2/tests/test_config.py +357 -0
  17. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/tests/test_parkobj.py +45 -1
  18. openrct2_objectcommon-0.1.1/openrct2_object_common/cli.py +0 -79
  19. openrct2_objectcommon-0.1.1/openrct2_object_common/config.py +0 -188
  20. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/.github/workflows/lint.yml +0 -0
  21. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/.github/workflows/publish.yml +0 -0
  22. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/.github/workflows/pytest.yml +0 -0
  23. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/.gitignore +0 -0
  24. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/.yamllint.yaml +0 -0
  25. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/LICENSE +0 -0
  26. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/README.md +0 -0
  27. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/blender/__init__.py +0 -0
  28. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/blender/lights.py +0 -0
  29. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/objectjson.py +0 -0
  30. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/openrct2_object_common/py.typed +0 -0
  31. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/scripts/ci/set_version.py +0 -0
  32. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/tests/test_objectjson.py +0 -0
  33. {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.2}/tests/test_placement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenRCT2-ObjectCommon
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Shared config, CLI, .parkobj, and object.json scaffolding for OpenRCT2 object generators.
5
5
  Project-URL: Homepage, https://github.com/alex-parisi/OpenRCT2-ObjectCommon
6
6
  Project-URL: Repository, https://github.com/alex-parisi/OpenRCT2-ObjectCommon
@@ -9,7 +9,7 @@ License-Expression: GPL-3.0-or-later
9
9
  License-File: LICENSE
10
10
  Requires-Python: >=3.11
11
11
  Requires-Dist: numpy>=1.26
12
- Requires-Dist: openrct2-x7-renderer>=0.3.0
12
+ Requires-Dist: openrct2-x7-renderer>=0.3.1
13
13
  Requires-Dist: pillow>=10.0
14
14
  Requires-Dist: pyyaml>=6.0
15
15
  Provides-Extra: blender
@@ -11,3 +11,22 @@ try:
11
11
  __version__ = version("OpenRCT2-ObjectCommon")
12
12
  except PackageNotFoundError: # pragma: no cover - source tree without an install
13
13
  __version__ = "0.0.0"
14
+
15
+ from .cli import make_context, run_cli
16
+ from .config import LoadError, load_meshes, load_preview, parse_config
17
+ from .objectjson import object_json_header
18
+ from .parkobj import assemble_parkobj, write_images_dat_lgx
19
+ from .placement import add_model_to_scene
20
+
21
+ __all__ = [
22
+ "LoadError",
23
+ "add_model_to_scene",
24
+ "assemble_parkobj",
25
+ "load_meshes",
26
+ "load_preview",
27
+ "make_context",
28
+ "object_json_header",
29
+ "parse_config",
30
+ "run_cli",
31
+ "write_images_dat_lgx",
32
+ ]
@@ -0,0 +1,183 @@
1
+ """Shared Blender mesh extraction utilities.
2
+
3
+ Extracts geometry from Blender scene objects into the renderer's Mesh format,
4
+ applying the standard coordinate basis change (Blender → OBJ space). Used by
5
+ both the vehicle and scenery add-ons so the extraction logic has a single
6
+ source of truth.
7
+
8
+ Requires ``bpy``; only import inside Blender.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Callable
15
+
16
+ import bpy
17
+ import numpy as np
18
+ from mathutils import Matrix, Vector
19
+ from openrct2_x7_renderer.image import quantize_to_indexed, read_png
20
+ from openrct2_x7_renderer.mesh import Material, Mesh
21
+ from openrct2_x7_renderer.types import IndexedImage
22
+
23
+ # Blender (x, y, z) → OBJ (x, z, -y). Proper rotation (det = +1), so winding
24
+ # is preserved. Every contributing object bakes this into emitted vertices.
25
+ BASIS = Matrix(((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)))
26
+
27
+
28
+ class SceneError(Exception):
29
+ """Raised when the Blender scene can't be converted to a valid object."""
30
+
31
+
32
+ MaterialFn = Callable[[object], Material]
33
+
34
+
35
+ def base_color(bmat) -> tuple[float, float, float]:
36
+ """The material's flat RGB colour from the Principled BSDF Base Color.
37
+
38
+ Falls back to ``diffuse_color`` (viewport colour) when there's no Principled
39
+ BSDF node or the Base Color input is linked (textured).
40
+ """
41
+ if getattr(bmat, "use_nodes", False) and bmat.node_tree is not None:
42
+ for node in bmat.node_tree.nodes:
43
+ if node.type != "BSDF_PRINCIPLED":
44
+ continue
45
+ base = node.inputs.get("Base Color")
46
+ if base is not None and not base.is_linked:
47
+ c = base.default_value
48
+ return (c[0], c[1], c[2])
49
+ col = bmat.diffuse_color
50
+ return (col[0], col[1], col[2])
51
+
52
+
53
+ RegionMap = dict[str, tuple[int, int]]
54
+
55
+
56
+ def material_base(
57
+ bmat, *, prop_attr: str, region_map: RegionMap
58
+ ) -> tuple[Material, object]:
59
+ """Build a Material with shared colour/specular/region/flag handling.
60
+
61
+ Returns ``(material, settings)`` where *settings* is the add-on's property
62
+ group (e.g. ``bmat.vg_material``) or ``None``. Callers extend *material*
63
+ with domain-specific flags and texture loading after this returns.
64
+
65
+ *prop_attr*: attribute name on the bpy material (e.g. ``"vg_material"``).
66
+ *region_map*: maps region enum string → ``(flag_bits, region_id)``.
67
+ """
68
+ from openrct2_x7_renderer.constants import MaterialFlag
69
+
70
+ m = Material()
71
+ if bmat is None:
72
+ return m, None
73
+
74
+ s = getattr(bmat, prop_attr, None)
75
+
76
+ if s is not None and s.use_color_override:
77
+ m.color = np.array(tuple(s.diffuse_color), dtype=np.float64)
78
+ else:
79
+ m.color = np.array(base_color(bmat), dtype=np.float64)
80
+
81
+ intensity = float(s.specular_intensity) if s is not None else 0.5
82
+ m.specular_exponent = float(s.specular_exponent) if s is not None else 50.0
83
+ tint = tuple(s.specular_tint) if (s is not None and s.use_specular_tint) else (1.0, 1.0, 1.0)
84
+ m.specular_color = np.array(tint, dtype=np.float64) * intensity
85
+
86
+ if s is None:
87
+ return m, None
88
+
89
+ flag, region = region_map.get(s.region, (0, 0))
90
+ m.flags |= flag
91
+ m.region = region
92
+ if s.is_mask:
93
+ m.flags |= MaterialFlag.IS_MASK
94
+ if s.no_ao:
95
+ m.flags |= MaterialFlag.NO_AO
96
+ if s.edge:
97
+ m.flags |= MaterialFlag.BACKGROUND_AA
98
+ if s.dark_edge:
99
+ m.flags |= MaterialFlag.BACKGROUND_AA_DARK
100
+ if s.no_bleed:
101
+ m.flags |= MaterialFlag.NO_BLEED
102
+
103
+ return m, s
104
+
105
+
106
+ def extract_mesh(obj, depsgraph, material_fn: MaterialFn) -> Mesh | None:
107
+ """Evaluate *obj*, bake its world rotation+scale + basis change, → Mesh.
108
+
109
+ *material_fn* converts a ``bpy.types.Material`` (or ``None``) into the
110
+ renderer's :class:`~openrct2_x7_renderer.mesh.Material`. Each add-on
111
+ provides its own implementation to handle domain-specific flags.
112
+ """
113
+ eval_obj = obj.evaluated_get(depsgraph)
114
+ me = eval_obj.to_mesh()
115
+ try:
116
+ me.calc_loop_triangles()
117
+ tris = me.loop_triangles
118
+ if len(tris) == 0:
119
+ return None
120
+
121
+ slots = [s.material for s in obj.material_slots]
122
+ materials = [material_fn(bm) for bm in slots] or [Material()]
123
+ n_mats = len(materials)
124
+
125
+ linear = BASIS @ obj.matrix_world.to_3x3()
126
+ normal_mat = linear.inverted_safe().transposed()
127
+
128
+ uv_layer = me.uv_layers.active
129
+ verts: list[tuple[float, float, float]] = []
130
+ norms: list[tuple[float, float, float]] = []
131
+ uvs: list[tuple[float, float]] = []
132
+ faces: list[tuple[int, int, int]] = []
133
+ face_mats: list[int] = []
134
+
135
+ for lt in tris:
136
+ corner = []
137
+ split_n = lt.split_normals
138
+ for k in range(3):
139
+ vidx = lt.vertices[k]
140
+ loop_idx = lt.loops[k]
141
+ co = linear @ me.vertices[vidx].co
142
+ n = (normal_mat @ Vector(split_n[k])).normalized()
143
+ uv = uv_layer.data[loop_idx].uv if uv_layer else (0.0, 0.0)
144
+ verts.append((co.x, co.y, co.z))
145
+ norms.append((n.x, n.y, n.z))
146
+ uvs.append((uv[0], uv[1]))
147
+ corner.append(len(verts) - 1)
148
+ faces.append((corner[0], corner[1], corner[2]))
149
+ face_mats.append(min(lt.material_index, n_mats - 1))
150
+
151
+ return Mesh(
152
+ vertices=np.array(verts, dtype=np.float32),
153
+ normals=np.array(norms, dtype=np.float32),
154
+ uvs=np.array(uvs, dtype=np.float32),
155
+ faces=np.array(faces, dtype=np.uint32),
156
+ face_materials=np.array(face_mats, dtype=np.uint32),
157
+ materials=materials,
158
+ )
159
+ finally:
160
+ eval_obj.to_mesh_clear()
161
+
162
+
163
+ def object_position(obj) -> list[float]:
164
+ """World translation of *obj* converted to OBJ space."""
165
+ p = BASIS @ obj.matrix_world.to_translation()
166
+ return [float(p.x), float(p.y), float(p.z)]
167
+
168
+
169
+ def load_preview(filepath) -> IndexedImage | None:
170
+ """Load a preview image from *filepath*, quantizing non-paletted sources."""
171
+ if not filepath:
172
+ return None
173
+ path = bpy.path.abspath(filepath)
174
+ if not path or not os.path.exists(path):
175
+ return None
176
+ try:
177
+ return read_png(path)
178
+ except Exception:
179
+ pass
180
+ try:
181
+ return quantize_to_indexed(path)
182
+ except Exception:
183
+ return None
@@ -18,7 +18,14 @@ import time
18
18
  import traceback
19
19
  from typing import Any
20
20
 
21
- from bpy.types import Operator
21
+ try:
22
+ from bpy.types import Operator
23
+ except ImportError: # pragma: no cover
24
+ # Outside Blender: provide a no-op stub so this module can be imported
25
+ # (e.g. for type-checking or test collection) without a Blender runtime.
26
+ # A real operator requires bpy; this stub allows the class to be defined.
27
+ class Operator: # type: ignore[no-redef]
28
+ pass
22
29
 
23
30
  _SPINNER_FRAMES = "|/-\\"
24
31
 
@@ -0,0 +1,76 @@
1
+ """Shared Blender PropertyGroup utilities and base classes.
2
+
3
+ Provides helpers and property definitions common to both the vehicle and scenery
4
+ add-ons. Requires ``bpy``; only import inside Blender.
5
+
6
+ NOTE: no ``from __future__ import annotations``; PEP 563 would stringify the
7
+ ``prop: SomeProperty(...)`` definitions and break Blender registration.
8
+ """
9
+
10
+ from bpy.props import (
11
+ BoolProperty,
12
+ EnumProperty,
13
+ FloatProperty,
14
+ FloatVectorProperty,
15
+ )
16
+ from bpy.types import PropertyGroup
17
+ from openrct2_x7_renderer.constants import TILE_SIZE
18
+
19
+
20
+ def title(name: str) -> str:
21
+ """``"steep_slopes"`` → ``"Steep Slopes"``."""
22
+ return name.replace("_", " ").title()
23
+
24
+
25
+ def simple_items(names):
26
+ """Build ``(identifier, label, description)`` tuples for a single-select enum."""
27
+ return [(n, title(n), "") for n in names]
28
+
29
+
30
+ SCALE_PRESET_VALUES = {
31
+ "REALISTIC": TILE_SIZE,
32
+ "TILE": 1.0,
33
+ }
34
+
35
+ SCALE_PRESET_ITEMS = [
36
+ ("REALISTIC", f"Realistic ({TILE_SIZE:g} m/tile)", "Match RCT2's real-world tile scale"),
37
+ ("TILE", "1 unit = 1 tile", "Model in tiles: one OBJ unit spans one tile"),
38
+ ("CUSTOM", "Custom", "Set the units-per-tile value manually"),
39
+ ]
40
+
41
+
42
+ def scale_preset_update(self, _context):
43
+ """Write the preset's units-per-tile into the consumed value (Custom: no-op)."""
44
+ value = SCALE_PRESET_VALUES.get(self.scale_preset)
45
+ if value is not None:
46
+ self.units_per_tile = value
47
+
48
+
49
+ LIGHT_TYPE_ITEMS = [
50
+ ("diffuse", "Diffuse", "Directional diffuse light"),
51
+ ("specular", "Specular", "Specular highlight light"),
52
+ ]
53
+
54
+
55
+ class SharedLight(PropertyGroup):
56
+ """One entry in a custom lighting rig (shared by both add-ons)."""
57
+
58
+ type: EnumProperty(name="Type", items=LIGHT_TYPE_ITEMS, default="diffuse")
59
+ shadow: BoolProperty(
60
+ name="Casts Shadow",
61
+ description="Whether this light contributes to ambient-occlusion shadowing",
62
+ default=False,
63
+ )
64
+ direction: FloatVectorProperty(
65
+ name="Direction",
66
+ description="Direction in OBJ space (+X forward, +Y up, +Z right); normalized at render",
67
+ size=3,
68
+ default=(0.0, 1.0, 0.0),
69
+ subtype="XYZ",
70
+ )
71
+ strength: FloatProperty(
72
+ name="Strength",
73
+ description="Light intensity",
74
+ default=0.5,
75
+ min=0.0,
76
+ )
@@ -0,0 +1,19 @@
1
+ """Re-export CLI scaffolding from the renderer for downstream generators."""
2
+
3
+ from openrct2_x7_renderer.cli import ( # noqa: F401
4
+ TEST_ZOOM,
5
+ RenderFn,
6
+ make_context,
7
+ output_directory_of,
8
+ parse_cli_args,
9
+ run_cli,
10
+ )
11
+
12
+ __all__ = [
13
+ "TEST_ZOOM",
14
+ "RenderFn",
15
+ "make_context",
16
+ "output_directory_of",
17
+ "parse_cli_args",
18
+ "run_cli",
19
+ ]
@@ -0,0 +1,35 @@
1
+ """Re-export config utilities from the renderer for downstream generators."""
2
+
3
+ from openrct2_x7_renderer.config import ( # noqa: F401
4
+ LoadError,
5
+ as_array_or_wrap,
6
+ load_meshes,
7
+ load_preview,
8
+ optional_bool,
9
+ optional_int,
10
+ optional_number,
11
+ optional_string,
12
+ optional_string_list,
13
+ parse_config,
14
+ read_vector3,
15
+ require_int,
16
+ require_number,
17
+ require_string,
18
+ )
19
+
20
+ __all__ = [
21
+ "LoadError",
22
+ "as_array_or_wrap",
23
+ "load_meshes",
24
+ "load_preview",
25
+ "optional_bool",
26
+ "optional_int",
27
+ "optional_number",
28
+ "optional_string",
29
+ "optional_string_list",
30
+ "parse_config",
31
+ "read_vector3",
32
+ "require_int",
33
+ "require_number",
34
+ "require_string",
35
+ ]
@@ -7,17 +7,22 @@ references it via ``$LGX:``, and zip the two together. The per-kind work is just
7
7
  *what* sprites to render and *what* metadata to emit; this module owns the rest.
8
8
  """
9
9
 
10
+ import contextlib
10
11
  import json
11
12
  import logging
13
+ import math
14
+ import os
15
+ import tempfile
12
16
  import zipfile
13
17
  from collections.abc import Callable
14
18
  from pathlib import Path
15
19
  from typing import Any
16
20
 
21
+ import numpy as np
17
22
  from openrct2_x7_renderer.images_dat import write_images_dat
18
23
  from openrct2_x7_renderer.types import IndexedImage
19
24
 
20
- __all__ = ["RenderFn", "assemble_parkobj", "write_images_dat_lgx"]
25
+ __all__ = ["RenderFn", "assemble_parkobj", "combine_indexed_images", "write_images_dat_lgx"]
21
26
 
22
27
  log = logging.getLogger(__name__)
23
28
 
@@ -26,6 +31,39 @@ log = logging.getLogger(__name__)
26
31
  RenderFn = Callable[[Path], list[str]]
27
32
 
28
33
 
34
+ def combine_indexed_images(images: list[IndexedImage], columns: int = 2) -> IndexedImage:
35
+ """Tile IndexedImages into a single grid image, aligned by draw offset.
36
+
37
+ Each cell spans the union of every image's draw-offset bounding box, so a
38
+ shared sprite anchor lands at the same spot in every cell and the rotated
39
+ views line up. Cells fill left-to-right, top-to-bottom over a transparent
40
+ (palette index 0) background; ``columns`` is capped at the image count so a
41
+ single image doesn't leave a blank cell. Used to show all four rotated
42
+ preview directions in one image.
43
+ """
44
+ if not images:
45
+ return IndexedImage.blank(1, 1)
46
+ columns = max(1, min(columns, len(images)))
47
+ left = min(im.x_offset for im in images)
48
+ top = min(im.y_offset for im in images)
49
+ cell_w = max(im.x_offset + im.width for im in images) - left
50
+ cell_h = max(im.y_offset + im.height for im in images) - top
51
+ rows = math.ceil(len(images) / columns)
52
+ canvas = np.zeros((rows * cell_h, columns * cell_w), dtype=np.uint8)
53
+ for idx, im in enumerate(images):
54
+ row, col = divmod(idx, columns)
55
+ x = col * cell_w + (im.x_offset - left)
56
+ y = row * cell_h + (im.y_offset - top)
57
+ canvas[y : y + im.height, x : x + im.width] = im.pixels
58
+ return IndexedImage(
59
+ width=canvas.shape[1],
60
+ height=canvas.shape[0],
61
+ x_offset=0,
62
+ y_offset=0,
63
+ pixels=canvas,
64
+ )
65
+
66
+
29
67
  def write_images_dat_lgx(
30
68
  images: list[IndexedImage], work_dir: Path, *, note: str = ""
31
69
  ) -> list[str]:
@@ -83,6 +121,14 @@ def assemble_parkobj(
83
121
  (work_dir / "object.json").write_text(json.dumps(obj_json, indent=4))
84
122
 
85
123
  parkobj_path.parent.mkdir(parents=True, exist_ok=True)
86
- with zipfile.ZipFile(parkobj_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
87
- zf.write(work_dir / "object.json", "object.json")
88
- zf.write(work_dir / "images.dat", "images.dat")
124
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".parkobj", dir=parkobj_path.parent)
125
+ os.close(tmp_fd)
126
+ try:
127
+ with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
128
+ zf.write(work_dir / "object.json", "object.json")
129
+ zf.write(work_dir / "images.dat", "images.dat")
130
+ os.replace(tmp_path, parkobj_path)
131
+ except BaseException:
132
+ with contextlib.suppress(OSError):
133
+ os.unlink(tmp_path)
134
+ raise
@@ -12,6 +12,7 @@ last pose), which is the ``clamp_frame`` flag.
12
12
  import math
13
13
 
14
14
  import numpy as np
15
+ from numpy.typing import NDArray
15
16
  from openrct2_x7_renderer.geometry import rotate_x, rotate_y, rotate_z
16
17
  from openrct2_x7_renderer.mesh import Mesh
17
18
  from openrct2_x7_renderer.ray_trace import SceneBuilder
@@ -20,7 +21,7 @@ from openrct2_x7_renderer.types import Model
20
21
  __all__ = ["add_model_to_scene", "orientation_to_matrix"]
21
22
 
22
23
 
23
- def orientation_to_matrix(orientation_deg: np.ndarray) -> np.ndarray:
24
+ def orientation_to_matrix(orientation_deg: NDArray[np.float64]) -> NDArray[np.float64]:
24
25
  """A MeshFrame orientation ``(angle_y, angle_z, angle_x)`` in degrees as a
25
26
  ``(3, 3)`` rotation matrix, applied as ``rotate_y @ rotate_z @ rotate_x``."""
26
27
  rx, ry, rz = orientation_deg * (math.pi / 180.0)
@@ -0,0 +1,82 @@
1
+ """Shared renderer stubs for unit-testing object generators.
2
+
3
+ VehicleGenerator and SceneryGenerator both need a lightweight fake of the
4
+ Embree render pipeline that records lifecycle events without touching real
5
+ GPU/Embree resources. Import these instead of reimplementing them per-suite.
6
+
7
+ Event format in ``FakeContext.events``:
8
+ ``"begin"`` — :meth:`FakeContext.begin_render` was called.
9
+ ``"finalize"`` — :meth:`FakeBuilder.finalize` was called.
10
+ ``"end"`` — :meth:`FakeScene.end_render` was called (via explicit
11
+ call or context-manager exit).
12
+ ``("add", mask)`` — :meth:`FakeBuilder.add_model` was called with ``mask``.
13
+ """
14
+
15
+ from openrct2_x7_renderer.types import IndexedImage
16
+
17
+ __all__ = ["FakeContext", "FakeScene", "FakeBuilder"]
18
+
19
+ # Events appended to FakeContext.events by the stub pipeline.
20
+ Event = str | tuple[str, int]
21
+
22
+
23
+ class FakeScene:
24
+ """Stands in for ``FinalizedScene``; every view renders a 1×1 dummy."""
25
+
26
+ def __init__(self, events: list[Event]) -> None:
27
+ self._events = events
28
+
29
+ def __enter__(self) -> "FakeScene":
30
+ return self
31
+
32
+ def __exit__(self, *_: object) -> None:
33
+ self.end_render()
34
+
35
+ def render_view(self, _view: object) -> IndexedImage:
36
+ return IndexedImage.blank(1, 1)
37
+
38
+ def render_silhouette(self, _view: object) -> IndexedImage:
39
+ return IndexedImage.blank(1, 1)
40
+
41
+ def end_render(self) -> None:
42
+ self._events.append("end")
43
+
44
+
45
+ class FakeBuilder:
46
+ """Stands in for ``SceneBuilder``; records lifecycle events."""
47
+
48
+ def __init__(self, events: list[Event]) -> None:
49
+ self._events = events
50
+
51
+ def __enter__(self) -> "FakeBuilder":
52
+ return self
53
+
54
+ def __exit__(self, *_: object) -> None:
55
+ pass
56
+
57
+ def add_model(
58
+ self,
59
+ mesh: object,
60
+ matrix: object,
61
+ translation: object,
62
+ mask: int = 0,
63
+ ) -> "FakeBuilder":
64
+ self._events.append(("add", mask))
65
+ return self
66
+
67
+ def finalize(self) -> FakeScene:
68
+ self._events.append("finalize")
69
+ return FakeScene(self._events)
70
+
71
+
72
+ class FakeContext:
73
+ """Records render lifecycle calls without touching Embree."""
74
+
75
+ def __init__(self) -> None:
76
+ self.events: list[Event] = []
77
+ # Mirrors Context.remap_overrides; may be overwritten by export_*_test.
78
+ self.remap_overrides: dict[int, tuple[int, ...]] = {}
79
+
80
+ def begin_render(self) -> FakeBuilder:
81
+ self.events.append("begin")
82
+ return FakeBuilder(self.events)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "OpenRCT2-ObjectCommon"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Shared config, CLI, .parkobj, and object.json scaffolding for OpenRCT2 object generators."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -9,7 +9,7 @@ authors = [
9
9
  { name = "Alex Parisi", email = "alex@atparisi.com" }
10
10
  ]
11
11
  dependencies = [
12
- "openrct2-x7-renderer>=0.3.0",
12
+ "openrct2-x7-renderer>=0.3.1",
13
13
  "numpy>=1.26",
14
14
  "pillow>=10.0",
15
15
  "pyyaml>=6.0",
@@ -40,6 +40,7 @@ addopts = "-q --cov=openrct2_object_common"
40
40
 
41
41
  [tool.coverage.run]
42
42
  source = ["openrct2_object_common"]
43
+ omit = ["openrct2_object_common/testing.py"]
43
44
 
44
45
  [tool.coverage.report]
45
46
  exclude_also = [
@@ -63,6 +64,7 @@ select = ["E", "F", "I", "UP", "B", "W", "N"]
63
64
  [tool.mypy]
64
65
  python_version = "3.11"
65
66
  files = ["openrct2_object_common"]
67
+ strict = true
66
68
  warn_unused_ignores = true
67
69
  warn_redundant_casts = true
68
70
  warn_unused_configs = true
@@ -70,11 +72,6 @@ warn_unused_configs = true
70
72
  # type-checked in the consuming add-on repos, not here.
71
73
  exclude = ["openrct2_object_common/blender/"]
72
74
 
73
- # openrct2-x7-renderer ships no py.typed marker, so mypy can't resolve its types.
74
- [[tool.mypy.overrides]]
75
- module = ["openrct2_x7_renderer.*"]
76
- ignore_missing_imports = true
77
-
78
75
  [build-system]
79
76
  requires = ["hatchling"]
80
77
  build-backend = "hatchling.build"