Skip to content

Commit 8cf6499

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): redesign vertex-color-ao as a dressed-stone well with V-groove joints
The masonry was flat, identical plates with wide butt gaps, so the baked AO had almost nothing concave to find and the stone read as uniform grey. Every course block, coping slab and flagstone is now chamfered top and bottom (new `_plate(chamfer=)`), so neighbours meet in a V groove the bake can see into. Blocks have irregular widths, a shallow arced outer face and uneven heights, and the joints are tighter (half-gap 0.078 -> 0.018 m over the ring radius). The courses are 17/16/17 blocks. It is still 11 parts, so check 0 (EXPECT_PARTS) and every other check are untouched. Render path only: the shader raises the AO to the power 2.4 before multiplying it in (AO_GAMMA), so partly visible joints read dark. Stone gets a per-block Voronoi tone and a chisel bump; timber gets streaked grain. The key light is trimmed 400 -> 330 W. The stored attribute is unchanged. A roof stays out: the script records that two gable attempts hid the masonry that carries the AO story. Evidence (live runs, local): - .scratch/blender-5.2.1-windows-x64/blender.exe (Blender 5.2.1 LTS): check exit 0, cal_err=6.760e-04, unoccluded=1.000000, asset_bake parts=11 verts=15678 (was 6966) spread=1.000, byte_model_err=3.247e-07, evaluated_dev=0. --output: framing fill y=0.833, margins >= 0.078; aq_ok (materials n=11, edge90 0.018). --falsify renders (inverted AO). - .scratch/blender-4.5.11-windows-x64/blender.exe (Blender 4.5.11 LTS): check exit 0 with identical numbers (verts=15678). - Checks are unchanged, so the existing falsifiers stand; not re-run. - Contact sheet: candidate luma 0.264 / stage 0.232 / warmth +0.039 / sat 0.136, 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. Saturation is low: stone and timber. - Asset sheet (docs/gallery/asset-sheets/vertex-color-ao.webp): the well reads as designed beside the hydrant, drone, wheelbarrow and shelf; not the least-designed object in the lineup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3cca51b commit 8cf6499

8 files changed

Lines changed: 145 additions & 46 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,7 +1204,7 @@ under animation).
12041204
</tr>
12051205
<tr>
12061206
<td width="46%" valign="middle">
1207-
<a href="examples/vertex-color-ao/"><img src="examples/vertex-color-ao/preview.webp" alt="Vertex colour AO: a stone village well on a dark studio stage, its masonry joints, shaft mouth and coping undersides darkened by ambient occlusion baked into a colour attribute rather than by the lights" /></a>
1207+
<a href="examples/vertex-color-ao/"><img src="examples/vertex-color-ao/preview.webp" alt="Vertex colour AO: a stone village well of chamfered, irregular blocks on a dark studio stage, its V-groove masonry joints, shaft mouth and coping undersides darkened by ambient occlusion baked into a colour attribute rather than by the lights" /></a>
12081208
</td>
12091209
<td valign="middle">
12101210

14.3 KB
Loading
2.79 KB
Loading
-1.62 KB
Loading

‎examples/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@
617617
"name": "vertex-color-ao",
618618
"dir": "examples/vertex-color-ao",
619619
"teaches": "A stone well carrying baked ambient occlusion in a colour attribute, with the bake held to the closed-form hemisphere integral rather than to a captured value.",
620-
"alt": "A round stone well with a wooden windlass frame, its stones darkened near the base by baked vertex-colour ambient occlusion.",
620+
"alt": "A round well of chamfered, irregular stone blocks with a timber windlass frame, every masonry joint and the shaft darkened by baked vertex-colour ambient occlusion.",
621621
"witnessesFix": "Integrator matches 1 - 0.5(1 - 1/sqrt(1+k^2)) to 6.760e-04, unoccluded plate exactly 1.0, FLOAT_COLOR exact vs BYTE_COLOR sRGB-quantised (model agreement 3.189e-07), evaluated deviation 0.0.",
622622
"hero": "docs/gallery/assets/vertex-color-ao-hero.webp",
623623
"preview": "examples/vertex-color-ao/preview.webp",

‎examples/vertex-color-ao/README.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ exactly "a concave corner darkens monotonically with depth", as an equation.
1818
For an unoccluded flat surface the integral is exactly **1**.
1919

2020
**The asset, for reuse:** `Well.Stone`, a 2.5 m stone village well — three
21-
courses of 15 masonry blocks each on a 16-slab paved apron, a 15-slab coping
21+
courses of 16-17 chamfered masonry blocks of irregular width on a 16-slab
22+
paved apron, a 15-slab coping
2223
ring, a lined bore, two braced timber posts with a crossbeam, an iron-banded
23-
windlass with a crank, and a hanging bucket. 11 parts, 11 materials, 6966
24+
windlass with a crank, and a hanging bucket. 11 parts, 11 materials, 15678
2425
vertices. Origin at the ground contact centre (`z == 0` is where it rests),
2526
identity transforms, datablocks under `Well.Stone.*`. Every part ships with the
2627
AO baked into two colour attributes, and `default_color_name` points at the
@@ -97,13 +98,18 @@ attribute reported a clean **0.0** deviation and the probe exited **0**. The
9798
length guard was added because the falsification pass failed to fail.
9899

99100
**Version witness:** check output is byte-identical on Blender 4.5.11 LTS and
100-
5.1.2 — same 6966 vertices, same closed-form errors to every printed digit,
101+
5.1.2 — same 15678 vertices, same closed-form errors to every printed digit,
101102
same storage deviations.
102103

103104
**Render as proof:** the well on the dark stage, lit only by the studio rig,
104105
with the baked AO multiplied into base colour — the masonry joints, the shaft
105106
mouth, the coping undersides and the apron contact all darken from the
106-
attribute, not from the lights. The falsification variant (`--falsify`) writes
107+
attribute, not from the lights. Every block, coping slab and flagstone is
108+
chamfered top and bottom, so neighbours meet in a V groove the bake can see
109+
into. The shader raises the AO to the power 2.4 before multiplying it in, so a
110+
joint that is 70 % visible reads clearly darker; stone gets a per-block
111+
Voronoi tone and a chisel bump, timber a streaked grain. The stored attribute
112+
is untouched by any of this. The falsification variant (`--falsify`) writes
107113
the same bake **inverted**: sky-facing coping and apron tops go grimy while the
108114
recesses and post bases glow, occlusion turned inside out.
109115

2.45 KB
Loading

‎examples/vertex-color-ao/vertex_color_ao.py‎

Lines changed: 133 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -350,17 +350,40 @@ def build(t):
350350
_emit(bm, build, cuts=cuts)
351351

352352

353-
def _plate(bm, verts_xy, z0, z1, cuts=2):
354-
"""A prism from an explicit polygon footprint, subdivided for the bake."""
353+
def _plate(bm, verts_xy, z0, z1, cuts=2, chamfer=0.0):
354+
"""A prism from an explicit polygon footprint, subdivided for the bake.
355+
356+
chamfer > 0 insets the top and bottom rings toward the footprint centroid
357+
by that distance over the same height, so stacked blocks meet in a V
358+
groove: real concave geometry for the AO bake to darken, instead of a
359+
butt joint the rays cannot see into.
360+
"""
361+
n = len(verts_xy)
362+
cx = sum(x for x, _ in verts_xy) / n
363+
cy = sum(y for _, y in verts_xy) / n
364+
365+
def inset(d):
366+
out = []
367+
for x, y in verts_xy:
368+
dx, dy = x - cx, y - cy
369+
ln = math.hypot(dx, dy) or 1.0
370+
k = max(0.0, 1.0 - d / ln)
371+
out.append((cx + dx * k, cy + dy * k))
372+
return out
373+
355374
def build(t):
356-
top = [t.verts.new((x, y, z1)) for x, y in verts_xy]
357-
bot = [t.verts.new((x, y, z0)) for x, y in verts_xy]
358-
t.faces.new(top)
359-
t.faces.new(list(reversed(bot)))
360-
n = len(verts_xy)
361-
for i in range(n):
362-
j = (i + 1) % n
363-
t.faces.new((bot[i], bot[j], top[j], top[i]))
375+
if chamfer > 0.0:
376+
rings = [(inset(chamfer), z0), (verts_xy, z0 + chamfer),
377+
(verts_xy, z1 - chamfer), (inset(chamfer), z1)]
378+
else:
379+
rings = [(verts_xy, z0), (verts_xy, z1)]
380+
loops = [[t.verts.new((x, y, z)) for x, y in pts] for pts, z in rings]
381+
t.faces.new(loops[-1])
382+
t.faces.new(list(reversed(loops[0])))
383+
for lo, hi in zip(loops, loops[1:]):
384+
for i in range(n):
385+
j = (i + 1) % n
386+
t.faces.new((lo[i], lo[j], hi[j], hi[i]))
364387
_emit(bm, build, cuts=cuts)
365388

366389

@@ -374,9 +397,9 @@ def build(t):
374397
# dropping the frame puts the stonework where the eye lands.
375398
SHAFT_R = 0.545 # inner bore radius
376399
COURSES = ( # (z0, z1, outer radius, stone count, phase offset)
377-
(0.000, 0.200, 0.845, 15, 0.00),
378-
(0.200, 0.385, 0.822, 15, 0.50),
379-
(0.385, 0.560, 0.805, 15, 0.00),
400+
(0.000, 0.200, 0.845, 17, 0.00),
401+
(0.200, 0.385, 0.822, 16, 0.50),
402+
(0.385, 0.560, 0.805, 17, 0.25),
380403
)
381404
COPING_Z0, COPING_Z1 = 0.560, 0.646
382405
COPING_R = 0.925
@@ -391,24 +414,29 @@ def _course_stone(bm, z0, z1, r_out, idx, count, phase, jog):
391414
Blocks are inset from each other by a small joint so the bake has real
392415
crevices to find — the mortar lines are geometry, not a texture.
393416
"""
394-
joint = 0.078 / r_out # angular half-gap
395-
a0 = 2.0 * math.pi * (idx + phase) / count + joint
396-
a1 = 2.0 * math.pi * (idx + 1 + phase) / count - joint
417+
joint = 0.018 / r_out # angular half-gap: tight joints
418+
# irregular block widths: each seam wanders by up to a fifth of a block
419+
def seam(k):
420+
return k + 0.2 * math.sin(k * 2.7 + count * 0.9 + phase * 5.0)
421+
a0 = 2.0 * math.pi * (seam(idx) + phase) / count + joint
422+
a1 = 2.0 * math.pi * (seam(idx + 1) + phase) / count - joint
397423
ro = r_out + jog
398424
ri = SHAFT_R
399425
pts = []
400-
for a in (a0, a1):
426+
for k in range(4): # outer face as a shallow arc
427+
a = a0 + (a1 - a0) * k / 3
401428
pts.append((math.cos(a) * ro, math.sin(a) * ro))
402429
for a in (a1, a0):
403430
pts.append((math.cos(a) * ri, math.sin(a) * ri))
404-
_plate(bm, pts, z0 + 0.012, z1 - 0.012)
431+
lift = 0.012 * math.sin(idx * 1.93 + count) # uneven block heights
432+
_plate(bm, pts, z0 + 0.004, z1 - 0.004 + lift, chamfer=0.026)
405433

406434

407435
def build_well_meshes():
408436
meshes = {}
409437
# deterministic per-stone jog so the courses are not machine-perfect
410438
def jog(i, c):
411-
return 0.014 * math.sin(i * 2.399963 + c * 1.107)
439+
return 0.026 * math.sin(i * 2.399963 + c * 1.107)
412440

413441
for ci, (z0, z1, r_out, count, phase) in enumerate(COURSES):
414442
part = Part()
@@ -431,7 +459,8 @@ def slabs(bm):
431459
for a in (a0, a1)]
432460
pts += [(math.cos(a) * (SHAFT_R - 0.02),
433461
math.sin(a) * (SHAFT_R - 0.02)) for a in (a1, a0)]
434-
_plate(bm, pts, COPING_Z0, COPING_Z1 + 0.006 * math.sin(i * 1.7))
462+
_plate(bm, pts, COPING_Z0, COPING_Z1 + 0.006 * math.sin(i * 1.7),
463+
chamfer=0.022)
435464
cop.group("Coping", slabs)
436465
meshes["Coping"] = cop.finish("Well.Stone.Coping")
437466

@@ -451,13 +480,14 @@ def apron(bm):
451480
# sits, the deeper the contact darkening the bake has to find
452481
n = 16
453482
for i in range(n):
454-
joint = 0.032 / 1.05
483+
joint = 0.018 / 1.05
455484
a0 = 2.0 * math.pi * i / n + joint
456485
a1 = 2.0 * math.pi * (i + 1) / n - joint
457486
r0, r1 = 0.870, 1.235 + 0.045 * math.sin(i * 2.1)
458487
pts = [(math.cos(a) * r1, math.sin(a) * r1) for a in (a0, a1)]
459488
pts += [(math.cos(a) * r0, math.sin(a) * r0) for a in (a1, a0)]
460-
_plate(bm, pts, 0.0, 0.048 + 0.008 * math.sin(i * 1.3), cuts=2)
489+
_plate(bm, pts, 0.0, 0.048 + 0.008 * math.sin(i * 1.3), cuts=2,
490+
chamfer=0.014)
461491
ap.group("Flag", apron)
462492
meshes["Apron"] = ap.finish("Well.Stone.Apron")
463493

@@ -748,8 +778,22 @@ def fail(code, msg):
748778
# Render
749779
# ---------------------------------------------------------------------------
750780

751-
def make_ao_material(name, rgb, rough, metallic=0.0, ao_strength=1.0):
752-
"""Principled with the AO colour attribute multiplied into base colour."""
781+
# AO contrast for the render: the baked value is a visibility fraction, and a
782+
# linear multiply leaves a 0.7-visible joint only 30 % darker - on grey stone
783+
# that read as no occlusion at all. Raising it to this power before the
784+
# multiply deepens the crevices without touching the stored attribute (the
785+
# check reads the attribute, never the shader).
786+
AO_GAMMA = 2.4
787+
788+
789+
def make_ao_material(name, rgb, rough, metallic=0.0, ao_strength=1.0,
790+
surface=None):
791+
"""Principled with the AO colour attribute multiplied into base colour.
792+
793+
surface: None, "stone" (Voronoi per-block tone + chisel bump) or "wood"
794+
(stretched grain bands). Both read Object coordinates so every part keeps
795+
its own pattern regardless of where the asset is placed.
796+
"""
753797
mat = bpy.data.materials.new(name)
754798
mat.use_nodes = True
755799
nt = mat.node_tree
@@ -759,35 +803,84 @@ def make_ao_material(name, rgb, rough, metallic=0.0, ao_strength=1.0):
759803
col = nt.nodes.new("ShaderNodeVertexColor")
760804
col.layer_name = AO_ATTR
761805
col.location = (-700, 100)
806+
gamma = nt.nodes.new("ShaderNodeGamma")
807+
gamma.inputs["Gamma"].default_value = AO_GAMMA
808+
nt.links.new(col.outputs["Color"], gamma.inputs["Color"])
762809
# lift so full occlusion does not go to pure black
763810
lift = nt.nodes.new("ShaderNodeMixRGB")
764811
lift.blend_type = "MIX"
765812
lift.inputs["Fac"].default_value = ao_strength
766813
lift.inputs["Color1"].default_value = (1.0, 1.0, 1.0, 1.0)
767814
lift.location = (-500, 100)
768-
nt.links.new(col.outputs["Color"], lift.inputs["Color2"])
815+
nt.links.new(gamma.outputs["Color"], lift.inputs["Color2"])
769816
tint = nt.nodes.new("ShaderNodeMixRGB")
770817
tint.blend_type = "MULTIPLY"
771818
tint.inputs["Fac"].default_value = 1.0
772819
tint.inputs["Color1"].default_value = (*rgb, 1.0)
773820
tint.location = (-300, 100)
774821
nt.links.new(lift.outputs["Color"], tint.inputs["Color2"])
775-
nt.links.new(tint.outputs["Color"], bsdf.inputs["Base Color"])
822+
823+
base = tint.outputs["Color"]
824+
if surface in ("stone", "wood"):
825+
coord = nt.nodes.new("ShaderNodeTexCoord").outputs["Object"]
826+
if surface == "stone":
827+
# one tone per block: Voronoi cells about a stone wide, each cell
828+
# a flat random value, so neighbouring blocks differ in colour
829+
vor = nt.nodes.new("ShaderNodeTexVoronoi")
830+
vor.inputs["Scale"].default_value = 3.2
831+
nt.links.new(coord, vor.inputs["Vector"])
832+
tone = nt.nodes.new("ShaderNodeValToRGB")
833+
tone.color_ramp.elements[0].color = (0.72, 0.70, 0.66, 1.0)
834+
tone.color_ramp.elements[1].color = (1.35, 1.24, 1.08, 1.0)
835+
nt.links.new(vor.outputs["Color"], tone.inputs["Fac"])
836+
grain = nt.nodes.new("ShaderNodeTexNoise")
837+
grain.inputs["Scale"].default_value = 26.0
838+
grain.inputs["Detail"].default_value = 8.0
839+
nt.links.new(coord, grain.inputs["Vector"])
840+
bump = nt.nodes.new("ShaderNodeBump")
841+
bump.inputs["Strength"].default_value = 0.35
842+
bump.inputs["Distance"].default_value = 0.01
843+
nt.links.new(grain.outputs["Fac"], bump.inputs["Height"])
844+
nt.links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
845+
else:
846+
# grain: noise squeezed in Y and Z, so it varies across X and
847+
# streaks along both the Y-running beam/drum and the Z-running posts
848+
mapping = nt.nodes.new("ShaderNodeMapping")
849+
mapping.inputs["Scale"].default_value = (1.0, 0.06, 0.06)
850+
nt.links.new(coord, mapping.inputs["Vector"])
851+
wave = nt.nodes.new("ShaderNodeTexNoise")
852+
wave.inputs["Scale"].default_value = 38.0
853+
wave.inputs["Detail"].default_value = 6.0
854+
nt.links.new(mapping.outputs["Vector"], wave.inputs["Vector"])
855+
tone = nt.nodes.new("ShaderNodeValToRGB")
856+
tone.color_ramp.elements[0].position = 0.3
857+
tone.color_ramp.elements[1].position = 0.7
858+
tone.color_ramp.elements[0].color = (0.80, 0.76, 0.72, 1.0)
859+
tone.color_ramp.elements[1].color = (1.14, 1.10, 1.05, 1.0)
860+
nt.links.new(wave.outputs["Fac"], tone.inputs["Fac"])
861+
vary = nt.nodes.new("ShaderNodeMixRGB")
862+
vary.blend_type = "MULTIPLY"
863+
vary.inputs["Fac"].default_value = 1.0
864+
nt.links.new(base, vary.inputs["Color1"])
865+
nt.links.new(tone.outputs["Color"], vary.inputs["Color2"])
866+
base = vary.outputs["Color"]
867+
nt.links.new(base, bsdf.inputs["Base Color"])
776868
return mat
777869

778870

871+
# (rgb, roughness, metallic, surface)
779872
SLOT_MATS = {
780-
"Stone": ((0.086, 0.079, 0.069), 0.86, 0.0),
781-
"Coping": ((0.118, 0.108, 0.094), 0.80, 0.0),
782-
"Bore": ((0.038, 0.035, 0.032), 0.92, 0.0),
783-
"Flag": ((0.066, 0.061, 0.055), 0.89, 0.0),
784-
"Timber": ((0.148, 0.078, 0.030), 0.74, 0.0),
785-
"Brace": ((0.120, 0.064, 0.026), 0.76, 0.0),
786-
"Shingle": ((0.092, 0.056, 0.028), 0.82, 0.0),
787-
"Drum": ((0.170, 0.098, 0.040), 0.72, 0.0),
788-
"Stave": ((0.205, 0.118, 0.048), 0.70, 0.0),
789-
"Rope": ((0.255, 0.208, 0.122), 0.88, 0.0),
790-
"Iron": ((0.075, 0.078, 0.084), 0.44, 0.85),
873+
"Stone": ((0.150, 0.136, 0.114), 0.86, 0.0, "stone"),
874+
"Coping": ((0.188, 0.170, 0.142), 0.80, 0.0, "stone"),
875+
"Bore": ((0.040, 0.036, 0.032), 0.92, 0.0, None),
876+
"Flag": ((0.112, 0.104, 0.092), 0.89, 0.0, "stone"),
877+
"Timber": ((0.210, 0.110, 0.042), 0.70, 0.0, "wood"),
878+
"Brace": ((0.170, 0.090, 0.036), 0.72, 0.0, "wood"),
879+
"Shingle": ((0.130, 0.078, 0.036), 0.78, 0.0, "wood"),
880+
"Drum": ((0.230, 0.132, 0.054), 0.68, 0.0, "wood"),
881+
"Stave": ((0.290, 0.166, 0.066), 0.66, 0.0, "wood"),
882+
"Rope": ((0.330, 0.268, 0.160), 0.88, 0.0, None),
883+
"Iron": ((0.075, 0.078, 0.084), 0.44, 0.85, None),
791884
}
792885

793886
_mat_cache = {}
@@ -800,9 +893,9 @@ def bind_materials(ob, ao_strength=1.0):
800893
for slot in me.get("slots", []):
801894
key = (slot, ao_strength)
802895
if key not in _mat_cache:
803-
rgb, rough, metal = SLOT_MATS[slot]
896+
rgb, rough, metal, surface = SLOT_MATS[slot]
804897
_mat_cache[key] = make_ao_material(slot, rgb, rough, metal,
805-
ao_strength)
898+
ao_strength, surface)
806899
me.materials.append(_mat_cache[key])
807900

808901

@@ -845,7 +938,7 @@ def light(name, loc, energy, size, col, rot):
845938
# and rim used to be strongly blue and lit the whole stage cold (stage
846939
# luma 0.243, warmth +0.01 against the calibration set); they are now
847940
# near neutral and weaker, and the warm wedge carries the back wall.
848-
light("Key", (-2.3, -2.4, 3.6), 400.0, 3.0, (1.0, 0.95, 0.88), (40, 0, -42))
941+
light("Key", (-2.3, -2.4, 3.6), 330.0, 2.4, (1.0, 0.95, 0.88), (40, 0, -42))
849942
light("Fill", (3.0, -2.0, 1.2), 45.0, 5.0, (0.88, 0.92, 1.0), (70, 0, 54))
850943
light("Rim", (-0.9, 3.0, 2.6), 170.0, 2.0, (0.85, 0.88, 0.95), (-54, 0, 196))
851944
light("Wedge", (0.3, 5.0, 0.7), 150.0, 4.0, (1.0, 0.70, 0.40), (-86, 0, 182))

0 commit comments

Comments
 (0)