From 1202d9d6c327f6a8d3642915d5c7354f7e2a2fb1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 28 Mar 2026 06:20:14 +0100 Subject: [PATCH 1/9] feat: add @layoutit/voxcss-vue package with 60fps imperative rendering --- .compare-output/headless.html | 1 + .compare-output/headless.png | Bin 0 -> 137208 bytes .compare-output/react.html | 1 + .compare-output/react.png | Bin 0 -> 8393 bytes .compare-output/vue.html | 1 + .compare-output/vue.png | Bin 0 -> 8393 bytes examples/test-renderer.html | 187 +++++++++ package.json | 3 + packages/voxcss/package.json | 3 +- packages/voxcss/tsup.config.ts | 2 +- packages/voxcss/vue/VoxCamera.ts | 102 ----- packages/voxcss/vue/VoxScene.ts | 112 ----- packages/voxcss/vue/context.ts | 4 - packages/voxcss/vue/index.ts | 3 +- packages/vue/package.json | 43 ++ packages/vue/src/VoxCamera.ts | 76 ++++ packages/vue/src/VoxCube.ts | 67 +++ packages/vue/src/VoxLayer.ts | 37 ++ packages/vue/src/VoxScene.ts | 381 +++++++++++++++++ packages/vue/src/VoxShape.ts | 355 ++++++++++++++++ packages/vue/src/VoxSliceRenderer.ts | 222 ++++++++++ packages/vue/src/colorResolver.ts | 55 +++ packages/vue/src/composables/useCamera.ts | 229 +++++++++++ .../vue/src/composables/useSceneContext.ts | 58 +++ packages/vue/src/context.ts | 11 + packages/vue/src/index.ts | 30 ++ packages/vue/src/sceneStore.ts | 72 ++++ packages/vue/src/styles.ts | 385 ++++++++++++++++++ packages/vue/tsconfig.build.json | 17 + packages/vue/tsconfig.json | 21 + packages/vue/tsup.config.ts | 14 + pnpm-lock.yaml | 51 ++- 32 files changed, 2320 insertions(+), 223 deletions(-) create mode 100644 .compare-output/headless.html create mode 100644 .compare-output/headless.png create mode 100644 .compare-output/react.html create mode 100644 .compare-output/react.png create mode 100644 .compare-output/vue.html create mode 100644 .compare-output/vue.png create mode 100644 examples/test-renderer.html delete mode 100644 packages/voxcss/vue/VoxCamera.ts delete mode 100644 packages/voxcss/vue/VoxScene.ts delete mode 100644 packages/voxcss/vue/context.ts create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/VoxCamera.ts create mode 100644 packages/vue/src/VoxCube.ts create mode 100644 packages/vue/src/VoxLayer.ts create mode 100644 packages/vue/src/VoxScene.ts create mode 100644 packages/vue/src/VoxShape.ts create mode 100644 packages/vue/src/VoxSliceRenderer.ts create mode 100644 packages/vue/src/colorResolver.ts create mode 100644 packages/vue/src/composables/useCamera.ts create mode 100644 packages/vue/src/composables/useSceneContext.ts create mode 100644 packages/vue/src/context.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/src/sceneStore.ts create mode 100644 packages/vue/src/styles.ts create mode 100644 packages/vue/tsconfig.build.json create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsup.config.ts diff --git a/.compare-output/headless.html b/.compare-output/headless.html new file mode 100644 index 0000000..487bf37 --- /dev/null +++ b/.compare-output/headless.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/.compare-output/headless.png b/.compare-output/headless.png new file mode 100644 index 0000000000000000000000000000000000000000..cdb7a091538060abd2e3d745d25dbb7f3569cd90 GIT binary patch literal 137208 zcmdSBc|4Ts`v?Bm5(+6xDN<;eNt==_C#^zG%b1yLQ?_I+$ySWerVs~ZJ*8Ak46+*r z*%Fe2gh6&=%`&q7u4hK)b3V)W_51htJAa%o_j5njeeKu#y6$=K`iPW3FoBlw7RfXK_vT3gl^j9M`5SZ$Qg=s?#9q37=(0ueFb-R93n(ec>%wz zzMqO%3BXg&cdbMy3N&r6-XLpE8f?9kGemk+ooJ(34K8wvNn7TK& zpzXki*ilaEqBt@gHa&5XB(Y6 z{M)l#czx$oME3@~Fi_Q;B1N*d_eYrB>#Oc!Nk4&h1(%UK(?5Bz=*ynTY%p(=B^|$v zB|U?WdiL21#pcB?Jm(4rer)!5E6;qzP1#;XcpxuC*arj?qt&mwl<{z*57QS$f>LER zlJtQ?;SUj^Z1;&{__`df@P0w0qsYpfqRK-#^@tBEz$M{iF4HQZ(PT$f$l^w_-vnEj zY^Oh*6!eh>iXG_lTk+N(5N6w`mm@bqx1^0fZomB;vHkd)gwuIm0(~myLq!7?Sd8HN zpZAFutlKN$v;cp1$)2ph$>(w-gpE29)Oq;JWCC&_%#weUhD zg0xwPzzZ)#8ACoPU&fsrjtCt>HHaQokKs-BLxlER=R@e1;W3#fh|s}EbfN9mGHxY( zGGEjwBT@_z(Cj0F)95_w&J$ytuJn*MU+Z;lWZuPwyc@jnSE#5d?E2Mbr)9 zlA!EDSw`6il;wB`P)$)fsXkvu###5gXWAbwoe$x&JwfJUmY_6BD{0=|5U3L_CDeEq zHu3=i5UXloiwCKs)hnqae;Xl?3{Ss!@i$RY(dm6aE#mwI<9A>){f_un5COTW4&&FL zK)=&~G6%tHr-&NDNy1}LYO}9<_){fO8d8y%dzI1R57Hh{6E1|V|4i5djDc0aS`eUaZ^IWggU)$T*8S^(Z}ZX~Jg-UIb|* zo61G$kV|Lxqx_+)r)eL2j0CtS0&kGUC{sCUZF1>`FX+mH4^8E44alVz*_^b)(zF|& zA^`{F(3STm1&LbRSi>lAF-V8;-ZBqx%qse!r_u=5;Ll;G$Vf6P(g@&Y3?K0gL$HY; z)v06j$)z7rJ3+?oV?C^o3s4DMsP>bSUg0aQt|6u7qZW)voK6YG$$>4Q7K~&c^zg@O zaYSadK%j`yEs&832&92n4+9T!io2jb5C5lx;fzZT2iWLx_wc?+fX64kST#D zQi9>H*TSMvDN|FymmUi#4HF|>JAl2yk06^>AE%1(5RE}-L8sA!(HABKbF{g!N;=>x z5c0h*nC{anso8r_wLpyTo5}^9BA4c)9sroP%HoyiVO2-X@Hy4wC%XR8B;J{#}O6rtCEy{u*Q?U@gjfB;3bTj11X^&8Y|qheQLR_X@5FH|=~SK<&MJL>*q?q!5jF6X2%YL!C6}OGkb&WEM4jcBsB| zZ0HxSIZAd3M=b~Gh#U|<{1jpOaegaXU!fL(a1KZ{US$!JTF2G6^#s0f+O{wZmLu3l z$4BcFR}k2GLKm#Vv{DdV_PGdWqgpYof3PzfP@#LPhs2ZJ$*)ioK`5^t4~i$-lP%eY zC3s4I!5Eh;ue6Jt8WB%6VCNp-nT#K$#@_l-tzR#1`-jE5qhtltCm~NWtayl+jU*!K zf{`)AHogD00uMXufKYeQ_xLDAg2dJYM9JF4(?4kq35|(}-JSS&U(*!XvO-KnuY~eNO))3`C|ov?Xn)@T)rqXGeFjRFm)K?T+Rte=Jxi( zH`WqD6W*3At@2i?x|W@5dTDnzQGhC?%fJ}FSmxU`enS(yTxDY7PR1izG1k`8-#r<> zG9c|etvE`A$~Fe46BUUMw>U;Bp1_Y=oysWd+Ih=&)6ryJ!c$A8IKzU&Co=!ac5T+& z@$AH_VwWkofwW@9X~7oF(cu@aG;P8kQs$aJyQ4}*^SV!jQlbQ?Y`glj+XB})=;0qK zYE>H5>FeY|E1phKq!+i&{MMMUDBOfP|Bf$bJpH%v5U&*YlS{Lo93K@D;k*l53_3efgVCtRe9D~= zoD#g`lbnvve$)PmtsKU_!8q~K%{nWtaWmiJ2|?xY^6N{9yMXj)=76~A?d3x zM=<19Vrhw9g)8wYJ_iKc4ZkRPq*PtsnCxJfBzbrnmqgb`Pye8GBzC|dJu5Z6WP)#d z!SeL*Cx*KQFo5i{$yB`Cvt{XFoOeSSK{q={$Gd&qC$44Q_*dDYm_<$ovZ%LYdl%2+MbW|d4`)`RR zi;_ShNgCs;4t0qIFl2vto?}OZjCE;=FQk;KD}OVrSJr94P|CZ`Mh2GbswYr?mmCx? z_@xmUDs4UurG(I22T5AFJyGt{!#;7VXU9jW+nuKcYelGx=}cLDJ=Ex_dlP8Ii93&m zh&zI`-5H9YrF-Nm1)Il3mc~RD%N+Ari-q4j2V}#I@1ud9jEl%vv9S23dc4y{$HsSO ziGklKh8_cHVq?E`s^Cpj`W9bdDuRx)W5u(PW5xN~@8WQVIM`j$GW&<2*8CHsspkp1 z@PO2dQkD24TMT17Zq@KisTTAmPmg2YBxgTLV*Bpm1JCpl6HP{9Zv-$Z@@*a_hrR}yi?lQOlKRUD=5dckr7VxoE7q_`yDNvz?Jc%w_hE{`BuvHY}P zI(Afo#uP91T}g!xJJf)a+kYge5+8^=-o0R}k^E)S!rhQN8VVcuxM`aO?lrAg2N&kD z>Eefj@xQPDPycGL+K9ddY(2)$le5!`8%eF6CMkR5iJ@-kMzeoQYri6FeiE*_zqT11xnI1GK~b}bbh)>FdM&}0^AVR^(!&5oo&ZIsB0c10 zo#!S=nrr$++L)>UWh1B|zr|O48U7o2N6Ur=jX%cYk-wZ!2!DdyC z7(tS=H4e6>72mA&%j7L59ev18?Dq3(m|&RO6tmn4W^NzFCwlxM7x^~3NIOj3U5 zrVCE=phvRZ0=w_M5*g}4Nw5jYWcx#9&Gi+h>y>?QTiNQ6vF~5TZIET@ve$5T6=c$3 zY$1^}^*#M@;xCfYX~h~MauEb_Lk9*mg+^lIhRHeKLp}$9lT&Z7C$AZT6cnIx2Mv4d z^k2?3?IkY(9-|)lai;#SVK0i#dPl<;1ezDOE{7G;z={R0HOW%u{u&q?Nm`So zsxbahYH^=DTI8Wm=j2in@lC@%{y3MkewuEEBC%11@U-s*k3(#VHZ1shLoR9OLoiVX z1;hKP-(Rq4EzaRAO`1Bb>XlJ;rZiPec!gUE7z5Y>!$IpF}|B34Ig#v^k-Z84#m)Y9|&ND!@tU}!pYqwVqI3JRW6(n$;<%OTtxkDf0{9>w{vQhHLs(lv{kBbK31v~ZZTjgWdrG{x zUvr+TW;-{AK1p9YHtc->gPGk=i)tA3uhseL#x|IayZ;@ERU+BUAP91_CsN9!G)k^v zLr{{kzIjC$^$BNdF>oKh0xtO056f{l8J|t$EZ8SV#6fP>DenhI2Ip>)LS0A_h$`1mYxnWAsU%slU5LHUjc{Kd2(0AS!+@P%g zWm=Fq?)FClWR*6NirDpRUrQw>s8e&LY|k};!VY9ol*$GzAy?7mSd85p)RMGsxMbp= z_Q`dJL}U4uHlXi)9F8U;o6nHI;kjt;tYULApUCDUxzQ$N&-N18@m~11|DKkZ!yUH5 zbR0J^3^TxAE{!rt#OX&^D;V!)lRziRC4tpD=_`gN?quek0Y3fH_L=~=fme=&{AaSE zgO{k;y%#>BALT^Z9gyk&!;eh~9xc0EH}`_gN~Cw+zo!{WfEdEF9hbEY#UbPKm$Lna z;Y0+vGAJl7dL2Ohg3_}4deL$pcMuXtb{H&c8=-hb4vL#{ap;dfmA8qokc>a{mYJB> ze4{1pWBx9Q>9sPCId2dI-LL-(%o##qd3m{S(dN!Hwq783Mg8>be$80C)DOx_lS$K{ z@-Ah~`+eefYjv_$a`tWrKvgH9o^WJH+=|H9jA3wY?_R;0z9P>jR z+SneB3`)VeVkLcxoc2AtfFm}61^&@<$e3ojyLOJ4fS(EB5YY6p3GxWJ%E%8U|DUsc}mQD!S<9ozp??xM_ODC<804;p;+aXBtBVQ=^8 zM!;Xut-~P3{f{7MmSp;zvp|94zW=?1IH2RjVKWB%f94fLOOCw4{!uji$}0?A>hd_- z-`4wQUePpI%E?;EE}{Qrh8LgzqRIZB4sFH4*vYg>4mq~TkohOPZCN0Ma?-Pmxm1q@ ziwBkha8~TS@fkNYId0>eVBFMPX=CG=Lamju<>29WR*l!y9U%r5msg(K=K~C-rxBjJ zixO15(8TrTe-YU>9FIRQ2{c`azc)eS?iW8o& z5n)gHeO$!7oYCs{A)3*@i+HbkT~j6~#9||>-oO22C7{^g0;n&;-DP+i+bTbqM{G4X z6U49iEO6+0w`sVES!$D`3lJQBF`C~7%^92BgVAg;chOz+t7!ekoJ2^e8kJAhOl0@p z?qsu`LLLxCmw}^Y#_{_+4q=OLF)*sPg6&1;X*r)a60N1c!&;7yxGpj8d7Y_9T;bbE zq8?CZtvUCuuHQ{rE%MBLBtY~|Vy9Ey_Pgo)zIRVCXJ(u<4#&#Kc}y*}0(yXimnoN^ zkg!%qy~8aT-h)Jjsx#?I1M#!Za}K-C>ZQqSG*@WTI0Y5i$AQUm)K}NmJV5YQJV^Z( z`veNPYBx%KV@?%2(v#HoTtE$o{v&dKnOY1#syR*jM7Z^))S#TwY1Da~HCKIzoe#+z zAal4j)H@`eU~@2rAN-4T-;rF?=a!$TqG-kty!Bo?+46qsnE)DF_+!#k*~=7NNl&)) zDUWhI{e|NAxoRL)zR7<#mi>Rz1bn|MTBl*GYNTv$%DhyM85`0#V_7jvV?jBMe(w0~jgSWq7lL z2YjBJ1m2pae^xr_1$^>yhSZBH+`F*?j{Ho?BaZ$5CXAoITZhl{W}Y%R4#8nRSEPNZ zcMqO!>4*RK-jI3ObX$E_*&Y9|{WuY!v3GSg;QTUtlgo?+=pwq3Icg=mJtF<}W^fahI?rs1U{Y{hr# zG`-YNV;%#U)vyMX@Wz^K@#Jq;Bx=q0w$F7K2thWeO%~@*Th3$PC$Wx)GsCDhThmyy zLRl@hvEK~;ckq}J?5ty-r$eEd)=#VcABAZLC+3ccI+qdtyEg6k&L(qDM2;lQ8Y+%| zfmBfMFJn~8fIvXb@Ba}+JpA`avX5h27QGjx@ZUf~{d%eE3UnS0c|Uqb#1kMGkGMMm zIAnSW@QUGB)=jj+jW%EhDLiT9xMHeP)-Nnzlm-aMzvp+ilZ35*vO#0KRgDh?MezQ# zIm1qtznNYF7X~21W{iG4Z$CTr=@_iuAAI0{m96}2sSd|maQV$hR!TOBmMJPbE%4!`9wfw zZCU11vjTQ2*so|Mt9tr#vOE{SW`nI2AluBvmUj+XWq5Qti!DO~**buuvB8wW*Z8NF z?pggNDZv>E>?%1*#rhFs!!IhL5+2k+W^nG^cs7)#cZ~%on>fb^0MB9bKTD2^S!gSR zseF0nS?9U?KcPPD_L+3yTboeKBP7y!Gv@z{)jnYyQY=+!e{1AmuYC?k zKaQP5S-fhtGl&=ZCuk00_!W$>@rHt7e)Fq~4L1Nps^Szp7{?mZOBIQ;3tNmsD_%pLZpt^xIdob>-h>WndLDcXgj?$m+bu<&Ytza@ac)d?-(xZdT8cDWXax`Lj> zX7Et3I2#u!e&^&2TTnn|}hF!Pg0H+VOsFZb+FCYU?$ox5^6l#B?xjv`N4a}P&(3JJp zX2S#wd_3kPIUt_d_X`|#HGOU>C&}hEqC{fby+7brXkEdnTTsB@=gdVeBzvEudsS44 z`D|U?SvMu`QBQ9zF7niYO%)!{yXxbLlQ3NyfT9Wik%3PMy1$vkrFa~LF@<>dxfXBi z{7=X$Hjq}YFsl4iqKQzl?g?8Q#~ER<%-l%YGJ;w;tEQ&5a*3bc_fj#B|H-%Q^VKYd zcRQ~HLf`tJu0i*K!Ng@O(#SwVNc~@JYy3IxfhoLyXq%JlTFCe!t~ofkEb%t355Xb_ zKSc8op}ZCQL1~TxL_qQf#p3Z(dStz>J_!}CQZLL2iL9n>*(-raU#^h?b>^h=qAWnE zn_fZ<5g95d2k&-w!i{Ms7)-#XjV;l@_n~!Yeh*OYG7zi$IDoPCV3YtBdc9AzawDC& znXA~ljvOBa3qorIcD{KV=jpFV?X}<*LOP0*odyTFupa!Rqn{woNG6L~fJk+g^A1RI zl+C-If(tmr{F2kqqe!g@w&0@5^{%hD8mTC3;V+V)Qm_1&huG8LRZidmk5I0-3R z?VMcYX@Z#|6-x4(895E&XGk$}mE1s{YOr`MCVE(W#dv;k&yaZJGG8<$_Fm7(MlfRL zC}AVwD=cTsr>z)+_q0MRw{X_{X4{;2NAd-JCH*d>4PUQD=C%bL*wIz3)R6cErCz|c z2&7Bhkt~;caq^egK)jGdYkH=eOfUci?>_MiHUr+0oZn6U)xaZF!J(iF?_g!Nm>$FjCQ0XdlI2sn+R+d`IJ)EJBvq41#-~<214-I$D*g&LXFHFJ ziBFOgR&iAD`xX$t2|isV51Nc0^eVYmQhQ%&!&sZNebLVWac-&{1XR1o*&B79m}$X! zWp$Iu8nL}lJW-xn*1QY92|B04)?^<*UexL_dbv`}j~=MLv3JSukNbqs0sXaT67{#VR6d~6$~RQXz4%Umvi}1CvAYkE60nvM%-m2l z+SOSY(~dI^7NDp;1Kl-z@HUBQU8j!Pmk-7C_1^)*Z+oI{n%~Z*a&*6AhMHLy2Vg&W zBJ^)nU#Ef-{Xj?$V3?!-PGCJCgW9Is%0+&aDYF&NT(_*>kZf<0tAvB1Qo1)O$VYHK zC&!C~|B>0TkFDLPS=kHU4OaNKo&=T*s?=dcv2@7rT1&7#m4d$|ux%vz0BX;|z z?b6)^zmw)*@r3N^%6A7Xmc_)Dc160CV|Y*Y_PZsW2^uoSkph$+vAC}G@j&lq2>s&z z>lp&8EV$^DY#xqp(n;BK>|&>Wva&wl4erH!FmDjm=Y|~gY06uzeEa-G$P12y_po~! zkP&i#R4HhKnchd3k#2$DY1}GHev?4-0AGfl91a(ndsvmS=jBBdBV)HVV-++WA-0_w z1k|Pr~RS4=B z*1z9RbTNS*@N&5i3s#F&FmrGv%?Ob-lnP>%`l9zz5 zbk;zmi*;opVB(GpfSw@Uq{+iyq(}0*pxb>o1Pp2BH|VJcDU}gAZ}+y(JZ8xw)NxQ6 zwDoKY@S=SDp`@PmdgQ57Fj>H#mF2n?A-Fo;W{LnT$ea9^bo)$O=i?q2ySOcYi0+Go znqK@e#L@|V5R==ZbeCCi(>1PZtnTY26H?;3GfhR1O@gm~liomb zN5$(_X+8|@j!vN*=x6bRM4_d{WcQ~V0XS1pqW{<^Lcco}Ql79k7>)o!JFaUzg}7t4dH>bT)MqrLZrdKd;&~- z=$AvwwflHgM*r=1+}K)<Ml> z?TW?!BOk8%j4>WYs*ZAu1)V)ymXo|ro&ATMQ7}A$V)XLy5KJQ(gcK(n&#$*qub^)| zu^~M%ahF86i%Dr-TP9^6r|lYQo8|HMfwRw`2Sx`n0?}?#gHAm~{S!6LI6h2(s_}vC zB#KslL2ZKFi2YZd%xQDSg<=_ke;vPprKF(w-KRmr92P+Or_vP1t+FO>e*r*0#KkhF{v0ns<~y6wq}BbX(m}_k z?p=ALvVLg(SIkCNW~v7?ID&fb{)=~Bs8ap)&(_=2DO$0#5L*GjYZiH|H3z31jQg*Y zIwzYc{5RW9)N$Ch^Wl+@Ro~tHp?UA2A)`>aJr?iw+_ZCw@0SLULQ$h8{tqNOjyW2N zja{~?&5(xCDQI`e_1Gm&k2kku)ttzpI(85JhbU??uNU3YeqjM2{L#7`FZG{5_hQEs zK?uali2+;_FrSU2|3bT=g^(Bt?dJ*aQ`cWDn;926QH@wx5>3(W(=~<0f#Kd(gM!Gv zMKIQnvg|@62d#+HGaam#IquxC zlokBhzma=5p+qDbwa7zFB|O+=e}ztw@{;$yeG2O zB46$L_z8}lEkRbqaEP-)jtwYMB|sPR?2<0bYv1j~%<@vr2KjD7>%=?mv8=fTQ4_xUsJ)$oE7E2=*NQ>OcM9EO zFQhv@7wVPMRpu79be|{QT=ydBDF9ziHxEIcg+f8^r7OUxA=xmtkI;>4?1ij|)8~9H zR3vIAiYFgR61Ps%9)gJNp0^ppYwF<-l@~gG;dZ}LUh#LF?0J{; zC6jLsUyy=Emc%gwjTX$Oi7|u8=oN@lvF{q>w`iXY5j~nh-|}qUzGye=c;Lexg0RYa z=wf>zJif|B94=o30a*0)3a+I`y+z_vi&A^Wh#^!?hUqQe(O5fE3H}m^f;%;c-J9Um zeEQCryQE$Y;4=C8nk;edGdoJ1MY^gJ*Ls~EZ!O_PND}}{k*m)&xkTGR!EZ^7_BkE? z8wYFJTlR2`(Tp?TOycI0sE1z>1ctcb-Rs(lye)A!sEB-*!r%W1+d{$T&8QDCkGKk* zp#^s{>&i9IJaq2|YE93F?@m6Y6*fJWl)6O|fH#=wt6LrD|F7`d)0-vAy{)m zHEHi+Uvy1!Hvf&Skc3Hp`f2UY5}y9o0Si<>6j%q^cMpi=)VAI3Aa=AOHFuGp1tVX79&N z56K3mpV_BnJdyp=4~vxuc1O?-6l=|7Shd`#Z;Z<9h=&;o0MpL1qvTKT@n4lb!~uqpDKrj~pa@-Td^2qCpZxJ) zpw7n&cn^&Hj)1i=08zTj98v(7A-Pud6ns8)mBteoiQAFMS9M*(b3Yi>!j{g9>`@4v zeNXKj*v6_*dCGY$k`1>dps>7P?>SuhHg3CPi*16JgWjja?}d>ABl9pnW$eO^AV`vYI0-A;*i!(PKAE*ZrJ)qRC0kN?*;z+9L!{ z-y%a?hHvG#_nF$^>WQ0sR4+Z5pH({TmLCu_l&(_JWpc~H03p5ecXhBb58ST7c%W)% za_gx8J~}bLv97c{)~WQZTEHiSfJZ%1oMu*5@=42qRz|59$%Ho25j%c+*MqU&@KRKM z(y)I-QQEC>ByW5Es_8jlsu8BM-3A^1vB;IbG<)JbUlzcN135IDOQu=-CjS>^-cuR` z<5xO!A56={U!SK0E4VOG|WYi*$R< z-oJjl(J(w55}YU_D{G^J-s^Mv{lTtNQ3H@RvPYh|sl;5$pj}~sZbmVaB*V>nq$qxD z)wT8Gn@Z#3`nxCtpa(S2puyp+l6|Y(7%8 z{@hd>lLW&mOp>@M|Ixb*yDEgwK58lwT1zs~i+CBk@tJDd_?iN>ypwwipCXhThgT)t zdPY=F;r+xC)dV4jJRRDcWb14s7s*~Vi}lgn;VE8aQl%h8p-x&jYZVtab^7D=>$h&L zi_Nv-x88W6SmTUjYvZ;3r_km>C^V7xNZstZh)`pJ3*&AYF}`&vg}jA&B5PXinCN~V zdUjeaOK?=-bP7T+ulGG&Y~;T`zFc@`XR-EY_uP)mO;&M4CnK3nn?78+d1*VsOk_4m zcGk)z=cbrxnM@|*XEt+FBG#3+$$k!b!;A+_ys*(#B;_s|5>7uHSUj2S_tdMb1oK+O z{LF@?eKXuVq}wsh4vua|%X4TGqEPOF<=9bgZn2sz>v*f2$;a>eoV6lHtnE$TS z>y?X;L()LeApwN)_A6#+c50hZW+>Qyq*bO}TvbX%Kyk#GTNZJ&=Er%Se%z!52pQv( zYjzHPyK~&lZ$nT}?Yx59jlJOBeL(5#{#0_3^0S?8Whg~T`Rf)guHrIezPc!O_bces zwT$V>;Oy-qm-l?AnR=>}9b?i#D%u6Wn1_QSjwYr5DY^v{dBuJbUt?dE2(YTBU6L`cNON*)xQaRPXF*5c?S|Zoi~B+Fv0b z&^&K5ov=R)(5h?R)6aZQr(s1LJD)mtmg&dUSxdH_iR;}3T0`@1DuPG1p*A9G$~$rE z=-iYvD|kb!U-8ztXW_?O6GA}gnYBk0D8YaYYZQQ(S~eYZ0Fs%MmOfEaezf=FzG>*7 zmR_#2r)Si3L07_jVM7Q)3RQjM*ASLbqm=#5sGct?wg({?wdK&<>(^b=?;c>1G_qVb zHXU=QV0O&%6HG1=8LkQEMhK^ogGU-Nsb#E97sO2Yg<5_i?m}%V6{gzt4`;c?tlhXv z069o>{avS7+1iYsr*Ch1E~@^a_CIoHwlm(?!=L?DU7xnP^{0RhxK`WhBMNrhs5M)R zw=(2aTT*0U(lIS_VKzgMnj8M1d4E`LxF4{I;4QrK5)aWyh^`nl&CFYDZuYv}q-7L$ zkAA@YI9#6~nXA6xwUTVT8D#)`A3hj2OGD@$YQs%UZR&@tXQzlP(^|DhohdiTJ$eer zL0~v39My$1rBx5WE&SZ0q&<8&^lOS^oCw}y&0yl;wz9sExta@uF7`*@M2*I*>2EUy zbzOgsEvVZ4xMPIeH=h}gsoCE*UFNEWS3XFjNsfwcjJ46>mAm{GaCbOH=EAwMyBBVF z{H|kmRM09$DSOxDxbF;b2wz{1O+tMEw6!}sjM@}>Dbx;|z%>Wd1orWV(Jf!~?aH=q zbO9NDy7<~_K~Up|+Ia!8e(#@R*<#XZ9TG?l%#h6XEc{em{xiWZ!^}wt5fPo6LI?j2 zw4@k-;!$2z8$l(W$lR%s7H6YlL6nhc&X9)?AiUGZmYNJq?|7b&ex%L3PyQey;ry{S zb8ANdX~(KCe$*9K<~~Upzs(=!*Hl#8zZY9P3+hPziK0Ob!iwclV0^JzzLk|<2oK{< zvp(+`skz{Bmi77f8`e9y=xWZt3$#)O?zD^z$;5WlVzXD*j&uqzqTYfbu+gG)&*;HM zn2D}RFCT!C9iE|9ZoI&)`^uD)8SOst{Pqv4dfo)3^R-ON7G{q)@<_h@0vNB)czV?^-42~mCJ)g6%v&<4koI` zjs5pS+amc6want6ia&oXF>EbxqIqhkbvsJEb3? zX!5g~4%q#|0>bFujK0C{POBYWnt6wLpZrC5l$8LwMTY8dljQhtwCCJZ*UZftajPTv zwWMD<^3|y-9aJaT#N%6vqcf%cps6$a2aHXZ=GEM8)F_CMJ*dBtA7BZ^ms5R4Oq@LT zJ#rttV^jj+=p8y0`f=d!B)bC@lr6FlqLnTE_;5=||J^sVJv`LK_k2DP*V9q0va)=4 zq|h7uYpzUK&(@e0&rT#s{kr8XGum*OabD=88)q7(rEGFsb)egT)OkbEC+5U$R zi@AF&aR8+y-1juO>Vc3$RIkNm+nYu9kpN@Rs>WZQ2w%(#l})Fysy|?e>!zg0|@N6BBsZ>bmM5mo)oF(XmJ zeXx(_dN~# z5)LWAq5Msow3VKyahTZqDeB|--Rns@Xvn|zyK3Xj1eGrLX+d>*wVG>@{Y=NqO#gFK zLa+SyB-+%}#^=v)QO`!E-MVJ^<3h!|6~50^9LYUkBSuJ1_(Z$FDgk>m&p)T9yi2`p!OrnZBb+OSgGQyP%0oP;oH{>j_vZcs9Iep+JC- zq#127-sZk8v|gY-^sfS@_;j)NB=irq0-8&Z9goV&-fFeeE6V!hDykQXy^qP#5_i5< zUxem`etP}!Q5X`-f5}Dt0eGvmPz_^Md;Zj%{f#h$lEHCrw1E+=NAXSr`aDz_U+n_s?TC5nuTb1$%`snv+WQs>@@ zF5JT`FZAzmS9k8A?jlEg;o#w+yog~az*!CZ?TfAmaevy%yMWfPcTo>U(rxWmhCNnEv&bv-8 zdYd(9%q_l4St_m?y)_xo9zNwDS-P1E>8L}8cVu*M`US2_Sp~P}%Z;kf=1;_g)(V?8 z-)e{vSsaiooOZ7Dvfuj^@LohikPUh%>+gT|hgK%5DkbLDKK)3iZTcaUxi@A9f^L5w zb6INaD;T!bVj0OUOb6W_$IW?W@t0gm!N0VLgqy?Y^e;K!Epz8Fy!XV2)~$RmB}Qd# z|4xHYuSPGbmQKNlzjbUSWA;ak*~0kf{Ma_Lshswop-s03F=lO!t37C5;mt<{5hpHm zA9rZ5$+mhoF+T=L=s$}NH1@|PMr73CQgmMpDz3NCF;U23EX;MuGQ55cm$bdxO`D%C znp+&oh;tjXja7Bi;3c@nx_VE`uR!)l2I&K=21SJT)JSb%?yPY>Oyp|l%>JzWtKq^- zc11{<&fU4%!+8^NcCt&7t~W!Q*3G44PdwN9IWOrwXJE=I?K3+Y-h#uuve|H%;sgyP zL<7Cj)1l!1bO)T(c2u_Y?P!^L>aMT41}~DF{SBn7D!pP_7yS#fmZnu!Rf(()=xsFo zakoihVKC+DVt;7dPrU5U3D5GZxdpG8sC#g~&5{WCoRbVH0#)tg$%tw1DWh`lZ@P+= zdPaNv4CQh^${yMmmY@@x;Y_IPe>0zMF`7|HJ@rGQ`D2vn;v~zvsy=1m?y&39;z-=> z0cO?Jugs}LxH3pH)B;s7ML8;YK;I<1$Eg`h}Z451ljSKNGAm`yvgK;?o zxq*Vj?-C<#v5GtkZ%^J+ufCM;{6%8O>aB;QL1>y%iCSXFV&QB>k%+ueo2X$$p*HtH zY@~*?{p!W3cku@E>RQviDSC{_WHWDr*uHD);o71V(HN?=l?Y(g$U#$LxK{qs?t)kI zgxCv}d@XlGM6+^&m@k)lYTMfu)l1?FU9DNAh2KUMXt&OF$1#SzE$8|Jw5HsS&mbyn3R;--f zz0kEF>p5)r^TB1s2dC>TewRPK1(LB+_1MxiWwNw=%cuL<$v7Z zMd*qs!YBPYQL#O5$*BGCV%NLg?a$Wd47wO4d*CD1yn4O*L-*K7cY1q1Yq0x)y0w;^ zepK7Lm;JYG2#fP;X(G4Y&Pto9&dka#jTefECAsEzZA#bbLoZ{S&gcR{rZ_&ygV;Sx zuu%;CQ9kYUGb7X>K_@i&1m68ijgf!?=SAH)>I;RpRDK3{#zzz`3_BXsPeyqEjCdUf(|w#qRW3PhFHBaWxE*tM z_hC!oVxFfK>&NUfw?2y_o*rT*yUuucZ0Wq#&^_uo*pc_mexy%=YXaNceERNG-`#sl zSCVGqJIA(_EvP)&wg7hl4W23zQLHS1y`$Z}9p0oR6*Dt0i_ecjJX`J@Z?rnmwAsOV zH%PYGU}OG(wQ!)vet4;>nW1t)Bb_BaaJV zZpXK1kFi{mS{8ay8LqYf%nb95Fc^y7O(fuO2Yw3i7tZyalr3l&Dp!a5WH~s;pJGwj z0!elu-)j$FpE1$O@SY6FGjd*_2N$h=__AM1{m|mn@DGF7O$!-am9D);?Z2<{)uovw@oTsb0)}^*gJEa#d3%h?db>>Zxb?+ml47w-8c)x!t=ZdnR&tIL2*;baqv&+htL&=hG}!4O9H! zui0xR#FixZ5+H;^uC(~k#y`3+(WTXDBOLndNfIM1lTUWDguu2?i$2N4Io~YW%|nYH zsM(*}H&;$rcs^aX5F`?GSQD!?eQRpkcW`y-*tl1rtgDH?#o+X?dMC_7-g*Etgiihj zP`=8n1c1BGoev?H>euTj_83hwe(Hw?S?k20uB1s~Xt%4;*sGT|QQ6{hS2k=4i@P<{ zAsrg9f-vs*WmF+6YgPzhY)s`0L6&Y>%~2RHRa9={BDnbLqN(d+e%o z#ah#^JH73H_qw(;HYcL!D7~tMN6=d9UHfR^kfnj2$n^9GV1BXdD<1B+lnvh)6=5ks z<3V)Mfmm!r@XLAnqGLh9e8r#Z71E(cl}Rd4GK#wVWGwr%MtR zX7k&oy~YnTSbvW>bUn;P=vx4mnC@ky5lE$keov2U^{%fS+O*gj)7E|cd&;NA%^wJ! zCF3bWvW2XH!FH{U-lI-);tQ9ZmJ+5roiNbm=zitFxw)v3@;lnsf$u3oI^gRPrtI&@ z&IH!olSzpSaBX}GzJ`dG*LW&lV&+j^WMr0uJB*tQJy};38q=8_akzf>(&9t|KTE{> zhWEVW&6_tT=A#WsDNUY3spTd7b90{e?!ku^a1+aSF~^%9{YEkfwhnp~CmHXTnC*sM z@2`9WV0VwBM~AOeIfl^6W1xKx&oQPQPz$H1TAp>*R@jQ-Vftjns$2i*zW$cj%mG4UdE|$V8@E zzVKo)eAhMS~IgM4v)R8ZG$y(HFR&@t)|WlHICN~ z2YF3YH7rfPna?enuJWX5=-0~H2npb!@&B!*C9e=KB^29vwJAS~Ry-9Gz(bB`q^%ol zYG1|G=prF&wtvDduFK?66wTP2I5Uga|-S&<7brE^YOOT1?t+%2w?w^o4PeP3ea zP`gGJYqt9|H)V6xH|BKX*(cs<_{FxoE?Kwdv$j7wdRki4McjsJ(&lSo%8U7duYJN6 z`f8o^+JRwxvGQDBvGrcJ@25t$cvfrNG>)6`mti=cK}aPZ>beTwFWIJGmflCaAN{Te zAuy|FXG@35KC{-|#nd1fFP6dZrupyhV!9K%-P6)>lC;`YL4}7Xhkq8=iEL_g91;%_ zFYPEAmAMyQ7ws_hxaWMcsb3a`cPY32&>?G|q!i z`dM*ys}bgFTcN=RDTVGnegg|;X_4GsUafYufW0Uao9DeqZgp8Vl zb&uhcPiLnr&1t@_?}T?e!{fNOQ(jf9%**W$%VoXYD`Z*IBZvRQe=QJcD_HXmh7%&0 ztbj@jv4U!YGX>2-fL{)8T$qKgzS{M>k%V2#ora<68Na^mve0dH6Q3XPWi9?3mmN{Q zSNW*|=Xf6SykM7WrYSlhc?MdA6LG>pizDV)AxYvgnrRfT=9*5Y`Q%GKQ)%&8LlKT` z-x#|d-os1OE7NYzoyiUi6n?GKNQ041bO?BR1;(|#i~Th-LbZRyskVA|6?N=hwW7tjQ8qI(vji93 z1K&{dNuD#vC=d6xSdJUWE)(cwI`qrO(AzmdwHo!g4K{_099jT;gVX78y;o5F&Ny@r zMWpl#k~7wc?EIO zxsI?#gv*w?7V4QvaseserBY+zp+~1NqYHxtp4!;3tjF>cd$uPD0S_d_FuU zyf~E=wB(kgdfMvSo#Ey??s+2*-7VU#`E{x7SnLl9Dj4RJ6{jWSEC@7>W|<2#yF#tS zd%GWbjXEq89@=hJn4a$Wyl<$jt))}#@L@Gpelc`VBwD9sbM1r>!z$&n)48JyC2)tl zrKw;>;PrwBAZzOj?+3|SC80fy2S?yL${LL{-gKu7E&Z_F7I!*y`qdsN8I$@YOa_8Y zF632f8U!Z3WJSrUx|yfp)`d&(>l7}1>X>OBuI!6dU#yEC-2JMwN%)?TQa(du&s@h^FcP!_-&CMcIAPzM_aIp;AgJ(jkg; zib|I>NViBz!vLeCw33peq;xa%&?z7-ISk!1bWGg`{NLZb-(WtS=bW?GUVH60(lp5Q zYQn}jB}3Gx#fjde;$hEif=j7>#nAqfU&ce5q9IDh9RoIiy2`Rw}WX1!|=6JPKgi> z>LwzgZJSl6U6!DOC)j2D_QD1_vkaGkTeLPboK1f4eP()^RL>jY+^~UNFfr$rn%ma; zuiVCls1m59^Z*KA70X=s3*@pbfdh*%E%WgR->l<78vK!(pB8Ecrc0U)216alws)!q z_k>$qw%Pmfd)ou*J?M`sTr9fJ8{&+N6RYX0xZBH+K+L(sTisw0ix zIoVJS>Z3U+WRfTjDA$|gjA&!ED3;l#LKOJ_BfFl&9o`aKWDLUPM6C_Lz{!y0dXy5a z$%Ew^x|kwBpWT`=jRI<8mr|6*ItBUe)q7J;=-OoPj1)OR% z_uyva;S(%yLEnjdA|J1EGd-#uVkYWGWafz;{AY_WQNKaDupU^|3zbrN7-Vi}0I}jf zON0dPooVq!;gtk2sTMBVu7-a*w9pyiu_5FD@gD-(v{F=3*Fo=*yNv+(3>whE@I=hLY6<46(g-m24K?k3+4+mGh~Xo-Y($d zBG{fz*~QjW)0!aUVmv=ze7^B|@2q87J5v7nq;`4;2~0`vYz@OMeV%gT)An)wu>XD) z1lukguq+%rRd=EiA8=1XooFE6^qHm^M?yU6S?Ec;I474Lckg<(>hMQG_yww7}?VdWYA z+XF?|GJ4fG6ywjY<{y{t>H<+L>?o!g+TtqWQwVM*cS4x%O+I=^2@1+Koug|$-MoNH zXZp!IqY6=>3)wuSViFP}Vq&+|cN-g_9eB(p1h=`elXa?CcbWL)&Df+0h@klt$__!dY^`pLvr! zzQNZId5&L&_~-RTWM|Rlo$D^(516oJuq{D+vSNJHwvf5G%;=b`WglX`BOm6$2in@7 z?GsdPl*Eb~0w|n_+dv{?@pF1xFv+K;%~e%t)--f+w})5JXfOF}jWch1dyvTIVx?~x1p+&0OH5tn{y z|3{p+ZeM_q_twyq|B5~Aba9e94pHJsp{cbV2EyZmhMRZJeG^XO`s#*812UYMbkM{2 zqsa`2bm2_L!HqkSV@__vfXeaz2i;$dV5pjBJgFwQM8w9l%=x(6L@fP;^H78yX_(<* zltr{$4+!Md@mkOOHq2vtXmWCL$CQ+Ol@Bz%j0nPFn>#v!4yl_Bfqd~yDgaG-eOEp= zG-PUU1xO&dblLn3WtwlSzou)HB4mtX>Poa5vwZ@b!!}na0b-g%;z5X)E7>TH$P^ni zEPYbAJg>*n9FV%Y`;cvQ!;lOD$418@DI1sf18x-^a4A-~v(rf&cIm-=BD@JgZZYxPp!M&r%(OgK)6(@gC0u8NR`^ zyGGI5mHxA3#(XXD)Wd$~w$76Ka7M4!`aly}dy`1;u`QD*rW7(GebF^vvXWOF-CkT@ z6Hn3)XQBt&;LrG^#LrVKT%p#J)=4_N|gOnt)t#FE8DGif8f3GV7e z>Q6NSv)oZ*Li0g$ES|l2ZlXXh#cX;2R_=!WSW+iI#buv4e(f&-N~VAcJqAf9Cf_Yz ze#TwczB9%gTiJNN+aRrSuprRW#QmJz{!t4beG&`fuf=oqm z?JxNJ&>Gl66R^Flt*zJTiB5ev9N&>c-q{#KJIMaOnj5Z7NoD5 z5k-cdUWsgkz?n|GX2k8{1RXz21egDqq9&P@%vO~0Gk32>*S3{-G3xA^`-GNLvs>wR zNK8Xm8l~~@9T=9hV?QiZ*0pL0wpSt}9xeC)k15B@+aAE)IpTYPxg<%(`^m5V$vZ8+ zfW8OFJMRD!7fS;~ejw*8{e3=48yF5&k6S+}wf!`aQ6P|6%aXN+O0_t_zP!9loJ}O5 zP?yWxEu-TC=C$-uLH3$ntQUN~X-R}dt3xPV>@Z<$&k2%X>W0GXHA>gULr!3+IsQ^g zRd>lckGMn`+I>$V%VwJohbq-#iiFI+ne!?@{R%TWl;q@BA*`S>RVRtoh<5ejjWCk! zItUo!c}|{52X=?yMn2+aLk-3CaJnwuHn7WJ3Lq(_79)*7>bSUMS{4a(7A$rV-UG^g z4*ew2$J-~PQkTUJCkT+-+38G1Y&P$$Rx$C@A90KF0V}iJ@<0#wzjp-W+r5s_@;yup z#{uRC{e0`<*@d-dpxcXhPdVu=D`ew{3TZK&+|F zgZ5Hj&FD(}sRndVlE#czWr5VZ#&M7wys=>jcLid>4CSZa8+;8WV=Np=Gr7qEtf3bj z`P-0(dSykmEkEIW)X&Ll<^hrLUl2yu0$9Ycr*m1kpg%#>sX9>G(h-0K8fDgNS(ebw zzzEXfW;b#!;)R1GXs~T+c6NttRR+A@WdEltdH|b|C#_&=A(HD)^xpl*lF!c?eI3#$ zSofkCl55~?&$pY4ZPH`)knYPrg;wG=lw+yNc4B+1d&@lmz|GNTak!I)-+F?U{^~&0 zcTm0B4m-V@zURa)dmgfIhOZF5s4df$7T*IOPo9*Z9GbsSa^_gZO5@&~Rgyl}J!6)B z@wKJ`aDIR90!@t__7-()@rpVb#dyH`bwrwFmMOvlR<9Sk8Qw=I=E9M~QU$;;YklQ) zM(sF2*W3M~)Pk*8(8F#ahI)#2t?Y&SO!$>HJfV8CUJx`Kyr#w8IIBLP$`GKhDQK;^ z+9yf=($0`&M;cpz->A^T!h1aUf5o@_Ff(5gIz?^I;Sbi4PWTK20^V4a#uHl|$rl#X zFxA*77=SPLTMHREWts%DZo*taAoNaQO%b|hp}|AksHaCB1R~#}a_R*lPNDv0K$89< z4~*+v@gG#o7l%TjTi6R7>@;LH?)y-$D%0SqE)YfSc|2<>(#@=AaxFTO5m(Qo0d20V zXTq_|AJ*&sD&Ca6SbUt|yoyHXOv)HhB&TdMe`k<7MmSPDD<@Tq2;LEAOJ@L524b2Ehr1w@h-J97t1P7l9u_q6Ay zXE}e}V(AROTgaxqBY8T7&sX0$87kEkOKGK5XH$)bpPmwbo>0^E+a$+L>S62gexoee zKsQS{H5;Zq^h>$%8&7YqgK6glkjFu1h81y*;rijqi4#8uTv`GbPZJZc5^_*BVOX|7 z(-BgdNU9zL1eJgy4F)4os72i8%bKXYrdP{=5hS94IUw=ND-LBYvVe``{#QijM+cN? zxj%8`+apQ@Vvo`x$zLp@(WULsC8*QY3-`LjgKRM7B5!@0(m{4G)ie?WoCFBo6gJinYdhUJ!iBuDG<9fMSG&AbiU3-t?x> zX&VuK5hdtM&xjv7aBzc6*f{#c*;?~~Y#K3Lh!ribQ!uLhOt}knVpfiV>;q{I7l3pD z#Sei0UJjfs2E`c_T^1uDuZBRJhN{D_8IQPu^cLA^uS!ZC44-{hYq=6KE=}Zj6p&?p z``1&u^;Cymj!yU_rwU_>+8RD(Emes+Q}!YN5uv z{6W!aZ76Y(_uic{9>(Er9e-`s+4{%DvWn{JEE1)uN922qem$;^ZqiTu9#Ueh-bSH& zkh4;_KUK?o^lAZ%E(Z-3<^H5}1?|WfmyqHQV0`-F?G8O0a2B3l!RCeyKsC4uQRU^7 zDg;A+79EM$bQk6Du4>#zKXKc0^szF7S;lPKmnZP5hd-~hBKek%B18&$Y=dh>7mz3w zh0|-iT#i8KX=kQA+{YS_#GKMaS2{czs{Ty%#otCW_+l>K^U&OLuSz*p0P!%tH6{mGt}nlpK6|sIqn6i<3`#}@xR+fhUaG#lXNkuv}CX1 z1i51H=eP}1aPIkZ$H8&OZrRolk&(9pWhNt#!Qlb2c;3%cEBBC8-HDWU97PCPkJAi} z0xE@@K)vbdnW0m~O0WP$0KtZ}BVd0nk+?CBXP8m@e%A(<-!QBZB)-={wm-T5{-`2o z%ZvO1DK;bdJFL%zz4{yZnMEk>$k`y$-}=i6M0Plb81h3Hd|Q1?h|59}nPC zjcB7YZCA_K!Zb-xBwR@m^+ri;bVPOcCF4s_=b1CR#NjF#RhmZV*a{iL!-Bm zoRNCuVL|rxs1VfYevc_*%Nd~HW&%CCyMV=@%w9w;Tn+(bOrW7DdFloWFd(?}D*N^3 zo?i_wza+vLoI&OadrxZ|GWP)>*S^vchV+b#0c*c-iOowv%e%JdM%CvERhV{)CkVyW zvqhQ{=_>T^_kY2g_una$SMu-{DfTLFC1Z-6(m*X9BBFKZsW4~&;@1azr%TfHhC3*4 ze$Au_859NxttBI2wKw~eZkbYv8}|Lc7vM(V(jo!KvDM+pcsSyjP)G9i?yOh4 z2T)%ITrlIKmN?*4%ou@M%J>FD@4Fe%)=A~2;6uLd`QC{$73D0tdagX~PT-}H;#}{y zeOSjfJ0~iWyIx+-USUa1XRqb5N5*I@`KCZgO~k8l_I6=gkbHXW~;>>{kJ4 zs1*Zfe3!jHei!bvNce0S3}J$ZCe&ZgETDuh1MhMGoecS(QKW}J5oufsU@G$d+mq8u zf35iIK>`>3BO8#t-4>*G5&fLfP3+2O_B;?|fe|p=XucP_&jM%>o};++mzn3F+KCYr zjyPviuiUNnJ{gb(ZLi{omw^-7GsK1Tjn?KH2=y&yGl}_isP8gim-cikU$xUUG&VQ% zN()Nh#t-B9cJ!RZig6ZTl)llvXT~cPOl7j>Fa0IzQ!d+F`;-JceCi=aH||GJv%Z2-yZUG_&cHHuA9 znp8T42;){IFyDWXo$pNA;skQ{^VVh+udV0POf$Du^e*3mp|JoBoaZ{x+j!7?7BYfl z_d2i=@j=c5A};tSyt!|7>%tz5ZHuO)e&s@6y1WeJi<^m#Zg(uBYj!h`F=9N}-#@8+}tdWMDjuUIxVBj}|cr35p@)%1+A%nzIfNlV_)75CX6hv0UW z;V9Fn?VMZ5pA$ofujziP2^n1DhJXW5OeExZ)7W_>UFBJh-|;2I{bTE4tI}{<@JdLZ zAvuvVBMH(7&!J2CYQ-gF{n#!laSoUwf7vEKB+*0AN-I+KZ-)2tVW2BIW;wOj9>mZT z^WXMd(wj4#jZ@_G7fwLP<<;3$Wc`P2BNIbIPIW`rie0b1>=7FBZW!nZvAitnm$*+MD#VE#L!#leI0pvsu?_l!2 z%96qJhTHJHy}boSlAC)}KNyfIwQO4BZ%iH(96ekI-odF|53~7>LKZd*X+i zFpG40{H>`ojG+(gon0`8UOlj5_hTl;WcWJ=7=P2gPY}CLPl9Mp-#=bssC-#oUpl$- zQWwqh{d7s~a5G`FCS$hoE6f9^`lfdR8W%Lk!{?7;E{TBqo(wCHQkyV)*5t8$a(yBH zHaHB3XJVOzU|qQ-CS=Yx0Sb->!TYjr(>Y%7gbb8H%y$g086yzK*fI2f4cXNN8td-WYI=DBP z14l2J9htVg4LH4n20M=9^X@*LAD{gD{*SNh*={}5Yse?$>RYIRF;rA0%Mr{?gyP@l zDLj0gkOb&sJ$f?_-f-aYoTd!+F#zDO8KAd{{1nE!dkZrQ3qGyzpCF3&2jgw~J+Y|} zSR;1dmwmn=(P~;}Ftd8uYQH}mD5KS}GK!+qSkGvH~08ceH_6J}SoAWY(T6DQm zlk`*I@s)pw7BQl8Cglle9N&MoETRhB1+u#3rQ!|Nb^If?xpko*^ z%iUCP$(u>{fG)>&H-}TQAw@z2kW2<9=9>%_on+s<43#a8Kc3`sT-v^e{GG6=wmyJw zek&>|DY{aSW+(`Xm$CS> z`eA4A&UD5S0sBM<1e_ssTs|eQ2#8hAJ)9V3a>myf9VJd>EOKNi50`H(7F3*CPSd^LD(XpcnIS z?kMDe+LLUN^YPe37XZU#wr+HnU zJPPxior8Aw;SQynJ=WJ_+4>#}}@3QGAvj)v_0o z0lQb#V=9u4d28%%40~X5p>ft9@>k3V0lNNx)wSWC@U-9rKdfzQVY zNl3s0Xvjj-^+bu7;Tu#>4^-5fWae@Zj6pPpB#>|4`QU5sswJT1( zb!9MRVeH=W_UXtR$n?}Pbz4aF_4Ppq%c!<){znEu*3)q%K1sztwF`ZEMTf&hEszY( zyI+217i;QpzSw@V_HZ)R{iCK%Vxo^)`j@}3=1K53ovr>_hACrtQ%1joGyfm$OflZg4vfn(F!=Q>2XYoCT@1sZfoA( z*mxjui$FS}-Hot$ipnMO(XGHohC_uduL`!fi+#@u7eYu+r^*z~GJv~F)j3hWlm&ft z_lC^P#192NElx6Z8x2k!0;-iZ{ajHy`rjoP3r+9r)P7JN68k;nTTnSG2;PK}HSd(N zH)2Wj+Zc?EBO;H>Y7h&uTwk7Sy0PQzEYV=~@4d8o8fFurJmLJp-_`;a&o|b{&k<7+ zVI=phxu{2K-l%3iKO9p&%IyUkI<>St^Zi9xM35uo$D-&u^y5^F!0Fx%nY%gi`ty3X zN^eUQHvAe2K>4D#`Ht9!M70XEwnOFvfv@T{(>gxQ(D7B#O*GnUK=?#^@P0?c-QJ#f zMgk$rEhCylw1<{j*XNaMiep6B5O!w*MHp@%(5XY6s9 zn-l5d9$4lV7L73m1W13lwCx{|@b!O-&ljmGdmD;g&(eR|TWF*;ValgCKxjZy$0>zu zCc$8*-0yIudWKTH_6PwEh=gUGUi+190e8peSW^%%DVS;-*2n7y~|T_lg46f*-o%>LyFz9X>^(t zt|@D!?e-Iy#;yU=QPSd?mfzq>*PwNUnga9|Yhk~4$T2I0vL|kK4de@%E|GKg#9EIU z<>llMG7oN|KDM;9N0QXZ0$1bff|ZACl))GFMB_&uxDiqg7(T;ffh=$@uE~$>$w^aeJu88+@IO2{*-lufL6}O6{3Ym*P zQJj|H?NM67->1iH)PHYpOSwdTpbaS;dbpq7Mw2|sq!i)Re<3vRZoPVNFpGb&+;EPw z_gzWFQ~pJ28Q?p7Qzi%vGFo1BFOvC9%e2{c`P}Dzs;z2bBI2Ri@yYXY{jHpI2b+Wq zgsNli^Px>YKX9HcY0yKW4$5jukn7z|GmmQ35(axe-JjkSfaOW9`ys#Nn{-;3F!R&M z1+yo-zkgO7JyH9qywb{le|*B}P^jFTnBwr@kNLz6iHZoG_HqTa!|R=J^_x0oI<4f} zqMf~_<#EZ>W63i|OtL#-gD72i{|V)f_MRSg1m;h853}tNRST^P_rPE6FPGPZHY1S7 z!0dhM{!GPZ+mBA43B7V%RJ)vHE|-ij;?ixi>Y%)V(YZ#etc9>wI=BfYsB3%s#lg+Pe2u0# zT)Hr#oRU(%MkD&kZ;=s*k66oAvy2~fHd|29_$3L)jj3Wc*)=&FgShy3418foT4C_{ zShcT^{_P=&=x&8BXkS){ZTRPwp_(Re>s0y^-ZDqJ zS3i&1RSj1<9DBFLKm!Nz*L_lJ_v5I}mOiFRBCC*HYm(mvRbn5|ZA#8P%ffI)OK%K| z#%8!g->koNTa)7lk;rH3>Kx+)mpInGxElA`0_1ol4^h~vSt=;wi`=V_*2CfjwBMPK z#irF_Rbp%92QIzUCpJPQ>pjnT&Bwl-Ukp`hGYKEfp)w0wcXS2~3}uKfO20>G7|<2W0lSTdvy;!Guv(?|ok|n}KMulG#Rm8Ho zzAi5j2CYycc4hz#+m@Cj(tn&FVMo2+vUS34aPo@kd;i(Dp6!%dhz%oSV7)w>(Rg|( zznyfv@zsC)f3)X;nLHWCaB)_>hEnc``#m4;o_(Zu^d5RtSI#p)MsyxofthH{i08HN z%`KVU$*tsoS-6M6&X$K6T7K=EP+2LiF{>-Kg;JP{G6V7GTyNI)zdoKYP#& z=~$xf_hT+zr zWQaT$p1F82E~Uz2F%ws5U3J{4p23Q|wVr`w_;?iA?}U8BRnF3`s;b35)cE$ZY(EQ+ z;UtZ^`WA(2x3@}oiN!r#V};c>ue2T$b0>tUhY3dtoc_H1_P+J|+jezxSwzt*raoL2fgmErKVSxCW3nT`N|8E zr5_gFM-<~1IP3XIIy_9}722}y zvnU4sAkF9VRu`aE9rMd@zPTdq_o*llHbLx*{Lb_Cug)J308mZ_3><((1iI7xJC#R| zQVC_DkHoiOUgHxD?+Ccf9uhO5Jj@gSI`P%a+E&xI!>CyM3OHajj#5eQe(v3yePqZ; zMoPdPE%gFC7vR%2B00+XhLGBJPA9r{F5{wTm5+O~GW9GE5tW0`{QZT?{GM(!hxL=k z*_~$>a5Qq(X4KA+19?Ly(VJ`P5E<4tpu9QaiL-nR3UU$=J%eKN2rEl{j_?bM-wT!@F?%8U3XF>%|45Z*n-Jf3Qep#YootlTrr16 zY8%)0xTnmKa<6vB4}_jJ6PSU*gN`u@-UG_&?Boq zB?WHFKK!&xdPEKU+dWTUekRz(OT_xT#Z)y(zobi8&U$^|j~Bj%_RhFlQn-1D&L8nB zTx}sFq`pL@D}AV@I=I$rAAcr9^8RA~<8`$t z)ba3yp1!t!2=lt}jb=;J$CnEQ|8KKnS;9r7ouNP01$l~QFY;3LA?t3$pf4&5-8-@q zd5^*=Yvl93hwq$7_`h!22-yBH+q~A6$Y*lbK(~Ws;>(*x%wgbtH%d#~cxLynyWETA zSHVl`VPU^@3f%_W_F(ZH|8IiGgbDpTH4+}tDY&VXmaPXRv`>FBlk{);Dl z&kNRb;PttGT7Z?Kq95JO-`d$D!Q{`Z*NJ`eQJ)Deac5KhA7WyAK7c0nbyM@&D$DaI z>6AEmZb5FXfL?DuT$623bgz;hpWv7MNmv3GJL2zslsj87$n4y_2nZsw-4jl_X5Z9O z8*_#snEdEn7$iYfpX+wiR_D z>h&cy#klNyARcXG_^wtba~^x&fxQ;A_A5bBwjHLg%MM-taB2gmbxqmixPRHhA9`{)Y6FK%67g#sRcZpFA( z!{)u-bJFXhq0bv0u!g-lSy$fU>{B8w+j*E!1kO0R`6`#{w<8~hkEjtcZ*on#m|!^c zXYXP0Tjh+2T_%`{+mt%Pg&u`C-g0Q*;*pA58R=vDer6tiP?oiScP!280qn6BjC*531Df*7`l_Oa)U^TL_G2im#k;}T5f&NxM$RDh>O zBPqO^B(g+vZZ1xL@0)$`uK(S>+TMmM@9=eQ?^aFgsZ&y5F@(>t|cuAEh;EPH2T>E5cI zPlM(DkscDWCn79ZpH#_eg6fixaAN6l0Od<$O&ylC4!^q7@-og*4=l9K+&|wba4Kd_ z7+&y4f1gxXKUq?8fu+}AC84umZOotdprhqPrsD|kQ*PHgo_!+Mbh~dlMNGgX;D7u; z@CujE|1PC(s**8h1i?aKvsX>4)+d2JA4u4K2MOYg^v;YOZlC}YK!gOd7OShpex{#^n%@tXK3Vv< z>Xd)tr4P|yoo9U*&hPfE`N>!K(4e6}M-e5J<@l*TYCdO;rRAOC*bNn39g$GlE3>x# zXV%RM`75_`fG3nIn;!Zol;1t>*f9Kz+wB$O5j7HIb-d%6Jf)`bW#@!ET?~WAu4QY< zMIXOdOiX(3KCW^W1(QDzsPEQQV#AeqFLlF*DvgrEvjOT5jS6H@@yh}6={SiAh%&$W z@pbAIg0nwScjsvz6Y02@TLEzo`@MY^{`4PP%1g5XBDRl~3pbtyvRy4r@qbH`l{c<0 zwVkbD5o@MaIuuZa9Q9FDTx5)sQl&l69o9A0NtoW9OO^ELxudR5f15XV;YN1KZ|Tjo zQI+!OS(^-kNE+Gx!#||*o>*w$UG*olT!~`l={FMo1m1w0jdNfv##nnrKMNbB#y>i? z$sSlg@XEO`WM^X|dTE>?YwOJ{6MacmjvjE7Uy=BqntH%HlTNRvggM`)x@Fo-t9+MY zIi;9kG*45W(^ zQtn>32yw7`ml)j9&Rl%4kyF3!83Y?nb;&!%DY(qgE;`cW$3awi9vb=7VEF)4@V8F59fD}I(Q2Jcz&kOa{%Z#vJeA=>SV$TU0(m?pEb zFcCJ(ck*GlT_SdiT&L_eZwPhKIr4qgFk@}A344*n{DC{Qzt*&pv246wN+JZja-{TS znD6TUuvu`AY(A~1Xo+&2k?@vzz#3p7civlk!S~%W*GbimRv#%drjJrP!g|JaPUOF@ zyxgLafBv}A+I@UtEjB$YoIy=+WaPrMqjOppzG1KG^JvH8d{)ckWfBLpHNCu9Wns^+imSC`osb#=pm0MpG6p&m_w8`e zf+IKb3C2Tr%%fFRBUG&jm(O63=e--dy1eWxp)z_FZL`3(eJ^SEFb)Q;wmRHEm zZiJNG139GdqcWPmW}jjuhJ$|0i+;}TyJT5H4~wtZmQd_ae^0IBTRp3>Yaptg5x<1_ ztu%G36T4~F)W@9I>I^Mw==R~5R}1E-^A4~4JjvKwU9*sU#e{A|rr!&$a)p#N#zP*v zi|AcEgOua@H(dZu{nSYne>^hm_dW_dIGaO!+n;Sf#4obUu1)WweevK#c43sunKUuHhA&oz3Y{9&DkdQGhoMqlGgj1(5 zH%gL9yKODnwjBSi+xs?X!R82Ycy??BdQ~WSy$Hbe$Sw*f<|FM>JN}P4z=_>6`5N`@ z%eUg^oXz5lrv#+j$5S`;SJUHIrVOX|YLA>u(TcN9@897wOeSUHI%O8!M4tTYzS<(f ze?BtWU8&T49wnhZo%N1g$u42FC+KM5Pm_2hf0f3s(2dA#aS-7h>m<-&zB34g2=8jw z7E)vhzs*CN9+%Cdbn>?6tI(!LFj$y-Bd(AA6nm5VOZNTiV{_ZVA0L;yswGJs4=ium z$_uG~B_lxXsc7C`on%HM-Uf&ZI)5HpV1p_+OGRnGPt8F;E+|zm!CaH+9}K+P_yvfE z&}XyyzpDlgpB*7de>=-f68H6KYmHtAF6J*rz1f|Ok!)H1JoaQJuhx4orzD>ADU<0; zo_A=I4|5!qgjGQ`me6elX&o*g2<+6iz_00v(ZPkOVAKrpvYi%t@65HZF41r;QH5i| z^Z1_^YJ+n>>1yZ5t5o^oo{=nO`$k;>c)@iZQhDG`HK^p4Qp$du4>fnl<+6xkRm3Ei zO3;eJ2k1>J!p)D7zd+ySGxPOo)>6qKN8_qBdW+Xs4Zg)EJgn^OwPB{{yKlFb46@AP z=dkH)7dV~yw62pF{y4I*3nRN_s`l2bDTl3lBxZ&dAA;B znRsWYx@%fIR!Vq(rMM{7$}L41Yw5(6@qYI$T|;%jFbiMA7u)l{f#T<3C_Akuw*eYP zyxN(6*-e!@jPi=5H4rZ{W`FUTZ|i_P;1cLdOIOq-5hS z(c7I6&|)qFog4MHUK%&AOCke_O1Gns8vf84uV9m2adS%BM=SoxX8wc0JFf&D zMwV~MavhdOOeZ*<@@9~Pd2I5datgSfPrS7q{xy9_Y`-?g#iE=<)I=GXfHbJPD-v^u zypkvA&J91m!!$VCC53gpXLc3MRLY340*=NfGvCe-Zhs?_RdFc=n4qD@)W6d)_cXX+ z3mxF|ZGNag(rYV*9r8dr@m;@-^hDF*9Ut$xq28lWwSof0G{@^cDQkOa`+r;DOCb;B ze^~1sET<0nMG8*ZLKb26o-~IZD&?@V0~L<;7Ba)(ijnWS+OE zbJD-nzjA;M^#3BEu95mCKYs;PC{a%DR^XAb zO=t1s^bf|M-K@W@cI{rQDk3IH@BG6p z2JNle)W)UT21%;EL0O9$>A{KzG|DGqDT3L)Zzfo*UsBHjDyF&@PbMzSSz6#>QEC%m zu6)y?vX6VGVqd}J)B2wShHuB&=&jqR6~#P`Z8>Q)_Si1(8@Gara=53##uiqFn`#T`R=2& z!J7R3DZy&nU%wd9dM64UrksY*z!x<&F6#yBY>Z;71%^rak{LDXhCUUPPQ#8`3#p|l zsP~VD|8`ctz^$V;zo=PVO2Y|udopUj-o(W=dl}WwPVe+xyRM)Uk+^oD*)X3BAJdh7 zKfO?%Xrc2q72N_^H#p`@z2pfi9-C-=03`0_=!#us%8nKIu5Cj^224*5!=5b1{8jV_ z(`S2FY;Y{wrkK?9B;BjUt8;|TD)K+K9LoFgk<5cs)&rR);R?0Id_z%mTCV;7R~u?Q zyvQSy&g8U6Rq6%MJ^q<^)Z_Mpp~Hq52@;ZZ3O+rIcy0sn3mXrWZ8c{96c)<-g8 zq2*%fbWyqvc>rQ{(GGH8c##5b&$SwY%kXL~R3-`$g>?f&PtFsoCi5jfJUm#z{|`TL zWT5rgS-#vR&GW%#!!N>KGVR~r)Qj@rjj~!4E1qiUyw(VHmyn5Lk@>ThPBDI^t@-?; zRp~PMEB((%-s(x#r3O-wIZT12nNQCyPE%g+&Axe&D^pCuX~vs;hM%oS4miQlbRU=S zfUhSCL5O;GqST-#N|}$HtqX2(d#AB3Loph(PYdE)^!0;vm>wBusxd3MHnzcSRYk_W zuPw+f@@Br^RG^2LRQ|#lmCl>ZlBIium1ma+KEHVx_H1bhTxt0q{xRYK_wmH#I+Tnr z`CGm6a#6q^}DJ{#nyP98`S%G&h7tK3wU2F44aEKg0D+ok9v0Kmokx_zwz*P+Rp<{`A>?*$GF4Sgam}e4+ z<D z;vD5mM)-o7>D!o##W-*EJtXEZk4OkOE1{UVuFIFnazTfhNRQW5APATUJQ|y;lN0p% zkjka_x}>6#=hz?eTXs!;%Kg!C z$!)%WOZc|ZZ(nVn387_Gm;xO3qQ5$o^gd=ONpuymF(48G-1}@)L$7qUe3!R$ul~P9&I1WkrAiut06i<|l-W6#FNNfuFI5ufOLvNabKHxLn@@36FqH24P9yufDrfqG1K$6v`sY2WOKRh)5iCm_9 z5@VnF`m5L{FR+?oI;Zk^%}-2{KDaDP{bc4rbP01r)}!}&{|F$-q2g zOO8H-x`bCGP#9~H`X*JyhzbbsgK4q|xNTVMRg{o1JBM(7fO2svMxM zeDU0N$?=B4z9|Q_w64Iy(H_PhQLGVO~*=}J4-84rU3#%EvDY# zAMQ^)2<GreMrZu6^37PMvv~8DZps9PFd8lW;*Yx#=Ney z;r{=HkmI*$lSW%iC;Yo_>>Ee_`#kZ4kBED0Zk2&Abr2TMI(n%v+*8W^z@j~Y37S%S zpq?ga+&SIzOm5WZRC@=B8EH-GQ-}T#$8v)8)R-)};>-^n z%FpO76Hw>Jrg(g?dFumvXmVbjC!k-C9#Rqxy9v<*^Re|a@%enVu+5YFYrbpyIIGXD zh3}m!(q~%3m*Y6^mur2Tz!+=0uL4{Dn@Vec#JKgsFBfiNiqCr+=%%{aHON^(&8p|64Uo`rB|8CJ^#FkWwYG&hY<_=8!0)St&9jKWN`_ zSK0IfEG;21e*#20^tIHwCh(vv#Rp6F)X+P`1k- zH|l@AT({Ykyz`Kr%Y$_wLhq@AE?(Exn_@?;(W&E^@B8f*4)3^}{AEnV+xi!-YLwK^ zGt6()K9`v0>l|F?WkzV4wci;H%doD0 zNw;+qn@(VW5=( zL8T4yr0+?-jk~chrO&LsTh_EYz2VqRzh~Q`ier>dKSVwECIP)jvWA>Dh9lAGk#N~d z`ojP0>U%ap0RMbcVum*m1txk8yVWg+nIbAKy-qFVALeq5e$>+D_kfDsmNzAT9Rfeo z)wXkMT0`AF3}&<}hvc)9g(Qs{mKazQ>bkqLu*=hUaYvg_=r?g_W)j&5G$5gO^al&y zyw25RFIH-2&cW^vM~z9pciIonF}-)JE82J7Yb4=OU9;}(eW=-@{Oecc*6pT5X4+q} zl0QXsMkNL`ReOtHO}fh!@K8M|l@uIxXdv52LvVK8?)Xn?@pGPCIn#AmCQ`lqnDrK> zP%84rC0X1AUwO}?=Rop!p}%(O-q|jC_k%Zu!wQH|q>NYpyfuzTF|J;ux!D>>&E!*s zeYp88jAQ?UAb@zpxWWBoDIa*Gr7z#br_Cn+8FRnKe^XLI=P0V~>2=&*u<8422>Z&b z%6;GNG6(+2X|O*$xPIPDTIjhQEbDac-bka%)275<4b0-P(7c%1G6~9-=wkOf{pEx| zf0aqH)1(KrT12S{(7Ex#@3Ui=RV`lYc*VrV2&9K@e@I=ZQeC1U$LzEqJK|p^^=t}F z;=HmsZp7HeeGnrZGKlk@p}I+)(W;7}!fECKd+beNRd&5^iB2-P z+Ik1)Tj~S869UnQSh1wUR=p7qksZ!KnQ+n22GEBlgh;I;_jTbjf~Pt^AR_BDqH4nb zpSJZCcjM9nC`gZR)(47v*To|Hxd`{x5Sfg7JK??l(6%q%#6An^C*&M{JQ)0g3v2y7 zcvH`v`SdNv*_xJ1UMCt@d5$vd87E3-q)nSP{1RB>T5HzNZkye=T$0g~oo0AGRA_kjguF1W zXiAcZIz8xu$dWMYLm&BE;(Zp8f>DVY_nOVWvpw=M&Ok=?fvt9(kgSFdPjN?00k>nE z8P;q)FV*qYA1gp`IikAqw--S%4Ze==9HF0h_da-!ZQqP^_%1Qr!QuDpEt0IHqcdD5 z@>jY8$k4>bdH)X&Dwbj0nUK2?Y}SEMYhY6}9& z-%TnzZVn_0{Q>#480ar4IO%>(!3>LeRAla8UHhnh#k&yluj|ElR6sB0o%`mCh)uSN`|XL(7Tf zC(3t#SDR~^#-G7hBz)4?f(IhT$8Fg%St=3A$cvjYJ zsL(nxmZUJhawc7U5&MIIe_v@Zx2{Qxk&=V-yrr-gd7N;^CUXhetGlMvRG?mo4`@@3&hukZu43E`&R+o}vN+o4-XzIeG znQL-{$dMs+IE=5+g8nkto|~F6mD=U*#Vs<^=pI%5jRdykk)+T2H=2=~C;RA+Uw$0M)r=$`w!xa3 ze&DBO@)~q`?_2A0ShEwgIc?WvRd0YBEjIpcnC^g?F5HID?Bgvcsp$lEEi#%FVsO4a?sqVfQ-sKFD}-~>QQAjdS5~N6PIEwlEv8EfVY6_#ELIRtEEue z#X9gVKG2T%{B*|EYX4mM_RoZKO#mcZ)J-ljhD&GG(Et(Fga zf(~cy`SXv|No|<$*>4ik%5x{S0x#<`pE{*N2THS;5<{uIlGga z2yuaBJr=b)!d+mE&&Sj6c?l82-uRM8|Lf(=kf?gch2nGz@K~DLur8(ZYmIUHt6Lsf z8V@d>B0igWdy{-66HD~ZUUi1|$e;BYh*z$xM_Gntg*T*D*zx;ApUPm}$x7FF%0s|M zooV7V)uJYI&#b2U=`*-~+l;D#GP=QupQ=eE<9#=hlg>mva{@!=h$tHQ-f{gkvK0Q0S_6v zAB*|fS%QEv6XLSGkg)h|vmmr=G7;8Gto$kEjx5lRcjTdHMJ7QqMGOx|dg^W!UJ5^~ z>p=D>eq}k$=H(EXcubfVgS3+n8?GF&^|27ipVrjFO4x-iME?fZ;torrP(X9NhIl`X z5{Jl->C7QI!ocmCTI?0_7wPbr^4Ehw+Q_RFd303iJn{`_tN33t(@WhVpC!yZ((^ZA z^N!ec8rib$4=hJ=DDvPrJdL1K-xJqK;}%Yu+U3NXO<7Sl{^Fy*C5zb}Kvgs|3;=Me zpR?l#tM57#P}93tH{^ zDU2hxzm*K-qna7mI=Fs`LZ#s1wE^-DvYi5VjORC#{_Nnz%H=6*2&Srk zjtdpT$``>CsvfY5Ily#cA1BVUYQw}{vc}4Yo9nRN?G5ACJ32kMLW8H@W_uD1#G%z{ zDGYu$=#xtW`nE0fe{4lEGr(5F$9V`N-k4!9d&-8KO=$n4Q?8&oB}2Xvg#EiR5#~_a zOdNUi2V)TX$#)2IEnL43u{GU+=QqXCGnB`nRC9$$F1enm7TtI0R5iy9YuWp8(4)hv z{+~*91m^klYXgD`#$i}jP6KSI>TFWlQ~y}ux4SE{OCPv;zl)$)3kw^;d@H`!D(<+5 zI*xY!aotn!E}6Z$wKWyp-RwchU#v7ECx9i^WN&XwnK^Gs4hZrw)r*<}`@-!TyP6Mn zPib~lzD6>bBNur)ais|?FL-izC1l%|HN%SxsZwjSIS8327FnXKyna>Umbhm;EUhsI5gZqiPBT-xyQL#3nLp)3rcE97lj)d0p_pXea1W*(32g27% zT89MmNLi3uo5VIeY`;mj7_54e&tmfmpJLA`!He4G?j*iue+Tgu4%16t>Ydy&^04(K z=0~&?nss<^EBit)h)Rvwk2bTriz)fy05Dx#iREoK2>J&%_X zJ=&7fOmxGVM9IxrlD+=hQ>j1z{f~pmtRKUxQ;s~tZw8salQ^zd zF|!E@^=+=)n9v}@)Ech|`H7DNc8@>jKIkdmW$NwY2#}gGsb!g#lc z!A5SEieA~JC=CUt*6UbS@Xw11nTpy(J78i zA(iCvRompqj@_6EgV*Z2Bv6O``$A$uX#A4h$u$Ubw01e6ANg5~RCuqrymy@HxYE23 zKeK8u;MA@b)?wNy5RmEAi{d6cXIjzoLnQTvkJT*InG3G}?6lQ zonc2zCH3~Dt%uz|-?`lv=u0r!0KW5~+6D0rMpv#6Y97|AWP3YixTq^NeFfvd_irth#mI1pL@zT${7 zm2zTa82nbB%fV2s2}+x5=9<5t5l=U5ye})%d?%5CHtucT`28Ev zbD=!HkSM@i>xorQhj{W!XCW0H_w^Z1ji|{?Al46+(;$sv`R!I>jztQ3fJ-j*^MqgJ zd@J{{s*ROzXn-V-hsfo(u9A=4{AB(7S4R2UcJx)9P&hbFv7m`T`EIR2-U3}yFg8-v z6}bq1a9~vBajxbH8}YCE8?aHO33=t6yOrLz?JT7RQIHnu;%<`E@2ePf_#iMYl)D+#YTtL z{+QpnYBJbL@Ii<`#jdcjlvUT^{&VtkIgaT_=aAk|LgI-Kw(#3H8<2j+e6B)#WI`&2 zj7W$Y?n&!A)3nb+NVg?oPg>wQ*a)_@3a6sFvrO8>#c251?8uc5LoYADo6_`f3->MW zssQ8)FTa9puFclStHu_OaNhDuRsH~5T;Y3TM1|4Ry@T(z0S#%XsYI=g&FQjr8){bu z^<|%5KOcAdunbU)WJVN77C7YQ{77zUwZ@&2AWB$5IP?whS8?4^E-i|+Le^!wFr^1> zK8D+)a0+tJTB$uTOAUTzFz%z!{DR(`PYO_AL{MylCTuwCpM<;@eusvKIAAS}%kk0eyieQ)Wtk^iQb}29j`r%5igL9MxGAL9 z^YTt);Mt}H7w*q-%@Ge_{dJ3{`ghIDz&@{;+1&ifVA={CyK)iTwzmoP@C%gRe$Xw7 zHc(}}uOkLk8bJ7&L_iyOs*sAXe8xQ}0S8*CVRX_jg*W0yjCBr^@4Ewa~m{t&V?`6^OgY$;!sdw%!zNmKV;is(kbqDm$n*~%2w3%yxxrBzZq>}r;f z%^QnPk#wrlLPF`6WY4jyrTlYzkwN-wH=9pFZSa;#Zh@mvJpJ_W4O;`nji{{gK6kw`z4j2qP1XKDfVwS*D-_ zN)cmKvzePx=9X@gwz_qDER>tGywIun-T>>W2CYW>7w@AbxNFae0~eS)V2}WLWEvUG z*?OCN-1w&^n1U*68;PDK?MSnS_*9_hy&Cq0OUvuVZnBdNp3;9ZcPE@@i$kwaS);le z;1WzhCKg}Px2eL+jqix{eJOtE(+T9^$A^PxamBv<$&h8HxDLORH zm*s1(P4H+gxTt#dhF1h$e{+1QE_=8s)QKA^R)GRYT3LRbTgO`G@nH~SLKhEn`CakP zG51WQ2GZ*)zPFDrN}A!fCUlZe15Hv_BfIE<@yR9Q+X1Be;_i9Zt8FSHe2K_zyQ3vV z`%Tdm`+R1UZkasMfjLZ89?wur`E#B%P)4ocK#8_zHCLZ_E`T0?5frDh7~DOry(=oAZOk+d8h48=YQ=ni3;sz^S!?+*LWnw)yBAbCMV8ksLG9l;DT* z4DP|U%F`jXh^C6J13|8A`{Lom(H9Hsf@d>*KT+6~vDJ~Q&D*0HFaj)Sq}Qghnv$oH zU}vq}PQCRCjp*Mx%KFUywCKT3xIFPvLQGWttqDq2`zI6cYSw=#5}X#@47Tlez2`Le z+|$WNH8Rok{#M;!3A@R5daGV)Q;4~8mss>xjlhpC!<|%kmjHDFqAH%FNX=`^7T0Nh zc_~666HjfuyA;|k15Bwl`x#gLs!ZHODzT~SznwjsMjtzQ^SU@M6O13aRfJEh*3YB72L%W#f|o zmd&m>B^qMTQW=Mu<(p-m@>LiaiRHUnfEA3Bgu+XrU&~X_A_$9>et`CAo<0B$Ce`_eC0rP|dOG@6gj2$>x2V{CtUeqRwvZpwKcyWD9*nF&HqVoG~a~TmY9S zxoB1y^&JAGqE_|NkCDM5ZlQ^4CW*-zW4gFY%wQOA)#o6Sjq3TwpOj2tGdc;3OC!1ogWG<#u47!JNQ0qxxj87D$b zFOyfD5lBFvn+#MPFwI^DJL9Oq+r$)G@)1H}LN?pye6qr_M5L6;F#bs5V$2_c&Y~Tb zZNKVSiK6_>;sq7JKYQD8|)t z5TUw!Q59W8c=A?`<(5f!iT7JHXL|I;>b@F7H50dx-4{U=*be8#K)?%&6@iC}9{dk{ z*A76ex4u&-1fR_8iP z*I0MHd|#3FePc1YD6%0uV%zZf?^6l4C+1hW0L-gSJ@dLEzWUW`p~n)(E3q-q<8frg zFC3~AB7LhY6lOo(8*=aj6CH!v2mYhhO2YA#6gZ%Wu}t1A(sjSdr@PPl_Aai-*q+p$ ziwL(IKX~6B&5`KVx8<3xi|u+&p#wkl0~aN1H>0Age&=bLyHnTx{Ju_)oOv8Y>hM0y zLr{hs_H*~s;SRF!=O^TiW(kB>tU>RuV`6ak2CuG`2&jN{#BmMazV^5I96HLT>%ifg z`DI&zNllH-wndt>tbN|-%Kg-aM&3~R9uY#{is#dJpc7iH#ERtP1E$1P zYuD~#vj>S@!3xOax?jB##A8*hE_#bS+>ayw=dP1dMaHBPE9Q+Rqkv`KypfC4kwrY4 zB;wn@F8_r@0(eR#oh9}t4Jk(2$f$hx38QRdWEM7Mc^q5Yjy+sH@vkV2;QX}fvIAOX zEYoPhDHemjDYo+x3XqNB6}~mhGClf6a885^Ts%R+jk#jq@2L8mTn)T<{+W+OP>ld% zuclusk`2wogqFfLQKt zz7~+^0(v-*HOPUp71$^T>+@%JQvQJ|_k`3A)rNlZbR!jcjkP@xS4{B}wFd#+icXf5 zUz@upRk?Qynqs_;yocSn9g7i(aEzNu=<_Ro_;^p%+e~td%h=IKy$u{JRc3(-f{9t^ z-crD_n+SBjA2p+9IJ@91+WQQ8dd8KT)Joh-JQk-{`o4pWDoiHrHjzrDnG`HNxPtn+ zk1KxeAFmVrtrOP7Y6kL+#p4^gWt1fdc@W#UuF^y?GlhNt)%$DQe4QVk47AF>ovhGKDF~U z9yvADBYp|Fi3lS6!{C3Qp$kgrQMAkMyQT3+4I$#Pr=@L(icxsGDSP#IQ02}WW&e}P z%;f~zCVT`)FP4I^!S~XK+t)elXS`eRV7KX=7+eIqt>JbhoI+Gb^=4^$1LKmc*^jFl zM+#EVoDxqk+ZP6fSQpl@iN5%T4fNH?U~yF}dR)0a$?l-e(l^UUkEGm-ERQm}${#N8 z7~39HQcOJ&_N2M1-xrGmUj^eB`!Gkqd#9y7v*vC#tO(ploD4fsLVxr{H`&Qf)$$Q@ zUti$56aqaAo)?ejevkH0hRqM^tV^yas_Ax-w3*I3AZHcP(|@YcneE{m`^k;|d2D0| zUxj);kyB26Q>_dZjR9fQ&=E}rv6A7@HG%Yx^Wdp0xz$$n&#F0%Mnm8g`8l1fviR)p zN*TYWocnO}YjRaIp~llsLBIm2)HS=b8M7L9uP3M`OxBIk?#0)FzBfoWc_=C+>71Id z@m(P2J*s3ln((-qXN)fV9VK0X8Mf@H#$qh4z#P>~rY4KOI$mLg@J7D%g!546`sFDrw(u#VF`$P??)S-)w<$1Bq?z$io00?bX>(Gy{ zhA&7@KB#ftFf|fV_Vkak2-_3ecENp87t!@^h#&j0jerP4!?5N#MDI;-Z2NfLpmVO6 zelmTk8K?Fcw>GU*6u-=wn<@Q{ouIEFtX-}==r#zw6 zSLTU8CZ(#DWQsuJI2mP_^z&u9;z`9p1|GgOM_Ly0th=mW8T#D$xlgt-YvON5otvG% zI1m(H2DyZPmnqNlGSvm}L|4K4zs4Ou7%v~(>LNVWgn11PfN4*4g`zZXB{6lCWgIW> zkUIjt^u1$~$3$Ql3E+ynxt9PXxz&V4(F;Zy{#m5R$Tl$?5B3I5nD&4~u)j|RetlN$({ z-s;fec#J|4FRsuJ*D|b|VF=&uRPW`l5;3Pmjw_AnS|CR6$5rIeAyU2_<@k2+hil%! zcI>j#aILVoMN|pzr4T)k6j$Z$_w7GQ1pv5qEC~YCEN+to=ppktS3+wW<}XPKZ)y`n z4=B=#cJ1t%`_0%qAq>ms&dQ^lQZTNCS>|##&7tkx0fBu($m$kgzmcE)EPwpy)YR8& zKA7#!X?Pflp15ZnT4z2^aJTc1j&wzE>7%|X@4=K6YHcijLw^}qiYFSO(uM0$d$=&> zzg~xhu@lX=1zT+d(pB3o#r!RwuR9o*Jw4=AR;qmht50RJJ1%p8XlMC$9PdanbwiIf zW1K2%4I3t8I$HV9`vDvnT)Q+#sjN7WHPqT?oUE>OZY$GeD3VI@7^K-Iz4p<(xZ;tT2P6K2i-L;h$e z0i+1{M>D)L3(tv6b2*);`Y36iyO46$7)N2tGCO<>s#0*El{Xmeyu01yr{Dc?XyAcP=79kO^~6n#=s2Va@M0>YojW&OTFzOCRqXx zgHfJ{4vylN&93zYtTWzfm#EJ((b?N=Dk_aK$nr)Fw|BdQ+}p3*27gYGd}nQ*T28K~ z0W`l+-ONg~Kb0I46HVT?wA(XvlGk*0YxI?PTNtufqPZ<-z^5(&I?wL5hta0w?P)Oo zh#*4?{5b%_G!fyEV|%r`5U2ZQgGcSU&f{=xx^fyi{BSe;T>hwX5`}9~0uKs%XIwt6 zX>sp1HboEdM6k03uSIcY)RgT%W zN6C~LeSE8&opw%Q_aR^ziH5TG|H<2x!8JH&H;ytlOQZ`aB>T>wrk=<&0^`pqz;us9 z|B6uCT3`5btwgZ61e{kBTwA4>s6TFVzrI_wbiXQEz7xnFUj_GffW67jC@;3Shqy+l zQ{@$ZXAFvvXRv+yCD-vdEhAKu(3 zVZBw-s_e0&NU&}PKmB>=N2cf4c zZG9&_y|4W4)#N#qo0qf$Q;00c;19-y8Qn-it=<>eI>02uT+u0ckr{_e?0^xK1!VV3 zZ@Yu81i1?2INLUKA~<9Lz5~-u7i$*RkgoYSwh_Ti{A?bx7{AxcQF56BpYm1c^wl-6 z8U6$)AL(ZiKfRY^P%D+G8dH3_iaADX$g|?h$Qw7DMhvA;IudnO6a|{HcJY4-D5hbK z5`X%`#l<#bF;`!K>aIj0Kn_4%GU8qan9;0AE3(tgl2AYh40=BP-T3xOXS=5+Is6w{ z<{&}vZF?Flsg2hFu#0+`JRXw;*Ae%(v$P@As1JlwLKcRL1Yl@hr z!}sG1fMB;F6i2_DjnLBg2)|~5pNlFPO zi2~H-R2GKrsU>mJx?OEW`&=kJWP+~`&JjiU<;q^DBDYVED81jfCb1OYXP{NZ{u96# z%Fv`x6zn{%F<=b*HCEot0+-s1QTP7{l?; z0A4wCLrS=x6i~rZfFfE>G6o?9e|Us3Y=>{*Qz$4OIV{lu{8eFFlx4 zn=xLwd04hHn627^^}C&!O&(X!X%V<=!5UtsB!H1J(b9i0l z-A&VuL8SHXKk*W?^%8YhKaMdry>og#Nq3eKu%zbSwJjH&W(xbc@?UhFq!&erXoPV}&)IGgb4Dh{K9<>8?BeP(U6 zZNdem%wm%u6*NnnLj%3DXn|GD#6FSFQ&HU$iSvYY3+_I`|TJ86$>Qj&3``GirX1k~~ zvT-yzjJ3aB?nj8z$ZYT41>GFeVyP$FWewlpGby~e{`_bNB~#NgqsNOJX96LMEH_Ez zOi@Ogxd|i1MbmCbG9ro^Y2_&C#|C*-vHpDlh_!~^S8^#&6;uD+7msy;5Ff~y7(jDQE%!$)R`~)Xt+fBYCe7tK^s2!U* z&onSubF#{k{3l@ArYp;2N<6t_T_Ec`b|&D=REGVsa`xcCoPUfh zt$0Juy>&bl&oZ@W`o~BNq#&X7^$OQP?By(X2Mpg2tmhb;3kjOy%}5k)N>93sod+{% z&t4nivIZbF+jYiN(V4Ss6x#{!w7+LzMYH~>GV|G4uF7#q%=2nyXoirXjn}cMwL=9-i1dE2{6^*fw9btbTNxtXBJD^`Q}t4 z>0j-q`z(kmfZfKH->wS5#0Cuze}}kB+&#K%0@&62$WM;{EB?^E8deuD7$Wn&1X*-D zE4>EyJTx48_cSC+(m?f}o=@{nH`f0kRnIvC>1jQyne)>QRa^|?f#FdncTX0kUQyG% z6^Glr-xjpn^4bBzP<<5V0HC~08_3qo?tA@8WWpzmf#-cuutuESP7z57QU|I<&pIJd zYm&m%Y7Vs^1u8X-Y2P^mI;1%8&!&np09utGtlZ#fYidE@Mi*HPq)4Q~zv?Va56Okk z3a3LVdv0_GJ-a-fKJQ%BhSJ2J`ea>*rRkS^ZT)sGru;w1q-wb#m-fsuHM-qM>yrl| z7O}ajohN**6d2+230j{eIbeV`Z9>O(7_R_1#C1R8c_p08PY@2#HJ*LYXV^cnA!Qd} z=;)4#xCUP#?KE|&iJdkM_xIM8C6zazKLLX#E<3m!QZC4)K3k+~yxA%fi^8>t$q#9E zD4nsfdPS=MvfsC(*`sZNSU<^aF2Ksf5w@mFkATJ1IxgPx{?{^nua<5*>b;(iAp&>E z^XR2xyXT6T^VuOhpMZ{tYFx2G=}gQ%0zb^8WTfD(RnEt%DQcWKxFc6)mR7ytP#>Z% zf9x5Sc-oE95Zc3BtDZ{oU@X-58%ozPH0g6Vsox689n_q@^Nw8)_J>=< z{g>_EuoA=qN|tH=EYnSI=rY|Ta+$(jw$Dru^t-ARB9syZW67mVDeJ5SSovTX73*`D zP%}ab251iTeU4FAbSPNq(~t4Wl#k=?Vr?ACKtviPPBNq$TT&zSUntmU5y zVFx0Z3+n-PKE4h42Yhe7JY_uI`5TPqfQ*K>2%!sfz^{xyo^nB*9W-a2|1A3G`mb}BwGCj!3c!M)s&;6wAwEfC>?0vPD zkDN=u;q&$~3J=|0Msxld9#y>G#xM3+2Cw5A1#SWJ#%{SaempK?nFHdZ}I4 znrw9?HU^2#NutVo(1~TO-=_YO)8!}IZVhlvr-`kTey)WieIu{?-6$lR2NJ%mPa7{hVOCUXa(MV(^7PCNySXB9?6V>T0^ZNNANNRQsp-;dqWD?B4eP7z}k)iKqgeBMu)6lwp`4c)VBvIhyq4tFUnlg+!5Q zT%jaW(3@I%>{0lme49n`f!;1&bl48C|4ZnE18P8uo6G?{-m|uIZdGHymc6eY*Af<# zVcUn|IV;kbBx9TD)B~1{v-~LsBz`?@z;XYn`LGalx7YgdJt!T_#==7z_GBO3aS?W} zhc_GIbwER(G$Mc;6S@7@!?9ylH^+)(TaFNCB}_bKz}V0ks46NgV|zyifr=37RZd65NK(<%Ou zdb4eK+yvq^PAkw1=X&v)?E&6`@y$zChI=H}-VVF7$$Mr``Z$eX?BsK#Bm&}aXm4$O z;iTVrn?zA^sL(~bxGZ_Wp|J|sDX&d%JfsyiLX~n1skvZcaM%~qB_0^2a(!pe3vMp` zl~kKodA2%nF{gm838X}bGp^Rn2qmu(8Y*C2z|0pKuj;$cL^B^n}KJB z20qcIE69}{VkHL@?cf~DdY;3*#uPb5;YjxQ{a%aP?*4-@ghE4|L!Gy(`m;zAMFGEz zosPimz2DQH*ME8_{Z;WbJ@4nZqy)i@feU@VH4Nea{1Ki&UiX^!w`|cz#zJefeeXX3 zjNc;lZiB$uip&h3LgZIO8TsobZ>|=_*J>>1Q)6`*0J8=q7BW5Cy^y@b7 zfdWr(v$2s)`PzJaMW|zgwGSyg(Cbbv@WK;G`^#K^a}br(!Tq7MTwdSD*fl6Nj3NXy zH|WmlHYn)_SZ1@i-iurZd4@UasxyHSLY(C0a8JNw$QgJn2 zV&8_Ls{;cjIHty)K9Ve7LGL1myUZb^!7-ecu=EBCj4@71#!ppez@(lk_kAvGrik&p z+T4-saW#-R?iR)^hPH1 z!Im8U6Ogup{WC~$jPl|w%R~ST;8N_OU9YsCqh2$|sm_eP2NC+vi~Y-vJ>6yjjm1KC z-*)-fE$?^GK>Ur>ZPlzl+x{6Z^PkRQ*9|dEb64c#<#4QDr}}s1%>{|eNIf?%bE?g4 zeIYT&FWT}5agK(jkan{EON@D$y;#^PO(W)+rghxZx;rX44c-9QA4^E1gUgV2r$ZM` zj^%o?c+3QSG$mkTo8&;Nxj?DHN)yoOP}BHJQ18xlGX7a}aI?F_4n(aLFjEyZp z12mIiw9n7<0s2gEV$b8g_PsnhTyYj3AHSSW1pGw)q%H@5YOhM$yT3x{i-Q=0G@>6S z2rE6NXuAzd7)~{oI3r-3uqStg2A}VcrOb_^CJV~ zH7m_;+syXQh3R@pNk!c&2FOQUQ*$?DY0<$&jV)T?oFp|3ye>ESRo-ld^+jQ~*X635 ze-zbFiPe3uGR#3k04Kq7HC`X6 zpervBDn0Pu04uLG$#$dr-sbA>{N$a@OY2WE<1SK6z1Wp*2q8cv3he8=^kAv`*=6@0 zU1TaUQx(I06W!nNp^`@ei?e(Wh4$X=8R-OkX=-2SGR(V9G5TZw`(R@*?(nJT0trpVI67qYhct410#<`d8+|%C5?UR-?>qres)t{k}?hu zFyE7rXENFBSm12wCV@RG+?a37jLVvIfqEYi*G)+xsXG4<<->`Xz_5h*Qi*VoLb^jv z=DutXU>Yzu*XcM>V-Ot4#rS??*srM8b~yaC)peNN%2?b~YL#ZUdah1dEk=fvboQ&J z-0e-Ho^xlGEvsrGjA~N&;RfX@aBB=Fif;Gk;78j3-_ZHoeJj}k)$ww}=L8-bzBv5t zcL5JWZ$W4l8rVAa=$I6>g{$<%ug65tzox`I4yRXof0w(F=E6xW|8a*TcB}UG;o>09 z&lv&?pGzP!hgH}u|K7Q7LqE6Jhg9zTB4p`p6s_IgL&jlO&{x9>p{-5RA+`FqznTBX zB~beTU!(2NRv?gVDec~7jzmIOR7mHcIrHi!^Zyr{cwacKo#V~v|9+>ex7%_fV4&_w7VvEEw6;U6> zw_uD#1}s^>$D1g3##p!9*l8M>croPH#iPK~f^0vpbwvw$%|Q>Yj|N+hxq{)3>jy`9 zxw%%JsI710$^mE%h)Z~`bdoGgOZ3&8dPkqlb&nHVSCCR@xdGma*o%piXa#+YzBoo; z0;ofNo)91afoe--7T-9&6un@+#0;xQ3dJ3rzb`GPh9IkWpYQ5B8j2&4|6nOeK5!7V zLm8I~RAT!=OW-1gRgHjhqxJNVHi+3)q-DGjJps)C8LUW{d?!T z!?G555!J27t=&^};|7C<0p$7|lLkKvbcoI@s@kV}9m-6O>o7Cp%B{sfLI-9WVq^Fb zSG9JN{sQa8to?Tsk{B!1a38ZEBEN(6pIJ4@4rp|z3ztZ!UeIW$>vWtN)8yK{< z|Ba?dI^xn0D+m$u_AI#ld~kkyqgYiqurP-jb^%)llfv2`JHrVl#V2TLQrmv%RK$93 zm=^7s$v0FuYVvPbM!5t?qY0f__DV!)gn-NXetQU39=6GR)xf>&l= z5@j^_{RIHe3K&3hQo=(#p9y^T-03TVzzjUiI!vcgFqI>d@uG#Zk;~6|SzXc$@<_?& z$!vjOXfH=W19aIx4~w*m2RfAvw3N+kXr;XPMoEzpn>IE(1bssbSj(~38eBlGSj2<~ zadkZr6egB^y|NUnxys`{@d%zqR5~fD#F#_+a015X96YsAbSz^>G^o1DzcE~L;D>qP zK8w8`+giVnGk)5l1hZauBW4Lc6xp}scBy@uQLLIpy4jgj*=z8%$O!GCsUN`kWEBXc z4`l9c^3mCP7N|W(TD&>281m_rP3BG$HMYZGEP-DLXtAsRzd6(E4M0^-Xg^QU8C@-s zDUWy;Zp12GwDC74xi!Bj?K=+r^mP8UF#8&!)I0{l{wM8mP8|(O2x2s~11kBjCb=6( z4m621JqA2g36f8WRL=XhqyWbk5`pTqpRm_`J0mWeTomjK;c-Bgcm-l5l}K69I|?0j zr;EZ^4H32H<^-&_HT{Djr0Q<H=V|vL2^ftFU!C|-ocW-DXh$L+%BNIfq z56P8yPgGRW_=CA-9O`3Y7Lsl8Xhaa={@bnTg#>*VhQBR-*Y@j9(Gvp$J2y99OV{rl zzEMeODgC{yzx6O88Z*_Nh1IK&eFyo_esSYZngRMwGtyO@Q)A#7RfcQAS-l=WNo9nO z0a^evSv%}}WmFS;DydA~D5Qh1j#=)CJ16~qKhtP4Q2K6-t0fh}GuD%GpeeYe|1s^8 zp1+zKGPu<6jjyf#!cFE_q=2{rFvrjTii6{(?yqyJr1Om$^MJM*ulKgl5go-&$!+LGmZ)Qzzj5Gs*j9rhE+1nZu}yh)O#(JEGMb?mR# z`GtM!(<=|7aASwsXU2Nd3z9iCokFY9W^->2bu)qlwhC9UOx45JA*^%oD&sD!6>p=% zoJp~PsS08+qsX#2xYyOn+K|4FI=#lI=BXGSZSYw{mfI#pZL~EZwn*eG=?dpSIg%W$ z9n&#=pVh@LL7>F@M#&UHk(JW?x>fdv*hN%~ zZZ}ULXL0GS$97eWeA+$G7q4yA@`;P0=^+1?WET;@ajjcAGFPpdJC}9Q6{d4?PKq-G4n51T9HSEghJ9r62_cY+v0F9OpCa=B_u9LkwMy|CF z>cdzM{mGz4>uF>v!kDzXP()i%IR`;Ml0IO?6s41-*{&kxx7D;+C<*+7}3iMEY<^@r;$y9d>BN)Rt)t0#y zi0nOp3S6ef+@LG*QItAsF;M3sGPvE90uqmr0XAwJT&=igl`k z?sSvZ%)!{}lLs3SB+(z?&D=OO8)B$vJZee1g`{`T(-8T%eZR|rzSi)yVM1>baJGlq zKgN&e9WX4_(fQn9Y{A7axQWL`n3JR1-%pu>vgk`o_o0WL8unYpN8LrMTmKEN;NN)N zpYe}m@kUU@<{Ny#YkYvrK$`KnM$F86=P?NIF*W%Jg?n% zY8rF$w158=T~kXLZCl?I|MtoY0zX&gcaeA&7n0O=?xEKV%;ABeLn|$)qbR|K{(TgZ zwqKeD!7q;#{-qUvBe-{;zo1aiFrA&cmX-U`LvV&NNrD(XCtsdq*C%^9ebV~TY&RCfpKn*oHG`}s}s1U0S=CS z$O6icG&9=#zvGIx@880P_BCcrb_%+Z&vn#dw5ju^c6_!YQ& z9K^f_>W#k7Y0L?Swqui*Cc}-&f+n#m_Hb$e6TInBLxsa1TWrXgCmbq*RYTi9>Rw>K zJ0rs}88JHyyjOiFycKK`lsry3b1=%Z>-wdn?{z)^Sxx{9j3u>19m}D<7Wmmq9|z`u zWfe!gg_(Gvi|=Rtw3}vMA%(7R;hyqR_D{b_Mw^NVt3Q7&zP|e+m$3HpKVHBF*FS+z zy8W%Z;I+U5ERhpqairOXocZ-;;|5Q%rD5Y|mfuoI(;TR7e%5K}_%DSH2GpTF9FoL< zy&lV~Qo=BI8B~^UX(ygja@???$@s@Gf9za9Y&T{^0Z?^wMu{XSyA|yZE_N8hl;QQw z+{GD-Nn8_!^(LX?4n+(lgi$pt<|`>8$3YfMRC)Dl_M|?$a?TN~W2GXl^?89vU9R#h zDv6_XCR?FW<4kl&JFsbqhKFejZFXICJsNjNmh&2^WnUX8{vT0q-BF$v32I-QJmX=L-cY}a{q_l)|cb&oa_wze9fIpbWYtLM<)^o`zbr=^- ztT22;W?g3F=NgLWwI7S;!eBonq58xQv4O3}xY3 z*~?f@GPe>GycFhy0=M$dF{{r0QeME;bFAvlRN4HPD#H=CNbR%dA~|0Gp8b10e4n_V z{9EY+P&Upr8&cA0hTASI&)A!1wZI_4qeez%Zeyq1X97~EJb!2}*gQ=UnV!6jO`?VC z&>G38k!2g1czju01~wVj&6EO&2R`P_D-i|INzW?bq;$#o z(-uII9tXdz$HbAsM zqt9Num$r8bWBBr<%HRj?-1VKV1b9smqm8xDrq%C%>F-wYj1#epqrjZOCTmvVwW_Mz zBpQGI3?fmnd!}+Kr;b_nMzbz#mW>wrM)C6W zi5DP=y(K@T-C0kC)HpIQ>YpPh4Vox^NZ|TuDXGK2RJ@5(1#TtMtEFl_Eg$U*96E3` z#!D+YSJPzE&){L5d)1oYIU(n#4aioA0xIFfy6#cPmC^K70cwAqH!#2>PiYmswSon= z_Q)L;c@Q&vvz7=!ykH)0IxkvaW1;UOBYBpmdW#B$h^Bfqd@sxt`{7Lv4T@qBizbAX zL{jMd>q%$-dgZe*nR4{>ABEJvJeOEs#-L61ag}{sjm)=rj(5=dn>uEU>EeoMe)Gde z^FEqHH{CGh$Bdf>=-@c=EsWaiA z_ZH`+6^Ira%i_H%n?#|Np*J$5Z3^}d)`B5|MHK@wxR-8xC zL~2HCOdoyB83-1Kzqt;n$}e5F+=16@H)fUX%2^|Cd|$}ZJ)Wv62R|g(?Bs@Ka^aLg zMo#CmRcpy>b5weGWuptRIFD@*HNr~1{7u?3QsFN?bkb|bzmvK$>8-EZ8v0fTmx z!PA*Efo3A50Lk`y@}Y=6AGWO8%;!>TKL^ryMByX1?_P`CF;ccAU&Z(i1@kmYDcfL+ zePSh#%=S#9P+|fi5?rT|Jn#rm9xAsEy_I5eEho~c=wA*XBHbce6(UQ-xTy)8HkA;B zZ*GyvH}Bx8+)xX)&}`upq=T>JJ5|0Ww6_G+L;ZGFBOuEp`+!par zJ#KGZ{gtPkmG`4s(}m&7PTZ4N#yMub+FiT&jwTf$jpARS@GQ$laZrmBWd2Dl@Xyy+ zT>EjnkZE8o4!7S&V;AyUD*m5@EB5@UrI;3$(CM7Qyl|1s>UekRO=^sbIWcm=C6NBy z*6a_3JL`H((2grRV0?$!N}sr2nnJE|MoQciC7D>HL&z=ClvdP6=u42R)NYUueg0n4 z<9%cu3uoh8P;;t4|6aNCo+9@XL81=aY7gn=VVvlc@Z(QU}wb8lM2V3gFOSxRhJe!w#;`!8WEu<{7W=4v^!QJCWNu9wT*yj3%)6RWqR`{J4uau2>vD3jcZ&G zY7$LSxM}Xn%oG_IYK+Y>hz$+tyx7QXzm16M|7+pKj$&c*UF={RJcHyMYB*=|DcZ55 z9*PeG&SDSIz1ObFEG}ULa3BZXeBE7S)#!o4q+M z11%R)o6w7wno01&X-bOT^LlARW-y0%vqmBHs87|S)zkPMs1wT0zBXKW02#;7PF|;{ZN!FqFuiv$6mC-<-66OB z7J1b(bL%);s*EUgc|^OFdW -9@Qv1xdhR_^+?KkWgoEt=>!X!(4D2%I7$0!iPb7 zX;|LDLKiiLW^hG*^|N8b^R7!=2r+WQ2e|}MK0uijv-*AE-8;e7*@F$!veKL%D6mx_ zCSZ(AB`$6~yjM#zm1%N%X~+%3L$UHX9IQH@k_EJ%fhmrIEdV&ub2RXN1bp1dqS^z3 zHn-G*>Q5K%zb)YX{*2uG)5ZdAGPiioWsO-9svElJ64;@)Fb}^~)t5uI;SDibyqS%# zOV@dR`h1zm%dyT7A2^&ydN8}Thp=cik%r=le!@j2er2bjzn{=>WewH%G^%fbhTgyc z;ZVhbN`)(Kdc~2z>Q>E_czYF+5$nyrP7_e|S4F5fmG9Q)wSCww-@AV)fQzSst>xde zvjvW%dP1E>UQNIJVO$ycYsph<@rq&MFR5Uo&@ekh(Yd>4(%Cy9W#hf&&d2n;q(27w z*Y8%-ftBUdxPNv)ta{wqt-!$cx}_!4WO$N=>oZTZK};i!)gvea2i`qR8qI*pVkd5} zIqkjc7v|m!`uu$gKK%7du?z1oIffXTc>?tqLztuxatbZO_!mm`X{}Xz>#{%S^FWU0 z!}3!(gXIXpbwv*CFdl4RYI+m=7wrdhjXajKAcu97JafBh03I(xNAv>j6_+oaDaL*IH=UwD-^-fup9A zp|Od@L^vt5f2h}eJ4y6Jf>h1tsJHD$%r4o{Lu&1HhEN-9CW(&ub6|nSj5|x9(kF6J zh|Kt^S5*Ny;8)4l$kVq!23S8^Qqo$RLWw@{%a^U}G3JwggciWqU#Be|0bI0dPo-WG zsAEXdTbhxaOfFa!!YQS8`j1O(PP2Q`Rkfk1VwzSKN{TM~MXLcjRnswljylS`J1U49d(hxg?^$W3)WNET zU$rOLywraSUb6rf>So-0bfKKPo-g5PvDx`@fB1YKpf31+(;OqEW&6bDbj)8&!g2#QWh7Shf)9Ff%TK-?dQ z`E!YJ$UPV}ll6!#Se7mL=*@H^Diulz%^YvT_m1#rS)lH-(rYkb3<3(jT{xX!Td$uv<6l~9WIS!)2ZB8d0!qJ1_sdI@so9dZ?e;4sw{Zpp+4}5uIhKkLi}hGmO=IeO*-- z-0C@*;_F1M7*ktE0c+)6phaT-8uVl(>4%>*()nw z$jU`>@%MkqGnoJ54~rm+mR`2fy4|!CY$x~`ts(Gvd2)fp6X(p>9NZ|V`}1_3vmBdf9(NXz&@ZHLq1PstRZ;xPWe=Bu5MlVM zi-hOu$jDe5wGQ2@?BL?L0MRATQ-X7{)HP6yM4V6%DDYaxYg&_=eE@>9Nhx@DM1azw${C2hl$g2I zX#*=fDhl$a6`!h>Ck%Y-DnRks>jV{(%QNWmWkhW3;6HzYsD1vk1`@&ScNl1J#~Cd| zb5Wl*vYZily~A1@af?fz;XkG0aPSqEAH`()Y_v{%`?mMLSwP_yd#a0dbUv!}GBSg@ zVP?Nc{Wr3~qc0AabD7~4E}F2PK)yI(V6v0~m@cE@z8d5RyN&BJsgRtfQWF_5-tTV4 zJ|9>+=$W8sJm_xN!Nm?%7rVP!B{^(=JU#xeKL7@`*Uj+Up?~R10+wiJ@ZHB84Ru$i zs6UG^{kQCGHzRyuC`y60%G)T;W2BkXvDme=|E1p)H{`8PGkpgiw*0LHk!Mn)r)9}% zQ^g__3d^(S=KPG_maZISRwqQjv?2poH`t89Y$7D!zc7IatPF0+)B^h~7|*~JT74n= zq7Qn=5n8a981?Vp#YKhKM}$9$G*bCb zum!ZBdX#$`z&_qEpvTC^t*KZk=znz%uLi^q>Bb=(Ht)&VEkS6@53%}hdBt$e@Xe4p zbq$2ph|>v9>bw~KoVx+X@V){4YOz0jg5D5(iK-I6?yhQOZ}927E2)vwsfm?=3Ziyr zGzT{<8miD;g_<3v5V%^;a76jj_VPNyxpa5DoJ50xvcI^a2>Z8*?FO$rO%KKIP>CsrZOtTINZ4y1yLh0eyQ~68Cr2+nq#Ii3tQNJoz)6 zBzR3#ezn(U4|;9B>!Q@Anwed683`uRF74+yxH;S`#nS#g{K8;bMfSVXN+X<#1J(s) z8Wj&-lfl4U?E+^rRV-I*7zK9e3ul4^2*JW7$$Hb<(q2qycqMg-F%b?x`C$X*m&`T~ zqh{hHxrM7}b2ylNe!X>IkHRx=&dxq>zEr+pAk-@hybb3WK@(pOtZdV zN~C$mpK1S47;3!QG;N^3)eC8OdC?-6e63*(6~AA<*BMtTX#pDu{z=a zC*V6t0a8A;Z{UZ65Eb3TYxb9?%3#G|R(=8&rkT6$Ai~D5Q4FVGo9yuRzrob&q;j$G zo}*5GZAriSk4yuG#eqwtU=pAxB$><^`% zXb(NejjQ7)OJGjreckYwQ#W_2E&uSMq~w0cr0MwAwvIzC{o8|HhxdGX2e`lTjJRVS zgh(0W?MZ#q6SNbVu&1U9l+JNBO@wjZ-fn{5Ny$_3B0T8FnvD2(6n0;cuBIy24T2i{ z$?3+}rB6ftFw}XMk5dfr7MX6^i&N@?c1MtN{uyE(jkKN#g=NTWo zfSCO?52#!MMf1y6NU+PIdEDzGj!vc>1w-3$c9vfd>p44&P%N5zVa@GZEa)pX)8KWh zi4t7#>u40BL?xCV(BJJ+zict_cuPxoyrL|@8o9JfAJn`B0F(v6p=a?OwV0a7r(!|1 z5q>U^&iGE@ib;lH)wkcRz)LMbkZE&%kUfl#65x`m_N9bz#^xK< zl5so*!WY$q+Q;vevHm~=IdKW97AKw0#pIiTE_jM=IK2vx6IancQU_F3QfCJgC3vbG zjqUICnWNdCE6I|kvgnMWBw>1p>#aUsuT?6fr-LXQWRG{Jf{;}h-G4QIK9ys0MBfO zQZ>Cn-hQvpLOoaGhHf5iY_8!jr3EIHIRx}(N6?lHzq@gX1nzeJH*+o#98LF6MrCYl zKU=`Xk8+w=__whKzX`?g6UG!%Bx}e0R3gAX5a{2owReq8T#1VW`eR%hQI!PQep=j=649W;YG097{gt7o5E>z5 zdNW?5e4f0!-i@ZiptY|2=m9b7ck67~T0XWELKOVG`Ds=NBckLRE7sGf2@tV(kxNqLBfn&RHa1i?G-35Wg@%7h;Ut4LR>RpcmMt`IbyQ;Eioel;J2Fn{{+LoWn7qGmFkiRZG$H<3*fs9W zQ7#PyS%BIlKl-w=0Ay?GE%-8g04QN1S!D;B-m(ioC$VKp5XK*f<8eWEmmY(n38U5iqf#mVX#D^CgxaPSd zjodgE_xy;?@G-so!jK--APyXZeZ#tM0Wkab&+)v2`8T^n95ja#0}#P%(&*kh*q(^# z=;~*k2rqv-x8oV7%v6=u(bTtMFAAU+%0O&})!fA%Tg{wU77z$wM8%e+W z%Rbak`r%^qveM=3|5VkQzYEW{hRIY*(}(p~Z#k-&X{r%M5C&kYu~#=kHGI_g7@s>t)LMMRhLH69PjwM)30 z+?47!S~=C_zZGf$H+epUEI$$pLRs*67{N^`X$yx5&lo8Re)t9qBt;_DN-&7M{z)BX zs)y+gLc+c>eg%5&raXewO|y*>D^1yqZre`5{2h8YTFrBR)24*NB3yRNv$jnddfku) z(%+?Y)W7-eI9316BX{VMukLD%vTim1z;iMCD5z8F^xAg%fWQ9nq;PN^etVsZPH*Qc zm4q_k+TkzIAMo4PROpZFq?77ywSUDu1#58r6~jQu23fU^ov zIT|b8qRszJHvlL;M?-ZqKTU@hdTMF#r}^8Q8*y z2_FjnZO6bntL99VT9Bqm>5Tk#;H=4!i!IGg( z_a@fKz_*Z%<|6)G^r!B%E*}Vu!l_4#{?mmhe3Qj69Pkb8GX|I}p7DckgCt+s!6Y?l zYju6mLu%5PSN0I)*Jq_$`&E{agkMeC1}TdqoDns>!!y4~I^p+&F)s5o=hO%I;5CnW z1iY6gZ84mvVCTbM-dOD0VPQI|9fg`?gne#;-loh-mp-4LIiHI4$>T*>!GQL`uQw(9 zTzKI?{1QRRd%su@1hsyyAHG{(qx82(FKPn5Ai52@Mln8T-qtj`YXMqtdKk#KpI`v0`={tUw*Va}bLWP9)OHIvNskxf#hkO{h($Yj}! zRw|6UF0Iq>?YIbZMg6UA#dnL|Z3#b95)GnEDcCj~U%>c(fhBn}z}l zZLx!JAUVO27Bz7za!%^S_fr5tsmQZZdP|H0y|0ZDhn0C{nGAP&y7Y_VInF~NUbN5N zm316t4MX8`bd#*&atvEb@+8#_E@<9Y=qOKwX2CG>9S%f^<(v1{RUiQFsz!}w4Kq&d z$pMwQs1jJMUM*ge@mI;w$=5Sm?6SqtFcTs`>ffMTa`+r(#R!Qhuy!F4$u%J$Vkc>% zads&kkqt|Ys{H@^uJeN?yy&BMl&zqGd1sbt`yCpRTzKdMD~p(0X5jalnR^QK((wS zl%OPpni-zJ2fGWgrQR`B5lQ^iUX)QsE`=2~QJ|%lyXFTww(cN|t3`bFaCDiOVR=m- zE?tKs%IK)FYG$A@F>+&0~P}Yiox{6T{9Lkurc|{yoA(SoRwx< zSR{$0wZ~^AiK-LWt6-zsxx{)xcqK(y(Ano?1*!%3N0Dz5NOAw!GwOSrv3jYd9) zEfbk4E{m<|AzO8os7}}q>LGzk>xQ(BpVeMfA!oxQT}Kq^K4gRKr`$Vt=K=wIc$p~_ zsqhmxIM^r73O1^_#Asls5%cXoLBTMj5?@J`w@pr7o;h-?+N#^-o>r7H(T`Ugr zzWM3@b;l%#V8uKeZAgyIauc~e${iZ*&1{DIp9KG1$#2fZjtCX+j<>=VnOpyX@*^w? z&)-N_0yfUh*Bg^&m)WH4*@J7PN_?X>kj9QpqJ?Ws=XsU$hPZ^!To4W3+~6K zK#crd1nWH-WK&XWgsGs^YZ~{rbPok4q>R?KO~EC$qp4Uft5UC`um@i;KfWubUG3Sh z?QHz1!J)ONes%aV{pu|2b?pKxI|uAE(i?IoP5A$+ZrUmYAR5Ox<)`n5r{I0(5H@aG z`_SM20~FRgqZ|-64u(dNX=%+^N@%46g2WH4mjk5UV(!Dk0_y_G|3B3z&L`@Unp3Hu zk-CCp=|8ZMt?{&2VmN#~3{)l3;>G;YN59IG7KAq3fong}!OQz0 zhgTe$18U%foKpd|5b8r)Hv7w0ka7CAx1P&h{vS`nwLqYpUAzdi>i5%}hKd&7U(Xq( z{=5l&WGJ9S{T7)p^{yWT*}@iXLZewAXE@JRE`QJd|y9*?+rldE3^rt1-#aXqd` zDEcF0gFUuu@!!Pp8BgGhlc?gfr^N!_zJ1)1=@<0z`Y0VgB&HG0w28h}?3a$Hp+Nk#eBR&_m|%0M|oN+lT1V9tS!41#&!+`a}zSLn?!#JYzN(-Ch1edUeBQ>rrz6= zBRUc4Uf7rDm8Fc!_AH4v(Za4g#m3EviS29zp*+uYP@i_%JSCX39* zf0p7fTEGuh^KjAjK;=S==Xe%}7FS?_5t`@gcC_36;EM%U1W4GDt#@GBX=PstC(0~U z1v?`#(`~hGDnbi_%2m>R7wf?=!L|YJpqHDwrg_0(j9}1EiwjUhRwW4teh^@48d<FYqW-uKC6`Uc(cW89MnbLc~r4v0#GL7I{aInuztFG0!n(qQSOva~sXLO#$R77N zA-QZ)1$BR@d%fG2Y`A9vY<)%oiqSDvDc#~WCv zIeQX}%rxUn-&FK$6%-Wyp1j&2TVmYiQXKxvL6&jMy+R;-N*1vyTRRfdc4Af^UuqO` zQ}62~)PSQpxmo~^zIo#}Bk(kEDcA)GLU7w8s##RC^m2+zZX5Yh&wqdHVUcZkr;eSk zEGXKu@6lan69AUVOVt3Yl$KmVw-y*o!f*){@*_YgLITH?yzN$eDz_AjM#{7NX>`Q} zL*B(KUy@7*hKcS|yx?o>n7$j=SKN~}Qp>6Q@=vjAa=5i_uTQNxDSq^nVl^mIT0FDt zwQ*;2FWJYF4`lpoC;guK-Dja#3M5PkFf-p`i%x)y zDiBHN7p)wXI7sFc#XhdRw4g(EL?wZ6UbTo0b?{v+G*tAtv7ZBMZ67Xcu z`>9=#8c?Jr_nZ>=Ns9Dr_-saKQR`=ONLVXj(M@~wVmWkv8CW2|B!)0M*@OmXnRiR^ ztDabBp4%s}2B`;az>kR8jza@VJV||gmhTKdxKE?cf0w1H1glBlT z2&R1MX;0(^e1wrflI*I_5!8 zhd8S;6IghuV(ulY#C+eFjXft)T7$+F=G4Z<63UM}9H<{qr45rs+1{={aExI?rqHm^ zS8gzr;~n4B%92Ydbvu0crj4pPh(lCv=h>~Q`YsX*{gw~yDMtB8o8@mr($lm1;BNfg z?-Ixp$LQ9YkFpS13I2=J$7cYoBQs{&*iY3R4&C>R3zLJH{! z&$+g-YORB@I;2u~OVJ=C3$bo;%V)IB@I@cgH&EnI+ak=+?u=9MP%uE|nlaL}R!{Vics3 zJq-Zc1%T%{dIVo_miEUdY_C-WI5+ixdg-KQc$~>UCnM;%LRWG+SUb{j%`T%Da{4XL zqMTW*yyBLxxX*ZnHZr=1>`mRo&=7L8SO$6XU7&`QS8TZ2!l8e0vHO+lxfqdw@|B%c zPj%xi12N|DhIpCs{C^$|acu=TEc#HZJchK(H(QQ84rnWB@ z8}3mNVcRsrW?moA#S(twSM-$bTW2r7vU_Zp|dK}O?;{>(MGX`CM$8MN~l;vAuyw_2MSW-b$LZw zL6V`dC^(#HiTtviwvf&c^sfwLsYf^%#%`7Tr1tcrknzD#pHdrsg?)=&a_ABY?Y3c5 z9Hs8dX08oxBac^HdcVR#$8qQOOAJ!>dEVNieqCxh`uJIGhMQF4jw-F|4l&d07fAMA7`8WdpDl5R!UoOOr?&bg{Db zt1)QR6L8^gR|(5ubK!1-6YR zY9GZiv%P|#(r?a6EOCo3xgV-5?NeV(y%FcG`e?3OfojX7c8TNjG#W|QjBK0@R0Sp! zv|D6Pq;X|!mJjMBUV{vp%FG#5q>locua=|_{SEw7^$e1}sL?&bHs77zC7DekNcD;b zUv0iODgsA^A)=#5^-Hx;K^|3(QrZ1BoUBc3!dmfEOSyifsSUz2Z=q()%D*&){IGNC zE72r9bedA&bm<96Z8p$AE}PvA`j;%Io!6L^SycWkubl?*{DpHq?Fpm${_;ul8i(A< zCXsG|Lx6I36_-@jTb<8J$Vv`aPv4Y87Kq~;*!6#o@{T#NX(vOf3E;9`*R^Qa?&vBj zyzTp(ugMqPwoQ21zo|I*=UZ6J>5ymaU0~{H4ha5iqJBQBSa z+d3yGodNE4=A}7AGissMg6JUJxfSWFIdGw-kvuz$wU3J$5*E?N`mF%ZTc>rc#~chMt+`M(XRy3 zodyr(B+aIq-_Dh#?eK>qdC`eIZDnuk*^)m-)sK^XF>teUlIy$=JSREoJVO3stZ} zn70CxSKJ~K7Rv79Zk~1h8;fsJ6v?XDDVC`h8b?Y-&H+miJ3>Ntt!AJjvCdZ*QFPMm z(m0TVU}s@NdL|>8U0n&jAYJz^`}G&-ZsuLO2z-%*H6v5nRx!PUZ(o(!D`*tkIV%by zec#vCC6~VtL}P|h=Hv=(O0H*Sz8j{@r!B-!mCUCJ~&U^XSA4F zqHAbK8IJyZT^)Be)sc~GGw2YpB=hDKeHMSlk7QF6(S#j*+ru6QGz!Cvq>z0(9AgtC(-9AQM^516rRhsY>!m=mhJui6Q zbkEi98*JhWXw&Dp3^e-G6!S@f46bA;9$W2*8PX+d!_@ks&q%rogBa!F95vq25U}dB z^p?jZ?zw2ikuf1D8kp6<+G|7&#^>YDNVP|4n3TmhuB#RRyXLPx_=#NqzhvUvQ5pO%@~a3&EhPoizVPcO%(YscszvP!AERUi z_pg+SRWE+yA5j%ni;vq@d8BLKM7fLV$BHsG(aR=Fj*4>=bA|!@_#tW!O-yJiZ+vYz zO~8Hp{2Qp^C@xW~C=^h$#YAx5CXX@RZztPQ?^17Gp9Oi0@%61%8q7qaBOKJC%n zHZE+l_ywDJW(NyFIZY-7s-+>8v#?neHDQjw9;C^Yu0v)wim9jwM;9>XlM)awyga|G z{2eb~%?}~O))18jTzQyhTUEkM)BsOf=RI%|+)iD!#T|lAnY4BK>Bdm(VboRk8&#z? znb(JyzRm>E>Be7~^iIluQAe=L(A2-yzQr|imp!Q|8Z2$m_Oe~Akq}2o&6rZZk{D|_ z$y9a(f)w9?KOJhQp!MJm|GLy+(K8{k$1a&vP^3;u<8#8C0FX(_ke@iO#qfbjT2XC5 z=F;20^|;NenFgDVaDhS;aWz2VS09GiUL>vsRRLRvqq!yWqMWc!elz7_fYr#i=x7|1 zF8j72!v=YGi;IUXz3b#lp~|xI3d!8xz86&#awhQdmToDRk&FnhV5AXuedtt|U-?8iD!o<-k{l|eloE+cV+Lh~20Ns;}1shBYP407s*Cvq_| z+(IAjRs=$*0Q2E;{!PQjr)DH{a&4pJxbSYXJ%2`Q1yUUP$zFrX1zHwZrNyGfWTuP* zsn@8A(s1S*3F|RW;#MYxSr5Zrqp0>%+FzzINooDtCd$K17%9LE&Egdn1Jk$cR2k6 zJF<)nYD2xFbk5x$`b3K$6C1Nsd-N?VX0D)8riU-~vH0Y2TmDZOFWfmmXQHMyv48`qLt`IInSLw=*qxO!70nxeE2fy7 z*iZv8-WSBG#K^DbX|v)?UQd$;F1~I`>EMG`6R`LwXHl;|#ZFOJ@aCxa5>q&eljBLr}XO|p`@g$K~_t`#nYgAAOYO$Fof;zdE^e-+P zTK{}Z>!K8rrJpmQ_Jp)AN(w|i29xnC+7XC~Lxgd2y9_=t_29m3zx}tDO&gC)*6mYs z`oQj1QzHJW|A^=p4nG2^kpg&JkrBIg^*tfR! z?2D|NaqAzt1J$yhJxzqbvknLM8UFfzwbIpd-%Pw- zA{5)lD<2J3Xlj{-2CT-Di+ntUyZx6-E5fz{)KvH?D}Q(9a9Kw+8g;DTM6-k`>-9W! zwpPdJWi0lwj^F5)_aBVVU}!23lH><*t_XNC41Uop+;M$5!@r?Wx!SLb! zWrq$7!TFYrWq&_=8de5oP}-8f1=ICC11*WZ3?vF*_c`F~OuAg0QqOK``U9B9=(U*! zUL~#i3OL>P3Uckk8>oc}QUPo0j)PFc{0)taaF2Co$sX=6$|?MjLFRjjrwJ}A1GroY zcj#rS);{b!P_{n|@7++&x{VIEiO6BXYfe;|P4GXcs;R-#b=lq|&i?Ol&YkAR{? z7q^CJ*I=r8tk})*`IGWB1MddAsA|YG1~tdDZ%h)RInk_~~a8it4bq^D~UR zg|{R~&jW3~^F(L9Sicpyf4zAXJ|%IyrRu_Wj7Oo$j4ZAah|hHZnUP3Ye7Rekmo05W zXZ&GV)-&(_bPe@_7i)6t5v+P;Tn@&fUp|+We~`7NX@=LluF>Hjq&jLkbJcS(54W{u?-ncP1$c34OhgV?gmhM*jVeq(7EF#F>^Qq_>0H4Gs2 z2B_6oQ@{i6?C-vs7P_8)_qg&ug4`1$y^c4){u`!AzdHh9Y?)24qP2#+o~#m&T$#9T-Jetvi2^$rAlEM(wg376JEb1~PemyCxSeMmoLB=Iu6cvo_+o5?>DTuw46jA(9SKOGa+LOOC>!{r-Y)8Mld#m(i)2~qjnLRi%o01<0{Uk-;X>$uIUH+ ztS2JF=l07z+5Cn;G6<`XX=mdMbLwd{o#leHYpPD;#C1HDWTXe zqsonMt|ClXS~ru-i%ZudTv3O2*TwS&&myF{dv^X1U>3?rS2OtMY9*RT8<$dV39+Si zFA@_W{u{Kygyzryn#nHDoGG!>keR(qTMm)Th7sm+pPw1}w8zQ=6a*Eb0qo07T69x@ z<7?anUDV=`(Wkxc$5!C6dyx@+!UqHkKLn+$$Ju8;{|V}~@!tFGfLR&P@qAFk{Udcw z7#X>R`5*-qNJV0_@NT>A`=89=ItBQs{TUh*yGprVt`K@5Mx47_f4r1A%K?K^Xm z`jrid@R<8BvUY->(TCX);7)sjW)#QX0z01rZy>AyL9pc#B+RuDi}kq@|m>_#o8w{1Hd?OdE6gp;CtD zX|YfD65~6j89B?wS_N(i;|rs0-fxzU5MtX&(47%V>(zVL5Va9;Q!NS;4L+1Rs{L%@%2k#fut zBlpM(I7Lc);NaD=#-Ad0J-zv48v0k$yNgP93C(lrg`~{7)@2yCZhu+sqOqD)pre%2 z1t}om;;0%OJ^Piox-#k*)d8hdkizwZu2)xe^_=CwL}=6UDFF#;T~ql{8xU(&&*~}) zRh8mfK7xgERb!P_EE8~F^|3~Z7Y7$(r zD8$Ci{3jK#nX$56tsXI#r&TrC9+}atOrg9Jl_~T>2zp7=)kY}ZC~o;N@J%`V%R(sq zmv4PL+#mGM(Gr{JL~mOdrHDvHni_87Of0Y=uD5;nm7l)yG+Ii1Bt6UzJt?NJ#FdIj zPF8KEUBx)&1?iMw=wr<#1nC8^wy3WeIn~<0|Bs&KzR_B^S;fC$a?qUn_f4S3^=k}^ zc`%y}w!@1I$hR1pV#XD^PKJsw=qRaTIzeJbf`Jx-ByauTE`IEkTkxww9Q697ZOD70 zZGlB~nqN4fKgA+5UWE_+GH&{Et&TlNB30k(@**>co47I#c8mU}MtTu^GXw8A`7yop z@`21b{1aDRKd#;NcD*tsctm0BI|YH!ub(w(Ov`BnX~a3a>!6hR1Q*EeSAM{Rv7SyL zg=!_h)Q6niw?hbyf;S_+n5|~4n=p4|i|lr6m04Ws=H5?;V0T%*RgyD|>7f3m$vM~_ zihzRclN5kCg8f?8ZCQ=buvpKyzEDoib|e4i>kx~WbUX&~v&gnCJpuc*NovzGnw_4k z-{DM`8pYUnivbqY;Y(-8*CC&;_zu`O6hVOr7{0xss}4_1L$LK3%3a8RO@sQ{7^U)V zt@?M2b0c@^aNTJ}l6{cWmiIN*shXj&OVm<^w!tlG2#N4$?&bW$_eTcdo$M#UL6O}K zFT*({AkE7)vEqVpYFGPXH+Ne$d*8e>VN!6~kk}A5^RlD83`Q&bHEDlcphqiZ(r%Pi1fzZ@r&uT2(}P5)OVMUal}O5E1?Y? zovHilbA>fQ`kt;q+niP?=BN71ZmhDW>DTN&X_ZAx@C*46Grx>+b+=2=F}=15mGggu z1w_@F+A*Y-4@Xhr3`QH=7DK6RR&7^jzJYegEjY9E0K`WN98nz9jzt%oE>n0{BjtHq zQo|om-HK%8WEmHB$dPMO!Gdd)g_Q$BlKWqia2#3?QB>X&zj& z%1*{)!ckM*-#T_6026=V#vMr#`dv&7tKYuTx*JVyZgh@i#F2ZodkTBhk6CPDxi%`f z@wXBq>u}5C<=`-<>OcDjt#!canKQHTA-R(Q0f>#a|Ix_uddVUtGKnA}JXgMC3nD!I zWd6RVHgjy+i+pdCqBX8gGg0IIawj~2Yh(g@BX#TQ_EB6l)F#wc_~)@^j67z2`!SDV>-}NH1?!8nyCs`^=gg$o)Y}D zUlun}(oPzA)VO*j+!V?p%^+tqi;aJ*Ei}r}niQVsQ1k68qxtp?A=6^U)*6LfAj@t( zOz4Yu)m{b6Yyn>=I?38}5lYBf-mCIaTb7W3YZUz{qN&hY-&{{Ylr~sdD=FiTEy9KV z7YYHAaS4%)%-MHxC0$MkR2gDd2^INt$iax=R3YeVK8oI;@Q*Z(%a#29c>2n)Hh`_^ zLW^5*cXxL$?oM!bE$;48+#QMsYk=bJ?oiyJxVwGnz3=`0<;lNncF)e4Gdn|?9@>U` zZ5@9Zs(u5X9`YZW)NNDE;H!oCQXUiVcU6o{SysHfih~LU>Qvf?z6__-+)nyCx&W8k zHbI?kU3~d7d~y{}0$1m7?FYP%7(b)50eFWc6{35w$I+-xVCg-1U``ZnzxQ_KNbj2b zCef3+^j&5CCp!D){Hr|AXq$RP3AI?R+XaCyYPS8T6`}e!*bl!(HC3-i;^Az`knoFt zqa~cjK?oj!5DOTuc)QE;6DWc=2y>wE?VEcR)jye?Hw=cBAlNwpd}KCfS}?5f^rAy2 zv|*D?SzmT3FSLwV`zR~yQe-VSo!)u01AHy9_(D%t%?3Co3gGG4UUyf9d4X zUBQP%7kpS4sADpn={v5$^1`H+01v$7k+<7UvMZo8)5us z@=u4^vGs;PNpqMDV$vop@N@I&6m}NraUDm|(4W2ZcS5W@Dxlbh=C83O#Dl+RQ^ceo+6Y2F$7}{+AL-#7~2y|Kl+&Gapyx>WK zR!%MKays+7*`iNI)7f9RG~YzVGaIWs|_Z6{pW7l8J88~ zc41>4p+gwPL}E3mVdk}T5#Xz;3&E0PZLD(ohEH#bX(iP>J`MV7hqhCr%3zEFnQ4~S~19~(4SukaSdGT!N3K(WiovkD%zh3fjZ{8+3 zM5eV!K6BlFi3a@KLLK9qLz(!M5pW>fw@X)`uX34+E3!3gBL7%oT5)%HItZ39F_)r^ zq?JKL7kGH>N@0Lgy)@@g+%#qcRyx~ZnUeQVQ8OaJLhY#XArlHE30qQ9g*@NZJ04Lk zc${bkg>FIp7LTbQQ|c-tHx`zxkfvbn)zyvf8X9JWG<3tr`a`}`5QS<&^_jJHyw4Rs zBz!}Ljsa-}BP(ReM)q9)#S#WIHWEx|tYQ>0mPNO5-OuiJQmt+6_c#pw&N2I%4PWW8 zf;iia0pB7Ri2B^rgWt zu#gvnDbDpt582rCP&eqRdYXrnH_9feE=jZjamRt*20uE5?Vk}N&qk|yAR z+g0IX|CPChA=rqXkA2B#(lWKqWeegXLMU-u=Q_vo}F`vK)C za9+(dcu-!N3(IIad)W1;?P-U<&%%djMeI;{(8WPct+qS%+R6;dRxCRPrKy^s-qz&! ziNjq$9Y-?)nI!C0KdyVb_o)%5NXtV#%HBq0$ zG;)T(VMATp`=$|a`&h+S?9hs%;HLQp3+oDr`oE>^t)RQ|UwWN3VohA#SIS`#5;p8> zUn*&$_`NQ>mPBxGg{smP(gumT5BO)4h)7i4S`OX2uj$vF=Su}z&CZ(FnRIxdfO4$m zhW`nUYG_A8xqR&nc&VpG>9qA0G9NSjVlM%s0)--CCs9RUB26K;Fi($siSiLv$pF&; zHaZ63O>6N|ipri(GCv7)J@xozgPp;LVBvq1q_+zH1aE)V{sf#Xc@MUaXtHH`$Q7{Q zoXI%>fSRtuW7zYQuNHPm4|;kChk?EJ22)wz4bxhFFI;~fvd<|&=|-7l`#U<_fX}>| zZBC1d+VHz6+?mm=u4Q;TP>|>R>#~l~5-u_v(>a%l=>jZ#)xTJ~S+~!MmN+Zi(%nMV zU%y0{TrV-6U^+0drH7rg6y8XC1#TDxV7c zuK@u7-I==I@(aHRA z=I{xt91b5Tl8e4y2$h%4qK%_I#S)*40Ix<9YxyOE8pk8byjO#E<=TL z?1b?osx>r>@b`V(2H0POULl$@dUIQ>b-svd&d}!1xUIY4S3dKh=l8L6lL_tl2F`)6 z_5D$}tSUT_paq5h?r{`lfJ{33cPeNl|2Fr^1qbl7$Jeo zDYT8G?pyU4)8lg(!q~e+A0MZ0a2fgwUI#G&J{y)bq9xjp{R}~0Zy2E2|4{WqqkjdV z^D9O)=VtHm9d#C@J*eDqfmEILj}kbSA@xYxPx?s|~J6k9p@9baWgnym?V zd?UUP*zV@>H>t(l?Ro|9bh~hAAfUMvXakzLL5`->ALN_YIyCl6twP;4Ls5g!6Etd# zB&;xIW_bCh0Gd$xqyWx$nofeSAj19N`s4)|;}yZq&p`6Lcqa7>a=pie z-Xnb6{2?dAexL0aPA#%GVjOC84Fe9qmj@ZQctO^V~4C%gk!a++s(f7+=9ctcZ5&F4VLW4=HdnXpO> z``s1uyrpgpuTot`ik$4fE^IG|{#)`woJIfsPxPFs<2xHM)%e}NQ}3s>K^e>xMia^7 z&Z&(#-DRd(^9E#sokQK9JEBN@+e-!Kzvmx>^-qpZmtFdHBdxhhz)SG33k5rHmaCH!f+#h7os$+UNhK}m_Whp#B7BQ%LzBZ zT89*ehDV~&DRd2kOrv`9RyMWmdL8eY<5&mfHpI%%Asn6aMg9DmTEmPnxz?uu>*qxj z2$?o#h=O5aQS6#3PIjxRR9?z|uChV@aq+-B(FrU&qrTjoNLIw-IZuuHey8+c$WBzv zo?Arvw)Msnt(9apbGn@Fi8{II`t9o@qM@t_y~ab#4y4>}&b`mws=sEn z`14bzOZ5bwEU-GXfNb&KWoH=a5}D%uMDQ@^dUGmb@6Vs9uueF{GYh*|r+gnufW$&} z&J+l_fJR45W#BOz)_Jonkv0|80a#_K4t~1|c8Go#Efr_CZi!Y~9~tPV(|%gbe}nVS z)2U&2TW9;2^A?K(F7W)&e2STUYNW|bVq~x$o<+h_c@xuN92VE%V(R+6Cc5pn8>a@k zIIeItw=z1qCTEX;miLdXSz~dXlL_tCHD#JUFqv`cb=hiwm0i${yH6ft*T`(BlH$@qa6bt#QNgJM89FE?HUG z70`*>UC@Z*_y`m;+o32-C-BhfHge@#z01Ne9H|jM6T~d{p_To%;5IC}>G7!Eewp%l zjc|LJY36a*>p5oL)d-(g3tkBv95g2u(2u5Vs8)?w!yWoM&xmBQ4*8nXDo9tle0J4( zIrH&)+SLPqCR}!@yL;%bh+W_?GiUfHDH7`M5a|rQQ0hsYfIrGTwl5Z@N}AQsl+W@o z4)9z9+CQ^NCbJm~#qa*(0b7}6< z9nq;PEh9i?y#5{UDEPJg$B?BtPfd1T>V4Y=n^``o1xa)fdDf#v!Ge%j^zik%)ylcukpKG@@5AW^Qw)^-p*|yk zJsB6IJlM-P-Q-ODrbn!3rf+=AB#IP67wLoQJ2hw1)9`7b&Tg#vx^eYmVzaUoc> ziLLnI`0R3?_?S}J1*YHUwlM_eyVefF9?S}`5vf+*ZeyKCDx<>`u4gP&y9T5{=)9bn zjqUkO{vNj|^}sWur=RF6#nz`XrZU}|Y1n5DXtaVyC?8sMfND2;>DTwnG{J#{#1;VW6KN%xl}8CJb7 zS?ffG-cMpqZW!Frn1}j$>dju!xZ?6GtP=^1&%677q_do)y#V#{Y-VnP*28fPdYmCs z8A7*V)F0@bjla#a)2bUU?8J&GoG=DZ{jbsoT!zp|&w?TyMq7LRfBZeCEPn4r(*~!k zlA0fC4!(RG)zpJ76oFVAMhD#4g@FUb!nf4vcEUc7^kVzttuQq8n)s9TyNB&Mn*v1n z1-l}gZ9D^HNG9=G&NKyYy=qD}rjjtrmd`oAvk|lIGaD)Z%Wf49<^ilI=$qplYEgk^ zRpg?OiyUl6@Rr0mV=Jp(O=slURtPi(P@;81N<`(E@!?MZ2~nOFe?|KAigFcY89a2d zg$rlOi2@q#uwjZ)SHLcx;@5C+ec}b@t$rK${+ZzQJPxG*Lj%;qW#Q9<&SYL?<~{u~ z>;~l$b_vB_yT{%21xgz*yq}Q7hP8mQvokAlWHS~t8Kj=K3o(#dhsL8L;rkrzaW%^8tV0GB8%~+oAa~(b& zpD=Z(f){u%dY>uuMS?!11_ZHqpOLQlBCjj(Rs8Vhd|49m5}gtN5{&!4L8cX3w>T5P zz~r~W{4w76mb3ELxJA9#@^l1(*>ib!(*Yv@t2Kc+#CXdF?m3mAjnD>$V=Rpca}2^J zz9b0@2)g5wDi)H|5epsSqrvy#+S|=TsFNMOa>D@w*T^>_DzNebN&9FK6}P>AB;V`> zE@qaDWoYymUS{MNvKR#rco@UtcVUOXu=X=f>tSKf>em8v#nd0$-P`5vwgl|jxIyN1OGk0s2e3JpD=4^_s) z5EI;2@jESxlBuwHV^V|x(xZa_FDhU8^BKDoise%H( zRF%3m4rA?btRvm&_-f(tG&U}sNwm{9tHbveY(s5U?GxSJplc>(BD)19dCUI63H?hl zw}+?8)%!C=&8^r?RuqYumD|2$>i^cySLyYp>-~xPG8F;_(eE8wz=VyIKGOiue4Rcf zFw|ZeLtVD44){~CmBuMs?8>>H_t~)OIil?S>t=0LlMy};>hM;rM2Z1qNa}yR8Dkkw zwW6+Hn7D|k!%@VS%puN(rD8fOXnDBw;`sXZz&zf`k2_tXAPcyml5J>ADZWD8Tfw;!XDfirTAgP+mgoue8}OBLa5!a-GXyHAOooiEYsfGPv(%`A0IP zUDftnR}=DcLdo|K^eSq#8wCUXW!qmGGaPMystm=S-Uy}tdIofl7SsDO?QH2*ewFgI z+7%3xQ!j|vCqt9i4kZ@Uo6Cb;r%pC8+us{;-*kkYO^D+;B)t4V01G{jD91V>>4~|T zAwv3v+08$Ysrj3kdz(5UBa-{sA3T@g%xlx_C4tP1S{8Ir+S|7t0|Wz48Y$X~dsDT@ zIH-sa;%}wwS0CDBVLDfO^eKBskE>~}4*Diy!-}PI z3jv)Khoc8AAshP^7uG_E{OyGWcN_N3v?!%-Cp z7EME&<^C@ohVKmL)G#Yq>I3!uVR}@?a8`Jr2F@-6cyTmEHxT_Ehgcb}(UrsI_%1-Heep@IuP?l_35A(dwP{Db z*d^w5#TnYu^~FPRvJ*H4HK;D~vIc~*{1o9t9wgqKoNnMTczO1@g#3eufY#@P-k_GK z3FsX)1mHWLjmgWYXZUM#40V1L3H9Q7;4AOXS9cFmBh+TQv1>aEaDKh{b=z5 z{&47Q&!eoVdf%686Nurxr?h@mQ_%II@SM9lp=VE7$4Rg)76pru#Fn@Y%+=U?VN>n= z?7F`BkehQ`bw->!{euUj5?Ftha*7OI+{B*r%+;M8plzEz0@Wl`^~cERFyf4C1Y47f z%(=z`yDpzzYR5ZQYixQv-dCOS67e*x_5yHoA-VDvGQvwF29R= z4b~g3QAaDfF!(3n9vn9i=zjL9K-~adIFBts>HAD3vbbGj)~g8-pkH{Fw;K;3IB_hK zBegusZ$p?(^FneYbPj3NBK(C)K-(I&R1HeW@PrxjcjeE6s=P98N2fA?e|D zs!h(}4h(UzsZgzyP7Lv*s!YNs4K=HM$$q8DJrY8)fN5i@PTX!E2*)5~fa6Q%B*(v{ z^hWS2FuMZz4N z%KD^L&z_WAJMyZmNZJ4^JhNdNcIVY`v(bfOJ*&^$;hmRz0lic8f%nK&SUG~pZeiBb z{Qghr12=;zOu0$w#iVlF*5Zxn4LU3`Kq%_{UQDOEfb|Z_7YBF$SZ3)j(9nphlF{>*I5m^H?`$9 z*bURORj+m_DfE<`P}CMvBYji^Of)q~J9a1l{+(0W4pF5stqxiv^H~i?jGZu2AqHUP zE?(Jh-@NEcO9^*L{kvdqjF0u6-WvwPCOOFTJBkT;YTd-_yU$VkqZCy9)Kiz%t3PAG z_#%*kAzO!2<}UX2(-VY7l10;t8)1KplH(7YX6QZa4qxUxqT4)HXTK2~V}p!4`ee2= z+vL~<=Zy7z{H5*PMwHVJS8 zGb6a(`7amCpP#S?l~(on-$ZsfsDAHOi_`$+FV(u@ zQYLtHv|nKb=}hlZ0v8(e?hG)zy@!>NAibJbRIYAvqt-gU^ynJAhO|C;W&~hfuT9z# zUVP}G73ZPN%i-0sk^(wg;LqGK$ifzIn43#PtFo_g^8ZWCY@7;SN=Y{{Ju$z&?IPA7XG^*(0Z{VxD6N8InI&$yNb?y!-}Bytf*%P!-pk}8aMuGDxoE}E2i_3H#gf?f!nmC1#(DDwbpM) zM8pZxZJq|7FE|oV9;^8P&G367R0n%cKZn@js3ESA z;+`eHx!9;ynoM|=cF=`O&L>$w<~I4E2rTgZy+XJjXsL}H4bTZtkBh-j|GL8i?wfym z-gk;8LoyZN&nB2_aTHmE`0{|XG!d>{rI%aot~vvnPu5P`<|?6(v{n$Jg{>ol)ge38 zVr%P`5DDt4m){Io4W}IAFSp#BP8L;F{(iRs*ZCY9{=acyg_g~3G z?lUC{YG-fT$oSh=kANJ0DafE@9B+xiV{ZuhmFK7h^``dKTLv<3wq3yIW47#Y1eDUrl@u?Zxy`FHtp51-az+G*>YkXStI zr&)-->l&2Q5xjX-#)v+RQZ8!b^aN*@Caf1hsf5w*)CJ6>B+jU+4D{(si4+6kA;@*Q zx3lCAC_`>W{H33QdSJ9gbppRj6Gh&R7ofwl8UIas)&_DeEu7&;%`Am#Q!gu~_r!_X z%rf`IpU`V=c^N=+Hwckd-%Tf|R^G)@S&oFYLGXfiKXkjyx&B^Nj+kqy66vP;c_lwi ze@=Q4w%#P;;(UJ08TN{TvQujKuyR;^p-Dq#8l_NjTYR3ja^4+{Jo7MnI#-5m z8`1N4Jtc}!+7!=o`Djb4p?0fop=WJ+5EOY6k=0Ph8>i-rtel(V?=zP}(rYw<_hnIz z&^l-AJUP!pQNO>JWnxr}yX<2JWEgA=nu_~{Smxbt z=vl()im@oCN7>b!H$o z+VJQ^@J4QmxQO?6KbhPd1V9t8w36Zqf;*${pqk4!2_L~nQl(P1ylaQe5e`rM!i045 z&`jv&xLXTIYuTp4&D9yxi7 zP??hrbI0{J7~%J&e{_X#tOJfVkJ~(YchgPSgP!x~*5{Iz{7YGD9uC7y9w!gCo0E6k zti<#IPPLz8$)eh5+(4*JmwT+}dQZTx5WA1g)b7jjf2ppeiNYia`Vm|b^vyOj3DgZ& z89c*35^S2-uv!XZnCs>BX3u9UYU?< zio)p}l9zErWwKELs};-PKUeO<{oH^{qJH``H`7oDWe}fEcUDpjhcDl(o-n}s4@=b8 z1xxJx^Oe%c%DTak+x94no%hNFUPYOw#?mxo=6W3ei%|7vLyEm$Zjq3h1A%bH!vyC4 z3V*4|q z#(u*@5E=J!Jh>?`FbX2*;}~&n{AxDY#sGzImCTH(7!bw}K878Os)KB~c?2p6p-m z7$Zxn+1wgLU3_5Q5~p@qh7B)^;P;h@Ec;lKt^zAm0K1a zq-Cp5Y6bLfOv+GUCxAC+cx}Z07@4geF5a)iy~DH}``9E52+g;II8$dVj~r?3<@ih3 z_3C@I_Qsl@!}tvLg(u4;V`V^7;M_eZCxN`MIfIUh=z?I@V7!JaS9NPY^%Skl|(OSKnH1cd~w;PCD=!fk(p$r6!&# z*S*;JPduHfPTi;Br}w<5OSEc_Rt4R{u6@apW-*H=ux@JZ z(>zAcrrvDz!hX7j;ajZiF(88}0@jpYmt7zH0mnDW0`qxOBXUZb53w{kFncH{Yy!vu zk1ohU&-uj75UOLTi)hmh_iMwlmnn46zQbM41H9Bof;xJCvUH9QSUGljY#PoBo)U-G zLUqO4DUU=KnV$QF(8Ea`!s{{WOg^az|M(vl@GECZjV+e$xwrxwyqS=4axYaa&-nSo z2X#y!fYvR&#t|V?B&NMZ2}FVOpa}EBGMp`?nD`FCA6r4;FCCIK4(S<3x4xrQ@U!)Q z{}Ff!fODraJ?t~dR@BUX3jA#e^VM=m28F8% zbtu)+6EyRr^Do0|qm?W=4yD0qd&9_i=EOiBBU(2W*aul-x}OVw1TEEYE6|@XO1sSO z{C76X&@$skJI^76Cl>5xr3!nV}ziZ0?Sa5&lp5?sY zGI~$JgCC0M3yq$d*h~3yjIQpxV5rljZ^z_fT#Ovv;(k^m9u%y1P9yf_!*{2=!H3%v zzfu@kk=7IoHRjddPPI$;3LV4!P3kOZ1uS?aT=rTvl(r-P4w;017iIs=rLi_tg#kWK zBd_96#gY@j?kPAzx4COuXb7P&in^QY%<>>&UhP%K0Gr6c*A2WyW>VE7s=}j5?DkwQ zdr~@%?Fl*rGh)_wIqFvfem=mfvOhEY-aVv=4Ql zGci|bw+nG5HojqXD9JEJh70Vtjr9v>)dXtPpE)q4gORUyv0~F0_`3QIo+pp4>mG_z zbs`ti%Jj{SXvvZMBL{})sQjdD)pZa5M)8Pkn_evIT!%XPG1Dw?0LBuhc?13tbs9y- zU$mU3POvzWk$W)622u)wS~YmnfzLvJsrJC7NVXKCFB&3V_!s6vPB2u>vJ|$!w(6kG zJ^grSRiW+Zz0SFmuPSw~murQDpzqJuuk9l5f#p>#aaT5Pw-@b?PUH$!OKz1|r##J} z11g~~b(`akYjBw1Y?_tm*6zq9MU=!UG{7iY^NEI>7|Dwd6qFCTe+>#zHI^?msJ&eh zGEWVWsg{8AHhjI*f{`b=KS~I-ru<6nKD*0;FT@O-7WS}W8BEe`FM&cs)gcb_qgp_3 zqqT0W$WZoank>{%((}c&z52)bjymbJ+gpXL1Z}CvEP_ol==oG^L^Qt4D)E( z3-?>pFhCXwgX6D>-m-56_S(%*iC*%GLH!tE!;+q)=M}Q0nx;nWqK3!J?i)6|%M&>i`v=rs#`(GyOtFB<+}0WS$e5BuxaxMK0u{ znWe6JZ8=>Ui`i5Nw%m>gFzH*6dGNj{LtV}Hw7d=9fB<$~3M5`eA1SSLmoYN5S)mk2 zD18Ayty4nZnN8@#&q$ak17aNfAQctL!Tt;hel1qAizZ;ycNEQc;SSaO(Dy3?YnxfL zRA>W^=hr!MKcR*PAc>eAq1TloS7(C(*18M69RfA>M*Yh;;BOKgrGiQ2?tv^fVP0r4 z4js(Huf%Z&VU6~?JFq|l!5p8v#?P#ag*G)PUqoo5rXx)*R?K6RCub z3rQF`;_8KsJUH}Go15Ru4mb9@ej`}K6-Dh0p4=Do>UR~pgpN=orhTM3t}0xl++b5W z7LqJH;Pc+)_c40frrlUj0iLb+#aNZLoUCMDEW5s5&V~J!q%+?>=iFRp-q7V{UdloG zt;TVbBZ=g=I^v#vd z9V(}qqxA56Wy*O5Bf4##2h&ubQu7g9-Opef< zTD!;wlv%+yd)!@mehY_ev+3}&DBfOz)0*B;qYT}#yZF4?lAJdaftK?yOEhHklPwFt zQS>F+Tm|dQf-lW&0DtqHJN}KUK^;s zO!?#py&~btIZ^S2Qree?md_x$jN89?)zo9r83o3R(GX3t6{4i4-e{Jk)LUkklj<^5 zz&=z8pn(e}tYfj)W=R$qyQeC^a0WaGue~99pPKp*`p2I&fn0k~5}|hb<;~o}d_gtS zdT+o1t(f43Ph)H)q+={AB(EdcfihznIhD9INWD1ky~!V_A1mPAsS?U3ysCx;!F%Z8 zwhMELFdi*ZZDO{rm=s9J7&2#yLp6(qbZtAPPg!aBG>Mz}nZ90cJ0cfae`eT*QYN|X zQc}S4jk$WhOH)-DiZq)@ayk+k`T7R`nKH^RD|;*vQ;t^7A-_i#qzSZGA6IGU?Z`8p zrq#!uYna-)LOZ{+^Z7+yInuu*xAhv>M=M7O$tkOu4OJ!I+B*OrC!k&%_!qO6r8_gs zW$~UHTZ-4_1Lp0LAAK_U*GshKUa6e5=ouM(BTf`@TrmC8v!ZByxl8c3tRI(Yn9in1 z`QWl51)d~*U3AIv^d6#Y)*aK`!ZYjQV{@W29hf_TPhV%TLLO`V<_pD)KdLq9yW3Zs z$dxpkn(_M6h(qAXcIq9APLoD|do+Uq&=DQ$WeGQ`^*ADospyk<2o_WDAvZvA7^RZa zB1?=ByWRKi=0Ek^EqhP{q14tZ^TG8buxzI>T|^ltm_ByDL`5#BP;@X6uA8?Qi)pj= zL)9qHy4jYHTI!)qH4O}HBv9W=WXP1LX>d!2z@k6~9W`dQt2>qnZtN9C(?+Ro8=abz zEZ0a%7$u+YBo-?6k97>GSgq{wyUp7#O7o2oeus(WG^aUs*5VB)6=fV$&ZXPCGP%Yh z2O$l=01@L3@!wauOrmD`6QW7M;xGA19IimhHp6BvJ9!*F^=Gy=KgBwRzi)M|y<_m_ zaFSoC6;{EYod|quHU69o_b&-I zteg}m$S!_^`Y&YZG}(@4o$tNGIb4u@O|kUtFeulR_vr-BdUZI1{k{}T z1|e4nP@j@a-Ae2{SvCet8*#-7RD+ys;avFX60Q*ri^rGGH`K>o(l=)y+ zv(ulAM75*7vIc z%$Bug=^kM$@t+)mv!YcTygu>fV)aA6js>ex5sL5mFpq* z?7O9IHV3N#?Pbcy(1{_EE5tSO?o0BTVmU4Ufq&IQI*QhqKD~kxRhfXpa9bXOt6$GW z)I?R;!g0|sw*=!QrGXiOs!DM9n32>O<0W$StJVv(h`73p_}kl*2jNHzMc|fN22+f9 z=TdNHE}-Kk`EfMxbGRb5Z{Bbf0o4nptQa`vS%MDxzy<#-Qc6YV%YoKEF~d&bWJ0Hj zu8~%Wik{V`8oQbSgELrGFt7mORQ0ze1d z^)|_n50;2vu5aEzV;*~F9h|Q}_831}sQBrK^^z#Y!UdF5P2zDnI_VEh zF#5&UzZWhDg}iVs<%TR2ET2q+7+wR?+m}dZ;KtkjwMLOm$)#u-eJtahIlv@N7ziM( zKBGmr(!Rak@Dt&_XnvGE1?&)=}pr)^5#iZyVYmW7oi zqLf**M8cU^@nyWJ1oJ^3fmCEsmvc`7r?xnp?ar+ZQw zXoK&6oA&j~f0@2tWd_7734mLmh2VZfFib0bgTG1+pvk+^F&TX?R5)FO+qV69YtyZ- z%>s8kI*if`Uzh03o$p}Qo-pk+~3)vHM^yZjvs;hs?N#Mtf=A5#F z1d0q|$XZIq_pfO3o>}b#RZtIz44AIX-OFKWhvUKYDfzX0I}q&gUV|7&QIkUeyK;UrAf?Q zSOmu6_r)l!6JgGs-aqp1e!L{tzEV24!Vsv|uE?-;7;c+Qzxa%`{0y7EO{bb{KPfTn z*@amx+z9Sp8-B)=BpuayjeC_*vK7nPNLUk#EN zuf7wr3O)CCQVSaJpKyZjZG;$C9L$pWcY$D4^U}}A6@1Ib++O|tBYBDY;VJ?dgk6vl z&bm*7FHH#6kim~SbyR9!f0}2sZctT^>h7b=T%R1}Z90YtG+m_}P^MI6MO5j=`_)e# zZ`d0e+vzOdx^)&|`3B#z_6$<*Z+*;C`r(ECq1~nvlf=OIhecu4y}~)c69CfbrGeMN z%Z<|gOQ41S@d`7h^u*4rV8@;HA8O^(QN$<*1An?+y$#PpQkxbk$0K0u`@?Vrbn54W z2)|u2Zs7zCg3rGq<(t6bXm2ZRAU};AZUYbX!8Z;eZ>;}u0bs)9hBuB_VDXzVz5S*r zm5|Bg)_ka{Pnzr(b~^0~8l$%b!}aJnf*QFSuj?1o8yKnWzkdw~;@TwMBc6{5o#IZg zCS9D99l;Wnm74}aYsCs;4*8gE>QV(-QyWx0angSOfmy0OcLV9JY|-e#Z?g{ZNa~0l z7p;7VzH29Gb|6FAFXjx)Sfrn)X!E?b6_TH{J_Qu$v!(D@cSJG3G!b&sQ+x@|P}CH_ z2@7IyOedtv-4SE%e zWh)+nZs)Sl&c&)UxsOmP6Fp+mhr{oA+1(TJas^Jy@-Q=_yi26Uf@JzUI4B8RaLU#M+&T+*=9+UDzo65$nhyByp$_+vmau7oQ{c05;IvKl%FQDLP0rxU@2 z<+GU6{(Gw$=3q)VWk88@uY00JlaJU}BBLY0?Aqx@4cWByxEJC{dc`+8bf zmFP_WB>3l+%1xtWo``*Q3YJ?>Zzo+(7{v?0&N;)S5GGsmZOMSdHtyYA3d)GSZtFmsZ?w2ix}*Jn&@?xd8y9 zm~XNMA&KH+9-hOnAqnMy6=sQ!=0U@K_IZ1FFMjOQ$r-0>#(tww_y4B+3F4GvOojwdh1C`HZf~^}2fgT+a@7qtS%(xKQW;y;|`2jmKZz>K0p{qJv^YymwQBJj|*! zZI5vLrS+^7@@cW;o_rx-ch)nAyacK{vJbr;88hsTHQWhCLn${1mvf?iWx4^WT>gH8 zt%xlt`y@&brR~09Pp8K`wD{R+U&@VrX53m9tC1=PTUF`G)?7>-9ikP2*G2<~rj3a% zruFZQP1Ni^oT;G3x@OSzAl&{q ztafV}&I|3b+4XeS=Bp zmIOAlnA5u=6_Hj0$dDJ> z`budr!Hv?*i9X1@@`Ra|`t$pPSI7f7ow9KTA>E;JXx31Q99Y*bqZ=*n)E6m>CB&@E z-7Ordy77|frB#D$DhjD7X))YON&EHfi>xAzKoG{fy)Pe=Zw0CZst`jmg%>?5Q6vme zl`e<@s4-GB_u>JqVF&w0dQQA`q%oSx$+x2{leKOjg*yhy<$(vc_0C{wP;EfB>8u%U z0ad-0A>=<*^f+!(fQr?p@{s$8?RUPpug102Ko8>$6(sQ$Xr?9I6r}cRfw=Y z!(e`Eb;Aogf>Y66sJD@ry2JDQoB$cR9i>BCS(501^Q1dW@)F49wr)kNi%V#Nm%MCmv>?T;{4#M2=g>AXnyWMCWFa2rcdo zWCbNIp%E^V3l*9q80Y}>Y7lWVeVOs>hEtjT^?=XqWC zd*8q3dtd&owT^w;Hyx4R?$WyoXvwp~Ge^x~)r#@Ago$kU$5Yl0{ zt5)!43h~ALWy7)PI&_ZM_g4;^iyfi3V?2mP^S3PFZBiav>2+XuPiG*i(&0|2F#5!n zg#G&e&;0B`r7oXsKNphQtUq!;jOBs4X@3sFR&)(14gQ;$So7qZZo81N&PV>mWh9PO zMePA{g;Y2^dys6WH`S;;L;`-K?GxuCsRSlCk>l*LtUVFu+Xdce*D9R(R6wCP*pQa zBvvDcnbxOf--P$E-nr_gZV1Z4Ycfdt8^#CjZG3fTY5AAztjeBhh|zO{)nM`B?_kxj z09!coRsh;TovAs#tXbq^6C1qVbNZV+kDc#&HH?wukmNJkUFVtTN=-uPhd@qeb7Ml7 zDH@*nA!6~)S4lQ$M1E0(OJ**{{Nv>IXS9k@?%G}&Kww4b>u{llCs4}MH%Lu{jt~9d_Xu=T)q4vn?nh{c}*cOv-i*hqpAs-xhIKv|L%oYcCm8I@* z<*q_@KCdYC9Ib-MlFq!tIQsL1H1BXYogp->LfLjj%5Amtx^|fee7uLhiwZ0rvdU*w zh%xoJrk{EKzoS`%%k)9=z0Zg;)r~-6fv=KNo4@rtX3 zv<(RUf87qI&{u&=Lu%7D0ff9Xfaywg0ahtYR+dtZB3(+7GkLPPnjo9+9WRBm2-}+a zjs1kA%!!Ivk#fw&-kR-WVd|=}rkEvXEvRDCgvL$p6mVqi5*eyFndI+FkU;K51-2xm zLPHJq*Ugelacg7&bM5(@v_LP=I0~CA-mc`K6l43ozI{0bInU+x2O?FKrgTV5uE)gR z`=@D*SFCDo?gq~Yj1M5>D-ZmyrtE5LGD*cN}d$U<5%b;t9um_Q^9wJ_}O@T(P+L`%j?NY^3b zIpUH-2?N_F4$uW5xcB{EGKVhDI8bK!jLM)?chEuD^ZePT9eW&C1Df&6$LivyS#}Yvwf~&jaa&A<; zMH{xq$=6JRe#Kotk2_`7iMV)fDb}O{-1JUKz5!`Kh8;Y1ga}$43-xAWdy5i=510F$ zFbHv66FwHUk7@}&hWeg0Yi2;;=b1c@s_#6S>GGM!_wHKlUK9pgyT4A3WyJrZa<4o8 z=XgASd-Uw%Xo9CubZM8ACxT0_#K)OJ%<h4a7&4cOo>_|6$! zV{D(6y`_adk?I@? z&tY{j2F8<&dX5;u&BJ@9%z+lWaH306_9H0TM3aY1j^(3{MjFhRm9$pdK<$zTdksyF&gx5kOEnHg zW2Oij%$T!dRI$7-tq>+OQJ?upasLpX6}{id->n0HXHweMW`GIS0x^RwvNE&Ti9^mc4;^D2^PL*v{?oxKbkVBW7&AUHx4(=|_ z_C=-MC7~ZLzH0exub`gKIIU~Uq7k*Eex7(`A6_7^U-iVO=feYl@#2b6z=oz(1bQg~ zOiz0pINL`rOt&Vw{@TOuYGRh1V7dP69kX3_Q*|&-dE~+_en>6QOBL`w(=GXy9ZOD2 znl8UsJSQR7wHJk!ECqmc4n^A_V6C#pDP)M~7J@W(MwSA;Nt$B6WI^X(DIqI!J-XE4 zBe=G#KMKiHjbe#^d85}(uKqnIgOl+m{>ufZ>*RkZ)q}?hxeO$N8BeHe;OBLPK+UPjk>j0J>605Kg{(t+S5jaa^^qxe5-rh1zHm0jsg; zaU?ffi?pMh7hYa9%Pynbx8X`}-t)qwTIA?8h+0ScEZS z3XL!+Yi@0#lSP#v!B8mxGcLh-p!)h@3_SosC07q9sSJdLH8_dDmxGBBjcJJR-r;k7 zf$xc4jv=o;dr6rz2iUKrbhhy=Q&SCzV}xID@C}-F@658UKTBkSkt$$rNun0LuTXlt z1WmnDJlmj8+n7`6q+2v@&GvxZyCp&Kxy^jP`fc;VIhORNI-%jUbiVcrd1{C6g7_P0 zdtj>6!`u_$gMvY~TdY~u)ae{f^K_@yG7DrUc8G&%zp#mR*8T4W0Cb6#Kmz+pV5Y{} z$u=?EMUrCKnrG&+tkEc~TIkHbApLlPr`6_%XoJh8{_x?^<&kOey-&$I16E6osah~d zm82^B5}KqVpivtv&&K-ACM)I9W(gS+cOVgU=S7B0@AqLXP$vTa7{Hrne_vhE-_z{0 z>=g&m%EfW6la5-Oro_2l@O5vAQ>&h93#)h5tx|eOashpBsj*&8AGqGCta}S(^7x1% zi;buPyb$T0EsoN!g!^)=x3G)Mu>_#NOtGhJ=x&L>n2e`gLm(=&;c?tCLx*;NAQZP& z1QlDHQy4v(+5D>7WYqSTB~xi?%pkX%i2(gmGw{chx_gMF>YNM8mThUMy+)npz=5|5 zk^DKz(qjtvj9Q@kI(6?vPNE$O5Wq~PWoxx@zYPNu2S8unG^cpmC>m;LSv+AA3S`$&VmEQ}b_z>tjsW0C>S{Fuh=732C===ecbO@pZgp;>@{Go7f+J z*}LE7*}U?t15KvE)x`Tmc@tKb<~E;43E+BIiu@Kh19>W_2M~QqtC4(LQCOYz-fPn9Xm@r+E0_L66$JD^d{*+PF6W`bR9t+nP`BFha>r zJKG~CD(_5~GW3-B`;X;ZT#xwyc4nF7YERH??&x1-=sZNYfTbG19>ENqe>+Y&)-hI= z=)CapQ&c(-UCHkd3&vuPzZGcBB~{X;ADXyu%S}A_4>$mfFOK@WkuDG-$^ir>NNXdW z1XF7uFT3^c^ zMbdEdb{IU})ifPdUE}Z?U*urBj%6~G6qS5OedF_o+Zn^_Brmc5d2`OIuEDfckQ-k{w=nAt}lkg zcq!kiTeBSjT6=KSj_tYO=!BA3kZ9DIbrT-hBVj|8a!i{)^Hx z!p-j=WQ+2>8!P3M1BHnC$CM|TfEw%_D9niN;vX>y@A=+ZC@lc&LBmzF$-e3EvM zF5cXPor%mY>Cz&k^1VpUBA7^`h|CNVFo@O2cGt0*qe3L&ewAsDJ0$l$?>%h~EeCh* zPXRg8vD5gM0_r#OVYH3{8l-B?CFLPEBnTl9Su4GQb@pRW?_GqjH>`Vdwb`^KqQdp} z!L+sOs~gw$W9kQRrRHcN%P-7A#}HuPlde5Q0I#zY;Mi3rneC#h zq;{$x1H9lLA5Zh#mld;}Ipje%f(`hj1BIgD#y33EHv&AjTGOWtRsf>ZvewNjVWXs2 z3_JiGG}E-RL%M_jq7N9M#?%b7dLaGa2`w3tfNa0d_DU(E!7ZClPd>Ds_`q_*zdiSf00x=#t2J=BF!ZisrzA(#bJu3| zjyb#2i0k)dTnr{W*yPHTw!ZHxf0!Sr@P^ZIN9#^m4m+11k}J{S0#60-Vl#C5u{|uO zjxWvwssZCS2wBinp?YopTo)cdnttUFAfrj%nha!D;`JY3+SOa*u)Y{^PIe`n`}z8* zyo=dJdo!*~eR!+TYsRv2Br8`sN%}e)WN`jlg8IR2XMI_AT|-7R)7HPYR^caXi~Xh zCJ&5zRM=IR%Cmrm0(7gS*$w-BHfgR9DPbjN>hiX=cr5-Pb)Xlh>&GG8Q;w2x@NV9Q zpFADnQzUrW3^9_-CNwbg}g;{|I_;D zHCQL@?9Z`>XUXk-Zb*DDzk1||p>?lWjZlaIRvGmc|1%@syG3(Q7<#U#w%=uT0(o`n z2A?eOexb6Q0Wj6e0O5Zr`vBU1EVx%3i3dl(##*(uKseruvJ8cI;DJh~Asvx#30QeH z@p7IY#B0 z_8hgnqFi?Qv3^i!&|u24>NaHiFIkDb&&kyTwK4lt0FlvNu4xBxS<8WBKaw~Wj>vSz z=v5pADNTY0P2 zL+`Md@+gZD9S1zVwT&I(f>0NRp1@?{-uMYU&j+$|@^J?A#Dc%DP;Q>==OShbUMP-g zPP7SqY$bqVyr7dlu(!t=dAxxJ=p>$QlaVP3u~@(-FDunNti#F17xbMTVI*Qr3@R^T6ozp5&$Yp;hh#Rb=4FgD|C ztQ|5~Z}u`P&b{#vfNO~s0!%M%&!vqsJi}~{E>osL{mqW#Sw@43k?0g5(F03)3UvO& zCY|U|VT&VcnKy=%*55wg0#~`zZ&$kDw9_8M&kNcz9=SimIZtaDdvqllAu0o}yDkdw zx+f6APT|P6js3CeXxC01h#UmPPkEr}mD4r&^=bf!BD^zIWl64%_C%eM z^0=;dq$|IvjmwdBKOzgCbiIJj{3EjPAI-QpUvQA{RuVW4vm-sggqqoO-xMsA9B67M zb-VstAs@rMclub6*6pZ=i0|y8nb7nfPDjSEK}*WF?UZFs)MY91a=vx*CwQ~NnzOS2 z4l}1>vuJ1@8OM8f5{T>M>_cUQLI=bx$-q6+AOnPQCKVS@$aXUxVsT0(#yG?E`}JP@Os`>bT{ zh;e+u;HJ_qIUcL7ZQeYC%hZY@&kr>OH|rNggKb&ZF6Ixmm&UPMu>^n5lRFCL3`*dg zP&6G$D5g_jNMk_=puE?ykti%sNQPPtZjWh0qZ)h=F9ZhT|AZI1*OD~1(wutHW+!aA z|J9T!HU#pm>Q@iIq)uQGfmZ^5>DSsw>YzfJh44c|<0(QLHjtu%srP0ow~pC|>u|Gs zWp@#f5@=6{Xvp-olb-@Mjw~p)j(uu_OZ13!6G=z4@Tho6SZEY(3_Z^mCbQ7h{cJb; z&zGM|Nrz>69&?<7fmZ^!e+m=~1dnQ|u9L2M;>s7&F#Hhy87}>S3rrc&s?ky@`I|w5 z)RO1};t}u1|D?q)dBz#YHk%sIRv_n1cg1}E!Ip%Y?)O9Eim$u2pFIN`y@(sioP9P+ zk>6assnR9?n_IacBPk340#ynd@((t6;QheFVkbirejEEW9!T-?KYPY(9l`b>Lk>RA zd&kK>0;`#oDnGnK{4MpKVt)pc@wVTy?@dKioQ�dWq3^##k|S1kvqls6tP|DAzC# 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 literal 0 HcmV?d00001 diff --git a/.compare-output/react.html b/.compare-output/react.html new file mode 100644 index 0000000..51526e9 --- /dev/null +++ b/.compare-output/react.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/.compare-output/react.png b/.compare-output/react.png new file mode 100644 index 0000000000000000000000000000000000000000..7dcda006080c5d15e689696830bd7ecda6f07618 GIT binary patch literal 8393 zcmeAS@N?(olHy`uVBq!ia0y~yU~^z#VA;UI1QgLp7CgYfAi&`1;uumf=gkd6Mg{|p z0|tB{3Q7N-o^|VYar^d;7pRUQu5RL#*OE`5v)Fl=% z5S7Wq4I~>(8d-sq!>mzlqd_v7N=7pdu0r7eL+WUOFRx6`b=4dNov|%;cXdG=v zgL)C8T?=3!jP|yGfiT*W1my>scCN4Y3%rMo1pw_D&3nK=7|naYKp4$?z(AmF-UFIH zn%{tdFq+?hfiRlifPpZY-#~#dS||VmVYE;H2Eu5e0Ll+EZ2$w!AI)#TKp4$$z(5$P z`R&8ajmeO4p00i_ I>zopr0IPxJ82|tP literal 0 HcmV?d00001 diff --git a/.compare-output/vue.html b/.compare-output/vue.html new file mode 100644 index 0000000..e15a91f --- /dev/null +++ b/.compare-output/vue.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/.compare-output/vue.png b/.compare-output/vue.png new file mode 100644 index 0000000000000000000000000000000000000000..7dcda006080c5d15e689696830bd7ecda6f07618 GIT binary patch literal 8393 zcmeAS@N?(olHy`uVBq!ia0y~yU~^z#VA;UI1QgLp7CgYfAi&`1;uumf=gkd6Mg{|p z0|tB{3Q7N-o^|VYar^d;7pRUQu5RL#*OE`5v)Fl=% z5S7Wq4I~>(8d-sq!>mzlqd_v7N=7pdu0r7eL+WUOFRx6`b=4dNov|%;cXdG=v zgL)C8T?=3!jP|yGfiT*W1my>scCN4Y3%rMo1pw_D&3nK=7|naYKp4$?z(AmF-UFIH zn%{tdFq+?hfiRlifPpZY-#~#dS||VmVYE;H2Eu5e0Ll+EZ2$w!AI)#TKp4$$z(5$P z`R&8ajmeO4p00i_ I>zopr0IPxJ82|tP literal 0 HcmV?d00001 diff --git a/examples/test-renderer.html b/examples/test-renderer.html new file mode 100644 index 0000000..e3ba8e8 --- /dev/null +++ b/examples/test-renderer.html @@ -0,0 +1,187 @@ + + + + + VoxCSS Renderer Test + + + + + +
+ + + diff --git a/package.json b/package.json index b52ff4c..e56afc1 100644 --- a/package.json +++ b/package.json @@ -24,5 +24,8 @@ "onlyBuiltDependencies": [ "esbuild" ] + }, + "devDependencies": { + "playwright": "^1.58.2" } } \ No newline at end of file diff --git a/packages/voxcss/package.json b/packages/voxcss/package.json index e0c016f..fe6ad7a 100644 --- a/packages/voxcss/package.json +++ b/packages/voxcss/package.json @@ -68,7 +68,8 @@ "@layoutit/voxcss-html": "workspace:^" }, "optionalDependencies": { - "@layoutit/voxcss-react": "workspace:^" + "@layoutit/voxcss-react": "workspace:^", + "@layoutit/voxcss-vue": "workspace:^" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/voxcss/tsup.config.ts b/packages/voxcss/tsup.config.ts index c6ed239..30879e8 100644 --- a/packages/voxcss/tsup.config.ts +++ b/packages/voxcss/tsup.config.ts @@ -27,6 +27,6 @@ export default defineConfig({ target: "es2020", tsconfig: "tsconfig.json", external: ["vue", "react", "react-dom", "svelte"], - noExternal: ["@layoutit/voxcss-core", "@layoutit/voxcss-html", "@layoutit/voxcss-react"], + noExternal: ["@layoutit/voxcss-core", "@layoutit/voxcss-html", "@layoutit/voxcss-react", "@layoutit/voxcss-vue"], esbuildPlugins: [externalSveltePlugin] }); diff --git a/packages/voxcss/vue/VoxCamera.ts b/packages/voxcss/vue/VoxCamera.ts deleted file mode 100644 index 248d5c7..0000000 --- a/packages/voxcss/vue/VoxCamera.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { defineComponent, h, computed, onBeforeUnmount, ref, watch, provide } from "vue"; -import type { AutoRotateOption } from "@layoutit/voxcss-core"; -import { - CAMERA_HOST_CLASS, - ensureCameraController, - type CameraSlotProps -} from "@layoutit/voxcss-html"; -import { mountCameraBinding } from "@layoutit/voxcss-html"; -import { controllerKey } from "./context"; - -const cameraPropOptions = { - zoom: { type: Number }, - pan: { type: Number }, - tilt: { type: Number }, - rotX: { type: Number }, - rotY: { type: Number }, - invert: { type: [Boolean, Number] as import("vue").PropType }, - perspective: { type: [Number, Boolean] as import("vue").PropType }, - interactive: { type: Boolean }, - animate: { type: [Boolean, Number, Object] as import("vue").PropType } -} as const; - -export default defineComponent({ - name: "VoxCamera", - props: cameraPropOptions, - setup(props, { slots, expose }) { - const slotProps = ref(null); - const cursor = ref("default"); - const controllerRef = ref(null); - provide(controllerKey, controllerRef); - const elementRef = ref(null); - let teardown: ReturnType | null = null; - - const mountBinding = () => { - teardown?.destroy(); - teardown = null; - const element = elementRef.value; - if (!element) return; - teardown = mountCameraBinding( - element, - props, - (snapshot) => { - slotProps.value = snapshot; - controllerRef.value = snapshot?.controller ?? null; - cursor.value = snapshot?.cursor ?? "default"; - }, - (nextCursor) => { - cursor.value = nextCursor; - } - ); - }; - - watch(elementRef, () => mountBinding(), { immediate: true }); - watch( - () => props, - (next) => { - teardown?.update(next); - }, - { deep: true } - ); - - onBeforeUnmount(() => { - teardown?.destroy(); - teardown = null; - slotProps.value = null; - controllerRef.value = null; - cursor.value = "default"; - }); - - const cursorStyle = computed(() => cursor.value ?? "default"); - - expose({ - get controller() { - return ensureCameraController(slotProps.value?.controller ?? null); - }, - startAutoRotate(config?: AutoRotateOption) { - teardown?.startAutoRotate(config); - }, - stopAutoRotate() { - teardown?.stopAutoRotate(); - } - }); - - return () => { - const slotPayload = slotProps.value; - const children = - slotPayload && slotProps.value?.controller && slots.default - ? slots.default(slotPayload) - : undefined; - - return h( - "div", - { - class: CAMERA_HOST_CLASS, - ref: elementRef, - style: { cursor: cursorStyle.value } - }, - children - ); - }; - } -}); diff --git a/packages/voxcss/vue/VoxScene.ts b/packages/voxcss/vue/VoxScene.ts deleted file mode 100644 index 6d74d5d..0000000 --- a/packages/voxcss/vue/VoxScene.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { defineComponent, h, onBeforeUnmount, ref, watch, computed, inject } from "vue"; -import { mountScene, normalizeSceneState, SCENE_HOST_CLASS, type SceneState } from "@layoutit/voxcss-html"; -import type { SceneController } from "@layoutit/voxcss-core"; -import { normalizeMergeVoxelsOption, type MergeVoxelsOption } from "@layoutit/voxcss-core"; -import { controllerKey } from "./context"; - -const scenePropOptions = { - controller: { type: Object as import("vue").PropType }, - voxels: { type: Array as import("vue").PropType }, - rows: { type: Number }, - cols: { type: Number }, - depth: { type: Number }, - showWalls: { type: Boolean as import("vue").PropType }, - showFloor: { type: Boolean as import("vue").PropType }, - projection: { type: String as import("vue").PropType }, - mergeVoxels: { - type: [String, Boolean] as import("vue").PropType, - default: false, - validator: (value: unknown) => value === false || value === "2d" || value === "3d" - } -} as const; - -export default defineComponent({ - name: "VoxScene", - props: scenePropOptions, - setup(props) { - const hostElement = ref(null); - let binding: ReturnType | null = null; - const injectedController = inject(controllerKey, null); - const resolvedController = computed(() => props.controller ?? injectedController?.value ?? null); - - const mountBinding = () => { - binding?.destroy(); - binding = null; - const element = hostElement.value; - const controller = resolvedController.value; - if (!element || !controller) return; - const rawVoxels = props.voxels ?? []; - const mergeOption = normalizeMergeVoxelsOption(props.mergeVoxels); - const options: SceneState & { controller: SceneController } = { - controller, - ...normalizeSceneState({ - voxels: rawVoxels, - rows: props.rows, - cols: props.cols, - depth: props.depth, - showWalls: props.showWalls, - showFloor: props.showFloor, - projection: props.projection, - mergeVoxels: mergeOption - }) - }; - binding = mountScene({ ...options, element }); - }; - - watch(hostElement, () => mountBinding(), { immediate: true }); - watch(resolvedController, () => mountBinding()); - watch( - () => props.controller, - (next, prev) => { - if (next === prev) return; - binding?.destroy(); - binding = null; - mountBinding(); - } - ); - watch( - () => [ - props.voxels, - props.rows, - props.cols, - props.depth, - props.showWalls, - props.showFloor, - props.projection, - props.mergeVoxels - ], - () => { - if (!binding) { - mountBinding(); - return; - } - const rawVoxels = props.voxels ?? []; - const mergeOption = normalizeMergeVoxelsOption(props.mergeVoxels); - binding.update( - normalizeSceneState({ - voxels: rawVoxels, - rows: props.rows, - cols: props.cols, - depth: props.depth, - showWalls: props.showWalls, - showFloor: props.showFloor, - projection: props.projection, - mergeVoxels: mergeOption - }) - ); - }, - { deep: true } - ); - - onBeforeUnmount(() => { - binding?.destroy(); - binding = null; - }); - - return () => - h("div", { - class: SCENE_HOST_CLASS, - ref: hostElement - }); - } -}); diff --git a/packages/voxcss/vue/context.ts b/packages/voxcss/vue/context.ts deleted file mode 100644 index f4c44a0..0000000 --- a/packages/voxcss/vue/context.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { InjectionKey, Ref } from "vue"; -import type { SceneController } from "@layoutit/voxcss-core"; - -export const controllerKey: InjectionKey> = Symbol("voxcss-controller"); diff --git a/packages/voxcss/vue/index.ts b/packages/voxcss/vue/index.ts index 56c689d..7ea1cf9 100644 --- a/packages/voxcss/vue/index.ts +++ b/packages/voxcss/vue/index.ts @@ -1,2 +1 @@ -export { default as VoxCamera } from "./VoxCamera"; -export { default as VoxScene } from "./VoxScene"; +export * from "@layoutit/voxcss-vue"; diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 0000000..ba4a18a --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,43 @@ +{ + "name": "@layoutit/voxcss-vue", + "version": "0.1.8", + "description": "Native Vue 3 components for CSS voxel rendering with 60fps imperative updates.", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/LayoutitStudio/voxcss.git", + "directory": "packages/vue" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "prepublishOnly": "npm run build" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@layoutit/voxcss-core": "workspace:^" + }, + "peerDependencies": { + "vue": "^3.0.0" + }, + "devDependencies": { + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vue": "^3.5.12" + } +} diff --git a/packages/vue/src/VoxCamera.ts b/packages/vue/src/VoxCamera.ts new file mode 100644 index 0000000..6cec8ec --- /dev/null +++ b/packages/vue/src/VoxCamera.ts @@ -0,0 +1,76 @@ +import { defineComponent, h, provide, computed } from "vue"; +import type { PropType } from "vue"; +import type { AutoRotateOption } from "@layoutit/voxcss-core"; +import { useCamera } from "./composables/useCamera"; +import { VoxCameraContextKey } from "./context"; + +const DEFAULT_PERSPECTIVE = 8000; + +export const VoxCamera = defineComponent({ + name: "VoxCamera", + props: { + zoom: { type: Number }, + pan: { type: Number }, + tilt: { type: Number }, + rotX: { type: Number }, + rotY: { type: Number }, + interactive: { type: Boolean }, + invert: { type: [Boolean, Number] as PropType }, + animate: { type: [Boolean, Number, Object] as PropType }, + perspective: { type: [Number, Boolean] as PropType }, + class: { type: String }, + }, + setup(props, { slots }) { + const cameraOptions = computed(() => ({ + zoom: props.zoom, + pan: props.pan, + tilt: props.tilt, + rotX: props.rotX, + rotY: props.rotY, + interactive: props.interactive, + invert: props.invert, + animate: props.animate, + })); + + const { + store, + cameraRef, + sceneElRef, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + cursor, + } = useCamera(cameraOptions); + + // Provide context — stable identity + provide(VoxCameraContextKey, { store, cameraRef, sceneElRef }); + + return () => { + const perspectiveValue = + props.perspective === false + ? "none" + : `${typeof props.perspective === "number" ? props.perspective : DEFAULT_PERSPECTIVE}px`; + + const cameraStyle: Record = { + perspective: perspectiveValue, + cursor: props.interactive ? cursor.value : undefined, + touchAction: props.interactive ? "none" : undefined, + userSelect: props.interactive ? "none" : undefined, + }; + + return h( + "div", + { + class: `voxcss-camera${props.class ? ` ${props.class}` : ""}`, + style: cameraStyle, + onPointerdown: props.interactive ? onPointerDown : undefined, + onPointermove: props.interactive ? onPointerMove : undefined, + onPointerup: props.interactive ? onPointerUp : undefined, + onPointercancel: props.interactive ? onPointerCancel : undefined, + }, + slots.default?.() + ); + }; + }, +}); diff --git a/packages/vue/src/VoxCube.ts b/packages/vue/src/VoxCube.ts new file mode 100644 index 0000000..c9a88ed --- /dev/null +++ b/packages/vue/src/VoxCube.ts @@ -0,0 +1,67 @@ +import { defineComponent, h } from "vue"; +import type { PropType } from "vue"; +import type { CubeFace, GridContext, Voxel } from "@layoutit/voxcss-core"; +import { computeVisibleFaces, computeCubeFaceAppearance, getVoxelBounds } from "@layoutit/voxcss-core"; + +const CubeFaceDiv = defineComponent({ + name: "CubeFaceDiv", + props: { + voxel: { type: Object as PropType, required: true }, + face: { type: String as PropType, required: true }, + context: { type: Object as PropType, required: true }, + }, + setup(props) { + return () => { + const appearance = computeCubeFaceAppearance(props.voxel, props.face, props.context); + return h("div", { + class: `voxcss-cube-face voxcss-cube-face--${props.face}`, + style: { + backgroundColor: appearance.backgroundColor || undefined, + backgroundImage: appearance.backgroundImage || undefined, + filter: appearance.filter || undefined, + }, + }); + }; + }, +}); + +export const VoxCube = defineComponent({ + name: "VoxCube", + props: { + voxel: { type: Object as PropType, required: true }, + context: { type: Object as PropType, required: true }, + }, + setup(props) { + return () => { + const faces = computeVisibleFaces(props.voxel, props.context); + if (faces.length === 0) return null; + + const { x2, y2 } = getVoxelBounds(props.voxel); + const tileSize = props.context.tileSize; + const halfTile = tileSize / 2; + const spanX = x2 - props.voxel.x; + const spanY = y2 - props.voxel.y; + + return h( + "div", + { + class: "voxcss-cube", + style: { + gridArea: `${props.voxel.x} / ${props.voxel.y} / ${x2} / ${y2}`, + "--voxcss-side-offset-x": `${spanX * halfTile}px`, + "--voxcss-side-offset-y": `${spanY * halfTile}px`, + "--voxcss-fr-offset": `${spanY * tileSize}px`, + }, + }, + faces.map((face) => + h(CubeFaceDiv, { + key: face, + voxel: props.voxel, + face, + context: props.context, + }) + ) + ); + }; + }, +}); diff --git a/packages/vue/src/VoxLayer.ts b/packages/vue/src/VoxLayer.ts new file mode 100644 index 0000000..3bf4704 --- /dev/null +++ b/packages/vue/src/VoxLayer.ts @@ -0,0 +1,37 @@ +import { defineComponent, h } from "vue"; +import type { PropType } from "vue"; +import type { GridContext, Voxel } from "@layoutit/voxcss-core"; +import { VoxCube } from "./VoxCube"; +import { VoxShape } from "./VoxShape"; + +function voxelKey(voxel: Voxel, index: number): string { + return `${voxel.x}:${voxel.y}:${voxel.z}:${index}`; +} + +export const VoxLayer = defineComponent({ + name: "VoxLayer", + props: { + layerIndex: { type: Number, required: true }, + voxels: { type: Array as PropType, required: true }, + context: { type: Object as PropType, required: true }, + }, + setup(props) { + return () => { + const elevation = props.context.layerElevation ?? props.context.tileSize; + const transform = `translateZ(${props.layerIndex * elevation}px)`; + + return h( + "div", + { class: "voxcss-layer", style: { transform } }, + props.voxels.map((voxel, i) => { + if (!voxel) return null; + const shape = voxel.shape ?? "cube"; + if (shape === "cube") { + return h(VoxCube, { key: voxelKey(voxel, i), voxel, context: props.context }); + } + return h(VoxShape, { key: voxelKey(voxel, i), voxel, context: props.context }); + }) + ); + }; + }, +}); diff --git a/packages/vue/src/VoxScene.ts b/packages/vue/src/VoxScene.ts new file mode 100644 index 0000000..b9afbd3 --- /dev/null +++ b/packages/vue/src/VoxScene.ts @@ -0,0 +1,381 @@ +import { defineComponent, h, computed, shallowRef, ref, inject, onMounted, onBeforeUnmount, watch } from "vue"; +import type { PropType } from "vue"; +import type { ProjectionMode, VoxelGrid, WallsMask } from "@layoutit/voxcss-core"; +import { DEFAULT_WALL_COLOR, wallMasksEqual } from "@layoutit/voxcss-core"; +import { createIsometricCamera } from "@layoutit/voxcss-core"; +import { shadeColor, shadeWallFace } from "@layoutit/voxcss-core"; +import type { MergeVoxelsOption, PlaneAxis } from "@layoutit/voxcss-core"; +import { VoxCameraContextKey } from "./context"; +import { useSceneContext } from "./composables/useSceneContext"; +import { VoxLayer } from "./VoxLayer"; +import { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "./VoxSliceRenderer"; +import { injectBaseStyles } from "./styles"; +import type { SceneStore } from "./sceneStore"; + +const X_AXES = new Set(["x"]); +const Y_AXES = new Set(["y"]); + +const FLOOR_BASE_DELTA = 120; +const DIMETRIC_CLASS = "voxcss-projection--dimetric"; +const FLOOR_GRID_ALPHA = 0.12; +const WALL_GRID_ALPHA = 0.1; +const GRID_DISABLE_THRESHOLD = 20; + +const gridSvgCache = new Map(); + +function buildGridSvgDataUrl(width: number, height: number, alpha: number): string { + const key = `${width}x${height}:${alpha}`; + const cached = gridSvgCache.get(key); + if (cached) return cached; + const svg = ``; + const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; + gridSvgCache.set(key, url); + return url; +} + +const WALL_DEFINITIONS: Array<{ + key: keyof WallsMask; + className: string; + useAltGrid: boolean; + getSize: (rows: number, cols: number, depth: number, tile: number) => [number, number]; + getTransform: (rows: number, cols: number, depth: number, halfTile: number) => string; +}> = [ + { + key: "bl", + className: "voxcss-wall voxcss-wall--backLeft", + useAltGrid: true, + getSize: (rows, _cols, depth, tile) => [depth * tile, rows * tile], + getTransform: (_rows, _cols, depth, halfTile) => + `rotateY(-90deg) translateZ(${halfTile * depth}px) translateX(${halfTile * depth}px)`, + }, + { + key: "fr", + className: "voxcss-wall voxcss-wall--frontRight", + useAltGrid: true, + getSize: (rows, _cols, depth, tile) => [depth * tile, rows * tile], + getTransform: (_rows, _cols, depth, halfTile) => + `rotateY(-90deg) translateZ(-${halfTile * depth}px) translateX(${halfTile * depth}px)`, + }, + { + key: "br", + className: "voxcss-wall voxcss-wall--backRight", + useAltGrid: false, + getSize: (_rows, cols, depth, tile) => [cols * tile, depth * tile], + getTransform: (_rows, _cols, depth, halfTile) => + `rotateX(90deg) translateZ(${halfTile * depth}px) translateY(${halfTile * depth}px)`, + }, + { + key: "fl", + className: "voxcss-wall voxcss-wall--frontLeft", + useAltGrid: false, + getSize: (_rows, cols, depth, tile) => [cols * tile, depth * tile], + getTransform: (rows, _cols, depth, halfTile) => + `rotateX(-90deg) translateZ(${halfTile * (2 * rows - depth)}px) translateY(-${halfTile * depth}px)`, + }, +]; + +export const VoxScene = defineComponent({ + name: "VoxScene", + props: { + voxels: { type: Array as PropType, required: true }, + rows: { type: Number }, + cols: { type: Number }, + depth: { type: Number }, + showFloor: { type: Boolean, default: false }, + showWalls: { type: Boolean, default: false }, + projection: { type: String as PropType, default: "cubic" }, + mergeVoxels: { type: [String, Boolean] as PropType }, + wallColor: { type: String, default: DEFAULT_WALL_COLOR }, + }, + setup(props) { + const cameraCtx = inject(VoxCameraContextKey); + if (!cameraCtx) { + throw new Error("voxcss: VoxScene must be used inside a VoxCamera."); + } + + const { store, sceneElRef } = cameraCtx; + + // Read camera state once for initial render — transform updates go via direct DOM + const cameraState = store.getState().cameraState; + + // Subscribe to wall mask — re-renders only when mask actually changes. + // Use shallowRef + equality check to avoid spurious Vue reactivity triggers. + const wallMask = shallowRef(store.getState().wallMask); + let unsubscribe: (() => void) | null = null; + + onMounted(() => { + unsubscribe = store.subscribe(() => { + const next = store.getState().wallMask; + if (!wallMasksEqual(wallMask.value, next)) { + wallMask.value = next; + } + }); + }); + + onBeforeUnmount(() => { + unsubscribe?.(); + unsubscribe = null; + }); + + // Inject base styles once + let injected = false; + onMounted(() => { + if (injected) return; + if (typeof document !== "undefined") { + injectBaseStyles(document); + injected = true; + } + }); + + const sceneElLocalRef = ref(null); + + // Sync local ref to camera context's sceneElRef + watch(sceneElLocalRef, (el) => { + sceneElRef.value = el; + }); + + const sceneContextOptions = computed(() => ({ + rows: props.rows, + cols: props.cols, + depth: props.depth, + projection: props.projection, + showFloor: props.showFloor, + showWalls: props.showWalls, + wallColor: props.wallColor, + // Only include wallMask for non-3d modes — 3d uses NO_WALLS internally + wallMask: props.mergeVoxels === "3d" ? undefined : wallMask.value, + mergeVoxels: props.mergeVoxels, + })); + + const voxelsRef = computed(() => props.voxels); + const sceneResult = useSceneContext(voxelsRef, sceneContextOptions); + + // Stable dimensions ref — only updates when rows/cols/depth actually change, + // NOT on every wall mask change. This prevents sceneStyle from recomputing + // and resetting the live camera transform set by applyTransformDirect. + const stableDimensions = computed(() => sceneResult.value.dimensions); + let prevDims = { rows: 0, cols: 0, depth: 0 }; + const dimensions = computed(() => { + const dims = stableDimensions.value; + if (dims.rows === prevDims.rows && dims.cols === prevDims.cols && dims.depth === prevDims.depth) { + return prevDims; + } + prevDims = { rows: dims.rows, cols: dims.cols, depth: dims.depth }; + return prevDims; + }); + + // Scene style: ONLY width/height. Transform is NEVER in Vue's style. + // Direct DOM exclusively owns el.style.transform. + const sceneStyle = computed(() => { + const dims = dimensions.value; + const tileSize = 50; + return { + width: `${dims.cols * tileSize}px`, + height: `${dims.rows * tileSize}px`, + }; + }); + + // Apply transform via direct DOM whenever scene element or dimensions change. + // This is the ONLY place transform is set — Vue never touches it. + watch( + [sceneElLocalRef, dimensions], + ([el, dims]) => { + if (!el) return; + const cam = cameraCtx.cameraRef.value; + const handle = createIsometricCamera(cam.state); + const camStyle = handle.getStyle({ + rows: dims.rows, + cols: dims.cols, + depth: dims.depth, + dimetric: props.projection === "dimetric", + }); + el.style.transform = camStyle.transform; + el.dataset.voxDepthOffset = String( + dims.depth * cam.state.depthOffset * (props.projection === "dimetric" ? 0.5 : 1) + ); + }, + { flush: "post" } + ); + + const is3d = computed(() => props.mergeVoxels === "3d"); + + const sliceLayers = computed(() => is3d.value ? sceneResult.value.layers : []); + const sliceContext = computed(() => sceneResult.value.context); + const sliceBrushes = useSliceBrushes(sliceLayers, sliceContext); + + const floorRef = ref(null); + + return () => { + // Don't render until voxels arrive — prevents flash with default zoom + if (!props.voxels || props.voxels.length === 0) { + return h("div", { ref: sceneElLocalRef, class: "voxcss-scene", style: { display: "none" } }); + } + + const { context, dimensions, layers } = sceneResult.value; + const mask = wallMask.value; + const tileSize = context.tileSize; + const layerElevation = context.layerElevation ?? tileSize; + const className = `voxcss-scene${props.projection === "dimetric" ? ` ${DIMETRIC_CLASS}` : ""}`; + + const floorVisible = props.showFloor && mask.b; + const floorColor = floorVisible ? shadeColor(props.wallColor, FLOOR_BASE_DELTA) : undefined; + const disableGrid = dimensions.rows > GRID_DISABLE_THRESHOLD && dimensions.cols > GRID_DISABLE_THRESHOLD; + const floorGrid = floorVisible && !disableGrid + ? buildGridSvgDataUrl(tileSize, tileSize, FLOOR_GRID_ALPHA) + : undefined; + + const sceneChildren = []; + + // Floor div + const floorChildren: any[] = []; + if (is3d.value) { + floorChildren.push( + h(SliceZBrushes, { + key: "slice-z", + floorRef, + plans: sliceBrushes.plans.value, + store: store as SceneStore, + tileSize, + layerElevation, + }) + ); + } else { + for (let i = 0; i < layers.length; i++) { + floorChildren.push( + h(VoxLayer, { key: i, layerIndex: i, voxels: layers[i], context }) + ); + } + } + + const floorStyle: Record = { + "--voxcss-floor-base": floorColor, + "--voxcss-grid-x": floorVisible ? `${tileSize}px` : undefined, + "--voxcss-grid-y": floorVisible ? `${tileSize}px` : undefined, + "--voxcss-floor-grid": floorGrid, + background: floorVisible ? undefined : "none", + pointerEvents: "none", + }; + + if (is3d.value) { + floorStyle.display = "grid"; + floorStyle.gridTemplateColumns = `repeat(${dimensions.cols}, ${tileSize}px)`; + floorStyle.gridTemplateRows = `repeat(${dimensions.rows}, ${tileSize}px)`; + } + + sceneChildren.push( + h("div", { + ref: floorRef, + class: "voxcss-floor-z", + style: floorStyle, + }, floorChildren) + ); + + // X/Y slice hosts for 3d mode + if (is3d.value) { + sceneChildren.push( + h(SliceAxisHost, { + key: "slice-x", + className: "voxcss-floor-x", + hostStyle: { + width: `${dimensions.cols * tileSize}px`, + height: `${dimensions.depth * layerElevation}px`, + display: "grid", + gridTemplateColumns: `repeat(${dimensions.cols}, ${tileSize}px)`, + gridTemplateRows: `repeat(${dimensions.depth}, ${layerElevation}px)`, + }, + plans: sliceBrushes.plans.value, + store: store as SceneStore, + tileSize, + layerElevation, + axes: X_AXES, + }) + ); + sceneChildren.push( + h(SliceAxisHost, { + key: "slice-y", + className: "voxcss-floor-y", + hostStyle: { + width: `${dimensions.depth * layerElevation}px`, + height: `${dimensions.rows * tileSize}px`, + display: "grid", + gridTemplateColumns: `repeat(${dimensions.depth}, ${layerElevation}px)`, + gridTemplateRows: `repeat(${dimensions.rows}, ${tileSize}px)`, + }, + plans: sliceBrushes.plans.value, + store: store as SceneStore, + tileSize, + layerElevation, + axes: Y_AXES, + }) + ); + } + + // Ceiling + if (props.showFloor && mask.t) { + const ceilingColor = shadeColor(props.wallColor, FLOOR_BASE_DELTA); + sceneChildren.push( + h("div", { + key: "ceiling", + class: "voxcss-ceiling", + style: { + width: `${dimensions.cols * tileSize}px`, + height: `${dimensions.rows * tileSize}px`, + transform: `translateZ(${dimensions.depth * tileSize}px)`, + "--voxcss-ceiling-base": ceilingColor, + "--voxcss-ceiling-opacity": "0.35", + }, + }) + ); + } + + // Walls + if (props.showWalls) { + const halfTile = tileSize / 2; + const { rows, cols, depth } = dimensions; + const wallGridUrl = disableGrid ? undefined : buildGridSvgDataUrl(tileSize, layerElevation, WALL_GRID_ALPHA); + const wallGridAltUrl = disableGrid ? undefined + : tileSize === layerElevation ? wallGridUrl + : buildGridSvgDataUrl(layerElevation, tileSize, WALL_GRID_ALPHA); + + for (const def of WALL_DEFINITIONS) { + if (!context.walls[def.key]) continue; + const [width, height] = def.getSize(rows, cols, depth, tileSize); + const transform = def.getTransform(rows, cols, depth, halfTile); + const bgColor = shadeWallFace(props.wallColor, def.key); + const gridUrl = def.useAltGrid ? wallGridAltUrl : wallGridUrl; + + sceneChildren.push( + h("div", { + key: def.key, + class: def.className, + style: { + width: `${width}px`, + height: `${height}px`, + transform, + backgroundColor: bgColor, + "--voxcss-wall-grid": gridUrl, + }, + }) + ); + } + } + + return h( + "div", + { + ref: sceneElLocalRef, + class: className, + "data-vox-depth-offset": String( + dimensions.depth * cameraCtx.cameraRef.value.state.depthOffset * (props.projection === "dimetric" ? 0.5 : 1) + ), + style: { + ...sceneStyle.value, + "--voxcss-rows": dimensions.rows, + "--voxcss-cols": dimensions.cols, + }, + }, + sceneChildren + ); + }; + }, +}); diff --git a/packages/vue/src/VoxShape.ts b/packages/vue/src/VoxShape.ts new file mode 100644 index 0000000..1a87b1e --- /dev/null +++ b/packages/vue/src/VoxShape.ts @@ -0,0 +1,355 @@ +import { defineComponent, h } from "vue"; +import type { PropType } from "vue"; +import type { GridContext, Voxel, ShapeType, ShapeSurfaceLighting } from "@layoutit/voxcss-core"; +import { getVoxelBounds, computeShapeLighting } from "@layoutit/voxcss-core"; + +const ORIENTATION_MAP: Record = { + 0: "east", + 90: "south", + 180: "west", + 270: "north", +}; + +function normalizeRotation(value: number | undefined): number { + if (!Number.isFinite(value)) return 0; + const snapped = Math.round((value as number) / 90) * 90; + return ((snapped % 360) + 360) % 360; +} + +function isCovered(voxel: Voxel, context: GridContext): boolean { + const { x2, y2 } = getVoxelBounds(voxel); + const layerAbove = Math.max(0, Math.floor((voxel.z ?? 0) + 1)); + for (let row = voxel.x; row < x2; row += 1) { + for (let col = voxel.y; col < y2; col += 1) { + if (context.getVoxel(row, col, layerAbove)) return true; + } + } + return false; +} + +function isBottomOccluded(voxel: Voxel, context: GridContext): boolean { + const targetZ = Math.floor((voxel.z ?? 0) - 1); + if (targetZ < 0) return false; + const { x2, y2 } = getVoxelBounds(voxel); + for (let x = voxel.x; x < x2; x += 1) { + for (let y = voxel.y; y < y2; y += 1) { + if (!context.getVoxel(x, y, targetZ)) return false; + } + } + return true; +} + +function shouldRenderBottom(voxel: Voxel, context: GridContext): boolean { + if (context.walls?.b) return false; + return !isBottomOccluded(voxel, context); +} + +function getSurfaceColor(lighting: ShapeSurfaceLighting[], surfaceId: string, fallback: string): string { + return lighting.find((s) => s.id === surfaceId)?.color ?? fallback; +} + +function getSurfaceDelta(lighting: ShapeSurfaceLighting[], surfaceId: string): number { + return lighting.find((s) => s.id === surfaceId)?.delta ?? 0; +} + +function resolveSurfaceTexture( + voxel: Voxel, + surfaceId: string, + context: GridContext +): string | undefined { + const textureKey = voxel.texture; + if (!textureKey || textureKey.startsWith("#")) return undefined; + const resolved = context.resolveTexture?.(textureKey, surfaceId); + if (resolved) return resolved; + if ( + textureKey.startsWith("/") || + textureKey.startsWith("./") || + textureKey.startsWith("../") || + textureKey.startsWith("http://") || + textureKey.startsWith("https://") || + textureKey.startsWith("data:") || + textureKey.includes(".") + ) { + return textureKey; + } + return undefined; +} + +function textureBrightnessFilter(delta: number): string | undefined { + const brightness = Math.max(0, 1 + delta / 200); + if (Math.abs(brightness - 1) < 0.001) return undefined; + const rounded = Math.round(brightness * 1000) / 1000; + return `brightness(${rounded})`; +} + +let _patternIdCounter = 0; +function nextPatternId(): string { + return `vox-pat-${++_patternIdCounter}`; +} + +function renderSvgSlope( + className: string, + path: string, + fill: string, + viewBox = "0 0 480 480", + width = "56", + height = "50", + textureUrl?: string, + brightnessDelta = 0, +) { + const patternId = textureUrl ? nextPatternId() : ""; + const effectiveFill = textureUrl ? `url(#${patternId})` : fill; + const filter = textureUrl ? textureBrightnessFilter(brightnessDelta) : undefined; + + const defs = textureUrl + ? h("defs", null, [ + h("pattern", { + id: patternId, + patternUnits: "objectBoundingBox", + patternContentUnits: "objectBoundingBox", + width: "1", + height: "1", + }, [ + h("image", { + width: "1", + height: "1", + preserveAspectRatio: "xMidYMid slice", + href: textureUrl, + }), + ]), + ]) + : null; + + return h("div", { class: className, style: { filter } }, [ + h( + "svg", + { + viewBox, + width, + height, + preserveAspectRatio: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": "true", + focusable: "false", + style: { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + display: "block", + pointerEvents: "none", + }, + }, + [ + defs, + h("path", { + d: path, + fill: effectiveFill, + stroke: "rgba(0, 0, 0, 0.1)", + "stroke-width": "1", + "vector-effect": "non-scaling-stroke", + }), + ] + ), + ]); +} + +function renderRamp( + voxel: Voxel, + context: GridContext, + baseColor: string, + lighting: ShapeSurfaceLighting[], + showBottom: boolean, +) { + const slopeColor = getSurfaceColor(lighting, "slope", baseColor); + const slopeDelta = getSurfaceDelta(lighting, "slope"); + const slopeTexture = resolveSurfaceTexture(voxel, "slope", context); + const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); + + const children = []; + if (showBottom) { + children.push( + h("div", { + class: "voxcss-ramp-bottom", + style: { + backgroundColor: bottomTexture ? undefined : baseColor, + backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, + filter: bottomTexture ? textureBrightnessFilter(0) : undefined, + }, + }) + ); + } + children.push( + h("div", { + class: "voxcss-ramp-slope", + style: { + backgroundColor: slopeTexture ? undefined : slopeColor, + backgroundImage: slopeTexture ? `url(${slopeTexture})` : undefined, + backgroundSize: "70px 50px", + filter: slopeTexture ? textureBrightnessFilter(slopeDelta) : undefined, + }, + }) + ); + + return children; +} + +function renderWedge( + voxel: Voxel, + context: GridContext, + baseColor: string, + lighting: ShapeSurfaceLighting[], + showBottom: boolean, +) { + const primaryColor = getSurfaceColor(lighting, "primary", baseColor); + const secondaryColor = getSurfaceColor(lighting, "secondary", baseColor); + const primaryDelta = getSurfaceDelta(lighting, "primary"); + const secondaryDelta = getSurfaceDelta(lighting, "secondary"); + const primaryTexture = resolveSurfaceTexture(voxel, "primary", context); + const secondaryTexture = resolveSurfaceTexture(voxel, "secondary", context); + const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); + + const children = []; + if (showBottom) { + children.push( + h("div", { + class: "voxcss-wedge-bottom", + style: { + backgroundColor: bottomTexture ? undefined : baseColor, + backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, + filter: bottomTexture ? textureBrightnessFilter(0) : undefined, + }, + }) + ); + } + children.push( + renderSvgSlope( + "voxcss-wedge-slope voxcss-wedge-slope--primary", + "M0 0 L480 0 L0 480 Z", + primaryColor, + undefined, + undefined, + undefined, + primaryTexture, + primaryDelta, + ) + ); + children.push( + renderSvgSlope( + "voxcss-wedge-slope voxcss-wedge-slope--secondary", + "M480 480 L0 480 L480 0 Z", + secondaryColor, + undefined, + "50", + "56", + secondaryTexture, + secondaryDelta, + ) + ); + + return children; +} + +function renderSpike( + voxel: Voxel, + context: GridContext, + baseColor: string, + lighting: ShapeSurfaceLighting[], + showBottom: boolean, +) { + const primaryColor = getSurfaceColor(lighting, "primary", baseColor); + const secondaryColor = getSurfaceColor(lighting, "secondary", baseColor); + const primaryDelta = getSurfaceDelta(lighting, "primary"); + const secondaryDelta = getSurfaceDelta(lighting, "secondary"); + const primaryTexture = resolveSurfaceTexture(voxel, "primary", context); + const secondaryTexture = resolveSurfaceTexture(voxel, "secondary", context); + const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); + + const children = []; + if (showBottom) { + children.push( + h("div", { + class: "voxcss-spike-bottom", + style: { + backgroundColor: bottomTexture ? undefined : baseColor, + backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, + filter: bottomTexture ? textureBrightnessFilter(0) : undefined, + }, + }) + ); + } + children.push( + renderSvgSlope( + "voxcss-spike-slope voxcss-spike-slope--primary", + "M480 0 L480 480 L0 480 Z", + primaryColor, + undefined, + undefined, + undefined, + primaryTexture, + primaryDelta, + ) + ); + children.push( + renderSvgSlope( + "voxcss-spike-slope voxcss-spike-slope--secondary", + "M0 0 L0 480 L480 0 Z", + secondaryColor, + undefined, + "50", + "56", + secondaryTexture, + secondaryDelta, + ) + ); + + return children; +} + +export const VoxShape = defineComponent({ + name: "VoxShape", + props: { + voxel: { type: Object as PropType, required: true }, + context: { type: Object as PropType, required: true }, + }, + setup(props) { + return () => { + const shapeKey = props.voxel.shape ?? "cube"; + if (shapeKey === "cube") return null; + const shape = shapeKey as ShapeType; + + if (isCovered(props.voxel, props.context)) return null; + + const { x2, y2 } = getVoxelBounds(props.voxel); + const rawRotation = Number.isFinite(props.voxel.rot as number) ? Number(props.voxel.rot) : 0; + const rotation = normalizeRotation(rawRotation); + const orientation = ORIENTATION_MAP[rotation] ?? "east"; + const baseColor = props.voxel.color ?? "#cccccc"; + const lighting = computeShapeLighting(shape, rawRotation, baseColor); + const showBottom = shouldRenderBottom(props.voxel, props.context); + + // Shape class + orientation on the SAME root div (matches HTML renderer's mountToRoot) + let shapeClass: string; + let children: any[]; + if (shape === "ramp") { + shapeClass = "voxcss-ramp"; + children = renderRamp(props.voxel, props.context, baseColor, lighting, showBottom); + } else if (shape === "wedge") { + shapeClass = "voxcss-wedge"; + children = renderWedge(props.voxel, props.context, baseColor, lighting, showBottom); + } else { + shapeClass = "voxcss-spike"; + children = renderSpike(props.voxel, props.context, baseColor, lighting, showBottom); + } + + return h( + "div", + { + class: `voxcss-${orientation} ${shapeClass}`, + style: { gridArea: `${props.voxel.x} / ${props.voxel.y} / ${x2} / ${y2}` }, + }, + children + ); + }; + }, +}); diff --git a/packages/vue/src/VoxSliceRenderer.ts b/packages/vue/src/VoxSliceRenderer.ts new file mode 100644 index 0000000..0fd8b0a --- /dev/null +++ b/packages/vue/src/VoxSliceRenderer.ts @@ -0,0 +1,222 @@ +import { computed, onMounted, onBeforeUnmount, watch, ref } from "vue"; +import type { Ref, VNode } from "vue"; +import { h, defineComponent } from "vue"; +import type { PropType } from "vue"; +import type { Voxel, GridContext, WallsMask } from "@layoutit/voxcss-core"; +import { + type PlaneAxis, + type FaceData, + type SlicePlan, + buildSlicePlan, + buildFaceDataFromSnapshot, + NEXT_LAYER_STEP, +} from "@layoutit/voxcss-core"; +import type { SceneStore } from "./sceneStore"; + +const BRUSH_CLASS = "voxcss-brush"; + +function applyBrush( + el: HTMLElement, + gridArea: string, + backgroundColor: string, + zOffset: string +): void { + const state = ((el as any).__voxBrush ??= {} as Record); + if (state.className !== BRUSH_CLASS) { + el.className = BRUSH_CLASS; + state.className = BRUSH_CLASS; + } + if (state.gridArea !== gridArea) { + el.style.gridArea = gridArea; + state.gridArea = gridArea; + } + if (state.backgroundColor !== backgroundColor) { + el.style.backgroundColor = backgroundColor; + state.backgroundColor = backgroundColor; + } + if (state.zOffset !== zOffset) { + el.style.setProperty("--vox-z", zOffset); + state.zOffset = zOffset; + } +} + +function renderBrushesToHost( + host: HTMLElement, + pool: HTMLElement[], + plans: SlicePlan[], + walls: WallsMask, + tileSize: number, + layerElevation: number, + axes: Set, +): number { + const doc = host.ownerDocument; + let poolIndex = 0; + + for (const plan of plans) { + const { axis, plane, face } = plan.key; + if (!axes.has(axis)) continue; + if (walls[face]) continue; + + const planeOffset = axis === "z" + ? plane * layerElevation + : -1 * (plane - 1) * tileSize; + const brushZ = `${planeOffset}px`; + const originRow = plan.buffer.minRow; + const originCol = plan.buffer.minCol; + + for (const brush of plan.brushes) { + const gridArea = `${originRow + brush.r0} / ${originCol + brush.c0} / ${originRow + brush.r1} / ${originCol + brush.c1}`; + let el = pool[poolIndex]; + if (!el) { + el = doc.createElement("b"); + pool[poolIndex] = el; + } + if (el.parentElement !== host) { + host.appendChild(el); + } + applyBrush(el, gridArea, brush.baseColor, brushZ); + poolIndex++; + } + } + + // Remove excess from DOM (keep in pool) + for (let i = poolIndex; i < pool.length; i++) { + pool[i]?.remove(); + } + + return poolIndex; +} + +export function useSliceBrushes( + layers: Ref, + context: Ref, +) { + const plans = computed(() => { + const tileSize = context.value.tileSize ?? 50; + const layerElevation = context.value.layerElevation ?? tileSize; + void tileSize; + void layerElevation; + + const faces = buildFaceDataFromSnapshot({ layers: layers.value, context: context.value }); + const faceIndex = new Map(); + for (const face of faces) { + faceIndex.set(`${face.key.axis}:${face.key.plane}:${face.key.face}`, face); + } + const result: SlicePlan[] = []; + for (const face of faces) { + const nextPlane = face.key.plane + NEXT_LAYER_STEP[face.key.face]; + const nextKey = `${face.key.axis}:${nextPlane}:${face.key.face}`; + const nextFace = faceIndex.get(nextKey); + const nextBuffer = nextFace?.buffer ?? null; + result.push(buildSlicePlan(face, nextBuffer)); + } + return result; + }); + + return { plans }; +} + +const Z_SET = new Set(["z"]); +const X_SET = new Set(["x"]); +const Y_SET = new Set(["y"]); + +/** + * Imperatively renders brushes into a host element. + * Subscribes directly to the scene store for wall mask changes — + * bypasses Vue reconciliation entirely for face visibility toggling. + */ +function useImperativeBrushRenderer( + plans: Ref, + store: SceneStore, + tileSize: Ref, + layerElevation: Ref, + axisSet: Set, + hostRef: Ref, +) { + const pool: HTMLElement[] = []; + let unsubscribe: (() => void) | null = null; + + function render() { + const host = hostRef.value; + if (!host) return; + const walls = store.getState().wallMask; + renderBrushesToHost(host, pool, plans.value, walls, tileSize.value, layerElevation.value, axisSet); + } + + // Initial render + plan changes + watch([plans, tileSize, layerElevation, hostRef], () => { + render(); + }, { flush: "post" }); + + onMounted(() => { + render(); + // Subscribe to store for wall mask changes — direct DOM, no Vue + unsubscribe = store.subscribe(() => { + render(); + }); + }); + + onBeforeUnmount(() => { + unsubscribe?.(); + unsubscribe = null; + }); +} + +/** + * Z-axis brushes — renders directly into the floor div via ref. + * Does NOT create a wrapper div. + */ +export const SliceZBrushes = defineComponent({ + name: "SliceZBrushes", + props: { + floorRef: { type: Object as PropType>, required: true }, + plans: { type: Array as PropType, required: true }, + store: { type: Object as PropType, required: true }, + tileSize: { type: Number, required: true }, + layerElevation: { type: Number, required: true }, + }, + setup(props) { + useImperativeBrushRenderer( + computed(() => props.plans), + props.store, + computed(() => props.tileSize), + computed(() => props.layerElevation), + Z_SET, + props.floorRef, + ); + return () => null; // No Vue elements — brushes managed imperatively + }, +}); + +/** + * Axis host for x/y brushes. Vue owns the wrapper div, brushes are imperative. + */ +export const SliceAxisHost = defineComponent({ + name: "SliceAxisHost", + props: { + className: { type: String, required: true }, + hostStyle: { type: Object as PropType>, required: true }, + plans: { type: Array as PropType, required: true }, + store: { type: Object as PropType, required: true }, + tileSize: { type: Number, required: true }, + layerElevation: { type: Number, required: true }, + axes: { type: Object as PropType>, required: true }, + }, + setup(props) { + const hostRef = ref(null); + + useImperativeBrushRenderer( + computed(() => props.plans), + props.store, + computed(() => props.tileSize), + computed(() => props.layerElevation), + props.axes, + hostRef, + ); + + return () => { + if (props.plans.length === 0) return null; + return h("div", { ref: hostRef, class: props.className, style: props.hostStyle }); + }; + }, +}); diff --git a/packages/vue/src/colorResolver.ts b/packages/vue/src/colorResolver.ts new file mode 100644 index 0000000..d3b9d8e --- /dev/null +++ b/packages/vue/src/colorResolver.ts @@ -0,0 +1,55 @@ +/** + * DOM-based CSS color resolver for named colors ("red", "tomato", etc). + * Uses getComputedStyle on a hidden probe element to resolve any CSS color string. + */ +import { parsePureColor, type ParsedColor } from "@layoutit/voxcss-core"; + +let probeEl: HTMLElement | null = null; +const resolvedCache = new Map(); + +function ensureProbe(): HTMLElement | null { + if (typeof document === "undefined") return null; + if (probeEl && probeEl.ownerDocument) return probeEl; + probeEl = document.createElement("div"); + document.head.appendChild(probeEl); + return probeEl; +} + +/** + * Resolve a CSS color string using the browser's CSS engine. + * Handles named colors like "red", "tomato", "rebeccapurple", etc. + * Returns null if the color cannot be resolved. + */ +export function resolveColor(input: string): ParsedColor | null { + if (!input) return null; + const key = input.trim(); + + // Try pure parsing first (hex, rgb, rgba) + const pure = parsePureColor(key); + if (pure) return pure; + + // Check cache + const cached = resolvedCache.get(key); + if (cached !== undefined) return cached; + + // Use DOM probe for named colors + const probe = ensureProbe(); + if (!probe) { + resolvedCache.set(key, null); + return null; + } + + probe.style.color = ""; + probe.style.color = key; + const computed = getComputedStyle(probe); + const value = computed.color; + + if (!value || value === "rgba(0, 0, 0, 0)" || value === "transparent") { + resolvedCache.set(key, null); + return null; + } + + const parsed = parsePureColor(value); + resolvedCache.set(key, parsed); + return parsed; +} diff --git a/packages/vue/src/composables/useCamera.ts b/packages/vue/src/composables/useCamera.ts new file mode 100644 index 0000000..23f4941 --- /dev/null +++ b/packages/vue/src/composables/useCamera.ts @@ -0,0 +1,229 @@ +import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from "vue"; +import type { Ref } from "vue"; +import { createIsometricCamera } from "@layoutit/voxcss-core"; +import type { CameraState, CameraHandle, AutoRotateOption, AutoRotateConfig } from "@layoutit/voxcss-core"; +import { createSceneStore, type SceneStore } from "../sceneStore"; + +const POINTER_DRAG_SPEED = 5; + +export interface UseCameraOptions { + zoom?: number; + pan?: number; + tilt?: number; + rotX?: number; + rotY?: number; + interactive?: boolean; + invert?: boolean | number; + animate?: AutoRotateOption | false; +} + +export interface UseCameraResult { + store: SceneStore; + cameraRef: Ref; + sceneElRef: Ref; + onPointerDown: (e: PointerEvent) => void; + onPointerMove: (e: PointerEvent) => void; + onPointerUp: (e: PointerEvent) => void; + onPointerCancel: (e: PointerEvent) => void; + cursor: Ref; +} + +function normalizeAngle(value: number): number { + let normalized = value % 360; + if (normalized < 0) normalized += 360; + return normalized; +} + +function normalizeAutoRotateOption( + option: AutoRotateOption +): { axis: "x" | "y"; speed: number; pauseOnInteraction: boolean } | null { + if (!option) return null; + if (option === true) return { axis: "y", speed: 0.3, pauseOnInteraction: true }; + if (typeof option === "number") { + if (!Number.isFinite(option) || option === 0) return null; + return { axis: "y", speed: option, pauseOnInteraction: true }; + } + const config = option as AutoRotateConfig; + const speed = + typeof config.speed === "number" && Number.isFinite(config.speed) ? config.speed : 0.3; + if (!speed) return null; + return { + axis: config.axis === "x" ? "x" : "y", + speed, + pauseOnInteraction: config.pauseOnInteraction !== false, + }; +} + +export function useCamera(options: Ref): UseCameraResult { + const handle = createIsometricCamera({ + zoom: options.value.zoom, + pan: options.value.pan, + tilt: options.value.tilt, + rotX: options.value.rotX, + rotY: options.value.rotY, + }); + + const cameraRef = shallowRef(handle); + const sceneElRef = ref(null); + const store = createSceneStore(handle.state); + + const isDragging = ref(false); + let activePointerId: number | null = null; + const pointer = { x: 0, y: 0 }; + let animationPaused = false; + + function getInvertSign(): number { + const inv = options.value.invert; + if (typeof inv === "number") return inv < 0 ? -1 : 1; + return inv === true ? -1 : 1; + } + + // Sync prop changes to camera handle — only update when values actually change + watch( + () => ({ + zoom: options.value.zoom, + pan: options.value.pan, + tilt: options.value.tilt, + rotX: options.value.rotX, + rotY: options.value.rotY, + }), + (next, prev) => { + const partial: Partial = {}; + if (next.zoom !== undefined && next.zoom !== prev?.zoom) partial.zoom = next.zoom; + if (next.pan !== undefined && next.pan !== prev?.pan) partial.pan = next.pan; + if (next.tilt !== undefined && next.tilt !== prev?.tilt) partial.tilt = next.tilt; + if (next.rotX !== undefined && next.rotX !== prev?.rotX) partial.rotX = next.rotX; + if (next.rotY !== undefined && next.rotY !== prev?.rotY) partial.rotY = next.rotY; + if (Object.keys(partial).length > 0) { + handle.update(partial); + applyTransformDirect(); + store.updateCameraFromRef(handle); + store.notifyAll(); + } + } + ); + + // Apply camera transform directly to scene element (bypasses Vue reactivity) + function applyTransformDirect(): void { + const el = sceneElRef.value; + if (!el) return; + const s = handle.state; + const depthOffset = Number(el.dataset.voxDepthOffset ?? 0); + el.style.transform = `scale(${s.zoom}) translateY(${depthOffset}px) translateY(${s.tilt}px) translateX(${s.pan}px) rotateX(${s.rotX}deg) rotate(${s.rotY}deg)`; + } + + // Auto-rotate + let animFrameId = 0; + let animStopped = false; + + function startAnimation(): void { + stopAnimation(); + const animOpt = options.value.animate; + if (!animOpt) return; + const config = normalizeAutoRotateOption(animOpt); + if (!config) return; + + animStopped = false; + const tick = () => { + if (animStopped) return; + if (!animationPaused) { + if (config.axis === "x") { + handle.update({ rotX: normalizeAngle(handle.state.rotX + config.speed) }); + } else { + handle.update({ rotY: normalizeAngle(handle.state.rotY + config.speed) }); + } + applyTransformDirect(); + store.updateCameraFromRef(handle); + } + animFrameId = requestAnimationFrame(tick); + }; + animFrameId = requestAnimationFrame(tick); + } + + function stopAnimation(): void { + animStopped = true; + if (animFrameId) { + cancelAnimationFrame(animFrameId); + animFrameId = 0; + } + } + + watch(() => options.value.animate, () => { + startAnimation(); + }); + + onMounted(() => { + startAnimation(); + }); + + onBeforeUnmount(() => { + stopAnimation(); + }); + + const cursor = ref("grab"); + + function onPointerDown(e: PointerEvent): void { + if (!options.value.interactive) return; + if (activePointerId !== null) return; + if (e.isPrimary === false) return; + + const animConfig = options.value.animate ? normalizeAutoRotateOption(options.value.animate) : null; + if (animConfig?.pauseOnInteraction) { + animationPaused = true; + } + + e.preventDefault(); + activePointerId = e.pointerId; + pointer.x = e.clientX; + pointer.y = e.clientY; + isDragging.value = true; + cursor.value = "grabbing"; + try { + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } catch { + // Ignore capture errors + } + } + + function onPointerMove(e: PointerEvent): void { + if (activePointerId === null || e.pointerId !== activePointerId) return; + e.preventDefault(); + const inv = getInvertSign(); + const dX = ((e.clientX - pointer.x) * inv) / POINTER_DRAG_SPEED; + const dY = ((e.clientY - pointer.y) * inv) / POINTER_DRAG_SPEED; + handle.update({ + rotX: Math.max(0, Math.min(100, handle.state.rotX - dY)), + rotY: (handle.state.rotY - dX + 360) % 360, + }); + applyTransformDirect(); + pointer.x = e.clientX; + pointer.y = e.clientY; + + // Update store — only notifies Vue if wall mask changed + store.updateCameraFromRef(handle); + } + + function onPointerUp(e: PointerEvent): void { + if (activePointerId === null || e.pointerId !== activePointerId) return; + activePointerId = null; + isDragging.value = false; + cursor.value = "grab"; + animationPaused = false; + try { + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + } catch { + // Ignore capture errors + } + } + + return { + store, + cameraRef, + sceneElRef, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onPointerUp, + cursor, + }; +} diff --git a/packages/vue/src/composables/useSceneContext.ts b/packages/vue/src/composables/useSceneContext.ts new file mode 100644 index 0000000..e9c4797 --- /dev/null +++ b/packages/vue/src/composables/useSceneContext.ts @@ -0,0 +1,58 @@ +import { computed } from "vue"; +import type { Ref } from "vue"; +import type { ProjectionMode, VoxelGrid } from "@layoutit/voxcss-core"; +import type { SceneContextBuildResult } from "@layoutit/voxcss-core"; +import { buildSceneContext } from "@layoutit/voxcss-core"; +import { mergeVoxels as mergeVoxelsGrid } from "@layoutit/voxcss-core"; +import type { MergeVoxelsOption, WallsMask } from "@layoutit/voxcss-core"; + +/** All-false wall mask — topology only for 3d merge mode. */ +const NO_WALLS = { t: false, b: false, bl: false, br: false, fl: false, fr: false } as const; + +export interface UseSceneContextOptions { + rows?: number; + cols?: number; + depth?: number; + projection?: ProjectionMode; + showFloor?: boolean; + showWalls?: boolean; + wallColor?: string; + wallMask?: WallsMask; + mergeVoxels?: MergeVoxelsOption; +} + +export function useSceneContext( + voxels: Ref, + options: Ref +): Ref { + return computed(() => { + const opts = options.value; + // For 3d merge mode, use NO_WALLS so the scene context is stable + // and doesn't rebuild when camera rotates. The imperative brush + // renderer handles wall mask filtering directly. + const effectiveWalls = opts.mergeVoxels === "3d" ? NO_WALLS : (opts.wallMask ?? NO_WALLS); + + let grid = voxels.value; + if (opts.mergeVoxels === "2d") { + grid = mergeVoxelsGrid(grid); + } + return buildSceneContext({ + grid, + context: { + rows: opts.rows, + cols: opts.cols, + depth: opts.depth, + projection: opts.projection, + showFloor: opts.showFloor, + showWalls: opts.showWalls, + wallColor: opts.wallColor, + walls: effectiveWalls, + }, + dimensions: { + rows: opts.rows, + cols: opts.cols, + depth: opts.depth, + }, + }); + }); +} diff --git a/packages/vue/src/context.ts b/packages/vue/src/context.ts new file mode 100644 index 0000000..06f9e26 --- /dev/null +++ b/packages/vue/src/context.ts @@ -0,0 +1,11 @@ +import type { InjectionKey, Ref } from "vue"; +import type { CameraHandle } from "@layoutit/voxcss-core"; +import type { SceneStore } from "./sceneStore"; + +export interface VoxCameraContextValue { + store: SceneStore; + cameraRef: Ref; + sceneElRef: Ref; +} + +export const VoxCameraContextKey: InjectionKey = Symbol("voxcss-camera"); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 0000000..fc48052 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,30 @@ +// Register DOM-based color resolver for named CSS colors +import { setColorResolver } from "@layoutit/voxcss-core"; +import { resolveColor } from "./colorResolver"; +setColorResolver(resolveColor); + +export { VoxCamera } from "./VoxCamera"; +export { VoxScene } from "./VoxScene"; +export { VoxLayer } from "./VoxLayer"; +export { VoxCube } from "./VoxCube"; +export { VoxShape } from "./VoxShape"; +export { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "./VoxSliceRenderer"; +export { useCamera } from "./composables/useCamera"; +export type { UseCameraOptions, UseCameraResult } from "./composables/useCamera"; +export { useSceneContext } from "./composables/useSceneContext"; +export type { UseSceneContextOptions } from "./composables/useSceneContext"; +export { VoxCameraContextKey } from "./context"; +export type { VoxCameraContextValue } from "./context"; +export { injectBaseStyles } from "./styles"; + +// Re-export commonly used core types for convenience +export type { + Voxel, + VoxelGrid, + CubeFace, + GridContext, + ProjectionMode, + WallsMask, +} from "@layoutit/voxcss-core"; +export type { CameraState, AutoRotateOption } from "@layoutit/voxcss-core"; +export type { MergeVoxelsOption } from "@layoutit/voxcss-core"; diff --git a/packages/vue/src/sceneStore.ts b/packages/vue/src/sceneStore.ts new file mode 100644 index 0000000..d728c3b --- /dev/null +++ b/packages/vue/src/sceneStore.ts @@ -0,0 +1,72 @@ +/** + * Lightweight reactive store for voxcss scene state. + * Components subscribe to specific slices via selectors, + * so only the components that care about a changed value re-render. + */ +import type { CameraState, WallsMask, CameraHandle } from "@layoutit/voxcss-core"; +import { computeWallMask, wallMasksEqual } from "@layoutit/voxcss-core"; + +export interface SceneStoreState { + cameraState: CameraState; + wallMask: WallsMask; +} + +export interface SceneStore { + getState(): SceneStoreState; + setState(partial: Partial): void; + subscribe(listener: () => void): () => void; + + /** Update camera + recompute wall mask. Only notifies if wall mask changed. Returns true if mask changed. */ + updateCameraFromRef(handle: CameraHandle): boolean; + + /** Force notify all subscribers (e.g. after prop-driven camera change). */ + notifyAll(): void; +} + +export function createSceneStore(initial: CameraState): SceneStore { + let state: SceneStoreState = { + cameraState: { ...initial }, + wallMask: computeWallMask(initial.rotX, initial.rotY), + }; + + const listeners = new Set<() => void>(); + + function notify() { + for (const listener of listeners) listener(); + } + + return { + getState() { + return state; + }, + + setState(partial) { + state = { ...state, ...partial }; + notify(); + }, + + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + + updateCameraFromRef(handle) { + const nextMask = computeWallMask(handle.state.rotX, handle.state.rotY); + const maskChanged = !wallMasksEqual(state.wallMask, nextMask); + + if (maskChanged) { + state = { + cameraState: { ...handle.state }, + wallMask: nextMask, + }; + notify(); + } + + return maskChanged; + }, + + notifyAll() { + notify(); + }, + }; +} diff --git a/packages/vue/src/styles.ts b/packages/vue/src/styles.ts new file mode 100644 index 0000000..b4ec20a --- /dev/null +++ b/packages/vue/src/styles.ts @@ -0,0 +1,385 @@ +import { STYLE_ID } from "@layoutit/voxcss-core"; + +export function injectBaseStyles(doc: Document): void { + if (!doc || doc.getElementById(STYLE_ID)) return; + const style = doc.createElement("style"); + style.id = STYLE_ID; + style.textContent = CORE_BASE_STYLES; + doc.head.appendChild(style); +} + +const CORE_BASE_STYLES = ` +.voxcss-layer { + display: grid; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; + grid-template-columns: repeat(var(--voxcss-cols, 8), 50px); + grid-template-rows: repeat(var(--voxcss-rows, 8), 50px); +} +.voxcss-layer > * { + pointer-events: all; +} +.voxcss-layer:first-of-type { + pointer-events: all; +} + +.voxcss-floor-x, +.voxcss-floor-y { + position: absolute; + top: 0; + left: 0; + transform-style: preserve-3d; + pointer-events: none; + z-index: 1; + transform-origin: 0 0; +} + +.voxcss-floor-x { + transform: rotateX(90deg); +} + +.voxcss-floor-y { + transform: rotateY(-90deg); +} + +.voxcss-floor-z { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + transform-style: preserve-3d; + background: var(--voxcss-floor-base, #c2c2f3); + background-image: var(--voxcss-floor-grid-image, var(--voxcss-floor-grid, none)); + background-repeat: repeat; + background-size: var(--voxcss-grid-x, 50px) var(--voxcss-grid-y, 50px); + z-index: 0; +} + +.voxcss-ceiling { + position: absolute; + inset: 0; + transform-style: preserve-3d; + pointer-events: none; + background: var(--voxcss-ceiling-base, #c2c2f3); + background-image: var(--voxcss-ceiling-grid-image, var(--voxcss-ceiling-grid, none)); + background-repeat: repeat; + background-size: 50px 50px; + opacity: var(--voxcss-ceiling-opacity, 0.35); + z-index: 0; +} + .voxcss-wall--frontRight, +.voxcss-wall--backRight { + right: 0; +} +.voxcss-wall { + position: absolute; + background-image: var(--voxcss-wall-grid, none); + background-repeat: repeat; + background-size: 50px var(--voxcss-layer-elevation, 50px); +} +.voxcss-wall--backLeft, +.voxcss-wall--frontRight { + background-size: var(--voxcss-layer-elevation, 50px) 50px; +} +.voxcss-brush { + position: relative; + inset: 0; + display: block; + pointer-events: none; + overflow: visible; + transform: translateZ(var(--vox-z, 0px)); + transform-origin: 0 0; +} +.voxcss-cube { + position: relative; + display: block; + width: 100%; + height: 100%; + transform-style: preserve-3d; + --voxcss-layer-half: calc(var(--voxcss-layer-elevation, 50px) / 2); + transform: translateZ(var(--voxcss-layer-half)); +} +.voxcss-projection--dimetric .voxcss-cube { + --voxcss-layer-half: var(--voxcss-layer-elevation, 50px); + transform: translateZ(var(--voxcss-layer-half)); +} +.voxcss-cube-face, +.voxcss-plane { + position: absolute; + inset: 0; + box-sizing: border-box; + outline: 1px solid rgba(0, 0, 0, 0.08); + outline-offset: -1px; + pointer-events: auto; + width: 100%; + height: 100%; + background-size: cover; + overflow: visible; +} + +.voxcss-cube-face--t { + transform: translateZ(var(--voxcss-layer-half)); +} +.voxcss-projection--dimetric .voxcss-cube-face--t { + transform: translateZ(0); +} +.voxcss-cube-face--b { + transform: translateZ(calc(-1 * var(--voxcss-layer-half))); +} +.voxcss-cube-face--fr { + width: var(--voxcss-layer-elevation, 50px); + transform: rotateY(90deg) translateZ(var(--voxcss-side-offset-y, 25px)); +} +.voxcss-cube-face--fl { + height: var(--voxcss-layer-elevation, 50px); + transform: rotateX(90deg) translateZ(calc(-1 * var(--voxcss-side-offset-x, 25px))); +} +.voxcss-projection--dimetric .voxcss-cube-face--fl { + height: var(--voxcss-layer-elevation, 50px); + transform-origin: bottom; + transform: rotateX(90deg) translateZ(calc(-1 * var(--voxcss-side-offset-x, 25px))); +} +.voxcss-cube-face--bl { + width: var(--voxcss-layer-elevation, 50px); + transform: rotateY(90deg) translateZ(calc(-1 * var(--voxcss-layer-half))); +} +.voxcss-projection--dimetric .voxcss-cube-face--bl { + transform: rotateY(90deg) translateZ(0px); + width: var(--voxcss-layer-elevation, 50px); + transform-origin: bottom left; +} +.voxcss-cube-face--br { + height: var(--voxcss-layer-elevation, 50px); + transform: rotateX(90deg) translateZ(var(--voxcss-layer-half)); +} +.voxcss-projection--dimetric .voxcss-cube-face--br { + height: var(--voxcss-layer-elevation, 50px); + transform-origin: bottom; + transform: rotateX(90deg) translateZ(var(--voxcss-layer-half)); +} +.voxcss-projection--dimetric .voxcss-cube-face--fr { + transform: rotateY(90deg) translateZ(var(--voxcss-fr-offset, var(--voxcss-side-offset-y, 25px))); + transform-origin: bottom left; + width: var(--voxcss-layer-elevation, 50px); +} +.voxcss-camera { + display: flex; + --voxcss-layer-elevation: 50px; + width: 100%; + justify-content: center; + align-items: center; + perspective: 8000px; + min-height: inherit; + height: 100%; + position: relative; + overflow: hidden; + contain: paint; + isolation: isolate; +} +.voxcss-camera * { + transform-style: preserve-3d; + position: absolute; +} + +.voxcss-projection--dimetric { + --voxcss-layer-elevation: 25px; +} + +.voxcss-shape-inner { + position: absolute; + inset: 0; + transform-style: preserve-3d; + pointer-events: none; +} + +.voxcss-shape-inner > * { + position: absolute; + inset: 0; + transform-style: preserve-3d; +} + +.voxcss-east { + --voxcss-shape-rotation: 0deg; +} + +.voxcss-south { + --voxcss-shape-rotation: 90deg; +} + +.voxcss-west { + --voxcss-shape-rotation: 180deg; +} + +.voxcss-north { + --voxcss-shape-rotation: 270deg; +} + +.voxcss-ramp .voxcss-ramp-slope, +.voxcss-ramp .voxcss-ramp-bottom, +.voxcss-wedge .voxcss-wedge-bottom, +.voxcss-spike .voxcss-spike-bottom { + background-size: cover; + background-repeat: no-repeat; + background-position: center; +} +.voxcss-ramp .voxcss-ramp-slope { + background-size: 70px 50px; +} + +.voxcss-ramp .voxcss-ramp-bottom, +.voxcss-wedge .voxcss-wedge-bottom, +.voxcss-spike .voxcss-spike-bottom { + position: absolute; + inset: 0; + transform-style: preserve-3d; + pointer-events: auto; + outline: 1px solid rgba(0, 0, 0, 0.08); + outline-offset: -1px; + backface-visibility: hidden; + transform: translateZ(calc(-1 * var(--voxcss-layer-elevation, 50px))) + rotateX(180deg); +} + +.voxcss-ramp, +.voxcss-wedge, +.voxcss-spike { + position: relative; + transform-style: preserve-3d; + backface-visibility: hidden; + pointer-events: none; + transform: translateZ(var(--voxcss-layer-elevation, 50px)) + rotate(var(--voxcss-shape-rotation, 0deg)); +} + +.voxcss-ramp .voxcss-ramp-slope { + position: absolute; + inset: 0; + transform-style: preserve-3d; + pointer-events: auto; + outline: 1px solid rgba(0, 0, 0, 0.08); + outline-offset: -1px; + background: transparent; + backface-visibility: hidden; +} + +.voxcss-wedge .voxcss-wedge-slope, +.voxcss-spike .voxcss-spike-slope { + position: absolute; + inset: 0; + transform-style: preserve-3d; + pointer-events: auto; + background: transparent; + backface-visibility: hidden; +} + +.voxcss-ramp { + --voxcss-ramp-offset: 21px; + --voxcss-ramp-angle: 45deg; +} + +.voxcss-projection--dimetric .voxcss-ramp { + --voxcss-ramp-offset: 6px; + --voxcss-ramp-angle: 26.565deg; +} + +.voxcss-ramp .voxcss-ramp-slope { + width: calc(100% + var(--voxcss-ramp-offset, 21px)); + right: calc(-1 * var(--voxcss-ramp-offset, 21px)); + transform-origin: top left; + transform: rotateY(var(--voxcss-ramp-angle, 45deg)); +} + +.voxcss-wedge { + --voxcss-wedge-offset: 21px; + --voxcss-wedge-angle: 45deg; + --voxcss-wedge-bottom-offset: 21px; + --voxcss-wedge-secondary-angle: 45deg; +} + +.voxcss-projection--dimetric .voxcss-wedge { + --voxcss-wedge-offset: 6px; + --voxcss-wedge-angle: 26.565deg; + --voxcss-wedge-bottom-offset: 6px; + --voxcss-wedge-secondary-angle: 26.565deg; +} + +.voxcss-wedge .voxcss-wedge-slope--primary { + width: calc(100% + var(--voxcss-wedge-offset, 21px)); + right: calc(-1 * var(--voxcss-wedge-offset, 21px)); + transform-origin: bottom left; + transform: rotateY(var(--voxcss-wedge-angle, 45deg)); +} + +.voxcss-wedge .voxcss-wedge-slope--secondary { + bottom: calc(-1 * var(--voxcss-wedge-bottom-offset, 21px)); + transform-origin: top left; + transform: translateZ(calc(-1 * var(--voxcss-layer-elevation, 50px))) + rotateX(var(--voxcss-wedge-secondary-angle, 45deg)); +} + +.voxcss-spike { + --voxcss-spike-offset: 21px; + --voxcss-spike-angle: 45deg; + --voxcss-spike-bottom-offset: 21px; + --voxcss-spike-secondary-angle: 45deg; +} + +.voxcss-projection--dimetric .voxcss-spike { + --voxcss-spike-offset: 6px; + --voxcss-spike-angle: 26.565deg; + --voxcss-spike-bottom-offset: 6px; + --voxcss-spike-secondary-angle: 26.565deg; +} + +.voxcss-spike .voxcss-spike-slope--primary { + width: calc(100% + var(--voxcss-spike-offset, 21px)); + right: calc(-1 * var(--voxcss-spike-offset, 21px)); + transform-origin: bottom left; + transform: rotateY(var(--voxcss-spike-angle, 45deg)); +} + +.voxcss-spike .voxcss-spike-slope--secondary { + bottom: calc(-1 * var(--voxcss-spike-bottom-offset, 21px)); + transform-origin: top left; + transform: translateZ(calc(-1 * var(--voxcss-layer-elevation, 50px))) + rotateX(var(--voxcss-spike-secondary-angle, 45deg)); +} + + +/* Wall mask visibility — applied via CSS classes on .voxcss-scene root. + Camera rotation toggles these classes directly (no Vue re-render). */ +/* Cube faces */ +.voxcss-mask-t .voxcss-cube-face--t { display: none; } +.voxcss-mask-b .voxcss-cube-face--b, +.voxcss-mask-b .voxcss-ramp-bottom, +.voxcss-mask-b .voxcss-wedge-bottom, +.voxcss-mask-b .voxcss-spike-bottom { display: none; } +.voxcss-mask-bl .voxcss-cube-face--bl { display: none; } +.voxcss-mask-br .voxcss-cube-face--br { display: none; } +.voxcss-mask-fl .voxcss-cube-face--fl { display: none; } +.voxcss-mask-fr .voxcss-cube-face--fr { display: none; } + +/* Slice renderer brushes */ +.voxcss-mask-t .voxcss-brush--t { display: none; } +.voxcss-mask-b .voxcss-brush--b { display: none; } +.voxcss-mask-bl .voxcss-brush--bl { display: none; } +.voxcss-mask-br .voxcss-brush--br { display: none; } +.voxcss-mask-fl .voxcss-brush--fl { display: none; } +.voxcss-mask-fr .voxcss-brush--fr { display: none; } + +/* Shell elements: show when mask bit is set (inverted from faces) */ +.voxcss-scene:not(.voxcss-mask-b) .voxcss-floor-z { display: none; } +.voxcss-scene:not(.voxcss-mask-t) .voxcss-ceiling { display: none; } +.voxcss-scene:not(.voxcss-mask-bl) .voxcss-wall--backLeft { display: none; } +.voxcss-scene:not(.voxcss-mask-br) .voxcss-wall--backRight { display: none; } +.voxcss-scene:not(.voxcss-mask-fl) .voxcss-wall--frontLeft { display: none; } +.voxcss-scene:not(.voxcss-mask-fr) .voxcss-wall--frontRight { display: none; } +`; diff --git a/packages/vue/tsconfig.build.json b/packages/vue/tsconfig.build.json new file mode 100644 index 0000000..a302d0b --- /dev/null +++ b/packages/vue/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["DOM", "ES2020"], + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 0000000..f7dbfdd --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["DOM", "ES2020"], + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../core" } + ] +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 0000000..ea3ccaf --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + minify: true, + target: "es2020", + tsconfig: "tsconfig.build.json", + external: ["vue", "@layoutit/voxcss-core"], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7b6f6b..33fd167 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + playwright: + specifier: ^1.58.2 + version: 1.58.2 packages/core: devDependencies: @@ -131,6 +135,25 @@ importers: '@layoutit/voxcss-react': specifier: workspace:^ version: link:../react + '@layoutit/voxcss-vue': + specifier: workspace:^ + version: link:../vue + + packages/vue: + dependencies: + '@layoutit/voxcss-core': + specifier: workspace:^ + version: link:../core + devDependencies: + tsup: + specifier: ^8.0.1 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vue: + specifier: ^3.5.12 + version: 3.5.30(typescript@5.9.3) packages: @@ -910,6 +933,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1079,6 +1107,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -2035,6 +2073,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2203,6 +2244,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-load-config@6.0.1(postcss@8.5.8): dependencies: lilconfig: 3.1.3 From 5a3428dcdc32b4844d9df577a8036373d6e992e1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 28 Mar 2026 12:05:47 +0100 Subject: [PATCH 2/9] refactor: restructure Vue package into domain folders, add behavior tests (95% coverage) --- packages/vue/package.json | 4 + packages/vue/src/VoxShape.ts | 355 ------------------ packages/vue/src/camera/VoxCamera.test.ts | 95 +++++ packages/vue/src/{ => camera}/VoxCamera.ts | 2 +- packages/vue/src/{ => camera}/context.ts | 2 +- packages/vue/src/camera/index.ts | 5 + packages/vue/src/camera/pointer.test.ts | 166 ++++++++ packages/vue/src/camera/useCamera.test.ts | 180 +++++++++ .../src/{composables => camera}/useCamera.ts | 2 +- packages/vue/src/index.ts | 27 +- packages/vue/src/scene/Ceiling.ts | 27 ++ packages/vue/src/scene/Floor.test.ts | 83 ++++ packages/vue/src/scene/Floor.ts | 142 +++++++ packages/vue/src/{ => scene}/VoxLayer.ts | 4 +- packages/vue/src/scene/VoxScene.test.ts | 165 ++++++++ packages/vue/src/{ => scene}/VoxScene.ts | 221 ++--------- packages/vue/src/scene/Walls.ts | 91 +++++ packages/vue/src/scene/index.ts | 4 + packages/vue/src/scene/integration.test.ts | 183 +++++++++ .../{composables => scene}/useSceneContext.ts | 0 packages/vue/src/shapes/Ramp.ts | 39 ++ packages/vue/src/shapes/Spike.ts | 56 +++ packages/vue/src/shapes/SvgSlope.ts | 74 ++++ packages/vue/src/{ => shapes}/VoxCube.ts | 0 packages/vue/src/shapes/VoxShape.ts | 57 +++ packages/vue/src/shapes/Wedge.ts | 56 +++ packages/vue/src/shapes/index.ts | 8 + packages/vue/src/shapes/shapes.test.ts | 236 ++++++++++++ packages/vue/src/shapes/textures.test.ts | 153 ++++++++ packages/vue/src/shapes/types.ts | 10 + packages/vue/src/shapes/utils.ts | 82 ++++ .../vue/src/slice/VoxSliceRenderer.test.ts | 166 ++++++++ .../vue/src/{ => slice}/VoxSliceRenderer.ts | 2 +- packages/vue/src/slice/index.ts | 1 + packages/vue/src/store/index.ts | 2 + packages/vue/src/{ => store}/sceneStore.ts | 0 packages/vue/src/styles/colorResolver.test.ts | 89 +++++ .../vue/src/{ => styles}/colorResolver.ts | 0 packages/vue/src/styles/index.ts | 2 + packages/vue/src/{ => styles}/styles.ts | 0 packages/vue/vitest.config.ts | 14 + pnpm-lock.yaml | 90 ++++- 42 files changed, 2338 insertions(+), 557 deletions(-) delete mode 100644 packages/vue/src/VoxShape.ts create mode 100644 packages/vue/src/camera/VoxCamera.test.ts rename packages/vue/src/{ => camera}/VoxCamera.ts (97%) rename packages/vue/src/{ => camera}/context.ts (87%) create mode 100644 packages/vue/src/camera/index.ts create mode 100644 packages/vue/src/camera/pointer.test.ts create mode 100644 packages/vue/src/camera/useCamera.test.ts rename packages/vue/src/{composables => camera}/useCamera.ts (99%) create mode 100644 packages/vue/src/scene/Ceiling.ts create mode 100644 packages/vue/src/scene/Floor.test.ts create mode 100644 packages/vue/src/scene/Floor.ts rename packages/vue/src/{ => scene}/VoxLayer.ts (92%) create mode 100644 packages/vue/src/scene/VoxScene.test.ts rename packages/vue/src/{ => scene}/VoxScene.ts (51%) create mode 100644 packages/vue/src/scene/Walls.ts create mode 100644 packages/vue/src/scene/index.ts create mode 100644 packages/vue/src/scene/integration.test.ts rename packages/vue/src/{composables => scene}/useSceneContext.ts (100%) create mode 100644 packages/vue/src/shapes/Ramp.ts create mode 100644 packages/vue/src/shapes/Spike.ts create mode 100644 packages/vue/src/shapes/SvgSlope.ts rename packages/vue/src/{ => shapes}/VoxCube.ts (100%) create mode 100644 packages/vue/src/shapes/VoxShape.ts create mode 100644 packages/vue/src/shapes/Wedge.ts create mode 100644 packages/vue/src/shapes/index.ts create mode 100644 packages/vue/src/shapes/shapes.test.ts create mode 100644 packages/vue/src/shapes/textures.test.ts create mode 100644 packages/vue/src/shapes/types.ts create mode 100644 packages/vue/src/shapes/utils.ts create mode 100644 packages/vue/src/slice/VoxSliceRenderer.test.ts rename packages/vue/src/{ => slice}/VoxSliceRenderer.ts (99%) create mode 100644 packages/vue/src/slice/index.ts create mode 100644 packages/vue/src/store/index.ts rename packages/vue/src/{ => store}/sceneStore.ts (100%) create mode 100644 packages/vue/src/styles/colorResolver.test.ts rename packages/vue/src/{ => styles}/colorResolver.ts (100%) create mode 100644 packages/vue/src/styles/index.ts rename packages/vue/src/{ => styles}/styles.ts (100%) create mode 100644 packages/vue/vitest.config.ts diff --git a/packages/vue/package.json b/packages/vue/package.json index ba4a18a..d35f5be 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -24,6 +24,7 @@ }, "scripts": { "build": "tsup", + "test": "vitest run", "prepublishOnly": "npm run build" }, "publishConfig": { @@ -36,8 +37,11 @@ "vue": "^3.0.0" }, "devDependencies": { + "@vitest/coverage-v8": "^3.2.4", + "happy-dom": "^20.8.9", "tsup": "^8.0.1", "typescript": "^5.3.3", + "vitest": "^3.2.4", "vue": "^3.5.12" } } diff --git a/packages/vue/src/VoxShape.ts b/packages/vue/src/VoxShape.ts deleted file mode 100644 index 1a87b1e..0000000 --- a/packages/vue/src/VoxShape.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { defineComponent, h } from "vue"; -import type { PropType } from "vue"; -import type { GridContext, Voxel, ShapeType, ShapeSurfaceLighting } from "@layoutit/voxcss-core"; -import { getVoxelBounds, computeShapeLighting } from "@layoutit/voxcss-core"; - -const ORIENTATION_MAP: Record = { - 0: "east", - 90: "south", - 180: "west", - 270: "north", -}; - -function normalizeRotation(value: number | undefined): number { - if (!Number.isFinite(value)) return 0; - const snapped = Math.round((value as number) / 90) * 90; - return ((snapped % 360) + 360) % 360; -} - -function isCovered(voxel: Voxel, context: GridContext): boolean { - const { x2, y2 } = getVoxelBounds(voxel); - const layerAbove = Math.max(0, Math.floor((voxel.z ?? 0) + 1)); - for (let row = voxel.x; row < x2; row += 1) { - for (let col = voxel.y; col < y2; col += 1) { - if (context.getVoxel(row, col, layerAbove)) return true; - } - } - return false; -} - -function isBottomOccluded(voxel: Voxel, context: GridContext): boolean { - const targetZ = Math.floor((voxel.z ?? 0) - 1); - if (targetZ < 0) return false; - const { x2, y2 } = getVoxelBounds(voxel); - for (let x = voxel.x; x < x2; x += 1) { - for (let y = voxel.y; y < y2; y += 1) { - if (!context.getVoxel(x, y, targetZ)) return false; - } - } - return true; -} - -function shouldRenderBottom(voxel: Voxel, context: GridContext): boolean { - if (context.walls?.b) return false; - return !isBottomOccluded(voxel, context); -} - -function getSurfaceColor(lighting: ShapeSurfaceLighting[], surfaceId: string, fallback: string): string { - return lighting.find((s) => s.id === surfaceId)?.color ?? fallback; -} - -function getSurfaceDelta(lighting: ShapeSurfaceLighting[], surfaceId: string): number { - return lighting.find((s) => s.id === surfaceId)?.delta ?? 0; -} - -function resolveSurfaceTexture( - voxel: Voxel, - surfaceId: string, - context: GridContext -): string | undefined { - const textureKey = voxel.texture; - if (!textureKey || textureKey.startsWith("#")) return undefined; - const resolved = context.resolveTexture?.(textureKey, surfaceId); - if (resolved) return resolved; - if ( - textureKey.startsWith("/") || - textureKey.startsWith("./") || - textureKey.startsWith("../") || - textureKey.startsWith("http://") || - textureKey.startsWith("https://") || - textureKey.startsWith("data:") || - textureKey.includes(".") - ) { - return textureKey; - } - return undefined; -} - -function textureBrightnessFilter(delta: number): string | undefined { - const brightness = Math.max(0, 1 + delta / 200); - if (Math.abs(brightness - 1) < 0.001) return undefined; - const rounded = Math.round(brightness * 1000) / 1000; - return `brightness(${rounded})`; -} - -let _patternIdCounter = 0; -function nextPatternId(): string { - return `vox-pat-${++_patternIdCounter}`; -} - -function renderSvgSlope( - className: string, - path: string, - fill: string, - viewBox = "0 0 480 480", - width = "56", - height = "50", - textureUrl?: string, - brightnessDelta = 0, -) { - const patternId = textureUrl ? nextPatternId() : ""; - const effectiveFill = textureUrl ? `url(#${patternId})` : fill; - const filter = textureUrl ? textureBrightnessFilter(brightnessDelta) : undefined; - - const defs = textureUrl - ? h("defs", null, [ - h("pattern", { - id: patternId, - patternUnits: "objectBoundingBox", - patternContentUnits: "objectBoundingBox", - width: "1", - height: "1", - }, [ - h("image", { - width: "1", - height: "1", - preserveAspectRatio: "xMidYMid slice", - href: textureUrl, - }), - ]), - ]) - : null; - - return h("div", { class: className, style: { filter } }, [ - h( - "svg", - { - viewBox, - width, - height, - preserveAspectRatio: "none", - xmlns: "http://www.w3.org/2000/svg", - "aria-hidden": "true", - focusable: "false", - style: { - position: "absolute", - inset: "0", - width: "100%", - height: "100%", - display: "block", - pointerEvents: "none", - }, - }, - [ - defs, - h("path", { - d: path, - fill: effectiveFill, - stroke: "rgba(0, 0, 0, 0.1)", - "stroke-width": "1", - "vector-effect": "non-scaling-stroke", - }), - ] - ), - ]); -} - -function renderRamp( - voxel: Voxel, - context: GridContext, - baseColor: string, - lighting: ShapeSurfaceLighting[], - showBottom: boolean, -) { - const slopeColor = getSurfaceColor(lighting, "slope", baseColor); - const slopeDelta = getSurfaceDelta(lighting, "slope"); - const slopeTexture = resolveSurfaceTexture(voxel, "slope", context); - const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); - - const children = []; - if (showBottom) { - children.push( - h("div", { - class: "voxcss-ramp-bottom", - style: { - backgroundColor: bottomTexture ? undefined : baseColor, - backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, - filter: bottomTexture ? textureBrightnessFilter(0) : undefined, - }, - }) - ); - } - children.push( - h("div", { - class: "voxcss-ramp-slope", - style: { - backgroundColor: slopeTexture ? undefined : slopeColor, - backgroundImage: slopeTexture ? `url(${slopeTexture})` : undefined, - backgroundSize: "70px 50px", - filter: slopeTexture ? textureBrightnessFilter(slopeDelta) : undefined, - }, - }) - ); - - return children; -} - -function renderWedge( - voxel: Voxel, - context: GridContext, - baseColor: string, - lighting: ShapeSurfaceLighting[], - showBottom: boolean, -) { - const primaryColor = getSurfaceColor(lighting, "primary", baseColor); - const secondaryColor = getSurfaceColor(lighting, "secondary", baseColor); - const primaryDelta = getSurfaceDelta(lighting, "primary"); - const secondaryDelta = getSurfaceDelta(lighting, "secondary"); - const primaryTexture = resolveSurfaceTexture(voxel, "primary", context); - const secondaryTexture = resolveSurfaceTexture(voxel, "secondary", context); - const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); - - const children = []; - if (showBottom) { - children.push( - h("div", { - class: "voxcss-wedge-bottom", - style: { - backgroundColor: bottomTexture ? undefined : baseColor, - backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, - filter: bottomTexture ? textureBrightnessFilter(0) : undefined, - }, - }) - ); - } - children.push( - renderSvgSlope( - "voxcss-wedge-slope voxcss-wedge-slope--primary", - "M0 0 L480 0 L0 480 Z", - primaryColor, - undefined, - undefined, - undefined, - primaryTexture, - primaryDelta, - ) - ); - children.push( - renderSvgSlope( - "voxcss-wedge-slope voxcss-wedge-slope--secondary", - "M480 480 L0 480 L480 0 Z", - secondaryColor, - undefined, - "50", - "56", - secondaryTexture, - secondaryDelta, - ) - ); - - return children; -} - -function renderSpike( - voxel: Voxel, - context: GridContext, - baseColor: string, - lighting: ShapeSurfaceLighting[], - showBottom: boolean, -) { - const primaryColor = getSurfaceColor(lighting, "primary", baseColor); - const secondaryColor = getSurfaceColor(lighting, "secondary", baseColor); - const primaryDelta = getSurfaceDelta(lighting, "primary"); - const secondaryDelta = getSurfaceDelta(lighting, "secondary"); - const primaryTexture = resolveSurfaceTexture(voxel, "primary", context); - const secondaryTexture = resolveSurfaceTexture(voxel, "secondary", context); - const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); - - const children = []; - if (showBottom) { - children.push( - h("div", { - class: "voxcss-spike-bottom", - style: { - backgroundColor: bottomTexture ? undefined : baseColor, - backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, - filter: bottomTexture ? textureBrightnessFilter(0) : undefined, - }, - }) - ); - } - children.push( - renderSvgSlope( - "voxcss-spike-slope voxcss-spike-slope--primary", - "M480 0 L480 480 L0 480 Z", - primaryColor, - undefined, - undefined, - undefined, - primaryTexture, - primaryDelta, - ) - ); - children.push( - renderSvgSlope( - "voxcss-spike-slope voxcss-spike-slope--secondary", - "M0 0 L0 480 L480 0 Z", - secondaryColor, - undefined, - "50", - "56", - secondaryTexture, - secondaryDelta, - ) - ); - - return children; -} - -export const VoxShape = defineComponent({ - name: "VoxShape", - props: { - voxel: { type: Object as PropType, required: true }, - context: { type: Object as PropType, required: true }, - }, - setup(props) { - return () => { - const shapeKey = props.voxel.shape ?? "cube"; - if (shapeKey === "cube") return null; - const shape = shapeKey as ShapeType; - - if (isCovered(props.voxel, props.context)) return null; - - const { x2, y2 } = getVoxelBounds(props.voxel); - const rawRotation = Number.isFinite(props.voxel.rot as number) ? Number(props.voxel.rot) : 0; - const rotation = normalizeRotation(rawRotation); - const orientation = ORIENTATION_MAP[rotation] ?? "east"; - const baseColor = props.voxel.color ?? "#cccccc"; - const lighting = computeShapeLighting(shape, rawRotation, baseColor); - const showBottom = shouldRenderBottom(props.voxel, props.context); - - // Shape class + orientation on the SAME root div (matches HTML renderer's mountToRoot) - let shapeClass: string; - let children: any[]; - if (shape === "ramp") { - shapeClass = "voxcss-ramp"; - children = renderRamp(props.voxel, props.context, baseColor, lighting, showBottom); - } else if (shape === "wedge") { - shapeClass = "voxcss-wedge"; - children = renderWedge(props.voxel, props.context, baseColor, lighting, showBottom); - } else { - shapeClass = "voxcss-spike"; - children = renderSpike(props.voxel, props.context, baseColor, lighting, showBottom); - } - - return h( - "div", - { - class: `voxcss-${orientation} ${shapeClass}`, - style: { gridArea: `${props.voxel.x} / ${props.voxel.y} / ${x2} / ${y2}` }, - }, - children - ); - }; - }, -}); diff --git a/packages/vue/src/camera/VoxCamera.test.ts b/packages/vue/src/camera/VoxCamera.test.ts new file mode 100644 index 0000000..97e843f --- /dev/null +++ b/packages/vue/src/camera/VoxCamera.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h } from "vue"; +import { VoxCamera } from "./VoxCamera"; + +function renderToDiv(cameraProps: Record = {}, children?: any[]): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => h(VoxCamera, cameraProps, { + default: () => children ?? [h("div")], + }); + }, + }); + app.mount(container); + return container; +} + +describe("VoxCamera behavior", () => { + describe("renders camera wrapper", () => { + it("has the voxcss-camera class", () => { + const container = renderToDiv(); + expect(container.querySelector(".voxcss-camera")).toBeTruthy(); + }); + }); + + describe("perspective", () => { + it("applies default perspective of 8000px when perspective is explicitly set to true", () => { + // In Vue, Boolean props default to false when absent. + // Passing perspective={true} triggers the default 8000px path. + const container = renderToDiv({ perspective: true }); + const camera = container.querySelector(".voxcss-camera") as HTMLElement; + expect(camera.style.perspective).toBe("8000px"); + }); + + it("applies a custom numeric perspective value", () => { + const container = renderToDiv({ perspective: 3000 }); + const camera = container.querySelector(".voxcss-camera") as HTMLElement; + expect(camera.style.perspective).toBe("3000px"); + }); + + it("sets perspective to none when false", () => { + const container = renderToDiv({ perspective: false }); + const camera = container.querySelector(".voxcss-camera") as HTMLElement; + expect(camera.style.perspective).toBe("none"); + }); + }); + + describe("interactive mode", () => { + it("sets cursor to grab when interactive", () => { + const container = renderToDiv({ interactive: true }); + const camera = container.querySelector(".voxcss-camera") as HTMLElement; + expect(camera.style.cursor).toBe("grab"); + }); + + it("sets touch-action to none when interactive", () => { + const container = renderToDiv({ interactive: true }); + const camera = container.querySelector(".voxcss-camera") as HTMLElement; + expect(camera.style.touchAction).toBe("none"); + }); + + it("does not set cursor or touch-action when not interactive", () => { + const container = renderToDiv(); + const camera = container.querySelector(".voxcss-camera") as HTMLElement; + expect(camera.style.cursor).toBe(""); + expect(camera.style.touchAction).toBe(""); + }); + }); + + describe("children", () => { + it("renders children inside the camera wrapper", () => { + const container = renderToDiv({}, [h("span", { class: "inner-child" }, "Hello")]); + const child = container.querySelector(".voxcss-camera .inner-child"); + expect(child).toBeTruthy(); + expect(child?.textContent).toBe("Hello"); + }); + + it("renders multiple children", () => { + const container = renderToDiv({}, [ + h("div", { class: "a" }), + h("div", { class: "b" }), + ]); + expect(container.querySelector(".a")).toBeTruthy(); + expect(container.querySelector(".b")).toBeTruthy(); + }); + }); + + describe("custom className", () => { + it("appends custom className alongside voxcss-camera", () => { + const container = renderToDiv({ class: "my-custom-class" }); + const camera = container.querySelector(".voxcss-camera"); + expect(camera?.classList.contains("my-custom-class")).toBe(true); + expect(camera?.classList.contains("voxcss-camera")).toBe(true); + }); + }); +}); diff --git a/packages/vue/src/VoxCamera.ts b/packages/vue/src/camera/VoxCamera.ts similarity index 97% rename from packages/vue/src/VoxCamera.ts rename to packages/vue/src/camera/VoxCamera.ts index 6cec8ec..f1aa355 100644 --- a/packages/vue/src/VoxCamera.ts +++ b/packages/vue/src/camera/VoxCamera.ts @@ -1,7 +1,7 @@ import { defineComponent, h, provide, computed } from "vue"; import type { PropType } from "vue"; import type { AutoRotateOption } from "@layoutit/voxcss-core"; -import { useCamera } from "./composables/useCamera"; +import { useCamera } from "./useCamera"; import { VoxCameraContextKey } from "./context"; const DEFAULT_PERSPECTIVE = 8000; diff --git a/packages/vue/src/context.ts b/packages/vue/src/camera/context.ts similarity index 87% rename from packages/vue/src/context.ts rename to packages/vue/src/camera/context.ts index 06f9e26..e31326b 100644 --- a/packages/vue/src/context.ts +++ b/packages/vue/src/camera/context.ts @@ -1,6 +1,6 @@ import type { InjectionKey, Ref } from "vue"; import type { CameraHandle } from "@layoutit/voxcss-core"; -import type { SceneStore } from "./sceneStore"; +import type { SceneStore } from "../store"; export interface VoxCameraContextValue { store: SceneStore; diff --git a/packages/vue/src/camera/index.ts b/packages/vue/src/camera/index.ts new file mode 100644 index 0000000..5e0f0fe --- /dev/null +++ b/packages/vue/src/camera/index.ts @@ -0,0 +1,5 @@ +export { VoxCamera } from "./VoxCamera"; +export { useCamera } from "./useCamera"; +export type { UseCameraOptions, UseCameraResult } from "./useCamera"; +export { VoxCameraContextKey } from "./context"; +export type { VoxCameraContextValue } from "./context"; diff --git a/packages/vue/src/camera/pointer.test.ts b/packages/vue/src/camera/pointer.test.ts new file mode 100644 index 0000000..ae9a442 --- /dev/null +++ b/packages/vue/src/camera/pointer.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createApp, h, computed } from "vue"; +import { useCamera } from "./useCamera"; +import type { UseCameraResult, UseCameraOptions } from "./useCamera"; + +function makePointerEvent( + type: string, + overrides: Partial<{ + clientX: number; + clientY: number; + pointerId: number; + isPrimary: boolean; + }> = {} +): PointerEvent { + return { + clientX: overrides.clientX ?? 0, + clientY: overrides.clientY ?? 0, + pointerId: overrides.pointerId ?? 1, + isPrimary: overrides.isPrimary ?? true, + preventDefault: vi.fn(), + currentTarget: { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn(), + }, + } as unknown as PointerEvent; +} + +function mountCamera(options: UseCameraOptions = {}): UseCameraResult { + let captured: UseCameraResult | null = null; + const container = document.createElement("div"); + const app = createApp({ + setup() { + captured = useCamera(computed(() => options)); + return () => h("div"); + }, + }); + app.mount(container); + return captured!; +} + +describe("pointer interaction behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("pointer down sets dragging state", () => { + it("changes cursor to grabbing after pointer down", () => { + const result = mountCamera({ interactive: true }); + expect(result.cursor.value).toBe("grab"); + + const downEvent = makePointerEvent("pointerdown"); + result.onPointerDown(downEvent); + + expect(result.cursor.value).toBe("grabbing"); + }); + }); + + describe("pointer move during drag updates camera rotation", () => { + it("updates rotY when dragging horizontally", () => { + const result = mountCamera({ interactive: true, rotY: 45 }); + const initialRotY = result.cameraRef.value.state.rotY; + + const downEvent = makePointerEvent("pointerdown", { clientX: 100, clientY: 100 }); + result.onPointerDown(downEvent); + + const moveEvent = makePointerEvent("pointermove", { clientX: 150, clientY: 100 }); + result.onPointerMove(moveEvent); + + const updatedRotY = result.cameraRef.value.state.rotY; + expect(updatedRotY).not.toBe(initialRotY); + }); + }); + + describe("pointer up ends drag", () => { + it("restores cursor to grab after pointer up", () => { + const result = mountCamera({ interactive: true }); + + const downEvent = makePointerEvent("pointerdown"); + result.onPointerDown(downEvent); + expect(result.cursor.value).toBe("grabbing"); + + const upEvent = makePointerEvent("pointerup"); + result.onPointerUp(upEvent); + + expect(result.cursor.value).toBe("grab"); + }); + }); + + describe("pointer move without prior down does nothing", () => { + it("does not change camera rotation when not dragging", () => { + const result = mountCamera({ interactive: true, rotY: 45 }); + const initialRotY = result.cameraRef.value.state.rotY; + + const moveEvent = makePointerEvent("pointermove", { clientX: 200, clientY: 200 }); + result.onPointerMove(moveEvent); + + expect(result.cameraRef.value.state.rotY).toBe(initialRotY); + }); + }); + + describe("invert option reverses drag direction", () => { + it("moves rotation in opposite direction when invert is true", () => { + const resultNormal = mountCamera({ interactive: true, invert: false }); + const normalInitialRotY = resultNormal.cameraRef.value.state.rotY; + + const downNormal = makePointerEvent("pointerdown", { clientX: 100, clientY: 100 }); + resultNormal.onPointerDown(downNormal); + const moveNormal = makePointerEvent("pointermove", { clientX: 200, clientY: 100 }); + resultNormal.onPointerMove(moveNormal); + + const normalDelta = resultNormal.cameraRef.value.state.rotY - normalInitialRotY; + + const resultInvert = mountCamera({ interactive: true, invert: true }); + const invertInitialRotY = resultInvert.cameraRef.value.state.rotY; + + const downInvert = makePointerEvent("pointerdown", { clientX: 100, clientY: 100 }); + resultInvert.onPointerDown(downInvert); + const moveInvert = makePointerEvent("pointermove", { clientX: 200, clientY: 100 }); + resultInvert.onPointerMove(moveInvert); + + const invertDelta = resultInvert.cameraRef.value.state.rotY - invertInitialRotY; + + expect(normalDelta).not.toBe(0); + expect(Math.sign(invertDelta)).toBe(-Math.sign(normalDelta)); + }); + }); + + describe("animation pauses on pointer interaction", () => { + it("pauses auto-rotate when pauseOnInteraction is true and pointer down occurs", () => { + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => { + callbacks.push(cb); + return callbacks.length; + }); + + const result = mountCamera({ + interactive: true, + rotY: 0, + animate: { speed: 1, pauseOnInteraction: true }, + }); + + // Run a few animation frames to confirm rotation advances + for (let i = 0; i < 5 && callbacks.length > 0; i++) { + const cb = callbacks.shift()!; + cb(performance.now()); + } + + const rotYBeforePause = result.cameraRef.value.state.rotY; + expect(rotYBeforePause).not.toBe(0); + + // Now trigger pointer down to pause + const downEvent = makePointerEvent("pointerdown"); + result.onPointerDown(downEvent); + + // Run more animation frames -- rotation should NOT advance further + const rotYAtPause = result.cameraRef.value.state.rotY; + for (let i = 0; i < 5 && callbacks.length > 0; i++) { + const cb = callbacks.shift()!; + cb(performance.now()); + } + + const rotYAfterPause = result.cameraRef.value.state.rotY; + expect(rotYAfterPause).toBe(rotYAtPause); + }); + }); +}); diff --git a/packages/vue/src/camera/useCamera.test.ts b/packages/vue/src/camera/useCamera.test.ts new file mode 100644 index 0000000..e753778 --- /dev/null +++ b/packages/vue/src/camera/useCamera.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createApp, h, ref, computed, nextTick } from "vue"; +import type { Ref } from "vue"; +import { useCamera } from "./useCamera"; +import type { UseCameraResult, UseCameraOptions } from "./useCamera"; + +function captureHook(options: UseCameraOptions = {}): UseCameraResult { + let captured: UseCameraResult | null = null; + const container = document.createElement("div"); + const optionsRef = computed(() => options); + const app = createApp({ + setup() { + captured = useCamera(optionsRef); + return () => h("div"); + }, + }); + app.mount(container); + return captured!; +} + +describe("useCamera behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("default camera state", () => { + it("starts with standard defaults for zoom, rotation, pan, tilt, and depthOffset", () => { + const result = captureHook(); + const state = result.store.getState().cameraState; + expect(state.zoom).toBe(0.65); + expect(state.rotX).toBe(65); + expect(state.rotY).toBe(45); + expect(state.pan).toBe(0); + expect(state.tilt).toBe(0); + expect(state.depthOffset).toBe(20); + }); + + it("returns grab cursor by default (useCamera always starts with grab)", () => { + const result = captureHook(); + expect(result.cursor.value).toBe("grab"); + }); + }); + + describe("initial camera props", () => { + it("applies custom zoom", () => { + const result = captureHook({ zoom: 2.0 }); + expect(result.store.getState().cameraState.zoom).toBe(2.0); + }); + + it("applies custom rotX and rotY", () => { + const result = captureHook({ rotX: 30, rotY: 120 }); + const state = result.store.getState().cameraState; + expect(state.rotX).toBe(30); + expect(state.rotY).toBe(120); + }); + + it("applies custom pan and tilt", () => { + const result = captureHook({ pan: 10, tilt: 5 }); + const state = result.store.getState().cameraState; + expect(state.pan).toBe(10); + expect(state.tilt).toBe(5); + }); + }); + + describe("prop changes update camera handle", () => { + it("updates the camera handle when rotation props change across a wall-mask boundary", async () => { + const opts = ref({ rotX: 65, rotY: 45 }); + let captured: UseCameraResult | null = null; + const container = document.createElement("div"); + const app = createApp({ + setup() { + captured = useCamera(computed(() => opts.value)); + return () => h("div"); + }, + }); + app.mount(container); + + const maskBefore = captured!.store.getState().wallMask; + + opts.value = { rotX: 65, rotY: 135 }; + await nextTick(); + + const maskAfter = captured!.store.getState().wallMask; + expect(maskAfter).not.toEqual(maskBefore); + expect(captured!.cameraRef.value.state.rotY).toBe(135); + }); + + it("updates the camera handle zoom directly", async () => { + const opts = ref({ zoom: 1.0 }); + let captured: UseCameraResult | null = null; + const container = document.createElement("div"); + const app = createApp({ + setup() { + captured = useCamera(computed(() => opts.value)); + return () => h("div"); + }, + }); + app.mount(container); + + expect(captured!.cameraRef.value.state.zoom).toBe(1.0); + + opts.value = { zoom: 2.5 }; + await nextTick(); + + expect(captured!.cameraRef.value.state.zoom).toBe(2.5); + }); + }); + + describe("cursor changes based on interaction state", () => { + it("shows grab cursor when interactive", () => { + const result = captureHook({ interactive: true }); + expect(result.cursor.value).toBe("grab"); + }); + + it("shows grab cursor when not interactive (cursor is always grab in useCamera)", () => { + const result = captureHook({ interactive: false }); + // The Vue useCamera always starts with "grab" — the VoxCamera component controls + // whether to apply it based on the interactive prop. + expect(result.cursor.value).toBe("grab"); + }); + }); + + describe("auto-rotate", () => { + it("schedules animation frame when animate is true", () => { + const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation(() => 1); + const container = document.createElement("div"); + const app = createApp({ + setup() { + useCamera(computed(() => ({ animate: true } as UseCameraOptions))); + return () => h("div"); + }, + }); + app.mount(container); + + expect(rafSpy).toHaveBeenCalled(); + }); + + it("does not schedule animation when animate is false", () => { + const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation(() => 1); + const container = document.createElement("div"); + const app = createApp({ + setup() { + useCamera(computed(() => ({ animate: false } as UseCameraOptions))); + return () => h("div"); + }, + }); + app.mount(container); + + expect(rafSpy).not.toHaveBeenCalled(); + }); + + it("updates the camera handle rotation over time via animation frames", () => { + let captured: UseCameraResult | null = null; + const callbacks: FrameRequestCallback[] = []; + vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => { + callbacks.push(cb); + return callbacks.length; + }); + + const container = document.createElement("div"); + const app = createApp({ + setup() { + captured = useCamera(computed(() => ({ rotY: 0, animate: true } as UseCameraOptions))); + return () => h("div"); + }, + }); + app.mount(container); + + const initialRotY = captured!.cameraRef.value.state.rotY; + + for (let i = 0; i < 10 && callbacks.length > 0; i++) { + const cb = callbacks.shift()!; + cb(performance.now()); + } + + const updatedRotY = captured!.cameraRef.value.state.rotY; + expect(updatedRotY).not.toBe(initialRotY); + }); + }); +}); diff --git a/packages/vue/src/composables/useCamera.ts b/packages/vue/src/camera/useCamera.ts similarity index 99% rename from packages/vue/src/composables/useCamera.ts rename to packages/vue/src/camera/useCamera.ts index 23f4941..fd561f1 100644 --- a/packages/vue/src/composables/useCamera.ts +++ b/packages/vue/src/camera/useCamera.ts @@ -2,7 +2,7 @@ import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from "vue"; import type { Ref } from "vue"; import { createIsometricCamera } from "@layoutit/voxcss-core"; import type { CameraState, CameraHandle, AutoRotateOption, AutoRotateConfig } from "@layoutit/voxcss-core"; -import { createSceneStore, type SceneStore } from "../sceneStore"; +import { createSceneStore, type SceneStore } from "../store"; const POINTER_DRAG_SPEED = 5; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index fc48052..6990406 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,20 +1,21 @@ // Register DOM-based color resolver for named CSS colors import { setColorResolver } from "@layoutit/voxcss-core"; -import { resolveColor } from "./colorResolver"; +import { resolveColor } from "./styles"; setColorResolver(resolveColor); -export { VoxCamera } from "./VoxCamera"; -export { VoxScene } from "./VoxScene"; -export { VoxLayer } from "./VoxLayer"; -export { VoxCube } from "./VoxCube"; -export { VoxShape } from "./VoxShape"; -export { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "./VoxSliceRenderer"; -export { useCamera } from "./composables/useCamera"; -export type { UseCameraOptions, UseCameraResult } from "./composables/useCamera"; -export { useSceneContext } from "./composables/useSceneContext"; -export type { UseSceneContextOptions } from "./composables/useSceneContext"; -export { VoxCameraContextKey } from "./context"; -export type { VoxCameraContextValue } from "./context"; +export { VoxCamera } from "./camera"; +export { useCamera } from "./camera"; +export type { UseCameraOptions, UseCameraResult } from "./camera"; +export { VoxCameraContextKey } from "./camera"; +export type { VoxCameraContextValue } from "./camera"; + +export { VoxScene, VoxLayer, useSceneContext } from "./scene"; +export type { UseSceneContextOptions } from "./scene"; + +export { VoxCube, VoxShape } from "./shapes"; + +export { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "./slice"; + export { injectBaseStyles } from "./styles"; // Re-export commonly used core types for convenience diff --git a/packages/vue/src/scene/Ceiling.ts b/packages/vue/src/scene/Ceiling.ts new file mode 100644 index 0000000..bdb9026 --- /dev/null +++ b/packages/vue/src/scene/Ceiling.ts @@ -0,0 +1,27 @@ +import { h } from "vue"; +import type { VNode } from "vue"; +import { shadeColor } from "@layoutit/voxcss-core"; + +const FLOOR_BASE_DELTA = 120; + +export interface CeilingOptions { + wallColor: string; + dimensions: { rows: number; cols: number; depth: number }; + tileSize: number; +} + +export function renderCeiling(opts: CeilingOptions): VNode { + const { wallColor, dimensions, tileSize } = opts; + const ceilingColor = shadeColor(wallColor, FLOOR_BASE_DELTA); + return h("div", { + key: "ceiling", + class: "voxcss-ceiling", + style: { + width: `${dimensions.cols * tileSize}px`, + height: `${dimensions.rows * tileSize}px`, + transform: `translateZ(${dimensions.depth * tileSize}px)`, + "--voxcss-ceiling-base": ceilingColor, + "--voxcss-ceiling-opacity": "0.35", + }, + }); +} diff --git a/packages/vue/src/scene/Floor.test.ts b/packages/vue/src/scene/Floor.test.ts new file mode 100644 index 0000000..2c616ac --- /dev/null +++ b/packages/vue/src/scene/Floor.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h } from "vue"; +import type { Voxel } from "@layoutit/voxcss-core"; +import { VoxCamera } from "../camera/VoxCamera"; +import { VoxScene } from "./VoxScene"; + +function renderScene( + sceneProps: Record, + cameraProps: Record = {} +): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, cameraProps, { + default: () => h(VoxScene, sceneProps), + }); + }, + }); + app.mount(container); + return container; +} + +describe("Floor behavior", () => { + describe("floor custom properties when visible", () => { + it("has --voxcss-floor-base custom property when showFloor is true", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0, color: "#ff0000" }]; + const container = renderScene({ voxels, showFloor: true }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + const floorBase = floor.style.getPropertyValue("--voxcss-floor-base"); + expect(floorBase).toBeTruthy(); + expect(floorBase).not.toBe(""); + }); + + it("has --voxcss-grid-x and --voxcss-grid-y CSS custom properties when visible", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0, color: "#ff0000" }]; + const container = renderScene({ voxels, showFloor: true }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + const gridX = floor.style.getPropertyValue("--voxcss-grid-x"); + const gridY = floor.style.getPropertyValue("--voxcss-grid-y"); + expect(gridX).toContain("px"); + expect(gridY).toContain("px"); + }); + }); + + describe("floor background when not visible", () => { + it("has background:none when showFloor is false", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0, color: "#ff0000" }]; + const container = renderScene({ voxels, showFloor: false }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + expect(floor.style.background).toContain("none"); + }); + }); + + describe("large grids suppress floor grid sprite", () => { + it("does not set --voxcss-floor-grid for grids larger than 20x20", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 25, y: 25, z: 0, color: "#00ff00" }, + ]; + const container = renderScene({ voxels, showFloor: true }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + const floorGrid = floor.style.getPropertyValue("--voxcss-floor-grid"); + expect(floorGrid).toBe(""); + }); + + it("sets --voxcss-floor-grid for small grids", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 3, y: 3, z: 0, color: "#00ff00" }, + ]; + const container = renderScene({ voxels, showFloor: true }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + const floorGrid = floor.style.getPropertyValue("--voxcss-floor-grid"); + expect(floorGrid).toContain("url("); + }); + }); +}); diff --git a/packages/vue/src/scene/Floor.ts b/packages/vue/src/scene/Floor.ts new file mode 100644 index 0000000..c0a2e2d --- /dev/null +++ b/packages/vue/src/scene/Floor.ts @@ -0,0 +1,142 @@ +import { h, computed, ref } from "vue"; +import type { Ref, VNode } from "vue"; +import type { Voxel, GridContext, PlaneAxis } from "@layoutit/voxcss-core"; +import { shadeColor } from "@layoutit/voxcss-core"; +import { buildGridSvgDataUrl } from "./VoxScene"; +import { VoxLayer } from "./VoxLayer"; +import { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "../slice"; +import type { SceneStore } from "../store"; + +const FLOOR_BASE_DELTA = 120; +const FLOOR_GRID_ALPHA = 0.12; + +const X_AXES = new Set(["x"]); +const Y_AXES = new Set(["y"]); + +export interface FloorOptions { + layers: Voxel[][]; + context: GridContext; + dimensions: { rows: number; cols: number; depth: number }; + showFloor: boolean; + wallMask: { b: boolean; t: boolean }; + wallColor: string; + tileSize: number; + layerElevation: number; + disableGrid: boolean; + is3d: boolean; + store: SceneStore; + sliceBrushes: { plans: Ref }; + floorRef: Ref; +} + +export function renderFloor(opts: FloorOptions): VNode[] { + const { + layers, + context, + dimensions, + showFloor, + wallMask, + wallColor, + tileSize, + layerElevation, + disableGrid, + is3d, + store, + sliceBrushes, + floorRef, + } = opts; + + const floorVisible = showFloor && wallMask.b; + const floorColor = floorVisible ? shadeColor(wallColor, FLOOR_BASE_DELTA) : undefined; + const floorGrid = floorVisible && !disableGrid + ? buildGridSvgDataUrl(tileSize, tileSize, FLOOR_GRID_ALPHA) + : undefined; + + const sceneChildren: VNode[] = []; + + // Floor div children + const floorChildren: any[] = []; + if (is3d) { + floorChildren.push( + h(SliceZBrushes, { + key: "slice-z", + floorRef, + plans: sliceBrushes.plans.value, + store: store as SceneStore, + tileSize, + layerElevation, + }) + ); + } else { + for (let i = 0; i < layers.length; i++) { + floorChildren.push( + h(VoxLayer, { key: i, layerIndex: i, voxels: layers[i], context }) + ); + } + } + + const floorStyle: Record = { + "--voxcss-floor-base": floorColor, + "--voxcss-grid-x": floorVisible ? `${tileSize}px` : undefined, + "--voxcss-grid-y": floorVisible ? `${tileSize}px` : undefined, + "--voxcss-floor-grid": floorGrid, + background: floorVisible ? undefined : "none", + pointerEvents: "none", + }; + + if (is3d) { + floorStyle.display = "grid"; + floorStyle.gridTemplateColumns = `repeat(${dimensions.cols}, ${tileSize}px)`; + floorStyle.gridTemplateRows = `repeat(${dimensions.rows}, ${tileSize}px)`; + } + + sceneChildren.push( + h("div", { + ref: floorRef, + class: "voxcss-floor-z", + style: floorStyle, + }, floorChildren) + ); + + // X/Y slice hosts for 3d mode + if (is3d) { + sceneChildren.push( + h(SliceAxisHost, { + key: "slice-x", + className: "voxcss-floor-x", + hostStyle: { + width: `${dimensions.cols * tileSize}px`, + height: `${dimensions.depth * layerElevation}px`, + display: "grid", + gridTemplateColumns: `repeat(${dimensions.cols}, ${tileSize}px)`, + gridTemplateRows: `repeat(${dimensions.depth}, ${layerElevation}px)`, + }, + plans: sliceBrushes.plans.value, + store: store as SceneStore, + tileSize, + layerElevation, + axes: X_AXES, + }) + ); + sceneChildren.push( + h(SliceAxisHost, { + key: "slice-y", + className: "voxcss-floor-y", + hostStyle: { + width: `${dimensions.depth * layerElevation}px`, + height: `${dimensions.rows * tileSize}px`, + display: "grid", + gridTemplateColumns: `repeat(${dimensions.depth}, ${layerElevation}px)`, + gridTemplateRows: `repeat(${dimensions.rows}, ${tileSize}px)`, + }, + plans: sliceBrushes.plans.value, + store: store as SceneStore, + tileSize, + layerElevation, + axes: Y_AXES, + }) + ); + } + + return sceneChildren; +} diff --git a/packages/vue/src/VoxLayer.ts b/packages/vue/src/scene/VoxLayer.ts similarity index 92% rename from packages/vue/src/VoxLayer.ts rename to packages/vue/src/scene/VoxLayer.ts index 3bf4704..d121428 100644 --- a/packages/vue/src/VoxLayer.ts +++ b/packages/vue/src/scene/VoxLayer.ts @@ -1,8 +1,8 @@ import { defineComponent, h } from "vue"; import type { PropType } from "vue"; import type { GridContext, Voxel } from "@layoutit/voxcss-core"; -import { VoxCube } from "./VoxCube"; -import { VoxShape } from "./VoxShape"; +import { VoxCube } from "../shapes/VoxCube"; +import { VoxShape } from "../shapes/VoxShape"; function voxelKey(voxel: Voxel, index: number): string { return `${voxel.x}:${voxel.y}:${voxel.z}:${index}`; diff --git a/packages/vue/src/scene/VoxScene.test.ts b/packages/vue/src/scene/VoxScene.test.ts new file mode 100644 index 0000000..5dcbf1f --- /dev/null +++ b/packages/vue/src/scene/VoxScene.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h, nextTick } from "vue"; +import type { Voxel } from "@layoutit/voxcss-core"; +import { VoxCamera } from "../camera/VoxCamera"; +import { VoxScene } from "./VoxScene"; + +function renderScene( + sceneProps: Record, + cameraProps: Record = {} +): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, cameraProps, { + default: () => h(VoxScene, sceneProps), + }); + }, + }); + app.mount(container); + return container; +} + +describe("VoxScene behavior", () => { + describe("scene dimensions", () => { + it("sets CSS custom properties for grid rows and cols based on voxel extents", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0 }, + { x: 5, y: 7, z: 0 }, + ]; + const container = renderScene({ voxels }); + const scene = container.querySelector(".voxcss-scene") as HTMLElement; + expect(scene).toBeTruthy(); + expect(scene.style.getPropertyValue("--voxcss-rows")).toBe("6"); + expect(scene.style.getPropertyValue("--voxcss-cols")).toBe("8"); + }); + + it("computes dimensions for single voxel at origin", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels }); + const scene = container.querySelector(".voxcss-scene") as HTMLElement; + expect(scene.style.getPropertyValue("--voxcss-rows")).toBe("1"); + expect(scene.style.getPropertyValue("--voxcss-cols")).toBe("1"); + }); + }); + + describe("layers", () => { + it("creates a layer element for each z-level that has voxels", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 0, y: 0, z: 1, color: "#00ff00" }, + { x: 0, y: 0, z: 2, color: "#0000ff" }, + ]; + const container = renderScene({ voxels }); + const layers = container.querySelectorAll(".voxcss-layer"); + expect(layers.length).toBe(3); + }); + + it("creates a single layer when all voxels are on the same z-level", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0 }, + { x: 1, y: 0, z: 0 }, + { x: 0, y: 1, z: 0 }, + ]; + const container = renderScene({ voxels }); + const layers = container.querySelectorAll(".voxcss-layer"); + expect(layers.length).toBe(1); + }); + }); + + describe("floor visibility", () => { + it("shows floor when showFloor is true", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showFloor: true }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + expect(floor.style.background).not.toContain("none"); + }); + + it("hides floor background when showFloor is false", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showFloor: false }); + const floor = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floor).toBeTruthy(); + expect(floor.style.background).toContain("none"); + }); + }); + + describe("walls visibility", () => { + it("renders wall elements when showWalls is true", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showWalls: true }); + const walls = container.querySelectorAll(".voxcss-wall"); + expect(walls.length).toBeGreaterThan(0); + }); + + it("does not render wall elements when showWalls is false", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showWalls: false }); + const walls = container.querySelectorAll(".voxcss-wall"); + expect(walls.length).toBe(0); + }); + }); + + describe("ceiling", () => { + it("shows ceiling when showFloor is true and camera rotX > 90 (wall mask t is true)", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showFloor: true }, { rotX: 95 }); + const ceiling = container.querySelector(".voxcss-ceiling"); + expect(ceiling).toBeTruthy(); + }); + + it("does not show ceiling when rotX is less than 90", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showFloor: true }, { rotX: 65 }); + const ceiling = container.querySelector(".voxcss-ceiling"); + expect(ceiling).toBeNull(); + }); + + it("does not show ceiling when showFloor is false even if rotX > 90", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showFloor: false }, { rotX: 95 }); + const ceiling = container.querySelector(".voxcss-ceiling"); + expect(ceiling).toBeNull(); + }); + }); + + describe("dimetric projection", () => { + it("applies the dimetric projection class to the scene", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, projection: "dimetric" }); + const scene = container.querySelector(".voxcss-scene"); + expect(scene?.classList.contains("voxcss-projection--dimetric")).toBe(true); + }); + + it("does not apply dimetric class for cubic projection", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, projection: "cubic" }); + const scene = container.querySelector(".voxcss-scene"); + expect(scene?.classList.contains("voxcss-projection--dimetric")).toBe(false); + }); + }); + + describe("voxels render in correct grid positions", () => { + it("positions cubes using grid-area based on voxel x,y coordinates", () => { + const voxels: Voxel[] = [ + { x: 2, y: 3, z: 0, color: "#ff0000" }, + ]; + const container = renderScene({ voxels }); + const cube = container.querySelector(".voxcss-cube") as HTMLElement; + expect(cube).toBeTruthy(); + expect(cube.style.gridArea).toBe("2 / 3 / 3 / 4"); + }); + + it("renders multiple cubes each with their own grid position", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 2, z: 0, color: "#00ff00" }, + ]; + const container = renderScene({ voxels }); + const cubes = container.querySelectorAll(".voxcss-cube"); + expect(cubes.length).toBe(2); + }); + }); +}); diff --git a/packages/vue/src/VoxScene.ts b/packages/vue/src/scene/VoxScene.ts similarity index 51% rename from packages/vue/src/VoxScene.ts rename to packages/vue/src/scene/VoxScene.ts index b9afbd3..9f429e2 100644 --- a/packages/vue/src/VoxScene.ts +++ b/packages/vue/src/scene/VoxScene.ts @@ -3,27 +3,22 @@ import type { PropType } from "vue"; import type { ProjectionMode, VoxelGrid, WallsMask } from "@layoutit/voxcss-core"; import { DEFAULT_WALL_COLOR, wallMasksEqual } from "@layoutit/voxcss-core"; import { createIsometricCamera } from "@layoutit/voxcss-core"; -import { shadeColor, shadeWallFace } from "@layoutit/voxcss-core"; -import type { MergeVoxelsOption, PlaneAxis } from "@layoutit/voxcss-core"; -import { VoxCameraContextKey } from "./context"; -import { useSceneContext } from "./composables/useSceneContext"; -import { VoxLayer } from "./VoxLayer"; -import { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "./VoxSliceRenderer"; -import { injectBaseStyles } from "./styles"; -import type { SceneStore } from "./sceneStore"; +import type { MergeVoxelsOption } from "@layoutit/voxcss-core"; +import { VoxCameraContextKey } from "../camera"; +import { useSceneContext } from "./useSceneContext"; +import { useSliceBrushes } from "../slice"; +import { injectBaseStyles } from "../styles"; +import type { SceneStore } from "../store"; +import { renderFloor } from "./Floor"; +import { renderCeiling } from "./Ceiling"; +import { renderWalls } from "./Walls"; -const X_AXES = new Set(["x"]); -const Y_AXES = new Set(["y"]); - -const FLOOR_BASE_DELTA = 120; const DIMETRIC_CLASS = "voxcss-projection--dimetric"; -const FLOOR_GRID_ALPHA = 0.12; -const WALL_GRID_ALPHA = 0.1; const GRID_DISABLE_THRESHOLD = 20; const gridSvgCache = new Map(); -function buildGridSvgDataUrl(width: number, height: number, alpha: number): string { +export function buildGridSvgDataUrl(width: number, height: number, alpha: number): string { const key = `${width}x${height}:${alpha}`; const cached = gridSvgCache.get(key); if (cached) return cached; @@ -33,47 +28,6 @@ function buildGridSvgDataUrl(width: number, height: number, alpha: number): stri return url; } -const WALL_DEFINITIONS: Array<{ - key: keyof WallsMask; - className: string; - useAltGrid: boolean; - getSize: (rows: number, cols: number, depth: number, tile: number) => [number, number]; - getTransform: (rows: number, cols: number, depth: number, halfTile: number) => string; -}> = [ - { - key: "bl", - className: "voxcss-wall voxcss-wall--backLeft", - useAltGrid: true, - getSize: (rows, _cols, depth, tile) => [depth * tile, rows * tile], - getTransform: (_rows, _cols, depth, halfTile) => - `rotateY(-90deg) translateZ(${halfTile * depth}px) translateX(${halfTile * depth}px)`, - }, - { - key: "fr", - className: "voxcss-wall voxcss-wall--frontRight", - useAltGrid: true, - getSize: (rows, _cols, depth, tile) => [depth * tile, rows * tile], - getTransform: (_rows, _cols, depth, halfTile) => - `rotateY(-90deg) translateZ(-${halfTile * depth}px) translateX(${halfTile * depth}px)`, - }, - { - key: "br", - className: "voxcss-wall voxcss-wall--backRight", - useAltGrid: false, - getSize: (_rows, cols, depth, tile) => [cols * tile, depth * tile], - getTransform: (_rows, _cols, depth, halfTile) => - `rotateX(90deg) translateZ(${halfTile * depth}px) translateY(${halfTile * depth}px)`, - }, - { - key: "fl", - className: "voxcss-wall voxcss-wall--frontLeft", - useAltGrid: false, - getSize: (_rows, cols, depth, tile) => [cols * tile, depth * tile], - getTransform: (rows, _cols, depth, halfTile) => - `rotateX(-90deg) translateZ(${halfTile * (2 * rows - depth)}px) translateY(-${halfTile * depth}px)`, - }, -]; - export const VoxScene = defineComponent({ name: "VoxScene", props: { @@ -217,147 +171,52 @@ export const VoxScene = defineComponent({ const layerElevation = context.layerElevation ?? tileSize; const className = `voxcss-scene${props.projection === "dimetric" ? ` ${DIMETRIC_CLASS}` : ""}`; - const floorVisible = props.showFloor && mask.b; - const floorColor = floorVisible ? shadeColor(props.wallColor, FLOOR_BASE_DELTA) : undefined; const disableGrid = dimensions.rows > GRID_DISABLE_THRESHOLD && dimensions.cols > GRID_DISABLE_THRESHOLD; - const floorGrid = floorVisible && !disableGrid - ? buildGridSvgDataUrl(tileSize, tileSize, FLOOR_GRID_ALPHA) - : undefined; const sceneChildren = []; - // Floor div - const floorChildren: any[] = []; - if (is3d.value) { - floorChildren.push( - h(SliceZBrushes, { - key: "slice-z", - floorRef, - plans: sliceBrushes.plans.value, - store: store as SceneStore, - tileSize, - layerElevation, - }) - ); - } else { - for (let i = 0; i < layers.length; i++) { - floorChildren.push( - h(VoxLayer, { key: i, layerIndex: i, voxels: layers[i], context }) - ); - } - } - - const floorStyle: Record = { - "--voxcss-floor-base": floorColor, - "--voxcss-grid-x": floorVisible ? `${tileSize}px` : undefined, - "--voxcss-grid-y": floorVisible ? `${tileSize}px` : undefined, - "--voxcss-floor-grid": floorGrid, - background: floorVisible ? undefined : "none", - pointerEvents: "none", - }; - - if (is3d.value) { - floorStyle.display = "grid"; - floorStyle.gridTemplateColumns = `repeat(${dimensions.cols}, ${tileSize}px)`; - floorStyle.gridTemplateRows = `repeat(${dimensions.rows}, ${tileSize}px)`; - } - + // Floor + slice hosts sceneChildren.push( - h("div", { - ref: floorRef, - class: "voxcss-floor-z", - style: floorStyle, - }, floorChildren) + ...renderFloor({ + layers, + context, + dimensions, + showFloor: props.showFloor, + wallMask: mask, + wallColor: props.wallColor, + tileSize, + layerElevation, + disableGrid, + is3d: is3d.value, + store: store as SceneStore, + sliceBrushes, + floorRef, + }) ); - // X/Y slice hosts for 3d mode - if (is3d.value) { - sceneChildren.push( - h(SliceAxisHost, { - key: "slice-x", - className: "voxcss-floor-x", - hostStyle: { - width: `${dimensions.cols * tileSize}px`, - height: `${dimensions.depth * layerElevation}px`, - display: "grid", - gridTemplateColumns: `repeat(${dimensions.cols}, ${tileSize}px)`, - gridTemplateRows: `repeat(${dimensions.depth}, ${layerElevation}px)`, - }, - plans: sliceBrushes.plans.value, - store: store as SceneStore, - tileSize, - layerElevation, - axes: X_AXES, - }) - ); - sceneChildren.push( - h(SliceAxisHost, { - key: "slice-y", - className: "voxcss-floor-y", - hostStyle: { - width: `${dimensions.depth * layerElevation}px`, - height: `${dimensions.rows * tileSize}px`, - display: "grid", - gridTemplateColumns: `repeat(${dimensions.depth}, ${layerElevation}px)`, - gridTemplateRows: `repeat(${dimensions.rows}, ${tileSize}px)`, - }, - plans: sliceBrushes.plans.value, - store: store as SceneStore, - tileSize, - layerElevation, - axes: Y_AXES, - }) - ); - } - // Ceiling if (props.showFloor && mask.t) { - const ceilingColor = shadeColor(props.wallColor, FLOOR_BASE_DELTA); sceneChildren.push( - h("div", { - key: "ceiling", - class: "voxcss-ceiling", - style: { - width: `${dimensions.cols * tileSize}px`, - height: `${dimensions.rows * tileSize}px`, - transform: `translateZ(${dimensions.depth * tileSize}px)`, - "--voxcss-ceiling-base": ceilingColor, - "--voxcss-ceiling-opacity": "0.35", - }, + renderCeiling({ + wallColor: props.wallColor, + dimensions, + tileSize, }) ); } // Walls if (props.showWalls) { - const halfTile = tileSize / 2; - const { rows, cols, depth } = dimensions; - const wallGridUrl = disableGrid ? undefined : buildGridSvgDataUrl(tileSize, layerElevation, WALL_GRID_ALPHA); - const wallGridAltUrl = disableGrid ? undefined - : tileSize === layerElevation ? wallGridUrl - : buildGridSvgDataUrl(layerElevation, tileSize, WALL_GRID_ALPHA); - - for (const def of WALL_DEFINITIONS) { - if (!context.walls[def.key]) continue; - const [width, height] = def.getSize(rows, cols, depth, tileSize); - const transform = def.getTransform(rows, cols, depth, halfTile); - const bgColor = shadeWallFace(props.wallColor, def.key); - const gridUrl = def.useAltGrid ? wallGridAltUrl : wallGridUrl; - - sceneChildren.push( - h("div", { - key: def.key, - class: def.className, - style: { - width: `${width}px`, - height: `${height}px`, - transform, - backgroundColor: bgColor, - "--voxcss-wall-grid": gridUrl, - }, - }) - ); - } + sceneChildren.push( + ...renderWalls({ + walls: context.walls, + wallColor: props.wallColor, + dimensions, + tileSize, + disableGrid, + layerElevation, + }) + ); } return h( diff --git a/packages/vue/src/scene/Walls.ts b/packages/vue/src/scene/Walls.ts new file mode 100644 index 0000000..7740e01 --- /dev/null +++ b/packages/vue/src/scene/Walls.ts @@ -0,0 +1,91 @@ +import { h } from "vue"; +import type { VNode } from "vue"; +import type { WallsMask } from "@layoutit/voxcss-core"; +import { shadeWallFace } from "@layoutit/voxcss-core"; +import { buildGridSvgDataUrl } from "./VoxScene"; + +const WALL_GRID_ALPHA = 0.1; + +export const WALL_DEFINITIONS: Array<{ + key: keyof WallsMask; + className: string; + useAltGrid: boolean; + getSize: (rows: number, cols: number, depth: number, tile: number) => [number, number]; + getTransform: (rows: number, cols: number, depth: number, halfTile: number) => string; +}> = [ + { + key: "bl", + className: "voxcss-wall voxcss-wall--backLeft", + useAltGrid: true, + getSize: (rows, _cols, depth, tile) => [depth * tile, rows * tile], + getTransform: (_rows, _cols, depth, halfTile) => + `rotateY(-90deg) translateZ(${halfTile * depth}px) translateX(${halfTile * depth}px)`, + }, + { + key: "fr", + className: "voxcss-wall voxcss-wall--frontRight", + useAltGrid: true, + getSize: (rows, _cols, depth, tile) => [depth * tile, rows * tile], + getTransform: (_rows, _cols, depth, halfTile) => + `rotateY(-90deg) translateZ(-${halfTile * depth}px) translateX(${halfTile * depth}px)`, + }, + { + key: "br", + className: "voxcss-wall voxcss-wall--backRight", + useAltGrid: false, + getSize: (_rows, cols, depth, tile) => [cols * tile, depth * tile], + getTransform: (_rows, _cols, depth, halfTile) => + `rotateX(90deg) translateZ(${halfTile * depth}px) translateY(${halfTile * depth}px)`, + }, + { + key: "fl", + className: "voxcss-wall voxcss-wall--frontLeft", + useAltGrid: false, + getSize: (_rows, cols, depth, tile) => [cols * tile, depth * tile], + getTransform: (rows, _cols, depth, halfTile) => + `rotateX(-90deg) translateZ(${halfTile * (2 * rows - depth)}px) translateY(-${halfTile * depth}px)`, + }, +]; + +export interface WallsOptions { + walls: WallsMask; + wallColor: string; + dimensions: { rows: number; cols: number; depth: number }; + tileSize: number; + disableGrid: boolean; + layerElevation: number; +} + +export function renderWalls(opts: WallsOptions): VNode[] { + const { walls, wallColor, dimensions, tileSize, disableGrid, layerElevation } = opts; + const halfTile = tileSize / 2; + const { rows, cols, depth } = dimensions; + const wallGridUrl = disableGrid ? undefined : buildGridSvgDataUrl(tileSize, layerElevation, WALL_GRID_ALPHA); + const wallGridAltUrl = disableGrid ? undefined + : tileSize === layerElevation ? wallGridUrl + : buildGridSvgDataUrl(layerElevation, tileSize, WALL_GRID_ALPHA); + + const children: VNode[] = []; + for (const def of WALL_DEFINITIONS) { + if (!walls[def.key]) continue; + const [width, height] = def.getSize(rows, cols, depth, tileSize); + const transform = def.getTransform(rows, cols, depth, halfTile); + const bgColor = shadeWallFace(wallColor, def.key); + const gridUrl = def.useAltGrid ? wallGridAltUrl : wallGridUrl; + + children.push( + h("div", { + key: def.key, + class: def.className, + style: { + width: `${width}px`, + height: `${height}px`, + transform, + backgroundColor: bgColor, + "--voxcss-wall-grid": gridUrl, + }, + }) + ); + } + return children; +} diff --git a/packages/vue/src/scene/index.ts b/packages/vue/src/scene/index.ts new file mode 100644 index 0000000..37f1c8c --- /dev/null +++ b/packages/vue/src/scene/index.ts @@ -0,0 +1,4 @@ +export { VoxScene } from "./VoxScene"; +export { VoxLayer } from "./VoxLayer"; +export { useSceneContext } from "./useSceneContext"; +export type { UseSceneContextOptions } from "./useSceneContext"; diff --git a/packages/vue/src/scene/integration.test.ts b/packages/vue/src/scene/integration.test.ts new file mode 100644 index 0000000..a447740 --- /dev/null +++ b/packages/vue/src/scene/integration.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h, ref, nextTick } from "vue"; +import type { Voxel } from "@layoutit/voxcss-core"; +import { VoxCamera } from "../camera/VoxCamera"; +import { VoxScene } from "./VoxScene"; + +function renderScene( + sceneProps: Record, + cameraProps: Record = {} +): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, cameraProps, { + default: () => h(VoxScene, sceneProps), + }); + }, + }); + app.mount(container); + return container; +} + +describe("Scene integration", () => { + describe("mixed scene with multiple shape types", () => { + it("renders cubes, ramp, wedge, and spike together in the same scene", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#00ff00" }, + { x: 2, y: 0, z: 0, shape: "ramp", color: "#0000ff", rot: 0 }, + { x: 3, y: 0, z: 0, shape: "wedge", color: "#ffff00", rot: 90 }, + { x: 4, y: 0, z: 0, shape: "spike", color: "#ff00ff", rot: 180 }, + ]; + const container = renderScene({ voxels }); + + const cubes = container.querySelectorAll(".voxcss-cube"); + expect(cubes.length).toBe(2); + + const ramp = container.querySelector(".voxcss-ramp"); + expect(ramp).toBeTruthy(); + + const wedge = container.querySelector(".voxcss-wedge"); + expect(wedge).toBeTruthy(); + + const spike = container.querySelector(".voxcss-spike"); + expect(spike).toBeTruthy(); + }); + + it("places all shape types in the correct single layer", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, shape: "ramp", color: "#0000ff" }, + { x: 2, y: 0, z: 0, shape: "wedge", color: "#00ff00" }, + ]; + const container = renderScene({ voxels }); + + const layers = container.querySelectorAll(".voxcss-layer"); + expect(layers.length).toBe(1); + }); + }); + + describe("updating voxels changes the rendered scene", () => { + it("re-renders with new voxels when props change", async () => { + const voxelData = ref([ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + ]); + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, {}, { + default: () => h(VoxScene, { voxels: voxelData.value }), + }); + }, + }); + app.mount(container); + + let cubes = container.querySelectorAll(".voxcss-cube"); + expect(cubes.length).toBe(1); + + voxelData.value = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#00ff00" }, + { x: 2, y: 0, z: 0, color: "#0000ff" }, + ]; + await nextTick(); + + cubes = container.querySelectorAll(".voxcss-cube"); + expect(cubes.length).toBe(3); + }); + + it("removes shapes when voxels are reduced", async () => { + const voxelData = ref([ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, shape: "ramp", color: "#0000ff" }, + ]); + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, {}, { + default: () => h(VoxScene, { voxels: voxelData.value }), + }); + }, + }); + app.mount(container); + + expect(container.querySelector(".voxcss-ramp")).toBeTruthy(); + + voxelData.value = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + ]; + await nextTick(); + + expect(container.querySelector(".voxcss-ramp")).toBeNull(); + }); + }); + + describe("wall mask changes show/hide appropriate faces", () => { + it("shows back-left and back-right walls with default camera angle", () => { + const voxels: Voxel[] = [{ x: 0, y: 0, z: 0 }]; + const container = renderScene({ voxels, showWalls: true }); + + const walls = container.querySelectorAll(".voxcss-wall"); + const wallClasses = Array.from(walls).map((w) => w.className); + expect(wallClasses.some((c) => c.includes("backLeft"))).toBe(true); + expect(wallClasses.some((c) => c.includes("backRight"))).toBe(true); + }); + + it("changes visible walls when camera rotY changes past a quadrant boundary", async () => { + const rotY = ref(45); + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, { rotY: rotY.value }, { + default: () => h(VoxScene, { voxels: [{ x: 0, y: 0, z: 0 }], showWalls: true }), + }); + }, + }); + app.mount(container); + + const wallsBefore = container.querySelectorAll(".voxcss-wall"); + const beforeClasses = Array.from(wallsBefore).map((w) => w.className); + + rotY.value = 135; + await nextTick(); + + const wallsAfter = container.querySelectorAll(".voxcss-wall"); + const afterClasses = Array.from(wallsAfter).map((w) => w.className); + + expect(afterClasses).not.toEqual(beforeClasses); + }); + + it("cube face visibility changes with camera rotation", async () => { + const rotY = ref(45); + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, { rotX: 65, rotY: rotY.value }, { + default: () => + h(VoxScene, { voxels: [{ x: 0, y: 0, z: 0, color: "#ff0000" }] }), + }); + }, + }); + app.mount(container); + + const facesBefore = Array.from( + container.querySelectorAll(".voxcss-cube-face") + ).map((f) => f.className); + + rotY.value = 225; + await nextTick(); + + const facesAfter = Array.from( + container.querySelectorAll(".voxcss-cube-face") + ).map((f) => f.className); + + expect(facesAfter).not.toEqual(facesBefore); + }); + }); +}); diff --git a/packages/vue/src/composables/useSceneContext.ts b/packages/vue/src/scene/useSceneContext.ts similarity index 100% rename from packages/vue/src/composables/useSceneContext.ts rename to packages/vue/src/scene/useSceneContext.ts diff --git a/packages/vue/src/shapes/Ramp.ts b/packages/vue/src/shapes/Ramp.ts new file mode 100644 index 0000000..b197270 --- /dev/null +++ b/packages/vue/src/shapes/Ramp.ts @@ -0,0 +1,39 @@ +import { h } from "vue"; +import type { ShapeInnerProps } from "./types"; +import { getSurfaceColor, getSurfaceDelta, resolveSurfaceTexture, textureBrightnessFilter } from "./utils"; + +export function renderRamp( + { voxel, context, baseColor, lighting, showBottom }: ShapeInnerProps, +) { + const slopeColor = getSurfaceColor(lighting, "slope", baseColor); + const slopeDelta = getSurfaceDelta(lighting, "slope"); + const slopeTexture = resolveSurfaceTexture(voxel, "slope", context); + const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); + + const children = []; + if (showBottom) { + children.push( + h("div", { + class: "voxcss-ramp-bottom", + style: { + backgroundColor: bottomTexture ? undefined : baseColor, + backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, + filter: bottomTexture ? textureBrightnessFilter(0) : undefined, + }, + }) + ); + } + children.push( + h("div", { + class: "voxcss-ramp-slope", + style: { + backgroundColor: slopeTexture ? undefined : slopeColor, + backgroundImage: slopeTexture ? `url(${slopeTexture})` : undefined, + backgroundSize: "70px 50px", + filter: slopeTexture ? textureBrightnessFilter(slopeDelta) : undefined, + }, + }) + ); + + return children; +} diff --git a/packages/vue/src/shapes/Spike.ts b/packages/vue/src/shapes/Spike.ts new file mode 100644 index 0000000..54d57c5 --- /dev/null +++ b/packages/vue/src/shapes/Spike.ts @@ -0,0 +1,56 @@ +import { h } from "vue"; +import type { ShapeInnerProps } from "./types"; +import { getSurfaceColor, getSurfaceDelta, resolveSurfaceTexture, textureBrightnessFilter } from "./utils"; +import { renderSvgSlope } from "./SvgSlope"; + +export function renderSpike( + { voxel, context, baseColor, lighting, showBottom }: ShapeInnerProps, +) { + const primaryColor = getSurfaceColor(lighting, "primary", baseColor); + const secondaryColor = getSurfaceColor(lighting, "secondary", baseColor); + const primaryDelta = getSurfaceDelta(lighting, "primary"); + const secondaryDelta = getSurfaceDelta(lighting, "secondary"); + const primaryTexture = resolveSurfaceTexture(voxel, "primary", context); + const secondaryTexture = resolveSurfaceTexture(voxel, "secondary", context); + const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); + + const children = []; + if (showBottom) { + children.push( + h("div", { + class: "voxcss-spike-bottom", + style: { + backgroundColor: bottomTexture ? undefined : baseColor, + backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, + filter: bottomTexture ? textureBrightnessFilter(0) : undefined, + }, + }) + ); + } + children.push( + renderSvgSlope( + "voxcss-spike-slope voxcss-spike-slope--primary", + "M480 0 L480 480 L0 480 Z", + primaryColor, + undefined, + undefined, + undefined, + primaryTexture, + primaryDelta, + ) + ); + children.push( + renderSvgSlope( + "voxcss-spike-slope voxcss-spike-slope--secondary", + "M0 0 L0 480 L480 0 Z", + secondaryColor, + undefined, + "50", + "56", + secondaryTexture, + secondaryDelta, + ) + ); + + return children; +} diff --git a/packages/vue/src/shapes/SvgSlope.ts b/packages/vue/src/shapes/SvgSlope.ts new file mode 100644 index 0000000..fd56999 --- /dev/null +++ b/packages/vue/src/shapes/SvgSlope.ts @@ -0,0 +1,74 @@ +import { h } from "vue"; +import { textureBrightnessFilter } from "./utils"; + +let _patternIdCounter = 0; +function nextPatternId(): string { + return `vox-pat-${++_patternIdCounter}`; +} + +export function renderSvgSlope( + className: string, + path: string, + fill: string, + viewBox = "0 0 480 480", + width = "56", + height = "50", + textureUrl?: string, + brightnessDelta = 0, +) { + const patternId = textureUrl ? nextPatternId() : ""; + const effectiveFill = textureUrl ? `url(#${patternId})` : fill; + const filter = textureUrl ? textureBrightnessFilter(brightnessDelta) : undefined; + + const defs = textureUrl + ? h("defs", null, [ + h("pattern", { + id: patternId, + patternUnits: "objectBoundingBox", + patternContentUnits: "objectBoundingBox", + width: "1", + height: "1", + }, [ + h("image", { + width: "1", + height: "1", + preserveAspectRatio: "xMidYMid slice", + href: textureUrl, + }), + ]), + ]) + : null; + + return h("div", { class: className, style: { filter } }, [ + h( + "svg", + { + viewBox, + width, + height, + preserveAspectRatio: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": "true", + focusable: "false", + style: { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + display: "block", + pointerEvents: "none", + }, + }, + [ + defs, + h("path", { + d: path, + fill: effectiveFill, + stroke: "rgba(0, 0, 0, 0.1)", + "stroke-width": "1", + "vector-effect": "non-scaling-stroke", + }), + ] + ), + ]); +} diff --git a/packages/vue/src/VoxCube.ts b/packages/vue/src/shapes/VoxCube.ts similarity index 100% rename from packages/vue/src/VoxCube.ts rename to packages/vue/src/shapes/VoxCube.ts diff --git a/packages/vue/src/shapes/VoxShape.ts b/packages/vue/src/shapes/VoxShape.ts new file mode 100644 index 0000000..16b378a --- /dev/null +++ b/packages/vue/src/shapes/VoxShape.ts @@ -0,0 +1,57 @@ +import { defineComponent, h } from "vue"; +import type { PropType } from "vue"; +import type { GridContext, Voxel, ShapeType } from "@layoutit/voxcss-core"; +import { getVoxelBounds, computeShapeLighting } from "@layoutit/voxcss-core"; +import { normalizeRotation, ORIENTATION_MAP, isCovered, shouldRenderBottom } from "./utils"; +import { renderRamp } from "./Ramp"; +import { renderWedge } from "./Wedge"; +import { renderSpike } from "./Spike"; + +export const VoxShape = defineComponent({ + name: "VoxShape", + props: { + voxel: { type: Object as PropType, required: true }, + context: { type: Object as PropType, required: true }, + }, + setup(props) { + return () => { + const shapeKey = props.voxel.shape ?? "cube"; + if (shapeKey === "cube") return null; + const shape = shapeKey as ShapeType; + + if (isCovered(props.voxel, props.context)) return null; + + const { x2, y2 } = getVoxelBounds(props.voxel); + const rawRotation = Number.isFinite(props.voxel.rot as number) ? Number(props.voxel.rot) : 0; + const rotation = normalizeRotation(rawRotation); + const orientation = ORIENTATION_MAP[rotation] ?? "east"; + const baseColor = props.voxel.color ?? "#cccccc"; + const lighting = computeShapeLighting(shape, rawRotation, baseColor); + const showBottom = shouldRenderBottom(props.voxel, props.context); + + const innerProps = { voxel: props.voxel, context: props.context, baseColor, lighting, showBottom }; + + let shapeClass: string; + let children: any[]; + if (shape === "ramp") { + shapeClass = "voxcss-ramp"; + children = renderRamp(innerProps); + } else if (shape === "wedge") { + shapeClass = "voxcss-wedge"; + children = renderWedge(innerProps); + } else { + shapeClass = "voxcss-spike"; + children = renderSpike(innerProps); + } + + return h( + "div", + { + class: `voxcss-${orientation} ${shapeClass}`, + style: { gridArea: `${props.voxel.x} / ${props.voxel.y} / ${x2} / ${y2}` }, + }, + children + ); + }; + }, +}); diff --git a/packages/vue/src/shapes/Wedge.ts b/packages/vue/src/shapes/Wedge.ts new file mode 100644 index 0000000..95d2c4a --- /dev/null +++ b/packages/vue/src/shapes/Wedge.ts @@ -0,0 +1,56 @@ +import { h } from "vue"; +import type { ShapeInnerProps } from "./types"; +import { getSurfaceColor, getSurfaceDelta, resolveSurfaceTexture, textureBrightnessFilter } from "./utils"; +import { renderSvgSlope } from "./SvgSlope"; + +export function renderWedge( + { voxel, context, baseColor, lighting, showBottom }: ShapeInnerProps, +) { + const primaryColor = getSurfaceColor(lighting, "primary", baseColor); + const secondaryColor = getSurfaceColor(lighting, "secondary", baseColor); + const primaryDelta = getSurfaceDelta(lighting, "primary"); + const secondaryDelta = getSurfaceDelta(lighting, "secondary"); + const primaryTexture = resolveSurfaceTexture(voxel, "primary", context); + const secondaryTexture = resolveSurfaceTexture(voxel, "secondary", context); + const bottomTexture = resolveSurfaceTexture(voxel, "bottom", context); + + const children = []; + if (showBottom) { + children.push( + h("div", { + class: "voxcss-wedge-bottom", + style: { + backgroundColor: bottomTexture ? undefined : baseColor, + backgroundImage: bottomTexture ? `url(${bottomTexture})` : undefined, + filter: bottomTexture ? textureBrightnessFilter(0) : undefined, + }, + }) + ); + } + children.push( + renderSvgSlope( + "voxcss-wedge-slope voxcss-wedge-slope--primary", + "M0 0 L480 0 L0 480 Z", + primaryColor, + undefined, + undefined, + undefined, + primaryTexture, + primaryDelta, + ) + ); + children.push( + renderSvgSlope( + "voxcss-wedge-slope voxcss-wedge-slope--secondary", + "M480 480 L0 480 L480 0 Z", + secondaryColor, + undefined, + "50", + "56", + secondaryTexture, + secondaryDelta, + ) + ); + + return children; +} diff --git a/packages/vue/src/shapes/index.ts b/packages/vue/src/shapes/index.ts new file mode 100644 index 0000000..0dc2ca5 --- /dev/null +++ b/packages/vue/src/shapes/index.ts @@ -0,0 +1,8 @@ +export { renderRamp } from "./Ramp"; +export { renderWedge } from "./Wedge"; +export { renderSpike } from "./Spike"; +export { renderSvgSlope } from "./SvgSlope"; +export { normalizeRotation, ORIENTATION_MAP, isCovered, shouldRenderBottom } from "./utils"; +export type { ShapeInnerProps } from "./types"; +export { VoxCube } from "./VoxCube"; +export { VoxShape } from "./VoxShape"; diff --git a/packages/vue/src/shapes/shapes.test.ts b/packages/vue/src/shapes/shapes.test.ts new file mode 100644 index 0000000..eaa4282 --- /dev/null +++ b/packages/vue/src/shapes/shapes.test.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h } from "vue"; +import type { GridContext, Voxel } from "@layoutit/voxcss-core"; +import { buildSceneContext } from "@layoutit/voxcss-core"; +import { VoxShape } from "./VoxShape"; +import { VoxCube } from "./VoxCube"; + +function makeContext(voxels: Voxel[], walls?: Record): GridContext { + return buildSceneContext({ grid: voxels, context: walls ? { walls } : undefined }).context; +} + +function renderToDiv(component: any, props: Record): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => h(component, props); + }, + }); + app.mount(container); + return container; +} + +describe("Shape behaviors", () => { + describe("ramp", () => { + it("renders with a slope element", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + expect(container.querySelector(".voxcss-ramp")).toBeTruthy(); + expect(container.querySelector(".voxcss-ramp-slope")).toBeTruthy(); + }); + }); + + describe("wedge", () => { + it("renders with two SVG slope elements", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "wedge", color: "#00ff00" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + expect(container.querySelector(".voxcss-wedge")).toBeTruthy(); + const svgs = container.querySelectorAll("svg"); + expect(svgs.length).toBe(2); + const paths = container.querySelectorAll("path"); + expect(paths.length).toBe(2); + }); + }); + + describe("spike", () => { + it("renders with two SVG slope elements", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "spike", color: "#0000ff" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + expect(container.querySelector(".voxcss-spike")).toBeTruthy(); + const svgs = container.querySelectorAll("svg"); + expect(svgs.length).toBe(2); + }); + }); + + describe("rotation orientations", () => { + const shapes: Array<{ shape: "ramp" | "wedge" | "spike"; className: string }> = [ + { shape: "ramp", className: "voxcss-ramp" }, + { shape: "wedge", className: "voxcss-wedge" }, + { shape: "spike", className: "voxcss-spike" }, + ]; + + const orientationCases: Array<{ rot: number; expected: string }> = [ + { rot: 0, expected: "voxcss-east" }, + { rot: 90, expected: "voxcss-south" }, + { rot: 180, expected: "voxcss-west" }, + { rot: 270, expected: "voxcss-north" }, + ]; + + for (const { shape, className } of shapes) { + for (const { rot, expected } of orientationCases) { + it(`${shape} at rot=${rot} has orientation class ${expected}`, () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape, rot, color: "#aabbcc" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + const shapeEl = container.querySelector(`.${className}`); + expect(shapeEl).toBeTruthy(); + expect(shapeEl?.classList.contains(expected)).toBe(true); + }); + } + } + }); + + describe("covered shape", () => { + it("returns null when a voxel exists directly above the shape", () => { + const ramp: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const above: Voxel = { x: 0, y: 0, z: 1, color: "#00ff00" }; + const context = makeContext([ramp, above]); + const container = renderToDiv(VoxShape, { voxel: ramp, context }); + + expect(container.querySelector(".voxcss-ramp")).toBeNull(); + }); + + it("renders when there is no voxel above", () => { + const ramp: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const context = makeContext([ramp]); + const container = renderToDiv(VoxShape, { voxel: ramp, context }); + + expect(container.querySelector(".voxcss-ramp")).toBeTruthy(); + }); + }); + + describe("bottom face rendering", () => { + it("renders bottom face when not occluded and walls.b is false", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const context = makeContext([voxel], { b: false, t: false, bl: false, br: false, fl: false, fr: false }); + const container = renderToDiv(VoxShape, { voxel, context }); + + const bottom = container.querySelector(".voxcss-ramp-bottom"); + expect(bottom).toBeTruthy(); + }); + + it("hides bottom face when walls.b is true (back-face culled)", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const context = makeContext([voxel], { b: true }); + const container = renderToDiv(VoxShape, { voxel, context }); + + const bottom = container.querySelector(".voxcss-ramp-bottom"); + expect(bottom).toBeNull(); + }); + + it("hides bottom face when the voxel below occludes it", () => { + const ramp: Voxel = { x: 0, y: 0, z: 1, shape: "ramp", color: "#ff0000" }; + const below: Voxel = { x: 0, y: 0, z: 0, color: "#00ff00" }; + const context = makeContext([ramp, below], { b: false, t: false, bl: false, br: false, fl: false, fr: false }); + const container = renderToDiv(VoxShape, { voxel: ramp, context }); + + const bottom = container.querySelector(".voxcss-ramp-bottom"); + expect(bottom).toBeNull(); + }); + }); + + describe("textured shape", () => { + it("applies backgroundImage when voxel has a texture URL", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "ramp", + color: "#ff0000", + texture: "https://example.com/texture.png", + }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + const slope = container.querySelector(".voxcss-ramp-slope") as HTMLElement; + expect(slope).toBeTruthy(); + expect(slope.style.backgroundImage).toContain("https://example.com/texture.png"); + }); + + it("does not apply backgroundImage when no texture is set", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + const slope = container.querySelector(".voxcss-ramp-slope") as HTMLElement; + expect(slope).toBeTruthy(); + expect(slope.style.backgroundImage).toBe(""); + }); + }); + + describe("shape colors from lighting", () => { + it("applies computed slope color to ramp slope element", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "ramp", color: "#ff0000" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + const slope = container.querySelector(".voxcss-ramp-slope") as HTMLElement; + expect(slope).toBeTruthy(); + expect(slope.style.backgroundColor).toBeTruthy(); + }); + + it("applies fill colors to wedge SVG paths", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, shape: "wedge", color: "#00ff00" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxShape, { voxel, context }); + + const paths = container.querySelectorAll("path"); + expect(paths.length).toBe(2); + for (const path of paths) { + expect(path.getAttribute("fill")).toBeTruthy(); + } + }); + }); + + describe("cube faces", () => { + it("renders only visible faces for an isolated cube", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, color: "#ff0000" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxCube, { voxel, context }); + + const faces = container.querySelectorAll(".voxcss-cube-face"); + expect(faces.length).toBe(3); + }); + + it("renders no faces when cube is fully surrounded", () => { + const voxels: Voxel[] = []; + for (let x = 0; x < 3; x++) + for (let y = 0; y < 3; y++) + for (let z = 0; z < 3; z++) + voxels.push({ x, y, z }); + const context = makeContext(voxels); + const center = voxels.find((v) => v.x === 1 && v.y === 1 && v.z === 1)!; + const container = renderToDiv(VoxCube, { voxel: center, context }); + + const faces = container.querySelectorAll(".voxcss-cube-face"); + expect(faces.length).toBe(0); + }); + + it("applies correct background color based on face lighting", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, color: "#ff0000" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxCube, { voxel, context }); + + const topFace = container.querySelector(".voxcss-cube-face--t") as HTMLElement; + expect(topFace).toBeTruthy(); + expect(topFace.style.backgroundColor).toBe("rgb(255, 0, 0)"); + }); + + it("has distinct face class names for each visible face", () => { + const voxel: Voxel = { x: 0, y: 0, z: 0, color: "#ff0000" }; + const context = makeContext([voxel]); + const container = renderToDiv(VoxCube, { voxel, context }); + + const faces = container.querySelectorAll(".voxcss-cube-face"); + const classNames = Array.from(faces).map((f) => f.className); + expect(classNames).toContain("voxcss-cube-face voxcss-cube-face--t"); + expect(classNames).toContain("voxcss-cube-face voxcss-cube-face--fr"); + expect(classNames).toContain("voxcss-cube-face voxcss-cube-face--fl"); + }); + }); +}); diff --git a/packages/vue/src/shapes/textures.test.ts b/packages/vue/src/shapes/textures.test.ts new file mode 100644 index 0000000..a5cc122 --- /dev/null +++ b/packages/vue/src/shapes/textures.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h } from "vue"; +import type { GridContext, Voxel } from "@layoutit/voxcss-core"; +import { buildSceneContext } from "@layoutit/voxcss-core"; +import { VoxShape } from "./VoxShape"; + +function makeContext(voxels: Voxel[], walls?: Record): GridContext { + return buildSceneContext({ grid: voxels, context: walls ? { walls } : undefined }).context; +} + +function renderToDiv(props: Record): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => h(VoxShape, props); + }, + }); + app.mount(container); + return container; +} + +describe("Shape texture behaviors", () => { + describe("ramp with texture", () => { + it("applies backgroundImage on slope when texture is set", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "ramp", + color: "#ff0000", + texture: "https://example.com/brick.png", + }; + const context = makeContext([voxel]); + const container = renderToDiv({ voxel, context }); + + const slope = container.querySelector(".voxcss-ramp-slope") as HTMLElement; + expect(slope).toBeTruthy(); + expect(slope.style.backgroundImage).toContain("https://example.com/brick.png"); + }); + }); + + describe("wedge with texture", () => { + it("creates SVG pattern with image href for textured wedge", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "wedge", + color: "#00ff00", + texture: "https://example.com/stone.png", + }; + const context = makeContext([voxel]); + const container = renderToDiv({ voxel, context }); + + const patterns = container.querySelectorAll("pattern"); + expect(patterns.length).toBeGreaterThan(0); + + const images = container.querySelectorAll("image"); + expect(images.length).toBeGreaterThan(0); + const hrefs = Array.from(images).map((img) => img.getAttribute("href")); + expect(hrefs.some((href) => href === "https://example.com/stone.png")).toBe(true); + }); + }); + + describe("spike with texture", () => { + it("creates SVG pattern with image href for textured spike", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "spike", + color: "#0000ff", + texture: "https://example.com/metal.png", + }; + const context = makeContext([voxel]); + const container = renderToDiv({ voxel, context }); + + const patterns = container.querySelectorAll("pattern"); + expect(patterns.length).toBeGreaterThan(0); + + const images = container.querySelectorAll("image"); + expect(images.length).toBeGreaterThan(0); + const hrefs = Array.from(images).map((img) => img.getAttribute("href")); + expect(hrefs.some((href) => href === "https://example.com/metal.png")).toBe(true); + }); + }); + + describe("shape bottom with texture", () => { + it("applies backgroundImage on ramp bottom when texture is set and bottom is visible", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "ramp", + color: "#ff0000", + texture: "https://example.com/wood.png", + }; + const context = makeContext([voxel], { b: false, t: false, bl: false, br: false, fl: false, fr: false }); + const container = renderToDiv({ voxel, context }); + + const bottom = container.querySelector(".voxcss-ramp-bottom") as HTMLElement; + expect(bottom).toBeTruthy(); + expect(bottom.style.backgroundImage).toContain("https://example.com/wood.png"); + }); + + it("applies backgroundImage on wedge bottom when texture is set and bottom is visible", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "wedge", + color: "#00ff00", + texture: "https://example.com/wood.png", + }; + const context = makeContext([voxel], { b: false, t: false, bl: false, br: false, fl: false, fr: false }); + const container = renderToDiv({ voxel, context }); + + const bottom = container.querySelector(".voxcss-wedge-bottom") as HTMLElement; + expect(bottom).toBeTruthy(); + expect(bottom.style.backgroundImage).toContain("https://example.com/wood.png"); + }); + }); + + describe("texture brightness filter", () => { + it("applies brightness filter on slope based on face delta", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "ramp", + color: "#ff0000", + texture: "https://example.com/texture.png", + }; + const context = makeContext([voxel]); + const container = renderToDiv({ voxel, context }); + + const slope = container.querySelector(".voxcss-ramp-slope") as HTMLElement; + expect(slope).toBeTruthy(); + const filter = slope.style.filter; + if (filter) { + expect(filter).toContain("brightness("); + } + }); + + it("applies brightness filter on wedge SVG slope wrappers based on face delta", () => { + const voxel: Voxel = { + x: 0, y: 0, z: 0, + shape: "wedge", + color: "#00ff00", + texture: "https://example.com/texture.png", + }; + const context = makeContext([voxel]); + const container = renderToDiv({ voxel, context }); + + const slopeWrappers = container.querySelectorAll(".voxcss-wedge-slope"); + expect(slopeWrappers.length).toBe(2); + + const filters = Array.from(slopeWrappers).map( + (el) => (el as HTMLElement).style.filter + ); + const hasFilter = filters.some((f) => f && f.includes("brightness(")); + expect(hasFilter).toBe(true); + }); + }); +}); diff --git a/packages/vue/src/shapes/types.ts b/packages/vue/src/shapes/types.ts new file mode 100644 index 0000000..e3b377a --- /dev/null +++ b/packages/vue/src/shapes/types.ts @@ -0,0 +1,10 @@ +import type { GridContext, Voxel } from "@layoutit/voxcss-core"; +import type { ShapeSurfaceLighting } from "@layoutit/voxcss-core"; + +export interface ShapeInnerProps { + voxel: Voxel; + context: GridContext; + baseColor: string; + lighting: ShapeSurfaceLighting[]; + showBottom: boolean; +} diff --git a/packages/vue/src/shapes/utils.ts b/packages/vue/src/shapes/utils.ts new file mode 100644 index 0000000..b9f1883 --- /dev/null +++ b/packages/vue/src/shapes/utils.ts @@ -0,0 +1,82 @@ +import type { GridContext, Voxel } from "@layoutit/voxcss-core"; +import { getVoxelBounds } from "@layoutit/voxcss-core"; +import type { ShapeSurfaceLighting } from "@layoutit/voxcss-core"; + +export const ORIENTATION_MAP: Record = { + 0: "east", + 90: "south", + 180: "west", + 270: "north", +}; + +export function normalizeRotation(value: number | undefined): number { + if (!Number.isFinite(value)) return 0; + const snapped = Math.round((value as number) / 90) * 90; + return ((snapped % 360) + 360) % 360; +} + +export function isCovered(voxel: Voxel, context: GridContext): boolean { + const { x2, y2 } = getVoxelBounds(voxel); + const layerAbove = Math.max(0, Math.floor((voxel.z ?? 0) + 1)); + for (let row = voxel.x; row < x2; row += 1) { + for (let col = voxel.y; col < y2; col += 1) { + if (context.getVoxel(row, col, layerAbove)) return true; + } + } + return false; +} + +export function isBottomOccluded(voxel: Voxel, context: GridContext): boolean { + const targetZ = Math.floor((voxel.z ?? 0) - 1); + if (targetZ < 0) return false; + const { x2, y2 } = getVoxelBounds(voxel); + for (let x = voxel.x; x < x2; x += 1) { + for (let y = voxel.y; y < y2; y += 1) { + if (!context.getVoxel(x, y, targetZ)) return false; + } + } + return true; +} + +export function shouldRenderBottom(voxel: Voxel, context: GridContext): boolean { + if (context.walls?.b) return false; + return !isBottomOccluded(voxel, context); +} + +export function getSurfaceColor(lighting: ShapeSurfaceLighting[], surfaceId: string, fallback: string): string { + return lighting.find((s) => s.id === surfaceId)?.color ?? fallback; +} + +export function getSurfaceDelta(lighting: ShapeSurfaceLighting[], surfaceId: string): number { + return lighting.find((s) => s.id === surfaceId)?.delta ?? 0; +} + +export function resolveSurfaceTexture( + voxel: Voxel, + surfaceId: string, + context: GridContext +): string | undefined { + const textureKey = voxel.texture; + if (!textureKey || textureKey.startsWith("#")) return undefined; + const resolved = context.resolveTexture?.(textureKey, surfaceId); + if (resolved) return resolved; + if ( + textureKey.startsWith("/") || + textureKey.startsWith("./") || + textureKey.startsWith("../") || + textureKey.startsWith("http://") || + textureKey.startsWith("https://") || + textureKey.startsWith("data:") || + textureKey.includes(".") + ) { + return textureKey; + } + return undefined; +} + +export function textureBrightnessFilter(delta: number): string | undefined { + const brightness = Math.max(0, 1 + delta / 200); + if (Math.abs(brightness - 1) < 0.001) return undefined; + const rounded = Math.round(brightness * 1000) / 1000; + return `brightness(${rounded})`; +} diff --git a/packages/vue/src/slice/VoxSliceRenderer.test.ts b/packages/vue/src/slice/VoxSliceRenderer.test.ts new file mode 100644 index 0000000..b298ae1 --- /dev/null +++ b/packages/vue/src/slice/VoxSliceRenderer.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { createApp, h, ref, nextTick } from "vue"; +import type { Voxel } from "@layoutit/voxcss-core"; +import { VoxCamera } from "../camera/VoxCamera"; +import { VoxScene } from "../scene/VoxScene"; + +function renderScene( + sceneProps: Record, + cameraProps: Record = {} +): HTMLElement { + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, cameraProps, { + default: () => h(VoxScene, sceneProps), + }); + }, + }); + app.mount(container); + return container; +} + +describe("VoxSliceRenderer behavior", () => { + describe("3d merge mode produces brush elements in the DOM", () => { + it("renders brush elements when mergeVoxels is 3d", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#ff0000" }, + { x: 0, y: 1, z: 0, color: "#ff0000" }, + ]; + const container = renderScene({ voxels, mergeVoxels: "3d" }); + + const brushes = container.querySelectorAll(".voxcss-brush"); + expect(brushes.length).toBeGreaterThan(0); + }); + + it("does not render brush elements when mergeVoxels is not 3d", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#ff0000" }, + ]; + const container = renderScene({ voxels }); + + const brushes = container.querySelectorAll(".voxcss-brush"); + expect(brushes.length).toBe(0); + }); + }); + + describe("brushes have correct grid-area and background-color", () => { + it("sets grid-area style on brush elements", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + ]; + const container = renderScene({ voxels, mergeVoxels: "3d" }); + + const brushes = container.querySelectorAll(".voxcss-brush") as NodeListOf; + expect(brushes.length).toBeGreaterThan(0); + for (const brush of brushes) { + expect(brush.style.gridArea).toBeTruthy(); + } + }); + + it("sets background-color on brush elements", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + ]; + const container = renderScene({ voxels, mergeVoxels: "3d" }); + + const brushes = container.querySelectorAll(".voxcss-brush") as NodeListOf; + expect(brushes.length).toBeGreaterThan(0); + for (const brush of brushes) { + expect(brush.style.backgroundColor).toBeTruthy(); + } + }); + }); + + describe("wall mask changes toggle brush visibility", () => { + it("changes brush count when camera rotation crosses a quadrant boundary", async () => { + const rotY = ref(45); + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#ff0000" }, + { x: 0, y: 1, z: 0, color: "#ff0000" }, + { x: 1, y: 1, z: 0, color: "#ff0000" }, + ]; + + const container = document.createElement("div"); + const app = createApp({ + setup() { + return () => + h(VoxCamera, { rotY: rotY.value }, { + default: () => h(VoxScene, { voxels, mergeVoxels: "3d" }), + }); + }, + }); + app.mount(container); + + const brushesBefore = container.querySelectorAll(".voxcss-brush"); + const countBefore = brushesBefore.length; + + rotY.value = 135; + await nextTick(); + + const brushesAfter = container.querySelectorAll(".voxcss-brush"); + const countAfter = brushesAfter.length; + + expect(countBefore).toBeGreaterThan(0); + expect(countAfter).toBeGreaterThan(0); + }); + }); + + describe("brush plans are computed from voxel layers", () => { + it("produces more brushes for more voxels", () => { + const singleVoxel: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + ]; + const containerSingle = renderScene({ voxels: singleVoxel, mergeVoxels: "3d" }); + const brushesSingle = containerSingle.querySelectorAll(".voxcss-brush").length; + + const multiVoxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#00ff00" }, + { x: 0, y: 1, z: 0, color: "#0000ff" }, + { x: 0, y: 0, z: 1, color: "#ffff00" }, + ]; + const containerMulti = renderScene({ voxels: multiVoxels, mergeVoxels: "3d" }); + const brushesMulti = containerMulti.querySelectorAll(".voxcss-brush").length; + + expect(brushesMulti).toBeGreaterThan(brushesSingle); + }); + }); + + describe("multiple axes produce separate host elements", () => { + it("renders floor-x and floor-y host elements in 3d mode", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#ff0000" }, + { x: 0, y: 0, z: 1, color: "#ff0000" }, + ]; + const container = renderScene({ voxels, mergeVoxels: "3d" }); + + const floorZ = container.querySelector(".voxcss-floor-z"); + const floorX = container.querySelector(".voxcss-floor-x"); + const floorY = container.querySelector(".voxcss-floor-y"); + + expect(floorZ).toBeTruthy(); + expect(floorX).toBeTruthy(); + expect(floorY).toBeTruthy(); + }); + + it("floor-z host is rendered as a grid container in 3d mode", () => { + const voxels: Voxel[] = [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 1, y: 0, z: 0, color: "#ff0000" }, + ]; + const container = renderScene({ voxels, mergeVoxels: "3d" }); + + const floorZ = container.querySelector(".voxcss-floor-z") as HTMLElement; + expect(floorZ).toBeTruthy(); + expect(floorZ.style.display).toBe("grid"); + expect(floorZ.style.gridTemplateColumns).toBeTruthy(); + expect(floorZ.style.gridTemplateRows).toBeTruthy(); + }); + }); +}); diff --git a/packages/vue/src/VoxSliceRenderer.ts b/packages/vue/src/slice/VoxSliceRenderer.ts similarity index 99% rename from packages/vue/src/VoxSliceRenderer.ts rename to packages/vue/src/slice/VoxSliceRenderer.ts index 0fd8b0a..89a1f1d 100644 --- a/packages/vue/src/VoxSliceRenderer.ts +++ b/packages/vue/src/slice/VoxSliceRenderer.ts @@ -11,7 +11,7 @@ import { buildFaceDataFromSnapshot, NEXT_LAYER_STEP, } from "@layoutit/voxcss-core"; -import type { SceneStore } from "./sceneStore"; +import type { SceneStore } from "../store"; const BRUSH_CLASS = "voxcss-brush"; diff --git a/packages/vue/src/slice/index.ts b/packages/vue/src/slice/index.ts new file mode 100644 index 0000000..44ff217 --- /dev/null +++ b/packages/vue/src/slice/index.ts @@ -0,0 +1 @@ +export { useSliceBrushes, SliceZBrushes, SliceAxisHost } from "./VoxSliceRenderer"; diff --git a/packages/vue/src/store/index.ts b/packages/vue/src/store/index.ts new file mode 100644 index 0000000..d09f0c0 --- /dev/null +++ b/packages/vue/src/store/index.ts @@ -0,0 +1,2 @@ +export { createSceneStore } from "./sceneStore"; +export type { SceneStore, SceneStoreState } from "./sceneStore"; diff --git a/packages/vue/src/sceneStore.ts b/packages/vue/src/store/sceneStore.ts similarity index 100% rename from packages/vue/src/sceneStore.ts rename to packages/vue/src/store/sceneStore.ts diff --git a/packages/vue/src/styles/colorResolver.test.ts b/packages/vue/src/styles/colorResolver.test.ts new file mode 100644 index 0000000..2b6f08c --- /dev/null +++ b/packages/vue/src/styles/colorResolver.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { resolveColor } from "./colorResolver"; + +describe("colorResolver behavior", () => { + describe("hex colors", () => { + it("resolves #ff0000 to rgb values [255, 0, 0]", () => { + const result = resolveColor("#ff0000"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([255, 0, 0]); + expect(result!.alpha).toBe(1); + }); + + it("resolves short hex #f00 to rgb values [255, 0, 0]", () => { + const result = resolveColor("#f00"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([255, 0, 0]); + }); + + it("resolves #00ff00 to rgb values [0, 255, 0]", () => { + const result = resolveColor("#00ff00"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([0, 255, 0]); + }); + + it("resolves #0000ff to rgb values [0, 0, 255]", () => { + const result = resolveColor("#0000ff"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([0, 0, 255]); + }); + }); + + describe("rgb() colors", () => { + it("resolves rgb(128, 64, 32) correctly", () => { + const result = resolveColor("rgb(128, 64, 32)"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([128, 64, 32]); + expect(result!.alpha).toBe(1); + }); + + it("resolves rgb(0, 0, 0) correctly", () => { + const result = resolveColor("rgb(0, 0, 0)"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([0, 0, 0]); + }); + }); + + describe("rgba() colors with alpha", () => { + it("resolves rgba(255, 128, 0, 0.5) with correct alpha", () => { + const result = resolveColor("rgba(255, 128, 0, 0.5)"); + expect(result).not.toBeNull(); + expect(result!.rgb).toEqual([255, 128, 0]); + expect(result!.alpha).toBe(0.5); + }); + + it("resolves rgba(0, 0, 0, 1) with alpha 1", () => { + const result = resolveColor("rgba(0, 0, 0, 1)"); + expect(result).not.toBeNull(); + expect(result!.alpha).toBe(1); + }); + }); + + describe("empty and invalid inputs", () => { + it("returns null for empty string", () => { + const result = resolveColor(""); + expect(result).toBeNull(); + }); + + it("returns null for invalid color string", () => { + const result = resolveColor("notacolor"); + expect(result).toBeNull(); + }); + }); + + describe("caching", () => { + it("returns the same object reference on second call for hex color", () => { + const first = resolveColor("#aabbcc"); + const second = resolveColor("#aabbcc"); + expect(first).toEqual(second); + }); + + it("returns the same cached object for DOM-resolved named colors", () => { + const first = resolveColor("red"); + const second = resolveColor("red"); + if (first !== null) { + expect(second).toBe(first); + } + }); + }); +}); diff --git a/packages/vue/src/colorResolver.ts b/packages/vue/src/styles/colorResolver.ts similarity index 100% rename from packages/vue/src/colorResolver.ts rename to packages/vue/src/styles/colorResolver.ts diff --git a/packages/vue/src/styles/index.ts b/packages/vue/src/styles/index.ts new file mode 100644 index 0000000..8747fca --- /dev/null +++ b/packages/vue/src/styles/index.ts @@ -0,0 +1,2 @@ +export { injectBaseStyles } from "./styles"; +export { resolveColor } from "./colorResolver"; diff --git a/packages/vue/src/styles.ts b/packages/vue/src/styles/styles.ts similarity index 100% rename from packages/vue/src/styles.ts rename to packages/vue/src/styles/styles.ts diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts new file mode 100644 index 0000000..6b3d1a5 --- /dev/null +++ b/packages/vue/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "happy-dom", + }, + resolve: { + alias: { + "@layoutit/voxcss-core": path.resolve(__dirname, "../core/src/index.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33fd167..37435a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: ^3.1.1 - version: 3.2.4(vitest@3.2.4(@types/node@25.5.0)(happy-dom@20.8.7)) + version: 3.2.4(vitest@3.2.4(@types/node@25.5.0)(happy-dom@20.8.9)) tsup: specifier: ^8.0.1 version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) @@ -25,7 +25,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.1 - version: 3.2.4(@types/node@25.5.0)(happy-dom@20.8.7) + version: 3.2.4(@types/node@25.5.0)(happy-dom@20.8.9) packages/html: dependencies: @@ -145,12 +145,21 @@ importers: specifier: workspace:^ version: link:../core devDependencies: + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@25.5.0)(happy-dom@20.8.9)) + happy-dom: + specifier: ^20.8.9 + version: 20.8.9 tsup: specifier: ^8.0.1 version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) typescript: specifier: ^5.3.3 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@25.5.0)(happy-dom@20.8.9) vue: specifier: ^3.5.12 version: 3.5.30(typescript@5.9.3) @@ -956,6 +965,10 @@ packages: resolution: {integrity: sha512-7wfBi+UqulQlyLcis+9a+hTK0A/fMO4QKP6w6J9HnadXVkRdOvGf/N5G4XVpfgCYfnY7oKazlOSdWmsfatNSLQ==} engines: {node: '>=20.0.0'} + happy-dom@20.8.9: + resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1788,6 +1801,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.5.0)(happy-dom@20.8.9))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@25.5.0)(happy-dom@20.8.9) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -2105,6 +2137,18 @@ snapshots: - bufferutil - utf-8-validate + happy-dom@20.8.9: + dependencies: + '@types/node': 25.5.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} html-escaper@2.0.2: {} @@ -2581,6 +2625,48 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/node@25.5.0)(happy-dom@20.8.9): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.5.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@25.5.0) + vite-node: 3.2.4(@types/node@25.5.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + happy-dom: 20.8.9 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vue@3.5.30(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.30 From c9e8a9ab544bd65f2aaad4fa80d4b01c9def508c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 28 Mar 2026 12:16:46 +0100 Subject: [PATCH 3/9] chore: add test:coverage script to vue package.json --- packages/vue/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vue/package.json b/packages/vue/package.json index d35f5be..877e354 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -25,6 +25,7 @@ "scripts": { "build": "tsup", "test": "vitest run", + "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build" }, "publishConfig": { From 630c48bedb628b34ad36ce5b9db3bf64006609a3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 28 Mar 2026 14:28:12 +0100 Subject: [PATCH 4/9] fix: perspective default and empty scene rendering regressions --- packages/vue/src/camera/VoxCamera.ts | 2 +- packages/vue/src/scene/VoxScene.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/vue/src/camera/VoxCamera.ts b/packages/vue/src/camera/VoxCamera.ts index f1aa355..ed09383 100644 --- a/packages/vue/src/camera/VoxCamera.ts +++ b/packages/vue/src/camera/VoxCamera.ts @@ -17,7 +17,7 @@ export const VoxCamera = defineComponent({ interactive: { type: Boolean }, invert: { type: [Boolean, Number] as PropType }, animate: { type: [Boolean, Number, Object] as PropType }, - perspective: { type: [Number, Boolean] as PropType }, + perspective: { type: [Number, Boolean] as PropType, default: undefined }, class: { type: String }, }, setup(props, { slots }) { diff --git a/packages/vue/src/scene/VoxScene.ts b/packages/vue/src/scene/VoxScene.ts index 9f429e2..2e3e9ec 100644 --- a/packages/vue/src/scene/VoxScene.ts +++ b/packages/vue/src/scene/VoxScene.ts @@ -160,10 +160,6 @@ export const VoxScene = defineComponent({ const floorRef = ref(null); return () => { - // Don't render until voxels arrive — prevents flash with default zoom - if (!props.voxels || props.voxels.length === 0) { - return h("div", { ref: sceneElLocalRef, class: "voxcss-scene", style: { display: "none" } }); - } const { context, dimensions, layers } = sceneResult.value; const mask = wallMask.value; From c8fbcb019b5d0efaf6fbae90e9e0c579faa03b03 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 28 Mar 2026 14:30:00 +0100 Subject: [PATCH 5/9] test: fix perspective test to check default behavior without explicit prop --- packages/vue/src/camera/VoxCamera.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/vue/src/camera/VoxCamera.test.ts b/packages/vue/src/camera/VoxCamera.test.ts index 97e843f..4b4d95e 100644 --- a/packages/vue/src/camera/VoxCamera.test.ts +++ b/packages/vue/src/camera/VoxCamera.test.ts @@ -24,10 +24,8 @@ describe("VoxCamera behavior", () => { }); describe("perspective", () => { - it("applies default perspective of 8000px when perspective is explicitly set to true", () => { - // In Vue, Boolean props default to false when absent. - // Passing perspective={true} triggers the default 8000px path. - const container = renderToDiv({ perspective: true }); + it("applies default perspective of 8000px when perspective is not set", () => { + const container = renderToDiv({}); const camera = container.querySelector(".voxcss-camera") as HTMLElement; expect(camera.style.perspective).toBe("8000px"); }); From 5c1526541c5aabdd8c212f930458491f6e0336bc Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 28 Mar 2026 14:56:59 +0100 Subject: [PATCH 6/9] fix: apply wall mask CSS classes on scene mount for correct initial visibility --- examples/vue/index.html | 29 ++++++++++++++--------------- packages/vue/src/scene/VoxScene.ts | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/examples/vue/index.html b/examples/vue/index.html index 6b8c34d..2dd0835 100644 --- a/examples/vue/index.html +++ b/examples/vue/index.html @@ -28,26 +28,25 @@