From 848529acb0ec8e6cb97bf6e6790b7a2dcc8fb330 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 4 May 2026 16:38:52 +0530 Subject: [PATCH] Add column presets and procedural shaft controls --- apps/editor/public/icons/column.png | Bin 19623 -> 32380 bytes apps/editor/public/icons/column1.png | Bin 0 -> 19623 bytes packages/core/src/events/bus.ts | 3 + .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 16 +- packages/core/src/schema/material.ts | 1 + packages/core/src/schema/nodes/column.ts | 403 +++++ packages/core/src/schema/nodes/level.ts | 2 + packages/core/src/schema/types.ts | 2 + .../editor/floating-action-menu.tsx | 12 +- .../components/editor/selection-manager.tsx | 52 +- .../components/tools/column/column-tool.tsx | 97 ++ .../tools/column/move-column-tool.tsx | 105 ++ .../src/components/tools/item/move-tool.tsx | 3 + .../src/components/tools/tool-manager.tsx | 6 +- .../ui/action-menu/structure-tools.tsx | 1 + .../src/components/ui/panels/column-panel.tsx | 686 +++++++++ .../src/components/ui/panels/node-display.ts | 1 + .../components/ui/panels/panel-manager.tsx | 8 + .../panels/site-panel/column-tree-node.tsx | 77 + .../sidebar/panels/site-panel/tree-node.tsx | 3 + packages/editor/src/lib/material-paint.ts | 21 +- packages/editor/src/store/use-editor.tsx | 3 + .../renderers/column/column-renderer.tsx | 1302 +++++++++++++++++ .../components/renderers/node-renderer.tsx | 2 + .../components/viewer/selection-manager.tsx | 24 +- packages/viewer/src/hooks/use-node-events.ts | 3 + 28 files changed, 2808 insertions(+), 27 deletions(-) create mode 100644 apps/editor/public/icons/column1.png create mode 100644 packages/core/src/schema/nodes/column.ts create mode 100644 packages/editor/src/components/tools/column/column-tool.tsx create mode 100644 packages/editor/src/components/tools/column/move-column-tool.tsx create mode 100644 packages/editor/src/components/ui/panels/column-panel.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx create mode 100644 packages/viewer/src/components/renderers/column/column-renderer.tsx diff --git a/apps/editor/public/icons/column.png b/apps/editor/public/icons/column.png index 8e44f6d8e4c9613c39f32866c98d17d6628f4f6f..0e337b7f1be27db26e46b8e0f1af269b8c612fe0 100644 GIT binary patch literal 32380 zcmXtfWn5d&^EJVp;BG-%++B-%p~c3gRsK{^MQ78IhaBx64MH#72K7f-> zln@4aFZO?VM_qP(YKxVh=ik%NO45YV_n?M3g}SEsyf}sVnqnr})b^Uj^^*PL6kR6C zv86*pLXY!B-w_d5KdXM~)(SiyKe0z@uSoZ7gm!wh{Zz~QSuJj4 zlBe~Vrylulx*ZL)$E^l}c`yWHnl$Ew!X*L3$dSGxXM`Yg7ji%nb34t;ASn0;U%h#- zEXfXVu`DIelW~&0=%77eKi>X^Q|4XY#DjGc^=5uQ968*UY`4+-r-zR4?TXs3w<{E2 zvf+@RmB87PFWguwItZlG=M|}OsdgJJP)X#Q?Xy&Cjx@WCuK$co@+@;z5Qg&JTv3Ay zc>Rl15bo#RjGm9^w=Ky8&v=snu$JeNzZh0zEj4dO>pDz0j7$z9(4PKp|J_RLQhlT~ znU5{sn_bZy_^v?(0Vnp&PJ-2biYnV~qv$_pk=pAxd(i*xY~_DGYV3c7z73lA-=Grf z-2SO=gI2tGyRXQue2dLx@qe+IYW~;ivXz@!SI1Rca#+9;u-+GUjk@sWPW}PqpX};6 zoCpwisNwT0Z!Z;RUf`=qNzZnP==1c@NTlckAFAcw+TM3rY>>QK(SR|7HLn*F*xb6& zL+*ZIp(y;Srkd3+H%TPQDca|rq16R1stv?n?OW)I#!s^gc~=~-SA*aH^ZqIc-`Ouy zkG-<$FSq9%Te}jl5v+yPs|10rOQUiv_K!WsC6d!)ZsiFlUpn5N#h;IYx*3a{A2Gxy zqfL(lh=&*Vj7Dro*3b`joa)6G?2;#O@csE+R5x@%LT>$XJB(RI)&vE+K){Bo)O zVtFEyM}X>A9y0X7-%8yd^}pEDYFvfEP?1{nw^^!Nq>XO+ycPma)r~JYx?8*kuj3+l~?|v<9e(i5m&tMs-S$An2{1O5kO=M-W>dadR`KoReEgz2jN7Y6iCj4(V z_~I&6;%bxn-&pZv*MUP|>D&}wnW-uqISDcfz96h4V=E*Iq5yYUVt4T?7Y7kQqtB!U z622{>B+-4$9TGpXGig68JBLBXOkS=UGmYPK$+iRq542tnSARlajQ{0d;~)u86EONy zYHbp$6MV65{CcwAedYh*1B)>(;$VkIpO@f43X~d*4T5jnsqAmCAY8J%A{_=_(|>cf z%U7htVT=iPfCadDTFw=8ZTwmYIJcmFuF!sTB6@)jz0%Kw zdXM)OHea7=3Sd=O)UPkiI#tH6_k&xbbMs0nA;P$53L2VhxJhvY8|apdB2w7yJ)MS` zhnjkHW^irSZkAh;5$Y6_!iTE`uPf@^w?#OCn@FIo-~&1EzTSP|yfDtlxBUNBg8XOt zYf52L%9p0*z=OWTwFlT#m2S`-)Ag6niONapcxMGD5Hvzp#KPv;)un88v}ux9_KETm zl{`#}@Yfr)5a^AEZi^%w{%mHm8y=$&&r^6~#U$*+M*Oi}y?dWp!W(inS&!9rosciV z;-E!(TZ#N2>)+&}++=Z7cD?&HajjFG0Tb~8Ygbc=et%M5DqKu=t<0Pkg+oPAZM_iM zjtF>vQ^2|ZIumf!)cwQ=G@DNrmXfJz<3@yEXBR_9bi<>cylppk+Yy))JPvhnUU@hL zi=W!58~g}CDUVPq_-e?#iF^BP4|i^PyQ7{`ptBu&YTdP;*yQ?`#8b{W2f6SgpHy`X zGjbi5lSQcDbYs(jH96Oh4MJK@#cbnB#^f{((6wXwN07Ldak&EBI=fY?eI(OJ?`5y3 zHx7k9-f~tT^DWj*Rp9EevH#qCP4^2%jU`=>T`zzAtw{PzEG>c`P5>diWnIqM+FcWl zNt0PWP8W}`-@F7$?=O7(oJ7cU^hK63D_hkUK=gNdM%r({&R1fyXB2`Mh4O@wfFT9Z z%8`YvsSOWFD&@k#d|jM`vkojny!~x+vz@ebmZ_&68}aMn*QXv7@k`d^FSjC0$!gn6 z(sDS>$m9L15Qdik99Q^(_CuH8XOyYdv$ofNJAZ9VE?NVeZ;?KVCg~CzSQs9#mTH98 z3q|oE`blfZeJYT41!%o59SD8KO27}-d|&95o@KGq2De~sHWEiecC5KV7_|GmKSB|p-4N=g-hGUisisHdxZb7ON7fYs~W2yAE7EdUcQ+nrC;{$g7ljV6$5~t zUYSen1A!@A2a2Rge*93KVqamf?chOMbMTW^^UK4ML-#+CzR866Xe+m#n9vhHU0zeF zjB@so$Rm!J@zS{r*q#$_Fl0y%w4K>j?0taJ*1XB;t zMIAyG{fH>8f_T-XIi49Mysk{)!WQ>0y-1+7e>O7W`!a4a{NhoN_`47!q4dw@V?ZX> znO`z8j;2C5s86YyL34i{bn-ec>kGP0UjsJ~3IZ`P@cGxg_Mu8$*53Zi|Lrxs3)~Bv zPsPQDHHlsSc55WI+m2lx7k`xJzowhJ4W8c40-p#mt)2?{6V!ct0P{hxL$Amlu)?>q`RPa~gMd2wBNs)B?X;342G!aUewy=k@2^qhMIy`Rjy1_jBjWjMmzcj$!9`t7c!n>yS}X zh(<^jY62Vtk3d|lPE0)^-ZsKXT86e1a}vPYI;!kp*OenKNTl~2?D}=@Z<-uR14qg{ zuA)WYy!mW3`VDnK6=_*sqTSy|c66VX@Oesh`~4I{PWP*;nbhjXKjlH5W6u&Tn?FE@ zZYykmU50#DC9;~|Fp_q=Cv<;d^Umb;wqAX`c2oYfE?~}1;0~u!n7mAq?kqQpo-YBgjQF0SDF@ygpL{&*fv7-BEH`Ft~f^R{#AZU*r0ZJ}+CD03BiuN?7;r{c^0>pv(|qHgnfA-DGFq-NKn zIFpRW64LX&ht>(#3pzJZnjg;ME(OP+3!{MVM68sqKLCOJnc6nOzjhgn3e?yHz4||E_a(w`(ki+ut~OS+sv`Uxy8L z2i(pc(g$`&4yxr zC2-#I7)|~9fYU9=9Wcv;{G?Mrj94l|?@CczB*-Xxc8xd%GLWGGb>-@QNm0nwDO1QM zO7<1PodJ#Fz1N2bB@-y9h}%lh2S%tNqohn~VFW6Q6#jyTmHVdKc&SdNW(eu~k+6Ky z2Fib-Pe3-RYq?{7I+*N8cn zSwR=ENva)0{U-6jk_7jZAI)jy{bFzL-0L<2hwp8q9zYtY9w+jZrYa3rr7Wx;+CvGh zya{7bhzR!GZ+d@s>eS%S$gESK;rF-Xx;>o_ePjyS}dm4Oh2ke&(=2^j(e42$~w z=*V!me4l#rf3<)B<|`{;x+uLJWsz289iA$4f%;58>ihF$CK5hvC0{$M36|2<{!S6} zG8iFk%MTtY$xP;<=3($tMh_xx;&#k;?GSpdM9SyqZj-PR;rIU~gmj~|=NE6e7mLCDzg`QCxzh1v>u3&k!E}8L5}AeKc5VTyeY?Kv&_e- zS%=naQG^|Fi{hI0Mdf7zDyMGH)?^08I1iWSf3ZiGSjFj<;VlJ`f!B4$c(IQ9S`i-u zsH8}~kq%~kedi(cNmd4DL^Co$89TXQ!kWj4shzs@zdB0UN7%bcK?5l#pjhR__Wd!w1)uCW z9m_fp5bokqXDE=D@y=ChqqOim!>;l)w#3P5y#Vac8uQ@GtS#XSccvW*~3FV-EfyZ|1 zhg1yw!xitw_&!+2UlcO`K%6|UMH-(jGb`>1C3AQzQlL+t<4)ItM+Cul%FOCMDuKrR zXEpOUzYFYnc+Olu!ZT6zo){9Qrt*B(CN~E2b2aQ?yP0pI`bZYeuY+Q$s78lc( zNPZn(@R?$pc>YihT4m`8zg2zwR!vA}V>d{+_)$bkh2>l@N(zI6G17W@Hm~50C}?5Q z?URF3wSXP0wH=eGR(t3Obc>P0WQTuEL#qr`0>KZxho{wvo&nfRP*d7H>(`Szv?+t0 z48^nJoLhf`;E~;-G$6ZBuxbqFRH46%wvlJx=QF$TFQO{!iT1@`6!kCd8>@lHHv+5l zA;p9`Mn~>Z)%e$FCL(e``YM{JQOC7osqHvy6NH~H{h7vnDGi|Lu9wNYbr`S8=^3Yr zyBmF-GahNNymSHr=jH~O9zr9pOl9ib6z#NpZvpKT;S?8-x90hl`_pJK7jH-~k{dOM zR_HB>*pUdxL-7o32HFK{krj`ul8w6SVm5<7fS=!5{Pi(WRZZ0q7H{owmcwuN#jqx}ULibeiG=dx-9cre ztN7Pz_QRV`q&fUfL-SQ4EmV0QDy-}_0oeL$B)Xr2mz8;~WpOX9q~SbA-c6ywF*q4+ z4~EmV$uzLjq`>RZPxPB$*oDS&=Z`!{>aigVhlNEY2@AddqPNqTlfU0CtmbFM?!f4) z`Ozx>L*ltPzM8>&Z!+u1qaVPBK@l&-E6oI4MkB-e@Cf2aa4Cr2=8q-|p&UF_|f3tVj>M z*y;b4?ez#EV0Aax8nQs9kULVHa-n27VQ~W6vZ)xdb%LI2t|WF17p*BYGFG8HCW-0# zP%Si&f13IRJk3pJnlD3Qk&Ho!E*r)P3^wHN3MS)$ddWED5qzf;6u#jqJfBdU!Mnsp zGnmQv9$P~Cr$Wo%r-y;@!>Eop45y~^?wsYM`@(&=n(jX|Lig!wDC}wsX2R)Y;;~)v z>Nb}D+C>v%BZ*gSM0xXt9sc+I@ylqVQ>_?oXbPnbVyDal z0f0oRhSxiQL&|oxhyYm#z5|C=VA)<2g_^ox*ZGN+%u6jG0321s$5bLDLLW`@i%1*Q z=ZK9{2vzC3+p4%w>+`C~%YCr#EcyBa3i+bRtHaNf){~!i#oum@DL&7Y8rW_BW=X+b zyreeym&sPY`@_3Re-}m9*zivcUWt1`U+vgi0adsSm8vBBBn24ogeV?OEqk2Y+cv(!6oo27WLpOZ z806lq<>T*4irjag>d;-?|0>hdMXK)>=v}8PEp8%HOlTni=n4)%_V8fSg{%^X9 zHS_S1o*6PtFK~=+@@ra3&SkZOp`-dx=OL=7Z{6;s^M#T~sD3S;A<$5vPzu!H3EJon zp&fnOEqRM=PQ+#9dq7!i=~@x4%2Ka%REAKcUiLmBUwquC+VLM9rkm~<)<1HZYldgve?gelUwuDFhu;+=biC-wLzi%+}p{wWgxKJ&~31I`Q{tuuVa86 zjn+nTMPhM5ltt%|_+1;$J+WHQyyR2TjBlqN`Vi_FdAM*XTN7g$3X z8ze0pHBlMWGfj>b+A}2N+;Dq)PgmG(%&t@GvM$J*VBysNkpER)htjC^;~J8U)~Z=^SARJo4b$h)R~ zq3dpmcrT2v8*r%Jmy71^3rW~u%>0(Rt(sPD(U2XX9Up**C>M2u7lS5QaaKvEOlwMt zVa|TOzn8sO=frW=X+xg4ZS6}TU$&!5CHXLA@F=VO6)A5+lH#dhd>xuLD+Qeq3jG1vuhDs-@BVPHmQl;KFa*YzRsf9J$8 z2kXH$R+bAh_B|9%gQfcC9oOvpoXECL93Bl#6k*kWE5`-~=mm1gQ2XFYU*MNS#i2%f z=nxt5THZgF^lDl)rXh+pwKA%eum84 z#PZi!i_7mG3ucz|IJiUioOsLhb?n%3nKgb$Z&S+Pq9ivir4m!W;poe#cfN9LUuM10v2Yl;RvQGgKy8k$I0YJoFw5^sXD9 zl4e6!Pmw+y;LnJxA<+5hJ?)-IoVtoB$Lftz6|%f5IleSoOTjqBFf<3W*7vJ3${#yE zqQ9xC{UW|55JjXZaeJI+YCwyl6?H$)pKLyVI`Rd=-$xTsbe}qK6YfYq;ho-oKWG!Y zBSx*q2y#?fKMFas{@VX1L`bDo#W5LKF6infZ3|=e<4K#qu%*BDOcR2vfzGm_H^o?&p)FulO81ISbrc_AvmCx_QD==XSj7noOUAs747 zezp`%DN?TSV<-xh##z69MJkI(U%Hk$0Y6&(0~5R_`;acgL9(YdaN?NfLDXD6jb{2+ z8Z-7-M=3d5Ztc}*55elS*mD`D$by_Bn)A+P)NMpc=cy!`{RDq;Dv&;kx*z(%`uZA$`BX`a72t=68c2etLSg;0r587 z{v9v39hYE;@O3M}&<3?X;3JSA>K!kcDq0*Zjr;KK2TU9ss4$l4U<{$*P{c~%(ir7o z%G7bfzX3#uuujo38Kjs@5^ht2c#;eqkAW!U1C{VbQ7YU*Bgl6=J!kQk6%L>~{_G!S zwjq`7Jh-xg#v_-tC)?;sgBYcg_cS^_233!qI&2CTbyitME0kuqZXFh|ApWU>P245s z_<}o=9xX?GVfiQ>+IvTX!i2mph7X?~xV46Kqb|AX_RJr!Zy*t>q1S!a5qp^yOC4X7 zob;H5kI-Wm&!?9&rMeLyzUMzJl48xGxRjha=b+|;_mA&Y2N1yCPTBPzi|DG}W004- znxm=Zquk+=Fa7?i77Dh&GSUd+XMe6Nq>_%8Fxh2@A(`b%}r4v(L)Umx?{> zNN|^j?nlWd_uvE}vFRrL-f;`WKbgMO8gz65a)9V%H{?CZwZ(4!YPV`GWX$fQq-qDj~`sQ9Aw^% zfq#^O2Q!~3rHSnucL33PuN(R_cx3;2m^j8(mh0}kpiqDtqEs~rE~0q0stRkglc$`q zRZO@J7hX443b=>AJIfzP4F1Qn(r$$@Uu~c!01FU(x!j+&qW)5-dU2&2nmY660duR# zf|zny1#-41Zj$K>T)}p;EH!qp@cY?8c4!44qO{*#P0oL>GGQocvw)^K$HD?~C;9`e(01PRkHYktlqGn7_*o z)XQS77fG@hZ}C9B{?&sIpzyd6KB&@=Q#iqYHWS$)h*yb44WDu~tWiJV*k+RGAa7pp zN_ThyWjjS46m z@oQ3#44slflWqoOp`?^wk;jvxZDxB6IwuGCx(eu3)1I#SJUrp~BlHD<0Y4e!wQS;O zB|^8Ejq9xmS5MwG9LyRJ)0(oBYT}0VBQW0(YLt>o$R1Y{HBd55Q#xXpX-zJG8_tw6 zasrl&h4&yD?yiU$jk5#%@GsTv&88dz;|E@Jv~ZMwbJm2*flLod!*&mWca+@0_t_-c{Au1#(V2PgxMB2w*6LankJ!S(y4R+o3hlDzKJyAQAw3J-PDz^8TP zhj=6uYXK1f$WPC{GJNreU*A!w57x3r;L;vkkx($trkg}qjb%Lc);QeTH~x-i!;p`4 zLi1SwG<#rUy3Ruu&7S|ov1*aJ4vB0nfv3*UR=gI79RD_@;>#mFAqbem?_48=x57fH+Os9vY@FGyPZ1AJmP0 zIrtxtJmwfd`W*zr7qvVwt|?H(FG#{>2SWtnWBQv+qeXu`7wg;O{98o2Q}@s7Jyg3} z`Ng^Vy?nl&GB#8bSWylc1)kVdNQ^jiLu%?RNJ=PWYXQrJNvBUN`K}AaDmhW zaaZpdnUeJLuKt)%h%7j3#!grRms-z*HadgvknZrv1&k*T)wD<{e;|80QrP7&aCEzE z69)BkJ_pvkD%9(}1T%imnwgQN*aL;iS2dQVzn|X9fWz-sBu^;(bL%RtY_koVb2o2x zUdwqffv>aYN05Ze?E$ zJX(|16#z($!~EIE_*OoSRL$`=9h!t?6;)6|RjrFM6&>LnTFKc}+vZgtI-C#wf^ykR zGp@jEz5-{R_f+AK{%zC?i~?7xNyA`}b`^!R$q%?LD)0!Mnjzctrku?2YRq!OBo|w= zY^-GU0^Xkr+`Ds9dAvh<+k-yxKR>bV>X4~QTfgJdSNVZX=RXPR{0%yupICo3f;+tt zSd<|gbgcQZcAj_$W52V+F%rHAarc<2Ag_XHB`LL7-`Z-95`Wz7agwLlX~H5?pdk=c zG^Omdq|4-y(!@k(^lX(B=zYJ7{H@4=Sm9%im6FB5o%X6*9AL9I1MxmFLgB%p$U)~e0*#IIpYtosp<0h;XV7Zw%{PKQ*W5RW#TO%pGEeC4B72pQ*Ub<3v=^pk+n-O zN6-_WK$l0wQMTn{11m)-^7zMjgR42ujUO1nFqCz7mv$l8vRk`0ajfawsMqDsITqZE zg9KuG!{GZ~248G%T`&3AFOpY})<9YRpxGGj)ub^7TMq>LgyYMs!`n=q5?)~6$Zz)sQT zb0JVYbTEL0;)G>sv)9Tk_*D|5jYr;IGUu41T|u;SU6qYyQ=`6epAXA>W0yFz=~KHH zp@!@(%Z5Yx`a+xUSKMty$|zYwr(MBaAulf}>y<1-vC}3gU%!5p4a?D2bVM^oY5Z<# zG}syiY)L>2X}{`Igxg_Qv5iF?{sUhhby~iQ5Vg#3vOebZ;FMGAvB1u1G7~8;c0`ct zH9w-;>JAJX45?zOb?X~Zmb8JKUXwZ&6Uy4oSlG$~zA;C7V^r%qwH|&I;t~3syy{<; zs%mi01m$sZ=)*JN$kf$#YIczNlaJeWXtLgU&(!_ILH!T9{<5Xx(xLU5V}EDJouq{^ zM3ZGP{X50^8^&FZv){Hrb`_ht*WxvnO?h1WlG$uZNWP=#w*9o@*^0PM=lw+domk@Y z4%6!u!|R<3InyUMDGiv0c-+VFeFjYU0XIw-n*gp^9g->~9%!@;5ik}z82SpynVB&Q ze+2UEqFe91w{2Kzn5DVU#p=rm(q~X*S%30255}G{N0M;ol%l3VQc+8D+o#N_wRm=} zBr%F$5`Cc%z5B=Gc4SwIhbhH;vX9R;s(%PCU7xOacSc7*K)eBqG3 zI5DX<^6)_Px87k){k&y+$UpcAN{z$=O@*i~F_tAMA4yelzk_PEcejII^D}g=AMpef_>F&)%R3K9|;Oqw}X?oAJ;5uWI$J zuODuIuhleu>nw2KzkfLdcfTGn1zqQyKQ7knG z#HitfRl6cQ<;CUqWf!@PH9zd`vrhL`3Qo|)ltfWmOKv%7pK8Q- zOa*@eo0X)5+|#F8kC30@t8+TtkEzrhPoFv3&z@er4kN4mA8SlrHy1kmp^PdAlE}ZA z>73uVZS-jH%QbZl1@$`YhrLzA21aEn+@mx$THdl;@u@%^f#Jrk?Hqn}BpX1z5`>BM zg!076&E2Ei(#q#{posip2)f1Lb=4{J+)VE$>Qzl`tu~8%j;V6`g7>()9-bO+X@rda(Un; zM)31D_24JU0`spXFPwM5k%?|ZREStvC`A(t%k&V3$E${U^3Xc2r{CkQh0mg?c@WVu z?s4{T2xO$RVPU~|Yq}_8mGYhC>R-Xc*#T0*39G0A*@nDNlD=`<#H_xFlmnX# z1J*mEkBxVqh60hfQQ9fVJ*>BX)5H+6+}RbLF@ST7U)3D5UWN5fx`&So>~?5@RHz&u7b z;u~M50z~s+EX13k7?b^qE1x;iuL={fop=hSid8=i<Py?~^NzMxMGdsLINh zBlq%y=TnpB;o(_qx1rv9Uw`5dF8%KQZYt<*FYl<=v1DJG<98XC5uzmAgKE<=6yw)% zmP5#g9iWqSr9~;=K9=0Cg4nI~N&+(;F`YgpGhx6r$aAu%}EIGak zi^}mutMU=oQDO7$jF#g8)h^p|C)n+JL0zu*sun-6bOk)38(usNneASsJKMW+*x$kg z;B2otrGJwa{s=clC7t%)U|9)zmh-Y{t@{kKmq}tLr%0J0?9Zym_?VwV3r8nonCPH` zRpkV9uaA>tB~tb*&0*HrvmL2u*a{<|qWXPsg^P)WO@kS-nJm>%%+KbAmAVc6KK>b3 z32r;;gED}bh>Zzexd#K_QfMl`xTEG5*V>AFESEpJD|E8nP2CPYmjjaI2Hxoj!s2-T zvhAO>Tql^`Pl}~N@!Pv)B9JSZ#%E`n4x0pY@gxU;wb+jV2N2`;NX$eY4U=q5GZ8N< zetp;H1L!ogR$mZtz(bwfV-w;G7kH?<FdTV#6}-RL8xPK$qtS*0NaR{Jd>{l5jJsTm;dF)`2r!8x#w~=y)GP{((oj9ZSht z4+OSN>;UOzv9e54-vDd0v+yBnMaN|44u&}VP?LYub&DNd0`D76%5&YhsRCbGPo~J4 z_`)U(&b|@P_Kc>pv|g`A%JJFJwEJnk9Rcw_tRJw&&v9K*!RVhBS{fjlD!XnKov+m2 z215;~F8u3U&Vxc1aaXVbZ1rc8@+*Q`z-1W#IsO6m%1yZ!hbvu8&#ec3x)Rn?s$*JV zA=9vhI#F_dvWHQ1`{AvA*zq%W#x2fP9M8PP?GazDYxZKpuJg%SJ-)+y^%YH` z;Nke>C(|V54URVCwz>`I-zwral|?6Umvl1@t?6J-wy3S)NaJwixJUe|^W4YrFS>&h zHC_(t8+!}yr*%C}R|gcF2=<%2*Z2qjj6H*3vZ=8+Hzv#OGu zPULmgNBj&=pmgYZl_>9hn}R&c0aX^$?+ZjjyGfeQnH0$u9o39gtL#(gY4l5&Z;H2X zgl}953k%b2_hxgCNH=X2XUAP4;_9t@{|TIHEYyiGovsdaw^1{C%xd4e)sJ;gI2F`C zAgQv>At;*o#Ryfx?KLizEy&9ooM&4;kW%ptsnP392z+N&y&~z`lt6>>GZFNWiF?Db89^H{%IO<)xv4_~=>rZ7@N zzJ4S+gdH}1g89}I6x2gqy_+*UPV`v_-cMLHEN&Lvu(4r@^Ef!>xFQ8UjJ=x1sarL# zO@}0et{o`OW7NF02J9x$`wBy}S&)+xdcvt3xnRDHS6`$7VWQ$J88NQ80DaHD2o|*g zo>5-t3Mmz*XcYmIhcD<$9jWR>O8&{WLk*#{PMH zf@LRM9N!jdb-05K9W4}sQu-aO+zUGtPRTx*&TA(6$uM6#dpNEb6&4t%+iBmr{#A$6 zDH1gOCNpx!CpjoHDqcH+6DvYWoZ0ddO`zP4GM2&;0iPiYAlA9hF>jT8(GMgV)4|*< zNmUDZF^f0nN(`@$u8&BSMrbt*>3vD5rn_+qQ7%4NBPZkRoQba_{s|z~#Z^tBKm`3h zuN2wCV&kXNx7_c|pK?chG(m0MIw_$Ta5K*Dic@7s z_6m=0$P)kB8&(LAJIOSh;V$gV>F3>A*MiT*qfnU=f(Xz7z2b4H>UwW29#EgCXpP2j zu?DLK`;N@a-ek})R&PfFla%*uF7i$PjbXXpuMap@7!pD#og6aIXa`RWpsM+Nx)~ zpjC#|(<#S$`q&lP)xB-_>c6i(S8%lj5YW-}=Ne*W0??K%_gh|w(SXQEh>R$k=^Q&r zV@SgJG6l&WUF;+!R9tRt(eCB2&bJnrf(nOb(5tFB994SweEMBte#qum9PH_Z(_jLb zxO#2h9UaKsTilMOPj|~`y(j#aEnb2GcQ(x=8VcoW(@dAQXw=7wxV;^+xs5cDlauG- znx)_gnG-hmu?B4ZjwhB13GcF@IGs@g)UjQ9ZHFHw>af-qSjj zL1yatY*1P7j9flAuo+eyUr`L>>mngaVSk3_`*zFUnlvw7_9>3u&Lcq~X-bG7wlXo? zcger{2jNDQ9KMqu8~~z!L@pVfS>am4d=O2up(|l&-p8%toY3Iyk zm02`~6Ws2BS3?Ztly7*Z_*F5)YwA}iq1+{sz&Qsen;4SWEkzQ|tnp5&HWm?Xgyc7{ zp_P<)&)u`&8qumxRWvk@y{%OFoEeAfty_?|oBiDt+&SpwJB$Q|ICpC%&`i~Jc_i5E z5cw^BE;-6rMR7{Ywv%!la{GCfByC|q+@^4BLp7YfS zRf)>smH+USu?-ACWwVW}>3M4te`W3&wh3%ILmR<+a5{?+n6cOUjn=!=GwvAA?u(=a z^+4?W!X<<#6h?7BE!}n|1=MKT6j%tQNu&_?_lK={C0^ZA3+E(1tXl>V!Uas>g2yep z-FHeh>iiCX#^#MarP+sET%uiqC2tCPOhDN{Q~)xnq-Npq%-7%O)8=)k$_v4MO-ivV zaycPh>>GN5RoagdWG%Prr<8sE6f5~fDGocX?6;k@T&Rj z;|nLiOqtB)k3X62gL~&fGs6O%;?2fM&w{ojoAh^0IC#40p+QGhf~o5PT@!GMR$g*L85$yBP_WexQhcU zXl8feq>X{hiFQ^WhIv*oiA+G_b^Vw@#v6D>LYCG=3%orXiw;KmZ7?({%jRHj#o?(c(vQ{j?iophXF<>UHd3 z=KMm(Jl;h?2_8u0LDT{{{Sc3OSusYbWB^UlL%-0;E_b%CDW_&1!`DJ#tlq!Ijt0Sx zBQW`4W70F34u#-N^}-J7GL+f7wv_Zx${#T(&wBU39jwLpC7e{s zkF5bm(0fY#eV6NIhLZKY@$8#qhw6!}P43)V-i!`WS71G!_|4Op8UtDfyD~tr9RoKV z_aJiqlK|0ygrp2-Fc({&sAA#k#yKeTODxXE?)*4*oj2jU2AJx_|pY(^qU3C4YcONz*}UJ@Nqd&8#FX{hBW zc??+#`?1x7igwy#i~7wNl{BFDWrGSWk3-=}zosq74ua09j^}4j)HXc9Y=PiS=j^Sj zcG67uTXxg0jC&RZGdawtdSQg5o(Or|5pXtuvBrG7)JV)~t&uSW;yd`Z_VLoVgu|44Tn=aY+NZ%?^RP+#rKD~k4#2>=cx)#56 z`Er1`7bfThH)kJjJPe3!DG#jTAW>$TJ!JvoIXF9OpW!PH1`?;{H zPruZq0M0KmFVx)Q0%XhTG-6JU#IUl~-d>*5WG4e0QZiTi+7$R?)&dQ+JmYeU`{4dYu{+DNNYQc!}rRdWN0K~xr%~A0SwS&V-$ud-{25cz0 z-+yu!JQ@X#Wasx2$3|A^C3FnvO9_VMiiF2Wp5}7z`&s>a<30$ha((^sf5$rWuyG~x z{y=aaw*c=@u84OD8k9!yX4G>>V@kM4qA$weQIg$n`o*FhR z9n`h4fha5E<8gCFyb)CUDdQaQCYCveBu<}(UMN)Mh<_S)2Wsbmh8iS&M->3)TH(1+ zt41ctLLD77D8WVY3uw%|tBg{4Kxc-4M?9%WPY%@KwIsttgj*JFd!K_gT)p<+X;rEx z#7S?H?@e-AMDiYfHK;ScU;1P zQMDnxV-$v2yZ&e1b4R< zm*QTucxjQ~(Bke|f)%%-#VHhuyBBx2;_mM5C%^xk_k25F7zQ#EHapMWYd!nEuJ!+Y zgq@{7-(HKF;foFK^(u`q?cxP30#~(6HM;_Wc7&w;JzsnRzJ9}9OK6{yTvwvh%@>gF zj&~9XYkN$?xhm-lA3m@Fu)dXXyZ(Q511YD-#v7sJCJ`0JDID^6aDeEXB>aAE_3xjR zN$#=3euOQ{y{*rKm;JNmInI_s1UU{ND1&Re`j@|KT*aO*-+uWjSL%c3C2qD649RP@T2o{N&37L7`-K-<)-XY1`;O)+Uf!iCJI??6`k30}O2z>9%a zKZe5xgqFh__kw9)v%WZnd<3w>eq_G{ zhMpm}q`!>FwXNrC1Tj@z=UPFTTh)@0Ffx1Yc9A$X?X|C1?-UDAm51GP;vtYhGx6iH zgFTr^u8D|e=nvly-T7ye&A9j?=tXJ~`^>*IKS^h|MTd{d45(Pq;3`jYj9d7NxYGBZ{8hl{efvxj|65}CXtgT6t8`59TtbR?8Eee z^nN6HXX)d?kL&xO7%274J`2Am& zK%h<1Pu8n!WF_ny%K^7H3VX>^8^^(GP$TqsAjH^5z@J(~LWz3K{Pr5jNJT+<3T7sQ zNc-l^+;WGq15jNo7yzD(kixJOYQ9ZHzU%QWRaC4OH}=?bKW(@u+`j%p*Vk7lD(X_o z4rq3K+ml*RQYOB-PXpCqGw z1Ej0yOv{?>s`W-0?FwtcN4DiR{NvYhCAx9f+M{fo7n9ylxgm|c z$@T9S|32@=rx2>XW&4pYBVA4Zp}{4EIYjX8^x;R(T#Ug!(b2>xTdf_^ zsMj1A@q5ZdB-oigHn|i3bvAgp2MIWY z;le9GIVgET@(#RM2$dfkcNLR7kk4G7e|m~Y9S@TnBCJfAPM1jez?90Hn4kCthmO-k zUI@-qdZ~e)CPn9pOB4%{@l7p@oQbF+IL*#TRtOn>f}{zZ34e=XQIy`(ZXjj@v_2G9 z4nynwyPC;`_X7iKmvy$zip)M@|IeNhTt;>ER^P^L^Sy`~^`+j1jhL^4R7df@?Fg{6 zbSZ}^xeF>2LhZD8xlAdoSD-yi0b=|y1-{Yr2cEmT-3ZxVQz||3ZR+FR=Ew(qu`sSJ z8N#Ch{8XomNHIAp!T&crf|7X7xyxf?BDxo5WO#B|#b%gj0ONjF%HEuxNN^|ZcKE*~ zom2iR&C$|5eWRC-3(0VBx;cq?ugM1zOr|IV;Tjy4QWIp@4z-9+7|)Jkz7(U!*;dz- zET>SHP5mm9rG}7Hg?Y%zYMR^&{rrZY|3vgZ6N~8Oo4Ghn`utG5fUpjKey6Z^1#x3& zcd7u$dC(|B#+{lvRG1AyGxu%)P08v&jGngD7J@J~O#Osn(9KioQ~$0Psv`E&i|GO$ zGP}+E?`Y@v)gzR@uviF%>Mco1MX%xNgc5z|VVY`Ib+eoFf0IW4kzxR(;V5J{2|1Q+ zedeX}LPS9;(9x`=A1OHg5K_-r- zG~OkxrR?mOH2qLF9`&BVa)O_1vZq@5po{lcAKR==`q)HPAY;t7&jMW%xid{sNJgo3qZ_HVy=`Ri9F%>lkl-#?~7yEkm#r3ed$Hb z1N_VL@eKFXpisWNe{V*98BD;}yum$g#6#Q$bBp)Que>p;gF0dZu?$UaiX*|@Z2ArK zU0obVsEcLhGdkWv5*mqVHX^vH*c~qFX-RHXW_|$eUHv1-TZ+hS{3HPhX-yt>5-ibt z$M7{g51=8qaI2!9KZp36x%?w6)C0%?LEzalfbuhx_Jy<0)+8S6dd*wK3zKQEf{T^OVYD{QK3LkfvV#--#{vLA`l@b=t%c5FQY91Oir0 z4=6IpqF4!Z%lwo%Hjc|PzhsQ?(6%7=s`OZSKmH`5kctbD?{g)Bkn2&+PxJhDLkDGT z{JlI;Q*taF;&}^{Wn;^**a3lGErl3H;+W}v^e*@s&JS6tT+PrVRVdtX``__;pepFr zh>6`LB~_&p8VpUMhS+j-^+^rMAY!~hz%izrclZ_V#r?%FKUqvML3J$>Z;G*>F4I3G z2n!*Pn)V*#cr)vN=)PXSgWznLjUQ*JP5UO$9fkc~dBab4X&<{4r=gP^=fbYNcRi@I z(iUPI6`Z|h2H6%Mu?W}}Qr~lInlav8d+fULJ(7~m0eMUQ*D%2hoBQBBbF*ongmh$k z;E#5VIo3%OL0Fsdx`}~^27rd9MH=y8j6a8;VNW@Xp;MLPe`k%+^OX8?&)>gQKFB!a z4q?n)v88y?Q=Xf!3)U%N_?F~UVhK}kjfMPv!<0~fCXq>D0#<*@g})BkeeI>sFmTt; zAvo+25h?d0H2Y1=7_TDp;pNAwq^S6E?no@~Af6Od06Pqvz14#$5h}d!Ek!y--6SE{ zU=sH2x52^KkCTX6S?RV=lsFpj^_aLfFWX))C+>Rbkg+2_OE!SasU18C9X1L-sr?#G*073o?6lO=+_Xom6;U)?FQsoxy zehCKq!yk|PJai&cnpw9Ps zq_lO<&83i#HS>NuM_=`k9mRTe#SN*6mHsg_p%m})nsR&Y&%f;^=_L`C*Z6Ov>L&=M zRJJ4f+bQCd^?C|+F=^(ZCC!ri{_T5q1a`H0(XgnR)KSveS@-k!%xB4POnt{5g0NOH zcxUimcjl)J4+;i!7`TxO5LCRhe=($kiMvzC>` zxMYDD;LG1DbueG?Nysc|FydyzJ30!&ay0kT-0;=Cv2zfezP}GhSp_+y#S{qZFm)9; zu{aFD1{vv?I2Cl?O*`!3C`h1cwF0L zzO}BHWK#zj2EJrBa*W>R`-;mA(biIWcu0QTQResERPC_t`Yt2vH*CJXJ^}ky9kM&1 z_*9r|*h)i8hWE(icavFw)X@$Piv;Pl;t*rY(s%@s9^g3U6Hf1fIitc@O%jnP-o)tU z`^wLY-AC9(%joM)yCKn7X{o(!it9s=*$$F3d3zFCT; zS!QL$%$ZeQ zH7psi_!$aAFd;td$Jr(&hJ-TCqi}vLm0>sM95-z5x1dUb>{UP_Au1?XVR-TAC=-Uz z^N>@Y#YYtgr1&(F!R>nT@L>1o_X}%SYr40cmz@~5OXpYQ;Bg%V6Eai_NLMIM z8TwMNz*&dsN2F2VLQbfqN|5Yb6)Jyz4t;KKbzbj&+SOucxnTt|yIJXt}H+ zN~E0FQ_jg}rWtx2xlC`{x6p>NtUG}v+Kwg@(W&4-oEe?6SuEZSaO6I42Fb4p?dHHw%X7t1ucnUf=?S0p_%dhA0p)CnmWF;hbJ z8x?OVlMORps0U7;SLOtb=Uy!!fz%snQV0JK6D6dJ7NP<}~( z9IRl?b{wGF}5=F3{*L3{;F#Y_qD@e1n;Ey41gXH} z;8TJelp$W66jN_>lXu4XptJQT=Jli=7vn{p2Tj5rih&UnEXk~PD;lj!IowN}YLcm1 z9f_QN^I4wImnF49Hf$_Z z{as{gR5UAB%=7*XZxd8l@HVoJzXayK<8Nj5twmIKtacCT5KAq zvs-?fUitC`o`v+nz{I49HkCj$VeL%MO~h7vyB%AgyBfY=!AlCZyuLKHI;N0tq|@73 z)SGKNdXf{}zJzD`W(`oO%f0b^n4SwIZc^Yjr#;es{O9b#xe4W2s&JBAV}jcvt>?>2 z!)qpfpJw~Dnp>IkGY9f*7nEoTwI|^Kei&D;t`j7%vGMJ)NuN(gvVStzpAqKDh4x$I zyBxA5TZG64=~f<2dt6q)lK-y?#~^a1XgpQzC=vI|3~RdR1HfiZ2d>m+Lp|Yp4v1t5 z`h?Xx)P>aY@-*8X69h(1&h$_W$ue=`sa3-u%0hy0gw$j3HrjTSBzu$M!*Wy zV0r1|@O*6ayD_JFjzm>?^uaIxtL6kEG+yjc7^$&9$~ZBZD)|n#%Mfzm2TG-YqTZ2C zBeS^$#d^M{hL8ikBA_b{kxfpYo^sGoeIE>Hx&?MI;iX`4(x!7VsvYoq{A(;QSq^)z zU0D|J5b|T6S#!KDqndZnGFX{P4SPmU?}lqCej&=TCiRuR@T_ zBu=S=owyZG{|b&Wwgl8#zK=!B$@z?rAL02GqJ;S|-g0}F8kdu;W+j^XL+rQ{;}ZO- z?*vIGLx);#J0i`$hLgp5?8HIx(r=W$zP@0KZ9KwIVq!SN?@%3cEg3#CQBOq_%k^yAoFsrbvuXZH!M!X!^WOT(EEDX0a5ey(-vyv?r< zL-SU$YShS#s*U6bJ!5@dgU*@&72;8ZBuwZiWs;E_U&x=YoLmOI-Q5@+^V9$k_+p%U zmsLA30U6S@yQ|hur2h8W*@T&kojQ5Qdo6j0ppK`9kNkTQW^N2MzbsA=GQu1RoGvGE zzObyt0}n~yQg#SRuU&$@p^te9#5_$l4)bAt&F4E=FKbPQtrxl_Xh?sZ+0`5-=?0b^pw!>B-f{b;IPr*qlvg2yf_r zU&HhaV#iR?8Ua00@)gK?r|4YcR8^ul4HAV$YRshgiU<$=RK%>=ZEfu?{(nWJZXoG6 z4K@WV;(_PS8Wv8IB;@4XRXGSrAYh0DBF%uK2FLmqE)yZ9sJT`)%J=vm2ukt;YRM;| z8NbExP-f^y6~s_4hekdhSeNMf_9dkloWr)3p*y@7-RQ^X^*`#Yk9@)yQ zBM*r6SWhp|uf4X+Z4d zH45PCAjTE2t!Yg9>_CqgW~FiWS&15Kfg0GJt<9Ppb7=CMgCEciYSAptF|ZyV>@~_| zcAzk1O{}n)=f;SEcg^A3+AU@bGwGaCq@7_*FhJvt(?F1@H zlNQNOILLX`LT%CGq96=FMHWl?vn0(9PG$ELWGb#cKWXbd)@eyjL|T_~Oa<_l zoHQG7uLv?ST0c<`A-7cNg4EZt;Q2*`E+61TgdRC6MaATb@Qi%ZspF5-P=Q8hsE8q0 z&z7|Jt7W0^YvD#B(RSWKPLQ-~8Xbx7`bzV!#i#j&H6(KLqb?# z0s7Sj98&FFm!6y6sW7NZdgu?%BVpI$COrGOiuFaq#)G0UJyt3EtY0=!iEu;=V%bg} zcZEyH*xtC#XXeWP9A$rZHgf-jN)~9eY^pq-70NRzPYiR1Qd))4{dC9dM8GryamoK8 zwkmx`K`JSKyn9Kryo1m*;Ce)rmCtf&ag7;D)rm_fxv#1FCkt8{!Z$yrL&&ln2=5Z9CCOhw@ra z>?{a})3TG{O529uZ=srjF9`WDP>>(?9T7`7O2`87s7=@Dlg^$g?habnxYs|4?-^j3 zXwzr&nA#Yj)g>6-4>4uk?Y%x675F{*0V*T~+WY#;x@KX5J`=y$J%4MpD=>*(zalp# zawHgeV(_$gu>#3uUE0IJ(m{cc@=YHub0r)2f-w(MyrnLh5x9q3Fk&mD4c2Tpry&6I8f3=VER9Hw6 zr@>Bhbnm-0rH$m$|3F>FbeAlwYgvnG`I510d9Aqe8F$n&tc@HT;LrTX^}Qu==sC%a zX(gmQtb935fglifAxZ{uV*8N#cL-=Gdx$DWn+rAItC@qt)d;`AE0tfr6lF){FGg-5 z8_blhB%dub%D~J+Do1?p`|W;yt48#+D3J!uMCvSJydaxZ3F&c)iZ6LNrlmoQNhK(;dHKdV%{& z`M^FHBh)@~V_l0lZFN*QEb1?Kg7j%FYgrGMmlU%^)EE;P`9r^Bf%sOHv?(egb;*8o z?sA=vyV*X?85T{2OppRY)X`J*^?jocW$;HD`H~2@$*MKb7x#CS13oFIoVxjd=Kg6& zSSo^hGhNf1GkKXZaex3A*h@Vp=wwWS9u!FS_fUWj84Lu`0E$E?Bj~6ZVKNwOS(4FW zJ~UKD*&H$JIS3XR`pq|If4xXuksMg{(8+lB8D|e6{L7?EXCJL_T~IqtnUk|SK#yLz zBMrfXqA)%-hh_@8{C6rM@PtRTy#gxHOmyB#z{|MuaIIALfHYGwU#V+#oWqFi#D_sNFCe;;t+%$oa(|t{k>P)w%Bh!0 zbx|J}q!I}mRBoFLA_N3`EZ{M@^(n~mF&`5cHyL$W7X7mq>4$eaEG0ZzQ4QWy#%6T` zweErODg>LGpY3)=90TpC?4H*%=ITs=Oqm{NGv%SteB4Erm7i!r#k0T58s9rgrqHq=)n+uf`%eqY%pUp~TH+6Etdgldk{L)}UbMZ!Q z>}_eU3n7*&Xy>e(yRZxyQK6~+%OyPd_Elw^_Tq=YcPQ6#4I1hlHD@}Y{ina_N5F`+ zPUWZeE1RF!tP6VOx^{LXbl^r{|2D;kn(xoPGOSb-9Yu-7YHzRc+P?z7@k{bWJ2igS zM3ky`PB6EoDDvjW7Fld3SJ>qKYHn7Xk~7SD zn54k7HB#l{Rn*I8m13g1DPonD^2casM!(^@uYUbT__U|L-$~@WlgZ?Gp_Vhf+ewh7 zz2nw_6QQFsz{_3XBSG)J%c zY;S^gV1yssEMs9LxhSXM9X72qb{oa8e!xcydyi<6W12b_a`LC|IAzVf-*Ju53Bd`` zULF%A0hg#9XZ&|7xk;w*>7uxTyGRJ(?74`SImwfbXWZ_;XNL7rulA@?a?NNg<7+3V zM?kh-nMpBa+VN8GGM+}tMpLaU`iLhom;Whl-SQ4%dsw!@i(aFowPDv4uRo7yU-h|Q zr1O-GPgQ^;bnsb8HF6eSyzg@9f;JyXMXan%oqj22%cT5_enVyY7)|nYRmZl(oW51L z`1a|bI!h@wD=f}9#K%G%jOf{_tAa6m7wubV;pjU_KUw@P0=XqciUzjMF4nN-=(5e` zYEn1`JJns=>Sf(Ia8()^HFhiO-Toe^QOw~(0zqhDw%!ye{<6-@YZU!!^harxJr!6H z3pRJJwM~S-TC_oH)G(WEcT_W*| zOd8j$ZTv=1ms@r31Fk*t{Z0Px!4ux?8*QUO?>@6|32|d+hZGK29&HJITZ|T+tM&tK zYJ45WxpURm<)5Amv5=!d&^r5Pv#V|$lm|#8<9kk%n;X$d)1zC>;#ZNEfNPHz=d3U$@qJ*4urX3HA;M}`wsf*+G7)&L6cJHyd?N^s z=d05>uprv9_viHAu2oSm0)o6W-V!?hQ-xqQF>7d4r)v+U<1YZN<8Ze4CJF(RX9xFy z&V}&^D#_ePEG^KE0}_v*kqJ;UGOWj1xi3|XO-kOHl)PQZ8CKT}D-fo_Swf=pCO}=_ z4HbBUKnY#3M3bPAZIv|#~KS*Xb=}0t19Ewnm*a$pKlIj*g z9bXMdOHbL(e4hp*OwvHn8>LUGMs|kfeAC&w71Zvyq_#|0#?}HET!*my{kPqE^NxO| zDJe9X$3IsX6|0+F5cqjYs>@XHVzI_*#0;c>`-rf*YA9`3DRHPN@hU;ZW&&yUl8Ygx>yg-wz2<)UGtavDU=i?8ehFq8J;P zEu-ui+Xyah|9L-7nrG3_q(e#GBNggg6n$$&t*;(;je#B zj|)8p<7YSbRlV_N=VE#;vz%$ZJ>L_p&t|!EWj3Jh-M@y|J;tp7C%3c zakQKx0rUz^lbLv#ArB?TOkO0zWhfOTelP>2IP(iv_Rsm8il@Iv9s)!bM~ih;K2X!b zAlf^hVv>Xoi?}K)r&04`otN|hnq^O^)XmZIpqRpZKnxRt224c7fw#p+K*CN1ldIcYU6cKO!}H|B%pE69w27od*w+tp2aY5$#u-E zEaUca1E<+k#5X^cXbVPm8~I~P8$-6wtp(DUnZzl@COPqHD!2+j=wiu!OO znFSY1`hNDd?P99-;ObOY_a{!F^U&~XdUKK__FM**36p_7Es&-^V~i&XN2;AkVMB6Z zqcO#y0o$pi)v1SBMQmTX#GobVaE6qBs?PMZRUv?Gr&l6+Z8&Gy$$}5>lcmBnM@xby zve;0@&4=G%f_A^hJ{SL)vC0!Fx+Xp3n^+_9)=&@yv17Vysw9IUElAhI3zaen%m__C zXV!SG`t){-xZdW?JH=a{lIG`=XhZ4)?4C`Qal9nt`@mjSl5UH1|~suoX=is)yW~@IfHC)Agx_2CZFC z>C94V;eH`e%Qjp2lj<|Fsi`T+xZ@WsxQTN-In*ItuyK7s%g@E>(N8RAdBHJJxd=6O z+&~D+5LY>AoAa}SFjRg9P(tU(y9nsrZg~3{&6rLug2cI$lx8b|OXO20sS zlgL3(yNZ!uoQ}W?s4?=-JAq_r$y@_FO>_6w$Hmpw9aMX-DRjHMi{NBUX}ACLHVKWh zxB0w3oA+z8+pXn)8lX~5_PeO*R`HRc=QeTH`?`aRvrNdqGf_Bc~i-Y_v{oCQBj9@?%{93DD{L44g(8c0sB}yu$F*Ks^5^omS==@$X8UnP&vm z#Eh?(l_^Z8SEFXjyU)DR@=#^Tagk!K>E0lSu~*RiaN)HwJwc_{>BiAx9-z0u$s^i@ zYP?XspVQ7D*jiMZw8x+hDk3;@`Ukp3hP3wxGC6W_v2z~JlqBrdee)Wsvp2j*w!hC1 zZ@J&GzO|XFputh8fonnkV+?ZD7G;~xU8qLpQeGtcUoVe~%U`nECT&hVxt6xf7ODZ7 za(N2r;|3F{4CG5mGeeLkiSG0O(e-#Xf!_UL=WIMo1^k@plCDX#n_Geg?{7fLzUres z2hedOkILGDJI<&q@hX3G^T|?p^XXhTTg{#KZM3Od*$1s=f8qoK&$FF6YiytWqD_`N-LPrH1s^dFs}qg-`y2ARABI1x_alHhw8m zMqG}GD5QDs|VTly1Oe0KX4kYOp4xmIm`$PQXAq3 zI5Mt_-`R&PyM>E3D$}ZStegoaOYiJ;0?GITX9c^d0%gL zk*k*NlEZPa*wbd#(@MOq+pI8aei7|)dt>`Ny?KYe%OZ`OHu_x*C<|d!P?Kov-Bw@{ z$Ie2FdIsHIu{NJ}`_Dtwt0KB zBdjIQzKn+5Oz%qDN4Guu_TxoGz)42emPg`=M@CX}Z`iMJ(dHqSHm1tyR~RN!lsEtS zX!B|B^6g3>+v2HA>F3_UL)CT}B7Kg43S~z)J%ETnFD3duO9fUl55^UXN|dL zcWg6rvqr?qarbGhDj#e)31I;>1Ru7Q+Bwu!K|pL^bz}FO7-~@XhkT}?L}kD8rnZKw zAMUM}+gWbx&)A&!Yem+Y{hgq3m?yxsy69hfZ`4a0^vBTtsNbn~O4G@~>vR5x`##mE z%`lEoYc%)Bs5nkky}cme`3mwiL{aK9qSAEGt003@sCPa%k~r~#_2y_Hti{`{!|~E; zxrt!>HFO`YMKh&9Usv2*u z*^l4tWZ^r7a6QhGuIEfCnMM54T$&8dOr^7UIuCh zg&#>1HRxfJ{!ztO)Hi-EQ#1DZSNZ(I|V*`;5u_0841 z+Wnq3Pag(qXa7D`Ap{3w!eU(J~Bz#WS@UuOo&cTRDMNV9@gE)zQ18R%~ zmIU~#g}VzB2S~)5k8W;iG~8y|&NZ46y3+%b4qufQSe~Z-!Bl^_j#I} zV(+g0cK^m>)HySH*YE~I@8)m8CK5*jxb2*bXp`UX5O;Fw?6B>eSI=#}2mY!oDaB`e zkxdfS&iJe=;`GFbi0TAXqOl=PUd!eFOPpL{*fMnx0ck z)xr2(n$NJ?6W#8f9UhG4XNK%arD3&{Y^O0PSkU4z@#I67uSY~T#l3G1SI&ytUR7VO zlGE_lYzAJr-(x-A4%tto-sEjp_E%P~FI=?Hc^-uh=xn_JcJLZqNz z9K0~J_W~uTzydXSi>b}CfQVb9D?|lSdG-O)VA$)^2&B~ z3^gNMPQB`k*rG2yVE9InvB@i|SpyFh1DfG6RhVyn!RR+8z=q0jUMpxIZsZ~CBtOg= zxwdy@`LXx(X>+#ieD>SEt^H$`;qS7BRW7rC$|&yKar%9Fpq-TZly_iY#-iiKzl;K* zRQ{ziqSprN*$kq(RGwMwLdK2R)0%mgoeW^QQ3W5h1fr6ZG<8sF986OjrsxFVpEE-L zlVEzZz}Ino{jIEV$R#H&NH#!%)mqvn7t=hgri!h1xKElyoq0xmRQ-Fb=^>41NbK>h z5=?EXmlK%@n?VK8D1!U})Q^Jn&sw?cxB&G^=ts)?-gDx1RDtBm-{fy#d)@*eS0B%+ zhu(I5Zjf&eptq27KAxX{K7EbR6?0sNJ2U09)Mttah#)8KkvcooXVmbR;jyHZpfzE{ z@;!&|K2^Qn-uL8Zbn*<75L*o%f;kb60XM7h_La^VWXhuFq!bW5gI8W9 zVHVD~`+Z#;eQn)Rw!e>heg4g%-=>N%`a$j>zxo4Vv@B@H;_X*gc;S6{@-&yi!@nwW zvpTEy7;fHED#}r1=Yyw59*ToKDKBxWW1;Vl6P+Ds`TlN9&^I#>$3n^|oQsvHhAzVQ zaS(26rMfwuepek#*ASc}l&B0Jod-iG9@VuLgO5&gqF{ocI8c;Iuo0p&CqDfBotd86 zao5covq)MIHV9^ABHoo`E1`3+`Byb2)S<0q)B=0zR~T0!hj#v!7`Ibh(a2mrZ?^a7 zy8O1C^|H_LjZ>Cld~?5H-=QQnI79-pL*pTP1Ga(%rkxbJtGA4byKGw9Ew$Kh#{crz z(8tJycQW8t3h6xtdK?knmT$=`T*}nNwFTF_RzC*nU2?6b!NPS>rk5?=9xj?Y zm3kXrB?ToUEx}f=+GcE#7P_bs>A}5|ULkt3w{&19X03xcRhacsG=+4Ktv_{vKn*5C zEOvzuYnuB}Arp~z*(0FuZxb@HXaNrd6k#&GRy>yztLqB0Rv-2;hyS_O@!1a^uxq9lg?Zk5z* zk@wWF4`~pfhMFAG)0uHbT4%EoXuEi0VpfOsd^uj!KU@Q(#7;D;X}NSSy| z2e;1{l$%vCrEsSMe@SG+D<$zbvlS>s>fz1b`-dkI{% zsi}K0Gco^`PJb+6BY!HKtz3&$QHb=r?w_;U7AL1U0@SaM#|x$&X(g?4tsQw4`B|GH1JzzptfKsA%FXe&n9~aVOA$(DoR4tu+McDK4rBe zOG{+Uay!adrL65`Ow9Runxi#uW$8S5=5u~zMA(hIw9DaAWdShMA8cwyA&LIy`t$gk zJqr4421fZ}XWOGWF@N|B2&vcGWLX}@E#OwT4F1Cdj)squ8S-NvlUk`Gg~}l&aDGxs zI19O5C%77Oj$ieFA{O1Z%vpi~ix$AnH9KD#_TZcDX0OI2{~MbB3HJ_@*<{*-DlhD`NjvGF&=i=jVX&&-i(8~Y}@ z+ayYVsRxN!vUM4 z_%iKOkJJ4Z5vQY)YOZvgh?)BKv}r%@b6v~ft6s}>y+mKQ{MG_Vx)xG!A`8c9oESlDANg^zp@ECc( z(<$0#qseq2@ux(I!jF7QzMa-O)4qK3*KI?Q-59&S=eD|iy>{zGZHM{QUW8sB{-YER zobN`5n=egGM}4lk{4VUZ^sj#v$dne&&Ncki$Ri=`!n3aoqqIkLUk`H+NYo^u39_rN z#!_lO4;_V^sUUjc^NZ@p)K0=)CQ#v&JRsSj(+I#CbO8$>t5}G>#h0oh_z0!1jLJ4x zY_nW_X*PUV5clilS-mbJTK#qC0JT5KH~jD_=^b#?RE|vPrUIYf`;1541VW4vkDglg z{J>k*coMV9)B4Cl#GjjST3a`Rp+}5Zw?~d^t%QNs9c1wk_)ZOWpgQ@cvMTqpTLS&l z&#CM;D7%ObNZc1CEP03~xDuf{&mHg@tZq#J1#zTzcAz0hX*UD4RYVaJY zJJl1pnIU?a7QJ2KDLizrt(oFytR6pv2jo{eO8g8!b~Za1bDkaIWnPq#+Lh%f34Bs=+;DDXG)&Yu36f@^*kgMq%T3; zzG4vT?s@t@vjz4U?5m#hhpTrbtG=&~s~+BHXZNlRS#Ny;=Sz40BQ$o!C!|nNU5Yh7 zb*LP&&aRv-VCvSj9-5>nJz?5-NAF>-;X=YnXx(Rdym^C_w5b}zaGJt=gw4bufY?NFJacmCinBQw=vSp5Io<$R`FnrrT7-_ z;0-48T$ma2THC|4?hsrhN1&x?=J%BMr{ywNmJ~-fPl25=C~rENCFvdGjB%s7+}cA4 z*OEoG3=5yV@n-gN>Ibu3H~r_;)%$LZm8aRZ=P~@j=%GJBn>6sS`PMg?HeprWW51>o zI!SX{?uGEjX~vm7QMz4=QeEdSn14qMgjErc5OWbQx?b1 zy*J0=cfa}DwyToum+o3iQ5yGcXwhT7{Fh^=_Y+;c4bCbfBXi!#PcwL(E-t%oQ3Nl| z($}?8v16tGw#Qn3SYC8{x5vfS-0jF?QOi`PqGIbcfB3rL*>*K{`dDpmN%k}Cguq*G z8E!yl%8Fmp^C2hFcVA{%t+szn&LXa56bA_72`=U#j42pBxb#rgkyZ1=#|yvS7}|Z? z!F%1Y<#>oKk}SQO)4o~!S`@Jv6^*;smqr?$bvtn_yxQA*ne}|+DlG2uJY?|s=i|Wd zu#6|(9}g}qLe=JWcB?3-D_7eQ<>cNSX{QgA`S@#4re_TmrLXJ%H?8}D=;F{F>X}=1 z2j5QVsxNEZA!w_0?JMATnoNGZ4c0Qaf6{{2*up21SyrH`P44o_&8H-UXMvY?#3uJ$ z5;FGT?aQn-$HUV4l}4?Oa*^jkKA1Orw(+L^f8_iv$M7xPpVE^mo}JqnaX?&YS78zZ z!QHy|v2XQ@h&CzpxMx|W&KZhgTg#Fi96EXt(plxkB&UP_^TlPuhf@c5$A|vAj1Czx z9qQ)$o96j5AYYm0)t3BJ0>yCtcZ!01D@vMwBEt(a_}j@^>ty`@{34_zo(n$KyzDYoBjH?5{h~wM%*xWzpCT zmd}T#mJEVAgo-C?n)92VxVX1+s_2B`^lP(J2W>~Id~?C-W*X)eU!c2xPa@dqG-lyA z%G)s1HDa^4x(B`*hJWm;R2G4x@p3unc=T*@bF|3H zv9{q`POFGs#^@4gop9bJR2Rjgz}{(;JhB*ns7pK<#TvrCUKQH5D=RBKLtLPK1_Fkap+!J3B}LO6^A}$A@#8{!1Mi*Cnc})za?dF~Hj} z83lK}sU|3s8~%!Vj!g>A177+*ITc|l%DpoBOh2Ud z5p;i-CX^4fUl-*k!pHT;ll)J3=K|}qK8l9i@cewsn=tE0T(&)r41D3GnufCQA#XzQ zIF~mb-j1mhM5>FdixQb3GU~*Mn5khSSa^FrI282O4pUz3F)Qo5YpBsIN zo*~H39$J!Aw9=-jH1U?Hw!Wig_^*?%Q z-%8*Pw$a2)lld|d?C-QiJ~w$$J@Zr@uV<))CpfJ4q4%nwg{TbRBCkKyQ4=DI>go6# z9bwVfQKg^bm~x)Q`CO;Has(iL)cfU^R)$3y5G~sPJceUGKHhX#CN|_3{7Hb}kB5;K z3uG|74W~9}kIvxha2U0`Ap<)}!Lz)QNr|=BJpUUh;#B%)Z&@8WqPJ{}MAQZ!npoQ4#PE>Bt_-&k$dW%N-PZk`Sr#FYgxE;Fj{GE;AJan}s44lvX zpuD=VEkkCO{&X z?;GQX*-xkJ!e_yh>TtmvQcOEk!(ZBq?$@@b!2}O((`9o%68c(dk<68UCMO}T3$ud& zud3o(Vgq_aczzAiJK^Gri%jq=Gy`$l{oH8ATc4V!|5GRIISNdL+sz)vYTLkmZ_)$? zqc@E+EMn!Y%5wL_Hp7+Xc~pZM2wcz_6b@o$)&n`}PRnwt4NrV+xYw}bojFD4Lo9dc z-oUejY5`vd2i)x<46ga$qjQ%v(3`3|x5B;ZqtSbXylQ>aAAotV3;^VTEB?jQ%_mZ< z$uH2*8zNx-J2n;=K7nqWL{+aBH9lr>bJfaHVIJnHt}WT~6W??zRb2AC>P*{ULxBsb znAJ@x-}<$}?-`5T=Ro}i4q5!4Zz6(o6D|vqHpj=u&Wg812sMb56MpHnHH)~}$zCSk zGD#AE?JL=iG*{1l80;Is;~R-~A1@;Ll@!GzybhLR+6g$@Ww${;dEVJ+d)l)KuKLv| z?F0+5q4leDhwNW^bE3k0oaGs%uXc7am`@_$Ew~?s2GM;Z|95^;<MrS2dX%pm2|9mVJ6*rCh-K8FKYxiMULbcLXo~yqf_+{TrTO~XigdGVcxD68@PDY{QuVHaP zsT0{9PfHyF(jfm};pm%*t#3$R}x?5g@-=Yx9`Ed(=IzdZOS74?6L-lLwu8V zU4H&On#WAi760(;%kDCda7DSh+Wdh7ZBcD74R0(XyHG9d5l;Sy= z?Neo*ZVBT2gUGaZgXN#H#YjJwq-3@&oyVJc7aXhPS*wi9F?8c^DgywIBhH21&(ykM z11x&Qr#Qu`%3>1A^=vMdWxq#s&VBkpkvp*jiJqN%+?9a?T>aD8}hIvGsXjYB{%;&|L+kBhdRqV;Z2aP90LWB zfkJ|2FF4QFN&4$=rZoTxNqZDWv4VVTe=pn43Qt%hcFggWjwiB4Y>e9A7=Nkm;)VF0 zcoV|?%icm=wXrJV7meN7*H&JYe7u^Ig5tbPb3VXk#-8%-?a{+Jzp3RuCsF$O4qR}?% z)XAcS4VMyQ#w?Cxm0;OARU((@%TDNOAk?8rDp@vq{*W_1cr`fYDQ7l(YcG+lB=+o| zz+qsvOY9`1hYLsT2$0rCjF5@FU2M$$Dx5hZ@b9v?aI>#z!2y3~a*8NRh_vYO;E{+` z1Kz2AjcvjgH&l>RbJR7CE} zZodgj*kfn@k^vfhf1P|GMv<@4BXc4zmjV_+?G*VM;Ha@ z{42X7eH^1uQxVFrxyn<_S^aMb`7;jA7Z97SsnGuk*xVzfEPTE6do^bQ2}8pbKNy1T zGfRaSf{Rdt;{T>6b=lKy`4dPd@$ZNby$h$rhRmdrK8msfns%dLrdwqSh~Z)pD^W!% zNk41iimBsmum#h9*lT9#;l|*F9)@67ocMwA^>?n&cP;gxL2XSn;w-fh=qJ7~xiZFq zxY3Klxr@VvQ0Ur7U+<-|Jt(m=WUb%-&_u%ow=xm&f{Y`@yGDh!D8bhu%8w5NhN@KA zMOEp5`Y03Uy}-nlF81z50IMuLA~-<}BNZ{6E56Y9ctiUjDX*Has@DM0D6$%QLPj8M zkBX4csq7Q$c*JxnvE$U%Qs$jBPUM#3IM$zXxDoOFI#e-@;9syQeP*CmV&}CQm>GvZ z2^`Voi}ivw9sjXct$F-eMkha!h&L4yJTO|yG!S<-%|${MH@$lf{IPjLp0e7t4{E1r z*8O#xo#06&UFLPG8(~1)TqKF?>{Ufk`0weG*~AQ_ZxugjnB2CyNu1gd>_w)`nS9o+ z`7NhGUQ?RM{08FCMw#;=XTv9Q8^f1{COjT46#tH_?T6iVoENlGl2Hr<5ZzEav(@?TgX38Buh z>n5cJE%CLL2A5gs;+_!2;zYIDSN=89+WHc;u0iO0!T6G{aXgSZ{_2j&VAk-pTr=vi z^2BA@^k%;&jUmeUA*W6e_ghB$&OA|6ov%XEq)s%|upYD*?HebWotV=zKXlbS?rQbi zIad?LX|ZKP2L)h=9Noeypf4iZwg_)!j|DVNJ#`62Kx9RbDPbZ>2sLx-h z9FvCYPu7I@uH=+p6=`^UPMa8pXc-9=E*l)_lj52-;}>%d=_8ro$x2{l(x@-@TF2dr zA92#hxNNtb%)}r-H_cj^)xNiwv$TD0xx#Tpuo{^n>e$8QRk6l{LAgH_hR59n;D(85Z&mFo4j00U9?n{BJsx4+$GX4HQGkCjM`<>vcp4Qgv>rUPX{ zpCvURU6x}kEtre-UgDu7*V)AA&ctuX55G<0o~3deR5(}Cdb9iI!oe(sZP1ET?4}pr z7~aSF`SXTM&cL_B@|=x7<31F)CrHW!&=5Ie5MGnEEIeVcS{Io3tc5}1=y`Z;@p$ML zWq)h-mnt(wCHXg?O{gDJYoJl$AM_Flz@}R$EzO75+&LKi-?HotdmNnH^NJ61z(9fs%8CX@V|c?nNNt59h|TxySFAZ}?;|}!`uV243&j4V{f&1Q z;`3KBdCUzosNz?jtW@+LE~ihPX{1Gh9ofJi(B6*GHD3hxP!6kAQ1VH};J;PTpMQGg zxys(L`&nTcSqF_qMGWpfdH+wGM~BR(Lv37N5t2z78H&4)c#=pW!KYxcXLK9=@XIn{ z{$@_lw_d){q~%oQoGgeax-nZ0&A5bB4&SoH`(fMzNgORNLByLor`OlJdY#Bi1y`)J z(i2i>^K9^R!rNFi>qiICf2~9Jtn>$C_J{1&v*(I;yv+pWiLW&}>_<(}_}gy z*{rTNlf1~i0f!*8ilv)|sf5QimljGKi^jWL%cYh+ z5a$w>?45FqfT$O$Q-;L_4j%RHwTRm0W0b#Basn@&*_0vde$8_y^Yp=Z61cRP%)I8` zn{?CcD3)ZXrB)W96IXl;_z}s78_t;_6-MJLJHMUh4|jL`@2qN$x4k2&<@_@c?i%NO zlD*G#VqX2zXJ*fK{96@G$d#H4(A=R@xF52-W?j6#d9jZZGkNV=OeJDg2JarwXWVd{ ze9yx-*Ytk!p`nqQE*Q-Vt#d4?{ymRP9P!}TbMYv%wyX^JBka!Lm0c-R-+b4GEY(aTJ)opOuv= z(scRi=rF2tIA6^$Q;R?PJP0@EY2FRp*@cq8XUFWeQos2pcfjz%aSTBXEgQ(Xw#JP& zB$*uRaWHru*o4?zpX8lpigzSYD6~KR-MKGa$&UJ=UMyX%!s8v!wK2^6s-k`A0{p?r zuS_Ds`F>DsS`|Dl(rxl)n#U7-K65JN$WUTOYU1jYyA_3(QzNzp2%#`p8%D9|>5b$6 zQ+UVuWT_TVx%>V=Xi~1BX6UC|##4!Bw9N2U;1A6@gxbZH^hDVO_ZI8aWq1Fm5R_37 zS6)$9ED@N(dld#hV^JQa@n^P@PneD)xw(|EI9WzeFxjTBa@(lH{oyWwF7%Qee}vqi z@af`_{?|hI{`HLGpT~6wpUV^*YZ?O^DQq>x?ACP(lwC9i>(YrMRd5mn`)YN!c@A46 zxRq;LuZ3hY{*H8bh5dsAqze7QCQ{lg;YP3=Vv7R8q1&YOpwlT2llf1jpI<2u%5EfT$=9D$b8eMU!FK~{V2LR>i3tl#Un zQg%1m&pN7lX!w_S&bG?_TCa7OPMLiU`lP6#wH5l9+tcNF ziR`&qwVYlk={9}%_6|~mRyscux#VV{$qSo$YG7o* zDLQ$-?a{aD-p+o4&BWS@Z3}_Gp2VWY*QgX(4m%f_AX&+<{Q?>06c$dzfGs5_W2QE$ zzzCT(r}%BnwUyts1C~@diwx<`n=}`FN@k6!!^N6vmb!!E4M7$^A{aLenD=}YXQg16 z-t4=Laoj(GG`F}LZ9Ryao2xyQjoO5-dzeIyS$jBTNc^D9dnIoh^zL+jxc=5CCcjaO zYa^=v?(Yepq#lMT*{gql_aN7sf4n!id7AmMdJlP|s)G~S*KTmLQPR+ldF;%pjBmsd zHV<~G7t$%xV(+_S{1;C0am|ZkH$Lx5zf4x4l59uwtfF@Fh6#R3td^0P0+6bq z%|wLwYj=d05!Bj`C*F5@FX-XeTu@6KZ^9OxasLf9rdVG_V`5%CSwk}9Q_COgv=JGC z<}D5lA6>AyBe2s>HPxHBjsl$>hT|Im$C|jJ*DALwk3tPB$y1;4H9wSU_3RXS6zyeZ z@P1KBQxsTHGfQ})Fp9COgs=PU=!D)yeI@|YHsF`5;>v3^!^3Z3$?a%x5e#0D>1nNI znTHZi9hTCg>@nxUh9VI%wq3(#Y11LEGxKC=wlT)r*04c*OkFuJ=u~xDi#tt@NYm7m z!PDq$5RZ6OeX%+LZ7TaS*;YGKHUaU~{W6mPr+uRQkT%!sn(IbjWqI+HyH)m|n;|ey z>LZ5=P*CY7BJRqbqT@8I{VY zy^~;N7RLjp6QXpMDMEPEN>$YEKHGdGks=5KO!0B{GTN(H3)E+vS&d4xcV|2@**4p2 z59`SD?jElwa~AiBe1Cme3m>DrqUzNl>G6VHIk-82@%Jox;qOfC6>s>U=E~NUccF#r z@h&UkBo90tO$sgOx*GGD>r!bz?NHzM8GE%J5illV0n&Y)SOT1$O{ZCnvRxq0o5;Lg zBKe)AygeEf6@=<9=$?Nz9pJx73zub?9J7bIcI$ z7w(aS(pw;rgekKwX{)k##iSa4)(bl6{;yxoXC(9K+I-w9J+Nxy0J>=pOcO{L&@~(_ z4y{c-IIb5~`RA3Al?9ve%*?gP@*8L@l||yb9j|85!diJh9`D@>h!;1C&XVj-?d|~!X}6vDA=UYs6#1)>f(sOwjI;H_J_ zwn{pbmD4^$X*iPC(o(VvG@T-|fboUFf@JiFwz6{8Iz$cj?hf7!G=uHIS0t30#YRL;IN_<}aXcx! zxcawv8IyJn_I_d`+nHND-_mRnHrng_sQ=~AiMl+I+#*PTm4vNHQ%aaSc9^ertCssl$gTkdjs7JTakFHU@<*jBZ?7@_%S;N72LM~S^ z`bf^z4V?X~eG)ZjYp6v2Ghhadt2M^P50|HGOw zBc5z&XH%8|Ba%v)@iFsSt$z2N<>1(UhKaQ zj9L|8??qF$30&6My<9nwvYMv~pZwkZkFtwqF*Rl75jQ9H-Gwow zM~)1V59<62xULj;$6F9tJAJ8-Xae#xdsJ3cR#3&z7BicEYtfwi9j9(nY;a4u8DQ7e zIL?(3b9u4PzPJx#c4CxMxz5#oIDnvtwivxqw>fe5D>c*Z+SSb`fr<_MV^-kEFY$;# zu)7mZzkh-ek&nU{EPZN;f(yB_nfz(TB!>i8n%gEpzMFQQ^YFfk|>+xI)959me?hvfrmh?+4QC-+sdF@15BxJK7fInT`Bm9Bmy6 zz)}XKwu8h$!&&^+A^~Sq<>4?7L`aTTjECz@DVAoAG12CSI7v;K>c#CUWtgQP#OM9l zVpjz?)RrUYR&}4znQMuhaV=pyQj*O zYR~0_v3>l)c)Viab1$yhJEw|DZm|~84|6>|9g<0CNApz)aWaG7f|BeH6Q30eQjK}5 zw~O-Zo(ImXf(GJ$;U@ly(TteY!tFDwI_2zo zZ@O}Gy}$y)Q>+Yj>EzjafL93bi0Cy|U`?i>*(Jh+|n+l7q;kDJ;+GMPv?tDps!h z!rt|bOd20dJkuu{c|S{%nO#~e~?WSo@880S?^&efV&4)>I_Q^18(Uz5*GmtKrIrETv!YO(cPH zg=LHFL3-sdcGAIUW}7WHpcs#r(Xm-Va@Qg=HVi7|M!aYkWW`*Ts&705S(jwtZ~Xn% z_pu{gE!ufi3j{s{R7f}?xj+)vJ&fpcqq=LS!w)bI=4u|t zvrrxtu08#uaj&&-0nW@k5Jvpq@+}u#vc6O-+dQQ{T2u}->jUujuHmC~N*q|XBHOAM z0<*x*(CetQwUj%D4pH5-(%~xvShzV;l*1nt_bk0?blqgX8dmG8#GclYq_0Z?b67{a zFr{i$ob!N|*5l_J9gaZ3oty9QC* z(hrsl3&k9$L}XglKGj-;S}B(GS-vC+>j~@RYAP`)5^nPk^kyMY44b=dx^Gv2?RwPo zvrGRHaJzDc2r2GxM_vfI%=BJwY47?TlNTCX1vLzQ1BB7jcwY^F{k@QB<%G^Marbr1 zxKiETUSu@Z=HN#iKE&vYaWvV+{oaO0NK(A;Qq090!I_NiD`D7Wc`^j<+bVcwLwvH( z&8sI0onG1PT5qQ+VX8v;SDu6k-y=^#tDV;LL#b>IGgu98ZfEE($7%{ZOaJGMaPG#| znvo%~j+kWI2lFMq0pkr8qf;wT9sdQUPrYUCZ!dj5)0BR3f(o}S0^t~*PFHwQj+ZZB z8YA195wEvERx1v7n`l3bA+Chz1?z2N)`UC6)fz;X;tSTHd~Fe{#pGhRDdGF-z5hPc zpD5F_mR|{$wW?A#jV3XQVssC>LqUgCJ{ytQa?AFmrOrAl4~?H zqGx~~T7|=#|C^r7+`qJow4l-Xs_OHqVsZfIOB*cO5SavA_-}WI+K_iWfFbK@M8~FH zK*x?`do@}eul8Q6goub88;QHHLl%rLK1}MEHNLXgT&rlyv!=Y?HX?DkOb7ft=ubBV zHH^#}UI2lD4_Ybvztj?@2{MyD&d$m53M_cN&>^_?Ke^tIC}pK)2S)+|4hy`@62A)p zo+uh_%JC0;3VMTTC&*x$rwvsFaUwLetxzU4g3jLJX$;z!P3suMNG!BFOuDQ1m5P^?;y>R3Rw{RFQz z);fKuWmCj>FRD&mFu8m2Vzj4+i;M|`K~`ivaI)-Ge;d2N9Mg^}pG-*gXpkV^sDzlU zk3HgoF!QJ%S>b)SDxYE(8Ox#IQ`iiS-=Jp(tkRgU_CC>@s&MGOigF50{bbZQwtAVy zA};rL07QcgwX}l5M;Pl2}P`{yBsUs&z z(zNm0+OnVC#Guwr7yUQp5xhJ2CzJZal_Pogp3|vYt4^{WG74Kz6sxQDVUC#ZT{GsP z0!(}(?HSdTv6)Bo&c9unVv$_A_HTx(=Ebuc2CtOIYdX4zC64E18+8pG_3(0V?}EDe zl49pIXqaHl3h&~pWqO0xva`JA$`>wr6D?I4S=ZSa-S}k+9rytD8jGantKCAH zYm-6Q_K@BR-n*1wA{)%xcZvq0GV+Upl^TU1pgOneywcw}jk3dt_xsmh)1>^UvWxSR zH@d=I?liJ}7YU8u{y3#1D5zFL++Nx8i-S2;pnc0L%wP?{?JQ<~S=Z)`4|x5~$M?IR zpFy(xOJt1*Hf=YMw|J*B?E9^kUq9~4k4RMCtkeHf6udr}_r|ie)MMV!%;X38%eu+G zj<}U%AjQFG^WN@~>FXX=WvYZEO89I(3Nyx|uEeqXsALzs3$+zkut$ zHIqx(!NR3<>Bx00oKh_>W$UH2^`i$XW;ftuzj0Y2uI;~W83-#S_XL2*&U=_1HV@0K znMg?ynLQ>AkD7q~iq6~Qy8GKYX2LTaRbd7t1?OmeTi%@;|Kr~zHR z6h)^V?-xWl^pM6)6yIg*XWM~$vpA864|!2Aq7A2oDbN`zidzz}aNK%a6-CC0J589XncW&S2*p3^?FtY;H7lW{3hfh*5Ar8odF8K!CBccx6lhac|6&?JYSNyOvqWAhu#?UEB(?~a) zorK%uLUwQoZ|_h80}4fWV`dEw01^pe*2u)VFCN{6$Q+bI-vf; zw+`0_=HD{*&TZnP*>CQa+lVK6C?a+}Jw`_AY*##ZRiAZ-PLeDHW)oZCjx-$VrwSj1 z=2&3I9gWGmOQ175&V`(xQ72~8xL@hzWXd|0Ne{lgicA)tv2?c*_**Juwu`$Uj9ZV| z88NR9fl2-P`f5yqfI0e5*DLa;%_zY@7kHBI1S#UZT;*86t0Xa+RPuR0ni5craY}*# z0Qz=A)9%>(B~~}iMI5@23^z{oo6ystO9JYBCRxoZt(Wg=2-fVKN6@Jy7dwxggesmz z8y4C9T}Is{U0UT~BuUO~7#WApuJs!^@OF(AK9cA|mYMdULW!>793258?@2K;6&~2d9jh!<;*$@U!ox7wm3utePw86}Kg*%!E>_0@ie)4_em_3Pb_P{l( zIH8}cG@?e^A}lMPU8*Jkzj7hT0{g-8Y_N=slX|?{;JIu8g?=RRyyzk(kDiNbLHJh` zbM~CC&CO$zed4CI^@c_ni1(=UGZv3o-*0!b$F7KBx>`bn!c~kTt9VbO6r8*JdPgox zkN;Y+rd`2@e@(q&lPw1kWD!{93U{4DctFOQT7?nz$5aRhgg=Uj&ue&qkI#bDu5PZ-$+ zhc_P0O^yX5U0JHF|GKOoFZLZx$Vw2HBPgbLtwIFSEd~{X7Y$kYAC6Ls&~bnw$5vF$*(d8J zw%@L+URxsxH_+U_4DhzKh`&2ykDt0HB1&y=-r49_LqMDwl+am*y~OPjQNf*|ll*ye zCurq%KYV=gSY~cGC;`SY03KDMqhz2zSFGC&_(}+XyqimCRdMX=?v<6?;>O;v$G={2 zoi&y@F{fKRfuzd>Y}gPNSR{C4e{L-Q8(;4@eEoCd6W<5QkI0|C$55Zdq-sBlq94dd zN&?7p_CaBSeI^qQT5f>G*J#h2)`-gv8(#6Sv{|np7SCGA{KF-NpUthL#I~EI3_YqG z|9FNCH!etfzsB;Bd z6y=VgG41Cu^5Ud)B3u6hbi%!c)==nJB=B@|!j*A$E9dN{qkS2uw~3l@GdL%__pgauG)m?z8Rrxa6_J|x{?l*jkg zmHU9gvo}b&*WM87xR8YhFol0My~NWAyP#~6scqH;Q7&=Ui|~b+XHt~I8mIoqa~*v0 z3nV*xYj?l1I3E}0-Z9@f)8qG0gEln66WEG8$knDRf|4 zsM%=L71`9L)524<{e(XAQIaHIg+uCD0+s;v%w~;j*=$>}W~Op$3LWF@0NBR0gQ03q z3b@7gmkH<%q2hkQn3c`PR8^xp9_LA4Y%gKHh@P#%en{tD{ESv`ABjA=<)B$Jd=z+) z)l6F|@W3E>e*hk`bS)2DKc|EM&LLg%Awf{C-jIKzgIV>zxBdORRsu8FOIOq%NJMn1 zNa2&iGbHq5l_3;P*U7jcQ=O4k_L=@qPRi8HN9kOm?8tDgJ*tWpMO!|()DoCUr zc;PtYD|Y~@R4nH~wy99OyPwk^EAz^mP8qhHWQ7WJo*_LIsVEk0<22 zcb)_=8YaoQb&@4yulq_CZQBN=w(fTljyE1r5s)L^>h&ZT=Fm1Tm)+h>Uyc{gkPSr;4e_-e-a z2PEb1$XB#ANAPUj^$f6s@Z14dBof;F)$aTU8Pc(#^v2sPkG=4h1HjVUC^wxR?~D}d z6N>X#8z1?`xj%Oq?@Dt<-syDb&KklN0pR00+=fYQLW9*LNA~WBS+m2d_jq_Qx^TTPv>yR8`#>>!V9?3p9t<`*WYBNr}X5OFC15Ea*Jw~_j7 zPXEZY)tpR!de+){pMviU9-7UNKDoP0^cm)_3Gq7(Y2)to!se6B6?Z=1pQSv-Nlmtx zhaufDiPL)-7jJH zZ~yg%(^dNz7aFjWu1Owkp>Im5W6GYdXr)RKusU7X3D=x}a2Ng3L;U_nYF#yO>l+8n z<`)0BiqI#Bct#w$^2c$>1nVQ8$I>vVB{k>;ZaTU(X}nw`BBYbamG$FRxzkIOiKCN6 z@H#E0)DWpRm#Ta`~7?=h<%Z;r$2>icg=Vx+BYLjO;;!uESl+j znfaag<1~7#0@zag5wotJ-QS z*DXNAe3V}vOzC)U;G$UYb*{Cd#c_sjmF}Ux0Z3iRx&{jwTgmr4I=`nP*Jvo!*6d5B zTw}RP3*XwN=t&;id${5vF05SG(qZcda8Ha);viUgKXnNsuxr1BTl?L;p9#JK0rR^U zFHD__0U+;(WfF6-^lps)t$`m!mBDxTaTF5vo>M<*tYCPr9d@NT1VO9i>ABC(ggra*dyFoidh)Mi8f) z_|NH+N4X4MSwj?m`{}LoWR<1)2)kCIZX?v-xM7s{VT5_oT=Bp}es~jaJUY9ExZDGE z>(4R(2{&Cg{f^c-X+h)DCD)25nI}ILv5SfOl0s!nFoyt|cwXfhm(ha%pUAI?KMGAu zbppx&KB@7%ebhXP_(as_Z3-2$IxKzuS}SQ8Lb?-k5}ABol}%iYAL~!S<`mEANw9&b z42h%7Rb>@$R zt0KUouq#rSpR7WFZ4LRPU42S#q@AE8Mh(G z(5R~kUm6KeUFU{xgt2tpeP!+ct?Ly?;0$-645RfMM@nwZNBez6X$SjPIOqDFMp?si zjs@SBOfWIbo#N;K`B2p|22SBx>fgU~a6R~wQJN0{mJ&zMbnhY!{ozmZfpH-xV_rHb z3EsTuZhQ@SCK*V!`l|h>wpWJ1+J84F)~;bueNDBlOI)Gxk^4_biDuKxDE$g=`KRI{ z@dyp|>LI4tE*i^uj|J}h#uX9Emdln*o3Vhdh?$PH@6<1~+2Omfo%D7p^`?p_3U#3{ zJ9t7oi%QeZ;k2p7>d-OEQnEsB z6Hw$Xlkgrj_uHyWGgCEsx@kVVISx@~tYK(tavu>|Z=Uul*6S1r)} z_kRbEM|X0%Tn%EZ?qtuO*p`zX?HBn+Vai9OA61Eo?Wh zVCnp7;L+Q=@PjFEfiQ#}`rR(Bz59wB=AG%3<{2e2r#?EZ5q@bT@eZp$jGBc~E=L@m z3OoO7M{a~-{|xcu@MVc$c^NV#I;ZB;fcXaTo_s+VE{lSc+o+lP zxEJo|es2fhlD2edOU1hIkVa8lE;IW01}0^FRlPEt643q#aZHa?hR?G0y>$hJisfR~ z=9KCtc%oBEP`BcGekoIEU8oI=8!`3V1gK2PE|C~Bsle!qJywxwbQ>iXuKv~cTNzfPRD2qQ4}N%tg#$ojONUN*La8fF>f22; zIH!e^|1ESKv8i{?%0$eqL*pxj4j7F~E;>d*jxB_aoi3frL8MmppiTj#>h|^S1qv{j zyk`BOA{CYb8)VRz)yn9=su{KL*7UO_JXM%E{4C4?e@1mDKnoFY`Nb_*j7ukY*5+vr zX!Y9nlK4-V>5rwOup%zlyQvp>B;--#G+jTi(xp|Z%;wVP_~+wamws9h#W8wj0_wC* zWDnoYtGYVWUi`AvOo$k)QQIZFX3wPMGADeF3B1L9_~hpEZ{ zV{W3Fw-%KRg&%7w&T5e#@?!5?C%03#Mijxr+!B`dZ^{ohdLdkEtE;&*4DP%d?}j<6sA*9M)X+<%Tccfi-HrA8pH{raE5&T) zmu7Km9hD5Fj+erXD8~OcZcmkESCdX)Oyga|$TP=Q3>H&1Pu(EzKc1%xq7aH1?W^Nec;VL|PVC z?~S$$L^-GGyP}NKNGz%H>%)o`DGiYDAY5Qe{p5RtrE*V<{rm+gTm+>1#(U(`ej302 zDl@=~6eo_?z)%l$W$3+K~-(TCYuf)y@O-^kfw6ap_ zu#;GghzobTIvO|+Wm}t-S70Q@Rw=zO^n5x8?2HeviY3)M)Ooq<3>Aw$@V7u)y}; zny@oKmwZn(s_a47zFde>u3HsaG0D@aullT@_22%wePQ(@2k~%gXa}Z;Q+%;%b5hna zWu5~bli0iVm^{$nW^R&=WV@fQ#Y+4h6Z|~IUq&&$kB84AwT>UzWPZp{_byZq{}s*h z>`21KIXHQ1+Yq@#Gt=)w{_4q}Rn)8N)4&wLYQd_Xz;dMsp1F<+RP>p|8%$t5uhxHd z0^Cb=Iq~-VxWrSdRnTNrTaaSXX@5`zz;L%Tlu(T%e>H;=HiMLXETbAu*Bg>j*7QlC z#x+Y*sj!LJ>M``KvUQYB3iq%evV#-VQHY`=-l@faO@K0JL>9 zVLjtkSwA#Ej=q#6pZVQ>cok+zxm3y5JkO}w8b!Fe*(Fh`hK~- zS9}g(eZ*1NQ?@SW22o*V!6`gLwzAzB54V*{i$-eF62Y0uYM&>F%=t)T#xbRz##|A3_6pa3y8bpAH%VY6NH3)1 zwzj5w3icn^(T&x<8$rBNyOH8FNM{^k*m-`h6|}zQbJ0~ikq5gcMM=p-^;V-#FLCvd zO|KsR-d4X!WEyk9*LZ0;MJ^Ph&9vME)mKnY&8Rfwami;suC&BfNufdVhpI^fZEk#1 zU&q7JqyJsBz98xu)d&F&DgNg+X$VVe#3O?Lvt_HYXJFItSh zHy@f(b*b4qw+PgZa*RkyuW$4`z2GI13^~01UpLp?&W6Ip6Osz46(uoB(i*q7W+_rL zG>=)XYKaB*xh!I3%)1s&vE!tAOb;Y=CRP3TQ?N!@FxppHpg3|KxdOyW`KEQdN zbAIFebI##GlKhGvEH^HexGl3GISw=Uix($jHL*4r`>cOFD_Q+^!}>aK#h6j#`js7A zhw_C)MU@&0^nOYkqFp~Xt+PM;(JY^8O_8{q0o#WW7;cpDtBP6FlZT@TGr~v9Z1qF4 zX18q5hz4QDh^bMOX>avcRePuryQJMRldV{5|StWt70iWX$C7zIE884FCG%BT8l!Yr@tL7wMOe4KA{ zr%ngM`~R0OzkQ@Jtnd51@+KaM$Bso|gBNi!r_IOV)EQI{OtsEkP?@)p2m{*D6EYKTO*RCn;xpT&8!7Vpd*V zj(Lot9gNd2nYu4^mgq{0r-4!4`d*AUrmFhB;}0`fg10#Fy^&bsmO$1d6a~1VFgp3| znQtHaqedZRSx)!0JN;N50brxtyp}nOM|$qPGt;oks-hnQU{Z**szOUI^^l1UL=8lH zbrnB&KT}?9%QLdBH$m7^WG~iCS2qou9@iTQtQQ;|-*9l^3^K~Cl~soKcc^?#%#0A* z|B}gOq>jSQEip;B8QE;t+H~q!B0%kf(4VP4APTkhkr$!L*_S(aY(1#S@ zxn5>TWlORuo4W@MKtbdO&!Pn9pa+C+L-xP3TC5{71~Si_@U%9=5p72OAxV+0;Mp#S z>()iNGm+QH{@h?VGwb%N1?X6!W8qcDmxd?E4y{2T>x(XbMFl? z%K36iA+>`Xx!Vj6HFx@&{G3Mr#osYCm9A&tERz<0lau?!b$uO~6N`j!C7K4ci?j*o z!ODX{;gBg0{#=Fhe0mz&IF`lH}z4~6obs@ka*Awn>%_8C#;~D1WHrRt@ z8{Duv7aqD@+nV4%ek%IB$%!G)v-)BLor8s&yFM-b7>MUI=5x3liDEN$ zM0pX$4w9})6q)n#%uc!Bq|&+~x%bMJrZ7Q%UH=m%@(9YA? zDECJHd^>ExG%h{L1DNNtpz!fT(Za05CO~};5u&`0)_#BVgOcGamIdjoY^>xhcZsYu1-if=qa_9 z{`fG8lc5L4-Pe**E-*OkV69dhR@wvYj|Yqm`5uM+sAZ}Fk)Gd5FB8-Dv<<`gO5&gl zA{5}pM>0N*bM^3b^9`d^C$e~?4Na~eOsq+X9DUeAp01|ew)h$g0v-L|`oE7E^R9;Q T71D_0aiwNhtE+S)SK5C7SS>T+ diff --git a/apps/editor/public/icons/column1.png b/apps/editor/public/icons/column1.png new file mode 100644 index 0000000000000000000000000000000000000000..8e44f6d8e4c9613c39f32866c98d17d6628f4f6f GIT binary patch literal 19623 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 T71D_0aiwNhtE+S)SK5C7SS>T+ literal 0 HcmV?d00001 diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7530b30e..8a5c0bf6 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,6 +4,7 @@ import type { Object3D } from 'three' import type { BuildingNode, CeilingNode, + ColumnNode, DoorNode, FenceNode, GuideNode, @@ -57,6 +58,7 @@ export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent +export type ColumnEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent @@ -169,6 +171,7 @@ type EditorEvents = GridEvents & NodeEvents<'slab', SlabEvent> & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & + NodeEvents<'column', ColumnEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index ec727481..62efe95d 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -13,6 +13,7 @@ export const sceneRegistry = { site: new Set(), building: new Set(), ceiling: new Set(), + column: new Set(), level: new Set(), wall: new Set(), fence: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59e84f2b..50dad336 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export type { CameraControlEvent, CameraControlFitSceneEvent, CeilingEvent, + ColumnEvent, DoorEvent, EventSuffix, FenceEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index cfb464c5..ed182320 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -26,6 +26,20 @@ export { } from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' +export { + COLUMN_PRESETS, + ColumnBaseStyle, + ColumnCapitalStyle, + ColumnCarvingPlacement, + ColumnCrossSection, + ColumnNode, + ColumnPanelShape, + type ColumnPresetId, + ColumnRingPlacement, + ColumnShaftDetail, + ColumnShaftProfile, + ColumnStyle, +} from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' @@ -50,8 +64,8 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' -export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { SpawnNode } from './nodes/spawn' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { getEffectiveStairSurfaceMaterial, StairNode, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 846140ef..e2e76ec5 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -46,6 +46,7 @@ export const MaterialTarget = z.enum([ 'stair', 'stair-segment', 'fence', + 'column', 'slab', 'ceiling', 'door', diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts new file mode 100644 index 00000000..d19f725d --- /dev/null +++ b/packages/core/src/schema/nodes/column.ts @@ -0,0 +1,403 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const ColumnStyle = z.enum([ + 'plain', + 'faceted', + 'fluted', + 'lathe-turned', + 'dravidian-carved', + 'cluster', +]) + +export const ColumnCrossSection = z.enum([ + 'round', + 'square', + 'rectangular', + 'octagonal', + 'sixteen-sided', +]) + +export const ColumnShaftProfile = z.enum(['straight', 'tapered', 'bulged', 'baluster', 'hourglass']) + +export const ColumnShaftDetail = z.enum(['none', 'fluted', 'spiral', 'panelled', 'lathe-turned']) + +export const ColumnPanelShape = z.enum(['rectangle', 'arched', 'diamond']) + +export const ColumnBaseStyle = z.enum([ + 'none', + 'simple-square', + 'round-rings', + 'square-plinth', + 'stepped-square', + 'lotus', + 'ribbed-lotus', + 'panelled-pedestal', +]) + +export const ColumnCapitalStyle = z.enum([ + 'none', + 'simple', + 'simple-slab', + 'rounded', + 'stepped', + 'doric', + 'volute', + 'ionic-volute', + 'leaf-carved', + 'corinthian-leaf', + 'south-indian-bracket', + 'wood-bracket', +]) + +export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom']) + +export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all']) + +export type ColumnStyle = z.infer +export type ColumnCrossSection = z.infer +export type ColumnShaftProfile = z.infer +export type ColumnShaftDetail = z.infer +export type ColumnPanelShape = z.infer +export type ColumnBaseStyle = z.infer +export type ColumnCapitalStyle = z.infer +export type ColumnRingPlacement = z.infer +export type ColumnCarvingPlacement = z.infer + +export const ColumnNode = BaseNode.extend({ + id: objectId('column'), + type: nodeType('column'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + style: ColumnStyle.default('plain'), + crossSection: ColumnCrossSection.default('round'), + height: z.number().positive().default(2.8), + radius: z.number().positive().default(0.22), + width: z.number().positive().default(0.44), + depth: z.number().positive().default(0.44), + edgeSoftness: z.number().min(0).max(0.12).default(0.025), + baseHeight: z.number().min(0).default(0.18), + capitalHeight: z.number().min(0).default(0.18), + shaftProfile: ColumnShaftProfile.default('straight'), + shaftTaper: z.number().min(0).max(0.85).default(0), + shaftBulge: z.number().min(-0.5).max(0.8).default(0), + shaftStartScale: z.number().min(0.2).max(2).default(0.72), + shaftEndScale: z.number().min(0.2).max(2).default(0.72), + shaftSegmentCount: z.number().int().min(1).max(64).default(24), + shaftCornerRadius: z.number().min(0).max(0.3).default(0.035), + shaftDetail: ColumnShaftDetail.default('none'), + baseStyle: ColumnBaseStyle.default('round-rings'), + baseWidthScale: z.number().min(0.4).max(3).default(1.24), + baseDepthScale: z.number().min(0.4).max(3).default(1.24), + baseTierCount: z.number().int().min(1).max(8).default(3), + baseStepSpread: z.number().min(0).max(1).default(0.42), + basePlinthHeightRatio: z.number().min(0.2).max(0.7).default(0.44), + baseRoundBandScale: z.number().min(0.5).max(1.2).default(0.92), + baseNeckScale: z.number().min(0.35).max(1).default(0.72), + baseRoundBandCount: z.number().int().min(0).max(16).default(3), + baseRibCount: z.number().int().min(0).max(48).default(0), + baseCarvingLevel: z.number().int().min(0).max(4).default(0), + basePanelInset: z.number().min(0).max(0.1).default(0.02), + capitalStyle: ColumnCapitalStyle.default('simple'), + capitalWidthScale: z.number().min(0.4).max(3).default(1.46), + capitalDepthScale: z.number().min(0.4).max(3).default(1.46), + capitalTierCount: z.number().int().min(1).max(8).default(3), + capitalStepSpread: z.number().min(0).max(1).default(0.42), + capitalBandCount: z.number().int().min(0).max(16).default(2), + voluteSize: z.number().min(0.02).max(0.3).default(0.08), + voluteCount: z.number().int().min(0).max(8).default(4), + leafCount: z.number().int().min(0).max(48).default(18), + leafRows: z.number().int().min(0).max(4).default(2), + bracketDepth: z.number().min(0).max(1.5).default(0.35), + bracketTierCount: z.number().int().min(0).max(8).default(3), + pendantCount: z.number().int().min(0).max(16).default(0), + capitalCarvingLevel: z.number().int().min(0).max(4).default(0), + ringCount: z.number().int().min(0).max(16).default(0), + ringPlacement: ColumnRingPlacement.default('ends'), + ringThickness: z.number().min(0.01).max(0.14).default(0.055), + ringSpread: z.number().min(0.04).max(0.45).default(0.16), + fluteCount: z.number().int().min(0).max(32).default(0), + fluteDepth: z.number().min(0.005).max(0.08).default(0.02), + fluteWidth: z.number().min(0.005).max(0.1).default(0.02), + spiralTwist: z.number().min(-3).max(3).default(0), + spiralRibCount: z.number().int().min(0).max(32).default(0), + panelCount: z.number().int().min(0).max(24).default(0), + panelInsetDepth: z.number().min(0).max(0.1).default(0.02), + panelShape: ColumnPanelShape.default('rectangle'), + latheRingCount: z.number().int().min(0).max(32).default(0), + latheRingSpacing: ColumnRingPlacement.default('ends'), + carvingLevel: z.number().int().min(0).max(4).default(0), + carvingPlacement: ColumnCarvingPlacement.default('capital'), + lowerBandEnabled: z.boolean().default(false), + lowerBandHeight: z.number().min(0).max(1).default(0.24), + lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0), + dentilCount: z.number().int().min(0).max(48).default(0), + beadCount: z.number().int().min(0).max(64).default(0), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), +}).describe(dedent` + Column node - used to represent structural or decorative pillars/columns. + - style: visual approach such as plain, lathe-turned, carved, or cluster + - crossSection: plan shape used by the procedural renderer + - height/radius/width/depth: primary dimensions in meters + - edgeSoftness: bevel radius for square/plinth/block edges + - shaftProfile/shaftDetail: profile and surface treatment of the shaft + - shaftCornerRadius: rounded-corner radius for square/rectangular shaft cross-sections + - baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls + - baseHeight/capitalHeight: bottom and top block proportions + - ring/flute/spiral/panel/lathe/carving fields: procedural detail controls +`) + +export const COLUMN_PRESETS = { + basicPillar: { + label: 'Straight Round', + style: 'plain', + crossSection: 'round', + height: 2.9, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.26, + baseDepthScale: 1.26, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.22, + capitalDepthScale: 1.22, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + squarePillar: { + label: 'Square Block', + style: 'faceted', + crossSection: 'square', + height: 2.9, + radius: 0.24, + width: 0.48, + depth: 0.48, + edgeSoftness: 0.035, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + shaftCornerRadius: 0.045, + shaftDetail: 'none', + baseStyle: 'simple-square', + baseWidthScale: 1.18, + baseDepthScale: 1.18, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.18, + capitalDepthScale: 1.18, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + taperedPillar: { + label: 'Tapered Round', + style: 'plain', + crossSection: 'round', + height: 3, + radius: 0.23, + width: 0.46, + depth: 0.46, + edgeSoftness: 0.025, + baseHeight: 0.23, + capitalHeight: 0.2, + shaftProfile: 'tapered', + shaftTaper: 0.14, + shaftBulge: 0, + shaftStartScale: 0.82, + shaftEndScale: 0.72, + shaftSegmentCount: 32, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.26, + baseDepthScale: 1.26, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.22, + capitalDepthScale: 1.22, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + bulgedPillar: { + label: 'Soft Bulged', + style: 'plain', + crossSection: 'round', + height: 2.9, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'bulged', + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.68, + shaftEndScale: 0.68, + shaftSegmentCount: 32, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.24, + baseDepthScale: 1.24, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.2, + capitalDepthScale: 1.2, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, + hourglassPillar: { + label: 'Hourglass', + style: 'plain', + crossSection: 'round', + height: 2.9, + radius: 0.22, + width: 0.44, + depth: 0.44, + edgeSoftness: 0.025, + baseHeight: 0.22, + capitalHeight: 0.2, + shaftProfile: 'hourglass', + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.84, + shaftEndScale: 0.84, + shaftSegmentCount: 32, + shaftCornerRadius: 0.035, + shaftDetail: 'none', + baseStyle: 'square-plinth', + baseWidthScale: 1.24, + baseDepthScale: 1.24, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'simple-slab', + capitalWidthScale: 1.2, + capitalDepthScale: 1.2, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + }, +} as const satisfies Record>> + +export type ColumnPresetId = keyof typeof COLUMN_PRESETS + +export type ColumnNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index c1d7f371..0b24763a 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -2,6 +2,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' +import { ColumnNode } from './column' import { FenceNode } from './fence' import { GuideNode } from './guide' import { ItemNode } from './item' @@ -21,6 +22,7 @@ export const LevelNode = BaseNode.extend({ z.union([ WallNode.shape.id, FenceNode.shape.id, + ColumnNode.shape.id, ItemNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 00e07fa1..a4977b5d 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,6 +1,7 @@ import z from 'zod' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' +import { ColumnNode } from './nodes/column' import { DoorNode } from './nodes/door' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' @@ -22,6 +23,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SiteNode, BuildingNode, LevelNode, + ColumnNode, WallNode, FenceNode, ItemNode, diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fec5d288..6aa84cf0 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -25,8 +25,8 @@ import { Move } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { duplicateRoofSubtree } from '../../lib/roof-duplication' -import { duplicateStairSubtree } from '../../lib/stair-duplication' import { sfxEmitter } from '../../lib/sfx-bus' +import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' import { NodeActionMenu } from './node-action-menu' @@ -40,6 +40,7 @@ const ALLOWED_TYPES = [ 'stair-segment', 'wall', 'fence', + 'column', 'slab', 'ceiling', 'spawn', @@ -147,10 +148,7 @@ export function FloatingActionMenu() { node.type === 'wall' ? obj.localToWorld( new THREE.Vector3( - Math.hypot( - segment.end[0] - segment.start[0], - segment.end[1] - segment.start[1], - ), + Math.hypot(segment.end[0] - segment.start[0], segment.end[1] - segment.start[1]), 0, 0, ), @@ -186,6 +184,7 @@ export function FloatingActionMenu() { node.type === 'door' || node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'spawn' || @@ -335,7 +334,7 @@ export function FloatingActionMenu() { } else if (duplicate.type === 'stair') { setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) } - if (duplicate.type !== 'stair' && duplicate.type !== 'roof') { + if (duplicate.type !== 'stair') { setSelection({ selectedIds: [] }) } } @@ -426,6 +425,7 @@ export function FloatingActionMenu() { onDuplicate={ node && node.type !== 'spawn' && + node.type !== 'column' && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) ? handleDuplicate diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b42ee1c8..71f278cf 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + type ColumnNode, emitter, type FenceNode, getMaterialPresetByRef, @@ -65,6 +66,7 @@ type SelectableNodeType = | 'wall' | 'fence' | 'item' + | 'column' | 'building' | 'zone' | 'slab' @@ -333,7 +335,7 @@ function applyStairPaintPreview( } function applySingleSurfacePaintPreview( - node: FenceNode | SlabNode | CeilingNode, + node: FenceNode | ColumnNode | SlabNode | CeilingNode, material: ActivePaintMaterial, ): PaintPreviewCleanup | null { if (node.type === 'ceiling') { @@ -377,12 +379,32 @@ function applySingleSurfacePaintPreview( } } - const mesh = getRegisteredMesh(node.id) - if (!mesh) return null + const registeredObject = getRegisteredNodeObject(node.id) + const mesh = + registeredObject && (registeredObject as Mesh).isMesh ? (registeredObject as Mesh) : null const previewMaterial = getSingleSurfacePreviewMaterial(material) if (!previewMaterial) return null + if (node.type === 'column') { + if (!registeredObject) return null + const restores: PaintPreviewCleanup[] = [] + + registeredObject.traverse((object) => { + if (!(object as Mesh).isMesh) return + restores.push(previewMeshMaterial(object as Mesh, previewMaterial)) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } + } + + if (!mesh) return null + if (node.type === 'slab') { const slabMaterial = previewMaterial.clone() applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset)) @@ -542,6 +564,7 @@ const SELECTION_STRATEGIES: Record = { 'wall', 'fence', 'item', + 'column', 'zone', 'slab', 'ceiling', @@ -595,6 +618,7 @@ const SELECTION_STRATEGIES: Record = { if ( node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -658,6 +682,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { if ( node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -857,7 +882,12 @@ export const SelectionManager = () => { } } - if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') { + if ( + node.type === 'fence' || + node.type === 'column' || + node.type === 'slab' || + node.type === 'ceiling' + ) { const compatible = hasActivePaintMaterial(activePaintMaterial) return { @@ -870,17 +900,16 @@ export const SelectionManager = () => { .getState() .updateNode( node.id as AnyNodeId, - buildSingleSurfaceMaterialPatch( - activePaintMaterial.material, - activePaintMaterial.materialPreset, - ), + buildSingleSurfaceMaterialPatch< + FenceNode | ColumnNode | SlabNode | CeilingNode + >(activePaintMaterial.material, activePaintMaterial.materialPreset), ) } : null, preview: compatible ? () => applySingleSurfacePaintPreview( - node as FenceNode | SlabNode | CeilingNode, + node as FenceNode | ColumnNode | SlabNode | CeilingNode, activePaintMaterial, ) : () => previewCursor('not-allowed'), @@ -963,6 +992,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', @@ -1131,6 +1161,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'building', 'zone', 'slab', @@ -1227,6 +1258,7 @@ export const SelectionManager = () => { } else if ( node.type === 'wall' || node.type === 'fence' || + node.type === 'column' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -1279,6 +1311,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'building', 'slab', 'ceiling', @@ -1352,6 +1385,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx new file mode 100644 index 00000000..9a239f10 --- /dev/null +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -0,0 +1,97 @@ +import '../../../three-types' + +import { + type AnyNode, + COLUMN_PRESETS, + ColumnNode, + type ColumnPresetId, + emitter, + type GridEvent, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import type { Group } from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +const COLUMN_ICON = ( + // eslint-disable-next-line @next/next/no-img-element + Column +) + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId + +function createColumnFromPreset(presetId: ColumnPresetId, position: [number, number, number]) { + const { label, ...preset } = COLUMN_PRESETS[presetId] + return ColumnNode.parse({ + name: label, + position, + rotation: 0, + ...preset, + }) +} + +type ColumnToolProps = { + currentLevelId: LevelNode['id'] | null +} + +export const ColumnTool: React.FC = ({ currentLevelId }) => { + const [, setCursorPosition] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(null) + + useEffect(() => { + if (!currentLevelId) return + + const onGridMove = (event: GridEvent) => { + const nextPosition: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + 0, + roundToHalf(event.localPosition[2]), + ] + setCursorPosition(nextPosition) + cursorRef.current?.position.set(nextPosition[0], event.localPosition[1], nextPosition[2]) + } + + const onGridClick = (event: GridEvent) => { + const position: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + 0, + roundToHalf(event.localPosition[2]), + ] + const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) + useScene.getState().createNode(column, currentLevelId) + useViewer.getState().setSelection({ selectedIds: [column.id as AnyNode['id']] }) + sfxEmitter.emit('sfx:structure-build') + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, [currentLevelId]) + + if (!currentLevelId) return null + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/column/move-column-tool.tsx b/packages/editor/src/components/tools/column/move-column-tool.tsx new file mode 100644 index 00000000..ae02e102 --- /dev/null +++ b/packages/editor/src/components/tools/column/move-column-tool.tsx @@ -0,0 +1,105 @@ +import '../../../three-types' + +import { + type AnyNodeId, + ColumnNode, + type ColumnNode as ColumnNodeType, + emitter, + type GridEvent, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useCallback, useEffect, useState } from 'react' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 + +export function MoveColumnTool({ node }: { node: ColumnNodeType }) { + const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + useScene.temporal.getState().pause() + let committed = false + + const applyPreview = (position: [number, number, number]) => { + setPreviewPosition(position) + useLiveTransforms.getState().set(node.id, { + position, + rotation: node.rotation, + }) + sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2]) + } + + const onGridMove = (event: GridEvent) => { + applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]) + } + + const onGridClick = (event: GridEvent) => { + const position: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + 0, + roundToHalf(event.localPosition[2]), + ] + const nodeId = (node as { id?: ColumnNodeType['id'] }).id + + if (nodeId && useScene.getState().nodes[nodeId]) { + committed = true + useLiveTransforms.getState().clear(nodeId) + useScene.temporal.getState().resume() + useScene.getState().updateNode(nodeId, { position }) + } else if (node.parentId) { + const column = ColumnNode.parse({ + ...node, + id: undefined, + metadata: {}, + position, + }) + committed = true + useScene.temporal.getState().resume() + useScene.getState().createNode(column, node.parentId as AnyNodeId) + } + + useLiveTransforms.getState().clear(node.id) + sfxEmitter.emit('sfx:item-place') + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + useLiveTransforms.getState().clear(node.id) + sceneRegistry.nodes + .get(node.id) + ?.position.set(node.position[0], node.position[1], node.position[2]) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + useLiveTransforms.getState().clear(node.id) + if (!committed) { + sceneRegistry.nodes + .get(node.id) + ?.position.set(node.position[0], node.position[1], node.position[2]) + useScene.temporal.getState().resume() + } + } + }, [exitMoveMode, node]) + + return +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index b6c1397a..8556d4e7 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,6 +1,7 @@ import type { BuildingNode, CeilingNode, + ColumnNode, DoorNode, FenceNode, ItemNode, @@ -18,6 +19,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MoveBuildingContent } from '../building/move-building-tool' import { MoveCeilingTool } from '../ceiling/move-ceiling-tool' +import { MoveColumnTool } from '../column/move-column-tool' import { MoveDoorTool } from '../door/move-door-tool' import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' @@ -100,6 +102,7 @@ export const MoveTool: React.FC<{ if (movingNode.type === 'window') return if (movingNode.type === 'fence') return if (movingNode.type === 'ceiling') return + if (movingNode.type === 'column') return if (movingNode.type === 'slab') return if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 0938a254..a0205ae5 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -10,6 +10,7 @@ import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' +import { ColumnTool } from './column/column-tool' import { DoorTool } from './door/door-tool' import { CurveFenceTool } from './fence/curve-fence-tool' import { FenceTool } from './fence/fence-tool' @@ -164,7 +165,10 @@ export const ToolManager: React.FC = () => { {!movingNode && showBuildTool && tool === 'spawn' && ( )} - {!movingNode && BuildToolComponent && } + {!movingNode && showBuildTool && tool === 'column' && ( + + )} + {!movingNode && BuildToolComponent && tool !== 'column' && } ) diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 21937885..cc060af6 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -24,6 +24,7 @@ export const tools: ToolConfig[] = [ // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, + { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, diff --git a/packages/editor/src/components/ui/panels/column-panel.tsx b/packages/editor/src/components/ui/panels/column-panel.tsx new file mode 100644 index 00000000..e4ae296a --- /dev/null +++ b/packages/editor/src/components/ui/panels/column-panel.tsx @@ -0,0 +1,686 @@ +'use client' + +import { + type AnyNode, + COLUMN_PRESETS, + type ColumnNode, + type ColumnPresetId, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2 } from 'lucide-react' +import { useCallback } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelSection } from '../controls/panel-section' +import { SliderControl } from '../controls/slider-control' +import { PanelWrapper } from './panel-wrapper' + +const SELECT_CLASS = + 'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border' + +const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({ + value: value as ColumnPresetId, + label: preset.label, +})) + +const COLUMN_PROPORTION_PRESETS = { + slender: { + label: 'Slender', + height: 3.6, + width: 0.34, + baseHeight: 0.18, + capitalHeight: 0.16, + baseWidthScale: 1.18, + capitalWidthScale: 1.16, + edgeSoftness: 0.02, + }, + standard: { + label: 'Standard', + height: 2.9, + width: 0.44, + baseHeight: 0.22, + capitalHeight: 0.2, + baseWidthScale: 1.24, + capitalWidthScale: 1.22, + edgeSoftness: 0.025, + }, + heavy: { + label: 'Heavy', + height: 3, + width: 0.58, + baseHeight: 0.28, + capitalHeight: 0.26, + baseWidthScale: 1.34, + capitalWidthScale: 1.3, + edgeSoftness: 0.035, + }, + stout: { + label: 'Short / Stout', + height: 2.2, + width: 0.62, + baseHeight: 0.3, + capitalHeight: 0.28, + baseWidthScale: 1.38, + capitalWidthScale: 1.34, + edgeSoftness: 0.04, + }, +} as const + +type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS + +const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({ + value: value as ColumnProportionPresetId, + label: preset.label, +})) + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function presetUpdates(presetId: ColumnPresetId): Partial { + const { label, ...preset } = COLUMN_PRESETS[presetId] + return { + name: label, + ...preset, + } +} + +function proportionUpdates( + node: ColumnNode, + presetId: ColumnProportionPresetId, +): Partial { + const preset = COLUMN_PROPORTION_PRESETS[presetId] + const depth = + node.crossSection === 'rectangular' + ? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6) + : preset.width + const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18) + + return { + height: preset.height, + width: preset.width, + depth, + radius: preset.width / 2, + baseHeight: preset.baseHeight, + capitalHeight: preset.capitalHeight, + baseWidthScale: preset.baseWidthScale, + baseDepthScale: preset.baseWidthScale, + capitalWidthScale: preset.capitalWidthScale, + capitalDepthScale: preset.capitalWidthScale, + edgeSoftness: preset.edgeSoftness, + shaftCornerRadius, + } +} + +function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial { + if (shaftProfile === 'tapered') { + return { + shaftProfile, + shaftTaper: 0.14, + shaftBulge: 0, + shaftStartScale: 0.82, + shaftEndScale: 0.72, + shaftSegmentCount: 32, + } + } + + if (shaftProfile === 'bulged') { + return { + shaftProfile, + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.68, + shaftEndScale: 0.68, + shaftSegmentCount: 32, + } + } + + if (shaftProfile === 'hourglass') { + return { + shaftProfile, + shaftTaper: 0, + shaftBulge: 0.12, + shaftStartScale: 0.84, + shaftEndScale: 0.84, + shaftSegmentCount: 32, + } + } + + return { + shaftProfile, + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 0.72, + shaftEndScale: 0.72, + shaftSegmentCount: 1, + } +} + +export function ColumnPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const selectedCount = useViewer((s) => s.selection.selectedIds.length) + const setSelection = useViewer((s) => s.setSelection) + const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined, + ) + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId) return + sfxEmitter.emit('sfx:structure-delete') + deleteNode(selectedId as AnyNode['id']) + setSelection({ selectedIds: [] }) + }, [deleteNode, selectedId, setSelection]) + + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null + const shaftProfile = node.shaftProfile ?? 'straight' + + return ( + + + + + + + + handleUpdate({ edgeSoftness: value })} + precision={3} + step={0.005} + unit="m" + value={node.edgeSoftness ?? 0.025} + /> + {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( + handleUpdate({ shaftCornerRadius: value })} + precision={3} + step={0.005} + unit="m" + value={node.shaftCornerRadius ?? 0.035} + /> + )} + + + + + handleUpdate({ height: value })} + precision={2} + step={0.05} + unit="m" + value={node.height} + /> + + handleUpdate({ + width: value, + radius: value / 2, + ...(node.crossSection === 'rectangular' ? {} : { depth: value }), + }) + } + precision={2} + step={0.02} + unit="m" + value={node.width} + /> + {node.crossSection === 'rectangular' && ( + handleUpdate({ depth: value })} + precision={2} + step={0.02} + unit="m" + value={node.depth} + /> + )} + + + + + {shaftProfile === 'straight' && ( + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.72} + /> + )} + {shaftProfile === 'tapered' && ( + <> + handleUpdate({ shaftStartScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.82} + /> + handleUpdate({ shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftEndScale ?? 0.72} + /> + handleUpdate({ shaftTaper: value })} + precision={2} + step={0.01} + value={node.shaftTaper ?? 0.14} + /> + + )} + {shaftProfile === 'bulged' && ( + <> + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.68} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + {shaftProfile === 'hourglass' && ( + <> + handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.84} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + + handleUpdate({ + ringCount: Math.round(value) * 2, + ringPlacement: 'ends', + ringSpread: node.ringSpread ?? 0.16, + ringThickness: node.ringThickness ?? 0.055, + }) + } + precision={0} + step={1} + value={Math.ceil((node.ringCount ?? 0) / 2)} + /> + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.ringThickness ?? 0.055} + /> + )} + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} + precision={2} + step={0.01} + value={node.ringSpread ?? 0.16} + /> + )} + + + + + {node.capitalStyle !== 'none' && ( + handleUpdate({ capitalHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.capitalHeight} + /> + )} + {node.capitalStyle !== 'none' && ( + + handleUpdate({ + capitalWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ capitalDepthScale: value })} + precision={2} + step={0.02} + value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.capitalTierCount ?? 3} + /> + )} + {node.capitalStyle === 'stepped' && ( + handleUpdate({ capitalStepSpread: value })} + precision={2} + step={0.01} + value={node.capitalStepSpread ?? 0.34} + /> + )} + + {node.baseStyle !== 'none' && ( + handleUpdate({ baseHeight: value })} + precision={2} + step={0.02} + unit="m" + value={node.baseHeight} + /> + )} + {node.baseStyle !== 'none' && ( + + handleUpdate({ + baseWidthScale: value, + ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), + }) + } + precision={2} + step={0.02} + value={node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( + handleUpdate({ baseDepthScale: value })} + precision={2} + step={0.02} + value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ basePlinthHeightRatio: value })} + precision={2} + step={0.01} + value={node.basePlinthHeightRatio ?? 0.44} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseRoundBandScale: value })} + precision={2} + step={0.01} + value={node.baseRoundBandScale ?? 0.92} + /> + )} + {node.baseStyle === 'round-rings' && ( + handleUpdate({ baseNeckScale: value })} + precision={2} + step={0.01} + value={node.baseNeckScale ?? 0.72} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseTierCount: Math.round(value) })} + precision={0} + step={1} + value={node.baseTierCount ?? 3} + /> + )} + {node.baseStyle === 'stepped-square' && ( + handleUpdate({ baseStepSpread: value })} + precision={2} + step={0.01} + value={node.baseStepSpread ?? 0.34} + /> + )} + + + + handleUpdate({ rotation: (value * Math.PI) / 180 })} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation * 180) / Math.PI)} + /> + + + + + } label="Move" onClick={handleMove} /> + } + label="Delete" + onClick={handleDelete} + /> + + + + ) +} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 6cc26c66..3b545680 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -12,6 +12,7 @@ const TYPE_DEFAULTS: Record = { window: { icon: '/icons/window.png', label: 'Window' }, slab: { icon: '/icons/floor.png', label: 'Slab' }, ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' }, + column: { icon: '/icons/column.png', label: 'Column' }, fence: { icon: '/icons/fence.png', label: 'Fence' }, roof: { icon: '/icons/roof.png', label: 'Roof' }, 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' }, diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index d5faef6f..e948ab80 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + type ColumnNode, type DoorNode, type FenceNode, type ItemNode, @@ -23,6 +24,7 @@ import { useIsMobile } from '../../../hooks/use-mobile' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CeilingPanel } from './ceiling-panel' +import { ColumnPanel } from './column-panel' import { DoorPanel } from './door-panel' import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' @@ -45,6 +47,7 @@ type MovableNode = | WindowNode | DoorNode | CeilingNode + | ColumnNode | SlabNode | WallNode | FenceNode @@ -59,6 +62,7 @@ const MOVABLE_TYPES = new Set([ 'window', 'door', 'ceiling', + 'column', 'slab', 'wall', 'fence', @@ -90,6 +94,8 @@ function panelForType(type: string | null) { return case 'ceiling': return + case 'column': + return case 'wall': return case 'fence': @@ -250,6 +256,8 @@ export function PanelManager() { return case 'ceiling': return + case 'column': + return case 'wall': return case 'fence': diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx new file mode 100644 index 00000000..af37aaf1 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx @@ -0,0 +1,77 @@ +import { type AnyNodeId, type ColumnNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface ColumnTreeNodeProps { + nodeId: AnyNodeId + depth: number + isLast?: boolean +} + +export const ColumnTreeNode = memo(function ColumnTreeNode({ + nodeId, + depth, + isLast, +}: ColumnTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const node = useScene((s) => s.nodes[nodeId] as ColumnNode | undefined) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + const defaultName = node?.name || 'Column' + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 4a4d9194..2e7ba5df 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -56,6 +56,7 @@ export function focusTreeNode(nodeId: AnyNodeId) { import { cn } from '../../../../../lib/utils' import { BuildingTreeNode } from './building-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' +import { ColumnTreeNode } from './column-tree-node' import { DoorTreeNode } from './door-tree-node' import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' @@ -84,6 +85,8 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr return case 'ceiling': return + case 'column': + return case 'level': return case 'slab': diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index ffdde786..37582b0d 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -2,6 +2,7 @@ import { type CeilingNode, + type ColumnNode, type FenceNode, getCatalogMaterialById, getEffectiveRoofSurfaceMaterial, @@ -21,7 +22,7 @@ import { export type PaintableMaterialTarget = Extract< MaterialTarget, - 'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling' + 'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' > export type SingleSurfaceMaterialRole = 'surface' @@ -131,10 +132,9 @@ export function buildStairSurfaceMaterialPatch( } } -export function buildSingleSurfaceMaterialPatch( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { +export function buildSingleSurfaceMaterialPatch< + TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode, +>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial { return { material, materialPreset, @@ -170,7 +170,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'wall', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'wall', @@ -190,7 +190,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'roof', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'roof', @@ -210,7 +210,7 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'stair', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, sourceTarget: 'stair', @@ -220,6 +220,7 @@ export function resolveActivePaintMaterialFromSelection(params: { if ( (selectedNode.type === 'fence' || + selectedNode.type === 'column' || selectedNode.type === 'slab' || selectedNode.type === 'ceiling') && selectedMaterialTarget.role === 'surface' @@ -267,6 +268,10 @@ export function resolvePaintTargetFromSelection(params: { return 'fence' } + if (selectedNode.type === 'column') { + return 'column' + } + if (selectedNode.type === 'slab') { return 'slab' } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 560dc094..b3aa2616 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -5,6 +5,7 @@ import { type AssetInput, type BuildingNode, type CeilingNode, + type ColumnNode, type DoorNode, type FenceNode, type ItemNode, @@ -139,6 +140,7 @@ type EditorState = { | DoorNode | FenceNode | CeilingNode + | ColumnNode | SlabNode | WallNode | RoofNode @@ -155,6 +157,7 @@ type EditorState = { | DoorNode | FenceNode | CeilingNode + | ColumnNode | SlabNode | WallNode | RoofNode diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/viewer/src/components/renderers/column/column-renderer.tsx new file mode 100644 index 00000000..1d7932dc --- /dev/null +++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx @@ -0,0 +1,1302 @@ +import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { createContext, useContext, useMemo, useRef } from 'react' +import type { Group, Material } from 'three' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' + +const ColumnMaterialContext = createContext(baseMaterial as Material) +const ColumnEdgeSoftnessContext = createContext(0.025) + +function ColumnMaterial() { + const material = useContext(ColumnMaterialContext) + return +} + +function createColumnMaterial({ + material, + materialPreset, +}: Pick) { + const presetMaterial = createMaterialFromPresetRef(materialPreset) + if (presetMaterial) return presetMaterial + if (material) return createMaterial(material) + return baseMaterial +} + +function getSegments(node: ColumnNode) { + if (node.crossSection === 'octagonal') return 8 + if (node.crossSection === 'sixteen-sided') return 16 + return 32 +} + +function getShaftProfile(node: ColumnNode) { + return node.shaftProfile ?? (node.shaftTaper > 0 ? 'tapered' : 'straight') +} + +function getShaftSegmentCount(node: ColumnNode) { + const shaftProfile = getShaftProfile(node) + const shaftTaper = node.shaftTaper ?? 0 + return Math.max( + 1, + shaftProfile === 'straight' && shaftTaper <= 0 ? 1 : (node.shaftSegmentCount ?? 24), + ) +} + +function getShaftScaleAt(node: ColumnNode, t: number) { + const shaftProfile = getShaftProfile(node) + const shaftTaper = Math.min(node.shaftTaper ?? 0, 0.85) + const startScale = node.shaftStartScale ?? 0.72 + const endScale = node.shaftEndScale ?? startScale + const shaftBulge = + node.shaftBulge ?? + (shaftProfile === 'bulged' + ? 0.16 + : shaftProfile === 'baluster' + ? 0.2 + : shaftProfile === 'hourglass' + ? 0.18 + : 0) + const taperedScale = 1 - shaftTaper * t + const linearScale = (startScale + (endScale - startScale) * t) * taperedScale + const bulgeCurve = Math.sin(Math.PI * t) + const hourglassCurve = Math.abs(t - 0.5) * 2 + const profileScale = + shaftProfile === 'bulged' || shaftProfile === 'baluster' + ? linearScale + shaftBulge * bulgeCurve + : shaftProfile === 'hourglass' + ? linearScale - shaftBulge * (1 - hourglassCurve) + : linearScale + + return Math.max(0.1, profileScale) +} + +function SquareBlock({ + y, + height, + width, + depth, + softenEdges = true, +}: { + y: number + height: number + width: number + depth: number + softenEdges?: boolean +}) { + const edgeSoftness = useContext(ColumnEdgeSoftnessContext) + const minDimension = Math.max(0, Math.min(width, height, depth)) + const bevelRadius = softenEdges ? Math.min(Math.max(0, edgeSoftness), minDimension * 0.35) : 0 + const roundedGeometry = useMemo(() => { + if (bevelRadius <= 0.001) return null + return new RoundedBoxGeometry(width, height, depth, 3, bevelRadius) + }, [bevelRadius, depth, height, width]) + + if (height <= 0) return null + + const position = [0, y + height / 2, 0] as const + + return ( + + {roundedGeometry ? ( + + ) : ( + + )} + + + ) +} + +function RoundBlock({ + x = 0, + y, + z = 0, + height, + radius, + segments = 32, +}: { + x?: number + y: number + z?: number + height: number + radius: number + segments?: number +}) { + if (height <= 0) return null + + return ( + + + + + ) +} + +function RoundedRectangleShaftSegment({ + y, + height, + width, + depth, + cornerRadius, +}: { + y: number + height: number + width: number + depth: number + cornerRadius: number +}) { + if (height <= 0) return null + + const radius = Math.min(Math.max(0, cornerRadius), Math.min(width, depth) * 0.45) + if (radius <= 0.001) { + return + } + + const innerWidth = Math.max(0, width - radius * 2) + const innerDepth = Math.max(0, depth - radius * 2) + const cornerX = width / 2 - radius + const cornerZ = depth / 2 - radius + + return ( + + {innerWidth > 0 && ( + + )} + {innerDepth > 0 && ( + + )} + {( + [ + [cornerX, cornerZ], + [cornerX, -cornerZ], + [-cornerX, cornerZ], + [-cornerX, -cornerZ], + ] satisfies [number, number][] + ).map(([x, z], index) => ( + + ))} + + ) +} + +function OvalBlock({ + y, + height, + width, + depth, + segments = 32, +}: { + y: number + height: number + width: number + depth: number + segments?: number +}) { + if (height <= 0) return null + + return ( + + + + + ) +} + +function ColumnBlock({ + node, + y, + height, + scale = 1, +}: { + node: ColumnNode + y: number + height: number + scale?: number +}) { + if (height <= 0) return null + + const width = node.width * scale + const depth = node.depth * scale + const radius = node.radius * scale + + if (node.crossSection === 'square' || node.crossSection === 'rectangular') { + return + } + + return +} + +function TaperedRoundShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + const segmentCount = getShaftSegmentCount(node) + const segmentHeight = height / segmentCount + + return ( + + {Array.from({ length: segmentCount }, (_, index) => { + const t = (index + 0.5) / segmentCount + const profileScale = getShaftScaleAt(node, t) + return ( + + ) + })} + + ) +} + +function TaperedSquareShaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + const segmentCount = getShaftSegmentCount(node) + const segmentHeight = height / segmentCount + + return ( + + {Array.from({ length: segmentCount }, (_, index) => { + const t = (index + 0.5) / segmentCount + const profileScale = getShaftScaleAt(node, t) + return ( + + ) + })} + + ) +} + +function Shaft({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + if (height <= 0) return null + + if (node.style === 'cluster') { + const sideRadius = Math.max(0.04, node.radius * 0.36) + const offset = Math.max(node.radius * 0.78, node.width * 0.22) + return ( + + + + + + {( + [ + [offset, 0], + [-offset, 0], + [0, offset], + [0, -offset], + ] satisfies [number, number][] + ).map(([x, z], index) => ( + + + + + ))} + + ) + } + + if ( + node.crossSection === 'round' || + node.crossSection === 'octagonal' || + node.crossSection === 'sixteen-sided' + ) { + return + } + + return +} + +function Base({ node, height }: { node: ColumnNode; height: number }) { + if (height <= 0) return null + + const baseStyle = node.baseStyle ?? 'round-rings' + const widthScale = node.baseWidthScale ?? 1.24 + const depthScale = node.baseDepthScale ?? widthScale + + if (baseStyle === 'none') return null + + if (baseStyle === 'simple-square') { + return ( + + ) + } + + if (baseStyle === 'square-plinth') { + return ( + + + + + ) + } + + if (baseStyle === 'stepped-square') { + const tierCount = Math.max(3, node.baseTierCount ?? 3) + const tierHeight = height / tierCount + const stepSpread = node.baseStepSpread ?? 0.42 + return ( + + {Array.from({ length: tierCount }, (_, index) => { + const t = index / Math.max(1, tierCount - 1) + const widthScaleAt = Math.max(0.5, widthScale - t * stepSpread) + const depthScaleAt = Math.max(0.5, depthScale - t * stepSpread) + return ( + + ) + })} + + ) + } + + if (baseStyle === 'round-rings') { + const baseWidth = node.width * widthScale + const baseDepth = node.depth * depthScale + const plinthRatio = Math.min(0.7, Math.max(0.2, node.basePlinthHeightRatio ?? 0.44)) + const plinthHeight = height * plinthRatio + const roundedHeight = height - plinthHeight + const bandHeight = roundedHeight * 0.57 + const neckHeight = roundedHeight - bandHeight + const bandScale = node.baseRoundBandScale ?? 0.92 + const neckScale = node.baseNeckScale ?? 0.72 + return ( + + + + + + ) + } + + if (baseStyle === 'lotus' || baseStyle === 'ribbed-lotus') { + const ribCount = node.baseRibCount ?? (baseStyle === 'ribbed-lotus' ? 24 : 14) + const ribRadius = Math.max(0.01, node.width * 0.025) + const baseRadius = Math.max(node.radius * widthScale, node.width * widthScale * 0.5) + return ( + + + + {Array.from({ length: ribCount }, (_, index) => { + const angle = (index / ribCount) * Math.PI * 2 + return ( + + + + + ) + })} + + + ) + } + + if (baseStyle === 'panelled-pedestal') { + const inset = node.basePanelInset ?? 0.02 + return ( + + + {( + [ + [0, node.depth * widthScale * 0.51, 0], + [0, -node.depth * widthScale * 0.51, 0], + [node.width * widthScale * 0.51, 0, Math.PI / 2], + [-node.width * widthScale * 0.51, 0, Math.PI / 2], + ] satisfies [number, number, number][] + ).map(([x, z, rotation], index) => ( + + + + + ))} + + ) + } + + return +} + +function BaseCarvings({ node, height }: { node: ColumnNode; height: number }) { + const placement = node.carvingPlacement ?? 'capital' + const carvingLevel = node.baseCarvingLevel ?? 0 + if (carvingLevel <= 0 || height <= 0 || (placement !== 'base' && placement !== 'all')) { + return null + } + + const count = Math.max(8, carvingLevel * 8) + const radius = Math.max(node.radius * 1.04, Math.max(node.width, node.depth) * 0.5) + const y = height * 0.52 + + return ( + + {Array.from({ length: count }, (_, index) => { + const angle = (index / count) * Math.PI * 2 + return ( + + + + + ) + })} + + ) +} + +function Rings({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + if (node.ringCount <= 0 || shaftHeight <= 0) return null + + const ringPlacement = node.ringPlacement ?? 'ends' + const ringSpread = Math.min(0.45, Math.max(0.04, node.ringSpread ?? 0.16)) + const ringHeight = Math.min( + node.ringThickness ?? 0.055, + shaftHeight / Math.max(8, node.ringCount * 3), + ) + const rings = Array.from({ length: node.ringCount }, (_, index) => { + const pairIndex = Math.floor(index / 2) + const nearTop = index % 2 === 1 + const pairCount = Math.ceil(node.ringCount / 2) + const pairT = pairCount <= 1 ? 0 : pairIndex / (pairCount - 1) + const offset = Math.min(0.48, 0.06 + pairT * Math.max(0, ringSpread - 0.06)) + const oneSideT = + 0.06 + (index / Math.max(1, node.ringCount - 1)) * Math.max(0, ringSpread - 0.06) + const t = + ringPlacement === 'even' + ? (index + 1) / (node.ringCount + 1) + : ringPlacement === 'top' + ? 1 - Math.min(0.48, oneSideT) + : ringPlacement === 'bottom' + ? Math.min(0.48, oneSideT) + : nearTop + ? 1 - offset + : offset + return { + scale: Math.min(1.4, getShaftScaleAt(node, t) + 0.12), + y: shaftY + shaftHeight * t - ringHeight / 2, + } + }).sort((a, b) => a.y - b.y) + + return ( + + {rings.map((ring, index) => ( + + ))} + + ) +} + +function LatheBands({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const latheRingCount = Math.max( + node.latheRingCount ?? 0, + node.shaftDetail === 'lathe-turned' ? 8 : 0, + ) + if (latheRingCount <= 0 || shaftHeight <= 0) return null + + const placement = node.latheRingSpacing ?? 'ends' + const bandHeight = Math.min(0.04, shaftHeight / Math.max(12, latheRingCount * 3)) + const bands = Array.from({ length: latheRingCount }, (_, index) => { + const pairIndex = Math.floor(index / 2) + const nearTop = index % 2 === 1 + const offset = Math.min(0.48, 0.1 + pairIndex * 0.04) + const t = + placement === 'even' + ? (index + 1) / (latheRingCount + 1) + : placement === 'top' + ? 1 - Math.min(0.48, 0.08 + index * 0.04) + : placement === 'bottom' + ? Math.min(0.48, 0.08 + index * 0.04) + : nearTop + ? 1 - offset + : offset + return shaftY + shaftHeight * t - bandHeight / 2 + }).sort((a, b) => a - b) + + return ( + + {bands.map((y, index) => ( + + ))} + + ) +} + +function Flutes({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const fluteCount = Math.max(node.fluteCount, node.shaftDetail === 'fluted' ? 16 : 0) + if (fluteCount <= 0 || shaftHeight <= 0 || node.crossSection !== 'round') return null + + const fluteDepth = node.fluteDepth ?? 0.02 + const fluteWidth = node.fluteWidth ?? fluteDepth + const fluteRadius = Math.max(0.006, fluteWidth * 0.42) + const shaftRadius = node.radius * 0.74 + + return ( + + {Array.from({ length: fluteCount }, (_, index) => { + const angle = (index / fluteCount) * Math.PI * 2 + const x = Math.cos(angle) * shaftRadius + const z = Math.sin(angle) * shaftRadius + return ( + + + + + ) + })} + + ) +} + +function DravidianShaftPanels({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const panelCount = Math.max( + node.panelCount ?? 0, + node.style === 'dravidian-carved' || node.shaftDetail === 'panelled' ? 3 : 0, + ) + if (panelCount <= 0 || shaftHeight <= 0) return null + + const shaftWidth = node.width * 0.72 + const shaftDepth = node.depth * 0.72 + const panelHeight = Math.min(0.42, shaftHeight / Math.max(4, panelCount + 2)) + const panelWidth = node.width * 0.26 + const rail = Math.max(0.012, node.width * 0.028) + const reliefDepth = Math.max(0.012, node.panelInsetDepth ?? node.width * 0.025) + const rows = Array.from({ length: panelCount }, (_, index) => (index + 1) / (panelCount + 1)) + const panelShape = node.panelShape ?? 'rectangle' + + const PanelFace = ({ + position, + rotation = 0, + }: { + position: [number, number, number] + rotation?: number + }) => ( + + + + + + + + + + + + + + + + + + {panelShape === 'diamond' && ( + + + + + )} + {panelShape === 'arched' && ( + + + + + )} + + ) + + return ( + + {rows.map((t, rowIndex) => { + const y = shaftY + shaftHeight * t + return ( + + + + + + + ) + })} + + ) +} + +function SpiralRibs({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const spiralRibCount = node.spiralRibCount ?? 0 + const spiralTwist = node.spiralTwist ?? 0 + const shaftTaper = node.shaftTaper ?? 0 + const ribCountSetting = Math.max(spiralRibCount, node.shaftDetail === 'spiral' ? 12 : 0) + if (ribCountSetting <= 0 || spiralTwist === 0 || shaftHeight <= 0) return null + + const ribCount = Math.min(ribCountSetting, 24) + const stepCount = 28 + const ribDistance = node.radius * 0.78 + const ribWidth = Math.max(0.012, node.radius * 0.06) + const segmentHeight = (shaftHeight / stepCount) * 1.18 + const lean = spiralTwist > 0 ? -0.55 : 0.55 + + return ( + + {Array.from({ length: ribCount * stepCount }, (_, index) => { + const ribIndex = index % ribCount + const stepIndex = Math.floor(index / ribCount) + const t = (stepIndex + 0.5) / stepCount + const angle = (ribIndex / ribCount) * Math.PI * 2 + t * spiralTwist * Math.PI * 2 + const taperScale = 1 - Math.min(shaftTaper, 0.85) * t + return ( + + + + + ) + })} + + ) +} + +function LowerCarvedBand({ + node, + shaftY, + shaftHeight, +}: { + node: ColumnNode + shaftY: number + shaftHeight: number +}) { + const placement = node.carvingPlacement ?? 'capital' + if ( + !node.lowerBandEnabled || + shaftHeight <= 0 || + (placement !== 'shaft' && placement !== 'all') + ) { + return null + } + + const bandHeight = Math.min(node.lowerBandHeight ?? 0.24, shaftHeight * 0.35) + const y = shaftY + shaftHeight * 0.12 + const level = Math.max(1, node.lowerBandCarvingLevel ?? 1) + const count = Math.max(6, level * 6) + const distance = Math.max(node.radius * 0.82, Math.max(node.width, node.depth) * 0.36) + + return ( + + + {Array.from({ length: count }, (_, index) => { + const angle = (index / count) * Math.PI * 2 + return ( + + + + + ) + })} + + ) +} + +function CapitalCarvings({ + node, + capitalY, + capitalHeight, +}: { + node: ColumnNode + capitalY: number + capitalHeight: number +}) { + const placement = node.carvingPlacement ?? 'capital' + const carvingLevel = Math.max(node.carvingLevel ?? 0, node.capitalCarvingLevel ?? 0) + const bandSetting = node.capitalBandCount ?? 0 + if ( + (carvingLevel <= 0 && bandSetting <= 0) || + capitalHeight <= 0 || + (placement !== 'capital' && placement !== 'all') + ) { + return null + } + + const level = Math.min(Math.max(carvingLevel, bandSetting > 0 ? 1 : 0), 4) + const bandHeight = Math.min(0.035, capitalHeight / 8) + const bandCount = Math.min(bandSetting > 0 ? bandSetting : level + 1, 16) + const bands = Array.from({ length: bandCount }, (_, index) => { + const t = (index + 1) / (bandCount + 1) + return capitalY + capitalHeight * t - bandHeight / 2 + }) + + if (node.crossSection === 'square' || node.crossSection === 'rectangular') { + const dentilCount = Math.max(node.dentilCount ?? 0, level * 4, 4) + const dentilHeight = Math.min(0.08, capitalHeight * 0.28) + const dentilDepth = Math.min(0.08, Math.min(node.width, node.depth) * 0.16) + const dentilWidth = Math.max(0.025, node.width / (dentilCount * 1.75)) + const halfWidth = node.width * 0.56 + const halfDepth = node.depth * 0.56 + const y = capitalY + capitalHeight * 0.28 + const xPositions = Array.from({ length: dentilCount }, (_, index) => { + const t = dentilCount === 1 ? 0.5 : index / (dentilCount - 1) + return -halfWidth + t * halfWidth * 2 + }) + const zPositions = Array.from({ length: dentilCount }, (_, index) => { + const t = dentilCount === 1 ? 0.5 : index / (dentilCount - 1) + return -halfDepth + t * halfDepth * 2 + }) + + return ( + + {bands.map((bandY, index) => ( + + ))} + {xPositions.map((x, index) => ( + + + + + + + + + + + ))} + {zPositions.map((z, index) => ( + + + + + + + + + + + ))} + + ) + } + + const beadCount = Math.max(node.beadCount ?? 0, 8, level * 8) + const beadRadius = Math.max(0.012, Math.min(0.03, node.radius * 0.12)) + const beadDistance = node.radius * 1.24 + const beadY = capitalY + capitalHeight * 0.24 + + return ( + + {bands.map((bandY, index) => ( + + ))} + {Array.from({ length: beadCount }, (_, index) => { + const angle = (index / beadCount) * Math.PI * 2 + return ( + + + + + ) + })} + + ) +} + +function Volutes({ + node, + capitalY, + capitalHeight, +}: { + node: ColumnNode + capitalY: number + capitalHeight: number +}) { + if (!['volute', 'ionic-volute'].includes(node.capitalStyle ?? 'simple') || capitalHeight <= 0) + return null + + const y = capitalY + capitalHeight * 0.62 + const radius = node.voluteSize ?? Math.min(0.085, Math.max(0.04, node.width * 0.12)) + const x = node.width * 0.46 + const z = node.depth * 0.7 + const maxVolutes = Math.max(0, Math.min(node.voluteCount ?? 4, 8)) + const volutes = [ + { + position: [x, y, z] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + { + position: [-x, y, z] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + { + position: [x, y, -z] as [number, number, number], + rotation: [0, Math.PI, 0] as [number, number, number], + }, + { + position: [-x, y, -z] as [number, number, number], + rotation: [0, Math.PI, 0] as [number, number, number], + }, + { + position: [z, y, x] as [number, number, number], + rotation: [0, Math.PI / 2, 0] as [number, number, number], + }, + { + position: [z, y, -x] as [number, number, number], + rotation: [0, Math.PI / 2, 0] as [number, number, number], + }, + { + position: [-z, y, x] as [number, number, number], + rotation: [0, -Math.PI / 2, 0] as [number, number, number], + }, + { + position: [-z, y, -x] as [number, number, number], + rotation: [0, -Math.PI / 2, 0] as [number, number, number], + }, + ].slice(0, maxVolutes) + + return ( + + {volutes.map((volute, index) => ( + + + + + ))} + + ) +} + +function LeafCarvings({ + node, + capitalY, + capitalHeight, +}: { + node: ColumnNode + capitalY: number + capitalHeight: number +}) { + if ( + !['leaf-carved', 'corinthian-leaf'].includes(node.capitalStyle ?? 'simple') || + capitalHeight <= 0 + ) { + return null + } + + const leafCount = node.leafCount ?? (node.crossSection === 'round' ? 18 : 12) + const distance = Math.max(node.radius * 1.05, Math.max(node.width, node.depth) * 0.48) + const rowCount = Math.max(0, Math.min(node.leafRows ?? 2, 4)) + const rows = Array.from({ length: rowCount }, (_, index) => ({ + y: capitalY + capitalHeight * (0.3 + index * 0.16), + scale: 0.28 - index * 0.04, + offset: index % 2 === 0 ? 0 : Math.PI / leafCount, + })) + + return ( + + {rows.flatMap((row, rowIndex) => + Array.from({ length: leafCount }, (_, index) => { + const angle = (index / leafCount) * Math.PI * 2 + row.offset + return ( + + + + + ) + }), + )} + + ) +} + +function Capital({ node, y, height }: { node: ColumnNode; y: number; height: number }) { + if (height <= 0) return null + + const capitalStyle = node.capitalStyle ?? 'simple' + if (capitalStyle === 'none') return null + + if (capitalStyle === 'south-indian-bracket' || capitalStyle === 'wood-bracket') { + const tierCount = Math.max(1, node.bracketTierCount ?? 3) + const tierHeight = height / tierCount + const bracketDepth = node.bracketDepth ?? 0.35 + return ( + + {Array.from({ length: tierCount }, (_, index) => { + const t = index / Math.max(1, tierCount - 1) + const scale = (node.capitalWidthScale ?? 1.6) + t * 0.32 + return ( + + ) + })} + {Array.from({ length: node.pendantCount ?? 0 }, (_, index) => { + const count = Math.max(1, node.pendantCount ?? 0) + const angle = (index / count) * Math.PI * 2 + const distance = Math.max(node.width, node.depth) * 0.56 + return ( + + + + + ) + })} + + ) + } + + if (capitalStyle === 'rounded' || capitalStyle === 'doric') { + const topWidth = node.width * (node.capitalWidthScale ?? 1.34) + const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.34) + return ( + + + + + + ) + } + + if (capitalStyle === 'stepped') { + const widthScale = node.capitalWidthScale ?? 1.46 + const depthScale = node.capitalDepthScale ?? widthScale + const tierCount = Math.max(3, node.capitalTierCount ?? 3) + const tierHeight = height / tierCount + const stepSpread = node.capitalStepSpread ?? 0.42 + + return ( + + {Array.from({ length: tierCount }, (_, index) => { + const t = index / Math.max(1, tierCount - 1) + const widthScaleAt = Math.max(0.5, widthScale - (1 - t) * stepSpread) + const depthScaleAt = Math.max(0.5, depthScale - (1 - t) * stepSpread) + return ( + + ) + })} + + ) + } + + if ( + capitalStyle === 'volute' || + capitalStyle === 'ionic-volute' || + capitalStyle === 'leaf-carved' || + capitalStyle === 'corinthian-leaf' + ) { + const topWidth = node.width * (node.capitalWidthScale ?? 1.46) + const topDepth = node.depth * (node.capitalDepthScale ?? node.capitalWidthScale ?? 1.46) + + return ( + + + + + + + + ) + } + + const widthScale = node.capitalWidthScale ?? (capitalStyle === 'simple-slab' ? 1.28 : 1.18) + const depthScale = node.capitalDepthScale ?? widthScale + + if (node.crossSection === 'square' || node.crossSection === 'rectangular') { + return ( + + ) + } + + return ( + + ) +} + +export const ColumnRenderer = ({ node }: { node: ColumnNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'column') + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const material = useMemo( + () => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset }), + [ + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.materialPreset, + ], + ) + + useRegistry(node.id, node.type, ref) + + const shaftLayout = useMemo(() => { + const baseHeight = node.baseStyle === 'none' ? 0 : Math.min(node.baseHeight, node.height * 0.4) + const capitalHeight = + node.capitalStyle === 'none' ? 0 : Math.min(node.capitalHeight, node.height * 0.4) + const shaftHeight = Math.max(0.1, node.height - baseHeight - capitalHeight) + return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } + }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) + + return ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 80eb5b6a..c4b6db97 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -3,6 +3,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' +import { ColumnRenderer } from './column/column-renderer' import { DoorRenderer } from './door/door-renderer' import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' @@ -30,6 +31,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'site' && } {node.type === 'building' && } {node.type === 'ceiling' && } + {node.type === 'column' && } {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index f51c4dd5..9f7adadf 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type ColumnNode, emitter, type ItemNode, type LevelNode, @@ -32,6 +33,7 @@ type SelectableNodeType = | 'fence' | 'window' | 'door' + | 'column' | 'item' | 'slab' | 'ceiling' @@ -132,6 +134,11 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = return pointInPolygonWithTolerance(item.position[0], item.position[2], zone.polygon) } + if (node.type === 'column') { + const column = node as ColumnNode + return pointInPolygonWithTolerance(column.position[0], column.position[2], zone.polygon) + } + if (node.type === 'wall') { const wall = node as WallNode const startIn = pointInPolygonWithTolerance(wall.start[0], wall.start[1], zone.polygon) @@ -227,9 +234,20 @@ const getStrategy = (): SelectionStrategy | null => { } } - // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors) + // Zone selected -> can select/hover contents (walls, items, columns, slabs, ceilings, roofs, windows, doors) return { - types: ['wall', 'fence', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'], + types: [ + 'wall', + 'fence', + 'item', + 'column', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'window', + 'door', + ], handleClick: (node, nativeEvent) => { let nodeToSelect = node if (node.type === 'roof-segment' && node.parentId) { @@ -258,6 +276,7 @@ const getStrategy = (): SelectionStrategy | null => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', @@ -318,6 +337,7 @@ export const SelectionManager = () => { 'wall', 'fence', 'item', + 'column', 'slab', 'ceiling', 'roof', diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index d0b39b53..18487347 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -3,6 +3,8 @@ import { type BuildingNode, type CeilingEvent, type CeilingNode, + type ColumnEvent, + type ColumnNode, type DoorEvent, type DoorNode, type EventSuffix, @@ -48,6 +50,7 @@ type NodeConfig = { slab: { node: SlabNode; event: SlabEvent } spawn: { node: SpawnNode; event: SpawnEvent } ceiling: { node: CeilingNode; event: CeilingEvent } + column: { node: ColumnNode; event: ColumnEvent } roof: { node: RoofNode; event: RoofEvent } 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } stair: { node: StairNode; event: StairEvent }