Skip to content

Commit d89ed5a

Browse files
TMHSDigitalclaude
andauthored
fix(examples): bmesh-gear turned-brass finish replaces moire banding (#258)
The hero's face was covered in coarse ring stripes: a scale-16 RINGS wave on Generated coordinates drove roughness over 0.14-0.36, so the rings were centred on the bounding-box corner and read at thumbnail scale as a striped render defect rather than machining. The old still also clipped about 40k pixels and left the tooth flanks nearly black. The finish now follows how a gear blank is cut. Caps are lathe-faced: rings about the object Z axis at scale 150 (sub-pixel), a narrow 0.17-0.21 roughness band and a 0.05 bump, which aggregate into the soft radial fan a turned face actually shows. Flanks are hobbed at roughness 0.55, split from the caps by the object-space normal. A softbox placed on the camera ray's reflection about the front cap gives one legible key highlight, and a low bounce card lights the downward flanks. Clipped pixels: 0. Geometry is untouched. A bore would change the closed-form counts the check asserts, so the gear stays a solid blank; no hole is faked in the shader. The render path now also runs the Layer 1 framing gate (exit 10), which it previously lacked. bmesh-gear is a pinned contact-sheet calibration member, so this changes the calibration image other examples are judged against. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6f57e76 commit d89ed5a

9 files changed

Lines changed: 280 additions & 77 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ Gallery still is a dual-elevation diptych so the contract reads at thumbnail sca
350350
<table>
351351
<tr>
352352
<td width="46%" valign="middle">
353-
<a href="examples/bmesh-gear/"><img src="examples/bmesh-gear/preview.webp" alt="Bmesh gear: a machined brass 14-tooth gear with concentric turning marks catching the light, tilted at a three-quarter angle on a dark studio floor" /></a>
353+
<a href="examples/bmesh-gear/"><img src="examples/bmesh-gear/preview.webp" alt="Bmesh gear: a brass 14-tooth gear leaning back on a dark display wedge, its lathe-turned face catching a soft highlight that fans from the centre and its hobbed tooth flanks lit against the stage" /></a>
354354
</td>
355355
<td valign="middle">
356356

-24.3 KB
Loading

‎docs/gallery/bmesh-gear/index.html‎

Lines changed: 135 additions & 40 deletions
Large diffs are not rendered by default.
-12.7 KB
Loading

‎docs/gallery/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ <h2><a href="driver-wave/">driver-wave</a></h2>
409409
</article>
410410
<article class="card" data-tags="mesh bmesh">
411411
<a class="card-media" href="bmesh-gear/" tabindex="-1" aria-hidden="true">
412-
<img src="assets/bmesh-gear-hero.webp" alt="A gold fourteen-tooth gear leaning back on a dark display wedge, its face covered in concentric grooves." loading="lazy" decoding="async" />
412+
<img src="assets/bmesh-gear-hero.webp" alt="A brass fourteen-tooth gear leaning back on a dark display wedge, its lathe-turned face catching a soft highlight that fans from the centre." loading="lazy" decoding="async" />
413413
</a>
414414
<div class="card-body">
415415
<h2><a href="bmesh-gear/">bmesh-gear</a></h2>

‎examples/bmesh-gear/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ contacts are derived from the posed mesh: the lowest back-cap vertex sits 3 mm
1616
into the floor, and the wedge's slope lies in the back-cap plane, so the lean
1717
is carried rather than held in the air.
1818

19+
The finish follows how a gear blank is actually cut, and the render path
20+
changes only the material and lights — the mesh the check counts is untouched.
21+
The caps are lathe-faced: turning marks run concentric about the gear's own
22+
axis (object coordinates, so they centre on that axis rather than on a
23+
bounding-box corner), far finer than a pixel, so they read only as a narrow
24+
satin roughness band and a whisper of bump — the soft highlight that fans
25+
from the centre. The tooth flanks are hobbed, a rougher finish that spreads
26+
the light across every facet. The key highlight on the face is a softbox
27+
placed on the camera ray's reflection about the front cap, and a low bounce
28+
card gives the downward flanks something to mirror. There is no bore: the
29+
closed-form topology check counts exactly two rings and two caps, so the
30+
gear stays a solid blank rather than faking a hole in the shader.
31+
1932
## Run
2033

2134
```bash
@@ -43,6 +56,7 @@ against it.
4356
| 3 | Topology ≠ closed form (`--no-extrude` lands here) |
4457
| 4 | Non-manifold edges |
4558
| 6 | `--output` produced no file |
59+
| 10 | `--output` framing violation (Layer 1 fill / margin gate, `gallery_framing`) |
4660

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

‎examples/bmesh-gear/bmesh_gear.py‎

Lines changed: 128 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
import bpy, bmesh, sys, os, math, argparse
2222
from mathutils import Vector
2323

24+
# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
25+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
26+
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
27+
import gallery_framing
28+
2429
TEETH = 14
2530
R_ROOT = 1.0
2631
R_TIP = 1.25
@@ -32,6 +37,15 @@
3237
STAGE_BITE = 0.003 # contact depth into the floor and into the gear's back
3338
WEDGE_SLOPE = 1.55 # display wedge slope length, up the gear's back face
3439
WEDGE_WIDTH = 1.8 # display wedge width across the gear
40+
CAP_RING_SCALE = 150.0 # lathe turning marks: finer than a pixel at gallery scale
41+
CAP_ROUGH = (0.17, 0.21) # faced caps: a narrow satin band, not a stripe pattern
42+
CAP_BUMP = 0.05 # whisper of bump from the turning marks
43+
FLANK_ROUGH = 0.55 # hobbed tooth flanks: rougher, so every facet catches light
44+
BOUNCE_W = 120.0 # low front card for the downward tooth flanks
45+
CARD_W = 45.0 # softbox whose reflection is the face's key highlight
46+
CARD_SIZE = 2.5
47+
CARD_DIST = 5.0
48+
CARD_OFFSET = Vector((-0.35, 0.0, 0.25)) # push the reflection off-centre
3549

3650

3751
def gear_profile():
@@ -101,33 +115,83 @@ def eevee_engine_id():
101115
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
102116

103117

104-
def render_still(obj, path, engine):
105-
scene = bpy.context.scene
106-
for poly in obj.data.polygons:
107-
poly.use_smooth = False # crisp machined facets
108-
# machined brass: a flat metal face lit by area lights renders as one
109-
# featureless gradient, so the finish carries the design — concentric
110-
# turning marks (a RINGS wave driving roughness) break the face into
111-
# rings that each catch the key at a different angle
118+
def machined_brass():
119+
"""Brass finished the way a gear blank is actually cut.
120+
121+
The two caps are faced on a lathe: turning marks run concentric about the
122+
gear axis, far finer than a pixel at gallery scale, so they read only as
123+
a slight satin sheen and a whisper of bump, never as stripes. The tooth
124+
flanks are hobbed, a rougher milled finish that spreads the key and fill
125+
across each facet so every flank reads against the stage. Caps and flanks
126+
are told apart by the object-space face normal, so the finish follows
127+
the geometry wherever the gear is posed.
128+
"""
112129
mat = bpy.data.materials.new("MachinedBrass")
113130
mat.use_nodes = True
114131
nt = mat.node_tree
115-
bsdf = nt.nodes["Principled BSDF"]
116-
bsdf.inputs["Base Color"].default_value = (0.72, 0.44, 0.16, 1.0)
132+
nodes, links = nt.nodes, nt.links
133+
bsdf = nodes["Principled BSDF"]
134+
bsdf.inputs["Base Color"].default_value = (0.80, 0.47, 0.12, 1.0)
117135
bsdf.inputs["Metallic"].default_value = 1.0
118-
rings = nt.nodes.new("ShaderNodeTexWave")
136+
137+
# turning marks: rings about the object Z axis (the gear axis), centred
138+
# on the gear's own origin — Generated coordinates would centre them on
139+
# the bounding-box corner and turn the face into off-axis arcs
140+
coords = nodes.new("ShaderNodeTexCoord")
141+
rings = nodes.new("ShaderNodeTexWave")
119142
rings.wave_type = 'RINGS'
120-
rings.rings_direction = 'SPHERICAL' # concentric from center, not X-axis bands
121-
rings.inputs["Scale"].default_value = 16.0
122-
rings.inputs["Distortion"].default_value = 0.5
123-
rough = nt.nodes.new("ShaderNodeMapRange")
124-
rough.inputs["From Min"].default_value = 0.0
125-
rough.inputs["From Max"].default_value = 1.0
126-
rough.inputs["To Min"].default_value = 0.14
127-
rough.inputs["To Max"].default_value = 0.36
128-
nt.links.new(rings.outputs["Fac"], rough.inputs["Value"])
129-
nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
130-
obj.data.materials.append(mat)
143+
rings.rings_direction = 'Z'
144+
rings.inputs["Scale"].default_value = CAP_RING_SCALE
145+
rings.inputs["Distortion"].default_value = 0.0
146+
links.new(coords.outputs["Object"], rings.inputs["Vector"])
147+
148+
# cap mask: 1 on the faced caps, 0 on the hobbed flanks
149+
geo = nodes.new("ShaderNodeNewGeometry")
150+
to_obj = nodes.new("ShaderNodeVectorTransform")
151+
to_obj.vector_type = 'NORMAL'
152+
to_obj.convert_from = 'WORLD'
153+
to_obj.convert_to = 'OBJECT'
154+
links.new(geo.outputs["Normal"], to_obj.inputs["Vector"])
155+
sep = nodes.new("ShaderNodeSeparateXYZ")
156+
links.new(to_obj.outputs["Vector"], sep.inputs["Vector"])
157+
absz = nodes.new("ShaderNodeMath")
158+
absz.operation = 'ABSOLUTE'
159+
links.new(sep.outputs["Z"], absz.inputs[0])
160+
cap = nodes.new("ShaderNodeMath")
161+
cap.operation = 'GREATER_THAN'
162+
cap.inputs[1].default_value = 0.5
163+
links.new(absz.outputs["Value"], cap.inputs[0])
164+
165+
# narrow roughness band on the caps: a satin sheen, not a stripe pattern
166+
cap_rough = nodes.new("ShaderNodeMapRange")
167+
cap_rough.inputs["To Min"].default_value = CAP_ROUGH[0]
168+
cap_rough.inputs["To Max"].default_value = CAP_ROUGH[1]
169+
links.new(rings.outputs["Fac"], cap_rough.inputs["Value"])
170+
rough = nodes.new("ShaderNodeMapRange") # cap mask 0..1 -> flank..cap
171+
rough.clamp = True
172+
links.new(cap.outputs["Value"], rough.inputs["Value"])
173+
rough.inputs["To Min"].default_value = FLANK_ROUGH
174+
links.new(cap_rough.outputs["Result"], rough.inputs["To Max"])
175+
links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
176+
177+
# a gentle bump from the same marks, caps only
178+
height = nodes.new("ShaderNodeMath")
179+
height.operation = 'MULTIPLY'
180+
links.new(rings.outputs["Fac"], height.inputs[0])
181+
links.new(cap.outputs["Value"], height.inputs[1])
182+
bump = nodes.new("ShaderNodeBump")
183+
bump.inputs["Strength"].default_value = CAP_BUMP
184+
bump.inputs["Distance"].default_value = 0.002
185+
links.new(height.outputs["Value"], bump.inputs["Height"])
186+
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
187+
return mat
188+
189+
190+
def render_still(obj, path, engine):
191+
scene = bpy.context.scene
192+
for poly in obj.data.polygons:
193+
poly.use_smooth = False # crisp machined facets
194+
obj.data.materials.append(machined_brass())
131195
# the gear leans back on an inclined display wedge. Both contacts are
132196
# derived from the posed mesh: the lowest back-cap vertex bites into
133197
# the floor, and the wedge's slope lies in the back-cap plane, sunk into
@@ -174,7 +238,8 @@ def render_still(obj, path, engine):
174238
spec = wb.inputs.get("Specular IOR Level") or wb.inputs.get("Specular")
175239
spec.default_value = 0.1
176240
wedge_me.materials.append(wmat)
177-
scene.collection.objects.link(bpy.data.objects.new("Wedge", wedge_me))
241+
wedge = bpy.data.objects.new("Wedge", wedge_me)
242+
scene.collection.objects.link(wedge)
178243

179244
floor_me = bpy.data.meshes.new("Floor")
180245
bm = bmesh.new()
@@ -202,28 +267,43 @@ def render_still(obj, path, engine):
202267
world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.022, 0.028, 1.0)
203268
scene.world = world
204269

205-
def light(name, loc, energy, size, col, rot):
270+
def light(name, loc, energy, size, col, rot=None, aim=None):
206271
ld = bpy.data.lights.new(name, 'AREA')
207272
ld.energy = energy; ld.size = size; ld.color = col
208273
ob = bpy.data.objects.new(name, ld)
209274
ob.location = loc
210-
ob.rotation_euler = tuple(math.radians(a) for a in rot)
275+
if aim is not None: # area lights emit along local -Z
276+
ob.rotation_euler = (Vector(aim) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
277+
else:
278+
ob.rotation_euler = tuple(math.radians(a) for a in rot)
211279
scene.collection.objects.link(ob)
212280

213281
# metals live on reflections: soft warm key, restrained cool fill, and the
214282
# warm wedge raking the back wall (docs/VISUAL-STYLE.md)
215283
light("Key", (-3.5, -4.5, 5.5), 550.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35))
216284
light("Fill", (5.0, -3.5, 2.5), 130.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
217285
light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
218-
# a small hot glint: brass needs one crisp specular streak to read machined
219-
light("Glint", (1.8, -5.2, 6.2), 900.0, 0.9, (1.0, 0.9, 0.7), (40, 0, 18))
286+
# the camera rides with the gear's derived seat height (it was posed for
287+
# a gear hung at z=0.85), so the composition is unchanged
288+
cam_loc = Vector((0.0, -7.6, 4.2 + obj.location.z - 0.85))
289+
# a polished face shows the key only where it mirrors it. The softbox is
290+
# placed on the camera ray's reflection about the front cap, offset
291+
# toward the upper left, so its soft reflection sweeps across the face
292+
# as one legible highlight instead of missing the face altogether
293+
face_c = mw @ Vector((0.0, 0.0, DEPTH))
294+
view = (face_c - cam_loc).normalized()
295+
mirror = view - 2.0 * view.dot(into) * into
296+
card = face_c + (mirror + CARD_OFFSET).normalized() * CARD_DIST
297+
light("Card", tuple(card), CARD_W, CARD_SIZE, (1.0, 0.93, 0.82), aim=face_c)
298+
# metal flanks mirror whatever faces them, and here that is the black
299+
# stage: a low bounce card in front gives the downward flanks something
300+
# to reflect (it aims up at the gear, so the floor in front stays dark)
301+
light("Bounce", (0.6, -2.6, 0.35), BOUNCE_W, 2.0, (1.0, 0.9, 0.78), aim=centre)
220302

221303
cam_data = bpy.data.cameras.new("Cam")
222304
cam_data.lens = 55.0
223305
cam = bpy.data.objects.new("Cam", cam_data)
224-
# the camera rides with the gear's derived seat height (it was posed for
225-
# a gear hung at z=0.85), so the composition is unchanged
226-
cam.location = (0.0, -7.6, 4.2 + obj.location.z - 0.85)
306+
cam.location = cam_loc
227307
cam.rotation_euler = (math.radians(66), 0.0, 0.0)
228308
scene.collection.objects.link(cam)
229309
scene.camera = cam
@@ -242,8 +322,22 @@ def light(name, loc, energy, size, col, rot):
242322
scene.render.filepath = path
243323
# AgX would flatten the steel toward chalk (docs/VISUAL-STYLE.md)
244324
scene.view_settings.view_transform = 'Standard'
325+
# Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
326+
# the beauty render so a defective composition ships no artifact. The
327+
# display wedge is staging, hidden from the matte like the floor and wall.
328+
fcode = gallery_framing.check_framing(
329+
scene, cam,
330+
hero=[obj],
331+
elements=[obj],
332+
stage=[floor, wall, wedge],
333+
)
334+
if fcode:
335+
return fcode
245336
bpy.ops.render.render(write_still=True)
246-
return os.path.exists(path) and os.path.getsize(path) > 0
337+
if not (os.path.exists(path) and os.path.getsize(path) > 0):
338+
print("ERROR: render produced no file", file=sys.stderr)
339+
return 6
340+
return 0
247341

248342

249343
def main():
@@ -262,9 +356,9 @@ def main():
262356
return code
263357

264358
if args.output:
265-
if not render_still(obj, os.path.abspath(args.output), args.engine):
266-
print("ERROR: render produced no file", file=sys.stderr)
267-
return 6
359+
rcode = render_still(obj, os.path.abspath(args.output), args.engine)
360+
if rcode:
361+
return rcode
268362
print(f"rendered still {args.output}")
269363

270364
print("bmesh-gear OK")

‎examples/bmesh-gear/preview.webp‎

-22.7 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
"name": "bmesh-gear",
8787
"dir": "examples/bmesh-gear",
8888
"teaches": "A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm.free() in a try/finally, exactly as the ownership contract demands.",
89-
"alt": "A gold fourteen-tooth gear leaning back on a dark display wedge, its face covered in concentric grooves.",
89+
"alt": "A brass fourteen-tooth gear leaning back on a dark display wedge, its lathe-turned face catching a soft highlight that fans from the centre.",
9090
"witnessesFix": "Parametric bmesh topology is exactly predictable: verts, edges, and faces match their closed forms, and every edge borders exactly two faces (watertight).",
9191
"hero": "docs/gallery/assets/bmesh-gear-hero.webp",
9292
"preview": "examples/bmesh-gear/preview.webp",

0 commit comments

Comments
 (0)