Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ Gallery still is a dual-elevation diptych so the contract reads at thumbnail sca
<table>
<tr>
<td width="46%" valign="middle">
<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>
<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>
</td>
<td valign="middle">

Expand Down
Binary file modified docs/gallery/assets/bmesh-gear-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
175 changes: 135 additions & 40 deletions docs/gallery/bmesh-gear/index.html

Large diffs are not rendered by default.

Binary file modified docs/gallery/contact-sheets/bmesh-gear-contact-sheet.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/gallery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ <h2><a href="driver-wave/">driver-wave</a></h2>
</article>
<article class="card" data-tags="mesh bmesh">
<a class="card-media" href="bmesh-gear/" tabindex="-1" aria-hidden="true">
<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" />
<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" />
</a>
<div class="card-body">
<h2><a href="bmesh-gear/">bmesh-gear</a></h2>
Expand Down
14 changes: 14 additions & 0 deletions examples/bmesh-gear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ contacts are derived from the posed mesh: the lowest back-cap vertex sits 3 mm
into the floor, and the wedge's slope lies in the back-cap plane, so the lean
is carried rather than held in the air.

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

## Run

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

The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
Expand Down
162 changes: 128 additions & 34 deletions examples/bmesh-gear/bmesh_gear.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
import bpy, bmesh, sys, os, math, argparse
from mathutils import Vector

# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree
import gallery_framing

TEETH = 14
R_ROOT = 1.0
R_TIP = 1.25
Expand All @@ -32,6 +37,15 @@
STAGE_BITE = 0.003 # contact depth into the floor and into the gear's back
WEDGE_SLOPE = 1.55 # display wedge slope length, up the gear's back face
WEDGE_WIDTH = 1.8 # display wedge width across the gear
CAP_RING_SCALE = 150.0 # lathe turning marks: finer than a pixel at gallery scale
CAP_ROUGH = (0.17, 0.21) # faced caps: a narrow satin band, not a stripe pattern
CAP_BUMP = 0.05 # whisper of bump from the turning marks
FLANK_ROUGH = 0.55 # hobbed tooth flanks: rougher, so every facet catches light
BOUNCE_W = 120.0 # low front card for the downward tooth flanks
CARD_W = 45.0 # softbox whose reflection is the face's key highlight
CARD_SIZE = 2.5
CARD_DIST = 5.0
CARD_OFFSET = Vector((-0.35, 0.0, 0.25)) # push the reflection off-centre


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


def render_still(obj, path, engine):
scene = bpy.context.scene
for poly in obj.data.polygons:
poly.use_smooth = False # crisp machined facets
# machined brass: a flat metal face lit by area lights renders as one
# featureless gradient, so the finish carries the design — concentric
# turning marks (a RINGS wave driving roughness) break the face into
# rings that each catch the key at a different angle
def machined_brass():
"""Brass finished the way a gear blank is actually cut.

The two caps are faced on a lathe: turning marks run concentric about the
gear axis, far finer than a pixel at gallery scale, so they read only as
a slight satin sheen and a whisper of bump, never as stripes. The tooth
flanks are hobbed, a rougher milled finish that spreads the key and fill
across each facet so every flank reads against the stage. Caps and flanks
are told apart by the object-space face normal, so the finish follows
the geometry wherever the gear is posed.
"""
mat = bpy.data.materials.new("MachinedBrass")
mat.use_nodes = True
nt = mat.node_tree
bsdf = nt.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.72, 0.44, 0.16, 1.0)
nodes, links = nt.nodes, nt.links
bsdf = nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.80, 0.47, 0.12, 1.0)
bsdf.inputs["Metallic"].default_value = 1.0
rings = nt.nodes.new("ShaderNodeTexWave")

# turning marks: rings about the object Z axis (the gear axis), centred
# on the gear's own origin — Generated coordinates would centre them on
# the bounding-box corner and turn the face into off-axis arcs
coords = nodes.new("ShaderNodeTexCoord")
rings = nodes.new("ShaderNodeTexWave")
rings.wave_type = 'RINGS'
rings.rings_direction = 'SPHERICAL' # concentric from center, not X-axis bands
rings.inputs["Scale"].default_value = 16.0
rings.inputs["Distortion"].default_value = 0.5
rough = nt.nodes.new("ShaderNodeMapRange")
rough.inputs["From Min"].default_value = 0.0
rough.inputs["From Max"].default_value = 1.0
rough.inputs["To Min"].default_value = 0.14
rough.inputs["To Max"].default_value = 0.36
nt.links.new(rings.outputs["Fac"], rough.inputs["Value"])
nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
obj.data.materials.append(mat)
rings.rings_direction = 'Z'
rings.inputs["Scale"].default_value = CAP_RING_SCALE
rings.inputs["Distortion"].default_value = 0.0
links.new(coords.outputs["Object"], rings.inputs["Vector"])

# cap mask: 1 on the faced caps, 0 on the hobbed flanks
geo = nodes.new("ShaderNodeNewGeometry")
to_obj = nodes.new("ShaderNodeVectorTransform")
to_obj.vector_type = 'NORMAL'
to_obj.convert_from = 'WORLD'
to_obj.convert_to = 'OBJECT'
links.new(geo.outputs["Normal"], to_obj.inputs["Vector"])
sep = nodes.new("ShaderNodeSeparateXYZ")
links.new(to_obj.outputs["Vector"], sep.inputs["Vector"])
absz = nodes.new("ShaderNodeMath")
absz.operation = 'ABSOLUTE'
links.new(sep.outputs["Z"], absz.inputs[0])
cap = nodes.new("ShaderNodeMath")
cap.operation = 'GREATER_THAN'
cap.inputs[1].default_value = 0.5
links.new(absz.outputs["Value"], cap.inputs[0])

# narrow roughness band on the caps: a satin sheen, not a stripe pattern
cap_rough = nodes.new("ShaderNodeMapRange")
cap_rough.inputs["To Min"].default_value = CAP_ROUGH[0]
cap_rough.inputs["To Max"].default_value = CAP_ROUGH[1]
links.new(rings.outputs["Fac"], cap_rough.inputs["Value"])
rough = nodes.new("ShaderNodeMapRange") # cap mask 0..1 -> flank..cap
rough.clamp = True
links.new(cap.outputs["Value"], rough.inputs["Value"])
rough.inputs["To Min"].default_value = FLANK_ROUGH
links.new(cap_rough.outputs["Result"], rough.inputs["To Max"])
links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])

# a gentle bump from the same marks, caps only
height = nodes.new("ShaderNodeMath")
height.operation = 'MULTIPLY'
links.new(rings.outputs["Fac"], height.inputs[0])
links.new(cap.outputs["Value"], height.inputs[1])
bump = nodes.new("ShaderNodeBump")
bump.inputs["Strength"].default_value = CAP_BUMP
bump.inputs["Distance"].default_value = 0.002
links.new(height.outputs["Value"], bump.inputs["Height"])
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
return mat


def render_still(obj, path, engine):
scene = bpy.context.scene
for poly in obj.data.polygons:
poly.use_smooth = False # crisp machined facets
obj.data.materials.append(machined_brass())
# the gear leans back on an inclined display wedge. Both contacts are
# derived from the posed mesh: the lowest back-cap vertex bites into
# the floor, and the wedge's slope lies in the back-cap plane, sunk into
Expand Down Expand Up @@ -174,7 +238,8 @@ def render_still(obj, path, engine):
spec = wb.inputs.get("Specular IOR Level") or wb.inputs.get("Specular")
spec.default_value = 0.1
wedge_me.materials.append(wmat)
scene.collection.objects.link(bpy.data.objects.new("Wedge", wedge_me))
wedge = bpy.data.objects.new("Wedge", wedge_me)
scene.collection.objects.link(wedge)

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

def light(name, loc, energy, size, col, rot):
def light(name, loc, energy, size, col, rot=None, aim=None):
ld = bpy.data.lights.new(name, 'AREA')
ld.energy = energy; ld.size = size; ld.color = col
ob = bpy.data.objects.new(name, ld)
ob.location = loc
ob.rotation_euler = tuple(math.radians(a) for a in rot)
if aim is not None: # area lights emit along local -Z
ob.rotation_euler = (Vector(aim) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler()
else:
ob.rotation_euler = tuple(math.radians(a) for a in rot)
scene.collection.objects.link(ob)

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

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


def main():
Expand All @@ -262,9 +356,9 @@ def main():
return code

if args.output:
if not render_still(obj, os.path.abspath(args.output), args.engine):
print("ERROR: render produced no file", file=sys.stderr)
return 6
rcode = render_still(obj, os.path.abspath(args.output), args.engine)
if rcode:
return rcode
print(f"rendered still {args.output}")

print("bmesh-gear OK")
Expand Down
Binary file modified examples/bmesh-gear/preview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion examples/gallery.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"name": "bmesh-gear",
"dir": "examples/bmesh-gear",
"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.",
"alt": "A gold fourteen-tooth gear leaning back on a dark display wedge, its face covered in concentric grooves.",
"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.",
"witnessesFix": "Parametric bmesh topology is exactly predictable: verts, edges, and faces match their closed forms, and every edge borders exactly two faces (watertight).",
"hero": "docs/gallery/assets/bmesh-gear-hero.webp",
"preview": "examples/bmesh-gear/preview.webp",
Expand Down
Loading