OpenRCT2-ObjectCommon 0.1.1__tar.gz → 0.1.3__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.
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/PKG-INFO +2 -2
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/__init__.py +19 -0
- openrct2_objectcommon-0.1.3/openrct2_object_common/blender/mesh_extract.py +191 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/blender/modal.py +8 -1
- openrct2_objectcommon-0.1.3/openrct2_object_common/blender/props.py +76 -0
- openrct2_objectcommon-0.1.3/openrct2_object_common/cli.py +19 -0
- openrct2_objectcommon-0.1.3/openrct2_object_common/config.py +35 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/parkobj.py +50 -4
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/placement.py +2 -1
- openrct2_objectcommon-0.1.3/openrct2_object_common/testing.py +82 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/pyproject.toml +4 -7
- openrct2_objectcommon-0.1.3/tests/conftest.py +125 -0
- openrct2_objectcommon-0.1.3/tests/test_blender.py +355 -0
- openrct2_objectcommon-0.1.3/tests/test_blender_shared.py +307 -0
- openrct2_objectcommon-0.1.3/tests/test_cli.py +151 -0
- openrct2_objectcommon-0.1.3/tests/test_config.py +357 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/tests/test_parkobj.py +45 -1
- openrct2_objectcommon-0.1.1/openrct2_object_common/cli.py +0 -79
- openrct2_objectcommon-0.1.1/openrct2_object_common/config.py +0 -188
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/.github/workflows/lint.yml +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/.github/workflows/publish.yml +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/.github/workflows/pytest.yml +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/.gitignore +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/.yamllint.yaml +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/LICENSE +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/README.md +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/blender/__init__.py +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/blender/lights.py +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/objectjson.py +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/py.typed +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/scripts/ci/set_version.py +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/tests/test_objectjson.py +0 -0
- {openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/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.
|
|
3
|
+
Version: 0.1.3
|
|
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.
|
|
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
|
{openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/__init__.py
RENAMED
|
@@ -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,191 @@
|
|
|
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
|
+
# Blender UVs use a bottom-left origin (V=0 at the bottom of the
|
|
144
|
+
# image); the renderer samples textures top-left (V=0 = row 0).
|
|
145
|
+
# Flip V so the render matches Blender's viewport. With no UV
|
|
146
|
+
# layer, fall back to (0, 0) (unflipped) since UVs are unused.
|
|
147
|
+
if uv_layer:
|
|
148
|
+
u, v = uv_layer.data[loop_idx].uv
|
|
149
|
+
uv = (u, 1.0 - v)
|
|
150
|
+
else:
|
|
151
|
+
uv = (0.0, 0.0)
|
|
152
|
+
verts.append((co.x, co.y, co.z))
|
|
153
|
+
norms.append((n.x, n.y, n.z))
|
|
154
|
+
uvs.append((uv[0], uv[1]))
|
|
155
|
+
corner.append(len(verts) - 1)
|
|
156
|
+
faces.append((corner[0], corner[1], corner[2]))
|
|
157
|
+
face_mats.append(min(lt.material_index, n_mats - 1))
|
|
158
|
+
|
|
159
|
+
return Mesh(
|
|
160
|
+
vertices=np.array(verts, dtype=np.float32),
|
|
161
|
+
normals=np.array(norms, dtype=np.float32),
|
|
162
|
+
uvs=np.array(uvs, dtype=np.float32),
|
|
163
|
+
faces=np.array(faces, dtype=np.uint32),
|
|
164
|
+
face_materials=np.array(face_mats, dtype=np.uint32),
|
|
165
|
+
materials=materials,
|
|
166
|
+
)
|
|
167
|
+
finally:
|
|
168
|
+
eval_obj.to_mesh_clear()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def object_position(obj) -> list[float]:
|
|
172
|
+
"""World translation of *obj* converted to OBJ space."""
|
|
173
|
+
p = BASIS @ obj.matrix_world.to_translation()
|
|
174
|
+
return [float(p.x), float(p.y), float(p.z)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_preview(filepath) -> IndexedImage | None:
|
|
178
|
+
"""Load a preview image from *filepath*, quantizing non-paletted sources."""
|
|
179
|
+
if not filepath:
|
|
180
|
+
return None
|
|
181
|
+
path = bpy.path.abspath(filepath)
|
|
182
|
+
if not path or not os.path.exists(path):
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
return read_png(path)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
try:
|
|
189
|
+
return quantize_to_indexed(path)
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
{openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/blender/modal.py
RENAMED
|
@@ -18,7 +18,14 @@ import time
|
|
|
18
18
|
import traceback
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
|
-
|
|
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
|
+
]
|
{openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/parkobj.py
RENAMED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
{openrct2_objectcommon-0.1.1 → openrct2_objectcommon-0.1.3}/openrct2_object_common/placement.py
RENAMED
|
@@ -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.
|
|
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.
|
|
3
|
+
version = "0.1.3"
|
|
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.
|
|
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"
|