4SfdjS*TJcoH_wJnqtJeHmdQsZogY^ADwDVCkmDT{1 zF08+M|6birj4Z>v``m$RP$g*6c;}HI{SNY9U4 xkaph)hy;3mbHcO;AO{2E#`;>#>Q5S1 zR6_0(hFX&60P)Lo)a#|*oRljtFQo9%54_SmhRk)bVjk~?ux_W6c*J?>XeHF&sn!5l z-ud+D6XFHGadRau8K4EUO$M8EKS$FUHkcAX0HQX8=@vi(^;27tGAA3GDQst>Q4b}e zxlW&kn#2uCp`)V!a39X18aPUvTd|Bhw0rmLp>tog0)z%V&UN#huAW|%z{5n2h8re7 zmY7LGeV^%jp*7| yD&jy*v=u8Y?hDiC9*W*n_;TawZ`8+<|^S`kE*@!nL!P1m|C#R<0W8fdv6yyiby z&GW7jp9Jg8kKci5>HoC*JpEmLy^L%CJU87}gl$IyqL{#ZHJ9jiBN|?p4d{RWP+4|K zk-xIF1Xfz_sN$JwR{N~R_I8!GV%U(A`V;wS3iJNzw}?dgoAiaKFT%XKm6YPLe1O6c z&Hua_lj6r|jZ+bk6=VY{D=*^fV01ys424l#r-H1TJni@S(IoB?Xl4-v-|Gt#T#9#Y zx1J)uxVTveJC^B{Yb7XH%Lrq{ b`*t{x0B z-~Ju?+AzArn*0=0upj*9OBegq@6)39Ab !PVLd!3I9Ihk(ugDVfb4U23iTHMawLe*J z*vZk6+57R+r!>^mEiW(awYOhHB`qN-*~L}adG~GW`iYY#>z|!Hef)R@JR`{Cusx~3 z4y>seP7CTLL+7pcy?gfv7NoFn>o33Tpc6d9$Cq2XyUAfdsWSFbj52W~ +`TJ_n@5ex-rJ232Mdjn zEGi!S+aA$F0(QyXqd!stY6%qK`r6neU_(O=Yy0eD=S~@f&IXcP^j>9UWn-h9fJA+) zw!aAvuzY6m?j|`kP)>&rA0BH-id(S4N*fQz%LwU7&O&kaqC~RhB?q4tP8 LO+FO{;dosfNfH0={f#2;#UN`sB%J)Amz@iGB*K3V_bn z;o;%QNllb9CbI(vavnac2bBd|ro6m7e8hST1X%_{lj)eUXyW%(9)3nMsW_P1xe(>-i|Nb2yyHGhjM@Il6OG`RB zF{~gpfE>aPQ1?JWSV3|JB*Ljf+17GuLNaw9?a9DWQqdWdAasB0(!c_Uj=-f!=*1^J zRY1fy7~&aGiGE*Nf=MCx9pq6tGA{kv{kA@W+rs(GLXFztQ}dz(%2LcD+9k-Qt4>B3 zT9^&)rRa2iQRupoo0gVFxZ)*WqIM={_<8eYX_;qmJ9D@vDbydbNmK4WLJuLH> 7(Af5We(f;^ z@h)cmGWE_(wk6O~X06E~g;1(w!#flFY0DMR`-1achNcrd2&8~&6HTv@^qVUGC413x zt6nIs6x1LRj4x#${P%qGqV2W>*Yl$$pGZDjl>CNR4Ip6B!NG<}_B`MV#+o1Dh45`a zsj~hR0o{)5i4ZFfeL<&Dh(e(tmLoVIGN%f*c&5eJ7 z_LI_JNF{m*BnU`P2r-nRFkGX!IDB{lTs>akXFe0JkHwC+7(m6|QLRD~74KRWj&)yI zJx8omNF#_L$H8wGb_t=oCmn@23WBZ%@(I%kaY%?Ds$r=fXz32ZUiT6=IDF_3Ex_TA zm2@$4y2D)Mq}QKwH-YfX0KJ|O5SVJu=*I?NO>?9MN2Z-|)!7L5IpZbRD__4n;Pd8< z=t`(TZqr~#v|6ZLE*)*_`;OM<>O(K(+7h#GK27uv4R%(@HoGM%M7?`StnCmq2lMcIbsf&XkBHEDAg z8}gvuKBFdob;nreFR6;eW^leTf07mv%)n0IJv1gAEm`gc%dZ~^@(=Apr3;-V@aFX7 zm^>YE-P |U zO5!(l5{uWX6LDR}gDdAt8R>t2L9|->(FqyvO8^c~&QT!k0Ev*Yq3)tW`>DwyNK>!6Uh%YM9Y?HPRU>t9piq{4lqZIYLd-wgjchyfy;6HYUsDRc*{xvW8RIj1Y z(Lx|*6#du<6DfH;kIVYj;j=R)eB&_x(b3bpgDQhW4Th+yWdv`kP2z&$t{xJ0Q6ZTI z4jwe=bWnHJ=AkXJ89C~C4|0s$b%4KBX$Npi!0L7N^$&e~SZ5t=|HJ|Pa0#(_er=4Y zfu-Hs+neKh;qj-7v){g5l$Ornt(}_=YUJ;AIp+CoVWAZAL`q4zk;-w$M8NUvXU`6; zkfb6cK9>^r4Y#IAso+zuOG{1+|AtUzbaSA8t5k%|5ydm&X6JaaA3!ro67I2vHiXjo z^3|*BXmvQ@35^hyU#{Q%1;k>l`RO1?IhQY9JhguZiUNpN&SNQg&F|H#$L|AYAZ`(Y zB~mD1QAZL-=a1+;!q6CFBx0>NQvmHHJjlZ6nSveZxvWT 7g9fQ4G|a`0HVx5 zU?Y~7m*UFRt2=-FmHG5+=8e}s^GbzpmXwePeM_{WWM`v)wK4OMxGcetHKjK vtC?)s=Zhg$t^z}e_1O*24 z!1Wp{h%+UJwS$OCpNU?+5m5_KPF+%_KRq4=N9`LlXc)^I@iEf!@YTnPUGS1u=zsCT z^%?J^23jl5SVgH0KrfIk&z|D;EsrWGgDMzwc(*A0{a3fb?pXcSL|SP1Ycw>-y&KOB zPZ Y zGmK ~O6{Xxwx6@NH1 z1S?~iY_tSuOk{PfEPY=+BU*a`2=!=1H#or7ci^->l(Bc+-6f#ZkjGJ6%h9-^3JwVg zL0p2ugA4%qhX}iFZl#~H^zj&njwv@X{y!4q|26gFgl+o&U|zwUYVSXdDTz*9O(o9u z; H1Pp;Oln|D)+!YKlOjv-+P* zdj)r0k+Hm^fG(BnFA#scDX>qWn*x0wNT-W1pBAdit{@(LFyv9PXSKOza*kv<%h{#; z*W`vzoA2KNFU#!R2+@kLo=*T2$y4b;-i;-U !U~CbafTsyuwjYXw`p5PYTAVkv^K%wrf&XuPRZ+zyk5( z4#B+OH^EE7Nmh#X&BG-ZuU>^gSs&ezFvS8BIM02}Aee($+=`=&0@Q(873&YnBy(06=nJ`<(ErE2ekB~nOF>XMZ~wHpxiT)?>9Q|4cq&m9 zjzc#(_XKK0zW~%yItQ^EEt5JO#mZW)Ez4v9Mo55wO0k2UckbAYHeVvMf}MqzO<1^x zX9#YwT)EhBarg~#O*{HNf75b|huO2E)93ql2UwtMD)AhvZWE+ILD|dZW`(Rqmhno6 zNuHEgNA9jty9qUhieRpr&Fs08KJV$ArHCeFBfUA&zFCxr`U1#BG(tx)!H3C9>kC{1 zuHN3>Uc{=;Q*{9SIA?$iDk>__G7Mn^+M?@RgCt-Wn!ZrOamPZ*61@kxYG`0!$3Z@P z{MeZD%s>_J_43kEK~WLv!J3O9hL*OrBn+gU2j51o k3ai5Oo4q!{@0f zdP>S~aA7}(_bM{-Gx$c>GuN@fOh@11o{)gNDF2>ys2lB0*Y=>)!Egq(W%HP0xC$f} z?BlH-uWeRPCUAJ(!&%O2FXa4TguT6wuwlj`{e($`pRsz=-QC^WyMc;<#9_g`=bKYm za|p^6<3_;!JFu`uH{_dj@iA01$%wG`K7Pvg^5x4)kv|Li7z#Q;6*4Z@Wt%KD7Ac zQ>g!7-%`2rpNr-h{dJ;fW{`Ti-_p|ZQQp<&G%YPM22KQ>O0Cb*@W>E7IfD@prn7U% z(Zjl1R1n@o!b;JFtZyOtJCy!CqB{ 58ji$}kl03)=ImYQCj1DWg!)1Ks`u~X zaI*DEQNhmikvXnacJsi3iBmY)Q=%@tzv`uj)NdD;C&`IJcRLYwTOLByg!0bj2~ h5Sc7jW>F;q>4JM+TVn$a$__VvSRDz zxlwUNoH1|`hT7U+h#@M!G64ER#(J@h-X$FzslGpN0S$2lmJbUJ_D~T1JV^f%67PT> zuWM*XJfw3)|5H}5r{^yW6-2i>COl;7U77b@Ls_F7YrHg;cX|C &pJP=>S4p4g|JR<( =C!_BIurWaBiTbej6H z2&3Vbc#1*(5c5x92?G1LjAjq84i5k}$CEXCW{%Jwiu|)OQwvFiY_Gc =HK zw|D1TU3kdn4F^qRWF*nSgkWav9>SjeVycu_q)qXYzG8%3Wd+y8YtiF^!a(#MyeEQd zb4}RN8>n^kY4_36vJiZuBiKd+L}TMo!s1(dy1w$RWcvgH2jV(ZjRnHwg)uNVLrmV> z@TO*+5| i4!*yW>xzAow(b4rF7sCNRhglj(u(+I=Dt~kmwm? zd2yHSF&B*D(C2SiTQ9>B*|JMtU!TvYQA{>~Fwu0YAhEx=ur14)4#@&c z=&ua0U>qqYvgGI}ixy^5XpwR0RZ}g+xjgn-HoX|~f>aTOK;`Ag5 Trk4lxt*rYv3x9C~hzaO5cPYAbISj;kdKW_R-iQeVgYnaFn@){3} zj&|aKLy%%q&3su~xxThSm=wWq4Yus3q9TqX %+dlOy7VEu3k&CJ?C zoVueu5T)5C@a*s38vv3aF{kJn 3 ze9;HbTE8qSE91CMMnMti_zRRC`2Q4QNk@Ov1XXm2j7&Z~28sNszh1vYtiz@o#6tX# zR6kw#v)g0S-;DSw*Lg(x{onXC{x^Dh{(qVJb?tW;P*}aKA^nY-lMz?*neMlLmxKR( z^4Ow9)E7hmv}78>O9mSono2R?VPUG*VqZVn8JR9)zoXw3dEirbH=IZN4<9yD4gFit z%2xjK&p%@z5q38?Fq~yT0PL96<*~G<0Rh3{82zCK{{$X9K68D29Y|RX=JD4z(%DY` zxBSUFul&1?NikMVy8l=1T&+8ZhC<3W5NDpIZ$I|%u)t7%|4lxsKV>#jYcwShAJ}GK z?>$WcQztmUj6+=n2u;?6OM#(+h>k~zfJAGzG@}RmX3fuF6m*nsxVRMSVJZ~3J^7n= zb{vo!O4WkrSk(w!4fZxRJichdm}pLTLfHpei`wrjX@e||P*XtksjsSnC%lI6ilPZr zv-k`=FBqQ#eJL(3E^LbhI`0tE!5>xE*1Etc ;_#cUPA)#up)PMC(l;ED*uh$;k J#0 zfg%7*@i<}!p&bK@@ZfBKLId@()q_DmU 3^0Z+CIj7cgR^w%(U7kK5j~va&+e z;7JIViu~2A|7KJDXX)xr%3sn|lkbf89YnrFb#UHL;F&znxUJ?FS^?pR-jfkts&Jx5 zwlZT7!)GHerbwzietLGiGv`WorsJ$WgV>E5H-L2ttx#=PhyQ7j_{}HOTKvLasH%mr z)@cB>o);ws%^9NPX!b?^nweWiABz6;4}_OV$x!iE(~{|p8<9pT&_1Qhr3lC2Kdh@# zoE(Tg)S{RMz{|+^_A))@m3dOaqF{CFR@NSU9!MIWU|Z`2XM0XSfYm#SsT#7kCnXwr z6v}nq11ygA^+lN<5_GVHfSgRVlUUE&%;>cEjrUR9T&(KW1fm=yt5H7*@4#Qs4cKF* z9@YUe2K0G}EDh|Pj$joVASdZCdYkc`rhpJn;B-;QlM3M;1oHhpSu}-8j}Gx0Ewi3p zUKI4gpT<3qe5grNYn ;qONTL9j@D~8^m~%{~3qLdZ6zfJvC76(dDb_bk{4{34tK?U|nS% zTvpQj1P_kI<@FiwDJi1tLmLTSNrVpaI<`e?G^r8v_KK+VB_6Qrtll~c-zAF5lO ppfVjli0DlaJd;C~VMa5x# zbqOsU@Z_Q`zM!x$Q5!@ePy$Rd<4tigh|QXsL=)OWdJ#Hm^aj=JJ8-NmP~-2Q;Wp!7 z+EX`fmzBB0%?DKzYhQp(NEE9u%zn{_{(#MHx7e@qNc0{GSE4x>#z_2DRw4W}M9;SX znUwE=(?hW25z0slJI*SS&6DL}pdOs>Ft}Bx?PYnQW07G zRu0^bahfCj{TQwnj9GR;2S|N-zlBhR(dAf{VfY~o87Q_#X}yU@wds}j%G{p~%h#y? zXJaSQ_5R;7dX;puf7e6(-yBa%^f#bH0&(c=-N;xQdy(`7k(_Tqv|5m(GDqGj#KFLp zkq{G8;I56Wq>Tc42P=Y+qAJk7l8OlD-KI?nmmx5FQeqE*Fd_;BxSC-xVLhNPuPgm0 zmc3@8u#tjcAh3iX>;mMJVjvvG*#z#$V%|ggl&^$=@7o5iY5O;nd`?~+RHS{nX-@7K zypp6>PWf7xVLreR3VIM2aNof) Xk(M7lqQn$Xcq$gZHr&KX z!(<_2NPh3Usaav0C){zPqqLNir@-4_<`wstG`czN-GhXfs+`t?R#S6~olq1awFG}o zP1@~qzQ+~W52?Glx*8-ThF^AM8HAZ%x_Hq6#1!1dwMW}h)cJ|2engFr?u)y6?7%Is zFNTi oD7^{i>dR<8_!tDKU!| z?u`6`f(`|*;#zIEm=K@>AXay9BQRnDD3Bni(Sm9GvLlJC`?H? 7oZ {|3EtOYEH? m zlB;0d0Di|1H%TaOBx@=`RQo7UiLY_~*s_hTp@xr$i5eL23n%w6cJ>L3@4#$==Yo!z zM!%6*I&eO@au)$yDRq$eaOTV~6vOGsTX>%9j&A<>XHr)YSGNk-{%6lLI>LWk=IKvu z_qPWAbCU(2bjt+Dk{_c4ZK4k>1;Nla@-D({c?7#o-~S&Dg++Nx-ys|dPBtXicYD-6 z;sh;WE`pER)6)~Y3~a3^AY`7M$-vWPVuJNrkpj~==H};1U Ii{&diu2=b(5+ z`znkhmHP`)2Vkqi6kYg7;H(n?af(5}?@<3IH=+|3%x&Dumx93807tA$OyAd6rnDRf zsm)_aH`PqLIMUC)GCyWea0rvQfC&paI=7>?BLwx4@?$SSF-Si 5A)zV@J$ICS_h``BSYhi}Bb#2ij% zC#U7_-*K??VRF=yAg#3}XQ52mpCL{0i*U)G)xN@(|G_Qn(C9x8563ZG1~I5e1yBfn z0WdLSY9AF9(+N=e%G750^>3l8?VLdifL1rVC`BN|sELVgK;R$DhGlPp+5J0-#7slb zkU|TXdx|RGl@Xo=)WuSvJPDB22p-Pf9@04$a{+jf-2>7bOg6o+2ViCZP?j&fh%nkh z6IoMI(Pu$*4Fly z0VRQV96}OU6cn2^l;by4jci*?>|-< EsU6}u^jLTU0CZ=d%aH~}$v z PFALg~b$xQc7Wj5&pGPvOIRkC|O~J%e={-7~%nPg-GP!Ryzd< zmMT&%QJ_g1p5Mjk=?qh1IB)ZQf_5TYk|Wy&Ejm$IaCbAg(?t;O7$ZzB;rffEN}hu~ zJ;ziV%nk-0MKK|@E1lEnrKXsvt$zKQft_929Pqa=I{2=KTcZCA9jOT45U2mSJ6;pA z5Sk+ll{&GN(eXv+@cDNvVQ2TtndIw5N@QGyACEXjF?v4Qr9qIJsxLnsmFcoc%Pt%( zArDk~{J+}!?x3o!wqJ} gaDeZ(jwyb5-@M< |sjbzF4-ZpCu=iuNtY!XBGRcyaV&v%8(0mTnk{X9r{wp7@C zuBc8^&z+hoSr@>{?>*7QWzbJ9`*2+Djmiy`@TiITT0(}5lRa3%Al32~l-9k)zx%{R zX8#+oReEk!@s-u4+PQfZJ48iaxtn}?6x|M@9=JA+ENQQ2&t_+6v(#j-4u2WIu_Q$v z&Hy*{`WR)d&s{d;(lT@3=AKeKTP#HNQZFqGRjx>tlRas^!H$z0j&JkD9o)&nu@O zbLHpRW>hg8AJ9p&FJ3Ig-rrVO$ngPiy?IP2+YGp6XWP3RYoUTgIg6M{DA=p#E|I-s zFT?Fm1dF~LsN9hcf)G~Q+ySyEwe%A !92udT?XCNvP+m zl9C7Nj=$q{$;a5pR>R7Vq^F;JKHH*ND<}eaSTn5Vwx+ Eh#S@}EJhiG|22fV#!a3ZB{xcu}mhXS@RZAI+t4z*fTE`N=h zy8X4NK&vTTTcJ~Htl2VU{xBdw4cnU?HJ ZBaIL9!s#6QGAPN_O)GA4h79xZcN%B)*Lf{$VgJ+~6m%gIL(e1m( zMULC%8VTd{N{oNo7wx6KZO4wbX{8vt_Fj~QTEgnxyNxp*IC$NV3^-vhB@)mKc;nqy zdg2tu1Dut;A?r1nF(QG2o8PzLT!pswqM$<0Fh#0OO= Q5d?FL`&GfE%#E_;VoU|_l9(W)qDS6}A)k9|< zGl5!w;VL36S#}2d_g@Oxd+Otxr!dmuy~VDl{~`IBU@IC)0{~Vyw#u*cf)IsgaM!Lg zwpqi#uFB)gWf79BbqMEFqDM?lHME+Mn%t%5_&bI9wTu!Vy9ur}ypM<^YlkS+j2}Bj zN6_=#Q@DterA&qtd#Bg0jMCR6A$H}8oM;t;uU$jh26{F?Xrss#vun-MW4}>3%eHXg zHk`!Bodtq~)US08@5P4_mP0rJt5yMCVM2Pk_lr&B_M;TNe}3d+)o#|M2$kjG3C9Y# zjY^dVH!yMK?t5O>ZI!CovB|#wx{bstRHSmUve3>Z)ufbbl2`2~ zt&>CyiOu|`+w3Sgx^9s=x}~}mbg4){HezL@MC{Os#2x`;3Mq@3xiPqj!wtVoFl77H zN)jcizL2EtNC+cX(~dl!FD;3~90!&8929H_rD`h|Z78z}992q8G&T?k9?*||8gF-* zTUt#xd>K1{*buX@Q!isflRTpDKKtWW%>}c~|AHh; |!l$oNeKwW+N0zYm7V03_ z@B0DKfrnVQcrgUwlX$dAL60Fkz}?OTrA4HbjhR!Z#!-{+u09Qp95f~JYwn{bZEbBu zNekT $fe45|Tu@9Cob0Qw;{6%`W1jB{!ZLJ=S$1H^#{+9ar#%!=Vp zXXA(1RH!~VFS`#%@8@A|PB9J5ck&4t&=3E~VnTXhwo^-!YC4or?RurDu?_9kB7XSY zj^&@qk7XjI{JuwPMiTrp-vtS202o-!!!w0$JY@C3g9o8X1CZyo4}DOIP<}e}DMR zn 3i^5v#-4|C4XlYR+e z4XNBnvayxBl0Xj3kA8d3YQrJ`5}`HQ!2}jO=j^UR{3Z+eN#0FI`g|M5eD2yW-FK;G zW@gE{Y0<#eA#6+MEoH99;i6 =3UB+Or$+ z-Ar9gj_;pvt;TOK#y3H!7`|5AXU|NwWQiB}H*WWTl{ZKWRB`a~rfj&3Gk)WdEs9L! zge4z8^tX?h_s3J;K%(lJn74q%dg1S2@xMtM{a-}N|CtEKytn^v4!avBDb9+AF#XGw ztM3nu`eorh5V6r4&^M(<(@&_S%lAw(7%LP@D=PAH{i#CO-d89>Qzk0@ ZzQOxe;SKDu+H`a}l%FV-L3@}UTci5n nhkXWu)x%Gu)ADiA_ z*~tY+ff)4(paoP*OL;DYC6ECCicE|dU4)~rWgh5Nlz<4QA_0NU**J@LS`dB_p9Z@y z% (Gd@~_>1Dc)fd+Uyk?GY!_x8MpT zFuuMu<=e=Fi`G`Bou%{Kg5yqHevO_GqcU)6xx{JVLAt1dt%1-3E-ZMOoxv>-eWkOL zlVQ5KDo`8e>~da|OPjg n8QF2YC zrTfR8iO}q~q^?&xgrINOo+2@z4{Y^UEKW`f4oeb`1(zD?t1R;hZvGd5*nsvJxA=d@ z;(=JJs>2dQD*5y-cY!Iwd@;tT8?YSl#pO?B>cpRyp>2d5Er^*gR@=gXtB|V=g2x<{ zGRRg!-{O;1vQ3a#hd)vclR`0w#7!PxWhm#%MSlKUk&=q(-{wlbtLlb&wAy)fa? 5AZ-kBO^d+AUl49RZedN5Xm?i39wx+>+4-0o`B=W zEF~k()FQL`tsjyywsj%l`!`%mRs}=3)~T~zkBCUxK;VFLMDah+kRAtLJJ=|UQSlt= zR$YD1>oprc=)HeQs<4y_E3yoo4 s|B#eecL$j|sF5t$EYB==pgw}|N zfl_<)a#dm_#0kPPC;NR9m}*L6pc2mID5x>| e5Z%yDUpxJKW +=*0n478Bk%bJFn2h|TinS>i< zwSa4Y09n(DdFP2iVf8ws2Q@CWVpLwS#^gY(zgOPZOfs#5uT*XALAxpL%T$5-6XegY zMv@&dg1D_?9;p8{JKNjuZh*z`K h4VM^ ZsQOuiVC20f3#AxPyppfR7D&E3iz+c+ew8+^ZbmD2Fk}0}?W1I3m9n(D=jN!n>bI z2zCE#bnvX^$l##ff0wC&IYq!^MkZIF@g;Z+kdiw0@jeJ@FzpN-Fwcq=_cvT_pgigl zp8Pvo#2QhaaEqF}dSeJcK+*|d P2l+uJ_hmqYROKN z(3m8K1Pe=K#mdG}b6#7=#c)yKw`3(vmFAH&{k2_J=wo9LbGt&|hTX~l;}7sI;$Qjz zoi=}3*OR9r1sw%OY5s{{hHQqFz$^B8W89e#W6Ne6tzkJJnM?aG+%GHpXhq?z_}af? zyoxSF@`d`{r&Ucf^uyu?`>X19znxrBJEL_c%`I?r_ghS!F!%*aBUWG)aFeKm%a^l5 zrgfL&15q9Ol=);LB@o+4!Y0s(BrUQ~Jk)y>azRdo3@kp3u28B4(#oj-v`}!nbJuM3 zE#@GrKcXiuLX()tBR#6{R-_84s!UznonA^9D3^^sej2k+Bfm+#6gv(xp~6Lk9BIBg z3=@4)v+)fs*7$ke$PHAu2$1R4)h~Ft6LZpokJYhhX_0;f!#32<&9mMwr4qiS!l~o; zm K)l?+LD?tp!p@xzh(@9gX>-Jg5^o7>aXUv-svd&bG_ zAyB~Yn5D9^1av2IcZ-t|9DR+_kbvN+4Zg0h=7dYz#M*-m2%5E&$8ZU&75$Av889gfcTy;8NR#!egVzxyhD1sZ(WFumMUcG}u?=mje#H#St6 zh@5 T%Y$Z)(qh{?_{S+JPr5m-D|c8RYv?F>!Z=_kUc3}#F1M@=_+hj zNnadj7%z&+tWNhB{3$qu>Jo2Bnkk`A?}{mb+35{&93UAN+rHWY;kRGW%+TFL9jVB? z{)c#@VtJsAf2Hs7H&|-(8m&nOGk*`eZw)3GSF^=S*~nfsrEGmulKP66ZYOflL0|Dv zXQ47%bvWYoXihf2TJ+f?S*dWr6;3we#y!~2sPXZZ5MF?2qH``(&Kj+s{PDqv4fR>~ z{<=b5XUflHFSCvE!YL#&bOL=hcl`SG+++?ah7T< dqKy*G5D}(iFt%^rDi=mS+9fBl$ #T!ncL$~OpS`k$|}if;T!5z59_HPJ)R%{i}iYNuvVyo z?RMT6I7}E2;8n_v8N!2Gw)jD2;pFQxi^2u1CfsWVuVwTm?lc{;6^OfR7>FL_&Z+nl z#7|8IRv^v<0+Gjxt*gU>f{t+lJoBN_sGR{ZOh<{%-v!ItE*L(nA>K*xtneP*A|Rd* zyCUAa*0afrSXnt{C!QYE($}{hJsZckl$qyT{>r*a!E{KoZcP8LN`h^}*yB~%uGbrt z3pvT`NW5Nb9G`LfYNOK!Dp&e`0hg2r2jTG8Gb6_Da_XICd7)cd7F=*_Hy-3<9n@>S z9Q5-(BEx;D*yb9;Eg}al+%Hc3&Y;2RPHDp0I84RZ`T1bSGIr3OxsF|zap1$`(HVmL zu0{NS=PV{2p8l?`)Tw3W;R8Xk%L%FdHVr>Gk5!v%%SiOMfsSVLOZdtFTi> 1-;x%pC{YV{L$@lUNb?p{m4 zdi{x2pW=!hbk)SnnrT=gfSw1lZL&O6I@35Tjo_vvO=<^!!eHVP?Ja98>5oFh$g3<+ z#xVLgAmP>b0>9)EK6zQw;>8oq t8wt!PQwZ=}Eto *{e~ zvVx&o?DR4BuPc|n+7?GH_*z8awqU12-3eH;Lb#&5{6+;2R0 fnJB6xy&=d7*bMS z4oJ8VATIe7iJ-sZ*{5Xhs+ta6X&enxp@3{Bwi4{|2lnrufa(=B_WQ0b0bfYp&n_mx zdWEaXEgCWW35Pf_(afY6m%cu5OJWzVq4^Ds9sc U*G-i3FZz9L*R!?g1#IOqh<1%n+<311*qc= z`f}jTXj7|OO~g( LGCMUrQk~ 0rgeUIAj z8!r$`rVKg(5mC`kZ| &WHMvo^r-PK z1W5N6O+O`eePz@!>)S(;RUE5VTXAl7?$b<{%v75zmjC8ay%U31V%_a5-R`+;nE_G7 z9ndJ1E!ascJMM`YV>+e}_jl9>6?a>Gt=%W}>&CSXYv5?-7vj^Ke8wiO%lG3t0?r&) zOAxv=A22`EuE!zwL1$tb`V=DHzh)vhbiGUi6$_`&Uu`|Xy{2!wDoU}yfQ;h&Mlbjy zB+59I5u> Jmb86A*$$or3H7l(&!Lva!}^TgFc&IdoB9E; z4chfzHph5{SofX_$&c+AI~@vP8|)qMV8 lVC>uV${>mb B!%^F+*dKy@Ia0aD>6v-zi zwpWO#brmkkz~mV8hjhW7t}f@^ zw`O?%w`Z}BMZkIYyu927MYfhv6{rspUyNS0uaNam#mC>+XfjV<)F>wdp>NR5t=vm5 z0 ZclJ}Xkdc_u6=*}_WMM9 z?@pJWx$!^380g+(!uKrSM0frxnEA(d|AARv`%VA#zkyQ5@BCvrh_lT7N$69*j#1K% z<)Ba~iihwzB)@o3Tr9jE(hsdrZf6H$RgLFy@hugJ#a0JRH?7A*UH*&IlZd13pju`8 zwt>O~jf(*c?I^oet$K$(5xOL!0?!6fqCy2Bh(+$=>^w3y#wYJN4%d_FTyiw&6(On4 zBF77?`Q>PEk~+I)nKwu4;-1~o<2WOrF}`voY2f@jq4n!yq49$HVLym-#Jub4H-b(b z#(|Cvr6ZOoOFa;ikI05WYy}A9f z9zCy8$>l*Arz`@1AA*s1!L@lU%lk8tZA zRRP2xc+K+kwtEYGe0 L4kw%8(ZNVjA$Gr_kDttlw z18Y`yPY j_sq4$ z^rjn;k<$oX_JI)rRR) muFSfXA~-8_%TqS0KP;~*XfZ18>X0>I2q*6z!fg4?!D z5X*?z=Cx~~N&>0a^7%FM77lAykX=_?@en=_ ph{|N^A zJP&)+{}_L9Yd+*Ym=$>b{CPxs;tLxKk-a7nzlfvUuYXKh$@xJ62t)Eb>rY7qowZcZ zhs1-hZ2f|CtRRAG-Xp_)2cR&)fcLXRIE)Gcw+U%+ItAyz9bS%x68O1O2e1d+Y~&NG z%KWYAP{KdWYG4|4uA1!N9xQuLFRy+6+(>8C>t1`D+a-mwF+A>v@_%b&g!c|&c2^G% zgakVfH_|#i#foaeyX&k#!BTANfjC3Vn!~5!hTA6VbSwovbl5#3Bzx6;)oGaa{yLhb zqpVr(e#iZ{?_|TbCwA#px3aB?i&t$MLO~LSIa&nNGc*J*i2gbQAym2HaZ?+s ~kM8b!5!N W9C3zqK;ZY*ZcPpho7)W(=nh>ecf;2 zsLCGL09AQVn*A)YW39AwZQXa$r?MnFla~WC?F&I=qrkcpGXr&XExXR9=Fu;6d rQIRS0f~EvhD1HlF zUEo$4C=kn6BR>YY9pVL#aaVtZ)L6-|mDuxEZIAUW9in~=!JmdHf{U({vW54@T 90DpMzh5X>EYwXWZXiiy(k^6A7DLV>`gpc1jQO4JqyUWi#4 zCl=e=vXYNG5~Q}MP5opeTPHQD&?ZEk_L#wOL(*IAejh$#8I_!?QZDsI!89K-?q##n zl%sCv^iecpD^*>GQHBY{KFcimmU}Hh({slNbL5~BXBMi-1}H2bHoD5VP=c4@e-8ev zC<~e$|MK_HR3jS^LUA00%M3~xXuy^$j89GijiUoPhs+3C0ANOv^t6|}%N3y4E@@f> zcKYi?xN{t^^k;t>0fg>L9+R+xZU$F;OnUH 7o!;^8FyTqoKPj6fMQXB748uXjxq?h z=-*f^f*CEX(6Ca5-|W5|jhQp`h!GPM#NpG{(lU+n(tKXu6h*^-_UAY9>nC|p7VIRG zQg30zXzyM(=jmA++Kg#FwSU3YLh)w4pE%3D&oer6Wf4QGcY~FkZ&yI+FYGV)*e{6L zt
DMA#yJ>`2m2ewsKPDE@*dDQA7<8w{j>*KAJI?*$X9*Rl*tC}kk zG#=Re $n&ljTWo3AlVkS1s%iYI) z 8f{XIPz8=i+%$CDuhffV+@#g**qGFnU=DdTE2gkJ zM*VSrO0K#RSTWr$_J!==4b+6DKUq+04liq|&qsid=z<`qb3Qy}tUf$4XYX7a<&sZY zGgBE5&BeKPLI910uPn6E 8-v! zp!y3Ixrw8K|MaHhn#amtiXFIFcDgxS^4sOzz4> FN+Vd7Q{Mm)MN z!&^+%yBx^Pb3~ktXW(J^#wjFroEmF!L2_{yDQnPYBH(#r&c|qIhgUw=bf3<0oAFF7 zR+VX~X>MkmH(Ew#3{|QLiD7^h?pFR{ZAYyM+`%Aqyr7Mz=ghsl0P{#9DBDGRQ^iK? zxnZPt9;DUPVL0u}*dXM)1oPp8Rh6u;Wh5sj7ai6yJW;fxb?~aYMN(QrzlQw19el6N zU}4{iTnp!VBOR{-_qo;V4Zrq<*$2=(CIsS@$Cb1kN6W-*L+z3~)qPW=6Pl+yqs}0q z2a{K*<3%9qdd{oMiV0ZIzRy4AMYYL+fxb1sd-xtSg(TJkGD=d=_sEYu9_U!iFxCHf z^5*+JqT(zZ{bEbG_g?)`Z@LZP5ch+q;#biu!rK-Hv#t}0Wa*o6?oe;J|JWlG7a@sQ zpbpivPF4ebeL971Y??;9HG7>_H6Gx@SQq02`I-##3p2AxWICRC`OTY6NIavJw;Ih| z_sQ#snlE}*;E?@f(mAr5aFn|M6T!b~gZdRpzT>>_A=)}1w34ewq;ntrrM5JT{;4^A zRHSH8yT7v)EWwx?Fcd**w!Cm A{$GAXY+}$c!TE)NM;`un9%z3raExc`G zmp^J48;R877zmpwL@Sp2vTAgjM;BMt@2g&Oz)N1-)!D3s{;BJ+FvX*tHdml_M+;0` z6PQNWjM43|E`Zi<>exDW_7>-4v%rSl3iqfvr-wNgvdam}F9YG)o9aE12n8g~dn}i@ z9{e<_ZGI55Mt1wSE5+K@H~-ZUCB(y-0)#@m=tnFk`a60D8C7l*;qU+8<~grOy4XT0 zB{H!J7yWb|7f5y*>OVE1uIi&2N^o2Y#h^K^UwoVLR<`xfQ?cx3^#skU+54v4ohYwt zY-~J`eg=#Cbh3k&&8K&1XhL#3uI_lfy+4V;udD5?RVsdPY-p>yZ2CHDJFCO08&8dS z1XW7)4tn00zsJt9wz07hmT`A*1Z|9N8KD@oIH`K+-P1H5pMsC(&&d*22+3|2q%g$d z_Z5Ar&3s^%l^LkKH1~4Ip?oWs+x8Xnjq5+ =-b2C z tTY_abfSVhM8iSf;5m{01+8qE>M~aeP+1|zMT6I*QFEeX+1!E zCe!cnc&z2K{Jl;*m+=SgCg i8`=InYSobg*)PZP8Ot=w?c%%B zKUg~_YvTB^uV9&&ziH&P@mw`WT9bZh%ta%`^75No!2>8)bhzE9q=->G*z=YQKjscP zRPPjkfwnEq^`Bewr~FJ9M{R8S@xme(!)CARuUVO`E(;f>h0p)`YFpb|M=s%|pRt*w z?GKiInFH{hq?VfA@LO}FpsyVWQ6PmOzK-U etg+R(=-uoWPtXsv_p*TL2 zecN02C|~^&aq_j5jZOJ1oOp2>wRW9D(rK5KFZz5k `dZ|?+}0Jk^`tZ|rBKT6jd zR(Dtk5r@8pl3=9MTW}55_BA>*)+xhx^w-;ZB9Mzpey_;LNF2Mas7d5;HL*dE-W-&B zhwp}_fkEPo;U%6$i8GYv)g@w58fT?xqAOqTJC@fY2gD07_j|P38}o c<}ZtLvYEd zqLn(t#?#x^rw^IR^RXY2T*}30S9c8jBGlqJR3Q|D)lwMMTbDa;#IgPoy}F|QId->P zNG>2ks&8nRsBrM!Oew|U@00KL9CvTB%f`x@?K_e3oeniHU8nwrq4{sCBPMEWf&qEN z5+7G6d9c?)8gX)?A-TmMWcnlX=Nmti*EuVxbif7v7V>^z^Y*jq+QI45t87B$H5lis zmdyR$Sx#_7zH;K!Eiz1T%J(~#mxCd!>@qD*8mcq#1!5ndg_ZwuW=YLri6hT^F_|(g z?ae_)->xx#V-zLW2X~BgGOLu;s0nn(+Wm}bbM~&{;m%P@$;=! VszLU+>D?nZhoyDaK0+v7ZdRy~GO)fI3l>IVlBmJ@ajp#rwkY16`((hjC=&Gmp9w)X+#Z zGNjIKNNE+z&I7_!u&N!$en ^+w6unKhwCu?2u z`;F}E>_{P`<}+Cx_SD#|D;RrF7Hqv48KgipnW5hV;7tEPY3|*-ovh_F9xdTuoESbd zkG5RA7Ht!+U0`8+j}5R5IwGhK+IiJ<<28kW!90eD35&z{Q=s;R^YnF#7qg@L|A5UC zsyV`+;YTb6(1$h}s9VD7PvEMM%+rY*Lm9riB=S_F6=)?W=}A%{9GWJE)`<^*?ituW zHbWbQDv|shbTzOYo$H(9zL>oz{{uH0d$5ujwgE1~ 82i4beMScEhrCCX z&YxF`dFVMo_uH6*(Kox|A{@)ecOn)kLYaH(<;4DmL}htbxI7SdUyt+pu;m6yi|5ZI z%3~@ecVK(RmCdLJUL*eJHltIpCHu}ZaqmID^w--HU2{Kr`>tj_d%Buqsa-Nd{ri+? z=lse-Ts$yuQ*12C%H=?3P}h+`F9RK-+i=_+^Gca-HZ0$Q4zovG$g^ihTf#&Uk}%0B zg5-IkuNXZ1iLW}4`MNpFRVvJ7*<(eOau?{~0NX@|svpzXF8lrRw5b&LA8wi)T#e@v zH OlUn2!L%y*E+qi@X$f2o3k%0yCffas z6|f1*&Q*5_V$QmbncsWF7yq2)rz>ypUzUIV(LXnW<)0PtUpz9>Ma@X8HfysWG$U Y#-(4`%~o6pCuiY)#RGd%6b}FTUrVrVr2qf` literal 0 HcmV?d00001 diff --git a/website/en/book/elements/openscad.md b/website/en/book/elements/openscad.md new file mode 100644 index 00000000..e7b9ef46 --- /dev/null +++ b/website/en/book/elements/openscad.md @@ -0,0 +1,123 @@ +--- +name: OpenSCAD +permaid: openscad +--- + +# OpenSCAD + +The `openscad` directive provides an interactive OpenSCAD editor with: + +- a **code view**, +- a **parameter view** (JSON object mapped to `-D` variables), +- and a **3D preview**. + +You can render the model, copy the code, and download exports as **STL** or **3MF**. + +## Usage + +Wrap OpenSCAD code in a `:::openscad` block and use a `scad` (or `openscad`) code fence. + +````md +:::openscad + +```scad +cube([20,20,20], center=true); +``` + +::: +```` + +:::openscad + +```scad +cube([20,20,20], center=true); +``` + +::: + +## Attributes + +| Attribute | Description | Default | +|---|---|---| +| `id` | Unique id for persistence | auto-generated | +| `src` | Load source from an external file path | inline code block | +| `height` | Height of the editor/preview container | `calc(100dvh - 80px)` | + +## Load code from file + +````md +:::openscad{src="openscad/example.scad"} +::: +```` + +## Parameters (JSON) + +Open the **Parameters** tab and provide a JSON object. Each key/value pair is passed to OpenSCAD as `-Dname=value`. + +Example: + +```json +{ + "size": 24, + "height": 16, + "segments": 64, + "rounded": true, + "label": "A" +} +``` + +## Example with variables + +````md +:::openscad + +```scad +$fn = segments; + +module body(size, height, rounded) { + if (rounded) { + minkowski() { + cube([size, size, height], center=true); + sphere(r=1); + } + } else { + cube([size, size, height], center=true); + } +} + +difference() { + body(size, height, rounded); + translate([0, 0, height / 2 + 0.1]) + linear_extrude(height=1) + text(label, size=8, halign="center", valign="center"); +} +``` + +::: +```` + +:::openscad + +```scad +$fn = segments; + +module body(size, height, rounded) { + if (rounded) { + minkowski() { + cube([size, size, height], center=true); + sphere(r=1); + } + } else { + cube([size, size, height], center=true); + } +} + +difference() { + body(size, height, rounded); + translate([0, 0, height / 2 + 0.1]) + linear_extrude(height=1) + text(label, size=8, halign="center", valign="center"); +} +``` + +::: From c71b72df585440ddf05da1bf8cbdad90f58cc044 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 08:45:15 +0000 Subject: [PATCH 3/4] fix(openscad): use ESM-remapped three imports for browser Agent-Logs-Url: https://github.com/openpatch/hyperbook/sessions/54797f57-f0b3-4844-9b47-55758f96cca5 --- packages/markdown/assets/directive-openscad/client.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/markdown/assets/directive-openscad/client.js b/packages/markdown/assets/directive-openscad/client.js index aa9e3c58..ffb6a104 100644 --- a/packages/markdown/assets/directive-openscad/client.js +++ b/packages/markdown/assets/directive-openscad/client.js @@ -37,9 +37,9 @@ hyperbook.openscad = (function () { const getThree = async () => { if (!threePromise) { threePromise = Promise.all([ - import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"), - import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js"), - import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js/+esm"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js/+esm"), + import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js/+esm"), ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({ THREE, STLLoader: STLLoaderModule.STLLoader, From a1d3fcf3510484823ea06d07b498f8e0a101c231 Mon Sep 17 00:00:00 2001 From: Mike Barkmin Date: Mon, 18 May 2026 10:22:25 +0200 Subject: [PATCH 4/4] add openscad element --- .changeset/hot-frogs-train.md | 6 + .../assets/directive-openscad/client.js | 645 ++++++++++++++++-- .../assets/directive-openscad/style.css | 238 ++++++- packages/markdown/locales/de.json | 5 +- packages/markdown/locales/en.json | 5 +- packages/markdown/openscad-config.json | 7 + packages/markdown/package.json | 1 + packages/markdown/postbuild.mjs | 28 + .../markdown/src/remarkDirectiveOpenscad.ts | 181 ++--- .../remarkDirectiveOpenscad.test.ts.snap | 23 +- pnpm-lock.yaml | 8 + website/de/book/elements/openscad.md | 133 +++- website/en/book/changelog.md | 12 + website/en/book/elements/openscad.md | 134 +++- 14 files changed, 1167 insertions(+), 259 deletions(-) create mode 100644 .changeset/hot-frogs-train.md create mode 100644 packages/markdown/openscad-config.json diff --git a/.changeset/hot-frogs-train.md b/.changeset/hot-frogs-train.md new file mode 100644 index 00000000..219adc6d --- /dev/null +++ b/.changeset/hot-frogs-train.md @@ -0,0 +1,6 @@ +--- +"@hyperbook/markdown": minor +"hyperbook": minor +--- + +Add openscad element diff --git a/packages/markdown/assets/directive-openscad/client.js b/packages/markdown/assets/directive-openscad/client.js index ffb6a104..7cd12743 100644 --- a/packages/markdown/assets/directive-openscad/client.js +++ b/packages/markdown/assets/directive-openscad/client.js @@ -6,6 +6,8 @@ * @memberof hyperbook */ hyperbook.openscad = (function () { + const _scriptBase = window.HYPERBOOK_ASSETS + "directive-openscad/"; + window.codeInput?.registerTemplate( "openscad-highlighted", codeInput.templates.prism(window.Prism, [ @@ -14,32 +16,223 @@ hyperbook.openscad = (function () { ]), ); - let openscadPromise = null; + // Cache the ESM module import (loaded once). Each render creates a fresh + // WASM instance to avoid C++ singleton state issues + // (e.g. the Manifold backend throwing a C++ exception on second callMain). + let openscadModulePromise = null; let threePromise = null; + // Font bytes are fetched once and re-written to each fresh WASM instance. + let robotoFontData = null; + + // Per-render stderr capture — cleared before each render. + let openscadStderr = []; + const i18nGet = (key, fallback = key) => hyperbook.i18n?.get(key) || fallback; + const FONTS_CONF = ` + + + `; + + // Create a fresh OpenSCAD WASM instance for each render. + // The ESM module (and its compiled WASM binary) is imported only once; + // the browser's WebAssembly module cache makes subsequent instantiations fast. const getOpenScad = async () => { - if (!openscadPromise) { - openscadPromise = import("https://cdn.jsdelivr.net/npm/openscad-wasm@0.0.4/+esm") - .then((m) => m.createOpenSCAD()) - .then((instance) => { - const fs = instance.getInstance().FS; - try { - fs.mkdir("/tmp"); - } catch (_) {} - return instance; - }); + if (!openscadModulePromise) { + openscadModulePromise = import(/* @vite-ignore */ _scriptBase + "openscad.js"); + } + const OpenSCAD = (await openscadModulePromise).default; + const instance = await OpenSCAD({ + noInitialRun: true, + locateFile: (file) => _scriptBase + file, + printErr: (text) => openscadStderr.push(text), + }); + const fs = instance.FS; + try { fs.mkdir("/tmp"); } catch (_) {} + try { fs.mkdir("/fonts"); } catch (_) {} + // Fonts are resolved from $(cwd)/fonts — keep cwd at / + try { instance.FS.chdir("/"); } catch (_) {} + try { fs.writeFile("/fonts/fonts.conf", FONTS_CONF); } catch (_) {} + // Write cached font data if already fetched + if (robotoFontData) { + try { fs.writeFile("/fonts/Roboto-Regular.ttf", robotoFontData); } catch (_) {} + } + return instance; + }; + + // Known library URLs hosted at the openscad-playground deployment. + const KNOWN_LIBRARIES = { + BOSL2: "https://ochafik.com/openscad2/libraries/BOSL2.zip", + BOSL: "https://ochafik.com/openscad2/libraries/BOSL.zip", + MCAD: "https://ochafik.com/openscad2/libraries/MCAD.zip", + NopSCADlib: "https://ochafik.com/openscad2/libraries/NopSCADlib.zip", + fonts: "https://ochafik.com/openscad2/libraries/fonts.zip", + }; + + // Per-name cache of extracted file maps: Map/fonts ++ const libraryCache = new Map(); + + // Minimal ZIP extractor using the browser-native DecompressionStream API. + // Supports Stored (method 0) and Deflate (method 8) entries. + const extractZip = async (buffer) => { + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + const files = {}; + const dec = new TextDecoder(); + + // Locate End of Central Directory record. + let eocdPos = -1; + for (let i = buffer.byteLength - 22; i >= Math.max(0, buffer.byteLength - 65558); i--) { + if (view.getUint32(i, true) === 0x06054b50) { eocdPos = i; break; } } - return openscadPromise; + if (eocdPos < 0) throw new Error("Not a valid ZIP file"); + + const entryCount = view.getUint16(eocdPos + 10, true); + let cdOffset = view.getUint32(eocdPos + 16, true); + + for (let i = 0; i < entryCount; i++) { + if (view.getUint32(cdOffset, true) !== 0x02014b50) break; + const compression = view.getUint16(cdOffset + 10, true); + const compressedSize = view.getUint32(cdOffset + 20, true); + const fnLen = view.getUint16(cdOffset + 28, true); + const extraLen = view.getUint16(cdOffset + 30, true); + const commentLen = view.getUint16(cdOffset + 32, true); + const localOffset = view.getUint32(cdOffset + 42, true); + const name = dec.decode(bytes.subarray(cdOffset + 46, cdOffset + 46 + fnLen)); + cdOffset += 46 + fnLen + extraLen + commentLen; + + if (name.endsWith("/")) continue; + + const localFnLen = view.getUint16(localOffset + 26, true); + const localExtraLen = view.getUint16(localOffset + 28, true); + const dataStart = localOffset + 30 + localFnLen + localExtraLen; + const compressed = bytes.subarray(dataStart, dataStart + compressedSize); + + if (compression === 0) { + files[name] = new Uint8Array(compressed); + } else if (compression === 8) { + const ds = new DecompressionStream("deflate-raw"); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + writer.write(compressed); + writer.close(); + const chunks = []; + let totalLen = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + totalLen += value.byteLength; + } + const out = new Uint8Array(totalLen); + let pos = 0; + for (const c of chunks) { out.set(c, pos); pos += c.byteLength; } + files[name] = out; + } + } + return files; + }; + + // Fetch and extract a library zip, caching the result. + const loadLibrary = async (name) => { + if (libraryCache.has(name)) return libraryCache.get(name); + const url = KNOWN_LIBRARIES[name]; + if (!url) throw new Error(`Unknown OpenSCAD library: ${name}`); + const resp = await fetch(url); + if (!resp.ok) throw new Error(`Failed to fetch library ${name}: ${resp.status}`); + const files = await extractZip(await resp.arrayBuffer()); + libraryCache.set(name, files); + return files; + }; + + // Mount a list of libraries into a WASM FS instance. + // Each library is written to / / so `use ` resolves correctly. + const mountLibraries = async (instance, libraryNames) => { + for (const libName of libraryNames) { + const files = await loadLibrary(libName); + try { instance.FS.mkdir(`/${libName}`); } catch (_) {} + for (const [filePath, data] of Object.entries(files)) { + const parts = filePath.split("/"); + let dir = `/${libName}`; + for (let j = 0; j < parts.length - 1; j++) { + dir += "/" + parts[j]; + try { instance.FS.mkdir(dir); } catch (_) {} + } + try { instance.FS.writeFile(`/${libName}/${filePath}`, data); } catch (_) {} + } + } + }; + + // Fetch the Roboto TTF once and cache it in memory so it can be written + // to each new WASM instance's FS to enable OpenSCAD text() rendering. + const loadFonts = async () => { + if (robotoFontData) return; + try { + const resp = await fetch( + "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Me5Q.ttf", + ); + if (resp.ok) { + robotoFontData = new Uint8Array(await resp.arrayBuffer()); + } + } catch (e) { + console.warn("[openscad] Failed to load fonts:", e); + } + }; + + // Extract parameters from SCAD code. Tries OpenSCAD WASM with + // --export-format=param first (uses the built-in Customizer engine with full + // comment syntax support). Falls back to regex parsing if WASM returns nothing. + const extractParams = async (code, libraryNames = []) => { + try { + const openscad = await getOpenScad(); + const instance = openscad; + + if (libraryNames.length > 0) { + await mountLibraries(instance, libraryNames); + } + + const sourcePath = "/tmp/params_model.scad"; + const outPath = "/tmp/params_out.json"; + + try { instance.FS.unlink(sourcePath); } catch (_) {} + try { instance.FS.unlink(outPath); } catch (_) {} + + // Prepend $preview=true as the playground does, to avoid full geometry evaluation. + instance.FS.writeFile(sourcePath, "$preview=true;\n" + code); + + const exitCode = instance.callMain([ + sourcePath, + "-o", outPath, + "--export-format=param", + ]); + + if (exitCode === 0) { + try { + const json = instance.FS.readFile(outPath, { encoding: "utf8" }); + const paramSet = JSON.parse(json); + if (Array.isArray(paramSet.parameters) && paramSet.parameters.length > 0) { + // Filter out OpenSCAD special variables (e.g. $preview, $fn, $fa, $fs) + // that are internal and should not be exposed in the parameter UI. + return paramSet.parameters.filter(p => !p.name?.startsWith('$')); + } + } catch (e) { + console.warn("[openscad] Failed to parse param output:", e); + } + } + } catch (e) { + console.warn("[openscad] WASM param extraction failed:", e); + } + return []; }; const getThree = async () => { if (!threePromise) { threePromise = Promise.all([ - import("https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js/+esm"), - import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/loaders/STLLoader.js/+esm"), - import("https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js/+esm"), + import(/* @vite-ignore */ _scriptBase + "three.module.js"), + import(/* @vite-ignore */ _scriptBase + "STLLoader.js"), + import(/* @vite-ignore */ _scriptBase + "OrbitControls.js"), ]).then(([THREE, STLLoaderModule, OrbitControlsModule]) => ({ THREE, STLLoader: STLLoaderModule.STLLoader, @@ -56,8 +249,67 @@ hyperbook.openscad = (function () { throw new Error("Only numbers, booleans, strings and arrays are supported in parameters"); }; - function setupSplitter(elem, previewContainer, editorContainer, splitter) { - if (!previewContainer || !editorContainer || !splitter) return; + function setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, splitter, onSplitChanged) { + if (!leftSide || !previewContainer || !paramsPanel || !splitter) return; + + const minSize = 80; + + const applySplitSize = (rawSize) => { + const total = leftSide.clientHeight; + const splitterSize = splitter.offsetHeight; + const maxSize = Math.max(minSize, total - splitterSize - minSize); + const clamped = Math.max(minSize, Math.min(rawSize, maxSize)); + previewContainer.style.flex = `0 0 ${clamped}px`; + return clamped; + }; + + const applyStoredSplitSize = () => { + const rawStored = Number(leftSide.dataset.splitCanvasParams); + if (!Number.isFinite(rawStored) || rawStored <= 0) { + previewContainer.style.flex = ""; + return; + } + applySplitSize(rawStored); + }; + + applyStoredSplitSize(); + + splitter.addEventListener("pointerdown", (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + const startPointer = event.clientY; + const startSize = previewContainer.getBoundingClientRect().height; + + const onPointerMove = (moveEvent) => { + const delta = moveEvent.clientY - startPointer; + const size = applySplitSize(startSize + delta); + leftSide.dataset.splitCanvasParams = String(Math.round(size)); + }; + + const onPointerUp = () => { + splitter.removeEventListener("pointermove", onPointerMove); + splitter.removeEventListener("pointerup", onPointerUp); + splitter.removeEventListener("pointercancel", onPointerUp); + const splitCanvasParams = Number(leftSide.dataset.splitCanvasParams); + if (Number.isFinite(splitCanvasParams) && splitCanvasParams > 0) { + onSplitChanged?.({ splitCanvasParams: Math.round(splitCanvasParams) }); + } + }; + + splitter.addEventListener("pointermove", onPointerMove); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + return applyStoredSplitSize; + } + + function setupSplitter(elem, leftSide, editorContainer, splitter, onSplitChanged) { + if (!leftSide || !editorContainer || !splitter) return; + + const previewContainer = leftSide; const minPanelSize = 120; @@ -113,6 +365,16 @@ hyperbook.openscad = (function () { splitter.removeEventListener("pointermove", onPointerMove); splitter.removeEventListener("pointerup", onPointerUp); splitter.removeEventListener("pointercancel", onPointerUp); + const splitHorizontal = Number(elem.dataset.splitHorizontal); + const splitVertical = Number(elem.dataset.splitVertical); + onSplitChanged?.({ + ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0 + ? { splitHorizontal: Math.round(splitHorizontal) } + : {}), + ...(Number.isFinite(splitVertical) && splitVertical > 0 + ? { splitVertical: Math.round(splitVertical) } + : {}), + }); }; splitter.addEventListener("pointermove", onPointerMove); @@ -121,6 +383,7 @@ hyperbook.openscad = (function () { }); window.addEventListener("resize", applyStoredSplitSize); + return applyStoredSplitSize; } const updateFullscreenButtonState = (elem, button) => { @@ -161,26 +424,86 @@ hyperbook.openscad = (function () { elem.setAttribute("data-openscad-initialized", "true"); const id = elem.getAttribute("data-id"); + const libraryNames = (elem.getAttribute("data-library") || "") + .split(",").map(s => s.trim()).filter(Boolean); const previewContainer = elem.querySelector(".preview-container"); + const leftSide = elem.querySelector(".left-side"); + const canvasWrapper = elem.querySelector(".canvas-wrapper"); + const canvasOverlay = elem.querySelector(".canvas-overlay"); const editorContainer = elem.querySelector(".editor-container"); const splitter = elem.querySelector(".splitter"); + const canvasParamsSplitter = elem.querySelector(".canvas-params-splitter"); const canvas = elem.querySelector(".preview-canvas"); - const output = elem.querySelector(".output"); const editor = elem.querySelector("code-input.editor"); const params = elem.querySelector("textarea.parameters"); - const tabCode = elem.querySelector("button.tab-code"); - const tabParameters = elem.querySelector("button.tab-parameters"); + // The parameters panel is its own card below the canvas. + const paramsPanel = elem.querySelector(".parameters-panel"); + const paramsForm = paramsPanel?.querySelector(".parameters-body") ?? paramsPanel; const renderBtn = elem.querySelector("button.render"); const copyBtn = elem.querySelector("button.copy"); const downloadStlBtn = elem.querySelector("button.download-stl"); - const download3mfBtn = elem.querySelector("button.download-3mf"); const resetBtn = elem.querySelector("button.reset"); const fullscreenBtn = elem.querySelector("button.fullscreen"); - setupSplitter(elem, previewContainer, editorContainer, splitter); + // --- Canvas overlay --- + let overlayDismissTimer = null; + + const hideOverlay = () => { + clearTimeout(overlayDismissTimer); + if (canvasOverlay) { + canvasOverlay.className = "canvas-overlay hidden"; + canvasOverlay.innerHTML = ""; + } + }; + + const showOverlay = (type, message) => { + clearTimeout(overlayDismissTimer); + if (!canvasOverlay) return; + canvasOverlay.innerHTML = ""; + canvasOverlay.className = `canvas-overlay ${type}`; + + if (type === "loading") { + const spinner = document.createElement("div"); + spinner.className = "canvas-spinner"; + const label = document.createElement("span"); + label.className = "overlay-message"; + label.textContent = message; + canvasOverlay.appendChild(spinner); + canvasOverlay.appendChild(label); + } else { + const msg = document.createElement("div"); + msg.className = "overlay-message"; + msg.textContent = message; + const btn = document.createElement("button"); + btn.className = "overlay-dismiss"; + btn.textContent = "✕"; + btn.addEventListener("click", hideOverlay); + canvasOverlay.appendChild(msg); + canvasOverlay.appendChild(btn); + } + }; + + // Resize the Three.js renderer to match the current canvas-wrapper size. + const resizeCanvas = () => { + if (!viewerState.renderer || !viewerState.camera || !canvasWrapper) return; + const w = Math.max(1, Math.floor(canvasWrapper.clientWidth)); + const h = Math.max(1, Math.floor(canvasWrapper.clientHeight)); + viewerState.renderer.setSize(w, h, false); + viewerState.camera.aspect = w / h; + viewerState.camera.updateProjectionMatrix(); + }; + + const applyMainSplitSize = setupSplitter(elem, leftSide, editorContainer, splitter, () => { + resizeCanvas(); + save(); + }); + const applyCanvasParamsSplitSize = setupCanvasParamsSplitter(leftSide, previewContainer, paramsPanel, canvasParamsSplitter, () => { + resizeCanvas(); + save(); + }); const viewerState = { renderer: null, @@ -190,31 +513,183 @@ hyperbook.openscad = (function () { mesh: null, raf: 0, disposed: false, - }; - - const setOutput = (text) => { - if (output) output.textContent = text || ""; + resizeObserver: null, }; const save = async () => { if (!id) return; + const splitHorizontal = Number(elem.dataset.splitHorizontal); + const splitVertical = Number(elem.dataset.splitVertical); + const splitCanvasParams = Number(leftSide?.dataset.splitCanvasParams); await hyperbook.store.db.openscad.put({ id, code: editor?.value || "", params: params?.value || "{}", + ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0 + ? { splitHorizontal: Math.round(splitHorizontal) } + : {}), + ...(Number.isFinite(splitVertical) && splitVertical > 0 + ? { splitVertical: Math.round(splitVertical) } + : {}), + ...(Number.isFinite(splitCanvasParams) && splitCanvasParams > 0 + ? { splitCanvasParams: Math.round(splitCanvasParams) } + : {}), }); }; const load = async () => { - if (!id) return; + if (!id) return null; const result = await hyperbook.store.db.openscad.get(id); - if (!result) return; + if (!result) return null; if (editor && typeof result.code === "string") { editor.value = result.code; } if (params && typeof result.params === "string") { params.value = result.params; } + if (Number.isFinite(result.splitHorizontal) && result.splitHorizontal > 0) { + elem.dataset.splitHorizontal = String(Math.round(result.splitHorizontal)); + } + if (Number.isFinite(result.splitVertical) && result.splitVertical > 0) { + elem.dataset.splitVertical = String(Math.round(result.splitVertical)); + } + if (leftSide && Number.isFinite(result.splitCanvasParams) && result.splitCanvasParams > 0) { + leftSide.dataset.splitCanvasParams = String(Math.round(result.splitCanvasParams)); + } + return result; + }; + + // Rebuild the parameters form from the code's top-level variable assignments. + // Stored overrides from the textarea are preserved so user edits survive + // code changes that don't touch those variable names. + const buildParamForm = async (code) => { + // Show a loading indicator while WASM extracts params. + paramsForm.innerHTML = ""; + paramsPanel?.classList.remove("hidden"); + canvasParamsSplitter?.classList.remove("hidden"); + const loading = document.createElement("p"); + loading.className = "params-empty"; + loading.textContent = i18nGet("openscad-params-loading", "Loading parameters..."); + paramsForm.appendChild(loading); + + const codeParams = await extractParams(code, libraryNames); + paramsForm.innerHTML = ""; + + if (codeParams.length === 0) { + paramsPanel?.classList.add("hidden"); + canvasParamsSplitter?.classList.add("hidden"); + if (params) params.value = "{}"; + return; + } + + let currentOverrides = {}; + try { + currentOverrides = JSON.parse(params?.value || "{}"); + } catch (_) {} + + const syncTextarea = () => { + const values = {}; + paramsForm.querySelectorAll("[data-param-name]").forEach((input) => { + const name = input.dataset.paramName; + const type = input.dataset.paramType; + if (type === "boolean") { + values[name] = input.checked; + } else if (type === "number") { + values[name] = Number(input.value); + } else { + values[name] = input.value; + } + }); + if (params) params.value = JSON.stringify(values); + save(); + }; + + codeParams.forEach(({ name, caption, type, initial, min, max, step, options }) => { + const current = + currentOverrides[name] !== undefined ? currentOverrides[name] : initial; + + const row = document.createElement("div"); + row.className = "param-row"; + + const label = document.createElement("label"); + label.textContent = caption || name; + label.setAttribute("for", `openscad-param-${id}-${name}`); + + let input; + if (type === "boolean") { + input = document.createElement("input"); + input.type = "checkbox"; + input.checked = Boolean(current); + } else if (options && options.length > 0) { + // Dropdown for parameters with a fixed set of options. + input = document.createElement("select"); + options.forEach(({ name: optName, value: optValue }) => { + const opt = document.createElement("option"); + opt.value = String(optValue); + opt.textContent = optName || String(optValue); + if (String(optValue) === String(current)) opt.selected = true; + input.appendChild(opt); + }); + input.addEventListener("change", syncTextarea); + } else if (type === "number" && Array.isArray(initial)) { + // Vector: render one number input per component. + input = document.createElement("span"); + input.className = "param-vector"; + const arr = Array.isArray(current) ? current : initial; + arr.forEach((val, idx) => { + const ni = document.createElement("input"); + ni.type = "number"; + ni.value = String(val); + ni.step = step != null ? String(step) : "any"; + if (min != null) ni.min = String(min); + if (max != null) ni.max = String(max); + ni.dataset.paramName = name; + ni.dataset.paramType = "vector"; + ni.dataset.vectorIndex = String(idx); + ni.addEventListener("input", () => { + const all = Array.from(input.querySelectorAll("input")).map( + (i) => Number(i.value) + ); + const sibling = paramsForm.querySelector( + `[data-param-name="${name}"][data-param-type="number"]` + ); + if (sibling) sibling.value = JSON.stringify(all); + syncTextarea(); + }); + input.appendChild(ni); + }); + // Hidden input holds the JSON array for syncTextarea to read. + const hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.dataset.paramName = name; + hidden.dataset.paramType = "number"; + hidden.value = JSON.stringify(arr); + input.appendChild(hidden); + } else if (type === "number") { + input = document.createElement("input"); + input.type = "number"; + input.value = String(current); + input.step = step != null ? String(step) : "any"; + if (min != null) input.min = String(min); + if (max != null) input.max = String(max); + } else { + input = document.createElement("input"); + input.type = "text"; + input.value = String(current); + } + input.id = `openscad-param-${id}-${name}`; + if (input.tagName !== "SPAN") { + input.dataset.paramName = name; + input.dataset.paramType = type; + input.addEventListener("input", syncTextarea); + } + + row.appendChild(label); + row.appendChild(input); + paramsForm.appendChild(row); + }); + + syncTextarea(); }; const getParamDefinitions = () => { @@ -225,14 +700,18 @@ hyperbook.openscad = (function () { return Object.entries(parsed).map(([k, v]) => `-D${k}=${formatValue(v)}`); }; - const renderWithFormat = async (format) => { + const renderWithFormat = async (format, libraryNames = []) => { renderBtn?.setAttribute("disabled", "true"); - setOutput(i18nGet("openscad-rendering", "Rendering ...")); + showOverlay("loading", i18nGet("openscad-rendering", "Rendering...")); try { const paramDefinitions = getParamDefinitions(); const openscad = await getOpenScad(); - const instance = openscad.getInstance(); + const instance = openscad; + + if (libraryNames.length > 0) { + await mountLibraries(instance, libraryNames); + } const sourcePath = "/tmp/model.scad"; const outPath = `/tmp/output.${format}`; @@ -251,7 +730,6 @@ hyperbook.openscad = (function () { sourcePath, "-o", outPath, - "--backend=manifold", `--export-format=${exportFormat}`, ...paramDefinitions, ]; @@ -278,13 +756,25 @@ hyperbook.openscad = (function () { }; const renderPreview = async () => { + openscadStderr = []; try { + // Ensure font bytes are fetched before creating the WASM instance + await loadFonts(); await save(); - const stl = await renderWithFormat("stl"); + const stl = await renderWithFormat("stl", libraryNames); await renderStl(stl); - setOutput(i18nGet("openscad-render-success", "Render complete")); + hideOverlay(); } catch (error) { - setOutput(error?.message || `${error}`); + // Prefer actual OpenSCAD error lines over raw JS/C++ exception values. + // emscripten throws C++ exceptions as raw numbers (WASM memory pointers). + const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n"); + if (stderrErrors) { + showOverlay("error", stderrErrors); + } else if (typeof error === "number") { + showOverlay("error", i18nGet("openscad-render-failed", "OpenSCAD render failed")); + } else { + showOverlay("error", error?.message || `${error}`); + } } }; @@ -326,9 +816,12 @@ hyperbook.openscad = (function () { viewerState.raf = requestAnimationFrame(tick); }; tick(); + + viewerState.resizeObserver = new ResizeObserver(() => resizeCanvas()); + viewerState.resizeObserver.observe(canvasWrapper || previewContainer); } - const bounds = previewContainer?.getBoundingClientRect(); + const bounds = canvasWrapper?.getBoundingClientRect() ?? previewContainer?.getBoundingClientRect(); const width = Math.max(1, Math.floor(bounds?.width || canvas.clientWidth || 320)); const height = Math.max(1, Math.floor(bounds?.height || canvas.clientHeight || 320)); viewerState.renderer.setSize(width, height, false); @@ -380,23 +873,9 @@ hyperbook.openscad = (function () { viewerState.controls.update(); }; - tabCode?.addEventListener("click", () => { - tabCode.classList.add("active"); - tabParameters?.classList.remove("active"); - editor?.classList.add("active"); - params?.classList.remove("active"); - }); - - tabParameters?.addEventListener("click", () => { - tabParameters.classList.add("active"); - tabCode?.classList.remove("active"); - params?.classList.add("active"); - editor?.classList.remove("active"); - }); - copyBtn?.addEventListener("click", async () => { await navigator.clipboard.writeText(editor?.value || ""); - setOutput(i18nGet("openscad-copy-done", "Code copied")); + hideOverlay(); }); resetBtn?.addEventListener("click", async () => { @@ -410,25 +889,17 @@ hyperbook.openscad = (function () { renderBtn?.addEventListener("click", renderPreview); downloadStlBtn?.addEventListener("click", async () => { + openscadStderr = []; try { + await loadFonts(); await save(); - const stl = await renderWithFormat("stl"); + const stl = await renderWithFormat("stl", libraryNames); downloadBinary(stl, "stl"); await renderStl(stl); - setOutput(i18nGet("openscad-download-ready", "Download ready")); + hideOverlay(); } catch (error) { - setOutput(error?.message || `${error}`); - } - }); - - download3mfBtn?.addEventListener("click", async () => { - try { - await save(); - const threeMf = await renderWithFormat("3mf"); - downloadBinary(threeMf, "3mf"); - setOutput(i18nGet("openscad-download-ready", "Download ready")); - } catch (error) { - setOutput(error?.message || `${error}`); + const stderrErrors = openscadStderr.filter((l) => /error/i.test(l)).join("\n"); + showOverlay("error", stderrErrors || error?.message || `${error}`); } }); @@ -442,19 +913,45 @@ hyperbook.openscad = (function () { updateFullscreenButtonState(elem, fullscreenBtn); - editor?.addEventListener("code-input_load", async () => { - await load(); - editor.addEventListener("input", save); - params?.addEventListener("input", save); - if (!editor.value.trim()) { - editor.value = "// OpenSCAD\ncube([20,20,20], center=true);"; - } + let editorStateRestored = false; + const restoreEditorState = async () => { + if (editorStateRestored) return; + editorStateRestored = true; + + const stored = await load(); + // Re-apply split sizes after stored dataset values are applied by load(). + applyMainSplitSize?.(); + applyCanvasParamsSplitSize?.(); + let paramRebuildTimer = null; + editor.addEventListener("input", () => { + save(); + clearTimeout(paramRebuildTimer); + paramRebuildTimer = setTimeout(() => buildParamForm(editor.value), 500); + }); + + // Use stored code if available; otherwise fall back to the editor's + // current value (the markdown default) or the built-in placeholder. + const initialCode = stored?.code || editor.value.trim() || "// OpenSCAD\ncube([20,20,20], center=true);"; + editor.value = initialCode; if (!params?.value.trim()) { params.value = "{}"; } - await save(); + await buildParamForm(initialCode); + // Only persist when there was no stored entry; if one already existed we + // must not overwrite it — reading editor.value right now may return stale + // data because code-input's async re-render may not have completed yet. + if (!stored) { + await save(); + } renderPreview(); - }); + }; + + editor?.addEventListener("code-input_load", restoreEditorState); + // SPA timing: if code-input already rendered its inner textarea before we + // attached the listener, fire the handler immediately (mirrors pyide). + if (editor?.querySelector("textarea")) { + void restoreEditorState(); + } } function init(root) { diff --git a/packages/markdown/assets/directive-openscad/style.css b/packages/markdown/assets/directive-openscad/style.css index 3d4b00d7..8dbe86bd 100644 --- a/packages/markdown/assets/directive-openscad/style.css +++ b/packages/markdown/assets/directive-openscad/style.css @@ -8,14 +8,25 @@ height: var(--openscad-height, calc(100dvh - 80px)); } -.directive-openscad .preview-container { +/* Left side: wraps preview-container + params card */ +.directive-openscad .left-side { width: 100%; min-height: 120px; min-width: 120px; + flex: 1 1 0; + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; +} + +.directive-openscad .preview-container { + width: 100%; + min-height: 120px; border: 1px solid var(--color-spacer); border-radius: 8px; overflow: hidden; - background-color: var(--color--background); + background-color: var(--color-background, var(--color--background, #fff)); flex: 1 1 0; display: flex; flex-direction: column; @@ -30,20 +41,149 @@ .directive-openscad .preview-canvas { width: 100%; height: 100%; - min-height: 260px; display: block; background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); } -.directive-openscad .output { - margin: 0; - border-top: 1px solid var(--color-spacer); - padding: 8px 12px; - min-height: 56px; - max-height: 120px; - overflow: auto; +/* Canvas wrapper: relative container for overlay positioning */ +.directive-openscad .canvas-wrapper { + position: relative; + flex: 1; + overflow: hidden; + min-height: 80px; + display: flex; +} + +/* Canvas overlay: covers the canvas area */ +.directive-openscad .canvas-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + box-sizing: border-box; + pointer-events: none; +} + +.directive-openscad .canvas-overlay.hidden { + display: none; +} + +/* Loading state */ +.directive-openscad .canvas-overlay.loading { + background: rgba(0, 0, 0, 0.45); + color: #fff; +} + +/* Error state */ +.directive-openscad .canvas-overlay.error { + background: rgba(239, 68, 68, 0.12); + color: #991b1b; + align-items: flex-start; + justify-content: flex-start; + overflow-y: auto; + pointer-events: auto; +} + +.directive-openscad .canvas-overlay .overlay-message { + font-weight: 600; + font-size: 0.95em; + text-align: center; +} + +.directive-openscad .canvas-overlay.error .overlay-message { white-space: pre-wrap; + word-break: break-word; + font-weight: 400; + text-align: left; font-family: hyperbook-monospace, monospace; + font-size: 0.85em; +} + +.directive-openscad .overlay-dismiss { + padding: 4px 14px; + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.85em; + opacity: 0.8; + flex: 0 0 auto; +} + +.directive-openscad .overlay-dismiss:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.06); +} + +/* Spinner */ +.directive-openscad .canvas-spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: openscad-spin 0.75s linear infinite; +} + +@keyframes openscad-spin { + to { transform: rotate(360deg); } +} + +/* Splitter between canvas and parameters card */ +.directive-openscad .canvas-params-splitter { + width: 100%; + height: 4px; + margin: 6px 0; + cursor: row-resize; + background: var(--color-spacer); + border-radius: 999px; + flex-shrink: 0; + touch-action: none; + opacity: 0.45; + transition: opacity 0.15s ease-in-out; +} + +.directive-openscad .canvas-params-splitter:hover { + opacity: 0.65; +} + +/* Parameters card */ +.directive-openscad .parameters-panel { + width: 100%; + min-height: 80px; + border: 1px solid var(--color-spacer); + border-radius: 8px; + overflow: hidden; + background-color: var(--color-background, var(--color--background, #fff)); + flex: 1 1 0; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.directive-openscad .parameters-panel.hidden { + display: none; +} + +.directive-openscad .parameters-header { + border-bottom: 1px solid var(--color-spacer); + padding: 8px 16px; + font-weight: 600; + flex-shrink: 0; +} + +.directive-openscad .parameters-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; } .directive-openscad .editor-container { @@ -55,6 +195,7 @@ flex: 1 1 0; } +/* Main left/right splitter */ .directive-openscad .splitter { background: var(--color-spacer); border-radius: 999px; @@ -107,7 +248,7 @@ padding: 8px 16px; border: none; border-right: 1px solid var(--color-spacer); - background-color: var(--color--background); + background-color: var(--color-background, var(--color--background, #fff)); color: var(--color-text); cursor: pointer; } @@ -133,19 +274,61 @@ width: 100%; border: 1px solid var(--color-spacer); flex: 1; + margin: 0; /* override code-input.min.css default margin: 8px */ } +/* The parameters textarea is always hidden — the form replaces it visually */ .directive-openscad .parameters { - display: none; - box-sizing: border-box; - resize: none; - padding: 12px; - font-family: hyperbook-monospace, monospace; + display: none !important; } -.directive-openscad .editor:not(.active), -.directive-openscad .parameters:not(.active) { - display: none; +.directive-openscad .param-row { + display: flex; + align-items: center; + gap: 10px; +} + +.directive-openscad .param-row label { + flex: 1; + font-weight: 500; + min-width: 0; + word-break: break-word; +} + +.directive-openscad .param-row input[type="text"], +.directive-openscad .param-row input[type="number"], +.directive-openscad .param-row select { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--color-spacer); + border-radius: 4px; + background: var(--color-background, var(--color--background, #fff)); + color: var(--color-text); + font-family: inherit; +} + +.directive-openscad .param-vector { + flex: 1; + display: flex; + gap: 4px; +} + +.directive-openscad .param-vector input[type="number"] { + flex: 1; + min-width: 0; +} + +.directive-openscad .param-row input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.directive-openscad .params-empty { + color: var(--color-text-muted, #888); + font-style: italic; + margin: auto; + text-align: center; } .directive-openscad:fullscreen { @@ -165,9 +348,24 @@ flex-direction: row; } - .directive-openscad .preview-container, + .directive-openscad .left-side, .directive-openscad .editor-container { flex: 1; height: 100% !important; } } + +@media (prefers-color-scheme: dark) { + .directive-openscad .preview-canvas { + background: linear-gradient(180deg, #2a2a2a 0%, #1e1e1e 100%); + } + + .directive-openscad .canvas-overlay.error { + background: rgba(239, 68, 68, 0.18); + color: #fca5a5; + } + + .directive-openscad .overlay-dismiss:hover { + background: rgba(255, 255, 255, 0.1); + } +} diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index 14346c7d..71fbe708 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -89,13 +89,10 @@ "typst-binary-files": "Binärdateien", "typst-no-binary-files": "Keine Binärdateien", "openscad-preview": "Vorschau", - "openscad-code": "Code", - "openscad-parameters": "Parameter", "openscad-render": "Rendern", "openscad-copy": "Kopieren", "openscad-copy-done": "Code kopiert", "openscad-download-stl": "STL herunterladen", - "openscad-download-3mf": "3MF herunterladen", "openscad-download-ready": "Download bereit", "openscad-reset": "Zurücksetzen", "openscad-reset-prompt": "Sind Sie sicher, dass Sie den Code zurücksetzen möchten?", @@ -103,6 +100,8 @@ "openscad-render-success": "Rendern abgeschlossen", "openscad-render-failed": "OpenSCAD-Rendern fehlgeschlagen", "openscad-params-object": "Parameter müssen ein JSON-Objekt sein", + "openscad-params-loading": "Parameter werden geladen...", + "openscad-parameters": "Parameter", "user-login-title": "Anmelden", "user-username": "Benutzername", "user-password": "Passwort", diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json index 39f688ee..5a7a5fd7 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -89,13 +89,10 @@ "typst-binary-files": "Binary Files", "typst-no-binary-files": "No binary files", "openscad-preview": "Preview", - "openscad-code": "Code", - "openscad-parameters": "Parameters", "openscad-render": "Render", "openscad-copy": "Copy", "openscad-copy-done": "Code copied", "openscad-download-stl": "Download STL", - "openscad-download-3mf": "Download 3MF", "openscad-download-ready": "Download ready", "openscad-reset": "Reset", "openscad-reset-prompt": "Are you sure you want to reset the code?", @@ -103,6 +100,8 @@ "openscad-render-success": "Render complete", "openscad-render-failed": "OpenSCAD render failed", "openscad-params-object": "Parameters must be a JSON object", + "openscad-params-loading": "Loading parameters...", + "openscad-parameters": "Parameters", "user-login-title": "Login", "user-username": "Username", "user-password": "Password", diff --git a/packages/markdown/openscad-config.json b/packages/markdown/openscad-config.json new file mode 100644 index 00000000..4393e0b1 --- /dev/null +++ b/packages/markdown/openscad-config.json @@ -0,0 +1,7 @@ +{ + "wasmBuild": { + "url": "https://files.openscad.org/playground/OpenSCAD-2025.03.25.wasm24456-WebAssembly-web.zip", + "target": "directive-openscad", + "files": ["openscad.js", "openscad.wasm"] + } +} diff --git a/packages/markdown/package.json b/packages/markdown/package.json index d6e259ab..95898ccb 100644 --- a/packages/markdown/package.json +++ b/packages/markdown/package.json @@ -67,6 +67,7 @@ "remark-rehype": "^11.1.2", "shiki": "^3.21.0", "sort-keys": "^6.0.0", + "three": "0.170.0", "unified": "^11.0.5", "unist-util-find-after": "^5.0.0", "unist-util-visit": "^5.1.0", diff --git a/packages/markdown/postbuild.mjs b/packages/markdown/postbuild.mjs index f28c9e81..2934a09b 100644 --- a/packages/markdown/postbuild.mjs +++ b/packages/markdown/postbuild.mjs @@ -92,6 +92,9 @@ async function downloadAndExtractZip(url, destination) { } async function postbuild() { + // Read openscad-config.json for WASM asset configuration + const openscadConfig = JSON.parse(await readFile("openscad-config.json", "utf-8")); + // Download and extract zips const zipFiles = [ { @@ -102,6 +105,10 @@ async function postbuild() { url: "https://github.com/openpatch/online-ide/releases/download/v2.2.1-hyperbook.5/dist-embedded.zip", dst: path.join("./dist", "assets", "directive-onlineide", "include"), }, + { + url: openscadConfig.wasmBuild.url, + dst: path.join("./dist", "assets", openscadConfig.wasmBuild.target), + }, ]; for (let zip of zipFiles) { @@ -358,6 +365,20 @@ async function postbuild() { "struktolab-renderer.umd.js", ), }, + { + src: path.join("./node_modules", "three", "build", "three.module.js"), + dst: path.join("./dist", "assets", "directive-openscad", "three.module.js"), + }, + { + src: path.join("./node_modules", "three", "examples", "jsm", "loaders", "STLLoader.js"), + dst: path.join("./dist", "assets", "directive-openscad", "STLLoader.js"), + rewriteThreeImport: true, + }, + { + src: path.join("./node_modules", "three", "examples", "jsm", "controls", "OrbitControls.js"), + dst: path.join("./dist", "assets", "directive-openscad", "OrbitControls.js"), + rewriteThreeImport: true, + }, ]; for (let asset of assets) { @@ -368,6 +389,13 @@ async function postbuild() { mangle: true, }); await writeFile(asset.dst, result.code); + } else if (asset.rewriteThreeImport) { + // Rewrite bare `from 'three'` specifier to a relative path so the file + // works as a standalone ES module without an import map. + let code = await readFile(asset.src, "utf8"); + code = code.replaceAll("from 'three'", "from './three.module.js'"); + await mkdir(path.dirname(asset.dst), { recursive: true }); + await writeFile(asset.dst, code); } else { await cp(asset.src, asset.dst, { recursive: true }); } diff --git a/packages/markdown/src/remarkDirectiveOpenscad.ts b/packages/markdown/src/remarkDirectiveOpenscad.ts index 19fdaba7..add4c122 100644 --- a/packages/markdown/src/remarkDirectiveOpenscad.ts +++ b/packages/markdown/src/remarkDirectiveOpenscad.ts @@ -30,7 +30,7 @@ export default (ctx: HyperbookContext) => () => { return (tree: Root, file: VFile) => { visit(tree, function (node) { if (isDirective(node) && node.name === name) { - const { src = "", id = hash(node), height } = node.attributes || {}; + const { src = "", id = hash(node), height, library } = node.attributes || {}; const data = node.data || (node.data = {}); expectContainerDirective(node, file, name); @@ -59,44 +59,75 @@ export default (ctx: HyperbookContext) => () => { class: "directive-openscad", "data-id": id, ...(height ? { style: `--openscad-height: ${height}` } : {}), + ...(library ? { "data-library": library } : {}), }; data.hChildren = [ { type: "element", tagName: "div", - properties: { - class: "preview-container", - }, + properties: { class: "left-side" }, children: [ { type: "element", tagName: "div", - properties: { - class: "preview-header", - }, + properties: { class: "preview-container" }, children: [ { - type: "text", - value: i18n.get("openscad-preview"), + type: "element", + tagName: "div", + properties: { class: "preview-header" }, + children: [{ type: "text", value: i18n.get("openscad-preview") }], + }, + { + type: "element", + tagName: "div", + properties: { class: "canvas-wrapper" }, + children: [ + { + type: "element", + tagName: "canvas", + properties: { class: "preview-canvas" }, + children: [], + }, + { + type: "element", + tagName: "div", + properties: { class: "canvas-overlay hidden" }, + children: [], + }, + ], }, ], }, { type: "element", - tagName: "canvas", + tagName: "div", properties: { - class: "preview-canvas", + class: "canvas-params-splitter hidden", + role: "separator", + "aria-label": "Resize canvas and parameters", }, children: [], }, { type: "element", - tagName: "pre", - properties: { - class: "output", - }, - children: [], + tagName: "div", + properties: { class: "parameters-panel hidden" }, + children: [ + { + type: "element", + tagName: "div", + properties: { class: "parameters-header" }, + children: [{ type: "text", value: i18n.get("openscad-parameters") }], + }, + { + type: "element", + tagName: "div", + properties: { class: "parameters-body" }, + children: [], + }, + ], }, ], }, @@ -113,42 +144,18 @@ export default (ctx: HyperbookContext) => () => { { type: "element", tagName: "div", - properties: { - class: "editor-container", - }, + properties: { class: "editor-container" }, children: [ { type: "element", tagName: "div", - properties: { - class: "buttons", - }, + properties: { class: "buttons" }, children: [ { type: "element", tagName: "button", - properties: { - class: "tab-code active", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-code"), - }, - ], - }, - { - type: "element", - tagName: "button", - properties: { - class: "tab-parameters", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-parameters"), - }, - ], + properties: { class: "render" }, + children: [{ type: "text", value: i18n.get("openscad-render") }], }, ], }, @@ -156,16 +163,11 @@ export default (ctx: HyperbookContext) => () => { type: "element", tagName: "code-input", properties: { - class: "editor active line-numbers", + class: "editor line-numbers", language: "clike", template: "openscad-highlighted", }, - children: [ - { - type: "raw", - value: htmlEntities(source), - }, - ], + children: [{ type: "raw", value: htmlEntities(source) }], }, { type: "element", @@ -174,84 +176,30 @@ export default (ctx: HyperbookContext) => () => { class: "parameters", placeholder: '{"size": 20, "height": 10}', }, - children: [ - { - type: "text", - value: "{}", - }, - ], + children: [{ type: "text", value: "{}" }], }, { type: "element", tagName: "div", - properties: { - class: "buttons bottom", - }, + properties: { class: "buttons bottom" }, children: [ { type: "element", tagName: "button", - properties: { - class: "render", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-render"), - }, - ], + properties: { class: "copy" }, + children: [{ type: "text", value: i18n.get("openscad-copy") }], }, { type: "element", tagName: "button", - properties: { - class: "copy", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-copy"), - }, - ], + properties: { class: "download-stl" }, + children: [{ type: "text", value: i18n.get("openscad-download-stl") }], }, { type: "element", tagName: "button", - properties: { - class: "download-stl", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-download-stl"), - }, - ], - }, - { - type: "element", - tagName: "button", - properties: { - class: "download-3mf", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-download-3mf"), - }, - ], - }, - { - type: "element", - tagName: "button", - properties: { - class: "reset", - }, - children: [ - { - type: "text", - value: i18n.get("openscad-reset"), - }, - ], + properties: { class: "reset" }, + children: [{ type: "text", value: i18n.get("openscad-reset") }], }, { type: "element", @@ -261,12 +209,7 @@ export default (ctx: HyperbookContext) => () => { title: i18n.get("ide-fullscreen-enter"), "aria-label": i18n.get("ide-fullscreen-enter"), }, - children: [ - { - type: "text", - value: "⛶", - }, - ], + children: [{ type: "text", value: "⛶" }], }, ], }, diff --git a/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap index 63fd4dd4..5884f71c 100644 --- a/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap +++ b/packages/markdown/tests/__snapshots__/remarkDirectiveOpenscad.test.ts.snap @@ -3,16 +3,25 @@ exports[`remarkDirectiveOpenscad > should transform basic openscad 1`] = ` " --" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16e4e023..85eab820 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: sort-keys: specifier: ^6.0.0 version: 6.0.0 + three: + specifier: 0.170.0 + version: 0.170.0 unified: specifier: ^11.0.5 version: 11.0.5 @@ -8168,6 +8171,9 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + three@0.170.0: + resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==} + throttle-debounce@5.0.2: resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} engines: {node: '>=12.22'} @@ -17717,6 +17723,8 @@ snapshots: dependencies: editions: 6.22.0 + three@0.170.0: {} + throttle-debounce@5.0.2: {} through2@4.0.2: diff --git a/website/de/book/elements/openscad.md b/website/de/book/elements/openscad.md index 8bd1e09a..f0d167bb 100644 --- a/website/de/book/elements/openscad.md +++ b/website/de/book/elements/openscad.md @@ -43,6 +43,9 @@ cube([20,20,20], center=true); | `id` | Eindeutige ID für Persistenz | automatisch generiert | | `src` | Lädt Quellcode aus einem externen Dateipfad | eingebetteter Codeblock | | `height` | Höhe des Editor-/Vorschau-Containers | `calc(100dvh - 80px)` | +| `library` | Kommaseparierte Liste von Bibliotheken, die in die OpenSCAD-Umgebung geladen werden sollen | keine | + +Mögliche Bibliotheken sind: BOSL2, BOSL, MCAD, NopSCADlib, fonts ## Code aus Datei laden @@ -51,28 +54,18 @@ cube([20,20,20], center=true); ::: ```` -## Parameter (JSON) - -Öffnen Sie den Tab **Parameters** und geben Sie ein JSON-Objekt an. Jedes Schlüssel/Wert-Paar wird als `-Dname=value` an OpenSCAD übergeben. - -Beispiel: - -```json -{ - "size": 24, - "height": 16, - "segments": 64, - "rounded": true, - "label": "A" -} -``` - ## Beispiel mit Variablen ````md :::openscad ```scad +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + $fn = segments; module body(size, height, rounded) { @@ -100,6 +93,12 @@ difference() { :::openscad ```scad +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + $fn = segments; module body(size, height, rounded) { @@ -122,3 +121,105 @@ difference() { ``` ::: + +## Beispiel mit Bibliothek + +````hyperbook +:::openscad{library="BOSL2"} +```scad +includeopenscad-preview- - ++++ +openscad-preview++ + ++- -cube([20,20,20], center=true); - + +cube([20,20,20], center=true); ++include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +::: +```` + +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +::: diff --git a/website/en/book/changelog.md b/website/en/book/changelog.md index 3d407272..788a209e 100644 --- a/website/en/book/changelog.md +++ b/website/en/book/changelog.md @@ -38,6 +38,18 @@ If you need a new feature, open an [issue](https://github.com/openpatch/hyperboo :::: --> +## v0.92.0 + +::::tabs + +:::tab{title="Improved :+1:" id="improved"} + +- Add OpenSCAD element for rendering OpenSCAD code with interactive 3D viewer. [Learn more](/elements/openscad) + +::: + +:::: + ## v0.91.1 ::::tabs diff --git a/website/en/book/elements/openscad.md b/website/en/book/elements/openscad.md index e7b9ef46..9fbbee9e 100644 --- a/website/en/book/elements/openscad.md +++ b/website/en/book/elements/openscad.md @@ -42,6 +42,9 @@ cube([20,20,20], center=true); | `id` | Unique id for persistence | auto-generated | | `src` | Load source from an external file path | inline code block | | `height` | Height of the editor/preview container | `calc(100dvh - 80px)` | +| `library` | Comma-separated list of libraries to load into the OpenSCAD environment | none | + +Possible libraries include: BOSL2, BOSL, MCAD, NopSCADlib, fonts ## Load code from file @@ -49,29 +52,18 @@ cube([20,20,20], center=true); :::openscad{src="openscad/example.scad"} ::: ```` - -## Parameters (JSON) - -Open the **Parameters** tab and provide a JSON object. Each key/value pair is passed to OpenSCAD as `-Dname=value`. - -Example: - -```json -{ - "size": 24, - "height": 16, - "segments": 64, - "rounded": true, - "label": "A" -} -``` - ## Example with variables ````md :::openscad ```scad +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + $fn = segments; module body(size, height, rounded) { @@ -99,6 +91,12 @@ difference() { :::openscad ```scad +segments = 5; +size = 5; +height = 10; +rounded = 1; +label = "K"; + $fn = segments; module body(size, height, rounded) { @@ -121,3 +119,105 @@ difference() { ``` ::: + +## Example with Library + +````hyperbook +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +::: +```` + +:::openscad{library="BOSL2"} +```scad +include +include + +$fn = 100; +rod_diameter = 12; + +module bridge(width = 230, height = 80, rod_diameter = 12) { + pilar_width = height / 2; + pilar_height = height + 5; + middle_width = width - pilar_width * 2; + middle_thickness = max(rod_diameter, 19); + rod_diameter_plus_threshold = rod_diameter + 0.5; + module pilar() { + difference() { + cube([pilar_width, middle_thickness + 10, pilar_height], center=true); + rotate([90, 0, 0]) + translate([0, 0, -middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold, center=true); + rotate([90, 0, 0]) + translate([0, 0, middle_thickness/2]) + cylinder(h=middle_thickness, d=rod_diameter_plus_threshold / 4, center=true); + translate([0, 0, pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + translate([0, 0, -pilar_height/2]) + cylinder(h=rod_diameter_plus_threshold*2, d=rod_diameter_plus_threshold, center=true); + } + } + + module middle() { + s = [[0, 0], [middle_width, 0], [middle_width, -height / 2 - 20], [middle_width - 20, -height / 2 - 10], [20, -height / 2 - 10], [0, -height / 2 - 20]]; + rotate([90, 0, 0]) + translate([0,2.5,0]) + hex_panel(s, 2, 10, h=12, frame=5); + } + + middle(); + translate([-pilar_width / 2, 0, -height/2]) + pilar(); + translate([middle_width + pilar_width / 2, 0, -height/2]) + pilar(); +} + +bridge(rod_diameter=rod_diameter); +``` + +:::