Skip to content

Commit a31a2b1

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign usd-export-evaluation-mode as re-imported brass goblets
The still used to show two steel balls built from the live SUBSURF modifier, which depicted the two qualities rather than the files. The --output path now builds a turned goblet on an 8-sided lathe cage (SUBSURF levels=0, render_levels=3) and exports it twice through the same wm.usd_export call the check proves (TESSELLATE + VIEWPORT and TESSELLATE + RENDER). It re-imports both USDA files with wm.usd_import and stages the imported meshes on turned plinths. Both are flat-shaded with one brass material, so geometry is the only difference. The placards read VIEWPORT / RENDER plus the point count measured on each re-imported mesh, set in the bundled DejaVu Sans Mono (Inter is a variable font whose overlapping contours render with holes on 4.5). The render path also asserts that the re-imported RENDER mesh is denser than the VIEWPORT one (exit 6). The cube check is unchanged. Evidence (live runs, local): - E:\Blender-Developer-Tools\.scratch\blender-5.2.1-windows-x64\blender.exe (Blender 5.2.1 LTS): check-only exit 0, base=8 usda_vp=26/24 usda_rd=98/96 usda_cage=8 scheme_cage=catmullClark. --evaluation-mode VIEWPORT exits 4 ("RENDER TESSELLATE wrote 26 points / 24 faces"). --output: goblet usda points viewport=130 render=8450. - E:\Blender-Developer-Tools\.scratch\blender-4.5.11-windows-x64\blender.exe (Blender 4.5.11 LTS): check-only exit 0 with the same numbers; --output renders the same still (130 / 8450). - Framing: fill x=0.731 y=0.739; margins l=0.119 r=0.128 b=0.089 t=0.133. - Contact sheet (docs/gallery/contact-sheets/usd-export-evaluation-mode-contact-sheet.webp): candidate luma 0.253 / stage 0.233 / warmth +0.081 / sat 0.132, against armature-bend 0.243/0.192/-0.144/0.237, damped-track-aim 0.237/0.218/+0.105/0.178, and bmesh-gear 0.236/0.177/+0.303/0.250. Luma, stage and warmth are in range; saturation is the lowest, since the subject is brass on grey stone, but the silhouettes read at thumbnail size. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent d9e6511 commit a31a2b1

7 files changed

Lines changed: 236 additions & 52 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ three. `--assume-distinct-names` is red only on 5.2.
477477
</tr>
478478
<tr>
479479
<td width="46%" valign="middle">
480-
<a href="examples/usd-export-evaluation-mode/"><img src="examples/usd-export-evaluation-mode/preview.webp" alt="USD export evaluation_mode: two steel SUBSURF cubes resting on a dark studio floor — left faceted viewport tessellation, right smoother render tessellation, warm wedge on the back wall" /></a>
480+
<a href="examples/usd-export-evaluation-mode/"><img src="examples/usd-export-evaluation-mode/preview.webp" alt="USD export evaluation_mode: two brass goblets on dark plinths, each re-imported from its own USDA file — left an eight-sided 130-point VIEWPORT export, right a smooth 8450-point RENDER export" /></a>
481481
</td>
482482
<td valign="middle">
483483

15.9 KB
Loading
4.07 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@
645645
"name": "usd-export-evaluation-mode",
646646
"dir": "examples/usd-export-evaluation-mode",
647647
"teaches": "The USD exporter evaluation_mode chooses viewport versus render modifier quality. TESSELLATE makes the split observable; BEST_MATCH writes the cage and the mode is silent.",
648-
"alt": "Two steel balls resting on the floor: a coarse, flat-shaded one on the left and a smooth, subdivided one on the right.",
648+
"alt": "Two brass goblets on dark plinths, each re-imported from its own USDA export: an eight-sided 130-point goblet labelled VIEWPORT on the left and a smooth 8450-point goblet labelled RENDER on the right.",
649649
"witnessesFix": "TESSELLATE+VIEWPORT writes 26/24; TESSELLATE+RENDER writes 98/96 subdivisionScheme=none; BEST_MATCH writes the 8-vert catmullClark cage. --evaluation-mode VIEWPORT and --subdivision BEST_MATCH exit 4.",
650650
"hero": "docs/gallery/assets/usd-export-evaluation-mode-hero.webp",
651651
"preview": "examples/usd-export-evaluation-mode/preview.webp",

‎examples/usd-export-evaluation-mode/README.md‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,19 @@ RENDER writes 98 / 96. Default `export_subdivision='BEST_MATCH'` writes the
1212
silent — both files are the cage. TESSELLATE is what makes the mode
1313
observable.
1414

15-
The still is numeric-adjacent: left is viewport tessellation (flat-shaded
16-
L1), right is render tessellation (smoothed L2). The USDA point counts are
17-
the evidence; the two balls depict the two qualities the exporter chooses
18-
between.
15+
The still shows the exported files themselves. The `--output` path builds a
16+
turned goblet on an 8-sided lathe cage with SUBSURF `levels=0` /
17+
`render_levels=3`, exports it twice through the same `wm.usd_export` call
18+
the check proves (TESSELLATE + VIEWPORT, TESSELLATE + RENDER), re-imports
19+
both USDA files with `wm.usd_import`, and stages the two imported meshes.
20+
Left is the VIEWPORT file (the raw 8-sided cage, 130 points), right the
21+
RENDER file (8450 points). Both are flat-shaded with the same material, so
22+
geometry is the only difference, and the point count on each placard is
23+
read from the re-imported mesh at render time. The goblet widens the
24+
cube's L1/L2 split to L0/L3 so the difference reads at thumbnail size. The
25+
render path also asserts that the re-imported RENDER goblet is denser than
26+
the VIEWPORT one (exit 6). Placards use the bundled DejaVu Sans Mono, whose
27+
`1` cannot be misread as `I`.
1928

2029
**What failure each check would catch:**
2130

@@ -26,11 +35,9 @@ between.
2635
- exit 5 — BEST_MATCH stopped writing the cage
2736
- exit 6 — RENDER and VIEWPORT files are identical
2837

29-
Staging: Catmull-Clark pulls the surface inside the 2 m cage, and level 1
30-
and level 2 shrink by different amounts, so both balls used to hover about
31-
0.16 m over the floor at the cage's z = 1.0. The render path now rests each
32-
one on the floor from its own evaluated vertices (measured zmin 0.000000
33-
for both), after the check has run.
38+
Staging: Catmull-Clark pulls the surface inside the cage, and each level
39+
shrinks it by a different amount, so each re-imported goblet is seated on
40+
its plinth from its own vertices rather than from the cage's z.
3441

3542
Probed on the CI Linux portables **Blender 5.2.1 LTS** (`9e2066aef7ef`) and
3643
**Blender 4.5.13 LTS** (`daeeeca98fb0`): `wm.usd_export` exists, `poll()` is
@@ -58,7 +65,7 @@ against it. `10` is the shared framing helper.
5865
| 3 | VIEWPORT TESSELLATE point/face count off closed form |
5966
| 4 | RENDER TESSELLATE point/face count or scheme off closed form |
6067
| 5 | BEST_MATCH cage point count or scheme off |
61-
| 6 | RENDER and VIEWPORT USDA point counts are identical |
68+
| 6 | RENDER and VIEWPORT USDA point counts are identical (check), or the re-imported RENDER goblet is not denser than the VIEWPORT one (`--output`) |
6269
| 10 | Gallery framing violation |
6370
| 12 | `--output` produced no file |
6471

15.5 KB
Loading

‎examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py‎

Lines changed: 217 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
blender --background --python usd_export_evaluation_mode.py -- --output u.png
1919
"""
2020
import bpy, bmesh, sys, os, math, argparse, tempfile
21-
from mathutils import Vector
21+
from mathutils import Matrix, Vector
2222

2323
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
2424
sys.dont_write_bytecode = True
@@ -199,43 +199,214 @@ def principled(name, color, metallic, roughness):
199199
return mat
200200

201201

202+
# Gallery subject: a turned goblet whose coarse lathe cage (GOBLET_SEGS
203+
# around) carries the same SUBSURF split as the check's cube. The render path
204+
# exports it twice through the same exporter call the check proves, then
205+
# re-imports both USDA files — the still shows the files, not the modifier.
206+
GOBLET_SEGS = 8
207+
# The goblet widens the gap the check proves on the cube (levels 1 vs 2): an
208+
# 8-sided cage at viewport level 0 against render level 3, so the difference
209+
# between the two files reads at thumbnail size.
210+
GOBLET_VP = 0
211+
GOBLET_RD = 3
212+
GOBLET_PROFILE = [ # (r, z) bottom -> rim outside -> rim inside -> well
213+
(0.0, 0.0), (0.62, 0.0), (0.66, 0.06), (0.52, 0.14), (0.20, 0.26),
214+
(0.14, 0.52), (0.24, 0.64), (0.13, 0.76), (0.15, 1.02), (0.46, 1.16),
215+
(0.72, 1.46), (0.80, 1.86), (0.84, 2.14), (0.78, 2.16),
216+
(0.72, 1.88), (0.62, 1.52), (0.40, 1.30), (0.0, 1.24),
217+
]
218+
219+
220+
def build_goblet():
221+
me = bpy.data.meshes.new("Goblet")
222+
bm = bmesh.new()
223+
try:
224+
rings = []
225+
for r, z in GOBLET_PROFILE:
226+
if r == 0.0:
227+
rings.append([bm.verts.new((0.0, 0.0, z))])
228+
else:
229+
rings.append([bm.verts.new((r * math.cos(2 * math.pi * s / GOBLET_SEGS),
230+
r * math.sin(2 * math.pi * s / GOBLET_SEGS), z))
231+
for s in range(GOBLET_SEGS)])
232+
for a, b in zip(rings, rings[1:]):
233+
for s in range(GOBLET_SEGS):
234+
t = (s + 1) % GOBLET_SEGS
235+
if len(a) == 1:
236+
bm.faces.new((a[0], b[t], b[s]))
237+
elif len(b) == 1:
238+
bm.faces.new((a[s], a[t], b[0]))
239+
else:
240+
bm.faces.new((a[s], a[t], b[t], b[s]))
241+
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
242+
bm.to_mesh(me)
243+
finally:
244+
bm.free()
245+
ob = bpy.data.objects.new("Goblet", me)
246+
bpy.context.collection.objects.link(ob)
247+
mod = ob.modifiers.new("Subsurf", "SUBSURF")
248+
mod.levels = GOBLET_VP
249+
mod.render_levels = GOBLET_RD
250+
return ob
251+
252+
253+
def label_font():
254+
"""DejaVu Sans Mono ships in datafiles/fonts on 4.5 and 5.2, and its 1 never
255+
reads as I. (Inter ships too, but it is a variable font whose overlapping
256+
contours render with holes on 4.5.)"""
257+
# system_resource resolves directories only; a file path returns ''
258+
fonts = bpy.utils.system_resource("DATAFILES", path="fonts")
259+
path = os.path.join(fonts, "DejaVuSansMono.woff2") if fonts else ""
260+
if path and os.path.exists(path):
261+
try:
262+
return bpy.data.fonts.load(path, check_existing=True)
263+
except RuntimeError:
264+
pass
265+
return None
266+
267+
268+
def import_usda(path):
269+
before = set(bpy.data.objects)
270+
if bpy.ops.wm.usd_import(filepath=path) != {"FINISHED"}:
271+
raise RuntimeError(f"usd_import failed for {path}")
272+
meshes = [o for o in set(bpy.data.objects) - before if o.type == "MESH"]
273+
if len(meshes) != 1:
274+
raise RuntimeError(f"expected one mesh from {path}, got {len(meshes)}")
275+
ob = meshes[0]
276+
# bake whatever Xform chain the importer built into the mesh, so staging
277+
# below starts from identity like any other prop
278+
mw = ob.matrix_world.copy()
279+
ob.parent = None
280+
ob.data.transform(mw)
281+
ob.matrix_world = Matrix.Identity(4)
282+
return ob
283+
284+
285+
def text_plate(name, lines, loc, mat_plate, mat_ink, scene):
286+
"""A bevelled brass plate leaning back on the floor with engraved text."""
287+
pme = bpy.data.meshes.new(name)
288+
bm = bmesh.new()
289+
try:
290+
bmesh.ops.create_cube(bm, size=1.0)
291+
for v in bm.verts:
292+
v.co.x *= 1.7
293+
v.co.y *= 0.06
294+
v.co.z *= 0.52
295+
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=0.015, segments=2,
296+
affect="EDGES", clamp_overlap=True)
297+
bm.to_mesh(pme)
298+
finally:
299+
bm.free()
300+
pme.materials.append(mat_plate)
301+
plate = bpy.data.objects.new(name, pme)
302+
plate.location = loc
303+
plate.rotation_euler = (math.radians(-14), 0.0, 0.0)
304+
scene.collection.objects.link(plate)
305+
objs = [plate]
306+
for i, (body, size) in enumerate(lines):
307+
cu = bpy.data.curves.new(f"{name}.T{i}", "FONT")
308+
cu.body = body
309+
cu.size = size
310+
cu.align_x = "CENTER"
311+
cu.align_y = "CENTER"
312+
cu.extrude = 0.004
313+
cu.materials.append(mat_ink)
314+
font = label_font()
315+
if font is not None:
316+
cu.font = font
317+
t = bpy.data.objects.new(f"{name}.T{i}", cu)
318+
t.parent = plate
319+
t.location = (0.0, -0.065, 0.10 if i == 0 else -0.14)
320+
t.rotation_euler = (math.radians(90), 0.0, 0.0)
321+
scene.collection.objects.link(t)
322+
objs.append(t)
323+
return objs
324+
325+
202326
def render_still(obj, path, engine):
203-
"""Left: viewport tessellation (levels=1). Right: render tessellation (levels=2)."""
327+
"""Left: the VIEWPORT USDA re-imported. Right: the RENDER USDA re-imported."""
328+
bpy.ops.wm.read_factory_settings(use_empty=True)
204329
scene = bpy.context.scene
205-
steel = principled("Steel", (0.55, 0.57, 0.60, 1.0), 1.0, 0.58)
206-
207-
left = obj
208-
left.location = (-1.55, 0.0, 1.0)
209-
left.rotation_euler = (0.0, 0.0, math.radians(28))
210-
left.data.materials.append(steel)
211-
left.modifiers["Subsurf"].levels = LEVEL_VP
212-
for poly in left.data.polygons:
213-
poly.use_smooth = False
214-
215-
right_me = left.data.copy()
216-
right = bpy.data.objects.new("IngotRender", right_me)
217-
right.location = (1.55, 0.0, 1.0)
218-
right.rotation_euler = (0.0, 0.0, math.radians(28))
219-
right.data.materials.append(steel)
220-
bpy.context.collection.objects.link(right)
221-
rm = right.modifiers.new("Subsurf", "SUBSURF")
222-
rm.levels = LEVEL_RD
223-
rm.render_levels = LEVEL_RD
224-
for poly in right.data.polygons:
225-
poly.use_smooth = True
226-
# Subdivision pulls the surface inside the 2 m cage, so at z = 1.0 both
227-
# ingots hovered above the floor. Rest each on z = 0 from its own
228-
# evaluated bounds (level 1 and level 2 shrink by different amounts).
229-
bpy.context.view_layer.update()
230-
dg = bpy.context.evaluated_depsgraph_get()
231-
for ob in (left, right):
232-
ev = ob.evaluated_get(dg)
233-
em = ev.to_mesh()
330+
331+
goblet = build_goblet()
332+
out_dir = tempfile.mkdtemp(prefix="bdt_usd_goblet_")
333+
p_vp = os.path.join(out_dir, "goblet_viewport.usda")
334+
p_rd = os.path.join(out_dir, "goblet_render.usda")
335+
export_usda(p_vp, "VIEWPORT", "TESSELLATE")
336+
export_usda(p_rd, "RENDER", "TESSELLATE")
337+
bpy.data.objects.remove(goblet)
338+
left = import_usda(p_vp)
339+
right = import_usda(p_rd)
340+
n_vp, n_rd = len(left.data.vertices), len(right.data.vertices)
341+
print(f"goblet usda points viewport={n_vp} render={n_rd}")
342+
if n_rd <= n_vp:
343+
print("ERROR: re-imported RENDER goblet is not denser than VIEWPORT", file=sys.stderr)
344+
return 6
345+
346+
pewter = principled("Brass", (0.92, 0.64, 0.30, 1.0), 1.0, 0.30)
347+
stone = principled("Plinth", (0.06, 0.062, 0.07, 1.0), 0.0, 0.34)
348+
plate_mat = principled("Plate", (0.035, 0.036, 0.04, 1.0), 0.7, 0.42)
349+
ink = principled("Inlay", (0.95, 0.70, 0.36, 1.0), 1.0, 0.32)
350+
351+
# turned plinths: a stepped drum per goblet, the goblet seated on top
352+
PLINTH_H = 0.62
353+
plinths = []
354+
for x in (-1.75, 1.75):
355+
me = bpy.data.meshes.new("Plinth")
356+
bm = bmesh.new()
234357
try:
235-
zmin = min((ev.matrix_world @ v.co).z for v in em.vertices)
358+
prof = [(0.0, 0.0), (1.12, 0.0), (1.16, 0.04), (1.16, 0.16), (1.10, 0.20),
359+
(1.02, 0.22), (1.02, PLINTH_H - 0.06), (1.06, PLINTH_H - 0.03),
360+
(1.06, PLINTH_H), (0.0, PLINTH_H)]
361+
rings = []
362+
for r, z in prof:
363+
rings.append([bm.verts.new((0.0, 0.0, z))] if r == 0.0 else
364+
[bm.verts.new((r * math.cos(2 * math.pi * s / 64),
365+
r * math.sin(2 * math.pi * s / 64), z))
366+
for s in range(64)])
367+
for a, b in zip(rings, rings[1:]):
368+
for s in range(64):
369+
t = (s + 1) % 64
370+
if len(a) == 1:
371+
f = bm.faces.new((a[0], b[t], b[s]))
372+
elif len(b) == 1:
373+
f = bm.faces.new((a[s], a[t], b[0]))
374+
else:
375+
f = bm.faces.new((a[s], a[t], b[t], b[s]))
376+
f.smooth = True
377+
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
378+
bm.to_mesh(me)
236379
finally:
237-
ev.to_mesh_clear()
238-
ob.location.z -= zmin
380+
bm.free()
381+
me.set_sharp_from_angle(angle=math.radians(40.0))
382+
me.materials.append(stone)
383+
pl = bpy.data.objects.new("Plinth", me)
384+
pl.location = (x, 0.0, 0.0)
385+
scene.collection.objects.link(pl)
386+
plinths.append(pl)
387+
388+
# Both goblets are shaded identically (flat) — the only difference in the
389+
# picture is the geometry each USDA file carries.
390+
for ob, x in ((left, -1.75), (right, 1.75)):
391+
ob.data.materials.clear()
392+
ob.data.materials.append(pewter)
393+
for poly in ob.data.polygons:
394+
poly.use_smooth = False
395+
ob.location = (x, 0.0, 0.0)
396+
ob.rotation_euler = (0.0, 0.0, math.radians(11.25))
397+
ob.scale = (1.0, 1.0, 1.0)
398+
# Subdivision pulls the surface inside the cage by a level-dependent
399+
# amount, so each goblet is seated on its plinth from its own verts.
400+
bpy.context.view_layer.update()
401+
for ob in (left, right):
402+
zmin = min((ob.matrix_world @ v.co).z for v in ob.data.vertices)
403+
ob.location.z += PLINTH_H - zmin
404+
405+
labels = []
406+
labels += text_plate("PlacardVP", [("VIEWPORT", 0.30), (f"{n_vp} points", 0.17)],
407+
(-1.75, -1.45, 0.30), plate_mat, ink, scene)
408+
labels += text_plate("PlacardRD", [("RENDER", 0.30), (f"{n_rd} points", 0.17)],
409+
(1.75, -1.45, 0.30), plate_mat, ink, scene)
239410

240411
floor_me = bpy.data.meshes.new("Floor")
241412
bm = bmesh.new()
@@ -269,18 +440,23 @@ def light(name, loc, energy, size, col, rot):
269440
ob.rotation_euler = tuple(math.radians(a) for a in rot)
270441
scene.collection.objects.link(ob)
271442

272-
light("Key", (-4.0, -5.0, 6.0), 420.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35))
273-
light("Fill", (5.0, -3.5, 3.0), 180.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
274-
light("Wedge", (2.5, 5.5, 4.0), 360.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
443+
light("Key", (-4.0, -5.0, 6.0), 520.0, 3.5, (1.0, 0.95, 0.88), (46, 0, -35))
444+
light("Fill", (5.0, -3.5, 3.0), 90.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
445+
light("Rim", (0.0, 4.0, 5.5), 260.0, 4.0, (0.62, 0.78, 1.0), (-42, 0, 180))
446+
light("Wedge", (2.5, 5.5, 4.0), 520.0, 6.0, (1.0, 0.72, 0.45), (-68, 0, 190))
447+
# a broad warm card low and front-left: polished brass mirrors its
448+
# surroundings, and on a near-black stage it otherwise reads olive-dark.
449+
# The card's reflection is also what draws each facet on the viewport cup.
450+
light("Card", (-3.2, -4.2, 2.0), 380.0, 5.0, (1.0, 0.82, 0.6), (72, 0, -38))
275451

276452
aim = bpy.data.objects.new("Aim", None)
277-
aim.location = (0.0, 0.0, 0.78)
453+
aim.location = (0.0, 0.0, 1.25)
278454
aim.hide_render = True
279455
scene.collection.objects.link(aim)
280456
cam_data = bpy.data.cameras.new("Cam")
281457
cam_data.lens = 50.0
282458
cam = bpy.data.objects.new("Cam", cam_data)
283-
cam.location = (4.16, -5.85, 3.00)
459+
cam.location = (1.05, -10.6, 3.7)
284460
scene.collection.objects.link(cam)
285461
scene.camera = cam
286462
track = cam.constraints.new("TRACK_TO")
@@ -303,7 +479,8 @@ def light(name, loc, energy, size, col, rot):
303479
scene.view_settings.view_transform = "Standard"
304480

305481
fcode = gallery_framing.check_framing(
306-
scene, cam, hero=[left, right], elements=[left, right], stage=[floor, wall],
482+
scene, cam, hero=[left, right] + plinths, elements=[left, right] + plinths + labels,
483+
stage=[floor, wall],
307484
)
308485
if fcode:
309486
return fcode

0 commit comments

Comments
 (0)