@threenative/core 0.3.3 → 0.3.4

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,184 @@
1
+ # SPDX-License-Identifier: GPL-2.0-or-later
2
+ #
3
+ # Copyright (C) 2026 ThreeNative contributors
4
+ #
5
+ # This program is free software; you can redistribute it and/or modify it under the terms of the
6
+ # GNU General Public License as published by the Free Software Foundation; either version 2 of the
7
+ # License, or (at your option) any later version. See ../LICENSE.GPL.
8
+ #
9
+ # Build the tiny, deterministic, CC0-primitives-only world that the export_world spec runs. It
10
+ # exercises every convention the recipe understands: a marked terrain, two tn_asset_id scatter
11
+ # sources and a ground-cover asset with tn_max_distance, a render-density scatter switched by an
12
+ # Is Viewport node, and a tn_world_chunk collection straddling a cell boundary.
13
+ #
14
+ # blender -b --factory-startup --python make_world_fixture.py -- --out /tmp/world.blend
15
+
16
+ import math
17
+ import os
18
+ import sys
19
+
20
+ import bpy
21
+ from mathutils import Matrix
22
+
23
+
24
+ def fail(message):
25
+ sys.stderr.write("TN_BLENDER_ERROR: %s\n" % message)
26
+ sys.stderr.flush()
27
+ os._exit(2)
28
+
29
+
30
+ def parse_args():
31
+ argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
32
+ payload = {}
33
+ index = 0
34
+ while index < len(argv):
35
+ token = argv[index]
36
+ if token.startswith("--"):
37
+ key = token[2:]
38
+ if "=" in key:
39
+ key, value = key.split("=", 1)
40
+ else:
41
+ index += 1
42
+ if index >= len(argv):
43
+ fail("'--%s' needs a value" % key)
44
+ value = argv[index]
45
+ payload[key] = value
46
+ index += 1
47
+ return payload
48
+
49
+
50
+ def displace(terrain):
51
+ for vertex in terrain.data.vertices:
52
+ x, y = vertex.co.x, vertex.co.y
53
+ vertex.co.z = (
54
+ 3.0 * math.sin(x * 0.11) * math.cos(y * 0.13)
55
+ + 1.5 * math.sin(x * 0.031 + y * 0.047)
56
+ + 0.5 * math.cos((x + y) * 0.21)
57
+ )
58
+
59
+
60
+ def scatter_group(asset, render_density, viewport_density, seed):
61
+ group = bpy.data.node_groups.new("TNScatter_%s" % asset.name, "GeometryNodeTree")
62
+ group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
63
+ group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
64
+ source = group.nodes.new("NodeGroupInput")
65
+ output = group.nodes.new("NodeGroupOutput")
66
+ distribute = group.nodes.new("GeometryNodeDistributePointsOnFaces")
67
+ distribute.distribute_method = "RANDOM"
68
+ distribute.inputs["Seed"].default_value = seed
69
+ instance = group.nodes.new("GeometryNodeInstanceOnPoints")
70
+ info = group.nodes.new("GeometryNodeObjectInfo")
71
+ info.inputs["Object"].default_value = asset
72
+ # Object-level instancing: the depsgraph reports the asset object itself for every point, which
73
+ # is what lets the exporter read tn_asset_id off `instance_object`. Realised geometry instances
74
+ # report the emitter instead, and the asset id is lost.
75
+ info.inputs["As Instance"].default_value = True
76
+ is_viewport = group.nodes.new("GeometryNodeIsViewport")
77
+ switch = group.nodes.new("GeometryNodeSwitch")
78
+ switch.input_type = "FLOAT"
79
+ switch.inputs["False"].default_value = render_density
80
+ switch.inputs["True"].default_value = viewport_density
81
+ group.links.new(source.outputs["Geometry"], distribute.inputs["Mesh"])
82
+ group.links.new(is_viewport.outputs["Is Viewport"], switch.inputs["Switch"])
83
+ group.links.new(switch.outputs["Output"], distribute.inputs["Density"])
84
+ group.links.new(distribute.outputs["Points"], instance.inputs["Points"])
85
+ group.links.new(info.outputs["Geometry"], instance.inputs["Instance"])
86
+ group.links.new(instance.outputs["Instances"], output.inputs["Geometry"])
87
+ return group
88
+
89
+
90
+ def make_emitter(asset, render_density, viewport_density, seed):
91
+ bpy.ops.mesh.primitive_grid_add(x_subdivisions=8, y_subdivisions=8, size=200, location=(0, 0, 0))
92
+ emitter = bpy.context.object
93
+ emitter.name = "scatter_%s" % asset.name
94
+ modifier = emitter.modifiers.new("TNScatter", "NODES")
95
+ modifier.node_group = scatter_group(asset, render_density, viewport_density, seed)
96
+ return emitter
97
+
98
+
99
+ def move_to_collection(instance, collection):
100
+ for existing in list(instance.users_collection):
101
+ existing.objects.unlink(instance)
102
+ collection.objects.link(instance)
103
+
104
+
105
+ def main():
106
+ payload = parse_args()
107
+ out = payload.get("out")
108
+ if not isinstance(out, str) or not out:
109
+ fail("make_world_fixture requires an '--out' .blend path")
110
+ out = os.path.abspath(out)
111
+ directory = os.path.dirname(out)
112
+ if directory and not os.path.isdir(directory):
113
+ os.makedirs(directory, exist_ok=True)
114
+
115
+ bpy.ops.wm.read_factory_settings(use_empty=True)
116
+
117
+ bpy.ops.mesh.primitive_grid_add(x_subdivisions=64, y_subdivisions=64, size=256)
118
+ terrain = bpy.context.object
119
+ terrain.name = "terrain"
120
+ displace(terrain)
121
+ terrain["tn_world_terrain"] = 1
122
+
123
+ bpy.ops.mesh.primitive_cone_add(vertices=8, radius1=1.0, radius2=0.0, depth=4.0)
124
+ pine = bpy.context.object
125
+ pine.name = "pine"
126
+ pine["tn_asset_id"] = "pine"
127
+ make_emitter(pine, 0.02, 0.002, 7)
128
+
129
+ bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1.0)
130
+ rock = bpy.context.object
131
+ rock.name = "rock"
132
+ rock["tn_asset_id"] = "rock"
133
+ make_emitter(rock, 0.02, 0.002, 11)
134
+
135
+ bpy.ops.mesh.primitive_circle_add(vertices=8, radius=0.5, fill_type="NGON")
136
+ cover = bpy.context.object
137
+ cover.name = "ground_cover"
138
+ cover["tn_asset_id"] = "ground_cover"
139
+ cover["tn_max_distance"] = 30.0
140
+ make_emitter(cover, 0.05, 0.005, 13)
141
+
142
+ # Real worlds keep scatter sources in an excluded collection so only their instances show.
143
+ sources = bpy.data.collections.new("_src")
144
+ bpy.context.scene.collection.children.link(sources)
145
+ for source in (pine, cover):
146
+ move_to_collection(source, sources)
147
+ bpy.context.view_layer.layer_collection.children["_src"].exclude = True
148
+
149
+ # Other real worlds nest a source in a visible collection under a hidden one, which is a
150
+ # different shape from an excluded collection: the object is in the view layer, so the glTF
151
+ # exporter skips it for not being in any visible collection unless the recipe stages it.
152
+ hidden = bpy.data.collections.new("hidden_sources")
153
+ hidden.hide_viewport = True
154
+ hidden.hide_render = True
155
+ bpy.context.scene.collection.children.link(hidden)
156
+ nested = bpy.data.collections.new("_src nested")
157
+ hidden.children.link(nested)
158
+ move_to_collection(rock, nested)
159
+
160
+ yard = bpy.data.collections.new("yard")
161
+ yard["tn_world_chunk"] = 1
162
+ bpy.context.scene.collection.children.link(yard)
163
+
164
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(-100, 0, 1))
165
+ first = bpy.context.object
166
+ first.name = "yard_crate_a"
167
+ move_to_collection(first, yard)
168
+
169
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(-30, 20, 1))
170
+ second = bpy.context.object
171
+ second.name = "yard_crate_b"
172
+ move_to_collection(second, yard)
173
+
174
+ # A unit-scale sanity anchor: this object crosses no convention and must not disturb a count.
175
+ bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 3))
176
+ anchor = bpy.context.object
177
+ anchor.name = "anchor"
178
+ anchor.matrix_world = Matrix.Identity(4)
179
+
180
+ bpy.ops.wm.save_as_mainfile(filepath=out)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
@@ -81,6 +81,17 @@ def uv_layers():
81
81
  return sum(len(item.data.uv_layers) for item in meshes())
82
82
 
83
83
 
84
+ def collapse_decimate(item, ratio):
85
+ """Add the collapse-decimate modifier `decimate` uses, so other recipes reuse that recipe's
86
+ operation instead of copying it. It measures nothing and writes nothing; the caller decides
87
+ what to export."""
88
+ modifier = item.modifiers.new(name="TNDecimate", type="DECIMATE")
89
+ modifier.decimate_type = "COLLAPSE"
90
+ modifier.ratio = float(ratio)
91
+ modifier.use_collapse_triangulate = True
92
+ return modifier
93
+
94
+
84
95
  def export(out):
85
96
  directory = os.path.dirname(out)
86
97
  if directory and not os.path.isdir(directory):
@@ -23,7 +23,16 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
23
 
24
24
  import bpy # noqa: E402
25
25
 
26
- from _common import export, fail, load, meshes, request, triangles, emit # noqa: E402
26
+ from _common import (
27
+ collapse_decimate,
28
+ emit,
29
+ export,
30
+ fail,
31
+ load,
32
+ meshes,
33
+ request,
34
+ triangles,
35
+ ) # noqa: E402
27
36
 
28
37
  payload = request()
29
38
  source = payload.get("source")
@@ -43,10 +52,7 @@ if before == 0:
43
52
  fail("'%s' has no triangles to decimate" % source)
44
53
 
45
54
  for item in meshes():
46
- modifier = item.modifiers.new(name="TNDecimate", type="DECIMATE")
47
- modifier.decimate_type = "COLLAPSE"
48
- modifier.ratio = float(ratio)
49
- modifier.use_collapse_triangulate = True
55
+ collapse_decimate(item, ratio)
50
56
 
51
57
  after = triangles()
52
58
  export(out)