Skip to content
Merged
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
63 changes: 59 additions & 4 deletions tests/check_helper_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
This hashes each top-level function (docstrings and comments ignored) across
showcase/*/*.py and, for every helper named in CANONICAL, lists the variants
and which pieces carry each. A helper may have at most its declared number of
variants; anything beyond that is drift.
variants; anything beyond that is drift. A helper marked `literals` is hashed
with string constants and ALL_CAPS names blanked, so a per-piece image name or
material index does not count as a new body.

Any function that calls bmesh.ops.convex_hull must be listed here, so a copy
renamed out of CANONICAL is reported instead of drifting unseen (#361).

python tests/check_helper_drift.py report; exit 0 (warn-only)
python tests/check_helper_drift.py --strict exit 1 when a helper drifts
Expand Down Expand Up @@ -38,26 +43,73 @@
"hygiene_audit": (7, "per-piece audits report extra keys (euler, nv, edge90)"),
"pack_uvs": (13, "island margins tuned per piece's texel density"),
"assign_slots": (20, "material slot layout is per-piece by nature"),
# #361: helpers that were copied under names the check never saw.
"hull_collider": (15, "each piece's hull is bespoke: material-filtered points "
"(apothecary-shelf), compound per-ring hulls (toboggan); all "
"start from bare points, so the #386 edge fix does not apply"),
"setup_bake_image": (3, "one body, plus the shelf/brazier family that drops the "
"BAKE_RES default and the no-UV early return, plus rope-bridge",
"literals"),
"bake_normal": (4, "one body; deselect_all() instead of the inline loop (11 pieces), "
"a None guard (bowling-pins), one different bake() argument (grain-sacks)"),
"face_area": (5, "equivalent fan-triangulation area, written five ways; some drop "
"the <3-vertex guard, which polygons never need"),
"uv_stats": (7, "pairwise AABB overlap, plus a sorted sweep and a bucketed grid for "
"high-island pieces; the grid variant returns (..., 0.0, 0) on no UVs"),
"zfight_pairs": (9, "two algorithms: shell-aware coplanar test (from grindstone) and "
"a centre-distance test that skips faces sharing a vertex"),
"wire_normal": (10, "the baked normal goes under a bump node where the piece has one, "
"and each piece finds that node its own way", "literals"),
}

# Functions allowed to call bmesh.ops.convex_hull. Every one must be in CANONICAL.
HULL_CALL = ("bmesh", "ops", "convex_hull")


class _BlankLiterals(ast.NodeTransformer):
def visit_Constant(self, n):
return ast.Constant("") if isinstance(n.value, str) else n

def _normalised(fn: ast.FunctionDef) -> str:
def visit_Name(self, n):
return ast.Name("K", n.ctx) if n.id.isupper() else n


def _normalised(fn: ast.FunctionDef, literals: bool = False) -> str:
node = ast.parse(ast.unparse(fn)).body[0]
body = node.body
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
node.body = body[1:] or [ast.Pass()]
if literals:
node = _BlankLiterals().visit(node)
return ast.dump(node)


def _calls_hull(fn: ast.FunctionDef) -> bool:
return any(isinstance(x, ast.Attribute) and x.attr == HULL_CALL[2]
and ast.unparse(x.value) == ".".join(HULL_CALL[:2]) for x in ast.walk(fn))


def unlisted_hull_callers() -> list[str]:
"""`piece:function` for every bmesh.ops.convex_hull caller not in CANONICAL."""
out = []
for script in sorted(ROOT.glob("showcase/*/*.py")):
for node in ast.parse(script.read_text(encoding="utf-8")).body:
if isinstance(node, ast.FunctionDef) and node.name not in CANONICAL \
and _calls_hull(node):
out.append(f"{script.parent.name}:{node.name}")
return out


def variants() -> dict[str, dict[str, list[tuple[str, str]]]]:
"""name -> hash -> [(piece, source)]"""
out: dict = collections.defaultdict(lambda: collections.defaultdict(list))
for script in sorted(ROOT.glob("showcase/*/*.py")):
tree = ast.parse(script.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in CANONICAL:
h = hashlib.sha1(_normalised(node).encode()).hexdigest()[:8]
literals = "literals" in CANONICAL[node.name][2:]
h = hashlib.sha1(_normalised(node, literals).encode()).hexdigest()[:8]
out[node.name][h].append((script.parent.name, ast.unparse(node)))
return out

Expand All @@ -69,7 +121,7 @@ def main(argv=None) -> int:
a = ap.parse_args(argv)
found = variants()
drift = False
for name, (allowed, why) in CANONICAL.items():
for name, (allowed, why, *_flags) in CANONICAL.items():
groups = sorted(found.get(name, {}).items(), key=lambda kv: -len(kv[1]))
n = len(groups)
over = n > allowed
Expand All @@ -82,6 +134,9 @@ def main(argv=None) -> int:
print(f" {h} x{len(pieces)}: {names}")
if a.show == name:
print("\n".join(" " + l for l in pieces[0][1].splitlines()))
for caller in unlisted_hull_callers():
drift = True
print(f"DRIFT {caller} calls bmesh.ops.convex_hull but is not in CANONICAL")
if drift:
msg = "helper drift: copies of a shared helper disagree (see above)"
if a.strict:
Expand Down
Loading