From 0fee215e6f91df6a9b9ab45933638c855a945bc2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 21:07:37 -0400 Subject: [PATCH 1/2] Choose the antimeridian world copy per tile Wide views rendered as horizontal bands stretched across the map, in area fills and lines. Each vertex stage computed world.x = p.x + rint(wrap_x - p.x), which places a vertex at the world copy nearest the camera. That rule dates from tile-local geometry, where p.x spans one tile and every vertex of a tile gets the same result. The renderer builds one scene against one origin, so p.x spans the whole view and the result changes halfway across. A triangle lying over the half-world seam ended up with corners a whole world apart. Six corners of a quad share one anchor, so label quads were unaffected. Camera.placeTileX picks the copy by the tile's centre. Camera.wrappedCopy names the second copy when the view is wide enough to see both, from around z1 down, and the tile is then listed and placed twice. Camera.mvpOrigin wraps the origin-to-camera delta in x, so a camera that crossed the antimeridian since the scene was built moves the whole scene at once. The vertex stages now only project. Uniforms keeps wrap_x as a reserved field to hold the block at 128 bytes. --- shaders/d3d12/fill.vert.hlsl | 12 ++-- shaders/d3d12/overlay.vert.hlsl | 8 +-- shaders/d3d12/pattern.vert.hlsl | 3 +- shaders/d3d12/sprite.vert.hlsl | 3 +- shaders/metal.metal | 26 ++++----- shaders/vk/fill.vert | 12 ++-- shaders/vk/fill.vert.spv | Bin 5368 -> 5080 bytes shaders/vk/overlay.vert | 8 +-- shaders/vk/overlay.vert.spv | Bin 2196 -> 1844 bytes shaders/vk/pattern.vert | 3 +- shaders/vk/pattern.vert.spv | Bin 4292 -> 4004 bytes shaders/vk/sprite.vert | 3 +- shaders/vk/sprite.vert.spv | Bin 5568 -> 5280 bytes src/camera.zig | 97 +++++++++++++++++++++++++++++++- src/map.zig | 20 +++++-- src/map_object.zig | 53 ++++++++++++++--- src/scene/types.zig | 8 ++- 17 files changed, 202 insertions(+), 54 deletions(-) diff --git a/shaders/d3d12/fill.vert.hlsl b/shaders/d3d12/fill.vert.hlsl index 5e27e13..74b41b1 100644 --- a/shaders/d3d12/fill.vert.hlsl +++ b/shaders/d3d12/fill.vert.hlsl @@ -37,13 +37,13 @@ struct VSOut { float4 color : TEXCOORD0; }; -// Longitude is cyclic: draw each vertex at the world instance nearest the -// camera, so a view straddling the antimeridian is seamless. The world period -// is exactly 1.0 in ANY translated frame, so this works on tile-local -// coordinates as long as u_wrap_x is stated in the same frame. +// Longitude is cyclic, so every tile has a world copy every 1.0 world units +// and one of them has to be drawn. That choice belongs to the HOST, which +// makes it once per tile (Camera.placeTileX) and again once per scene, in the +// matrix (Camera.mvpOrigin takes the short way round in x). Deciding it here, +// per vertex, tore every primitive lying across the half-world seam. float4 project(float2 p) { - float2 world = float2(p.x + round(u_wrap_x - p.x), p.y); - return mul(u_mvp, float4(world, 0.0, 1.0)); + return mul(u_mvp, float4(p, 0.0, 1.0)); } // The per-vertex zoom visibility window, quantized to 1/256 zoom steps and diff --git a/shaders/d3d12/overlay.vert.hlsl b/shaders/d3d12/overlay.vert.hlsl index a476cad..52532c1 100644 --- a/shaders/d3d12/overlay.vert.hlsl +++ b/shaders/d3d12/overlay.vert.hlsl @@ -28,10 +28,10 @@ struct VSOut { VSOut main(VSIn i) { VSOut o; - // The chart shader's antimeridian wrap: draw at the world instance nearest - // the camera. A whole world width is 1.0 in the relative frame too. - float2 world = float2(i.a_world.x + round(u_wrap_x - i.a_world.x), i.a_world.y); - float4 clip = mul(u_mvp, float4(world, 0.0, 1.0)); + // No antimeridian wrap here: the host turns the whole frame at once, in + // the matrix (Camera.mvpOrigin). Wrapping per vertex tore any primitive + // lying across the half-world seam. + float4 clip = mul(u_mvp, float4(i.a_world, 0.0, 1.0)); // z = 0 is the near plane. The chart's paint-order depths are all in (0,1) // and this pass writes no depth, so host content is never hidden by the // chart and never hides it from a later pass. diff --git a/shaders/d3d12/pattern.vert.hlsl b/shaders/d3d12/pattern.vert.hlsl index 604b983..acca866 100644 --- a/shaders/d3d12/pattern.vert.hlsl +++ b/shaders/d3d12/pattern.vert.hlsl @@ -34,8 +34,7 @@ struct VSOut { VSOut main(VSIn i) { VSOut o; - float2 world = float2(i.a_pos.x + round(u_wrap_x - i.a_pos.x), i.a_pos.y); - float4 clip = mul(u_mvp, float4(world, 0.0, 1.0)); + float4 clip = mul(u_mvp, float4(i.a_pos, 0.0, 1.0)); float2 off = i.a_off; if ((i.a_flags & 1) != 0) { off = float2(off.x * u_rot_cos - off.y * u_rot_sin, diff --git a/shaders/d3d12/sprite.vert.hlsl b/shaders/d3d12/sprite.vert.hlsl index 332b43d..adb09dc 100644 --- a/shaders/d3d12/sprite.vert.hlsl +++ b/shaders/d3d12/sprite.vert.hlsl @@ -45,8 +45,7 @@ VSOut main(VSIn i) { bool flip = ((i.a_pack >> 8) & 0xFF) != 0; float tangent = float((i.a_pack >> 16) & 0xFF) / 256.0 * 6.2831853071795864; - float2 world = float2(i.a_pos.x + round(u_wrap_x - i.a_pos.x), i.a_pos.y); - float4 clip = mul(u_mvp, float4(world, 0.0, 1.0)); + float4 clip = mul(u_mvp, float4(i.a_pos, 0.0, 1.0)); float2 off = i.a_off; // Keep a tangent-rotated run (line-following text) upright: if the run, diff --git a/shaders/metal.metal b/shaders/metal.metal index 3eb45bf..21fc447 100644 --- a/shaders/metal.metal +++ b/shaders/metal.metal @@ -25,7 +25,7 @@ struct U { float size_scale; // pixel density x symbol size multiplier float zoom; // fractional zoom * 256, tested against zmin/zmax float zoom_t; // fract(zoom): mix factor for zoom-interpolated paint - float wrap_x; // camera centre x IN THE VERTEX FRAME (antimeridian) + float wrap_x; // RESERVED: the host now wraps per tile and per scene float rot_sin; float rot_cos; float4 color; // SDF halo background; SDF fragment stage only @@ -72,14 +72,15 @@ static_assert(sizeof(Quad) == 40, "Quad must match scene.Quad (40 B)"); constant uint FLAG_MAP_ALIGN = 1u; -// Longitude is cyclic: draw each vertex at the world instance nearest the -// camera (x, x-1 or x+1), so a view straddling the antimeridian is seamless. -// The world period is exactly 1.0 world unit in ANY translated frame, so this -// works on tile-local coordinates as long as u.wrap_x is stated in the same -// frame (host: camera.center.x - tile_origin.x). +// Longitude is cyclic, so every tile has a world copy every 1.0 world units +// and one of them has to be drawn. That choice belongs to the HOST, which +// makes it once per tile (Camera.placeTileX) and again once per scene, in the +// matrix (Camera.mvpOrigin takes the short way round in x). Deciding it here, +// per vertex, tore every primitive lying across the half-world seam: the +// corners on one side moved a whole world and the corners on the other did +// not, so a coastline came out as a band stretched across the map. static inline float4 project(constant U &u, float2 p) { - float2 world = float2(p.x + rint(u.wrap_x - p.x), p.y); - return u.mvp * float4(world, 0.0, 1.0); + return u.mvp * float4(p, 0.0, 1.0); } // The per-vertex zoom visibility window: zmin/zmax quantized to 1/256 zoom @@ -276,7 +277,7 @@ fragment float4 sdf_frag(QuadOut in [[stage_in]], // // The stream is world-space positions relative to the frame's own origin, with // a colour per vertex. The host supplies the matching uniform (an mvp built -// for that origin), so this shader reads only mvp and wrap_x. +// for that origin), so this shader reads only mvp. struct OverlayVertex { // scene.OverlayVertex, 24 B // packed_float2/4 hold the stride at 24; natural alignment would pad to 32 // and shear the stream. @@ -294,10 +295,9 @@ vertex OverlayOut overlay_vert(uint vid [[vertex_id]], const device OverlayVertex *verts [[buffer(0)]], constant U &u [[buffer(2)]]) { OverlayVertex v = verts[vid]; - // The same antimeridian wrap the scene shaders apply: draw at the world - // instance nearest the camera, so an overlay across the seam is seamless. - float2 world = float2(v.world.x + rint(u.wrap_x - v.world.x), v.world.y); - float4 clip = u.mvp * float4(world, 0.0, 1.0); + // No antimeridian wrap here either: the host turns the whole frame at + // once, in the matrix (Camera.mvpOrigin). See project() above. + float4 clip = u.mvp * float4(v.world, 0.0, 1.0); // z = 0 is the near plane. Every paint-order depth the scene writes is in // (0,1), so a depth-test-only overlay pass is never hidden by the map it // annotates — and it writes no depth, so it cannot hide the map either. diff --git a/shaders/vk/fill.vert b/shaders/vk/fill.vert index 4922a19..6a75bea 100644 --- a/shaders/vk/fill.vert +++ b/shaders/vk/fill.vert @@ -34,13 +34,13 @@ layout(set = 1, binding = 0) uniform U { layout(location = 0) out vec4 v_color; -// Longitude is cyclic: draw each vertex at the world instance nearest the -// camera, so a view straddling the antimeridian is seamless. The world period -// is exactly 1.0 in ANY translated frame, so this works on tile-local -// coordinates as long as u.wrap_x is stated in the same frame. +// Longitude is cyclic, so every tile has a world copy every 1.0 world units +// and one of them has to be drawn. That choice belongs to the HOST, which +// makes it once per tile (Camera.placeTileX) and again once per scene, in the +// matrix (Camera.mvpOrigin takes the short way round in x). Deciding it here, +// per vertex, tore every primitive lying across the half-world seam. vec4 project(vec2 p) { - vec2 world = vec2(p.x + round(u.wrap_x - p.x), p.y); - return u.mvp * vec4(world, 0.0, 1.0); + return u.mvp * vec4(p, 0.0, 1.0); } // The per-vertex zoom visibility window, quantized to 1/256 zoom steps and diff --git a/shaders/vk/fill.vert.spv b/shaders/vk/fill.vert.spv index 984df9e49a5e66228b944597a7bde900ade5a736..089cb3783ed51d8fba775ce665e93626bbe33d11 100644 GIT binary patch literal 5080 zcmZveiIY^-6^Eb9BI+n2vbY3hL?bB?6;VNgf>;?)lu^VzY1*L&8k_01y8$zr1T@4% zg~TN8CNV@~42x@GjNpP+`4?F(RW9H6`kvv{l#lw&J?D3pd+xdCy{2o@+~c$Cxa_2C za`s8qd8THQU^2Mgd>ve~Zq0&Ld*gydmtCyKDOp$UsLvEMpKh?VTpd-ef%`xU>;nhD zLGUhk4;%qq{F}sjlq5Yw7vp=_w+~yRS>I9_ZqM63vT!LXeqGcApUXYjG}cY!c4gkS zOOm}iI}L7ZN2iV+&NK_XHQcOJMoaaPkyfRXtrK9r8Q3x8HOxdFsg*Zbx3;rb*MnZF zQ})@qy@B4F>z==Hf2mzB4cDp^6};zaRmUo&)^NF2k?+axFC9+ocvqi+o&M&JEXF~2>4N{K#o~Uo7 zR+II-KS19GypIc6$F@cvyR(J4UN$_pZ(;46x}$sNz10d``-xPUqd5Rup2pv+Wb~pQG<+)#?p;tG_c=E^Vwd+MCgPvwx#+s+EQ+&08zY zb|tUbUda1stGBA{YTaHQpYz^j&R0j<>$p4p!sdNqTgfF~TWz&zWo6vvood@wtBu<{ zuQTsseFs^~rOs5UA3{IYo9_I**k0<~n)df{|ImB#_odvimo`_kY%tBHb*;ibX4AP@ z#g3snAE!>>=A!$ApXZiaUf|3tx2nK-PPsJ&&e@e4Dsa{ycT0hDcI9p_aMmVwcY$+u zSUUamJS>-TRlbUVGCIjsq7YeGamZ z@%^m&y03QkJQw(ceqOHayjuVH$Wfnm&wUa2_S<9m7omIh*l#hi_hI}JR`+wCh#y3b z`pmZ)_(c3#WcLic^BdI3S4}qiV*Yzs&85HnPO{5$g%%kWcyJ(-yahf`#pso{rfwzvF>BN z>iZHG{;!~$^K9&|vg$hsW+VB&+ABG2!anS0br0?4RX>op=!@TN_jVuS-vm6%=Tp5o z-vZ8#(B|CuPQA_QJlF&GdWY4$j4`*m?|Gc@|H$qWc6Hx-IsMgr@6|8o-SVvF_A$p7 zFn%BOt+H+bF%RCOzrjVlF+unDHtfEu{@#Y}{QG+wy7og!U&em&qrjQFifY_TeF+tL zo;v6Q^4hIUE@J)vhV!Y-I@GnD!ny-Eo7${NeF-<=o__mh0b{i38V}1?fR^Y#c^*6xp zlB;I<=`2;XdKF;wcWaE5i;~alRmXBJVL^f95xcRJ41Ml8ZI*_%(14Xgid2 z-?dYbUkB5GeDv=PWb+Nty|-Ea1N1B6Rk~uY|C{W_yalYuZ`J?60Wb%+ue!dm$6;i@ zSE0X;d;}Qp?0*29nd0n!nD{<^X}ptTAt zFmWv7-1&*K&N$ONWPP>8d7nu2{Rqx|^!bLG)T4gy+I#ky3aoz$>uJFHPiOVF!1wM# zV2zUa%x)!n+TIsCVGroE~CUFDw1fT0e*?i>Aa;Lgt z_p0O{%#MYdp6d27#GV#ow=1n`b+XhLA8%K?)w&7RTZkV=-oqmF@p^e@8);7`Ag3Cw z`i|Tl$VQN#0mJyzeJ1uav4?Y;tz^x;rB0(%sn=*Sc=y?^O;=0pO1WN@AIjsM;&h`i zf#`A0L9Wy}#0|kswaU%X-b36lT&vM3wb^Ra;~;ac(rEX%*>IIcz0pEOKOaCZPgZs{ zTBRl>d$C6tTdmh+oy{`r-N0FFKu%A@TnDpFxm`9r_p5Yw-L<^~a;bX~u6--~q1?gj_CEVg?5TFW(PXy9d*{lf9o1%M7xr-W z8|fa4lo)&AU+S)w`hWT=l=5 zY-c*3Eb~TL!Mf@T(e#agMZ`O2ZG9uy&R9<9cfR`VXOzFrR?fHu*naItfRFiek><)D zM^*2adGnC!`bX(@0n#|@$eosIorvyZttCifTz@h4Nl4eWm!;WJ`MPouV_ZLs*DtI& zitO$+%ho4*S>oK!@;t74PWGgIGH|};pMo^sb)x5wq92xQ<5vM6<5we%w@>5ETLXN; zUYlFDoE!BvV!!`>*KfTGfV0!DeGwQa*w>)DzwlSkUB~sOlFvD4e+6l;#=C!Q@4sBw zHz(WpTaxa1>err0T&#B&w&xiAe*>NNd;7ZwooADN`}%fnX6Hoy{n(yq*pH*zkLy2y zbbiJCJ(IXt?*(k*5ck1zyuQ!Asn5Q(VEgXgUa);K-ME(aIy!PcHv<2!C$Hb$;4}B?)M(* zYd@Me<6{kjA zyd}u*0MGsf5P9ECeB^x(+gy2Lyg%Ou=3P!l_Vi-n%N3;{)ylU z;+*LZfw4Z$${4xG`4PG~enTSX$LQv`o;k+I#a@1j?sq9_{0zN+zdwhQkMsTox_R=E z_bYVs{HD2%wsEfG9sWId1?bzCY~MA%H-7;0fqcyGkLcDLV|x3Me*(r8@gh@k)_+cZ zb6x}X^g8kl;J0xVa9wRZt-@E@;;GHSn{l6uC4!<;>$=`vqJq_qv z1N@%ax4r!X7*m9~`u+*bT?y>P^^LdJ14#Yv5pg!~`)SS`;By36OWbVak-%^1QAod~ zawh=qU7U~ap>f`)IQymO^0D7j(cSOKAYwj*E+2JPp!e7LFr0jRcRqq{o_u@{oR51I z`e)dW^E(~bhyGZ@dANr6IQsY)y8IY7>3r71xsEaa<4m;U{Ek8Qp8L!Lp5I*LvB2{? z4(a*%-Pr)_G3-srw)dma&jsei@4E zTLkR+cx24?%EZU{d=^{IJN;6c$5rU^zLU=5^GF}(q3?4*&f4nnetZGl_05a@UyUvw zGy5XCGrI;vObJ~+zT?}`&6AJXWpr!XXXIUrE+4gbpj*2FBBqKiAGOEP&6AJs!cO$< zKwqr43%!3YH8}ZL?>hAU^}YlrAM4f8&6AJX6X^YWnS_&%+6{DT7w6Q(mXF$BMmJA> ohO_hjw}5Nvi*HgJ-975_opEhB?_Zpw(wTX$I=~q3oVs`J{}E==n*aa+ diff --git a/shaders/vk/overlay.vert b/shaders/vk/overlay.vert index 03a3986..824004e 100644 --- a/shaders/vk/overlay.vert +++ b/shaders/vk/overlay.vert @@ -32,10 +32,10 @@ layout(set = 1, binding = 0) uniform U { layout(location = 0) out vec4 v_color; void main() { - // The chart shader's antimeridian wrap: draw at the world instance nearest - // the camera. A whole world width is 1.0 in the relative frame too. - vec2 world = vec2(a_world.x + round(u.wrap_x - a_world.x), a_world.y); - vec4 clip = u.mvp * vec4(world, 0.0, 1.0); + // No antimeridian wrap here: the host turns the whole frame at once, in + // the matrix (Camera.mvpOrigin). Wrapping per vertex tore any primitive + // lying across the half-world seam. + vec4 clip = u.mvp * vec4(a_world, 0.0, 1.0); // z = 0 is the near plane. The chart's paint-order depths are all in (0,1) // and this pass writes no depth, so plugin content is never hidden by the // chart and never hides it from a later pass. diff --git a/shaders/vk/overlay.vert.spv b/shaders/vk/overlay.vert.spv index 857bd55c7c269c626748289420fd88db399418e7..fd0c06be6050642d224023d81353c26adb4e27ad 100644 GIT binary patch literal 1844 zcmZ9L%Wl&^6o#islN1UqrSt-nkhDN4frVSZ`3)P-w811z!%^;Bvs%m&__;Uqv+ueJ zui2cM?x(3g_Ri*5U09l^hMwu-M4rWhB%5JN!h$4DQfW6EZ}dmOw+ zpuBxm_Cx|N@aTJ|Ng5x^uA4pCUt@0&rXRyJ4=H2ogLCjDS(HZ!^EGTdTvgh3{hoN!mtwPPzp%T-9_i65?Gavk%65B& zzf1pN?maG!H-Nn=#ZecmSHhSXtY5;|FW8`jF$38962@-9K9w+L1N&UU*e%$X62@-9 z_<-Ha>=le9b@hJs+Lmyfy{kKK!)Dh_2?y@TopM*RckpF?bm)PfTrjl5$N{UCFz#S* zK`-_;?={^UHg(q3l^ShTAdmZ|Zt&BRY$+dJu&LM5FPwlO+p>8(cl^ZDn|Nw%$i5)m zl5m>(68IL#adE^FgLXna{0Kc)q^U>lv%-dB*Mo1WC0USk8QB zKJ~EKiOXk>Z3%~Z*jKIf^CdR(!XY)OcT<{~iF5w8?$q0nf4A^~H5J7^T|7R7I=7_R z1AgYh=6?_j|9){N7e6-pb^d$0)6@B}I|cSc-x2e&Ck{D(gz*1CE%wT{P3~)82Gy)0mp{FEeYJcQx5OHR$x*y?0SJQ+dv)Yu_GZCA6&!lFV(7UT9M0 x0%I0Knm%b~3N8Uj^HZd;#zV7gZ??4*+ub6x)`3EC>(Kj{@pWk3MIs#~tB@!PL>&)VphY_E01n=s8(UQpWT8MdP+Ds9xqoLSVzypP@dreJ386?jLOxlT)1 zJH=c%b5}MySNgoGY!=hcOXs*bVV3pdPH-g=5xm zA1fTS!F{T5%o^@L}OnAWf=}T*|z=Jt7@k7#y=M2spDRkmrUb*sb7St?`O5 z_4Dqi5zI{BsBv6-Q$iio2%eDeR=DG&c6`Zi@6eQvKlWSNIgfK~{DO2A_S@Qtr*7u@LMIkG znD=7s&!u-stR2kn9&TOTp+4rqBId6cdPXkZ3BL{M2GgVY0>6?Mb03!QR(KCv@}YkI z58&~k?^h+vig{R{YXxufTo*o{XG$FWFXbWc4GD4Z17hIp?|}PY|5^CnlujMk?HM!D zIio4Db<9eKZz+y@&Pj-cf1!@p{|0jK2JT3>%YuX)yob9IcrZEcNr-z})bgO`)GYRjA Xd*Bbo$NIl8dT!%Af&b~%Y)JkC&Qy%r diff --git a/shaders/vk/pattern.vert b/shaders/vk/pattern.vert index b0d8af7..e51a40f 100644 --- a/shaders/vk/pattern.vert +++ b/shaders/vk/pattern.vert @@ -29,8 +29,7 @@ layout(location = 0) out vec2 v_anchor; layout(location = 1) out vec2 v_cell; void main() { - vec2 world = vec2(a_pos.x + round(u.wrap_x - a_pos.x), a_pos.y); - vec4 clip = u.mvp * vec4(world, 0.0, 1.0); + vec4 clip = u.mvp * vec4(a_pos, 0.0, 1.0); vec2 off = a_off; if ((a_flags & 1u) != 0u) { off = vec2(off.x * u.rot_cos - off.y * u.rot_sin, diff --git a/shaders/vk/pattern.vert.spv b/shaders/vk/pattern.vert.spv index b805fd29b89c87892107d0f381e206162e8e2740..6b3bada45fa88cd4181ee67bbf7d0c9ee61a83a5 100644 GIT binary patch literal 4004 zcmZ9NX>(Ln5QZ;=1QcbFRRv6liV6ZMAc|~)CebKC7IDGLkW9khBs0zo0i!|?P&CM* zzryk__=zw6BFm-9!soejJDHk0^`^VuUQVBL`rdTR?_HQA^OD8MqU4vP`7BT7!z6HB znd}=pHn_Fk7}>gG=XN8OBpq3xF-wz2GkpPn#|xzijaA?&&7_ZY3$9jaq;<9$^*F$c8?Mx6xkYfpm2#zq zjDFl#VPg1PrIuDHIUD=1vBh#(*8MKQz5qN6&$5tKsoXq^4amyqXy)^b3#nE!*7Gi; zqvgU_J@b3^g>|1WIg>$athFUm#kUQSOIahM0S~z=?`?Q5~uH??NaL$#S_Z2-mQ*vHZ zANzNvdVo*Z8`0gHwsYoe`Di;^+TpjBb_dY!Y-zhcZ7)~bbMfpi=eqr<`z&sXtVcO( z%PAwyd^y(%PF*h6nn8ChAJ_JqqHUcObYP7hDwwCf8qM?V25X4-T(zy&&0o({POQcD z?9}xe@7l&&YXjdqXUCY}^|ix%{lYc;N}6x(p4|3MlkgjV#vCnNer++)T`)u{^YO{Uk?8W#2r173Z)O#=IqOYUa&Vup2 zOXgbNeP}z&auI(P-I}paXS>xuhW+5d+=6|8X`c;`Z&_wD@NK-y2t`%QG~nB!VM=61}I z_Ds&j9&Ta#ov|1FzH`xwvwIgfo4a`5Jv-O(G3IZWPNcbukk)Z+?e$3C-LRdHkv98$ zi|zSVbNe7weJ>sZzDwT0UTWIM3LvlTzE%U@kE7&xc8}*AVwQU%=e+A7Ii6B5*zPT+jV(1@gvhN4^9+vuz+^Ue5U&#F+C6Fi$>e??AV< zeMa6+bor?LD!R3OZzE ze{qjW&&>EfV1M>9fP4qoOFxL1cXQre%ozmc$;TevM>o&!V%*0ObotwP-VnOE^5!{5 z9{^|C?`Py4%lXJVj%}{IF$<9=fO$u-?CE6A$@|O$o|C>0fqv`6x~I_1cYfo2{RrJy zAJ5Jhxybn#-5lpKay~&f$MwuHMlSa98Tx4uHB$8U{hoo7kGn3Qn|B68-Y~j(XMyWz z8|OMb$Wc%P@vfiCxiMrle?Lp;^5*;f(~kVl(apclnOScf$dy6lPvpG$=h3xepI@L` z(|&#T>%e)afrx43{O`p0?YRKVllR+m5$WT%N8con^V_2ywJ)K!*ZvYtKGvE-x5rrP zD|BPzeHWsx@=s>Se!d2-yBQeon(noV)Mstut^zr2XYpI$-S|Dff&2~_(~4jD+8O(O z?lRYeRSpn?brSVf=++NxEGTtr1rV+jyRo0y~*t0IVaMHEmJ z6x5H7f5A_D@fSH>W*mH;de?RDz0PUd^p!I+nQ58XnOT`%GL2_mW;#p; zuA{#8^zG|gU#%TkziIOZJ?3QE>W=!%&D>U(XYh9@R~S~A4ITlT!3j_W=fM?l4Ycu_ z&U%9c?U@<;ref7^K_4qsibq)0&6i8cCG)fsJD>Fc=)k7zGc;O8@2KncRUS`jrDU*J zDC^hYtywKht@`GRMOpW|5PbxA7OS+?chxwHjp(JL zN9#7vJeR07d_DhMa-I7+)VMQqd%n43VoXNeo+gHJF6eEU zldPi!b9H3SAs;Iy{rSqle5IDJ`?xQ8A8n;-p;jn)E;H-A_fqEz!?k_fgMMN2Zm{iQ zYr6~88po2Kw0S?;MvBEroBcRbKJEORtTN)~v!(8S)SV@D z&%v{HE|fj9m=W*8CpdG+{|0U>mu*#_~ z<9q7t$wgnrwL<^q9v7h7zp?hH?w!in<0959un&7wUkscz_qc>rU+cskvdH>tzmL^= z){Xep$UfTFuo`dQ+8cRLn)G#b?bMDqLuV@TZ%Sp)QoFh{rhNgKyI8IBBx{^Y^syu5 zLVp@P_TfHX22scN$3D#w{=PqQ>ee3s&We3_cPD`HVfQ`KKl*gmo9!d$H*ZempFsB; zV*l^6THF0Z{Zq)+TZ;V)R(sEaWk}~(V_vig^L)u_jCT7__j@R3UUOUkJl*T4XpW0u zZ3=Dcfb)2X)qb2mb6;jPk1^`sr@DPxOWk)aYI(+2f!~?1H~!C0?eRJ`-=i%|oM&e( zA9MT-;~X06yJw!L<=m}m(zS1F(w&D&i$30@d)_BgeJ54C&$(bW@EzSoP0z~vk=JJl z>z%;&$9E)r?n?Q{b2oZxo~3Z|XUJpTdw_BBoiK9oZJLIx{dDSkFS31Tj~dI7t&s)M z#|mWme&Rf*mB3i}?VM?@=ylK8ox&K$~}}uC0@G3-JB_gIwy5rCgTvH@Gg= z$AP@}_!l|0vikf>4sA~WIp<5+^Kf3a0qZs2|MryE$NTRFo|)ekd-C3Pf^KjYAN8kz zaq^LS7qYptAmVl-%SY~Kkj?FPDtvm7Z4za!h?8WbW_`I6(_F~Lyz&QEX!|TY#9R_h9Zy?K`Pva6~ zW95x=j&i`69sm*dCbE3Q4I&#Wug^TzBfz*lbYxHYl#}Bg@BKmynHVY)> 8) & 0xFFu) != 0u; float tangent = float((a_pack >> 16) & 0xFFu) / 256.0 * 6.2831853071795864; - vec2 world = vec2(a_pos.x + round(u.wrap_x - a_pos.x), a_pos.y); - vec4 clip = u.mvp * vec4(world, 0.0, 1.0); + vec4 clip = u.mvp * vec4(a_pos, 0.0, 1.0); vec2 off = a_off; // Keep a tangent-rotated run (line-following text) upright: if the run, diff --git a/shaders/vk/sprite.vert.spv b/shaders/vk/sprite.vert.spv index fb017580d190640f6071ba7d3a1e3f82aaeed94a..634a8937c691fdd5f18f82d0a440c86e337b7160 100644 GIT binary patch literal 5280 zcmZ9OiI&fpHs30Fd5unAvbQ`vUz>Gvu*u`i!Lx?an@G^8nYxjwb1+dccN0C)c6qi z6u1oR29JUR;2?Mtybb2SJKzX73i|k4fILjmfvlguWA(HbVsCF$#@a}2*Qn&JO7)Je zeRAQiw-AN@Y-Fc0IaZtO6!seIY9su8+4$A1IU9B`~<_{F{c4W5MoIuQToTxLrS8cZE zIeSoTHkwn&*v|>*mC5ROb1H99az57nRBMff>;S!8i@gKbJMV_xQCYh$yA;{nzP<2y zXO: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 -half_w) return k; + } + return null; +} + /// lon/lat (degrees) -> normalized web-mercator [0,1], y down. pub fn lonLatToWorld(lon: f64, lat: f64) Vec2 { const x = wrapX((lon + 180.0) / 360.0); @@ -90,7 +122,13 @@ pub const Camera = struct { const b: f64 = 2.0 * s / @as(f64, self.vh); const c = std.math.cos(self.rotation); const sn = std.math.sin(self.rotation); - const dx = origin.x - self.center.x; // added before rotate/scale + // SHORT WAY in x: the camera and a scene origin are both world x in + // [0,1), so a camera that has crossed the antimeridian since the + // scene was built is a whole world away by subtraction and next door + // by longitude. Wrapping here turns the whole scene at once, which is + // the only place the turn can happen without splitting geometry: a + // per-vertex wrap tears any primitive lying across the seam. + const dx = wrapDx(origin.x, self.center.x); // added before rotate/scale const dy = origin.y - self.center.y; var m = [_]f32{0} ** 16; m[0] = @floatCast(a * c); @@ -361,3 +399,60 @@ test "halfExtents holds the corners of a rotated viewport" { } } } + +// The seamap style over a wide view came out as horizontal bands stretched +// across the map: the vertex stages chose a world copy PER VERTEX, so a +// triangle lying across the half-world seam had corners a whole world apart. +// The choice is the host's now, and these are the two halves of it. +test "placeTileX puts a whole tile on its nearest copy" { + const std_testing = std.testing; + // z2: four columns, a quarter of the world each. + const span = 0.25; + inline for (.{ 0.0, 0.1234, 0.2361, 0.5, 0.75, 0.9999 }) |origin_x| { + var col: usize = 0; + while (col < 4) : (col += 1) { + const x0 = @as(f64, @floatFromInt(col)) * span; + const dx = placeTileX(x0, span, origin_x); + // Still the same geography: a whole number of worlds from where + // the tile actually is. + const worlds = dx - (x0 - origin_x); + try std_testing.expectApproxEqAbs(@round(worlds), worlds, 1e-12); + // And the nearest copy of it, measured where the tile IS rather + // than at its left edge -- which is what keeps the body of a tile + // on the near side instead of a corner of it. + try std_testing.expect(@abs(dx + span * 0.5) <= 0.5); + } + } +} + +test "wrappedCopy asks for the second copy only when the view can see it" { + const std_testing = std.testing; + // z1: two columns, half the world each, and a view of the whole world. + // The column placed off to the west is visible in the east as well, and + // has to be drawn there too or that side is empty ocean. + try std_testing.expectEqual(@as(?f64, 1), wrappedCopy(-0.7361, 0.5, 0.5)); + // The same tile under a view half that wide is off screen on both sides. + try std_testing.expect(wrappedCopy(-0.7361, 0.5, 0.25) == null); + // A tile under the camera never needs a second copy: its other copies are + // a world away, and no view is that wide. + try std_testing.expect(wrappedCopy(-0.1, 0.25, 0.5) == null); +} + +// mvpOrigin's x delta takes the short way round. Without that a camera that +// crossed the antimeridian since the scene was built is a whole world from +// its origin by subtraction, and the scene is translated off screen. +test "mvpOrigin turns the whole scene at the antimeridian" { + const std_testing = std.testing; + const cam = Camera{ + .origin = .{ .x = 0.99, .y = 0.5 }, + .center = .{ .x = 0.01, .y = 0.5 }, + .zoom = 4, + .vw = 1024, + .vh = 768, + }; + const near = cam.mvpOrigin(cam.origin); + // 0.99 is 0.02 WEST of 0.01, not 0.98 east: the translation is the small + // one, and of the sign that puts the origin left of centre. + const a = 2.0 * cam.worldToPx() / @as(f64, cam.vw); + try std_testing.expectApproxEqAbs(@as(f32, @floatCast(a * -0.02)), near[12], 1e-3); +} diff --git a/src/map.zig b/src/map.zig index 14b6746..65190d7 100644 --- a/src/map.zig +++ b/src/map.zig @@ -42,6 +42,11 @@ pub const SourcedTile = struct { /// Empty means unlabeled, which matches any layer: a caller with one /// source has nothing to disambiguate. source: []const u8 = "", + /// Which world copy to draw this tile at: 0 for the one nearest the view + /// origin, -1 or +1 for the copy a world away. Longitude is cyclic, and a + /// view wide enough sees the same tile on both sides (Camera.wrappedCopy), + /// so the same tile is listed twice with different values here. + wrap: f64 = 0, }; /// The build's output: everything Gpu.SceneData borrows, plus the effective @@ -289,6 +294,8 @@ pub const RasterTile = struct { /// the starting alpha (in: 0, out: opaque) and records the quad span in /// Built.fades; the Map animates it through the quad paint stream. fade: Fade = .none, + /// Which world copy to draw at — see SourcedTile.wrap. + wrap: f64 = 0, pub const Fade = enum(u2) { none, in, out }; }; @@ -896,7 +903,12 @@ pub fn buildSceneWithRasters( // that column's own world position puts it on the wrong side of // the map. Harmless while the viewport is a sliver of the world; // at low zoom it scrambles the geography. - const dx: f32 = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)); + // + // By the tile's CENTRE, so the whole tile lands on one copy. The + // left edge alone leaves the body reaching a full tile span past + // the half-world seam, and at low zoom -- where a tile is a large + // fraction of the world -- that is most of the tile. + const dx: f32 = @floatCast(cameras.placeTileX(rect.x0, tile_span, view.origin.x) + st.wrap); const dy: f32 = @floatCast(rect.y0 - view.origin.y); const tile_quads_first: u32 = @intCast(quads.items.len); var text_scratch: std.ArrayList(types.Quad) = .empty; @@ -1230,7 +1242,7 @@ fn layoutDemLayer( } const rect = rt.id.worldRect(); - const x0: f32 = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)); + const x0: f32 = @floatCast(cameras.placeTileX(rect.x0, rect.x1 - rect.x0, view.origin.x) + rt.wrap); const y0: f32 = @floatCast(rect.y0 - view.origin.y); const x1: f32 = x0 + @as(f32, @floatCast(rect.x1 - rect.x0)); const y1: f32 = @floatCast(rect.y1 - view.origin.y); @@ -1364,7 +1376,7 @@ fn layoutRasterLayer( if (rt.rgba.len < @as(usize, rt.w) * rt.h * 4) continue; const rect = rt.id.worldRect(); - const x0: f32 = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)); + const x0: f32 = @floatCast(cameras.placeTileX(rect.x0, rect.x1 - rect.x0, view.origin.x) + rt.wrap); const y0: f32 = @floatCast(rect.y0 - view.origin.y); const x1: f32 = x0 + @as(f32, @floatCast(rect.x1 - rect.x0)); const y1: f32 = @floatCast(rect.y1 - view.origin.y); @@ -2641,7 +2653,7 @@ test "concatScenes: per-tile geometry plus a global symbol pass equals one build }, .{}); try parts.append(a, .{ .built = part, - .dx = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)), + .dx = @floatCast(cameras.placeTileX(rect.x0, rect.x1 - rect.x0, view.origin.x)), .dy = @floatCast(rect.y0 - view.origin.y), }); } diff --git a/src/map_object.zig b/src/map_object.zig index a09db76..9ba9bd0 100644 --- a/src/map_object.zig +++ b/src/map_object.zig @@ -1696,6 +1696,10 @@ pub const Map = struct { // would outlive the eviction that frees it. var parts: std.ArrayListUnmanaged(map.ScenePart) = .empty; defer parts.deinit(self.gpa); + // How far the scene has to reach either side of the origin, so a tile + // the view sees on BOTH sides of the world can be drawn on both. + const half_w = self.extentsAt(zoom).x; + var tile_parts: usize = 0; var vector_tiles: std.ArrayListUnmanaged(map.SourcedTile) = .empty; var rasters: std.ArrayListUnmanaged(map.RasterTile) = .empty; // How many tiles this pass may TESSELLATE. Cached buckets are free @@ -1728,11 +1732,26 @@ pub const Map = struct { switch (self.cache.sourceKind(key)) { .vector => { const tile = self.cache.get(key) orelse continue; + const rect = key.tileId().worldRect(); + const span = rect.x1 - rect.x0; + // The nearest world copy: see the note in map.zig. A view + // wide enough sees the same tile on both sides of the + // world, and then it is listed -- and placed -- twice. + const dx = cameras.placeTileX(rect.x0, span, origin.x); + const second = cameras.wrappedCopy(dx, span, half_w); try vector_tiles.append(a, .{ .id = key.tileId(), .tile = tile, .source = self.sourceName(key.source), }); + if (second) |copy_dx| { + try vector_tiles.append(a, .{ + .id = key.tileId(), + .tile = tile, + .source = self.sourceName(key.source), + .wrap = copy_dx, + }); + } if (!self.bucketReady(key, zoom)) { if (budget == 0) { deferred += 1; @@ -1741,17 +1760,26 @@ pub const Map = struct { budget -= 1; } const bucket = (try self.bucketFor(style, key, zoom)) orelse continue; - const rect = key.tileId().worldRect(); + const dy: f32 = @floatCast(rect.y0 - origin.y); try parts.append(self.gpa, .{ .built = bucket.built, - // The nearest world copy: see the note in map.zig. - .dx = @floatCast(cameras.wrapDx(rect.x0, origin.x)), - .dy = @floatCast(rect.y0 - origin.y), + .dx = @floatCast(dx), + .dy = dy, }); + tile_parts += 1; + // The same bucket, placed a world over: the second copy + // costs a rebase at concatenation, not a tessellation. + if (second) |copy_dx| { + try parts.append(self.gpa, .{ + .built = bucket.built, + .dx = @floatCast(dx + copy_dx), + .dy = dy, + }); + } }, .raster => { const img = self.cache.getRaster(key) orelse continue; - try rasters.append(a, .{ + const rt = map.RasterTile{ .id = key.tileId(), .source = self.sourceName(key.source), .w = img.w, @@ -1760,7 +1788,16 @@ pub const Map = struct { // A source whose level swapped fades its new tiles // in from invisible over the outgoing ones. .fade = if (self.build_fade_sources & (@as(u8, 1) << key.source) != 0) .in else .none, - }); + }; + try rasters.append(a, rt); + const rect = key.tileId().worldRect(); + const span = rect.x1 - rect.x0; + const dx = cameras.placeTileX(rect.x0, span, origin.x); + if (cameras.wrappedCopy(dx, span, half_w)) |copy_dx| { + var copy = rt; + copy.wrap = copy_dx; + try rasters.append(a, copy); + } }, } } @@ -1797,7 +1834,9 @@ pub const Map = struct { // Tiles whose geometry is actually IN the scene: `have` less // whatever the budget deferred. The gap is what a test can watch // to catch the scene silently losing ground. - .scene_tiles = parts.items.len - 1, // less the global pass + // Distinct tiles, not parts: a tile the view sees on both sides + // of the world is placed twice and is still one tile. + .scene_tiles = tile_parts, .partial = deferred > 0, .origin = origin, .zoom = zoom, diff --git a/src/scene/types.zig b/src/scene/types.zig index 2b4620a..fb524c1 100644 --- a/src/scene/types.zig +++ b/src/scene/types.zig @@ -262,7 +262,13 @@ pub const Uniforms = extern struct { /// slope is the continuous answer and snapping back to a bracket end is /// exactly the jump this field exists to remove. zoom_t: f32, - wrap_x: f32, // camera centre world-x (antimeridian wrap) + /// RESERVED. It carried the antimeridian wrap the vertex stages used to + /// apply per vertex — which tore every primitive lying across the + /// half-world seam, because the corners on one side moved a whole world + /// and the corners on the other did not. The choice of world copy is the + /// host's now: once per tile in Camera.placeTileX, once per scene in + /// Camera.mvpOrigin. Kept so the block stays 128 bytes. + wrap_x: f32, rot_sin: f32, rot_cos: f32, color: [4]f32, // SDF halo background; SDF fragment stage only From 1a7fa4031c8f72aa20e3edb5d3773cc8daaecdc3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 9 Sep 2026 21:07:37 -0400 Subject: [PATCH 2/2] Drop the retired wrap from the overlay uniform charttable's vertex stages placed each vertex at the world copy nearest the camera, and wanted that offset stated beside the MVP. Applied per vertex it split any primitive lying across the half-world seam, so the choice moved to the host, per tile and per scene. Camera.mvpOrigin, which the overlay already builds its MVP with, wraps the x delta itself. The wrap_x field is gone from Uniforms. --- DESIGN.md | 12 +++-- examples/macos/main.m | 1 + shaders/d3d12/README.md | 2 +- shaders/d3d12/fill.frag.hlsl | 28 +++++++++++ shaders/d3d12/fill.vert.hlsl | 29 ++++++++--- shaders/d3d12/overlay.vert.hlsl | 9 ++-- shaders/d3d12/pattern.frag.hlsl | 28 +++++++++++ shaders/d3d12/pattern.vert.hlsl | 12 ++++- shaders/d3d12/sdf.frag.hlsl | 3 +- shaders/d3d12/sprite.vert.hlsl | 3 +- shaders/metal.metal | 57 +++++++++++++++++----- shaders/vk/README.md | 4 +- shaders/vk/fill.frag | 31 +++++++++++- shaders/vk/fill.frag.spv | Bin 372 -> 2240 bytes shaders/vk/fill.vert | 29 ++++++++--- shaders/vk/fill.vert.spv | Bin 5080 -> 6164 bytes shaders/vk/overlay.vert | 9 ++-- shaders/vk/overlay.vert.spv | Bin 1844 -> 1900 bytes shaders/vk/pattern.frag | 23 +++++++++ shaders/vk/pattern.frag.spv | Bin 1248 -> 3012 bytes shaders/vk/pattern.vert | 12 ++++- shaders/vk/pattern.vert.spv | Bin 4004 -> 5072 bytes shaders/vk/sdf.frag | 3 +- shaders/vk/sdf.frag.spv | Bin 3032 -> 3088 bytes shaders/vk/sprite.vert | 3 +- shaders/vk/sprite.vert.spv | Bin 5280 -> 5336 bytes src/camera.zig | 82 ++++++++++++++++---------------- src/gpu/gpu_d3d12.zig | 9 +++- src/gpu/gpu_metal.zig | 25 +++++++--- src/gpu/gpu_vk.zig | 18 +++++-- src/gpu/metal_shim.m | 3 +- src/layout/fill.zig | 4 +- src/map.zig | 40 ++++++++++++---- src/map_object.zig | 19 ++++---- src/scene/batch.zig | 26 ++++++++++ src/scene/types.zig | 50 +++++++++++++++---- 36 files changed, 446 insertions(+), 128 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a89b017..d05a840 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,10 +115,16 @@ independently, so the unit of tessellation is the tile: - **Layout** runs per (tile × style-layer) on worker threads: decode → filter → evaluate data-driven properties → tessellate into *buckets*. - A bucket's vertices are **tile-local f32** (origin at tile corner); each - tile draws with its own tile→clip matrix (lookout's origin-relative rule: + A bucket's vertices are **tile-local f32** (origin at tile corner), so a + cached bucket is reusable at any camera (lookout's origin-relative rule: absolute world f32 quantizes visibly at depth; `overlay.zig` proved the - fix). + fix). At CONCATENATION the buckets are rebased onto one scene origin, and + the scene draws with one matrix, one draw per (tile × layer). The host + therefore picks the world copy at the antimeridian, per tile and per scene, + because a per-vertex choice splits a primitive lying across the seam + (`Camera.placeTileX`). A tile's own bounds also travel with its geometry, + because a merged scene has no per-tile draw state to clip against + (`scene.CLIP_NONE`). - **Fills and lines** draw straight from resident buckets — pan/zoom/rotate never re-tessellates; a zoom change is a matrix change until the tile set itself changes (then new tiles lay out async while old ones keep drawing — diff --git a/examples/macos/main.m b/examples/macos/main.m index 40e2bc8..ee23bea 100644 --- a/examples/macos/main.m +++ b/examples/macos/main.m @@ -823,6 +823,7 @@ int main(int argc, const char *argv[]) { if ((ev = getenv("CHARTTABLE_LON"))) v.lon = atof(ev); if ((ev = getenv("CHARTTABLE_LAT"))) v.lat = atof(ev); if ((ev = getenv("CHARTTABLE_ZOOM"))) v.zoom = atof(ev); + if ((ev = getenv("CHARTTABLE_BEARING"))) v.bearing_deg = atof(ev); BOOL composing = NO; #ifdef USE_TILE57_COMPOSE diff --git a/shaders/d3d12/README.md b/shaders/d3d12/README.md index d711237..e64cdce 100644 --- a/shaders/d3d12/README.md +++ b/shaders/d3d12/README.md @@ -54,7 +54,7 @@ Three root parameters and one static sampler, shared by all five pipelines: Both CBVs are root descriptors pointed at a per-draw slice of one upload-heap ring, so a draw costs one address write and no descriptor allocation. The block -is `scene.Uniforms`, 128 bytes; HLSL's constant-buffer packing lands every field +is `scene.Uniforms`, 144 bytes; HLSL's constant-buffer packing lands every field at the offset the struct declares. ## Conventions this backend does not have to correct for diff --git a/shaders/d3d12/fill.frag.hlsl b/shaders/d3d12/fill.frag.hlsl index 4079b1b..113ab34 100644 --- a/shaders/d3d12/fill.frag.hlsl +++ b/shaders/d3d12/fill.frag.hlsl @@ -1,11 +1,39 @@ // Flat color straight from stream B, blended in paint order by the // fixed-function blend state. +// b0 is visible to both stages (the root signature says ALL), so the pixel +// stage reads the same block the vertex stage did. +cbuffer U : register(b0) { + column_major float4x4 u_mvp; + float2 u_px_to_clip; + float u_size_scale; + float u_zoom; + float u_zoom_t; + float u_world_per_px; + float u_rot_sin; + float u_rot_cos; + float4 u_color; + float2 u_anchor_px; + float2 u_cell_px; + float4 u_clip_rect; +}; + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +bool clipped(float2 world) { + return world.x < u_clip_rect.x || world.y < u_clip_rect.y || + world.x > u_clip_rect.z || world.y > u_clip_rect.w; +} + struct PSIn { float4 pos : SV_Position; float4 color : TEXCOORD0; + float2 world : TEXCOORD1; }; float4 main(PSIn i) : SV_Target { + if (clipped(i.world)) discard; return i.color; } diff --git a/shaders/d3d12/fill.vert.hlsl b/shaders/d3d12/fill.vert.hlsl index 74b41b1..b05ee8a 100644 --- a/shaders/d3d12/fill.vert.hlsl +++ b/shaders/d3d12/fill.vert.hlsl @@ -11,12 +11,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; // fractional zoom * 256 float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -35,13 +36,14 @@ struct VSIn { struct VSOut { float4 pos : SV_Position; float4 color : TEXCOORD0; + float2 world : TEXCOORD1; }; -// Longitude is cyclic, so every tile has a world copy every 1.0 world units -// and one of them has to be drawn. That choice belongs to the HOST, which -// makes it once per tile (Camera.placeTileX) and again once per scene, in the -// matrix (Camera.mvpOrigin takes the short way round in x). Deciding it here, -// per vertex, tore every primitive lying across the half-world seam. +// Longitude is cyclic, so a tile has a world copy every 1.0 world units and +// one of them is drawn. The host picks it, once per tile (Camera.placeTileX) +// and once per scene in the matrix (Camera.mvpOrigin wraps its x delta). +// Picking it here, per vertex, split every primitive lying across the +// half-world seam. float4 project(float2 p) { return mul(u_mvp, float4(p, 0.0, 1.0)); } @@ -57,6 +59,20 @@ bool gate(uint zwin) { // (ox, oy) is added AFTER projection, in reference px. map_align means it is // stated in the MAP frame: a rotated view must turn it, or the pen shears to // |cos(rotation)| of its width. +// The world position a vertex's fragments cover: the vertex position plus its +// screen-space (ox, oy) converted to world units. The offset is applied after +// projection, so a line's stroke reaches ground its anchor does not, and a +// clip test against the anchor alone cuts the stroke along its length. +// +// Undoing the projection needs no inverse. The linear part of mvp is scale +// times rotation, so R(-view) applied to the offset and divided by pixels per +// world unit gives the world delta of the clip-space offset. +float2 world_of(float2 p, float2 off) { + float2 back = float2( off.x * u_rot_cos + off.y * u_rot_sin, + -off.x * u_rot_sin + off.y * u_rot_cos); + return p + back * u_size_scale * u_world_per_px; +} + float2 screen_offset(float2 off, uint flags) { if ((flags & 1) != 0) { off = float2(off.x * u_rot_cos - off.y * u_rot_sin, @@ -82,5 +98,6 @@ VSOut main(VSIn i) { // Clamped: mid-gesture zoom_t can leave [0,1]; hold the end color // rather than wrapping to the far one. o.color = lerp(i.a_color, i.a_color_hi, saturate(u_zoom_t)); + o.world = world_of(i.a_pos, off); return o; } diff --git a/shaders/d3d12/overlay.vert.hlsl b/shaders/d3d12/overlay.vert.hlsl index 52532c1..0cefce1 100644 --- a/shaders/d3d12/overlay.vert.hlsl +++ b/shaders/d3d12/overlay.vert.hlsl @@ -8,12 +8,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -28,9 +29,9 @@ struct VSOut { VSOut main(VSIn i) { VSOut o; - // No antimeridian wrap here: the host turns the whole frame at once, in - // the matrix (Camera.mvpOrigin). Wrapping per vertex tore any primitive - // lying across the half-world seam. + // The host places the whole frame at once, in the matrix + // (Camera.mvpOrigin). A per-vertex wrap split any primitive lying across + // the half-world seam. float4 clip = mul(u_mvp, float4(i.a_world, 0.0, 1.0)); // z = 0 is the near plane. The chart's paint-order depths are all in (0,1) // and this pass writes no depth, so host content is never hidden by the diff --git a/shaders/d3d12/pattern.frag.hlsl b/shaders/d3d12/pattern.frag.hlsl index a031779..cb51c24 100644 --- a/shaders/d3d12/pattern.frag.hlsl +++ b/shaders/d3d12/pattern.frag.hlsl @@ -7,13 +7,41 @@ Texture2D cell_tex : register(t0); SamplerState cell_smp : register(s0); +// b0 is visible to both stages (the root signature says ALL), so the pixel +// stage reads the same block the vertex stage did. +cbuffer U : register(b0) { + column_major float4x4 u_mvp; + float2 u_px_to_clip; + float u_size_scale; + float u_zoom; + float u_zoom_t; + float u_world_per_px; + float u_rot_sin; + float u_rot_cos; + float4 u_color; + float2 u_anchor_px; + float2 u_cell_px; + float4 u_clip_rect; +}; + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +bool clipped(float2 world) { + return world.x < u_clip_rect.x || world.y < u_clip_rect.y || + world.x > u_clip_rect.z || world.y > u_clip_rect.w; +} + struct PSIn { float4 pos : SV_Position; float2 anchor : TEXCOORD0; float2 cell : TEXCOORD1; + float2 world : TEXCOORD2; }; float4 main(PSIn i) : SV_Target { + if (clipped(i.world)) discard; float2 sz = max(i.cell, float2(1.0, 1.0)); float2 uv = frac((i.pos.xy - i.anchor) / sz); float4 c = cell_tex.Sample(cell_smp, uv); diff --git a/shaders/d3d12/pattern.vert.hlsl b/shaders/d3d12/pattern.vert.hlsl index acca866..e88cedf 100644 --- a/shaders/d3d12/pattern.vert.hlsl +++ b/shaders/d3d12/pattern.vert.hlsl @@ -10,12 +10,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -30,8 +31,16 @@ struct VSOut { float4 pos : SV_Position; float2 anchor : TEXCOORD0; float2 cell : TEXCOORD1; + float2 world : TEXCOORD2; }; +// See fill.vert.hlsl: the world position a vertex's fragments actually land on. +float2 world_of(float2 p, float2 off) { + float2 back = float2( off.x * u_rot_cos + off.y * u_rot_sin, + -off.x * u_rot_sin + off.y * u_rot_cos); + return p + back * u_size_scale * u_world_per_px; +} + VSOut main(VSIn i) { VSOut o; float4 clip = mul(u_mvp, float4(i.a_pos, 0.0, 1.0)); @@ -49,5 +58,6 @@ VSOut main(VSIn i) { o.pos = vis ? clip : float4(0.0, 0.0, 2.0, 1.0); o.anchor = u_anchor_px; o.cell = u_cell_px; + o.world = world_of(i.a_pos, off); return o; } diff --git a/shaders/d3d12/sdf.frag.hlsl b/shaders/d3d12/sdf.frag.hlsl index bdfa9d0..7bd022f 100644 --- a/shaders/d3d12/sdf.frag.hlsl +++ b/shaders/d3d12/sdf.frag.hlsl @@ -18,12 +18,13 @@ cbuffer U : register(b1) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; // the SDF halo color for this draw float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct PSIn { diff --git a/shaders/d3d12/sprite.vert.hlsl b/shaders/d3d12/sprite.vert.hlsl index adb09dc..a48551a 100644 --- a/shaders/d3d12/sprite.vert.hlsl +++ b/shaders/d3d12/sprite.vert.hlsl @@ -12,12 +12,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { diff --git a/shaders/metal.metal b/shaders/metal.metal index 21fc447..0d9e380 100644 --- a/shaders/metal.metal +++ b/shaders/metal.metal @@ -25,14 +25,15 @@ struct U { float size_scale; // pixel density x symbol size multiplier float zoom; // fractional zoom * 256, tested against zmin/zmax float zoom_t; // fract(zoom): mix factor for zoom-interpolated paint - float wrap_x; // RESERVED: the host now wraps per tile and per scene + float world_per_px; // world units per reference px (offset -> world) float rot_sin; float rot_cos; float4 color; // SDF halo background; SDF fragment stage only float2 anchor_px; // pattern phase origin, framebuffer px float2 cell_px; // pattern cell period, framebuffer px + float4 clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; -static_assert(sizeof(U) == 128, "U must match scene.Uniforms (128 B)"); +static_assert(sizeof(U) == 144, "U must match scene.Uniforms (144 B)"); // ---- vertex streams (== scene/types.zig structs) ---------------------------- struct Vertex { // scene.Vertex, 28 B @@ -72,13 +73,13 @@ static_assert(sizeof(Quad) == 40, "Quad must match scene.Quad (40 B)"); constant uint FLAG_MAP_ALIGN = 1u; -// Longitude is cyclic, so every tile has a world copy every 1.0 world units -// and one of them has to be drawn. That choice belongs to the HOST, which -// makes it once per tile (Camera.placeTileX) and again once per scene, in the -// matrix (Camera.mvpOrigin takes the short way round in x). Deciding it here, -// per vertex, tore every primitive lying across the half-world seam: the -// corners on one side moved a whole world and the corners on the other did -// not, so a coastline came out as a band stretched across the map. +// Longitude is cyclic, so a tile has a world copy every 1.0 world units and +// one of them is drawn. The host picks it, once per tile (Camera.placeTileX) +// and once per scene in the matrix (Camera.mvpOrigin wraps its x delta). +// Picking it here, per vertex, split every primitive lying across the +// half-world seam: the corners on one side moved a whole world and the +// corners on the other did not, so a coastline drew as a band stretched +// across the map. static inline float4 project(constant U &u, float2 p) { return u.mvp * float4(p, 0.0, 1.0); } @@ -107,6 +108,7 @@ static inline float2 screen_offset(constant U &u, float2 off, uchar flags) { struct FillOut { float4 pos [[position]]; float4 color; + float2 world; }; // The zoom-interpolated pair: buffer(3) is the same property one integer @@ -122,6 +124,29 @@ static inline float4 paint_of(const device Paint *lo, const device Paint *hi, return mix(a, b, clamp(u.zoom_t, 0.0, 1.0)); } +// The world position a vertex's fragments cover: the vertex position plus its +// screen-space (ox, oy) converted to world units. The offset is applied after +// projection, so a line's stroke reaches ground its anchor does not, and a +// clip test against the anchor alone cuts the stroke along its length. +// +// Undoing the projection needs no inverse. The linear part of mvp is scale +// times rotation, so R(-view) applied to the offset and divided by pixels per +// world unit gives the world delta of the clip-space offset. +static inline float2 world_of(constant U &u, float2 p, float2 off) { + float2 back = float2(off.x * u.rot_cos + off.y * u.rot_sin, + -off.x * u.rot_sin + off.y * u.rot_cos); + return p + back * u.size_scale * u.world_per_px; +} + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +static inline bool clipped(constant U &u, float2 world) { + return world.x < u.clip_rect.x || world.y < u.clip_rect.y || + world.x > u.clip_rect.z || world.y > u.clip_rect.w; +} + vertex FillOut fill_vert(uint vid [[vertex_id]], const device Vertex *verts [[buffer(0)]], const device Paint *paint [[buffer(1)]], @@ -141,10 +166,12 @@ vertex FillOut fill_vert(uint vid [[vertex_id]], FillOut out; out.pos = gate(u, v.zmin, v.zmax) ? clip : float4(0.0, 0.0, 2.0, 1.0); // z=2 -> clipped out.color = paint_of(paint, paint_hi, vid, u); + out.world = world_of(u, v.pos, off); return out; } -fragment float4 fill_frag(FillOut in [[stage_in]]) { +fragment float4 fill_frag(FillOut in [[stage_in]], constant U &u [[buffer(1)]]) { + if (clipped(u, in.world)) discard_fragment(); return in.color; } @@ -156,6 +183,7 @@ struct PatternOut { float4 pos [[position]]; float2 anchor; float2 cell; + float2 world; }; vertex PatternOut pattern_vert(uint vid [[vertex_id]], @@ -170,6 +198,7 @@ vertex PatternOut pattern_vert(uint vid [[vertex_id]], out.pos = gate(u, v.zmin, v.zmax) ? clip : float4(0.0, 0.0, 2.0, 1.0); out.anchor = u.anchor_px; out.cell = u.cell_px; + out.world = world_of(u, v.pos, off); return out; } @@ -177,7 +206,9 @@ vertex PatternOut pattern_vert(uint vid [[vertex_id]], // both by the same amount, so the pattern is fixed to the map, not the screen. fragment float4 pattern_frag(PatternOut in [[stage_in]], texture2d cell [[texture(0)]], - sampler smp [[sampler(0)]]) { + sampler smp [[sampler(0)]], + constant U &u [[buffer(1)]]) { + if (clipped(u, in.world)) discard_fragment(); float2 sz = max(in.cell, float2(1.0)); float2 uv = fract((in.pos.xy - in.anchor) / sz); float4 c = cell.sample(smp, uv); @@ -295,8 +326,8 @@ vertex OverlayOut overlay_vert(uint vid [[vertex_id]], const device OverlayVertex *verts [[buffer(0)]], constant U &u [[buffer(2)]]) { OverlayVertex v = verts[vid]; - // No antimeridian wrap here either: the host turns the whole frame at - // once, in the matrix (Camera.mvpOrigin). See project() above. + // The host places the whole frame at once, in the matrix + // (Camera.mvpOrigin). See project() above. float4 clip = u.mvp * float4(v.world, 0.0, 1.0); // z = 0 is the near plane. Every paint-order depth the scene writes is in // (0,1), so a depth-test-only overlay pass is never hidden by the map it diff --git a/shaders/vk/README.md b/shaders/vk/README.md index c9fcc5f..53bf703 100644 --- a/shaders/vk/README.md +++ b/shaders/vk/README.md @@ -51,7 +51,9 @@ are not negotiable — they are the struct layouts in scene/types.zig. lookout's SDL_GPU convention, kept so a port lands on familiar ground: set 1 binding 0 = the vertex-stage uniform block, set 2 binding 0 = the fragment sampler, set 3 binding 0 = the fragment-stage uniform block. The -block is `scene.Uniforms`, 128 bytes, std140-compatible in this field order. +block is `scene.Uniforms`, 144 bytes, std140-compatible in this field order. +The fill and pattern FRAGMENT stages read it from set 3 as well as sdf: every +triangle draw is clipped to `clip_rect`. ## Two contract changes from lookout, called out diff --git a/shaders/vk/fill.frag b/shaders/vk/fill.frag index e2a8091..c15a867 100644 --- a/shaders/vk/fill.frag +++ b/shaders/vk/fill.frag @@ -2,5 +2,34 @@ // Flat color straight from stream B, blended in paint order by the // fixed-function blend state. Matches metal.metal fill_frag. layout(location = 0) in vec4 v_color; +layout(location = 1) in vec2 v_world; layout(location = 0) out vec4 o_color; -void main() { o_color = v_color; } + +layout(set = 3, binding = 0) uniform U { + mat4 mvp; + vec2 px_to_clip; + float size_scale; + float zoom; + float zoom_t; + float world_per_px; + float rot_sin; + float rot_cos; + vec4 color; + vec2 anchor_px; + vec2 cell_px; + vec4 clip_rect; +} u; + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +bool clipped(vec2 world) { + return world.x < u.clip_rect.x || world.y < u.clip_rect.y || + world.x > u.clip_rect.z || world.y > u.clip_rect.w; +} + +void main() { + if (clipped(v_world)) discard; + o_color = v_color; +} diff --git a/shaders/vk/fill.frag.spv b/shaders/vk/fill.frag.spv index ac9d2fcda7d1d96f3bd071871e952309f259ba95..4d7f1838e34303fda66d882f42e83a83044174d9 100644 GIT binary patch literal 2240 zcmZ9O*=`e242B2NmVKv`orJO%N?FSyAO%DU^a2TxxIh}BG>A$m6D6e}u6P_C0ddJY zAr=AR`zGUR)FZjJfBV?KeWq0_Ysc3m$(m$SvOam749<>ZEl2`3lIzCY)49q1(){G* zD^q4{O)7b!Ior55gjN=t3vUhFK^n+IWIMUWXo6Lta*}-G;xK6W&M>_;6Ki!Ay508t zspZ!fZ{p^8o6zsGUS~e{E6FJOIWj^8ZO`Iz7k?!8<<MlTT1(^AnH1^SS#zVVC%tJ zStskEgVzS^=G)ertSB1yH=5h-bZiy5?!u|kUb_V~Ca5H1=y$n2l<$5y9c;@T*TK8Z zUen?9eHZqUGtl>qmJ7c@E%)i3W=p$u?%85jnE6|7Uxkeu;6$EpAXYD7))kvAVeVdR zu7tUJu_q-=4r0$rm^&AHQNrB0*vk^;&c$ApFn2D-=dr@seT(_9F^BsW`-C*uiMy^L zp74)iR|?;6|1jeG#@)BRN8f#izl%+~lNz_Zue}G$xvIz*`Oc$ntcvd}V#;n%=cs|p zUEBC5{#JAq@k|u{wt<`1@%^H{R7hXZVgUIU(M(DKyZhq!m|??;*5E{65{sRkbG3(3m5h6e9JBR*X@~$EBwU0eKelyeL$E*U9V}vBik^q?y(|8iTJrGsK-^iYXpggs zA3>^!oV0_}QEa)^kl-|qEgqbXVF#yj-f?ha!RZ9Hcg26vr;4_p`a7>B$CHR0-HZ8h z3I30_5mS=F8omUJ_VHvo#?dg(8SykNw30G$+8)jb6-A_I$p^0msyt0K zd#K45`|k(SlWlg{F}paO{(Fv-T0oxk&2%CAn8l;#DaKb`ie+X^fqx~Hx!{+&=ZZJ;; diff --git a/shaders/vk/fill.vert b/shaders/vk/fill.vert index 6a75bea..f0b89c5 100644 --- a/shaders/vk/fill.vert +++ b/shaders/vk/fill.vert @@ -24,25 +24,41 @@ layout(set = 1, binding = 0) uniform U { float size_scale; float zoom; // fractional zoom * 256 float zoom_t; - float wrap_x; + float world_per_px; float rot_sin; float rot_cos; vec4 color; vec2 anchor_px; vec2 cell_px; + vec4 clip_rect; } u; layout(location = 0) out vec4 v_color; +layout(location = 1) out vec2 v_world; -// Longitude is cyclic, so every tile has a world copy every 1.0 world units -// and one of them has to be drawn. That choice belongs to the HOST, which -// makes it once per tile (Camera.placeTileX) and again once per scene, in the -// matrix (Camera.mvpOrigin takes the short way round in x). Deciding it here, -// per vertex, tore every primitive lying across the half-world seam. +// Longitude is cyclic, so a tile has a world copy every 1.0 world units and +// one of them is drawn. The host picks it, once per tile (Camera.placeTileX) +// and once per scene in the matrix (Camera.mvpOrigin wraps its x delta). +// Picking it here, per vertex, split every primitive lying across the +// half-world seam. vec4 project(vec2 p) { return u.mvp * vec4(p, 0.0, 1.0); } +// The world position a vertex's fragments cover: the vertex position plus its +// screen-space (ox, oy) converted to world units. The offset is applied after +// projection, so a line's stroke reaches ground its anchor does not, and a +// clip test against the anchor alone cuts the stroke along its length. +// +// Undoing the projection needs no inverse. The linear part of mvp is scale +// times rotation, so R(-view) applied to the offset and divided by pixels per +// world unit gives the world delta of the clip-space offset. +vec2 world_of(vec2 p, vec2 off) { + vec2 back = vec2( off.x * u.rot_cos + off.y * u.rot_sin, + -off.x * u.rot_sin + off.y * u.rot_cos); + return p + back * u.size_scale * u.world_per_px; +} + // The per-vertex zoom visibility window, quantized to 1/256 zoom steps and // inclusive at both ends. This replaces lookout's scamin / cat_mask gates. bool gate(uint zwin) { @@ -78,4 +94,5 @@ void main() { // Clamped: mid-gesture zoom_t can leave [0,1]; hold the end color // rather than wrapping to the far one. v_color = mix(a_color, a_color_hi, clamp(u.zoom_t, 0.0, 1.0)); + v_world = world_of(a_pos, off); } diff --git a/shaders/vk/fill.vert.spv b/shaders/vk/fill.vert.spv index 089cb3783ed51d8fba775ce665e93626bbe33d11..f3d08c263f9e497d0190a9e2ee5100aff9c5f322 100644 GIT binary patch literal 6164 zcmZve36NY>6^0*~Ey5UxS%oA_CZK^}$|MkoF(DW1jg0Ti(PoR0 zSEg*<0c|_0)hV0%_1*HZKi{!(DZ9n$_o5f~=leHN%1&4Nfz&_ruJpYuXZF&_XqMCW zbfT2rs zo0v&I>tf&&x}5t}_kJ$|KI(o))x+)`R__4Xy_4$h+4*}Xos;#IeeA&}ICIIl24{?% zd4p4y%W`M;%Ub$cdq1nXkGl6s-C3*qomY3x>fR-F>&_yJc@|UAI?8jA^z8%l@b@g# zwe_KU4st@fXQ18w7W2RJm(y=CUCdzBS6$4(XBKN8@G<9XR(<4WQ)v#Xx#qGeYd6=? ztdUD@K0e2?TF?56sMg0Sr=NZOKj^PrDBHtg)@+YSvNqTIbIzWZq<-1lmgV|!a6ZN# z&uYB2wOjWD;1l|ZskY~Ib@#e5)wB3j$kB7;U!8O2KNZ>h=8brNI1kHp`&$pfu6_O9Ai};VBdk;dt4LRz64%u1QZ{)uNIrjG* zWPQzRf9k&1a^e3YWOFRQ{u9>B*L#=!-guYfvy^zyi{a~)^{}*KQ z#Tlz-e={b@YHpIVu(pqW|Ag^=nalffHHcWx_1ZSwzY~h~8w$Gb_l*U874l65UHiQS z-Ta5zbblAkzm9%f-wwQ^>*>~B)Ys6l^LPj71@hYMT`pqZjqH8aW}oWXX0g5pc$c-= zv-%otf;Ha@76N0m>EpiTBmRBJ#_yvS^SvL)-3;!`xm$AHccM;R@5u*&{H6G)e<GP`|+LqJg}eJK=^zi=k3Rww*%wkV;^5cHqLKg?Bh$w z^3ltek&Tr%ZWZfSfMo@)7q{WMk#^@&0@b7vpo-z8!#a_OL>~}44d>^@ezdwMJ zkMsT^vT^be_hV$^{3cpQT|eu1hkpj{0or^|)qU6Iu>Ks(2l6q$Um%-rfa%@O`YWJc z5w|cEXZ--sZp?kao*rcVHSpVd9I&qX10ZVr7WqIy{~hu{V7zz#_n;pX@BSZhzL#Gn z&*YE5*)9XxP5>tY`?j|~0ey-vR@C zZhM$jJ-#=8N1h3c(PsQZtn&Y%`f0##xBc`2pToha_)TX$0{9(&Jgaj#p7lwRS6j^Ts=PnvWF39}OHb<2zcX^5J~Ke{ ze`G;E3fa9pfz{t6_qPHV8~6BRWc^kG_u?6!0_5XfoQCZFSA+0bgDfBK$kULGlaJg_ zM>e&L+xPYyIQhu^Tx4?>=e7l1K5{<~**N*V`QDz7Y)x$g zaGuv$z&&g8PJ51W&P@9N@a#iB8(E*|Wh=71oC9Lc=OW9;cl|tM~|cMkG=;4>F^cW1NC1K!ssvU*=vv0ebo7y9;Gx0hMS zF9F8I-w7AyT!}UQPADVGJ2&&GNBkhN@$t7u1zBzgMEo$ayz$mok6G+MHfPLh6xn%= zfbh8pS^jorxMKoMWs$|7K6xszARo|soP6YdJ#zbA-T)^bx!;Iv?&6%@gf1VsuS7OZelKUx&H83wO>Ob5dJD39 b)aE;5Z8`5>oTICA^;?)lu^VzY1*L&8k_01y8$zr1T@4% zg~TN8CNV@~42x@GjNpP+`4?F(RW9H6`kvv{l#lw&J?D3pd+xdCy{2o@+~c$Cxa_2C za`s8qd8THQU^2Mgd>ve~Zq0&Ld*gydmtCyKDOp$UsLvEMpKh?VTpd-ef%`xU>;nhD zLGUhk4;%qq{F}sjlq5Yw7vp=_w+~yRS>I9_ZqM63vT!LXeqGcApUXYjG}cY!c4gkS zOOm}iI}L7ZN2iV+&NK_XHQcOJMoaaPkyfRXtrK9r8Q3x8HOxdFsg*Zbx3;rb*MnZF zQ})@qy@B4F>z==Hf2mzB4cDp^6};zaRmUo&)^NF2k?+axFC9+ocvqi+o&M&JEXF~2>4N{K#o~Uo7 zR+II-KS19GypIc6$F@cvyR(J4UN$_pZ(;46x}$sNz10d``-xPUqd5Rup2pv+Wb~pQG<+)#?p;tG_c=E^Vwd+MCgPvwx#+s+EQ+&08zY zb|tUbUda1stGBA{YTaHQpYz^j&R0j<>$p4p!sdNqTgfF~TWz&zWo6vvood@wtBu<{ zuQTsseFs^~rOs5UA3{IYo9_I**k0<~n)df{|ImB#_odvimo`_kY%tBHb*;ibX4AP@ z#g3snAE!>>=A!$ApXZiaUf|3tx2nK-PPsJ&&e@e4Dsa{ycT0hDcI9p_aMmVwcY$+u zSUUamJS>-TRlbUVGCIjsq7YeGamZ z@%^m&y03QkJQw(ceqOHayjuVH$Wfnm&wUa2_S<9m7omIh*l#hi_hI}JR`+wCh#y3b z`pmZ)_(c3#WcLic^BdI3S4}qiV*Yzs&85HnPO{5$g%%kWcyJ(-yahf`#pso{rfwzvF>BN z>iZHG{;!~$^K9&|vg$hsW+VB&+ABG2!anS0br0?4RX>op=!@TN_jVuS-vm6%=Tp5o z-vZ8#(B|CuPQA_QJlF&GdWY4$j4`*m?|Gc@|H$qWc6Hx-IsMgr@6|8o-SVvF_A$p7 zFn%BOt+H+bF%RCOzrjVlF+unDHtfEu{@#Y}{QG+wy7og!U&em&qrjQFifY_TeF+tL zo;v6Q^4hIUE@J)vhV!Y-I@GnD!ny-Eo7${NeF-<=o__mh0b{i38V}1?fR^Y#c^*6xp zlB;I<=`2;XdKF;wcWaE5i;~alRmXBJVL^f95xcRJ41Ml8ZI*_%(14Xgid2 z-?dYbUkB5GeDv=PWb+Nty|-Ea1N1B6Rk~uY|C{W_yalYuZ`J?60Wb%+ue!dm$6;i@ zSE0X;d;}Qp?0*29nd0n!nD{<^X}ptTAt zFmWv7-1&*K&N$ONWPP>8d7nu2{Rqx|^!bLG)T4gy+I#ky3aoz$>uJFHPiOVF!1wM# zV2zUa%x)!