Skip to content

Commit 60255eb

Browse files
TMHSDigitalclaude
andcommitted
fix: signpost quality pass, worn paint and rusted iron
No geometric defect found. Boards are cream paint worn through to the wood, the iron is rusted instead of chrome, per-piece wood, 60 m stage, sorted bevel input, temp glb removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent 5bbf13e commit 60255eb

7 files changed

Lines changed: 389 additions & 40 deletions

File tree

672 Bytes
Loading
276 Bytes
Loading

‎docs/gallery/signpost/index.html‎

Lines changed: 190 additions & 21 deletions
Large diffs are not rendered by default.

‎showcase/gallery.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@
247247
"name": "signpost",
248248
"dir": "showcase/signpost",
249249
"teaches": "A procedural timber fingerboard signpost through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
250-
"alt": "A wooden signpost with two pointed finger boards and dark iron bands on the post.",
250+
"alt": "A wooden signpost with two pointed finger boards in worn cream paint and rusted iron bands on the post.",
251251
"witnessesFix": "Recomputed: 376 tris, three materials with 108 metal, 22 board and 60 wood faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.260×0.142×1.551 m at zmin 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD1/LOD2), convex collider 52 tris, non-empty glTF. Hygiene all zero. Board-to-post gap 5 mm, strap gap 3 mm, board AABB Y 28 mm. --skip-decimate exits 9; --stray-vert 15; --lift-z / --short-shoe 16; --gap-board 17; --float-strap 18; --yaw-boards 19.",
252252
"hero": "docs/gallery/assets/signpost-hero.webp",
253253
"preview": "showcase/signpost/preview.webp",

‎showcase/signpost/README.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ materials, UVs, evaluated LOD, collider, or export file.
4141
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
4242
| Outer AABB | (1.260, 0.142, 1.551) m ± 0.015 | (1.2600, 0.1420, 1.5510), zmin 0 |
4343
| Collider tris | ≤ 180 | 52 |
44-
| Export | written, size > 0 | 35688 / 35688 / 35676 bytes |
44+
| Export | written, size > 0 | 35648 / 35648 / 35640 bytes |
4545

4646
Base triangles dropped from **504 to 376** in the quality pass: glued
4747
diamond cubes and a solid shoe block became wedges and a four-plate
@@ -50,9 +50,22 @@ collar. Outer AABB tightened from 1.368 × 0.201 × 1.566 m.
5050
DECIMATE COLLAPSE triangle counts are **not** identical across series —
5151
5.2.1 is leaner on LOD1 and LOD2. The gate is a ratio band, not an
5252
exact count. Bake pixels are stochastic; the gate is `has_data` plus
53-
operator `FINISHED`, not byte-identity. Construction uses no RNG.
54-
Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a
55-
gated axis.
53+
operator `FINISHED`, not byte-identity. Construction is closed-form;
54+
the only RNG is the seeded per-piece wood tone. Bevel inputs are sorted
55+
by edge index, so the face order is the same on every run. Export byte
56+
counts differ on 5.2.1 (glTF serializer), not a gated axis.
57+
58+
### Surface and stage
59+
60+
The quality pass found no geometric defect: the boards, straps, bands
61+
and shoe seat as their budgets say. The defects were surface. The finger
62+
boards were flat pale yellow; they are now cream paint worn through to
63+
the wood by a noise mask, a shade apart per board. The iron read as
64+
chrome (metallic 1.0, roughness 0.38) and is now rusted. The post has its
65+
own tone and grain. The stage grid grew from 14 to 60 m, because the
66+
wall's left edge showed in the corner of the hero. The temp `.glb` is
67+
removed after its size is measured. With no new geometric defect, there
68+
is no new budget.
5669

5770
### Hygiene
5871

‎showcase/signpost/preview.webp‎

536 Bytes
Loading

‎showcase/signpost/signpost.py‎

Lines changed: 181 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
origin. Hygiene family 15–19: ``--stray-vert``, ``--short-shoe``,
1212
``--gap-board``, ``--float-strap``, ``--yaw-boards``.
1313
14-
No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
14+
Construction is closed-form; the only RNG is the seeded per-piece wood
15+
tone. DECIMATE COLLAPSE triangle counts
1516
are not byte-identical across Blender versions — the LOD gate is a
1617
ratio band, not an exact count.
1718
@@ -22,6 +23,7 @@
2223
import argparse
2324
import math
2425
import os
26+
import random
2527
import sys
2628
import tempfile
2729
import traceback
@@ -83,6 +85,11 @@
8385
BOARD_IDX = 1
8486
METAL_IDX = 2
8587

88+
# Per-piece wood tone jitter and grain frequency, as in shipping-crate.
89+
PLANK_TONE_JITTER = 0.28
90+
TONE_SEED = 29
91+
WOOD_GRAIN_SCALE = 30.0
92+
8693
DOUBLES_EPS = 1e-5
8794
AREA_EPS = 1e-10
8895
ZMIN_EPS = 1e-4
@@ -267,6 +274,10 @@ def build_signpost_mesh(
267274

268275
if bevel_offset > 0.0:
269276
edges = list({e for v in wood for e in v.link_edges})
277+
# set order follows memory addresses; sort so the bevel, and the
278+
# face order it produces, are the same on every run
279+
bm.edges.index_update()
280+
edges.sort(key=lambda e: e.index)
270281
ret = bmesh.ops.bevel(
271282
bm,
272283
geom=edges,
@@ -381,6 +392,165 @@ def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None):
381392
return mat
382393

383394

395+
def _long_axis(pts):
396+
"""Principal axis of a point set, by power iteration on its covariance."""
397+
c = sum(pts, Vector()) / len(pts)
398+
cov = [[0.0] * 3 for _ in range(3)]
399+
for p in pts:
400+
d = p - c
401+
for i in range(3):
402+
for j in range(3):
403+
cov[i][j] += d[i] * d[j]
404+
v = Vector((1.0, 0.3, 0.1))
405+
for _ in range(30):
406+
w = Vector([sum(cov[i][j] * v[j] for j in range(3)) for i in range(3)])
407+
if w.length < 1e-12:
408+
break
409+
v = w.normalized()
410+
return v
411+
412+
413+
def paint_planks(me):
414+
"""Per-shell ``PlankTone`` and ``GrainDir`` face attributes for the wood shader.
415+
416+
The post and each finger board is its own shell, so each gets one
417+
tone and grain running along its own long axis.
418+
"""
419+
tone = [0.5] * len(me.polygons)
420+
grain = [(0.0, 0.0, 1.0)] * len(me.polygons)
421+
owner = {}
422+
rng = random.Random(TONE_SEED)
423+
for g in shells(me):
424+
pts = [me.vertices[i].co.copy() for i in g]
425+
d = _long_axis(pts) if len(pts) > 2 else Vector((0.0, 0.0, 1.0))
426+
t = 0.5 + rng.uniform(-PLANK_TONE_JITTER, PLANK_TONE_JITTER)
427+
for i in g:
428+
owner[i] = (t, tuple(d))
429+
for poly in me.polygons:
430+
t, d = owner[poly.vertices[0]]
431+
tone[poly.index] = t
432+
grain[poly.index] = d
433+
a = me.attributes.new("PlankTone", "FLOAT", "FACE")
434+
a.data.foreach_set("value", tone)
435+
b = me.attributes.new("GrainDir", "FLOAT_VECTOR", "FACE")
436+
b.data.foreach_set("vector", [c for v in grain for c in v])
437+
438+
439+
def _sock(sockets, identifier):
440+
"""A Mix-node socket by identifier; its A/B/Result names repeat per type."""
441+
return next(sk for sk in sockets if sk.identifier == identifier)
442+
443+
444+
def wood_material(name):
445+
"""Grain along the post (``GrainDir``), tone per piece (``PlankTone``)."""
446+
mat = bpy.data.materials.new(name)
447+
mat.use_nodes = True
448+
nt = mat.node_tree
449+
bsdf = nt.nodes["Principled BSDF"]
450+
coord = nt.nodes.new("ShaderNodeTexCoord")
451+
gdir = nt.nodes.new("ShaderNodeAttribute")
452+
gdir.attribute_name = "GrainDir"
453+
tone = nt.nodes.new("ShaderNodeAttribute")
454+
tone.attribute_name = "PlankTone"
455+
dot = nt.nodes.new("ShaderNodeVectorMath")
456+
dot.operation = "DOT_PRODUCT"
457+
nt.links.new(coord.outputs["Object"], dot.inputs[0])
458+
nt.links.new(gdir.outputs["Vector"], dot.inputs[1])
459+
squash = nt.nodes.new("ShaderNodeMath")
460+
squash.operation = "MULTIPLY"
461+
squash.inputs[1].default_value = 0.94
462+
nt.links.new(dot.outputs["Value"], squash.inputs[0])
463+
along = nt.nodes.new("ShaderNodeVectorMath")
464+
along.operation = "SCALE"
465+
nt.links.new(gdir.outputs["Vector"], along.inputs[0])
466+
nt.links.new(squash.outputs["Value"], along.inputs["Scale"])
467+
grain_co = nt.nodes.new("ShaderNodeVectorMath")
468+
grain_co.operation = "SUBTRACT"
469+
nt.links.new(coord.outputs["Object"], grain_co.inputs[0])
470+
nt.links.new(along.outputs["Vector"], grain_co.inputs[1])
471+
shift = nt.nodes.new("ShaderNodeVectorMath")
472+
shift.operation = "ADD"
473+
nt.links.new(grain_co.outputs["Vector"], shift.inputs[0])
474+
nt.links.new(tone.outputs["Fac"], shift.inputs[1])
475+
noise = nt.nodes.new("ShaderNodeTexNoise")
476+
noise.inputs["Scale"].default_value = WOOD_GRAIN_SCALE
477+
noise.inputs["Detail"].default_value = 6.0
478+
noise.inputs["Roughness"].default_value = 0.62
479+
nt.links.new(shift.outputs["Vector"], noise.inputs["Vector"])
480+
ramp = nt.nodes.new("ShaderNodeValToRGB")
481+
ramp.color_ramp.elements[0].position = 0.30
482+
ramp.color_ramp.elements[0].color = (0.12, 0.052, 0.018, 1.0)
483+
ramp.color_ramp.elements[1].position = 0.72
484+
ramp.color_ramp.elements[1].color = (0.38, 0.18, 0.065, 1.0)
485+
nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
486+
gain = nt.nodes.new("ShaderNodeMath")
487+
gain.operation = "MULTIPLY_ADD"
488+
gain.inputs[1].default_value = 1.1
489+
gain.inputs[2].default_value = 0.45
490+
nt.links.new(tone.outputs["Fac"], gain.inputs[0])
491+
mix = nt.nodes.new("ShaderNodeMix")
492+
mix.data_type = "RGBA"
493+
mix.blend_type = "MULTIPLY"
494+
_sock(mix.inputs, "Factor_Float").default_value = 1.0
495+
nt.links.new(ramp.outputs["Color"], _sock(mix.inputs, "A_Color"))
496+
nt.links.new(gain.outputs["Value"], _sock(mix.inputs, "B_Color"))
497+
nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
498+
rough = nt.nodes.new("ShaderNodeMapRange")
499+
rough.inputs["To Min"].default_value = 0.72
500+
rough.inputs["To Max"].default_value = 0.52
501+
nt.links.new(noise.outputs["Fac"], rough.inputs["Value"])
502+
nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"])
503+
return mat
504+
505+
506+
def board_material(name):
507+
"""Painted finger boards, weathered: cream paint worn through to wood.
508+
509+
A noise mask on object coordinates picks where the paint has gone and
510+
the wood grain shows; ``PlankTone`` shifts each board's paint so the
511+
two boards are not one board twice.
512+
"""
513+
mat = wood_material(name)
514+
nt = mat.node_tree
515+
bsdf = nt.nodes["Principled BSDF"]
516+
grain = bsdf.inputs["Base Color"].links[0].from_socket
517+
coord = nt.nodes.new("ShaderNodeTexCoord")
518+
wear = nt.nodes.new("ShaderNodeTexNoise")
519+
wear.inputs["Scale"].default_value = 18.0
520+
wear.inputs["Detail"].default_value = 8.0
521+
wear.inputs["Roughness"].default_value = 0.65
522+
nt.links.new(coord.outputs["Object"], wear.inputs["Vector"])
523+
mask = nt.nodes.new("ShaderNodeValToRGB")
524+
mask.color_ramp.elements[0].position = 0.58
525+
mask.color_ramp.elements[1].position = 0.66
526+
nt.links.new(wear.outputs["Fac"], mask.inputs["Fac"])
527+
tone = nt.nodes.new("ShaderNodeAttribute")
528+
tone.attribute_name = "PlankTone"
529+
paint = nt.nodes.new("ShaderNodeMix")
530+
paint.data_type = "RGBA"
531+
_sock(paint.inputs, "A_Color").default_value = (0.62, 0.56, 0.42, 1.0)
532+
_sock(paint.inputs, "B_Color").default_value = (0.74, 0.68, 0.52, 1.0)
533+
nt.links.new(tone.outputs["Fac"], _sock(paint.inputs, "Factor_Float"))
534+
mix = nt.nodes.new("ShaderNodeMix")
535+
mix.data_type = "RGBA"
536+
nt.links.new(mask.outputs["Color"], _sock(mix.inputs, "Factor_Float"))
537+
nt.links.new(_sock(paint.outputs, "Result_Color"), _sock(mix.inputs, "A_Color"))
538+
nt.links.new(grain, _sock(mix.inputs, "B_Color"))
539+
nt.links.new(_sock(mix.outputs, "Result_Color"), bsdf.inputs["Base Color"])
540+
return mat
541+
542+
543+
def signpost_materials():
544+
"""(wood, board, iron): shared by the check, the render and inspection."""
545+
wood = wood_material("SignpostWood")
546+
board = board_material("SignpostBoard")
547+
metal = principled(
548+
"SignpostIron", (0.17, 0.165, 0.155, 1.0), 0.80, 0.46,
549+
noise_scale=18.0, wear=(0.20, 0.085, 0.032, 1.0),
550+
)
551+
return wood, board, metal
552+
553+
384554
def assign_slots(obj, wood, board, metal):
385555
mats = obj.data.materials
386556
wanted = (wood, board, metal)
@@ -713,18 +883,9 @@ def check(
713883
)
714884
low = build_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2, **kw)
715885
high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4, **kw)
716-
wood = principled(
717-
"SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62,
718-
noise_scale=14.0, wear=(0.22, 0.10, 0.04, 1.0),
719-
)
720-
board = principled(
721-
"SignpostBoard", (0.76, 0.58, 0.22, 1.0), 0.0, 0.52,
722-
noise_scale=10.0, wear=(0.55, 0.38, 0.14, 1.0),
723-
)
724-
metal = principled(
725-
"SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38,
726-
noise_scale=18.0, wear=(0.22, 0.18, 0.14, 1.0),
727-
)
886+
wood, board, metal = signpost_materials()
887+
paint_planks(low.data)
888+
paint_planks(high.data)
728889
assign_slots(low, wood, board, metal)
729890
assign_slots(high, wood, board, metal)
730891

@@ -780,6 +941,10 @@ def check(
780941
os.remove(export_path)
781942
export_unity(export_path, [low, collider])
782943
export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
944+
# Blender points TMPDIR at the working directory, so the export must not
945+
# outlive the measurement.
946+
if os.path.exists(export_path):
947+
os.remove(export_path)
783948

784949
hyg = hygiene_audit(low.data)
785950
zf = zfight_pairs(low.data)
@@ -943,7 +1108,9 @@ def render_still(low, wood, tex, path, engine):
9431108
floor_me = bpy.data.meshes.new("Floor")
9441109
bm = bmesh.new()
9451110
try:
946-
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
1111+
# Oversized so no edge of the set can enter frame; at 14 m the wall's
1112+
# left edge showed in the corner of the hero.
1113+
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0)
9471114
bm.to_mesh(floor_me)
9481115
finally:
9491116
bm.free()

0 commit comments

Comments
 (0)