Skip to content

Commit 0cd13c5

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign gn-sdf-remesh as a kitbashed vase fused by the SDF tree
The still showed two red tori under wire cages. Now a vase is kitbashed from overlapping primitives (foot, belly, neck, lip, two handles) in five clay tones under a cage of its own ring-topology edges. Beside it the identical kit runs through the same build_remesh_via_sdf tree (0.018 voxels, cobalt glaze carried by Set Material) and comes out one fused, filleted, watertight shell. Both stand on walnut bats. The torus the check measures leaves the stage first; the check, the tree and the falsifier are unchanged. The render path now runs the gallery_framing gate (exit 10), which it previously skipped. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check-only exit 0, link_valid=True base_vcount=576 eval_vcount=3256 materials=['Ceramic']; --no-sdf exits 3 ("SDF remesh produced no/unchanged geometry"). Render: kit_verts=3170 fused_verts=42260. - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): check + --output exit 0, eval_vcount=3400, fused_verts=42058, framing_ok. - Framing: fill x=0.806 y=0.661; margins l=0.106 r=0.088 b=0.094 t=0.150. - Contact sheet: candidate luma 0.261 / stage 0.229 / warmth +0.126 / sat 0.269, 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. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 128d3be commit 0cd13c5

7 files changed

Lines changed: 171 additions & 65 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ exits 0 on all three. `--assume-present` is red only on 5.2.
561561
<table>
562562
<tr>
563563
<td width="46%" valign="middle">
564-
<a href="examples/gn-sdf-remesh/"><img src="examples/gn-sdf-remesh/preview.webp" alt="Geometry Nodes SDF remesh: a crimson ceramic torus with visible voxel-remesh facets, resting on a dark studio floor" /></a>
564+
<a href="examples/gn-sdf-remesh/"><img src="examples/gn-sdf-remesh/preview.webp" alt="Geometry Nodes SDF remesh: a vase kitbashed from overlapping clay primitives beside the same kit fused by the SDF remesh into one cobalt-glazed shell" /></a>
565565
</td>
566566
<td valign="middle">
567567

-42 KB
Loading
-16.5 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"name": "gn-sdf-remesh",
3535
"dir": "examples/gn-sdf-remesh",
3636
"teaches": "A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.",
37-
"alt": "Two red tori side by side under pale edge cages: the source with even ring topology, and its SDF remesh covered in a dense irregular voxel grid.",
37+
"alt": "Two amphora vases on walnut bats: left, a kit of overlapping clay primitives under their own ring-topology cage; right, the same kit SDF-remeshed into one fused cobalt-glazed shell.",
3838
"witnessesFix": "An SDF grid is meshed with Grid to Mesh, not Volume to Mesh; GN geometry needs Set Material or it renders untextured.",
3939
"hero": "docs/gallery/assets/gn-sdf-remesh-hero.webp",
4040
"preview": "examples/gn-sdf-remesh/preview.webp",

‎examples/gn-sdf-remesh/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ input mesh's material is dropped on remesh (the result renders with the default
1616
it inside the tree with a **Set Material** node (`GeometryNodeSetMaterial`) on the remeshed
1717
output — this example does that and asserts the material survives onto the evaluated mesh.
1818

19+
## Render
20+
21+
The check measures a torus; the still proves the same `build_remesh_via_sdf` tree on a
22+
subject where remeshing is visible. A vase is kitbashed from overlapping primitives (foot,
23+
belly, neck, lip, two handles), each in its own clay tone under a thin cage of its own edges,
24+
so the interior faces and hard intersection seams read. Beside it the identical kit runs
25+
through the tree (finer 0.018 voxels, cobalt glaze carried in by Set Material) and comes out
26+
one fused, watertight shell with the handles filleted into the belly. The torus leaves the
27+
stage before the render; the render path gates framing through `examples/gallery_framing.py`
28+
(exit 10).
29+
1930
## Run
2031

2132
```bash
@@ -44,6 +55,7 @@ against it.
4455
| 4 | `--output` produced no file |
4556
| 5 | Wrong-era EEVEE engine id was accepted |
4657
| 6 | Input material dropped by remesh |
58+
| 10 | Framing gate violation on the `--output` path (`gallery_framing`) |
4759

4860
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
4961
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).

‎examples/gn-sdf-remesh/gn_sdf_remesh.py‎

Lines changed: 157 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
"""
1818
import bpy, sys, os, math, argparse
1919

20+
# Shared Layer 1 framing measurement (render path only) -- see
21+
# gallery_framing.py for the __file__-relative import shim this relies on.
22+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
23+
import gallery_framing # noqa: E402
24+
2025
def get_eevee_engine_id():
2126
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
2227

@@ -55,9 +60,125 @@ def build():
5560
obj.data.materials.append(mat)
5661
return obj
5762

63+
def build_vase_kit(name):
64+
"""Render-only input: a vase blocked out the way a modeller kitbashes it --
65+
foot, belly, neck, lip and two handles as separate primitives that simply
66+
overlap, joined into one mesh with interior faces and hard intersection
67+
seams everywhere. Each part keeps its own bisque tone so the seams read.
68+
The same `build_remesh_via_sdf` tree then fuses it into one watertight
69+
shell: the thing an SDF remesh is actually for."""
70+
tones = [("Clay.Foot", (0.30, 0.13, 0.07)), ("Clay.Belly", (0.62, 0.30, 0.15)),
71+
("Clay.Neck", (0.78, 0.62, 0.42)), ("Clay.Lip", (0.86, 0.78, 0.62)),
72+
("Clay.Handle", (0.45, 0.20, 0.10))]
73+
mats = []
74+
for mname, rgb in tones:
75+
m = bpy.data.materials.new(mname); m.use_nodes = True
76+
mb = m.node_tree.nodes.get('Principled BSDF')
77+
mb.inputs['Base Color'].default_value = (*rgb, 1)
78+
mb.inputs['Roughness'].default_value = 0.85 # unfired clay: matte
79+
mats.append(m)
80+
parts = []
81+
82+
def add(op, mat_i, **kw):
83+
op(**kw)
84+
ob = bpy.context.active_object
85+
ob.data.materials.append(mats[mat_i])
86+
for poly in ob.data.polygons:
87+
poly.use_smooth = True
88+
parts.append(ob)
89+
return ob
90+
91+
add(bpy.ops.mesh.primitive_cylinder_add, 0, vertices=48, radius=0.42, depth=0.22,
92+
location=(0, 0, 0.11))
93+
belly = add(bpy.ops.mesh.primitive_uv_sphere_add, 1, segments=48, ring_count=24,
94+
radius=0.72, location=(0, 0, 0.82))
95+
belly.scale = (1.0, 1.0, 0.86)
96+
add(bpy.ops.mesh.primitive_cylinder_add, 2, vertices=40, radius=0.27, depth=0.72,
97+
location=(0, 0, 1.62))
98+
add(bpy.ops.mesh.primitive_torus_add, 3, major_segments=48, minor_segments=16,
99+
major_radius=0.30, minor_radius=0.075, location=(0, 0, 1.98))
100+
for sx in (-1.0, 1.0):
101+
add(bpy.ops.mesh.primitive_torus_add, 4, major_segments=40, minor_segments=14,
102+
major_radius=0.30, minor_radius=0.065, location=(sx * 0.58, 0, 1.36),
103+
rotation=(math.radians(90), 0, 0))
104+
with bpy.context.temp_override(active_object=parts[0], selected_editable_objects=parts,
105+
selected_objects=parts):
106+
bpy.ops.object.join()
107+
kit = parts[0]
108+
kit.name = name
109+
return kit
110+
111+
58112
def render_still(obj, path, engine):
59113
import bmesh
60114
sc = bpy.context.scene
115+
# The torus the check measured leaves the stage; the render proves the
116+
# same tree on a subject where fusing is visible.
117+
bpy.context.collection.objects.unlink(obj)
118+
119+
kit = build_vase_kit("VaseKit")
120+
glaze = bpy.data.materials.new("Glaze"); glaze.use_nodes = True
121+
g = glaze.node_tree.nodes.get('Principled BSDF')
122+
g.inputs['Base Color'].default_value = (0.03, 0.16, 0.30, 1) # cobalt glaze
123+
g.inputs['Roughness'].default_value = 0.12
124+
try:
125+
g.inputs['Coat Weight'].default_value = 0.6
126+
except KeyError:
127+
pass
128+
fused = bpy.data.objects.new("VaseFused", kit.data.copy())
129+
bpy.context.collection.objects.link(fused)
130+
# Same builder, finer voxels than the check's 0.1 so the fused shell
131+
# holds the handles' profile; the glaze rides in on Set Material.
132+
tree, _ok = build_remesh_via_sdf(voxel_size=0.018, material=glaze)
133+
fused.modifiers.new("sdf", 'NODES').node_group = tree
134+
kit.location.x = -1.45
135+
fused.location.x = 1.45
136+
dg = bpy.context.evaluated_depsgraph_get()
137+
em = fused.evaluated_get(dg).to_mesh()
138+
fused_v = len(em.vertices)
139+
zmin = min(v.co.z for v in em.vertices)
140+
fused.evaluated_get(dg).to_mesh_clear()
141+
fused.location.z = -zmin + 0.12
142+
kit.location.z = -min(v.co.z for v in kit.data.vertices) + 0.12
143+
# the kit's own edges as a thin dark cage: UV rings and torus loops
144+
# running straight through each other, the topology the remesh replaces
145+
cage_mat = bpy.data.materials.new("Cage"); cage_mat.use_nodes = True
146+
cm = cage_mat.node_tree.nodes.get('Principled BSDF')
147+
cm.inputs['Base Color'].default_value = (0.05, 0.03, 0.02, 1)
148+
cm.inputs['Roughness'].default_value = 0.6
149+
cage = bpy.data.objects.new("VaseKitCage", kit.data.copy())
150+
cage.data.materials.clear(); cage.data.materials.append(cage_mat)
151+
for poly in cage.data.polygons:
152+
poly.material_index = 0
153+
cage.location = kit.location
154+
wire = cage.modifiers.new("cage", 'WIREFRAME')
155+
wire.thickness = 0.005; wire.offset = 1.0; wire.use_even_offset = True
156+
bpy.context.collection.objects.link(cage)
157+
print(f"render: kit_verts={len(kit.data.vertices)} fused_verts={fused_v} "
158+
f"kit_mats={len(kit.data.materials)}")
159+
160+
# walnut potter's bats under each piece
161+
wood = bpy.data.materials.new("Walnut"); wood.use_nodes = True
162+
wn = wood.node_tree
163+
wb = wn.nodes.get('Principled BSDF')
164+
wave = wn.nodes.new('ShaderNodeTexWave'); wave.inputs['Scale'].default_value = 1.2
165+
wave.inputs['Distortion'].default_value = 1.5
166+
wave.inputs['Detail'].default_value = 4.0
167+
ramp = wn.nodes.new('ShaderNodeValToRGB')
168+
ramp.color_ramp.elements[0].color = (0.13, 0.065, 0.03, 1)
169+
ramp.color_ramp.elements[1].color = (0.20, 0.105, 0.05, 1)
170+
wn.links.new(wave.outputs['Fac'], ramp.inputs['Fac'])
171+
wn.links.new(ramp.outputs['Color'], wb.inputs['Base Color'])
172+
wb.inputs['Roughness'].default_value = 0.5
173+
bats = []
174+
for x in (-1.45, 1.45):
175+
bpy.ops.mesh.primitive_cylinder_add(vertices=64, radius=0.95, depth=0.12,
176+
location=(x, 0, 0.06))
177+
bat = bpy.context.active_object
178+
bat.data.materials.append(wood)
179+
bev = bat.modifiers.new("bev", 'BEVEL'); bev.width = 0.025; bev.segments = 3
180+
bats.append(bat)
181+
61182
fme = bpy.data.meshes.new("Floor"); bm = bmesh.new()
62183
try:
63184
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme)
@@ -73,69 +194,32 @@ def render_still(obj, path, engine):
73194
wall.rotation_euler = (1.5708, 0, 0); bpy.context.collection.objects.link(wall)
74195
w = bpy.data.worlds.new("W"); w.use_nodes = True
75196
w.node_tree.nodes["Background"].inputs[0].default_value = (0.02, 0.021, 0.025, 1); sc.world = w
76-
# Input beside output. Alone, the remeshed torus read as a glossy inner
77-
# tube with blotchy highlights: nothing said "remeshed". The source torus
78-
# (a modifier-free copy of the same datablock) on the left and the SDF
79-
# result on the right, each under a cage of its own edges, show the
80-
# thing the check measures: UV-ring topology in, voxel-grid topology
81-
# out. Render-path scaffolding only; the check ran before this.
82-
obj.location.x = 1.85
83-
src = bpy.data.objects.new("SourceTorus", obj.data.copy())
84-
src.location = (-1.85, obj.location.y, obj.location.z)
85-
bpy.context.collection.objects.link(src)
86-
# build() centres the torus at z=0.55 with a 0.5 minor radius, so both
87-
# hovered 5 cm over the floor. Ground each on its own measured bottom:
88-
# the SDF surface is not exactly the source surface.
89-
dg0 = bpy.context.evaluated_depsgraph_get()
90-
em = obj.evaluated_get(dg0).to_mesh()
91-
obj.location.z -= obj.location.z + min(v.co.z for v in em.vertices)
92-
obj.evaluated_get(dg0).to_mesh_clear()
93-
src.location.z = -min(v.co.z for v in src.data.vertices)
94-
cage_mat = bpy.data.materials.new("Cage"); cage_mat.use_nodes = True
95-
cb = cage_mat.node_tree.nodes.get('Principled BSDF')
96-
cb.inputs['Base Color'].default_value = (0.92, 0.82, 0.62, 1)
97-
cb.inputs['Roughness'].default_value = 0.4
98-
cages = []
99-
dg = bpy.context.evaluated_depsgraph_get()
100-
for host, remeshed in ((src, False), (obj, True)):
101-
# The remeshed cage is the evaluated result frozen into a plain mesh:
102-
# a live copy of the tree would re-apply its Set Material and paint
103-
# the cage crimson.
104-
data = (bpy.data.meshes.new_from_object(obj.evaluated_get(dg))
105-
if remeshed else obj.data.copy())
106-
cage = bpy.data.objects.new(host.name + "Cage", data)
107-
cage.data.materials.clear(); cage.data.materials.append(cage_mat)
108-
for poly in cage.data.polygons:
109-
poly.material_index = 0
110-
cage.location = host.location
111-
wire = cage.modifiers.new("cage", 'WIREFRAME')
112-
wire.thickness = 0.012; wire.offset = 1.0; wire.use_even_offset = True
113-
wire.material_offset = 0
114-
bpy.context.collection.objects.link(cage)
115-
cages.append(cage)
116-
# Softer than 0.16: at that gloss every voxel facet threw its own hard
117-
# glint and the highlight broke into blocks that read as artifacts.
118-
for m in obj.data.materials:
119-
if m is not None and m.node_tree is not None:
120-
bsdf = m.node_tree.nodes.get('Principled BSDF')
121-
if bsdf is not None:
122-
bsdf.inputs['Roughness'].default_value = 0.32
123-
aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 0.45); bpy.context.collection.objects.link(aim)
124-
# Raised so both donut holes read, and pulled back to hold the pair.
125-
cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (1.5, -10.8, 5.5)
197+
198+
aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.05)
199+
bpy.context.collection.objects.link(aim)
200+
cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam"))
201+
cam.data.lens = 58.0
202+
cam.location = (0.9, -9.4, 3.0)
126203
bpy.context.collection.objects.link(cam); sc.camera = cam
127204
c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y'
128-
# low raking warm key so every facet catches a distinct glint, faint cool
129-
# fill (docs/VISUAL-STYLE.md)
130-
for nm, loc, en, sz, col in [("K", (-5, -3.5, 2.8), 480, 3.0, (1.0, 0.96, 0.9)),
131-
("F2", (5, -4, 2), 110, 7.0, (0.75, 0.85, 1.0))]:
205+
206+
def light(nm, loc, en, sz, col, target=aim):
132207
ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = sz; ld.color = col
133-
lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo)
134-
lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y'
135-
# warm wedge raking the back wall, aimed past the torus at the wall
136-
wd = bpy.data.lights.new("Wedge", 'AREA'); wd.energy = 380; wd.size = 6.0; wd.color = (1.0, 0.76, 0.5)
208+
lo = bpy.data.objects.new(nm, ld); lo.location = loc
209+
bpy.context.collection.objects.link(lo)
210+
lc = lo.constraints.new('TRACK_TO'); lc.target = target
211+
lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y'
212+
# shaped warm key, faint cool fill, cool rim for the glaze silhouette,
213+
# warm wedge raking the back wall (docs/VISUAL-STYLE.md)
214+
light("Key", (-4.5, -4.5, 5.0), 520, 3.0, (1.0, 0.95, 0.88))
215+
light("Fill", (5, -4, 2.2), 90, 7.0, (0.75, 0.85, 1.0))
216+
light("Rim", (2.5, 3.5, 4.0), 260, 2.5, (0.62, 0.78, 1.0))
217+
wd = bpy.data.lights.new("Wedge", 'AREA'); wd.energy = 520; wd.size = 6.0
218+
wd.color = (1.0, 0.72, 0.45)
137219
wo = bpy.data.objects.new("Wedge", wd); wo.location = (2.5, 5.5, 4.0)
138-
wo.rotation_euler = (math.radians(-68), 0, math.radians(190)); bpy.context.collection.objects.link(wo)
220+
wo.rotation_euler = (math.radians(-68), 0, math.radians(190))
221+
bpy.context.collection.objects.link(wo)
222+
139223
sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id()
140224
if sc.render.engine == 'CYCLES':
141225
try: sc.cycles.samples = 32
@@ -148,10 +232,17 @@ def render_still(obj, path, engine):
148232
# for a --background run with no .blend is the drive root, not the cwd.
149233
path = os.path.abspath(path)
150234
sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path
151-
# AgX would wash the crimson toward brick (docs/VISUAL-STYLE.md)
235+
# AgX would wash the cobalt glaze toward slate (docs/VISUAL-STYLE.md)
152236
sc.view_settings.view_transform = 'Standard'
237+
bpy.context.view_layer.update()
238+
# Layer 1 framing gate before the beauty render (exit 10 on violation)
239+
fcode = gallery_framing.check_framing(sc, cam, hero=[kit, fused],
240+
elements=[kit, fused] + bats, stage=[floor, wall])
241+
if fcode:
242+
return fcode
153243
bpy.ops.render.render(write_still=True)
154-
return os.path.exists(path) and os.path.getsize(path) > 0
244+
return 0 if os.path.exists(path) and os.path.getsize(path) > 0 else 4
245+
155246

156247
def main():
157248
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
@@ -191,8 +282,11 @@ def main():
191282
print(f"ERROR: material '{src_mat.name}' dropped by remesh", file=sys.stderr); return 6
192283

193284
if args.output:
194-
if not render_still(obj, args.output, args.engine):
195-
print("ERROR: render produced no file", file=sys.stderr); return 4
285+
rcode = render_still(obj, args.output, args.engine)
286+
if rcode:
287+
if rcode == 4:
288+
print("ERROR: render produced no file", file=sys.stderr)
289+
return rcode
196290
print(f"rendered {args.output} ({os.path.getsize(args.output)} bytes)")
197291
print("gn-sdf-remesh OK")
198292
return 0
-39.7 KB
Loading

0 commit comments

Comments
 (0)