-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgn_instance_grid.py
More file actions
270 lines (227 loc) · 10 KB
/
Copy pathgn_instance_grid.py
File metadata and controls
270 lines (227 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""Geometry Nodes Instance-on-Points grid — a runnable example.
Witnesses the geometry-nodes-python construction contract for instancing:
a generative GeometryNodeTree (Mesh Grid → Instance on Points → Realize
Instances → Transform) attached as a NODES modifier, with no Group Input
geometry. The check asserts the closed-form evaluated topology —
verts = grid_points × cube_verts — proving instances were realized, not
left as empty instance geometry, and that a corner instance sits at its
closed-form grid coordinate.
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python gn_instance_grid.py -- # check only
blender --background --python gn_instance_grid.py -- --output g.png # + render
"""
import bpy, bmesh, sys, os, math, argparse
GRID_X = 3
GRID_Y = 3
GRID_SIZE = 2.4
CUBE_SIZE = 0.42
CUBE_VERTS = 8
CUBE_FACES = 6
GRID_POINTS = GRID_X * GRID_Y
EXPECT_VERTS = GRID_POINTS * CUBE_VERTS
EXPECT_FACES = GRID_POINTS * CUBE_FACES
# Mesh Grid spans [-GRID_SIZE/2, +GRID_SIZE/2]; corner point at (+half, +half)
GRID_HALF = GRID_SIZE / 2
# after Transform lift, corner cube center is at (GRID_HALF, GRID_HALF, CUBE_SIZE/2)
CORNER_CENTER = (GRID_HALF, GRID_HALF, CUBE_SIZE / 2)
def build_instance_grid_tree(material=None):
tree = bpy.data.node_groups.new("InstanceGrid", 'GeometryNodeTree')
# generative: no Group Input — the tree owns the geometry
tree.interface.new_socket(
name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry',
)
go = tree.nodes.new('NodeGroupOutput')
grid = tree.nodes.new('GeometryNodeMeshGrid')
grid.inputs["Size X"].default_value = GRID_SIZE
grid.inputs["Size Y"].default_value = GRID_SIZE
grid.inputs["Vertices X"].default_value = GRID_X
grid.inputs["Vertices Y"].default_value = GRID_Y
cube = tree.nodes.new('GeometryNodeMeshCube')
cube.inputs["Size"].default_value = (CUBE_SIZE, CUBE_SIZE, CUBE_SIZE)
iop = tree.nodes.new('GeometryNodeInstanceOnPoints')
realize = tree.nodes.new('GeometryNodeRealizeInstances')
xform = tree.nodes.new('GeometryNodeTransform')
# cubes are centered on grid points at z=0; lift so they rest on the floor
xform.inputs["Translation"].default_value = (0.0, 0.0, CUBE_SIZE / 2)
# crisp facets — matches the other studio examples
shade = tree.nodes.new('GeometryNodeSetShadeSmooth')
shade.inputs["Shade Smooth"].default_value = False
tree.links.new(grid.outputs["Mesh"], iop.inputs["Points"])
tree.links.new(cube.outputs["Mesh"], iop.inputs["Instance"])
tree.links.new(iop.outputs["Instances"], realize.inputs["Geometry"])
tree.links.new(realize.outputs["Geometry"], xform.inputs["Geometry"])
tree.links.new(xform.outputs["Geometry"], shade.inputs["Geometry"])
out_socket = shade.outputs["Geometry"]
if material is not None:
set_mat = tree.nodes.new('GeometryNodeSetMaterial')
set_mat.inputs["Material"].default_value = material
tree.links.new(out_socket, set_mat.inputs["Geometry"])
out_socket = set_mat.outputs["Geometry"]
tree.links.new(out_socket, go.inputs["Geometry"])
return tree
def build():
bpy.ops.wm.read_factory_settings(use_empty=True)
# carrier mesh is unused by the generative tree; one vertex is enough
me = bpy.data.meshes.new("Carrier")
me.vertices.add(1)
obj = bpy.data.objects.new("InstanceGrid", me)
bpy.context.collection.objects.link(obj)
mat = bpy.data.materials.new("Lime")
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.22, 0.95, 0.06, 1.0) # lime
bsdf.inputs["Roughness"].default_value = 0.22
tree = build_instance_grid_tree(material=mat)
mod = obj.modifiers.new("instance_grid", 'NODES')
mod.node_group = tree
return obj, mat
def check(obj):
base = len(obj.data.vertices)
if base != 1:
print(f"ERROR: carrier should have 1 vertex, got {base}", file=sys.stderr)
return 3
dg = bpy.context.evaluated_depsgraph_get()
ev = obj.evaluated_get(dg)
em = ev.to_mesh()
try:
got_v = len(em.vertices)
got_f = len(em.polygons)
mat_names = [m.name for m in em.materials if m is not None]
# corner instance: the 8 verts nearest CORNER_CENTER should average to it
corner = [v.co for v in em.vertices
if (v.co.x > GRID_HALF - CUBE_SIZE and v.co.y > GRID_HALF - CUBE_SIZE)]
if len(corner) != CUBE_VERTS:
print(f"ERROR: corner instance has {len(corner)} verts, expected {CUBE_VERTS}",
file=sys.stderr)
return 4
cx = sum(c.x for c in corner) / CUBE_VERTS
cy = sum(c.y for c in corner) / CUBE_VERTS
cz = sum(c.z for c in corner) / CUBE_VERTS
finally:
ev.to_mesh_clear()
if got_v != EXPECT_VERTS or got_f != EXPECT_FACES:
print(f"ERROR: evaluated topology verts={got_v} faces={got_f} != "
f"expected verts={EXPECT_VERTS} faces={EXPECT_FACES}",
file=sys.stderr)
return 5
if "Lime" not in mat_names:
print(f"ERROR: Set Material did not carry Lime onto evaluated mesh "
f"(materials={mat_names})", file=sys.stderr)
return 6
for got, exp, axis in ((cx, CORNER_CENTER[0], 'x'),
(cy, CORNER_CENTER[1], 'y'),
(cz, CORNER_CENTER[2], 'z')):
if abs(got - exp) > 1e-3:
print(f"ERROR: corner center {axis}={got:.4f} != {exp:.4f}",
file=sys.stderr)
return 7
print(f"grid={GRID_X}x{GRID_Y} points={GRID_POINTS} "
f"eval_verts={got_v} eval_faces={got_f} "
f"corner=({cx:.2f},{cy:.2f},{cz:.2f}) material=Lime")
return 0
def eevee_engine_id():
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
def render_still(obj, path, engine):
scene = bpy.context.scene
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
try:
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
bm.to_mesh(floor_me)
finally:
bm.free()
fmat = bpy.data.materials.new("Studio")
fmat.use_nodes = True
fb = fmat.node_tree.nodes["Principled BSDF"]
fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
fb.inputs["Roughness"].default_value = 0.7
floor_me.materials.append(fmat)
floor = bpy.data.objects.new("Floor", floor_me)
scene.collection.objects.link(floor)
wall = bpy.data.objects.new("Wall", floor_me.copy())
wall.location = (0.0, 9.0, 0.0)
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
scene.collection.objects.link(wall)
world = bpy.data.worlds.new("World")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
scene.world = world
# tip the grid so depth reads; cubes already rest on the floor via the tree
obj.rotation_euler = (0.0, 0.0, math.radians(24))
aim = bpy.data.objects.new("Aim", None)
aim.location = (0.0, 0.0, CUBE_SIZE / 2)
scene.collection.objects.link(aim)
def light(name, loc, energy, size, col):
ld = bpy.data.lights.new(name, 'AREA')
ld.energy = energy
ld.size = size
ld.color = col
ob = bpy.data.objects.new(name, ld)
ob.location = loc
scene.collection.objects.link(ob)
lc = ob.constraints.new('TRACK_TO')
lc.target = aim
lc.track_axis = 'TRACK_NEGATIVE_Z'
lc.up_axis = 'UP_Y'
# warm shaped key, faint cool fill, warm wedge on the back wall
# (docs/VISUAL-STYLE.md)
light("Key", (-3.5, -4.5, 5.5), 520.0, 4.5, (1.0, 0.96, 0.9))
light("Fill", (5.0, -3.5, 2.5), 110.0, 8.0, (0.75, 0.85, 1.0))
wedge = bpy.data.lights.new("Wedge", 'AREA')
wedge.energy = 400.0
wedge.size = 6.0
wedge.color = (1.0, 0.76, 0.5)
wob = bpy.data.objects.new("Wedge", wedge)
wob.location = (2.5, 5.5, 4.0)
wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190))
scene.collection.objects.link(wob)
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
cam.location = (3.7, -4.3, 2.7)
scene.collection.objects.link(cam)
scene.camera = cam
track = cam.constraints.new('TRACK_TO')
track.target = aim
track.track_axis = 'TRACK_NEGATIVE_Z'
track.up_axis = 'UP_Y'
scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
if engine == 'cycles':
scene.cycles.samples = 32
else:
try:
scene.eevee.taa_render_samples = 64
except AttributeError:
pass
scene.render.resolution_x = 1280
scene.render.resolution_y = 720
scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = path
# AgX would wash the Lime instances toward sage (docs/VISUAL-STYLE.md)
scene.view_settings.view_transform = 'Standard'
bpy.ops.render.render(write_still=True)
return os.path.exists(path) and os.path.getsize(path) > 0
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
p = argparse.ArgumentParser()
p.add_argument("--output", default=None, help="optional: render a still PNG here")
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
help="render engine for --output (cycles for GPU-less hosts)")
args = p.parse_args(argv)
obj, _mat = build()
code = check(obj)
if code:
return code
if args.output:
if not render_still(obj, os.path.abspath(args.output), args.engine):
print("ERROR: render produced no file", file=sys.stderr)
return 8
print(f"rendered still {args.output}")
print("gn-instance-grid OK")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)