-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdepsgraph_export.py
More file actions
231 lines (198 loc) · 8.84 KB
/
Copy pathdepsgraph_export.py
File metadata and controls
231 lines (198 loc) · 8.84 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
"""Depsgraph-evaluated export — a runnable example.
Witnesses the depsgraph lifetime contract AND that modifiers actually ship in
exports. Builds a cube with a SUBSURF modifier, measures the evaluated mesh via
evaluated_get().to_mesh() (paired with to_mesh_clear()), exports through
wm.obj_export, and asserts the exported vertex count equals the EVALUATED
(modifier-applied) count and is strictly greater than the base.
By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:
blender --background --python depsgraph_export.py -- # check only
blender --background --python depsgraph_export.py -- --output d.png # + render
"""
import bpy, bmesh, sys, os, math, argparse, tempfile
# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
import gallery_framing
def build():
bpy.ops.wm.read_factory_settings(use_empty=True)
me = bpy.data.meshes.new("Cube")
bm = bmesh.new()
try:
bmesh.ops.create_cube(bm, size=2.0)
bm.to_mesh(me)
finally:
bm.free()
obj = bpy.data.objects.new("Cube", me)
bpy.context.collection.objects.link(obj)
obj.modifiers.new("ss", 'SUBSURF').levels = 2
return obj
def check(obj, obj_path):
base = len(obj.data.vertices)
# depsgraph lifetime contract: evaluate, read, then release with to_mesh_clear
dg = bpy.context.evaluated_depsgraph_get()
ev = obj.evaluated_get(dg)
em = ev.to_mesh()
eval_vcount = len(em.vertices)
ev.to_mesh_clear() # must be paired; releases the temporary mesh
out = obj_path or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj")
os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True)
# obj_export writes the evaluated (modifier-applied) geometry by default
bpy.ops.wm.obj_export(filepath=out, export_selected_objects=False)
if not (os.path.exists(out) and os.path.getsize(out) > 0):
print("ERROR: no OBJ written", file=sys.stderr)
return 4
exported = 0
with open(out) as f:
for line in f:
if line.startswith("v "):
exported += 1
print(f"base_vcount={base} eval_vcount={eval_vcount} exported_vcount={exported}")
if not (eval_vcount > base):
print("ERROR: evaluated mesh did not apply the modifier", file=sys.stderr)
return 3
if exported != eval_vcount:
print(f"ERROR: export ({exported}) != evaluated ({eval_vcount}); modifier did not ship",
file=sys.stderr)
return 5
return 0
def eevee_engine_id():
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
def principled(name, color, metallic, roughness):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
return mat
def render_still(obj, path, engine):
"""Base cube beside its evaluated form — the two counts the check compares."""
scene = bpy.context.scene
# one hero material on BOTH objects: the two silhouettes share a color so
# the only visible difference is the thing the check measures — base-mesh
# geometry versus the depsgraph-evaluated subsurf
hero = principled("Teal", (0.012, 0.30, 0.34, 1.0), 0.0, 0.2)
# left: the base mesh, modifier-free (a plain copy of the datablock)
base_obj = bpy.data.objects.new("Base", obj.data.copy())
base_obj.location = (-1.7, 0.0, 1.0)
# turned off camera-parallel so the key rakes the front face into a
# gradient instead of one flat tone
base_obj.rotation_euler = (0.0, 0.0, math.radians(32))
base_obj.data.materials.append(hero)
bpy.context.collection.objects.link(base_obj)
# right: the SUBSURF object — what the depsgraph evaluates and the OBJ ships.
# Rest it on the floor by measuring its own EVALUATED bounds (the subsurf
# limit surface shrinks, so the base-mesh -1.0 is not where it ends).
dg = bpy.context.evaluated_depsgraph_get()
em = obj.evaluated_get(dg).to_mesh()
bottom = min(v.co.z for v in em.vertices)
obj.evaluated_get(dg).to_mesh_clear()
obj.location = (1.8, 0.0, -bottom)
obj.data.materials.append(hero)
for poly in obj.data.polygons:
poly.use_smooth = True
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()
floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
floor = bpy.data.objects.new("Floor", floor_me)
scene.collection.objects.link(floor)
wall = bpy.data.objects.new("Wall", floor_me.copy())
wall.data.materials.clear()
wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
wall.location = (0.0, 9.0, 0.0)
wall.rotation_euler = (math.pi / 2, 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
def light(name, loc, energy, size, col, rot):
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
ob.rotation_euler = tuple(math.radians(a) for a in rot)
scene.collection.objects.link(ob)
# warm shaped key, faint cool fill, warm wedge on the back wall
# (docs/VISUAL-STYLE.md)
light("Key", (-4.0, -5.0, 6.0), 650.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35))
light("Fill", (5.0, -3.5, 3.0), 120.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
# Reframed: the rotated base cube spans ~5.9 units with the evaluated
# sphere, wider than the old (0,-7.4,3.0) frame — the cube was amputated
# at the left edge. Aim-based framing on the pair's center.
cam.location = (0.0, -9.6, 2.6)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
aim.location = (0.0, 0.0, 0.9)
scene.collection.objects.link(aim)
con = cam.constraints.new('TRACK_TO')
con.target = aim
con.track_axis = 'TRACK_NEGATIVE_Z'
con.up_axis = 'UP_Y'
scene.camera = cam
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 flatten the graphite-vs-green contrast this still hinges on
# (docs/VISUAL-STYLE.md)
scene.view_settings.view_transform = 'Standard'
# Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
# the beauty render so a defective composition ships no artifact.
fcode = gallery_framing.check_framing(
scene, cam,
hero=[base_obj, obj],
elements=[base_obj, obj],
stage=[floor, wall],
)
if fcode:
return fcode
bpy.ops.render.render(write_still=True)
if not (os.path.exists(path) and os.path.getsize(path) > 0):
print("ERROR: render produced no file", file=sys.stderr)
return 6
return 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)")
p.add_argument("--obj", default=None,
help="optional: write the exported OBJ here (else a temp path)")
args = p.parse_args(argv)
obj = build()
code = check(obj, args.obj)
if code:
return code
if args.output:
rcode = render_still(obj, os.path.abspath(args.output), args.engine)
if rcode:
return rcode
print(f"rendered still {args.output}")
print("depsgraph-export 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)