Skip to content

Commit d9e6511

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign wave-displace as a cast bronze tile in a walnut frame
The render-only tile copy (the checked grid plus an extruded skirt) is now cast bronze, polished on the crests with verdigris pooled in the troughs. The patina mask is the tile's own object-space Z, so the colouring is a readout of the displaced height the check asserts. A walnut frame whose rails stop at mid-wave height surrounds it, so the crests rise proud of the frame and the troughs sink below it. A broad soft overhead card gives the bronze something to mirror. The render path now runs the gallery_framing gate (exit 10); before this, the README quoted framing numbers the script never measured. The displacement (one foreach_get, one foreach_set) and both checks are unchanged. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check-only exit 0, verts=9409 z_span=1.0999 max_err=2.98e-08. --flat exits 4 ("z-span 0.0000 not in (0.8800, 1.1000]"). - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): check exit 0 with the same numbers; --output render exit 0, framing ok. - Framing: fill x=0.856 y=0.850, margins l=0.066 r=0.078 b=0.050 t=0.100. - Contact sheet (docs/gallery/contact-sheets/wave-displace-contact-sheet.webp): candidate luma 0.244 / stage 0.225 / warmth +0.106 / sat 0.258, 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, warmth and saturation are inside the band; the stage is slightly above bmesh-gear because the high camera sees mostly lit floor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent cda2e0d commit d9e6511

7 files changed

Lines changed: 154 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ then re-reads its own render and fails unless the pixels prove the flat-vs-check
386386
</tr>
387387
<tr>
388388
<td width="46%" valign="middle">
389-
<a href="examples/wave-displace/"><img src="examples/wave-displace/preview.webp" alt="Wave displace: a sapphire-blue tile whose top is displaced into smooth standing-wave dunes, standing on a dark studio floor" /></a>
389+
<a href="examples/wave-displace/"><img src="examples/wave-displace/preview.webp" alt="Wave displace: a cast bronze tile in a walnut frame whose top is displaced into smooth standing-wave crests, verdigris pooled in the troughs" /></a>
390390
</td>
391391
<td valign="middle">
392392

17 KB
Loading
4.42 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"name": "wave-displace",
6161
"dir": "examples/wave-displace",
6262
"teaches": "Bulk vertex IO at real scale — 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.",
63-
"alt": "A blue tile whose top is a grid displaced into rolling sine-wave hills, standing on a dark studio floor.",
63+
"alt": "A cast bronze tile in a walnut frame whose top is a grid displaced into rolling sine-wave crests, polished on the peaks with green verdigris pooled in the troughs.",
6464
"witnessesFix": "The bulk path is correct, not just fast: the Z span matches the wave amplitude and every vertex matches the closed-form wave, so a stride bug in the flat buffer cannot hide.",
6565
"hero": "docs/gallery/assets/wave-displace-hero.webp",
6666
"preview": "examples/wave-displace/preview.webp",

‎examples/wave-displace/README.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ so that framing could not be gated at all.
2020

2121
The render path now stages a **copy** of the checked grid. Its boundary is
2222
extruded straight down to a flat base 0.30 m below the deepest trough, so the
23-
wave is the top face of a cast tile standing on the studio floor, framed whole.
24-
The checked `Wave` object is left exactly as asserted and hidden from the
25-
render. The copy is render-only and never enters `check()`. Measured framing on
26-
5.2.1: fill 0.753 x / 0.761 y, every margin ≥ 0.117.
23+
wave is the top face of a cast bronze tile, set in a walnut frame whose rails stop
24+
at mid-wave height: crests rise proud of the frame, troughs sink below it. The
25+
bronze is polished on the crests and carries verdigris in the troughs — the patina
26+
mask is the tile's own object-space Z, so the colouring is a readout of the
27+
displaced height the check asserts. The checked `Wave` object is left exactly as
28+
asserted and hidden from the render. The copy and frame are render-only and never
29+
enter `check()`. The render path gates framing through
30+
`examples/gallery_framing.py` (exit 10); measured on 5.2.1: fill 0.856 x /
31+
0.850 y, every margin ≥ 0.050.
2732

2833
## Run
2934

@@ -52,6 +57,7 @@ against it.
5257
| 4 | Z-span not in the closed-form band (`--flat` lands here) |
5358
| 5 | A vertex is off the closed-form wave |
5459
| 6 | `--output` produced no file |
60+
| 10 | Gallery framing violation (`gallery_framing`) |
5561

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

‎examples/wave-displace/wave_displace.py‎

Lines changed: 142 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import bpy, bmesh, sys, os, math, argparse
1919
from array import array
2020

21+
# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
22+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
23+
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
24+
import gallery_framing # noqa: E402
25+
2126
GRID = 96 # segments per side -> (GRID+1)^2 verts
2227
SIZE = 6.0
2328
AMP = 0.55
@@ -75,16 +80,93 @@ def eevee_engine_id():
7580
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
7681

7782

83+
def bronze_material():
84+
"""Cast bronze: polished on the crests, verdigris pooled in the troughs.
85+
86+
The patina mask is the tile's own object-space Z — the displaced wave
87+
height the check asserts — so the colouring is a readout of the wave.
88+
"""
89+
mat = bpy.data.materials.new("CastBronze")
90+
mat.use_nodes = True
91+
nt = mat.node_tree
92+
out = nt.nodes["Material Output"]
93+
metal = nt.nodes["Principled BSDF"]
94+
metal.inputs["Base Color"].default_value = (0.82, 0.52, 0.23, 1.0)
95+
metal.inputs["Metallic"].default_value = 1.0
96+
metal.inputs["Roughness"].default_value = 0.26
97+
patina = nt.nodes.new("ShaderNodeBsdfPrincipled")
98+
patina.inputs["Base Color"].default_value = (0.07, 0.24, 0.19, 1.0)
99+
patina.inputs["Roughness"].default_value = 0.72
100+
coord = nt.nodes.new("ShaderNodeTexCoord").outputs["Object"]
101+
sep = nt.nodes.new("ShaderNodeSeparateXYZ")
102+
nt.links.new(coord, sep.inputs["Vector"])
103+
band = nt.nodes.new("ShaderNodeMapRange") # trough (z=-AMP) -> 1, mid -> 0
104+
band.inputs["From Min"].default_value = -AMP
105+
band.inputs["From Max"].default_value = -0.05
106+
band.inputs["To Min"].default_value = 0.85
107+
band.inputs["To Max"].default_value = 0.0
108+
nt.links.new(sep.outputs["Z"], band.inputs["Value"])
109+
noise = nt.nodes.new("ShaderNodeTexNoise")
110+
noise.inputs["Scale"].default_value = 2.5
111+
noise.inputs["Detail"].default_value = 8.0
112+
nt.links.new(coord, noise.inputs["Vector"])
113+
speck = nt.nodes.new("ShaderNodeMapRange")
114+
speck.inputs["To Min"].default_value = 0.55
115+
speck.inputs["To Max"].default_value = 1.25
116+
nt.links.new(noise.outputs["Fac"], speck.inputs["Value"])
117+
fac = nt.nodes.new("ShaderNodeMath")
118+
fac.operation = "MULTIPLY"
119+
fac.use_clamp = True
120+
nt.links.new(band.outputs["Result"], fac.inputs[0])
121+
nt.links.new(speck.outputs["Result"], fac.inputs[1])
122+
rough = nt.nodes.new("ShaderNodeMapRange") # hand-polish wear on the metal
123+
rough.inputs["To Min"].default_value = 0.20
124+
rough.inputs["To Max"].default_value = 0.40
125+
nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
126+
nt.links.new(rough.outputs["Result"], metal.inputs["Roughness"])
127+
mix = nt.nodes.new("ShaderNodeMixShader")
128+
nt.links.new(fac.outputs["Value"], mix.inputs["Fac"])
129+
nt.links.new(metal.outputs["BSDF"], mix.inputs[1])
130+
nt.links.new(patina.outputs["BSDF"], mix.inputs[2])
131+
nt.links.new(mix.outputs["Shader"], out.inputs["Surface"])
132+
return mat
133+
134+
135+
def walnut_material():
136+
mat = bpy.data.materials.new("Walnut")
137+
mat.use_nodes = True
138+
nt = mat.node_tree
139+
b = nt.nodes["Principled BSDF"]
140+
base = (0.15, 0.065, 0.026)
141+
b.inputs["Roughness"].default_value = 0.42
142+
coord = nt.nodes.new("ShaderNodeTexCoord").outputs["Object"]
143+
mapping = nt.nodes.new("ShaderNodeMapping")
144+
mapping.inputs["Scale"].default_value = (0.5, 12.0, 12.0)
145+
nt.links.new(coord, mapping.inputs["Vector"])
146+
tex = nt.nodes.new("ShaderNodeTexWave")
147+
tex.wave_type = "BANDS"
148+
tex.bands_direction = "Y"
149+
tex.inputs["Scale"].default_value = 0.35
150+
tex.inputs["Distortion"].default_value = 6.0
151+
tex.inputs["Detail"].default_value = 4.0
152+
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
153+
ramp = nt.nodes.new("ShaderNodeValToRGB")
154+
ramp.color_ramp.elements[0].color = (*(c * 0.55 for c in base), 1.0)
155+
ramp.color_ramp.elements[1].color = (*(c * 1.5 for c in base), 1.0)
156+
nt.links.new(tex.outputs["Fac"], ramp.inputs["Fac"])
157+
nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"])
158+
return mat
159+
160+
78161
def render_still(obj, path, engine):
79162
scene = bpy.context.scene
80-
for poly in obj.data.polygons:
81-
poly.use_smooth = True
82-
mat = bpy.data.materials.new("WaveMat")
83-
mat.use_nodes = True
84-
bsdf = mat.node_tree.nodes["Principled BSDF"]
85-
bsdf.inputs["Base Color"].default_value = (0.012, 0.09, 0.38, 1.0) # deep sapphire
86-
bsdf.inputs["Roughness"].default_value = 0.18
87-
obj.data.materials.append(mat)
163+
bronze = bronze_material()
164+
cast = bpy.data.materials.new("CastSide") # sand-cast flanks, no polish
165+
cast.use_nodes = True
166+
cb = cast.node_tree.nodes["Principled BSDF"]
167+
cb.inputs["Base Color"].default_value = (0.30, 0.17, 0.08, 1.0)
168+
cb.inputs["Metallic"].default_value = 1.0
169+
cb.inputs["Roughness"].default_value = 0.55
88170

89171
# render-only staging: the displaced grid is the contract and stays as
90172
# checked. A copy of it gets a skirt, its boundary extruded straight
@@ -95,25 +177,60 @@ def render_still(obj, path, engine):
95177
bm = bmesh.new()
96178
try:
97179
bm.from_mesh(tile_me)
180+
for f in bm.faces:
181+
f.smooth = True
98182
rim = [e for e in bm.edges if e.is_boundary]
99183
ret = bmesh.ops.extrude_edge_only(bm, edges=rim)
100184
skirt = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMFace)]
101185
for g in ret["geom"]:
102186
if isinstance(g, bmesh.types.BMVert):
103187
g.co.z = -AMP - base
104188
bottom = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMEdge) and g.is_boundary]
105-
bmesh.ops.holes_fill(bm, edges=bottom, sides=0)
189+
filled = bmesh.ops.holes_fill(bm, edges=bottom, sides=0)["faces"]
106190
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
107-
for f in skirt:
191+
for f in skirt + filled:
108192
f.smooth = False
193+
f.material_index = 1
109194
bm.to_mesh(tile_me)
110195
finally:
111196
bm.free()
197+
tile_me.materials.append(bronze) # 0: the displaced wave face
198+
tile_me.materials.append(cast) # 1: skirt and base
112199
tile = bpy.data.objects.new("WaveTile", tile_me)
113200
tile.location.z = AMP + base
114201
scene.collection.objects.link(tile)
115202
obj.hide_render = True
116203

204+
# a walnut frame around the cast tile: its rails stop at mid-wave height,
205+
# so the crests rise proud of the frame and the troughs sink below it
206+
walnut = walnut_material()
207+
rail_w, rail_h, half = 0.42, AMP + base + 0.02, SIZE / 2
208+
frame = []
209+
for name, dims, loc in (
210+
("Frame.Front", (SIZE + 2 * rail_w, rail_w, rail_h), (0, -half - rail_w / 2, rail_h / 2)),
211+
("Frame.Back", (SIZE + 2 * rail_w, rail_w, rail_h), (0, half + rail_w / 2, rail_h / 2)),
212+
("Frame.Left", (SIZE, rail_w, rail_h), (-half - rail_w / 2, 0, rail_h / 2)),
213+
("Frame.Right", (SIZE, rail_w, rail_h), (half + rail_w / 2, 0, rail_h / 2)),
214+
):
215+
me = bpy.data.meshes.new(name)
216+
bm = bmesh.new()
217+
try:
218+
bmesh.ops.create_cube(bm, size=1.0)
219+
for v in bm.verts:
220+
v.co.x *= dims[0]; v.co.y *= dims[1]; v.co.z *= dims[2]
221+
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=0.035, segments=3,
222+
profile=0.5, affect="EDGES", clamp_overlap=True)
223+
bm.to_mesh(me)
224+
finally:
225+
bm.free()
226+
me.materials.append(walnut)
227+
ob = bpy.data.objects.new(name, me)
228+
ob.location = loc
229+
if name in ("Frame.Left", "Frame.Right"):
230+
ob.rotation_euler.z = math.radians(90) # grain runs along each rail
231+
scene.collection.objects.link(ob)
232+
frame.append(ob)
233+
117234
floor_me = bpy.data.meshes.new("Floor")
118235
bm = bmesh.new()
119236
try:
@@ -153,6 +270,9 @@ def light(name, loc, energy, size, col, rot):
153270
light("Fill", (9.0, -6.0, 3.0), 120.0, 10.0, (0.78, 0.86, 1.0), (70, 0, 55))
154271
light("Rim", (5.0, 7.5, 4.0), 1800.0, 5.0, (1.0, 0.68, 0.38), (-62, 0, 148))
155272
light("Wedge", (4.0, 10.5, 7.0), 1500.0, 9.0, (1.0, 0.76, 0.5), (-65, 0, 190))
273+
# a broad soft overhead card the polished bronze can mirror; bare metal
274+
# in a dark studio otherwise goes muddy on every face turned from the key
275+
light("Card", (1.0, -3.0, 9.0), 220.0, 14.0, (1.0, 0.93, 0.82), (15, 0, 0))
156276

157277
aim = bpy.data.objects.new("Aim", None)
158278
aim.location = (0.0, -0.4, 0.05)
@@ -180,8 +300,14 @@ def light(name, loc, energy, size, col, rot):
180300
scene.render.resolution_y = 720
181301
scene.render.image_settings.file_format = 'PNG'
182302
scene.render.filepath = path
303+
bpy.context.view_layer.update()
304+
# Layer 1 framing gate before the beauty render (exit 10 on violation)
305+
fcode = gallery_framing.check_framing(scene, cam, hero=[tile] + frame,
306+
elements=[tile] + frame, stage=[floor, wall])
307+
if fcode:
308+
return fcode
183309
bpy.ops.render.render(write_still=True)
184-
return os.path.exists(path) and os.path.getsize(path) > 0
310+
return 0 if os.path.exists(path) and os.path.getsize(path) > 0 else 6
185311

186312

187313
def main():
@@ -201,9 +327,11 @@ def main():
201327
return code
202328

203329
if args.output:
204-
if not render_still(obj, os.path.abspath(args.output), args.engine):
205-
print("ERROR: render produced no file", file=sys.stderr)
206-
return 6
330+
rcode = render_still(obj, os.path.abspath(args.output), args.engine)
331+
if rcode:
332+
if rcode == 6:
333+
print("ERROR: render produced no file", file=sys.stderr)
334+
return rcode
207335
print(f"rendered still {args.output}")
208336

209337
print("wave-displace OK")

0 commit comments

Comments
 (0)