diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 788fb2f..1d64a19 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -576,14 +576,14 @@ jobs: # 1e-6, shell bbox == declared tile (dev 0), manifold shell except # the 16 open-end edges, watertight detail parts contained inside # the tile, and reuse hygiene (identity scales, kit names, pivot - # origins). Exits non-zero on failure. + # origins) across 21 parts. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/modular-kit-snap/modular_kit_snap.py -- - name: Shipped example - lightmap UV channel (two-layer bake contract) run: | set -euo pipefail - # Check only (no render): a 9-part market cart; asserts per-part + # Check only (no render): a 21-part market cart; asserts per-part # UVMap/UVLight layers with active == UVLight and # active_render == UVMap re-asserted after the ops (they clear # both flags), UV0 loops untouched by the UV1 unwrap (drift 0), diff --git a/CLAUDE.md b/CLAUDE.md index 3fe53ac..51543dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor - `docs/VISUAL-STYLE.md` is the **binding** render standard; deviations are defects. - **Framing gate:** Layer 1 framing is measured, not eyeballed — the example's `--output` render path calls the shared helper `examples/gallery_framing.py` (`check_framing`, exit 10 on violation) before writing the still: hero fill 0.70–0.90 of frame in at least one axis, every element that matters clearing all four edges by ≥ 0.02. The check-only path never invokes it, so smoke runtimes are unaffected. Documented framing deviations (VISUAL-STYLE Layer 1) call `check_framing(..., deviation="reason")` — the helper reports instead of enforcing, and the reason string is required. - **Contact-sheet gate:** composite the candidate hero beside the pinned calibration set — currently `armature-bend`, `damped-track-aim`, `bmesh-gear` — commit the composite under `docs/gallery/contact-sheets/`, link it in the PR body, and report per-criterion verdicts (stage darkness, wedge warmth, subject fill, saturation, thumbnail legibility) including mean luminance versus the calibration images. A claim without the committed composite is not acceptable evidence. **This list is the canonical home of the pinned set** — update it here when a new example outclasses a member; `docs/new-example-prompt.md` points here rather than naming members. The longer "calibration references" list in `docs/VISUAL-STYLE.md` is a style reference, not this contact-sheet set. +- **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. diff --git a/ROADMAP.md b/ROADMAP.md index 2bf85b0..4ac754b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,8 +109,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - UV-handle lifetime snippet: re-fetch attribute/UV layers by name after any CustomData-reallocating call (`calc_tangents`, `VertexGroup.add`, modifier edits) — held handles dangle silently on 4.5, survive by luck on 5.1 (found authoring `triangulate-tangents`; **confirmed harder authoring `lightmap-uv-channel`: iterating a held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 headless, EXCEPTION_ACCESS_VIOLATION 5/5, survives on 5.1.2**) - ~~glTF skinned-mesh export witness: follow-up to `gltf-export-roundtrip` + `vertex-weight-limit`~~ **SHIPPED** as `examples/gltf-skin-roundtrip/` — `skins[0].joints` names every bone, JOINTS_0/WEIGHTS_0 with unit sums (3e-8), weights bit-exact, rest matrices to 2.4e-07, deformation to 4.8e-07; exporter welds duplicate loops (sorted-multiset comparisons mispair — compare by rest-key); unparented skinned meshes make the exporter bind an armature by name - ~~Degenerate-bevel weld hazard~~ **SHIPPED** as `examples/degenerate-bevel-weld/` — at `offset == min_dim/2` the bevel band collapses to exactly 12 zero-area faces (4 min-axis edges × 3 segments) with 16 coincident verts (4 × (segments+1)) and min_area down 3.2e5×; a stdlib GLB re-parse counts **32 degenerate triangles crossing the export boundary** (MEASURED regression constant) versus 0 for the safe mesh — the loops an engine merge-by-distance welds; byte-identical on 4.5.11 and 5.1.2 -- ~~Modular kit snap witness~~ **SHIPPED** as `examples/modular-kit-snap/` — tiling corridor segment: 16 boundary verts on `x ∈ {0, TILE}` within 1e-6 m, end rings coincident under the tile offset (nearest-key match), linked-duplicate joint deviation 0.0, shell bbox == declared tile (dev 0.0), manifold except the 16 open-end edges, 12 detail parts contained inside the tile; unsnapped probe (3 mm skew, 2 mm nudge) fails at exits 3/4 with measured errors; sorted-zip ring matching mispairs displaced verts — match by nearest key (authoring hazard, fixed); byte-identical on 4.5.11 and 5.1.2 -- ~~Lightmap UV channel witness~~ **SHIPPED** as `examples/lightmap-uv-channel/` — two-channel UV contract on a market cart: per-part `UVMap`/`UVLight` with `active`/`active_render` re-asserted after the ops (edit-mode UV ops clear both flags; `uv_layers.new()` never moves them), UV0 drift 0.0 (clobber trap measured 2.068), every UV1 loop in [0,1], 0 overlapping islands via independent binned strict-SAT (15 hits when falsified), min island distance 0.00401 vs nominal margin 0.002 (`MARGIN_DIV × 0.01` semantics measured live); held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 (5/5, EXCEPTION_ACCESS_VIOLATION), silent no-op pack without prior unwrap; byte-identical on 4.5.11 and 5.1.2 (1114 islands) +- ~~Modular kit snap witness~~ **SHIPPED** as `examples/modular-kit-snap/` — tiling corridor segment: 16 boundary verts on `x ∈ {0, TILE}` within 1e-6 m, end rings coincident under the tile offset (nearest-key match), linked-duplicate joint deviation 0.0, shell bbox == declared tile (dev 0.0), manifold except the 16 open-end edges, 20 detail parts contained inside the tile; unsnapped probe (3 mm skew, 2 mm nudge) fails at exits 3/4 with measured errors; sorted-zip ring matching mispairs displaced verts — match by nearest key (authoring hazard, fixed); byte-identical on 4.5.11 and 5.1.2 +- ~~Lightmap UV channel witness~~ **SHIPPED** as `examples/lightmap-uv-channel/` — two-channel UV contract on a market cart: per-part `UVMap`/`UVLight` with `active`/`active_render` re-asserted after the ops (edit-mode UV ops clear both flags; `uv_layers.new()` never moves them), UV0 drift 0.0 (clobber trap measured 2.068), every UV1 loop in [0,1], 0 overlapping islands via independent binned strict-SAT (15 hits when falsified), min island distance 0.00401 vs nominal margin 0.002 (`MARGIN_DIV × 0.01` semantics measured live); held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 (5/5, EXCEPTION_ACCESS_VIOLATION), silent no-op pack without prior unwrap; byte-identical on 4.5.11 and 5.1.2 (3680 islands) - UV atlas **utilization** witness — coverage/wasted-texel closed forms for a packed lightmap atlas (the non-overlap, unit-square, margin, and active/active_render contracts shipped in `lightmap-uv-channel`; utilization is the remaining unbuilt slice of the old "UV atlas pack" candidate) - ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list` - Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`) @@ -131,6 +131,23 @@ 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-quality survey worklist (measured 2026-07-24, `gallery_asset_quality` floors) + +Full-gallery floor survey after the gate landed (`feat/asset-quality-gate`). +26 of 42 examples pass all floors. The 14 below-floor entries, with +measured numbers — every one is a **contract-vehicle or display subject** +(testcard TV, VSE monitors, text bars, swatch/display rigs, single honest +primitives), not a game asset meant for reuse, so none is a remodeling +mandate; the floors exist to catch future asset-type examples at authoring +time. Two non-mesh subjects (curve-bevel-arc, grease-pencil-rosette) are +not measurable by the floors (curve/GP data, no mesh hero). Re-survey after +any example remodel. + +- naming (default datablock names): gltf-skin-roundtrip (3 parts), gn-sdf-remesh (`Torus`), turntable (`Suzanne`) +- materials (multi-part, single material): armature-bend (3 parts/1 mat — vertex-color weight bands are the honest display), degenerate-bevel-weld (2/1), driver-wave (16/1, also edge90 1.0), shape-key-blend (3/1, also edge90 1.0) +- edge90 (raw right angles, 1.0): depsgraph-export (also naming + materials), image-pixels-testcard, png-exr-alpha, temp-override-join, text-version-stamp, vse-cut-list; vse-gamma-cross at 0.92 +- reference: gallery best-modeled measure collision-hull-proxy 0.044, custom-normals-shade 0.288, vertex-weight-limit 0.150, lod-decimate-chain 0.019; calibration details in `docs/VISUAL-STYLE.md` § Asset quality + ## Future (uncommitted) - Asset library and asset browser scripting skill diff --git a/docs/VISUAL-STYLE.md b/docs/VISUAL-STYLE.md index cccf588..66b73ea 100644 --- a/docs/VISUAL-STYLE.md +++ b/docs/VISUAL-STYLE.md @@ -103,6 +103,85 @@ the default stage. (`vse-cut-list` on a monitor, `image-pixels-testcard` on a TV) rather than recreating them. +## Asset quality (measured gate) + +"Modeled with intent" is measured, not a vibe. Three parts: measurable +floors, a named reference bar, and the asset sheet. The floors are the +cheap filter that catches the obvious failures; the asset sheet is what +actually decides. Applies to asset-type examples (game props, kits) — +not to world, field, art, or media subjects. + +### Floors + +Computed by `examples/gallery_asset_quality.py` (render path only, same +call pattern as `gallery_framing`; prints measured values; exit 11 on +violation; never imported by a check-only path): + +- **Naming** — no default datablock names on hero parts (Cube, Sphere, + Torus, Suzanne, …). A designed asset names its parts. +- **Material variation** — a hero assembled from ≥ 2 parts carries ≥ 2 + distinct materials. A single flat Principled slot across an entire prop + is this gallery's most reliable placeholder predictor. Single-part + subjects are exempt: one honest material is their truth (a brass gear). +- **Edge treatment** — at most **75 %** of a hero's manifold edges may be + right angles (90° ± 2°). Unbroken 90° corners across the board means no + bevel, no chamfer, no shading break. Honest machined teeth pass + (`bmesh-gear` measures 0.667); raw boxes (1.0) fail. + +Calibrated empirically against the gallery's own assets, exactly as the +0.02 margin floor was: + +| asset | parts | materials | edge90 | compactness (info only) | +| --- | --- | --- | --- | --- | +| collision-hull-proxy (reference) | 16 | 6 | 0.044 | 65.9 | +| custom-normals-shade (reference) | 39 | 2 | 0.288 | 31.0 | +| vertex-weight-limit (reference) | 1 | 4 | 0.150 | 34.8 | +| lod-decimate-chain (reference) | 3 | 4 | 0.019 | 79.1 | +| depsgraph-export (weak) | 2 | 1 | 1.000 | 23.9 | +| text-version-stamp (weak) | 1 | 1 | 1.000 | 405.3 | +| bmesh-gear (simple, honest) | 1 | 1 | 0.667 | 18.3 | +| soccer-ball-goldberg (simple, honest) | 1 | 2 | 0.000 | 10.0 | + +Dropped floors, with the evidence that killed them — a floor that cannot +be set without failing a genuinely good asset is not a floor, and a floor +hand-tuned to pass everything is not one either: + +- **Part count** — `vertex-weight-limit`'s mech arm is a single skinned + mesh and is reference-quality. Skinned deform meshes are one mesh by + design. The naming half survives. +- **Silhouette compactness** — `soccer-ball-goldberg` scores 10.0, + `bmesh-gear` 18.3, `turntable` 19.1: genuinely good simple subjects + with low-complexity silhouettes. Measured and printed as information. +- **Dominant-material share** — `custom-normals-shade`'s jerry can and + `lod-decimate-chain`'s rocket carry their accents in per-face + assignment, invisible to a per-part share (both measure 1.0 dominant). + Printed as information only. + +### The Goodhart warning + +These floors are necessary, not sufficient. Bolting meaningless greebles +onto a box to raise silhouette complexity satisfies every number above +and still fails. Detail must serve the object's plausibility: fixtures +where fixtures belong, wear where contact happens, structure where +structure carries load. The asset sheet exists because metrics can be +gamed; a lineup of peers cannot. + +### Reference bar + +The asset-quality reference set is pinned in `CLAUDE.md` § Quality Gates +for Example Runs — canonical membership lives there, not here. + +### Asset sheet gate + +For every new asset-type example: render the hero alone — neutral +three-quarter view, plain studio lighting, no staging tricks, no labels, +no comparison props — composite it beside the reference assets rendered +the same way, commit under `docs/gallery/asset-sheets/`, and link it in +the PR body with a verdict. The asset ships only if it is not +identifiable as the least-designed object in that lineup. Isolating the +asset from its staging is the point: a strong scene can carry a weak +model, and this gate removes the scene. + ## Output - Render 1280×720 PNG (`taa_render_samples`/`cycles.samples` 32–64). diff --git a/docs/gallery/asset-sheets/lightmap-uv-channel.webp b/docs/gallery/asset-sheets/lightmap-uv-channel.webp new file mode 100644 index 0000000..95e0bc9 Binary files /dev/null and b/docs/gallery/asset-sheets/lightmap-uv-channel.webp differ diff --git a/docs/gallery/asset-sheets/modular-kit-snap.webp b/docs/gallery/asset-sheets/modular-kit-snap.webp new file mode 100644 index 0000000..7be9eff Binary files /dev/null and b/docs/gallery/asset-sheets/modular-kit-snap.webp differ diff --git a/docs/gallery/assets/lightmap-uv-channel-hero.webp b/docs/gallery/assets/lightmap-uv-channel-hero.webp index f83e1af..4924fc2 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 6c14840..b1bdd1b 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/contact-sheets/lightmap-uv-channel.webp b/docs/gallery/contact-sheets/lightmap-uv-channel.webp index 26db47f..2f1d4b8 100644 Binary files a/docs/gallery/contact-sheets/lightmap-uv-channel.webp and b/docs/gallery/contact-sheets/lightmap-uv-channel.webp differ diff --git a/docs/gallery/contact-sheets/modular-kit-snap.webp b/docs/gallery/contact-sheets/modular-kit-snap.webp index 9715cec..c0aa3de 100644 Binary files a/docs/gallery/contact-sheets/modular-kit-snap.webp and b/docs/gallery/contact-sheets/modular-kit-snap.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 85bcdcc..7cf0d43 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -780,7 +780,7 @@

modular-kit-snap

lightmap-uv-channel

A market cart carrying the two-channel UV contract for baked lighting: UV0 untouched, UVLight packed with no overlaps and a respected margin.

-

witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 1114 islands with 0 SAT overlaps, min island distance 0.00401.

+

witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 3680 islands with 0 SAT overlaps, min island distance 0.00401.

View example
diff --git a/docs/gallery/lightmap-uv-channel/index.html b/docs/gallery/lightmap-uv-channel/index.html index 8c2215e..9ef34c4 100644 --- a/docs/gallery/lightmap-uv-channel/index.html +++ b/docs/gallery/lightmap-uv-channel/index.html @@ -253,21 +253,21 @@

lightmap-uv-channel

lightmap-uv-channel render

Rendered headless by the example itself — click to zoom.

-
witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 1114 islands with 0 SAT overlaps, min island distance 0.00401.
+
witnesses Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 3680 islands with 0 SAT overlaps, min island distance 0.00401.
blender --background --python examples/lightmap-uv-channel/lightmap_uv_channel.py --
-

A runnable example building the second UV layer engines require for baked lighting, on an asset built to be reused: a market cart (bed, axle, two wheels, four posts, arched canvas canopy — ground-level pivot at z=0, identity transforms by construction, Cart.* datablocks, watertight parts throughout). UV0 is the texture channel (deterministic dominant-axis box projection per face); UVLight is the baked-lighting atlas (smart_projectlightmap_pack).

+

A runnable example building the second UV layer engines require for baked lighting, on an asset built to be reused: a market cart assembled from 21 named, watertight parts — a plank-grooved bed with side rails, corner brackets, and fasteners, real wheel assemblies (disc + hub + iron band + bolt ring) on a capped axle, four posts, and a ribbed canvas canopy. Ground-level pivot at z=0, identity transforms by construction, Cart.* datablocks. (An earlier revision shipped a soft uniform-tan cart with lumpy polygon wheels and was remodeled under the asset-quality gate; its wheel material was also silently never applied — a name-key lookup bug the exact per-part mapping now replaces.) UV0 is the texture channel (deterministic dominant-axis box projection per face); UVLight is the baked-lighting atlas (smart_projectlightmap_pack).

What it witnesses (all independently derived, nothing trusted from the packer):

What each check catches on failure: wrong layer names/count (exit 3); flags not re-established after the ops (exit 4); channel 0 touched by the UV1 unwrap (exit 5 — probed via the clobber trap, drift 2.068); UV1 loops outside the unit square (exit 6); overlapping islands (exit 7 — probed by translating an island onto a neighbor, 15 SAT hits); margin floor broken (exit 8 — probed with MARGIN_DIV=0, measured 2e-5 < 0.001); non-watertight part (exit 9).

Authoring hazards pinned while building this (probes in .scratch-style bisect scripts; the reasons the check code re-fetches layers by name and re-asserts flags):

Pipeline arc neighbors: UV-layer authoring in uv-layer-grid, tangent-space from UVs in triangulate-tangents, export round-trip in gltf-export-roundtrip, pivot discipline in prop-origin-transform.

-

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 (9 parts, 1114 islands, drift 0, overlap 0, min island distance 0.00401). The UV *layout* is packer-version-dependent by design; the contracts are layout-independent invariants.

-

Render as proof: the cart beside its UV1 atlas board — the Bed's packed lightmap built from live UV data, so a change in the atlas moves the board geometry. The falsification variant (--falsify) translates the second-largest island onto the largest: a big emissive-red island visibly stacked over the atlas (15 SAT hits in the check probe).

+

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 (21 parts, 3680 islands, drift 0, overlap 0, min island distance 0.00401). The UV *layout* is packer-version-dependent by design; the contracts are layout-independent invariants.

+

Render as proof: the cart beside its enlarged UV1 atlas board — the Bed's packed lightmap built from live UV data, so a change in the atlas moves the board geometry. The falsification variant (--falsify) translates the second-largest island onto the largest: a big emissive-red island visibly stacked over the atlas (15 SAT hits in the check probe). The render path also gates the asset itself through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11): 21 named parts, 5 materials, right-angle share 0.069.

Run

blender --background --python lightmap_uv_channel.py --
 blender --background --python lightmap_uv_channel.py -- --output atlas.png
@@ -327,6 +327,7 @@ 

Source

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree import gallery_framing +import gallery_asset_quality TOL = 1e-6 # UV0 preservation tolerance BOUNDS_TOL = 1e-5 # UV1 unit-square slack @@ -370,11 +371,11 @@

Source

return me -def _wheel(name, radius, width, center): +def _wheel_disc(name, radius, width, center): me = bpy.data.meshes.new(name) bm = bmesh.new() try: - bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=20, + bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=24, radius1=radius, radius2=radius, depth=width) # cone axis is Z; the wheel rolls around Y -> rotate verts, applied in data for v in bm.verts: @@ -388,37 +389,135 @@

Source

return me +def _ring(name, r_out, r_in, width, center, segments=24): + """Flat annulus (iron tire band) spun from a 4-vert profile, watertight.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + hw = width / 2 + prof = [(r_in, -hw), (r_out, -hw), (r_out, hw), (r_in, hw)] + vs = [bm.verts.new((r, 0.0, z)) for r, z in prof] + es = [bm.edges.new((vs[i], vs[(i + 1) % 4])) for i in range(4)] + bmesh.ops.spin(bm, geom=vs + es, cent=(0.0, 0.0, 0.0), + axis=(0.0, 0.0, 1.0), dvec=(0.0, 0.0, 0.0), + angle=2 * math.pi, steps=segments, use_merge=True) + for v in bm.verts: + x, y, z = v.co.x, v.co.y, v.co.z + v.co = (x + center[0], z + center[1], y + center[2]) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _bolt_ring(name, count, ring_r, bolt_r, center): + """count bolt heads on a ring around Y — one mesh, disconnected islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for i in range(count): + a = 2 * math.pi * i / count + cx, cz = center[0] + ring_r * math.cos(a), center[2] + ring_r * math.sin(a) + before_v = set(bm.verts) + bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=bolt_r) + for v in set(bm.verts) - before_v: + v.co.x += cx + v.co.y += center[1] + v.co.z += cz + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _arc_band(bm, xc, width, radius, thickness, z0, segments): + """One arched sheet spanning x in [xc, xc+width], solidified with rims.""" + sweep = math.radians(120.0) + a0 = math.radians(90.0) - sweep / 2 + outer, inner = [], [] + for i in range(segments + 1): + a = a0 + sweep * i / segments + ca, sa = math.cos(a), math.sin(a) + outer.append(bm.verts.new((xc, radius * ca, z0 + radius * sa))) + inner.append(bm.verts.new((xc, (radius - thickness) * ca, + z0 + (radius - thickness) * sa))) + def band(ring_o, ring_i): + for i in range(segments): + a, b = i, i + 1 + bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a])) + outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer] + inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner] + band(outer, inner) # underside (thickness face) + band(inner_x2, outer_x2) # top face + for i in range(segments): # the two long rims + a, b = i, i + 1 + bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b])) + bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a])) + bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0])) + bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1])) + + def _canopy(name, width, radius, thickness, z0, segments=14): """Arched canvas sheet: an arc band solidified with a rim, watertight.""" me = bpy.data.meshes.new(name) bm = bmesh.new() try: - sweep = math.radians(120.0) - a0 = math.radians(90.0) - sweep / 2 - outer, inner = [], [] - for i in range(segments + 1): - a = a0 + sweep * i / segments - ca, sa = math.cos(a), math.sin(a) - outer.append(bm.verts.new((-width / 2, radius * ca, z0 + radius * sa))) - inner.append(bm.verts.new((-width / 2, (radius - thickness) * ca, - z0 + (radius - thickness) * sa))) - def band(off_x, ring_o, ring_i): - for i in range(segments): - a, b = i, i + 1 - bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a])) - outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer] - inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner] - band(0, outer, inner) # underside (thickness face) - band(width, inner_x2, outer_x2) # top face - for i in range(segments): # the two long rims - a, b = i, i + 1 - bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b])) - bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a])) - for ring in ((outer, outer_x2), (inner, inner_x2)): # end caps - for i in (0, segments): - pass - bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0])) - bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1])) + _arc_band(bm, -width / 2, width, radius, thickness, z0, segments) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _canopy_ribs(name, positions, width, radius, thickness, z0, segments=14): + """Thin wooden ribs following the canopy arc, one mesh, N islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for px in positions: + _arc_band(bm, px - width / 2, width, radius, thickness, z0, segments) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _bolts(name, positions, radius): + """Bolt head spheres at positions — one mesh, disconnected islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for px, py, pz in positions: + before_v = set(bm.verts) + bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius) + for v in set(bm.verts) - before_v: + v.co.x += px + v.co.y += py + v.co.z += pz + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _multi_box(name, specs): + """Several beveled boxes as one mesh (disconnected islands, each + watertight). specs: (dims, center, bevel).""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for dims, center, bevel in specs: + before_e = set(bm.edges) + before_v = set(bm.verts) + bmesh.ops.create_cube(bm, size=1.0) + new_edges = list(set(bm.edges) - before_e) + for v in set(bm.verts) - before_v: + v.co.x = v.co.x * dims[0] + center[0] + v.co.y = v.co.y * dims[1] + center[1] + v.co.z = v.co.z * dims[2] + center[2] + if bevel > 0.0: + bmesh.ops.bevel(bm, geom=new_edges, offset=bevel, segments=2, + profile=0.5, affect="EDGES", clamp_overlap=True) bm.to_mesh(me) finally: bm.free() @@ -426,16 +525,43 @@

Source

def build_cart_meshes(): + """The cart as an assembly of named, watertight parts: real wheel + assemblies (disc + hub + iron band + bolt ring), plank-grooved bed with + side rails, corner brackets, and fasteners, and a ribbed canvas canopy.""" parts = {} parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03) - parts["Axle"] = _wheel("Cart.Axle", 0.05, 1.5, (0.55, 0.0, 0.5)) - parts["Wheel.L"] = _wheel("Cart.Wheel.L", 0.45, 0.12, (0.55, 0.68, 0.45)) - parts["Wheel.R"] = _wheel("Cart.Wheel.R", 0.45, 0.12, (0.55, -0.68, 0.45)) + parts["Bed.Rails"] = _multi_box("Cart.Bed.Rails", [ + ((2.2, 0.06, 0.18), (0.0, 0.57, 1.02), 0.015), + ((2.2, 0.06, 0.18), (0.0, -0.57, 1.02), 0.015)]) + parts["Bed.Grooves"] = _multi_box("Cart.Bed.Grooves", [ + ((0.035, 1.14, 0.015), (x, 0.0, 0.9575), 0.0) + for x in (-0.66, -0.22, 0.22, 0.66)]) + parts["Bed.Brackets"] = _multi_box("Cart.Bed.Brackets", [ + ((0.03, 0.13, 0.26), (sx * 1.105, sy * 0.535, 0.85), 0.008) + for sx in (-1, 1) for sy in (-1, 1)]) + parts["Bed.Bolts"] = _bolts("Cart.Bed.Bolts", [ + (sx * 1.125, sy * 0.535, z) for sx in (-1, 1) for sy in (-1, 1) + for z in (0.78, 0.92)], 0.018) + parts["Axle"] = _wheel_disc("Cart.Axle", 0.05, 1.56, (0.55, 0.0, 0.5)) + parts["Axle.Caps"] = _multi_box("Cart.Axle.Caps", [ + ((0.14, 0.05, 0.14), (0.55, sy * 0.80, 0.5), 0.02) for sy in (-1, 1)]) + for tag, sy in (("L", 1.0), ("R", -1.0)): + y = sy * 0.68 + parts[f"Wheel.{tag}.Disc"] = _wheel_disc( + f"Cart.Wheel.{tag}.Disc", 0.45, 0.10, (0.55, y, 0.45)) + parts[f"Wheel.{tag}.Hub"] = _wheel_disc( + f"Cart.Wheel.{tag}.Hub", 0.14, 0.16, (0.55, y, 0.45)) + parts[f"Wheel.{tag}.Band"] = _ring( + f"Cart.Wheel.{tag}.Band", 0.47, 0.435, 0.10, (0.55, y, 0.45)) + parts[f"Wheel.{tag}.Bolts"] = _bolt_ring( + f"Cart.Wheel.{tag}.Bolts", 6, 0.09, 0.02, (0.55, y + sy * 0.085, 0.45)) for tag, (px, py) in (("FL", (0.95, 0.48)), ("FR", (0.95, -0.48)), ("RL", (-0.95, 0.48)), ("RR", (-0.95, -0.48))): parts[f"Post.{tag}"] = _box(f"Cart.Post.{tag}", (0.09, 0.09, 1.35), (px, py, 1.55), 0.015) parts["Canopy"] = _canopy("Cart.Canopy", 2.3, 0.85, 0.04, 1.9) + parts["Canopy.Ribs"] = _canopy_ribs("Cart.Canopy.Ribs", (-0.75, 0.0, 0.75), + 0.07, 0.875, 0.035, 1.9) return parts @@ -794,18 +920,30 @@

Source

bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene - wood = make_material("Wood", (0.42, 0.26, 0.13), rough=0.6) + wood = make_material("Wood", (0.38, 0.22, 0.10), rough=0.55) darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7) - iron = make_material("Iron", (0.18, 0.18, 0.20), rough=0.35, metallic=0.9) + iron = make_material("Iron", (0.26, 0.26, 0.30), rough=0.3, metallic=0.9) canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9) + groovemat = make_material("Groove", (0.05, 0.04, 0.03), rough=0.9) + + mat_by_part = { + "Bed": wood, "Bed.Rails": wood, "Bed.Grooves": groovemat, + "Bed.Brackets": iron, "Bed.Bolts": iron, + "Axle": iron, "Axle.Caps": iron, + "Canopy": canvas, "Canopy.Ribs": darkwood, + } + for tag in ("L", "R"): + mat_by_part[f"Wheel.{tag}.Disc"] = darkwood + mat_by_part[f"Wheel.{tag}.Hub"] = darkwood + mat_by_part[f"Wheel.{tag}.Band"] = iron + mat_by_part[f"Wheel.{tag}.Bolts"] = iron + for tag in ("FL", "FR", "RL", "RR"): + mat_by_part[f"Post.{tag}"] = darkwood - mats = {"Bed": wood, "Axle": iron, "Wheel.L": darkwood, "Wheel.R": darkwood, - "Canopy": canvas} parts_obs = [] meshes = build_cart_meshes() for suffix, me in meshes.items(): - me.materials.append(mats.get(suffix.split(".")[0], wood) - if not suffix.startswith("Post") else wood) + me.materials.append(mat_by_part[suffix]) me.uv_layers.new(name=LAYER0) me.uv_layers.new(name=LAYER1) write_uv0_box_projection(me) @@ -835,10 +973,10 @@

Source

# atlas board: the Bed's live UV1 as flat island polygons mapped onto the # board face — a change in the packed atlas moves the board geometry board_mat = make_material("Board", (0.04, 0.04, 0.05), rough=0.5, metallic=0.4) - board = _box("Atlas.Board", (0.07, 1.7, 1.7), (0.0, 0.0, 0.0), 0.02) + board = _box("Atlas.Board", (0.08, 2.2, 2.0), (0.0, 0.0, 0.0), 0.02) board.materials.append(board_mat) board_ob = bpy.data.objects.new("Atlas.Board", board) - board_ob.location = (1.75, 0.55, 1.0) + board_ob.location = (2.05, 0.55, 1.15) sc.collection.objects.link(board_ob) bed = meshes["Bed"] @@ -853,7 +991,7 @@

Source

bm = bmesh.new() try: # board face: +X side; UV [0,1]^2 -> y,z on the face (u -> -y, v -> z) - vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.5, (uv[1] - 0.5) * 1.5)) + vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.9, (uv[1] - 0.5) * 1.9)) for uv in pts] bm.faces.new(vs) bm.to_mesh(me) @@ -864,7 +1002,7 @@

Source

me.materials.append(make_material(f"IslandMat{fi}", rgb, rough=0.5, emit=rgb, estr=1.6 if defect else 0.5)) ob = bpy.data.objects.new(f"Atlas.Island.{fi}", me) - ob.location = (1.75 + 0.037 + (0.004 if fi in dragged else 0.0), 0.55, 1.0) + ob.location = (2.05 + 0.045 + (0.004 if fi in dragged else 0.0), 0.55, 1.15) sc.collection.objects.link(ob) iso_obs.append(ob) @@ -873,10 +1011,10 @@

Source

cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 47.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (5.4, -6.6, 2.7) + cam.location = (5.9, -7.2, 2.8) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.75, 0.1, 1.08) + aim.location = (0.85, 0.1, 1.05) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim @@ -907,6 +1045,10 @@

Source

) if fcode: return fcode + aqcode = gallery_asset_quality.check_asset_quality( + sc, cam, hero=hero, stage=[floor, wall]) + if aqcode: + return aqcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): print("ERROR: render produced no file", file=sys.stderr) diff --git a/docs/gallery/modular-kit-snap/index.html b/docs/gallery/modular-kit-snap/index.html index d50f3f2..3bc9d91 100644 --- a/docs/gallery/modular-kit-snap/index.html +++ b/docs/gallery/modular-kit-snap/index.html @@ -260,13 +260,13 @@

modular-kit-snap

A runnable example building the asset a tiling modular kit lives or dies by: a corridor segment whose open-end boundary vertices sit exactly on the declared tile grid, so instances placed at 4 m multiples share boundary positions with no gap and no overlap. Interior geometry is free-form (beveled detail boxes); only the boundary is snapped — the snap is a deliberate authoring pass, and the check is what catches you skipping it.

-

The asset, for reuse: a 4 × 3 × 3 m corridor segment (hollow-rectangle shell plus twelve detail parts — frame rib, walkway plate, wall panels, trim rails, emissive light strips). Origin at the connection pivot (floor-center of the start edge, so instance *n* places at x = n·4), identity transforms by construction, datablocks under Kit.CorridorSeg.*, manifold everywhere except the two intentional open ends, and every detail part contained strictly inside the tile so it can never break a joint. Drop the hierarchy into a scene and array it along X.

+

The asset, for reuse: a 4 × 3 × 3 m corridor segment (hollow-rectangle shell plus twenty detail parts — frame rib, walkway plate, wall panel assemblies (backing plate + inset panel + four bolt heads each), trim rails, emissive light strips). Origin at the connection pivot (floor-center of the start edge, so instance *n* places at x = n·4), identity transforms by construction, datablocks under Kit.CorridorSeg.*, manifold everywhere except the two intentional open ends, and every detail part contained strictly inside the tile so it can never break a joint. Drop the hierarchy into a scene and array it along X.

Pipeline arc neighbors: watertight parametric solids in bmesh-gear, topology gates in mesh-hygiene-audit, origin/pivot discipline in prop-origin-transform, collision packaging in collision-hull-proxy.

What it witnesses (all closed form or independently re-derived):

  • Snap. Exactly 16 boundary verts (8 per open end, from the hollow-rectangle profile), every one on its end plane x ∈ {0, 4} within 1e-6 m; every boundary edge lies on an end plane.
  • Loop coincidence. The two end rings, matched by nearest (y, z) key, agree within 1e-6 — opposing loops are coincident under the tile offset.
  • Tiling. A linked duplicate offset by (4, 0, 0) places its start ring at world positions matching the original's end ring within 1e-6 — no gap, no overlap at the joint.
  • Bounding box. The shell's local bbox equals the declared tile exactly (0..4 × ±1.5 × 0..3, all float32-exact values, deviation 0.0), and the whole-asset bbox matches it because detail is contained.
  • Manifold. Every edge has exactly 2 link faces except the 16 boundary ring edges (1 face each) — the open ends are the only boundaries.
  • Reuse hygiene. Identity scales, Kit.CorridorSeg.* names, all part origins at the pivot.

What each check catches on failure (probed with an unsnapped variant built by the same code minus the snap pass — 3 mm end-ring skew, 2 mm y-nudge on two verts): boundary verts off the end planes, worst 3.000e-03 m (exit 3); opposing rings displaced 2.000e-03 (exit 4); tiled joint gap/overlap (exit 5); bbox off the declared tile by 3.000e-03 (exit 6); boundary edges torn off the rim (exit 7); non-watertight detail (exit 8); detail escaping the tile (exit 9); unapplied transforms, default names, or wandering origins (exit 11).

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same zero measured deviations.

-

Render as proof: a four-segment run — the joints vanish. The falsification variant (--falsify) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure class at 3 mm; the render exaggerates it to read at frame scale.

+

Render as proof: a four-segment run — the joints vanish. The falsification variant (--falsify) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure class at 3 mm; the render exaggerates it to read at frame scale. The render path also gates the asset through examples/gallery_asset_quality.py (naming, material variation, edge treatment — exit 11). An earlier revision shipped flat grey wall panels and was remodeled under that gate: each panel is now an assembly (backing plate + inset panel + four bolt heads), the palette shifted teal-slate so the card does not twin with lightmap-uv-channel's warm cart, and mean luminance was brought into the calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation needed.

Framing deviation: the still is an interior corridor run — the envelope surrounds the camera on five sides and the tiling joints are the proof, so the subject reads as extending past the frame (the radiating-architecture class of VISUAL-STYLE Layer 1). check_framing measures and reports (fill 1.000/1.000, all-edge bleed) without enforcing, with the reason string at the call site.

Run

blender --background --python modular_kit_snap.py --
@@ -328,6 +328,7 @@ 

Source

sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree import gallery_framing +import gallery_asset_quality TILE = 4.0 # tile length along X, metres WIDTH = 3.0 # corridor width along Y ( +/- 1.5 ) @@ -423,11 +424,6 @@

Source

("Rib.Beam", (0.24, 2 * inner_y - 0.30, 0.34), (0.18, 0.0, HEIGHT - WALL - 0.17), 0.02), # walkway floor plate ("FloorPlate", (3.4, 1.8, 0.05), (TILE / 2, 0.0, WALL + 0.025), 0.015), - # wall panels, two per side, recessed-look slabs on the bore face - ("Panel.L.1", (1.15, 0.05, 1.15), (1.2, inner_y - 0.025, 1.55), 0.02), - ("Panel.L.2", (1.15, 0.05, 1.15), (2.8, inner_y - 0.025, 1.55), 0.02), - ("Panel.R.1", (1.15, 0.05, 1.15), (1.2, -(inner_y - 0.025), 1.55), 0.02), - ("Panel.R.2", (1.15, 0.05, 1.15), (2.8, -(inner_y - 0.025), 1.55), 0.02), # orange trim rails — the lines that jog visibly when a joint steps ("Trim.L", (3.7, 0.06, 0.09), (TILE / 2, inner_y - 0.03, 0.78), 0.015), ("Trim.R", (3.7, 0.06, 0.09), (TILE / 2, -(inner_y - 0.03), 0.78), 0.015), @@ -435,14 +431,49 @@

Source

("Light.L", (3.0, 0.05, 0.07), (TILE / 2, inner_y - 0.025, HEIGHT - WALL - 0.09), 0.01), ("Light.R", (3.0, 0.05, 0.07), (TILE / 2, -(inner_y - 0.025), HEIGHT - WALL - 0.09), 0.01), ] + # wall panel assemblies: backing plate proud of the bore face, a lighter + # inset panel — depth, not flat grey rectangles. Bolt heads are spheres, + # built by _bolts_mesh in build_kit_meshes rather than as boxes. + for side, sy in (("L", 1.0), ("R", -1.0)): + for idx, px in ((1, 1.2), (2, 2.8)): + parts.append((f"PanelBack.{side}.{idx}", (1.35, 0.04, 1.35), + (px, sy * (inner_y - 0.02), 1.55), 0.015)) + parts.append((f"Panel.{side}.{idx}", (1.15, 0.05, 1.15), + (px, sy * (inner_y - 0.045), 1.55), 0.02)) return parts +def _bolts_mesh(name, positions, radius): + """Bolt head spheres at positions — one mesh, disconnected islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for px, py, pz in positions: + before_v = set(bm.verts) + bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius) + for v in set(bm.verts) - before_v: + v.co.x += px + v.co.y += py + v.co.z += pz + bm.to_mesh(me) + finally: + bm.free() + return me + + def build_kit_meshes(skew=0.0, nudge=0.0): """All kit meshes, keyed by part suffix. Skew/nudge touch the shell only.""" meshes = {"Shell": build_shell("Kit.CorridorSeg.Shell", skew, nudge)} for suffix, dims, center, bevel in detail_layout(): meshes[suffix] = build_detail(f"Kit.CorridorSeg.{suffix}", dims, center, bevel) + # panel fixings: four bolt heads per panel, proud of the inset panel + inner_y = WIDTH / 2 - WALL + for side, sy in (("L", 1.0), ("R", -1.0)): + for idx, px in ((1, 1.2), (2, 2.8)): + positions = [(px + dx, sy * (inner_y - 0.07), 1.55 + dz) + for dx in (-0.5, 0.5) for dz in (-0.5, 0.5)] + meshes[f"PanelBolts.{side}.{idx}"] = _bolts_mesh( + f"Kit.CorridorSeg.PanelBolts.{side}.{idx}", positions, 0.022) return meshes @@ -650,19 +681,28 @@

Source

SEGMENT_MATS = { - "Shell": ("Shell", (0.10, 0.11, 0.13), 0.5, 0.8, None, 0.0), + "Shell": ("Shell", (0.075, 0.095, 0.115), 0.5, 0.8, None, 0.0), "Rib": ("Rib", (0.05, 0.05, 0.06), 0.45, 0.85, None, 0.0), - "FloorPlate": ("FloorPlate", (0.07, 0.08, 0.09), 0.6, 0.7, None, 0.0), - "Panel": ("Panel", (0.19, 0.25, 0.33), 0.4, 0.6, None, 0.0), + "FloorPlate": ("FloorPlate", (0.06, 0.075, 0.09), 0.6, 0.7, None, 0.0), + "Panel": ("Panel", (0.10, 0.21, 0.26), 0.42, 0.6, None, 0.0), + "PanelBack": ("PanelBack", (0.06, 0.10, 0.13), 0.5, 0.75, None, 0.0), + "PanelBolts": ("PanelBolts", (0.35, 0.38, 0.42), 0.3, 0.9, None, 0.0), "Trim": ("Trim", (0.85, 0.35, 0.08), 0.35, 0.3, None, 0.0), "Light": ("LightStrip", (0.30, 0.25, 0.18), 0.5, 0.2, (1.0, 0.75, 0.4), 8.0), } +_mat_cache = {} + + def mat_for(suffix): + """One material per design key, shared across segments (an earlier + revision instantiated a duplicate material per part per segment).""" key = suffix.split(".")[0] - name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key] - return make_material(name, rgb, rough, metal, emit, estr) + if key not in _mat_cache: + name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key] + _mat_cache[key] = make_material(name, rgb, rough, metal, emit, estr) + return _mat_cache[key] def build_kit_objects(sc, x_offset, skew=0.0, nudge=0.0, y_jog=0.0, z_jog=0.0): @@ -725,7 +765,7 @@

Source

# the kit's own lighting language, designed with the asset for i in range(4): ld = bpy.data.lights.new(f"Fixture{i}", "AREA") - ld.energy = 130.0 + ld.energy = 85.0 ld.size = 3.2 ld.color = (1.0, 0.8, 0.55) ob = bpy.data.objects.new(f"Fixture{i}", ld) @@ -740,7 +780,7 @@

Source

Not part of the kit — the corridor's destination for this still.""" frame_mat = make_material("Bulkhead", (0.06, 0.06, 0.07), rough=0.4, metallic=0.85) door_mat = make_material("BulkheadDoor", (0.30, 0.16, 0.07), rough=0.5, - metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.6) + metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.5) inner_y = WIDTH / 2 - WALL end_x = TILE * 4 parts = [] @@ -761,7 +801,7 @@

Source

parts.append(door_ob) # warm spill into the corridor, toward the camera ld = bpy.data.lights.new("BulkheadSpill", "AREA") - ld.energy = 140.0 + ld.energy = 90.0 ld.size = 1.8 ld.color = (1.0, 0.62, 0.3) ob = bpy.data.objects.new("BulkheadSpill", ld) @@ -771,7 +811,7 @@

Source

# cool rim from the mouth behind the camera: lifts the near right wall and # the ceiling edge out of dead black without touching the warm pools rd = bpy.data.lights.new("MouthRim", "AREA") - rd.energy = 65.0 + rd.energy = 45.0 rd.size = 2.0 rd.color = (0.62, 0.76, 1.0) rob = bpy.data.objects.new("MouthRim", rd) @@ -790,6 +830,7 @@

Source

sc = bpy.context.scene all_parts = [] + aq_hero = None for i in range(4): if falsify: obs = build_kit_objects( @@ -798,6 +839,8 @@

Source

z_jog=(0.04 if i % 2 else 0.0)) else: obs = build_kit_objects(sc, TILE * i) + if aq_hero is None: + aq_hero = obs[1:] # the asset-quality gate scores one segment all_parts.extend(obs[1:]) # mesh parts only, not the root empties floor, wall = build_studio(sc) bulkhead = build_bulkhead(sc) @@ -845,6 +888,10 @@

Source

) if fcode: return fcode + aqcode = gallery_asset_quality.check_asset_quality( + sc, cam, hero=aq_hero, stage=[floor, wall]) + if aqcode: + return aqcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): print("ERROR: render produced no file", file=sys.stderr) diff --git a/examples/gallery.json b/examples/gallery.json index d0c484e..60a2ae8 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -522,7 +522,7 @@ "name": "lightmap-uv-channel", "dir": "examples/lightmap-uv-channel", "teaches": "A market cart carrying the two-channel UV contract for baked lighting: UV0 untouched, UVLight packed with no overlaps and a respected margin.", - "witnessesFix": "Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 1114 islands with 0 SAT overlaps, min island distance 0.00401.", + "witnessesFix": "Per-part UVMap/UVLight with active vs active_render pinned (ops clear both flags), UV0 drift 0.0, 3680 islands with 0 SAT overlaps, min island distance 0.00401.", "hero": "docs/gallery/assets/lightmap-uv-channel-hero.webp", "preview": "examples/lightmap-uv-channel/preview.webp", "tags": [ diff --git a/examples/gallery_asset_quality.py b/examples/gallery_asset_quality.py new file mode 100644 index 0000000..8e56fbe --- /dev/null +++ b/examples/gallery_asset_quality.py @@ -0,0 +1,313 @@ +"""Gallery asset-quality measurement — the numeric form of the Layer 1 +"modeled with intent" rule (docs/VISUAL-STYLE.md). + +gallery_framing measures where the subject sits in the frame. This module +measures the subject itself: whether the model reads as designed rather +than placeholder. Same call pattern: render-path only (the check-only path +never imports it), printed measured values, non-zero exit on violation. + +Floors (calibrated against the gallery's own best- and weakest-modeled +assets — the calibration table lives in docs/VISUAL-STYLE.md): + +- **materials** — distinct materials across the hero's parts. A single + flat Principled slot across an entire prop is the gallery's most + reliable placeholder predictor. +- **parts** — named mesh parts the hero is assembled from, excluding + default datablock names (Cube, Sphere, Plane, ...). Designed assets are + assembled, not dumped. +- **edge90** — fraction of manifold mesh edges whose dihedral angle is + 90° ± EDGE90_DEG. Unbroken right angles everywhere means no bevel, no + chamfer, no shading break — manufactured props catch light on treated + edges. (Fraction, not count: big props carry honest structural corners.) +- **compactness** — silhouette perimeter²/area from an alpha matte of the + hero (the same EEVEE matte approach gallery_framing uses for fill). + Scale-invariant: a rectangle scores ~16–20, a circle ~12.6, a prop with + fixtures, cutouts, and protrusions scores higher. + +The floors are a cheap filter, not the standard — see the Goodhart note in +docs/VISUAL-STYLE.md. Bolting meaningless greebles onto a box satisfies +every number here and still fails the asset sheet, which is the deciding +gate. + +Sharing mechanism matches gallery_framing: consumers add the examples +directory to sys.path from their own __file__ and import this module. The +check-only path of every example stays free of it, so smoke runtimes are +unaffected. +""" +import math +import os +import sys +import tempfile + +import bpy +import bmesh + +EXIT_ASSET_QUALITY = 11 + +# Calibrated against reference assets (collision-hull-proxy, +# custom-normals-shade, vertex-weight-limit, lod-decimate-chain) and the +# gallery's weakest; the measurement table lives in docs/VISUAL-STYLE.md. +MATERIALS_MIN = 2 # for heroes with >= 2 parts (see scope note below) +DOMINANT_MAT_MAX = 0.75 # dominant material's share of hero parts (parts >= 2) +EDGE90_DEG = 2.0 # window around a right angle, degrees +EDGE90_MAX_FRAC = 0.75 # gear teeth peak at 0.667; raw boxes score 1.0 +COMPACTNESS_MIN = None # DROPPED as a gate floor: soccer-ball 10.0, + # bmesh-gear 18.3, turntable 19.1 are genuinely + # good low-scorers. Printed as information only. +PARTS_MIN = None # DROPPED: vertex-weight-limit's mech arm is a + # single skinned mesh and is reference-quality. + # Naming (no default names) is kept instead. + +MATTE_WIDTH = 320 +ALPHA_THRESHOLD = 0.5 + +DEFAULT_NAMES = { + "cube", "sphere", "uvsphere", "plane", "cylinder", "cone", "torus", + "grid", "monkey", "suzanne", "icosphere", "circle", "beziercurve", +} + + +def _eevee_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def _as_list(objs): + if objs is None: + return [] + if isinstance(objs, bpy.types.Object): + return [objs] + return [ob for ob in objs if ob is not None] + + +def _hero_meshes(hero): + meshes = [] + for ob in hero: + if ob.type == "MESH" and ob.data is not None: + meshes.append((ob.name, ob.data)) + return meshes + + +def measure_parts(hero): + """(part count, offending default-ish names).""" + meshes = _hero_meshes(hero) + bad = [name for name, _ in meshes + if name.lower().split(".")[0] in DEFAULT_NAMES] + return len(meshes), bad + + +def measure_materials(hero): + """(distinct materials across all hero slots, names, dominant share, + dominant name). Distinct counts every slot; the dominant share counts + the first slot per part — the flat-single-material placeholder signal + on assembled assets.""" + from collections import Counter + distinct = set() + primary = Counter() + for _, me in _hero_meshes(hero): + slots = [m for m in me.materials if m is not None] + for slot in slots: + distinct.add(slot.name) + primary[slots[0].name if slots else ""] += 1 + total = sum(primary.values()) or 1 + dominant_name, dominant_n = primary.most_common(1)[0] + return len(distinct), sorted(distinct), dominant_n / total, dominant_name + + +def measure_edge90(hero): + """Fraction of two-face edges with dihedral angle in 90 ± EDGE90_DEG. + + Boundary edges (open kit ends, sheet rims) are excluded — they have no + dihedral to treat. Angles are face-normal angles on manifold edges. + """ + total = 0 + right = 0 + for _, me in _hero_meshes(hero): + bm = bmesh.new() + try: + bm.from_mesh(me) + 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: + right += 1 + finally: + bm.free() + return (right / total) if total else 0.0, right, total + + +def _matte_render(scene, camera, hide, path, width, height): + """One small EEVEE alpha-matte render; caller-hidden state restored. + Mirrors gallery_framing._matte_render's approach (stage hidden, + film_transparent, low samples).""" + rd = scene.render + saved = { + "engine": rd.engine, + "res": (rd.resolution_x, rd.resolution_y, rd.resolution_percentage), + "film": rd.film_transparent, + "filepath": rd.filepath, + "fmt": rd.image_settings.file_format, + "cmode": rd.image_settings.color_mode, + "cam": scene.camera, + } + touched = [] + try: + try: + saved["samples"] = scene.eevee.taa_render_samples + except AttributeError: + saved["samples"] = None + rd.engine = _eevee_id() + rd.resolution_x, rd.resolution_y, rd.resolution_percentage = width, height, 100 + rd.film_transparent = True + rd.image_settings.file_format = "PNG" + rd.image_settings.color_mode = "RGBA" + rd.filepath = path + scene.camera = camera + for ob in hide: + if not ob.hide_render: + touched.append(ob) + ob.hide_render = True + if saved["samples"] is not None: + scene.eevee.taa_render_samples = 8 + bpy.ops.render.render(write_still=True, scene=scene.name) + finally: + for ob in touched: + ob.hide_render = False + rd.engine = saved["engine"] + rd.resolution_x, rd.resolution_y, rd.resolution_percentage = saved["res"] + rd.film_transparent = saved["film"] + rd.image_settings.file_format = saved["fmt"] + rd.image_settings.color_mode = saved["cmode"] + rd.filepath = saved["filepath"] + scene.camera = saved["cam"] + if saved["samples"] is not None: + scene.eevee.taa_render_samples = saved["samples"] + + +def measure_compactness(scene, camera, hero, stage=()): + """Silhouette perimeter²/area from an alpha matte of the hero alone. + + Scale- and framing-invariant: the same shape scores the same fill or + far. Rectangle ~16-20, circle ~12.6, fixtures/cutouts raise it. + """ + render_types = {"MESH", "CURVE", "SURFACE", "FONT", "META", "VOLUME", + "GPENCIL", "GREASEPENCIL"} + stage_set = set(_as_list(stage)) + hero_set = set(_as_list(hero)) + hide = [ob for ob in scene.objects + if ob.type in render_types and ob not in hero_set] + [ + ob for ob in stage_set if ob.type in render_types] + hide = list({id(ob): ob for ob in hide}.values()) + + rd = scene.render + aspect_h = max(1, round(MATTE_WIDTH * rd.resolution_y / max(1, rd.resolution_x))) + fd, tmp = tempfile.mkstemp(suffix=".png", prefix="aq_matte_") + os.close(fd) + try: + _matte_render(scene, camera, hide, tmp, MATTE_WIDTH, aspect_h) + img = bpy.data.images.load(tmp, check_existing=False) + try: + w, h = img.size + px = img.pixels[:] + finally: + bpy.data.images.remove(img) + finally: + if os.path.exists(tmp): + os.remove(tmp) + + mask = [[px[(y * w + x) * 4 + 3] > ALPHA_THRESHOLD for x in range(w)] + for y in range(h)] + area = 0 + perim = 0 + for y in range(h): + for x in range(w): + if not mask[y][x]: + continue + area += 1 + if (x == 0 or not mask[y][x - 1]) or (x == w - 1 or not mask[y][x + 1]) \ + or (y == 0 or not mask[y - 1][x]) or (y == h - 1 or not mask[y + 1][x]): + perim += 1 + if area == 0: + return 0.0, 0, 0 + return perim * perim / area, perim, area + + +class AssetQualityResult: + def __init__(self): + self.parts = 0 + self.bad_names = [] + self.materials = 0 + self.mat_names = [] + self.dominant_share = 0.0 + self.dominant_name = "" + self.edge90 = 0.0 + self.edge_right = 0 + self.edge_total = 0 + self.compactness = 0.0 + self.perimeter = 0 + self.area = 0 + + def report(self): + def mark(ok): + return "ok" if ok else "FAIL" + name_ok = not self.bad_names + mat_ok = self.parts < 2 or (self.materials >= MATERIALS_MIN + and self.dominant_share <= DOMINANT_MAT_MAX) + edge_ok = self.edge90 <= EDGE90_MAX_FRAC + return "\n".join([ + f"aq_naming default_names={self.bad_names or 'none'} {mark(name_ok)}", + f"aq_materials n={self.materials} floor={MATERIALS_MIN} " + f"dominant={self.dominant_name}@{self.dominant_share:.2f} " + 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"ceiling={EDGE90_MAX_FRAC} {mark(edge_ok)}", + f"aq_compactness {self.compactness:.1f} (perim={self.perimeter} " + f"area={self.area}) informational-only", + ]) + + +def measure_asset_quality(scene, camera, hero, stage=()): + """Measure the floors for one staged hero. Returns AssetQualityResult.""" + hero = _as_list(hero) + res = AssetQualityResult() + 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.compactness, res.perimeter, res.area = measure_compactness( + scene, camera, hero, stage=stage) + return res + + +def check_asset_quality(scene, camera, hero, stage=(), *, + materials_min=MATERIALS_MIN, dominant_max=DOMINANT_MAT_MAX, + edge90_max=EDGE90_MAX_FRAC): + """Measure, print, gate: 0 pass, EXIT_ASSET_QUALITY (11) on violation. + + Render path only — never call from an example's check-only path. + Floors: no default datablock names; for assembled heroes (parts >= 2) + at least two materials with the dominant share under the ceiling; + right-angle edge fraction under the ceiling. Compactness is measured + and printed but never gated (dropped: fails genuinely good simple + subjects — see module docstring and docs/VISUAL-STYLE.md). + """ + res = measure_asset_quality(scene, camera, hero, stage=stage) + print(res.report()) + failures = [] + if res.bad_names: + failures.append(f"default datablock names {res.bad_names}") + if res.parts >= 2 and res.materials < materials_min: + failures.append(f"materials {res.materials}<{materials_min} " + f"on a {res.parts}-part hero") + if res.edge90 > edge90_max: + failures.append(f"edge90 {res.edge90:.3f}>{edge90_max}") + if failures: + print("ERROR: asset-quality violation — " + "; ".join(failures), + file=sys.stderr) + return EXIT_ASSET_QUALITY + print("aq_ok") + return 0 diff --git a/examples/lightmap-uv-channel/README.md b/examples/lightmap-uv-channel/README.md index 61b8654..66314ee 100644 --- a/examples/lightmap-uv-channel/README.md +++ b/examples/lightmap-uv-channel/README.md @@ -1,10 +1,15 @@ # Lightmap UV Channel A runnable example building the second UV layer engines require for baked -lighting, on an asset built to be reused: a market cart (bed, axle, two -wheels, four posts, arched canvas canopy — ground-level pivot at `z=0`, -identity transforms by construction, `Cart.*` datablocks, watertight parts -throughout). UV0 is the texture channel (deterministic dominant-axis box +lighting, on an asset built to be reused: a market cart assembled from +21 named, watertight parts — a plank-grooved bed with side rails, corner +brackets, and fasteners, real wheel assemblies (disc + hub + iron band + +bolt ring) on a capped axle, four posts, and a ribbed canvas canopy. +Ground-level pivot at `z=0`, identity transforms by construction, `Cart.*` +datablocks. (An earlier revision shipped a soft uniform-tan cart with +lumpy polygon wheels and was remodeled under the asset-quality gate; +its wheel material was also silently never applied — a name-key lookup +bug the exact per-part mapping now replaces.) UV0 is the texture channel (deterministic dominant-axis box projection per face); UVLight is the baked-lighting atlas (`smart_project` → `lightmap_pack`). @@ -60,15 +65,19 @@ layers by name and re-asserts flags): [`prop-origin-transform`](../prop-origin-transform/). **Version witness:** check output is byte-identical on Blender 4.5.11 LTS -and 5.1.2 (9 parts, 1114 islands, drift 0, overlap 0, min island distance +and 5.1.2 (21 parts, 3680 islands, drift 0, overlap 0, min island distance 0.00401). The UV *layout* is packer-version-dependent by design; the contracts are layout-independent invariants. -**Render as proof:** the cart beside its UV1 atlas board — the Bed's -packed lightmap built from live UV data, so a change in the atlas moves -the board geometry. The falsification variant (`--falsify`) translates the -second-largest island onto the largest: a big emissive-red island visibly -stacked over the atlas (15 SAT hits in the check probe). +**Render as proof:** the cart beside its enlarged UV1 atlas board — the +Bed's packed lightmap built from live UV data, so a change in the atlas +moves the board geometry. The falsification variant (`--falsify`) +translates the second-largest island onto the largest: a big emissive-red +island visibly stacked over the atlas (15 SAT hits in the check probe). +The render path also gates the asset itself through +`examples/gallery_asset_quality.py` (naming, material variation, edge +treatment — exit 11): 21 named parts, 5 materials, right-angle share +0.069. ## Run diff --git a/examples/lightmap-uv-channel/lightmap_uv_channel.py b/examples/lightmap-uv-channel/lightmap_uv_channel.py index d47404a..9a3bb6a 100644 --- a/examples/lightmap-uv-channel/lightmap_uv_channel.py +++ b/examples/lightmap-uv-channel/lightmap_uv_channel.py @@ -45,6 +45,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree import gallery_framing +import gallery_asset_quality TOL = 1e-6 # UV0 preservation tolerance BOUNDS_TOL = 1e-5 # UV1 unit-square slack @@ -88,11 +89,11 @@ def _box(name, dims, center, bevel=0.0): return me -def _wheel(name, radius, width, center): +def _wheel_disc(name, radius, width, center): me = bpy.data.meshes.new(name) bm = bmesh.new() try: - bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=20, + bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=24, radius1=radius, radius2=radius, depth=width) # cone axis is Z; the wheel rolls around Y -> rotate verts, applied in data for v in bm.verts: @@ -106,37 +107,135 @@ def _wheel(name, radius, width, center): return me +def _ring(name, r_out, r_in, width, center, segments=24): + """Flat annulus (iron tire band) spun from a 4-vert profile, watertight.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + hw = width / 2 + prof = [(r_in, -hw), (r_out, -hw), (r_out, hw), (r_in, hw)] + vs = [bm.verts.new((r, 0.0, z)) for r, z in prof] + es = [bm.edges.new((vs[i], vs[(i + 1) % 4])) for i in range(4)] + bmesh.ops.spin(bm, geom=vs + es, cent=(0.0, 0.0, 0.0), + axis=(0.0, 0.0, 1.0), dvec=(0.0, 0.0, 0.0), + angle=2 * math.pi, steps=segments, use_merge=True) + for v in bm.verts: + x, y, z = v.co.x, v.co.y, v.co.z + v.co = (x + center[0], z + center[1], y + center[2]) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _bolt_ring(name, count, ring_r, bolt_r, center): + """count bolt heads on a ring around Y — one mesh, disconnected islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for i in range(count): + a = 2 * math.pi * i / count + cx, cz = center[0] + ring_r * math.cos(a), center[2] + ring_r * math.sin(a) + before_v = set(bm.verts) + bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=bolt_r) + for v in set(bm.verts) - before_v: + v.co.x += cx + v.co.y += center[1] + v.co.z += cz + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _arc_band(bm, xc, width, radius, thickness, z0, segments): + """One arched sheet spanning x in [xc, xc+width], solidified with rims.""" + sweep = math.radians(120.0) + a0 = math.radians(90.0) - sweep / 2 + outer, inner = [], [] + for i in range(segments + 1): + a = a0 + sweep * i / segments + ca, sa = math.cos(a), math.sin(a) + outer.append(bm.verts.new((xc, radius * ca, z0 + radius * sa))) + inner.append(bm.verts.new((xc, (radius - thickness) * ca, + z0 + (radius - thickness) * sa))) + def band(ring_o, ring_i): + for i in range(segments): + a, b = i, i + 1 + bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a])) + outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer] + inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner] + band(outer, inner) # underside (thickness face) + band(inner_x2, outer_x2) # top face + for i in range(segments): # the two long rims + a, b = i, i + 1 + bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b])) + bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a])) + bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0])) + bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1])) + + def _canopy(name, width, radius, thickness, z0, segments=14): """Arched canvas sheet: an arc band solidified with a rim, watertight.""" me = bpy.data.meshes.new(name) bm = bmesh.new() try: - sweep = math.radians(120.0) - a0 = math.radians(90.0) - sweep / 2 - outer, inner = [], [] - for i in range(segments + 1): - a = a0 + sweep * i / segments - ca, sa = math.cos(a), math.sin(a) - outer.append(bm.verts.new((-width / 2, radius * ca, z0 + radius * sa))) - inner.append(bm.verts.new((-width / 2, (radius - thickness) * ca, - z0 + (radius - thickness) * sa))) - def band(off_x, ring_o, ring_i): - for i in range(segments): - a, b = i, i + 1 - bm.faces.new((ring_o[a], ring_o[b], ring_i[b], ring_i[a])) - outer_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in outer] - inner_x2 = [bm.verts.new((v.co.x + width, v.co.y, v.co.z)) for v in inner] - band(0, outer, inner) # underside (thickness face) - band(width, inner_x2, outer_x2) # top face - for i in range(segments): # the two long rims - a, b = i, i + 1 - bm.faces.new((outer[a], outer_x2[a], outer_x2[b], outer[b])) - bm.faces.new((inner[a], inner[b], inner_x2[b], inner_x2[a])) - for ring in ((outer, outer_x2), (inner, inner_x2)): # end caps - for i in (0, segments): - pass - bm.faces.new((outer[0], inner[0], inner_x2[0], outer_x2[0])) - bm.faces.new((outer[-1], outer_x2[-1], inner_x2[-1], inner[-1])) + _arc_band(bm, -width / 2, width, radius, thickness, z0, segments) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _canopy_ribs(name, positions, width, radius, thickness, z0, segments=14): + """Thin wooden ribs following the canopy arc, one mesh, N islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for px in positions: + _arc_band(bm, px - width / 2, width, radius, thickness, z0, segments) + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _bolts(name, positions, radius): + """Bolt head spheres at positions — one mesh, disconnected islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for px, py, pz in positions: + before_v = set(bm.verts) + bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius) + for v in set(bm.verts) - before_v: + v.co.x += px + v.co.y += py + v.co.z += pz + bm.to_mesh(me) + finally: + bm.free() + return me + + +def _multi_box(name, specs): + """Several beveled boxes as one mesh (disconnected islands, each + watertight). specs: (dims, center, bevel).""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for dims, center, bevel in specs: + before_e = set(bm.edges) + before_v = set(bm.verts) + bmesh.ops.create_cube(bm, size=1.0) + new_edges = list(set(bm.edges) - before_e) + for v in set(bm.verts) - before_v: + v.co.x = v.co.x * dims[0] + center[0] + v.co.y = v.co.y * dims[1] + center[1] + v.co.z = v.co.z * dims[2] + center[2] + if bevel > 0.0: + bmesh.ops.bevel(bm, geom=new_edges, offset=bevel, segments=2, + profile=0.5, affect="EDGES", clamp_overlap=True) bm.to_mesh(me) finally: bm.free() @@ -144,16 +243,43 @@ def band(off_x, ring_o, ring_i): def build_cart_meshes(): + """The cart as an assembly of named, watertight parts: real wheel + assemblies (disc + hub + iron band + bolt ring), plank-grooved bed with + side rails, corner brackets, and fasteners, and a ribbed canvas canopy.""" parts = {} parts["Bed"] = _box("Cart.Bed", (2.2, 1.2, 0.22), (0.0, 0.0, 0.85), 0.03) - parts["Axle"] = _wheel("Cart.Axle", 0.05, 1.5, (0.55, 0.0, 0.5)) - parts["Wheel.L"] = _wheel("Cart.Wheel.L", 0.45, 0.12, (0.55, 0.68, 0.45)) - parts["Wheel.R"] = _wheel("Cart.Wheel.R", 0.45, 0.12, (0.55, -0.68, 0.45)) + parts["Bed.Rails"] = _multi_box("Cart.Bed.Rails", [ + ((2.2, 0.06, 0.18), (0.0, 0.57, 1.02), 0.015), + ((2.2, 0.06, 0.18), (0.0, -0.57, 1.02), 0.015)]) + parts["Bed.Grooves"] = _multi_box("Cart.Bed.Grooves", [ + ((0.035, 1.14, 0.015), (x, 0.0, 0.9575), 0.0) + for x in (-0.66, -0.22, 0.22, 0.66)]) + parts["Bed.Brackets"] = _multi_box("Cart.Bed.Brackets", [ + ((0.03, 0.13, 0.26), (sx * 1.105, sy * 0.535, 0.85), 0.008) + for sx in (-1, 1) for sy in (-1, 1)]) + parts["Bed.Bolts"] = _bolts("Cart.Bed.Bolts", [ + (sx * 1.125, sy * 0.535, z) for sx in (-1, 1) for sy in (-1, 1) + for z in (0.78, 0.92)], 0.018) + parts["Axle"] = _wheel_disc("Cart.Axle", 0.05, 1.56, (0.55, 0.0, 0.5)) + parts["Axle.Caps"] = _multi_box("Cart.Axle.Caps", [ + ((0.14, 0.05, 0.14), (0.55, sy * 0.80, 0.5), 0.02) for sy in (-1, 1)]) + for tag, sy in (("L", 1.0), ("R", -1.0)): + y = sy * 0.68 + parts[f"Wheel.{tag}.Disc"] = _wheel_disc( + f"Cart.Wheel.{tag}.Disc", 0.45, 0.10, (0.55, y, 0.45)) + parts[f"Wheel.{tag}.Hub"] = _wheel_disc( + f"Cart.Wheel.{tag}.Hub", 0.14, 0.16, (0.55, y, 0.45)) + parts[f"Wheel.{tag}.Band"] = _ring( + f"Cart.Wheel.{tag}.Band", 0.47, 0.435, 0.10, (0.55, y, 0.45)) + parts[f"Wheel.{tag}.Bolts"] = _bolt_ring( + f"Cart.Wheel.{tag}.Bolts", 6, 0.09, 0.02, (0.55, y + sy * 0.085, 0.45)) for tag, (px, py) in (("FL", (0.95, 0.48)), ("FR", (0.95, -0.48)), ("RL", (-0.95, 0.48)), ("RR", (-0.95, -0.48))): parts[f"Post.{tag}"] = _box(f"Cart.Post.{tag}", (0.09, 0.09, 1.35), (px, py, 1.55), 0.015) parts["Canopy"] = _canopy("Cart.Canopy", 2.3, 0.85, 0.04, 1.9) + parts["Canopy.Ribs"] = _canopy_ribs("Cart.Canopy.Ribs", (-0.75, 0.0, 0.75), + 0.07, 0.875, 0.035, 1.9) return parts @@ -512,18 +638,30 @@ def render_still(path, engine, falsify=False): bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene - wood = make_material("Wood", (0.42, 0.26, 0.13), rough=0.6) + wood = make_material("Wood", (0.38, 0.22, 0.10), rough=0.55) darkwood = make_material("DarkWood", (0.16, 0.10, 0.05), rough=0.7) - iron = make_material("Iron", (0.18, 0.18, 0.20), rough=0.35, metallic=0.9) + iron = make_material("Iron", (0.26, 0.26, 0.30), rough=0.3, metallic=0.9) canvas = make_material("Canvas", (0.66, 0.56, 0.40), rough=0.9) + groovemat = make_material("Groove", (0.05, 0.04, 0.03), rough=0.9) + + mat_by_part = { + "Bed": wood, "Bed.Rails": wood, "Bed.Grooves": groovemat, + "Bed.Brackets": iron, "Bed.Bolts": iron, + "Axle": iron, "Axle.Caps": iron, + "Canopy": canvas, "Canopy.Ribs": darkwood, + } + for tag in ("L", "R"): + mat_by_part[f"Wheel.{tag}.Disc"] = darkwood + mat_by_part[f"Wheel.{tag}.Hub"] = darkwood + mat_by_part[f"Wheel.{tag}.Band"] = iron + mat_by_part[f"Wheel.{tag}.Bolts"] = iron + for tag in ("FL", "FR", "RL", "RR"): + mat_by_part[f"Post.{tag}"] = darkwood - mats = {"Bed": wood, "Axle": iron, "Wheel.L": darkwood, "Wheel.R": darkwood, - "Canopy": canvas} parts_obs = [] meshes = build_cart_meshes() for suffix, me in meshes.items(): - me.materials.append(mats.get(suffix.split(".")[0], wood) - if not suffix.startswith("Post") else wood) + me.materials.append(mat_by_part[suffix]) me.uv_layers.new(name=LAYER0) me.uv_layers.new(name=LAYER1) write_uv0_box_projection(me) @@ -553,10 +691,10 @@ def render_still(path, engine, falsify=False): # atlas board: the Bed's live UV1 as flat island polygons mapped onto the # board face — a change in the packed atlas moves the board geometry board_mat = make_material("Board", (0.04, 0.04, 0.05), rough=0.5, metallic=0.4) - board = _box("Atlas.Board", (0.07, 1.7, 1.7), (0.0, 0.0, 0.0), 0.02) + board = _box("Atlas.Board", (0.08, 2.2, 2.0), (0.0, 0.0, 0.0), 0.02) board.materials.append(board_mat) board_ob = bpy.data.objects.new("Atlas.Board", board) - board_ob.location = (1.75, 0.55, 1.0) + board_ob.location = (2.05, 0.55, 1.15) sc.collection.objects.link(board_ob) bed = meshes["Bed"] @@ -571,7 +709,7 @@ def render_still(path, engine, falsify=False): bm = bmesh.new() try: # board face: +X side; UV [0,1]^2 -> y,z on the face (u -> -y, v -> z) - vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.5, (uv[1] - 0.5) * 1.5)) + vs = [bm.verts.new((0.0, (0.5 - uv[0]) * 1.9, (uv[1] - 0.5) * 1.9)) for uv in pts] bm.faces.new(vs) bm.to_mesh(me) @@ -582,7 +720,7 @@ def render_still(path, engine, falsify=False): me.materials.append(make_material(f"IslandMat{fi}", rgb, rough=0.5, emit=rgb, estr=1.6 if defect else 0.5)) ob = bpy.data.objects.new(f"Atlas.Island.{fi}", me) - ob.location = (1.75 + 0.037 + (0.004 if fi in dragged else 0.0), 0.55, 1.0) + ob.location = (2.05 + 0.045 + (0.004 if fi in dragged else 0.0), 0.55, 1.15) sc.collection.objects.link(ob) iso_obs.append(ob) @@ -591,10 +729,10 @@ def render_still(path, engine, falsify=False): cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 47.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (5.4, -6.6, 2.7) + cam.location = (5.9, -7.2, 2.8) sc.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.75, 0.1, 1.08) + aim.location = (0.85, 0.1, 1.05) sc.collection.objects.link(aim) tr = cam.constraints.new("TRACK_TO") tr.target = aim @@ -625,6 +763,10 @@ def render_still(path, engine, falsify=False): ) if fcode: return fcode + aqcode = gallery_asset_quality.check_asset_quality( + sc, cam, hero=hero, stage=[floor, wall]) + if aqcode: + return aqcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): print("ERROR: render produced no file", file=sys.stderr) diff --git a/examples/lightmap-uv-channel/preview.webp b/examples/lightmap-uv-channel/preview.webp index 0902233..2365629 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/README.md b/examples/modular-kit-snap/README.md index 859c681..8f0a7ba 100644 --- a/examples/modular-kit-snap/README.md +++ b/examples/modular-kit-snap/README.md @@ -8,8 +8,9 @@ positions with no gap and no overlap. Interior geometry is free-form deliberate authoring pass, and the check is what catches you skipping it. **The asset, for reuse:** a 4 × 3 × 3 m corridor segment (hollow-rectangle -shell plus twelve detail parts — frame rib, walkway plate, wall panels, -trim rails, emissive light strips). Origin at the connection pivot +shell plus twenty detail parts — frame rib, walkway plate, wall panel +assemblies (backing plate + inset panel + four bolt heads each), trim +rails, emissive light strips). Origin at the connection pivot (floor-center of the start edge, so instance *n* places at `x = n·4`), identity transforms by construction, datablocks under `Kit.CorridorSeg.*`, manifold everywhere except the two intentional open @@ -57,7 +58,15 @@ and 5.1.2 — same counts, same zero measured deviations. falsification variant (`--falsify`) accumulates a 120 mm gap, 50 mm lateral jogs, and 40 mm floor steps at each joint: floor plates split with dark seams and the trim rails visibly break. The check measures the same failure -class at 3 mm; the render exaggerates it to read at frame scale. +class at 3 mm; the render exaggerates it to read at frame scale. The render +path also gates the asset through `examples/gallery_asset_quality.py` +(naming, material variation, edge treatment — exit 11). An earlier revision +shipped flat grey wall panels and was remodeled under that gate: each panel +is now an assembly (backing plate + inset panel + four bolt heads), the +palette shifted teal-slate so the card does not twin with +`lightmap-uv-channel`'s warm cart, and mean luminance was brought into the +calibration range (82.7 → 66.1, ceiling 77.7) — no luminance deviation +needed. **Framing deviation:** the still is an interior corridor run — the envelope surrounds the camera on five sides and the tiling joints are the proof, so diff --git a/examples/modular-kit-snap/modular_kit_snap.py b/examples/modular-kit-snap/modular_kit_snap.py index b3a2127..d3bd73e 100644 --- a/examples/modular-kit-snap/modular_kit_snap.py +++ b/examples/modular-kit-snap/modular_kit_snap.py @@ -46,6 +46,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree import gallery_framing +import gallery_asset_quality TILE = 4.0 # tile length along X, metres WIDTH = 3.0 # corridor width along Y ( +/- 1.5 ) @@ -141,11 +142,6 @@ def detail_layout(): ("Rib.Beam", (0.24, 2 * inner_y - 0.30, 0.34), (0.18, 0.0, HEIGHT - WALL - 0.17), 0.02), # walkway floor plate ("FloorPlate", (3.4, 1.8, 0.05), (TILE / 2, 0.0, WALL + 0.025), 0.015), - # wall panels, two per side, recessed-look slabs on the bore face - ("Panel.L.1", (1.15, 0.05, 1.15), (1.2, inner_y - 0.025, 1.55), 0.02), - ("Panel.L.2", (1.15, 0.05, 1.15), (2.8, inner_y - 0.025, 1.55), 0.02), - ("Panel.R.1", (1.15, 0.05, 1.15), (1.2, -(inner_y - 0.025), 1.55), 0.02), - ("Panel.R.2", (1.15, 0.05, 1.15), (2.8, -(inner_y - 0.025), 1.55), 0.02), # orange trim rails — the lines that jog visibly when a joint steps ("Trim.L", (3.7, 0.06, 0.09), (TILE / 2, inner_y - 0.03, 0.78), 0.015), ("Trim.R", (3.7, 0.06, 0.09), (TILE / 2, -(inner_y - 0.03), 0.78), 0.015), @@ -153,14 +149,49 @@ def detail_layout(): ("Light.L", (3.0, 0.05, 0.07), (TILE / 2, inner_y - 0.025, HEIGHT - WALL - 0.09), 0.01), ("Light.R", (3.0, 0.05, 0.07), (TILE / 2, -(inner_y - 0.025), HEIGHT - WALL - 0.09), 0.01), ] + # wall panel assemblies: backing plate proud of the bore face, a lighter + # inset panel — depth, not flat grey rectangles. Bolt heads are spheres, + # built by _bolts_mesh in build_kit_meshes rather than as boxes. + for side, sy in (("L", 1.0), ("R", -1.0)): + for idx, px in ((1, 1.2), (2, 2.8)): + parts.append((f"PanelBack.{side}.{idx}", (1.35, 0.04, 1.35), + (px, sy * (inner_y - 0.02), 1.55), 0.015)) + parts.append((f"Panel.{side}.{idx}", (1.15, 0.05, 1.15), + (px, sy * (inner_y - 0.045), 1.55), 0.02)) return parts +def _bolts_mesh(name, positions, radius): + """Bolt head spheres at positions — one mesh, disconnected islands.""" + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + for px, py, pz in positions: + before_v = set(bm.verts) + bmesh.ops.create_uvsphere(bm, u_segments=8, v_segments=6, radius=radius) + for v in set(bm.verts) - before_v: + v.co.x += px + v.co.y += py + v.co.z += pz + bm.to_mesh(me) + finally: + bm.free() + return me + + def build_kit_meshes(skew=0.0, nudge=0.0): """All kit meshes, keyed by part suffix. Skew/nudge touch the shell only.""" meshes = {"Shell": build_shell("Kit.CorridorSeg.Shell", skew, nudge)} for suffix, dims, center, bevel in detail_layout(): meshes[suffix] = build_detail(f"Kit.CorridorSeg.{suffix}", dims, center, bevel) + # panel fixings: four bolt heads per panel, proud of the inset panel + inner_y = WIDTH / 2 - WALL + for side, sy in (("L", 1.0), ("R", -1.0)): + for idx, px in ((1, 1.2), (2, 2.8)): + positions = [(px + dx, sy * (inner_y - 0.07), 1.55 + dz) + for dx in (-0.5, 0.5) for dz in (-0.5, 0.5)] + meshes[f"PanelBolts.{side}.{idx}"] = _bolts_mesh( + f"Kit.CorridorSeg.PanelBolts.{side}.{idx}", positions, 0.022) return meshes @@ -368,19 +399,28 @@ def make_material(name, rgb, rough=0.45, metallic=0.6, emit=None, estr=0.0): SEGMENT_MATS = { - "Shell": ("Shell", (0.10, 0.11, 0.13), 0.5, 0.8, None, 0.0), + "Shell": ("Shell", (0.075, 0.095, 0.115), 0.5, 0.8, None, 0.0), "Rib": ("Rib", (0.05, 0.05, 0.06), 0.45, 0.85, None, 0.0), - "FloorPlate": ("FloorPlate", (0.07, 0.08, 0.09), 0.6, 0.7, None, 0.0), - "Panel": ("Panel", (0.19, 0.25, 0.33), 0.4, 0.6, None, 0.0), + "FloorPlate": ("FloorPlate", (0.06, 0.075, 0.09), 0.6, 0.7, None, 0.0), + "Panel": ("Panel", (0.10, 0.21, 0.26), 0.42, 0.6, None, 0.0), + "PanelBack": ("PanelBack", (0.06, 0.10, 0.13), 0.5, 0.75, None, 0.0), + "PanelBolts": ("PanelBolts", (0.35, 0.38, 0.42), 0.3, 0.9, None, 0.0), "Trim": ("Trim", (0.85, 0.35, 0.08), 0.35, 0.3, None, 0.0), "Light": ("LightStrip", (0.30, 0.25, 0.18), 0.5, 0.2, (1.0, 0.75, 0.4), 8.0), } +_mat_cache = {} + + def mat_for(suffix): + """One material per design key, shared across segments (an earlier + revision instantiated a duplicate material per part per segment).""" key = suffix.split(".")[0] - name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key] - return make_material(name, rgb, rough, metal, emit, estr) + if key not in _mat_cache: + name, rgb, rough, metal, emit, estr = SEGMENT_MATS[key] + _mat_cache[key] = make_material(name, rgb, rough, metal, emit, estr) + return _mat_cache[key] def build_kit_objects(sc, x_offset, skew=0.0, nudge=0.0, y_jog=0.0, z_jog=0.0): @@ -443,7 +483,7 @@ def light(name, loc, energy, size, col, rot): # the kit's own lighting language, designed with the asset for i in range(4): ld = bpy.data.lights.new(f"Fixture{i}", "AREA") - ld.energy = 130.0 + ld.energy = 85.0 ld.size = 3.2 ld.color = (1.0, 0.8, 0.55) ob = bpy.data.objects.new(f"Fixture{i}", ld) @@ -458,7 +498,7 @@ def build_bulkhead(sc): Not part of the kit — the corridor's destination for this still.""" frame_mat = make_material("Bulkhead", (0.06, 0.06, 0.07), rough=0.4, metallic=0.85) door_mat = make_material("BulkheadDoor", (0.30, 0.16, 0.07), rough=0.5, - metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.6) + metallic=0.3, emit=(1.0, 0.42, 0.12), estr=0.5) inner_y = WIDTH / 2 - WALL end_x = TILE * 4 parts = [] @@ -479,7 +519,7 @@ def build_bulkhead(sc): parts.append(door_ob) # warm spill into the corridor, toward the camera ld = bpy.data.lights.new("BulkheadSpill", "AREA") - ld.energy = 140.0 + ld.energy = 90.0 ld.size = 1.8 ld.color = (1.0, 0.62, 0.3) ob = bpy.data.objects.new("BulkheadSpill", ld) @@ -489,7 +529,7 @@ def build_bulkhead(sc): # cool rim from the mouth behind the camera: lifts the near right wall and # the ceiling edge out of dead black without touching the warm pools rd = bpy.data.lights.new("MouthRim", "AREA") - rd.energy = 65.0 + rd.energy = 45.0 rd.size = 2.0 rd.color = (0.62, 0.76, 1.0) rob = bpy.data.objects.new("MouthRim", rd) @@ -508,6 +548,7 @@ def render_still(path, engine, falsify=False): sc = bpy.context.scene all_parts = [] + aq_hero = None for i in range(4): if falsify: obs = build_kit_objects( @@ -516,6 +557,8 @@ def render_still(path, engine, falsify=False): z_jog=(0.04 if i % 2 else 0.0)) else: obs = build_kit_objects(sc, TILE * i) + if aq_hero is None: + aq_hero = obs[1:] # the asset-quality gate scores one segment all_parts.extend(obs[1:]) # mesh parts only, not the root empties floor, wall = build_studio(sc) bulkhead = build_bulkhead(sc) @@ -563,6 +606,10 @@ def render_still(path, engine, falsify=False): ) if fcode: return fcode + aqcode = gallery_asset_quality.check_asset_quality( + sc, cam, hero=aq_hero, stage=[floor, wall]) + if aqcode: + return aqcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): print("ERROR: render produced no file", file=sys.stderr) diff --git a/examples/modular-kit-snap/preview.webp b/examples/modular-kit-snap/preview.webp index 669e504..e597df3 100644 Binary files a/examples/modular-kit-snap/preview.webp and b/examples/modular-kit-snap/preview.webp differ