From f828299fef87904c7f30a1c3aeb0097ad98c7d55 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Sun, 13 Sep 2026 13:05:05 -0400
Subject: [PATCH 1/3] feat: add sibling showcase/ category to the content
schema
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 3 ++-
.github/workflows/pages.yml | 1 +
.github/workflows/validate.yml | 20 +++++++++++++++++--
README.md | 4 ++--
scripts/build_gallery.py | 27 +++++++++++++++++++++++---
scripts/site/build_site.py | 35 +++++++++++++++++++++++-----------
scripts/site/template.html.j2 | 30 +++++++++++++++++++++++++++++
showcase/gallery.json | 8 ++++++++
8 files changed, 109 insertions(+), 19 deletions(-)
create mode 100644 showcase/gallery.json
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 34eb6dd..d562c10 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -136,5 +136,6 @@
"examples/vse-gamma-cross",
"examples/vse-linear-modifiers",
"examples/wave-displace"
- ]
+ ],
+ "showcase": []
}
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/README.md b/README.md
index d0b5246..25a849a 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 0 showcase pieces
@@ -36,7 +36,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 0 showcase pieces** 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.
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 @@
]Object Mode ▾
{% if examples %}Examples {% endif %}
+ {% if showcase %}Showcase {% endif %}
{% if skills %}Skills {% endif %}
{% if rules %}Rules {% endif %}
Install
@@ -264,6 +265,7 @@
{% if snippet_count %}Snippets 0 {% endif %}
{% if template_count %}Templates 0 {% endif %}
{% if example_count %}Examples 0 {% endif %}
+ {% if showcase_count %}Showcase 0 {% 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) →
+
+
+
+ {% endif %}
+
{% if skills %}
diff --git a/showcase/gallery.json b/showcase/gallery.json
new file mode 100644
index 0000000..7dd32f0
--- /dev/null
+++ b/showcase/gallery.json
@@ -0,0 +1,8 @@
+{
+ "_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": []
+}
From 93ba58b78ad5adfbcaf35a2f4ee51567c051cfa5 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Sun, 13 Sep 2026 13:06:11 -0400
Subject: [PATCH 2/3] docs: record showcase category conventions
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
AGENTS.md | 11 +++++---
CLAUDE.md | 3 +-
CONTRIBUTING.md | 24 +++++++++++++++-
README.md | 6 ++--
showcase/README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 105 insertions(+), 8 deletions(-)
create mode 100644 showcase/README.md
diff --git a/AGENTS.md b/AGENTS.md
index 9616f3e..7fcf7f0 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 0 showcase pieces (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..eec8ec4 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, 0 pieces (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..ff16d83 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 0 showcase pieces"). 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 25a849a..d53f750 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,8 @@
Examples Gallery
• Quick start
- • Examples
+ • Examples
+ • Showcase
• Skills
• Rules
• Templates
@@ -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
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.
From 6ba9a44d7ad5735fcd5c61857db775ebbaff4ec5 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Sun, 13 Sep 2026 13:21:19 -0400
Subject: [PATCH 3/3] feat: add shipping-crate as the first showcase piece
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 4 +-
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
CONTRIBUTING.md | 2 +-
README.md | 12 +-
ROADMAP.md | 5 +
docs/gallery/assets/shipping-crate-hero.webp | Bin 0 -> 21798 bytes
docs/gallery/index.html | 14 +-
docs/gallery/shipping-crate/index.html | 981 +++++++++++++++++++
showcase/gallery.json | 15 +-
showcase/shipping-crate/README.md | 72 ++
showcase/shipping-crate/preview.webp | Bin 0 -> 19000 bytes
showcase/shipping-crate/shipping_crate.py | 657 +++++++++++++
tests/smoke/catalog.json | 3 +-
14 files changed, 1760 insertions(+), 9 deletions(-)
create mode 100644 docs/gallery/assets/shipping-crate-hero.webp
create mode 100644 docs/gallery/shipping-crate/index.html
create mode 100644 showcase/shipping-crate/README.md
create mode 100644 showcase/shipping-crate/preview.webp
create mode 100644 showcase/shipping-crate/shipping_crate.py
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index d562c10..4bff418 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -137,5 +137,7 @@
"examples/vse-linear-modifiers",
"examples/wave-displace"
],
- "showcase": []
+ "showcase": [
+ "showcase/shipping-crate"
+ ]
}
diff --git a/AGENTS.md b/AGENTS.md
index 7fcf7f0..12681d4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,7 @@ 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, 59
-examples, and 0 showcase pieces (counts are CI-enforced against README.md
+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
diff --git a/CLAUDE.md b/CLAUDE.md
index eec8ec4..50b88a8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,7 @@ 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)
-showcase// - Budget-conformance props, 0 pieces (sibling of examples/; see showcase/README.md)
+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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ff16d83..2cdd333 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -191,7 +191,7 @@ The drift-check workflow enforces these on every push and PR.
## Aggregate Counts
-`README.md` declares aggregate counts (e.g. "16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 0 showcase pieces"). 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.
+`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 d53f750..1fe1fbf 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 0 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 1 showcase piece
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 0 showcase pieces** 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.
@@ -71,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).
+
+
+
+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 0000000000000000000000000000000000000000..fc9eebb5c9f84b0f6e6e1f910e03f548533c8132
GIT binary patch
literal 21798
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
Z>i9q(G5HG}@EVQ}1YyopKxbyC-~b>J4^{vG
literal 0
HcmV?d00001
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
Compact
Detailed
@@ -313,6 +313,7 @@
Examples Gallery
rendering
sequencer
shape-keys
+
showcase
sky
transform
transforms
@@ -884,6 +885,17 @@
View example →
+
+
+
+
+
+
+
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 →
+
+
No examples match the current filters.
Clear search and tags
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ 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 --
+
Copy
+
+
+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
+
+ """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/showcase/gallery.json b/showcase/gallery.json
index 7dd32f0..60995b5 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -4,5 +4,18 @@
"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": []
+ "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 0000000000000000000000000000000000000000..66b6b627697e6187d9e13cba10bcda79341b6450
GIT binary patch
literal 19000
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
literal 0
HcmV?d00001
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"}
]