Skip to content

Commit 3cca51b

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign soccer-ball-goldberg as a match ball on pitch turf
The hero was a bare ball in a void. It now sits pressed 1 cm into a patch of mown turf, on a chalked penalty spot with a touchline behind it. - Turf (render-only): alternating mowing stripes, blade noise in colour and bump, and chalk broken up by the same noise. Everything reads Object coordinates, so the spot centres under the ball. - Leather: both panel materials gain a fine Voronoi pebble-grain bump and a thin clear coat. - Camera: now aims through a TRACK_TO empty. - The README records the turf floor as a stage deviation. The checked mesh, the panel binding and every check are unchanged. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): - check-only exits 0: V=60 E=90 F=32, census 12x5+20x6, degree 3, watertight; edge max_dev 6.471e-06, planarity 1.888e-06, circumradius max_dev 8.956e-06. - --invert-bind exits 13: "32 face(s) carry the wrong panel material". - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): --output renders with framing_ok. - Framing: fill x=0.434, y=0.772. Margins: left 0.284, right 0.281, bottom 0.194, top 0.033. - Contact sheet (docs/gallery/contact-sheets/soccer-ball-goldberg-contact-sheet.webp): | tile | luma | stage | warmth | sat | |------------------------|-------|-------|--------|-------| | candidate | 0.267 | 0.217 | -0.011 | 0.250 | | armature-bend | 0.243 | 0.192 | -0.144 | 0.237 | | damped-track-aim | 0.237 | 0.218 | +0.105 | 0.178 | | bmesh-gear (redesign) | 0.237 | 0.252 | +0.092 | 0.153 | Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent de92629 commit 3cca51b

7 files changed

Lines changed: 174 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1059,7 +1059,7 @@ teleported flange) vs MPI KEEP.
10591059
</tr>
10601060
<tr>
10611061
<td width="46%" valign="middle">
1062-
<a href="examples/soccer-ball-goldberg/"><img src="examples/soccer-ball-goldberg/preview.webp" alt="Soccer ball Goldberg: a round white soccer ball with black pentagon panels and dark stitched seams around every hexagon on a dark studio floor — a bmesh icosphere truncated at one-third per edge into the Goldberg polyhedron, proving closed-form counts, uniform degree, equal edges, planar faces, one circumsphere, and panels bound by face vertex count" /></a>
1062+
<a href="examples/soccer-ball-goldberg/"><img src="examples/soccer-ball-goldberg/preview.webp" alt="Soccer ball Goldberg: a round white soccer ball with black pentagon panels and dark stitched seams around every hexagon, resting on mown pitch turf beside a chalked touchline — a bmesh icosphere truncated at one-third per edge into the Goldberg polyhedron, proving closed-form counts, uniform degree, equal edges, planar faces, one circumsphere, and panels bound by face vertex count" /></a>
10631063
</td>
10641064
<td valign="middle">
10651065

12.9 KB
Loading
7.09 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@
516516
"name": "soccer-ball-goldberg",
517517
"dir": "examples/soccer-ball-goldberg",
518518
"teaches": "A soccer ball as a Goldberg polyhedron: a bmesh icosphere truncated at 1/3 per edge, faces ordered by link-topology walks, panels bound by face vertex count.",
519-
"alt": "A round white soccer ball with black pentagon panels and dark stitched seams outlining every white hexagon, built as a smoothed Goldberg polyhedron.",
519+
"alt": "A white soccer ball with black pentagon panels and dark stitched seams, pressed into mown pitch turf on a chalked penalty spot with a touchline behind it.",
520520
"witnessesFix": "Goldberg invariants are closed forms — counts, degree 3, uniform edges, planar faces, one circumsphere — and panels bind by vertex count, never enumeration order.",
521521
"hero": "docs/gallery/assets/soccer-ball-goldberg-hero.webp",
522522
"preview": "examples/soccer-ball-goldberg/preview.webp",

‎examples/soccer-ball-goldberg/README.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,26 @@ None of this touches the checked mesh, its binding, or any check.
7474
render-only copy of the mesh, turned into a wire, subdivided and cast onto a
7575
sphere 2 mm proud of the ball, draws every panel boundary as a stitched seam.
7676
It is parented to the ball and bound to the dark slot on the copy only.
77+
- **Match leather.** Both panel materials carry a fine Voronoi pebble grain
78+
in the bump and a thin clear coat, so each panel holds a soft sheen.
79+
- **On the pitch.** The ball sits pressed 1 cm into a patch of mown turf:
80+
alternating mowing stripes, blade noise in colour and bump, and chalk
81+
(a penalty spot under the ball, a touchline behind it) broken up by the
82+
same blade noise. The chalk and stripes read Object coordinates on the
83+
turf, so the spot centres under the ball's origin.
7784
- **Framing gate.** The render path calls
7885
[`gallery_framing.check_framing`](../gallery_framing.py) before writing the
7986
still. The helper returns 10, which this example already spends on the
8087
centroid check, so the call site maps a violation to **14**. The larger,
8188
rounder ball touched the top edge at the old 55 mm lens; the lens is now
82-
50 mm.
89+
50 mm, and a `TRACK_TO` aim sits 0.25 below the ball's centre.
90+
91+
### Stage deviation
92+
93+
The floor is dark mown turf rather than the near-black studio floor: a ball
94+
alone on a black stage read as an object in a void, and the pitch is the
95+
context that makes it a match ball. The back wall and warm wedge are the
96+
default stage; measured stage luminance 0.217 sits inside the calibration set.
8397

8498
## Run
8599

11.7 KB
Loading

‎examples/soccer-ball-goldberg/soccer_ball_goldberg.py‎

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
blender --background --python soccer_ball_goldberg.py -- --output b.png # + render
1818
"""
1919
import bpy, bmesh, sys, os, math, argparse
20+
from mathutils import Vector
2021

2122
# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
2223
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
@@ -40,6 +41,12 @@
4041
SEAM_WIDTH = 0.018 # render only: stitched seam width on the ball
4142
SEAM_LIFT = 0.002 # render only: seam sits this far proud of the ball
4243
FRAMING_EXIT = 14 # render only: gallery_framing returns 10, taken here
44+
TURF_SIZE = (16.0, 14.0) # render only: pitch turf footprint (x, y)
45+
TURF_H = 0.22 # render only: sod sample thickness
46+
TURF_STRIPE = 0.6 # render only: mowing stripe width
47+
CHALK_SPOT_R = 0.42 # render only: penalty spot radius under the ball
48+
CHALK_LINE_Y = 0.95 # render only: touchline behind the ball
49+
CHALK_LINE_W = 0.12
4350

4451
# closed forms for a truncated icosahedron (Goldberg polyhedron GP(1,1))
4552
EXPECT_V, EXPECT_E, EXPECT_F = 60, 90, 32
@@ -282,17 +289,146 @@ def eevee_engine_id():
282289
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
283290

284291

292+
def _leather(mat, base, rough):
293+
"""Synthetic match leather: a fine pebble grain in the bump and a thin
294+
clear coat, so each panel carries a soft sheen instead of flat paint."""
295+
mat.use_nodes = True
296+
nt = mat.node_tree
297+
b = nt.nodes["Principled BSDF"]
298+
b.inputs["Base Color"].default_value = (*base, 1.0)
299+
b.inputs["Roughness"].default_value = rough
300+
b.inputs["Coat Weight"].default_value = 0.35
301+
b.inputs["Coat Roughness"].default_value = 0.25
302+
grain = nt.nodes.new("ShaderNodeTexVoronoi")
303+
grain.inputs["Scale"].default_value = 160.0
304+
bump = nt.nodes.new("ShaderNodeBump")
305+
bump.inputs["Strength"].default_value = 0.12
306+
bump.inputs["Distance"].default_value = 0.002
307+
nt.links.new(grain.outputs["Distance"], bump.inputs["Height"])
308+
nt.links.new(bump.outputs["Normal"], b.inputs["Normal"])
309+
310+
285311
def _panel_materials(obj):
286312
"""Leather finishes for the two panel classes (render path only)."""
287313
white, black = obj.data.materials
288-
white.use_nodes = True
289-
wb = white.node_tree.nodes["Principled BSDF"]
290-
wb.inputs["Base Color"].default_value = (0.82, 0.82, 0.84, 1.0)
291-
wb.inputs["Roughness"].default_value = 0.52
292-
black.use_nodes = True
293-
bb = black.node_tree.nodes["Principled BSDF"]
294-
bb.inputs["Base Color"].default_value = (0.018, 0.02, 0.024, 1.0)
295-
bb.inputs["Roughness"].default_value = 0.48
314+
_leather(white, (0.80, 0.80, 0.82), 0.48)
315+
_leather(black, (0.016, 0.018, 0.022), 0.42)
316+
317+
318+
def _turf_patch(scene):
319+
"""Render-only staging: a patch of mown pitch turf — striped grass on
320+
top, a soil edge on its (off-frame) sides — with a chalked penalty spot
321+
under the ball and a touchline running behind it."""
322+
me = bpy.data.meshes.new("Turf")
323+
bm = bmesh.new()
324+
try:
325+
res = bmesh.ops.create_cube(bm, size=1.0)
326+
for v in res["verts"]:
327+
v.co.x *= TURF_SIZE[0]
328+
v.co.y *= TURF_SIZE[1]
329+
v.co.z = (v.co.z + 0.5) * TURF_H
330+
bmesh.ops.bevel(bm, geom=[e for e in bm.edges], offset=0.06, segments=3,
331+
profile=0.5, affect='EDGES', clamp_overlap=True)
332+
for f in bm.faces:
333+
f.material_index = 0 if f.normal.z > 0.7 else 1
334+
f.smooth = True
335+
bm.to_mesh(me)
336+
finally:
337+
bm.free()
338+
339+
grass = bpy.data.materials.new("MownGrass")
340+
grass.use_nodes = True
341+
nt = grass.node_tree
342+
g = nt.nodes["Principled BSDF"]
343+
g.inputs["Roughness"].default_value = 0.85
344+
coord = nt.nodes.new("ShaderNodeTexCoord").outputs["Object"]
345+
# mowing stripes: alternating light/dark bands where the mower laid the
346+
# blades in opposite directions
347+
stripes = nt.nodes.new("ShaderNodeTexWave")
348+
stripes.wave_type = 'BANDS'
349+
stripes.bands_direction = 'X'
350+
stripes.wave_profile = 'SIN'
351+
stripes.inputs["Scale"].default_value = 2.0 * math.pi / (20.0 * TURF_STRIPE)
352+
nt.links.new(coord, stripes.inputs["Vector"])
353+
sharpen = nt.nodes.new("ShaderNodeMapRange")
354+
sharpen.inputs["From Min"].default_value = 0.4
355+
sharpen.inputs["From Max"].default_value = 0.6
356+
nt.links.new(stripes.outputs["Fac"], sharpen.inputs["Value"])
357+
blades = nt.nodes.new("ShaderNodeTexNoise")
358+
blades.inputs["Scale"].default_value = 90.0
359+
blades.inputs["Detail"].default_value = 10.0
360+
nt.links.new(coord, blades.inputs["Vector"])
361+
band = nt.nodes.new("ShaderNodeMix")
362+
band.data_type = 'RGBA'
363+
band.inputs[6].default_value = (0.016, 0.060, 0.013, 1.0)
364+
band.inputs[7].default_value = (0.032, 0.105, 0.021, 1.0)
365+
nt.links.new(sharpen.outputs["Result"], band.inputs[0])
366+
tuft = nt.nodes.new("ShaderNodeMix")
367+
tuft.data_type = 'RGBA'
368+
tuft.blend_type = 'MULTIPLY'
369+
tuft.inputs[0].default_value = 0.55
370+
nt.links.new(band.outputs[2], tuft.inputs[6])
371+
ramp = nt.nodes.new("ShaderNodeValToRGB")
372+
ramp.color_ramp.elements[0].color = (0.45, 0.45, 0.4, 1.0)
373+
ramp.color_ramp.elements[1].color = (1.25, 1.25, 1.1, 1.0)
374+
nt.links.new(blades.outputs["Fac"], ramp.inputs["Fac"])
375+
nt.links.new(ramp.outputs["Color"], tuft.inputs[7])
376+
# chalk: a penalty spot under the ball and a touchline behind it
377+
spot = nt.nodes.new("ShaderNodeVectorMath")
378+
spot.operation = 'LENGTH'
379+
nt.links.new(coord, spot.inputs[0])
380+
in_spot = nt.nodes.new("ShaderNodeMath")
381+
in_spot.operation = 'LESS_THAN'
382+
in_spot.inputs[1].default_value = CHALK_SPOT_R
383+
nt.links.new(spot.outputs["Value"], in_spot.inputs[0])
384+
sep = nt.nodes.new("ShaderNodeSeparateXYZ")
385+
nt.links.new(coord, sep.inputs["Vector"])
386+
off = nt.nodes.new("ShaderNodeMath")
387+
off.operation = 'SUBTRACT'
388+
off.inputs[1].default_value = CHALK_LINE_Y
389+
nt.links.new(sep.outputs["Y"], off.inputs[0])
390+
dist = nt.nodes.new("ShaderNodeMath")
391+
dist.operation = 'ABSOLUTE'
392+
nt.links.new(off.outputs["Value"], dist.inputs[0])
393+
in_line = nt.nodes.new("ShaderNodeMath")
394+
in_line.operation = 'LESS_THAN'
395+
in_line.inputs[1].default_value = CHALK_LINE_W / 2
396+
nt.links.new(dist.outputs["Value"], in_line.inputs[0])
397+
chalk = nt.nodes.new("ShaderNodeMath")
398+
chalk.operation = 'MAXIMUM'
399+
nt.links.new(in_spot.outputs["Value"], chalk.inputs[0])
400+
nt.links.new(in_line.outputs["Value"], chalk.inputs[1])
401+
# chalk sits in the grass: the blade noise breaks its edge up a little
402+
patchy = nt.nodes.new("ShaderNodeMath")
403+
patchy.operation = 'MULTIPLY'
404+
nt.links.new(chalk.outputs["Value"], patchy.inputs[0])
405+
nt.links.new(blades.outputs["Fac"], patchy.inputs[1])
406+
chalk_fac = nt.nodes.new("ShaderNodeMapRange")
407+
chalk_fac.inputs["From Min"].default_value = 0.2
408+
chalk_fac.inputs["From Max"].default_value = 0.45
409+
nt.links.new(patchy.outputs["Value"], chalk_fac.inputs["Value"])
410+
lined = nt.nodes.new("ShaderNodeMix")
411+
lined.data_type = 'RGBA'
412+
lined.inputs[7].default_value = (0.62, 0.64, 0.60, 1.0)
413+
nt.links.new(chalk_fac.outputs["Result"], lined.inputs[0])
414+
nt.links.new(tuft.outputs[2], lined.inputs[6])
415+
nt.links.new(lined.outputs[2], g.inputs["Base Color"])
416+
bump = nt.nodes.new("ShaderNodeBump")
417+
bump.inputs["Strength"].default_value = 0.5
418+
bump.inputs["Distance"].default_value = 0.01
419+
nt.links.new(blades.outputs["Fac"], bump.inputs["Height"])
420+
nt.links.new(bump.outputs["Normal"], g.inputs["Normal"])
421+
422+
soil = bpy.data.materials.new("Soil")
423+
soil.use_nodes = True
424+
s = soil.node_tree.nodes["Principled BSDF"]
425+
s.inputs["Base Color"].default_value = (0.055, 0.035, 0.02, 1.0)
426+
s.inputs["Roughness"].default_value = 0.95
427+
me.materials.append(grass)
428+
me.materials.append(soil)
429+
turf = bpy.data.objects.new("Turf", me)
430+
scene.collection.objects.link(turf)
431+
return turf
296432

297433

298434
def render_still(obj, path, engine):
@@ -359,7 +495,9 @@ def render_still(obj, path, engine):
359495
min_z = min((ev.matrix_world @ v.co).z for v in ev_me.vertices)
360496
finally:
361497
ev.to_mesh_clear() # no argument: clears this object's evaluated mesh
362-
obj.location.z -= min_z - 0.002 # 2 mm contact: grounded, not intersecting
498+
# seated on the turf: the ball presses 1 cm into the grass it rests on
499+
obj.location.z -= min_z - TURF_H + 0.01
500+
turf = _turf_patch(scene)
363501

364502
floor_me = bpy.data.meshes.new("Floor")
365503
bm = bmesh.new()
@@ -404,9 +542,16 @@ def light(name, loc, energy, size, col, rot):
404542
cam_data = bpy.data.cameras.new("Cam")
405543
cam_data.lens = 50.0
406544
cam = bpy.data.objects.new("Cam", cam_data)
407-
cam.location = (0.0, -7.2, 3.8)
408-
cam.rotation_euler = (math.radians(68), 0.0, 0.0)
545+
cam.location = (1.2, -7.0, 3.6)
409546
scene.collection.objects.link(cam)
547+
# aimed a little below the ball's centre so the turf and its chalk read
548+
aim = bpy.data.objects.new("Aim", None)
549+
aim.location = obj.location - Vector((0.0, 0.0, 0.25))
550+
scene.collection.objects.link(aim)
551+
track = cam.constraints.new('TRACK_TO')
552+
track.target = aim
553+
track.track_axis = 'TRACK_NEGATIVE_Z'
554+
track.up_axis = 'UP_Y'
410555
scene.camera = cam
411556

412557
scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
@@ -425,7 +570,7 @@ def light(name, loc, energy, size, col, rot):
425570
# (docs/VISUAL-STYLE.md); Standard is the house transform
426571
scene.view_settings.view_transform = 'Standard'
427572
if gallery_framing.check_framing(scene, cam, hero=[obj], elements=[obj],
428-
stage=[floor, wall]):
573+
stage=[floor, wall, turf]):
429574
return FRAMING_EXIT
430575
bpy.ops.render.render(write_still=True)
431576
if not (os.path.exists(path) and os.path.getsize(path) > 0):

0 commit comments

Comments
 (0)