From 7f01770017deae5298206d39310c9224a4505e9f Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 24 Jul 2026 18:42:36 -0400 Subject: [PATCH] feat: add socket-attach-points example (spawn mount contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spawn system bolting modules onto a prop at runtime rests on a chain of matrix identities that all fail quietly: a skipped matrix_parent_inverse, a basis built with the cross product the wrong way round, a transform frozen on the parent after the sockets were placed. Nothing in the gallery pinned that chain down — prop-origin-transform covers pivots and parent-inverse-orrery covers parent-inverse under animation, but neither asserts that a module dropped on a named socket with an identity local transform lands where the artist put it. The socket orientation check is deliberately non-circular: the mount pad mesh is swung onto its declared normal by a quaternion, while the socket basis is built by explicit Gram-Schmidt, and the check compares the socket's +Z against a Newell normal recomputed from raw vertex coordinates. Two independent derivations of the same declared value, so a basis-construction bug cannot hide behind the spec that produced it. Two hazards surfaced while authoring and are asserted rather than merely described, so they cannot change under us silently: applying a transform on an Empty root has no object data to bake into, so Blender pushes it down into every child (all 7 parent-inverses cleared, root scale handed to each child's local matrix) while preserving world matrices; and a child left selected during that apply gets the transform applied twice, drifting sockets 2.335 m. Signed-off-by: fOuttaMyPaint Signed-off-by: fOuttaMyPaint --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 16 + README.md | 27 +- ROADMAP.md | 1 + .../asset-sheets/socket-attach-points.webp | Bin 0 -> 31128 bytes .../assets/socket-attach-points-hero.webp | Bin 0 -> 19204 bytes .../socket-attach-points-contact-sheet.webp | Bin 0 -> 47026 bytes docs/gallery/index.html | 13 +- docs/gallery/socket-attach-points/index.html | 1427 +++++++++++++++++ examples/gallery.json | 14 + examples/socket-attach-points/README.md | 133 ++ examples/socket-attach-points/preview.webp | Bin 0 -> 17678 bytes .../socket_attach_points.py | 1099 +++++++++++++ 13 files changed, 2728 insertions(+), 3 deletions(-) create mode 100644 docs/gallery/asset-sheets/socket-attach-points.webp create mode 100644 docs/gallery/assets/socket-attach-points-hero.webp create mode 100644 docs/gallery/contact-sheets/socket-attach-points-contact-sheet.webp create mode 100644 docs/gallery/socket-attach-points/index.html create mode 100644 examples/socket-attach-points/README.md create mode 100644 examples/socket-attach-points/preview.webp create mode 100644 examples/socket-attach-points/socket_attach_points.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 056418a..14e55ac 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -91,6 +91,7 @@ "examples/shape-key-blend", "examples/sky-texture-sun-elevation", "examples/soccer-ball-goldberg", + "examples/socket-attach-points", "examples/swatch-grid", "examples/temp-override-join", "examples/text-version-stamp", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 1d64a19..e26c63a 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -593,3 +593,19 @@ jobs: # non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/lightmap-uv-channel/lightmap_uv_channel.py -- + + - name: Shipped example - socket attach points (spawn mount contract) + run: | + set -euo pipefail + # Check only (no render): a 21-part survey drone with 7 SKT_ socket + # empties and 7 seated modules; asserts every socket's evaluated + # world matrix equals the authored transform within 1e-6 with an + # orthonormal right-handed basis, socket +Z == the mount pad's + # Newell normal and origin == that face's centroid (two independent + # derivations), the documented up-axis fallback rule, modules seated + # with a strictly identity local transform, rigid invariance under a + # re-pose, and that freezing the root transform preserves world + # matrices while clearing all 7 parent-inverses and pushing the + # root scale down into the children. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/socket-attach-points/socket_attach_points.py -- diff --git a/README.md b/README.md index d0eec88..a30d389 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  42 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  43 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 38 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 43 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. @@ -820,6 +820,29 @@ by an independent SAT scan, and a measured min island distance of [`uv-layer-grid`](examples/uv-layer-grid/) (UV authoring) and [`triangulate-tangents`](examples/triangulate-tangents/) (tangents from UVs). + + + + +Socket attach points: a dark carbon survey quadcopter hovering on a studio stage, orange mount pads ringing each canted rotor, a gimbal camera pod under the belly and a sensor mast on the deck — every module seated exactly on its named socket + + + +### [socket-attach-points](examples/socket-attach-points/) + +Named `SKT_` empties as the spawn contract, on a reusable survey drone. All +**7** socket world matrices land within **1.788e-07** of the authored +transform with orthonormal right-handed bases, each socket's **+Z** equals its +mount pad's **Newell normal** (**1.794e-07**) by a derivation independent of +the basis construction, and modules seat at offset **exactly 0.0** with +identity local transforms. Freezing the root preserves world matrices but +clears **7/7** parent-inverses and pushes the root scale into the children — +both halves asserted. The no-parent-inverse probe jumps **0.690128 m**. +Companion to [`prop-origin-transform`](examples/prop-origin-transform/) +(pivot discipline) and +[`parent-inverse-orrery`](examples/parent-inverse-orrery/) (parent-inverse +under animation). + diff --git a/ROADMAP.md b/ROADMAP.md index c95a1fa..c4c9664 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,6 +111,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Degenerate-bevel weld hazard~~ **SHIPPED** as `examples/degenerate-bevel-weld/` — at `offset == min_dim/2` the bevel band collapses to exactly 12 zero-area faces (4 min-axis edges × 3 segments) with 16 coincident verts (4 × (segments+1)) and min_area down 3.2e5×; a stdlib GLB re-parse counts **32 degenerate triangles crossing the export boundary** (MEASURED regression constant) versus 0 for the safe mesh — the loops an engine merge-by-distance welds; byte-identical on 4.5.11 and 5.1.2 - ~~Modular kit snap witness~~ **SHIPPED** as `examples/modular-kit-snap/` — tiling corridor segment: 16 boundary verts on `x ∈ {0, TILE}` within 1e-6 m, end rings coincident under the tile offset (nearest-key match), linked-duplicate joint deviation 0.0, shell bbox == declared tile (dev 0.0), manifold except the 16 open-end edges, 20 detail parts contained inside the tile; unsnapped probe (3 mm skew, 2 mm nudge) fails at exits 3/4 with measured errors; sorted-zip ring matching mispairs displaced verts — match by nearest key (authoring hazard, fixed); byte-identical on 4.5.11 and 5.1.2 - ~~Lightmap UV channel witness~~ **SHIPPED** as `examples/lightmap-uv-channel/` — two-channel UV contract on a market cart: per-part `UVMap`/`UVLight` with `active`/`active_render` re-asserted after the ops (edit-mode UV ops clear both flags; `uv_layers.new()` never moves them), UV0 drift 0.0 (clobber trap measured 2.068), every UV1 loop in [0,1], 0 overlapping islands via independent binned strict-SAT (15 hits when falsified), min island distance 0.00401 vs nominal margin 0.002 (`MARGIN_DIV × 0.01` semantics measured live); held `MeshUVLoopLayer.data` after edit-mode UV ops segfaults 4.5.11 (5/5, EXCEPTION_ACCESS_VIOLATION), silent no-op pack without prior unwrap; byte-identical on 4.5.11 and 5.1.2 (3680 islands) +- ~~Socket attach-point witness~~ **SHIPPED** as `examples/socket-attach-points/` — named `SKT_` empties as the spawn contract on a survey drone: 7 socket world matrices within 1.788e-07 of the authored transform with orthonormal right-handed bases (Gram 3.576e-07, det err 2.980e-07), socket +Z == the mount pad's Newell normal (1.794e-07) by a quaternion-swing construction independent of the Gram-Schmidt basis, origin on the mount-face centroid (6.687e-08), documented up-axis fallback for the +Z/−Z mounts, module seating offset exactly 0.0 with strictly identity local matrices, rigid invariance under a re-pose (2.384e-07); `transform_apply` on an **Empty** root preserves world matrices (2.235e-07) but clears **7/7** `matrix_parent_inverse` and pushes the root scale down into every child (0.35 for an applied 1.35) — both halves asserted; a child left selected during the apply drifts sockets **2.335 m**; `bm.normal_update()` does not fix inward winding (`recalc_face_normals` does — the lofted fuselage rendered as a flat white panel); no-parent-inverse probe jumps 0.690128 m; byte-identical on 4.5.11 and 5.1.2 - UV atlas **utilization** witness — coverage/wasted-texel closed forms for a packed lightmap atlas (the non-overlap, unit-square, margin, and active/active_render contracts shipped in `lightmap-uv-channel`; utilization is the remaining unbuilt slice of the old "UV atlas pack" candidate) - ~~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`) diff --git a/docs/gallery/asset-sheets/socket-attach-points.webp b/docs/gallery/asset-sheets/socket-attach-points.webp new file mode 100644 index 0000000000000000000000000000000000000000..504e9459cf33a93dfd26dc7a7caba7517e67ab84 GIT binary patch literal 31128 zcmZtsW0WRM7X^rxZQHhOtIO!JZQHhur)+lFwq0GeZP)bs4c5AM=3kzbnGq*;oW0MA zovAD>A(7w-1f(G@qNuLOMHKSyIw1-)7nmjiat%a)pCDG8gszC1xB=>K0%WA6-R2n| z^e6ZJ6I__~-P0-PZ0Eyw?Rd@I{QA3Y)0Xzz^sMN@wm+dLZ`{x2SK3R__f1VMg)^jE za{Z6{H{=W9W?jO^+>8FMzuu>qfAR#w@B_t({oBvJKb3&K|Hx0-ck=e?IpO>E-cM!k z#m~^UijSt>U+=fC@h8eJ$xrid!MmT{A4b93AII+PAKxGE_owfdL);I@H~lZaF26~^ zF17N<@v!|V}?pMR_hVPH(hjsTK!1Ycai6&O_}5-VABDHF@|#1#*`N6zp964UK;e(aPv%*)fPED^%Y2yeH|W33x?NVB`MGB9#D1ylzAQ@?^tLmma04fA-=oKJe{%CtX&o z{5FW*kNNh0&&ketaDV;Z!T(+=8hC3C#`*VR*>2nYnx%!m(8D+LEoslcL74;bv{kuT zHTD}jR1ZzGKGpMW`}`I3)1@qkbkP=zW8bMbFk%QNOG1`BfQ@LLq82Z_%+`oU90_H)#p^=K_D1(g6QV1z$Y z_o*Xn33hS6X{wNT4F69@h0>ZO)s|}cZQz~mZ@w6U0f)u#&JD*J!jcK}TX@nc#pT!0 zzRwT)NhzC>`MpNd*HOpb{q=)gN#Dk(zJd`i?Y}HTgRMXP3bXhlV#etaTR2fZYZNn{ zM>Gg!*9!q4)fIVb%^G~ly16wacn!>;!iG)yWSJ{A#MPPGiua=5%5%5Pxd>W|fACfpTt?9p+F-c9_ zarR5*!8z9Xp2VdZ`;z=d^l#QKGBGP;TeYlaM%jkJ;2#a_;e)1L22ZvBHg47IT)MmU zXkng)k0@HW%Hoh&t&x%yqMijr@#7i#%(LM_LR|d##8X4uxdrYrlF-#uw?R3#z4{nG zH7}B{8zPKwH8Tf`vM(yzenK!kA;j_3=etJ1#xwU{@i0mt9lqN9rEyT8`{A{CyMG&N zGy{hISKg&-{BU+?z3v=8mikVogtuPE zo+>n?HTMH3){b@Nc=T849pA|Kj@&s;Wtk%-eZ=0+Dol32o~BCiu^#)j9`zHX=8}s; zW-16OjTnaEW>)4|H1$%yLLo?~Cyj@xPTs<1S)0|dpleR}EsLg;4@fGDL=mmah-B@u zN?}uVjm66s6zAXd%?Gww@XlFYLefZ(M2@jY*9D*l3Pb8vM~kXHn&wIQ!juh)A@=>M zgIzoz1Wm8Z&NfF6IsJC#!F!CA_bFRXWowd&ha#CGw*E)J+pV4Wq6EvplTeao#&JEd zKd2dcf_AHBGPvS+pU>Zu$@}NsaNeEQw#dIF>>ak-Z&$ye(f9imoLv$iXmxUGd+BZ0 zQr{(5f&M(5x%D~o=k;RUoJRsB=+fm9(@o;`wHnV0%*+)LKLI~(AoG8jF()w_^}{R< zkWq5T(smR#I+=&?Q+*HE3!5&0r^8=*+^R-c`0a^)=R(-U|1zD2UZW+T(29rX_{wfOOosWfj*1-zdj`&t zSxu{&YN|3sj}-0mX>J}h-Pa(Um$l8LHz)+XP0BcIcofDX*t0#o{r#8p>Urmu*qCyB z%uEN1fi=%ueg%nx?W45n`pOZ3dwzpG8B&h?NUY2drKd8YuMSBvdxQ=Saw7;4-?ZCiw>Xn&8lXkF7K>0QM*>01rQmz63vmiy~@;* z^0e0_N@VdeX%oYU-A&C$Aa*bEHQfRCNWSsh__B0)YN>^PE&0$tU8Z27ILrvJ!N8*J zJL;;tRR1kFm^9M%R}L}Y+SXnJ!BtEU5{~zYGA9h2LdGi*3tZ|8TOU2AE#57Xryqn} z@g&Vn{B+{hc31v!ml}9{eypf#{7P_l$NpiZmEI|P?U+c8(~RXq_YPaw6W_ncvTbTzUHnm*K7fTK5^V-`ar_zxr zaGtPpIVbdzWK03<>aJV+9Gi%Ih1^26s z3#YjibcXyKnxD|3>9w^Iihkj5@X|)@cT7K@A(fD7Q0n);xPH4M`uZ)kPUzDu#!1UQ z52D;hawKgZo~lS3FUFTz229X~z@E+J6a|}cK_<)lp1AbF8zcScmecZnAT(MDE`;u8 zV8&2XfD6r0n_Ga%=;`wptJJF^9|qy-JfGG(goozvZ%P(}Mjt9q2PJ9SpnYm)a!x;$ z0Nx!}FcDjht51|*^EPaQpv*xzvq+*qP?|iHKowvz*P1Hvm*S$oklM=3PbUe{aODsM z?l?u9pZJ1y8F6PHOG;}! zMRAyO4mA2bD-bbq`9W&fV+>AH5t~3c7`SkDOGru_H$h&t=&NtsoCw}{nBF1TnUXUM;T4S64 zYCZIj8iKG8x!@B5Mxq^zcC3Pp@!TEjXU=6vPk^Hs`#_3 z?x7`(HCRWYSsV;=?oeeF))-XWvMZ*3>Vb_ z&SBHmFw<77JVX6NysyhrYJoKS7z9TdfD&~pGfLIugHqkKDx%1}_@{qws0bmz<`q`V zAaz0>4I6p~CmtazHKLKNkt}+o$%m5@wJTD~h$md+R|6!3tIH>l@VRuaH0(r2+#epG zEg4@lp(x}X;-%W#x18=arJfeR=P{kPwKc9{@qEFs=qLj?J+u~ZUN>GlzNsS!w*@aC zu2JXHw1|<`zFwKA?x}LhP7uSgvxt@+hvtu%(MMG2a4`IYFB>Q~Q}^C1IPelydp9 z&%Y&4j+En_nUu4L=j2<;sspY}kERMc`1O=(@KCKwXJje#vax=Y#R@tk))CyVqMz{K z@k@^s>40rf)6;xxT>BsdQd%2q2^}*e%NuaL{swEN+DOWwHqIyzxXfjBjM#36%_X<9 z9`&^~GjghkxFJ#irK574Zjfa#FQ<^`*Rgf`>6ANbV3C$l%`M{Y=>N>)a^t-ZOhbS) zq&t7+38pxbBe9r{PJi`L6Z?_q+iQJIZl-3t0_;Yzy*f{cCwauXM$$Bxg%{^LkEFSB z0gMdc1M2|>xu9S6K(?Y(-yv}x394`qjew5c@Yb)pCQ&sErIfDt8=)Rr=Uz}o#KEK= zu>7nG$<|OSGib?DXlLf3i^BmZgNN|C60`O@^Is!dfyodGi1ORBwS0%#4u&B3qRErp zPPFp*_2PzU+|_Hk>?-z)YKUM6MZXC98_%)1Gz7w_(5}2f(s`p{p(Flovcu5r4OO&# z9kqIQFZ_^6hVbu36n|a#^KZfc>J_v?a(~r@k-efywFV;HDt-1&!21DrpshWy5qG_7 z=)81zq7U67E^M^T61;)naogYf1vx*pWso2C1xBWUU{yj_S;R?NUzrtB^FU@&Qj_mA zad1;^5)^^#s6|o{)#7*|w{uh&c|j(%)alw|;#b#JoqXwzPt^Id*hiHSXeou>CU~lh z#$lV>;u{6wwH(qBKSQwttwou;cXA4m+91PFNGX17bCG=7M_tBmp%uveDG=N|$E_05 z#nIbtX_#MYPsd*7XH{&6+__$4Hv2ZqhdB%`kh5zkfuX{n-Kn?A!IoW7WvA=Op_TQ) z<`CQF#kwpFp|p;Qr~o8B3iM5qFOiiz$n=pfdYH1MCdqk$lIqKn?C5N*YYm#m81#<6 zqjdlio2q-!FFn);&-KiiJpI&(4klOB8;qKyEls?)^V&=F>uIP-DJ)rl#xLqR(91e{ zS3QR9QD9gZjri}KSrHb^@lL!yBk0e`2X52@oS=3D2N|KTW9)4s&=wz6f)^4(<{;HmEpibcDMV!Ev8C8>dxyg^%LMWlfmmZh+3=Dl5?ni5i36{Vua8(U#ZGW0X}5xdPLiN{cn zd;{9QJ2r*?=$>}^EI~?=W+YMQ>fv!s(5xgFqRO;C%5~@59_`vZh~zy<5Ton z>iX0TaqUy6SCEfX4{}}$nY+GDSs%#R2-M7}Tz_HV=vM&Q{q;sqZHfqEbzaR_REc{` zwJyZZ-?}TZQ6L^@HT1}8U{nlNi;{C(pIAbHF$2P?HYKEwjzEBkK?ds8rz)a{hu`Wq z0Z6r-Lj~DxwpdO>_z@%s8y2pHiMiv8>z7|fMcrwzpT1D>8}iBUD_ocM`>(_uV5pZG z%%}Jxst{dRC_|UWM{6CA-N`N5)Copb1=&Gb(~9Efp0MQQVPm9NSJ%cbZKmjwQop3H z(dUwdmU7Nm=Vb_sI(Q|`D_S@J|I$6+=Xv(>kzTZ7oqg9491K|?ebu3>?>U?<|Q&ZeAsPmvOpOL_w9)fGG-SE$;BuO&qCy1%wcQr6q%ptw#)E9fyG;j)Fb0J$`Az>%2!tVFq^zD&ZCE- zKf&dYtUdIc@0g_o+UBpp<>zze!l9r_b`RWq$0&f{T=Ypm9p2cv7vr#o5m@)INosI! z3B&(pG5qo~gCXgfP|#I04~V#zr8$TgM~nzSCy}e2b37!_fO)Gqnfv$yNy`YTSUDuzZ7Ic8ObOC663!qxdkAP-Tj2tfZQ6GE7#qzN&h5Z4lhtaS}QUCaeZ!B3g!$h zM)N_zu^EZTtonE8!++jMYZWWMlR03SF806 zI0SW5C!wIG8u$<<*B1>zGKn7C))x>7#03%~Xcb#;PQV2I>P9XPrD#WLBEsk$D%-BI z`qAAhA9w^v6v}-{Dk#k^eDlZ;fxLM=lr0!j1HQjs_YKEH`2KTZf(gYe$Nn51%Tm1w z3iJGiUf62S-2_=cHg8@z2eK;gy z_v3#qOr#<(m`v%|pkmH9b2Uk}WY8IeB#cky-Ef=ldgv}L)4J=AN3e+LpMn*Ql%()d zSsUH(N*Mwqq5S!$tu|66P-YLkk05p=*8kQX!yFsgKlKc}?__qhtVK&MdZvK-AZx_= z4vw6yEe-m6pmmg6V~uI>;i^Z|G?Y`eysvO6&?TcUH*rMqs5nOOXkL?2v(!Si)w>Bj zbXfP)Sm(w8m={Dqqs#cWpKojX6s(u}|KCcJA5rt&+T9Kd)W6~nU*Fazl&1io4{6WZ zyI1Eeu#--h@nsYfm?*cvL@uUg_PzjQ zr;a3tr=(Zu_Ef+;bggGcvWdRZM;0~Rx3I+QZ}kJ%Vh>XZ5Qo|O*v${Wz)tb2YefHl zz#smfy$EjWc#EOom9+)%lRPI=RtNl+--obsp`{`$Wo`IiY@{{>-ynIc)wJZ~7_s=5~AV%9b z`LKcJ{D19v@*_I(f7U1BXx#|^dPUb-Bw4rc##uDrj{6F+R0ZZW>6c;qD=$qK!2l`e z({fust!7b5C)b8i)Ti-3ldukaUg?H=Vyq&IttHXFa}n7j_(f*JWG85sJ1Lan;tT0k z+BOUi8H6BZ0aP#zTg0^K{9&Q7*zsRa_yS|=ZVg2>w9PyX^(c0$VV(2B^6X?V~3 z8fr;s>R%Q9BO`~)Kc_O*@I@Msaf*v|aUK@Jny7tc3^rDK5EjPX?pZ=C^9HK_10@Kh zPP!8RVa0bE8aTO~`Oop8mEE>)f9MCTUI$Uz#}tFujbt}GXp-EkyiLwNZ5Hy=Rruk6 z^IKVcZW4^V6QCR#)i<`WjcsV*7+KrE^?4nR7i7KI{YIk*MI8#YvN1nII}B3$loXaG zM3RWe{42-Q2BOw@LykueS*3}>g?hG6eLS=0Yf`9uRL0QW48$etbEsTOYI#i|u)0S^ znrd0nUS{Oo(J-)LWcO>lojq8a;&3xN+3CN4Pxa(DuT%Jy=plJSTiH1Q?rx_q#LIi( zQ{#iEQ}jRty+WM(`zi>v5k6ti?h!<#gUNDCFUX6i-N(ulAzp%GhoX@OyxE(F{)Q}D z(qe$j3fnUOn(fHw-#Uz~pID%gNyx+^68|*<>5l`*3(kf=l&91W+s#WIk_HCy$K)bj zmja;bFErP(rcxL>Zq|uDa=1f-CpjMq@@6N@ji=uG~ex$VEGuZf%lsONsI4#v^JD2 z{``M7oJm8MD3-h7k+!SX|VP%0Oy0@a9)z|nNqiGPQoXkm)iq!vf zAwL>Ko^t51y&e&D-$&tph4Nl^_nS&y&BoNws`_Z`V^}v^nKt7b@ul3Nrl7BQh7X zMtapKT<+wFU;@O{&sHHQHJe1yI0S~jcw!TWVTb>(HWO%V!Tz_A|L`XMc_4>6-=G|j zps|wnbjwLdxd#tNY`y~HyHOIkUR;IVi)b=HS3ob39{)yCv&BguB>d*pOfH_1g{7dI z2K`iDXs-n1igIm94Pys#_={?wp92$Q$8$is?mE&{fH_bOMo8ziI4aQ}Aoe-UF(pb4 zAAQZqhmr{Ws%YjY?b87rdIkJnkkIj}-_{6^$P&|M^~wUTTR15}?GeYWS0G4;<1yJt ztDIv?-lj5ltfx@o`Fm6oD;%YYi}4cqfVPND#yCxO$|<0vJr=s<9^4^nDZYENrul3C zWk~e~6IQhg4`YSq7RHIdj-hJ%Ck_8sYmQ-BX-Kcf_!NQf8Xzu&A#(j~>J4!kIaA=C zMrQjfiOt2xI2P3ge@WEvlzku`p$~0SlcODHoVk8yer}zFq2*fan^tzxb!yid61`=o zGjz}H`&9ef=zoO#&Hm>%OC8XaY47Yel0kxzFW^@JSFTYAmMrZ zhc^`)&lmUTm8f+-@(R)c5eA!;P1OpSxw=jBq3KQQf@$~Nqn}h087EL{;B$RC zsz<6*=bu{_6gkmtnsIJY4@p=#(|u4(m9aaWSMlC2J_nr|{V@>1cGxi^8S}o%nmaiz z`j}0{1wY!xG~{bTrV10-?P>aTy0*@n9GIXTJqM zP6?p1Ft|aaRByIOuZM`&w3LA#(Xr-3LXv5Nup|9n48?M50>W|S>^3;+bgXFn{T;ys zdNy$IHpor;>c+l;zW1?ybxwsLG#YSB%NTeNS*{Fuad0q=JPn7d?36w&4;`1BBYn`Zx(5N}9P}4E{{Zt!x7n`)IFmSwacL^D1Gsfc4g=0q? zOfh-3D#0Ld|Gu1@HP%VD`^E0a!`?QUdkd4-3V>Y|{w;CFsdi=_L1R#-r_IAEGY`fV z`{<~>NWy3BPzPjh%k+y_q@}{^5B=4CjV-(4A>E*6AFvZ&05uV6aUv4t`vbwIH+y~I zHVXwJUUTV{Y_+D^9xTlupF;HIspwcFXheG>A(!OCAFp{GU$ql?3%J(G3yL~aa?V#a zt7rtFF+4GX6uInt-%rtKwSp#dd9@H^b%c_(#)Ioz6#4bTpwzi zybUv(tx-6bJZp{B>6m zjDl}VV#^%qEt|io9?q&u1%?kb#)|9_YU&1Uf21=>Uz}Bmzkj!BA3LNqn;GiDFMw)| zx_T4(oW@qHa*K6zDo~iE`5GpQ270?^S6IR1v<%n6;C17)s|r zFQ(c{P+Ki3Ar~c*~r~4G=&?V>=i?Q>Eq)f*#j&lc zi))dBs*TD5&Qu>5stiH$y4pp(q|!9RNTXf?e?OpU_9-9+`9!3I7_(W)G=wB-pccPm z!|Z?g^(Tx@W4UVN7V&0g_C=^YALH!s;Mm1X8~O9ZZYWtul_~bR7l&pBKMQ+i@XO*= zS+HPyOelqd%J^rA-O`I1gdLx;I+xso6|Md!j;9 z<>V?dJB@pKVbFiXwiCu`Hem2vq!XJTus;BexOio-R5Ej~)IZ0l1dMhe%(MCa73j}B zQ@SS5JVDyy@PQd%6vJ37SjkumVxoGIuwJS9a8fc-YAM+U>liVQ zO3cyHlW--(JD`4z{^^2w{(PisetOk-{Y$?8nHoaNZ8;i6d)m-Z3mOgaO7lvN-3Hjo z1Y`v1W8Tseh&j;N`0%f4h=+&*_K8R1eV^kY|DcE1!s8rt;oTo)m~Ram1|eYQGR=+W zpQ7LhW}P2$gN^E`G$oj_L4$Mv?hAXKN10ng91i$LpzTy5?Pt3lzkNadKD%VVdBI=qb>F|bzuBQ2B|?QJXD0l9?SDg6H%7)8CIxV z$WL}n0y!QRqzmwr2TSWnLDLnicA^{X`a%0T@O2Zel!izMeDg}sf`?))Hnwcsy(!9} z;)uh`8)U4iUplVgn9ab{Ba2HH4b-iDq?l^9)xr4Lb77&*_D&ABh80*K#t~cT{;Q|n2e|ZAEa-;lJfeypB z6Jm_FJ?;_4s=>A(XwDX(ML^%RG!7Y_pMHgV=jzi4DgOHhM^TPCD`pG!ooTTCF=(Ic z>%Q^MFW{vmYLhfEP%VU(Cm%LDfCLNEQUg%VF3~sV!eVyQ@%ba`&Bof-egw`Ol^Ewb zfW5#q21Ch^<_tBZRlJqQ+;A20oJ)2oeDduhJ`|AZuG%|gPKh8GY@+~0a?+_9k6l8wW=fPW;%p;`*o`>B{ zslQnO#WB)gc-eTc_&wu|L7ekf7LF&K3pmB91{EmlZL|3IJIO_2so=hPqQ;*dY`Q1t zFK4|`!mC{RdMGgwj3AdD{s77Bif8htka}ZR<8b}li8dabauw<3$1Xom+7SYf&qRM4 zY&XhQr7L+@we_Gev&P4(<1Ut%viug^D~YL-+ul;&6VaKtx_%E7ZYFDC~wvc-1V z8Q@8S6LE74SC^{5zoq4H=`&;Ca-{Oq|4O|v$?pTBX(&S_Kk zZ@O#GQzxMtoipl)`_->e`&Z{^?83cNsV!>hT;7Pw7PpO|o?tO$Q_T5b#Rr1rfEAh! zysV3|?KO^5xPa^JtX*u@Qb?VYhePO*Ab+(*-+~lATBQ)tKPGNfwmE{{)KZhnhpcB) z2=$zCYd!pZ(ARatHm%EZqb5pQI*)PZ%eE2uVqtnW8|$~Vb4xz7Rd=p!Da=6y5?n$9 zdPgzkkK{w3i|q#LmN*cYS=JB`8(_Qq-|d~dBos+g|b$|fYIHj_VpxEd`8v2gb-g3Jx z=_P?T7@vu89wC5yz>+q@|C-*72@CYQzuV62JHZFjA&rbwsPst}B2J4wZr6HEAY#R0 z4i|QHJJallSq=Qq;}*{lVq-X~E>@B@Z`PIhPrkKW?vhQ=-&!a3dSo1V)-KHiB#=A` zVb3s)E9VF(^_^6&)Q|*NqzGx@xeK3WTc|e+&(OeeOB(w`lc9@|v$st_naji+^t4C( z(hqrI&4V%bV~)R>$95HkD(KqP>K0pmm4Zec9>mEcyy`$JvVa$uAMJYdxJqZN5@&t5 zpca%+1vgNXtA@GwUY+_nzwf>i8jKGX4??_hZPBtPCi(f)9Fmr6+?sWHf({siqjCjU z-e3a(-y)UoC^*Cy20-w}gV7KHfN~W3GKBVIFSs0kWp0ee?$mLON@61gqit zYp_kO1&q}#eQw6OGIUHoD`FV?ZadZ{4xj$$@sXI*5_0b7N*2Z}1@u>)+cR z*$aOcq9Q&h<0X)gVz`TUdR|!sck7L9HXP?vaoWfUBQCAR;WqxFad>R-w?b7`*)?sH z6m&eBDWQ!s?T{KPp^LNrSky9GVc9UX!ZQxSvFM$^ffzsa+K410n!+;rwqQN6Lgo-* z^dc~r-4hegyj%|#!usgNa6Mw|*aMdre-*hUyNg>^4pQlO)p8oQ43%`jHOVUwovi@T zFvaS)*%`EdQ)8|u?|C9p{FV^(h}Yy;-$j>crznSqu91S2_2c5C$KGT0fR8R$B?w{U za!f3~`=#iy77F@G*~lmYCC2)4UNaK~lieY0cN#TEpem%0XAB!zC&P1qe(~qcl<_mG z|rTb*h-Xi7N)O1~BbZD?OuK zbaT7mja_%?b?(Q#fKI`B2U^^Cf5oYwp)6Z|Ov<(Zu5S$jfATK$yyI2n%s3_|yhrTH@JwnbzRs*h@g zVSa*OUcImA8p#+O98W|S%v}{%3reIIM3hKTXGdP!`cuKA|DqT(u8?NvWk}^AP2n(h zcw7cof1%;1H!j>dv+tRB;De8y5mxlPD{(evFZ(kzDB@PUY0;f|5(;s+k?0u^?iO^6 zIk8rX2(;>#(j&Y5v5*w&oeBhC}q1NND4!UT+0Ds@Jq>Bw)@ z?lN~7&i9DqDee;gkXV5%4*(b8CyH^@`Sxy(WdmSi^ zM1&QGa?6jK<*OUHxw7q|3FE|08d9Z50Tfz!m5;_o~P{G+WYGKWvH?|rwN=sJCgp-*{T@t0v zNT8*~V04xS(fkvhi5$PT30ZFI8COTUPxXgU2mkign$EsT+Emx$W$7417B_1sWzs)c zn+2%EzqFfD-D0|XVc>pjx|n|yG8UL|_HCwrR3jYl(`#3$RHBz{FqHLb+@0}h9i9Jq zkS5Ds5oe#YMc6*SXV}Gae?QAYf&!^W*FLWm!^UUOGWdJ}i>!N141RcK!d+-HsQf*x zJLdj#2r{a~0B`|4aX<|Cs(W5Tb_ zTi|ibN$33tYuq)+JYVaPQD#Y7W{DfWzpptt{w}-4DUsn=)(OJ$C}n?82PHazdS%v7 z|By&X*9kCh#bR^M9=fsa1$WWJO71^IWnh?wyd`%Tt?+O49bO#OQ>w<|k~WDahh5$m2bF;{`w2C!DPVPVO_@_3x!qFw?*& zvz#cDNP)%80fX0EJahK`)K-xlC1S8sLV$1B%SVM_b`KJBZdre7nZ+CG|6UXK_Wbu@E(wZ?ZYm%pTU^ z4EB^Zk2)7aau!#)jLp=T#7$87Z~_08^fiF8&%?L}!{glgc2;&jv3T5I@g&fxAC=w@4o?-Rp<%abgJ^AbJauohR&Pfo zN_h9jL`FJQ=!*u>YCr(+dA}0={o;%VuIU~S>Wyb{^jJdB@+e(n0M^drflV?L-WQlm z*tkqwLtg2gy4J(hjfP%B0o~DdEu)9O@6~ojWQ^Jy%r8XN4-*f-i7|Ybh*|39lg@^9 zr%WpWBv~G;(gn}1^OAxC!L))|*$9H)*H!%HHnf~VPA`T_ncb`gER%4(UGSVZq;Hd| z)*fArln|mV*(LQ&)$+=2-^1XTXZh19NT@Z=wQ-t;2;o=dHgFKIIce(gg$eVAz3w*_ zr_bBtkk!$?`n}KiCh6sz%_AV`;KbL=WK4g=!tnS)bVyXh%h%)@aO459pR0g}QG3<5 zA;-BWJov=d0s*%f0pXqmJ6|NYbEDS1=RkLpu0LnZ=_Dp7&B9A}d^}4PT6Yp~M*g2s zfT8#{H}@YJL`6G3`Pw?87&V;*zgS31u4!r}p`f_x)eGQ}kVG5#2?Y-2GVAzbss%E8 z`+it5l=k)Hnf;eH%_~GUY&rFuD}Kseu+gpKHLdPUjK69$k7-`fPX zu=rxbKS0aAKJt;S6YWe-AgK&ExVL4G8fn^d_9U`@t~giE$EOKBpn|1@Qj6goq}@Pc zTZ3mc;|@qK_#$p z*Je)YuOv~#?x~5AY}x=(VC#nv5hAxsT77$dSu-L%j00P!`QS&9X8|qZcp`UH*XHt_ z^Y%R58Tnavax^HfHx&g4Y7NUE*a6a%<_hc@ENDzSUR_KZU#MqreJEqj7VPZ>;YXjg zeftX(4`?r4P|Lq8OAD#IN!L;o-SvZ0w@>Fubws^X!-hDw!MF*NeD+XbAIC{>EijjQ zZ>R86P-jEliC>kz;o;$W@A4Q5Csgz~PIqFz73(xIk~YwJF1{CO6?p@6xC+^)jw26% zofqjnO-^RbNyzogLf;LX49*A3q__ochb}ECjW;ZOU{|2C?xKbA+-j(y4APDB9>uT* zWn=)OW?mwRdKgJ|X9ey?(UudlYw}sAUP2**7<%|GnMo$lB1ML+1sElUPZQGgjWoly zE5D-H<)VoYb3??L)oNYwy1{D~k^I3#^fAj)@bBWm^HzIH)yZSW(CkFqZCW+y+au!J=N}9d>F$zeT$TZ8hCL!Tb#eT5I(4bOPYZbC1zBhQew|x z!Ft$CoAryC4rel>k6VU6MK=1(#7Ya4a)8Qlw#b}-+#eL9Z1_bmJVWqUicCZi0wu;1 zfxCVw>xcsCsxSMY6<~TAypu{---oasB$|k06D(g)V5C zsa|SMzRrIHvEwzLPq((-2(1H$jXI8iM!U_OrzTVWd3xVUlU)#8Ng?s z*oFvBV1Atd{1Cj~vK3OzH(anFbfT5ZiF+;sC!KYEb(<)V5vaN%0fxk5 z&8ke@{l_Z&V5@T5X&mzfn|}VY13FEw1{8L+b?9Ar5XCH(WOn-C*ctDOm&dN|^?teA#xwzmh}<`wd$#EUlqq+k+l-^i={zJD9hk>Tdq_9` z(%|l-`GYNxr&?|Ks!ri7fknnP>%$m70E>@dqI{VA{=I-?5%}U;SQci7!k`x!BgjY; zcgA#6Uz?b^=E@}lO^7* zz4Yc+nb=WB87BnWaa}tdeuZwgKU_;{k>lC~L1NTUM9_Dy&%()uj?35AvaKY zT={@sBXf)-hXDe3h@k<#K;|QQhY|NL)olQesOc+WB8aWacPJatdT^pELDX^Gi&xTp z)IO#8Yhes-No=Ptbxq$J7LEb(X+PNTGg2TFA( z+rio}ncZxYa@k|yZU!vKiKhL|mCr2{&qfseP?p$1HLWxW(r7?sw~L=sxZjyeoQ6Uq z4uUp2-}r&TQ2##uA?4fV4GGXyDu)V!t4O$|rZ`@sO+EfZ=$Zl;f0Ljr9qQ~Cy7UUc zSC?#?+$~fc%!`mgtaEtD^1MGMw_sEv{UQ=E)hK+~MqAak9o}>Rtd_LSezZ0z#i?)W zV7wrSZj_9-U7&U!Q3@SoKUXwy%OWSMKbD$7BMf;F6xI|>q-$%N*nx=aeQ+;i{Rr)$ z9pI>$(N_!KU^3PBs7?^L6s#-EQnNY6w7y}oE8Z*I18Q995(>(KCOTfTPmWk$-ebC# zI&c;|{{pXf;dSclnHxPK!&w_AtY9lywC~>sn&TTuzwFtFv2;`0DcC|?BCGG`1P)${ZAeHF8+pXvu;Tq(c@JJK{dR#e3YPNI=BG^b=b$K4P) zp)s?K26-(WD>;N(GZ9&Q>VYWLt(fGqB|oK}W-r(5j`+VoF$A(b<8~SREl~#q3g;te z+`yl7`{9VO<^z2Ys1V*`-1+Eky)@}5^S_A;)Jx@Ts=IYtrkBILnFOss%p+X$x2Nzi zNx%>R;f+zLVUTWvchiHT*q@k;;) z_zA<(HXrb=jZ97u7Avz^Dj7|#h zS`$CIY3c7PvIxX zak6JV_u8MLzaKRMaIm5~`^jEDAQH(vY0+ zZHm5qn#D+qC%Or;=i2%^{Mf?jFRlZvuc+O`1BVhIrkM@%fV-h5n7z?}#BU3DaPCQjS#|_()od70?r9A-N@@ z9GK;JV-aGWx|=H1+6ViWlmiOh^6O3az%N~6s5F~fK)sFs1+Jkb9o&FczAH}uICx`>$BB~mAYyYEFbq^^{mAqXxxTo< zw9JaGIhlLUNG<# zw8EuI<#>Vx0+?e*ZPmM?D{&vA2hx-7r!ZmJ14-(u6_vMsBQ4GX6uiRWPQIZ%0f(!b z&sZYVp_M+Eq`_t8?3spWss#pI-JiksM|M&Fd7M$F(p|_2)p;^Liuedk^I1CrQVbrfjkjPoHS7rr$K9O$T!Vy6{I8F*vLi-O0?Q zLy|HyhWY3qJAxPtQ~k>pH31Gxe;AJzuRRkTw1GuPg+%h_bm>LZda&Le2cucc6mk_7 zF{z!4GL$v4U6Gtf8ORpzyCq_m2ZPIe{r4LCZOc5~;qQBV3o$`IBLiBnI^OHw-~I3a z|LlN7+z?geSrnu{^(0ujdkJ5UFkOrO6{4Wb#H?~?f}8O;FP(tNGB!3bDuLeAc_qmV z3i)veGz7q2Pv%gI>*Br>0HF+Ld!Wk-@lOr(gJH^Ds;3(G%%POfphUvy#dYqc(9tX? z{7x$7M>|<%eu&XCb3|qn3B`8Ko-$$k%NGl5< zz-V0B-mv6mcdsXKk|KKV4}?0hBV-Aa@RY^t5va)Y39`2e@Jrl%7{zp$>VKF=?z4OO zAS-B*;mees$1(H=^UWoO1KAD@l`#$FJpY5aUFO>gsfDaPyN%Q7P|mW*29{LX2&}L+9Av`CF`;%1JuX zeR|Yu1QBiYQfUr;v-PjOHO?YV$_7O!%Rdd`QU)TkCv}hAq;taw*u0X38Y)V5(HKq8 zb+R;i(b;|Y31kHIJ&gyrzz0+X5c7uH{7!_H6?!FK2+;l1GarEDqb8+!k=T}_G z>He4KgInp3qJ14EyE6v({K6nkv~ziX%e^x;o2HBhsX(RHH=*cI3|Sku3Gu!Xn95Bh-d{Ho-|InQ$zs5NXc<_`yUiL6DG zS@5*-*^<0x9~6dFNy6SgwxLpQyqj&++I(B1tT|?%l)F1+#`QyCPT>5_>VE*+Bu(nj z-@V-C%ACl>5H({DH1%<}8+VLn`|^y*%v&fuOoq+Zs!)p42SmI$e$WT=PF@Rr<$?>B4-ib}S`X3|g0elyN+46*>wN zBnq4(4J|YJgP7OYJgw)S$9=Kd$O-V|@4UW+O!59nqxnsC4v889;%O}D!W~Z4MzRd{ zijl&^l$i6!2@c#lpB#zgUJpge{|iQKYv~hH%VA-m@Pm{Pp)3F`#z&tvG*j?*ifI7Q zeNJCJcJR)z=`L<1C%4Zq6z1+5q#^1cU}XQy^D-FvAJr|HC#NQdCPJ`~8lupN$6Mw* zz|pn6cY4Sp#qml8o|$KX6U2IocYwB6*`cX>Frq_tRz-${1#Fkg(!K^YDXIYxK^(7R zXg}4}WRC&oxXB*N;2Z-QOFDWonBT+nn=WEZidLu&$??diAgb6imvJL!lL~IZT;a5xJRJ#LpjOK1X*iv#v|YVU@m9Q z#0xeg)$1&38d=Ne3RU1;TKsS4CkS#nGU%HyRS-dKnk@}pqiFE5hhFbtFK93Yz&Qtl zy9iwO@I@;BJ9s16BS7jJ3?I`+ZnmxBD-CduRha(y_@Eg8J2Rx-jaaKTZ3$rSG|2*1 zmDex3__y}dVy9l9k;W(1w>NXh!eN~)A(0jsz)>8rywgyvd0j?l8mB-I`Q+xo@!vIM zI<5mb?UkJ~6T|?zhGMke6+7W<#PB7*(Lgm9^7w>U-MKr&PSHp5gx5a|jX30&W@8i>alt%$=pwWx{pb!y3-E%$l{}Y& zi9GP)K$4Fry~i)MJjaxABG3?h6avT3xcD)TaatoUS;O6TJE)fsmZImejfb#e8sc*m z(iRC38Z%Q2p+w1Jv+FIltF)$+eU68>@M}svl1mQ^;rkMv8eo5I*~OsFjo$W?B-mnj zZp0J{N@PuM%fjyxQHbU)RUM+Pg082D5K&&h@q0Gn;gy9NiP;-X(?{wATlPRtdJaZu zJkJdC{zX$0%dU^DCR0x0a`Pgdpu9(6uQ?V;x+`9c61og;40ye=>~>f-0u@%d{;CCT zy<>aZof4Qde+=r^MDFshp%AXUk{cCYG)_3<5_=S3sdnLHZ~&J({}V&aNnkxo)jYMb z6X zCGT14LS!xF6wgod;_q%X!PzJ-fuT~)HcFz?WO2kPXk_R{ zL8I=$>f?KpaS-3n5O-)moZrcCMeL`XG^(VR&u24~MA5(uf_vTRoyYEoC>0m^z=2~N zT`P<=P--$Yc73IEfjpFJJCJt7r&?Oly`)GQp1g+Oxy=X;AXDpfjRs}A@vpv012p@o zU9w-J`aT*$!ZjPw-NUQ{R|730dR1zjE>{rHp`IvUlk>a&Yq+5k6m!J%2#I*;5wSdN zKRFaE?6FnbFeLI{cZjFR&@+fwJL8UQq#-%Hw(T(nU1j>J-Nat%DpH^LA6k9)gJrIc z)#bPsWlT!q&Q+j#JYh{0bR359oB=%|oLMN(9L)%;ynNTPBbp;D=uX$FSlE3{=J>V# zNIu5Z(p6{GX2M8Vx0e+qM7L=kX=K4l57h9iUwN&9d!nbhcdsA>c&dFxQ&;l0S~*b` za9xJ*>A@s{I}?fVeuNZPAfIJsAtyLm+XJ=?IXS7m3pZRp&VR-ncd-mr_L>6#E@G<{ zz-d0`2fv->RN80?MO7yZKqv_VOjX&#qh?FCzE=X@>T6r#Bky= z{`03!2`-O6>;RMVXLkP&ydMkU1%P9>mwYDK_(4`Q_pMt8FZF@+v|X*GH-w=zQ?RF? z;n$ktguVQQI50C^;htmKeBZg*d}gRHQ>1z|vL346dFPU(mVoX-PlS&;)`l}4FqG=M({Irr9M&<4z z*E4+6VbIyU$Wx$PLwh;PoxQtLYo>y2BVCS0!;0bqCVMbcr59ZlJAa2VQ$e{FD0DK0 zFEen%256Qk&Ot*8Y2WV>DojxP(RAC_y(K0Uf&Y?nhTHO}G1*1GtOIQoMX{7^SR^^i zu@s0`0HOsz4qR77>K1UtJwo~g{(0*Jx8ydhDtp;iZ0_ZG*^Iv;A?X&hxL8$D=q|MF zXJdI;ktk~mmO_byj$tn!SAaO(+3pM?_IaZ<$MKb>(HfZbGjQncAcV zOuO903Cj~X<~r{L9;JigjDb@;V%jmKix=QU+{T@JwWn;)g_fgVAhw|57)GVI=f3gb z>NHE@<1;H6B$r^ZJgxM`ttRR(z%D^j7<9^?01iS!`_0w+<#GYOVY{RL9HkxUi!7xDxF?-w z^|@KS>wO{<8}!5GA;X>El&Xa(BYAy06H#CvyCfD0GGK&>K80lsZBJLN)cIU)ZB9w4 zd;UJ^b1lF<2p-W(RdDUe^|}m|%Fei}-Ja+l(MU*(7J$Ao0hvnk(e%b{WE(&A9%|?n z4rsP@T-XMxFx2fXNHeZtZ=4rLrqeCn%y((l3mPQcQ30&&^X1V|1b#1o+3~G$=56kl zXd9gFSq3qv+X^k+WFjniUpf-&t}Leic<#N(&cGa=(hZOUO%9nlv}t>t-dz6n`Q(jpay{oo0J~w=VH>`|3RMik<38EUuW9@;{~zolkO5k`3I*I=NK-+9f27!10wRTvT4|dW3s}#B;oxZ%3Kb&uxxwJ?k ztxqgb-vOlf+?q zGSl=RVafS9u75XVug}Rn9sBe}=5RIr(r*d<+CC-;IkhtzG58VI^4`ch87S&gq9#?} z*D658j+pwu~b(gYH;(2(8%FjAtv4+ z??nk&BU>H3MCu@a1tQ9o*jxgRP-wuiqq4;i46_0&|6J#5R8X6wF=rc_^%j*;WY(>K!$KVoMnSYizf=U@#AS7Z zJzQnvo0%~=ywSzLSG6T#HJgV?A=DSn_$9X~L|e4%2a;yDynofuyjz}xgV239h$n?N$J zoCBjecWLK>0!|=S@`y*#rH^(5E+}P?6>mHe&+y}1Jx*-e@_Zy!3d#F(^aMW>u_S(r z5tABP7}NT4AQKyp!*AR=2ft7t%xs4Fr|}SCAHWxN4t(D%(BYMu6a1(80h|duSJIa( z`4PhYVd@jGunq2*EN=fQ8pey$u?#9H+_K@u6nQV*@1BruVUkS(8JhxPZfM6iO*f5{ zSV2BIRGG95@fPqvAD>#B&?*JxL@d{Hh7nCD{8;(}T12ki_q0eL+~F$yAv&c0WIKW` z&&)bi1N-4{s!T({P;VO7C}9GJLY_&WFI>=smgx`Y^ypN9Qu}Hl*uX6ke$|XTWwE7| zY7EJxp!HQj3{UfQBqf>~$}R^%2<>pReMY z;?3+8Hff@;7?J{X{Fo~cr-fC|gs zyq!XrGtEl9@n4;4Qvej!<(oh%z&i;URyh8s2RGcE2k#qrLWk5t7_D*#YXhSYsFwzYpW88vX%3 zJqN-XM1Z|^=6RUI#Xj$r#s?$V`YL}_Q?9b=OxkgA%uFky1VKLTcCS%+5aOU(S6LQI znt^pBBE8bhW)OCOdH_Auo8WR@dj2l;KihklAq!W2)Yn#liPIB30Lhbwb#tMvw=4?z zxT+xo0ZIW#wM@keGJQ&k3F;C3g;`B-7v}H?JHQd1Re=bCsjC)BrNRQd!1p5Tog^{E z8d{6WP;Kot>f&2PqooQ1s`y^U+ou0PnBpvdb_E2vvlBlek<*(tYvpP0Zu4?=$c(i_ z*7-;1UmW^`f3yFdSF019PS?QB(3wQ4@jzl$j;d?&W3+Mu8lUP>B231;jBI3z$yko~ zJ8EwfXjt$7{DA=jn{9Zkh3%ws>2xa?SJ_2Y@2S}oMbxH*fNHmnLpi*VsFx{A7DTX) ziYxm;uR%hr8`wu-l|N;zDwN;)rWJnliRkPNf=l2`6q^v{$!VEfK=m)(;{y9$5UmF& z_!i~#);hmQou{v6@rTZJ6!f&k3%c<+k6lbb9ytJ=DiYtZdHPxNuNC7mEM|eai%47b zIzsz*Ul?oX&=;4tiyueUDj=N<#GI!y>axgRCDb&GF@j<0InAylOU6@6?xo*KxG!k7 zxQ^NrI67$AJ8Qnoj^S z4RZ~62ymx2vN(;t`q!L=T8H@Bwygx^L-L_GY~mQzOdUHbjk#L!??!(?+;NV#6%iIo zLTIEbuU~}~iZdg(`kO8J@G#(Y#m+n$OGxlysyox`f3lkWu10VS0+LCvgIVIG9{29t zZ#vMkNa;!!;zD!dZv07En1OYB5v$&xA-ekSag6{0rt|*up5s?@f9r#TLEXVMo9mRJ zh|8!*JKyQGZ2F@gP(Ox(esWP*f+wP#J@rSxbFt3uaEkpm_dT6ryLg+8FSg8d&gS|z zauIj&)+KLb{BckkDQD5H$gs`IT%7Y*Y$`K?8Q-)IvlvX|;M0a(vy2(M0f_qj!}n=y zsJ-oDzZ&-3vMfoaT&pEPo_uSd3z`3sPVL4Lz!tU-4M~#sAPuzOV|1UZ!ds=%wQ^_m z_3gBpLk(#)_iNmUCLJ9wYF?2Y0Cnkj zXqNsXzzGVjm{bI@wv+-$fx8uaxJ{BoRRe>z!qNljPkv2D~ZBZkw!hr93XAMjkz8VDyJNT zCkjX=DL$B77Z4YN5Kk!06vi$RTy71r^l9rgUn*_|?E+pyY0B{fB38M+zv*#s>iXQx zHo!UX{Lla1y}+#~?EDmJZ_W-y;O1}hv4`&V0JD}HQ0Vj<0*a{B>S1RgkBA?d%zbl5 z%k;TkAg(t~jZib-N?D#_c$FEIm$&-b^iYX;gjTCDDRgwRN1sCgMHn&iSSu!VG60JYj za)0cMC?y9#O7dADUAQMx6u!3fdV#Dzs8ozAk0SZh7N-a6nMq*sa0V;3FH^U#VQPg2 zVHOWm@E6=u=2Me*ZUY2RMVJ!Ft!55)KItc}yCCCp{9f_?!FcNMtVKoL#VpkVa?hi{ zoWpq4Sn~`8>dnFL>@$Vn$<9G`(U3r|M1sKuL89_H3bx%nTm##leew@Rv+p~E-BJb0 z;vjzIS2DMNzQ`^NOM;G`a+iSxq*Qiql$6FF2)9|g)g2*gbD6kJ7a4_RHU3^BH z`Q8xka+QbC7vZs}d>{LU^PzY3S>oQ9CsTe;rZ316D&n1yHgZek3>Y|o{DiTxZMIbD z8`T5WDQ%*lM=DBZV>D*M%RT>trv(J$X78ElfqI_SUCe|&T^cBc(&hV3`H2xHGSCwN zW#oVVS!pj{=O?NMi-ljq3kZQm=98YMjLNq129$5~7BpN-Y41VYTiE3XcC)swU?j;) zNkjo%#ZW>t;V-dh_3HK9245-hRrwfWTX6}y7r7`+3iglZ(S|_^#dztwZ{^UthYXhQ zDc+7v)Y_LQsxV+qiV?Vk$;(G-F=8x5=iRk`kds4YY<@Mm>$!SXG6wGz1@0^Is#pL3 z00000000004!Vvbc&h8q76_hZu?Yf~Gz^E*Bc~Fn7uC>rYR4Vj&Q;r)F^GOM#uT)G z*qCWQMRU1=J_z2wugaJYuw9A4&#nMF59TLn$CK(iQHv2`FcJmD7+22#6rrkYhj&d6 z2Y>(o005jOcdx>RMQlffvVkcZ@cmic)nWYqYH{ZCDq51^dYP8oPbrn791V zG9_uax9TkmdCEV63dm%rK$0he3Pl+I)#p|J4wvx$5F1<(Z2AeDeLMWVb>jGLC1q@$A*XW; zTX@}e*?ZFd7=iG+F>8{KJ^Us?fv&d#)oA@0gGwYn&TT3mAWDIc$_OipWY4202K1U^ z<|~)0&4-vh%eUXOY&qktmZ{S@Xm-*Ld4mEQ+oNB3#LwM|XKMA86Ixw%e0oWqJFLV; zPFVMd<4Ati>KCB?>UiK{!eSw-(7ek<_w-`<)eQf> zHw_xF)PMj00PSfG<5=X@S?qBo<7o+3yxDW_^6T0S`liX3{tXL9t6T@`|7_L}LH(gg z`^tUB1$&~FJ4Ikm>VfQqU+K&r;;c$)8^8QCNU3`sJ=CCkRF$v+hqe2?m_4t>8*r^& zEw4(1_1?yrxVQay&|YZh4K4_X;_@c>VW0F?S&NO%Arj9)YqqG;{VJPZ16S+l|&&1^yYDKkF|ei zu6Tdan7xl$tAh3DKSPKUh?ip%gTU}4YxH;IVC|A%IIgyc^I0MAT@({P&>U+%R`Lv6 zT0f`&MhZqr&^iCX2&Me%UK2Jl$UiD~7k`4L&n1LEOdYuA6-;l<4Z-6#M!yIE001w{ zt(xd|B|zanwS~1)2C3cv4&A1>JtK^O&;iv^p_>8%Dqnw!69$hWMMm{(MwRL>@^b3Y zlOba+#RLov089X{>yuc-z6#F%pl3x93#ZmqxhGjak$RCoi6(sDxDPXnn(`CCv1C84 zFZKp+%8Pq9@XSmXsSCT0iFhysj*P?C2Dw;-|Df$loJLi@;yC>iAaQXFLY&qYnXVy> zpgE^S<}8`?ChLi;QN3egg@GGMzJZ%mWj!LV&hlz)FnG8O0~U0e>{=Di$_5JBFr&gP zZR$jHUFJ=>E!%bitCYk>amw3j4&JsDHhr{<*S94+z*zEMWXb)O;PKIv_9~eGbtR7=4Y83<1***%Inv%ATV}di9W6S3 zoRLV(o9P;ZpnLR+A#FY@d35DsUVodYp@E2^DIAWteGM07LpsjTB z4R+19p1^JdFFqda#|@oWS!Ym?_9AvJw8rZ(Jr?_1Mu-kueaF4T5)>DWVxF#WNa;fY zjsV;oW?>mpR@xP*or(rk4+^?(*0l6aektnmF)Wxl4R0WjmF%S3O*pRhh;zSV-e8K| zESpSbm9e;}im;-L&!JYEb6kOI!R_)DLo`%PH04`u&n7m&?S9)ejj;sE@N{On{iu&y6KT2%mA2!jQeka)fX(_8i zXK=__5`o=&5f|ReJ=O)X>ZSl^jbI)EBr{yqWEcHr&S-kaj~_}EW1u}I zyC-t@zyJUM0n@bKD<9|s9@3KKFyk@;@p>kh6ExJ7cZE0(>bRhkNwnr$@ly}T>k3;Rec_H2?HxD4f7Z-`#}SI92jJ1 z)=jp@hU~|n&hiSk5Jiy9C5G}2YKi6FWzXUP(_I5tKZ616VUAfKJaa%n(7cF0=#&Z* zsS;C*<`fFmfd6ufx@&1R#c&H5((qpn@?NL1a6aQxvSQ7I>g@3l0sG5X=W0cq z3~~o21t8U`b1?54FfDPmNM|(ZFSwDdJ|AxbW*k0|Pj8ti5jV>D%RO)oW+v!voF6?$ zrlXim5Sp61mQ<@;vqnh&+2EylPX8B;uEqebF6y@Y&>tMq%LtAih8ti&00000B=yka zvp#(&lN^I&UBEXeh+7Krp)}r?aJ5Dmo^C++bLY{vEyx=A_eQh!G^?-*M;B-O?lMJR z7j%C$1mQBb=)sJcY=e;>rp|`SMyAKrUYk zE#Gt9e27e(emrir=47h+Fh3$w@s~1zSA&cHw1yD>eQVVsQnlZ~1-h8KI~AG9g<}P? zXgrgoJz`|W-?8wUZ77}k!3u^hZ1v$D*0}UY#asXY0002JzMk{XR}?Eoe$~)IQe`5$ z7odo&|4A9wl_EKG#>FJLc6KkY)L7DfLncBGB1|K3rq(60p&j5!V(*HJO5IyG@y}4V zkSGBr0vN?F)I!%0=(`QZs(FH2m+2k`7Z86lmM>tJ=*F-j$(+@Os`y-c_tbetR9yk`@yJt}IjCk-^`4w(Fc`WT{>* zu7R)hS&3T(9VR4Tl%vCA$AeAW?3DASMLSM#`nM@5K-((iM?kenNVCSiM*nGKC?=3j zbt39!=&Y~?#Swf4gs_X<`A5|&vG8RKF}B@))90@dbQ=NK>66_|pe^2;56~#8D^Gur z*CQ}wQN`DVCUJ-l98Y0yz!_q6PSpLd)_tGHHv#lrejrQ0;5zfhW=O)_#62qx4IJ~`?=c)JClyO-#)=|x&d zp^DfFr{l}XO|q?j$nrjmj0`3`4XEb_6s-RJ*-fItdwx2c8}MHspWViAI4EfvpLx%c z%8wBqC>G-MG+BDo=m&RCE3Y1JkA>zd%8rHw`0+ge^-UJyQ(k>Iqkm_wvodiVtiq;s z2mk;801UZT-_nxU|1X;BUkt>W@{nu*1oL~8pPxmT^^tnkYiirwofz;G*8wKyqtYf0 zzGJ3WpL7>sB-q{U?70a~W)JGo$QFj%@u2e8VZS%H_=k|TCyV8YVSkZ=I_HtF1^5$l z1xc=uchbrH>M%y|%%=l1{j{n1f$jV0V?a9`*@VK6fNe;2FY-SruUDqw#?E)0KQ&7= zxRoFNim~Az_*uL(1nVve`m$QWEo7 z2Z^WtTIxmNRkT|S_LFQ*-W#jQ=$+Ie=7hR9=J~g#%6B7+-E>;U^fAj>N8eE-ugLh% zdebcUwEUNrsKKXIt%W6`)04#k#lJR6m#{nOE4Zh1B{qs|^-7r-xi0y$Z>{_B}sE!4(vp0J&sabz7j}M+rtM-$dix*>3fybxI*0%O000v>4<@XB zPp|0h(67QX{tP(J0=zqh)86aCOiKUd*ib=um`wqX4FeR}k0Vtd%)O%*#8MIbU&13K z)&gp9*XS^2dw4@}SXwQf2%u%_-{4jT3fYP49CP7M4y%$R8FoY0ihzr?gFf`Tx^!xQ zo4&{_DH^0d0Q;}W?i2ZOq#AEfS3DywCWJcsc+800DUxY@D}Rp_?>#{9B)mnPsw5AE(bj<1-M#x=12(bITiU>4-%^tjcP) z=s^xBd4SrP38Sx&^5E&6c}+m2vgp6GqJ=_gi#CEPvZP;iWCWr6`a@D3N0yGk#k&@P z**P=Xky~+Gz&Y(wfEW`U45r>&yX(4j34FqNv3j*MezW~2&mXnNa)nuz2R9t|DZvg~ zMVIomm8@BxC|s6ldC}vjA1c#Vqnlp**)$mzb&)l_Ftw)tWjcPlKUj?0Z%k}&&J_zP zcBIydDq3VhNN#YkB-SxoEnq#`LLw0)Ji1zFvXe9$(WSXENn%gdL7YsLHYGPm0kUS*|+>LaMHUGbcQ0xlr@ zAf@5ztrb^bEOoOvWK>z${23tL@<}G%g%LV{x-UP$7l&HwuM?qaEI;3D00Q$OcB0#E z@O0q|5d9+Vgcv!)3Y1%l1hjVtIy2^CsxHtq$oW%QOEzhc-4~tmlTsLJ2#fG?= zO?J}>Ct{TOmS!PrFnK=2#lweo0e#++FL{FK-KW?$DIpb%v zvTKxlBCdv!g6L}1Fvyxh4u5>>Yk+7hBD^LJ=l}o!6&5iSC54{gKU5q18>m3MOO$-A z?aS^vqlXe#RgDvwXvOjDfvqv*<3&hYL#TamE2rpnMa19{!2Tg%6EA_g|w?WK! z6wVmal=AI0fjk>&v;ioP_AXNkAi?0ea6 zt#jaZd8`WlCMm@)g>@ctkD6Y`1CRC?)F^T$!e205%^)}b4pxa12s_Ag>yzjj+jHE9 zqMoO8Qd|P8Wby5tO-jMeFYc3lDb%+5_|$$wmGxwQo}FlWAY1*%ZE)y```DkVi~IGO zqtCcUkEF^{d@Nbldq4qJENl6Wi1a|}&|GcOz+a+`R6XD7OvUW6Jj6u(Ka9$OfUlAF z>emq~wxo>8bsJWzn5(Mv-EB%?&t`FNz^Do7&7jYI251B400003J#tNcqJ2NXV(Xx{ z%wcbumEed5DP%krLkSONDPRV{UV1$d-k@yk5J=j*`C9jC@;BNJE8<#Kym9 z@Ko(#5K<5J7rwhlBnmJsL@8|;pW6vcyiv{?M3TjCnoBflZ;zR!<`TkRf*VrH^^^nr zL7e7w0|z#nnu>&ZC>fDh7!Ve8OUKqIsj)(NG(I*H(Gc#*t(+>Cj|ma!f08DMG;zg9 z)$ldcs-%NyWRRU2CP1X;U$%+dBKYpb=CvgG6rZEkHQt7)H;Hc zQ2|hQP&`wPbC39+0{*V>QbKP}F3Qp#86LdG=6J*SVW*P~00000(YvR5?!?O-Z>Pie zX`U_fIAY$lColG96pP?_Z&bT54GOzBzT9 zhtUw`Eai}J?JwalLUZg1T(N7jlUEiNHo-MJVMmjY%7h5~n^B_@C@Faspx=SP&5zep zvxrhi&ED#L1*$DM8NKikWx$rX7ai<;v9mt2ymwpp^^yL|3;i<_Jt>m=Im72t-A9I2 zkoLJKT3oo=KMEFp9KEo|Nr!Cxp(S2Ey;l2q!UfL%oSCXGS8ma?-Tn>bt#iMxbL~SH zJ4)llg8WtOs2`YVZHZf(2CrlDy>~DG0000Apa#a8|B(Vg_!3CiC0D&|P9#1#bwP>p z(5!_+V^H*2LOt_09wUP>U1CiAxux@qC_qi(_7E?-^b%fbM2x$t%S;Zj6a{&VIFzA& zPBd1()!Cw6x%cwZX8lg>7^`pZIDK|#_^s%SZFuy*|92dLWieQyf{P;&n~JgZ*Z?iJ zj3o>}03m#FQHPk1LaJ8>H{AET6s_R_2jlharMCiKUP8|CcKJPg`Y+XWzX!l|)~|72 z=Zv>iFA{HHAi;R{%@B54f}Va75A%wP`DX%6u6y@!_a7(e>#URqv)Ty{x~$d5-V827 zXi8b0dfu4&twjXrrO711G+XyfD>2pz?2&zbn6L*d`UN(0@KsK^`|(f` z5bM~ z&%Z0CS0IZdp?W*kbhfukq)VDNdiJUOF$=i8Hk6KwU+oi6~mkrFId{g!%bL6lUKUoBR5mgmopOafI}=yiLdcW2T|}KskfudL)tyx_pi-A}=w%V|qr?~}Ptoi}dDFm} zyK}orcuL9b!|ugO)*)jrJ~p8nJwc8Pc|yF;DQUkj;_u&>m*o-Y(=$hL`z@|(k$6e$~+P!Oc7B~JSBvhLBp?ZBh~10-GdYp zC+G0)fjm$7?yXNtk|{_eZJ9>{nzZe(YVCsSv367(A%1DA6)oKxsllz_RkbzU&BA`E z?g-oiQ2e7JTGHnEoNiIf?zr!4i4ni#2*M^S4!U>I`~u)}J*Z5C?eEIZLU3)NTlJtg zI)Cm$JDDk(=QQMXC`ftyNj3U1ZO$qgso}7aH)O^Ok3(0l3;6&500WFoOc!NB{1v$k zq2MS$J=VES4{suTt`0ApOFL4&<5+}AJUwDbEWr=G+5&keqz*C8*2^=~k^u$Cc9`~f zioK?K-iN7FFm=O!2dcX(Z}IkplQ8Qjl6D(|v5;iRr$Q3%;4w<0Y1?9)W0qbBWv_^1 z&+TkjK5h7a+cHvjmzgPpMIL5AC{XSq3jh2$rQ(neFSCreYNXxQh|ps=Gto?A<$hwD zZs#Iy8?7*P$)e1^^&Q~~_j&c!+QuJ-0W~`OoyyC*OdSBy>2c{^ccStWXV-QhOcIq; z2>G8b6g{7Da9zPb!>iRVQ{ROm+3Kial0kV5!bq9PmH zf1`i^00mMF`aMTZ_L^oj64j76CnZ69B~g(os;#3#W0m<$XFKanJRG|bY;f?^oh7IM zrRdkIIv8f};v1*rs7s8-mXjowPt9=Cr>NiPYVJt)N3C`pZ{=Kq^OQwJUu^PrT*Y(} z|NSVyc({uQDR!{i5pqvOLiut-Yn9-N8R5wtOjWdsVHLH9RWY}{Q@9WQn5;WM-`dLz zWL$TTVBq6OC*=Kr&?)?XIk6Qp3?2H}oc$7cKFf;>PgzHkfUkM1BypIL0fHY_GqOnU zkQ9b=lRl{)pzAOJE@0b80000jDHnu&RIf&mGDt(qHKt3TDAtLK1jVBOyA7*d{@eey zIgKv>0m@e^U|{pyf_}IO6=?A)be`DOB_7^$lX-aG!`NBtmAKdwk!0NnqgGNu@S%BA z&|_2AJBn#ktoAd2yNZoef=h=^0000JJcpQSvF6nL2P)0hmA`b5rlkFWp}hK7tJtW_ zyq@MoX{E zME!^NRr*{1C--lFkJeAtk6IsJKixm+dXxQy{jc|(|6}`U?*IH(s89Dl_FZP)f?i!e zyPpj|*Z=VPUFy}n5A{1Ycr*Ba20xr_q;oe>*c14>G7oWb|HaSfdNJ}c`(H!<{GNzE zGz?HF2vfq}I(+H#r_Q2Z=TDskDf6dTq4{hYC#`~%`P1i5oj!E=)2vYV2fyyWy8i3? zwXRUs!1;XXZ)BRfy;-ZSt(v;(>vsKyvv00>x#v%v;19Wu>3gdX+w}$>HtYMZ?!UV7 zT=bA5^QFd~>3w4*K5lu^<4>JF5t5%eeBAS=&YwDb>GP$()bm=OI$UY}h!pFYm_^@dq0; zP?^x(KDDq?pE`J3=Sz(}(%lE0U3C$jKQ}z-Y?scTI&9X0*cQewSaNi_qA6cn$2S8x zOZ&F7)w9_au=JfCaH2rr;Gy#9D~Gu!EFOZLA33`I>-(?O&wS!H8hq(-r_P?~Txt&1 z(7a;}b#Y#w!o)ybCefJY;-P5kBtQmcl_|AT8s*zIr9N_+wYvW71g|}wx#deIz(ua& zv5nLK$jr+kLHaA%%ec`;jWg#@biK?t)8|W#K5lur=IJl)moh3>lTEop2&_eu{z^9S zRB2Nbx>YtNJn8eNI846ZsdT4-FuOx;WaW{v?o>632K0=N(O2Y4Nj>gyEBIRDPjtR? z_el_xcuq!rN*B+eCiJ5vccm4Lh4}{5&5oQ zA?*XwVsTPJ1bM!Go8qB{zq#h;ZSOw3`t$40ue+YUK5GGL(k0`N2wt=!&JXd1)Thp$ zS;SS7APg0Q8@pY{7oY8?oWtdMAJdWNk_k){(Jkc@n3iM zU)_IYvGK~)O1Kz#y8i<^vO6NX5W#85Pn(&-15pa1Ivc8`Un}RzfhxM9nvpaZ9XYrl znE!n-t=I`75JP1%8@ICG6#^q%i(sWWkyZ**bwufPQx!~9E-onCSplt7X<)-tCFL2` z^9A;u*pqRsgOD#H>LWQ^xGT(dPb1<%?QVXI9JV?!n?oWz{}#Ts^#G(lZ_DRTojz`E zcit+Kij#2XQI<27(Xk)TZm}XiF6dLn6>+D{&pLQPoFH>P0(qS9l!RN_kYxUwl_jSs zIgfQeS%*zgpoIqVSO)9LOH(S00*LzF!_|V#y-OpEgF;5*-+zSftlrIEEHf@}>?PQCl8ca)b@DeN& zaxmeNXv!B+^1(pUJu}+=+ry-9hc~>3`_H7tcVxl~W~ze}DRG$DJ9Q)o`=#>gr9NHM zs5_RU;*~}g5htq`Q~wF^>DX!)&NJQI86)%c&D;$&8cfkiuzSTVl-J;_xrD6=07#5y z$Er}lT1++$P=*wjQQMuf8hiQvBVrN-B-I^W~@+ zPjhytHL<#2b#^}4V}ryR(hAU)_4UlmHhNF8$iGx71Lp@|U_=XA`nYhUO0SK~&pAvR zF;vA;)TQ@Lvd#nr294h^vj@OpxiZO1BA1ibExdM=#=@HKCt%ac0{&7N-Zx)aYw{R^$4vcQZLI>P+)$$$_1oW)Qr1}(%4KOqS*%k zh6PdALSnh2s{9MHaALcjXM5$mWKV%;jKYG@RR{MTNFJEC-@L4@ONY}joOW}rLI$Wv zCa{BPjRuQZr}Ov^g}*kyNBz89!FXwuz;;Z!Lp8;J+qgcQ$5|3G7r$#QCcD_no-1mQ zkyM=VG>ioiQ++_D;)UGTW(vXEY7KT{CWTYH6Y@T(-Y%G!Hl=Ay7c3+Yp4mYN6>jRc ztyKjh*A{d?(2X7~KxUe{7Cs=(rc_*7R->{!53hUp4~k>ZikR#hc?4r&bUT)cHj;@P zrkAVXCT0~CXAxj0b6`^NRt{&eMh}qNU9fShHu0xAMOGDDXSjyi!Nj&NL{il%izLk6lK=R1>^dSMINc$y z3%P0&CNqs8z5V1R^U~Vzm(Sqj% za!}@tv=BvBp8!Hy;LNXmp%((aSvM>`&$j6KQIV(9hU{fK@l5t6;?h`Ec{?T7?4Jl` zSil;!b6o!SLZphXCAS&6zsYX_L^VN(>Mu!q!#JEV3TLN382U|)BtjB6TZWTGX#C@} zg}1F}e>adzr=QPLBb`ofK9&mdSZYjStQp+o7JBoS8+@kzaC7YAJq73i%X|FTH0v#U zKQhd1C2$io%%2g@F59-Ay`19CwYBSbPEVXu1`pe{6=ZcOr;DaoN)MtsA2c}zg)OG6 zqJ^NsZbk4|cjcKAe-H36tt|iDxv5vrL)Y9|DPY40){F%!UWJE86!nk@w0!DjB&~X# ztc7{AjQF0K?8gl>U6foVJI{5QFPZRX1;<*;Z#eyr?sw)!)0vDKdSq0b&7NG_60AP^ zpNNkOvjS#8!~6{m2b>*!QH((Z=f7gvc6F!^!R)>t39BLpV#y*fL9TaN9Ic?g_E~^i zR{nha4xGFzH$K6>?^?sD3FiQZqZ|Op6KB{6(j910o-2(JXKEwo>YITfl>^@w!;+#6 zqBZT_B0R^SPVkg#B1;Zy%5H5<(k}Q05lsObweYb|l9Yh5V{|#O7ZrBE=YqHqSj^_b zKNWPza~@dXlN}1|!9aYG?dZC>80A-A{lP?L!aFTN9|T@gC?vS9C*C0clN@rmgR=(( zcIBQJEyk0&@L@^^{iwdkqO@=E;o8`ja9agAW{|EK>mv9x`X6kG^3nEkKZ2$FBdRYl=6!piq_B%0+n%dVLp5Ay8=+-VTYR3;0R>)yu1^gx1x(2J-H zWBQhyHLCBW`y8}R2ZH%RYxGD4MXtMherGVe+Y8$?o*i>xHs-3amKX?r4slDby7RcI&mFrr)7t`p?+mp|wU#@K7rKaDQ0t+L_G+sZx`{AW zYn3Yev7ymYT70D-wW1l8T>R64IfnbrJh*&Fj1l7r!eI$l!0h`dQoXu=<8rzF^n0Y0 z`Q~8wu&h#Zih5Q2>u!)iRa0k`ncuY?4di!s{asW%oRj{s{UYzUP^T;h!PonC7_vJJ zx>#~r7AM+v*WdQv2tm@#v z7l9iAL#ZuuzXD-+cudvi#1@hxX~KSx$4ZuJNm)hlns_h32!UO%>h0mA4?%!e!Y;1-Zb2${WjQK>ch zNUW`P{Z%;?xh1=v^h1>VO{Ba-x+~jg9afxH`4v{VtXe9_WyZ3RFg01WJh_$R4JI6y zMNpenBb_)C8NZ2+{V@d zanv}Opcdp+m14{Ok>Whhce9DUTqg0YO~!7B4Jx!z@4MK$qL3 z*t-nWS@?TEplk+%H~Tx{gebLDiPC}VfV8a*%VgkoaRVa=IeHs4@ql6;;?Ok?@O znNWiiBSedZH&)5zG5EKtq>r)R2NV+MiNwgS94k578u2D!BWkTpsz@a-le1{vl{Pyn z*b_&m9EuJzudAGS2QZ2l$auXI#^wlW;9iTeUK*=FOM9LW3eLM1U5idhxcv2P)h)V# z&2hEAF9neR*z|X0`%@D_tf&k7tvi+Z8^J`8A>pL6EV#kuFHSbigNE}RxjRvY+y#s+l4winHrr+zk^>fke|u`jj^>m&*yZXYXdhLQppxIFzI$pdl;t>IMzFd|~! zMo0ik9#Vzp<2$t9uk`Tdn^rwq*1$k;YE!(-!ZMoI-v527yufn$2xr1DChRoN^V4F9 z)_pCK2Lc8|Dn4-rE{!s}a6VocfRPVo5!?EEi*3DS9>CDW(GKSpP1J7wwCQ0m0Q(tu zDit%Yq27r}FqXjC_DuQygb`xYM&dZ28cHefN^3>&JSnK)BVAvO^!1=G@H2 zEmer%yO^Lmv|9<)^FG1#eCe&$d3d>0XAM;_^1yf(Eud-XwNnt1p^H-~pd1^3X992d zKZr~La9{Xw9(JT1vnVal7{s7u-;Y63`g!kM*1O-(^%rWOLm)dx0Refi-OlI=ZxotX z8G4^wo(rgTVQTzSRd0817MpnSluh+iJ;c+aY?+(b8^!Y)Hy%cLvs1#mH$YNG4I>(y z%;067DqgDm{OY`3_S)>EwR~?)E*@Ep?VB!*^5r9DA*sa^WZ4WaD_L(G_@gzWN*4;X zF#SIAyIl3ZL38c?iMcn%qLMXrSa_c$g^!6-%FaWYC>I>V;p`0Haxg=1dKKEo){iLf zrshLqC9n+WB_!xT3;cNksHNeCb?Cz_)3lzi#BUiDFYdp({QrltBEj?%T9KHt5$}p; zt8efXebG8>HF-~L{*^m_F9oZnOl+!l30~ReQ-J>=wDL44dlfUN(OSu|Po}Q>HGf*}Nf1Cshda7<5thpnmi;mY zqKr=~2_QJ_`05ao96fw$%{hupphS5Ie+Udt20B2OL`fC+kbK?m$ol?=UlJycY4m9w z)*tnDC~wH)&D8H``6E@*9Vm2sWc1y9Z;YCsQeX2`(~+PLcgc_b$3=jC}ubSG~ zz2+aV@i^Y+I4$X^L7q`5a?!QN9h#0U*2Dq54c_2;EB})~jRILuX|WYBAUle!07DpE zgF5^zG-@%rGe$~$ATwbAORzvAq^T~`jp!Ld)QQLYCyCmInGtr^G*T16WM$ivtPm`( zvTVoaEf#3NjHt^Ex97qtF15pj^<&OJ)#8c;W*+a|5~W81?zaBywM0JwkKX9<*>jvc1s<#*{AbAE(9&Win9bz zNQhYRs$;6aP2G-kZvZd*g!2Rky`(qv?9Mo^1qzKgUk4Ak)o%Un(;KbjpC}3Ss{Z`D z{9+DeS>Grkpj^I7DR5)*`tgTeM4zd0+$%;{wmLNhdRzf+nd(Sp2EbrO(&$d7Iqnv45jb%*-vbVNQO5HKjcI>tO;mdnPJxv z%oxC56z}?$vw>XX{9$1}(7v;XGnqJw+YC5c*E6YU3P0E7|DiNUUxRm!7b-cuMrq+$ zI|Gyg08B(oE}vY9+SGLC6RF3wEZ8S>y)k&kC5iWcdT{9&$T?k)gJn&iY33w*1g=5v ztEwBQfi20Os&7dR(^|)-VgHN2y#M2#mFng4+W z=;{tLaMj$Qtq1C(ft8mQ&shbFDNspy4z90JDyB4exshkTp2{9GEQTffb{#qg&Vnpj!lcBe}m+tYx|(gqyy*{h+xZ8JgF&U-H-MR;cOX#7gD-jg)R zoVZKEl_6$W3iJHruRHFB7iLD}ZM?@DmQ?SIV|2H*@fv+uLeV^$`UKg#JnrdR;D#<0 zFM1Gnv5UD>w^F~ya)h;foL-xO3leI)KM0P5TH9~lkhE~1DDkeKbf~z61;j_0->68z zsF}d5`o%kMh$NISJt?UV0Wmzm!qLQg_?~7?k1yrC77`M*{LEDD{F)iG_yxhxmnuz(;LP^8$e@Gsk_xjSjHwrNxaAYL|aUt;T>L7C5LJ__# z7+Di?Vh;Dgi6)TOm~!50tgh~dkj`gHfXF~c$NJb>HD=1=olx`)%%+b$GfIBm(LA4YXsw%SHEzvi2;KYp%hA7!p) zG>CjptX6|D{O^K;4`bbUw-TD0^qHPzQL2bona6*@Bbc4pI!sR2+5Cyu(#B&&LxpCH zCaHw&^glRC9a?QAkz(=nB)k9cd@DPmEcSv@ZH?239B^ekn$Q|n2_bt4Ng|Dp;$(dz z3xU095^yD5o0#YUL0&JyqED6~Ah~yN<955lHz_ zWavb+i>a{|Yir;^bXvS;2l`BggoQ5F6$xRJeFp^p?FFq$q(D3W%jcefjehu+R?|?W zEd)EhMTN(`Qhb(-9WV8DnS~(!1KG<4pUEGfnjrO1hDi)NOtY$6rleR8D2#&LAc?P< z@X*_WzYJI?W|0wN>H7z}^B+Rk(xP?&dJi}BvArkr>|;9NY^AOnMvMXPXdvG%N zW34d+rT$^}=QG8%WqNdic?I9@OMIx*6zECApQ5@h6k>ZiZH>ag5;BucAh>D&d4|4mvw;{ftj;}y}!+`A&dX#-2#0cYBD&2AC3`aK4^bgWmV4o&7Z#+6g(B9qp7S;JULU(XL?=Mq zj)=9V1sm*psCcH-+ic&4dE)TkutYJ&@w%MOwYlk4h^Usq009*WB{5*Jr?vH7y2IVt zl~aA+F$gc#3|N;A+=&Qe-ATPX_B!2eV$28sS;iBOd^Q7~e^obCUol-TimD}=aqdV~ zDd_un?l}dvSgGx4Bm69*BVhABCl5v%>i@rWszHv0%1Olfv+R_|z!LtjE`1x4n=d&lhRKqD`)y0959oP~O2L?+!#0FV zSIZB}&vHH8#S11_*;v-U&jrrB$IC7iHpF|`tzP#?(B*&Lc-r|8_A)uv*5*D4lUxRt z$5A_tQ?yilbBhX48Mm!s$E<=P7kb(9=<~g4X?*gUOt&Y_6CG535h;;C!kn?b-KZtp zx`F25C)gZOT~KcCA+z8i<<_34@3$zJpmt)Fb3OpE)LSlj{#uXj$RVn7l-K9l$!DP7 z{jokjdv|oHR6Gjse_F8%_ld!%zu$OcYIN`YGj9A%d(FlgNxDiHco41?18~`;c~|Fl zM^@C&A6vg;rI-)EcJ);KXZK>5C68va-(nw&5qw9HNBkbT{bYuW1ietdPc%>qBO;uSL+pMG;{7tdkvjRkpiagFs6%}6mY(mv6ZMaRm(elsIj5Bmt@~X9BqJf#U zO~I%}C3xh5X}X6~)SE7{q36T>g}|>NI@e2bxk!W$PGV=r3HhL=RS@N;Zyixe=afQk zby)dF#_{rlIUb0&Kb_P+`7qWHL_fC*s%a%o@%3iTz2y;~0V#lnAzWJwSMXIgbJzb^ zQqlQeO9jqn4j_m8IY1m_Z(-Ojy(B66ktYher@7B!`g7KgiNFJ9!9zx+uQO*nePfK- zR2$6@G*}4HVw*d=bvuitFIJ-cgPVP_|D!WuS$ty`sAibralSqhZAr4ue0t+T%CVxj zAT`$8#ikQPoCHO+i=s_zLxU=sorjln46v0;2sY1JpwngA+;}>3@PxjEU53Hr33%%V zHX!*~US?=n1+qZ4@-gAO8^k@@g^md}|K}@ImCg5iQV4taYud{#j2fZ@t;dBhetzp= zNSHSj5#$6)w7}h{OTx?0qD11W8*^Flt7TLoQ?4#3Rfmj3KR5`{BWQ`?~ zs<=YY{Oi2&N_CkhKLLNbnU&Qz@TZU>`dDE=$ZQp@w?S{ckUlh`if||Y+a3YNFP8)K zYystk0eV0*l~k2dtyjpXzDX%HP|Rg+yv(DH%xtnmq3P?@KR@~SgXb#5h$_j&Wy=yq zWuve7hbccc!}JVt-T5NccDBXlwOTxyJIW=jilOAJB3!?p7DnqxZ%!_0U>l71fuz%b zF5CqK%$8kYIrienpZ?jH|rQlwlM43;Q=$APQ-*Z@M@h zKh_t1?}Ha5PpqpMml{?prlmw{LiNdDW9_$1i+v73pthjL^!%Q|&=Zff9~z@ng`P8h zaT;!9ZLj_lVrXs;fKH(+>wK1Hqk#Q=1(1^ytEev^>MRwnC6AMQr;x|zV+5aAgCvG^ zbDs-FK-b?4OE}+=F)j}1c`X{YU%nzB+Xb#GESSc@#SBg2T26zaExf4?TF!_TLy+>H z^fHo&fc9{X1D9)d{oLVm7(Oju6Jpo2V1uAnc>Re-<=9?qUs zv}KX7ug1rPd0Zv-3$pZu2k3%aJHYCH3cBa5Fk~GFAXr#OLy{2M4ZXHtRc~g$#FhQ7 zXpH3th7o?vdkTP}yAV`f6}|jHwv)=dA-2>FI9UN1_vm`yGEsHq3*-`uk5#IJDUY=c z5_8vID#Sm#3X*{p;+p`H&UdzhE#5Wl@Z?tpxYI%x_p(>ZQuMFEGn9&bJ}*z)!y|K( zfj^sclb*PzVOpw>;?6^|PjgNa^fFn#_VL;`_m-6h&PQUhuaQ(ya|jgZWz`y0hLvNV zs8OeZ2;pcV9)JpaSLfCk3+}(99WS}B{Cay{%{TA2%!e;s^sW42{mbTK1^8=A9*+{> zH1oyDY=qyPxkkapd{?g%OqHVMkw4Vu&%wlWfaC32QK?5uG1ew*%n)<~>ShMa{^&dY zAJH*}rS>kKxwm@L=xR59Yyow>G)DOSB?nbGNtqMAYDNTo`4=$_7(@<5SdyJ$r17%0Fs8V@}1Q0zPQMN zXD}@MlAhk9Ot?+>f(8hR7$H5Le`nsc4Vh?{R~N4--#{2bpL-p0mBwAf9)lBw#a{0* z3AFb2aG(0-Q=gA*1ao7iu(Xu$4me@wYpnye_Xc6L@nW*q_*M&dXZN!zj!8$=C-+^zKer1a&+dk=b5legJj+ z&7dba^)hn9@(=`xSTqi2vWfyv0qn_g0!Qov-nAiJC_OU1(ICKV_m~N2Hay4Ur{fS6_IOu2q z*FteuM&2T57;uwv8Lae0B283b(413S!vGvE%!oZj;NbUJ?lR&rcV#JH{XZ$7R$>Mv z+04pANxd6{=QkYQ*?SfqRj5*Y14Q|Xg%$qu!zmGQ+rm6c77J{A7fOdYrThq|utp-{ zqKN%%Ut~@0M}&a7N!J;VPLjIRT@Q~&(=K|5Z`QL&k^C+*^xGG&y zXP?(Wv&YUIIFY%VHQWDQz#U_zPm{UoeeT_)lXSRczi8xLW;#?PTQAn4(v~4M7IF)3 z?ClDK1^*|TSc6|KpdB%2D=}uex7(A8z4Gz+>3XW06;HJkknP0t|A1l63r4W|G zn13+73c$8h&GJc5_1xo9I0A)@od5IqZKj}(20+RUE+EEo1`78RvI$Kp&l=JAXQi$7 z*|2`a5)~fSfN^^-P^7A{#Dd(i%2OxY3X&I4GN+5-Sy_W`06Oi)u+}o3Bf$2c*3RqV+a++x$U*<0oeGHJioBXUsZB_;T!rT3xiS9O;SxLu;#r zu37SrZv)!({>&6o+}mao{-7JPnJ&j`{i%)(Hwgh>-7*9~0fEcDo6g6d>0`2fR`M)a z20r&N;tO}VLEi-jo%5gJUfEiHlh z#aKDQESyZd;#S-gUvUh3ySuq;K|ViS<|G)m)=SkflbnQ9h)7$e_oUEPf9~rYR7lbz$B)DV;SbH^!`-%ry#;y7OR;eJlXO zPpWpWl40_dqv=v6TH+j#SckpcyA>UcxVR-#SnrxH6x6%&@U?J5woBk01$E6q5Pktu zLhnd>PmHeXU;v8H++_tJejqEJK3#VrP21v0JtxA&lAnZ9&7)lEcg2RNHBtXMr-n5D zdhW>&yUm~eRmXG@WC*S~ctRQxO zp89|GP_i5a3Nx^i5c`y>0YC?Ns_{YE{VfXyP(3Hhn{qBXV=+bnxDm#prYUYKjLc#C z`u9HX7S_}O>eAXmXkKNQcAaOrASntp#MiG~;`AOSBOILWnGL^)VKov^Utmoy$}NuX zllYTUBzIN_4m2C0@&F!qm;FOTDsY$vWtNxwkWUe4?1$f9nwz{5Q(&B(ws2XfOF>i8 z5^`Q30J0bD(R5DWpa9cS!-UIKw*!2@n*K~lwYeO#FGD| zO_&lFA@s(>Gg4d2z+WfTaPe1L4pvH7oSX`nf)0xWHfAFP^S^DnemS~3?5)x zX$2cZ(bCP;deq6O6t7c*(`i)FP@FzDXQ&|wer6DH=0`73r}~Y(1E`h%Y-*go=oOMD zo+T3$NDk9V*h*OgrX5@d1Uz@yN!}#KhF1|HHt!?$X+!q;8_0zfh!!gg@zkm#bhL+o z(rNqghjxjf0vu3Lt+-<=UMP-7Q+mguOZ<7DsGM}@N$SZ{FSHrQAd7|0_S@WmR1&cP z$k~-HoU^NTl1qojxX`2piCgGmSqwue*%q0@ zoDS5teSyK`nOR`0vC2Sdeg%zcRuO(`PC{im--5jouXJO`(4osd>ZUn}$CQdR#{>mP|k{y*OdQ(lX`LZ&+FmV&#d@ihQr@DconffWHV(=+*vmyP6XgN!9GrH=%CSVl8W@ zX3|Ek@p^P%U9-qmBCgmHE+#*vmnFaQ{m%8lM^2A1nT0%TLx)Jk*-weN7SnBw9PCdAkaH z_d-=Gvdi=NRyEP%G8-eiKlf_eS^RwM3jY0)DPLbYdY#0I4>?~kJvI8&drOG4Fi z(F?>(dN*_5Bh}8NZl3l(7`5G24gDStAcEWb+qk!zR7a=s;ZT11bIFH|n7D#(Iz{u8 zw2_ksPiatdkQ5Dw){C5jk|Zq2>C~B_Tw@!Y62^52w7QLTi)OU(#x6z{zqkc^)NP0#BxF=dJ)Sj&W`h#-<;>vj4&ZdjNc}B% zoYe~q)88xr_+fBiuLJ*03ppGY?l(K0B3vCd!bcjOSu6zO1_m$t2l0+2NbZ?YVF=Hl zAD5f$oxxqr0|YN!got7vy96H1B}@FOc*j2*RYQ-@W7Bf(a_4dW?b~(^Bwc%r7vz zudXZk)oiu6WOD#<0_# z8DtNRWx9yC79rTidJ^Gq>jdl23I8$-Qu^ntWST4#3%QFAl}BN6Fqut{>l$5Tna6f^ z>&{O_owlaAd+(K5bSZJxkeR-+mzH;K-@mPrfl^1-2 z_Q(DI&Z)K>RvaR9a~at;sjK1ZmocLu+g)57QTX9b!CGjZz4*XP=$^=cqyx+HQ6b;O zpmXh#$Uk;7*$9u0%#?8-_*_qs*bWJwaZnS>dB5vv>VmrM&b zq4Podl6>gVci+p^lQ`@-({&btXrTM)dN^Q;G`O+IH>qlN*?j7a=*g+x_XZ(Jr&^s4dop3jZOKi#`^8rh; zNxKa6drFfxh_PsEV&-4n!<^oErE}OQp`(SyA9e&0mds>dl#g<-fCqc zcS19sF`hTVAQm^7L9VRpPiVDC*0#mhVH5 zZy>1cE1U{SR8zQ7C)~Du+y#7A!7GBa%to3-Er6>{O?{LIk&FyN3ll7_)stluk3RC( zo#kCd<}D?}HM4K@a_u4iE=dQzf#ws0OVgpG3D;_Xh(w!j{2%WMR&1EBK!;K>r*Xia zUui~KdRlaCE2Wlsetn3e?jM>&=)D&m!ewtHtim?)T$k^f6=us&;mkf$i}np@(xh&P znLM?lN*h($7dNF}S$WV+2%S^b3?XY!5w8uf?I*UBxtO%oz@xxT2{W8vln;G@ea2j|bAN{Z+kKr*Xrpia;S`^^$t(t;0UN z*yOCJDJN!9(U9fIzO}VMmD;Z}5wK_bYP{io;s`wV?*Vo3{Mq!2VcJE!U?)0c>yysm zMT@q9ubvPP0*-BQd0-p-;$D(GlYU;Yml4 zmuW_0h{Pgw!zNYJE_){37lc`Cu) zpOc*lv`B&Dbr=p;kD2Xofs)eIWOb__wI?{_#0GmcY%7aIX zpHm@?`;O?^FaLSf-x$5psb4+OiQ%5IPHUi~7?{b;MF%{^t!9g&c5cJSOBT0Cgt#uv z1TG*zt4&8ieNViMT;`HE&0H6_T~tI!;g~|%(62NUQi$0i4`J?94~^hbGbH4!Ke^B6Su0t3FT<(Pja-4E}EWfDtIVX$$H`26ZfR0^H!A2?55 zx|5gc+niM|iljp=HQ2bJPUr`H1cCjIM$4L+YsDw`45+WGEZp^s22wEffIP3hc1ShJGS-7K@zsNZ}W{3vq& z!nDF=t=w{Q{i_7c-hrv7$;nMJc6EmoFvReTZYDS_%p_xtY%R4HDMe)w$kqV#08(eB zq?(4fLZcWcE9sp90y6c{KIV(yU5Q1bpkW#zSZ|9#)IYlv)yRRgF>fh31UDkk}Wn) z;4jsGNLC$aulok^Ahn5YijU4t8=L-*qH3y?K?tmUy*yi+E56hh%+-?G9!_^TRQg#7 zh7Y9{=2KUuSF%dLYYn~GZKnt_gzMh?DEBWYBM{^^7;J4Io(fA5Hh!@Ip*x%is`Q|P z_P&n7)f(Z=TamCxNfK`O*2Fsx0&j;67|wD-ju^s0+mY<^qB@8VBu(XDnKZ>3v7kSA z?@V2|Qma9|0P$Wu%yC-WI_E9{pS)R@_@2HQvziooj@l2YI*=Oh8^G0QR8wmY2bM=c z&u8tDEkkY9V3%#9lm3o)oPyzaylYjF^H`w8#XUiO0Dusl0_NLZSt=6?oJnEZk_pGW zIW$?A&-2*aSc53oiMSgFe;?k%9mfMtiW~V-6>ST_$5=FgRP*OCQmPJ4QDTWinUgbB zUsPwNv+xobTDKtl6=Ei>^Nb<_Rh16*eQ@pInC;j;0z%nCf>OB^uWRm0Pw58kcX;=! zKR%trM&FRY#LG;-)Bsyc1d#VA;L}gqW_DgE^~|(?0lY{eU1Sz?lwlxR2dM!et{jRS zRoI4BRg}Qxa)QX2EZzUV(zSU7kY~qNJ4eBq3hmrsC!1YGWPR~yY5HTzzr1EVtmCj-LeUT zAYp!L*9B@(M8S@sffOSW zxJ5EqrkJoVX{3{f+SJ&IyuIsT)(>Xb)K!*eNU~~rI`11N?=(w0zr65tyTJ<%iJ}`}0;y_&( zh=Whl=smLM&37&&T@{wMw_70@*>*2#&%O6xYI3K3*LlRun)A<@4GVjhX*A0jh3n z$*8^KXLw8Sjd~C_lq0eGeu6_YI=7{;XxQ(m%M7?c+E;bc$X-5`e&hZ#hxf}&ZMw>b zKoqR$vqqOJUajQxT>b%kiQ1lc3SjZLm?9Hl3J zkJ(JnC0K{IcUbu*&ZZjI>J@JEo(%c&;EwnNn@2c;9Q`Kd!Jr`<#4j&r6r_;Dv8F`;RCvH32~CjD zQbXMh@w>6OeY}E8ZP*v(5?+E>N1`mBtM{qwG_a0-0B}LXX@^QMp(mS08&B=aF`U6D zM|AiGpfNiQQ_Za{8_k1Ij+z+?RbF;J(Ifvzl4UU|lP~f4h(1EtkT;C4m|)p2>x^tF zljf7GknjhW@syL9uNueb+S=r96ENU7=#d^p|J7K)ch4Rsg&3V~nE-rIPHwW3>94mP z{%@TzUZWmScH%6~!t<=c>u}Z{#X+Z{lY#L|_D^9}=dJ4Do{Xqt>K;VCp-Ctl@T350 zoObMB+4TG?YGV~CZ=<|IDn`tO=cqqAEK{B>0O?-;Q!7BrJTPzypAHzt*7&Oq zHu`;8R{d&&JpL!~a{XMA)dK2XRuz)5-(l_OEz_5>dNBRRm$=tce? z(Wm$s1IX`O<>{re1_JxI_46JdKOkWfPWTf{5I^CBOrA*sfSEGxt6(|#W0tYzM~ON} z;Kz`t@*&v01}4pOPh6uAKlqdVx;dr--7ZADur3yTYcklLpu7gA>Qu5@>M;mog}Z?U zv+9Tas-6@hNt)Nd?;uKzLHp7h={vOby&7aS_R7f!OrFT2MH7Plfsf0t!&lZv0gc$Q zGaGh47BmS2rKw-)6wyB63zkRL+o2xBwM!xrEgl|KyC%X$nVy@tJj^TzZ+hES%N$~-5RM5pzwyTi2X(g-d2bd?yV*t5tY$> zdWFx&t@we!)y=eFDBci(MfyR+nrTyQ4OJeNk$Q`6hEg|)fFUsrv7R1>RH7g=tU1P% zaW_&aiv4*_=GPeJVOuT;e4bB#gbHi{C3l&w!+QAvmqha>YEnmig*NE!O~-)`t8lii zWio##C(e)#%Y^V(5*0`5B;B$3X+9tRSfTLfy|WVc|AepnlR+Y2C@9$_)=q63`4J_? z2$JoLaP~_86+Jpx!9=Ohws0{bNLawumUp2`J8d_Zmr;w3WDZ_NK+XmLNS| z?*o0GJ6Ufi2O)*fXd9UODXv+gWn*e!x47Twt>JOs@t7C-LXU^vFbWpnRfy5uU#A1f zo0O>(c>cO2HNv%_%qZPA$F2baWw|1Kspn4|0lx}B^=Z=~*H~8tCdKi3oUBWZzY`?W zncC|U zi_Cw!WwAID+E9oW;ePLy>MnpZNRtfy(+Gi>U#r)Mk!l4!V@RCB9L-|$mGlOk6Ub>ADrU(%vU z0Ob_mv+tPk=y$Wkbj2Wi6MVlfbw@ze5q22shJ3lNtV<5A6fC}jH2Em@3cYe?FbyC6 zh9nxNBnazmO>k=oEvjpm=ep%~acDl43%U=mx;pKRW14PW=y=!jJV#4X2BemKU15RQ zo#dR9p)t3FqMb6Jg502iNQv{&h_DQP`>q}~OwZ7-y1X*~#ed7?6X)NckWrjG8-g*O zFd_{t0X6I6%34dE4R=V{DkfyjOhnYBhU~a?sNPEu0!8n{Yop&L_5LxNJ~KwEnmLm) zHLa6QSYo2~`I4?gdl9_*Zgz8^~PR|f?MAt|EdA8pSGrsmQ zw|b*$K-P)J^>*g8A)e$&@EH=^~xSyaJ^0*sBHybB$EAL02yCJ$G|eu znwW*|E-JDwzX9To8P5`2NsT9H;2I((e}8vtk;eg1;3NWnmDRmAGCj|ptjSAHh+8*+ z1gRTC*J|}P=cxK1e%?nOjTtge63{Z7@kTLN%kp;G`vY9X5E+jw*MLeaYXr7p55iofC@&F#2YzJWgyhYaSzNh;A6{8C*&{|EOmL z_y4|-F<6sgjP6c&LA#yO_F>`ANdxc>hma|pEl^rZ{hKS6faHTt0ORlcGGK4c%9y}X z;f(Voi)J;+xTr>`1XY2tiI1@gpDl!c_S?c+RqgpJr~ydI6@#*!&a+5X+Pd3GE$; z7Ll(f=YU(30wxyAzYEG4N@xAVf)#H{1Po9rvx~xL+XRnLvDfcb8Vxf0wlrfCpSH5S}j%mBPR)(SsE0JjYqws>+cKW;8+N z{n5r5bHwVt8wI`i!nI6#?t_6|+M5hNOn`MNmiiUs#wKgU=p29I+R+`7m=pyz($!r= zeQUif_8Sa=B!B#?l<~!KUMVN38vgLlgCB3OHK%s50CByGCHTH%e*7tkSm_})hjYZ~ zF-nWQ?<9h&fWUe#L$5NAJ9ckwSSaHQS=9+n7JG`nu&HiU*%s16X1a_u)4TVe%im;^ z7Qf-aafTF0$tU+iuM|eWKmr+f}UPlej}}eksD1;|RWTuo3}2CvsmH&;n`O75ge_ z6c`DOy_;~vaQ(rBD5e9_cz1aHL4W;v@Zt61i6Y$3i>-05k1N6a_)m7H#kLV~nm&&m zD?!oP*G}ZlcvARR=w)4x&iJ?CXwG;QiMCOYd^cg+$E%NZcaNX5G{(miE|Sxs*5An# z&A?w=fq>JZfbbrBtJ zf(RE`0%mly3+H^b6_UvdOmDq|Y$Iyy@O$2`lONw|Bd-MF556ws4P)QLuS|{09 z4PH66iDjj(tW7ETv&5yIgv+nmm8|T(-3(`1^a)uym3Zm1ApE;pJ3!cc?~{vTx{Vtr ziwB9(gmJ;Zj*q}~L|-bg48}``DlYc$^jNQY@Ih>?)TsdoCb@E++vj3&>=QT1h*Az! zA@BeIp_Xt1oh4g!uz_r@x?EdPSS63Ht5TL@43Ih2ztHAw&u!PRMdu+YJNe&XDoo*KyN@l3)p(_&ygP|yQ@gwtT0z5_WI$!%tKzfxnbc9KtVWmwyGVdn(Lm`oU!iEXhML`{ zGYf(~2il_u_HcWT7>MbNF8Cv%!DA0DpCDmGdOsVp4eJ%>a$(vJo42X!mC&NQFgg!s z$h@Fg@jbe+zD`8FYRi#_f7AnB6Q}ORZ*t{-AV6C;rz1AkR|RQ}`*dupJNL!b*Z^99 z00oQyAMOk(OOn3>2WJNB6ubwB?jZW+UkOwg3AW<2Nu8{*;IY$t0Yxlb2vrs#gd3hQ z!0iH#Qu7q~ZE*b%LPEkYS#fD9E-t`f)FKan3pa<}c64P&ceA6G2^Ws9?S87^{$8f?U2lBznrXC5gLJ$fcaqKZCKJ~-!E-#V#P%l zZtbzAzVggP5@%}Kb!aIWmgtm1(&FTDNB)E#mU?GFK&h$KMS~NO`1k~$A800PU% z<%iS5jWE%N5cZt@cAWT3J1z)Y$g#nj`~|(qQj*l~D~JED5(oeR&ozM%qa8e>t}U+e zy<)#j8Ql@&^n-vElb`-Xy6E*m?J&B#u*t0MIiUaHel6ItI z-TCO{x8~FS)S?S13w1S)W6qa!>KL2Y3c0@E_+Z&Ay)G;ye?p<>nK%M=IH f5wxXnj>4om$)K}(@fYy$8e1}b&QtpnBtQTF%ut7U literal 0 HcmV?d00001 diff --git a/docs/gallery/contact-sheets/socket-attach-points-contact-sheet.webp b/docs/gallery/contact-sheets/socket-attach-points-contact-sheet.webp new file mode 100644 index 0000000000000000000000000000000000000000..953d740564f29a05c28a4889896692027eb2b2b9 GIT binary patch literal 47026 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 7cf0d43..b8c3390 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" /> - 42 examples + 43 examples
@@ -784,6 +784,17 @@

lightmap-uv-channel

View example
+
+ + socket-attach-points — A survey drone whose named SKT_ empties are the spawn contract: modules parented with an identity local transform seat exactly on their mount pads. + +
+

socket-attach-points

+

A survey drone whose named SKT_ empties are the spawn contract: modules parented with an identity local transform seat exactly on their mount pads.

+

witnesses 7 socket world matrices within 1.788e-07 of the authored transform, socket +Z == the pad's Newell normal (1.794e-07), seating offset 0.0; the no-parent-inverse probe jumps 0.690128 m.

+ View example +
+
diff --git a/docs/gallery/socket-attach-points/index.html b/docs/gallery/socket-attach-points/index.html new file mode 100644 index 0000000..6e618c3 --- /dev/null +++ b/docs/gallery/socket-attach-points/index.html @@ -0,0 +1,1427 @@ + + + + + + socket-attach-points — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

socket-attach-points

+

A survey drone whose named SKT_ empties are the spawn contract: modules parented with an identity local transform seat exactly on their mount pads.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses 7 socket world matrices within 1.788e-07 of the authored transform, socket +Z == the pad's Newell normal (1.794e-07), seating offset 0.0; the no-parent-inverse probe jumps 0.690128 m.
+
+
blender --background --python examples/socket-attach-points/socket_attach_points.py --
+ +
+
+

A runnable example building the thing a spawn system actually leans on: named socket empties parented into an asset, so a module dropped onto a socket with an *identity local transform* lands exactly where the artist put it, oriented the way the naming convention promises. Every clause in that sentence is a matrix identity, and every one of them drifts silently — a skipped matrix_parent_inverse, a basis built with the cross product the wrong way round, a transform frozen on the parent after the sockets were placed.

+

The asset, for reuse: Drone.Survey, an 0.86 m span survey quadcopter — a lofted fuselage with a vented deck and canopy face, four canted booms with finned motor cans and nav lights, and a two-rail landing gear. Seven mount pads carry seven sockets, and four interchangeable modules seat on them:

+

| Socket | Mount normal | Module | | --- | --- | --- | | SKT_Rotor.FL/FR/RL/RR | 8° canted outward | Mod.Rotor (twisted, tapered blades) | | SKT_Camera | belly, −Z | Mod.CamPod (gimbal ball + lens) | | SKT_Mast | dorsal, +Z | Mod.Mast (antenna, dish, beacon) | | SKT_Battery | rear, tilted down | Mod.Battery (cell block + gauge) |

+

Origin is the skid contact plane centre (z == 0 is where it rests, +X is forward), transforms are identity by construction, datablocks live under Drone.Survey.* and SKT_*, and accents are per-face material slots rather than extra objects. To use it: instantiate a module, parent it to the socket, zero the local transform. That is the whole API — and it only works because the socket matrices hold.

+

Socket convention (encoded in the SKT_ prefix):

+
  • +Z — the outward mount normal, matching the mount pad's own face normal.
  • +Y — the module's up reference: chassis +Z orthogonalised against the socket normal, falling back to chassis +X when the normal is parallel to chassis up (the belly and dorsal mounts take that branch).
  • +XY × Z, so the basis is right-handed (det == +1).
+

Pipeline arc neighbours: pivot and origin discipline in prop-origin-transform, parent-inverse under animation in parent-inverse-orrery, tile-grid boundary contracts in modular-kit-snap, collision packaging in collision-hull-proxy.

+

What it witnesses (all closed form or independently re-derived):

+
  • Socket matrices. All 7 evaluated matrix_world values equal root.matrix_world @ authored_local within 1e-6 (measured 1.788e-07); every basis is orthonormal (Gram error 3.576e-07) and right-handed (det − 1 = 2.980e-07).
  • Orientation vs geometry. Each socket's world +Z equals the Newell normal of its mount pad's mount face — computed from raw vertex coordinates, and the pad mesh is built by a *quaternion swing from +Z* while the socket basis is built by *explicit Gram-Schmidt*, so the two derivations are independent (normal deviation 1.794e-07). The socket origin sits on that face's centroid (6.687e-08), and the up axis matches the documented fallback rule (1.943e-07).
  • Seating. Every module's mount origin coincides with its socket origin (offset exactly 0.0) with its mount axis on the socket +Z (dot − 1 = 1.735e-07), and carries a strictly identity local matrix — seated by the socket, not nudged into place.
  • Rigid invariance. Re-posing the chassis root to a second arbitrary transform moves every socket to root.matrix_world @ authored_local (2.384e-07) and every module stays on its socket (0.0).
  • Transform apply. Freezing loc/rot/scale on the root leaves every socket's *world* matrix put (2.235e-07) — see the hazard below for what it does to the *local* ones.
  • Reuse hygiene. 14 chassis parts at identity scale, Drone.Survey.* / SKT_* names, no default datablocks, min z == 0.00e+00.
+

Hazards found while authoring

+
  • Applying a transform on an Empty root pushes it down into the children. An Empty has no object data to bake into, so transform_apply on the root resets 7/7 children's matrix_parent_inverse to identity and hands each child the root's scale locally (measured 0.35 for an applied 1.35). World matrices survive to float32; *local* transforms do not. A spawn system that reads matrix_local, or an exporter that trusts "transforms are applied", reads different numbers after an artist freezes the rig. Both halves are asserted, so the behaviour cannot change under us silently.
  • Only the root may be selected during the apply. A child left selected gets the parent transform applied twice — measured 2.335 m of socket drift on this asset.
  • bm.normal_update() does not fix winding. It recomputes normals from the existing winding. The lofted fuselage wound its side quads inward and rendered as a flat unshaded white panel rather than an obvious hole; bmesh.ops.recalc_face_normals is what fixes it, and Part.finish now always calls it.
  • Bevel offsets near half the local edge length collapse. The skid rails at a 5 mm offset on a 9.9 mm octagon edge produced 3 zero-area faces each — the collapse degenerate-bevel-weld witnesses. 2.2 mm is clean. Note that examples/gallery_asset_quality.py raises ValueError: zero length vectors have no valid angle on such a mesh rather than reporting it.
+

What each check catches on failure (every one probed, with the measured error): sockets parented without the parent-inverse — worst jump 0.690128 m (exit 3); a left-handed basis from z.cross(y)det == −1.000000 (exit 3); a rotor pad built flat instead of canted — 1.395e-01 off the Newell normal (exit 4); an 11° roll on the basis — 1.917e-01 off the up rule (exit 4); a module nudged 4 mm off its socket (exit 5); a default Cube datablock name (exit 6); a socket left unparented so it ignores the re-pose — 1.417e+00 (exit 7); a child selected during the apply — 1.252e+00 to 2.244e+00 of socket drift (exit 8).

+

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same counts, same measured deviations to every printed digit, and the same framing numbers on the render path.

+

Render as proof: the drone on the dark stage with every module seated, the orange mount pads reading as the socket signature. The falsification variant (--falsify) parents the sockets without matrix_parent_inverse: the pads are left bare, the rotors and mast vanish from the airframe, and the camera pod and battery hang in space near the top of frame. A broken matrix is not a subtle pixel difference here — the asset comes apart.

+

Run

+
blender --background --python socket_attach_points.py --
+blender --background --python socket_attach_points.py -- --output drone.png
+blender --background --python socket_attach_points.py -- --falsify adrift.png
+blender --background --python socket_attach_points.py -- --probe
+

Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. The --output render path additionally gates framing via examples/gallery_framing.py (fill 0.881x, margins 0.066/0.053/0.122/0.106, no edge touched) and the asset floors via examples/gallery_asset_quality.py (32 materials, edge90 0.027, no default names). The --falsify render is a diagnostic, not a gallery hero, so it takes a documented framing deviation — its whole point is that the modules leave the frame.

+
+
+

Source

+
+ examples/socket-attach-points/socket_attach_points.py + View on GitHub → +
+
"""Socket attach points — named empties parented into an asset as spawn mounts.
+
+Witnesses the contract a spawn system depends on when it bolts modules onto a
+prop at runtime: an attachment placed at socket S with an identity local
+transform must land exactly where the artist authored the socket, oriented the
+way the naming convention promises. Everything in that sentence is a matrix
+identity, and every one of them can silently drift — a skipped
+``matrix_parent_inverse``, a basis built with the cross product the wrong way
+round, a transform applied on the parent after the sockets were placed.
+
+The asset is a survey drone chassis with seven sockets: four canted rotor
+mounts, a belly camera mount, a dorsal mast mount, and a rear battery mount.
+Sockets are authored in WORLD space (where an artist actually places them) and
+then parented to the chassis root, which is itself posed at a non-trivial world
+transform — so the parent-inverse is load-bearing rather than incidentally
+identity.
+
+Socket convention, encoded in the name prefix ``SKT_``:
+
+    +Z  the outward mount normal — the direction the module grows away from
+        the hull, matching the mount pad's own face normal
+    +Y  the module's up reference: chassis +Z orthogonalised against +Z_socket,
+        falling back to chassis +X when the normal is parallel to chassis up
+    +X  Y x Z, so the basis is right-handed (det == +1)
+
+Check (all closed form or independently re-derived, nothing captured):
+
+1. Socket matrices (exit 3): every evaluated ``matrix_world`` equals the
+   authored world transform within 1e-6, and every basis is orthonormal
+   right-handed (det == +1, Gram error ~0).
+2. Orientation vs geometry (exit 4): each socket's world +Z equals the Newell
+   normal of its mount pad's mount face, computed from mesh vertex coordinates
+   by a construction path (quaternion swing from +Z) independent of the socket
+   basis (explicit Gram-Schmidt); the origin sits on that face's centroid; the
+   up axis follows the documented fallback rule.
+3. Seating (exit 5): each module, parented to its socket with an identity local
+   transform, puts its mount origin exactly on the socket origin and its own
+   mount axis exactly on the socket +Z (dot == 1 within 1e-6).
+4. Reuse hygiene (exit 6): identity object scales, ``Drone.Survey.*`` /
+   ``SKT_*`` names, no default datablock names, chassis resting on z == 0.
+5. Rigid invariance (exit 7): re-posing the chassis root to an arbitrary
+   transform moves every socket to ``root.matrix_world @ authored_local``
+   within 1e-6, and every module follows its socket.
+6. Transform apply (exit 8): applying loc/rot/scale on the chassis root leaves
+   every socket's world matrix unchanged within 1e-6 — but, because the root is
+   an Empty with no data to bake into, Blender pushes the transform DOWN into
+   the children: every ``matrix_parent_inverse`` is cleared to identity and
+   every child picks up the root's scale locally. Both halves are asserted, so
+   the hazard cannot silently change under us.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python socket_attach_points.py --                  # check
+    blender --background --python socket_attach_points.py -- --output d.png   # + render
+    blender --background --python socket_attach_points.py -- --falsify f.png  # no-MPI variant
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector, Matrix
+
+# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True  # keep examples/__pycache__ out of the repo tree
+import gallery_framing
+import gallery_asset_quality
+
+TOL = 1e-6            # matrix / position tolerance (metres, unitless for axes)
+                      # float32 noise on a 4x4 world matrix measures ~3.6e-07 here
+ORTHO_TOL = 1e-6      # orthonormality + handedness tolerance
+PARALLEL = 0.999      # |n . chassis_up| above this takes the +X up-axis fallback
+
+CHASSIS_UP = Vector((0.0, 0.0, 1.0))       # chassis local up
+CHASSIS_FWD = Vector((1.0, 0.0, 0.0))      # chassis local forward
+
+# Chassis geometry, metres. Origin is the skid contact plane centre: z == 0 is
+# where the drone rests, +X is forward. An 0.86 m span survey quadcopter.
+HULL_Z = 0.152            # fuselage centreline height above the skid plane
+DECK_Z = 0.214            # top of the fuselage
+BELLY_Z = 0.090           # bottom of the fuselage
+ARM_YAW = math.radians(41.0)
+ARM_LEN = 0.430           # hub centre -> rotor pad centre, horizontal
+ARM_RISE = 0.053          # rotor pads sit this far above the centreline
+ROTOR_CANT = math.radians(8.0)   # rotor pads cant outward: NOT axis aligned
+PAD_T = 0.014             # mount pad plate thickness
+
+# Chassis root pose in the scene: translated, yawed and banked, so the sockets'
+# parent-inverse is load-bearing rather than incidentally identity.
+ROOT_LOC = (0.240, -0.130, 0.470)
+ROOT_ROT = (math.radians(-7.0), math.radians(5.0), math.radians(24.0))
+
+# Re-pose used by check 5 (rigid invariance) — a second arbitrary transform.
+REPOSE_LOC = (-1.150, 0.820, 0.315)
+REPOSE_ROT = (math.radians(13.0), math.radians(-21.0), math.radians(-64.0))
+
+# Transform-apply pose used by check 6: a uniform scale, so applying it is a
+# real change to the object transform rather than a no-op.
+APPLY_SCALE = 1.35
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+# ---------------------------------------------------------------------------
+# Socket specification (authored, chassis-local)
+# ---------------------------------------------------------------------------
+
+def rotor_dirs():
+    """Horizontal outward directions of the four arms, front-left first."""
+    out = []
+    for tag, sx, sy in (("FL", 1, 1), ("FR", 1, -1), ("RL", -1, 1), ("RR", -1, -1)):
+        yaw = sy * (ARM_YAW if sx > 0 else math.pi - ARM_YAW)
+        out.append((tag, Vector((math.cos(yaw), math.sin(yaw), 0.0))))
+    return out
+
+
+def socket_spec():
+    """[(socket_name, pad_name, centre, normal, pad_radius)], chassis-local.
+
+    The normal is the declared mount direction. The pad MESH is built from it
+    by a quaternion swing from +Z; the socket BASIS is built from it by
+    explicit Gram-Schmidt. Check 2 compares the two independent derivations.
+    """
+    spec = []
+    for tag, d in rotor_dirs():
+        # canted outward: the rotor discs tilt away from the hull, so no rotor
+        # socket is axis aligned and a "just use +Z" spawn breaks visibly
+        n = (CHASSIS_UP * math.cos(ROTOR_CANT) + d * math.sin(ROTOR_CANT)).normalized()
+        centre = d * ARM_LEN + Vector((0.0, 0.0, HULL_Z + ARM_RISE))
+        spec.append((f"SKT_Rotor.{tag}", f"Pad.Rotor.{tag}", centre, n, 0.047))
+    # belly camera mount: normal is -Z, exactly antiparallel to chassis up ->
+    # exercises the +X up-axis fallback
+    spec.append(("SKT_Camera", "Pad.Camera",
+                 Vector((0.062, 0.0, BELLY_Z)), Vector((0.0, 0.0, -1.0)), 0.040))
+    # dorsal mast mount: normal is +Z, parallel to chassis up -> fallback again
+    spec.append(("SKT_Mast", "Pad.Mast",
+                 Vector((-0.130, 0.0, DECK_Z)), Vector((0.0, 0.0, 1.0)), 0.034))
+    # rear battery mount: rearward and tilted down, a fully general normal
+    spec.append(("SKT_Battery", "Pad.Battery",
+                 Vector((-0.286, 0.0, 0.146)),
+                 Vector((-1.0, 0.0, -0.26)).normalized(), 0.042))
+    return spec
+
+
+def socket_basis(normal):
+    """The documented convention, by explicit Gram-Schmidt. Returns a 3x3."""
+    z = Vector(normal).normalized()
+    ref = CHASSIS_FWD if abs(z.dot(CHASSIS_UP)) > PARALLEL else CHASSIS_UP
+    y = (ref - z * ref.dot(z)).normalized()
+    x = y.cross(z)                       # x cross y == z, so this is right-handed
+    return Matrix((x, y, z)).transposed()
+
+
+def authored_local(centre, normal):
+    """The socket's authored chassis-local 4x4."""
+    m = socket_basis(normal).to_4x4()
+    m.translation = Vector(centre)
+    return m
+
+
+# ---------------------------------------------------------------------------
+# Mesh construction
+#
+# Parts are assembled from primitive groups, each tagged with a material slot
+# name. Slot indices are written onto the bmesh faces before to_mesh, so
+# accents (lens glass, nav lights, pad rings) are per-face assignments inside
+# one object rather than extra objects — the same technique
+# custom-normals-shade and lod-decimate-chain use for their accents.
+# ---------------------------------------------------------------------------
+
+class Part:
+    """A bmesh under construction with named material slots."""
+
+    def __init__(self):
+        self.bm = bmesh.new()
+        self.slots = []            # slot name per material index, in order
+
+    def slot(self, name):
+        if name not in self.slots:
+            self.slots.append(name)
+        return self.slots.index(name)
+
+    def group(self, slot_name, fn):
+        """Run fn(bm), then tag everything it created with slot_name."""
+        before = set(self.bm.faces)
+        fn(self.bm)
+        idx = self.slot(slot_name)
+        for f in self.bm.faces:
+            if f not in before:
+                f.material_index = idx
+        return self
+
+    def finish(self, name):
+        me = bpy.data.meshes.new(name)
+        try:
+            # HAZARD: bm.normal_update() recomputes normals from the EXISTING
+            # winding — it does not fix a face built the wrong way round. The
+            # lofted fuselage below winds its side quads inward, which renders
+            # as a flat unshaded white panel rather than an obvious hole.
+            # recalc_face_normals makes each shell outward-consistent; it runs
+            # per connected shell, so the intersecting sub-solids in one Part
+            # are each fixed independently.
+            bmesh.ops.recalc_face_normals(self.bm, faces=list(self.bm.faces))
+            self.bm.normal_update()
+            self.bm.to_mesh(me)
+        finally:
+            self.bm.free()
+        me["slots"] = self.slots      # material slot order, read by the renderer
+        return me
+
+
+def _box(bm, dims, centre, bevel=0.0, segments=2, rot=None):
+    before = set(bm.verts)
+    bmesh.ops.create_cube(bm, size=1.0)
+    made = [v for v in bm.verts if v not in before]
+    for v in made:
+        c = Vector((v.co.x * dims[0], v.co.y * dims[1], v.co.z * dims[2]))
+        if rot is not None:
+            c = rot @ c
+        v.co = c + Vector(centre)
+    if bevel > 0.0:
+        edges = [e for e in bm.edges if all(v in made for v in e.verts)]
+        bmesh.ops.bevel(bm, geom=edges, offset=bevel, segments=segments,
+                        profile=0.5, affect="EDGES", clamp_overlap=True)
+
+
+def _prism(bm, sides, radius, half_h, centre, stretch=1.0, bevel=0.0, rot=None,
+           taper=1.0):
+    """An n-gon prism along local Z, optionally tapered toward +Z."""
+    before = set(bm.verts)
+    ring = []
+    for i in range(sides):
+        a = 2.0 * math.pi * i / sides + math.pi / sides
+        ring.append((math.cos(a) * radius * stretch, math.sin(a) * radius))
+    top = [bm.verts.new((x * taper, y * taper, half_h)) for x, y in ring]
+    bot = [bm.verts.new((x, y, -half_h)) for x, y in ring]
+    bm.faces.new(top)
+    bm.faces.new(list(reversed(bot)))
+    for i in range(sides):
+        j = (i + 1) % sides
+        bm.faces.new((bot[i], bot[j], top[j], top[i]))
+    made = [v for v in bm.verts if v not in before]
+    for v in made:
+        c = Vector(v.co)
+        if rot is not None:
+            c = rot @ c
+        v.co = c + Vector(centre)
+    if bevel > 0.0:
+        edges = [e for e in bm.edges if all(v in made for v in e.verts)]
+        bmesh.ops.bevel(bm, geom=edges, offset=bevel, segments=2,
+                        profile=0.7, affect="EDGES", clamp_overlap=True)
+
+
+def _rounded_ring(half_w, half_h, z_c, corner):
+    """Rounded-rectangle cross-section in (y, z), CCW seen from +X."""
+    hw, hh, k = half_w, half_h, corner
+    return [(-hw, z_c - hh + k), (-hw, z_c + hh - k), (-hw + k, z_c + hh),
+            (hw - k, z_c + hh), (hw, z_c + hh - k), (hw, z_c - hh + k),
+            (hw - k, z_c - hh), (-hw + k, z_c - hh)]
+
+
+def _loft(bm, stations, bevel=0.0):
+    """Loft rounded-rect cross-sections along X. stations: (x, hw, zc, hh, k)."""
+    before = set(bm.verts)
+    rings = []
+    for x, hw, zc, hh, k in stations:
+        rings.append([bm.verts.new((x, y, z)) for y, z in
+                      _rounded_ring(hw, hh, zc, k)])
+    n = len(rings[0])
+    for a, b in zip(rings, rings[1:]):
+        for i in range(n):
+            j = (i + 1) % n
+            bm.faces.new((a[i], a[j], b[j], b[i]))
+    bm.faces.new(list(reversed(rings[0])))       # rear cap
+    bm.faces.new(rings[-1])                      # nose cap
+    if bevel > 0.0:
+        made = [v for v in bm.verts if v not in before]
+        edges = [e for e in bm.edges if all(v in made for v in e.verts)]
+        bmesh.ops.bevel(bm, geom=edges, offset=bevel, segments=2,
+                        profile=0.6, affect="EDGES", clamp_overlap=True)
+
+
+def _blade(bm, length, root_chord, tip_chord, thick, twist_deg, yaw, z0):
+    """A tapered, twisted rotor blade: four stations lofted tip-ward.
+
+    A rotor blade is an aerofoil, not a plank — chord tapers, the section
+    twists toward the tip, and the trailing edge is thinner than the leading
+    edge. Modelled here because a plank reads as programmer art at any
+    lighting.
+    """
+    yawm = Matrix.Rotation(yaw, 3, "Z")
+    fr = [0.0, 0.38, 0.76, 1.0]
+    rings = []
+    for f in fr:
+        chord = root_chord + (tip_chord - root_chord) * f
+        t = thick * (1.0 - 0.55 * f)
+        tw = math.radians(twist_deg * (1.0 - f))
+        r = length * (0.14 + 0.86 * f)
+        sec = []
+        # 6-point aerofoil-ish section in (chordwise, thickness)
+        for cx, cz in ((-0.5, 0.0), (-0.18, 0.62), (0.22, 0.48),
+                       (0.5, 0.0), (0.22, -0.30), (-0.18, -0.42)):
+            p = Vector((cx * chord, 0.0, cz * t))
+            p = Matrix.Rotation(tw, 3, "Y") @ p
+            sec.append(yawm @ Vector((p.x + 0.0, r, p.z)) + Vector((0, 0, z0)))
+        rings.append([bm.verts.new(tuple(v)) for v in sec])
+    n = len(rings[0])
+    for a, b in zip(rings, rings[1:]):
+        for i in range(n):
+            j = (i + 1) % n
+            bm.faces.new((a[i], a[j], b[j], b[i]))
+    bm.faces.new(list(reversed(rings[0])))
+    bm.faces.new(rings[-1])
+
+
+MOUNT_FACE_SIDES = 12    # the mount face is a 12-gon; polygon index 0 by build order
+
+
+def build_pad(name, centre, normal, radius, thick=PAD_T):
+    """A mount pad plate whose MOUNT FACE IS POLYGON 0.
+
+    Construction path: a flat ring in the XY plane swung onto ``normal`` by
+    ``Vector((0,0,1)).rotation_difference(normal)``. This never calls
+    ``socket_basis``, so check 2's Newell-vs-socket comparison is a comparison
+    of two independent derivations of the same declared normal, not a tautology.
+
+    The pad is authored FIRST in the part so its mount face is polygon 0; the
+    collar detail below it is added afterwards and never disturbs that index.
+    """
+    n = Vector(normal).normalized()
+    swing = Vector((0.0, 0.0, 1.0)).rotation_difference(n)
+    part = Part()
+
+    def plate(bm):
+        top, bot = [], []
+        for i in range(MOUNT_FACE_SIDES):
+            a = 2.0 * math.pi * i / MOUNT_FACE_SIDES
+            p = Vector((math.cos(a) * radius, math.sin(a) * radius, 0.0))
+            top.append(bm.verts.new(swing @ p + Vector(centre)))
+            bot.append(bm.verts.new(swing @ (p * 0.86 - Vector((0, 0, thick)))
+                                    + Vector(centre)))
+        bm.faces.new(top)                        # polygon 0 == the mount face
+        bm.faces.new(list(reversed(bot)))
+        for i in range(MOUNT_FACE_SIDES):
+            j = (i + 1) % MOUNT_FACE_SIDES
+            bm.faces.new((bot[i], bot[j], top[j], top[i]))
+
+    part.group("SocketPad", plate)
+
+    def collar(bm):
+        # four fixing bosses around the pad: where a mount plate actually bolts
+        for i in range(4):
+            a = math.pi / 4 + i * math.pi / 2
+            p = Vector((math.cos(a), math.sin(a), 0.0)) * radius * 0.70
+            _prism(bm, 6, radius * 0.16, thick * 0.62,
+                   tuple(swing @ (p - Vector((0, 0, thick * 0.40)))
+                         + Vector(centre)),
+                   rot=swing.to_matrix())
+
+    part.group("Fixing", collar)
+    return part.finish(name)
+
+
+def newell_normal(me, poly_index=0):
+    """Area-weighted normal of one polygon straight from vertex coordinates.
+
+    Newell's method: independent of ``Mesh.polygons[i].normal`` (which Blender
+    computes for us) and independent of the pad's construction quaternion.
+    """
+    poly = me.polygons[poly_index]
+    co = [Vector(me.vertices[i].co) for i in poly.vertices]
+    n = Vector((0.0, 0.0, 0.0))
+    for i, a in enumerate(co):
+        b = co[(i + 1) % len(co)]
+        n.x += (a.y - b.y) * (a.z + b.z)
+        n.y += (a.z - b.z) * (a.x + b.x)
+        n.z += (a.x - b.x) * (a.y + b.y)
+    return n.normalized()
+
+
+def polygon_centroid(me, poly_index=0):
+    poly = me.polygons[poly_index]
+    c = Vector((0.0, 0.0, 0.0))
+    for i in poly.vertices:
+        c += Vector(me.vertices[i].co)
+    return c / len(poly.vertices)
+
+
+# Fuselage cross-sections: (x, half_width, centre_z, half_height, corner).
+HULL_STATIONS = [
+    (-0.280, 0.062, 0.152, 0.036, 0.018),
+    (-0.215, 0.122, 0.152, 0.058, 0.026),
+    (-0.060, 0.150, 0.152, 0.062, 0.030),
+    (0.090, 0.144, 0.151, 0.059, 0.030),
+    (0.205, 0.108, 0.147, 0.046, 0.024),
+    (0.286, 0.052, 0.142, 0.026, 0.013),
+]
+
+
+def build_chassis_meshes():
+    """Every chassis part, keyed by suffix. Chassis-local space."""
+    meshes = {}
+
+    # --- fuselage: lofted body + deck plate + canopy face + intake louvres ---
+    hull = Part()
+    hull.group("Carbon", lambda bm: _loft(bm, HULL_STATIONS, bevel=0.006))
+    hull.group("Deck", lambda bm: _box(bm, (0.250, 0.176, 0.012),
+                                       (-0.050, 0.0, DECK_Z + 0.002), bevel=0.004))
+    # forward canopy: the sensor face, angled down toward the nose
+    hull.group("Canopy", lambda bm: _box(
+        bm, (0.118, 0.150, 0.010), (0.176, 0.0, 0.186), bevel=0.004,
+        rot=Matrix.Rotation(math.radians(24.0), 3, "Y")))
+    def louvres(bm):
+        for i in range(4):
+            _box(bm, (0.020, 0.104, 0.007), (-0.140 + i * 0.030, 0.0, DECK_Z + 0.010),
+                 bevel=0.002, rot=Matrix.Rotation(math.radians(-18.0), 3, "Y"))
+    hull.group("Vent", louvres)
+    def flank(bm):
+        # proud of the skin, not flush with it: at y == +/-0.150 the strip was
+        # coplanar with the fuselage side and z-fought into a speckled band
+        for sy in (1.0, -1.0):
+            _box(bm, (0.196, 0.016, 0.030), (0.010, sy * 0.156, 0.150), bevel=0.005)
+    hull.group("Trim", flank)
+    meshes["Hull"] = hull.finish("Drone.Survey.Hull")
+
+    # --- arms: tapered booms with a hull-side fairing and a motor can --------
+    for tag, d in rotor_dirs():
+        yaw = math.atan2(d.y, d.x)
+        rot = (Matrix.Rotation(yaw, 3, "Z")
+               @ Matrix.Rotation(math.radians(-4.0), 3, "Y"))
+        arm = Part()
+        mid = d * (ARM_LEN * 0.56) + Vector((0.0, 0.0, HULL_Z + ARM_RISE * 0.42))
+        arm.group("Boom", lambda bm, r=rot, m=mid: _box(
+            bm, (ARM_LEN * 0.80, 0.042, 0.030), tuple(m), bevel=0.010, rot=r))
+        # the fairing is deliberately NOT coplanar with the boom's flat sides:
+        # at r=0.046/half=0.052 two facets landed on the boom faces and
+        # z-fought into a speckled patch at render scale
+        root = d * 0.142 + Vector((0.0, 0.0, HULL_Z + 0.004))
+        arm.group("Fairing", lambda bm, r=rot, m=root: _prism(
+            bm, 10, 0.053, 0.044, tuple(m), bevel=0.010, taper=0.52,
+            rot=r @ Matrix.Rotation(math.radians(90.0), 3, "Y")))
+        hub = d * ARM_LEN + Vector((0.0, 0.0, HULL_Z + ARM_RISE - 0.032))
+        arm.group("Motor", lambda bm, h=hub: _prism(
+            bm, 12, 0.036, 0.028, tuple(h), bevel=0.005))
+        def fins(bm, h=hub):
+            for i in range(8):
+                a = i * math.pi / 4
+                p = Vector((math.cos(a), math.sin(a), 0.0)) * 0.036
+                _box(bm, (0.008, 0.010, 0.040), tuple(Vector(h) + p),
+                     bevel=0.0015, rot=Matrix.Rotation(a, 3, "Z"))
+        arm.group("MotorFin", fins)
+        # navigation light: front arms green-white, rear arms red
+        nav = "NavFwd" if tag.startswith("F") else "NavAft"
+        arm.group(nav, lambda bm, dd=d: _prism(
+            bm, 8, 0.011, 0.006,
+            tuple(dd * (ARM_LEN + 0.030) + Vector((0.0, 0.0, HULL_Z + ARM_RISE - 0.038))),
+            bevel=0.002))
+        meshes[f"Arm.{tag}"] = arm.finish(f"Drone.Survey.Arm.{tag}")
+
+    # --- landing gear: two skid rails on splayed struts ----------------------
+    for side, sy in (("L", 1.0), ("R", -1.0)):
+        y = sy * 0.158
+        gear = Part()
+        # bevel offset must stay well under half the octagon's edge length
+        # (2 * r * sin(22.5 deg) = 9.9 mm here): at offset == min_dim/2 the
+        # band collapses to zero-area faces, the failure degenerate-bevel-weld
+        # witnesses. 5 mm produced 3 zero-area faces per rail; 2.2 mm is clean.
+        gear.group("Skid", lambda bm, yy=y: _prism(
+            bm, 8, 0.013, 0.185, (0.0, yy, 0.013), bevel=0.0022,
+            rot=Matrix.Rotation(math.radians(90.0), 3, "Y")))
+        def feet(bm, yy=y):
+            for xx in (0.150, -0.150):
+                _prism(bm, 8, 0.017, 0.008, (xx, yy, 0.008), bevel=0.003)
+        gear.group("Foot", feet)
+        def struts(bm, yy=y):
+            for xx in (0.112, -0.116):
+                _box(bm, (0.026, 0.024, 0.082), (xx, yy * 0.78, 0.055),
+                     bevel=0.006,
+                     rot=Matrix.Rotation(math.radians(12.0) * (1 if yy > 0 else -1),
+                                         3, "X"))
+            # shoulder block where the strut meets the fuselage: without it the
+            # rails read as detached rods floating under the airframe (draft 3)
+            for xx in (0.112, -0.116):
+                _box(bm, (0.044, 0.038, 0.022), (xx, yy * 0.62, 0.096), bevel=0.006)
+        gear.group("Strut", struts)
+        meshes[f"Gear.{side}"] = gear.finish(f"Drone.Survey.Gear.{side}")
+
+    # --- mount pads, one per socket ------------------------------------------
+    for skt_name, pad_name, centre, normal, radius in socket_spec():
+        meshes[pad_name] = build_pad(f"Drone.Survey.{pad_name}", centre, normal,
+                                     radius)
+    return meshes
+
+
+# Module (attachment) geometry, authored in MOUNT SPACE: origin at the mount
+# point, +Z growing away from the mount surface, +Y the module's up. Parented
+# to a socket with an identity local transform, a module seats by construction.
+def build_module_meshes():
+    out = {}
+
+    rotor = Part()
+    rotor.group("Hub", lambda bm: _prism(bm, 12, 0.028, 0.016, (0, 0, 0.016),
+                                         bevel=0.005, taper=0.82))
+    rotor.group("Spinner", lambda bm: _prism(bm, 10, 0.015, 0.014, (0, 0, 0.043),
+                                             bevel=0.006, taper=0.35))
+    def blades(bm):
+        for k in range(2):
+            _blade(bm, length=0.200, root_chord=0.058, tip_chord=0.032,
+                   thick=0.0115, twist_deg=15.0,
+                   yaw=math.pi * k + math.radians(5.0), z0=0.029)
+    rotor.group("Blade", blades)
+    out["Mod.Rotor"] = rotor.finish("Drone.Survey.Mod.Rotor")
+
+    cam = Part()
+    cam.group("PodYoke", lambda bm: _prism(bm, 8, 0.032, 0.018, (0, 0, 0.018),
+                                           bevel=0.006, taper=0.78))
+    cam.group("PodBall", lambda bm: _prism(bm, 14, 0.041, 0.034, (0, 0, 0.070),
+                                           bevel=0.016))
+    # lens barrel points forward: in the belly socket's frame +X is chassis aft,
+    # so the barrel runs along -X. Glass is its own slot, the only emitter.
+    cam.group("LensRing", lambda bm: _prism(
+        bm, 14, 0.023, 0.024, (-0.038, 0.0, 0.072), bevel=0.005, taper=0.88,
+        rot=Matrix.Rotation(math.radians(-90.0), 3, "Y")))
+    cam.group("LensGlass", lambda bm: _prism(
+        bm, 14, 0.016, 0.004, (-0.062, 0.0, 0.072), bevel=0.0008,
+        rot=Matrix.Rotation(math.radians(-90.0), 3, "Y")))
+    out["Mod.CamPod"] = cam.finish("Drone.Survey.Mod.CamPod")
+
+    mast = Part()
+    mast.group("MastBase", lambda bm: _prism(bm, 8, 0.024, 0.013, (0, 0, 0.013),
+                                             bevel=0.005, taper=0.72))
+    mast.group("MastTube", lambda bm: _prism(bm, 8, 0.0095, 0.088, (0, 0, 0.114),
+                                             bevel=0.002, taper=0.80))
+    def dish(bm):
+        _prism(bm, 16, 0.036, 0.004, (0, 0, 0.176), bevel=0.0015, taper=0.72)
+        _prism(bm, 8, 0.006, 0.016, (0, 0, 0.196), bevel=0.001, taper=0.4)
+    mast.group("Dish", dish)
+    mast.group("SensorHead", lambda bm: _box(bm, (0.070, 0.026, 0.040),
+                                             (0, 0, 0.226), bevel=0.008))
+    mast.group("Beacon", lambda bm: _prism(bm, 8, 0.008, 0.007, (0, 0, 0.252),
+                                           bevel=0.002, taper=0.5))
+    out["Mod.Mast"] = mast.finish("Drone.Survey.Mod.Mast")
+
+    batt = Part()
+    batt.group("Cell", lambda bm: _box(bm, (0.104, 0.124, 0.058), (0, 0, 0.058),
+                                       bevel=0.010))
+    def ribs(bm):
+        for i in range(3):
+            _box(bm, (0.014, 0.128, 0.048), (0.0, 0.0, 0.030 + i * 0.022),
+                 bevel=0.003)
+    batt.group("Rib", ribs)
+    batt.group("Latch", lambda bm: _box(bm, (0.018, 0.086, 0.024), (0, 0, 0.098),
+                                        bevel=0.005))
+    def gauge(bm):
+        for i in range(3):
+            _box(bm, (0.006, 0.014, 0.004), (0.038, -0.030 + i * 0.030, 0.088),
+                 bevel=0.001)
+    batt.group("Gauge", gauge)
+    out["Mod.Battery"] = batt.finish("Drone.Survey.Mod.Battery")
+    return out
+
+
+# Which module seats on which socket, and the module's own mount axis in its
+# authored mount space (+Z by convention — asserted, not assumed).
+MODULE_FOR_SOCKET = {
+    "SKT_Rotor.FL": "Mod.Rotor", "SKT_Rotor.FR": "Mod.Rotor",
+    "SKT_Rotor.RL": "Mod.Rotor", "SKT_Rotor.RR": "Mod.Rotor",
+    "SKT_Camera": "Mod.CamPod",
+    "SKT_Mast": "Mod.Mast",
+    "SKT_Battery": "Mod.Battery",
+}
+MODULE_MOUNT_AXIS = Vector((0.0, 0.0, 1.0))
+
+
+# ---------------------------------------------------------------------------
+# Assembly
+# ---------------------------------------------------------------------------
+
+def assemble(sc, root_loc=ROOT_LOC, root_rot=ROOT_ROT, skip_mpi=False,
+             chassis_meshes=None, module_meshes=None):
+    """Build the posed drone. Returns (root, parts, sockets, modules).
+
+    Sockets are authored in WORLD space (where an artist places them) and then
+    parented to the posed root. Keeping their world transform requires
+    ``matrix_parent_inverse = root.matrix_world.inverted()``; skip_mpi=True
+    omits it, which is the falsification probe.
+    """
+    chassis_meshes = chassis_meshes or build_chassis_meshes()
+    module_meshes = module_meshes or build_module_meshes()
+
+    root = bpy.data.objects.new("Drone.Survey", None)
+    root.empty_display_type = "PLAIN_AXES"
+    root.location = root_loc
+    root.rotation_euler = root_rot
+    sc.collection.objects.link(root)
+    bpy.context.view_layer.update()
+
+    parts = []
+    for suffix, me in chassis_meshes.items():
+        ob = bpy.data.objects.new(me.name, me)
+        sc.collection.objects.link(ob)
+        ob.parent = root
+        parts.append(ob)
+
+    sockets, modules = {}, {}
+    for skt_name, pad_name, centre, normal, radius in socket_spec():
+        local = authored_local(centre, normal)
+        skt = bpy.data.objects.new(skt_name, None)
+        skt.empty_display_type = "ARROWS"
+        skt.empty_display_size = 0.09
+        sc.collection.objects.link(skt)
+        # authored in world space first — this is the step that makes the
+        # parent-inverse load-bearing
+        skt.matrix_world = root.matrix_world @ local
+        bpy.context.view_layer.update()
+        skt.parent = root
+        if not skip_mpi:
+            skt.matrix_parent_inverse = root.matrix_world.inverted()
+        bpy.context.view_layer.update()
+        sockets[skt_name] = skt
+
+        mod_key = MODULE_FOR_SOCKET[skt_name]
+        mod = bpy.data.objects.new(f"Drone.Survey.{mod_key}@{skt_name}",
+                                   module_meshes[mod_key])
+        sc.collection.objects.link(mod)
+        mod.parent = skt
+        # the spawn contract: identity local transform, identity parent-inverse
+        mod.matrix_parent_inverse = Matrix.Identity(4)
+        mod.matrix_basis = Matrix.Identity(4)
+        modules[skt_name] = mod
+    bpy.context.view_layer.update()
+    return root, parts, sockets, modules
+
+
+# ---------------------------------------------------------------------------
+# Check helpers
+# ---------------------------------------------------------------------------
+
+def mat_dev(a, b):
+    return max(abs(x - y) for ra, rb in zip(a, b) for x, y in zip(ra, rb))
+
+
+def evaluated_matrix(ob):
+    """World matrix straight off the depsgraph, not the cached object."""
+    dg = bpy.context.evaluated_depsgraph_get()
+    return Matrix(ob.evaluated_get(dg).matrix_world)
+
+
+def ortho_error(m3):
+    """Max deviation of M^T M from the identity — 0 for an orthonormal basis."""
+    g = m3.transposed() @ m3
+    return max(abs(g[i][j] - (1.0 if i == j else 0.0))
+               for i in range(3) for j in range(3))
+
+
+def check():
+    sc = bpy.context.scene
+    fails = []
+
+    def fail(code, msg):
+        print(f"ERROR ({code}): {msg}", file=sys.stderr)
+        fails.append(code)
+
+    chassis = build_chassis_meshes()
+    root, parts, sockets, modules = assemble(sc, chassis_meshes=chassis)
+    spec = socket_spec()
+    pad_by_socket = {s[0]: s[1] for s in spec}
+    local_by_socket = {s[0]: authored_local(s[2], s[3]) for s in spec}
+    normal_by_socket = {s[0]: Vector(s[3]).normalized() for s in spec}
+
+    # --- 1. socket matrices == authored world transform ---------------------
+    worst_m, worst_o, worst_det = 0.0, 0.0, 0.0
+    for name, skt in sockets.items():
+        want = root.matrix_world @ local_by_socket[name]
+        d = mat_dev(evaluated_matrix(skt), want)
+        m3 = evaluated_matrix(skt).to_3x3()
+        oe = ortho_error(m3)
+        dd = abs(m3.determinant() - 1.0)
+        worst_m, worst_o = max(worst_m, d), max(worst_o, oe)
+        worst_det = max(worst_det, dd)
+        if d > TOL:
+            fail(3, f"{name} world matrix deviates {d:.3e} > {TOL:.0e} from the "
+                    f"authored transform — parent-inverse or basis is wrong")
+        if oe > ORTHO_TOL:
+            fail(3, f"{name} basis is not orthonormal (Gram error {oe:.3e}) — "
+                    f"modules would spawn sheared or scaled")
+        if dd > ORTHO_TOL:
+            fail(3, f"{name} basis determinant {m3.determinant():.6f} != +1 — "
+                    f"left-handed socket, modules mirror on spawn")
+    print(f"socket_matrices n={len(sockets)} max_dev={worst_m:.3e} "
+          f"ortho_err={worst_o:.3e} det_err={worst_det:.3e} tol={TOL:.0e}")
+
+    # --- 2. orientation vs pad geometry (independent derivations) -----------
+    rot3 = root.matrix_world.to_3x3()
+    worst_n, worst_c, worst_up = 0.0, 0.0, 0.0
+    for name, skt in sockets.items():
+        pad_me = chassis[pad_by_socket[name]]
+        n_world = (rot3 @ newell_normal(pad_me, 0)).normalized()
+        z_world = evaluated_matrix(skt).to_3x3().col[2].normalized()
+        ndev = (n_world - z_world).length
+        c_world = root.matrix_world @ polygon_centroid(pad_me, 0)
+        cdev = (c_world - evaluated_matrix(skt).translation).length
+        # documented up-axis rule, re-derived here rather than reused
+        n_l = normal_by_socket[name]
+        ref = CHASSIS_FWD if abs(n_l.dot(CHASSIS_UP)) > PARALLEL else CHASSIS_UP
+        y_want = (rot3 @ (ref - n_l * ref.dot(n_l)).normalized()).normalized()
+        updev = (y_want - evaluated_matrix(skt).to_3x3().col[1].normalized()).length
+        worst_n, worst_c = max(worst_n, ndev), max(worst_c, cdev)
+        worst_up = max(worst_up, updev)
+        if ndev > TOL:
+            fail(4, f"{name} +Z deviates {ndev:.3e} from its pad's Newell normal "
+                    f"— the socket does not face the way its pad does")
+        if cdev > TOL:
+            fail(4, f"{name} origin is {cdev:.3e} m off its pad's mount-face "
+                    f"centroid — the module would float or sink")
+        if updev > TOL:
+            fail(4, f"{name} +Y deviates {updev:.3e} from the documented up rule "
+                    f"— modules spawn rolled about their mount axis")
+    print(f"pad_orientation sockets={len(sockets)} normal_dev={worst_n:.3e} "
+          f"centroid_dev={worst_c:.3e} up_dev={worst_up:.3e} tol={TOL:.0e}")
+
+    # --- 3. module seating --------------------------------------------------
+    worst_seat, worst_axis = 0.0, 0.0
+    for name, mod in modules.items():
+        skt_m = evaluated_matrix(sockets[name])
+        mod_m = evaluated_matrix(mod)
+        seat = (mod_m.translation - skt_m.translation).length
+        axis_dot = (mod_m.to_3x3() @ MODULE_MOUNT_AXIS).normalized().dot(
+            skt_m.to_3x3().col[2].normalized())
+        worst_seat = max(worst_seat, seat)
+        worst_axis = max(worst_axis, abs(axis_dot - 1.0))
+        if seat > TOL:
+            fail(5, f"module on {name} sits {seat:.3e} m off the socket origin")
+        if abs(axis_dot - 1.0) > TOL:
+            fail(5, f"module on {name} mount axis dot {axis_dot:.9f} != 1 — "
+                    f"seated crooked")
+        if mat_dev(mod.matrix_basis, Matrix.Identity(4)) > 0.0:
+            fail(5, f"module on {name} carries a non-identity local transform — "
+                    f"it is not seated by the socket, it is nudged into place")
+    print(f"module_seating n={len(modules)} max_offset={worst_seat:.3e} "
+          f"max_axis_err={worst_axis:.3e} tol={TOL:.0e}")
+
+    # --- 4. reuse hygiene (before the destructive apply below) --------------
+    default_names = {"Cube", "Sphere", "Torus", "Suzanne", "Plane", "Circle",
+                     "Cylinder", "Cone", "Grid", "Icosphere", "Empty"}
+    for ob in parts:
+        if max(abs(s - 1.0) for s in ob.scale) > 0.0:
+            fail(6, f"{ob.name} scale {tuple(ob.scale)} not applied")
+        if not ob.name.startswith("Drone.Survey."):
+            fail(6, f"chassis part {ob.name!r} outside the asset namespace")
+        if ob.data.name.split(".")[0] in default_names:
+            fail(6, f"{ob.name} carries a default datablock name {ob.data.name!r}")
+    for name in sockets:
+        if not name.startswith("SKT_"):
+            fail(6, f"socket {name!r} does not carry the SKT_ prefix a spawn "
+                    f"system scans for")
+    lo = min(min(v.co.z for v in me.vertices) for me in chassis.values())
+    if abs(lo) > 1e-4:
+        fail(6, f"chassis rests at z={lo:.5f}, not on the skid contact plane")
+    print(f"hygiene parts={len(parts)} sockets={len(sockets)} "
+          f"modules={len(modules)} skid_plane_z={lo:.2e}")
+
+    # --- 5. rigid invariance: re-pose the root, sockets follow exactly -------
+    root.location = REPOSE_LOC
+    root.rotation_euler = REPOSE_ROT
+    bpy.context.view_layer.update()
+    worst_r, worst_rm = 0.0, 0.0
+    for name, skt in sockets.items():
+        want = root.matrix_world @ local_by_socket[name]
+        d = mat_dev(evaluated_matrix(skt), want)
+        worst_r = max(worst_r, d)
+        if d > TOL:
+            fail(7, f"{name} does not track the re-posed root (dev {d:.3e})")
+        md = (evaluated_matrix(modules[name]).translation
+              - evaluated_matrix(skt).translation).length
+        worst_rm = max(worst_rm, md)
+        if md > TOL:
+            fail(7, f"module on {name} lost its socket under the re-pose "
+                    f"(dev {md:.3e})")
+    print(f"rigid_invariance repose_dev={worst_r:.3e} module_dev={worst_rm:.3e} "
+          f"tol={TOL:.0e}")
+
+    # --- 6. transform apply on the root (destructive — runs last) -----------
+    root.scale = (APPLY_SCALE,) * 3
+    bpy.context.view_layer.update()
+    before = {n: Matrix(evaluated_matrix(s)) for n, s in sockets.items()}
+    mpi_before = {n: Matrix(s.matrix_parent_inverse) for n, s in sockets.items()}
+    part_scale_before = max(max(abs(s - 1.0) for s in ob.scale) for ob in parts)
+    for ob in bpy.context.selected_objects:
+        ob.select_set(False)
+    root.select_set(True)
+    bpy.context.view_layer.objects.active = root
+    # HAZARD: only the root may be selected. A child left selected has the
+    # parent transform applied twice — measured at 2.335 m of socket drift on
+    # this asset (probe double_apply).
+    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
+    bpy.context.view_layer.update()
+    worst_a, cleared = 0.0, 0
+    for name, skt in sockets.items():
+        d = mat_dev(evaluated_matrix(skt), before[name])
+        worst_a = max(worst_a, d)
+        if d > TOL:
+            fail(8, f"{name} moved {d:.3e} when the root transform was applied "
+                    f"— the asset cannot be frozen without breaking its sockets")
+        if (mat_dev(skt.matrix_parent_inverse, Matrix.Identity(4)) == 0.0
+                and mat_dev(mpi_before[name], Matrix.Identity(4)) > 0.0):
+            cleared += 1
+    # HAZARD, asserted rather than described: the root is an Empty, so there is
+    # no object data to bake the transform into. Blender pushes it DOWN into
+    # every child instead — parent-inverses reset to identity and the children
+    # pick up the root's scale in their own local matrices. World matrices are
+    # preserved to float32; local transforms are not what they were. A spawn
+    # system that reads socket.matrix_local, or an exporter that trusts
+    # "transforms are applied", reads different numbers after an artist freezes
+    # the rig.
+    part_scale_after = max(max(abs(s - 1.0) for s in ob.scale) for ob in parts)
+    print(f"transform_apply scale={APPLY_SCALE} world_dev={worst_a:.3e} "
+          f"mpi_cleared={cleared}/{len(sockets)} "
+          f"child_scale_before={part_scale_before:.3e} "
+          f"child_scale_after={part_scale_after:.3e} tol={TOL:.0e}")
+    if cleared != len(sockets):
+        fail(8, f"only {cleared}/{len(sockets)} parent-inverses were cleared by "
+                f"the apply — the documented hazard changed behaviour, so the "
+                f"README's warning about reading local transforms is now wrong")
+    if part_scale_after < APPLY_SCALE - 1.0 - 1e-3:
+        fail(8, f"child scale pushdown {part_scale_after:.3e} does not match the "
+                f"applied {APPLY_SCALE} — the documented Empty-root hazard "
+                f"changed behaviour")
+    worst_as = max((evaluated_matrix(modules[n]).translation
+                    - evaluated_matrix(sockets[n]).translation).length
+                   for n in sockets)
+    if worst_as > TOL:
+        fail(8, f"modules drifted {worst_as:.3e} m off their sockets after the "
+                f"apply")
+    print(f"transform_apply module_seating_dev={worst_as:.3e}")
+
+    if fails:
+        return fails[0]
+    print(f"socket-attach-points OK sockets={len(sockets)} modules={len(modules)} "
+          f"parts={len(parts)} matrix_dev={worst_m:.3e} normal_dev={worst_n:.3e} "
+          f"seat_dev={worst_seat:.3e} apply_dev={worst_a:.3e} "
+          f"repose_dev={worst_r:.3e}")
+    return 0
+
+
+def probe_no_mpi():
+    """Falsification probe: parent the sockets without the parent-inverse."""
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    sc = bpy.context.scene
+    root, parts, sockets, modules = assemble(sc, skip_mpi=True)
+    worst = 0.0
+    for name, pad, centre, normal, radius in socket_spec():
+        want = root.matrix_world @ authored_local(centre, normal)
+        worst = max(worst, (evaluated_matrix(sockets[name]).translation
+                            - want.translation).length)
+    print(f"probe_no_mpi worst_socket_jump={worst:.6f} m")
+    return worst
+
+
+# ---------------------------------------------------------------------------
+# Render
+# ---------------------------------------------------------------------------
+
+def make_material(name, rgb, rough=0.45, metallic=0.6, emit=None, estr=0.0):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    b = mat.node_tree.nodes["Principled BSDF"]
+    b.inputs["Base Color"].default_value = (*rgb, 1.0)
+    b.inputs["Roughness"].default_value = rough
+    b.inputs["Metallic"].default_value = metallic
+    if emit is not None:
+        sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+        sock.default_value = (*emit, 1.0)
+        b.inputs["Emission Strength"].default_value = estr
+    return mat
+
+
+# One entry per material slot name used by the Part builders above:
+# (base rgb, roughness, metallic, emission rgb or None, emission strength).
+SLOT_MATS = {
+    "Carbon":     ((0.044, 0.049, 0.060), 0.62, 0.22, None, 0.0),
+    "Deck":       ((0.058, 0.067, 0.080), 0.52, 0.55, None, 0.0),
+    "Canopy":     ((0.020, 0.048, 0.062), 0.14, 0.30, None, 0.0),
+    "Vent":       ((0.066, 0.074, 0.086), 0.58, 0.70, None, 0.0),
+    "Trim":       ((0.046, 0.052, 0.062), 0.72, 0.30, None, 0.0),
+    "Boom":       ((0.040, 0.045, 0.055), 0.40, 0.60, None, 0.0),
+    "Fairing":    ((0.050, 0.056, 0.068), 0.64, 0.45, None, 0.0),
+    "Motor":      ((0.150, 0.158, 0.172), 0.26, 0.92, None, 0.0),
+    "MotorFin":   ((0.108, 0.114, 0.126), 0.30, 0.90, None, 0.0),
+    "NavFwd":     ((0.060, 0.140, 0.090), 0.30, 0.10, (0.30, 1.00, 0.55), 2.6),
+    "NavAft":     ((0.150, 0.048, 0.040), 0.30, 0.10, (1.00, 0.22, 0.16), 2.6),
+    "Skid":       ((0.092, 0.098, 0.110), 0.46, 0.78, None, 0.0),
+    "Foot":       ((0.030, 0.030, 0.033), 0.78, 0.05, None, 0.0),
+    "Strut":      ((0.070, 0.075, 0.086), 0.44, 0.72, None, 0.0),
+    # the signature: every mount pad is the same machined orange, so the
+    # sockets read as sockets at a glance
+    "SocketPad":  ((0.640, 0.250, 0.045), 0.34, 0.40, None, 0.0),
+    "Fixing":     ((0.170, 0.176, 0.188), 0.24, 0.95, None, 0.0),
+    "Hub":        ((0.120, 0.126, 0.138), 0.28, 0.90, None, 0.0),
+    "Spinner":    ((0.520, 0.200, 0.038), 0.26, 0.55, None, 0.0),
+    "Blade":      ((0.072, 0.076, 0.086), 0.26, 0.40, None, 0.0),
+    "PodYoke":    ((0.096, 0.102, 0.114), 0.34, 0.85, None, 0.0),
+    "PodBall":    ((0.038, 0.052, 0.062), 0.22, 0.45, None, 0.0),
+    "LensRing":   ((0.070, 0.074, 0.082), 0.20, 0.90, None, 0.0),
+    "LensGlass":  ((0.010, 0.026, 0.038), 0.05, 0.30, (0.06, 0.26, 0.40), 0.30),
+    "MastBase":   ((0.096, 0.102, 0.114), 0.34, 0.85, None, 0.0),
+    "MastTube":   ((0.130, 0.136, 0.148), 0.28, 0.92, None, 0.0),
+    "Dish":       ((0.150, 0.156, 0.166), 0.32, 0.88, None, 0.0),
+    "SensorHead": ((0.044, 0.050, 0.060), 0.30, 0.55, None, 0.0),
+    "Beacon":     ((0.170, 0.090, 0.030), 0.30, 0.10, (1.00, 0.52, 0.16), 3.0),
+    "Cell":       ((0.150, 0.108, 0.042), 0.54, 0.28, None, 0.0),
+    "Rib":        ((0.088, 0.064, 0.028), 0.60, 0.25, None, 0.0),
+    "Latch":      ((0.130, 0.136, 0.148), 0.28, 0.90, None, 0.0),
+    "Gauge":      ((0.060, 0.140, 0.090), 0.30, 0.10, (0.34, 1.00, 0.50), 2.2),
+}
+
+_mat_cache = {}
+
+
+def mat_for_slot(slot):
+    if slot not in _mat_cache:
+        rgb, rough, metal, emit, estr = SLOT_MATS[slot]
+        _mat_cache[slot] = make_material(slot, rgb, rough, metal, emit, estr)
+    return _mat_cache[slot]
+
+
+def bind_materials(ob):
+    """Append this mesh's material slots in the order the Part builder used."""
+    me = ob.data
+    if me.materials:
+        return
+    for slot in me.get("slots", []):
+        me.materials.append(mat_for_slot(slot))
+
+
+def build_studio(sc):
+    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.030, 0.032, 0.037), rough=0.7, metallic=0.0)
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    sc.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    # far enough back that the wedge draws a contained pool rather than
+    # flooding the whole backdrop (drafts 2-3 blew the wall out to mid grey)
+    wall.location = (0.0, 5.8, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    sc.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.020, 0.021, 0.025, 1.0)
+    sc.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)
+        sc.collection.objects.link(ob)
+        return ob
+
+    # VISUAL-STYLE Layer 2 rig, energies scaled to a 0.86 m subject
+    light("Key", (-1.30, -0.80, 2.30), 150.0, 1.4, (1.0, 0.96, 0.9), (26, 0, -58))
+    light("Fill", (1.70, -1.00, 0.90), 14.0, 2.6, (0.75, 0.85, 1.0), (66, 0, 54))
+    light("Rim", (-0.35, 1.60, 1.40), 55.0, 1.2, (0.6, 0.78, 1.0), (-50, 0, 196))
+    # Wedge sits between subject and wall, aimed at the wall, not the drone.
+    # Draft 3 put it high and hot: the pool blew to white and was clipped by
+    # the top-right corner. Low, larger and softer keeps the pool contained
+    # behind the subject where it lifts the silhouette.
+    light("Wedge", (0.10, 3.30, 0.34), 70.0, 3.0, (1.0, 0.76, 0.5), (-88, 0, 182))
+    return floor, wall
+
+
+def render_still(path, engine, falsify=False):
+    """The drone on the stage with every module seated at its socket.
+
+    Falsified: the sockets are parented without ``matrix_parent_inverse``, so
+    every socket collapses toward the world origin and drags its module with
+    it — rotors, camera and battery hang in space off the airframe.
+    """
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    _mat_cache.clear()
+    sc = bpy.context.scene
+
+    root, parts, sockets, modules = assemble(sc, skip_mpi=falsify)
+    hero = parts + list(modules.values())
+    for ob in hero:
+        bind_materials(ob)
+    floor, wall = build_studio(sc)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    # three-quarter from above: the deck, the canted rotor pads and the belly
+    # pod all read from here; a level side-on view (draft 2) flattened the
+    # airframe into a silhouette
+    cam.location = (1.28, -1.42, 1.44)
+    sc.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.10, -0.05, 0.60)
+    sc.collection.objects.link(aim)
+    tr = cam.constraints.new("TRACK_TO")
+    tr.target = aim
+    tr.track_axis = "TRACK_NEGATIVE_Z"
+    tr.up_axis = "UP_Y"
+    sc.camera = cam
+
+    sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        sc.cycles.device = "CPU"
+        sc.cycles.samples = 64
+        sc.cycles.use_denoising = True
+    else:
+        try:
+            sc.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    sc.render.resolution_x = 1280
+    sc.render.resolution_y = 720
+    sc.render.image_settings.file_format = "PNG"
+    sc.render.filepath = path
+    # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE)
+    sc.view_settings.view_transform = "Standard"
+    bpy.context.view_layer.update()
+
+    # The gallery still is gated; the --falsify diagnostic is not. Its whole
+    # point is that the modules fly off the airframe, so measuring it against
+    # the Layer 1 band would only ever report the breakage as a framing
+    # violation. The numbers are still printed under an explicit reason.
+    fcode = gallery_framing.check_framing(
+        sc, cam, hero=hero, elements=hero, stage=[floor, wall],
+        deviation=("falsification diagnostic: the sockets are deliberately "
+                   "unparented, so modules leave the frame — this render is "
+                   "evidence, not a gallery hero") if falsify else None)
+    if fcode:
+        return fcode
+    aqcode = gallery_asset_quality.check_asset_quality(sc, cam, hero=hero,
+                                                       stage=[floor, wall])
+    if aqcode:
+        return aqcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        print("ERROR: render produced no file", file=sys.stderr)
+        return 9
+    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, help="optional: render a still PNG here")
+    p.add_argument("--falsify", default=None,
+                   help="optional: render the no-parent-inverse variant here")
+    p.add_argument("--probe", action="store_true",
+                   help="optional: print the measured no-parent-inverse jump")
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    args = p.parse_args(argv)
+
+    print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    code = check()
+    if code:
+        return code
+    if args.probe:
+        probe_no_mpi()
+    if args.output:
+        rcode = render_still(os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    if args.falsify:
+        rcode = render_still(os.path.abspath(args.falsify), args.engine, falsify=True)
+        if rcode:
+            return rcode
+        print(f"rendered falsified variant {args.falsify}")
+
+    print("socket-attach-points 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/examples/gallery.json b/examples/gallery.json index 60a2ae8..26e2e2d 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -530,6 +530,20 @@ "game-pipeline", "render" ] + }, + { + "name": "socket-attach-points", + "dir": "examples/socket-attach-points", + "teaches": "A survey drone whose named SKT_ empties are the spawn contract: modules parented with an identity local transform seat exactly on their mount pads.", + "witnessesFix": "7 socket world matrices within 1.788e-07 of the authored transform, socket +Z == the pad's Newell normal (1.794e-07), seating offset 0.0; the no-parent-inverse probe jumps 0.690128 m.", + "hero": "docs/gallery/assets/socket-attach-points-hero.webp", + "preview": "examples/socket-attach-points/preview.webp", + "tags": [ + "mesh", + "transforms", + "game-pipeline", + "instancing" + ] } ] } diff --git a/examples/socket-attach-points/README.md b/examples/socket-attach-points/README.md new file mode 100644 index 0000000..3367923 --- /dev/null +++ b/examples/socket-attach-points/README.md @@ -0,0 +1,133 @@ +# Socket Attach Points + +A runnable example building the thing a spawn system actually leans on: +**named socket empties parented into an asset**, so a module dropped onto a +socket with an *identity local transform* lands exactly where the artist put +it, oriented the way the naming convention promises. Every clause in that +sentence is a matrix identity, and every one of them drifts silently — a +skipped `matrix_parent_inverse`, a basis built with the cross product the +wrong way round, a transform frozen on the parent after the sockets were +placed. + +**The asset, for reuse:** `Drone.Survey`, an 0.86 m span survey quadcopter — +a lofted fuselage with a vented deck and canopy face, four canted booms with +finned motor cans and nav lights, and a two-rail landing gear. Seven mount +pads carry seven sockets, and four interchangeable modules seat on them: + +| Socket | Mount normal | Module | +| --- | --- | --- | +| `SKT_Rotor.FL/FR/RL/RR` | 8° canted outward | `Mod.Rotor` (twisted, tapered blades) | +| `SKT_Camera` | belly, −Z | `Mod.CamPod` (gimbal ball + lens) | +| `SKT_Mast` | dorsal, +Z | `Mod.Mast` (antenna, dish, beacon) | +| `SKT_Battery` | rear, tilted down | `Mod.Battery` (cell block + gauge) | + +Origin is the skid contact plane centre (`z == 0` is where it rests, `+X` is +forward), transforms are identity by construction, datablocks live under +`Drone.Survey.*` and `SKT_*`, and accents are per-face material slots rather +than extra objects. To use it: instantiate a module, parent it to the socket, +zero the local transform. That is the whole API — and it only works because +the socket matrices hold. + +**Socket convention** (encoded in the `SKT_` prefix): + +- **+Z** — the outward mount normal, matching the mount pad's own face normal. +- **+Y** — the module's up reference: chassis +Z orthogonalised against the + socket normal, falling back to chassis +X when the normal is parallel to + chassis up (the belly and dorsal mounts take that branch). +- **+X** — `Y × Z`, so the basis is right-handed (`det == +1`). + +**Pipeline arc neighbours:** pivot and origin discipline in +[`prop-origin-transform`](../prop-origin-transform/), parent-inverse under +animation in [`parent-inverse-orrery`](../parent-inverse-orrery/), tile-grid +boundary contracts in [`modular-kit-snap`](../modular-kit-snap/), collision +packaging in [`collision-hull-proxy`](../collision-hull-proxy/). + +**What it witnesses** (all closed form or independently re-derived): + +- **Socket matrices.** All **7** evaluated `matrix_world` values equal + `root.matrix_world @ authored_local` within **1e-6** (measured + **1.788e-07**); every basis is orthonormal (Gram error **3.576e-07**) and + right-handed (`det − 1` = **2.980e-07**). +- **Orientation vs geometry.** Each socket's world **+Z** equals the **Newell + normal** of its mount pad's mount face — computed from raw vertex + coordinates, and the pad mesh is built by a *quaternion swing from +Z* + while the socket basis is built by *explicit Gram-Schmidt*, so the two + derivations are independent (normal deviation **1.794e-07**). The socket + origin sits on that face's centroid (**6.687e-08**), and the up axis + matches the documented fallback rule (**1.943e-07**). +- **Seating.** Every module's mount origin coincides with its socket origin + (offset **exactly 0.0**) with its mount axis on the socket +Z + (`dot − 1` = **1.735e-07**), and carries a strictly identity local matrix — + seated by the socket, not nudged into place. +- **Rigid invariance.** Re-posing the chassis root to a second arbitrary + transform moves every socket to `root.matrix_world @ authored_local` + (**2.384e-07**) and every module stays on its socket (**0.0**). +- **Transform apply.** Freezing loc/rot/scale on the root leaves every socket's + *world* matrix put (**2.235e-07**) — see the hazard below for what it does + to the *local* ones. +- **Reuse hygiene.** 14 chassis parts at identity scale, `Drone.Survey.*` / + `SKT_*` names, no default datablocks, `min z == 0.00e+00`. + +## Hazards found while authoring + +- **Applying a transform on an Empty root pushes it down into the children.** + An Empty has no object data to bake into, so `transform_apply` on the root + resets **7/7** children's `matrix_parent_inverse` to identity and hands each + child the root's scale locally (measured **0.35** for an applied 1.35). + World matrices survive to float32; *local* transforms do not. A spawn system + that reads `matrix_local`, or an exporter that trusts "transforms are + applied", reads different numbers after an artist freezes the rig. Both + halves are asserted, so the behaviour cannot change under us silently. +- **Only the root may be selected during the apply.** A child left selected + gets the parent transform applied twice — measured **2.335 m** of socket + drift on this asset. +- **`bm.normal_update()` does not fix winding.** It recomputes normals from + the existing winding. The lofted fuselage wound its side quads inward and + rendered as a flat unshaded white panel rather than an obvious hole; + `bmesh.ops.recalc_face_normals` is what fixes it, and `Part.finish` now + always calls it. +- **Bevel offsets near half the local edge length collapse.** The skid rails + at a 5 mm offset on a 9.9 mm octagon edge produced 3 zero-area faces each — + the collapse [`degenerate-bevel-weld`](../degenerate-bevel-weld/) witnesses. + 2.2 mm is clean. Note that `examples/gallery_asset_quality.py` raises + `ValueError: zero length vectors have no valid angle` on such a mesh rather + than reporting it. + +**What each check catches on failure** (every one probed, with the measured +error): sockets parented without the parent-inverse — worst jump **0.690128 m** +(exit 3); a left-handed basis from `z.cross(y)` — `det == −1.000000` (exit 3); +a rotor pad built flat instead of canted — **1.395e-01** off the Newell normal +(exit 4); an 11° roll on the basis — **1.917e-01** off the up rule (exit 4); a +module nudged 4 mm off its socket (exit 5); a default `Cube` datablock name +(exit 6); a socket left unparented so it ignores the re-pose — **1.417e+00** +(exit 7); a child selected during the apply — **1.252e+00** to **2.244e+00** of +socket drift (exit 8). + +**Version witness:** check output is byte-identical on Blender 4.5.11 LTS and +5.1.2 — same counts, same measured deviations to every printed digit, and the +same framing numbers on the render path. + +**Render as proof:** the drone on the dark stage with every module seated, the +orange mount pads reading as the socket signature. The falsification variant +(`--falsify`) parents the sockets without `matrix_parent_inverse`: the pads +are left bare, the rotors and mast vanish from the airframe, and the camera +pod and battery hang in space near the top of frame. A broken matrix is not a +subtle pixel difference here — the asset comes apart. + +## Run + +```bash +blender --background --python socket_attach_points.py -- +blender --background --python socket_attach_points.py -- --output drone.png +blender --background --python socket_attach_points.py -- --falsify adrift.png +blender --background --python socket_attach_points.py -- --probe +``` + +Exits non-zero on failure. The `blender-smoke` workflow runs the check on +Blender 4.5 LTS and 5.1. The `--output` render path additionally gates framing +via `examples/gallery_framing.py` (fill **0.881x**, margins +**0.066/0.053/0.122/0.106**, no edge touched) and the asset floors via +`examples/gallery_asset_quality.py` (32 materials, `edge90` **0.027**, no +default names). The `--falsify` render is a diagnostic, not a gallery hero, so +it takes a documented framing deviation — its whole point is that the modules +leave the frame. diff --git a/examples/socket-attach-points/preview.webp b/examples/socket-attach-points/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..842121b6d5dfe476dc78a8c2f6651355c7900758 GIT binary patch literal 17678 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

);Zkd(UBe! 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 N9OZ<3X{%ctAONukX<7gP literal 0 HcmV?d00001 diff --git a/examples/socket-attach-points/socket_attach_points.py b/examples/socket-attach-points/socket_attach_points.py new file mode 100644 index 0000000..09bb6d4 --- /dev/null +++ b/examples/socket-attach-points/socket_attach_points.py @@ -0,0 +1,1099 @@ +"""Socket attach points — named empties parented into an asset as spawn mounts. + +Witnesses the contract a spawn system depends on when it bolts modules onto a +prop at runtime: an attachment placed at socket S with an identity local +transform must land exactly where the artist authored the socket, oriented the +way the naming convention promises. Everything in that sentence is a matrix +identity, and every one of them can silently drift — a skipped +``matrix_parent_inverse``, a basis built with the cross product the wrong way +round, a transform applied on the parent after the sockets were placed. + +The asset is a survey drone chassis with seven sockets: four canted rotor +mounts, a belly camera mount, a dorsal mast mount, and a rear battery mount. +Sockets are authored in WORLD space (where an artist actually places them) and +then parented to the chassis root, which is itself posed at a non-trivial world +transform — so the parent-inverse is load-bearing rather than incidentally +identity. + +Socket convention, encoded in the name prefix ``SKT_``: + + +Z the outward mount normal — the direction the module grows away from + the hull, matching the mount pad's own face normal + +Y the module's up reference: chassis +Z orthogonalised against +Z_socket, + falling back to chassis +X when the normal is parallel to chassis up + +X Y x Z, so the basis is right-handed (det == +1) + +Check (all closed form or independently re-derived, nothing captured): + +1. Socket matrices (exit 3): every evaluated ``matrix_world`` equals the + authored world transform within 1e-6, and every basis is orthonormal + right-handed (det == +1, Gram error ~0). +2. Orientation vs geometry (exit 4): each socket's world +Z equals the Newell + normal of its mount pad's mount face, computed from mesh vertex coordinates + by a construction path (quaternion swing from +Z) independent of the socket + basis (explicit Gram-Schmidt); the origin sits on that face's centroid; the + up axis follows the documented fallback rule. +3. Seating (exit 5): each module, parented to its socket with an identity local + transform, puts its mount origin exactly on the socket origin and its own + mount axis exactly on the socket +Z (dot == 1 within 1e-6). +4. Reuse hygiene (exit 6): identity object scales, ``Drone.Survey.*`` / + ``SKT_*`` names, no default datablock names, chassis resting on z == 0. +5. Rigid invariance (exit 7): re-posing the chassis root to an arbitrary + transform moves every socket to ``root.matrix_world @ authored_local`` + within 1e-6, and every module follows its socket. +6. Transform apply (exit 8): applying loc/rot/scale on the chassis root leaves + every socket's world matrix unchanged within 1e-6 — but, because the root is + an Empty with no data to bake into, Blender pushes the transform DOWN into + the children: every ``matrix_parent_inverse`` is cleared to identity and + every child picks up the root's scale locally. Both halves are asserted, so + the hazard cannot silently change under us. + +By default it runs only the correctness check (no render) — the CI smoke +check. Pass --output to also render a still: + + blender --background --python socket_attach_points.py -- # check + blender --background --python socket_attach_points.py -- --output d.png # + render + blender --background --python socket_attach_points.py -- --falsify f.png # no-MPI variant +""" +import bpy, bmesh, sys, os, math, argparse +from mathutils import Vector, Matrix + +# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)) +sys.dont_write_bytecode = True # keep examples/__pycache__ out of the repo tree +import gallery_framing +import gallery_asset_quality + +TOL = 1e-6 # matrix / position tolerance (metres, unitless for axes) + # float32 noise on a 4x4 world matrix measures ~3.6e-07 here +ORTHO_TOL = 1e-6 # orthonormality + handedness tolerance +PARALLEL = 0.999 # |n . chassis_up| above this takes the +X up-axis fallback + +CHASSIS_UP = Vector((0.0, 0.0, 1.0)) # chassis local up +CHASSIS_FWD = Vector((1.0, 0.0, 0.0)) # chassis local forward + +# Chassis geometry, metres. Origin is the skid contact plane centre: z == 0 is +# where the drone rests, +X is forward. An 0.86 m span survey quadcopter. +HULL_Z = 0.152 # fuselage centreline height above the skid plane +DECK_Z = 0.214 # top of the fuselage +BELLY_Z = 0.090 # bottom of the fuselage +ARM_YAW = math.radians(41.0) +ARM_LEN = 0.430 # hub centre -> rotor pad centre, horizontal +ARM_RISE = 0.053 # rotor pads sit this far above the centreline +ROTOR_CANT = math.radians(8.0) # rotor pads cant outward: NOT axis aligned +PAD_T = 0.014 # mount pad plate thickness + +# Chassis root pose in the scene: translated, yawed and banked, so the sockets' +# parent-inverse is load-bearing rather than incidentally identity. +ROOT_LOC = (0.240, -0.130, 0.470) +ROOT_ROT = (math.radians(-7.0), math.radians(5.0), math.radians(24.0)) + +# Re-pose used by check 5 (rigid invariance) — a second arbitrary transform. +REPOSE_LOC = (-1.150, 0.820, 0.315) +REPOSE_ROT = (math.radians(13.0), math.radians(-21.0), math.radians(-64.0)) + +# Transform-apply pose used by check 6: a uniform scale, so applying it is a +# real change to the object transform rather than a no-op. +APPLY_SCALE = 1.35 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +# --------------------------------------------------------------------------- +# Socket specification (authored, chassis-local) +# --------------------------------------------------------------------------- + +def rotor_dirs(): + """Horizontal outward directions of the four arms, front-left first.""" + out = [] + for tag, sx, sy in (("FL", 1, 1), ("FR", 1, -1), ("RL", -1, 1), ("RR", -1, -1)): + yaw = sy * (ARM_YAW if sx > 0 else math.pi - ARM_YAW) + out.append((tag, Vector((math.cos(yaw), math.sin(yaw), 0.0)))) + return out + + +def socket_spec(): + """[(socket_name, pad_name, centre, normal, pad_radius)], chassis-local. + + The normal is the declared mount direction. The pad MESH is built from it + by a quaternion swing from +Z; the socket BASIS is built from it by + explicit Gram-Schmidt. Check 2 compares the two independent derivations. + """ + spec = [] + for tag, d in rotor_dirs(): + # canted outward: the rotor discs tilt away from the hull, so no rotor + # socket is axis aligned and a "just use +Z" spawn breaks visibly + n = (CHASSIS_UP * math.cos(ROTOR_CANT) + d * math.sin(ROTOR_CANT)).normalized() + centre = d * ARM_LEN + Vector((0.0, 0.0, HULL_Z + ARM_RISE)) + spec.append((f"SKT_Rotor.{tag}", f"Pad.Rotor.{tag}", centre, n, 0.047)) + # belly camera mount: normal is -Z, exactly antiparallel to chassis up -> + # exercises the +X up-axis fallback + spec.append(("SKT_Camera", "Pad.Camera", + Vector((0.062, 0.0, BELLY_Z)), Vector((0.0, 0.0, -1.0)), 0.040)) + # dorsal mast mount: normal is +Z, parallel to chassis up -> fallback again + spec.append(("SKT_Mast", "Pad.Mast", + Vector((-0.130, 0.0, DECK_Z)), Vector((0.0, 0.0, 1.0)), 0.034)) + # rear battery mount: rearward and tilted down, a fully general normal + spec.append(("SKT_Battery", "Pad.Battery", + Vector((-0.286, 0.0, 0.146)), + Vector((-1.0, 0.0, -0.26)).normalized(), 0.042)) + return spec + + +def socket_basis(normal): + """The documented convention, by explicit Gram-Schmidt. Returns a 3x3.""" + z = Vector(normal).normalized() + ref = CHASSIS_FWD if abs(z.dot(CHASSIS_UP)) > PARALLEL else CHASSIS_UP + y = (ref - z * ref.dot(z)).normalized() + x = y.cross(z) # x cross y == z, so this is right-handed + return Matrix((x, y, z)).transposed() + + +def authored_local(centre, normal): + """The socket's authored chassis-local 4x4.""" + m = socket_basis(normal).to_4x4() + m.translation = Vector(centre) + return m + + +# --------------------------------------------------------------------------- +# Mesh construction +# +# Parts are assembled from primitive groups, each tagged with a material slot +# name. Slot indices are written onto the bmesh faces before to_mesh, so +# accents (lens glass, nav lights, pad rings) are per-face assignments inside +# one object rather than extra objects — the same technique +# custom-normals-shade and lod-decimate-chain use for their accents. +# --------------------------------------------------------------------------- + +class Part: + """A bmesh under construction with named material slots.""" + + def __init__(self): + self.bm = bmesh.new() + self.slots = [] # slot name per material index, in order + + def slot(self, name): + if name not in self.slots: + self.slots.append(name) + return self.slots.index(name) + + def group(self, slot_name, fn): + """Run fn(bm), then tag everything it created with slot_name.""" + before = set(self.bm.faces) + fn(self.bm) + idx = self.slot(slot_name) + for f in self.bm.faces: + if f not in before: + f.material_index = idx + return self + + def finish(self, name): + me = bpy.data.meshes.new(name) + try: + # HAZARD: bm.normal_update() recomputes normals from the EXISTING + # winding — it does not fix a face built the wrong way round. The + # lofted fuselage below winds its side quads inward, which renders + # as a flat unshaded white panel rather than an obvious hole. + # recalc_face_normals makes each shell outward-consistent; it runs + # per connected shell, so the intersecting sub-solids in one Part + # are each fixed independently. + bmesh.ops.recalc_face_normals(self.bm, faces=list(self.bm.faces)) + self.bm.normal_update() + self.bm.to_mesh(me) + finally: + self.bm.free() + me["slots"] = self.slots # material slot order, read by the renderer + return me + + +def _box(bm, dims, centre, bevel=0.0, segments=2, rot=None): + before = set(bm.verts) + bmesh.ops.create_cube(bm, size=1.0) + made = [v for v in bm.verts if v not in before] + for v in made: + c = Vector((v.co.x * dims[0], v.co.y * dims[1], v.co.z * dims[2])) + if rot is not None: + c = rot @ c + v.co = c + Vector(centre) + if bevel > 0.0: + edges = [e for e in bm.edges if all(v in made for v in e.verts)] + bmesh.ops.bevel(bm, geom=edges, offset=bevel, segments=segments, + profile=0.5, affect="EDGES", clamp_overlap=True) + + +def _prism(bm, sides, radius, half_h, centre, stretch=1.0, bevel=0.0, rot=None, + taper=1.0): + """An n-gon prism along local Z, optionally tapered toward +Z.""" + before = set(bm.verts) + ring = [] + for i in range(sides): + a = 2.0 * math.pi * i / sides + math.pi / sides + ring.append((math.cos(a) * radius * stretch, math.sin(a) * radius)) + top = [bm.verts.new((x * taper, y * taper, half_h)) for x, y in ring] + bot = [bm.verts.new((x, y, -half_h)) for x, y in ring] + bm.faces.new(top) + bm.faces.new(list(reversed(bot))) + for i in range(sides): + j = (i + 1) % sides + bm.faces.new((bot[i], bot[j], top[j], top[i])) + made = [v for v in bm.verts if v not in before] + for v in made: + c = Vector(v.co) + if rot is not None: + c = rot @ c + v.co = c + Vector(centre) + if bevel > 0.0: + edges = [e for e in bm.edges if all(v in made for v in e.verts)] + bmesh.ops.bevel(bm, geom=edges, offset=bevel, segments=2, + profile=0.7, affect="EDGES", clamp_overlap=True) + + +def _rounded_ring(half_w, half_h, z_c, corner): + """Rounded-rectangle cross-section in (y, z), CCW seen from +X.""" + hw, hh, k = half_w, half_h, corner + return [(-hw, z_c - hh + k), (-hw, z_c + hh - k), (-hw + k, z_c + hh), + (hw - k, z_c + hh), (hw, z_c + hh - k), (hw, z_c - hh + k), + (hw - k, z_c - hh), (-hw + k, z_c - hh)] + + +def _loft(bm, stations, bevel=0.0): + """Loft rounded-rect cross-sections along X. stations: (x, hw, zc, hh, k).""" + before = set(bm.verts) + rings = [] + for x, hw, zc, hh, k in stations: + rings.append([bm.verts.new((x, y, z)) for y, z in + _rounded_ring(hw, hh, zc, k)]) + n = len(rings[0]) + for a, b in zip(rings, rings[1:]): + for i in range(n): + j = (i + 1) % n + bm.faces.new((a[i], a[j], b[j], b[i])) + bm.faces.new(list(reversed(rings[0]))) # rear cap + bm.faces.new(rings[-1]) # nose cap + if bevel > 0.0: + made = [v for v in bm.verts if v not in before] + edges = [e for e in bm.edges if all(v in made for v in e.verts)] + bmesh.ops.bevel(bm, geom=edges, offset=bevel, segments=2, + profile=0.6, affect="EDGES", clamp_overlap=True) + + +def _blade(bm, length, root_chord, tip_chord, thick, twist_deg, yaw, z0): + """A tapered, twisted rotor blade: four stations lofted tip-ward. + + A rotor blade is an aerofoil, not a plank — chord tapers, the section + twists toward the tip, and the trailing edge is thinner than the leading + edge. Modelled here because a plank reads as programmer art at any + lighting. + """ + yawm = Matrix.Rotation(yaw, 3, "Z") + fr = [0.0, 0.38, 0.76, 1.0] + rings = [] + for f in fr: + chord = root_chord + (tip_chord - root_chord) * f + t = thick * (1.0 - 0.55 * f) + tw = math.radians(twist_deg * (1.0 - f)) + r = length * (0.14 + 0.86 * f) + sec = [] + # 6-point aerofoil-ish section in (chordwise, thickness) + for cx, cz in ((-0.5, 0.0), (-0.18, 0.62), (0.22, 0.48), + (0.5, 0.0), (0.22, -0.30), (-0.18, -0.42)): + p = Vector((cx * chord, 0.0, cz * t)) + p = Matrix.Rotation(tw, 3, "Y") @ p + sec.append(yawm @ Vector((p.x + 0.0, r, p.z)) + Vector((0, 0, z0))) + rings.append([bm.verts.new(tuple(v)) for v in sec]) + n = len(rings[0]) + for a, b in zip(rings, rings[1:]): + for i in range(n): + j = (i + 1) % n + bm.faces.new((a[i], a[j], b[j], b[i])) + bm.faces.new(list(reversed(rings[0]))) + bm.faces.new(rings[-1]) + + +MOUNT_FACE_SIDES = 12 # the mount face is a 12-gon; polygon index 0 by build order + + +def build_pad(name, centre, normal, radius, thick=PAD_T): + """A mount pad plate whose MOUNT FACE IS POLYGON 0. + + Construction path: a flat ring in the XY plane swung onto ``normal`` by + ``Vector((0,0,1)).rotation_difference(normal)``. This never calls + ``socket_basis``, so check 2's Newell-vs-socket comparison is a comparison + of two independent derivations of the same declared normal, not a tautology. + + The pad is authored FIRST in the part so its mount face is polygon 0; the + collar detail below it is added afterwards and never disturbs that index. + """ + n = Vector(normal).normalized() + swing = Vector((0.0, 0.0, 1.0)).rotation_difference(n) + part = Part() + + def plate(bm): + top, bot = [], [] + for i in range(MOUNT_FACE_SIDES): + a = 2.0 * math.pi * i / MOUNT_FACE_SIDES + p = Vector((math.cos(a) * radius, math.sin(a) * radius, 0.0)) + top.append(bm.verts.new(swing @ p + Vector(centre))) + bot.append(bm.verts.new(swing @ (p * 0.86 - Vector((0, 0, thick))) + + Vector(centre))) + bm.faces.new(top) # polygon 0 == the mount face + bm.faces.new(list(reversed(bot))) + for i in range(MOUNT_FACE_SIDES): + j = (i + 1) % MOUNT_FACE_SIDES + bm.faces.new((bot[i], bot[j], top[j], top[i])) + + part.group("SocketPad", plate) + + def collar(bm): + # four fixing bosses around the pad: where a mount plate actually bolts + for i in range(4): + a = math.pi / 4 + i * math.pi / 2 + p = Vector((math.cos(a), math.sin(a), 0.0)) * radius * 0.70 + _prism(bm, 6, radius * 0.16, thick * 0.62, + tuple(swing @ (p - Vector((0, 0, thick * 0.40))) + + Vector(centre)), + rot=swing.to_matrix()) + + part.group("Fixing", collar) + return part.finish(name) + + +def newell_normal(me, poly_index=0): + """Area-weighted normal of one polygon straight from vertex coordinates. + + Newell's method: independent of ``Mesh.polygons[i].normal`` (which Blender + computes for us) and independent of the pad's construction quaternion. + """ + poly = me.polygons[poly_index] + co = [Vector(me.vertices[i].co) for i in poly.vertices] + n = Vector((0.0, 0.0, 0.0)) + for i, a in enumerate(co): + b = co[(i + 1) % len(co)] + n.x += (a.y - b.y) * (a.z + b.z) + n.y += (a.z - b.z) * (a.x + b.x) + n.z += (a.x - b.x) * (a.y + b.y) + return n.normalized() + + +def polygon_centroid(me, poly_index=0): + poly = me.polygons[poly_index] + c = Vector((0.0, 0.0, 0.0)) + for i in poly.vertices: + c += Vector(me.vertices[i].co) + return c / len(poly.vertices) + + +# Fuselage cross-sections: (x, half_width, centre_z, half_height, corner). +HULL_STATIONS = [ + (-0.280, 0.062, 0.152, 0.036, 0.018), + (-0.215, 0.122, 0.152, 0.058, 0.026), + (-0.060, 0.150, 0.152, 0.062, 0.030), + (0.090, 0.144, 0.151, 0.059, 0.030), + (0.205, 0.108, 0.147, 0.046, 0.024), + (0.286, 0.052, 0.142, 0.026, 0.013), +] + + +def build_chassis_meshes(): + """Every chassis part, keyed by suffix. Chassis-local space.""" + meshes = {} + + # --- fuselage: lofted body + deck plate + canopy face + intake louvres --- + hull = Part() + hull.group("Carbon", lambda bm: _loft(bm, HULL_STATIONS, bevel=0.006)) + hull.group("Deck", lambda bm: _box(bm, (0.250, 0.176, 0.012), + (-0.050, 0.0, DECK_Z + 0.002), bevel=0.004)) + # forward canopy: the sensor face, angled down toward the nose + hull.group("Canopy", lambda bm: _box( + bm, (0.118, 0.150, 0.010), (0.176, 0.0, 0.186), bevel=0.004, + rot=Matrix.Rotation(math.radians(24.0), 3, "Y"))) + def louvres(bm): + for i in range(4): + _box(bm, (0.020, 0.104, 0.007), (-0.140 + i * 0.030, 0.0, DECK_Z + 0.010), + bevel=0.002, rot=Matrix.Rotation(math.radians(-18.0), 3, "Y")) + hull.group("Vent", louvres) + def flank(bm): + # proud of the skin, not flush with it: at y == +/-0.150 the strip was + # coplanar with the fuselage side and z-fought into a speckled band + for sy in (1.0, -1.0): + _box(bm, (0.196, 0.016, 0.030), (0.010, sy * 0.156, 0.150), bevel=0.005) + hull.group("Trim", flank) + meshes["Hull"] = hull.finish("Drone.Survey.Hull") + + # --- arms: tapered booms with a hull-side fairing and a motor can -------- + for tag, d in rotor_dirs(): + yaw = math.atan2(d.y, d.x) + rot = (Matrix.Rotation(yaw, 3, "Z") + @ Matrix.Rotation(math.radians(-4.0), 3, "Y")) + arm = Part() + mid = d * (ARM_LEN * 0.56) + Vector((0.0, 0.0, HULL_Z + ARM_RISE * 0.42)) + arm.group("Boom", lambda bm, r=rot, m=mid: _box( + bm, (ARM_LEN * 0.80, 0.042, 0.030), tuple(m), bevel=0.010, rot=r)) + # the fairing is deliberately NOT coplanar with the boom's flat sides: + # at r=0.046/half=0.052 two facets landed on the boom faces and + # z-fought into a speckled patch at render scale + root = d * 0.142 + Vector((0.0, 0.0, HULL_Z + 0.004)) + arm.group("Fairing", lambda bm, r=rot, m=root: _prism( + bm, 10, 0.053, 0.044, tuple(m), bevel=0.010, taper=0.52, + rot=r @ Matrix.Rotation(math.radians(90.0), 3, "Y"))) + hub = d * ARM_LEN + Vector((0.0, 0.0, HULL_Z + ARM_RISE - 0.032)) + arm.group("Motor", lambda bm, h=hub: _prism( + bm, 12, 0.036, 0.028, tuple(h), bevel=0.005)) + def fins(bm, h=hub): + for i in range(8): + a = i * math.pi / 4 + p = Vector((math.cos(a), math.sin(a), 0.0)) * 0.036 + _box(bm, (0.008, 0.010, 0.040), tuple(Vector(h) + p), + bevel=0.0015, rot=Matrix.Rotation(a, 3, "Z")) + arm.group("MotorFin", fins) + # navigation light: front arms green-white, rear arms red + nav = "NavFwd" if tag.startswith("F") else "NavAft" + arm.group(nav, lambda bm, dd=d: _prism( + bm, 8, 0.011, 0.006, + tuple(dd * (ARM_LEN + 0.030) + Vector((0.0, 0.0, HULL_Z + ARM_RISE - 0.038))), + bevel=0.002)) + meshes[f"Arm.{tag}"] = arm.finish(f"Drone.Survey.Arm.{tag}") + + # --- landing gear: two skid rails on splayed struts ---------------------- + for side, sy in (("L", 1.0), ("R", -1.0)): + y = sy * 0.158 + gear = Part() + # bevel offset must stay well under half the octagon's edge length + # (2 * r * sin(22.5 deg) = 9.9 mm here): at offset == min_dim/2 the + # band collapses to zero-area faces, the failure degenerate-bevel-weld + # witnesses. 5 mm produced 3 zero-area faces per rail; 2.2 mm is clean. + gear.group("Skid", lambda bm, yy=y: _prism( + bm, 8, 0.013, 0.185, (0.0, yy, 0.013), bevel=0.0022, + rot=Matrix.Rotation(math.radians(90.0), 3, "Y"))) + def feet(bm, yy=y): + for xx in (0.150, -0.150): + _prism(bm, 8, 0.017, 0.008, (xx, yy, 0.008), bevel=0.003) + gear.group("Foot", feet) + def struts(bm, yy=y): + for xx in (0.112, -0.116): + _box(bm, (0.026, 0.024, 0.082), (xx, yy * 0.78, 0.055), + bevel=0.006, + rot=Matrix.Rotation(math.radians(12.0) * (1 if yy > 0 else -1), + 3, "X")) + # shoulder block where the strut meets the fuselage: without it the + # rails read as detached rods floating under the airframe (draft 3) + for xx in (0.112, -0.116): + _box(bm, (0.044, 0.038, 0.022), (xx, yy * 0.62, 0.096), bevel=0.006) + gear.group("Strut", struts) + meshes[f"Gear.{side}"] = gear.finish(f"Drone.Survey.Gear.{side}") + + # --- mount pads, one per socket ------------------------------------------ + for skt_name, pad_name, centre, normal, radius in socket_spec(): + meshes[pad_name] = build_pad(f"Drone.Survey.{pad_name}", centre, normal, + radius) + return meshes + + +# Module (attachment) geometry, authored in MOUNT SPACE: origin at the mount +# point, +Z growing away from the mount surface, +Y the module's up. Parented +# to a socket with an identity local transform, a module seats by construction. +def build_module_meshes(): + out = {} + + rotor = Part() + rotor.group("Hub", lambda bm: _prism(bm, 12, 0.028, 0.016, (0, 0, 0.016), + bevel=0.005, taper=0.82)) + rotor.group("Spinner", lambda bm: _prism(bm, 10, 0.015, 0.014, (0, 0, 0.043), + bevel=0.006, taper=0.35)) + def blades(bm): + for k in range(2): + _blade(bm, length=0.200, root_chord=0.058, tip_chord=0.032, + thick=0.0115, twist_deg=15.0, + yaw=math.pi * k + math.radians(5.0), z0=0.029) + rotor.group("Blade", blades) + out["Mod.Rotor"] = rotor.finish("Drone.Survey.Mod.Rotor") + + cam = Part() + cam.group("PodYoke", lambda bm: _prism(bm, 8, 0.032, 0.018, (0, 0, 0.018), + bevel=0.006, taper=0.78)) + cam.group("PodBall", lambda bm: _prism(bm, 14, 0.041, 0.034, (0, 0, 0.070), + bevel=0.016)) + # lens barrel points forward: in the belly socket's frame +X is chassis aft, + # so the barrel runs along -X. Glass is its own slot, the only emitter. + cam.group("LensRing", lambda bm: _prism( + bm, 14, 0.023, 0.024, (-0.038, 0.0, 0.072), bevel=0.005, taper=0.88, + rot=Matrix.Rotation(math.radians(-90.0), 3, "Y"))) + cam.group("LensGlass", lambda bm: _prism( + bm, 14, 0.016, 0.004, (-0.062, 0.0, 0.072), bevel=0.0008, + rot=Matrix.Rotation(math.radians(-90.0), 3, "Y"))) + out["Mod.CamPod"] = cam.finish("Drone.Survey.Mod.CamPod") + + mast = Part() + mast.group("MastBase", lambda bm: _prism(bm, 8, 0.024, 0.013, (0, 0, 0.013), + bevel=0.005, taper=0.72)) + mast.group("MastTube", lambda bm: _prism(bm, 8, 0.0095, 0.088, (0, 0, 0.114), + bevel=0.002, taper=0.80)) + def dish(bm): + _prism(bm, 16, 0.036, 0.004, (0, 0, 0.176), bevel=0.0015, taper=0.72) + _prism(bm, 8, 0.006, 0.016, (0, 0, 0.196), bevel=0.001, taper=0.4) + mast.group("Dish", dish) + mast.group("SensorHead", lambda bm: _box(bm, (0.070, 0.026, 0.040), + (0, 0, 0.226), bevel=0.008)) + mast.group("Beacon", lambda bm: _prism(bm, 8, 0.008, 0.007, (0, 0, 0.252), + bevel=0.002, taper=0.5)) + out["Mod.Mast"] = mast.finish("Drone.Survey.Mod.Mast") + + batt = Part() + batt.group("Cell", lambda bm: _box(bm, (0.104, 0.124, 0.058), (0, 0, 0.058), + bevel=0.010)) + def ribs(bm): + for i in range(3): + _box(bm, (0.014, 0.128, 0.048), (0.0, 0.0, 0.030 + i * 0.022), + bevel=0.003) + batt.group("Rib", ribs) + batt.group("Latch", lambda bm: _box(bm, (0.018, 0.086, 0.024), (0, 0, 0.098), + bevel=0.005)) + def gauge(bm): + for i in range(3): + _box(bm, (0.006, 0.014, 0.004), (0.038, -0.030 + i * 0.030, 0.088), + bevel=0.001) + batt.group("Gauge", gauge) + out["Mod.Battery"] = batt.finish("Drone.Survey.Mod.Battery") + return out + + +# Which module seats on which socket, and the module's own mount axis in its +# authored mount space (+Z by convention — asserted, not assumed). +MODULE_FOR_SOCKET = { + "SKT_Rotor.FL": "Mod.Rotor", "SKT_Rotor.FR": "Mod.Rotor", + "SKT_Rotor.RL": "Mod.Rotor", "SKT_Rotor.RR": "Mod.Rotor", + "SKT_Camera": "Mod.CamPod", + "SKT_Mast": "Mod.Mast", + "SKT_Battery": "Mod.Battery", +} +MODULE_MOUNT_AXIS = Vector((0.0, 0.0, 1.0)) + + +# --------------------------------------------------------------------------- +# Assembly +# --------------------------------------------------------------------------- + +def assemble(sc, root_loc=ROOT_LOC, root_rot=ROOT_ROT, skip_mpi=False, + chassis_meshes=None, module_meshes=None): + """Build the posed drone. Returns (root, parts, sockets, modules). + + Sockets are authored in WORLD space (where an artist places them) and then + parented to the posed root. Keeping their world transform requires + ``matrix_parent_inverse = root.matrix_world.inverted()``; skip_mpi=True + omits it, which is the falsification probe. + """ + chassis_meshes = chassis_meshes or build_chassis_meshes() + module_meshes = module_meshes or build_module_meshes() + + root = bpy.data.objects.new("Drone.Survey", None) + root.empty_display_type = "PLAIN_AXES" + root.location = root_loc + root.rotation_euler = root_rot + sc.collection.objects.link(root) + bpy.context.view_layer.update() + + parts = [] + for suffix, me in chassis_meshes.items(): + ob = bpy.data.objects.new(me.name, me) + sc.collection.objects.link(ob) + ob.parent = root + parts.append(ob) + + sockets, modules = {}, {} + for skt_name, pad_name, centre, normal, radius in socket_spec(): + local = authored_local(centre, normal) + skt = bpy.data.objects.new(skt_name, None) + skt.empty_display_type = "ARROWS" + skt.empty_display_size = 0.09 + sc.collection.objects.link(skt) + # authored in world space first — this is the step that makes the + # parent-inverse load-bearing + skt.matrix_world = root.matrix_world @ local + bpy.context.view_layer.update() + skt.parent = root + if not skip_mpi: + skt.matrix_parent_inverse = root.matrix_world.inverted() + bpy.context.view_layer.update() + sockets[skt_name] = skt + + mod_key = MODULE_FOR_SOCKET[skt_name] + mod = bpy.data.objects.new(f"Drone.Survey.{mod_key}@{skt_name}", + module_meshes[mod_key]) + sc.collection.objects.link(mod) + mod.parent = skt + # the spawn contract: identity local transform, identity parent-inverse + mod.matrix_parent_inverse = Matrix.Identity(4) + mod.matrix_basis = Matrix.Identity(4) + modules[skt_name] = mod + bpy.context.view_layer.update() + return root, parts, sockets, modules + + +# --------------------------------------------------------------------------- +# Check helpers +# --------------------------------------------------------------------------- + +def mat_dev(a, b): + return max(abs(x - y) for ra, rb in zip(a, b) for x, y in zip(ra, rb)) + + +def evaluated_matrix(ob): + """World matrix straight off the depsgraph, not the cached object.""" + dg = bpy.context.evaluated_depsgraph_get() + return Matrix(ob.evaluated_get(dg).matrix_world) + + +def ortho_error(m3): + """Max deviation of M^T M from the identity — 0 for an orthonormal basis.""" + g = m3.transposed() @ m3 + return max(abs(g[i][j] - (1.0 if i == j else 0.0)) + for i in range(3) for j in range(3)) + + +def check(): + sc = bpy.context.scene + fails = [] + + def fail(code, msg): + print(f"ERROR ({code}): {msg}", file=sys.stderr) + fails.append(code) + + chassis = build_chassis_meshes() + root, parts, sockets, modules = assemble(sc, chassis_meshes=chassis) + spec = socket_spec() + pad_by_socket = {s[0]: s[1] for s in spec} + local_by_socket = {s[0]: authored_local(s[2], s[3]) for s in spec} + normal_by_socket = {s[0]: Vector(s[3]).normalized() for s in spec} + + # --- 1. socket matrices == authored world transform --------------------- + worst_m, worst_o, worst_det = 0.0, 0.0, 0.0 + for name, skt in sockets.items(): + want = root.matrix_world @ local_by_socket[name] + d = mat_dev(evaluated_matrix(skt), want) + m3 = evaluated_matrix(skt).to_3x3() + oe = ortho_error(m3) + dd = abs(m3.determinant() - 1.0) + worst_m, worst_o = max(worst_m, d), max(worst_o, oe) + worst_det = max(worst_det, dd) + if d > TOL: + fail(3, f"{name} world matrix deviates {d:.3e} > {TOL:.0e} from the " + f"authored transform — parent-inverse or basis is wrong") + if oe > ORTHO_TOL: + fail(3, f"{name} basis is not orthonormal (Gram error {oe:.3e}) — " + f"modules would spawn sheared or scaled") + if dd > ORTHO_TOL: + fail(3, f"{name} basis determinant {m3.determinant():.6f} != +1 — " + f"left-handed socket, modules mirror on spawn") + print(f"socket_matrices n={len(sockets)} max_dev={worst_m:.3e} " + f"ortho_err={worst_o:.3e} det_err={worst_det:.3e} tol={TOL:.0e}") + + # --- 2. orientation vs pad geometry (independent derivations) ----------- + rot3 = root.matrix_world.to_3x3() + worst_n, worst_c, worst_up = 0.0, 0.0, 0.0 + for name, skt in sockets.items(): + pad_me = chassis[pad_by_socket[name]] + n_world = (rot3 @ newell_normal(pad_me, 0)).normalized() + z_world = evaluated_matrix(skt).to_3x3().col[2].normalized() + ndev = (n_world - z_world).length + c_world = root.matrix_world @ polygon_centroid(pad_me, 0) + cdev = (c_world - evaluated_matrix(skt).translation).length + # documented up-axis rule, re-derived here rather than reused + n_l = normal_by_socket[name] + ref = CHASSIS_FWD if abs(n_l.dot(CHASSIS_UP)) > PARALLEL else CHASSIS_UP + y_want = (rot3 @ (ref - n_l * ref.dot(n_l)).normalized()).normalized() + updev = (y_want - evaluated_matrix(skt).to_3x3().col[1].normalized()).length + worst_n, worst_c = max(worst_n, ndev), max(worst_c, cdev) + worst_up = max(worst_up, updev) + if ndev > TOL: + fail(4, f"{name} +Z deviates {ndev:.3e} from its pad's Newell normal " + f"— the socket does not face the way its pad does") + if cdev > TOL: + fail(4, f"{name} origin is {cdev:.3e} m off its pad's mount-face " + f"centroid — the module would float or sink") + if updev > TOL: + fail(4, f"{name} +Y deviates {updev:.3e} from the documented up rule " + f"— modules spawn rolled about their mount axis") + print(f"pad_orientation sockets={len(sockets)} normal_dev={worst_n:.3e} " + f"centroid_dev={worst_c:.3e} up_dev={worst_up:.3e} tol={TOL:.0e}") + + # --- 3. module seating -------------------------------------------------- + worst_seat, worst_axis = 0.0, 0.0 + for name, mod in modules.items(): + skt_m = evaluated_matrix(sockets[name]) + mod_m = evaluated_matrix(mod) + seat = (mod_m.translation - skt_m.translation).length + axis_dot = (mod_m.to_3x3() @ MODULE_MOUNT_AXIS).normalized().dot( + skt_m.to_3x3().col[2].normalized()) + worst_seat = max(worst_seat, seat) + worst_axis = max(worst_axis, abs(axis_dot - 1.0)) + if seat > TOL: + fail(5, f"module on {name} sits {seat:.3e} m off the socket origin") + if abs(axis_dot - 1.0) > TOL: + fail(5, f"module on {name} mount axis dot {axis_dot:.9f} != 1 — " + f"seated crooked") + if mat_dev(mod.matrix_basis, Matrix.Identity(4)) > 0.0: + fail(5, f"module on {name} carries a non-identity local transform — " + f"it is not seated by the socket, it is nudged into place") + print(f"module_seating n={len(modules)} max_offset={worst_seat:.3e} " + f"max_axis_err={worst_axis:.3e} tol={TOL:.0e}") + + # --- 4. reuse hygiene (before the destructive apply below) -------------- + default_names = {"Cube", "Sphere", "Torus", "Suzanne", "Plane", "Circle", + "Cylinder", "Cone", "Grid", "Icosphere", "Empty"} + for ob in parts: + if max(abs(s - 1.0) for s in ob.scale) > 0.0: + fail(6, f"{ob.name} scale {tuple(ob.scale)} not applied") + if not ob.name.startswith("Drone.Survey."): + fail(6, f"chassis part {ob.name!r} outside the asset namespace") + if ob.data.name.split(".")[0] in default_names: + fail(6, f"{ob.name} carries a default datablock name {ob.data.name!r}") + for name in sockets: + if not name.startswith("SKT_"): + fail(6, f"socket {name!r} does not carry the SKT_ prefix a spawn " + f"system scans for") + lo = min(min(v.co.z for v in me.vertices) for me in chassis.values()) + if abs(lo) > 1e-4: + fail(6, f"chassis rests at z={lo:.5f}, not on the skid contact plane") + print(f"hygiene parts={len(parts)} sockets={len(sockets)} " + f"modules={len(modules)} skid_plane_z={lo:.2e}") + + # --- 5. rigid invariance: re-pose the root, sockets follow exactly ------- + root.location = REPOSE_LOC + root.rotation_euler = REPOSE_ROT + bpy.context.view_layer.update() + worst_r, worst_rm = 0.0, 0.0 + for name, skt in sockets.items(): + want = root.matrix_world @ local_by_socket[name] + d = mat_dev(evaluated_matrix(skt), want) + worst_r = max(worst_r, d) + if d > TOL: + fail(7, f"{name} does not track the re-posed root (dev {d:.3e})") + md = (evaluated_matrix(modules[name]).translation + - evaluated_matrix(skt).translation).length + worst_rm = max(worst_rm, md) + if md > TOL: + fail(7, f"module on {name} lost its socket under the re-pose " + f"(dev {md:.3e})") + print(f"rigid_invariance repose_dev={worst_r:.3e} module_dev={worst_rm:.3e} " + f"tol={TOL:.0e}") + + # --- 6. transform apply on the root (destructive — runs last) ----------- + root.scale = (APPLY_SCALE,) * 3 + bpy.context.view_layer.update() + before = {n: Matrix(evaluated_matrix(s)) for n, s in sockets.items()} + mpi_before = {n: Matrix(s.matrix_parent_inverse) for n, s in sockets.items()} + part_scale_before = max(max(abs(s - 1.0) for s in ob.scale) for ob in parts) + for ob in bpy.context.selected_objects: + ob.select_set(False) + root.select_set(True) + bpy.context.view_layer.objects.active = root + # HAZARD: only the root may be selected. A child left selected has the + # parent transform applied twice — measured at 2.335 m of socket drift on + # this asset (probe double_apply). + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + bpy.context.view_layer.update() + worst_a, cleared = 0.0, 0 + for name, skt in sockets.items(): + d = mat_dev(evaluated_matrix(skt), before[name]) + worst_a = max(worst_a, d) + if d > TOL: + fail(8, f"{name} moved {d:.3e} when the root transform was applied " + f"— the asset cannot be frozen without breaking its sockets") + if (mat_dev(skt.matrix_parent_inverse, Matrix.Identity(4)) == 0.0 + and mat_dev(mpi_before[name], Matrix.Identity(4)) > 0.0): + cleared += 1 + # HAZARD, asserted rather than described: the root is an Empty, so there is + # no object data to bake the transform into. Blender pushes it DOWN into + # every child instead — parent-inverses reset to identity and the children + # pick up the root's scale in their own local matrices. World matrices are + # preserved to float32; local transforms are not what they were. A spawn + # system that reads socket.matrix_local, or an exporter that trusts + # "transforms are applied", reads different numbers after an artist freezes + # the rig. + part_scale_after = max(max(abs(s - 1.0) for s in ob.scale) for ob in parts) + print(f"transform_apply scale={APPLY_SCALE} world_dev={worst_a:.3e} " + f"mpi_cleared={cleared}/{len(sockets)} " + f"child_scale_before={part_scale_before:.3e} " + f"child_scale_after={part_scale_after:.3e} tol={TOL:.0e}") + if cleared != len(sockets): + fail(8, f"only {cleared}/{len(sockets)} parent-inverses were cleared by " + f"the apply — the documented hazard changed behaviour, so the " + f"README's warning about reading local transforms is now wrong") + if part_scale_after < APPLY_SCALE - 1.0 - 1e-3: + fail(8, f"child scale pushdown {part_scale_after:.3e} does not match the " + f"applied {APPLY_SCALE} — the documented Empty-root hazard " + f"changed behaviour") + worst_as = max((evaluated_matrix(modules[n]).translation + - evaluated_matrix(sockets[n]).translation).length + for n in sockets) + if worst_as > TOL: + fail(8, f"modules drifted {worst_as:.3e} m off their sockets after the " + f"apply") + print(f"transform_apply module_seating_dev={worst_as:.3e}") + + if fails: + return fails[0] + print(f"socket-attach-points OK sockets={len(sockets)} modules={len(modules)} " + f"parts={len(parts)} matrix_dev={worst_m:.3e} normal_dev={worst_n:.3e} " + f"seat_dev={worst_seat:.3e} apply_dev={worst_a:.3e} " + f"repose_dev={worst_r:.3e}") + return 0 + + +def probe_no_mpi(): + """Falsification probe: parent the sockets without the parent-inverse.""" + bpy.ops.wm.read_factory_settings(use_empty=True) + sc = bpy.context.scene + root, parts, sockets, modules = assemble(sc, skip_mpi=True) + worst = 0.0 + for name, pad, centre, normal, radius in socket_spec(): + want = root.matrix_world @ authored_local(centre, normal) + worst = max(worst, (evaluated_matrix(sockets[name]).translation + - want.translation).length) + print(f"probe_no_mpi worst_socket_jump={worst:.6f} m") + return worst + + +# --------------------------------------------------------------------------- +# Render +# --------------------------------------------------------------------------- + +def make_material(name, rgb, rough=0.45, metallic=0.6, emit=None, estr=0.0): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + b = mat.node_tree.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*rgb, 1.0) + b.inputs["Roughness"].default_value = rough + b.inputs["Metallic"].default_value = metallic + if emit is not None: + sock = b.inputs.get("Emission Color") or b.inputs["Emission"] + sock.default_value = (*emit, 1.0) + b.inputs["Emission Strength"].default_value = estr + return mat + + +# One entry per material slot name used by the Part builders above: +# (base rgb, roughness, metallic, emission rgb or None, emission strength). +SLOT_MATS = { + "Carbon": ((0.044, 0.049, 0.060), 0.62, 0.22, None, 0.0), + "Deck": ((0.058, 0.067, 0.080), 0.52, 0.55, None, 0.0), + "Canopy": ((0.020, 0.048, 0.062), 0.14, 0.30, None, 0.0), + "Vent": ((0.066, 0.074, 0.086), 0.58, 0.70, None, 0.0), + "Trim": ((0.046, 0.052, 0.062), 0.72, 0.30, None, 0.0), + "Boom": ((0.040, 0.045, 0.055), 0.40, 0.60, None, 0.0), + "Fairing": ((0.050, 0.056, 0.068), 0.64, 0.45, None, 0.0), + "Motor": ((0.150, 0.158, 0.172), 0.26, 0.92, None, 0.0), + "MotorFin": ((0.108, 0.114, 0.126), 0.30, 0.90, None, 0.0), + "NavFwd": ((0.060, 0.140, 0.090), 0.30, 0.10, (0.30, 1.00, 0.55), 2.6), + "NavAft": ((0.150, 0.048, 0.040), 0.30, 0.10, (1.00, 0.22, 0.16), 2.6), + "Skid": ((0.092, 0.098, 0.110), 0.46, 0.78, None, 0.0), + "Foot": ((0.030, 0.030, 0.033), 0.78, 0.05, None, 0.0), + "Strut": ((0.070, 0.075, 0.086), 0.44, 0.72, None, 0.0), + # the signature: every mount pad is the same machined orange, so the + # sockets read as sockets at a glance + "SocketPad": ((0.640, 0.250, 0.045), 0.34, 0.40, None, 0.0), + "Fixing": ((0.170, 0.176, 0.188), 0.24, 0.95, None, 0.0), + "Hub": ((0.120, 0.126, 0.138), 0.28, 0.90, None, 0.0), + "Spinner": ((0.520, 0.200, 0.038), 0.26, 0.55, None, 0.0), + "Blade": ((0.072, 0.076, 0.086), 0.26, 0.40, None, 0.0), + "PodYoke": ((0.096, 0.102, 0.114), 0.34, 0.85, None, 0.0), + "PodBall": ((0.038, 0.052, 0.062), 0.22, 0.45, None, 0.0), + "LensRing": ((0.070, 0.074, 0.082), 0.20, 0.90, None, 0.0), + "LensGlass": ((0.010, 0.026, 0.038), 0.05, 0.30, (0.06, 0.26, 0.40), 0.30), + "MastBase": ((0.096, 0.102, 0.114), 0.34, 0.85, None, 0.0), + "MastTube": ((0.130, 0.136, 0.148), 0.28, 0.92, None, 0.0), + "Dish": ((0.150, 0.156, 0.166), 0.32, 0.88, None, 0.0), + "SensorHead": ((0.044, 0.050, 0.060), 0.30, 0.55, None, 0.0), + "Beacon": ((0.170, 0.090, 0.030), 0.30, 0.10, (1.00, 0.52, 0.16), 3.0), + "Cell": ((0.150, 0.108, 0.042), 0.54, 0.28, None, 0.0), + "Rib": ((0.088, 0.064, 0.028), 0.60, 0.25, None, 0.0), + "Latch": ((0.130, 0.136, 0.148), 0.28, 0.90, None, 0.0), + "Gauge": ((0.060, 0.140, 0.090), 0.30, 0.10, (0.34, 1.00, 0.50), 2.2), +} + +_mat_cache = {} + + +def mat_for_slot(slot): + if slot not in _mat_cache: + rgb, rough, metal, emit, estr = SLOT_MATS[slot] + _mat_cache[slot] = make_material(slot, rgb, rough, metal, emit, estr) + return _mat_cache[slot] + + +def bind_materials(ob): + """Append this mesh's material slots in the order the Part builder used.""" + me = ob.data + if me.materials: + return + for slot in me.get("slots", []): + me.materials.append(mat_for_slot(slot)) + + +def build_studio(sc): + 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.030, 0.032, 0.037), rough=0.7, metallic=0.0) + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + sc.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + # far enough back that the wedge draws a contained pool rather than + # flooding the whole backdrop (drafts 2-3 blew the wall out to mid grey) + wall.location = (0.0, 5.8, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + sc.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.020, 0.021, 0.025, 1.0) + sc.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) + sc.collection.objects.link(ob) + return ob + + # VISUAL-STYLE Layer 2 rig, energies scaled to a 0.86 m subject + light("Key", (-1.30, -0.80, 2.30), 150.0, 1.4, (1.0, 0.96, 0.9), (26, 0, -58)) + light("Fill", (1.70, -1.00, 0.90), 14.0, 2.6, (0.75, 0.85, 1.0), (66, 0, 54)) + light("Rim", (-0.35, 1.60, 1.40), 55.0, 1.2, (0.6, 0.78, 1.0), (-50, 0, 196)) + # Wedge sits between subject and wall, aimed at the wall, not the drone. + # Draft 3 put it high and hot: the pool blew to white and was clipped by + # the top-right corner. Low, larger and softer keeps the pool contained + # behind the subject where it lifts the silhouette. + light("Wedge", (0.10, 3.30, 0.34), 70.0, 3.0, (1.0, 0.76, 0.5), (-88, 0, 182)) + return floor, wall + + +def render_still(path, engine, falsify=False): + """The drone on the stage with every module seated at its socket. + + Falsified: the sockets are parented without ``matrix_parent_inverse``, so + every socket collapses toward the world origin and drags its module with + it — rotors, camera and battery hang in space off the airframe. + """ + bpy.ops.wm.read_factory_settings(use_empty=True) + _mat_cache.clear() + sc = bpy.context.scene + + root, parts, sockets, modules = assemble(sc, skip_mpi=falsify) + hero = parts + list(modules.values()) + for ob in hero: + bind_materials(ob) + floor, wall = build_studio(sc) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + # three-quarter from above: the deck, the canted rotor pads and the belly + # pod all read from here; a level side-on view (draft 2) flattened the + # airframe into a silhouette + cam.location = (1.28, -1.42, 1.44) + sc.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.10, -0.05, 0.60) + sc.collection.objects.link(aim) + tr = cam.constraints.new("TRACK_TO") + tr.target = aim + tr.track_axis = "TRACK_NEGATIVE_Z" + tr.up_axis = "UP_Y" + sc.camera = cam + + sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + sc.cycles.device = "CPU" + sc.cycles.samples = 64 + sc.cycles.use_denoising = True + else: + try: + sc.eevee.taa_render_samples = 64 + except AttributeError: + pass + sc.render.resolution_x = 1280 + sc.render.resolution_y = 720 + sc.render.image_settings.file_format = "PNG" + sc.render.filepath = path + # Standard, always — AgX would lift the stage toward grey (VISUAL-STYLE) + sc.view_settings.view_transform = "Standard" + bpy.context.view_layer.update() + + # The gallery still is gated; the --falsify diagnostic is not. Its whole + # point is that the modules fly off the airframe, so measuring it against + # the Layer 1 band would only ever report the breakage as a framing + # violation. The numbers are still printed under an explicit reason. + fcode = gallery_framing.check_framing( + sc, cam, hero=hero, elements=hero, stage=[floor, wall], + deviation=("falsification diagnostic: the sockets are deliberately " + "unparented, so modules leave the frame — this render is " + "evidence, not a gallery hero") if falsify else None) + if fcode: + return fcode + aqcode = gallery_asset_quality.check_asset_quality(sc, cam, hero=hero, + stage=[floor, wall]) + if aqcode: + return aqcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + print("ERROR: render produced no file", file=sys.stderr) + return 9 + 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, help="optional: render a still PNG here") + p.add_argument("--falsify", default=None, + help="optional: render the no-parent-inverse variant here") + p.add_argument("--probe", action="store_true", + help="optional: print the measured no-parent-inverse jump") + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + args = p.parse_args(argv) + + print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") + bpy.ops.wm.read_factory_settings(use_empty=True) + code = check() + if code: + return code + if args.probe: + probe_no_mpi() + if args.output: + rcode = render_still(os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + if args.falsify: + rcode = render_still(os.path.abspath(args.falsify), args.engine, falsify=True) + if rcode: + return rcode + print(f"rendered falsified variant {args.falsify}") + + print("socket-attach-points 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)