diff --git a/AGENTS.md b/AGENTS.md index 4d2e7b7..2afae39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 40 +The content base is 12 skills, 6 rules, 2 templates, 17 snippets, and 44 examples (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; the render look is specified diff --git a/CLAUDE.md b/CLAUDE.md index 3f735ef..c8c6928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 12 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 40 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 44 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -82,11 +82,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (40) +## Examples (44) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via -`--output`. All forty run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All of them run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and @@ -127,7 +127,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor - **Asset-sheet gate (asset-type examples — game props/kits):** composite the hero asset rendered alone (neutral three-quarter view, plain studio lighting, no staging tricks, no labels, no comparison props) beside the pinned asset-quality reference set — currently `collision-hull-proxy`, `custom-normals-shade`, `vertex-weight-limit`, `lod-decimate-chain` — rendered the same way; commit under `docs/gallery/asset-sheets/`, link it in the PR body, and report a verdict. The asset ships only if it is not identifiable as the least-designed object in that lineup — a strong scene can carry a weak model; this gate removes the scene. **This list is the canonical home of the reference set** — update it here when a new asset outclasses a member. The measurable floors behind the gate (naming, material variation, edge treatment) live in `examples/gallery_asset_quality.py` — render path only, same call pattern as `gallery_framing`, exit 11 on violation — with the calibration table and dropped-floor evidence in `docs/VISUAL-STYLE.md` § Asset quality. - **Falsification:** every check must be proven to fail once — break the contract, observe the non-zero exit, restore — with the probe and the measured error reported in the PR body. An assertion that cannot fail witnesses nothing. - **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery//index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68). -- **Playwright gallery captures:** gallery `` tags lazy-load, and Chromium scroll-anchoring defeats `scrollTo(0, 0)` after images load — take full-page captures and crop locally, not scroll-then-shoot. +- **Playwright gallery captures:** gallery `` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 44-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels. ## Example-Run Process diff --git a/ROADMAP.md b/ROADMAP.md index 16055fc..d1e76f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,16 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Gallery framing recomposition worklist~~ **DRAINED**: all four recomposition cases restaged and gated with `check_framing` — depsgraph-export (0.984x/touch→0.812x; cube+dome recentered, aim-based camera), vertex-weight-limit (touch B→0.867y; more frontal camera, lower aim, base in frame), triangulate-tangents (0.334x→0.833y; 2x-class move-in plus Key/Glint trim that also cleared a pre-existing dome blowout), text-version-stamp (0.663x→0.719x; moved in with aim on the stamp center, dead third gone). The seven marginal cases were camera-nudged in the preceding sweep; deviations documented in #108 (damped-track-aim, wave-displace); sky-texture-sun-elevation is a measurement artifact (world subject — report-only if wired). Remaining: nothing on the framing worklist — future examples inherit the gate at authoring time. +## Asset sheets committed before 2026-07-25 are not comparable + +The neutral asset-sheet harness sized its camera from the subject's bounding +box **diagonal**, which cropped tall subjects and shrank wide ones, so panels +in one sheet were at different effective scales. It now solves the required +distance from the real field of view on both axes. Sheets committed before +this fix (`modular-kit-snap`, `lightmap-uv-channel`) show mis-scaled panels +and must not be cited as evidence for or against an asset; re-render them if +that comparison is ever needed. + ## Asset-quality survey worklist (measured 2026-07-24, `gallery_asset_quality` floors) Full-gallery floor survey after the gate landed (`feat/asset-quality-gate`). diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md index 66b73ea..f83ea95 100644 --- a/docs/VISUAL-STYLE.md +++ b/docs/VISUAL-STYLE.md @@ -157,6 +157,23 @@ hand-tuned to pass everything is not one either: assignment, invisible to a per-part share (both measure 1.0 dominant). Printed as information only. +### Status: what the floors have actually caught + +Recorded honestly so nobody mistakes a floor pass for a quality verdict. +**No floor has ever bound on a shipped asset.** Every example that reached +the asset sheet had already cleared naming, material variation and edge +treatment. More pointedly, `socket-attach-points` passed all three on its +first draft (`edge90` 0.000, 10 materials, no default names) — a model that +was then judged bad by eye and rebuilt from scratch. The floors scored the +bevels, not the design. + +Treat them as a crude filter for obvious failures — a raw box, an unnamed +`Cube`, one flat slot across a whole prop — and nothing more. The asset +sheet is the gate that decides. Their one confirmed catch to date was +indirect: `measure_edge90` hit a zero-area face from an over-large bevel. +It used to raise `ValueError` there; it now reports `degenerate_faces` in +the line, which is the more useful signal. + ### The Goodhart warning These floors are necessary, not sufficient. Bolting meaningless greebles @@ -185,9 +202,23 @@ model, and this gate removes the scene. ## Output - Render 1280×720 PNG (`taa_render_samples`/`cycles.samples` 32–64). -- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85 - (load the PNG in Blender, `save(filepath=..., quality=85)`, `scale()` for - the preview — this preserves pixels without re-applying color management). +- Gallery assets: hero webp 1280×720 and preview webp 1200×675, quality 85. + **Encode with Pillow, not with Blender's `Image.save()`.** The Blender path + keeps the buffer RGBA and ignores the `quality` argument, writing a + near-lossless file: measured 991 KB for a hero that Pillow writes at 19 KB + from the same PNG, and two heroes shipped at 1.15 MB and 984 KB before this + was caught. Reading the rendered PNG and writing WebP does not re-apply + colour management either way, so the pixels are equally faithful: + + ```python + from PIL import Image + im = Image.open(png).convert("RGB") # drop the unused alpha + im.save(hero_webp, quality=85, method=6) + im.resize((1200, 675), Image.LANCZOS).save(preview_webp, quality=85, method=6) + ``` + + Sanity floor: a 1280×720 hero should land in the tens of kilobytes. Anything + over ~200 KB means the alpha channel survived and the encode went lossless. - After touching any example: `python scripts/build_gallery.py`, and update the README gallery-row alt text if the composition changed. diff --git a/docs/gallery/assets/lightmap-uv-channel-hero.webp b/docs/gallery/assets/lightmap-uv-channel-hero.webp index 4924fc2..022c028 100644 Binary files a/docs/gallery/assets/lightmap-uv-channel-hero.webp and b/docs/gallery/assets/lightmap-uv-channel-hero.webp differ diff --git a/docs/gallery/assets/modular-kit-snap-hero.webp b/docs/gallery/assets/modular-kit-snap-hero.webp index b1bdd1b..8a708a7 100644 Binary files a/docs/gallery/assets/modular-kit-snap-hero.webp and b/docs/gallery/assets/modular-kit-snap-hero.webp differ diff --git a/docs/gallery/gltf-export-roundtrip/index.html b/docs/gallery/gltf-export-roundtrip/index.html index 0c1b860..d466e06 100644 --- a/docs/gallery/gltf-export-roundtrip/index.html +++ b/docs/gallery/gltf-export-roundtrip/index.html @@ -379,6 +379,7 @@

Source

NRM_TOL = 2e-4 UV_TOL = 3e-5 UNIT_TOL = 1e-3 # |length(n) - 1| for re-imported loop normals +UV_SAMPLE = 32 # loops sampled for the V-flip check; asserted, not assumed KEY_DIGITS = 4 # position-key grid for attribute lookups @@ -624,12 +625,32 @@

Source

disk_pos = acc_floats(prim0["POSITION"], 3) disk_uv = acc_floats(prim0["TEXCOORD_0"], 2) uv_set = set_map(snap["loops"], snap["uvs"]) + # The comparison below is only evidence if it actually runs on a known + # number of pairs. zip() truncates to the shorter sequence, so without + # these guards a primitive that carried no TEXCOORD_0 at all would + # leave flip_err at its initial 0.0 and pass. Assert the pairing and + # count the comparisons, then require the count. + if len(disk_uv) != len(disk_pos): + print(f"ERROR: prim0 TEXCOORD_0 count {len(disk_uv)} != POSITION " + f"count {len(disk_pos)} - the UV attribute does not cover " + "the primitive", file=sys.stderr) + return 11 + if len(disk_pos) < UV_SAMPLE: + print(f"ERROR: prim0 carries only {len(disk_pos)} loops, fewer than " + f"the {UV_SAMPLE} the V-flip check samples", file=sys.stderr) + return 11 flip_err = 0.0 - for pd, (u, v) in zip(disk_pos[:32], disk_uv[:32]): + sampled = 0 + for pd, (u, v) in zip(disk_pos[:UV_SAMPLE], disk_uv[:UV_SAMPLE]): pb = (pd[0], -pd[2], pd[1]) # glTF (x, z, -y) -> Blender (x, y, z) best = min((max(abs(u - e[0]), abs((1.0 - v) - e[1])) for e in candidates(uv_set, pb)), default=1e30) flip_err = max(flip_err, best) + sampled += 1 + if sampled != UV_SAMPLE: + print(f"ERROR: V-flip check compared {sampled} loops, expected " + f"{UV_SAMPLE}", file=sys.stderr) + return 11 if flip_err > UV_TOL: print(f"ERROR: on-disk UVs deviate {flip_err:.3e} from the V-flipped " "authored layout (glTF texture origin is top-left)", file=sys.stderr) diff --git a/docs/gallery/gltf-skin-roundtrip/index.html b/docs/gallery/gltf-skin-roundtrip/index.html index 26dd9f0..91e1d78 100644 --- a/docs/gallery/gltf-skin-roundtrip/index.html +++ b/docs/gallery/gltf-skin-roundtrip/index.html @@ -599,6 +599,12 @@

Source

a = p["attributes"] w = acc_f(a["WEIGHTS_0"], 4) j = acc_u(a["JOINTS_0"], 4, g["accessors"][a["JOINTS_0"]]["componentType"]) + # JOINTS_0 and WEIGHTS_0 must cover the same vertices; zip() would + # otherwise silently score only the shorter accessor + if len(w) != len(j): + print(f"ERROR: WEIGHTS_0 count {len(w)} != JOINTS_0 count " + f"{len(j)} on a primitive", file=sys.stderr) + return 7 disk_verts += len(w) for ws, js in zip(w, j): total = sum(x for x, ji in zip(ws, js) if ji < len(BONES)) diff --git a/docs/gallery/lightmap-uv-channel/index.html b/docs/gallery/lightmap-uv-channel/index.html index 9ef34c4..ed27621 100644 --- a/docs/gallery/lightmap-uv-channel/index.html +++ b/docs/gallery/lightmap-uv-channel/index.html @@ -781,6 +781,28 @@

Source

print(f"ERROR ({code}): {msg}", file=sys.stderr) fails.append(code) + # --- 0. positive control for the overlap detector ----------------------- + # "0 overlapping pairs" is only evidence if the detector can find one. A + # blinded detector reports zero and the whole check passes: widening + # SAT_EPS to 1e9 makes every axis test read as separated, and this example + # still exited 0 before this control existed. Prove sensitivity and + # strictness on hand-built triangles before trusting the scan. + _overlapping = (((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)), + ((0.2, 0.2), (1.2, 0.2), (0.2, 1.2))) + _disjoint = (((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)), + ((5.0, 5.0), (6.0, 5.0), (5.0, 6.0))) + _touching = (((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)), + ((1.0, 0.0), (2.0, 0.0), (1.0, 1.0))) + ctl = (_tri_overlap(*_overlapping), _tri_overlap(*_disjoint), + _tri_overlap(*_touching)) + print(f"sat_control overlapping={ctl[0]} disjoint={ctl[1]} touching={ctl[2]} " + f"(want True/False/False)") + if ctl != (True, False, False): + fail(7, f"SAT self-test {ctl} != (True, False, False) — the overlap " + f"detector cannot detect, so a zero-overlap result proves " + f"nothing") + return fails[0] + nominal_margin = MARGIN_DIV * 0.01 total_islands = 0 for suffix, me in meshes.items(): diff --git a/docs/gallery/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html index 3bc9d91..a2e7356 100644 --- a/docs/gallery/modular-kit-snap/index.html +++ b/docs/gallery/modular-kit-snap/index.html @@ -340,6 +340,9 @@

Source

NUDGE = 2e-3 # unsnapped variant: two verts jogged in y (2 mm) EXPECT_BOUNDARY_VERTS = 16 # 8 per open end (hollow-rectangle profile) EXPECT_BOUNDARY_EDGES = 16 # 8 per open end +EXPECT_PARTS = 21 # shell + 20 detail parts; asserted before the + # per-part loops below, which assert nothing on + # an empty dict # Hollow-rectangle profile (y, z): outer shell corners then inner bore corners, # ordered as one continuous ring so the extrusion's side faces come out quads. @@ -539,6 +542,11 @@

Source

print(f"ERROR ({code}): {msg}", file=sys.stderr) fails.append(code) + if len(meshes) != EXPECT_PARTS: + fail(11, f"kit built {len(meshes)} parts, expected {EXPECT_PARTS} - the " + f"per-part loops below would iterate over the wrong set") + return fails[0] + # --- 1. snap: 16 boundary verts, all on x in {0, TILE} ----------------- on, off = boundary_verts(shell) print(f"snap boundary_verts={len(on)} off_plane={len(off)} " diff --git a/docs/gallery/socket-attach-points/index.html b/docs/gallery/socket-attach-points/index.html index 6e618c3..19a8758 100644 --- a/docs/gallery/socket-attach-points/index.html +++ b/docs/gallery/socket-attach-points/index.html @@ -384,6 +384,13 @@

Source

# real change to the object transform rather than a no-op. APPLY_SCALE = 1.35 +# Asset population, asserted before any per-element loop runs (see check 0). +EXPECT_SOCKETS = 7 # 4 rotor + camera + mast + battery +EXPECT_PARTS = 14 # hull + 4 arms + 2 landing gear + 7 mount pads +# Camera (-Z) and mast (+Z) are parallel to chassis up and take the +X up-axis +# fallback; the four canted rotors and the tilted battery take Gram-Schmidt. +EXPECT_FALLBACK_SOCKETS = 2 + def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" @@ -956,6 +963,19 @@

Source

local_by_socket = {s[0]: authored_local(s[2], s[3]) for s in spec} normal_by_socket = {s[0]: Vector(s[3]).normalized() for s in spec} + # --- 0. population: every check below is a loop over these collections, + # and a loop over nothing asserts nothing. Pin the counts first so an + # empty spec or a failed build cannot report a clean pass. + if (len(sockets) != EXPECT_SOCKETS or len(modules) != EXPECT_SOCKETS + or len(parts) != EXPECT_PARTS): + fail(6, f"asset population is {len(parts)} parts / {len(sockets)} " + f"sockets / {len(modules)} modules, expected {EXPECT_PARTS} / " + f"{EXPECT_SOCKETS} / {EXPECT_SOCKETS} - the checks below would " + f"iterate over the wrong set") + return fails[0] + print(f"population parts={len(parts)} sockets={len(sockets)} " + f"modules={len(modules)}") + # --- 1. socket matrices == authored world transform --------------------- worst_m, worst_o, worst_det = 0.0, 0.0, 0.0 for name, skt in sockets.items(): @@ -1004,8 +1024,22 @@

Source

if updev > TOL: fail(4, f"{name} +Y deviates {updev:.3e} from the documented up rule " f"— modules spawn rolled about their mount axis") + # The up-axis assertion above compares the socket basis against a + # re-derivation that uses the same PARALLEL threshold, so on its own it + # cannot notice a wrong threshold — both sides move together. Requiring + # that both branches are actually taken makes the threshold observable: + # collapsing PARALLEL sends every socket down one branch and this fires. + fallback = sum(1 for n in normal_by_socket.values() + if abs(n.dot(CHASSIS_UP)) > PARALLEL) + gram = len(sockets) - fallback print(f"pad_orientation sockets={len(sockets)} normal_dev={worst_n:.3e} " - f"centroid_dev={worst_c:.3e} up_dev={worst_up:.3e} tol={TOL:.0e}") + f"centroid_dev={worst_c:.3e} up_dev={worst_up:.3e} " + f"up_branch fallback={fallback} gram_schmidt={gram} tol={TOL:.0e}") + if fallback != EXPECT_FALLBACK_SOCKETS or gram != len(sockets) - EXPECT_FALLBACK_SOCKETS: + fail(4, f"up-axis branch split is {fallback} fallback / {gram} " + f"Gram-Schmidt, expected {EXPECT_FALLBACK_SOCKETS} / " + f"{len(sockets) - EXPECT_FALLBACK_SOCKETS} — the PARALLEL " + f"threshold is not selecting the branches it documents") # --- 3. module seating -------------------------------------------------- worst_seat, worst_axis = 0.0, 0.0 diff --git a/docs/gallery/vertex-color-ao/index.html b/docs/gallery/vertex-color-ao/index.html index a9605c5..2b94331 100644 --- a/docs/gallery/vertex-color-ao/index.html +++ b/docs/gallery/vertex-color-ao/index.html @@ -376,6 +376,7 @@

Source

FLOAT_TOL = 1e-6 # FLOAT_COLOR round-trip (measured 1.43e-08) BYTE_MODEL_TOL = 1e-6 # agreement with the independent sRGB model SPREAD_MIN = 0.25 # the asset must actually use its AO range +EXPECT_PARTS = 11 # asserted before any per-part loop runs (check 0) def eevee_engine_id(): @@ -926,7 +927,16 @@

Source

# --- 3. asset bake: in range, and actually using the range -------------- root, parts = build_asset(sc) + # A loop over nothing asserts nothing: pin the population before the + # per-part and per-vertex checks below iterate over it. + if len(parts) != EXPECT_PARTS: + fail(8, f"asset built {len(parts)} parts, expected {EXPECT_PARTS} - " + f"the checks below would iterate over the wrong set") + return fails[0] values = bake_asset(parts) + if not values: + fail(5, "the bake produced no values at all") + return fails[0] lo, hi = min(values), max(values) print(f"asset_bake parts={len(parts)} verts={len(values)} " f"samples={ASSET_SAMPLES} min={lo:.6f} max={hi:.6f} spread={hi - lo:.6f}") diff --git a/docs/gallery/vertex-weight-limit/index.html b/docs/gallery/vertex-weight-limit/index.html index 8cca943..465443a 100644 --- a/docs/gallery/vertex-weight-limit/index.html +++ b/docs/gallery/vertex-weight-limit/index.html @@ -593,6 +593,13 @@

Source

pose_after = eval_positions(obj) # contract 3: pruning did not damage the pose + # zip() would truncate if the prune changed the evaluated vertex count, + # comparing only the shared prefix and reporting a clean deviation. + if len(pose_after) != len(pose_before) or not pose_before: + print(f"ERROR: evaluated vertex count changed across the prune " + f"({len(pose_before)} -> {len(pose_after)}) - the pose " + f"comparison would only cover the shared prefix", file=sys.stderr) + return 7 pose_dev = max((mathutils.Vector(a) - mathutils.Vector(b)).length for a, b in zip(pose_before, pose_after)) if pose_dev > POSE_TOL: diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html index f2c7cfe..c199f30 100644 --- a/docs/gallery/vse-cut-list/index.html +++ b/docs/gallery/vse-cut-list/index.html @@ -876,6 +876,12 @@

Source

def near(got, want, tol): + # all() over an empty zip is True, so an empty or short sample would make + # every "is this pixel the right colour" test pass without comparing + # anything. Refuse to answer rather than answer vacuously. + if len(got) != len(want) or not got: + raise ValueError(f"near(): {len(got)} sampled values against " + f"{len(want)} expected - nothing would be compared") return all(abs(g - w) <= tol for g, w in zip(got, want)) diff --git a/examples/gallery_asset_quality.py b/examples/gallery_asset_quality.py index 8e56fbe..d2881d5 100644 --- a/examples/gallery_asset_quality.py +++ b/examples/gallery_asset_quality.py @@ -121,6 +121,7 @@ def measure_edge90(hero): """ total = 0 right = 0 + degenerate = 0 for _, me in _hero_meshes(hero): bm = bmesh.new() try: @@ -128,15 +129,21 @@ def measure_edge90(hero): for e in bm.edges: if len(e.link_faces) != 2: continue - total += 1 n1 = e.link_faces[0].normal n2 = e.link_faces[1].normal - ang = math.degrees(n1.angle(n2)) - if abs(ang - 90.0) <= EDGE90_DEG: + # A zero-area face has a zero-length normal, and Vector.angle + # RAISES on one. Reporting beats throwing: a degenerate face is + # a real defect the caller should hear about, not a crash that + # hides every other measurement on the asset. + if n1.length_squared <= 0.0 or n2.length_squared <= 0.0: + degenerate += 1 + continue + total += 1 + if abs(math.degrees(n1.angle(n2)) - 90.0) <= EDGE90_DEG: right += 1 finally: bm.free() - return (right / total) if total else 0.0, right, total + return (right / total) if total else 0.0, right, total, degenerate def _matte_render(scene, camera, hide, path, width, height): @@ -246,6 +253,7 @@ def __init__(self): self.edge90 = 0.0 self.edge_right = 0 self.edge_total = 0 + self.degenerate_faces = 0 # zero-area faces skipped by measure_edge90 self.compactness = 0.0 self.perimeter = 0 self.area = 0 @@ -264,6 +272,7 @@ def mark(ok): f"ceiling={DOMINANT_MAT_MAX} (applies at parts>=2, n={self.parts}) " f"{mark(mat_ok)}", f"aq_edge90 frac={self.edge90:.3f} ({self.edge_right}/{self.edge_total}) " + f"degenerate_faces={self.degenerate_faces} " f"ceiling={EDGE90_MAX_FRAC} {mark(edge_ok)}", f"aq_compactness {self.compactness:.1f} (perim={self.perimeter} " f"area={self.area}) informational-only", @@ -277,7 +286,8 @@ def measure_asset_quality(scene, camera, hero, stage=()): res.parts, res.bad_names = measure_parts(hero) (res.materials, res.mat_names, res.dominant_share, res.dominant_name) = measure_materials(hero) - res.edge90, res.edge_right, res.edge_total = measure_edge90(hero) + (res.edge90, res.edge_right, res.edge_total, + res.degenerate_faces) = measure_edge90(hero) res.compactness, res.perimeter, res.area = measure_compactness( scene, camera, hero, stage=stage) return res diff --git a/examples/gallery_framing.py b/examples/gallery_framing.py index 3049662..30a4180 100644 --- a/examples/gallery_framing.py +++ b/examples/gallery_framing.py @@ -126,6 +126,45 @@ def margins_ok(self): def ok(self): return self.fill_ok and self.margins_ok + def advice(self): + """Diagnostic only: how far off, and which way to move. + + A bare FAIL costs a render per guess. Subject extent on screen is + very nearly inversely proportional to camera distance, so + `distance x= measured_fill / target_fill` lands the dominant axis in + the band in one step. Margins are reported as the shortfall per edge. + """ + out = [] + if not self.fill_ok: + worst = max(self.fill_x, self.fill_y) + axis = "x" if self.fill_x >= self.fill_y else "y" + target = (FILL_MIN + FILL_MAX) / 2.0 + if worst > 0.0: + factor = worst / target + verb = "back" if factor > 1.0 else "in" + # A silhouette that runs off frame measures 1.000 no matter how + # far past the edge it actually goes, so the ratio is only a + # lower bound and one step will not be enough. + saturated = worst >= 1.0 - 1e-6 or bool(self.touches or self.crosses) + qual = (" AT LEAST (fill saturated: the subject leaves the " + "frame, so its true extent is unknown - re-measure " + "and repeat)" if saturated else "") + out.append(f"framing_advice fill {axis}={worst:.3f} target" + f"={target:.3f} -> move camera {verb}, " + f"distance x={factor:.3f}{qual} " + f"(aim-relative; keeps the aim point fixed)") + short = {e: MARGIN_MIN - m for e, m in self.margins.items() + if m < MARGIN_MIN} + for e in self.touches: + short.setdefault(e, MARGIN_MIN) + if short: + worst_edge = max(short, key=short.get) + detail = " ".join(f"{e}+{d:.3f}" for e, d in sorted(short.items())) + out.append(f"framing_advice margins short on {detail} " + f"(worst {worst_edge}); shift the aim point away from " + f"that edge, or move back if several edges are short") + return out + def report(self): def mark(flag): return "ok" if flag else "FAIL" @@ -142,6 +181,7 @@ def mark(flag): f"framing_edges touch={touch} cross={cross}", f"framing_strategy {self.strategy} {self.note}".rstrip(), ] + lines.extend(self.advice()) if self.ok: lines.append("framing_ok") return "\n".join(lines) diff --git a/examples/gltf-export-roundtrip/gltf_export_roundtrip.py b/examples/gltf-export-roundtrip/gltf_export_roundtrip.py index a888b38..b9e7834 100644 --- a/examples/gltf-export-roundtrip/gltf_export_roundtrip.py +++ b/examples/gltf-export-roundtrip/gltf_export_roundtrip.py @@ -96,6 +96,7 @@ NRM_TOL = 2e-4 UV_TOL = 3e-5 UNIT_TOL = 1e-3 # |length(n) - 1| for re-imported loop normals +UV_SAMPLE = 32 # loops sampled for the V-flip check; asserted, not assumed KEY_DIGITS = 4 # position-key grid for attribute lookups @@ -341,12 +342,32 @@ def check(crate): disk_pos = acc_floats(prim0["POSITION"], 3) disk_uv = acc_floats(prim0["TEXCOORD_0"], 2) uv_set = set_map(snap["loops"], snap["uvs"]) + # The comparison below is only evidence if it actually runs on a known + # number of pairs. zip() truncates to the shorter sequence, so without + # these guards a primitive that carried no TEXCOORD_0 at all would + # leave flip_err at its initial 0.0 and pass. Assert the pairing and + # count the comparisons, then require the count. + if len(disk_uv) != len(disk_pos): + print(f"ERROR: prim0 TEXCOORD_0 count {len(disk_uv)} != POSITION " + f"count {len(disk_pos)} - the UV attribute does not cover " + "the primitive", file=sys.stderr) + return 11 + if len(disk_pos) < UV_SAMPLE: + print(f"ERROR: prim0 carries only {len(disk_pos)} loops, fewer than " + f"the {UV_SAMPLE} the V-flip check samples", file=sys.stderr) + return 11 flip_err = 0.0 - for pd, (u, v) in zip(disk_pos[:32], disk_uv[:32]): + sampled = 0 + for pd, (u, v) in zip(disk_pos[:UV_SAMPLE], disk_uv[:UV_SAMPLE]): pb = (pd[0], -pd[2], pd[1]) # glTF (x, z, -y) -> Blender (x, y, z) best = min((max(abs(u - e[0]), abs((1.0 - v) - e[1])) for e in candidates(uv_set, pb)), default=1e30) flip_err = max(flip_err, best) + sampled += 1 + if sampled != UV_SAMPLE: + print(f"ERROR: V-flip check compared {sampled} loops, expected " + f"{UV_SAMPLE}", file=sys.stderr) + return 11 if flip_err > UV_TOL: print(f"ERROR: on-disk UVs deviate {flip_err:.3e} from the V-flipped " "authored layout (glTF texture origin is top-left)", file=sys.stderr) diff --git a/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py b/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py index e126a83..2b6e903 100644 --- a/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py +++ b/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py @@ -316,6 +316,12 @@ def weight_map(o): a = p["attributes"] w = acc_f(a["WEIGHTS_0"], 4) j = acc_u(a["JOINTS_0"], 4, g["accessors"][a["JOINTS_0"]]["componentType"]) + # JOINTS_0 and WEIGHTS_0 must cover the same vertices; zip() would + # otherwise silently score only the shorter accessor + if len(w) != len(j): + print(f"ERROR: WEIGHTS_0 count {len(w)} != JOINTS_0 count " + f"{len(j)} on a primitive", file=sys.stderr) + return 7 disk_verts += len(w) for ws, js in zip(w, j): total = sum(x for x, ji in zip(ws, js) if ji < len(BONES)) diff --git a/examples/lightmap-uv-channel/lightmap_uv_channel.py b/examples/lightmap-uv-channel/lightmap_uv_channel.py index 9a3bb6a..0a45e66 100644 --- a/examples/lightmap-uv-channel/lightmap_uv_channel.py +++ b/examples/lightmap-uv-channel/lightmap_uv_channel.py @@ -499,6 +499,28 @@ def fail(code, msg): print(f"ERROR ({code}): {msg}", file=sys.stderr) fails.append(code) + # --- 0. positive control for the overlap detector ----------------------- + # "0 overlapping pairs" is only evidence if the detector can find one. A + # blinded detector reports zero and the whole check passes: widening + # SAT_EPS to 1e9 makes every axis test read as separated, and this example + # still exited 0 before this control existed. Prove sensitivity and + # strictness on hand-built triangles before trusting the scan. + _overlapping = (((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)), + ((0.2, 0.2), (1.2, 0.2), (0.2, 1.2))) + _disjoint = (((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)), + ((5.0, 5.0), (6.0, 5.0), (5.0, 6.0))) + _touching = (((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)), + ((1.0, 0.0), (2.0, 0.0), (1.0, 1.0))) + ctl = (_tri_overlap(*_overlapping), _tri_overlap(*_disjoint), + _tri_overlap(*_touching)) + print(f"sat_control overlapping={ctl[0]} disjoint={ctl[1]} touching={ctl[2]} " + f"(want True/False/False)") + if ctl != (True, False, False): + fail(7, f"SAT self-test {ctl} != (True, False, False) — the overlap " + f"detector cannot detect, so a zero-overlap result proves " + f"nothing") + return fails[0] + nominal_margin = MARGIN_DIV * 0.01 total_islands = 0 for suffix, me in meshes.items(): diff --git a/examples/lightmap-uv-channel/preview.webp b/examples/lightmap-uv-channel/preview.webp index 2365629..3defe79 100644 Binary files a/examples/lightmap-uv-channel/preview.webp and b/examples/lightmap-uv-channel/preview.webp differ diff --git a/examples/modular-kit-snap/modular_kit_snap.py b/examples/modular-kit-snap/modular_kit_snap.py index d3bd73e..8924325 100644 --- a/examples/modular-kit-snap/modular_kit_snap.py +++ b/examples/modular-kit-snap/modular_kit_snap.py @@ -58,6 +58,9 @@ NUDGE = 2e-3 # unsnapped variant: two verts jogged in y (2 mm) EXPECT_BOUNDARY_VERTS = 16 # 8 per open end (hollow-rectangle profile) EXPECT_BOUNDARY_EDGES = 16 # 8 per open end +EXPECT_PARTS = 21 # shell + 20 detail parts; asserted before the + # per-part loops below, which assert nothing on + # an empty dict # Hollow-rectangle profile (y, z): outer shell corners then inner bore corners, # ordered as one continuous ring so the extrusion's side faces come out quads. @@ -257,6 +260,11 @@ def fail(code, msg): print(f"ERROR ({code}): {msg}", file=sys.stderr) fails.append(code) + if len(meshes) != EXPECT_PARTS: + fail(11, f"kit built {len(meshes)} parts, expected {EXPECT_PARTS} - the " + f"per-part loops below would iterate over the wrong set") + return fails[0] + # --- 1. snap: 16 boundary verts, all on x in {0, TILE} ----------------- on, off = boundary_verts(shell) print(f"snap boundary_verts={len(on)} off_plane={len(off)} " diff --git a/examples/modular-kit-snap/preview.webp b/examples/modular-kit-snap/preview.webp index e597df3..8de606f 100644 Binary files a/examples/modular-kit-snap/preview.webp and b/examples/modular-kit-snap/preview.webp differ diff --git a/examples/socket-attach-points/socket_attach_points.py b/examples/socket-attach-points/socket_attach_points.py index 09bb6d4..3c08c80 100644 --- a/examples/socket-attach-points/socket_attach_points.py +++ b/examples/socket-attach-points/socket_attach_points.py @@ -96,6 +96,13 @@ # real change to the object transform rather than a no-op. APPLY_SCALE = 1.35 +# Asset population, asserted before any per-element loop runs (see check 0). +EXPECT_SOCKETS = 7 # 4 rotor + camera + mast + battery +EXPECT_PARTS = 14 # hull + 4 arms + 2 landing gear + 7 mount pads +# Camera (-Z) and mast (+Z) are parallel to chassis up and take the +X up-axis +# fallback; the four canted rotors and the tilted battery take Gram-Schmidt. +EXPECT_FALLBACK_SOCKETS = 2 + def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" @@ -668,6 +675,19 @@ def fail(code, msg): local_by_socket = {s[0]: authored_local(s[2], s[3]) for s in spec} normal_by_socket = {s[0]: Vector(s[3]).normalized() for s in spec} + # --- 0. population: every check below is a loop over these collections, + # and a loop over nothing asserts nothing. Pin the counts first so an + # empty spec or a failed build cannot report a clean pass. + if (len(sockets) != EXPECT_SOCKETS or len(modules) != EXPECT_SOCKETS + or len(parts) != EXPECT_PARTS): + fail(6, f"asset population is {len(parts)} parts / {len(sockets)} " + f"sockets / {len(modules)} modules, expected {EXPECT_PARTS} / " + f"{EXPECT_SOCKETS} / {EXPECT_SOCKETS} - the checks below would " + f"iterate over the wrong set") + return fails[0] + print(f"population parts={len(parts)} sockets={len(sockets)} " + f"modules={len(modules)}") + # --- 1. socket matrices == authored world transform --------------------- worst_m, worst_o, worst_det = 0.0, 0.0, 0.0 for name, skt in sockets.items(): @@ -716,8 +736,22 @@ def fail(code, msg): if updev > TOL: fail(4, f"{name} +Y deviates {updev:.3e} from the documented up rule " f"— modules spawn rolled about their mount axis") + # The up-axis assertion above compares the socket basis against a + # re-derivation that uses the same PARALLEL threshold, so on its own it + # cannot notice a wrong threshold — both sides move together. Requiring + # that both branches are actually taken makes the threshold observable: + # collapsing PARALLEL sends every socket down one branch and this fires. + fallback = sum(1 for n in normal_by_socket.values() + if abs(n.dot(CHASSIS_UP)) > PARALLEL) + gram = len(sockets) - fallback print(f"pad_orientation sockets={len(sockets)} normal_dev={worst_n:.3e} " - f"centroid_dev={worst_c:.3e} up_dev={worst_up:.3e} tol={TOL:.0e}") + f"centroid_dev={worst_c:.3e} up_dev={worst_up:.3e} " + f"up_branch fallback={fallback} gram_schmidt={gram} tol={TOL:.0e}") + if fallback != EXPECT_FALLBACK_SOCKETS or gram != len(sockets) - EXPECT_FALLBACK_SOCKETS: + fail(4, f"up-axis branch split is {fallback} fallback / {gram} " + f"Gram-Schmidt, expected {EXPECT_FALLBACK_SOCKETS} / " + f"{len(sockets) - EXPECT_FALLBACK_SOCKETS} — the PARALLEL " + f"threshold is not selecting the branches it documents") # --- 3. module seating -------------------------------------------------- worst_seat, worst_axis = 0.0, 0.0 diff --git a/examples/vertex-color-ao/vertex_color_ao.py b/examples/vertex-color-ao/vertex_color_ao.py index 3dc6bf2..d0c0ef1 100644 --- a/examples/vertex-color-ao/vertex_color_ao.py +++ b/examples/vertex-color-ao/vertex_color_ao.py @@ -89,6 +89,7 @@ FLOAT_TOL = 1e-6 # FLOAT_COLOR round-trip (measured 1.43e-08) BYTE_MODEL_TOL = 1e-6 # agreement with the independent sRGB model SPREAD_MIN = 0.25 # the asset must actually use its AO range +EXPECT_PARTS = 11 # asserted before any per-part loop runs (check 0) def eevee_engine_id(): @@ -639,7 +640,16 @@ def fail(code, msg): # --- 3. asset bake: in range, and actually using the range -------------- root, parts = build_asset(sc) + # A loop over nothing asserts nothing: pin the population before the + # per-part and per-vertex checks below iterate over it. + if len(parts) != EXPECT_PARTS: + fail(8, f"asset built {len(parts)} parts, expected {EXPECT_PARTS} - " + f"the checks below would iterate over the wrong set") + return fails[0] values = bake_asset(parts) + if not values: + fail(5, "the bake produced no values at all") + return fails[0] lo, hi = min(values), max(values) print(f"asset_bake parts={len(parts)} verts={len(values)} " f"samples={ASSET_SAMPLES} min={lo:.6f} max={hi:.6f} spread={hi - lo:.6f}") diff --git a/examples/vertex-weight-limit/vertex_weight_limit.py b/examples/vertex-weight-limit/vertex_weight_limit.py index 3348a33..e19a778 100644 --- a/examples/vertex-weight-limit/vertex_weight_limit.py +++ b/examples/vertex-weight-limit/vertex_weight_limit.py @@ -310,6 +310,13 @@ def check(obj, arm, groups, pose_before): pose_after = eval_positions(obj) # contract 3: pruning did not damage the pose + # zip() would truncate if the prune changed the evaluated vertex count, + # comparing only the shared prefix and reporting a clean deviation. + if len(pose_after) != len(pose_before) or not pose_before: + print(f"ERROR: evaluated vertex count changed across the prune " + f"({len(pose_before)} -> {len(pose_after)}) - the pose " + f"comparison would only cover the shared prefix", file=sys.stderr) + return 7 pose_dev = max((mathutils.Vector(a) - mathutils.Vector(b)).length for a, b in zip(pose_before, pose_after)) if pose_dev > POSE_TOL: diff --git a/examples/vse-cut-list/vse_cut_list.py b/examples/vse-cut-list/vse_cut_list.py index 93de80f..bbbf443 100644 --- a/examples/vse-cut-list/vse_cut_list.py +++ b/examples/vse-cut-list/vse_cut_list.py @@ -586,6 +586,12 @@ def render_still(path, engine): def near(got, want, tol): + # all() over an empty zip is True, so an empty or short sample would make + # every "is this pixel the right colour" test pass without comparing + # anything. Refuse to answer rather than answer vacuously. + if len(got) != len(want) or not got: + raise ValueError(f"near(): {len(got)} sampled values against " + f"{len(want)} expected - nothing would be compared") return all(abs(g - w) <= tol for g, w in zip(got, want))