Skip to content

Commit b606f98

Browse files
TMHSDigitalclaude
andcommitted
fix(examples): asset-quality floors measure evaluated meshes and used materials
Two of the floors behind exit 11 could pass without measuring anything: - edge90 read each MESH part's base data, so Solidify, Array, Bevel or geometry-nodes output was invisible, and a hero with no manifold edge returned 0.0, which passed; - the materials floor counted filled slots, so an unused second slot satisfied it. Now: - every part is measured on its evaluated mesh (freed with to_mesh_clear); - zero measured edges is a failure ('nothing was checked'); - a material counts only when some polygon's material_index uses its slot. Calibrated before switching. A BDT_AQ_SURVEY hook (calibration only; records both measures and exits before the render) was run over all 102 heroes that call the floor on 5.2.1: - none fails the old or the new measure; - no material count changes; - edge90 moves on five modifier/GN heroes, and the highest is signpost at 0.703 against the 0.75 ceiling. Falsified on 4.5.14 and 5.2.1, each exiting 11: a Solidify-only plane (edge90 1.0 over 12 edges, was 0 edges), two parts sharing one used material plus an unused slot (materials 2 -> 1), and a bare plane (no edges). docs/VISUAL-STYLE.md records the new definitions and the re-measurement. Closes #472 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: TM Hospitality Strategies <TMHospitalityStrategies@gmail.com>
1 parent 0663419 commit b606f98

2 files changed

Lines changed: 80 additions & 16 deletions

File tree

‎docs/VISUAL-STYLE.md‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,28 @@ violation; never imported by a check-only path):
123123
- **Naming** — no default datablock names on hero parts (Cube, Sphere,
124124
Torus, Suzanne, …). A designed asset names its parts.
125125
- **Material variation** — a hero assembled from ≥ 2 parts carries ≥ 2
126-
distinct materials. A single flat Principled slot across an entire prop
126+
distinct materials **that faces actually use** (a mesh slot counts only
127+
when some polygon's `material_index` points at it, so an unused second
128+
slot cannot satisfy the floor; #472). A single flat Principled slot across an entire prop
127129
is this gallery's most reliable placeholder predictor. Single-part
128130
subjects are exempt: one honest material is their truth (a brass gear).
129131
- **Edge treatment** — at most **75 %** of a hero's manifold edges may be
130132
right angles (90° ± 2°). Unbroken 90° corners across the board means no
131133
bevel, no chamfer, no shading break. Honest machined teeth pass
132-
(`bmesh-gear` measures 0.667); raw boxes (1.0) fail.
134+
(`bmesh-gear` measures 0.667); raw boxes (1.0) fail. Measured on each
135+
part's **evaluated** mesh, so modifiers count (a Bevel modifier rounds
136+
corners, Solidify/Array/GN output is measured at all), and a hero with no
137+
manifold edge at all fails: nothing was checked (#472).
138+
139+
Re-measured for #472 on 5.2.1 with `BDT_AQ_SURVEY` (the helper's
140+
calibration hook: it records both measures and exits before the render)
141+
across all 102 heroes that call the floor: no hero fails either the old or
142+
the new measure, no material count changed, and edge90 moved on five heroes
143+
where modifier or geometry-nodes output is now counted
144+
(`car-mirror-symmetry` 3174 → 43570 measured edges, 0.035 → 0.005;
145+
`gn-instance-grid` 0.141 → 0.111). Highest new edge90: `signpost` 0.703.
146+
A plane with only a Solidify modifier, which used to pass with 0.0 from zero
147+
measured edges, now measures 1.0 and exits 11.
133148

134149
Calibrated empirically against the gallery's own assets, exactly as the
135150
0.02 margin floor was. The four new reference rows were measured on the

‎examples/gallery_asset_quality.py‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
check-only path of every example stays free of it, so smoke runtimes are
3535
unaffected.
3636
"""
37+
import json
3738
import math
3839
import os
3940
import sys
@@ -124,16 +125,33 @@ def measure_parts(hero):
124125
return len(meshes), bad
125126

126127

127-
def measure_materials(hero):
128-
"""(distinct materials across all hero slots, names, dominant share,
129-
dominant name). Distinct counts every slot; the dominant share counts
130-
the first slot per part — the flat-single-material placeholder signal
131-
on assembled assets."""
128+
def _used_slots(data):
129+
"""Material slot indices that faces actually use. A mesh reads its
130+
polygons' material_index; a curve has no faces to read, so every
131+
filled slot counts (its splines index them)."""
132+
if isinstance(data, bpy.types.Mesh):
133+
n = len(data.polygons)
134+
if n == 0:
135+
return set()
136+
idx = [0] * n
137+
data.polygons.foreach_get("material_index", idx)
138+
return set(idx)
139+
return set(range(len(data.materials)))
140+
141+
142+
def measure_materials(hero, used_only=True):
143+
"""(distinct materials across the hero, names, dominant share, dominant
144+
name). With used_only (the gate, #472) a material counts only when faces
145+
use its slot, so an unused second slot cannot satisfy the floor;
146+
used_only=False counts every filled slot (the pre-#472 measure). The
147+
dominant share counts the first slot per part — the
148+
flat-single-material placeholder signal on assembled assets."""
132149
from collections import Counter
133150
distinct = set()
134151
primary = Counter()
135152
for _, me, _ in _hero_meshes(hero):
136-
slots = [m for m in me.materials if m is not None]
153+
used = _used_slots(me) if used_only else set(range(len(me.materials)))
154+
slots = [m for i, m in enumerate(me.materials) if m is not None and i in used]
137155
for slot in slots:
138156
distinct.add(slot.name)
139157
primary[slots[0].name if slots else "<none>"] += 1
@@ -142,20 +160,25 @@ def measure_materials(hero):
142160
return len(distinct), sorted(distinct), dominant_n / total, dominant_name
143161

144162

145-
def measure_edge90(hero):
163+
def measure_edge90(hero, evaluated=True):
146164
"""Fraction of two-face edges with dihedral angle in 90 ± EDGE90_DEG.
147165
148166
Boundary edges (open kit ends, sheet rims) are excluded — they have no
149167
dihedral to treat. Angles are face-normal angles on manifold edges.
150-
A curve part is measured on its depsgraph-evaluated tessellation (the
151-
tube the renderer draws), freed with to_mesh_clear().
168+
Every part is measured on its depsgraph-evaluated mesh, the geometry
169+
the renderer draws, so modifiers count: a Bevel that rounds raw right
170+
angles helps, and Solidify or Array output is measured at all (#472).
171+
evaluated=False reads a MESH part's base data (the pre-#472 measure).
172+
Evaluated meshes are freed with to_mesh_clear(). Returns frac None when
173+
no manifold edge was measured: nothing was checked, which the gate
174+
treats as a failure, not a pass.
152175
"""
153176
total = 0
154177
right = 0
155178
degenerate = 0
156179
depsgraph = None
157180
for _, data, ob in _hero_meshes(hero):
158-
if ob.type == "MESH":
181+
if ob.type == "MESH" and not evaluated:
159182
t, r, d = _edge90_counts(data)
160183
else:
161184
if depsgraph is None:
@@ -169,7 +192,7 @@ def measure_edge90(hero):
169192
total += t
170193
right += r
171194
degenerate += d
172-
return (right / total) if total else 0.0, right, total, degenerate
195+
return (right / total) if total else None, right, total, degenerate
173196

174197

175198
def _matte_render(scene, camera, hide, path, width, height):
@@ -292,14 +315,15 @@ def mark(ok):
292315
# (docs/VISUAL-STYLE.md § Asset quality) printed as information, so
293316
# it must not print FAIL next to a gate that passes.
294317
mat_ok = self.parts < 2 or self.materials >= MATERIALS_MIN
295-
edge_ok = self.edge90 <= EDGE90_MAX_FRAC
318+
edge_ok = self.edge90 is not None and self.edge90 <= EDGE90_MAX_FRAC
319+
frac = "none" if self.edge90 is None else f"{self.edge90:.3f}"
296320
return "\n".join([
297321
f"aq_naming default_names={self.bad_names or 'none'} {mark(name_ok)}",
298322
f"aq_materials n={self.materials} floor={MATERIALS_MIN} "
299323
f"(applies at parts>=2, n={self.parts}) {mark(mat_ok)}; "
300324
f"dominant={self.dominant_name}@{self.dominant_share:.2f} "
301325
f"informational-only",
302-
f"aq_edge90 frac={self.edge90:.3f} ({self.edge_right}/{self.edge_total}) "
326+
f"aq_edge90 frac={frac} ({self.edge_right}/{self.edge_total}) "
303327
f"degenerate_faces={self.degenerate_faces} "
304328
f"ceiling={EDGE90_MAX_FRAC} {mark(edge_ok)}",
305329
f"aq_compactness {self.compactness:.1f} (perim={self.perimeter} "
@@ -333,6 +357,8 @@ def check_asset_quality(scene, camera, hero, stage=(), *,
333357
and printed but never gated (dropped: fails genuinely good simple
334358
subjects — see module docstring and docs/VISUAL-STYLE.md).
335359
"""
360+
if os.environ.get("BDT_AQ_SURVEY"):
361+
_survey(hero, os.environ["BDT_AQ_SURVEY"]) # exits; calibration only
336362
res = measure_asset_quality(scene, camera, hero, stage=stage)
337363
print(res.report())
338364
failures = []
@@ -341,11 +367,34 @@ def check_asset_quality(scene, camera, hero, stage=(), *,
341367
if res.parts >= 2 and res.materials < materials_min:
342368
failures.append(f"materials {res.materials}<{materials_min} "
343369
f"on a {res.parts}-part hero")
344-
if res.edge90 > edge90_max:
370+
if res.edge90 is None:
371+
failures.append("edge90 measured no manifold edge: nothing was checked")
372+
elif res.edge90 > edge90_max:
345373
failures.append(f"edge90 {res.edge90:.3f}>{edge90_max}")
346374
if failures:
347375
print("ERROR: asset-quality violation — " + "; ".join(failures),
348376
file=sys.stderr)
349377
return EXIT_ASSET_QUALITY
350378
print("aq_ok")
351379
return 0
380+
381+
382+
def _survey(hero, path):
383+
"""Calibration hook, never used by CI: with BDT_AQ_SURVEY=<file>, append
384+
the old (base mesh, every slot) and new (evaluated, used slots) floor
385+
measurements for this hero as one JSON line, then exit 0 before the
386+
gate and the render. Lets a floor change be measured across every
387+
gallery hero without rendering any (#472)."""
388+
hero = _as_list(hero)
389+
parts, _ = measure_parts(hero)
390+
row = {"script": os.path.relpath(sys.argv[sys.argv.index("--python") + 1])
391+
if "--python" in sys.argv else "?", "parts": parts}
392+
for tag, kw_m, kw_e in (("old", {"used_only": False}, {"evaluated": False}),
393+
("new", {"used_only": True}, {"evaluated": True})):
394+
n, _, _, _ = measure_materials(hero, **kw_m)
395+
frac, right, total, _ = measure_edge90(hero, **kw_e)
396+
row[tag] = {"materials": n, "edge90": frac, "edge_total": total}
397+
with open(path, "a", encoding="utf-8") as fh:
398+
fh.write(json.dumps(row) + "\n")
399+
print(f"aq_survey {json.dumps(row)}", flush=True)
400+
raise SystemExit(0)

0 commit comments

Comments
 (0)