Skip to content

Commit cda2e0d

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign driver-wave as a brass pipe-organ facade
The sixteen driven objects are now organ pipes. They share one open-tube body mesh of unit height (z 0..1), so the driven Z scale is each pipe's speaking length and the pipe tops trace wave_scale directly. The SCRIPTED drivers, driver_namespace registration and both checks (evaluated copy and flushed original) are unchanged. Render-only staging surrounds them: a walnut windchest with panelled front and brass pulls, a dark walnut case back and side towers with brass finials, and a brass foot cone and mouth under each pipe. Lighting adds a broad soft card for the brass to mirror, and the camera is a TRACK_TO three-quarter front view. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check-only exit 0, columns=16 driven_range=0.404..2.399 flushed_to_original=True. --flat-expr exits 3 ("col 0 evaluated scale 1.0000 != wave_scale 1.4000"). - .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.806 y=0.622, margins l=0.097 r=0.097 b=0.194 t=0.183. - Contact sheet (docs/gallery/contact-sheets/driver-wave-contact-sheet.webp): candidate luma 0.240 / stage 0.199 / warmth +0.235 / sat 0.225, 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. Every criterion is inside the calibration band. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent d2d442d commit cda2e0d

7 files changed

Lines changed: 228 additions & 50 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ A slotted-actions Z-rotation turntable keyed through the cross-version channelba
691691
</tr>
692692
<tr>
693693
<td width="46%" valign="middle">
694-
<a href="examples/driver-wave/"><img src="examples/driver-wave/preview.webp" alt="Driver wave: sixteen orange columns seen from above whose heights form a sine wave on a dark studio floor, each driven by a driver_namespace function" /></a>
694+
<a href="examples/driver-wave/"><img src="examples/driver-wave/preview.webp" alt="Driver wave: a pipe-organ facade of sixteen brass pipes on a walnut windchest whose heights form a sine wave, each driven by a driver_namespace function" /></a>
695695
</td>
696696
<td valign="middle">
697697

16.5 KB
Loading
3.19 KB
Loading

‎examples/driver-wave/README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Driver Wave
22

3-
A runnable example that drives sixteen column heights from a custom function registered in
3+
A runnable example that drives sixteen organ-pipe heights from a custom function registered in
44
`bpy.app.driver_namespace` — the pattern from
55
[`drivers-and-app-handlers`](../../skills/drivers-and-app-handlers/SKILL.md). Each column
66
gets a SCRIPTED driver on Z scale whose expression calls `wave_scale(i)`, producing a sine
@@ -17,9 +17,14 @@ open. Headless, registering before driver creation (as here) is enough.
1717

1818
## Staging
1919

20-
The camera is raised to look down about 18 degrees, so the column tops
21-
and their shadows read as a wave. From nearly level (86 degrees) the
22-
skyline flattened into a bar chart. Render path only.
20+
The sixteen driven objects are the speaking pipes of a small organ facade. They share one
21+
open-tube body mesh of unit height (`z` 0..1), so the driven Z scale **is** each pipe's
22+
speaking length and the pipe tops trace `wave_scale` directly; the rim annulus is horizontal
23+
and stays crisp under any Z scale. Everything else — the walnut windchest and case back, the
24+
side towers with brass finials, a brass foot cone and a mouth under each pipe — is render-only
25+
staging built around the driven bodies. The case back sits behind the pipes so their tops read
26+
as a wave against wood rather than fading into the stage. The render path gates framing through
27+
`examples/gallery_framing.py` (exit 10) before writing the still.
2328

2429
## Run
2530

‎examples/driver-wave/driver_wave.py‎

Lines changed: 216 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
blender --background --python driver_wave.py -- --output d.png # + render
2020
"""
2121
import bpy, bmesh, sys, os, math, argparse
22+
from mathutils import Vector
2223

2324
# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
2425
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
@@ -35,20 +36,56 @@ def wave_scale(i):
3536
return 1.4 + math.sin(i * 0.6)
3637

3738

39+
# Organ-pipe body profile (r, z) in mesh units; BASE x/y scale makes the
40+
# outer radius ~0.26. Bottom closed, open mouth at the top with a wall.
41+
PIPE_PROFILE = [(0.0, 0.0), (0.92, 0.0), (0.92, 1.0), (0.80, 1.0), (0.80, 0.94), (0.0, 0.94)]
42+
43+
44+
def lathe(bm, profile, segs=32):
45+
"""Revolve an (r, z) profile about Z; r == 0 points become poles."""
46+
rings = []
47+
for r, z in profile:
48+
if r < 1e-9:
49+
rings.append([bm.verts.new((0.0, 0.0, z))])
50+
else:
51+
rings.append([bm.verts.new((r * math.cos(2 * math.pi * s / segs),
52+
r * math.sin(2 * math.pi * s / segs), z))
53+
for s in range(segs)])
54+
for a, b in zip(rings, rings[1:]):
55+
for s in range(segs):
56+
t = (s + 1) % segs
57+
if len(a) == 1 and len(b) == 1:
58+
continue
59+
if len(a) == 1:
60+
bm.faces.new((a[0], b[t], b[s]))
61+
elif len(b) == 1:
62+
bm.faces.new((a[s], a[t], b[0]))
63+
else:
64+
bm.faces.new((a[s], a[t], b[t], b[s]))
65+
return rings
66+
67+
3868
def build_columns(flat_expr=False):
3969
bpy.ops.wm.read_factory_settings(use_empty=True)
4070
# driver_namespace entries do not persist in .blend files; real add-ons
4171
# re-register them from a load_post handler. Headless, registering before
4272
# driver creation is enough.
4373
bpy.app.driver_namespace["wave_scale"] = wave_scale
4474

75+
# one shared organ-pipe body: an open tube of unit height (z 0..1), so the
76+
# driven Z scale IS the speaking length; the rim annulus is horizontal and
77+
# stays crisp under any Z scale
4578
me = bpy.data.meshes.new("Column")
4679
bm = bmesh.new()
4780
try:
48-
bmesh.ops.create_cube(bm, size=1.0)
81+
lathe(bm, PIPE_PROFILE, segs=32)
82+
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
4983
bm.to_mesh(me)
5084
finally:
5185
bm.free()
86+
for poly in me.polygons:
87+
poly.use_smooth = True
88+
me.set_sharp_from_angle(angle=math.radians(50.0))
5289

5390
objs = []
5491
x0 = -(COUNT - 1) * SPACING / 2
@@ -90,18 +127,155 @@ def eevee_engine_id():
90127

91128

92129
def render_still(objs, path, engine):
130+
"""A pipe-organ facade: the sixteen driven bodies are the speaking pipes.
131+
132+
Everything added here is render-only staging around the driven objects:
133+
a walnut windchest, a brass foot cone and a mouth under each pipe. The
134+
pipe bodies keep their SCRIPTED drivers; their tops trace wave_scale.
135+
"""
93136
scene = bpy.context.scene
94-
mat = bpy.data.materials.new("ColMat")
95-
mat.use_nodes = True
96-
bsdf = mat.node_tree.nodes["Principled BSDF"]
97-
bsdf.inputs["Base Color"].default_value = (1.0, 0.26, 0.012, 1.0) # selection orange
98-
bsdf.inputs["Roughness"].default_value = 0.32
99-
objs[0].data.materials.append(mat) # shared mesh -> all columns
100-
101-
# columns stand on the floor: lift each by its DRIVEN half-height
137+
138+
def principled(name, base, rough, metal=0.0, grain=None, noise=None):
139+
m = bpy.data.materials.new(name)
140+
m.use_nodes = True
141+
nt = m.node_tree
142+
b = nt.nodes["Principled BSDF"]
143+
b.inputs["Base Color"].default_value = (*base, 1.0)
144+
b.inputs["Roughness"].default_value = rough
145+
b.inputs["Metallic"].default_value = metal
146+
coord = nt.nodes.new("ShaderNodeTexCoord").outputs["Object"]
147+
if grain:
148+
# walnut: stretched noise bands along X, dark/light figure
149+
mapping = nt.nodes.new("ShaderNodeMapping")
150+
mapping.inputs["Scale"].default_value = (0.6, 14.0, 14.0)
151+
nt.links.new(coord, mapping.inputs["Vector"])
152+
tex = nt.nodes.new("ShaderNodeTexWave")
153+
tex.wave_type = "BANDS"
154+
tex.bands_direction = "Y"
155+
tex.inputs["Scale"].default_value = 0.35
156+
tex.inputs["Distortion"].default_value = 6.0
157+
tex.inputs["Detail"].default_value = 4.0
158+
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
159+
ramp = nt.nodes.new("ShaderNodeValToRGB")
160+
ramp.color_ramp.elements[0].color = (*(c * 0.55 for c in base), 1.0)
161+
ramp.color_ramp.elements[1].color = (*(min(1.0, c * 1.45) for c in base), 1.0)
162+
nt.links.new(tex.outputs["Fac"], ramp.inputs["Fac"])
163+
nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"])
164+
if noise:
165+
# handling wear on the brass: mottled roughness, never a flat slot
166+
tex = nt.nodes.new("ShaderNodeTexNoise")
167+
tex.inputs["Scale"].default_value = noise
168+
tex.inputs["Detail"].default_value = 6.0
169+
nt.links.new(coord, tex.inputs["Vector"])
170+
mr = nt.nodes.new("ShaderNodeMapRange")
171+
mr.inputs["To Min"].default_value = rough - 0.08
172+
mr.inputs["To Max"].default_value = rough + 0.16
173+
nt.links.new(tex.outputs["Fac"], mr.inputs["Value"])
174+
nt.links.new(mr.outputs["Result"], b.inputs["Roughness"])
175+
return m
176+
177+
brass = principled("Brass", (0.86, 0.62, 0.30), 0.24, metal=1.0, noise=9.0)
178+
walnut = principled("Walnut", (0.17, 0.075, 0.03), 0.48, grain=True)
179+
mouth_mat = principled("PipeMouth", (0.012, 0.010, 0.009), 0.7)
180+
objs[0].data.materials.append(brass) # shared mesh -> all sixteen pipes
181+
182+
def box(name, dims, loc, mat, bevel=0.02):
183+
me = bpy.data.meshes.new(name)
184+
bm = bmesh.new()
185+
try:
186+
bmesh.ops.create_cube(bm, size=1.0)
187+
for v in bm.verts:
188+
v.co.x *= dims[0]; v.co.y *= dims[1]; v.co.z *= dims[2]
189+
if bevel:
190+
bmesh.ops.bevel(bm, geom=list(bm.edges), offset=bevel, segments=2,
191+
profile=0.5, affect="EDGES", clamp_overlap=True)
192+
bm.to_mesh(me)
193+
finally:
194+
bm.free()
195+
me.materials.append(mat)
196+
ob = bpy.data.objects.new(name, me)
197+
ob.location = loc
198+
scene.collection.objects.link(ob)
199+
return ob
200+
201+
# the windchest: a walnut cabinet the pipe feet stand in
202+
span = (COUNT - 1) * SPACING
203+
chest_w, chest_d = span + 0.95, 1.1
204+
parts = [
205+
box("Chest.Plinth", (chest_w + 0.12, chest_d + 0.12, 0.14), (0, 0, 0.07), walnut),
206+
box("Chest.Body", (chest_w, chest_d, 0.78), (0, 0, 0.53), walnut, bevel=0.015),
207+
box("Chest.Rail", (chest_w + 0.10, chest_d + 0.10, 0.08), (0, 0, 0.96), walnut),
208+
box("Chest.Toe", (chest_w - 0.2, chest_d - 0.4, 0.06), (0, 0, 1.03), walnut),
209+
]
210+
# raised front panels with brass pulls, one per four pipes
211+
for k in range(4):
212+
px = -span / 2 + span * (k + 0.5) / 4
213+
parts.append(box(f"Chest.Panel.{k}", (span / 4 - 0.32, 0.04, 0.5),
214+
(px, -chest_d / 2 - 0.015, 0.53), walnut, bevel=0.012))
215+
parts.append(box(f"Chest.Pull.{k}", (0.22, 0.04, 0.04),
216+
(px, -chest_d / 2 - 0.05, 0.53), brass, bevel=0.01))
217+
218+
# the case back: a dark walnut screen behind the pipes, so the driven
219+
# tops trace their wave against wood rather than fading into the stage;
220+
# two side towers with brass finials close the facade
221+
dark_walnut = principled("WalnutDark", (0.07, 0.032, 0.014), 0.55, grain=True)
222+
parts.append(box("Case.Back", (chest_w - 0.2, 0.12, 3.05),
223+
(0, 0.42, 1.06 + 1.525), dark_walnut, bevel=0.02))
224+
parts.append(box("Case.Cornice", (chest_w + 0.1, 0.36, 0.14),
225+
(0, 0.40, 1.06 + 3.12), walnut, bevel=0.02))
226+
for sx in (-1.0, 1.0):
227+
tx = sx * (chest_w / 2 - 0.05)
228+
parts.append(box(f"Case.Tower.{'LR'[sx > 0]}", (0.34, 0.62, 3.3),
229+
(tx, 0.18, 1.06 + 1.65), walnut, bevel=0.025))
230+
parts.append(box(f"Case.Cap.{'LR'[sx > 0]}", (0.44, 0.72, 0.1),
231+
(tx, 0.18, 1.06 + 3.35), walnut, bevel=0.02))
232+
fin_me = bpy.data.meshes.new(f"Finial.{'LR'[sx > 0]}")
233+
bm = bmesh.new()
234+
try:
235+
lathe(bm, [(0.0, 0.0), (0.12, 0.0), (0.12, 0.05), (0.06, 0.09),
236+
(0.10, 0.18), (0.07, 0.27), (0.02, 0.33), (0.0, 0.36)], segs=24)
237+
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
238+
bm.to_mesh(fin_me)
239+
finally:
240+
bm.free()
241+
for poly in fin_me.polygons:
242+
poly.use_smooth = True
243+
fin_me.materials.append(brass)
244+
fin = bpy.data.objects.new(fin_me.name, fin_me)
245+
fin.location = (tx, 0.18, 1.06 + 3.40)
246+
scene.collection.objects.link(fin)
247+
parts.append(fin)
248+
249+
# brass foot cone + mouth per pipe; the driven body starts above the foot
250+
foot_h = 0.50
251+
chest_top = 1.06
252+
foot_me = bpy.data.meshes.new("PipeFoot")
253+
bm = bmesh.new()
254+
try:
255+
lathe(bm, [(0.0, 0.0), (0.05, 0.0), (0.06, 0.03), (0.24, foot_h - 0.04), (0.258, foot_h),
256+
(0.0, foot_h)], segs=32)
257+
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
258+
bm.to_mesh(foot_me)
259+
finally:
260+
bm.free()
261+
for poly in foot_me.polygons:
262+
poly.use_smooth = True
263+
foot_me.materials.append(brass)
102264
dg = bpy.context.evaluated_depsgraph_get()
103265
for obj in objs:
104-
obj.location.z = obj.evaluated_get(dg).scale[2] / 2
266+
x = obj.location.x
267+
foot = bpy.data.objects.new(f"Foot.{obj.name[-2:]}", foot_me)
268+
foot.location = (x, 0.0, chest_top)
269+
scene.collection.objects.link(foot)
270+
parts.append(foot)
271+
# body stands on its foot; its height is the DRIVEN Z scale
272+
obj.location.z = chest_top + foot_h
273+
z0 = chest_top + foot_h
274+
parts.append(box(f"Mouth.{obj.name[-2:]}", (0.20, 0.04, 0.15),
275+
(x, -0.238, z0 + 0.10), mouth_mat, bevel=0.008))
276+
parts.append(box(f"Lip.{obj.name[-2:]}", (0.23, 0.05, 0.035),
277+
(x, -0.245, z0 + 0.19), brass, bevel=0.01))
278+
heights = [obj.evaluated_get(dg).scale[2] for obj in objs]
105279

106280
floor_me = bpy.data.meshes.new("Floor")
107281
bm = bmesh.new()
@@ -119,7 +293,7 @@ def render_still(objs, path, engine):
119293
floor_me.materials.append(fmat)
120294
scene.collection.objects.link(floor)
121295
wall = bpy.data.objects.new("Wall", floor_me.copy())
122-
wall.location = (0.0, 9.0, 0.0)
296+
wall.location = (0.0, 7.0, 0.0)
123297
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
124298
scene.collection.objects.link(wall)
125299

@@ -128,41 +302,38 @@ def render_still(objs, path, engine):
128302
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0)
129303
scene.world = world
130304

131-
# warm shaped key, faint cool fill, warm wedge pooling on the back wall
132-
# (docs/VISUAL-STYLE.md)
133-
key = bpy.data.lights.new("Key", 'AREA'); key.energy = 650.0; key.size = 4.5
134-
key.color = (1.0, 0.96, 0.9)
135-
key_ob = bpy.data.objects.new("Key", key)
136-
key_ob.location = (-4.5, -5.5, 6.5)
137-
key_ob.rotation_euler = (math.radians(46), 0.0, math.radians(-33))
138-
scene.collection.objects.link(key_ob)
139-
fill = bpy.data.lights.new("Fill", 'AREA'); fill.energy = 110.0; fill.size = 8.0
140-
fill.color = (0.75, 0.85, 1.0)
141-
fill_ob = bpy.data.objects.new("Fill", fill)
142-
fill_ob.location = (5.5, -4.0, 3.5)
143-
fill_ob.rotation_euler = (math.radians(62), 0.0, math.radians(48))
144-
scene.collection.objects.link(fill_ob)
145-
wedge = bpy.data.lights.new("Wedge", 'AREA'); wedge.energy = 380.0; wedge.size = 6.0
146-
wedge.color = (1.0, 0.76, 0.5)
147-
wedge_ob = bpy.data.objects.new("Wedge", wedge)
148-
wedge_ob.location = (2.5, 6.0, 4.0)
149-
wedge_ob.rotation_euler = (math.radians(-68), 0.0, math.radians(190))
150-
scene.collection.objects.link(wedge_ob)
151-
152-
# lens/distance chosen so all sixteen columns (span 11.1 units) sit inside
153-
# the frame with a small margin -- the skyline must not clip
154-
cam_data = bpy.data.cameras.new("Cam"); cam_data.lens = 42.0
305+
def light(name, loc, energy, size, col, at):
306+
ld = bpy.data.lights.new(name, 'AREA')
307+
ld.energy = energy; ld.size = size; ld.color = col
308+
ob = bpy.data.objects.new(name, ld)
309+
ob.location = loc
310+
ob.rotation_euler = (Vector(at) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
311+
scene.collection.objects.link(ob)
312+
313+
# warm shaped key, faint cool fill, a broad soft card the brass can
314+
# mirror (bare metal in a dark studio otherwise reads black), a cool rim
315+
# on the pipe edges and a warm wedge pooling on the back wall
316+
light("Key", (-5.0, -6.5, 7.0), 900.0, 4.0, (1.0, 0.94, 0.86), (-0.5, 0.0, 2.0))
317+
light("Fill", (6.5, -5.0, 3.0), 160.0, 8.0, (0.75, 0.85, 1.0), (0.0, 0.0, 2.0))
318+
light("Card", (1.5, -9.0, 4.5), 260.0, 10.0, (1.0, 0.92, 0.80), (0.0, 0.0, 2.2))
319+
light("Rim", (-2.0, 4.5, 5.5), 420.0, 4.0, (0.62, 0.78, 1.0), (0.0, 0.0, 2.2))
320+
light("Wedge", (5.5, 1.5, 3.2), 650.0, 6.0, (1.0, 0.70, 0.42), (7.0, 7.0, 1.4))
321+
322+
cam_data = bpy.data.cameras.new("Cam")
323+
cam_data.lens = 45.0
155324
cam = bpy.data.objects.new("Cam", cam_data)
156-
# raised so the column tops and their shadows read as a wave; from
157-
# nearly level (86 deg) it flattened into a bar chart
158-
cam.location = (0.0, -14.2, 5.6)
159-
cam.rotation_euler = (math.radians(72), 0.0, 0.0)
325+
cam.location = (-4.3, -18.2, 3.2)
160326
scene.collection.objects.link(cam)
327+
aim = bpy.data.objects.new("Aim", None)
328+
aim.location = (-0.35, 0.0, 2.25)
329+
scene.collection.objects.link(aim)
330+
con = cam.constraints.new('TRACK_TO')
331+
con.target = aim
161332
scene.camera = cam
162333

163334
scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
164335
if engine == 'cycles':
165-
scene.cycles.samples = 32
336+
scene.cycles.samples = 48
166337
else:
167338
try:
168339
scene.eevee.taa_render_samples = 64
@@ -172,14 +343,16 @@ def render_still(objs, path, engine):
172343
scene.render.resolution_y = 720
173344
scene.render.image_settings.file_format = 'PNG'
174345
scene.render.filepath = path
175-
# AgX would wash the orange columns toward tan (docs/VISUAL-STYLE.md)
346+
# AgX would wash the brass toward tan (docs/VISUAL-STYLE.md)
176347
scene.view_settings.view_transform = 'Standard'
348+
bpy.context.view_layer.update()
349+
print(f"pipe speaking lengths (driven) {min(heights):.3f}..{max(heights):.3f}")
177350
# Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
178351
# the beauty render so a defective composition ships no artifact.
179352
fcode = gallery_framing.check_framing(
180353
scene, cam,
181-
hero=list(objs),
182-
elements=list(objs),
354+
hero=list(objs) + parts,
355+
elements=list(objs) + parts,
183356
stage=[floor, wall],
184357
)
185358
if fcode:

‎examples/driver-wave/preview.webp‎

13.4 KB
Loading

‎examples/gallery.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@
7272
{
7373
"name": "driver-wave",
7474
"dir": "examples/driver-wave",
75-
"teaches": "A driver_namespace function driving sixteen column heights through SCRIPTED drivers — the sine skyline is entirely driver-evaluated.",
76-
"alt": "A row of orange bars seen from above, their heights rising and falling in a wave, low in the middle and tall at both ends, each casting a shadow on the floor.",
75+
"teaches": "A driver_namespace function driving sixteen organ-pipe heights through SCRIPTED drivers — the sine skyline of the pipe tops is entirely driver-evaluated.",
76+
"alt": "A small pipe-organ facade: sixteen brass pipes on a walnut windchest in front of a dark walnut case, their tops rising and falling in a sine wave, tall at both ends and short in the middle.",
7777
"witnessesFix": "Driven values appear after a view-layer update in two places that must agree: the evaluated copy and the original datablock the animation system flushes for display.",
7878
"hero": "docs/gallery/assets/driver-wave-hero.webp",
7979
"preview": "examples/driver-wave/preview.webp",

0 commit comments

Comments
 (0)