OpenRCT2-ObjectCommon 0.2.3__tar.gz → 0.2.5__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 (32) hide show
  1. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/PKG-INFO +2 -2
  2. openrct2_objectcommon-0.2.5/openrct2_object_common/blender/bake.py +163 -0
  3. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/blender/props.py +5 -0
  4. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/pyproject.toml +2 -2
  5. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_blender_shared.py +82 -2
  6. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/.github/workflows/lint.yml +0 -0
  7. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/.github/workflows/publish.yml +0 -0
  8. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/.github/workflows/pytest.yml +0 -0
  9. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/.gitignore +0 -0
  10. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/.yamllint.yaml +0 -0
  11. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/LICENSE +0 -0
  12. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/README.md +0 -0
  13. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/__init__.py +0 -0
  14. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/blender/__init__.py +0 -0
  15. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/blender/lights.py +0 -0
  16. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/blender/mesh_extract.py +0 -0
  17. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/blender/modal.py +0 -0
  18. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/cli.py +0 -0
  19. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/config.py +0 -0
  20. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/objectjson.py +0 -0
  21. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/parkobj.py +0 -0
  22. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/placement.py +0 -0
  23. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/py.typed +0 -0
  24. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/openrct2_object_common/testing.py +0 -0
  25. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/scripts/ci/set_version.py +0 -0
  26. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/conftest.py +0 -0
  27. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_blender.py +0 -0
  28. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_cli.py +0 -0
  29. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_config.py +0 -0
  30. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_objectjson.py +0 -0
  31. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_parkobj.py +0 -0
  32. {openrct2_objectcommon-0.2.3 → openrct2_objectcommon-0.2.5}/tests/test_placement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenRCT2-ObjectCommon
3
- Version: 0.2.3
3
+ Version: 0.2.5
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.8
12
+ Requires-Dist: openrct2-x7-renderer>=0.3.9
13
13
  Requires-Dist: pillow>=10.0
14
14
  Requires-Dist: pyyaml>=6.0
15
15
  Provides-Extra: blender
@@ -0,0 +1,163 @@
1
+ """Bake a material's procedural shader-node graph to an albedo texture.
2
+
3
+ The standalone renderer has no Cycles/``bpy`` at shade time, so it can't evaluate
4
+ Blender's procedural node graphs directly. Instead, when a material opts in
5
+ (``bake_procedural``), the add-on bakes the node graph's albedo (Cycles
6
+ ``DIFFUSE``/``COLOR`` pass — colour without lighting, since the renderer does its
7
+ own shading) into an image at export and feeds it through the normal texture
8
+ path. Baked textures are UV-locked, so they are rotation-stable across sprites.
9
+
10
+ Shared by both add-ons. ``bpy``-dependent orchestration lives in
11
+ :func:`bake_materials`; :func:`_image_to_texture` is kept ``bpy``-free so it can
12
+ be unit-tested without Blender.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+ from openrct2_x7_renderer.mesh import Texture
19
+
20
+ from .mesh_extract import SceneError
21
+
22
+ _TARGET_NODE = "__orct2_bake_target"
23
+
24
+ # (identifier, label, description) tuples for a bpy EnumProperty; both add-ons
25
+ # expose the same bake resolutions.
26
+ BAKE_RESOLUTION_ITEMS = [
27
+ ("64", "64×64", "Bake at 64×64"),
28
+ ("128", "128×128", "Bake at 128×128"),
29
+ ("256", "256×256", "Bake at 256×256"),
30
+ ("512", "512×512", "Bake at 512×512"),
31
+ ]
32
+
33
+
34
+ def _image_to_texture(image) -> Texture:
35
+ """Convert a baked Blender image into a linear-RGB :class:`Texture`.
36
+
37
+ Blender stores image rows bottom-up; the renderer samples top-left, so the
38
+ rows are flipped (mirroring the UV V-flip in ``mesh_extract.extract_mesh``).
39
+ Float images are already linear, matching what the renderer expects.
40
+ """
41
+ width, height = int(image.size[0]), int(image.size[1])
42
+ flat = np.asarray(image.pixels[:], dtype=np.float32).reshape(height, width, 4)
43
+ rgb = np.ascontiguousarray(flat[::-1, :, :3])
44
+ return Texture(width=width, height=height, pixels=rgb)
45
+
46
+
47
+ def _bake_settings(mat, prop_attr):
48
+ """The add-on's per-material settings if this material opts into baking."""
49
+ s = getattr(mat, prop_attr, None)
50
+ if s is not None and getattr(s, "bake_procedural", False):
51
+ return s
52
+ return None
53
+
54
+
55
+ def _add_target_node(mat, image):
56
+ """Add (and activate) an Image Texture node Cycles will bake into."""
57
+ mat.use_nodes = True
58
+ nodes = mat.node_tree.nodes
59
+ node = nodes.new("ShaderNodeTexImage")
60
+ node.name = _TARGET_NODE
61
+ node.image = image
62
+ node.select = True
63
+ nodes.active = node
64
+ return node
65
+
66
+
67
+ def _select_only(view_layer, obj) -> None:
68
+ for other in view_layer.objects:
69
+ other.select_set(False)
70
+ obj.select_set(True)
71
+ view_layer.objects.active = obj
72
+
73
+
74
+ def bake_materials(context, objects, *, prop_attr) -> dict:
75
+ """Bake every opted-in material on *objects* to a texture.
76
+
77
+ Returns ``{bpy.types.Material: Texture}``. Main-thread only (drives Cycles via
78
+ ``bpy.ops``). Raises :class:`SceneError` with author-facing guidance when a
79
+ to-bake object has no UV map. Render engine, selection and the touched
80
+ materials' ``use_nodes`` are saved and restored.
81
+
82
+ *prop_attr* is the add-on's material settings attribute (``"vgs_material"`` /
83
+ ``"vg_material"``); each opted-in material's ``bake_resolution`` sets its size.
84
+ """
85
+ import bpy
86
+
87
+ # Plan: per object, the opted-in materials and their settings.
88
+ plan = []
89
+ for obj in objects:
90
+ if obj.type != "MESH":
91
+ continue
92
+ wanted = {}
93
+ for slot in obj.material_slots:
94
+ if slot.material is None:
95
+ continue
96
+ settings = _bake_settings(slot.material, prop_attr)
97
+ if settings is not None:
98
+ wanted[slot.material] = settings
99
+ if not wanted:
100
+ continue
101
+ if not obj.data.uv_layers:
102
+ name = next(iter(wanted)).name
103
+ raise SceneError(
104
+ f"{obj.name} / {name}: bake needs a UV map — "
105
+ "unwrap the object (U ▸ Smart UV Project)."
106
+ )
107
+ plan.append((obj, wanted))
108
+ if not plan:
109
+ return {}
110
+
111
+ scene = context.scene
112
+ view_layer = context.view_layer
113
+ prev_engine = scene.render.engine
114
+ prev_active = view_layer.objects.active
115
+ prev_selected = [o for o in view_layer.objects if o.select_get()]
116
+
117
+ result: dict = {}
118
+ try:
119
+ scene.render.engine = "CYCLES"
120
+ for obj, wanted in plan:
121
+ # Every material slot used by the mesh needs an active image node for
122
+ # the bake to succeed; non-target slots get a 1x1 throwaway.
123
+ touched = [] # (mat, node, image, prev_use_nodes, keep)
124
+ for slot in obj.material_slots:
125
+ mat = slot.material
126
+ if mat is None:
127
+ continue
128
+ keep = mat in wanted
129
+ res = int(wanted[mat].bake_resolution) if keep else 1
130
+ prev_use_nodes = mat.use_nodes
131
+ image = bpy.data.images.new(
132
+ f"__orct2_bake_{mat.name}",
133
+ width=res,
134
+ height=res,
135
+ float_buffer=True,
136
+ alpha=False,
137
+ )
138
+ node = _add_target_node(mat, image)
139
+ touched.append((mat, node, image, prev_use_nodes, keep))
140
+
141
+ _select_only(view_layer, obj)
142
+ bpy.ops.object.bake(type="DIFFUSE", pass_filter={"COLOR"}, margin=4)
143
+
144
+ for mat, node, image, prev_use_nodes, keep in touched:
145
+ if keep:
146
+ result[mat] = _image_to_texture(image)
147
+ mat.node_tree.nodes.remove(node)
148
+ mat.use_nodes = prev_use_nodes
149
+ bpy.data.images.remove(image)
150
+ finally:
151
+ scene.render.engine = prev_engine
152
+ for o in view_layer.objects:
153
+ o.select_set(o in prev_selected)
154
+ view_layer.objects.active = prev_active
155
+
156
+ return result
157
+
158
+
159
+ def draw_bake(layout, ms) -> None:
160
+ """Draw the bake toggle (+ resolution when enabled) into *layout*."""
161
+ layout.prop(ms, "bake_procedural")
162
+ if ms.bake_procedural:
163
+ layout.prop(ms, "bake_resolution")
@@ -62,6 +62,11 @@ DITHER_MODE_ITEMS = [
62
62
  "Bayer (frame-stable)",
63
63
  "Ordered dither locked to screen position; stable across animation frames",
64
64
  ),
65
+ (
66
+ "blue_noise",
67
+ "Blue noise (frame-stable)",
68
+ "Like Bayer but a blue-noise mask; less perceptible residual motion under rotation",
69
+ ),
65
70
  ("none", "None", "No dithering; flat palette quantisation"),
66
71
  ]
67
72
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "OpenRCT2-ObjectCommon"
3
- version = "0.2.3"
3
+ version = "0.2.5"
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.8",
12
+ "openrct2-x7-renderer>=0.3.9",
13
13
  "numpy>=1.26",
14
14
  "pillow>=10.0",
15
15
  "pyyaml>=6.0",
@@ -7,8 +7,10 @@ collection), so these modules import and run without a Blender runtime.
7
7
  from types import SimpleNamespace
8
8
 
9
9
  import numpy as np
10
+ import pytest
10
11
  from mathutils import Matrix, Vector # provided by conftest fake
11
- from openrct2_object_common.blender import mesh_extract, props
12
+ from openrct2_object_common.blender import bake, mesh_extract, props
13
+ from openrct2_object_common.blender.mesh_extract import SceneError
12
14
  from openrct2_x7_renderer.constants import MaterialFlag
13
15
 
14
16
  # ===========================================================================
@@ -50,8 +52,10 @@ def test_shared_light_propertygroup_defined():
50
52
  def test_dither_mode_items_match_renderer_modes():
51
53
  # The add-on enum identifiers must be exactly the strings make_context /
52
54
  # Context accept, and the default must be one of them.
55
+ from openrct2_x7_renderer.ray_trace import DITHER_MODES
56
+
53
57
  ids = {ident for ident, _label, _desc in props.DITHER_MODE_ITEMS}
54
- assert ids == {"none", "floyd_steinberg", "bayer"}
58
+ assert ids == set(DITHER_MODES)
55
59
  assert props.DEFAULT_DITHER_MODE in ids
56
60
 
57
61
 
@@ -313,3 +317,79 @@ def test_load_preview_returns_none_when_both_fail(monkeypatch):
313
317
  monkeypatch.setattr(mesh_extract, "read_png", _boom)
314
318
  monkeypatch.setattr(mesh_extract, "quantize_to_indexed", _boom)
315
319
  assert mesh_extract.load_preview("/img.png") is None
320
+
321
+
322
+ # ===========================================================================
323
+ # bake.py
324
+ # ===========================================================================
325
+
326
+
327
+ def test_image_to_texture_flips_rows_and_drops_alpha():
328
+ # 2x2 RGBA float image, Blender's bottom-up row order: bottom red, top blue.
329
+ pixels = [
330
+ 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, # bottom row
331
+ 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, # top row
332
+ ]
333
+ image = SimpleNamespace(size=(2, 2), pixels=pixels)
334
+ tex = bake._image_to_texture(image)
335
+ assert tex.width == 2
336
+ assert tex.height == 2
337
+ assert tex.pixels.shape == (2, 2, 3)
338
+ # After the V-flip, row 0 is the top (blue), row 1 the bottom (red).
339
+ assert np.allclose(tex.pixels[0, 0], [0.0, 0.0, 1.0])
340
+ assert np.allclose(tex.pixels[1, 0], [1.0, 0.0, 0.0])
341
+
342
+
343
+ class _BakeMat:
344
+ # A real bpy Material is hashable (by identity); SimpleNamespace is not,
345
+ # so use a plain class for materials used as dict keys.
346
+ def __init__(self, name, *, on, res="256"):
347
+ self.name = name
348
+ self.vg_material = SimpleNamespace(bake_procedural=on, bake_resolution=res)
349
+
350
+
351
+ def _bake_mat(name, *, on, res="256"):
352
+ return _BakeMat(name, on=on, res=res)
353
+
354
+
355
+ def _bake_obj(materials, *, has_uv):
356
+ return SimpleNamespace(
357
+ name="Cube",
358
+ type="MESH",
359
+ material_slots=[SimpleNamespace(material=m) for m in materials],
360
+ data=SimpleNamespace(uv_layers=[object()] if has_uv else []),
361
+ )
362
+
363
+
364
+ def test_bake_materials_no_opted_in_materials_returns_empty():
365
+ obj = _bake_obj([_bake_mat("Plain", on=False)], has_uv=True)
366
+ assert bake.bake_materials(SimpleNamespace(), [obj], prop_attr="vg_material") == {}
367
+
368
+
369
+ def test_bake_materials_skips_non_mesh_objects():
370
+ obj = SimpleNamespace(type="EMPTY")
371
+ assert bake.bake_materials(SimpleNamespace(), [obj], prop_attr="vg_material") == {}
372
+
373
+
374
+ def test_bake_materials_missing_uv_raises_guidance():
375
+ obj = _bake_obj([_bake_mat("Wood", on=True)], has_uv=False)
376
+ with pytest.raises(SceneError, match="UV map"):
377
+ bake.bake_materials(SimpleNamespace(), [obj], prop_attr="vg_material")
378
+
379
+
380
+ class _FakeLayout:
381
+ def __init__(self):
382
+ self.props: list[str] = []
383
+
384
+ def prop(self, _data, name):
385
+ self.props.append(name)
386
+
387
+
388
+ def test_draw_bake_shows_resolution_only_when_enabled():
389
+ on = _FakeLayout()
390
+ bake.draw_bake(on, SimpleNamespace(bake_procedural=True, bake_resolution="256"))
391
+ assert on.props == ["bake_procedural", "bake_resolution"]
392
+
393
+ off = _FakeLayout()
394
+ bake.draw_bake(off, SimpleNamespace(bake_procedural=False, bake_resolution="256"))
395
+ assert off.props == ["bake_procedural"]