From 7feb9ac70a8e8381c6af8b5fc18155831b8be521 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 22 Jul 2026 09:31:44 -0400 Subject: [PATCH] feat: add custom-normals-shade example (post-4.1 shading contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prop's silhouette lives or dies on which edges shade hard and which shade smooth, and since Blender 4.1 that is mesh data: face smooth flags plus a sharp_edge attribute. AI-generated code still emits the removed API (mesh.use_auto_smooth, shade_auto_smooth), so this example pins the truth on both supported versions: the legacy API is AttributeError on 4.5 LTS and 5.1; set_sharp_from_angle marks sharp exactly the edges an independent dihedral recompute predicts (188/388 across 3 meshes); evaluated loop normals weld across smooth edges and split by the dihedral across sharp ones (0.0 deviation); and per-loop custom normals survive depsgraph evaluation within their int16 storage quantization (1.407e-04 — asserting float-exactness is a real, caught bug). Version-gated divergence, probed on both binaries: the legacy shade_auto_smooth operator needs the bundled Smooth-by-Angle node-group asset — headless on 4.5 it returns {'CANCELLED'} with the mesh untouched (silent flat shading for scripts that ignore the return set), while 5.1 FINISHES with the NODES modifier. The data API is the portable path. Falsified: threshold drift (exit 5, 4 extra edges), smooth flags lost after marking (exit 6, 3.83e-01), float-exact custom normals (exit 7, 1.407e-04 vs 1e-6). Check output identical on Blender 4.5.11 LTS and 5.1.2 except the asserted operator divergence. Signed-off-by: fOuttaMyPaint --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 14 + README.md | 27 +- ROADMAP.md | 2 +- .../assets/custom-normals-shade-hero.webp | Bin 0 -> 24020 bytes .../custom-normals-shade-contact-sheet.webp | Bin 0 -> 43420 bytes docs/gallery/custom-normals-shade/index.html | 836 ++++++++++++++++++ docs/gallery/index.html | 11 + examples/custom-normals-shade/README.md | 84 ++ .../custom_normals_shade.py | 588 ++++++++++++ examples/custom-normals-shade/preview.webp | Bin 0 -> 21046 bytes examples/gallery.json | 12 + 12 files changed, 1571 insertions(+), 4 deletions(-) create mode 100644 docs/gallery/assets/custom-normals-shade-hero.webp create mode 100644 docs/gallery/contact-sheets/custom-normals-shade-contact-sheet.webp create mode 100644 docs/gallery/custom-normals-shade/index.html create mode 100644 examples/custom-normals-shade/README.md create mode 100644 examples/custom-normals-shade/custom_normals_shade.py create mode 100644 examples/custom-normals-shade/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 9fab39a..528c7b4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -65,6 +65,7 @@ "examples/collision-hull-proxy", "examples/compositor-glare", "examples/curve-bevel-arc", + "examples/custom-normals-shade", "examples/damped-track-aim", "examples/depsgraph-export", "examples/driver-wave", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index c3d41b5..a6cf6f7 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -460,3 +460,17 @@ jobs: # the 255-face per-piece engine budget. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/collision-hull-proxy/collision_hull_proxy.py -- + + - name: Shipped example - custom normals + shade by angle (post-4.1 shading contract) + run: | + set -euo pipefail + # Check only (no render): a jerry can prop; asserts the legacy + # shading API stays removed (use_auto_smooth/use_custom_normals/ + # calc_normals), set_sharp_from_angle sharp sets matching an + # independent dihedral recompute exactly, evaluated normal welds/ + # splits, custom split normals surviving depsgraph evaluation + # within int16 quantization, and the version-gated shade_auto_smooth + # operator divergence (CANCELLED headless on 4.5, FINISHED on 5.1). + # Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/custom-normals-shade/custom_normals_shade.py -- diff --git a/README.md b/README.md index 713bf3b..4364e8c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  31 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  32 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 31 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 32 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. 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. @@ -504,7 +504,7 @@ round-trips through the raw `POINT` buffer.

-Game asset pipeline — 6 examples +Game asset pipeline — 7 examples @@ -610,6 +610,27 @@ tests prove containment (5.9e-08), convexity, watertightness, outward winding, and Euler characteristic 2 per piece. Proud details cost cage rows; concave grooves are free. + + + + +
+Custom normals and shade by angle: three olive-drab jerry can props with pressed X ribs and red spout rings on a dark studio floor - one faceted flat, one smeared by smooth-everything, one crisp with correct hard edges - proving the post-4.1 shading contract + + +### [custom-normals-shade](examples/custom-normals-shade/) + +The shading contract a prop's silhouette depends on: since Blender 4.1, +hard edges are mesh data (face smooth flags + `sharp_edge` attribute), and +`use_auto_smooth` / `use_custom_normals` / `calc_normals` are AttributeError +on **both** 4.5 LTS and 5.1. `set_sharp_from_angle` marks sharp exactly the +edges an independent dihedral recompute predicts; evaluated loop normals +weld across smooth edges and split by the dihedral across sharp ones; +custom split normals survive depsgraph evaluation within their int16 +quantization (1.407e-04, not float-exact). Documents the legacy +`shade_auto_smooth` operator trap: CANCELLED headless on 4.5, FINISHED +with the Smooth-by-Angle modifier on 5.1. +
diff --git a/ROADMAP.md b/ROADMAP.md index 2b3e782..666dca0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,7 +113,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list` - Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`) - ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 5.9e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 374 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2 -- Custom-normals / shade-by-angle witness (game prop shading) — `use_auto_smooth` removed in 4.1; assert what 4.5 vs 5.1 actually expose (`shade_smooth_by_angle` operator / Smooth-by-Angle node group), per-loop custom normals surviving depsgraph evaluation, unit-length basis, hard edges landing exactly where an independently recomputed dihedral angle crosses the threshold +- ~~Custom-normals / shade-by-angle witness~~ **SHIPPED** as `examples/custom-normals-shade/` — the post-4.1 shading contract: `use_auto_smooth`/`use_custom_normals`/`calc_normals` are AttributeError on BOTH 4.5.11 and 5.1.2; `set_sharp_from_angle` sharp sets match an independent dihedral recompute exactly (188/388 edges, 3 meshes); evaluated normal welds/splits exact (0.0 dev); custom split normals survive depsgraph evaluation within int16 quantization (1.407e-04, not float-exact); **divergence**: the legacy `shade_auto_smooth` operator CANCELS headless on 4.5 ("Asset loading is unfinished", mesh untouched, no exception) while 5.1 FINISHES with the Smooth-by-Angle NODES modifier — the data API is the portable path - prop-origin-transform witness — origin to base center, `transform_apply` through the data API, delta transforms, `matrix_parent_inverse` so parented children do not teleport; closed forms: post-apply scale exactly (1,1,1), local bbox min Z == 0, world bbox unchanged (builds on `parent-inverse-orrery`, does not duplicate it) - mesh-hygiene-audit witness — the engine-ingest checklist as executable contract: no ngons, no loose vertices, no non-manifold edges, no zero-area faces, consistent outward winding; closed forms via Euler characteristic and exact edge-face incidence counts diff --git a/docs/gallery/assets/custom-normals-shade-hero.webp b/docs/gallery/assets/custom-normals-shade-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..93500a8201fd183d6efa6aa2e572bd712d90f91c GIT binary patch literal 24020 zcmV(jK=!{t=AMM6+kP&gpKT>trK2V?Ss>5~ z31@EmtUBYY2R(Ufo;r@OxY|aSzE7T3NC&p&42EntQV%g>b{}>(Lcxa(0Utr&VTFntp4|tEjiaX;#cOU#U3;5 zfA4yAd&&KSjJ8fLFTg()^*B!-n)G}-eSWmzVBu}7i&;=eEjUgn(P;*2AY3ODXtaXJ zwUq>d(}d!U7R8A^)2v!>oJQz5>BUdI?&(=p=;ymr#!uH$zx5xZhRy&k5=~UK;$M2v z$~0O*WLWxgOL<+eQo2E?dfIJ7w~gN_WJ*SSFleBIXRNrs)*BEn9XskIGahG&S+cz2@ z-o3AXj5nN@eOq90+HPEB!KG4L#Yv|-0c{oAbX!y1&=bc=T8x7e97gCl>jjfTn~On0 zojVOiqZjapg~~0K|Kc9(3Z0}#13key5Kce(I~E@Zne`K9mJX??>19YqVs2*)GkYcF zgLEJGm~&R4iZpt$vBfS);D zQI%Y|A{f?f6=Rg`JRTjWgzzI$KnRU<$xVX-zy(;~gOYg*<+z#6m|ij^AYKulo*r}; z@Uak;*uMOBj>=u5&#fL@W4J>pI#347aX={TymW&70^FqQ#l|i*V_h}BcvX2%_);$LWL)tH=~>UBDZ6^Us?HOf1N^V{vK%EGsViH}%K6X_(4h}_YObX- z)f{UoEfd*0)sTCt0oc{e!`m;6j<{UGvKE>k2UZMxuRkAnWmnwLg5q{O z3LRBBo4AweL%)8Q`7*`e2r9XAZ6W1XPN$4Guk<}@FI0y2VOHdLvA@5SIUI{ijXybK zB{l$0Tu~L03Tf^Y5jTI^ezl;tLqP^VaNo=}9k*-dL;qO_%5awwuw=&6DWu-{L`bw_ z^ULc+impXxl6@{u@Jf@Z3NZetjS|{M^5VIgyF@kMf!t6pON6c(2+<6DghkZvU^g{D zmGrz|FmZpVL1vs_elHR5gzWOm%|e#)qP>NjMd#9icmGM?%#WWbRx(IuMZ4Q&Yy`e7 zrT~vOr73fRst?6I;1WEvq=kS(HZCXWUrU^&lEhICinJoHI;d~Xm^eH|uwq_u zyX!?(04jupAq*`rHta`{DmdU1b!1-3jwA5Q_=v*U{k6`MeBaZof;tHX2!ur>YR6Sg zVx4E7&=8^4X5<1myIlTgV#2{jv8Jc< z(GcDe{W#@9jKL>)=4Yi#_v=c3+FBQDoKp^1R~z_ig9D~Gv*zc@eDP4Ue&{yIrE-<- zfYx$J{+L^Yniabm!-U&rTbB{`!BVI=+lu92FFtBLRRaseZAP*G?GVc{&>(#I5s@fY zS<^R!<__8~%w_T;z1|J6rqtD)mW-4s0#`%i_l z;eqC2Ri%Rf3^E^&_x2vPLq1a*b7K4o1RV{BV2y3@{g!8oLt7Y0nYC)9sFH>TJeQT4_}mV$_OHzA)KL5&#GJx?HkK`sLRX=3o>ys=_In0( zK&~wiYw9k9mkhYlL;2*8SZ|qU&gmJ$;ZE**R_|a7pQYYVX)DIB78GEff!iks^GnwfP4KY9k`k&*`$;}gnr-7o zQKto>WNrDa7GvLM55<5TRXVyGb6?&MbQY22Tq(Ua;tN)`LT%9JYWSPgH_WcaAq>Nd zo@|==_H+^OVA9kU&o7QvPDjdx>h&S+ms@tQ=8C(7o@ui8SA_h^GTK3pII!+rc^hnw+!{!xD_2>O zE9(7To)hO<^6UzLs4gL}7kV|PG1p2Zz)>wRO_#X&vA>l}Tkgo|VX#S35_JP~ zVknD9)(P<1L?g@Tf|xDL=B8gVbgL1>eM4+;od!+Z%X~KL6AWa%u?IPkUV54Fc%?$F z>l1QBt2q{2C<-y5R1Kv3saCpR@GO?|B_a{z1Y9U9b4glb5Ac|x`a=Snq`QlH+Y7US zC7z;W%6%C|tYT}sGl)*8=?1vzSH3RlahYpDrSl^7F}Nejk$CbcTDS1gcp)k@o{e}> zg#;qP(Lus)h}J|Pa_J2^d!!* z0ciAwcNQSqjAY+s@g*)@|7ae`I7#QorNi{FOj@OU8CFkwNAb@6q03A54`ybzmFEf% zRg>&|*@222NQ$jMJo{_i#mL&OX(?bN*AJ#tK8H5aK^- z88EJAAHQmNKv|+9^_?z~RusC19l=~veKC5+qDuBndGJ&N(}@F|-_9;ERaQV~V2UkF z=JvYfEuJtelr;$Xo}VtBTnd0zHw(xi{yDSjxh2Abc|7_+0y$o}OXP>O_`}a+W2m zl9h{+%lytozxxJyHp%y#@0{IR=jF4A!4)mQhng=Bev z=9mtIg0?1_0bj9SPJ6OLX=I_Pl3<4>^2KXS`Q)oH{%ft=E=60O_JX$k4T`R}pi;$d z&}9GJV429qMWPlV>&O-h-5QgdV|3)xW{gO0@Kad-528HYPHpa+EHN{=ktTH&^kg zklw2L?cAoDVJIrz$A}F~ET`19h)5-2?d3Du+!ZNz3btwq4p($2>E#(h)>#4sQZ*s? zOfw)oVZ(%SW#tl(XTIU_ZbV`n4t_}p1aK@qOR-U=TJsu+XL8UUFiPtgJU8xo&9_~h zL?5$#XjrqrhDD0jkq#QHKN2~9^cQ{0T@3l`Z+;BC|C?FxSh&?;v}4aT6o@&i1*`GT zH^BNoFYxO(Km4rNMI1;BMn)(!{q(vGY#)4Ak}wL|Tv(3#B1%{e7d1B4+(U3EnK~fR zD4f)!*7y(SD@9XB!pMX>0t{i_Q$qy1cUi*f{5Y9VE0iR`+cuBOJq8R&C{T{{3khxF zGH{^ZJ#GrYi7B3Y>9~JitcbfR8+#-C#+kH@LtUToQ#OM(6&;er5d3Hy9=-u$Hoo}h zQ(aHgphD6L*Rm{Egu_*KEAVD$bPb+xJR38^dWN%RBuP=zK5=3gH~WT z#uSd0L<}`i-Hj(4-$59?YDDc#fLwM#Z!wIigpn64Q1gkNqYUU@I55?CIdEl|%)g^3 zAKw!yCZ{UrMrB-1Emz5ViP^o?Xys5<5Cg{fjsWq_R9s%z&d$ozjU*gI{JqX?oJdP_ z3u-r4WhFB;syB3~7*#H%Ew9Quurn2lQ{pbBD9-6fE5!PhW5$@UkJfOHu>i3d48$p`c)J!5Q5TXbEuOEx-W)$~+? z#kWd4Zwptpy`ZOk0d_=SED`P7$4&z@fxYUuE5nO@=33W){IqCLLEWQbl+qu2@(Kti z3e8!MXYJ(AAnpL$CYc=QBP=diE7s{Oyv2!NjVHBm3Tjhba-7s zh8=OJ2iyY>(qknb5BAh2gP(>aE-E%!A@)wTz(U57m;vyJ4bX->e8+XN7ea?y#?#yd z;A0$6gle6eII5#V5J4jgS zyeat>;DZ7Cgr7)j%!B*{jLtS{T`yV7ZJ2MFNGj(D8;Tm&7cKyznKXbQ=cfArmcTZzPe{jRe92M51@=Lh5FX;sbe_$Tn?abG|OI1`pcL9 zbvONUFR(U~)Y|41=evvcJW{DmdYOgqKet}Ifonl8+i;i8i)5B9)%BO-n7#+jTzX${ z5c=nWIhyI2zM0sJ;E2HKPJovMBHA?SA=q5YDMlQ+n{$m^+^GK3i71@aN-Y0>G34R= ze$M0QOR&(cXzk1QyhzuEx#--ta@c)m8Qo1m>l_3aw}T~|B3!Wl6)PIhg z@SmhuxfV9LSfZ(mRShXJY@n$h(1_U!zg0r~*s~Vu)jY%KHi2KzL*7==@Hr{?Nj>p} z@UnMGfRP+791;X_-aA0K_U3G@K5OiB6B3Q9soo#@oGp9HLaxi`z7L)hXMOn&^RL6= zHiusJPa~Z>VXHQj?mvuND*2V`3|*VHVMe~n5>bRbJ;dpL$*~r+HdovttT}6Tw8Sk@ z61sado}NcR$W3Eit0IQZ9NI`jxHD?$zI~pwD3?dGlGTV#Wn=~(m=U*jmFK$u&u|o5 z5}DfXEw54;jp$R8o~}kI=FFEL5bs;?G0~tz`7o3f)Cgk##PpoXhmp_3EITir>ZE^v zY$;s!+v`AJJ8jXklIaCNH`O{~eHth(1JQF6*`bK%s1D{8*-s>J$|8r^nuLKI8(d_f zdFPz|gLB`R*;|eqz?MWU8o6_D_5C!9OMj+sRy3#eN<)CUd6`dNotiGxm979l<^-k9 zT(->2F2ne8DrHx<(^1^axG*x9pg6Kb{D0qrB{i8+hKQ7e3t|}H3>MfUR1NT`+mP}j6YUd|0+Z7%3Nj*g7+_A~OY zS6AH|PA-^CAzSt^K201U>8 zNJJi|bd)#8JQ1Z#vE|Ae4`ot7?J{-Md1u<=>2s^w97LF(`Pd6^wFzc!=c#b*i;g_K zR|zE_9PZ5YSe2J$A>e&2gFJ7}L%XP+FQu#@ddSsM#F51g;T5)y{3$#&LatM({qFcV zZyH9Vc+}Q<6soB=K-r=_bUtx&EOCeHWL-?y^U@FXKm~(R{mh&#K%ie5kK}JHQDAE; z%0Gb%Mn;`B8;oyjJvlcSQU8c8Wk<%LOQ4KNn5}a#pgS7R-GHziHAmr zTh)|R+!E9k-r4o56rtSTC`2l8<0>c&|F) z*H*$3Z^~pJVyFl6z;6UED}=7LAP1(|+V8(jLfxAg=xPE+WVC}j9@uvGhfgt*Q=YA# zUDLi1+29A;b0M@|X?@>4p?P4e{A0JgoTe5KI%~Bhdk^xt0vgpFRA@QTaM|p0k=R>E zLzeiET&pdg{c3t$0_q|4UVjMy&po%PeIQNTi+P5wm2J4r49vNssjV89bB!}RwL>$@ z8y46b*_kT@y?^9Bh(lAq&iHQR;|!$TLw@XWT4!fyIG!E-R z*yJKNq&XVd=6u+Y5X^J$H*2iO9cvmYIeFjyG_L>u{z4cPU%|4MTUdT_HWY2CZ4kz9 zJ2+JJPL~X)V-2T`nM&K}7|urw>1fq`h&tzfBkNG~-^OlUPmn?<$`U(ELTvKfGZ<(v zOarL|fWk`hWH@Ws-bioaWL6in4nzIX?bHf*h%a$^O16v78f;m44j@#s<80JiW9R&} zezgS}B~Z5;vr31>cZe_oK}D!Z|MMr>+V8$SdKPA6YFzrIsmmq$b!A$iaUYb1?;I)q zx|phRQ_@>%r|zX~NclrpY2DcF;JpTnUKsFRACDOl$G6~|Y~#Q8Xczg%7#5wDz6c91 zj=R=$qgN&95qJU$u9;9n{dzPPaQ22(c7F^@#0zVroh}E*7;5GwzY=Jjy7vBQF*03! zZ9qLJ-2VV_X63>G1ziF+$P&II{#YgZ~o^T)HO#ANDhP+0^U}#*t0WTPfw4q|0Lz zrbKwl{xO`HULPA!f~cNyEmt@I0RHj&Qa9_0J8dZ9O+@?G6FjmDUak!BsDzSL~ZCG>D_xJE*Kd{K2PdjVBaQ%`%`g1ctCknM|u&vXiF7 zM7Olj8D*p`>=ZwBAQGgzJq_{+K3;gc4kq(hR7kejK!TWs>#_xBNERb9EtP)K6b)chHNFuJA)a+kg1h=i3AHV zOp8U0z(z~XQ~+@gUG5oE1gw*m2H$jA z7k8$YQnre8z7o2|JO=yj&V)^4dfhe=zv4?O&z+~nMM(NBGd@G*Na2|4WKWcmTcM!m z8F@6yhzPf%E{Nd{W|31Cg&Gl?x44$=qnW=JtI6pH08ROCzR0}%h&}XU5>)gwm)A;knIznYd>qs zroTjiw)C?;=%gXZY<_vl-Q?%jD}d_;fBEkO&E0~i&8)~p&2F?+*QuS&GCL<4b}}==L6<3LgWZz>12Si+v_Mo4?tk*bHu6aD~9fog)66xI9Lr@%8+1xapBcELJC;; z%@smrLK$}e4U!55xhF9VdOM`0A^ynahSon{)rW`RXVwE<|JSRv_imAuryXcup=}DA z?fG%3okYJc9oG#o*DOH5s#LlSW2|Y0&Y7Yg)HQ!~wBGY{U%$0*+(4J-!)BF_P0p*UsTnTpmUFZUz0lL_V6x z04X;+Ac=IT_#sEVxGG9&NL*JbRmTP4gKf(!Si_8)c*oFigE6DxUd9u~Ql9}A|MBzp zT_u4uOC(qwb@1X?m~-v3>`zo%#{%(XHfj=Ha47qb#iJY@C{dLz1w5i!%%D`2ZJE1C zo%&@bAowz}b$)B0pt1*g%zThflO^;NCiFF;0{3KxKSbZ<_5Gp8^B>!bAz#G3wUx^L52l*Zq_P&oxLSH4t@ONeAvqIsj zf*l#a=9VRt7M`){?r z{oVpc_Ouvd)7OGq!05vYi=E_($ud1M82NWPac*j;4WK?@L@TYk(pH`*p0GrM#UD&yBE*hci==?hsz8%Ctb~g4b%h-1DrDa&#vM6dez>of#BY%i)hrbGc9&RU8 zm{Zo%dCEPmbqU2k?Q|2t1~9wM9A1$n*K{vmTfua37W;ji11tK<3BLC@~tt*Y)DN&07~P`(d^XZo(bO- z92Dt96H(*+Tkb|?(ahZkGM@G)P+z1Rw>g!qTqc@ctQJ6i-01nx{nr<+4MoMon1DoI zdy_4?LDjji?Z;vmnSImPUv9bE=@G)t>q^Ha8eeIEp5Iuv+M<*wPQ-tjJjgl`4yI7j zd*PgxEY^y>M;2hWPbb)0=4=oX0M-0Q~&E#c$@5ugv~NQvz4 zL9vC+O5dLd0= z`HLAD1h5rlfy$f%-V4dQ}f8pAW^^rBS=fDtJMja{Y-iCi7ipO(<2>;&!lUeaAFrWC3R4!Tq_G}fK${ppiJXgZ8&tcmr z8I)T9Xe-v3rho$B;^%SM8SgFi3ZkYD{4^T|q}}A*QD9vt>T2Aogkm$zoNbC!(DvSe z=1xrdwsP42h7TeTV4q+Zial>tCGUkE-I`}ONrg%;g!@nm1M6&iqY5&GV`JH>7djBG zGXkJDcZQraX}j-?qA>9Z zf{=2t`gP-sa^#~5m?RDS08>YYt_;?I**1oQ?QkpnnTh)$fbHgcUEsx1g?k`JB&6Qm z%cFiMdNRMq=D_P;$bAlhUJ(zFOcp4;a%OqJ1IHQzk5A@}AXwaeg0}G*zveJCo6vnw z$FJ zt^)$B=E7B19uB-hR%$`!Wzo;zL0F!r7aYI9U5 z_*vc=(1WB1dx{LB-Yi$5)PP`Q5a+I))sr!}Sa?3&VJ7nW*0k02We# z(slJwOLyqc24gYWbrTq11Y3hlnVvywaxbC+bEiX>bId!>*vj->7tSbtt{bT;hoT%JH=5fNqy@NcEW zM_*Mq=N@n{5@UO#kDP-bNLv>WyS*vx(6L7|6D@trr+)w!+ll|wh=pZ!G!M_Xs7CYW zfa|fj^UsbCOU1zVf@n)MJqm={D~?AexSl5&J8Y-s`jBvH)H}A?&~gealB;{b{%~cS zMfEP>LYa2)@+|EO)vfJeLr`w=Xh*?6=BpA8+!=ifJy&f6)ekr_^jtkDEjuPdJ9o%Qp~Q<)3K@9u|ESx; zdM}@nnoBA1iR%{p>~E7ua7oSO|8p<)jV#bB8;U$`7R`g?D4ZY#%Sazk4f!cpOU3r> zds{AIuUa<02b5<`M&Qd}nA@3tE;_w{1oU_pltY!bv&1=yuh$w7uvA5*yg4n55}nZC zPCsLW=wmj1YH3vUtq!k-UwgU`4kw_;JAI(7_UvkTd$S;%y(zu&fCC><#LWu>m?T5O ztjp4jWG9sdK=t%P5$CxMbe>BT4^9q^ac?l9@PE<7&#uX=Xr3~1_`ICpng^M;-k;2{ zg)wz_#9@?F{}9A3vvOy5kG7$8J{7}`y|-7|Yg@*m+>c$D9UUO;;nb_lW4fRH%F~9|4uOAJIQpXbm1A`R3?v6L9+hTn?;r@4E$zavXen17H@aJr`IThal+c z^BI1gB`RVik3N6eM%qX;ex&s7qnVw)Gr+xQ))eE!`Jyn{<4=44nbxi29)kSmQ8Hnz zJ22a_bN-3Cs}yt=ue`RI8ECv9{4oR)cpoVj+KrxJgGvZ9S_%)cUJuY3dB_omHr z$~Sy1cGNWrh1nE%dj#N@r#nUpMAswTUN1Y7U4mItwV}vOhUr9TzwLtxb5?h- zBxUrzBxI^Hnr`efT@(af8LWCBsJNjW_cswru{p1h0#)Q$pM=-#J!!datA{f$|6k?i zxb&~i!4HTQ2o(DgDBN3nTs|D&#a_oKD-htn{Zfjq_h-k{ z?8a?u<~R2kW}HwO@lkV?5?`^|?T(lV6xSmT z%Aypo?={8M^or2?J18Y9a^q;;~zQ5f&Mkd{(Aj)E3FM=E&zs7}{EUo0MiNzsL6 zRY37ab7SR>{@rzhb=jI@oVRRleS@RcGpbe)&XUO+VI@`eMxnKQS6%*okpWfP(_ zsR8Cj&iU%Jx6dbG)R?Kkc**$;nzZ$7xztOKo7s8l?fwA%-A7)V?|o4_UlB7Cw3yk( zMio@qm#lZ)6ThZZ z04EHd7FL;*;j*1c3;-CvBpwRb*+FtFGOPl@n^Z03V|_%TC$U=dj(vB(0jkVNUcU>1 z55?7>PW+YTk>ae~Wmob~;rR`CiG2 z_->>1;_w)CU7Su3CQmYF9+Ss~qPKcrd^x{J3eT2Pd2_p-OHbwKZ%;&@)Ub}O7OrK; zdEfom)RM)Ta(Z6{FECW;OusqnxKA&$F?4!55LxnJ1>p0s1+V3QQOj}EKcXV=>p$Ny zRMqNpekH)Hs?t<}JtJ8JX_?HMQ=SuKzexIUf%s&~XyX#EzZR(22G(KseE!RPNqv0~K-%#D)E!<{U61Aq8rXAeS=qq4L=v}oDfevPqYGAkU zlkK`vee~)dA7UNFJ!kz4F(glo?iS}O+Du+8fSRq@4Dak4`9y5_pO2cqpTEu^CZFO5 z_f4{7#&h4W#rR|Nw7|`PhrXE+=3PZf|(sTOiT^wrMA zzT^8cH{KD2+PhTkfry&zgbXH+75I&gBHY$pQq=Cbc#5-;MHmLRe0H}0wxw+MT_4_@ zKSDeEZNA^H8)>!7cDS8gsCf_PIf!z4Ss7LN5J=VkHYh`RpQZTH7iXl~v?n=Spk2To zBmTpyZDuOPx+A+PNq_!O!_ynA2$?lJ-8u%5#3KvwC~Qi0{Knh&cCCg+v>4)3IWU~q zx|5NMtkx$V>fNa3Zqx*4T^_ndU5mO5v?gH5U^wo`3B~`1qJ{z-&hnO3MYls z7^y_!(|(?lI;mW`{;GThxpLJ#NmZ_@<226!r$^8TqYhRFg2%g*qj5eJy-CWJ^3wrF z@Y5u0>;SIJ5CG5I1H@pRIznE|K}q@2mdNpc!n9_M(^t?pO%b0ZcaZEOn8|IyQ&roc zPwc=cVnIqx0JysfCL~hmg0@;gIv0EmpIg?q!nL@oYeRY*Gj4{AdYcDeS}Ly;1>u9= zD!=B_KOCb@Ilad#-+Y||6Ymf$Fm6F@IiGCLlk@)_1g4Ei2U?V-3w8~z-np| zb2a;VWdTmZXdtjZ*=c(X*PTJy4NY;Z83A1Lxw5FZBP)vd<&8*h1S281;K{44{%u3L zL-gF*$_q9(8!A7lcfFrJq>KIM(#36YC^RziZ6k9D{_y+$|3V^X-WMB#_qDr>KGZH^ z?}RN!77X!{CXNrfkWT0+DSl?g|2f?NIbp$Fk=7V;%*; zL8$CeJaUEr?qhESq2Q|WVkCZnE1LgDeARw)FH8h56a+`yk;BuQLh=Iad%VeTs%&B= zK&b!{FhT?GsW-Sg3^di@&X|IEwz$Q>7_$;E?#zMVW%~vGReN{bUvzBE;R=Xre|hc| z+Q}1!Jq$}kXTJThoYFzKJ&}{_!m1@>slKcIeIG0M%xhe*!|K~Nz-Ti(NXk$Jj@)wD zI>X+EA7#oOK5R*_74|Q-8@@>W4SrIz|9ogoZd~f018#{0VN#K6d~k?aW_MiYL;`~- z;ZXwg$w;)-X#;&^V|TfC2j6x1@nUNC!1BmG%N5U?8S9rCvr>hTWrX4%Fb6tRn9L{iLnMi(?6I%_VWc1^s z`el?Icq61?nF0PR<1BNS9CQ{cUWm0KlfsvcAl*0*-9m+AdXdujF!7{jT}=$p&U%Rf zp8{6`!j)=nKUz)R#AOn>@PPYeM+G}w2X^$k!mUAphI25AX48S-bI6+bH1fOAO!E-U zta`bU5PXNXwPVN=mil3j zNg*6!bXI;mD$5+{+uMoTvy9I3;s7JQ8?IOc^6u^DS7l(xXz%<t$HwFbEy9K;X8;dxd(o_nv6} zi8YGUwcZ&)NtLfzQe?I z2Xa^K%$E2-S5%F9+c8+^&nDwJ=G1{QbM5cm=L;?%=&V~FG(xMS6jjc;Ujif^(Hjdj zCIx8d0@*|{4wB}JZmY^^0A6k}Pe$+~)7)BMb?R$uhV1c47(}Us8x_fiq`?qQf7n$4 z;0wPY1B%#!$ilE`>8k5Pr!W<`;aNGx-Ssn}F`6*TPBg=)?ZoUTRjFIW&h`eONn^B9 zpnN7~fhL>AdoRzS;~Ke*GjZ?RnF_ubvm_{SSE36p_>V^i|!siT&%|u&)Mdx#osYhALed z7^emOlUC7v^Z@Gzd2N~^4$-MJi$uYj_u5h8QNU<}_Wam6$&mw;zW;DP|2a3*xOV=` zneW6EkA(2)cE-(gQ8lF25X+w9Owgc%|av7cuI^nJT>6DWl+EaOp`zXoK)fa%NKFf9ofZ_ZLT5`LZYmpBYWgDQ|6S>)JJfB1f~ z%bxb$wELMFy|YIhYt;kcXhT2Al6!)m3V?`C*vMQnnGi9B!?`i`vLUz-I0cAE67<^| zjEJMiQ)nhd#1MMLPdwfAjsb*N=Y!d}R-_clZ|oQPMg!8i&JpbLF>GnV=TB34mWn&p z2SBuR%XSNcA!=Rnp=k4kbZDg602^n1*@sA+hFwAd34jga-m1Vrsr{|vnDlaeQxR&v zV$9rYb)I%oQaV?s;mkFZey)^jE>tGQo3g-owfsp`Vro(>`isDhdz7|vZm`pCt|Zfk zVDiZ2{=klDZ(j3rCuMsvd^x=$lxjleHaYD`ur?98^C8w-k@C26Ie6Rsio*OWJ`v-< ztl=n>6zz*U)y{kK|0Co5ac}v#z7mGR&ANKU0KL^_-TU`$7%}P257gzgz*a^0ce|1e zuN_=--#0dNi@H)olEBh(WGUh+GoZP7ksK**Xx5@b$(^!bVwd7l84*-m+s zv6^&By(tcuUpX1zMD)XA$zMXiWIYUiq}hY-@GS2IzE@Ug^2f|RS(OU?C9se0tyEm$ zsSfeB_`n}MAu08f>zYRP4Bl?jCLI>9HmV>Jh<481`!$ z^7Sjmyr7a)%8U8X92a53=`bEBzWO*PQr%}ezY{*5jcH)Bw_5o zs!>|v>Y7z;UM%8|WMN;ywBh5ON$BcattOX*jH~#K%{e1;cB^`*;uIiZqQbj^ zTw%(BwX$Bf12(@v#FBX%Y;d+ZP!XTytVov&-uy46{VhXJAos{nBDv;x_oy_#yai?Y zS{KaLJwO+SILc)FkpMTJToR)(%^t5>TI*#9!(kY-0?r~?1GXXd`Q-4>WsFp*lkOo! zvXDrh9y9^_$QxR{MrQYfkVsy3sn{^ABRB&G(V_o-CIp~SlwAtmKBqJCP~?-2tR+8{ zxkP`XKxjTF4YX{2UioZGfSvuk^!b1KmgXML$B!rGt20C%WOj< z%i-kt?p)TeTznBJ#%L%!UG+DUWWG?UqwcClPNI!=TsecNOA+uYIGVB6zX#(dJ4l666~ z1cJiqalDwG3ctj|N+&hXstC4#?+)9eTuv8_$7;No0ZZP^fARZ%H*BkTW?;Ex9Al(p z+`>=|uNqY{w&G(plTb|#azS9bMQ+fbnZcxd(edE^r)hk#m@)vX~YX21Q+UY z-KYaM3CKXklgmBl-uY`hS^cLRlp*}J+qU*eHHS0qvOJxtd@+Ihbg%#T`wJb~cI6*A zjnrHgyc2u71>L^Y3!&n4oTYXZgDX5rj=KGWs=hWLLF{m6+$ee5z5OZ>^W*5T(typq}Uq0+S2?Y83y)5-mfQ>0K zNIiOlEE2PXyurADpM85)IiXr?PoI~ef87x;*QR{@BK|%*HS>I^ut}&Eycb~G$#L|strEEL-@wt2Gml|;fAK5Tgawzr#>CY}v{LCf75 zL+aWFj@WON>X(|3jww{)2Y%~e-&nc^RwW3RZ<^+4RgZ0>)vZtOILJc5W>C7{T4~rC>0(&W$YOqHozPP2a15P$&!!l8JQF8*nk#8-@94NlJVUu@_~EDEcK6GPh@bM$ zlX40j0>?YLZZsq2g!H79B8DqHc7dM4uPNq-+1qW#Rt>3gZ9uNjgD-XDrr11mjAs_4 z13+qjcO-Q&!X9)YOggR*_aT_Blx{uA*SU4$HvS8tyah@mQ2hYfv0h2@Dx0o61X8Ot z4Hk-4mQQ#$#(`hRrk+B>$&SAC7P$zfS#>~6fD_nFDLd`6w8E{zKkkfyIBxWKz(%K$ zjsH`WkKYsL28l8LDj3lEP5`5vEZotA>L6IBHQ`p0v@1&rZM0j^fZC|K%im88`NNF7 zp8W)TU7}T}BxdZtLoz7rW@K04i)#M2s22nPCWa14sD88H%$Gt_&Yr4Qb840+21DzH zGhz9}1@Drf^%c-GKy;z;8?`M!<)OWA6*^@Zq1l+R;y#Y-jBF8c$ohIItvMD*VVl*Q zOv1I=i-|Thmw9V3=@F&L=H^?>0m~gU;)34SElsj-z-%l9K`V!PNw_DYx*|D}%Q1pnt>!EbI>yPtytN-5LD z4RXe0pPCnO*#R;*H9I;Bwm33}l$@Uo(siQ`vn@phoUe61c}xS_p}_UubnAc zfjv{|!`$Zpqg&u*r(Kr;&zap{zX6?wmIaF^jC=Olf3YMSPI{OCUT81a4ZsKcmH#?1 z=M@eL&fq6H$UuJByd-HI!q~p;@3aV1wjq=ClYauJWNfb25{LPl3 z5RL32nN9;b%kWBgztt25!wY!sR1B}#{pPMT)fu?wPNTUR*ecd2UdP2`B#b1(9pyU0 z0eg&OpWORuGkmXHJ=tgn+3;zn1JvEywIkAG(gAf+k&jYpFRxX8Vm zMZpLvr0yslMmO}cF4&_{d-tAdN!!I|v~VfNhVV}51@r@3QTFr^%{?_@!o&+i0Zsl2LXm zv);Gh82KW7gjNz2JflIUf8z8e+y4=kDB?A;{lM7yK#tyL+ z>s0J)3(FnlqFC=C7E*X9t-vOnT-%F@ZZu)2iC3|`a^TQ%` zFYo>2!HG*D6dp^{D^p`JRV6c=4|jq@71gV75!pWRZs! z(}MF;Jp^x(D#kcT+=4Q}IDZ`}`evy#FoOCKx2-!Fdek#eJ*t8`DXrN2*dH~wMtycS zUocr?8aOw+gGweQU=ZAm3)Rm_QJnq&55M_BF-Rdh6o@AwBD{|(Z7r$R+eOC*z{Ik@3nEShSPj!5*8dXa(%Xx zbtB!Wx+n!@C{mk3ZBw1t2;{>qrd?-!+u}QTv;GD4Ucdxl?;4+G z!Ws_$;I+tWmK~h4#7~L8kDp=7W?S2)uZKuUEai1Ij=))Z`ned4Rt7cad8U&7!zjgg z2GcKm#4Z>BhQ@)kp>%lU>dFjy)v>fF+t;D_#1r8|8ZAdqoPr60*Ehonv-HGB!zIm? zhDE9U%@6>1X!xBrF7;UyK<`igxWtX4r=#nVPX|bLA3`6{kAHF?6Y<{+DD!TPCjQfU zglYt}w=Lk9>(y86J14f}+y@$aLT~^Jt^AI@KR4#_CkJ(EFDPQ99K$IMD{M-8fp0uM zT?MEYva>1v6kYB? zyFx-dFbQ5t3JeZrTlnO=G`ezMPldPLVV$_nFEnn9Y|zsCc_m&>Rt?eXF)3BpKagb_ zFw{B~dK9V>URGAxi=eppEu6lM+Z+EoN|K1Ai7|XaDgrU0Ci9NXnC@ zk*#YMqR9*@?VWMCm%YEGR`AK$l@Lo%AQL)5QW-ZpuA1Lbx+Of39b5gI69F;WLzVi> zzi$W2J6ai|`f%fgMIfHln5!UlcuH)b_o&5q8(X)ZH}0q7>|S-6!Y`2m(1H|K`l_cGC&5Q!w3JJ}u@6%`tP>Vam!09xzF@^Rc$8#+K? zpq!GSHJw@Nw}s>*mVk^&nCjV&y21ft$^2QpI-S|=%=;d_QDfdrl^v5KmIh=`at)ns z1lUiVDTZR^j{D}wn@t3NrU1l7y>#xTnxm0CT+pwCSU?1Ifzd;;*N2E4lm(ims-a@b z4WSxEA7TzFea=hEZbT7yv+CT?Z_}GpNgRaKN@HH@+?!hEVfnS`B9m>8t^H@3G~EI! z467Pob+vIouM^#~u#99{u+4L|ZPz3CxK#z|natrRsQ5ciuq|tU-=`@mj+{fTgX|oN z4dYkY$LyT{E{&4N-j6ut3y0nwzreyoW>;|oEw!VSWp)5WlXSkNfxca3ru<4L;*T<< z4u9}eTS9td?1Zs&9JIz4s_ZU_2Te1@Es$-+>LDrmYiWfapuVY*i2@6g<5^#!M*0u` zymK+2>1)HiaVS$zkNk(QImLDMQj}cpBGUqm%;=KUq73VMzA?8Z25^^Ci|I#u4b&lT zqTwbugwSvz7nQ>4Psn2?KVSoR(koaIuxe~MU0(E?t#L&H#DHpYunFlO)UIhPhJkwe z1I3i~C7zoq6YYbHl{UO#Rn*H2F8fnt>^z9kn=Zaly7oyJyQs9vgP zbmY0w%2>sH&@TDdnYJJ(llF&Fm^3gr=v~Za`64JQ;*h>hk7hg+pYH!X^ zK9EF77F1V>=DwyDla8+!%0z~;pLF5ELvmw>RUPvzl1)ANu$Q<@U^S4n2$|&l%e`F` znAYXxuiUmi>#aq5hp*$&9lvDo7ZC+9855n7Al)*$$|QTUNLJ# zD9*bXWGBsJ01rQzXgs-D*yH~HyL1vWFK-R%M_?=k@dQPv+MN80%F>L`(G9Ssd#O_^ zYQwIj8$&n)f*}J-U~9Y|Yl}GQc{2c^VuZ>TY>5zp2=IAcw?~FCzfb-6%EwEt_g^^F z#`yA*XEh(Kxx!`H1 z99>t0RP7ZXT_|3c@koQ?u}!2B_$Oc7LO$!OUH7A>w#qtEB+6LO{XS z?+{L4SP>hWXQZo6VrGbpJT&Lnf^EFzxG<>!K+0N*$;F!?BYSnihCY-pTDwalo%E3p z?Eoj(_4+8aJ%E*s;z}XD4{+Cpr7yIbNsDxZjJ&R9l<__MWEW@a3Pu#ikIi=To8 zlX*A^M0>sC)0`vq-1WvDb{4)6hOy|~X$AT!3dTDZvfqZ9hL4&Xg`i54RdO%{dRIkE z^||;xaIrt!t3t}ldH}a<@8C`v@c?wXP~1;_W9*S9plvFOwBhCLv>cU0Kfi^iusT(& z9BZ~d+VNjD?mR7y57^8IrKC^H02Cq!7eq(nryPRMzH}Oju)Px?gXJjvsu6Z0Js$Nd z%}%o@L!_OhmxrR=olNo+A!xHT8PCwoN8}!K%}h_TGAp1$Hdr5gG?;2Ay>J5j%enzM z7y5*^Veyv`8cdD-p7%noAPV+(p{#U4dstz zA`sgzSUGx_f1xZTDtV2(GDd3GZB|{CQ#Y2PF_GjzrEo|l7kV-ijOFj&sF>BDp(1RM z4c~24{A}ZmP52T4n<3<++Jj$8Z$WR&g0^&vk0g%HA$7%vi8nv@xm=K#hRz5TI}jHb z6oev2C7wN&N07Q1-fSL}qF`l-pGYpzDiH&Gl{i{r|IL1z1YfiIp5k8wc`W`V-e_kM zJ^9}LLDQFk@&T9>;+hE3b<03yO{Rh0kl?iGHIdG8mRdhGk;0C7gC51h5XV3ui;%T8 zwrNx{85h>Z)@I~aKqe@vH7BgbUVAQ zi)pz5hYRM7nr`0}hHDMU;aK1~#XNNMN!yjO z5Mo+lZQ+VQr|q&~!HdLEe~)C)m63u)I2)w)>3PMJHqt%~yxnSA%VPM8ezxL>dJttn z6%fXGLV}>r0xt7>Aqhq0!wwBdk#U`)@1aJz&R#zbP1!4CoVSXf;fVs>Jeb;VG|tO- z!JP>w5F#?~oGmpenC8`g{`yFMx}fs;F`qi(s@vq2t~!sg#yX&2AZ}}8ej)H`m&(D& z{)gGM*I{$9VQDb|@w(c1w0wg-G!5z|f#p%U*j6Ll#qkC~JT7xL5!`BZHs7wg{mxjH z3@chIh((#dQ_fDHy{1{7AB`53FE&n@VrWeTz2Hc)iZ4LtoWezwI-d`uElX`#jcZGZ-*f~ z6*fbZtplz zWb{`G7Rsn#7gGsLG!4K?T8y!g0i6TFDzloJ4LePa-Q61Hzz zXILhKRpQLjq_ou;F7lG>Ve4z7X4$-Pl_vmcs!`BbDFg%=upF!4s4u zzVIZ?K&8a8=8jJWG%XweNL`kZw0ung0-kHo;W+XbRDe&o0#%i&*QL;ViV@>!TmEt# zbNB5Q9m8b!H?e6Lj6BVSIbL-~+RJ+pM|^N;f`II*<_9T=dxz?T-cVIf+cm3s$_OOI zvuqy&b2AQrK*KFK5lCi_kWmvBJ0eR1S zLR2a$i62qdKv<>n=sdwH$C#>FHQc8L{0N``43yA4Q3HR@03Jtc7LhVKxEBuw05b^< zt*PJs!nybLD;=V$sLC2bmGHmZdIXe&PxIrnzz$v8Kh~%+lwS}&8DcN(m^@Fw0%PKA z)j}AGlQ@LN20*i=N&2lv)c`am#x7f#_F>kI5zDmXDz2uH~C5R6p@{y83_hshh z+7O#d5Q0w4wbQXd=q?&m6qnw(Je!kkJ9ncmM#*1VsD@PhIAw#-N0L;XqW8HcD!#|R ze8nq5su8a08NWZn(PKN^w!F567JCJ;!}bozM~^{^vOiM={!QRFXIEgkmO;Qu5&z7Q zw?%(aV!4WEYhG8sOPfH?F^KJX*Pw6%G-_lIAOnD%;RIsj2Q4k-)i5vx1(OvIpVl8) zxoGt0DQWtUPw#sY%*do(oQh4{6O^JKEz(8=<`M8}ehTWY)3r_8)i$Gcbej%wf~O~( zxVpY^DGR09p^-7X^aPPx6Fi=^6hy1d2O_k^NC)U~>2Ao#gngtx@5y2Bb`L~JiMnUC zC3&obEY1B92_!DCWe_aFP@GfH++Q@8#yD)s zYj0v%)lF0JMC1I@Oo&&5=Y>I|f_BH$-cpSwT`aG{&6fs^=x}h=N)^q@Fa7GC zfRaQO*BUkPHs-Y@=w1Bd<|qwAeyAKk{0t+RC~nIrbjan&UyCClM3z>8)r%mMX9Kzf z49Ko+smDsSLLV<(E85r*g}O%~V9lt>X{{kp42kW@j{3PB;GYy?>4$ejVm?eG|d@>`RJE4YArw=Ee4x+Gem}g1VQ3AcBD8${BWPqH>;KY(W z+AD{nHb9c0tk*;?t@=BOo(2W|eOz=~l&>*ZhR)$+ie&ogf5G((0Au}k>6dIFx{otz z-m2;NaRjX+?%BkoU#3IKy>iANVVwT6Rd0sv{5Dq@`Skr@(-Bh|z+027M@Q4hc<55Z z@hyim%_>dA>s#?eKxf7U8&FKf(u#d)4t<-25ONYm^!Y2PS#Z0tEO^U7zcciN+~bo6A{I(0MmPCYQNzu+a)7D)gxO! zh1O1u!;zUAXqXx?QOO!pEfnl5NHP0loBd8{Q|+XLLd^*}0XS?l5RQgAl6=0uq9;54 zDGJ%Z0L;g|0pz^4JikMe&_DrE>x9c0&k@%}QD_Okav%)lhH)IxCJ5e7BDDIpIU!+H zmJ=dLEV^RLf@?lW`*fp`rZ7i^cukYV&xe>6Ni$aF@^>&CTgSvG(&odkd9ICZ59Q0MXSHX_BLTw_4 zic;L@gHHe8sY^;h*kQ#0lDGg3iRzohj&0DgbZ?;WH>tDn1V--4c}Gp+`l zo=)$>_|g6abCy*1w`WG?Ihi3DVbY)D5)i0QesqCXlE>~NlD(fDxy~EK2ZK1``QlBk zdxnoDhuI4vc{1`sv**?dkr<6R+UV@j1QqLr0B(05-mN3#oKg+W{lrdq&7}d8WxLc= z2#E*|?*8^;KmcqLmywY5dAS|}SVL~_V#zn+X-H1BvdO)99Pn|{X(WxkaE#GzK@cc- zjKkTH{wA(&tye$Ke;8u=1A9ap-vwo*0<16Q;jPIKhY>f>av;>Jpfb09FkK>v@FcDT zQQMW=MgpMUtYZGPo)J_`JZw~OxTblIXTI}^ni!(h0ahB+>CPZ>;#~m#SU^K_<)ex( zX1OiH@rbjm5@h0Kz5YOp+w|^qAM>B6ew)oUizWNbE^)#Nu!)$E0-o}oW5#elkur4; zs?sts(toPP_N2%^{z~`0>FGRxuBcuREg&(qn-$-(m%bNt7DX?(0?{yn!jXdk=pw}@ ztCKA^J^%)FP{IUbzEW>{UU_*gV&r(M5)l@E5E4{E1oXBH0 zp8>YA)19L-_IGY?qYM3kOity20({$xd34_{MAsrpBSTUGZWN1T$=`t*w7?mEJFlrT zGd`1yc}Ex+=jzXsGY!JG$kQ{Jo8nAbKp#h-1ild6O+W1b0ANT&vH*ca%WO4(W}vbF z3xhgRMM*t1XM5T^Ghhu)tjMkSk%F<^dyrfJ5j!;tk!J4^K#%|c09R(`bo2Ls3^e-y zL@D5C`q@`2iD}T!DNN`k@aAZ!Fk15HfB*mhJ4+R=a|Zwcseu!K05tFb04+YUM;nBz Lj!ebM8+ZT!D%5SB literal 0 HcmV?d00001 diff --git a/docs/gallery/contact-sheets/custom-normals-shade-contact-sheet.webp b/docs/gallery/contact-sheets/custom-normals-shade-contact-sheet.webp new file mode 100644 index 0000000000000000000000000000000000000000..de7f4698210235ced1f2cbe8a6030c375f3e0beb GIT binary patch literal 43420 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

|7FHjhN_A;&fb8S=ae>ZP7NKkB)kqq(bK@Ya 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 U00000dyc>gS=GeZftmmS01|U!tN;K2 literal 0 HcmV?d00001 diff --git a/docs/gallery/custom-normals-shade/index.html b/docs/gallery/custom-normals-shade/index.html new file mode 100644 index 0000000..bbab0b9 --- /dev/null +++ b/docs/gallery/custom-normals-shade/index.html @@ -0,0 +1,836 @@ + + + + + + custom-normals-shade — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

custom-normals-shade

+

A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. Face smooth flags plus a sharp_edge attribute, verified against an independently recomputed dihedral test, and per-loop custom normals surviving depsgraph evaluation within their int16 storage quantization (1.407e-04, not float-exact).

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses use_auto_smooth, use_custom_normals and calc_normals are AttributeError on BOTH 4.5 LTS and 5.1 - AI code still emits them. The legacy shade_auto_smooth operator CANCELS headless on 4.5 (asset load never finishes; mesh untouched; no exception) while 5.1 FINISHES with the Smooth by Angle NODES modifier. By-angle sharp sets match the independent dihedral recompute exactly (188 of 388 edges over 3 meshes).
+
+
blender --background --python examples/custom-normals-shade/custom_normals_shade.py --
+ +
+
+

A runnable example that builds a jerry can prop — rounded-slab shell, pressed X ribs, spout and cap, three-post handle — and verifies the shading contract a game prop's silhouette depends on: which edges read hard and which read smooth is mesh DATA, carried since Blender 4.1 by face smooth flags plus a sharp_edge attribute, following mesh-editing-and-bmesh.

+

Pipeline arc neighbor: collision in collision-hull-proxy (the pair partner — same prop-pipeline audience), tangent space in triangulate-tangents — normal maps are baked against exactly this shading, and engines harden or soften the same edges at ingest.

+

Scope: this witnesses the bpy-level contract a prop pipeline relies on. It is not an engine exporter and does not claim engine/FiveM compatibility — it proves the mesh-data properties such an asset's shading must have.

+

What it witnesses:

+
  • The legacy shading API is gone — on both supported versions. use_auto_smooth, use_custom_normals, and calc_normals are AttributeError on 4.5 LTS *and* 5.1. AI-generated Blender code still emits mesh.use_auto_smooth = True constantly; any script carrying that habit dies immediately, and this check keeps it dead.
  • Shade-by-angle is exact. mesh.set_sharp_from_angle(30°) (which also sets the face smooth flags — probed on both versions) marks sharp exactly the edges whose *independently recomputed* dihedral angle crosses the threshold: Shell 48 of 72 edges, rib 12 of 12, neck 128 of 304 — an exact set match, not a count approximation.
  • The evaluated shading matches the attribute's promise. Through depsgraph evaluation, loop normals across a smooth edge are welded (deviation 0.0, tol 1e-3) and loop normals across a sharp edge carry their face normals split by exactly the dihedral (angle error 0.0 rad, tol 5e-3), all unit length (err 5.6e-08).
  • Custom split normals survive depsgraph evaluation — with a quantization budget, not float precision: normals_split_custom_set stores per-loop normals in int16, so a 144-loop round-trip reads back within 1.407e-04 (tol 2e-4), unit length within 1.1e-07. Asserting float-exact custom normals is a real bug this check catches.
  • The divergence: the legacy shade_auto_smooth OPERATOR is a version-split trap. It builds the Smooth-by-Angle node-group modifier from a bundled asset. Headless on 4.5 LTS the asset load never finishes: the op returns {'CANCELLED'} — no exception — and the mesh stays untouched (measured: 0 smooth faces, 0 modifiers). Any script that ignores the return set ships flat shading and never knows. On 5.1 it FINISHES and adds the Smooth by Angle NODES modifier. The portable path is the data API above, version-gated here explicitly.
+

What each check catches on failure: author/audit threshold drift (probe: sharp marks applied at 20° but audited at 30°, exit 5, 4 extra edges); the two halves of the contract out of sync (probe: sharp_edge set but face smooth flags lost, exit 6, smooth-edge loops split by 3.83e-01); float-exactness assumed of custom normals (probe: tolerance 1e-6, exit 7, measured 1.407e-04); and any future version that resurrects the legacy API or changes the operator's headless behavior (exit 3/8).

+

Version witness: every value above is identical on Blender 4.5.11 LTS and 5.1.2 except the shade_auto_smooth operator behavior, which is asserted per version (CANCELLED + untouched on 4.5, FINISHED + NODES modifier on 5.1).

+

The render shows the same can shaded three ways — flat (faceted corners), smooth-everywhere (the smeared AI bug: ribs melt, highlights warp at the rim), and by-angle (the contract: smooth walls, crisp edges) — under a strip light whose reflection exposes every normal discontinuity.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python custom_normals_shade.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python custom_normals_shade.py -- --output cans.png
+blender --background --python custom_normals_shade.py -- --output cans.png --engine cycles
+

It exits non-zero on failure (legacy API resurrected, sharp-set/dihedral mismatch, broken normal welds, custom normals lost or dequantized in evaluation, or legacy-operator divergence drift). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/custom-normals-shade/custom_normals_shade.py + View on GitHub → +
+
"""A jerry can prop shaded three ways — a runnable example.
+
+Witnesses the shading contract a game prop's silhouette depends on (engines
+generally, FiveM/GTA-style prop workflows specifically): which edges read
+hard and which read smooth is mesh DATA, and since Blender 4.1 it is carried
+by face smooth flags plus a `sharp_edge` attribute — `use_auto_smooth` is
+gone. AI-generated Blender code still emits the pre-4.1 API
+(`mesh.use_auto_smooth = True`, `bpy.ops.object.shade_auto_smooth()`), so
+this example asserts what the supported versions actually expose:
+
+  legacy API    — use_auto_smooth, use_custom_normals, calc_normals are
+                  AttributeError on BOTH 4.5 LTS and 5.1
+  by-angle data — `mesh.set_sharp_from_angle(angle)` + face smooth flags:
+                  the sharp set lands EXACTLY where an independently
+                  recomputed dihedral angle crosses the threshold
+  normal welds  — through depsgraph evaluation, loops across a smooth edge
+                  share one normal (welded) and loops across a sharp edge
+                  carry their face normals (split by the dihedral)
+  custom normals— per-loop normals set with `normals_split_custom_set`
+                  survive depsgraph evaluation within the int16 storage
+                  quantization (~7.5e-05 measured, tol 2e-4), unit length
+  divergence    — the legacy `shade_auto_smooth` OPERATOR needs the bundled
+                  Smooth-by-Angle node-group asset: headless on 4.5 LTS it
+                  returns {'CANCELLED'} ("Asset loading is unfinished") and
+                  the mesh is UNTOUCHED — silent flat shading for any script
+                  that ignores the return set; on 5.1 it FINISHES and adds
+                  the NODES modifier. The portable path is the data API.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still (the same can shaded flat /
+smooth-everywhere / by-angle, so a broken path reads as faceting or smear):
+
+    blender --background --python custom_normals_shade.py --                 # check only
+    blender --background --python custom_normals_shade.py -- --output c.png  # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
+
+ANGLE_DEG = 30.0          # shade-by-angle threshold
+ANGLE = math.radians(ANGLE_DEG)
+TOL_NORMAL = 2e-4         # custom-normal readback: int16 storage quantizes to ~7.5e-05
+TOL_UNIT = 1e-6           # unit-length tolerance for evaluated normals (measured 4.5e-08)
+TOL_SMOOTH = 1e-3         # loop-normal equality across a welded (smooth) edge
+TOL_SHARP = 5e-3          # radians: split-normal angle vs dihedral across a sharp edge
+
+# ---------------------------------------------------------------------------
+# Prop construction. A 20-unit jerry can: rounded-slab shell, pressed X ribs
+# floating on both faces, spout neck + cap, three-post carry handle. All
+# dimensions invented for this prop; ribs are floater prisms, the game-prop
+# norm — every checked mesh is itself closed and manifold.
+# ---------------------------------------------------------------------------
+
+SHELL_W, SHELL_H, SHELL_D, SHELL_R = 1.4, 1.8, 0.62, 0.28
+
+
+def signed_volume(me):
+    """Divergence-theorem volume; positive when face winding points outward."""
+    vol = 0.0
+    for p in me.polygons:
+        vs = [me.vertices[i].co for i in p.vertices]
+        v0 = vs[0]
+        for i in range(1, len(vs) - 1):
+            vol += v0.dot(vs[i].cross(vs[i + 1])) / 6.0
+    return vol
+
+
+def finish_mesh(name, bm):
+    me = bpy.data.meshes.new(name)
+    try:
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    if signed_volume(me) < 0.0:  # pin outward winding by the closed form
+        for p in me.polygons:
+            p.flip()
+    return obj
+
+
+def rounded_rect_profile(w, h, r, segs):
+    """(x, z) loop of a rounded rectangle, z from 0 to h, CCW seen from -Y."""
+    pts = []
+    for cx, cz, a0 in ((w / 2 - r, r, -90), (w / 2 - r, h - r, 0),
+                       (-(w / 2 - r), h - r, 90), (-(w / 2 - r), r, 180)):
+        for i in range(segs):
+            a = math.radians(a0 + 90.0 * i / segs)
+            pts.append((cx + r * math.cos(a), cz + r * math.sin(a)))
+    return pts
+
+
+def build_shell():
+    """Loft the rounded-rect profile along Y; caps are the end ngons."""
+    prof = rounded_rect_profile(SHELL_W, SHELL_H, SHELL_R, 6)
+    n = len(prof)
+    y0, y1 = -SHELL_D / 2, SHELL_D / 2
+    bm = bmesh.new()
+    front = [bm.verts.new((x, y0, z)) for x, z in prof]
+    back = [bm.verts.new((x, y1, z)) for x, z in prof]
+    for i in range(n):
+        j = (i + 1) % n
+        bm.faces.new((front[i], back[i], back[j], front[j]))
+    bm.faces.new(front)
+    bm.faces.new(back)
+    return finish_mesh("Shell", bm)
+
+
+def build_rib(name, p0, p1, face_y, outward):
+    """Trapezoid-section pressed rib from p0 to p1 (x, z), raised off the
+    face plane by `outward` (-1 front, +1 back)."""
+    wb, wt, hgt = 0.09, 0.06, 0.045
+    d = Vector((p1[0] - p0[0], 0.0, p1[1] - p0[1])).normalized()
+    side = Vector((-d.z, 0.0, d.x))
+    up = Vector((0.0, outward, 0.0))
+    bm = bmesh.new()
+    ends = []
+    for px, pz in (p0, p1):
+        c = Vector((px, face_y, pz))
+        ends.append([bm.verts.new(c - side * wb), bm.verts.new(c + side * wb),
+                     bm.verts.new(c + side * wt + up * hgt),
+                     bm.verts.new(c - side * wt + up * hgt)])
+    a, b = ends
+    bm.faces.new((a[0], a[1], a[2], a[3]))
+    bm.faces.new((b[1], b[0], b[3], b[2]))
+    bm.faces.new((a[1], b[1], b[2], a[2]))
+    bm.faces.new((a[3], b[3], b[0], a[0]))
+    bm.faces.new((a[0], b[0], b[1], a[1]))
+    bm.faces.new((a[2], b[2], b[3], a[3]))
+    return finish_mesh(name, bm)
+
+
+def lathe(name, profile, segments):
+    """Spin an (r, z) profile around Z; r == 0 ends become poles."""
+    bm = bmesh.new()
+    bot = top = None
+    if profile[0][0] == 0.0:
+        bot = bm.verts.new((0.0, 0.0, profile[0][1]))
+        profile = profile[1:]
+    if profile[-1][0] == 0.0:
+        top = bm.verts.new((0.0, 0.0, profile[-1][1]))
+        profile = profile[:-1]
+    rings = []
+    for i in range(segments):
+        a = 2.0 * math.pi * i / segments
+        rings.append([bm.verts.new((r * math.cos(a), r * math.sin(a), z))
+                      for r, z in profile])
+    for i in range(segments):
+        j = (i + 1) % segments
+        for k in range(len(profile) - 1):
+            bm.faces.new((rings[i][k], rings[j][k], rings[j][k + 1], rings[i][k + 1]))
+        if bot is not None:
+            bm.faces.new((rings[j][0], rings[i][0], bot))
+        if top is not None:
+            bm.faces.new((rings[i][-1], rings[j][-1], top))
+    return finish_mesh(name, bm)
+
+
+def build_jerry_can():
+    """All parts at world placement; returns {"shell", "rib", "neck", "parts"}."""
+    shell = build_shell()
+    parts = [shell]
+
+    ribs = []
+    inset = [(0.46, 0.22), (0.46, 1.58)]
+    specs = [((inset[0], ( -inset[0][0], inset[1][1])), "RibDiagA"),
+             (((-inset[0][0], inset[0][1]), inset[1]), "RibDiagB"),
+             (((0.0, 0.18), (0.0, 1.62)), "RibVert")]
+    for fy, out, tag in ((-SHELL_D / 2, -1.0, "F"), (SHELL_D / 2, 1.0, "B")):
+        for (p0, p1), rn in specs:
+            ribs.append(build_rib(f"{rn}{tag}", p0, p1, fy, out))
+    parts.extend(ribs)
+
+    neck = lathe("Neck", [(0.0, 0.0), (0.21, 0.0), (0.21, 0.05), (0.175, 0.07),
+                          (0.165, 0.10), (0.165, 0.16), (0.185, 0.17),
+                          (0.185, 0.20), (0.165, 0.21), (0.165, 0.24), (0.0, 0.24)], 16)
+    neck.location = (0.42, -0.12, 1.75)
+    neck.rotation_euler = (math.radians(-8.0), 0.0, 0.0)
+    cap = lathe("Cap", [(0.0, 0.0), (0.205, 0.0), (0.205, 0.04), (0.19, 0.05),
+                        (0.19, 0.11), (0.14, 0.14), (0.0, 0.145)], 16)
+    cap.location = (0.42, -0.0866, 1.9877)
+    cap.rotation_euler = (math.radians(-8.0), 0.0, 0.0)
+    parts.extend((neck, cap))
+
+    for i, x in enumerate((-0.28, 0.0, 0.28)):
+        post = lathe(f"Post{i}", [(0.0, 0.0), (0.055, 0.0), (0.055, 0.17), (0.0, 0.17)], 16)
+        post.location = (x, 0.08, 1.79)
+        parts.append(post)
+    grip = lathe("Grip", [(0.0, 0.0), (0.06, 0.0), (0.06, 0.68), (0.0, 0.68)], 16)
+    grip.rotation_euler = (0.0, math.radians(90.0), 0.0)
+    grip.location = (-0.34, 0.08, 1.96)
+    parts.append(grip)
+
+    return {"shell": shell, "rib": ribs[0], "neck": neck, "parts": parts}
+
+
+# ---------------------------------------------------------------------------
+# The contract checks.
+# ---------------------------------------------------------------------------
+
+def manifold_dihedrals(me):
+    """Independent recompute: {vertex-pair key: (degrees, v1, v2)} for edges
+    with exactly two link faces, plus the count of non-manifold edges."""
+    bm = bmesh.new()
+    out = {}
+    nonmanifold = 0
+    try:
+        bm.from_mesh(me)
+        for e in bm.edges:
+            if len(e.link_faces) != 2:
+                nonmanifold += 1
+                continue
+            deg = math.degrees(e.link_faces[0].normal.angle(e.link_faces[1].normal))
+            v1, v2 = e.verts[0].index, e.verts[1].index
+            out[frozenset((v1, v2))] = (deg, v1, v2)
+    finally:
+        bm.free()
+    return out, nonmanifold
+
+
+def sharp_edge_keys(me):
+    attr = me.attributes.get("sharp_edge")
+    if attr is None:
+        return set()
+    return {frozenset((me.edges[i].vertices[0], me.edges[i].vertices[1]))
+            for i, d in enumerate(attr.data) if d.value}
+
+
+def check_api_surface(me):
+    """The legacy shading API is gone on every supported version."""
+    for gone in ("use_auto_smooth", "use_custom_normals", "calc_normals"):
+        if hasattr(me, gone):
+            print(f"ERROR: mesh still exposes {gone} on {bpy.app.version_string}"
+                  f"the pre-4.1 shading API must stay removed", file=sys.stderr)
+            return 3
+    for needed in ("normals_split_custom_set", "normals_split_custom_set_from_vertices",
+                   "set_sharp_from_angle", "corner_normals", "has_custom_normals"):
+        if not hasattr(me, needed):
+            print(f"ERROR: mesh lacks {needed} on {bpy.app.version_string}",
+                  file=sys.stderr)
+            return 3
+    print(f"api-surface: use_auto_smooth/use_custom_normals/calc_normals absent, "
+          f"modern path present ({bpy.app.version_string})")
+    return 0
+
+
+def check_by_angle(objs):
+    """set_sharp_from_angle must mark exactly the edges whose independently
+    recomputed dihedral crosses the threshold — on every checked mesh."""
+    total_sharp = total_manifold = 0
+    for obj in objs:
+        me = obj.data
+        for p in me.polygons:
+            p.use_smooth = True
+        me.set_sharp_from_angle(angle=ANGLE)
+        dih, nonmanifold = manifold_dihedrals(me)
+        if nonmanifold:
+            print(f"ERROR: {obj.name}: {nonmanifold} non-manifold edge(s) — the "
+                  f"dihedral test is undefined there", file=sys.stderr)
+            return 4
+        expect = {k for k, (deg, _, _) in dih.items() if deg > ANGLE_DEG}
+        got = sharp_edge_keys(me)
+        if got != expect:
+            only_got = len(got - expect)
+            only_exp = len(expect - got)
+            print(f"ERROR: {obj.name}: sharp set mismatch vs independent dihedral "
+                  f"test ({only_got} extra, {only_exp} missing of {len(expect)} "
+                  f"expected)", file=sys.stderr)
+            return 5
+        total_sharp += len(got)
+        total_manifold += len(dih)
+        print(f"by-angle {obj.name}: edges={len(dih)} sharp={len(got)} "
+              f"matches independent dihedral recompute (>{ANGLE_DEG:.0f}deg)")
+    print(f"by-angle: {len(objs)} meshes, {total_manifold} manifold edges, "
+          f"{total_sharp} sharp, exact set match")
+    return 0
+
+
+def check_normal_welds(obj):
+    """Through depsgraph evaluation: loops across a smooth edge share one
+    normal; loops across a sharp edge carry their face normals (split by the
+    dihedral). The rendered shading, verified — not the attribute's say-so."""
+    me = obj.data
+    dih, _ = manifold_dihedrals(me)
+    sharp = sharp_edge_keys(me)
+    dg = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(dg).to_mesh()
+    try:
+        # per manifold edge, per endpoint vertex, per polygon: the loop normal
+        loop_normal = [tuple(l.normal) for l in ev.loops]
+        poly_of_loop = [0] * len(ev.loops)
+        for p in ev.polygons:
+            for li in range(p.loop_start, p.loop_start + p.loop_total):
+                poly_of_loop[li] = p.index
+        by_edge = {}
+        for li, l in enumerate(ev.loops):
+            pi, vi = poly_of_loop[li], l.vertex_index
+            for vj in ev.polygons[pi].vertices:
+                if vj == vi:
+                    continue
+                k = frozenset((vi, vj))
+                if k in dih:
+                    by_edge.setdefault(k, {}).setdefault(vi, {})[pi] = loop_normal[li]
+        max_smooth = 0.0
+        max_sharp = 0.0
+        unit_worst = 0.0
+        for k, (deg, v1, v2) in dih.items():
+            for v in (v1, v2):
+                sides = by_edge.get(k, {}).get(v, {})
+                if len(sides) != 2:
+                    print(f"ERROR: edge {tuple(sorted(k))} endpoint v{v}: expected "
+                          f"loop normals on both sides, got {len(sides)}", file=sys.stderr)
+                    return 6
+                n1, n2 = (Vector(s) for s in sides.values())
+                unit_worst = max(unit_worst, abs(n1.length - 1.0), abs(n2.length - 1.0))
+                if k in sharp:
+                    err = abs(n1.angle(n2) - math.radians(deg))
+                    max_sharp = max(max_sharp, err)
+                else:
+                    max_smooth = max(max_smooth, (n1 - n2).length)
+        if unit_worst > TOL_UNIT:
+            print(f"ERROR: evaluated normal off unit length by {unit_worst:.3e} "
+                  f"(tol {TOL_UNIT})", file=sys.stderr)
+            return 6
+        if max_smooth > TOL_SMOOTH:
+            print(f"ERROR: smooth edge not welded: loop normals differ by "
+                  f"{max_smooth:.3e} (tol {TOL_SMOOTH})", file=sys.stderr)
+            return 6
+        if max_sharp > TOL_SHARP:
+            print(f"ERROR: sharp edge split {max_sharp:.6f} rad off its dihedral "
+                  f"(tol {TOL_SHARP})", file=sys.stderr)
+            return 6
+        print(f"normal-welds {obj.name}: smooth max deviation {max_smooth:.3e} "
+              f"(tol {TOL_SMOOTH}), sharp max angle err {max_sharp:.3e} rad "
+              f"(tol {TOL_SHARP}), unit err {unit_worst:.3e}")
+    finally:
+        obj.evaluated_get(dg).to_mesh_clear()
+    return 0
+
+
+def check_custom_normals_roundtrip(obj):
+    """Per-loop custom normals survive depsgraph evaluation. Tolerance is the
+    int16 storage quantization, measured 7.5e-05 on both versions."""
+    me = obj.data
+    n = len(me.loops)
+    custom = []
+    for i in range(n):
+        a = 2.0 * math.pi * i / n
+        custom.append(Vector((0.5 * math.cos(a), 0.5 * math.sin(a), 0.85)).normalized())
+    me.normals_split_custom_set(custom)
+    if not me.has_custom_normals:
+        print("ERROR: has_custom_normals False after normals_split_custom_set — "
+              "no use_custom_normals flag exists to flip anymore", file=sys.stderr)
+        return 7
+    bpy.context.view_layer.update()
+    dg = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(dg).to_mesh()
+    try:
+        err = max((Vector(tuple(l.normal)) - custom[i]).length
+                  for i, l in enumerate(ev.loops))
+        unit = max(abs(Vector(tuple(l.normal)).length - 1.0) for l in ev.loops)
+    finally:
+        obj.evaluated_get(dg).to_mesh_clear()
+    if err > TOL_NORMAL or unit > TOL_UNIT:
+        print(f"ERROR: custom normals lost in evaluation: max_err {err:.3e} "
+              f"(tol {TOL_NORMAL}), unit err {unit:.3e}", file=sys.stderr)
+        return 7
+    print(f"custom-normals: {n} loops survive depsgraph evaluation, "
+          f"max_err {err:.3e} (tol {TOL_NORMAL}), unit err {unit:.3e}")
+    return 0
+
+
+def check_legacy_operator():
+    """The shade_auto_smooth OPERATOR is a version-split trap: it builds the
+    Smooth-by-Angle node-group modifier from a bundled asset. Headless on
+    4.5 LTS the asset load never finishes and the op CANCELS — silently, no
+    exception — leaving flat shading. On 5.1 it FINISHES with the modifier."""
+    me = bpy.data.meshes.new("LegacyProbe")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=1.0)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new("LegacyProbe", me)
+    bpy.context.collection.objects.link(obj)
+    bpy.context.view_layer.objects.active = obj
+    obj.select_set(True)
+    try:
+        result = bpy.ops.object.shade_auto_smooth(angle=ANGLE)
+    except Exception as e:
+        print(f"ERROR: shade_auto_smooth raised {type(e).__name__}: {e}",
+              file=sys.stderr)
+        return 8
+    mods = [(m.name, m.type) for m in obj.modifiers]
+    smooth = sum(1 for p in me.polygons if p.use_smooth)
+    bpy.data.objects.remove(obj)
+    bpy.data.meshes.remove(me)
+    if bpy.app.version >= (5, 0, 0):
+        ok = result == {'FINISHED'} and any(t == 'NODES' for _, t in mods) and smooth > 0
+        detail = f"expect FINISHED + Smooth-by-Angle NODES modifier, got {result} mods={mods} smooth={smooth}"
+    else:
+        ok = result == {'CANCELLED'} and not mods and smooth == 0
+        detail = f"expect CANCELLED headless (asset load never finishes) + untouched mesh, got {result} mods={mods} smooth={smooth}"
+    if not ok:
+        print(f"ERROR: legacy-operator divergence drifted: {detail}", file=sys.stderr)
+        return 8
+    print(f"legacy-op ({bpy.app.version_string}): {detail} — as asserted")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# Render: the same can three ways — flat (faceting), smooth-everywhere (the
+# smeared AI bug), by-angle (the contract). Failure modes flank the truth.
+# ---------------------------------------------------------------------------
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def make_material(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, 1.0)
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def variant_meshes(parts, mode):
+    """Fresh mesh copies with one shading treatment applied."""
+    out = []
+    for o in parts:
+        me = o.data.copy()
+        me.name = f"{o.name}_{mode}"
+        for p in me.polygons:
+            p.use_smooth = mode != "flat"
+        if mode == "byangle":
+            me.set_sharp_from_angle(angle=ANGLE)
+        dup = bpy.data.objects.new(f"{o.name}_{mode}", me)
+        dup.location = o.location
+        dup.rotation_euler = o.rotation_euler
+        bpy.context.collection.objects.link(dup)
+        out.append(dup)
+    return out
+
+
+def render_still(can, path, engine):
+    scene = bpy.context.scene
+    # semi-gloss painted steel: rough enough to read as paint, glossy enough
+    # that a highlight exposes every normal discontinuity — matte would hide
+    # the very shading differences this render exists to show
+    steel = make_material("OliveDrab", (0.31, 0.35, 0.15), 0.8, 0.26)
+    accent = make_material("FuelMarker", (0.55, 0.06, 0.03), 0.3, 0.30)
+
+    # the checked base can IS the by-angle variant; the two failure modes are
+    # mesh copies with their own shading, no materials yet
+    variants = []
+    for mode, x in (("flat", -1.5), ("smooth", 0.0), ("byangle", 1.5)):
+        objs = variant_meshes(can["parts"], mode)
+        for o in objs:
+            o.location.x += x
+            o.rotation_euler.z += math.radians(-10.0)  # uniform yaw, fronts to camera
+            o.data.materials.append(steel)
+            if o.name.startswith("Cap"):
+                o.data.materials.append(accent)
+                for p in o.data.polygons:  # cap rim ring in the marker red
+                    if p.center.z < 0.045:
+                        p.material_index = 1
+        variants.append(objs)
+    # hide the checked originals: the byangle variant re-shows the same data
+    for o in can["parts"]:
+        o.hide_render = True
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = make_material("Studio", (0.03, 0.032, 0.037), 0.0, 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, 11.0, 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)
+
+    # key/fill/rim/wedge per docs/VISUAL-STYLE.md
+    light("Key", (-4.0, -5.0, 6.0), 520.0, 4.5, (1.0, 0.96, 0.9), (50, 0, -38))
+    light("Fill", (5.5, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 55))
+    light("Rim", (1.5, 4.5, 4.0), 340.0, 3.0, (0.6, 0.78, 1.0), (-58, 0, 170))
+    light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+    # the shading audit light: a tall strip whose reflection runs down each
+    # can face — it stair-steps on flat shading, warps at the rim on
+    # smooth-everything, and stays straight with crisp edges on by-angle
+    ld = bpy.data.lights.new("Strip", 'AREA')
+    ld.energy = 460.0
+    ld.shape = 'RECTANGLE'
+    ld.size = 1.0
+    ld.size_y = 8.0
+    ld.color = (0.9, 0.95, 1.0)
+    strip = bpy.data.objects.new("Strip", ld)
+    strip.location = (-3.5, -4.5, 3.2)
+    strip.rotation_euler = (math.radians(60), 0.0, math.radians(-30))
+    scene.collection.objects.link(strip)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.9, -7.2, 2.6)
+    scene.collection.objects.link(cam)
+    target = bpy.data.objects.new("Aim", None)
+    target.location = (0.0, 0.0, 1.05)
+    scene.collection.objects.link(target)
+    con = cam.constraints.new('TRACK_TO')
+    con.target = target
+    scene.camera = cam
+
+    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+    if engine == 'cycles':
+        scene.cycles.samples = 48
+    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 = 'PNG'
+    scene.render.filepath = path
+    # AgX would flatten the olive drab toward mud (docs/VISUAL-STYLE.md)
+    scene.view_settings.view_transform = 'Standard'
+    bpy.ops.render.render(write_still=True)
+    return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None, help="optional: render a still PNG here")
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
+                   help="render engine for --output (cycles for GPU-less hosts)")
+    args = p.parse_args(argv)
+
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    can = build_jerry_can()
+
+    for step in (lambda: check_api_surface(can["shell"].data),
+                 lambda: check_by_angle([can["shell"], can["rib"], can["neck"]]),
+                 lambda: check_normal_welds(can["shell"]),
+                 lambda: check_custom_normals_roundtrip(can["shell"]),
+                 check_legacy_operator):
+        code = step()
+        if code:
+            return code
+
+    if args.output:
+        if not render_still(can, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 9
+        print(f"rendered still {args.output}")
+
+    print("custom-normals-shade OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback; 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/docs/gallery/index.html b/docs/gallery/index.html index f93e03d..7725177 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -546,6 +546,17 @@

collision-hull-proxy

View example +
+ + custom-normals-shade — A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. + +
+

custom-normals-shade

+

A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. Face smooth flags plus a sharp_edge attribute, verified against an independently recomputed dihedral test, and per-loop custom normals surviving depsgraph evaluation within their int16 storage quantization (1.407e-04, not float-exact).

+

witnesses use_auto_smooth, use_custom_normals and calc_normals are AttributeError on BOTH 4.5 LTS and 5.1 - AI code still emits them. The legacy shade_auto_smooth operator CANCELS headless on 4.5 (asset load never finishes; mesh untouched; no exception) while 5.1 FINISHES with the Smooth by Angle NODES modifier. By-angle sharp sets match the independent dihedral recompute exactly (188 of 388 edges over 3 meshes).

+ View example +
+