diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 34eb6dd..4bff418 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -136,5 +136,8 @@ "examples/vse-gamma-cross", "examples/vse-linear-modifiers", "examples/wave-displace" + ], + "showcase": [ + "showcase/shipping-crate" ] } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 00597bb..64e0b0e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,6 +18,7 @@ on: - ".cursor-plugin/plugin.json" - "assets/**" - "examples/**" + - "showcase/**" - "docs/gallery/**" - "scripts/build_gallery.py" - "scripts/site/**" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 165d376..825bd26 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -184,7 +184,7 @@ jobs: ) # Every manifest path must exist on disk. - for key in ('skills', 'rules', 'snippets', 'templates', 'examples'): + for key in ('skills', 'rules', 'snippets', 'templates', 'examples', 'showcase'): for path in manifest.get(key, []): if not os.path.exists(path): errors.append(f'{key}: manifest lists missing path {path}') @@ -200,6 +200,9 @@ jobs: 'examples': sorted( d for d in glob.glob('examples/*') if os.path.isdir(d) ), + 'showcase': sorted( + d for d in glob.glob('showcase/*') if os.path.isdir(d) + ), } for key, paths in expected.items(): listed = {p.replace('\\', '/') for p in manifest.get(key, [])} @@ -252,6 +255,13 @@ jobs: if os.path.isdir(os.path.join('examples', d)) and os.path.exists(os.path.join('examples', d, 'README.md')) ]) + showcase_count = 0 + if os.path.isdir('showcase'): + showcase_count = len([ + d for d in os.listdir('showcase') + if os.path.isdir(os.path.join('showcase', d)) + and os.path.exists(os.path.join('showcase', d, 'README.md')) + ]) readme = open('README.md').read() if f'{skill_count} skills' not in readme: @@ -265,13 +275,19 @@ jobs: errors.append(f'README snippet count mismatch (expected "{snippet_count} snippets" substring)') if f'{example_count} examples' not in readme: errors.append(f'README example count mismatch (expected "{example_count} examples" substring)') + showcase_word = 'piece' if showcase_count == 1 else 'pieces' + showcase_needle = f'{showcase_count} showcase {showcase_word}' + if showcase_needle not in readme: + errors.append( + f'README showcase count mismatch (expected "{showcase_needle}" substring)' + ) if errors: for e in errors: print(f'::error::{e}', file=sys.stderr) sys.exit(1) - print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} {template_word}, {snippet_count} snippets, {example_count} examples') + print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} {template_word}, {snippet_count} snippets, {example_count} examples, {showcase_needle}') PYEOF validate-harness: diff --git a/AGENTS.md b/AGENTS.md index 9616f3e..12681d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,10 +20,11 @@ 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 16 skills, 9 rules, 3 templates, 27 snippets, and 59 -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 +The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 59 +examples, and 1 showcase piece (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/`; +showcase conventions: `showcase/README.md`. The render look is specified in `docs/VISUAL-STYLE.md`; the canonical run prompt is `docs/new-example-prompt.md`. @@ -37,6 +38,8 @@ Blender-Developer-Tools/ snippets/.py # 27 standalone Python snippets examples// # 59 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) + showcase// # budget-conformance props (sibling of examples/) + showcase/gallery.json # this tree's gallery index; merged into docs/gallery/ scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) docs/gallery/ # committed generated gallery pages + hero assets diff --git a/CLAUDE.md b/CLAUDE.md index f129c18..50b88a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,8 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) +showcase// - Budget-conformance props, 1 piece (sibling of examples/; see showcase/README.md) +scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders VERSION - Source of truth for the repo version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51230e2..2cdd333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,12 +35,19 @@ templates/ blender_manifest.toml __init__.py README.md +showcase/ + README.md + gallery.json + / + README.md ``` - **`skills/`** - one directory per skill, each containing `SKILL.md` with YAML frontmatter (`name`, `description`, `standards-version`). - **`rules/`** - Cursor-style rules as `.mdc` files with YAML frontmatter (`description`, `alwaysApply`, `globs`, `standards-version`). - **`snippets/`** - small standalone `.py` files (5 to 50 lines) demonstrating a single canonical pattern. - **`templates/`** - copy-paste starting points; one directory per template. +- **`showcase/`** - budget-conformance props, sibling of `examples/`. Not API + contracts. Conventions: [`showcase/README.md`](showcase/README.md). ## Adding a Skill @@ -86,6 +93,21 @@ templates/ 1. Add a directory under `templates/`, e.g. `templates/headless-batch-script-template/`. 2. Include all files needed for an immediate copy-paste starting point. For add-on templates, include `blender_manifest.toml`, `__init__.py`, and a brief `README.md`. +## Adding a Showcase Piece + +Read [`showcase/README.md`](showcase/README.md) first. Showcase asserts +budget conformance, never an API contract. + +1. Add `showcase//` with a script, a README that includes an + exit-code table, and a falsifier that breaks one pipeline stage so a + **named** budget fails. +2. List the directory in `.cursor-plugin/plugin.json` `"showcase"` and add a + `tests/smoke/catalog.json` row. The runner takes opaque script paths. +3. Add a `showcase/gallery.json` `pieces[]` entry, render a still, and run + `python scripts/build_gallery.py`. Do not hand-edit `docs/gallery/` HTML. +4. Update the README showcase-piece count. `validate-counts` checks it + separately from the example total. + ## Blender Version Targeting Content targets **Blender 5.2 LTS** as primary, **Blender 5.1** as prior stable, and **Blender 4.5 LTS** as fallback. When the API differs, branch on `bpy.app.version` and document both paths. Example: @@ -169,7 +191,7 @@ The drift-check workflow enforces these on every push and PR. ## Aggregate Counts -`README.md` declares aggregate counts (e.g. "8 skills, 4 rules, 1 template, and 10 snippets"). The `validate-counts` job in `.github/workflows/validate.yml` enforces these substrings against the filesystem on every push and PR. When you add or remove content, update the README counts in the same commit. +`README.md` declares aggregate counts (e.g. "16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 1 showcase piece"). The `validate-counts` job in `.github/workflows/validate.yml` enforces these substrings against the filesystem on every push and PR. Showcase pieces are counted separately from examples. When you add or remove content, update the README counts in the same commit. ## Pull Request Process diff --git a/README.md b/README.md index d0b5246..1fe1fbf 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,14 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  1 showcase piece

Examples Gallery  •  Quick start -  •  Examples +  •  Examples +  •  Showcase  •  Skills  •  Rules  •  Templates @@ -36,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, and 59 examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 1 showcase piece** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -45,7 +46,8 @@ The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capabl | **Skills** | Guided workflows: scaffolding, operators, panels, properties, mesh and bmesh, headless batch, slotted actions, geometry nodes, procedural materials, depsgraph queries, drivers and handlers, `bl_info` migration, video sequencer, imported-mesh cleanup, engine export presets | | **Rules** | Guardrails for the most common AI mistakes: ops-in-loops, bmesh leaks, legacy `bl_info` only, prop assignments, deprecated context-copy override, per-element loops over bulk mesh data, import without scale check, export without evaluated geometry, mixed glTF/FBX axis RNA | | **Templates** | A working Extensions Platform add-on starter, a headless batch script starter, and a GLB-in engine-ready asset pipeline | -| **Snippets** | 24 small standalone Python files demonstrating canonical patterns | +| **Snippets** | 27 small standalone Python files demonstrating canonical patterns | +| **Showcase** | Budget-conformance props under [`showcase/`](showcase/). Not examples. Conventions: [`showcase/README.md`](showcase/README.md) | ## Quick start @@ -69,6 +71,14 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- | Blender 5.1 | Prior stable (weekly cron; PR via `needs-5.1` or manual dispatch) | | Blender 4.5 LTS | Fallback supported (skills show both code paths where 4.x and 5.x APIs diverge) | +## Showcase + +Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md). + +Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall + +First piece: [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. + ## Examples Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on diff --git a/ROADMAP.md b/ROADMAP.md index db59379..f6c8df5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,6 +106,11 @@ Provider-agnostic GLB-in / engine-ready-out. This repo does not generate meshes. Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.) +- ~~Showcase sibling tree + shipping-crate pilot~~ **SHIPPED** as `showcase/shipping-crate/` — budget-conformance crate composing bake, LOD, collider, Unity glTF; `--skip-decimate` exits 9 on the LOD1 ratio band; DECIMATE COLLAPSE ratios diverge on 5.2 vs 4.5/5.1 +- Procedural terrain or landscape showcase using Geometry Nodes scatter +- Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece +- Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract) + - ~~Custom ID-property delete witness~~ **SHIPPED** as `examples/cross-version-property-delete/` — IDs built via `bpy.data.objects.new` (not `active_object`); `property_unset` is TypeError and leaves the key; `del` removes it on 4.5 LTS and 5.x - ~~USD export evaluation_mode witness~~ **SHIPPED** as `examples/usd-export-evaluation-mode/` — probed `wm.usd_export` on CI Linux portables 5.2.1 (`9e2066aef7ef`) and 4.5.13 (`daeeeca98fb0`); TESSELLATE+VIEWPORT 26/24, TESSELLATE+RENDER 98/96; BEST_MATCH writes the 8-vert catmullClark cage so the mode is silent - ~~Repeat Zone / For Each Element pairing witness~~ **SHIPPED** as `examples/gn-zone-iterate/` — `pair_with_output` is load-bearing (unpaired evaluates 0 verts); Repeat `8×(1+N)` with X-centers at `k×STEP`; For Each `8×P` with Z-centers at `i×STEP`; For Each main Geometry is a passthrough (6 verts); count-only is insufficient (`--no-offset` still 32 verts, one X-center) diff --git a/docs/gallery/assets/shipping-crate-hero.webp b/docs/gallery/assets/shipping-crate-hero.webp new file mode 100644 index 0000000..fc9eebb Binary files /dev/null and b/docs/gallery/assets/shipping-crate-hero.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 0c2f7dd..59daff5 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 51 examples + 52 examples
@@ -313,6 +313,7 @@

Examples Gallery

+ @@ -884,6 +885,17 @@

gn-socket-rename

View example
+
+ + shipping-crate — A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

shipping-crate

+

A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 552 tris, two materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.256×0.856×0.748 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 20 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/shipping-crate/index.html b/docs/gallery/shipping-crate/index.html new file mode 100644 index 0000000..7ec9f12 --- /dev/null +++ b/docs/gallery/shipping-crate/index.html @@ -0,0 +1,981 @@ + + + + + + shipping-crate — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

shipping-crate

+

A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Recomputed: 552 tris, two materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.256×0.856×0.748 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 20 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/shipping-crate/shipping_crate.py --
+ +
+
+

A showcase piece, not an example. Procedural crate (beveled body, arrayed slats, metal corner brackets, two materials) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

+

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

+

Budgets

+

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 500–620 | 552 / 552 / 552 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4239 | | LOD2 ratio | 0.10–0.35 of base | 0.2174 / 0.2174 / 0.1558 | | Materials | exactly 2 distinct | 2 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.256, 0.856, 0.748) m ± 0.01 | (1.2560, 0.8560, 0.7480), zmin 0 | | Collider tris | ≤ 48 | 20 | | Export | written, size > 0 | 42092 / 42092 / 42088 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

Run

+
blender --background --python shipping_crate.py --
+blender --background --python shipping_crate.py -- --skip-decimate
+blender --background --python shipping_crate.py -- --output crate.png
+

Smoke does not pass --output or --skip-decimate.

+

Exit codes

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path (no deviation=).

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+
+
+

Source

+
+ showcase/shipping-crate/shipping_crate.py + View on GitHub → +
+
"""Game-ready shipping crate — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural crate after composing shipped
+pipeline pieces: bmesh construction, UVs, two materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python shipping_crate.py --
+    blender --background --python shipping_crate.py -- --skip-decimate
+    blender --background --python shipping_crate.py -- --output crate.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+# Body size (meters), sitting on z=0. Side slats sit proud on ±Y; lid
+# slats sit proud on +Z. OUTER_SIZE is the closed-form AABB of that
+# construction, compared against the measured world bbox — not assigned
+# onto the mesh.
+BODY_SIZE = (1.20, 0.80, 0.72)
+SLAT_THICK = 0.028
+OUTER_SIZE = (
+    round(BODY_SIZE[0] + 2.0 * SLAT_THICK, 3),
+    round(BODY_SIZE[1] + 2.0 * SLAT_THICK, 3),
+    round(BODY_SIZE[2] + SLAT_THICK, 3),
+)
+BBOX_TOL = 0.01
+
+# Measured 4.5.11 / 5.1.2 / 5.2.1 after locking geometry. DECIMATE
+# COLLAPSE ratios diverge across series — bands, not exact counts.
+BASE_TRIS_MIN = 500
+BASE_TRIS_MAX = 620
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 48
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def add_cube(bm, loc, scale, mat_idx):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    for v in verts:
+        v.co.x = v.co.x * scale[0] + loc[0]
+        v.co.y = v.co.y * scale[1] + loc[1]
+        v.co.z = v.co.z * scale[2] + loc[2]
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_crate_mesh(name, bevel_offset, bevel_segments):
+    sx, sy, sz = BODY_SIZE
+    slat_h = SLAT_THICK
+    bm = bmesh.new()
+    try:
+        body = add_cube(
+            bm, (0.0, 0.0, sz / 2.0), (sx, sy, sz), WOOD_IDX,
+        )
+        body_edges = list({e for v in body for e in v.link_edges})
+        if bevel_offset > 0.0:
+            bmesh.ops.bevel(
+                bm,
+                geom=body_edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+
+        slat_w = 0.118
+        n_lid = 5
+        gap = (sy - 0.04 - n_lid * slat_w) / (n_lid + 1)
+        y0 = -sy / 2.0 + 0.02 + gap + slat_w / 2.0
+        for i in range(n_lid):
+            y = y0 + i * (slat_w + gap)
+            add_cube(
+                bm,
+                (0.0, y, sz + slat_h / 2.0),
+                (sx - 0.06, slat_w, slat_h),
+                WOOD_IDX,
+            )
+
+        n_side = 4
+        side_h = 0.095
+        z0 = 0.08 + side_h / 2.0
+        z_step = (sz - 0.16 - side_h) / (n_side - 1)
+        for sign in (-1.0, 1.0):
+            for i in range(n_side):
+                z = z0 + i * z_step
+                add_cube(
+                    bm,
+                    (0.0, sign * (sy / 2.0 + slat_h / 2.0), z),
+                    (sx - 0.10, slat_h, side_h),
+                    WOOD_IDX,
+                )
+                add_cube(
+                    bm,
+                    (sign * (sx / 2.0 + slat_h / 2.0), 0.0, z),
+                    (slat_h, sy - 0.10, side_h),
+                    WOOD_IDX,
+                )
+
+        plate = 0.13
+        thick = 0.024
+        hx = sx / 2.0 + slat_h
+        hy = sy / 2.0 + slat_h
+        hz = sz
+        for sxn in (-1.0, 1.0):
+            for syn in (-1.0, 1.0):
+                for szt in (0.0, 1.0):
+                    zc = thick / 2.0 if szt == 0.0 else hz - thick / 2.0
+                    add_cube(
+                        bm,
+                        (sxn * (hx - plate / 2.0), syn * (hy - plate / 2.0), zc),
+                        (plate, plate, thick),
+                        METAL_IDX,
+                    )
+                z_bar = hz / 2.0
+                add_cube(
+                    bm,
+                    (sxn * (hx - thick / 2.0), syn * (hy - plate / 2.0), z_bar),
+                    (thick, plate, hz - 2.0 * thick),
+                    METAL_IDX,
+                )
+                add_cube(
+                    bm,
+                    (sxn * (hx - plate / 2.0), syn * (hy - thick / 2.0), z_bar),
+                    (plate, thick, hz - 2.0 * thick),
+                    METAL_IDX,
+                )
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(35.0):
+                    edge.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    obj.data.materials.clear()
+    obj.data.materials.append(wood)
+    obj.data.materials.append(metal)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, wood, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("CrateNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = wood.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    # Duplicated from snippets/export_preset_unity.py (not a package).
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_crate_mesh("CrateLow", bevel_offset=0.028, bevel_segments=2)
+    high = build_crate_mesh("CrateHigh", bevel_offset=0.028, bevel_segments=4)
+    wood = principled("CrateWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50)
+    metal = principled("CrateMetal", (0.58, 0.60, 0.64, 1.0), 1.0, 0.22)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("crate mesh did not build", 3), None, None, None, None, None
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x = bb[3] - bb[0]
+    size_y = bb[4] - bb[1]
+    size_z = bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, wood)
+    if img is None:
+        return fail("crate has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "CrateLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "CrateLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider = convex_hull_collider(low, "CrateCollider")
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_shipping_crate_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(
+        f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}"
+    )
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (2.30, -3.20, 1.92)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0 + 0.06)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+        strategy="projection",
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("shipping-crate OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/scripts/build_gallery.py b/scripts/build_gallery.py index 4d413a3..1f14c04 100644 --- a/scripts/build_gallery.py +++ b/scripts/build_gallery.py @@ -17,8 +17,10 @@ writes ONLY under docs/gallery/ so it never collides with the landing build's docs/index.html, docs/fonts/, or docs/assets/. -examples/gallery.json is the source of truth. Run after editing gallery.json, -an example script, or an example README: +examples/gallery.json is the source of truth for examples. showcase/gallery.json +is the source of truth for showcase pieces (``pieces`` key). This script merges +both into one docs/gallery/ index so each tree owns its JSON. Run after editing +either file, an example or showcase script, or a README: python scripts/build_gallery.py @@ -38,12 +40,29 @@ REPO = Path(__file__).resolve().parent.parent DATA = REPO / "examples" / "gallery.json" +SHOWCASE_DATA = REPO / "showcase" / "gallery.json" OUT_DIR = REPO / "docs" / "gallery" # Soft cap for gallery index card alt text (accessibility + layout). _ALT_CAP = 160 +def load_gallery_entries() -> tuple[dict, list]: + """Examples gallery metadata plus concatenated example + showcase cards.""" + data = json.loads(DATA.read_text(encoding="utf-8")) + entries = list(data["examples"]) + if SHOWCASE_DATA.is_file(): + show = json.loads(SHOWCASE_DATA.read_text(encoding="utf-8")) + for piece in show.get("pieces", []): + item = dict(piece) + tags = list(item.get("tags") or []) + if "showcase" not in tags: + tags.append("showcase") + item["tags"] = tags + entries.append(item) + return data, entries + + def first_sentence(text: str) -> str: """First sentence of *text*, splitting on period-followed-by-whitespace. @@ -892,7 +911,9 @@ def build_index(data: dict, *, base: str, repo_root_url: str, site: str) -> str: def main() -> int: - data = json.loads(DATA.read_text(encoding="utf-8")) + data, examples = load_gallery_entries() + data = dict(data) + data["examples"] = examples base = data["repoBaseUrl"].rstrip("/") repo_root_url = base.split("/tree/")[0] # strip /tree/ -> repo home site = data.get("siteBaseUrl", "").rstrip("/") diff --git a/scripts/site/build_site.py b/scripts/site/build_site.py index c56e4c6..bf5a771 100644 --- a/scripts/site/build_site.py +++ b/scripts/site/build_site.py @@ -233,21 +233,31 @@ def parse_changelog(repo_root: Path, max_entries: int = 2) -> list[dict]: return entries -def load_examples(repo_root: Path) -> list[dict]: - """Read examples/gallery.json (the gallery source of truth) when present. +def _hero_site(hero: str) -> str: + """Repo-root ``docs/...`` hero path → site-relative path under Pages.""" + return hero[len("docs/"):] if hero.startswith("docs/") else hero + - Hero paths in gallery.json are repo-root-relative (``docs/gallery/...``); - the deployed site serves ``docs/`` as its root, so expose a site-relative - ``heroSite`` alongside each entry.""" - gallery_path = repo_root / "examples" / "gallery.json" +def load_gallery_items(repo_root: Path, relpath: str, key: str) -> list[dict]: + """Read a gallery JSON file and attach ``heroSite`` on each entry.""" + gallery_path = repo_root / relpath if not gallery_path.is_file(): return [] data = load_json(gallery_path) - examples = data.get("examples", []) if isinstance(data, dict) else [] - for ex in examples: - hero = ex.get("hero", "") - ex["heroSite"] = hero[len("docs/"):] if hero.startswith("docs/") else hero - return examples + items = data.get(key, []) if isinstance(data, dict) else [] + for item in items: + item["heroSite"] = _hero_site(item.get("hero", "")) + return items + + +def load_examples(repo_root: Path) -> list[dict]: + """Read examples/gallery.json (the examples gallery source of truth).""" + return load_gallery_items(repo_root, "examples/gallery.json", "examples") + + +def load_showcase(repo_root: Path) -> list[dict]: + """Read showcase/gallery.json. Empty or absent is fine — not an error.""" + return load_gallery_items(repo_root, "showcase/gallery.json", "pieces") def pick_featured(examples: list[dict]) -> list[dict]: @@ -376,6 +386,7 @@ def main(): rules = parse_rules(repo_root) examples = load_examples(repo_root) featured = pick_featured(examples) + showcase = load_showcase(repo_root) mcp_tools = load_mcp_tools(repo_root) mcp_grouped = group_by_category(mcp_tools) changelog = parse_changelog(repo_root) @@ -391,6 +402,8 @@ def main(): "example_count": len(examples), "featured_examples": featured, "featured_count": len(featured), + "showcase": showcase, + "showcase_count": len(showcase), "snippet_count": len(plugin.get("snippets", [])), "template_count": len(plugin.get("templates", [])), # basenames for display: snippets/foo-bar.py -> foo-bar diff --git a/scripts/site/template.html.j2 b/scripts/site/template.html.j2 index eb3c4c7..7656eda 100644 --- a/scripts/site/template.html.j2 +++ b/scripts/site/template.html.j2 @@ -239,6 +239,7 @@
    {% if examples %}
  • Examples
  • {% endif %} + {% if showcase %}
  • Showcase
  • {% endif %} {% if skills %}
  • Skills
  • {% endif %} {% if rules %}
  • Rules
  • {% endif %}
  • Install
  • @@ -264,6 +265,7 @@ {% if snippet_count %}Snippets0{% endif %} {% if template_count %}Templates0{% endif %} {% if example_count %}Examples0{% endif %} + {% if showcase_count %}Showcase0{% endif %}
    smoke-gated on 4.5 LTS + 5.1 · exit 0 @@ -318,6 +320,34 @@ {% endif %} + {% if showcase %} +
    +
    + + Showcase + {{ showcase_count }} pieces +
    +
    +

    Budget-conformance props, not API contracts.

    +

    Showcase pieces compose shipped skills into a recognizable asset and assert + declared budgets — triangle counts, materials, UVs, LODs, colliders, exports. They are not + examples. A still that merely rendered is not an assertion.

    +

    Open the gallery (tag: showcase) →

    +
    + {% for ex in showcase %} + + {{ ex.name }} render +
    + {{ ex.name }} + {{ ex.teaches }} +
    +
    + {% endfor %} +
    +
    +
    + {% endif %} + {% if skills %}
    diff --git a/showcase/README.md b/showcase/README.md new file mode 100644 index 0000000..cf414f6 --- /dev/null +++ b/showcase/README.md @@ -0,0 +1,69 @@ +# Showcase + +Budget-conformance props. **Not examples.** + +An example witnesses one API contract and carries a falsifier that makes a +real assertion fail. A recognizable crate witnesses no API contract. +Forcing one into `examples/` produces a vacuous check. Showcase pieces +assert that generated geometry meets **declared asset budgets**. + +"It rendered without error" is not an assertion. A tolerance so wide +nothing can violate it is not an assertion. + +This directory is a sibling of `examples/`, not nested under it. The +manifest key is `showcase`. Counts are separate from the example total. + +## Conventions + +Every piece is a directory `showcase//` with a script, a README that +includes an exit-code table, a falsifier, a `catalog.json` row, a gallery +entry in `showcase/gallery.json`, and a rendered still. + +- **Deterministic.** Fixed seed (or no RNG). Identical output across runs + on the same binary, and across Blender 4.5, 5.1, and 5.2. If a value + legitimately cannot match across versions, the piece README names it, + states a tolerance, and justifies it. DECIMATE COLLAPSE triangle counts + are the usual suspect — prefer ratio bands, not exact counts. +- **Budgets declared** in the script as named constants and documented in + the piece README. Suggested axes: triangle count, material count, UV + bounds, bounding-box dimensions, LOD ratios, collider triangle ceiling, + export file written. +- **Assertions recompute** those budgets from the generated result. They + never restate constants the script set (`if n == DECLARED` where `n` was + assigned `DECLARED` is not a check). +- **Falsifier** breaks one pipeline stage so a **named** budget fails and + the piece exits its documented code. Prove default and falsifier on + 4.5.11, 5.1.2, and 5.2.1. +- **Exit codes** are file-local: `0` success, argparse `2`, `3` and above + in check order. `9` is legal. FATAL `sys.exit(1)` is a crash, never a + named check. +- **Rendered still and gallery entry.** Showcase pieces are visual by + definition. The pathology / sidecar exemption does not apply. Call + `examples/gallery_framing.check_framing` on the `--output` path only. + Do not pass `deviation=`. Do not move or modify `gallery_framing.py` — + import it by resolving the repo root (see the shipping-crate script). +- **Composition.** The README names which shipped skills and snippets the + piece composes. Duplicated helpers stay inlined or copied; showcase + scripts do not import snippets as a package. + +## Layout + +```text +showcase/ + README.md # this file + gallery.json # this tree's gallery index (pieces[]) + / + .py + README.md + preview.webp +``` + +Hero stills live at `docs/gallery/assets/-hero.webp` like examples. +`scripts/build_gallery.py` merges `showcase/gallery.json` into the same +`docs/gallery/` site as examples, tagged `showcase`. + +## Smoke + +`tests/smoke/catalog.json` takes opaque script paths. A showcase row is +enough; `blender-smoke.yml` has no path filter and runs the whole catalog +on every PR. Measure wall-clock before adding the next piece. diff --git a/showcase/gallery.json b/showcase/gallery.json new file mode 100644 index 0000000..60995b5 --- /dev/null +++ b/showcase/gallery.json @@ -0,0 +1,21 @@ +{ + "_comment": "SOURCE OF TRUTH for showcase gallery cards. scripts/build_gallery.py merges this file's pieces[] into the examples gallery index so each tree owns its JSON. Schema per piece matches examples/gallery.json entries: {name, dir, teaches, witnessesFix, hero, preview, tags?}. dir/hero/preview are repo-root-relative.", + "title": "Showcase", + "description": "Budget-conformance props that compose shipped Blender Developer Tools skills. Not API-contract examples.", + "repoBaseUrl": "https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main", + "siteBaseUrl": "https://tmhsdigital.github.io/Blender-Developer-Tools", + "pieces": [ + { + "name": "shipping-crate", + "dir": "showcase/shipping-crate", + "teaches": "A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 552 tris, two materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.256×0.856×0.748 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 20 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/shipping-crate-hero.webp", + "preview": "showcase/shipping-crate/preview.webp", + "tags": [ + "mesh", + "export" + ] + } + ] +} diff --git a/showcase/shipping-crate/README.md b/showcase/shipping-crate/README.md new file mode 100644 index 0000000..d3304c5 --- /dev/null +++ b/showcase/shipping-crate/README.md @@ -0,0 +1,72 @@ +# Shipping crate + +A showcase piece, not an example. Procedural crate (beveled body, arrayed +slats, metal corner brackets, two materials) then the shipped pipeline: +unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex +collider, Unity glTF export. + +It asserts **budget conformance** of the generated result. It does not +witness an API contract. "It rendered without error" is not a check. + +**Composes** skills `mesh-editing-and-bmesh`, `bake-high-to-low`, +`depsgraph-and-evaluated-data`, `engine-export-presets`, and snippets +`bake_normal_high_to_low.py`, `setup_bake_target_image.py`, +`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`, +`export_preset_unity.py` (helpers copied, not imported as a package). + +## Budgets + +Declared as named constants; every gate **recomputes** from the mesh, +materials, UVs, evaluated LOD, collider, or export file. + +| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | +| --- | --- | --- | +| Base triangles | 500–620 | 552 / 552 / 552 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4239 | +| LOD2 ratio | 0.10–0.35 of base | 0.2174 / 0.2174 / 0.1558 | +| Materials | exactly 2 distinct | 2 | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.256, 0.856, 0.748) m ± 0.01 | (1.2560, 0.8560, 0.7480), zmin 0 | +| Collider tris | ≤ 48 | 20 | +| Export | written, size > 0 | 42092 / 42092 / 42088 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is more aggressive. The gate is a ratio band, not an exact count. +Bake pixels are stochastic; the gate is `has_data` plus operator +`FINISHED`, not byte-identity. Construction uses no RNG. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python shipping_crate.py -- +blender --background --python shipping_crate.py -- --skip-decimate +blender --background --python shipping_crate.py -- --output crate.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path (no `deviation=`). + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/shipping-crate/preview.webp b/showcase/shipping-crate/preview.webp new file mode 100644 index 0000000..66b6b62 Binary files /dev/null and b/showcase/shipping-crate/preview.webp differ diff --git a/showcase/shipping-crate/shipping_crate.py b/showcase/shipping-crate/shipping_crate.py new file mode 100644 index 0000000..b19ac28 --- /dev/null +++ b/showcase/shipping-crate/shipping_crate.py @@ -0,0 +1,657 @@ +"""Game-ready shipping crate — a showcase piece, not an example. + +Asserts budget conformance of a procedural crate after composing shipped +pipeline pieces: bmesh construction, UVs, two materials, high-to-low +normal bake, LOD chain, convex collider, Unity glTF export. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. ``--skip-decimate`` skips the LOD +DECIMATE stage so the LOD-ratio budget fails. + +No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +are not byte-identical across Blender versions — the LOD gate is a +ratio band, not an exact count. + + blender --background --python shipping_crate.py -- + blender --background --python shipping_crate.py -- --skip-decimate + blender --background --python shipping_crate.py -- --output crate.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Vector + +# Showcase lives at repo-root/showcase/, not under examples/. The framing +# helper is the repo's only shared import and lives next to the examples; +# resolve the repo root so we do not move gallery_framing.py. +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +# Body size (meters), sitting on z=0. Side slats sit proud on ±Y; lid +# slats sit proud on +Z. OUTER_SIZE is the closed-form AABB of that +# construction, compared against the measured world bbox — not assigned +# onto the mesh. +BODY_SIZE = (1.20, 0.80, 0.72) +SLAT_THICK = 0.028 +OUTER_SIZE = ( + round(BODY_SIZE[0] + 2.0 * SLAT_THICK, 3), + round(BODY_SIZE[1] + 2.0 * SLAT_THICK, 3), + round(BODY_SIZE[2] + SLAT_THICK, 3), +) +BBOX_TOL = 0.01 + +# Measured 4.5.11 / 5.1.2 / 5.2.1 after locking geometry. DECIMATE +# COLLAPSE ratios diverge across series — bands, not exact counts. +BASE_TRIS_MIN = 500 +BASE_TRIS_MAX = 620 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 2 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 48 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_cube(bm, loc, scale, mat_idx): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + for v in verts: + v.co.x = v.co.x * scale[0] + loc[0] + v.co.y = v.co.y * scale[1] + loc[1] + v.co.z = v.co.z * scale[2] + loc[2] + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_crate_mesh(name, bevel_offset, bevel_segments): + sx, sy, sz = BODY_SIZE + slat_h = SLAT_THICK + bm = bmesh.new() + try: + body = add_cube( + bm, (0.0, 0.0, sz / 2.0), (sx, sy, sz), WOOD_IDX, + ) + body_edges = list({e for v in body for e in v.link_edges}) + if bevel_offset > 0.0: + bmesh.ops.bevel( + bm, + geom=body_edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + slat_w = 0.118 + n_lid = 5 + gap = (sy - 0.04 - n_lid * slat_w) / (n_lid + 1) + y0 = -sy / 2.0 + 0.02 + gap + slat_w / 2.0 + for i in range(n_lid): + y = y0 + i * (slat_w + gap) + add_cube( + bm, + (0.0, y, sz + slat_h / 2.0), + (sx - 0.06, slat_w, slat_h), + WOOD_IDX, + ) + + n_side = 4 + side_h = 0.095 + z0 = 0.08 + side_h / 2.0 + z_step = (sz - 0.16 - side_h) / (n_side - 1) + for sign in (-1.0, 1.0): + for i in range(n_side): + z = z0 + i * z_step + add_cube( + bm, + (0.0, sign * (sy / 2.0 + slat_h / 2.0), z), + (sx - 0.10, slat_h, side_h), + WOOD_IDX, + ) + add_cube( + bm, + (sign * (sx / 2.0 + slat_h / 2.0), 0.0, z), + (slat_h, sy - 0.10, side_h), + WOOD_IDX, + ) + + plate = 0.13 + thick = 0.024 + hx = sx / 2.0 + slat_h + hy = sy / 2.0 + slat_h + hz = sz + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + for szt in (0.0, 1.0): + zc = thick / 2.0 if szt == 0.0 else hz - thick / 2.0 + add_cube( + bm, + (sxn * (hx - plate / 2.0), syn * (hy - plate / 2.0), zc), + (plate, plate, thick), + METAL_IDX, + ) + z_bar = hz / 2.0 + add_cube( + bm, + (sxn * (hx - thick / 2.0), syn * (hy - plate / 2.0), z_bar), + (thick, plate, hz - 2.0 * thick), + METAL_IDX, + ) + add_cube( + bm, + (sxn * (hx - plate / 2.0), syn * (hy - thick / 2.0), z_bar), + (plate, thick, hz - 2.0 * thick), + METAL_IDX, + ) + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(35.0): + edge.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal): + obj.data.materials.clear() + obj.data.materials.append(wood) + obj.data.materials.append(metal) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, wood, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("CrateNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = wood.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + # Duplicated from snippets/bake_normal_high_to_low.py (not a package). + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + # Duplicated from snippets/export_preset_unity.py (not a package). + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_crate_mesh("CrateLow", bevel_offset=0.028, bevel_segments=2) + high = build_crate_mesh("CrateHigh", bevel_offset=0.028, bevel_segments=4) + wood = principled("CrateWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) + metal = principled("CrateMetal", (0.58, 0.60, 0.64, 1.0), 1.0, 0.22) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("crate mesh did not build", 3), None, None, None, None, None + + base_tris = triangle_count(low.data) + mats = [s for s in low.data.materials if s is not None] + nmat = len(mats) + distinct_mats = len({id(s) for s in mats}) + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x = bb[3] - bb[0] + size_y = bb[4] - bb[1] + size_z = bb[5] - bb[2] + + img, tex = setup_bake_image(low, wood) + if img is None: + return fail("crate has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "CrateLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "CrateLOD2", LOD2_TARGET, skip_decimate) + bpy.context.view_layer.update() + lod1_tris = evaluated_triangle_count(lod1) + lod2_tris = evaluated_triangle_count(lod2) + r1 = lod1_tris / base_tris if base_tris else 0.0 + r2 = lod2_tris / base_tris if base_tris else 0.0 + + collider = convex_hull_collider(low, "CrateCollider") + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_shipping_crate_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print( + f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" + ) + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + low.rotation_euler.x = math.radians(2.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (2.30, -3.20, 1.92) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0 + 0.06) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + strategy="projection", + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("shipping-crate OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index c0cde53..8e94463 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -73,5 +73,6 @@ {"name": "vse-linear-modifiers", "script": "examples/vse-linear-modifiers/vse_linear_modifiers.py"}, {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py"}, {"name": "eval-mesh-datablock-name", "script": "examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py"}, - {"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py"} + {"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py"}, + {"name": "shipping-crate", "script": "showcase/shipping-crate/shipping_crate.py"} ]