From 1dd40258c168df17bd1a50dc5817df2391d054d6 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Tue, 15 Sep 2026 07:38:42 -0400
Subject: [PATCH 1/2] feat: add tavern-stool showcase piece
The prompt list is exhausted; ROADMAP's next prop is a round-seat tavern stool with turned legs, not another crate.
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 3 +-
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
README.md | 7 +-
ROADMAP.md | 2 +-
docs/gallery/assets/tavern-stool-hero.webp | Bin 0 -> 13070 bytes
docs/gallery/index.html | 13 +-
docs/gallery/tavern-stool/index.html | 1078 ++++++++++++++++++++
showcase/gallery.json | 12 +
showcase/tavern-stool/README.md | 74 ++
showcase/tavern-stool/preview.webp | Bin 0 -> 10956 bytes
showcase/tavern-stool/tavern_stool.py | 754 ++++++++++++++
tests/smoke/catalog.json | 3 +-
13 files changed, 1942 insertions(+), 8 deletions(-)
create mode 100644 docs/gallery/assets/tavern-stool-hero.webp
create mode 100644 docs/gallery/tavern-stool/index.html
create mode 100644 showcase/tavern-stool/README.md
create mode 100644 showcase/tavern-stool/preview.webp
create mode 100644 showcase/tavern-stool/tavern_stool.py
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 765bca9..758ca31 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -159,6 +159,7 @@
"showcase/signpost",
"showcase/chopping-block",
"showcase/wooden-bucket",
- "showcase/wall-torch"
+ "showcase/wall-torch",
+ "showcase/tavern-stool"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index 4e9fd61..d411187 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 22 showcase pieces (counts are CI-enforced against README.md)
+examples, and 23 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
diff --git a/CLAUDE.md b/CLAUDE.md
index 91e054c..2387a84 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, 22 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 23 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
diff --git a/README.md b/README.md
index a1374e9..63c7f9a 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 22 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 23 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 22 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 23 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.
@@ -97,6 +97,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
+
[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9.
@@ -142,6 +143,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`wall-torch`](showcase/wall-torch/) — procedural wall-mounted torch sconce with a stone plaque, iron bracket, and flame through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`tavern-stool`](showcase/tavern-stool/) — procedural tavern stool with turned legs, stretchers, and iron ferrules through the same pipeline. 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 c3553b5..ad959b2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -127,7 +127,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Chopping block with embedded axe as a game-prop showcase piece~~ **SHIPPED** as `showcase/chopping-block/` — hooped timber stump, kerf, embedded axe; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wooden bucket with iron bands and a rope bail as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-bucket/` — overlapping staves, sealed bottom, iron hoops, ears, rope bail; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wall-mounted torch sconce as a game-prop showcase piece~~ **SHIPPED** as `showcase/wall-torch/` — coursed stone plaque, iron bracket and cup, wooden haft, emissive flame; `--skip-decimate` exits 9 on the LOD1 ratio band
-- Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece
+- ~~Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece~~ **SHIPPED** as `showcase/tavern-stool/` — round seat, turned splayed legs, stretchers, iron ferrules; `--skip-decimate` exits 9 on the LOD1 ratio band
- Cast-iron cooking cauldron on a tripod as a game-prop showcase piece
- ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band
diff --git a/docs/gallery/assets/tavern-stool-hero.webp b/docs/gallery/assets/tavern-stool-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..ce7aad942a48bf99ab18dd15b4b19210b8e33f5e
GIT binary patch
literal 13070
zcmV+pGx5w)Nk&EnGXMZrMM6+kP&gp^G5`Ruw*j32DgXu00zOeFkw&AWq$Hu!m{^bs
z32AQJiCrGq!_ph7rSLOU3$9uGh?RGHAMUWJoTJRuqr6wenLg-{`k$FUtvwPvown`}
z#pUFmn_nl+@RxXt=>OOD9sT?LFRm9!FYbPaJk|W*`fYt}Ka+Zxe$o2dUvO84uZ|`D
zPjY8$vu}o*D{scyS3HmZUU2_s|LhQ-BqFo!>h_91&yo-4$rXej^W=m1@(DGGDsm*GuFkv&2E{#NwL!*P;;_Zj+ZoM|cpfuy?F
z<6mTkGS`L1D!^0Yv~|i`Cm1N~-Ppm-J~a(j(a@@|XrvV&t)qDE
z>Ld+~F68j!nwcq{B<04YC@Wc5&E7J>9QV~Av?tX9ZE6Eud1J1!hnzMjU42bxhMP=5%Q`LNvhtB?QHCd*@gFaUVXc`Uud+Q|~8HRz45Zs``L38>yNTYK;jq`_ySWT$Yt6L$(YR4TC4-`Fz1)_)i$dTJ$^W>J_OmeE5!GZs}q7IN`d5?IYDjmo6z#uY!ecnXg
z5?100T7aCc*Pp%Zntv^Rl{~jl`A8VQ_+ex9L8$&W<+%WXHPSy=bDwEnnV}wI*lp(V
zBGRUa!4!WerxjyA>s7e-aTs$kx^R+!K`;YKrw2dGD776>h}jFhQ5xaDvh|#Al{r{b
z!kr>$D#WaY*~$P&6ZIGE-hdbeeY>rSG3f{yr`sLLDcx2?egRey?e1oCPV?zC8m>Ko
zPznhD{gGNJ5A~#W*Zlb)=`HnNWas>d%;a3pBeuRwR+6&=QfqJrzYB~)G_n-)q9@+k
z7&;`PqCn-=>xvl=lnCGChpXl}QZ2imBpnC27id6`e>|+fd=SM_v(|>PDNAkL
zAZQRS0ndUV{wMlv*VR$KlWRhgCfWlcpWA6rxAy0bAe$z%;M=Fa=9|R9aR1%Z@Pby>
zM)TGJ^t=V>g7aVN2&FD+99ZfO`X6_1XAe8cYgmH;_(fCm`{Fs%6DyQ
zWKhK1HDbBVc?UOSOc?kO9Az<>b1}QfMI&S0DCDR*2fX_S1r|cqtrUNqE@?qHW>43d
zyDgab{3g)?t%h5|lyhjs*Q$w?MHL)!TAnRO1;OhZgmCDC)LjzM!dj?IUy<8i@|!G1
z9L#SI2CqV=O-(Lmd5?q+Ebk(;QU1DBY2`-C1(WXi_A#jO1pJ!aOND-}P_%yBsjOS5
zCk0!CgtVlTKkFoM@fgU<|9*UseoXsZZuBkX)TWReMPfE?N*+{>%rJ!rpmIJNY;3T*
zfdqS@dnP^;|7qp=onZt2^d0<_s7@KcBQ8@w!FJ-GhV)x>gTVm5Odmo#$GlJ&zbybb
zqxmtarwLH6BjS0u4X(
zh{vO;Nc+d0v{a4cSji|1U#^u#8lDc#qr_wi&jW9lgYnwSX;Q22Z
zqjYwVx^pv6R8_q-?5p!HKEbLW?wnERIpKcMNAhWM
z)owjn+h6jVI@?_sswX3MJPkQTfyJlr7NiBk-{4sr9GiyD%R%t2HJ={)H16s7Gnk_AJdF_a6?|E_{Vc6W}Q$WK{g^fR%VnPo2r
zt{S`*iRNZ<)~ApqK8N1?j7djYFJW?ODD@z4l-7Vnmjnd>H`?r6_>5#}f%GI=vcXmD
zQS2c1$Nb^2^=OAeV<_XZhQ%Bspzi#`$13Pjt|RV3*k4eVo8fsM0;bL12aSnHKOUON
zBEQBwwafQ`%JVq6#vHl=aSB;f&xrTIj*mPDT#!--vc0K@c|TgDc63M&qp#ct9pm0~
zbPlR5lc;1LzKCBb{g!egKV@$%-*m?2yM0YH`BV=BGksGPK|ck#oLp
zLwxOcD3;9)@k@%#PG#I3jrkU55e+-iJ{;X7VFAZ-cYPfF>TDL#ynBzjyC)Ba{CI!s
zq428Zh^|N@41~aO-}~ss|GWp)R!MbB~=d?;%Q&lrbl{GagzT>v6ecEJ)
z7H=XWcX%n#RPTzo$GwU=CcpHl?TD|!uVGEn!1tXU1kH=ukEP#|SBEiMTZgMeIujW{
zXg-)PHU5jnsKo??9Gb^`S!DW2+2JUeF-2V{96rCzG?@Hu3OYs-Nz-D1Gu!hPv&w^w
z7I<1E&lO$c-gI;mHKQkG4@ysk!YS_BzmRUCHVd&W0sY~NtFQc$r)U+R|o
zO+ySUc>gne#E$YSLt3;ok!P;t_HzjbLB~$am`$$Cs&-LIM+qJ@jyX1+)~ZinQX87f
z%cde&$b)bh4AmYXFs^)NhAi#BdL3iuzItjrg|D0@n+1PM>J8)46fQ9aHRFZZ>5a^e
zu76@i^RptfQXtRv4RKg8=sE|y`vF&@$I@zDH+6t{3?SL>-I-omo8|N*(w?GQZD?Q5
z0^E`6lBfkx-1eL?c_yabFHG~eY)a2?<9PN=zR+oA)Z&uPwFkk+x}-D6-g_THV<-%2
zD0E*&m>)tJcwG5Sg|cp(GaAp2YAM}jhd$(WWw80KF__PWXk!)$i#U1~aSe(wMl5aw
zyTXNKyc8ubpX$YnnLnsqE1kf~)7AzilPc_^qK)gS4X(N#w@m?c}ycA2ttRLB_%Y`G;;xz9OVb
z8*o&?yOI8j64xN7^A%aoQz^f2)6d@6rdVdeAL>1KYN^A-c}DQ_9`NwfBF0hvx;((2
zubu}VKbQ1&rh-piiQ_q6fQSUZ_?mCdXedd+hxd}&!9&Oh!2%N~gSj_R+oXy>e#9u~
zCTw7~jmgqit0u`5eReiVqY5U6{q
z!*4ng1c7+Pv=lsMNp=W|RV15=xMD9Weqvz+TE6}5j26+mZU;$4NkA-+x_|~hV
z(Y$K}%5fHmZD(e?BM(jHn7=uSV5n$W=1Hr9FSiuyHM|Dx<(d
z*Y?_~BoIGO`nX&clW1|XUrsj!fU!2Cc=sSxUxyHumM|W%A;?82MGq?c%earcmPp@8
zD1c*5ToSi!p
z-G4C;)6pZO>-5iU&~kulmQHjGA8#qMv=C}?@Im*8Hr4RnAH&yiplJ!QgW%op3axE<
z7Tc#Ll2p#09G(6`>i`kRm;*NLn5NR;GZHCp+%TR&(|AHD#v{p*%a`mON-4#Vssdv(
ze<|dV-k}VKIO5tM(wubLg)eA?h){|gj0Up2Of4J@9Jy)lKzIr#W{skzPj@o*biCg3*n4J=g=a_=(dC4&O$Tkd$-K+SRkAl7Rs?Sx{Mm(#P_)c-(
zzxT78DvKtv)|L_w8uF4FrTy|BpRCih-)E}<6HRq*S1Iod75W$D6~_Sm>*sZ+?2{Q6+K>`vnNicKq?{C#osUhUK|uu1vvL-_!<_MHP9mb|#7c9@=;0eT4F109p)-{aSWqbp^i`jVyYQ){Me!Y4{N#4PJ~^>LRjWIbX_91>Zv{XU6dJCxy>3D2P4f_=eJ!6i9~E
zLUi1aIFB(L{CYjbe!pkL2iIg8x)RQ$a`Oe!{4CSec%!+fE|gNzSz`y|yD-B$xVcS_Yh*Er)o6j{m=JAIU&M+i!aqYE
z3=+Db0CcCMDj4JA2p-!Aqn49eGC6;;SwksgzK}xtnfM2+{G#1sbR&EoyjZu-1yQQn
zF^!pcn#mb2+H_Af=gNXJADzGBL{j4JUH3S>+lJog_Fc_N>;LJMs3K17O6Gj&u?$j+
z@i?YMWXy7JvoS<~OOug*mHCp0cCL!rwT-Z~8=b&&JV1D=Q~}qB?6$U!AM&jX
zyO#nk)_GOUFnHJRzs<0%X=s1%Q#~muE&fueqyO;@Yr%QP(ab2K0HtO2>sOqN{vubR
zt9Ey5Aa&$x6IjxUt3l4h?_kSG7w|f=PmNj2A=#tyE#B;Ur4U(WjkcU4%xIW&_$$SI
zH8!tMWz|DysXhSUj;1^SH4U!BH!)-rY~|_-IC4i}C@fOZTk^#2zn`80=;{kM<$ykBPRlrwd-~-)@oSYU9Rx>WOU%|um76EP6xBPMEI1;TT
z)VzxGdT5L>!&&F`7x_#vgwY$#3C}RW@WaJxOp{Gc3(==-Sa}?_2#i;Z2|oDMOg-3b
znTEtUD5aizYB5|U5~TiV>}pyeE{T`7w~wk0w|3`iMSzB@DyQyi(H>n4mZ(r*14@O6wFtHgE-luPfEEL>VG{soFaR;v!ityM{Mo3sWYfY!vBk
zlMH4xnPGK5-O@ZRk5Z$M90t2QT1DIWj%Jh+;{GDoJZ5{b=zfV=WD1>Ir8~<6lkwfz
z0M<~_T{i{w+A!!uD<@%H298VB!u^oGIKG$HY(PtkIofB{&IFx~NU4H1V-9|}N5Et?
zsrp59fak%^-|n!yp@z+_F_v7W!LtYk-K6jc$|d800#blIe0I3ai!!FS=yptXj)twf
z79&lUv4o+URg;y7Zc#adwbV2{sr8(9jAr`SRDW3PF9bhQFzOH5s^YPc4^Mk2K3G0wK
zIFCCE%C5f!76YWje>!n4HR;kco2l8QRB6MvOoLM2U8D^f*MAXuI6i4{A6fXefA~W3
zt9Lk2vp|r0^ZjycJsq^YOsJLj7Q>q=5phpp{f!LAx=mQF&dS{$|Ah`vw%82R1^oAC
zb_#%g{_F8w2y0bA7(D?3L)&m@pU8Kub54JPp+0pv9QO@;I74xrQ@Cl&QdjmKhuI|S
zUT>S)nb7VWj>YLgqq;`=tJoQTsreD1&b-ay;*&+h|gm{+sc$VwZLKoH%Pg*bT-63
zZb~L^>sG8`b3@YL7Z={1r)KS)^gf@($xAWw51I8K0poDVt6~kYzsE^4*hJpo_PR;(
zCtj85Tc6~gQIlxdt(0rH+V*vnIzKKuL$E*c;6)q=89)HxO`3-9%qK<@RjhMQqk8L$
zQVZUlO+ulPA3X!Y!uzTOh<2!rJBlj&?It_c8bv9_B>Gz-Vfo!}Bum7FAK2$Q=DuJl
zG9G?-3`xMA7s#oA@>v$B%|P$p5;y1&MKr02?+4nUYB3*(mMQNGQU609cxT
zLr0dLb%!5jMCXD<-Pe2wU>LXpmS^6#sYc+3Q45Fdv|1x|wOPGUP|(M_&SumifxN$u
z!qFfJa!Z6XJ9P`&If#Cd2v5arnr>!SgCC&2wBpgpp6z4cTQ739p9vR0O8=+W00dm2
zt3duajJo6=US>^S4iz8OmslDi=&>XzkINPfu*K|?y)|mBY)xW0ZL~k;FvL`JHn#Q5
zMNuvm2M~N^2vvyWW15A1e+p=Hdh<9W4DgW&Y-2?*Cwn8YTs_(Sz7!Nt}
zx_cpr;|v^P8%O_-!gsGZXTp-Uk7qwyz~D!xP&J@L@4bd^2&nt9h@Qx8q$1rJgzsDp
z2}Az2XF6E~Sk?)~Ivg|7#Xo~z;R~oXhI7?(m61OcrCJl!PsChn!@k%j9i;{iQ&vdT
zr(VR_2Xekkzzh%~0Be8=k^v40+dKf=aMZF*A1T_AP)jn5>oy&sL
z27++JKKE~FIg8LTqjke1lLwo>6RLhHv^F#uK%iql!7Mu1`HO@Z<
zUahmBC4~EtdxFrlwO0?Rub|7STLC`i=cyfDAl4jv1aCx3SeBGvQ^2~5tx;6#g?3)+
zx@Uv^az!O6|Gb~*$>{mKzYqEVy;TlmgEZj}7igu8RiAbpsFxSu>Yr1u$UpO9l0qg<
z?b0tmQc+Y>jihk7nNUKBF>OFzRQ$-*sok7zZo94_-baS(SBj)0Ci=)L;#qF#0t7U=
zMz9ZpQqzMMqx^<7;X$NR;H
zOd2YiZV?h&WN8Z?K=A1aK#kRN<3(;cvi{h|>5byJ-%Zn(raOzigQNjn5`~_l1W(ncY@17y${I!CLFY_BBu5
zR5L>+!)evy8|2U*o0LPBf=<})ary8iaJ*wU0jP`LMVP%ZFG9VBx_?l23H
z&?l++z?}VeW%ft<=oPGzAAe1|
z;jP1KHWWQ_PiksQ1^4s-a%0*&sYaRuXd}t#?1BMXpXex(cUNrYhX51c)LL=?33>nk
z01Or<=|<)gjpKhZR95M{}EptoO*N|9Rh{~
z%M3-Sj1`>!RUl^c7tAbrffq)!gEB9%1^*@LF~U8M+j@$A&i+Y#P{D_4l
zAAZq*MNG+?_4NNB=zj~fpWR9Up9F6B24yH5Qj5ztH(3ZY=pvi6cP*d|3HI)`!t=TI{z-xsQ}lm5dfNCOw~+U?uVqPhwEESyH+SO{mpBSeq=cG6tJ
z$^lz$)a6D8>^a
z5U}Fc(vS000Uqkr*_+X~;+Buu`fgQdzRlk47o!m;1S{uBBv#F#nFK=e2$R
zcfE2P`{t(;`aN1O2eq{OBV*&TZT2IVL+DI396Lc3q4^xMD6Ungl>#~(1Cj;wRh5AV
z$S%G{(-X1%tF;;pTg1%+i|Mvj386hb6cX1F>LHfp0=rGLN@?k#MOu`(m%6
z8UK+ZX5_MeD(BcS-6Nyk#+}3?F<@#uYHjT%^Xy752s{DN2A2NrD9&dJKK_KRiFa_5
zCy+f#cnwQI$t{o1r)@iJ(DF`LO)F<+v9!voWEq=D!r7Ds&am!ym0s}C0$F*|IeR(pM#=vH;I4X5`}l%KEJb)S4MF!yrR>Y*CMYVZJ3C>8gQ
ze$6_aSv^k5v%4v5p_ebl_TGMnxie$%qDurHJ%mB+Nsv$Ehgk$9ZbGaP1l5oVfpoTM
z<`Ed+Iz5{YvQPLSWhY_l;-q3j)1*>2$WG6J6){CnLS{~j+%rmSD03~f>~vbs93b3FGEe5go-=9v&1G(menBdqK#(fL;U@1#<9YWbaMtT8rK>NhC@lRT@9xq
zO^bpC4w<^w`?wP%fe!T6IE;J8*-$dUc~?mH_>T{b)+qY~IoCy6u==1@t*gr<7&9(MjX=q*jXfNUc;2f5u9^N9
z@eXl?j_Bn{)Cl#nFl>b~8p1>intHoj;AZ}6%@>j=is`2#b?HxW?*R~%fpfc3TkAu@
zX1;^2(!(T8H~jv(I3a!V>V_*|D-=IEFnm$
zezD0sjDNMXLfCs@3g8l<`y?;0yemC%hR~#4ra6K#t_v5eu>LY0nmRI_KC>@T8q15v
z|MzZa8X~t>@SRk}2i5N7%8K-fbd`RL$+asbFnZXbJHfCN1r2GzUN+teG~a$ZRNShd
zvyA#h+J)d_&c%M#6`Dd-nA-ASoukKs^3&VhC5yZQ!INec0Ifc0iS=b0)uwJMR0~8#IqSm~s*^6zr^hl_ynn_^b}h&wL-k}VP54;hlPPQh2atSuiBZhbd`_4C2}
z-k=(Emm}+Iko@%rBi_j;()KW9TD@e~rIxC#4Z1n~d&lIv3+ZvaowktmrkY9qPH|h&
zaa=nLFSqW&BRwRI9<Qp%Z
zG_*WKrI;-z#gD6gBUaEaqNE~Up4E8_haDULtsCsuPTry;ikAZqpmH)pp@fa+EWwr8
zdJu&)u-j2nAWDs!)E1h0qCu%}(51zH$CFt3YYc#0wng+bGC!hswq>_+{s4FoLudoa
zzfT3pExuo2d0>>}rFK7c~jLXy^t;K%jUU0|P+&}$4j>$i11x6c*;OX1S
z3p{uV-WN2nMVfPqB?L86x0GeeK}8^#F*a2|DH|$1%h|;taM!B(t?JuL+GDI_*Jke4
zq;<1bteEUIIO)fxF5q}|sBex-EB^$Nx)Ji`!1{uUjFWq?cj3tkjjx(}6kgnf3xx2}
zS2sNqgV+fSPpuCk&cb5PeJpD7crGGOLcfbjFJ}5c^JB?Vc1Or)^=eFr(~sRk9h(0q
z1j4PNbQ%o(qyAnyU!WfAH}{&UYRdFfrp>V>WZFi@u*wyhs+{FO?v6><`^HEV*zp^J
z@I48hD>8^NZrSovsV>x6@@7%00)I+T%cXD}X5nX2NzHSXZ8QCG8{3Hw0-3$7$#LFO
zSKq9(;jI@uVOt`YjZ9=e&UNndi|TBXX;j=A;8&b~6F%xas@d5l&z8R4(J_`h
zB9F_9R(}GmzUEqW#dADH1kibgzc!Wb?n$)=lw5StFr7odiE}|u%-Xi9$;`c36ni8j
zaxdGxak-7{aeR1v^9NJiF|AEea@rCNaq_C8+c3BQ3Y-A|vEOJt%%X7D3W?!TO}wRK
zgxUrt=|Jn&NKAESxjuO70Ij2j4#HJ$$peDmMYZ6t?n?2bKDKcwrGC#-Bqy!@=>d
z?#ZGC0(jyL0Zp2STW_}1PaH&wR3hOq{KTdqI=fV%1vUQ!O!}dX-H>Hp!#&B?#Yjlq
zJq=^8P+^IL6pv^pVa&v)<_UGxJY#_eKhB$mK(nWnSCUJ{0-&EAA`m)!n=Z=N9h*=#aOFr44!d;V@CRalO?
zIKLWdLdbw%ZueVX`+h;R#=U4G_oUzr|66N}{a0Pf$_X6bw;3v3IxwhA2N#kw6B%=j
z0ihie8^H@6H&um2Um3lOj<(d#@}U%or%q-VJr>j6W~uRkH!bvla#`W`S&vn=*Bi>LgwYHf04Y!a4z4KD
zRp8ySt|#18Lm}(g!Mj1GZ<(8oC!=l7*Sz?yB=IBryVHOUrGs7U^-T))ih|&!81(Ut
zW`W>!NGIrcKYMJ)<1i%`Q3iBX`TMlazlH|60skf}>}%>QyBDE}Dm3{PQ68_@p@heyf`J
z7VTz>kZ^8@?JqNJIRF={pO%Mp&1y3
zLAIIcyK_Ou&IQlE$ronLFrtJTKj=QT$w53qeQ0if9@X4*7w6|46
zXoM7xKxU;m5LS#gq*2s4?BJ!@x}D(PJ2U(_Y-^o{Jiqiq-(m`ZHxDfHvh-=P*?We4
z(A2Y=!S|GJ9Xh9
zs)jwefm)X0-Mno8w@+M7Ti6byhK&DvN~lgJURpOu(^E$O-)akjEx=mtvxdY_ElM_8+8RH0
z?SS4_2D>~i#Jr=po9m_{<`8ij+_7(6u7ZnNVM4}cjEHZoaN{*6dTHGc+B9?^nyzUuOia|+`T73zZR=c64(ui(Si7TY55}6>7DxR-cldhnJ56!84?9{ox{JTU
z=?i)j*VfSf3pNu2&i8q|(P)yi;Q!0w#vO>dYW2^)jjq5eZ#9aE-@tY4Y9RRE2cNBnQmVkCb
ze9&GDb~qBuBMnqgSHe*hx-;aa{Nlv~zZ0?U;Iyc!3KN83sl~=YE8aisK}wqv_%nl)
zQ2ieyrUoVVY{nl=0h$2`u(q)5yLF?`_S}a)ZYiC_n?awNi#S31@
z6cJ1sYh4{8@35kG(hrSRSuZhP`1Yz-oALEtw3
z3rYioMdm7DLQDX3Fd@vj#he`xt!ZIy_qqvHmn%5Eo#)^0Le*ycTNeio1zBRv5-?37#U
z#SOoIlTt!|Uk@iH42J^i9v<%Q2TfPv}yYF4Bm6WUoi->~|er
zc6^4vHXWPU1BFF)PA-&S4)j!FuZ27p2CL*J#^6i8RaGU>%QkZBZz;;xGk!-?yBj3(0G?DHPt6&lhm!;*kW+;QdG49G}{pN&=0<
z^7y7{Dp2`XJ43B}Y;5*4vIky&q#~lI8`Q4Kg)l(Ck0@XR$lZSJ&5G4c&SF3S025FM
z=crAy1#`^8nQlZ!-!Ef`N1DQ&sSUgNT^^vpl?TKm93*N3t-HUj^_PzmL0knPMevZ%
zQoWjHlkWgee9g`h-3+d~`HTT|%6pj65k=W_U(*zND~|qD8)|X+-uCU@a*zomFZdP*&FD#w
z?8-Lcr=Pd#DUP9FH^mJF)4yg?#VbKDSiO4i{r@7;_<5}koFi*#c5T&jp8x;=DwexW
zX2)`o8AP=UKP%0i679F&cJh^QRX@?dCuHZn@4pEE0Hv+c4Hm)cf{p;n0HZ+6Ja>;7
cnDcIc0ty>t0000008c59gn9r2FaQ7m0A4&|!TExamples Gallery
autocomplete="off" spellcheck="false" aria-label="Search examples" />
×
- 73 examples
+ 74 examples
+
+
+
+
+
+
+
A procedural tavern stool with a round seat, turned splayed legs, stretchers, and iron ferrules through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 5396 tris, two materials with 2542 wood and 40 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.309×0.309×0.467 m, LOD ratios in band, convex collider 96 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/tavern-stool/index.html b/docs/gallery/tavern-stool/index.html
new file mode 100644
index 0000000..13a993d
--- /dev/null
+++ b/docs/gallery/tavern-stool/index.html
@@ -0,0 +1,1078 @@
+
+
+
+
+
+ tavern-stool — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ tavern-stool
+ A procedural tavern stool with a round seat, turned splayed legs, stretchers, and iron ferrules 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: 5396 tris, two materials with 2542 wood and 40 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.309×0.309×0.467 m, LOD ratios in band, convex collider 96 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/tavern-stool/tavern_stool.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural tavern stool (round seat, turned splayed legs, stretchers, iron ferrules) 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 | 5250–5550 | 5396 / 5396 / 5396 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 2 distinct, ≥24 wood, ≥16 metal | 2 slots, 2542 wood, 40 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.309, 0.309, 0.467) m ± 0.01 | (0.3093, 0.3093, 0.4670), zmin 0.002 | | Collider tris | ≤ 140 | 96 | | Export | written, size > 0 | 380604 / 380604 / 380588 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series in general — the gate is a ratio band, not an exact count. On this mesh LOD2 matched across 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.
+--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 tavern_stool.py --
+blender --background --python tavern_stool.py -- --skip-decimate
+blender --background --python tavern_stool.py -- --output stool.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.
+| 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, or wood/metal faces missing | | 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 tavern stool — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural turned-leg stool 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 tavern_stool.py --
+ blender --background --python tavern_stool.py -- --skip-decimate
+ blender --background --python tavern_stool.py -- --output stool.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, 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
+
+SEAT_R = 0.155
+SEAT_T = 0.042
+SEAT_Z = 0.46
+N_LEGS = 4
+LEG_TOP_R = 0.078
+LEG_BOT_R = 0.125
+STRETCH_Z = 0.175
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.309 , 0.309 , 0.467 )
+
+BASE_TRIS_MIN = 5250
+BASE_TRIS_MAX = 5550
+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 = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+METAL_FACES_MIN = 16
+WOOD_FACES_MIN = 24
+
+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_box(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cube(bm, size=1.0 )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8 :
+ return []
+ quat = Vector((0.0 , 0.0 , 1.0 )).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ" )
+ return add_box(
+ bm,
+ ((a + b) * 0.5 ),
+ (scale_xy[0 ], scale_xy[1 ], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone_between(bm, a, b, r1, r2, segments, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8 :
+ return []
+ quat = Vector((0.0 , 0.0 , 1.0 )).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ" )
+ return add_cone(
+ bm, (a + b) * 0.5 , r1, r2, length, segments, mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True ,
+ cap_tris=False ,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = list(geo["verts" ])
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ n_major = 12
+ n_minor = 6
+ rings = []
+ for i in range(n_major):
+ u = i * (2.0 * math.pi / n_major)
+ ring = []
+ for j in range(n_minor):
+ v = j * (2.0 * math.pi / n_minor)
+ x = (major + minor * math.cos(v)) * math.cos(u)
+ y = (major + minor * math.cos(v)) * math.sin(u)
+ z = minor * math.sin(v)
+ ring.append(bm.verts.new((x, y, z)))
+ rings.append(ring)
+ for i in range(n_major):
+ i2 = (i + 1 ) % n_major
+ for j in range(n_minor):
+ j2 = (j + 1 ) % n_minor
+ face = bm.faces.new(
+ (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+ )
+ face.material_index = mat_idx
+ verts = [v for ring in rings for v in ring]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ 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_stool_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ wood_verts = []
+ metal_faces = set()
+ try :
+ seat_center = (0.0 , 0.0 , SEAT_Z - SEAT_T / 2.0 )
+ wood_verts.extend(add_cylinder(bm, seat_center, SEAT_R, SEAT_T, 16 , WOOD_IDX))
+ wood_verts.extend(
+ add_cylinder(
+ bm,
+ (0.0 , 0.0 , SEAT_Z - SEAT_T - 0.012 ),
+ SEAT_R - 0.018 ,
+ 0.022 ,
+ 16 ,
+ WOOD_IDX,
+ )
+ )
+ wood_verts.extend(
+ add_cone(
+ bm,
+ (0.0 , 0.0 , SEAT_Z + 0.004 ),
+ SEAT_R - 0.012 ,
+ SEAT_R,
+ 0.010 ,
+ 16 ,
+ WOOD_IDX,
+ )
+ )
+
+ leg_pts = []
+ for i in range(N_LEGS):
+ ang = math.pi / 4.0 + i * (math.pi * 2.0 / N_LEGS)
+ c, s = math.cos(ang), math.sin(ang)
+ top = Vector((LEG_TOP_R * c, LEG_TOP_R * s, SEAT_Z - SEAT_T - 0.004 ))
+ bot = Vector((LEG_BOT_R * c, LEG_BOT_R * s, 0.012 ))
+ leg_pts.append((top, bot, ang))
+ d = bot - top
+ p_knuckle = top + d * 0.22
+ p_ring = top + d * 0.55
+ p_foot = top + d * 0.88
+ wood_verts.extend(add_cone_between(bm, top, p_knuckle, 0.016 , 0.024 , 8 , WOOD_IDX))
+ wood_verts.extend(add_cone_between(bm, p_knuckle, p_ring, 0.018 , 0.015 , 8 , WOOD_IDX))
+ wood_verts.extend(add_cone_between(bm, p_ring, p_foot, 0.022 , 0.018 , 8 , WOOD_IDX))
+ wood_verts.extend(add_cone_between(bm, p_foot, bot, 0.018 , 0.028 , 8 , WOOD_IDX))
+ before = set(bm.faces)
+ add_cone_between(bm, bot + Vector((0 , 0 , 0.006 )), bot - Vector((0 , 0 , 0.010 )), 0.030 , 0.030 , 8 , METAL_IDX)
+ metal_faces.update(set(bm.faces) - before)
+
+ for i in range(N_LEGS):
+ a_top, a_bot, _ = leg_pts[i]
+ b_top, b_bot, _ = leg_pts[(i + 1 ) % N_LEGS]
+ a = a_top.lerp(a_bot, 0.62 )
+ b = b_top.lerp(b_bot, 0.62 )
+ wood_verts.extend(add_oriented_box(bm, a, b, (0.022 , 0.018 ), WOOD_IDX))
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+
+ 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
+ for f in metal_faces:
+ if f.is_valid:
+ f.material_index = METAL_IDX
+ 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):
+ mats = obj.data.materials
+ if len(mats) == 0 :
+ mats.append(wood)
+ mats.append(metal)
+ return
+ mats[0 ] = wood
+ if len(mats) == 1 :
+ mats.append(metal)
+ else :
+ mats[1 ] = 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, target_mat, 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("StoolNrm" , size, size, alpha=True , float_buffer=False )
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.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_stool_mesh("StoolLow" , bevel_offset=0.003 , bevel_segments=2 )
+ high = build_stool_mesh("StoolHigh" , bevel_offset=0.003 , bevel_segments=4 )
+ wood = principled("StoolWood" , (0.44 , 0.26 , 0.10 , 1.0 ), 0.0 , 0.48 )
+ metal = principled("StoolMetal" , (0.55 , 0.52 , 0.48 , 1.0 ), 1.0 , 0.28 )
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("stool 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})
+ idx_counts = {}
+ for poly in low.data.polygons:
+ idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0 ) + 1
+ print(f" measured mat_index_counts= {idx_counts}" )
+ 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("stool has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "StoolLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "StoolLOD2" , 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_src = build_stool_mesh("StoolColSrc" , bevel_offset=0.0 , bevel_segments=1 )
+ collider = convex_hull_collider(collider_src, "StoolCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_tavern_stool_ {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 idx_counts.get(METAL_IDX, 0 ) < METAL_FACES_MIN:
+ return fail(
+ f" metal faces {idx_counts.get(METAL_IDX, 0 )} < {METAL_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(WOOD_IDX, 0 ) < WOOD_FACES_MIN:
+ return fail(
+ f" wood faces {idx_counts.get(WOOD_IDX, 0 )} < {WOOD_FACES_MIN}" ,
+ 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(-32.0 )
+ low.rotation_euler.x = math.radians(6.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.4 ), 660.0 , 4.0 , (1.0 , 0.94 , 0.86 ), (50 , 0 , -36 ))
+ light("Fill" , (5.0 , -3.4 , 2.4 ), 46.0 , 8.0 , (0.72 , 0.82 , 1.0 ), (62 , 0 , 50 ))
+ light("Wedge" , (2.2 , 4.0 , 3.8 ), 600.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 = (0.94 , -1.26 , 0.90 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.26 )
+ 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],
+ )
+ 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("tavern-stool 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 1c59681..11b0930 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -268,6 +268,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "tavern-stool",
+ "dir": "showcase/tavern-stool",
+ "teaches": "A procedural tavern stool with a round seat, turned splayed legs, stretchers, and iron ferrules through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 5396 tris, two materials with 2542 wood and 40 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.309×0.309×0.467 m, LOD ratios in band, convex collider 96 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/tavern-stool-hero.webp",
+ "preview": "showcase/tavern-stool/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/tavern-stool/README.md b/showcase/tavern-stool/README.md
new file mode 100644
index 0000000..6833b2b
--- /dev/null
+++ b/showcase/tavern-stool/README.md
@@ -0,0 +1,74 @@
+# Tavern stool
+
+A showcase piece, not an example. Procedural tavern stool (round seat,
+turned splayed legs, stretchers, iron ferrules) 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 | 5250–5550 | 5396 / 5396 / 5396 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 |
+| Materials | exactly 2 distinct, ≥24 wood, ≥16 metal | 2 slots, 2542 wood, 40 metal |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.309, 0.309, 0.467) m ± 0.01 | (0.3093, 0.3093, 0.4670), zmin 0.002 |
+| Collider tris | ≤ 140 | 96 |
+| Export | written, size > 0 | 380604 / 380604 / 380588 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series in
+general — the gate is a ratio band, not an exact count. On this mesh LOD2
+matched across 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the
+gate is `has_data` plus operator `FINISHED`, not byte-identity.
+Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1
+(glTF serializer), not a gated axis.
+
+`--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 tavern_stool.py --
+blender --background --python tavern_stool.py -- --skip-decimate
+blender --background --python tavern_stool.py -- --output stool.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.
+
+| 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, or wood/metal faces missing |
+| 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/tavern-stool/preview.webp b/showcase/tavern-stool/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..50b33b50f00558c2fc7bc991fbcbcc90c17dcf45
GIT binary patch
literal 10956
zcmV;-Dl^qmNk&G*DgXdiMM6+kP&gpCDgXemmjRstDzF5j0zOeFkw&AWp`@ZT_*jq%
z32AQo9AwPNAA8wLsQ=T@QC4~xd${rRLpqOQ{b=_fS39Up9)JG_pgZY$gZ~5RW6~GR
zf11bsFGxffy)6r&os(m
zDRlw%QXc9ByOfw5N$rEhkf^y%#f!J(38l22DnNQh_q0W8@Dgxvzuof(WO!YJPq7Xu
z#%)nTXrpzUS!MePiMjC*H+UpR(E~~5?Gj*LMJHlGbbbp
zLFX&sXS#x_=Vi3%8qUi)e0h4bJsoZF`;P
zODn;PWORPioeItNW_e~6AijSfV^Tdp1*xq|1Pj+Q1a+a=j2#+$p5{l?PSh~_3soYT=^
z64v{O#dcg7d)tFL(r2ASF2mv3n&AI|c0RPjb-aM)eYux9LCRi_gR+4ky8S|jE496-
zTb{S|WRe(fwhV}6rBae+Azs7jshP5)_;@r|JM?mhSCu_kwhlImt6DXSBk~O23K1t_
z)X3sZw02VuJaJRynA#e)2#OUJDs3L-9a!gjutPo}lnZt*7axcbo**|E!RF4jwCIwN
z02Vu=kq6-%GaA{>jJ!8r&K=s^A^{LRRW
z_=Zp|*JGtec>$!3R190Uv=|_OU?_s0Ohaio`%{eAVhsT^3`D;w@p=^h?e#dZu227q
zo#W@$rcp)Sc`_5vZkLP}?WI?FB^Y7Hxz3oW40puWV(N_r$NS0N<`DI)Nk`K`jgsGe1EnRr?MjT|jA;lQQ
zW_KMnjip_C@o0CN*uOftX?~=K@!gIR#Fy5@^DvXC0s#k;8)ieck;sS+Cw`eN3?!ro
z&kp2D3^e!hb5R=6ChR%k|or!eUdEsq6r&eE_73WI6Ge3
zfaY5wK>e3U~{@6+}4kj
za$!#x+XA|$?35Cbv6iWXi}2jk`QWjFcy!ouiVgN&O^ektR8KF1vXX$%V)UD&lqrJS
zMQ3{_n$}`=2&}f%UZMM+fa>)L5o=VGLV+yM6EoUyxYs`Ug{g0NI;>$)7y#(-`*&uG
zI$G_07%j7!vKszYL5R=Uc{YXK{-HeMJ!BSv7FHkpsLAY=$gITNupYP+5=
z7O9soCKuZAei>H*%=NJu`!6QN@W9>@^;ZA?LRFtwHsnYjtuR4YR(kCJ*0}k5(NR~x
zO!(H)S?i@-jNJ2J8aBz1O}rIC)G@LKU;7Nk(JG*Z6oDC1+H}GQae1F@B%w;c)@ZjO
zr`7?+cz_&9GA2cNV4c@X#syo7x5iI*DB3Sj#LmOBADZ~Rh}5qk56Z>UKWA;U4bpyD
z6hp@g1}{*?{0$Xl*AN!jzIM0bo9*!?L2Aq+=H=;bN5BP*Da
zW!qdxO4eqAdAlj#3VtrZE$=au
zmQgf*5FD8904X%wtH2%tpIi5kxb)`KT-DHwi=VyDnp1$oiPCM7iLR5|{9f^xf12xH
z8-_)FDIR|4v@Xsq@ogWLhRpwv9^}+H~=rXzVCPQ^-ZIIJBS0>*?mNZw~zPJIek{@2Z;u@tMY&
zCo>r2M5uU&*KEjuGE`Ouu~{-A1Hc}6Va)Ig7vc-5Osa)TB7o^9WL4;5)JJ&h8_lv9
ztqMUc*!4lE^E)o}uz2OqdCQ*jlq&BzMFd&Kaqp+#ogt*7ZF%{zefYhVX$v0sz?ra9
zi!G)DLTTfYjpv%o?>{nbLSeA9_8ZW}vTo`UZ)~x{bIBIIvKx`!D={v*fs>RDG4|25
z8b_AO$reH!OSg6gvkE
z(mrP0MZnv)+C(d=?neEK8FyWMF>_*2!ej}0$ZPG%;nPkmow22D@v}A#bUqHRLCZsb
zf5o)|d0+YTrTkJN2Lpgz5beQeXO_TKhrhSR*e_xL@%i$28t0!H;e&GuWMlHh`o8CD
z(128&z2HebaVvJ18v0`9#JcQs*d@(XK@{}43Peu$)b&LCz%<$Hr;C%+Nbb_~)S2Gj
zq!bTs43?ysx(MgGIM|IxPT5;a{K&5(#Nmx`$2oa%PCYEey(Cdt4PdWODmj-qdaHl)
zYv`*)cZ?R;z4}N0kR=%r_RMs!#VK^fjVhCt#EBSjLZJjao&b=3~gh1#}aUC@;n8Qn>~#lXenfo0Y7B=M|T>uKhxJ
z#%u?XK;4VX`|P@8`H;;4dlt?<-ktMCpI-s3H;>x0?!9jiVCP{!fx$DSRHAI&$l8#{b`L|0UXb_GyCMi*B=n~!JHZXIi9vPbN;a2
zIo@=$A={b*8=vmzFg4ycxxP=QeLxQa`MzHkD_^E(vwm-m7zxew;$m8PAeVJ4O0Q1a
zH^gq5CAjh^8;?D@G{tVVvzb}O^UU9_lf+6mv&Y{+)fIS~FN5ws2p
zm~NOve(%c7e=9Di4Y8I6^V}5s0092^`GW3gshn^Z7!V^NiUI}83!IWlF*)8N3!bXm
zER6M2rWm4K3jdohWoB{`6yMyN4djTWbO6J{BW*P$<}!B3`RDyHcs$ti%j+
z-m?D>#Y%r?3
z-Hp`ht)y(YuN)Ww8oUY0q{)Cz?^#GJE9%#C22vkbQJ~Qp4BY~995o1@)(LoZ1@P*?C{?#K01t^k
z^y0RpQ70&LK%kpX9X(5jb)2Z?ltPGOMWWh2q#~{b!=tjoFDAFdOywP=#xa+{4?GQB
z%x_5eJp*QrCIoK=bk=K#p9yJPS8M8Kz50i3;EQK*qgh;aaQ>A(8!Fhi;tqxk1>ZzvSy{bG;a!QqEyy7Z@%o64C{br?y-Tfla%Z8JI7Z3nAhzIO%{}7iKosZmkC7
zy0&wo={o=c5F=!woO9mM!_xI^Uf0Z&=9
zjijw%zZqt6a{~)55!(`0!7ifQnuMC;fJrw~IqIEBtn+02An5W45Q-
za&@YRLxoz(j7F1W@UH%axv5Pw0ij6P=q!oH?#T;ikUe2T)TLGpWEGRZY6lz=EZilB
zIK=?sPSvW?E^HPm=V$n-_IHD=5-e8hpao^MK&KjoWTPLnFbF{&UY{>oB)BsYz^(^d
zM=!wXiNHM7MOFi{u;KD7Ook`o=)n5MQyOl`lx{eF7|thxm*4Ymm9P{^Y>%~foF-Ga
zXMViFNV-u-)nXG7pHTzP;zlc2@%?XLTjUS;2uA*DR0Bv1cTKg+WbDNTri6jP~@)v9qP;$nzMG=bRNIMok?
zQnTK1TQ)jWsRij`fXH$^UYqP}LEm-0*DOsrNE^j!>ZzA0B-xBs+`W67pU)olr2_q?
zaIxEYLDmjgyhr~ekEDX~#&ji2-#{3wC%ZJzBhnj~nH)kVh
zxy^m+%3kWMps@z(o`LSmhUm3!BNUyvOl8}C-xNN=?jjEbsPH34Y;UMohu%n>>E77ESLEFvqFQidaJL6
z*0r9d8{XGZj}}6a6^w5%E5+D~;==Gv>%vUN`M(+gW+A*n!QKyYJsQW{G@-a&-g|I)
zyMQNw^AjYxF48bRg&i01x^_Q6GAQE`BfPpb12Zp+#ZApkcqsQV->Xu638utCDt1ibo-v`bRtd!nk(CCj~ndNRcw4z}pZOWjJ7DI~ry5-tc
zT3<=qDkrz@eFbTI6~ZFk8b7U5=nFy53zljJkQ=3F!pel|#6Y-+S+~;t=u+PdB{cI6
zS~+j6PdHnM*_HdxAbmRzFN-hA-1L2%Vb_^Z(cRv@jva3lYLWO3d=-zJBFU(lMgNmY
zp?^+1Ffa))C;<_9uc#KlZAAP6rZT8*33q)iY3rs0Vtel_8AZF`L|GbGJSK80+V#6y;=?Krml$<>k_&HrTXDlR=_h&mijS1?iUJ%YZqcWRollE<`NF@fW
zc?tkcM6=M}_j2<{$Z{i~%Gpi@g3O&>fhszGI=GO_H?q3gG5fI~ED#eQc+$uW3M#PI
zb`VM5fa7f|do=%5d@ghG92uRnF)9ztc%>D)+IOi?yn^+9B*qBv-{+lfr-~~)h}e2}
ze<)TW11+8fLFRS3sC*-i)@%eF4609L_uJ!}3T?WTokHX?VJgn0Ogc5qI
z8!a+mwGV@jduttFJtIE19%RTkgf|MT`3j-F4oyZL`dsV3hjo}=702hu>F{RFd*vw0
ztJac#o#1LzZ?KJ1aHi7n7tge1c&f4$}lNq5uJBfB-(N
zXb*(Dn7Gs(AlnZQ#~Q9ry$FW|T2eY4$G`gsVfP!ijyy8rI?VzzVE%iG?%q6K!%JLy
z#oygj=85U0@F*Mr9?|GUh4mSGB~Y5qC7V!%8|ZQBe-&7E#V-&u;IaZhqiZg|&jqE7
z;FHRD80H+Fdn(aX3Q+!NQ>LX?i9sM5js`Y%(0xX)NeX}f0000`hG-6%4bCFXWJZBe
z7qc(UBo6MgY9{18igVKN`z}AHxQbU2HrW;A5C`u04@{QLr1r+d|EB&OrfK1{XpxNT
z*XMU#3oPy*t)bDBN09xD+!l1T@jMSX+`{krBu^KDDweUg(ngflaoX}`=VotH+CK@U
z#<%Zf2p03Jee8yTql~`#$hEp5=1<^wY~;!*){&kfvEN-(B9$Im3WgdkB)ry0eba;q
zhqHc$5_z`7$-p||*&?_AYatG?2d&kHndNL;?Ji`OUy292l6E(oh@BXym0)oIL`dbB
zul;Eo*QUZZqWt>;N4AtMH(F#K3?}i&voCXtYPk;_$Om?9FL&BfFBigP=a-smdToV*
z{?4oPm>JecC%C)BcURbZEL?Rr$mc9dSnc&SNv)dqh+|{}tMZBf2cQ4}onGbj5-Xvu
zML~s(ysy?HK`78#&@=C5q?&4od%RU_@l5+lCO|@#_T#}ijf{o>q7ydyV>m03Jrf1J
z?w$-*meziIrbnm3jtM!7BztvF5%HMP?WO5(LA0(Oq73mo)}y+Ot_w
z{hu>>_zcoA{aT8dN88$h>bk(|Jn~oHT+TLCD~e+jLDFH(*?8LIt>FHn-EeUD8lppvLokAW6RZ^Kng?q1C=2VeP#}DS_V7vo|C(YmX&
zU7uP^DX06|Krr8BUJKtVk-;-#qXuXI01kh=1vGKj24^Dg%RX&=#$!oe%ic8j*-O$#
zXmSi;XyYPvDmD^sG!fp64tar=dgU(H7_`AG3
z^};5rk!j^~J3-Ux!oIc@>E=r7+VM(%DN92~)VH3S0BLjk-fyg<
zs>PYNnmXV7rru^tlj#TS)I=au9VfUqk6(z15dszhYUX|2TDxN}1!s<(`SjXjXC%sSqxBR)t(14YXkvT;
zuF$$U>+r_~fqsxF|Btm>|XH{FnUByyfEgK7}#o&W&SXoN>OX+x!3q_O8OJ@4x^6
z6V+f=OJH6t=#xcBov|H(CiY-?n!L*H<*BQ8;rCYHCQZOY!#JZ{p7FlMPwE-hre>46
zRsV{ipiUI+Pg{yRO)e;Z2{9&1w6^JYTb7((=aU(j-JEPVi;TVl^~GD|U|63of(P;#
zP%)_FmhF@XS2LZuKid)+a;=EvsDV$#fDY~q+=1#KEWW#!cHBw1h1Y6-KUSrho!
zPut-gMeiaxJ2srqWU@b*7eaA9CS;5tIZUNkx{@nBy7LOR>QBD=n$W%^OVcNP6y4G(
zKPi9AusiJD0b&HxYN_7<g073VQ0u(BdF-g;X~A-w9CV}W
z&Iy=522~x~jSTT{)137&$2U&J@&FAp->^_)dBeludLdcDaVHrHC7-Y5X?=0CPZ!Wh
zouc6fgnJv*vDq~lnTBJCOO0Jto
z3Qw6^8lSv5Sa3*lS6??%uJ!dGCdnAjcK*l!060yKBc_%nQwUYHCx+VF*TA0bKAz59
z)*Nwv=|r)pu!X9Q_@Dy-*FMdM&mS_C*MlFzzOSFTHv)Q$I7%e?u;XBe)wZAiq}Sv+U7@q
zAJw;K!QM!w8Zvg?DN3B@V5kX3;X-h8GqXA&t~YG1P*kc{r;b(WSf=5J2wkm1ejMr?N3-;aGPGti$ts-duOqulNuEyV$Jl!Z7964Ik
zEhr4QsNkuRrWyGzlT=s51WjT^9v&FND{}-5c42J>$Lq@8tPFbRx@K5K)@s$5Ks;oQ
z@5ti4hTqHJKwq}S-Cfe=_`eN_EfvU>63)A7*9vlRmg(ZQ^Gjb51ONntAnzaHp%d0t
zFZexrN(ElqgC}Z}{%m8vUm9=F%9`Khcv1gTks}>8c0x*w2}!di|6RTg?f-N+u0l
zzNd^nrtZ@h)yU77vi98|<4oI~vv>Ks&ZKL%A>fav{8#>F1VzB+S1OYew^;;Rf&-c7
zu%mL}`7>8Gr2&63Z9QSIpgpxS8Twtk{}&AuR^QE@9(FRC>E{`f$p!fLQN9R5r(YVR
zGX)O**&YZvYoHq(9ir*n1Az)KF#X^9woSjiuY6?#$Aa|lrFVFHEw%7D{rjL`6W%EH3VRTi%kG|B6X6{
z%R1SHp*6F7P)?xKnt;jNi)Ba)i;Lwj<*rrvzXhF0?VlecNg}FG*O*qdkkkbzPDE2^
z%@VkFK8dz1pZx-gK6V&*M54CMkEvX|
z7Gw>YD)`tu9Hf)e6voivb9$aiI<<7i!fxiIs%r!%7YUc|aj!OD_xr||U
zwH1V~C4Bv`lGjPA*b`8(m;+|LdnZ`zT|>SFbiZB^Lr?$!26%7ag=rJhH#gnXRNuLA
z`cy#xAF1#rsXHefx`6ljp>*dt2le&1=d*yVBI}9uwEUzWxWH9Knb=*163(0v`@L3h
z(lh0{c78O-xNP^9-3=FekMu6(lO`m67Ppg1ZtM80j(+3@sx{t`BZ#o8sATbF*hQ6B
zGZZPj!U>z{Tv@gF3#q8?D)XTLyj;TaaI_m6A_c%eg6_dL96)yU!A}{OCTj7VW6ly8
zHTo-Qb0)v9$VI@wPVIE;rf@+&o6)|zW-a9Ny0~&><8BY->iD<n3XF6ZcP2b
z<*(mPg?7a_mw!7-sEsbXm}Os8shx!iF!&|vUGiG0p1h^^A4;i@)$;69g@o&|`ig^<
zH4${nfS&tZf+H*1CPn7S+2BsgeLI}d@c)ReztDZrsytc(QF^n0H!#oBS3gA12Ahf7
z>|OYoX@)^0MrCawQPY1lq2b@q`+V+&Hj!@%0RgqRnMINi-9;mI3ZQX-r@?qNj&haZlrTX7KDA
zX~!&|Ht;$sxfo*QuEyZ>3drhF!MZjL*YMW~S98e&BzIK7=zVZ@%*g
zaJ3NI%IqVWq)v?w0fgMcj*$x;{C*xnag664RiuL~dymT;1Eh=0rk-|l7
z+z9E8o&w(H9~z@84?QvadH?_s00A>T9?(YhFW1?H5%K&!RgR_r3nGx#=CSIi@J{{)
zkS`s7>p)@K1S1p1H!QwL(*ARv#QRi-K`4$zr6yD{3~~FOQeWqvaD?_UH(k@cbPq(3
zUS5Fsa-jx2ME%Q1(JSDW4e(y~!L>uTfs}Y~ARtPsuhTXq!*{1=BmF=1CBbzFgCChw~aIvP~Vj$e>PuwMm&9xe=Vdb|4kvos@>BoT=rHpGiy%
zr|S!ljP@U5t39U^u5aO!Q4(mA`o^>=c?F2@b)*mOM$3OmcgkALkhXf@Gt3GoY5^WP
zJf{uXFY0tJf$iJW@kG{r3(IQmFk&7HiS}lrx2)54=Hr=*0}dI2YFrWUrmp&Mz9(KJ
zPtWzB1dt4~u0{8U!+_JBfkUKWGJU;&gKj2OAc!nx)OaX^7Qwi>Ic(Ka0eH57uLV8W3HAN|R_n2Y|JFyM>o!$?mUs*BJJt+*!%NN=$4(DQk6;uE^)Tdoo4{ZB
z9LVcXGZ#qo4GxX{gk=ipp^q3e=Nm1Eq$uY1sehOw>)#~Copo1klwTT{+-XH*5bY|3q}q=E7FwfvQ#RV|q>&{seYp0%aH+%az@(2aXr8^)AC?6S5=
z*_oVxI&a{nXwQ8kW)_%8|8(Ahz*0GY-#8Q{LjlwpDUG1-_m7DDMV-p-^
zwr}l!WRi3HgBYZEDy#9Gkpurx;4BL8DyR^^$?r=;c*;QT<`w=ei~s-uN=imvI8pt2
ziwaYpz!Z`!gbdPzGXMYp2rWg0f=;77A$OcLKmZ9ok%p825a<9r;21#!4;)!o02Sy3
uo&jlg`^ijJaZ#ul;_A7=PCQq+5Q-ZyH{>QsM}+5CiEfi!gJGOG0002MMp=;n
literal 0
HcmV?d00001
diff --git a/showcase/tavern-stool/tavern_stool.py b/showcase/tavern-stool/tavern_stool.py
new file mode 100644
index 0000000..83aab8b
--- /dev/null
+++ b/showcase/tavern-stool/tavern_stool.py
@@ -0,0 +1,754 @@
+"""Game-ready tavern stool — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural turned-leg stool 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 tavern_stool.py --
+ blender --background --python tavern_stool.py -- --skip-decimate
+ blender --background --python tavern_stool.py -- --output stool.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, 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
+
+SEAT_R = 0.155
+SEAT_T = 0.042
+SEAT_Z = 0.46
+N_LEGS = 4
+LEG_TOP_R = 0.078
+LEG_BOT_R = 0.125
+STRETCH_Z = 0.175
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.309, 0.309, 0.467)
+
+BASE_TRIS_MIN = 5250
+BASE_TRIS_MAX = 5550
+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 = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+METAL_FACES_MIN = 16
+WOOD_FACES_MIN = 24
+
+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_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cube(bm, size=1.0)
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_box(
+ bm,
+ ((a + b) * 0.5),
+ (scale_xy[0], scale_xy[1], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone_between(bm, a, b, r1, r2, segments, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_cone(
+ bm, (a + b) * 0.5, r1, r2, length, segments, mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True,
+ cap_tris=False,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = list(geo["verts"])
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+ n_major = 12
+ n_minor = 6
+ rings = []
+ for i in range(n_major):
+ u = i * (2.0 * math.pi / n_major)
+ ring = []
+ for j in range(n_minor):
+ v = j * (2.0 * math.pi / n_minor)
+ x = (major + minor * math.cos(v)) * math.cos(u)
+ y = (major + minor * math.cos(v)) * math.sin(u)
+ z = minor * math.sin(v)
+ ring.append(bm.verts.new((x, y, z)))
+ rings.append(ring)
+ for i in range(n_major):
+ i2 = (i + 1) % n_major
+ for j in range(n_minor):
+ j2 = (j + 1) % n_minor
+ face = bm.faces.new(
+ (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+ )
+ face.material_index = mat_idx
+ verts = [v for ring in rings for v in ring]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ 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_stool_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ wood_verts = []
+ metal_faces = set()
+ try:
+ seat_center = (0.0, 0.0, SEAT_Z - SEAT_T / 2.0)
+ wood_verts.extend(add_cylinder(bm, seat_center, SEAT_R, SEAT_T, 16, WOOD_IDX))
+ wood_verts.extend(
+ add_cylinder(
+ bm,
+ (0.0, 0.0, SEAT_Z - SEAT_T - 0.012),
+ SEAT_R - 0.018,
+ 0.022,
+ 16,
+ WOOD_IDX,
+ )
+ )
+ wood_verts.extend(
+ add_cone(
+ bm,
+ (0.0, 0.0, SEAT_Z + 0.004),
+ SEAT_R - 0.012,
+ SEAT_R,
+ 0.010,
+ 16,
+ WOOD_IDX,
+ )
+ )
+
+ leg_pts = []
+ for i in range(N_LEGS):
+ ang = math.pi / 4.0 + i * (math.pi * 2.0 / N_LEGS)
+ c, s = math.cos(ang), math.sin(ang)
+ top = Vector((LEG_TOP_R * c, LEG_TOP_R * s, SEAT_Z - SEAT_T - 0.004))
+ bot = Vector((LEG_BOT_R * c, LEG_BOT_R * s, 0.012))
+ leg_pts.append((top, bot, ang))
+ d = bot - top
+ p_knuckle = top + d * 0.22
+ p_ring = top + d * 0.55
+ p_foot = top + d * 0.88
+ wood_verts.extend(add_cone_between(bm, top, p_knuckle, 0.016, 0.024, 8, WOOD_IDX))
+ wood_verts.extend(add_cone_between(bm, p_knuckle, p_ring, 0.018, 0.015, 8, WOOD_IDX))
+ wood_verts.extend(add_cone_between(bm, p_ring, p_foot, 0.022, 0.018, 8, WOOD_IDX))
+ wood_verts.extend(add_cone_between(bm, p_foot, bot, 0.018, 0.028, 8, WOOD_IDX))
+ before = set(bm.faces)
+ add_cone_between(bm, bot + Vector((0, 0, 0.006)), bot - Vector((0, 0, 0.010)), 0.030, 0.030, 8, METAL_IDX)
+ metal_faces.update(set(bm.faces) - before)
+
+ for i in range(N_LEGS):
+ a_top, a_bot, _ = leg_pts[i]
+ b_top, b_bot, _ = leg_pts[(i + 1) % N_LEGS]
+ a = a_top.lerp(a_bot, 0.62)
+ b = b_top.lerp(b_bot, 0.62)
+ wood_verts.extend(add_oriented_box(bm, a, b, (0.022, 0.018), WOOD_IDX))
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+
+ 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
+ for f in metal_faces:
+ if f.is_valid:
+ f.material_index = METAL_IDX
+ 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):
+ mats = obj.data.materials
+ if len(mats) == 0:
+ mats.append(wood)
+ mats.append(metal)
+ return
+ mats[0] = wood
+ if len(mats) == 1:
+ mats.append(metal)
+ else:
+ mats[1] = 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, target_mat, 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("StoolNrm", size, size, alpha=True, float_buffer=False)
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.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_stool_mesh("StoolLow", bevel_offset=0.003, bevel_segments=2)
+ high = build_stool_mesh("StoolHigh", bevel_offset=0.003, bevel_segments=4)
+ wood = principled("StoolWood", (0.44, 0.26, 0.10, 1.0), 0.0, 0.48)
+ metal = principled("StoolMetal", (0.55, 0.52, 0.48, 1.0), 1.0, 0.28)
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("stool 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})
+ idx_counts = {}
+ for poly in low.data.polygons:
+ idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+ print(f"measured mat_index_counts={idx_counts}")
+ 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("stool has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "StoolLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "StoolLOD2", 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_src = build_stool_mesh("StoolColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider = convex_hull_collider(collider_src, "StoolCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_tavern_stool_{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 idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+ return fail(
+ f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+ return fail(
+ f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+ 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(-32.0)
+ low.rotation_euler.x = math.radians(6.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.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+ light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+ light("Wedge", (2.2, 4.0, 3.8), 600.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 = (0.94, -1.26, 0.90)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.26)
+ 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],
+ )
+ 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("tavern-stool 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 602401f..734317a 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -95,5 +95,6 @@
{"name": "signpost", "script": "showcase/signpost/signpost.py"},
{"name": "chopping-block", "script": "showcase/chopping-block/chopping_block.py"},
{"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"},
- {"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"}
+ {"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"},
+ {"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"}
]
From 52fed0b6c4d4e2c8db69e9954038dd66f61f7537 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Tue, 15 Sep 2026 07:39:18 -0400
Subject: [PATCH 2/2] feat: add iron-cauldron showcase piece
A cooking cauldron has to hang in a tripod with an open mouth, not sit as a closed kettle.
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 3 +-
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
README.md | 7 +-
ROADMAP.md | 4 +-
docs/gallery/assets/iron-cauldron-hero.webp | Bin 0 -> 13246 bytes
docs/gallery/index.html | 13 +-
docs/gallery/iron-cauldron/index.html | 1159 +++++++++++++++++++
showcase/gallery.json | 12 +
showcase/iron-cauldron/README.md | 74 ++
showcase/iron-cauldron/iron_cauldron.py | 835 +++++++++++++
showcase/iron-cauldron/preview.webp | Bin 0 -> 11358 bytes
tests/smoke/catalog.json | 3 +-
13 files changed, 2106 insertions(+), 8 deletions(-)
create mode 100644 docs/gallery/assets/iron-cauldron-hero.webp
create mode 100644 docs/gallery/iron-cauldron/index.html
create mode 100644 showcase/iron-cauldron/README.md
create mode 100644 showcase/iron-cauldron/iron_cauldron.py
create mode 100644 showcase/iron-cauldron/preview.webp
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 758ca31..a7cffa3 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -160,6 +160,7 @@
"showcase/chopping-block",
"showcase/wooden-bucket",
"showcase/wall-torch",
- "showcase/tavern-stool"
+ "showcase/tavern-stool",
+ "showcase/iron-cauldron"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index d411187..43fc201 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 23 showcase pieces (counts are CI-enforced against README.md)
+examples, and 24 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
diff --git a/CLAUDE.md b/CLAUDE.md
index 2387a84..7f95061 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, 23 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 24 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
diff --git a/README.md b/README.md
index 63c7f9a..ef3bc93 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 23 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 24 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 23 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 24 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.
@@ -98,6 +98,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
+
[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9.
@@ -145,6 +146,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`tavern-stool`](showcase/tavern-stool/) — procedural tavern stool with turned legs, stretchers, and iron ferrules through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`iron-cauldron`](showcase/iron-cauldron/) — procedural hanging iron cauldron on a timber tripod through the same pipeline. 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 ad959b2..3db13a6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -128,7 +128,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Wooden bucket with iron bands and a rope bail as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-bucket/` — overlapping staves, sealed bottom, iron hoops, ears, rope bail; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wall-mounted torch sconce as a game-prop showcase piece~~ **SHIPPED** as `showcase/wall-torch/` — coursed stone plaque, iron bracket and cup, wooden haft, emissive flame; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece~~ **SHIPPED** as `showcase/tavern-stool/` — round seat, turned splayed legs, stretchers, iron ferrules; `--skip-decimate` exits 9 on the LOD1 ratio band
-- Cast-iron cooking cauldron on a tripod as a game-prop showcase piece
+- ~~Cast-iron cooking cauldron on a tripod as a game-prop showcase piece~~ **SHIPPED** as `showcase/iron-cauldron/` — bulged open pot, bail, timber tripod, hook; `--skip-decimate` exits 9 on the LOD1 ratio band
+- Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece
+- Bound hay bale as a game-prop showcase piece
- ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~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
diff --git a/docs/gallery/assets/iron-cauldron-hero.webp b/docs/gallery/assets/iron-cauldron-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..6873e35897b68f4d7726a25ed3bdddda8f8b07c9
GIT binary patch
literal 13246
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
literal 0
HcmV?d00001
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index 70c9b92..fe829c3 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" />
×
- 74 examples
+ 75 examples
+
+
+
+
+
+
+
A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 7012 tris, two materials with 2754 wood and 706 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.572×0.558×0.786 m, LOD ratios in band, convex collider 130 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/iron-cauldron/index.html b/docs/gallery/iron-cauldron/index.html
new file mode 100644
index 0000000..66fe0b0
--- /dev/null
+++ b/docs/gallery/iron-cauldron/index.html
@@ -0,0 +1,1159 @@
+
+
+
+
+
+ iron-cauldron — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ iron-cauldron
+ A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod 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: 7012 tris, two materials with 2754 wood and 706 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.572×0.558×0.786 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/iron-cauldron/iron_cauldron.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural hanging cauldron (bulged iron pot with an open mouth, bail, timber tripod) 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 | 6850–7200 | 7012 / 7012 / 7012 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 2754 wood, 706 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.572, 0.558, 0.786) m ± 0.01 | (0.5723, 0.5582, 0.7855), zmin 0.002 | | Collider tris | ≤ 180 | 130 | | Export | written, size > 0 | 501796 / 501796 / 501780 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series in general — the gate is a ratio band, not an exact count. On this mesh LOD2 matched across 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.
+--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 iron_cauldron.py --
+blender --background --python iron_cauldron.py -- --skip-decimate
+blender --background --python iron_cauldron.py -- --output cauldron.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.
+| 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, or wood/metal faces missing | | 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 iron cauldron — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural hanging cauldron 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 iron_cauldron.py --
+ blender --background --python iron_cauldron.py -- --skip-decimate
+ blender --background --python iron_cauldron.py -- --output cauldron.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, 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
+
+POT_H = 0.26
+POT_Z0 = 0.15
+POT_THICK = 0.016
+N_AROUND = 16
+N_RINGS = 5
+R_BOT = 0.065
+R_MID = 0.175
+R_TOP = 0.135
+APEX_Z = 0.78
+TRIPOD_R = 0.30
+BAIL_SEGS = 10
+BAIL_R = 0.010
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.572 , 0.558 , 0.786 )
+
+BASE_TRIS_MIN = 6850
+BASE_TRIS_MAX = 7200
+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 = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+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_box(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cube(bm, size=1.0 )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8 :
+ return []
+ quat = Vector((0.0 , 0.0 , 1.0 )).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ" )
+ return add_box(
+ bm,
+ ((a + b) * 0.5 ),
+ (scale_xy[0 ], scale_xy[1 ], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone_between(bm, a, b, r1, r2, segments, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8 :
+ return []
+ quat = Vector((0.0 , 0.0 , 1.0 )).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ" )
+ return add_cone(
+ bm, (a + b) * 0.5 , r1, r2, length, segments, mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True ,
+ cap_tris=False ,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = list(geo["verts" ])
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ n_major = 12
+ n_minor = 6
+ rings = []
+ for i in range(n_major):
+ u = i * (2.0 * math.pi / n_major)
+ ring = []
+ for j in range(n_minor):
+ v = j * (2.0 * math.pi / n_minor)
+ x = (major + minor * math.cos(v)) * math.cos(u)
+ y = (major + minor * math.cos(v)) * math.sin(u)
+ z = minor * math.sin(v)
+ ring.append(bm.verts.new((x, y, z)))
+ rings.append(ring)
+ for i in range(n_major):
+ i2 = (i + 1 ) % n_major
+ for j in range(n_minor):
+ j2 = (j + 1 ) % n_minor
+ face = bm.faces.new(
+ (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+ )
+ face.material_index = mat_idx
+ verts = [v for ring in rings for v in ring]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ 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 pot_radius(z_local):
+ t = max(0.0 , min(1.0 , z_local / POT_H))
+ return (1.0 - t) ** 2 * R_BOT + 2.0 * t * (1.0 - t) * (R_MID * 1.35 ) + t ** 2 * R_TOP
+
+
+def build_cauldron_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ wood_verts = []
+ metal_faces = set()
+ pot_verts = []
+ try :
+ zs = [POT_H * i / (N_RINGS - 1 ) for i in range(N_RINGS)]
+ gap = -0.06
+ for i in range(N_AROUND):
+ a0 = 2.0 * math.pi * i / N_AROUND
+ a1 = 2.0 * math.pi * (i + 1.0 - gap) / N_AROUND
+ outer = []
+ inner = []
+ for z_local in zs:
+ z = POT_Z0 + z_local
+ r = pot_radius(z_local)
+ ov = (
+ bm.verts.new((r * math.cos(a0), r * math.sin(a0), z)),
+ bm.verts.new((r * math.cos(a1), r * math.sin(a1), z)),
+ )
+ ri = max(r - POT_THICK, 0.02 )
+ iv = (
+ bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z)),
+ bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z)),
+ )
+ outer.append(ov)
+ inner.append(iv)
+ pot_verts.extend(ov)
+ pot_verts.extend(iv)
+ for k in range(N_RINGS - 1 ):
+ o0a, o0b = outer[k]
+ o1a, o1b = outer[k + 1 ]
+ i0a, i0b = inner[k]
+ i1a, i1b = inner[k + 1 ]
+ for vs in (
+ (o0a, o1a, o1b, o0b),
+ (i0b, i1b, i1a, i0a),
+ (o0a, i0a, i1a, o1a),
+ (o0b, o1b, i1b, i0b),
+ ):
+ face = bm.faces.new(vs)
+ face.material_index = METAL_IDX
+ rim = bm.faces.new((outer[-1 ][0 ], outer[-1 ][1 ], inner[-1 ][1 ], inner[-1 ][0 ]))
+ rim.material_index = METAL_IDX
+ bot = bm.faces.new((outer[0 ][1 ], outer[0 ][0 ], inner[0 ][0 ], inner[0 ][1 ]))
+ bot.material_index = METAL_IDX
+
+ before = set(bm.faces)
+ add_cylinder(
+ bm,
+ (0.0 , 0.0 , POT_Z0 + POT_THICK * 0.6 ),
+ pot_radius(0.0 ) - POT_THICK * 0.2 ,
+ POT_THICK * 1.2 ,
+ 16 ,
+ METAL_IDX,
+ )
+ mouth_z = POT_Z0 + POT_H
+ add_rim(bm, (0.0 , 0.0 , mouth_z), R_TOP + 0.008 , 0.012 , METAL_IDX)
+ ear_x = R_TOP + 0.012
+ for sign in (-1.0 , 1.0 ):
+ add_box(bm, (sign * ear_x, 0.0 , mouth_z - 0.01 ), (0.024 , 0.030 , 0.040 ), METAL_IDX)
+ add_rim(
+ bm,
+ (sign * (ear_x + 0.004 ), 0.0 , mouth_z + 0.008 ),
+ 0.014 ,
+ 0.005 ,
+ METAL_IDX,
+ euler=(math.radians(90.0 ), 0.0 , 0.0 ),
+ )
+ bail_major = ear_x + 0.004
+ bail_pts = []
+ for i in range(BAIL_SEGS + 1 ):
+ t = math.pi * i / BAIL_SEGS
+ bail_pts.append(
+ (
+ bail_major * math.cos(t),
+ 0.0 ,
+ mouth_z + 0.008 + bail_major * math.sin(t),
+ )
+ )
+ for a, b in zip(bail_pts, bail_pts[1 :]):
+ add_oriented_box(bm, a, b, (BAIL_R * 2.0 , BAIL_R * 2.0 ), METAL_IDX)
+ metal_faces.update(set(bm.faces) - before)
+
+ pole_feet = []
+ for i in range(3 ):
+ ang = i * (2.0 * math.pi / 3.0 ) + math.radians(18.0 )
+ foot = Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016 ))
+ apex = Vector((0.0 , 0.0 , APEX_Z))
+ pole_feet.append(foot)
+ wood_verts.extend(add_cone_between(bm, foot, apex, 0.028 , 0.018 , 8 , WOOD_IDX))
+ before = set(bm.faces)
+ add_cone_between(
+ bm,
+ foot + Vector((0.0 , 0.0 , 0.008 )),
+ foot - Vector((0.0 , 0.0 , 0.014 )),
+ 0.032 ,
+ 0.032 ,
+ 8 ,
+ METAL_IDX,
+ )
+ metal_faces.update(set(bm.faces) - before)
+
+ wood_verts.extend(add_cylinder(bm, (0.0 , 0.0 , APEX_Z - 0.02 ), 0.042 , 0.055 , 10 , WOOD_IDX))
+ before = set(bm.faces)
+ hook_z1 = APEX_Z - 0.055
+ bail_peak = mouth_z + 0.008 + bail_major
+ add_cone_between(
+ bm,
+ (0.0 , 0.0 , hook_z1),
+ (0.0 , 0.0 , bail_peak + 0.012 ),
+ 0.010 ,
+ 0.010 ,
+ 8 ,
+ METAL_IDX,
+ )
+ add_rim(
+ bm,
+ (0.0 , 0.0 , bail_peak + 0.004 ),
+ 0.018 ,
+ 0.006 ,
+ METAL_IDX,
+ euler=(math.radians(90.0 ), 0.0 , 0.0 ),
+ )
+ metal_faces.update(set(bm.faces) - before)
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in pot_verts + wood_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+
+ 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
+ for f in metal_faces:
+ if f.is_valid:
+ f.material_index = METAL_IDX
+ 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):
+ mats = obj.data.materials
+ if len(mats) == 0 :
+ mats.append(wood)
+ mats.append(metal)
+ return
+ mats[0 ] = wood
+ if len(mats) == 1 :
+ mats.append(metal)
+ else :
+ mats[1 ] = 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, target_mat, 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("CauldronNrm" , size, size, alpha=True , float_buffer=False )
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.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_cauldron_mesh("CauldronLow" , bevel_offset=0.003 , bevel_segments=2 )
+ high = build_cauldron_mesh("CauldronHigh" , bevel_offset=0.003 , bevel_segments=4 )
+ wood = principled("TripodWood" , (0.38 , 0.22 , 0.09 , 1.0 ), 0.0 , 0.58 )
+ metal = principled("CauldronIron" , (0.22 , 0.23 , 0.24 , 1.0 ), 1.0 , 0.42 )
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("cauldron 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})
+ idx_counts = {}
+ for poly in low.data.polygons:
+ idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0 ) + 1
+ print(f" measured mat_index_counts= {idx_counts}" )
+ 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("cauldron has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "CauldronLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "CauldronLOD2" , 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_src = build_cauldron_mesh("CauldronColSrc" , bevel_offset=0.0 , bevel_segments=1 )
+ collider = convex_hull_collider(collider_src, "CauldronCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_iron_cauldron_ {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 idx_counts.get(METAL_IDX, 0 ) < METAL_FACES_MIN:
+ return fail(
+ f" metal faces {idx_counts.get(METAL_IDX, 0 )} < {METAL_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(WOOD_IDX, 0 ) < WOOD_FACES_MIN:
+ return fail(
+ f" wood faces {idx_counts.get(WOOD_IDX, 0 )} < {WOOD_FACES_MIN}" ,
+ 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(4.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.4 ), 660.0 , 4.0 , (1.0 , 0.94 , 0.86 ), (50 , 0 , -36 ))
+ light("Fill" , (5.0 , -3.4 , 2.4 ), 46.0 , 8.0 , (0.72 , 0.82 , 1.0 ), (62 , 0 , 50 ))
+ light("Wedge" , (2.2 , 4.0 , 3.8 ), 600.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 = (1.55 , -2.05 , 1.35 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.38 )
+ 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],
+ )
+ 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("iron-cauldron 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 11b0930..371f4c1 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -280,6 +280,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "iron-cauldron",
+ "dir": "showcase/iron-cauldron",
+ "teaches": "A procedural hanging iron cauldron with a bulged open pot, bail, and timber tripod through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 7012 tris, two materials with 2754 wood and 706 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.572×0.558×0.786 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/iron-cauldron-hero.webp",
+ "preview": "showcase/iron-cauldron/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/iron-cauldron/README.md b/showcase/iron-cauldron/README.md
new file mode 100644
index 0000000..e6013cf
--- /dev/null
+++ b/showcase/iron-cauldron/README.md
@@ -0,0 +1,74 @@
+# Iron cauldron
+
+A showcase piece, not an example. Procedural hanging cauldron (bulged
+iron pot with an open mouth, bail, timber tripod) 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 | 6850–7200 | 7012 / 7012 / 7012 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 |
+| Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 2754 wood, 706 metal |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.572, 0.558, 0.786) m ± 0.01 | (0.5723, 0.5582, 0.7855), zmin 0.002 |
+| Collider tris | ≤ 180 | 130 |
+| Export | written, size > 0 | 501796 / 501796 / 501780 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series in
+general — the gate is a ratio band, not an exact count. On this mesh LOD2
+matched across 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the
+gate is `has_data` plus operator `FINISHED`, not byte-identity.
+Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1
+(glTF serializer), not a gated axis.
+
+`--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 iron_cauldron.py --
+blender --background --python iron_cauldron.py -- --skip-decimate
+blender --background --python iron_cauldron.py -- --output cauldron.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.
+
+| 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, or wood/metal faces missing |
+| 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/iron-cauldron/iron_cauldron.py b/showcase/iron-cauldron/iron_cauldron.py
new file mode 100644
index 0000000..7be8ed9
--- /dev/null
+++ b/showcase/iron-cauldron/iron_cauldron.py
@@ -0,0 +1,835 @@
+"""Game-ready iron cauldron — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural hanging cauldron 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 iron_cauldron.py --
+ blender --background --python iron_cauldron.py -- --skip-decimate
+ blender --background --python iron_cauldron.py -- --output cauldron.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, 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
+
+POT_H = 0.26
+POT_Z0 = 0.15
+POT_THICK = 0.016
+N_AROUND = 16
+N_RINGS = 5
+R_BOT = 0.065
+R_MID = 0.175
+R_TOP = 0.135
+APEX_Z = 0.78
+TRIPOD_R = 0.30
+BAIL_SEGS = 10
+BAIL_R = 0.010
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.572, 0.558, 0.786)
+
+BASE_TRIS_MIN = 6850
+BASE_TRIS_MAX = 7200
+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 = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+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_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cube(bm, size=1.0)
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_box(
+ bm,
+ ((a + b) * 0.5),
+ (scale_xy[0], scale_xy[1], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone_between(bm, a, b, r1, r2, segments, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_cone(
+ bm, (a + b) * 0.5, r1, r2, length, segments, mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True,
+ cap_tris=False,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = list(geo["verts"])
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+ n_major = 12
+ n_minor = 6
+ rings = []
+ for i in range(n_major):
+ u = i * (2.0 * math.pi / n_major)
+ ring = []
+ for j in range(n_minor):
+ v = j * (2.0 * math.pi / n_minor)
+ x = (major + minor * math.cos(v)) * math.cos(u)
+ y = (major + minor * math.cos(v)) * math.sin(u)
+ z = minor * math.sin(v)
+ ring.append(bm.verts.new((x, y, z)))
+ rings.append(ring)
+ for i in range(n_major):
+ i2 = (i + 1) % n_major
+ for j in range(n_minor):
+ j2 = (j + 1) % n_minor
+ face = bm.faces.new(
+ (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+ )
+ face.material_index = mat_idx
+ verts = [v for ring in rings for v in ring]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ 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 pot_radius(z_local):
+ t = max(0.0, min(1.0, z_local / POT_H))
+ return (1.0 - t) ** 2 * R_BOT + 2.0 * t * (1.0 - t) * (R_MID * 1.35) + t ** 2 * R_TOP
+
+
+def build_cauldron_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ wood_verts = []
+ metal_faces = set()
+ pot_verts = []
+ try:
+ zs = [POT_H * i / (N_RINGS - 1) for i in range(N_RINGS)]
+ gap = -0.06
+ for i in range(N_AROUND):
+ a0 = 2.0 * math.pi * i / N_AROUND
+ a1 = 2.0 * math.pi * (i + 1.0 - gap) / N_AROUND
+ outer = []
+ inner = []
+ for z_local in zs:
+ z = POT_Z0 + z_local
+ r = pot_radius(z_local)
+ ov = (
+ bm.verts.new((r * math.cos(a0), r * math.sin(a0), z)),
+ bm.verts.new((r * math.cos(a1), r * math.sin(a1), z)),
+ )
+ ri = max(r - POT_THICK, 0.02)
+ iv = (
+ bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z)),
+ bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z)),
+ )
+ outer.append(ov)
+ inner.append(iv)
+ pot_verts.extend(ov)
+ pot_verts.extend(iv)
+ for k in range(N_RINGS - 1):
+ o0a, o0b = outer[k]
+ o1a, o1b = outer[k + 1]
+ i0a, i0b = inner[k]
+ i1a, i1b = inner[k + 1]
+ for vs in (
+ (o0a, o1a, o1b, o0b),
+ (i0b, i1b, i1a, i0a),
+ (o0a, i0a, i1a, o1a),
+ (o0b, o1b, i1b, i0b),
+ ):
+ face = bm.faces.new(vs)
+ face.material_index = METAL_IDX
+ rim = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0]))
+ rim.material_index = METAL_IDX
+ bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1]))
+ bot.material_index = METAL_IDX
+
+ before = set(bm.faces)
+ add_cylinder(
+ bm,
+ (0.0, 0.0, POT_Z0 + POT_THICK * 0.6),
+ pot_radius(0.0) - POT_THICK * 0.2,
+ POT_THICK * 1.2,
+ 16,
+ METAL_IDX,
+ )
+ mouth_z = POT_Z0 + POT_H
+ add_rim(bm, (0.0, 0.0, mouth_z), R_TOP + 0.008, 0.012, METAL_IDX)
+ ear_x = R_TOP + 0.012
+ for sign in (-1.0, 1.0):
+ add_box(bm, (sign * ear_x, 0.0, mouth_z - 0.01), (0.024, 0.030, 0.040), METAL_IDX)
+ add_rim(
+ bm,
+ (sign * (ear_x + 0.004), 0.0, mouth_z + 0.008),
+ 0.014,
+ 0.005,
+ METAL_IDX,
+ euler=(math.radians(90.0), 0.0, 0.0),
+ )
+ bail_major = ear_x + 0.004
+ bail_pts = []
+ for i in range(BAIL_SEGS + 1):
+ t = math.pi * i / BAIL_SEGS
+ bail_pts.append(
+ (
+ bail_major * math.cos(t),
+ 0.0,
+ mouth_z + 0.008 + bail_major * math.sin(t),
+ )
+ )
+ for a, b in zip(bail_pts, bail_pts[1:]):
+ add_oriented_box(bm, a, b, (BAIL_R * 2.0, BAIL_R * 2.0), METAL_IDX)
+ metal_faces.update(set(bm.faces) - before)
+
+ pole_feet = []
+ for i in range(3):
+ ang = i * (2.0 * math.pi / 3.0) + math.radians(18.0)
+ foot = Vector((TRIPOD_R * math.cos(ang), TRIPOD_R * math.sin(ang), 0.016))
+ apex = Vector((0.0, 0.0, APEX_Z))
+ pole_feet.append(foot)
+ wood_verts.extend(add_cone_between(bm, foot, apex, 0.028, 0.018, 8, WOOD_IDX))
+ before = set(bm.faces)
+ add_cone_between(
+ bm,
+ foot + Vector((0.0, 0.0, 0.008)),
+ foot - Vector((0.0, 0.0, 0.014)),
+ 0.032,
+ 0.032,
+ 8,
+ METAL_IDX,
+ )
+ metal_faces.update(set(bm.faces) - before)
+
+ wood_verts.extend(add_cylinder(bm, (0.0, 0.0, APEX_Z - 0.02), 0.042, 0.055, 10, WOOD_IDX))
+ before = set(bm.faces)
+ hook_z1 = APEX_Z - 0.055
+ bail_peak = mouth_z + 0.008 + bail_major
+ add_cone_between(
+ bm,
+ (0.0, 0.0, hook_z1),
+ (0.0, 0.0, bail_peak + 0.012),
+ 0.010,
+ 0.010,
+ 8,
+ METAL_IDX,
+ )
+ add_rim(
+ bm,
+ (0.0, 0.0, bail_peak + 0.004),
+ 0.018,
+ 0.006,
+ METAL_IDX,
+ euler=(math.radians(90.0), 0.0, 0.0),
+ )
+ metal_faces.update(set(bm.faces) - before)
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in pot_verts + wood_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+
+ 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
+ for f in metal_faces:
+ if f.is_valid:
+ f.material_index = METAL_IDX
+ 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):
+ mats = obj.data.materials
+ if len(mats) == 0:
+ mats.append(wood)
+ mats.append(metal)
+ return
+ mats[0] = wood
+ if len(mats) == 1:
+ mats.append(metal)
+ else:
+ mats[1] = 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, target_mat, 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("CauldronNrm", size, size, alpha=True, float_buffer=False)
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.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_cauldron_mesh("CauldronLow", bevel_offset=0.003, bevel_segments=2)
+ high = build_cauldron_mesh("CauldronHigh", bevel_offset=0.003, bevel_segments=4)
+ wood = principled("TripodWood", (0.38, 0.22, 0.09, 1.0), 0.0, 0.58)
+ metal = principled("CauldronIron", (0.22, 0.23, 0.24, 1.0), 1.0, 0.42)
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("cauldron 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})
+ idx_counts = {}
+ for poly in low.data.polygons:
+ idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+ print(f"measured mat_index_counts={idx_counts}")
+ 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("cauldron has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "CauldronLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "CauldronLOD2", 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_src = build_cauldron_mesh("CauldronColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider = convex_hull_collider(collider_src, "CauldronCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_iron_cauldron_{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 idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+ return fail(
+ f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+ return fail(
+ f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+ 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(4.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.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+ light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+ light("Wedge", (2.2, 4.0, 3.8), 600.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 = (1.55, -2.05, 1.35)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.38)
+ 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],
+ )
+ 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("iron-cauldron 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/iron-cauldron/preview.webp b/showcase/iron-cauldron/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..804e511d54ab30ffb0be032f32f9c41675126bb0
GIT binary patch
literal 11358
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
literal 0
HcmV?d00001
diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json
index 734317a..1dc87c5 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -96,5 +96,6 @@
{"name": "chopping-block", "script": "showcase/chopping-block/chopping_block.py"},
{"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"},
{"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"},
- {"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"}
+ {"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"},
+ {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"}
]