From ddea7d348f8f640f342b7163cf5b413198383873 Mon Sep 17 00:00:00 2001 From: th
yGMY35p9@fY{k3oA}W-5zxo{ujmf8Xq&p&zK{SZ!G`E(r5Sj;YWt6m61K=&3r9{ zc_FS)L=iKtV3JCcSYn(jPAb9L;wHBy+~^)0dYoNZcZYjrXS<`C4|MzF_h8bhSqtK* z6qP~)5-?;kYi4&%t8Tl|u0FT#UiVt{0Pe%SfUiJm@57TE4JcVg_=U&6m&lNzzdMWb54UHJt1EBi8P_mH zIGuGBotseOCw|SyZ(+Gfc?;g3V}OU((2!oqNGN&+hGF7vC!i-|O^(WGw9Ox (&fr1Uh>dLqy@Ye7JA%j z-oHO9F=@uFte<`S&3_r4L2sTqhS2ajz%Q>eg`_ct+ELnA9JcDRpQ`K@^EKV&rS)#F z{A~C9mP6cLx%p~vHJ?TP6pP~wA=nYMBj!!ZU-3BkKK< fUi!VJiJnA3r z48o0^7SYGyr7}=?C=pk^bEyO>?t|cgd!v!cVGBKoZ etTnj5+rd}R32#VLsp+;P=s(@K6!#AgO zqWuBL0~OC7#|elxN|%YJsRtw+e^tUbMtPMlP9qL(X;jmA^e* iEMuG-E5n#f z<#JuO7mAx72Jl$`#T!%QF Y%bC#$BZKvD~7OJaO2h-|&s#0KnqRowV{}>h4rB zzaLEYtb||q;&jA|#mg__X;Kb#;IG48M#LY8-NfWcU;2gH )_ zRA<1s54ud87sslxuI gD-nIPIE69HS*Ju6udtK?aH@8<>-q%uX-6Lcx7KK+kkE_dBf?&^ yQ+QGf&gl_SZ zF3f`~%_wZiLuw`hclh3rmlD-K@Z8>&PNh#tr%FecH=lPwrIP)ReD^;;Ek-63D6UYf zawny)3Z{zm1aqMuQDXdstSw%UFC`gqi6ioqh)F4)`M}uF`Gl?ss9&@ zUqfnA-g=I*@$wi=!=c4nDdf66N&_0p1@T#W4t3Ivrllzy_m6~$Y(XihWAMW|cqS61 z-n`+9(@6S_C-vAL%uP&@sJ=^v!eun@j+iJe>KKefZqLJ1& =ZTvNXMe z`;p^Iyn}DaElSJ^oN?Zz=SXssGR^6BcJ(*0xWSf8T1~ak8eVt?q(`usR4!!Ct?Nq9 z2p44b2|H8M-5|>sH}~J;e&2VSKQE|C$zlZy(($Q?NwG2t_#fB4VqLat_$zDkYmQoz zUj6A_*SBJ@Qm76=m&Fq|#Qc `4DQwoz7dJH?gj%(M*=fU`5C9TAbYE{6ai! zs{Qroi-zLkY2r057r|^}IP~;$fYh~*=TG8MJ}(nm0d>(&+Xm4DB8ok3Pr#1EPDEaD z9^$--Q@UO|cH$5o=0jgYFvA&liT1{5^FDg*`a|lS>e=>; r;1j$t7qMMT(Te&<|hgG`Ug)8-?{vi8_r(+;(zOHTKAmI z!TNItDt(U^OGP(aE`|E2I|oZ#cfMUpL^nmcJ61oz!4X){c5<4irFYE%q7;OA#w7)P zT$im`1$qPnoLF4QkZzE;0_EZ^dfm c&J06&$lRq#}O}0^g|7ux!anYw>c`{&ivxk4H|@ zZ)K8eChoVmUE!)xkWm?vG?QV$8NB>s866n;9oC+a;W0VNHNSAE@qZoN$gBdBF}$-c z1<~wU9?^^ARdhU@^jlc2czgjo=$QGWOpOOk^cz0mvNCUjiv-PQ{dAjzu_J`ncr>Ua zehZsxaP{5L9Y(@gSi@~_%AbrINu#U}!uv~jPkxeHG@-g+dAI9=ZwS2S0C*;JdU3@| zygcR?e`X@TrURV&JkQS-<9xbRKm#7v)nbS4!qS~0{GlFD7+!67)#3eWl= TNU-~Tn_FHEOz$}J%Sh+66_-L_Qy|N_m cF}o zHV6z>iX48gy4GxyU*q3AG#o71vJNgN5d*5BhjVVzI_9=A85a)0o)k#27zD$^F4c@I zTS+T&*At@^f4rT;BknJEu}PUx2Pz5WftAZvF6|F?71n4{o#|}ui@E0Xe-Cf|QoYIL zT_lBZ(W J2Ulihe#lyy*;>I(~%ewD_B|dyl(olY*qw(}?8jY8XhcubSVGebx zUWe3FA53PZ;4Ex3l-<(gw|IktWjCE(?D0yRi~-RZP`9=!i8qc)X|gvkN?RUJ*pA=$ z7O#fG__nN;X5n#NkbH~+vvhz;A2(h*q4Q{Kl(X&34Lk``@vDBG}RCN!&^`+@m3PT}V}4Rz|dC;^mEp zlkpxn`I9@| TF4_y=6$m2_O< w4ndn;I$NM%#$}nAZ8~a&RXZAMa z(7#_3@gou`m4ZvPGb5n&5M8t-rQ$E#q6R JYaQTU13xyLv7bFPJGcyy$9NH6 fG?`?46dU_RX`euS)qyd=%8F<6;~j-V&s>BrA@;Yq zs4gpNml}AKa@W*y4}`><%9ku-JS-Tk8t= y5#2=}uMR+aEox^5K=I- PnQf4QW6vVLBKgvvPp$zz%6>B4`FjL=% zrhw7Hx^6NDhg-3hQ~0ENTVh?kq$w`q$cF@iQa@on`Iu=sk;}Wj-uKk3kEp^*?y@rV z{Jv60$-C^=Xs8YvFG*q+FE`$6O2R1|FNdXDehWt~Djm=E@QuzhHKBMTB8>I onU75FWF%yI$H z4eW7-Xgs%cgI|PyJy?Y=j(aTp6wlvl^4NHI>!cZe!z 0K0G;?vxEJWJ_hfl~RrnVbSimH0$q zZOoyuMzrSP#~yqP-Kz~$M-(R2PipxkY!0M*6+(l`T!!?AQim!7)`%ECyTyIqy=N6a zvGTQF>8-4LW4Tma$JR2&2%%fW2SkQBdzeda4xFa03Or98E*xg@ynw8|5<#)F^QA20 zz-MdFq{x|3h~F)+bQ!X(=o5*=6`9`R>sIUtBZAjVBN56!w2{*1f^;r-W;&aC$Cfpp zPFAR0DFaoCWW|VwMO)!X)eB{sjA7%t%G_SdQ;)vlOfK@v+T3j|+cr+Ux@0fIHo!y} z{(5Os87JQ34MUu#xZlb=VZPW&_z1Xf#F~r@hxnIY#%QR|Df4>&yZxVefV>cExRZQY zyf9r0?jO()shmbPo-UXu%s3p+t9ANLk>eTW-Zk<#9vjCQ&tmb4W6Hq8T@(kHpx^3_ z%4m(LmDgy G!oIS ztYPYW0mZ;b1izN{Sl&R43@&PGBKl~R3pRmfaEL2^Q6Szj^5FVkKO)g|mY>oBZ=nx8 zT1|1+$|qg5dGFm+XsZx<=H^$0h=;|B)d_=9+>{#hEpvo^aW_dL9u{vhK90soyB_MA z;td`~(-86e2CKMayuwyagWFZE${~7I9t(?J+;2QuW2W?R4Wkgje&4^65v0*ET82}p z^2;7yh&}+KxG@bi|MdWyObw5@$`|>gcleitZ^GKNq|%KC`7NJZ$^As?z+5G4$DBfj z_!OGqQ8xMGVG&%-)ZTBlgEN_`$Kj7AbJi6;tkWd$C)@5u7lJEpMYV)0&j1TNBn zp9sP3{beMgT|eK1E8}e}28=(AWcMo;@7`B{lX{8x9H9Ral(xm99JH8@ve49&);?JA zke`ne5|X9At@tR850JpA;uWq2<&+>bwe!@OQ-AZWaKHTuR}Z>@f8lfQVV;YGgZ4@| zYvv~BHqqfl*)h6cnhGT#1yLkiMxhMp><4^nQC({wvb=G=Y7C(uyz)T1l%+sWCi?CQ zet&bLa58mAgUF8GaAv+O^N!sl0aXy$xJiaB%_@t9r_`nF9|qz47*gXybhqV`z6P~+ zX$*d}cQm*0ko;zihVok6Xgs?nWaWv|7bOcDzs8e=C&UeM`8^p{*YFumqbVH1HxkA( zpP1wVwcwIQ$I1<#(u^;~$!~bq!^5A6)SArC6L9hT=2rvMLhjcAJEkf2_}ySHjd5V~ z43F^>cdy7T+MydItsOL6;w$M_ShS74U`82umc;f2K==&jD40Y*I5(2#o<#ia4F>Lh z7>Tx}MwG3L<{Cc=t6Vy+E=4(^LJH0sMi{k`geI&|1lZa%7JhxGTyZ`{ibc~_Q5eM% zt%0Cw@cT6sMj`lUO`kHe=ua8Q$L aObA D6k>@y{rU@(9MP^2sj~ zxzYN@s|r1%HX1|_TYoj(`Esnu!{nrLY-6Y-OcVHztZb7ufe7O-4r+9jCcn|I2kY}m z2K+q-Nbwklli$LI*FO&;*CM}1J%Xp`2l|ge`tch1IIqTYoHLDE^sE;uG80ePo}Xm{ zjU==Ht(O`>=m@;;vd}nrd9Y#D*B=t9RekzgA^P&vTJcg17?F6@qRXg?G@#p~xjWL& zf9PWyKQ-@rH-_odelIxgt}wN9C>8Xf8M08 qU 2Mg6^&eJSfQ#NmKYlH _6dkejBF$ `#j1S _+T;Vr!HIVA MR5o)ntC|8*@%D^o@Qsy2` z*k@{s7nb7dy^7Kto31$u7wzQ2 C!$R z+0_a}4UOJJF)SnQP5w#0U=CnV^)Rj~c^RuX`Bi7scvv3u6YYDHx)h6oMd}w973#lr zYmHlt4S{cC45t!A%Znd5|Bvt8Z38|k!pbr#QaEZ6$#j!L5`L<~qAPhAA94I9i+H)~ z!Nv28@=Go#5uFuTNy6S(!znn!Wv=4%JTzZ_JoYN=C==eiM^GrPIC~`LRvf}BBiGa5 zXE{~=cRrRMda}KYFn^8r0IX=n=@RsH$mivms&jG%apx
*0ulD&6YeFQL!S ze}_!GE*Q^oS3HZ7#nmOEuWNA%AbLNdvPS$x(3UsCRg(>o57tGtn v3Pij~7RI;n zoa9CaPyPEG7hDo(o3KU^TKC}S;MTPc)PO(xp%i-BS}XA%I>*&mBl_@pnbAg(^=ZZ& zv ;e8kY zO=Gd>&R0rEr#rTAIy
Oj+R%xeT5)5d?_xkgILKRcxgk3|E_yy}ex} z6v^9UZf?AiSe%rjzDuinqxZt@lW-T-!gBYH`4`}yi=By`j_ts SNTLg z3ghGDJ5{3>CB#j!v_d^geP~2!Tk}MD4c_9f<9yGkks%2`hYFQ_1b3VVqDPFB>KjKw zF%Nu6!5xcDwogW1&?9)|p`QIvkvm}GI46>Qgl-ZTnF<$ISd3eC!Nh5 _{u;N!&t-DPv+x-;hRb>L$l}= zOqAjUp; hON?=OF>4A;ZcM&$bvIi#mgUuQ`~rFgg#PTizNSPdp%3-$O2aR z>d`U!q8ay($HP>nnJnt@COTH$o#G8S>3qn!AkhvcnWQ|D^wq=HIzz9A-uiP2xZ*gB z|2Th=HkEqhD_A4Bxs&{_%;0Jo%eJ*D&B`dh;nBE$VWG!8hv$2{63j3%(Y;(VYGLDh zBp>ne%P37&`IljL#3;Y&t`wS#!iL|%7H6*F>lvl%U6w0rM)>W$G2Dhr3g_uy#J9<~ z9zT{QzDMLWDFvc-QcA?66G;HPT2hISN|n%bR$VTWatC(K3lHhqpS$6k7;Er3a} o=@r!>6*dvg5X_ZB;sB;i;s rCnNw zVU2z=zrfYCYJ$>f_(v%d!fzNUfgnd4;WB)_Ge`L6)RP_UmYVEEMR?<3#mN}GC^Y_W zz5FHz%Nd6;rD X$M~y#c2sN|GIhJ7PPWGNX#QOUFs0F%8e)6Ei zAcHj#O=|^5bh*?tfKd{mE@0H!NmVWiUR!&yL5XeHn$qHXx?cBYHBu?LRlUvCPc2y( z-&emo1Y#8=?%K1k3NjWSmOfGVB%UNq!((t>c>r`K7sLJDA}8QR&EB?vhF^3ee^dgC z-!{k8#@I+Rq8i~gf1C&TWvy2{DK2uy` EU(e9^!X9^Q~VqHyV2sj$uDch z(&TDQ%}?7*ZZtwpe_Jp84`I=mMjdwfHO1AM&jhFM`SP%{Wgbd~OKyqdy4oPmv`&Vt zv`;h*1~;_~y1SbO!*%%~x2aM}ZPWJv*X}TVuYc}>T( Y>|x1LZn)S{O{v@+zSyR3+H4C4u
u~11Z$*h{@6EBq&5q@Z=a=8- zi{ZMR(Z>UXr`i~=ahm2Ij}`~Sd^rVcChoU5J%1Huu0=eRE_^)03LLTJxrs$3xC`79 zikX}ODG#Mf8R|D^si;EE^Y)+42=|@V9h^Mx6||_WbUpNSzaVU8CsK|^_O{k_!R76} z!GnBYN+TBZNswZ8t6JQl&Q_u%lM4>M@%hb}YVoz{*2;Ozx$WVGhFP%C`88Ca*7tdV zDvT@&4|2|aIeH`quk8#P_=bG6Oo+xrUj+N{;jL%3c_HY)A=`nY&mw7A2;l(bUqz2w z#nh|En-XUD++$0VtMqRPZ|yzS{p{}G&O1Ky(MKm&iXDQc0RH8ZYsNoA3*aiOiY;#A zaORgQn0h7-qx3ke`3)wXA7S~Ps4C=t@F2dsgVPcIg^VM{pDdq}EpAeEKY@4RLH6Bw znxmDQ$wPROGNC83V+OfxVEX7c wmFCdJIR=1V6na>w4Bg9>IEGP@ zmobQz6Sy74ljPlJRZko~en6@|+cy?U#rswSx^K*)Kp41dKchaAnB=`aOX`|&y1Rbj zq+;XckU!4zR==KM9I_4xcz>vte=NwT+)-ZnXD|Yr#>NCrH9s{*TlZ1;+QeYIS(%e% zh{F1<-7Gw*mq`8TXg`&&^*nbekFdIL*DQC!{8xs9p}vPpEua(L1cg w|l> zWR#&Mv8~k)e)N&296WMM?(P)X@T(6!GVO}zrQ5PE4YSPPN^zv4I;^z|E?0ELCzzyr zAnF7%!r&{+R2leZD;ZN-jJOX)aU9+nrt(x39XiCpP!8`Wj9A$7>sN(PCb|>MA3W4x zJx5zIV_I0{6#Kn>FK{1Uc0hGg@!=osCK-qWabq$U lS9>c`eRx{ESgXzeUz_Df5qb zzW+i7EzYY>$tb_!mEXdzU=jY)gmq435jJiNMsf0+(PZ)lD)i1wUkR7^F&c8?G%Q`N zjLMNr_X)9|J8Bq{a27Ut{yigd4*klb$p5<-j@{~Iru`uJ*bkz~@+El|U*ccy?s^3# zr9eWCHR!66po*HHQ;t3fr?oT(FW%$iu%AP8S$f^-fVZP&Zfn~Ze!0ERg;EsigJH?f z(3hUNdh^m9-25&kcC{IJ`JsE{I)YE6+VgJ;^4WF<%G@*$_Yw~a|79*vEG}ca@q={! zqz734PzxgkqOXbs(|OmP+fvPF)X_vnokrJsiR7r4FfL|3ryC(AKbN?WUDIJfb=ovu zk9Joa9CkMkzbO37x&y+cx8Hy7A?b~mEZNlpsNt`%DaPfMD--uyoO*VGkQ>3PsoB~P zDAn@S(E70XqdbBdZP+(-<7o?>v%LTjoTgm@cQT-lp+rifv+Phv3-C#k ~=ebGX zT@gk&M}XH}Qqh-@%w#2Nxsi#K;kb>ofsW-e`oE1&-aHIaDF>O6GUFF5<3VYv^2Zi> z+*}%mR}oOUtc TQmw^lvA1YjhN6Ysn?H0j`j$@=MB$Z_@7Pa z8RXMfJ}qr@jtJiPk$VFpqBTjNNrpwstg?EZVmOQ!qZ>=A VHi=6agm0_S z7M$qnm#0jbz~mc{yOh4wM=P`OX<>6W0Y7 `c0*b4~c|Hcr|w0tscaWZ`CYlEVtU{&cq1=`=pq1xcXEvU2NLNcX_=j{?{vU$Y z%z_k$##s?Zk-=mhCDK<4OZN@A>Qb&BDezuMweqS 77iKxnSd#mhj@r8SWd!j$qTD9w^OQ{lq7( zzvwG<7bS&y7nq3g2(f1$uj1my!%&nbh&%@?sc1~5cr}C?K`5`R!s=;j4*N7>Y~qfn zZH)38P3>LV3mX4Iop{SOkUJ0ot^ts1DoFeq9diw%g~iLEG=Q^ni*~vJGTe1t2x+UT z8S{$1OgtR Mn(^>Hww!tUGwo;L_ebz);p;@^^9ux zos2{tVEQGAa}+#strmLxSrM(V2&ZVp^T;nNwWZqiS^~O=Q?RN>f|s#8qw|x4h@V6t zJM}C4M$-^Qd_|!+ebs#n8|)~T3703B8oC?ao*zr7m&f8n`!$6g_eDBVOJaF;ix;{x zd8ktI_KZZ=crzX>>|K5yX*%95^NDAicj-BL<0fo0;)rPxw`h-NxqN0erw6NyHqxw~ zhwj?0wZTPA1sw lPrIW^EG_3cQF=$^g4CW2ujXV?IGXdK zJ}}o+)xKStJO&p6SIt*79+|#n>#`Cl3ofOqwxc;ZTbMOtfn_ii+!($n{8n(VTim}f z+;Gi gZeKFT zSC)-0Uw+agEBWPm%g<)~J7p-_70C5paWzLIyh*c_kBItz0xNA|uyOw4al&)eB%akx zWPTE4b7~9-m(f<#Ryu(@lll`aAk}~qj`5*risv>lpQQz$sWQTE8aK(KwXpH{x^!6( zDzPARBZ<$$CgCk?Wl>o3%v}`dp9?+iAJ8PyRJ+CS-UamI-wpjeAZWbBX&ax4Q@X-b zm9280lmdxnu{6<9L2;yl1E)Q=TJYa}W$+2s+d4P7AGR>r!(-5gX5}_7Ou6Z&bcN}8 z^uzgxd1bil`e|xPMZ!_@E9R!eY(D<5X0{8zDl^#sk)S*GY?r6?=OU{z-zTXIu%5q+ z_5OAGW)^Z#m6(MFz-IQza`9CrNVhXi$r7hID?S<`mEeaKdc=~SL>DQL=&@U4NlO%_ znFwp |N|op=2gzqH#b zOH_bSl|+T|6#`c#?pNAaT~QAl|Is`YFU@;EAd$U|H075uHws0+7N^dGhNkbL5~m71 z?mgfU4?ARdM4Q=3{Se5dKn#!3O5#*J17i1n=w1rloo=*~1QZ6pR*0F*9O0)H?d4)T zD$YzV@H?|G6yXW|HNL+ha8?a*JvnTR3*6qoi66xoU*;-KZ;M_9K5F2 MMGBcoj@2(f+NOwQkwj-GFQOMR1D#bFQXG-AN?CotH xDfnj4B6%PV|aaneemJpYb7R}%m0c!pi+=e<+s=EHEZm6xqDHhGH$<6D|rU&$Kb zaZ(CIxcxO*pgUpy3%L0`=Nn_1>2P`Lko$h?u+~e!uO$=S zEv;Pn#0V@8|1`7u zjN@>+HC$4DZn(U%0I7Bdf6h;@-jgi{O)LaW@1*;=wl7%u^Tlq 7 j>dlwCGb|n`8%Y1D4?c{luJ0hKo!7jrNg8j zqW%C4!cvCS-$5C~;UegX{pzs7-`BV|{BnIojiInKfqs-#;g#%d{Fe5k#$e*!IfGy2 zJ+r+4@s#cO3q5Wg<@#Ubp>0w!#*4|=!b%f;Z%KSC{X$ uEY!eZ(+@ - zmQ?TW>~nWz!YaG2SQe> r1#H;MNZMNlh&&YeuBalZeQd~M!*?2=9AR5JM&vBRpVu!6M}9&b2@-B(AVH1=(Bx#=0lsdy;|7DBXO zfeO=yKm>=}q#V6`SZ?R+JR=JhMcSGIK7>mcjgqFLtZnN^n%!h!vC@PB?Qly5_Yam; z_izv7W`?(?+JZ;f<~7U-#B5VFXlgFF*_<6=Qp>HsZb`V}59>IwUah8wdmqV%!>?QY ztFJv2FUxN9Z> k!*K5Evb+jOGgW)-+~B$@3*O z?8INK#K{`y-3yLYAl3Z4;g&0FbmhuK>5(G4fR)hw;QMp* ^!)f zzV &{C&k5$O?>`{5eBk4$zVHo2m(A(x zSHZsA?05(YV_`nX&Sbhup9~z*{1kVKl%bwsE~|;Pie`2fHD&xXl$2N%o64df*qmIg za?LQU$WXbB%Y{3G-fCCaTb$umS3B6BG{fDJp6(t@wS{X@D*bsT^*kDuKDg>9-&_eh znkzvwi$ds-VECR@?!KQbMOm^&Qm#~+2iEs=G!6g#vR{1ti3t^S*8*8(w#pl?L>A{O z^NzAXBm5AJ@oRVye$A7f4j$1kx^dU&`8Z%Z$a7Jl$DKw4al>TklV>F_p7uw+xkgX; z&4`ADjfaT7ln84h&^{5q|C5*GCya hR1Unhth7S1R@)3IDZ$hM~KNafI2jTZ-_|1~ZXvAH^X>lGm@ZC`R z8ZGu2)aNgAljUz1DbslFnBhM9bh+$KKzV!=LdU_0>O!2R{NhEUA#0Q4ys5vfR{Nv5 zMWgrNPfCHP&)(hC=Gw9|xT~B~* E|W=5bVY3Q!H4V_Jh1u$m5t%wx&5-!D$irC=zxweKQ9yH+A=Pc zrVGax1+~?t%4t64*qDZbWR9Uv6aEy}d6kE`akn+>D|frW@-(-3pe 43UTIE{l@P=2JmaOX%7=IM2!Jm_}2ib7)1%ou&BAJd@ss zH@GF2FLmozlqfoPOje4;Y;p5`EW7;cn ypU*RVi1*<#B7OnBhiO&$c!aCgt0zp$vXRuU{;GM_ zp552+*@?5kbpT;0GRatU;$~a=@zki?7n12uqocQ0_GEnBa5~i<%xap=#|KG9ltH%S zuFVZ=hA0f?@+H1S?R?UeH4)lvHokfwjX-qKpyrQK?M!>K{nxKQkebP6W &cS_wEeYImJQ15*Okk#iBk+TSi$h(&O{&{nb2! zk2##*XbQKKJKaEkw_CivJ6v0x$$rnKa3f0XFV(!eqr&b%X`K{`HN(^soj`xIoF~@Y zJQQ@Zy|;yJ>iKE3M6GBDZGL0JR=|{&w1%eSctL|E22n%zuM3y{<}tV8Vb(nIjDTP( zQyh9S?}k1$?SSr|J@S>0k6AuRX*U@?5n1u#pH7XgRUUH{9e 6N$8ecDHX3oIF2n>)?s$JV<^tsR5;DDTJJ}4cxdIn8PTl!w$b$)elDK}J>uvZS zN2UXWHN~ZVxH^4pD4P!852i4OK$k-d%&(AukyG=Iv;h|O0I_rn%T*uwPVjt&@@z8- zqIsXV;kGEZKhsxi TH=+}C>bvRXC=bp~q;&jT3(^(U*l57PYot2JIo2R- zou6_;>Wh Yb1)>MmY%y+bo^X8 zBh6Wlc0Ot@Wzf-?ax=R*1Ja5fX-$QBJ`AFpg!#}EJc?_|qJKmON|2yK&=4)WntgY# zbNByzncKLUXUK>u#e5duxh*#r2Y#8$ls=lBk$Vi~LGGuOQKKfK$|K(DRU*Y39Nrx_ zj3UmbqEAPZAFWXmy?Qi7#M0C-??I)xywKxLL@6wp3 3l zR(4fl&c;s47`Wju8bYEp3(J*}`z5N+lRh-XKdICm|MQ7UovZByxSnp%6ELxGf@H+6 ztavnjZ$iFnCYZl282K;d*`AB5_Q&KTSfgPy>*W^=^W$zaui*}@mDNL9ixXY(BO3D8 zGf|qF_DLG*abKawy$;@gOWC!Z){K=mPB-osoY9M>n4)^AQTEAL!5ZJmd?Oiwkx8b< zDoD;m?gn{_<} G#MS?$Es<8M2A$8zg+tenj|)YLZLRy1itQgf^xYSK`M&>pQ{hWrIyHOv zeuvE4+pT J7Fo;oTXctVk)Yo;+p5CgK4wipJkCMiY3*Y3Y$8aiXz)E{Y}=};gBU7 z95!$1d<)!b4CRZ+$jNUybGZVV@18ZmeZPLvtz8YYrtGpg511|vKABCG&uN*K{n7Ft zepbDNr=wAc#xj;BcgjXkNa$Q7C52LN=ajujNuJaL-iL?z{bG-MB|V`DZ-=I`Kgvkv zy*#^1c!<%!j3r=ZPCWDK|TOJ=r`F=w=zmnCT1*| zB9C(mve3DM4`7W(oCl+7JS)5v&Uc^O<=`qB;10UW$agdLxLSVEFg}c4Eh$Dnim~)t zsRzpqXo_7c|Ms5wq{&hr*A{x*D|ptw;#o_H7%ky486^D**E7DGOF}$f9B!<5!NloW z9*dJ}xP2P@E`?tykCg_TbSI@inlfrta~jhG>skuoUucLmEXf?j8IU4LT&iWqC* zr jG~}x!EV@-TGTtpD3k+4c* 1~<*lJ4{GSo(u>dfw4%2A-dHrOt;R z&W6`+VG#nol*?AoON?}wBxB&iy(uUleUKf5ej6_>RA(1Dtc_zD(^9T=Ue2{or`>2n zqv+aQj&evkB{pR-o8}j0P$RC$i8MBiY3dEmw4@{Kuu4lOjpwK2h;8OvOQyHvHr>41 zExB%W(98B=wkT5=)>%N54hw};aq#CYmA-S=T>1GYHYTO~^a#VWe kZ95M3p~ej zMzD{jV|YX_ZsKA2E$sOrY`iLurJ3K-EMGiqeuGu@eTeeuVCAqPcbQ=-3R?dFEsgwT z#GmmKFN05v{U}X}d#8K-l(CE=yBu6E!t&PD?LCVp<0sA^FS>t+wZm+xjp(W2R7XQJ z<&XE!jUS?Yn%qen%Ks#z@)LLtIv5#0HzNF2?zmfzhQV5z@n-pwe2)d6%qM&XE4Kol z55uQ6wmfRY>ba9rAkEq4FvA{M?Yw%VNgqmQYUprQm2)({G{z8Qy2(pfjWCR;ndWIo z@9 PAkBZ)Vri6YOZ(LaWW35qUG*QMITLRu?T{7uAx!EJcxKVvT||4sdEetz*NK zOqlOZ1vB@^x@KnmGA)cCI#RBK(N9%NwoIi}Xw!Aw2;V{n2Xs~QK%=}u;7_WEHG-Px z7SDu6(#h=86xYI5-|FiAVDl~O+|r)4VL#tk541?HateTXymV>k?sTc|Q~B v!H z=k;PdZH-DT*2iIRytHw$Z9fSm(qHItZ%6dnrDa2TqgP{X6mN=?(X!Vm EUu%+Qc)^f;49+Xci)olweoO?Y>ju zI~r( }C`@v79S{NXKd2IYL5ypZ#osr_QE$;C@Jn2^6)fe)u+91swmfwM;Yk5&38CTkv zEe)O@4E0{P;p%_w6`H5HQHf<$ rG^T*nqJuH2>bWEJbQ!OcriN0 zN0L6>0?wIm%m$_KksJ%OmSH>>IQ0h3EA+U(KvU;5_8@HK6D`>!Eek8J{DK!;J)?9> zGx#K&r7K=lmeo9WA=O`}@h7AVlL#jphK>%o{fK-=#_}5Pa%JO2Jp0Cygp1<|JL6|r zay9EQe*}k(LgTVA9?Xq+*vgwWN%Zdw{c-XpX$mw>-}p4RaZrt)#5 5GP}MnYzcp1Brp{3vYsEY0F9kEO@MR;S~5)RZpg9XT(Tao^Ly$T-5W{F723 z`Ai3R*rl7Ph7YmY5$wnO5{I=JEHDCrK4%Ek-}5657D@_tm)-CKdS~DrZ30x(Qqxao z5JSh16f&ioIa@I!2wPaEXqlaLx_%_nDzv_Qelrw$NYfc!TmeeQ-+8DagGyS$FW{3N z;q-14A7vpTl2tTEMJCNxDK9_bMBUtckyV<-{qEskKJL~%UPd2*khL0?IRIuNg%L^G z75c-{@YSiH_~~4(ykzxd{~95N&ePpUe3&G^3d`1#y*~+~d{biRg+0d0b2h@i8j-6) z87-qP*WyG|epMkot9O!b347eX>zqjNryJytG#rdi-I(j{@{o=xV&Gmq=D7iOZCM zcrm}=>hY#{nR@&SZxl~;CU7gX&&lVWaYJzQqe+?`$*%f=?j^`h7cZ*pRu1Di$&2Tk zpS38uo2H5Mkh`?PB)%z{&>3b$tq1Y=_kq? zHHw %2FsYVi|tCZ|lY(`baN;kq1mw$m!-4{Q!K&BTr;+FD+AO&p*^%6yEY zpq G#K&(6|z45K@|0`U#K zh7}AIG+OXOjG*clyrXs=aE#!+2XR50e~f&Q(Euij&@2?}Km%kclw8wbCFs9@V|eeC zD}ug_B&4)Ch;3Z_YHto_P1qw>DP@NHugjITd?Dya-LmQjpA09w!dqkEDRv|ZE%9ig zPnA?Jj8!bLCclhtxV`IxyWl5qw?O5CFr+=_qAM$&{3D7{jKUFT;!9nZe`UY>!@&GD z2De==FUAVaDSU_){X%fOj|^`FZR<#>BMhP>nlk2^-*}9;@gSpq;_ym{%HwZhbAKVz zS814kNh8Qs%yFlfM =jZ>Zr^Ijk(=RYqZZ{))Q|epf1C%8Zm#=+=Ty z2Tx@pn%nxL82CZ3E8+iUi IRdvn&} z;HDtM`O8!rEAhk&rl^%});v5C1V<^PJorIj@S{-)VFiiwejGp|_26MuKIQQ)Uj6(1 zRYFE2S 8d9#JqJ$z_6+4-?ki~YvZ#@e@p=?j%ET&<^lP4QZ+PnoLGZk5EuN^UF}ye- z(^41F=~xd&ZNXJzMNRNLP^m`3s-Jpd;_v^pckVHARdpOccOE;tv%B4POIs+Ef}#?m zK`JN+sR6-Ai24T@i3th5kw`@TGZ0KP5(88v3Wh{eL>{F`X?@g)f(Swj@<^*7QYf_D zoqf;l>|=K4dHj6OovFqsbn3RW^mmdw=iYPAJ?Gx<+&SO*o!{dq1=8bud+8AushzlZ z-#+;_tbU${NUor>Yq ;9C4o%Wk~vs}-l zeIZ@u0uh$X$feZ(Gbgt2eKd?YUZuG8)a+=VuDs~|Yd>>(^6Wd-EzKu(yc;av%RCg5 zJ_)&T?}RvBfK6D{iB~|ARbD=T=@XN#{gh #KEx|AfMYeXh$Ol%S;g #)olxoQpEV3-E)}e&UH?KeVOb zic?glF?xXaxkrM=Q^_S$Q7tt)Gn||px~V!fcSqs3*CJV4dMhHJ5D)}weh)v~lX(kP z@{FhQ`Y4y;SA%JD(;M0xc=h9yK0}*+gs-vviRMm zMBJJLlFAZt%KPUTnv+WsSR*lT<20CflGq~HmDJ_#A|l|Im|V#?d^!uOFU75!$7-1T z>M=zOF;((p>~cZK#YJL-z#1Z#m d8wi4GK6s7pyo3fpGL4bkukpE2+ z7fU3trLo=Jd~pAGd#YZaYiF2MVA3>C!oU@#r^~#Pv;2W6qYytR5^qZca+J?TllWL9 z8I%c9h%OxIV@t`^Mqm}Dtk8kD=p^j*PNNb&8j7f1W3AX&dVml_9vae-AOM3_BsTC7 za{?nhyAY7h20aD2jsvOaQ^z_7*Ve=MFCqyq0;M#o>m`nGiIn;}#sJLK`ipMpcm1ya znHe9kAa7JdoKc0&4Dsu69BE4ilIf_=< I53YJoy97Cv8CF9vR2#Y8*hnBmbCZ!7cvAtCp3sg z@#NMy76f5nP9c?aVP&;C<5I^Fd0qMqB9ka^ltQ0q8QDM|L3hIMQ)HxD6cL2czp)$6 z2jL=2P>FXWZs->s3lAJ}8qQL&F5+19LJi%L%yv1Uxh_Q;tL>b0IY=bGo%>`OOFrga zRJfqa)q6Vme~IE8Q{!}bww#_WY>I~azPt2e=WhGk X9$=p@#<-G5EaKKGGUkYR* z1xjGC#a+&KV}z5&pbKyNj#{aboEUqwK3%vzF`j#3bo2Gq{KnTo%T5^rhJYbp2>c6y zPknp+wJov8tEI7McUQMdPGP;o3Ql~w#L0~OXLm{$;^e%c<^6(;ATnttMtY6-5)UMK zKnI%7LTsRd ~^eGE3pGIVGZ1Rxtg9FeJVXMa3fxV4-Ra&PLq$83;{#H5HJK 4s%YTg!qK& z1$q&EgloxE5uJ);k5~+mh@!@Vc!D#DY`W^N &c;18Lp7 zB}2dvFa(ZN1X?1HnYjYq9$43|zyddetyQHVWi)1?0v!ZGko*B z6ti9b{q^S`K_aFQO+jf&N|Hv9HmE`_S))MY@E??-9+TZftrDfDr*@}DM{Y|_kNz}w*Vo=KZgI0) z%MAfTzz{G5T0@{E0_i!n_l5CdxnA$VEf2kVAPR{BNC@$$A9t>BiG+ub^&TRLSh5gN z1R*0Ry~Ph9!bzY6&0xhdT3A>?2zW74wK(TyAIn9xCt2zVmqE4UWt+RamsQSLb2iCb znH(JJl_)zsz9SkO_*qme{$yy~l|~#w%aS2r2p9qf5P_Bmq*h7yB|4KcXcl_38dMnd z<*>&l^W1QZenTvw^)( TrF3NGCNFR>0uy~upfH~LJX2##2VgRz3J`^ z&c(I>qMJmTwuh!%>7jn-##xmF_e9^rT7@SpA&^SOD?k^m`L jF4vKD5rWC$1nhJYdPCL++11fu&)j*R{zwY2+@YI^bc z!K{|?9z6(7f?=wvA%SQ{OVe9Whx8n9NSL!yu6Vq%pvq8Gs<_(L9BU#KQJ~}Nv0{<) zOM8%9U=9c fKmZEoVJl)|q9cz zit~FxV4r_JU4RZmlPELkWV)boaWsA#sxU!pU!U35HJJ`fj35=<>{ 7|_JYnM_=+HaB*rGadluIXd_DY%qgcQr%kqNN zy06T>)HXGCUut$_Ez5%J9$a@h7g{m|3;{#H5IB?(*l%e>4|T$YYog+@C!JGS*n3^I zt>dg}0&RGrzSlS_c%HMcdZJ21^_L-zafFLSS-eZKtXW v5kop66y|PZ%0 cnU%`@ZAL!#7=Mq|pS;aznrnFa!(%L*Vd3 z;J7W-o6Q#B@cZ8Ejtv1rzz{G53;{#H5HJJ`0YktLFa!*NBN>6{`geTer6U;tdv-&> z5HJJ`0YktLFa!(%L% {~R?cCCfE;#R;i{GHCoi+pv0YktLFa!(% zL% -import { PhSparkle } from '@phosphor-icons/vue' import { Modal } from '@/components/ui' import { APP_NAME, APP_VERSION } from '@/version' import { t } from '@/i18n' +import ruwioLogo from '@/assets/logo.png' defineProps<{ modelValue: boolean }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() @@ -30,14 +30,9 @@ const shortcuts: Shortcut[] = [ @update:model-value="emit('update:modelValue', $event)" > -@@ -1814,6 +2246,39 @@ onBeforeUnmount(() => { cursor: crosshair; } +.vertex-menu { + position: absolute; + display: flex; + flex-direction: column; + min-width: 140px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.3)); + z-index: 20; + padding: 4px; +} + +.vertex-menu-item { + text-align: left; + padding: 6px 10px; + font-size: 12px; + border: none; + background: transparent; + color: var(--text); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.vertex-menu-item:hover { + background: var(--panel-alt); +} + +.vertex-menu-item.active { + background: var(--accent-soft); + color: var(--accent); +} + .mask-preview-canvas { position: absolute; inset: 0; @@ -1821,6 +2286,14 @@ onBeforeUnmount(() => { background: var(--bg); } +.box-select { + position: absolute; + pointer-events: none; + border: 1px dashed var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); + z-index: 15; +} + .dims { position: absolute; bottom: 10px; diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 6297fd5..bfc2cf3 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -103,15 +103,29 @@ const canStroke = computed(() => { return k === 'polygon' || k === 'rect' || k === 'circle' || k === 'text' }) +// Remember the last non-transparent stroke color so we can restore it when +// the user flips "Transparent" back off. Updates whenever the sampled stroke +// has a real color, regardless of whether the user set it via picker, keyframe +// interpolation or layer selection. +const lastStrokeColor = ref-- - - @@ -65,37 +55,22 @@ const shortcuts: Shortcut[] = [ padding: 18px 20px 14px; display: flex; flex-direction: column; - gap: 16px; -} - -.hero { - display: flex; - align-items: center; gap: 14px; } .logo { - width: 52px; - height: 52px; + width: 100%; + height: auto; + max-height: 160px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +.meta { display: flex; - align-items: center; justify-content: center; - background: var(--accent-soft); - color: var(--accent); - border-radius: 12px; - flex-shrink: 0; -} - -.hero-text { - display: flex; - flex-direction: column; - gap: 2px; -} - -.app-name { - font-size: 18px; - font-weight: 600; - letter-spacing: -0.01em; + margin-top: -4px; } .version { @@ -153,25 +128,4 @@ kbd { color: var(--text-dim-strong); } -.btn { - padding: 6px 14px; - border-radius: var(--radius-sm); - font: inherit; - font-size: 12px; - font-weight: 500; - border: 1px solid var(--border); - background: var(--panel-alt); - color: var(--text); - cursor: pointer; -} - -.btn.primary { - background: var(--accent-soft); - border-color: var(--accent); - color: var(--accent); -} - -.btn.primary:hover { - background: var(--accent-soft-strong); -} diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 08c95d0..4cafcd2 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -7,7 +7,7 @@ import { sampleLayer } from '@/lib/interpolate' import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader' import { useClipartStore } from '@/stores/clipart' import { drawSceneFrame, loadSceneImages } from '@/lib/render' -import type { AnimatableProps, Asset, Layer } from '@/types/scene' +import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene' import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' import { expandedTrackOrder } from '@/lib/sceneOrder' @@ -90,6 +90,7 @@ const backgroundConfig = computed(() => ({ width: store.scene.canvas.width, height: store.scene.canvas.height, fill: store.scene.canvas.background, + listening: false, })) const canvasBorderConfig = computed(() => ({ @@ -288,6 +289,14 @@ type KonvaCtx = { arc: (x: number, y: number, r: number, s: number, e: number, cc?: boolean) => void moveTo: (x: number, y: number) => void lineTo: (x: number, y: number) => void + bezierCurveTo: ( + c1x: number, + c1y: number, + c2x: number, + c2y: number, + x: number, + y: number, + ) => void fillStrokeShape: (shape: unknown) => void save: () => void restore: () => void @@ -380,6 +389,49 @@ function textConfig(layer: Layer, asset: Extract--- -+{{ APP_NAME }}-{{ t('about.versionLabel') }} {{ APP_VERSION }}-+
{{ t('about.tagline') }}
@@ -52,11 +47,6 @@ const shortcuts: Shortcut[] = [, props return cfg } +function traceKonvaPolyPath( + ctx: KonvaCtx, + samplePoints: { x: number; y: number }[], + assetPoints: Point[], + offX: number, + offY: number, + closed: boolean, +) { + const n = samplePoints.length + if (n < 2) return + ctx.beginPath() + ctx.moveTo(samplePoints[0].x - offX, samplePoints[0].y - offY) + const endI = closed ? n : n - 1 + for (let i = 0; i < endI; i++) { + const aPos = samplePoints[i] + const bIdx = (i + 1) % n + const bPos = samplePoints[bIdx] + const aAsset = assetPoints[i] as Point | undefined + const bAsset = assetPoints[bIdx] as Point | undefined + const aHas = aAsset && aAsset.type && aAsset.type !== 'corner' + const bHas = bAsset && bAsset.type && bAsset.type !== 'corner' + if (aHas || bHas) { + const aSp = samplePoints[i] as Point + const bSp = samplePoints[bIdx] as Point + const aox = aHas ? aSp.outX ?? aAsset!.outX ?? 0 : 0 + const aoy = aHas ? aSp.outY ?? aAsset!.outY ?? 0 : 0 + const bix = bHas ? bSp.inX ?? bAsset!.inX ?? 0 : 0 + const biy = bHas ? bSp.inY ?? bAsset!.inY ?? 0 : 0 + ctx.bezierCurveTo( + aPos.x + aox - offX, + aPos.y + aoy - offY, + bPos.x + bix - offX, + bPos.y + biy - offY, + bPos.x - offX, + bPos.y - offY, + ) + } else { + ctx.lineTo(bPos.x - offX, bPos.y - offY) + } + } + if (closed) ctx.closePath() +} + function polygonConfig( layer: Layer, asset: Extract , @@ -398,6 +450,7 @@ function polygonConfig( for (const p of pts) { flat.push(p.x - offX, p.y - offY) } + const hasCurves = asset.points.some((p) => p.type && p.type !== 'corner') const cfg: Record = { ...baseNodeConfig(layer, props), points: flat, @@ -414,18 +467,12 @@ function polygonConfig( cfg.stroke = undefined cfg.strokeWidth = 0 } - // Widen hit area on edges so double-click to insert a vertex is easy cfg.hitStrokeWidth = Math.max(strokeWidth, 14 / zoom.value) - if (blur > 0) { + if (blur > 0 || hasCurves) { cfg.sceneFunc = (ctx: KonvaCtx, shape: KonvaShape) => { ctx.save() - ctx.setAttr('filter', `blur(${blur}px)`) - ctx.beginPath() - if (pts.length > 0) { - ctx.moveTo(pts[0].x - offX, pts[0].y - offY) - for (let j = 1; j < pts.length; j++) ctx.lineTo(pts[j].x - offX, pts[j].y - offY) - } - if (closed) ctx.closePath() + if (blur > 0) ctx.setAttr('filter', `blur(${blur}px)`) + traceKonvaPolyPath(ctx, pts, asset.points, offX, offY, closed) ctx.fillStrokeShape(shape) ctx.restore() } @@ -662,13 +709,14 @@ const polygonVertexHandles = computed (() => { }) function vertexHandleConfig(h: VertexHandle) { + const selected = selectedVertexIndices.value.has(h.index) return { x: h.x, y: h.y, - radius: 5 / zoom.value, + radius: (selected ? 6 : 5) / zoom.value, stroke: '#6ea8ff', - strokeWidth: 1.5 / zoom.value, - fill: '#ffffff', + strokeWidth: (selected ? 2 : 1.5) / zoom.value, + fill: selected ? '#6ea8ff' : '#ffffff', draggable: true, name: 'vertex-handle', } @@ -691,20 +739,320 @@ function worldToPolygonLocal(layer: Layer, wx: number, wy: number): { x: number; } } -function onVertexDragMove(e: Konva.KonvaEventObject , index: number) { +interface VertexDragState { + layerId: string + indices: number[] + dragStartLocal: { x: number; y: number } + startPositions: Map +} + +const vertexDragState = ref (null) + +function onVertexClick(e: Konva.KonvaEventObject , index: number) { + const native = e.evt as MouseEvent + if (native.shiftKey) { + const next = new Set(selectedVertexIndices.value) + if (next.has(index)) next.delete(index) + else next.add(index) + selectedVertexIndices.value = next + } else { + selectedVertexIndices.value = new Set([index]) + } + e.cancelBubble = true +} + +function onVertexDragStart(_e: Konva.KonvaEventObject , index: number) { const id = store.selectedLayerId if (!id) return const layer = store.activeLayers.find((l) => l.id === id) if (!layer) return + // If the dragged vertex wasn't in the selection, reduce selection to just that one + const current = selectedVertexIndices.value + if (!current.has(index)) { + selectedVertexIndices.value = new Set([index]) + } + const indices = Array.from(selectedVertexIndices.value) + const sampled = sampleLayer(layer, store.playhead) + const asset = assetMap.value.get(layer.assetId) + const pts = sampled.points ?? (asset && asset.kind === 'polygon' ? asset.points : []) + const start = new Map () + for (const i of indices) { + const p = pts[i] + if (p) start.set(i, { x: p.x, y: p.y }) + } + const dragStart = start.get(index) ?? { x: 0, y: 0 } + vertexDragState.value = { + layerId: id, + indices, + dragStartLocal: dragStart, + startPositions: start, + } +} + +function onVertexDragMove(e: Konva.KonvaEventObject , index: number) { + const state = vertexDragState.value + if (!state) return + const layer = store.activeLayers.find((l) => l.id === state.layerId) + if (!layer) return const node = e.target - const local = worldToPolygonLocal(layer, node.x(), node.y()) - store.movePolygonVertex(id, index, local.x, local.y) + const currentLocal = worldToPolygonLocal(layer, node.x(), node.y()) + const dx = currentLocal.x - state.dragStartLocal.x + const dy = currentLocal.y - state.dragStartLocal.y + const updates: { index: number; x: number; y: number }[] = [] + for (const i of state.indices) { + const start = state.startPositions.get(i) + if (!start) continue + updates.push({ index: i, x: start.x + dx, y: start.y + dy }) + } + // Keep the dragged node itself in sync with the applied delta (in case Konva's internal node drifts) + const target = state.startPositions.get(index) + if (target) { + updates.push({ index, x: target.x + dx, y: target.y + dy }) + } + store.setPolygonVertexPositions(state.layerId, updates) } function onVertexDragEnd(e: Konva.KonvaEventObject , index: number) { onVertexDragMove(e, index) + vertexDragState.value = null } +interface VertexMenuState { + screenX: number + screenY: number + assetId: string + layerId: string + indices: number[] + currentType: 'corner' | 'curve' | 'splitcurve' +} + +const vertexMenu = ref (null) + +function onVertexContextMenu(e: Konva.KonvaEventObject , index: number) { + e.evt.preventDefault() + const id = store.selectedLayerId + if (!id) return + const layer = store.activeLayers.find((l) => l.id === id) + if (!layer) return + const asset = assetMap.value.get(layer.assetId) + if (!asset || asset.kind !== 'polygon') return + const rect = container.value?.getBoundingClientRect() + if (!rect) return + // If the right-clicked vertex is part of an existing multi-selection, + // apply the menu action to all selected. Otherwise focus on this vertex only. + const sel = selectedVertexIndices.value + const indices = sel.has(index) ? Array.from(sel) : [index] + if (!sel.has(index)) selectedVertexIndices.value = new Set([index]) + const point = asset.points[index] + vertexMenu.value = { + screenX: e.evt.clientX - rect.left, + screenY: e.evt.clientY - rect.top, + assetId: asset.id, + layerId: layer.id, + indices, + currentType: point?.type ?? 'corner', + } +} + +function applyVertexType(type: 'corner' | 'curve' | 'splitcurve') { + const m = vertexMenu.value + if (!m) return + store.setPolygonVertexType(m.assetId, m.indices, type) + vertexMenu.value = null +} + +function closeVertexMenu() { + vertexMenu.value = null +} + +interface TangentHandle { + key: string + layerId: string + assetId: string + vertexIndex: number + side: 'in' | 'out' + vertexWx: number + vertexWy: number + handleWx: number + handleWy: number +} + +const polygonTangentHandles = computed (() => { + const id = store.selectedLayerId + if (!id) return [] + const layer = store.activeLayers.find((l) => l.id === id) + if (!layer || layer.locked) return [] + const asset = assetMap.value.get(layer.assetId) + if (!asset || asset.kind !== 'polygon') return [] + // Only show tangent handles when exactly one vertex is selected + const vsel = selectedVertexIndices.value + if (vsel.size !== 1) return [] + const onlyIndex = vsel.values().next().value! + const sampled = sampleLayer(layer, store.playhead) + const live = liveLayerPose.value + const cx = live && live.layerId === id ? live.x : sampled.x + const cy = live && live.layerId === id ? live.y : sampled.y + const rot = live && live.layerId === id ? live.rotation : sampled.rotation + const scX = live && live.layerId === id ? live.scaleX : sampled.scaleX + const scY = live && live.layerId === id ? live.scaleY : sampled.scaleY + const offX = layer.offsetX ?? 0 + const offY = layer.offsetY ?? 0 + const pts = sampled.points ?? asset.points + const theta = (rot * Math.PI) / 180 + const c = Math.cos(theta) + const s = Math.sin(theta) + const out: TangentHandle[] = [] + for (let i = 0; i < asset.points.length; i++) { + if (i !== onlyIndex) continue + const ap = asset.points[i] + if (!ap.type || ap.type === 'corner') continue + const p = pts[i] as Point + const vx = p.x - offX + const vy = p.y - offY + const localVertex = { x: vx * scX, y: vy * scY } + const vertexWx = cx + localVertex.x * c - localVertex.y * s + const vertexWy = cy + localVertex.x * s + localVertex.y * c + for (const side of ['in', 'out'] as const) { + const dx = + (side === 'in' ? p.inX ?? ap.inX : p.outX ?? ap.outX) ?? 0 + const dy = + (side === 'in' ? p.inY ?? ap.inY : p.outY ?? ap.outY) ?? 0 + const tx = (p.x + dx - offX) * scX + const ty = (p.y + dy - offY) * scY + const handleWx = cx + tx * c - ty * s + const handleWy = cy + tx * s + ty * c + out.push({ + key: `tan-${layer.id}-${i}-${side}`, + layerId: layer.id, + assetId: asset.id, + vertexIndex: i, + side, + vertexWx, + vertexWy, + handleWx, + handleWy, + }) + } + } + return out +}) + +const polygonTangentLines = computed(() => + polygonTangentHandles.value.map((h) => ({ + key: `tl-${h.key}`, + config: { + points: [h.vertexWx, h.vertexWy, h.handleWx, h.handleWy], + stroke: '#6ea8ff', + strokeWidth: 1 / zoom.value, + strokeScaleEnabled: false, + listening: false, + opacity: 0.6, + }, + })), +) + +function tangentHandleConfig(h: TangentHandle) { + return { + x: h.handleWx, + y: h.handleWy, + radius: 3.5 / zoom.value, + hitStrokeWidth: 10 / zoom.value, + fill: '#6ea8ff', + stroke: '#ffffff', + strokeWidth: 1 / zoom.value, + strokeScaleEnabled: false, + draggable: true, + name: 'tangent-handle', + } +} + +function onTangentDrag(e: Konva.KonvaEventObject , h: TangentHandle) { + const layer = store.activeLayers.find((l) => l.id === h.layerId) + if (!layer) return + const asset = assetMap.value.get(h.assetId) + if (!asset || asset.kind !== 'polygon') return + const node = e.target + // Convert node world position to asset-local vertex offset + const handleLocal = worldToPolygonLocal(layer, node.x(), node.y()) + const sampled = sampleLayer(layer, store.playhead) + const pts = sampled.points ?? asset.points + const vp = pts[h.vertexIndex] + if (!vp) return + const dx = handleLocal.x - vp.x + const dy = handleLocal.y - vp.y + store.setPolygonVertexTangent(h.assetId, h.vertexIndex, h.side, dx, dy) +} + + +const selectedVertexIndices = ref >(new Set()) + +const selectedPolygonLayer = computed(() => { + const id = store.selectedLayerId + if (!id) return null + const layer = store.activeLayers.find((l) => l.id === id) + if (!layer) return null + const asset = assetMap.value.get(layer.assetId) + if (!asset || asset.kind !== 'polygon') return null + return { layer, asset } +}) + +watch( + () => store.selectedLayerId, + () => { + selectedVertexIndices.value = new Set() + closeVertexMenu() + }, +) + +const boxSelect = ref<{ + startX: number + startY: number + curX: number + curY: number +} | null>(null) + +const boxSelectRectPx = computed(() => { + const b = boxSelect.value + if (!b) return null + const x0 = Math.min(b.startX, b.curX) + const y0 = Math.min(b.startY, b.curY) + const x1 = Math.max(b.startX, b.curX) + const y1 = Math.max(b.startY, b.curY) + // Convert world coords to screen coords for DOM overlay + const sx0 = x0 * zoom.value + viewX.value + const sy0 = y0 * zoom.value + viewY.value + const sx1 = x1 * zoom.value + viewX.value + const sy1 = y1 * zoom.value + viewY.value + return { + left: Math.min(sx0, sx1), + top: Math.min(sy0, sy1), + width: Math.abs(sx1 - sx0), + height: Math.abs(sy1 - sy0), + } +}) + +function finishBoxSelect() { + const b = boxSelect.value + boxSelect.value = null + if (!b) return + const sel = selectedPolygonLayer.value + if (!sel) return + const { layer, asset } = sel + const sampled = sampleLayer(layer, store.playhead) + const pts = sampled.points ?? asset.points + const minX = Math.min(b.startX, b.curX) + const maxX = Math.max(b.startX, b.curX) + const minY = Math.min(b.startY, b.curY) + const maxY = Math.max(b.startY, b.curY) + const indices = new Set () + for (let i = 0; i < pts.length; i++) { + const world = transformLocalToWorld(layer, sampled, pts[i]) + if (world.x >= minX && world.x <= maxX && world.y >= minY && world.y <= maxY) { + indices.add(i) + } + } + selectedVertexIndices.value = indices +} const draggingPivot = ref(false) @@ -1028,6 +1376,24 @@ function pointerToWorld(e: Konva.KonvaEventObject ) { } function handleStageMouseDown(e: Konva.KonvaEventObject ) { + // Box-select for vertices when a polygon layer is selected and the + // click lands on the empty stage (not a shape/handle). + if ( + !store.drawingMode && + selectedPolygonLayer.value && + e.target === e.target.getStage() + ) { + const native = e.evt as MouseEvent + if (native.button === 0 && !native.ctrlKey) { + const wp = pointerToWorld(e) + if (wp) { + boxSelect.value = { startX: wp.x, startY: wp.y, curX: wp.x, curY: wp.y } + if (!native.shiftKey) selectedVertexIndices.value = new Set() + closeVertexMenu() + return + } + } + } if (store.drawingMode === 'freeform') { const native = e.evt as MouseEvent // Middle-click or Ctrl+click belong to the pan gesture, not drawing @@ -1066,7 +1432,9 @@ function handleStageMouseDown(e: Konva.KonvaEventObject ) { if (target.findAncestor?.('.pivot-handle')) return if (target.findAncestor?.('.rotation-handle')) return if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return + if (target.name?.() === 'tangent-handle') return if (target.name?.() === 'path-frame-dot') return + if (vertexMenu.value) vertexMenu.value = null if (target === target.getStage()) { store.selectLayer(null) return @@ -1081,6 +1449,11 @@ function handleStageMouseDown(e: Konva.KonvaEventObject ) { } function handleStageMouseMove(e: Konva.KonvaEventObject ) { + if (boxSelect.value) { + const wp = pointerToWorld(e) + if (wp) boxSelect.value = { ...boxSelect.value, curX: wp.x, curY: wp.y } + return + } if (store.drawingMode !== 'freeform') { drawingPreview.value = null return @@ -1092,6 +1465,10 @@ function handleStageMouseMove(e: Konva.KonvaEventObject ) { drawingPreview.value = layer ? worldToPolygonLocal(layer, wp.x, wp.y) : wp } +function handleStageMouseUp() { + if (boxSelect.value) finishBoxSelect() +} + function onContainerDblClick(e: MouseEvent) { const rect = container.value?.getBoundingClientRect() if (!rect) return @@ -1631,6 +2008,7 @@ onBeforeUnmount(() => { :config="stageConfig" @mousedown="handleStageMouseDown" @mousemove="handleStageMouseMove" + @mouseup="handleStageMouseUp" > @@ -1733,8 +2111,23 @@ onBeforeUnmount(() => { v-for="h in polygonVertexHandles" :key="`vh-${h.layerId}-${h.index}`" :config="vertexHandleConfig(h)" + @click="(e: Konva.KonvaEventObject ) => onVertexClick(e, h.index)" + @dragstart="(e: Konva.KonvaEventObject ) => onVertexDragStart(e, h.index)" @dragmove="(e: Konva.KonvaEventObject ) => onVertexDragMove(e, h.index)" @dragend="(e: Konva.KonvaEventObject ) => onVertexDragEnd(e, h.index)" + @contextmenu="(e: Konva.KonvaEventObject ) => onVertexContextMenu(e, h.index)" + /> + + ) => onTangentDrag(e, th)" + @dragend="(e: Konva.KonvaEventObject ) => onTangentDrag(e, th)" /> { ref="previewCanvasEl" class="mask-preview-canvas" > + @@ -1799,6 +2202,35 @@ onBeforeUnmount(() => {{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}+ +('#000000') + +watch( + () => [sampled.value?.stroke, selectedAsset.value?.id] as const, + ([s]) => { + if (s && s !== 'transparent') lastStrokeColor.value = s + }, + { immediate: true }, +) + const strokeColor = computed ({ get: () => { - if (sampled.value?.stroke) return sampled.value.stroke - const a = selectedAsset.value - if (a && 'stroke' in a && a.stroke) return a.stroke - return '#000000' + const s = sampled.value?.stroke + if (s && s !== 'transparent') return s + return lastStrokeColor.value }, set: (v: string) => { if (!selected.value) return + if (v !== 'transparent') lastStrokeColor.value = v store.upsertKeyframe(selected.value.id, store.playhead, { stroke: v }) }, }) @@ -223,20 +237,44 @@ const canFill = computed(() => { return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon') }) +const lastFillColor = ref ('#000000') + +watch( + () => [sampled.value?.fill, selectedAsset.value?.id] as const, + ([f]) => { + if (f && f !== 'transparent') lastFillColor.value = f + }, + { immediate: true }, +) + const fillColor = computed ({ get: () => { const samp = sampled.value?.fill if (samp && samp !== 'transparent') return samp - const a = selectedAsset.value - if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill - return '#000000' + return lastFillColor.value }, set: (v: string) => { if (!selected.value) return + if (v !== 'transparent') lastFillColor.value = v store.upsertKeyframe(selected.value.id, store.playhead, { fill: v }) }, }) +function resolveRestoreFill(): string { + // Prefer the cached last non-transparent color, but fall back to the layer + // base or asset fill so that the restore is visibly different from + // 'transparent' even if the user never picked a color via the picker. + if (lastFillColor.value && lastFillColor.value !== 'transparent') { + return lastFillColor.value + } + const layer = selected.value + const base = layer?.base?.fill + if (base && base !== 'transparent') return base + const a = selectedAsset.value + if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill + return '#6ea8ff' +} + const fillTransparent = computed ({ get: () => { const samp = sampled.value?.fill @@ -250,11 +288,25 @@ const fillTransparent = computed ({ if (v) { store.upsertKeyframe(selected.value.id, store.playhead, { fill: 'transparent' }) } else { - store.upsertKeyframe(selected.value.id, store.playhead, { fill: fillColor.value }) + const color = resolveRestoreFill() + lastFillColor.value = color + store.upsertKeyframe(selected.value.id, store.playhead, { fill: color }) } }, }) +function resolveRestoreStroke(): string { + if (lastStrokeColor.value && lastStrokeColor.value !== 'transparent') { + return lastStrokeColor.value + } + const layer = selected.value + const base = layer?.base?.stroke + if (base && base !== 'transparent') return base + const a = selectedAsset.value + if (a && 'stroke' in a && a.stroke && a.stroke !== 'transparent') return a.stroke + return '#000000' +} + const strokeTransparent = computed ({ get: () => { const s = sampled.value?.stroke @@ -268,7 +320,9 @@ const strokeTransparent = computed ({ if (v) { store.upsertKeyframe(selected.value.id, store.playhead, { stroke: 'transparent' }) } else { - store.upsertKeyframe(selected.value.id, store.playhead, { stroke: strokeColor.value }) + const color = resolveRestoreStroke() + lastStrokeColor.value = color + store.upsertKeyframe(selected.value.id, store.playhead, { stroke: color }) } }, }) diff --git a/src/components/WelcomeDialog.vue b/src/components/WelcomeDialog.vue index a284f34..4560a5f 100644 --- a/src/components/WelcomeDialog.vue +++ b/src/components/WelcomeDialog.vue @@ -1,12 +1,12 @@