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 Chopping block: a hooped timber stump with an embedded axe on a dark studio floor Wooden bucket: a coopered bucket with overlapping staves, iron hoops, and a rope bail on a dark studio floor Wall torch: a coursed stone plaque with an iron sconce, wooden haft, and flame on a dark studio floor +Tavern stool: a round wooden seat on turned splayed legs with stretchers and iron ferrules on a dark studio floor [`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 zSKq(;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

@@ -1127,6 +1127,17 @@

wall-torch

View example
+
+ + 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… + +
+

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.

+

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 +
+
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 + + + + + + + + + + + + + + + + + + +
+

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 --
+ +
+
+

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

+
+ showcase/tavern-stool/tavern_stool.py + View on GitHub → +
+
"""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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + 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 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 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 Wooden bucket: a coopered bucket with overlapping staves, iron hoops, and a rope bail on a dark studio floor Wall torch: a coursed stone plaque with an iron sconce, wooden haft, and flame on a dark studio floor Tavern stool: a round wooden seat on turned splayed legs with stretchers and iron ferrules on a dark studio floor +Iron cauldron: a bulged iron pot hanging from a timber tripod on a dark studio floor [`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 zcmV;vGeOK!Nk&GtGXMZrMM6+kP&go}GXMZ^y#bv8DgXu00zOeDkw&ATq9`FSC}98! z32AQs%(<`thHoHGP$)_J+PAb17T}H!?tGb%5;NpKhyO>X_9XeQUu_yjjcc|A0g6{#BT~ z6CNq(*T&QCo}i!4{USUFeasH;;RZl*OTTO$VN$toW+v)aIAgLkJ!m;D*`BFf;i6P~ zhv-I?gassXhKqOXx6A41k?cq*&K7@Fna6vQDn-HO<+S|4W<4GnJqA3?-&0=^2cKUNEzEY-6kW5bomd_i zP2H1wIp`n2jBN2{eM%tn`D=tz+2YSLeNB8sCdzB;+$>v%jXUT-=t!oZ3gFRfgB}~$ zh&=khp64M$CyLf|MMp#-FLO84?0WhA&#VC_i{oZm3_-3Mt2zAN@Q-v>XvXW^o4u(!f{o^5cHu*gvLR2%z8w0jtCP+Ys*#-r?$ zG(XybM@JF)?k`ROP5YcG# z7Q2+I1Qw4Mb2wY|9+#`j<()o*E6i7d(a2BSdp9jiLwEcb*030MP(7lrckP1?L;uF@ zDzn)5H>^&&$}Y?=LDw+vLVr6G2r@Xv=nY0j86&|Xy8`=-8Y>8?WUqic9O7e$ZYjWMEvCRG zM(xN689Q#<00jpz_8A7Kf;2>)hZBZeecTNu8FG+XWwNsl!ZA^lcIA61B;!NT<}PQy z4sqy6G?$}~W;ycD4IYa8^&V_vYfA1(n7VHTRUS;Ex)lOB9mPuF*;R0=i~D}1MCn2Z zYbUANhnO9$%uaJK1#klJe%jfDdjMioomrVN4DHe2uz@7zMNT%$&_2h8iv&)ZkJrUQ zNpViN>L5tzPfIQWa_Y~Df))5+8<`w^-7=*@IQhG!M{}bxhFl=Jt{0bgP87Eq!h-aA zdgh2@NyGg<8-6%F+B`N1oZiO%#GG_K*GR@EArr$UpF!8--#pbR|#@&M1?O6aq)?=~)a6>HK!Q^MzWP!zMznGql0I(AMMr{en73R*PXB!n&aU zns$?XE!=p#9?EDi100@mqQM$G4lK;VRU|m)jS=8RXpB?~2MQ0gXzKf;W;k0mYmYI2|G^N(f3iDU+EAKVmz(vssB1 zU|dY7H2d;!&`v!a5vFlcc5@=A5MkIwKUWAC1oFXiT)$1xZ2L%ASyBs6WRKabI+tZl znW_Itd$@ke17CmHcnR}t1pdsA*47@NqWl8WXlUo#w1``x`hMJs4md{h5Zle$;=e*P zgW9LuMF?X3X4m7?VlM&bObhq6VCOoN7W zjo$yq-&bi$(#URdpNNxEBfv%M>%ulxBTauYc_5mP&V|t$fq1U1iv{q`Klx?Zm5T59 zp%5PFRBS!Bu?t(HEg7-ED_^201J#&6%9@*;-0e zJbc~IzkhdUhDw+c(r_ETsGR=bcDFZKyO!3cZnW@Vv^B9Ra}*4xUG^b-p@D%1wgDEC z7*hc+Cu5^Y0qLb}%Bfq}(%-<>>EoGfr+m|^|*ivzWh?2yIR-s8pyxCc`>s9De z0$EY}8E&Lhz^^^)!7&GpJ$@2zv*XOJ_DEoV6!ChO`C$8DQjNDxNxMYm>&=x`$*aD? zh7BY^_86q*U}U%DI5HEL%9riUrkT9akGlOxCjwfkdB)TzZVxU3D?g(!tdTqcEo2U- zBL>`l0rf;bzR+xi+)cNRuuC5p{=$uw`9i#C^-ZfbVXp(aax>I;9RrD%Hh~ZI^LHQF z0TpB0O1#!^F>|F1)UJ8VWS&=#AmCN7DeM?GID6B2AE=__8F68)(*QN^u+b zDWZH$@+*W+{b^;kB9Ge`c2G^lCou$*>#y4~`y;?j?5%{17iZ<%VlVm={}{T#?|bfB zYU9BWN)2I4L6Rx=pj(m>UpqO-Bjc#~2EKZmq`)>Lk7g;pl~Yz}J4_a}wAQp`bahfg zuz7_dUSru({qkGLwBu^vvV>AkVZOgpwGD%o=xK4=N)BX{|a%L zKdw)>di)PS?5vq=?tDhi51UN#!8h(lk>Rna7Bh|MhVGKY@_ee1djBN7$;T>Qd6bVx z<7N}(sGD_*o-fdFQHZc!ADHh$jb)kLe+e3~SUEX9P|KWAQYnc0s9fJ-6(u9a3Mwt% zrCWQW2t=TEC1r&;tK@cRa)}blTB@c&Z_`I6M_UvD=2O5KoZiK{Li7-s zq=eh1Jz6wM{1_N$m_k1M{4f|AWFQ1Cge(#Ga5Ak!r<}qVW>;6i%sAdkdBhWpiK+_I z%0=@lJXxLoxl_vHttsUv%&E*NeyE#j( zwYiDkyS^#~{xvz^t=^j~s<#bvnJbp-tBJd{l`&vj zMOgdZH}phc|9V?T9DM5;7a^}T0-imi+C8h?fzyYHZqrMcXGgdls5;6udB3eQ zM?Ll9`q8;+hNOyQ^Mo(cxfM_$895S^E8X4-aUU##&L;JS{#khV4i+xlUogcgN9yp1 zYJ`i-y^VzD+&pJpQVUGqEo7+|8^5cBn5B|Wd-ZQb{7KES`2&cGqkdqKiP=90Mfq?e z1g1<{Ld(aMxAw81gVj)t@asWQnxT4`iPU7Me(!G-@qPm|3^NLq$pJ}qgk((>i&FvR zER{~J;C8;S-bj3X^3=(V+vQZfW!NO%ja96GZ^F2aAL`m?%xv!EW)ei^_9gLA{bfe% z5+Zx<63);E1fr%LOR}+e@cMAhk^0)vNz6ZX2~4UcL9KO0@ypqhpw0pkXTMX3BFl7Q zDCHX>!cO!-9P8l#mU|$kub}`}!HA3S{Y`o01or@%ZAWcM^K`XT_E3aR?_}d}hBtk@ z&C(YsxYh*AoRE?zkAncA;5Hol3Ob>tN#=ew@OcLS&XN~ZROTr;htqRn9v`h_@i)U{ zz9sLc&Du(E&Sn8L+L2yVY`VKI@TYD0nQ@YyZR>=gOEf>NK; zbPmdx8cf!@qj@LE!#e7L+2Q%6z`P;fkGp}V1T~Y?;he*JGnjtt5^hjDJyHu#VTved z@X}Hg~`P{`uyu$$?~uhQV$LQGZk?Wo448&zjq3n+;>%P2 zxw#D22+$0F=CTl~W5`xYefkjzg*?o)@tg*l#`1(}vo9o~R&2gSo${d-4urmr{Cs;l zxy+^_N;0n!=)l zb$uE~i&8C|Srq;?0IZT{O;Qx?ZQCQf84eq5020`*K+W1PFNSqdi6c)c=OalN)WJd^ zz(8&c-cSN!20M$DX;q1}!s=-+hJNOW?2BP=OcIut&}5s7p$H!S^wkI<@KGB_O)2#}h0_+eQ?X@CxeLX7lGP0@q7WLfYXA{l z6-R3{|3?6`*NEl0w6RmW@UybpjID!5=z7bOWCv?gJ=jS(mQ#Fgq!|pmn4mt&qP+G@ z7*)%{h2^MB6k(>~KHLlUfw2&NuYnW^ouZ-iG#1VOgm={#WHeVr zoVA<$F4vs%YFH{lf(_3@kq&_zv2xm=nxpw%j(7Ub>?ix3#kRg@zYp7W7z7y5}ZuE*vr%D zx)i7!Nv1`Bkws*V11E$+*b+pc5WbNDtzlq~I#gm?!LI{5Ac9n;to z>AX@3Mmwx+5~H4<(*g!s=O6)ZSBbU)T~q|!Rj4Ot391nE5E6WMBkUNr!-n#rnH)!2 zeBmn^ppxpw9OBD19WOUer7q+^TCqI@Op;rEgrM5P0Vw(a0ihFC5db%jJ`%X+)+wrNjfMJh)lQEr{c+_O7!67~;xLV_)*W6=hR{N{8DWK1$%y#( z6yCq0F!clE1TwvcqJnZ@Wxxg7RrSpZNM`!5CvCMk;^vY`j76p@>G%YWmc~6Gx6L`= z>qx&aM!A%S+img)h^TPLH4Ak?-;}mg9KemBYVt)vye{7+{@4cWgd>xkW?TeGjBe^? z7X5;P5)*0O$bls|6=**v`$4Pz5V(yctPpPk^gDbjH3o&F!=G#~?hVSYonw4?jSk@) z@nbmN)>%!!j1v7v_b?3LQD~?>Zez^|lh2<7-+}3LEqPs${?DnEl-J973#r5r^fM_h zI61heZP`(8t4A@T0XxCOxetfAHQp|SG>R&=YEtGA}X((3H2=l?Ce5$ z`%|pWGDwV~zsLBfL7$WTwi$m(vyz@skWDSX0x4V`tTl5}od5z2@IJn#O&9SqN-rGS z<(pK80@DcwI?)|iw)0(jpvx9u-Z?$eGmj%W3L{d*^Fx~WB2j}1k^rsgMMGgfP7K9w zR3eTw_PAY}7B!zh%K%loiI!9KL=F9mQs+k(3d^MHN&uahZAdBwZvP7q@U3E0b9)rQ zURe&Ur=9I~vol=}Z=~$1Ywy4_6HHd~q4s~?d`XU>zvQ;C6W8hi_?YKEviaHL@YzgA z7ey6W4t{LUgstlXqRx$VI?tLSQ7{&mT%-0d>ExMc2_e(@jb-zaiTupcfD_uvQcGzG zI0nGa3fFbEC0gXVcO@+P;TfgPmUw+b;J++on%kqJ;1@!5G!AUfN`ie*xP44ZxVJ&j zMKL`03!?yo<{0K#9{EQ3^+g?dRd9a`tmPc)jFhv?CVV@dF87-&*XA}7@mjJL81m4% z=a@~a09*XQ=aD3}S&I_(dJUzk^DcPdu(|$G-`pR0ZuobKmqcRL({` z0zqxT?iABuJKHiJer>neKm#tr(l~amTm6T1ek$exZA0r1X&E0-W8zgtCG`N%e$RJ}$QhCC&SL$=j zW~jXo#n6omAp=}|x^|weK?_B6=Xa9UAOS*<2u^nhkdAqv#iSv-FBHo!7N98NMpX<&E&+{S3+W6Rg)FqQT#Pb$O1gNmUkHo zoq7*kS>@&=@9cFWGKhRsG$jZ|$nWh3j-M!4|%9yrIS$0EHdFiUURwPJY zbpCT^bF+E(cv1q&^t|5ng4gdk8SZ59AK)6|W+$_x&epua9;6tms*EzIN*Eln)iHCg zR1uakbo$3%qDKK)DC#YAI`WlNoWBbPKL43^A~+R(<;UT`gCD zeqCPgW$7-P`=11#`0fA@1G6uX)>{2yAW)32%F$kdV(S?*HOPy4On(|1>m2jNsg3i?9c$}2IT^>2fV zAX|DkI}uhKn!n;6L=*{asoF7IN_x3WxE@2tc~-Y&U>?8ZB67V^$$tCALG*n-=^HA` zgNbd7<^B)3C)TzeLSbgG=(N}6hRI36+-N2<4*1=@HUQm23mr$AU6?#rQ*5MF4ng&5=oBQh+l zj&&Z?=11*casawZ)+a}%sk`V4sTtAlN$2=VB-rn*7q2wJ!z>5p^wiZ#oZ2lF zcdSVnLfIMD5Bs_Lm{rDw(J1lpP#WY*b)kbN7<5{G_2WDSx{48?(O=A{n`(N-0ZVg# zLdB;@NT4<{4^RMDaX`6Gv!)n2r3SiW-s7Wkf9A*Dxv)p!64wnS+@ydrgw@uAsuaYg z_z*d%Ym<+NX?b(bZf^b(!wNxSxQF>UYE3mUp!xmeBf~Cd`q41NIBXxlxf$SKCoAH! zqrT^(P02m29QhB3W^+#u$~BvjC)?Ht34?IuiF6H1&g|~Bi&V_rHw=v``n&0*S1NcU zI^vl*_xSHfhl(H^kC2Ddxwd9poIye64_8T(k)0MfdkYR9@Qtzdt74OCee0lHzmK^= zCohLN7qfh=-f_^I#vy;+{~?Wq%#}Y{^#AsUd42+1;RErvM380!FxfuAUK2(rTVNjF6kI<%tlz5S=uxW-?TuXDpAy!Yz`~sk7YTw zvJ?dakIN2`_C1&4#Fx^?D;KT|8FC z%Wr-^=Ps6X zQrMRbZuhUG7h?qzk3dOU_bR$2&UgVq0*eYZKy0m<>X13SM8oLioaKH z`jix~&f4X_93s29dm#9QAGlU(+QQJUDcAyy`G`gusVE3Gfdp`D!iBY zswdX9^a^__*hI6YF^(IR+tcXpK)1DeBNx}I!5`bnuC+6SXO#|nW(!`a#aSd(5-!R_ zzvs%bT=+U3nf`>mR%35ZX4zVE;(b#tFZPPE#h3CyDh+AQiMQ348y>kyO9-X4 z;WIAXiiu>AU0>`>d1~}=VsW>ccemjfb@+I8oI-!Ivi4+_MrZJBK-WX2lEqk(2Lb3? z*fLaa@u(eUIxU=C5iLpSOdn`l6>)|M$A4pxsbJ*IP5!WDAz}*U_L0ykZJ2lr`xLD&xc~!h3>QuKMpkWEQ9`0QhR-X7^8%1E;<3X{$pFS?b`HG^ zb6%%PpQPKygF8iL=) zk|+ks5ESlUMfy0PGmiXwUWUM{&~r#cFkNTPeb1a@yCChZ^MG%ytRZ)A-xL~Q?f@Goh4o{A*oOMMZGCDWgs82vrdW5zD}B@His7EB?po39XoS zY5H5+KwcCCj~SI#3v*-TJ;;RWjbsGtc`1F*FwjZTEsTh>2139!{lTwi4xU zUyuXn(yY+92Lm~<2$MrMeR#BRDyN)ef~h_L0}p0rz}A0vTY^bA0!}F^t9kXeiD=#x z=>&RG98{nOB(G`(qQ2y#v&r=&_tQAV#=nT6npMNqYv7tC5)s;gn{`YEttY!VN-1qM zw^e-gT?@rS?L?bDnXk4K{^r?BEMLIoct@mh(t_@OkX8m&#wWj&+Sw}H-LFc4InwGU zET|N?P)lj!SfvEpGwdAZXA6WPj(rq|LtqicN_bKUkV9hgH2rZg&HbG_;x z+rFM)E4gIBB}sqaVJ#AX_Xx#`E`3_lK_f=885By z1M8>0Wcuamf7hS-1L_H;TYvuTp07l*ew8nHG<&^*d&K7*aTixA`8DFQBwPEfQ$^;y zrUz}`_qM)LuzJA_X-O&y1rJkt2LbIX<7e8mgCnS@Tnt(;R~(SMjYkJO5V#Uvv)L=q zb=jjz+rz(74affCnXkiEH{IBj;Qy^wGz#fJLe05E=B*kX;h?5ESAOvDT8SCden>M* z(QQl217#k%6aQzK!2Y4M8A|0h$y4@E#rPH4W#+nSQC^UvyS4s^o-M3@RW(?4Smnia zm}pqpa(~@gx1om({?ohWyL4s|RcEMdq4a$=^yRL+Y>sbaI^#bw^IUJ^XWmgZ5p{5? zrJwF|pes2JC4m$g?d+B|yZ+u}W;aIe;V$x?dG8xP6ytB)yX=sVHq51G$V`e?3?L9O z(h!lGU&Y`XDtv6WG_W)Z<_OUH8Q|BTACcK1VioCCFHsItHKUF^EsBcjcu5{ab1O!P z{EiXsqQMGjdJZvtxB(7}(| zn4M-1oWge|c9uyt>Ez;&NBUy{J?H#EZcrL4KjjHYz3S!WK#55SAaeg~_I9hAXP~VQ znDj!keLFnTQf3PPBn7aWFpBn?eVm)Xk7irZ#6gNpR;jC`rD5dDGc8!zarygXL}Yc4 zcig4wFmfe49YcLgJPbZpSL`#(z~5zLV``b$b&)e5tGyt{=-<*!e!g$2+U|y{cH{qD zXduYjNtRqL(a+I{ZqV#L7G>GhE~HuurDrmn9|dzi({);u6$Ms1Bl&lXPXUp&kuPp9(0m4@qZz*s8SeRL8g2uNGB z#9W_etPLfjDS@0hSXji!`KKXzTd$HjYbn}{k`m!>yRQ{w@3=~1US>gL)T5hV#C%wp zK=s^nVDOUa4L#}=_f6JHj$T6TTvn87%fu^XG}{Dz!I0HCa*aMR@sfh*7SYcq<{i}x zcVdz9_6%%mYI2mHWPOs2Bg0YFulWr4n@$_nC+Vm$ew{5x<>&6Gf=0{H-&KDU{#(iq zwE^4~?LpM@Ydy?vxD5pBqRH%8Xv0({;ngD`X%nfRW{cTq^Ml-ol>7KU%rbH+Ne*j0nIg zc$JZ&&s^Ou3?w#oZDwj50>dUfs(G@mZSlWjWoqhJqHGcs46*nOZi-_GGT3(I*?@>VdlCf1Z8P3%QzK831Q4iSrR2f1bJpUd|54Kne z?U#6#&6Y}+$8St@^-5~^{Gv5Xvfx8sPmVCkJ|E+kD2Y$z#TeE2@GI{gIYC~f54duO zbSwm zz0DU!tH{s$ql2J(4W6@EKGbiQGo;4;U8?g3tHz}>X$+`QRk!MS9rgf@za|eNi*m?( zf!%Yc7hVJ#l_X%?Tc5hZBgBvQ$59rOa%7#y4d<`W>F!`$Z(lPtb7(MpDf>-84d?xs zY(<3?Qu5}nA!TQX7J`Q~o1^IA37pVTz^$BD`}LtBk0jJkjqZ_668XssISbx)8xVc} z?6i`~m}z{tKs97(D}^33)#&KERV3a|P^+)M*}$E8n?9z;v1`UDy=vY>r~_}1IzW08 z8>KdhM2~oiK#QR)5`0|yY-?6~zGn)Xcm2XPpnty8LBKprUnQksO04~pAg!67n5d^n zJnIp5u$49xfid9ZE?j^KeE+eIt(4IldOjU(4C)*LYR?9%TUyX`4j$}-4R4}m346N# zuMDk%E_@QD({Nq@ca$b4?YlC)PrEm_lR{A&*+24+FPx+iqez@OTfRWb>3^^^U*$6R z+3X{5M`o`@a3V2reWT#44~oP5o6^8%ImhO$i2%K3wthb)RGf46pHJ^>mvr>h63ZpX zbjqULJVOrky@VNK&>fzTSl*Z*HRt+(x=RiuonvNR%x=S+wQf0iHEXzwhk@Us&39gi zxPI(NzXZR=tIE6iCe7aCk;rv&H$XaAj)@!2NF7C!YY4?a6kiYZz8 zsw|HChaoWLRJY81Nhc@df5-RdhaI+DB&uXnNw|0} z&0oykLB^-@?m5=;b#Ov>!4=_|%%p6gl%k?81n8?wR6CriQf>F;S+vP6#yuQzRj653 zCHC{{8BP0dgB1OSs8NYYGY#t-Lx0HJia7-8uA4O=#hm{exLs90^U3f76nLM6B$i9C z-4AcuGg1R_esbc*QesTUF6T2~V;}w&OKF~hYv$3Xr}F0`KE*v{>oth)@XCIhYHO%j zi=*4wN#XSPTzok(su@$r65g4|Qy&<^0JY5}r364m?7bAWvbA*RH_N}B*|&4T^5+qp zE@DOl@HiQr@e)_0-9|H3z#0dtkpfsu_q4FH^~dj!!TtFsH6=sZGR9N$eYewVHSQB> zP$0SH-@_UB5XALRX|Wh5#!%QfhU4gU6Z$0pL36)v#<%taO9DR*0pv?|v1FmzKO4?q zerI8<;z}h5M`-gf1pDYy;|9~>JB0ahx9Xs>&!p{23s3-rv=!B;ZtXpS#6tovL4_^E ztKG|^?7pLjg47^f;d_lSE}lz2ES}UY<>A1&0}~#XTB?w(KJajb4}5P$-u9L~!b9hs zTK^!NFz72c8gI8B8R71!EhQ2uI0!ryUi8EK%tlfALc~QJ$s?b(?jG^Jx*x1!TkZT~ z5Ish77z_pqv{zVNeOFS!GBzU}m{$~8tb5{N2;2w?4VHvjnk4^{?PRZCu>|YV;yXj7&3=R*@Z*I9Tbv~m7J5757pvv;m!`;!Ragyhq#k5 zt{US(lKsd3*#g=p>t9rmjbIFQ-xrz_o2Z`h$T}Vbei_f?YpySSFlldJvFGykA8C{& zA<0Y;M8|GB{tHZxE=iU7d`+4h_iR~iN6c2*W@tS!DG3zJa2(ZFDz`S+*+usI61V$` zo^wNfllUi&u$d`YQLqaYIaXKD34~Hu+>KC*MAtsU`Xg}T_JRcRTem8`m^yMdt0!^Z(v6DG!Xs(76Mqo!=K-1gsHQk}{p!o2J>9#J)8{wT$by;m zY=94NVBG(vbZ5muv6MPw2#g$EFTEdM7qYI-en&SXuNIQMScryJy;DSPZrB!l0GCrI z1Mh2HCjvN6k3FTPNbQ0FhQ;;L^((?+MTdqnwBg%t#@Pz3%yS?cIQb-z{W#Z$x|)zU zeD^aH#Rm#FHTe0-gY?cm=1GAn0=y%mt{AneTD_TBp?#+SdQ%_O`)=2H!_551c$6ADM%XGJ70`^@b*{_gX@lq{(3>A_Z4q#xcds06~F~M zYRj7UktXkO&yaYu26VHlTxa7AmFkIkv-OhIlUoqNJCaRDQ;Y8w@~4y$r+1RIA)J#$B@j8Tm&s;qr6cnd4ywEU zqaGigDN%_1vnSfi==w^2y)6TSj{>v+lW{5B>|sD7Naw@6n#4Mepi*6^v4^dah{jj} zvIj!DPqd68mjZ*|kUfD?-F5fz^u6xYrT&9E&wC6Hf`=BeOQOmXRt^2{uJ)y+aCTbgFV+7lFQhuec_;FvZp|2c@9A zG3IHKR>yEqgtMu9eiqs;Bq#8#Z=d2M8CeruzdV8C)HQ3VqegW@Wphy^;WmN9E|9;R zGUM?XxCm~)KnjoxXW|Nv(x`W->G!u%7;xJ`NWGmw)6q&GAsksl=xnyr-Q`|68VfE# z#73S~`A91kv8l#~LfXjLSM&xjS;)O*x|Brwh#p;-fe;t~u8PXfIf3~``A2|)>I{uuV8dkRcl2QR=fFI%)Atn>+E0Xw*lpl)4OI5wG*EtgC^efExwbfL z#sRLsL>HNvX+a97k>XfPo*}LDS|?_6(EEYRH%{9Vv?9-vac9U}2k%%RGwm1>+LCC7 z3>EN|i9Ji4M1%j)*yIAZTll&*Rp?ks~dkdyGq~g%<+yV$$ ze;dcQX+}sAq%XzW~bY;~r{A?E9Mo15%UOg~?1^-lC>%24YEAEpN?PB&DYS=)KIOI;b4ALt&RgFx9vuX~gk~6eVHy|psPBJnJiX15s1BVnMwnYZ@( z*--;lAy!8uJOKxS9)Um=C=4zcDpR>UP}Qj;34FrnE0EBpn+>#w3}BiE>pFEorXXp2 zA=vI*mYTBS9EZ9cMDEo@4sR;{BpO+KCE00`1KV%Q#oW$K<=-AjpDi^OToxyg7>LU!YP4|B;`|jaYbr-o-h`y*6Jap8{Hpe!;{QD1^oy z)cXe!mMpo3$M7wmjxZbG)>-AiCS5?;+eRST_fb|PN1gHWQS-~#CQ~bg5Wr(+6io=k z3+?J?+tAD_zA(;3zXCNE5tCDcKC*xP$+m1L(o^UF0muO_hvM8l00%mU=Y^E19T#L{ zTK0-Ts~>=OH&Xkn5izYil9JvbMk)*(Hn2v|92(0nh{KBmE%2;GPw6}$P@n^>00O%Y wW^DT_k~y))a>}#-09>ItgC7AGIIVAh00003z!CB!0002%kZ=rY0D@ou08V?Fl>h($ 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
@@ -1138,6 +1138,17 @@

tavern-stool

View example
+
+ + 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… + +
+

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.

+

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 +
+
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 + + + + + + + + + + + + + + + + + + +
+

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 --
+ +
+
+

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

+
+ showcase/iron-cauldron/iron_cauldron.py + View on GitHub → +
+
"""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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + 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"} ]