diff --git a/Cargo.toml b/Cargo.toml index 799f8e48b..f5c2e0aa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,9 @@ workspace.members = [ "libs/latex_math", "libs/mbtile_reader", "libs/map_nav", + "libs/geodata", "libs/makepad_ai", + "libs/tesla", "libs/converse", "libs/pdf_parse", "libs/regex", diff --git a/draw/src/shader/draw_rotated_text.rs b/draw/src/shader/draw_rotated_text.rs index bad2d4927..e775f085d 100644 --- a/draw/src/shader/draw_rotated_text.rs +++ b/draw/src/shader/draw_rotated_text.rs @@ -17,16 +17,45 @@ script_mod! { rotated_pos: varying(vec2f) + // Camera-delta transform (best-effort label tracking while the + // map rotates/tilts between re-places): a full 2x2 matrix about + // the view pivot — tilt does NOT commute with rotation, so the + // exact delta S(t1)*R(d)*S(1/t0) is a general matrix. The async + // re-place trues up with identity. + cam_a: uniform(1.0) + cam_b: uniform(0.0) + cam_c: uniform(0.0) + cam_d: uniform(1.0) + cam_pivot: uniform(vec2(0.0, 0.0)) + // self.upright (instance from the Rust struct): 1.0 = screen-upright + // label (place names, pin/brand text) — its ANCHOR tracks the camera + // delta but its orientation must not; the re-place keeps such labels + // horizontal, so rotating them live would snap back on regen. + vertex: fn() { let p = mix(self.rect_pos, self.rect_pos + self.rect_size, self.geom.pos) let origin = self.rotation_origin let scaled = (p - origin) * self.label_scale let cs = cos(self.rotation) let sn = sin(self.rotation) - let rotated = vec2( + var rotated = vec2( scaled.x * cs - scaled.y * sn, scaled.x * sn + scaled.y * cs ) + origin + if self.upright > 0.5 { + let anchor_rel = origin - self.cam_pivot + let cam_anchor = vec2( + anchor_rel.x * self.cam_a + anchor_rel.y * self.cam_b, + anchor_rel.x * self.cam_c + anchor_rel.y * self.cam_d + ) + self.cam_pivot + rotated = rotated - origin + cam_anchor + } else { + let cam_rel = rotated - self.cam_pivot + rotated = vec2( + cam_rel.x * self.cam_a + cam_rel.y * self.cam_b, + cam_rel.x * self.cam_c + cam_rel.y * self.cam_d + ) + self.cam_pivot + } self.pos = self.geom.pos self.t = mix(self.t_min, self.t_max, self.geom.pos.xy) @@ -84,6 +113,22 @@ pub struct DrawRotatedText { pub label_scale: f32, #[live(vec2(0.0, 0.0))] pub rotation_origin: Vec2f, + #[live(0.0)] + pub upright: f32, +} + +impl DrawRotatedText { + /// Camera-delta uniforms: rotate placed glyphs about `pivot` by the + /// given cos/sin and compress y by `tilt_ratio` — identity when the + /// placement is fresh. + pub fn set_camera_delta(&mut self, cx: &mut Cx, m: [f32; 4], pivot: Vec2f) { + self.draw_vars.set_uniform(cx, live_id!(cam_a), &[m[0]]); + self.draw_vars.set_uniform(cx, live_id!(cam_b), &[m[1]]); + self.draw_vars.set_uniform(cx, live_id!(cam_c), &[m[2]]); + self.draw_vars.set_uniform(cx, live_id!(cam_d), &[m[3]]); + self.draw_vars + .set_uniform(cx, live_id!(cam_pivot), &[pivot.x, pivot.y]); + } } /// A single glyph positioned along a path, ready to draw. @@ -182,6 +227,7 @@ impl DrawRotatedText { // advances and labels render letter-spaced. let saved_font_scale = self.draw_super.font_scale; self.draw_super.font_scale = 1.0; + self.upright = 0.0; for glyph in glyphs { self.draw_glyph_at( cx, @@ -202,6 +248,78 @@ impl DrawRotatedText { self.draw_super.font_scale = saved_font_scale; } + /// Billboard variant for text INSIDE zoom-constant pins: the shared + /// anchor scales/translates with the map (tracking the pin's baked + /// anchor exactly), but glyph offsets and size stay in constant screen + /// px — so the text is rigid on the pin at every gesture zoom, like + /// the pin mesh itself. + pub fn draw_path_glyphs_billboard( + &mut self, + cx: &mut Cx2d, + glyphs: &[PathGlyphInstance], + scale: f32, + offset: Vec2f, + anchor: Vec2f, + ) { + let saved_font_scale = self.draw_super.font_scale; + self.draw_super.font_scale = 1.0; + self.upright = 1.0; + let scaled_anchor = + Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y); + for glyph in glyphs { + self.draw_glyph_at( + cx, + Point::new( + scaled_anchor.x + (glyph.glyph_origin.x - anchor.x), + scaled_anchor.y + (glyph.glyph_origin.y - anchor.y), + ), + scaled_anchor, + glyph.font_size_in_lpxs, + glyph.rasterized, + glyph.angle, + 1.0, + ); + } + self.upright = 0.0; + self.draw_super.font_scale = saved_font_scale; + } + + /// Draw a straightened (screen-upright) label: every glyph carries the + /// SAME anchor (the label's world-anchor in cached screen space) so the + /// camera-delta shader translates the string rigidly to where the next + /// re-place will put it, without rotating the glyphs. Straightened + /// glyphs have angle 0, so hijacking rotation_origin as the anchor is + /// free. + pub fn draw_path_glyphs_upright( + &mut self, + cx: &mut Cx2d, + glyphs: &[PathGlyphInstance], + scale: f32, + offset: Vec2f, + anchor: Vec2f, + ) { + let saved_font_scale = self.draw_super.font_scale; + self.draw_super.font_scale = 1.0; + self.upright = 1.0; + let anchor = Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y); + for glyph in glyphs { + self.draw_glyph_at( + cx, + Point::new( + glyph.glyph_origin.x * scale + offset.x, + glyph.glyph_origin.y * scale + offset.y, + ), + anchor, + glyph.font_size_in_lpxs * scale, + glyph.rasterized, + glyph.angle, + 1.0, + ); + } + self.upright = 0.0; + self.draw_super.font_scale = saved_font_scale; + } + /// Place glyphs from a `PreparedTextRun` along a polyline path. /// /// Glyphs are appended to `out_glyphs` (caller reuses the buffer to avoid allocs). diff --git a/examples/map/Cargo.toml b/examples/map/Cargo.toml index 47aa88f79..dbc5ad303 100644 --- a/examples/map/Cargo.toml +++ b/examples/map/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] makepad-widgets = { path = "../../widgets", version = "2.0.0", features = ["maps"] } makepad-map-nav = { path = "../../libs/map_nav" } +makepad-geodata = { path = "../../libs/geodata" } makepad-fast-inflate = { path = "../../libs/fast_inflate" } [dev-dependencies] diff --git a/examples/map/src/main.rs b/examples/map/src/main.rs index 4b08d1df3..129d17a3d 100644 --- a/examples/map/src/main.rs +++ b/examples/map/src/main.rs @@ -30,6 +30,9 @@ const NAV_DATA_BASENAME: &str = "local/maps/noord-holland"; /// search so any European place is a fly-to target. const EUROPE_PLACES_PATH: &str = "local/maps/europe-places.search"; const EUROPE_SEARCHDB_PATH: &str = "local/maps/europe.searchdb"; +/// Long-haul fallback graph: Europe major roads (motorway..secondary), +/// used when a route endpoint lies outside the detailed regional graph. +const EUROPE_MAJOR_GRAPH_PATH: &str = "local/maps/europe-major.graph"; /// Simulated drive runs this much faster than real time. const SIM_SPEED_MULT: f64 = 6.0; const MAX_RESULTS: usize = 8; @@ -104,7 +107,7 @@ script_mod! { zoom: 13.0 min_zoom: 3.0 mbtiles_path: "local/maps/europe-shortbread.mbtiles" - detail_mbtiles_path: "local/maps/noord-holland-detail.mbtiles" + detail_mbtiles_path: "local/maps/europe-osm-detail.mbtiles" buildings_3d: true } @@ -260,9 +263,10 @@ script_mod! { layer_districts := LayerCheck{text: "Districts"} layer_bag := LayerCheck{text: "Building age"} layer_population := LayerCheck{text: "Population"} + layer_rain := LayerCheck{text: "Rain radar"} PanelText{ margin: Inset{top: 6} - text: "Terrain · Noise · Flood · Rain: soon" + text: "Terrain · Noise · Flood: soon" } } } @@ -276,7 +280,7 @@ script_mod! { recenter_button := AppButton{ visible: false margin: Inset{right: 6, bottom: 18} - text: "Recenter" + text: "Detach" } layers_button := AppButton{ margin: Inset{right: 4, bottom: 18} @@ -355,6 +359,13 @@ enum NavResponse { }, } +#[derive(Clone)] +struct RainUpdate { + frames: Vec>, + width: usize, + height: usize, +} + #[derive(Script, ScriptHook)] pub struct App { #[live] @@ -392,6 +403,15 @@ pub struct App { #[rust] follow: bool, #[rust] + rain_on: bool, + #[rust] + rain_worker_started: bool, + #[rust] + rain_rx: ToUIReceiver, + /// Last decoded nowcast, kept so toggling rain back on is instant. + #[rust] + rain_cache: Option, + #[rust] program_moves: u32, #[rust] sim_progress_m: f64, @@ -479,6 +499,17 @@ impl App { .ok() .and_then(|data| SearchIndex::deserialize(&data).ok()) }; + // Europe-wide major-roads graph: loaded lazily as the long-haul + // fallback (missing file = regional routing only). + let major_graph = std::fs::read(EUROPE_MAJOR_GRAPH_PATH) + .ok() + .and_then(|data| RouteGraph::deserialize(&data).ok()); + if let Some(major) = &major_graph { + log!( + "nav: europe major-roads graph loaded ({} edges)", + major.edges.len() + ); + } let dem_cache = std::sync::Arc::new(std::sync::Mutex::new(dem::DemCache::new( "local/maps/dem", ))); @@ -521,7 +552,14 @@ impl App { }); } NavRequest::Route { id, from, to, mode } => { - let route = graph.route(from, to, mode); + // Detailed regional graph first; beyond its coverage + // (Paris!) fall back to the Europe major-roads graph. + let mut route = graph.route(from, to, mode); + if route.is_none() { + if let Some(major) = &major_graph { + route = major.route(from, to, mode); + } + } let _ = sender.send(NavResponse::RouteDone { id, route: Box::new(route), @@ -615,6 +653,80 @@ impl App { self.ui.view(cx, ids!(results_view)).set_visible(cx, false); } + /// Start the rain radar worker: polls the KNMI +2h nowcast through the + /// on-disk cache (RadarSync never hits the network more than once per + /// 4 min), decodes the 25-frame HDF5 and reprojects to mercator RGBA. + fn ensure_rain_worker(&mut self) { + if self.rain_worker_started { + return; + } + self.rain_worker_started = true; + let sender = self.rain_rx.sender(); + std::thread::spawn(move || { + use makepad_geodata::radar::{RadarConfig, RadarSync}; + use makepad_geodata::{knmi_hdf5, radar_raster}; + let sync = RadarSync::new(RadarConfig::new("local/overlays/radar")); + let projection = radar_raster::RadarProjection::new(1024, 1280); + let mut last_decoded: Option = None; + loop { + if let Ok(state) = sync.sync() { + if let Some(newest) = state.frames.last() { + if last_decoded.as_deref() != Some(newest.path.as_path()) { + if let Ok(data) = std::fs::read(&newest.path) { + if let Ok(frames) = knmi_hdf5::decode_frames(&data) { + let texels: Vec> = frames + .iter() + .map(|frame| { + radar_raster::rgba_to_bgra_texels( + &projection.frame_to_rgba(frame), + ) + }) + .collect(); + let _ = sender.send(RainUpdate { + frames: texels, + width: 1024, + height: 1280, + }); + last_decoded = Some(newest.path.clone()); + } + } + } + } + } + std::thread::sleep(std::time::Duration::from_secs(60)); + } + }); + } + + fn apply_rain(&mut self, cx: &mut Cx) { + let bbox = ( + makepad_geodata::radar_raster::RASTER_WEST, + makepad_geodata::radar_raster::RASTER_SOUTH, + makepad_geodata::radar_raster::RASTER_EAST, + makepad_geodata::radar_raster::RASTER_NORTH, + ); + if self.rain_on { + if let Some(update) = &self.rain_cache { + self.map(cx).set_rain_frames( + cx, + update.frames.clone(), + update.width, + update.height, + bbox, + ); + } + } else { + self.map(cx).set_rain_frames(cx, Vec::new(), 0, 0, bbox); + } + } + + /// The 3D button shows the mode a press switches TO ("3D" when flat, + /// "2D" when tilted) so the current mode is always visible. + fn sync_tilt_button(&mut self, cx: &mut Cx) { + let label = if self.tilt_target > 0.0 { "2D" } else { "3D" }; + self.ui.button(cx, ids!(tilt_button)).set_text(cx, label); + } + fn pick_result(&mut self, cx: &mut Cx, index: usize) { let Some(result) = self.search_results.get(index).cloned() else { return; @@ -689,7 +801,6 @@ impl App { .filter(|(_, on)| **on) .map(|(path, _)| *path) .collect(); - log!("overlays: {:?}", paths); self.map(cx).set_overlay_paths(cx, &paths.join(";")); } @@ -742,6 +853,12 @@ impl App { self.session = Some(NavSession::new(route.clone())); self.navigating = true; self.follow = true; + self.ui + .button(cx, ids!(recenter_button)) + .set_visible(cx, true); + self.ui + .button(cx, ids!(recenter_button)) + .set_text(cx, "Detach"); self.sim_progress_m = 0.0; self.sim_last_tick = Some(std::time::Instant::now()); self.sim_started = Some(std::time::Instant::now()); @@ -998,15 +1115,20 @@ impl MatchEvent for App { self.hide_results(cx); self.ui.view(cx, ids!(pin_info)).set_visible(cx, false); } + // Manual tilt gesture = entering/leaving 3D mode: track it so the + // 3D button toggles from the REAL camera state (no snap-to-flat + // then re-animate) and dragging upright returns cleanly to 2D. + if let Some(tilt) = map.tilt_changed(actions) { + self.tilt_target = tilt; + self.tilt_current = tilt; + self.sync_tilt_button(cx); + } if map.viewport_changed(actions).is_some() { if self.program_moves > 0 { self.program_moves -= 1; - } else if self.navigating && self.follow { - self.follow = false; - self.ui - .button(cx, ids!(recenter_button)) - .set_visible(cx, true); } + // Gestures (pan/tilt/rotate) do NOT detach the follow camera — + // attach/detach is an explicit toggle in the UI. } // Route bar @@ -1088,12 +1210,20 @@ impl MatchEvent for App { self.layer_states[5] = value; layers_changed = true; } + if let Some(value) = self.ui.check_box(cx, ids!(layer_rain)).changed(actions) { + self.rain_on = value; + if value { + self.ensure_rain_worker(); + } + self.apply_rain(cx); + } if layers_changed { self.apply_overlay_selection(cx); } if self.ui.button(cx, ids!(tilt_button)).clicked(actions) { self.tilt_target = if self.tilt_target > 0.0 { 0.0 } else { 42.0 }; self.tilt_next_frame = cx.new_next_frame(); + self.sync_tilt_button(cx); } if self.ui.button(cx, ids!(zoom_in_button)).clicked(actions) { if let Some(zoom) = self.map(cx).map_zoom() { @@ -1106,12 +1236,16 @@ impl MatchEvent for App { } } if self.ui.button(cx, ids!(recenter_button)).clicked(actions) { - self.follow = true; + // Attach/detach toggle: follow the puck, or roam freely. + self.follow = !self.follow; + let label = if self.follow { "Detach" } else { "Follow" }; self.ui .button(cx, ids!(recenter_button)) - .set_visible(cx, false); - if let Some(pos) = self.position { - self.map(cx).set_center(cx, pos.lon, pos.lat); + .set_text(cx, label); + if self.follow { + if let Some(pos) = self.position { + self.map(cx).set_center(cx, pos.lon, pos.lat); + } } } } @@ -1126,6 +1260,12 @@ impl AppMain for App { fn handle_event(&mut self, cx: &mut Cx, event: &Event) { // Worker responses + while let Ok(update) = self.rain_rx.try_recv() { + self.rain_cache = Some(update); + if self.rain_on { + self.apply_rain(cx); + } + } while let Ok(response) = self.nav_rx.try_recv() { match response { NavResponse::Ready { docs, edges } => { diff --git a/libs/geodata/Cargo.toml b/libs/geodata/Cargo.toml new file mode 100644 index 000000000..3877349e8 --- /dev/null +++ b/libs/geodata/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "makepad-geodata" +version = "0.1.0" +edition = "2021" +description = "Bulk open-geodata fetcher and per-layer overlay database builder" +license = "MIT OR Apache-2.0" + +[lib] +name = "makepad_geodata" +path = "src/lib.rs" + +[[bin]] +name = "geodata" +path = "src/main.rs" + +[dependencies] +makepad-fast-inflate = { path = "../fast_inflate", version = "0.1.0" } +makepad-mbtile-reader = { path = "../mbtile_reader" } +makepad-map-nav = { path = "../map_nav" } +flate2 = "1.0" +serde_json = "1.0" diff --git a/libs/geodata/README.md b/libs/geodata/README.md new file mode 100644 index 000000000..dd451238e --- /dev/null +++ b/libs/geodata/README.md @@ -0,0 +1,150 @@ +# makepad-geodata + +Bulk open-geodata fetching, per-layer overlay database building, live-source +syncing (rain radar), and a structured query surface — for the map stack. +Netherlands-first. Companion docs: `datasources.md` (source survey, licenses) +and `gps.md` (interaction layer) at the repo root. + +The two consumers of every layer database: +1. **The renderer** — standard gzipped MVT vector tiles / PNG raster tiles. +2. **An LLM (via the map app)** — the `features` sidecar table + `query` + module answer "what is at/near this location" with structured JSON, and + raster layers carry class tables so values are nameable. + +Design rules, in order: + +1. **Bulk downloads only.** Every static source is a single downloadable + file. No API paging, no WFS spidering, no tile scraping. Live sources + (radar) poll their official file APIs at most once per data refresh. + Search-style APIs (Wikipedia, Overpass, NDW live traffic, GTFS-RT) are + *not* this crate's job — the map app queries those by viewport at runtime. +2. **One .mbtiles per layer**, never merged into the base map archive: + `local/overlays/nl-.mbtiles` — independently rebuildable, + shippable, deletable. +3. **Reuse the existing stack.** SQLite read *and* write via + `makepad-mbtile-reader` (GeoPackages are just SQLite; the reader grew + `open_sqlite` / `schema_entries` / `for_each_row` / + `for_each_row_in_range`, the writer grew extra-table support). Mercator + math from `makepad-map-nav`. New here: RD New <-> WGS84 polynomials, WKB, + MVT encoding, TIFF subset reader, PNG codec, tiling. +4. **Library first.** The `geodata` binary is a thin CLI; the map app embeds + the same machinery (`fetch_source` for periodic re-syncs, `RadarSync` for + live radar, `query::LayerDb` for the LLM/tap-inspect surface). + +## Politeness (enforced in `fetch.rs` / `radar.rs`, not left to callers) + +- descriptive User-Agent with contact address; one transfer at a time; fixed + pause after every network hit +- cached files are not even revalidated before `recheck_days`; afterwards + If-Modified-Since makes an unchanged file cost one 304 +- interrupted downloads resume; per-source `--limit-rate` for small servers +- radar: `min_poll_secs` gate armed *before* the request (a failing API + cannot get hammered), request pacing + one 429 backoff-retry + +## CLI + +``` +geodata list layers + sources + ready/planned +geodata fetch [--force] download / revalidate bulk sources +geodata build build nl-.mbtiles +geodata status cache + outputs +geodata query [--radius m] [--limit n] features JSON +geodata radar-sync [forecast|reflectivity] poll rain radar +``` + +Defaults: cache `local/overlays/cache/`, output `local/overlays/`. + +## Database contract + +Written by `MbtilesWriter` (64 KB pages, deterministic block-major rowids, +passes `PRAGMA integrity_check`). Per file: + +- `tiles` — gzipped MVT 2.1 (vector, `format=pbf`) or PNG (raster, + `format=png`). +- `features` — the query sidecar (vector layers): columns `cell, layer, + name, min_lon, min_lat, max_lon, max_lat, attrs (JSON), ring (JSON|null)`. + rowid = `(z12 grid cell << 24) | seq` so bbox queries become b-tree range + scans. Polygon layers opt into `ring` (simplified exterior) for exact + point-in-polygon ("which buurt am I in"). +- `metadata` — mbtiles standard keys + `attribution`, `license`, TileJSON + `json.vector_layers` (field names/types per MVT layer), and for rasters + `geodata_encoding` (`terrarium` | `class-index`) + `geodata_classmap` + (JSON class -> label/color) so both shader and query side interpret pixels. + +Raster encodings: **terrarium** RGB (elevation, e = h+32768) — read by the +renderer's future hillshade/3D and by map_nav's EV grade baking; **class +index** gray8 (noise dB bands, flood depth bands) — colormapped in the +shader, named via the classmap. + +## Layers (all implemented) + +| layer | content | source (license) | zooms | output | +|---|---|---|---|---| +| `nature` | Natura 2000 + wetlands polygons, rings in sidecar | PDOK (CC0) | 6-12 | 5.9 MB | +| `chargers` | 66k EV charging locations, operator/power | NDW OCPI (open) | 8-14 | 25 MB | +| `demographics` | CBS 500m+100m grid stats per cell | CBS (CC BY) | 8-13 | 262 MB | +| `wijkbuurt` | gemeente/wijk/buurt polygons + kerncijfers, rings | CBS/Kadaster (CC BY) | 6-13 | 87 MB | +| `transit` | all NL stops + rail/tram/metro/ferry shapes | OVapi GTFS (CC0) | 7-14 | 13 MB | +| `buildings-age` | **all 11.4M BAG buildings** with bouwjaar+status | PDOK bag-light (CC0, 7.8 GB) | 13-14 | 1.5 GB | +| `terrain` | GLO-30 elevation, terrarium | Copernicus (attr) | 6-12 | raster | +| `noise` | RIVM 10m Lden binned to 5 dB classes | RIVM (CC0) | 6-13 | raster | +| `flood` | JRC RP100 river flood depth classes | JRC (CC BY) | 6-11 | raster | + +Live source: **rain radar** (`radar.rs`) — KNMI `radar_forecast` (+2h +nowcast, one file per 5 min, the map-app default) and +`radar_reflectivity_composites` (5-min frames, keeps ~1 h). `RadarSync::sync` +is safe to call every frame; it polls at most once per `min_poll_secs` and +returns cached `RadarFrame`s (raw KNMI HDF5 — decode to raster is the next +step). API key: config > `KNMI_API_KEY` env > shared anonymous key (register +a free personal key for real use; the anonymous quota is shared). + +## Query surface (the LLM tool-call backend) + +```rust +let mut db = query::LayerDb::open(path)?; +db.query_point(lon, lat, limit) // exact point-in-polygon w/ rings +db.query_radius(lon, lat, meters, limit) // nearest-first with distances +db.query_bbox(min_lon, min_lat, max_lon, max_lat, limit) +``` + +Verified examples: Dam Square resolves gemeente Amsterdam -> wijk +Burgwallen-Nieuwe Zijde -> buurt Nieuwe Kerk e.o. (with populations); nearest +charger 128 m (operator + kW); Royal Palace bouwjaar 1655 out of 11.4M +buildings. + +## Module map + +``` +src/fetch.rs polite bulk downloader (curl), cache + .meta.json +src/radar.rs RadarSync: KNMI radar poll/cache/prune, app-embeddable +src/geo.rs RD New <-> WGS84 polynomials (round-trip tested < 1 m); + writer-order tile key; mercator via makepad-map-nav +src/wkb.rs GeoPackage blob + ISO/EWKB parser +src/gpkg.rs GeoPackage feature iteration (schema-driven, srs transform) +src/mvt.rs MVT 2.1 encoder (extent 4096, key/value dedup) +src/tiler.rs geometry -> clipped tile features (shared); in-memory + Tileset for <= few-million-feature layers +src/spool.rs SpoolTiler: country-scale layers via per-block disk spool + (BAG: 11.4M polygons in one pass, peak memory = one block) +src/sidecar.rs features table builder (grid rowids, JSON attrs, rings) +src/query.rs LayerDb: point/radius/bbox queries over the sidecar +src/tiff.rs GeoTIFF subset: tiled/striped, deflate/LZW/none, + predictors 1/2/3, int/float samples, geo tags, nodata +src/png.rs PNG encode/decode (gray8 + rgb8) with hand-rolled CRC32 +src/raster.rs sampler -> 256px PNG tile pyramid (terrarium/class-index) +src/layers/ one module per layer + registry +src/main.rs CLI +``` + +## Known limits / follow-ups + +- Radar HDF5 -> renderable raster decode is not in yet (files are cached and + indexed; decode next). OPERA/MeteoGate is the Europe-wide radar follow-up. +- Flood: JRC `spurious_depth_areas` mask not applied; PDOK ROR official zone + polygons (CC0) planned as vector companion. +- Points are not duplicated into neighbor-tile buffers (edge icons may clip). +- No Douglas-Peucker (dedup + area culling only); BigTIFF unsupported; + `unzip` is shelled out. +- Raster query helper (`sample elevation/class at lon/lat` via PNG decode) + belongs in `query.rs` next, so the LLM can ask "how high / how loud / + flood depth here". diff --git a/libs/geodata/src/fetch.rs b/libs/geodata/src/fetch.rs new file mode 100644 index 000000000..159693d35 --- /dev/null +++ b/libs/geodata/src/fetch.rs @@ -0,0 +1,170 @@ +//! Polite bulk downloader. +//! +//! Rules, enforced here so no layer module can violate them: +//! - bulk file downloads only — never API paging, never tile scraping +//! - one transfer at a time, with a fixed pause after every network hit +//! - descriptive User-Agent with a contact address +//! - a source is never re-contacted while the cached copy is younger than its +//! `recheck_days`; after that we revalidate with If-Modified-Since so an +//! unchanged file costs the server a 304 and no bytes +//! - interrupted downloads resume (`.part` + curl `-C -`) +//! +//! Downloads shell out to `curl` (retries, TLS, resume for free). The same +//! functions are usable from the maps app later for live-ish sources: call +//! `fetch_source` with the source's `recheck_days` (e.g. 1-2 days for the NDW +//! charger file) and it does the right thing. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const USER_AGENT: &str = + "makepad-geodata/0.1 (bulk open-data fetcher; contact: rik@n4.io)"; +const PAUSE_AFTER_TRANSFER: Duration = Duration::from_secs(1); + +/// A bulk-downloadable source file. All fields static: the registry is code. +#[derive(Debug, Clone, Copy)] +pub struct SourceSpec { + pub id: &'static str, + pub url: &'static str, + /// Filename inside the cache directory. + pub filename: &'static str, + pub license: &'static str, + pub attribution: &'static str, + /// Do not even revalidate more often than this. + pub recheck_days: u32, + /// Optional curl --limit-rate value (e.g. "10M") for small origin servers. + pub limit_rate: Option<&'static str>, +} + +pub struct FetchOptions { + pub cache_dir: PathBuf, + pub force: bool, +} + +#[derive(Debug)] +pub enum FetchOutcome { + /// Cache is fresh; no network contact was made. + CachedFresh(PathBuf), + /// Revalidated; server said unchanged. + NotModified(PathBuf), + /// Downloaded (new or updated). + Downloaded(PathBuf), +} + +impl FetchOutcome { + pub fn path(&self) -> &Path { + match self { + FetchOutcome::CachedFresh(p) + | FetchOutcome::NotModified(p) + | FetchOutcome::Downloaded(p) => p, + } + } +} + +fn meta_path(cache_dir: &Path, spec: &SourceSpec) -> PathBuf { + cache_dir.join(format!("{}.meta.json", spec.filename)) +} + +fn read_fetched_unix(cache_dir: &Path, spec: &SourceSpec) -> Option { + let text = std::fs::read_to_string(meta_path(cache_dir, spec)).ok()?; + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + value.get("fetched_unix")?.as_u64() +} + +fn write_meta(cache_dir: &Path, spec: &SourceSpec, bytes: u64) -> std::io::Result<()> { + let meta = serde_json::json!({ + "id": spec.id, + "url": spec.url, + "license": spec.license, + "attribution": spec.attribution, + "fetched_unix": now_unix(), + "bytes": bytes, + }); + let mut file = std::fs::File::create(meta_path(cache_dir, spec))?; + file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes()) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Fetch one source into the cache directory, politely. Returns the cached +/// file path. Never contacts the network when the cached copy is fresh. +pub fn fetch_source(opts: &FetchOptions, spec: &SourceSpec) -> std::io::Result { + std::fs::create_dir_all(&opts.cache_dir)?; + let dest = opts.cache_dir.join(spec.filename); + let part = opts.cache_dir.join(format!("{}.part", spec.filename)); + + if dest.exists() && !opts.force { + let fetched = read_fetched_unix(&opts.cache_dir, spec) + .or_else(|| { + dest.metadata() + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + }) + .unwrap_or(0); + let age_days = (now_unix().saturating_sub(fetched)) / 86_400; + if age_days < u64::from(spec.recheck_days) { + return Ok(FetchOutcome::CachedFresh(dest)); + } + } + + let mut cmd = Command::new("curl"); + cmd.arg("-fsSL") + .arg("--retry") + .arg("3") + .arg("--retry-delay") + .arg("2") + .arg("--connect-timeout") + .arg("20") + .arg("-A") + .arg(USER_AGENT) + .arg("-o") + .arg(&part); + if let Some(rate) = spec.limit_rate { + cmd.arg("--limit-rate").arg(rate); + } + if dest.exists() && !opts.force { + // Revalidate: only transfer if newer than what we have. + cmd.arg("-z").arg(&dest); + } else if part.exists() { + // Resume an interrupted download. + cmd.arg("-C").arg("-"); + } + cmd.arg(spec.url); + + let status = cmd.status()?; + std::thread::sleep(PAUSE_AFTER_TRANSFER); + if !status.success() { + return Err(std::io::Error::other(format!( + "curl failed for {} ({})", + spec.url, status + ))); + } + + let part_len = part.metadata().map(|m| m.len()).unwrap_or(0); + if part_len == 0 && dest.exists() { + // 304 Not Modified: curl -z left us an empty output file. + let _ = std::fs::remove_file(&part); + write_meta(&opts.cache_dir, spec, dest.metadata()?.len())?; + return Ok(FetchOutcome::NotModified(dest)); + } + if part_len == 0 { + return Err(std::io::Error::other(format!( + "download of {} produced no data", + spec.url + ))); + } + std::fs::rename(&part, &dest)?; + write_meta(&opts.cache_dir, spec, part_len)?; + Ok(FetchOutcome::Downloaded(dest)) +} diff --git a/libs/geodata/src/geo.rs b/libs/geodata/src/geo.rs new file mode 100644 index 000000000..1c1391c91 --- /dev/null +++ b/libs/geodata/src/geo.rs @@ -0,0 +1,231 @@ +//! Coordinate transforms shared by all layer builders. +//! +//! Conventions: `f64` lon/lat (WGS84, degrees) at module boundaries, +//! normalized web-mercator `(0..1, 0..1)` internally (y grows south, matching +//! tile addressing), matching the map renderer's `center_norm`. + +/// RD New (EPSG:28992) -> WGS84, using the published approximation polynomials +/// (Schreutelkamp & Strang van Hees). Accuracy is decimeter-scale across NL, +/// far below overlay pixel size at any zoom we tile. +pub fn rd_to_wgs84(x: f64, y: f64) -> (f64, f64) { + const X0: f64 = 155_000.0; + const Y0: f64 = 463_000.0; + const PHI0: f64 = 52.155_174_40; + const LAM0: f64 = 5.387_206_21; + + let dx = (x - X0) * 1e-5; + let dy = (y - Y0) * 1e-5; + + // (p, q, coefficient) terms for phi (seconds of arc) + const KPQ: &[(i32, i32, f64)] = &[ + (0, 1, 3235.65389), + (2, 0, -32.58297), + (0, 2, -0.24750), + (2, 1, -0.84978), + (0, 3, -0.06550), + (2, 2, -0.01709), + (1, 0, -0.00738), + (4, 0, 0.00530), + (2, 3, -0.00039), + (4, 1, 0.00033), + (1, 1, -0.00012), + ]; + // (p, q, coefficient) terms for lambda (seconds of arc) + const LPQ: &[(i32, i32, f64)] = &[ + (1, 0, 5260.52916), + (1, 1, 105.94684), + (1, 2, 2.45656), + (3, 0, -0.81885), + (1, 3, 0.05594), + (3, 1, -0.05607), + (0, 1, 0.01199), + (3, 2, -0.00256), + (1, 4, 0.00128), + (0, 2, 0.00022), + (4, 0, -0.00022), + (5, 0, 0.00026), + ]; + + let mut dphi = 0.0; + for &(p, q, k) in KPQ { + dphi += k * dx.powi(p) * dy.powi(q); + } + let mut dlam = 0.0; + for &(p, q, l) in LPQ { + dlam += l * dx.powi(p) * dy.powi(q); + } + let lat = PHI0 + dphi / 3600.0; + let lon = LAM0 + dlam / 3600.0; + (lon, lat) +} + +/// WGS84 -> RD New (EPSG:28992), the published inverse approximation +/// polynomials. Decimeter-scale accuracy — used to sample RD-referenced +/// rasters (RIVM noise) at map coordinates. +pub fn wgs84_to_rd(lon: f64, lat: f64) -> (f64, f64) { + const X0: f64 = 155_000.0; + const Y0: f64 = 463_000.0; + const PHI0: f64 = 52.155_174_40; + const LAM0: f64 = 5.387_206_21; + + let dphi = 0.36 * (lat - PHI0); + let dlam = 0.36 * (lon - LAM0); + + const RPQ: &[(i32, i32, f64)] = &[ + (0, 1, 190_094.945), + (1, 1, -11_832.228), + (2, 1, -114.221), + (0, 3, -32.391), + (1, 0, -0.705), + (3, 1, -2.340), + (1, 3, -0.608), + (0, 2, -0.008), + (2, 3, 0.148), + ]; + const SPQ: &[(i32, i32, f64)] = &[ + (1, 0, 309_056.544), + (0, 2, 3_638.893), + (2, 0, 73.077), + (1, 2, -157.984), + (3, 0, 59.788), + (0, 1, 0.433), + (2, 2, -6.439), + (1, 1, -0.032), + (0, 4, 0.092), + (1, 4, -0.054), + ]; + + let mut x = X0; + for &(p, q, r) in RPQ { + x += r * dphi.powi(p) * dlam.powi(q); + } + let mut y = Y0; + for &(p, q, s) in SPQ { + y += s * dphi.powi(p) * dlam.powi(q); + } + (x, y) +} + +/// WGS84 lon/lat -> normalized web mercator (0..1, 0..1), y growing south. +/// Thin wrapper over the shared projection in `makepad-map-nav` so the math +/// lives in exactly one place across the map stack. +pub fn wgs84_to_norm(lon: f64, lat: f64) -> (f64, f64) { + makepad_map_nav::geo::lon_lat_to_norm(makepad_map_nav::geo::LonLat::new(lon, lat)) +} + +/// Axis-aligned bbox in normalized mercator. +#[derive(Debug, Clone, Copy)] +pub struct NormBBox { + pub min_x: f64, + pub min_y: f64, + pub max_x: f64, + pub max_y: f64, +} + +impl NormBBox { + pub fn empty() -> Self { + NormBBox { + min_x: f64::INFINITY, + min_y: f64::INFINITY, + max_x: f64::NEG_INFINITY, + max_y: f64::NEG_INFINITY, + } + } + pub fn add(&mut self, x: f64, y: f64) { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } + pub fn is_empty(&self) -> bool { + self.min_x > self.max_x + } +} + +/// Deterministic tile ordering key matching the mbtiles writer's rowid scheme +/// (zoom ascending, then 256x256 block row-major, then local row-major). +/// Sorting tile keys by this value yields the exact order `MbtilesWriter` +/// requires. +pub fn tile_order_key(zoom: u8, x: u32, y: u32) -> u128 { + let zoom_capacity = 1_u128 << (u32::from(zoom) * 2); + let prefix = (zoom_capacity - 1) / 3; + let axis = 1_u128 << zoom; + let within = if zoom <= 8 { + u128::from(y) * axis + u128::from(x) + } else { + let blocks_per_axis = 1_u128 << (zoom - 8); + let block_x = u128::from(x >> 8); + let block_y = u128::from(y >> 8); + let local_x = u128::from(x & 255); + let local_y = u128::from(y & 255); + ((block_y * blocks_per_axis + block_x) << 16) + (local_y << 8) + local_x + }; + prefix + within + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rd_origin_is_amersfoort() { + let (lon, lat) = rd_to_wgs84(155_000.0, 463_000.0); + assert!((lat - 52.155_174_40).abs() < 1e-9); + assert!((lon - 5.387_206_21).abs() < 1e-9); + } + + #[test] + fn rd_scale_is_sane() { + // 1 km east of the origin is ~0.0146 degrees of longitude at 52N. + let (lon, _lat) = rd_to_wgs84(156_000.0, 463_000.0); + let dlon = lon - 5.387_206_21; + assert!((dlon - 0.01464).abs() < 0.0005, "dlon = {dlon}"); + // 1 km north is ~0.0090 degrees of latitude. + let (_lon, lat) = rd_to_wgs84(155_000.0, 464_000.0); + let dlat = lat - 52.155_174_40; + assert!((dlat - 0.00899).abs() < 0.0005, "dlat = {dlat}"); + } + + #[test] + fn rd_round_trip_within_a_meter() { + for &(x, y) in &[ + (121_861.0, 487_981.0), // Amsterdam + (92_565.0, 437_428.0), // Rotterdam + (176_500.0, 317_500.0), // Maastricht area + (233_883.0, 582_065.0), // Groningen area + ] { + let (lon, lat) = rd_to_wgs84(x, y); + let (x2, y2) = wgs84_to_rd(lon, lat); + assert!( + (x - x2).abs() < 1.0 && (y - y2).abs() < 1.0, + "round trip drift: ({x},{y}) -> ({x2:.2},{y2:.2})" + ); + } + } + + #[test] + fn amsterdam_lands_in_amsterdam() { + // Amsterdam Centraal is around RD (121861, 487981). + let (lon, lat) = rd_to_wgs84(121_861.0, 487_981.0); + assert!((52.36..52.40).contains(&lat), "lat = {lat}"); + assert!((4.88..4.92).contains(&lon), "lon = {lon}"); + } + + #[test] + fn tile_order_matches_block_major() { + let mut last = 0u128; + for z in [6u8, 9] { + for y in 0..40u32 { + for x in 0..40u32 { + let k = tile_order_key(z, x, y); + assert!(k > last || (x == 0 && y == 0), "order broken at z{z} {x},{y}"); + if x > 0 || y > 0 { + last = k; + } else { + last = k; + } + } + } + } + } +} diff --git a/libs/geodata/src/gpkg.rs b/libs/geodata/src/gpkg.rs new file mode 100644 index 000000000..3d6865782 --- /dev/null +++ b/libs/geodata/src/gpkg.rs @@ -0,0 +1,202 @@ +//! GeoPackage feature reading on top of the generic SQLite table access in +//! `makepad-mbtile-reader` (a GeoPackage is just a SQLite file). + +use crate::geo; +use crate::wkb::{parse_gpkg_geometry, Geometry}; +use makepad_mbtile_reader::{MbtilesReader, Value}; +use std::path::Path; + +pub struct FeatureTableInfo { + pub table: String, + /// Column names in declaration order (matches record value order). + pub columns: Vec, + /// Index of the geometry column within `columns`. + pub geom_col: usize, + pub srs_id: i64, +} + +pub struct Gpkg { + db: MbtilesReader, +} + +impl Gpkg { + pub fn open(path: &Path) -> Result { + let db = MbtilesReader::open_sqlite(path) + .map_err(|e| format!("open {}: {e:?}", path.display()))?; + Ok(Gpkg { db }) + } + + /// Enumerate feature tables via gpkg_contents + gpkg_geometry_columns. + pub fn feature_tables(&mut self) -> Result, String> { + let schema = self + .db + .schema_entries() + .map_err(|e| format!("schema: {e:?}"))?; + let sql_for = |table: &str| -> Option<&str> { + schema + .iter() + .find(|e| e.obj_type == "table" && e.name == table) + .map(|e| e.sql.as_str()) + }; + + let contents_cols = columns_from_sql( + sql_for("gpkg_contents").ok_or("no gpkg_contents table")?, + ); + let c_table = col_index(&contents_cols, "table_name")?; + let c_type = col_index(&contents_cols, "data_type")?; + let c_srs = col_index(&contents_cols, "srs_id")?; + + let mut feature_tables: Vec<(String, i64)> = Vec::new(); + self.db + .for_each_row("gpkg_contents", |_rowid, values| { + let data_type = values.get(c_type).and_then(|v| v.as_text()).unwrap_or(""); + let table = values.get(c_table).and_then(|v| v.as_text()).unwrap_or(""); + let srs = values.get(c_srs).and_then(|v| v.as_integer()).unwrap_or(0); + if data_type == "features" && !table.is_empty() { + feature_tables.push((table.to_string(), srs)); + } + }) + .map_err(|e| format!("gpkg_contents: {e:?}"))?; + + let geom_cols = columns_from_sql( + sql_for("gpkg_geometry_columns").ok_or("no gpkg_geometry_columns table")?, + ); + let g_table = col_index(&geom_cols, "table_name")?; + let g_col = col_index(&geom_cols, "column_name")?; + let mut geom_col_names: Vec<(String, String)> = Vec::new(); + self.db + .for_each_row("gpkg_geometry_columns", |_rowid, values| { + let table = values.get(g_table).and_then(|v| v.as_text()).unwrap_or(""); + let col = values.get(g_col).and_then(|v| v.as_text()).unwrap_or(""); + if !table.is_empty() { + geom_col_names.push((table.to_string(), col.to_string())); + } + }) + .map_err(|e| format!("gpkg_geometry_columns: {e:?}"))?; + + let mut infos = Vec::new(); + for (table, srs_id) in feature_tables { + let Some(sql) = sql_for(&table) else { continue }; + let columns = columns_from_sql(sql); + let geom_name = geom_col_names + .iter() + .find(|(t, _)| *t == table) + .map(|(_, c)| c.clone()) + .unwrap_or_else(|| "geom".to_string()); + let Some(geom_col) = columns.iter().position(|c| *c == geom_name) else { + continue; + }; + infos.push(FeatureTableInfo { + table, + columns, + geom_col, + srs_id, + }); + } + Ok(infos) + } + + /// Iterate all features of a table. The callback gets the rowid, all + /// column values (geometry column included, as a blob), and the parsed + /// geometry already transformed to WGS84 lon/lat. + pub fn for_each_feature( + &mut self, + info: &FeatureTableInfo, + mut callback: impl FnMut(i64, &[Value], Geometry), + ) -> Result { + let srs = info.srs_id; + let geom_col = info.geom_col; + let mut skipped = 0u64; + self.db + .for_each_row(&info.table, |rowid, values| { + let Some(blob) = values.get(geom_col).and_then(|v| v.as_blob()) else { + skipped += 1; + return; + }; + let Some(geom) = parse_gpkg_geometry(blob) else { + skipped += 1; + return; + }; + let Some(geom) = to_wgs84(&geom, srs) else { + skipped += 1; + return; + }; + callback(rowid, &values, geom); + }) + .map_err(|e| format!("scan {}: {e:?}", info.table))?; + Ok(skipped) + } +} + +/// Transform a geometry from the given EPSG srs to WGS84 lon/lat. +pub fn to_wgs84(geom: &Geometry, srs_id: i64) -> Option { + match srs_id { + 4326 => Some(geom.clone()), + 28992 => Some(geom.map_coords(&|x, y| geo::rd_to_wgs84(x, y))), + _ => None, + } +} + +/// Crude but sufficient column-name extraction from a CREATE TABLE statement +/// (GeoPackage SQL is machine-generated and regular). +pub fn columns_from_sql(sql: &str) -> Vec { + let Some(open) = sql.find('(') else { + return Vec::new(); + }; + let Some(close) = sql.rfind(')') else { + return Vec::new(); + }; + let body = &sql[open + 1..close]; + let mut columns = Vec::new(); + let mut depth = 0i32; + let mut part = String::new(); + let mut parts = Vec::new(); + for ch in body.chars() { + match ch { + '(' => { + depth += 1; + part.push(ch); + } + ')' => { + depth -= 1; + part.push(ch); + } + ',' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + } + _ => part.push(ch), + } + } + parts.push(part); + + const CONSTRAINTS: &[&str] = &[ + "PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "CONSTRAINT", + ]; + for part in parts { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + let first = trimmed + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches(|c| c == '"' || c == '`' || c == '[' || c == ']' || c == '\''); + if first.is_empty() + || CONSTRAINTS + .iter() + .any(|k| first.eq_ignore_ascii_case(k)) + { + continue; + } + columns.push(first.to_string()); + } + columns +} + +fn col_index(columns: &[String], name: &str) -> Result { + columns + .iter() + .position(|c| c.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("column {name} not found in {columns:?}")) +} diff --git a/libs/geodata/src/knmi_hdf5.rs b/libs/geodata/src/knmi_hdf5.rs new file mode 100644 index 000000000..78c855284 --- /dev/null +++ b/libs/geodata/src/knmi_hdf5.rs @@ -0,0 +1,380 @@ +//! Minimal pure-Rust HDF5 reader for KNMI radar files. +//! +//! This is NOT a general HDF5 implementation. KNMI's radar products are +//! written by a fixed, old HDF5 writer and always look the same: +//! superblock v0, symbol-table groups (B-tree v1 + local heap), datasets +//! with v1 object headers, contiguous-or-chunked layout v3, and a deflate +//! filter with ONE chunk spanning the whole image. We walk exactly that +//! shape and nothing more; anything unexpected returns an error instead of +//! guessing. Times and geo constants come from the filename and the +//! documented RAD_NL25 grid, so no attribute parsing is needed. + +use makepad_fast_inflate::DecompressError; + +pub struct Hdf5File<'a> { + data: &'a [u8], +} + +#[derive(Debug)] +pub struct DatasetInfo { + /// (rows, cols) from the dataspace message. + pub dims: (u64, u64), + /// Raw (still-compressed if filtered) chunk bytes. + chunk_offset: u64, + chunk_size: u64, + /// Deflate filter present. + deflated: bool, +} + +fn u16le(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +fn u32le(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +fn u64le(b: &[u8], o: usize) -> u64 { + u64::from_le_bytes([ + b[o], + b[o + 1], + b[o + 2], + b[o + 3], + b[o + 4], + b[o + 5], + b[o + 6], + b[o + 7], + ]) +} + +impl<'a> Hdf5File<'a> { + pub fn open(data: &'a [u8]) -> Result { + if data.len() < 96 || &data[0..8] != b"\x89HDF\r\n\x1a\n" { + return Err("not an HDF5 file".into()); + } + // Superblock v0 with 8-byte offsets/lengths is the only layout the + // KNMI writer produces. + if data[8] != 0 { + return Err(format!("unsupported superblock version {}", data[8])); + } + if data[13] != 8 || data[14] != 8 { + return Err("unsupported offset/length size".into()); + } + Ok(Self { data }) + } + + /// Object header address of the root group. + fn root_object_header(&self) -> u64 { + // Superblock v0: root group symbol table entry at offset 24 + 4*8. + // Symbol table entry: link_name_offset(8) object_header_addr(8) ... + u64le(self.data, 24 + 32 + 8) + } + + /// Find a child object (group or dataset) by name inside the group whose + /// object header sits at `header_addr`. + pub fn find_child(&self, header_addr: u64, name: &str) -> Result, String> { + let (btree, heap) = self.group_symbol_table(header_addr)?; + let mut found = None; + self.walk_group_btree(btree, heap, &mut |child_name, child_addr| { + if child_name == name { + found = Some(child_addr); + } + })?; + Ok(found) + } + + pub fn find_path(&self, path: &[&str]) -> Result, String> { + let mut at = self.root_object_header(); + for part in path { + match self.find_child(at, part)? { + Some(next) => at = next, + None => return Ok(None), + } + } + Ok(Some(at)) + } + + /// Parse the SYMBOL_TABLE message (type 0x11) of a group object header. + fn group_symbol_table(&self, header_addr: u64) -> Result<(u64, u64), String> { + let mut result = None; + self.walk_object_header(header_addr, &mut |msg_type, body| { + if msg_type == 0x0011 && body.len() >= 16 { + result = Some((u64le(body, 0), u64le(body, 8))); + } + })?; + result.ok_or_else(|| "object is not a symbol-table group".into()) + } + + /// Iterate a group B-tree (v1, node type 0) yielding (name, header_addr). + fn walk_group_btree( + &self, + btree_addr: u64, + heap_addr: u64, + visit: &mut impl FnMut(&str, u64), + ) -> Result<(), String> { + let d = self.data; + let o = btree_addr as usize; + if d.len() < o + 24 || &d[o..o + 4] != b"TREE" { + return Err("bad group btree node".into()); + } + let node_type = d[o + 4]; + let node_level = d[o + 5]; + let entries = u16le(d, o + 6) as usize; + if node_type != 0 { + return Err("unexpected btree node type".into()); + } + // keys/children start after: sig(4) type(1) level(1) entries(2) + // left(8) right(8) = 24; layout: key0 child0 key1 child1 ... keyN + let mut pos = o + 24 + 8; // skip key0 (length-size offset into heap) + for _ in 0..entries { + let child = u64le(d, pos); + pos += 8 + 8; // child + next key + if node_level > 0 { + self.walk_group_btree(child, heap_addr, visit)?; + } else { + self.walk_snod(child, heap_addr, visit)?; + } + } + Ok(()) + } + + /// Symbol node: list of symbol table entries. + fn walk_snod( + &self, + snod_addr: u64, + heap_addr: u64, + visit: &mut impl FnMut(&str, u64), + ) -> Result<(), String> { + let d = self.data; + let o = snod_addr as usize; + if d.len() < o + 8 || &d[o..o + 4] != b"SNOD" { + return Err("bad symbol node".into()); + } + let count = u16le(d, o + 6) as usize; + // Local heap: signature HEAP, version, data segment address at +24. + let h = heap_addr as usize; + if d.len() < h + 32 || &d[h..h + 4] != b"HEAP" { + return Err("bad local heap".into()); + } + let heap_data = u64le(d, h + 24) as usize; + let mut pos = o + 8; + for _ in 0..count { + let name_off = u64le(d, pos) as usize; + let header = u64le(d, pos + 8); + pos += 40; // symbol table entry is 40 bytes with 8-byte offsets + let name_start = heap_data + name_off; + let mut end = name_start; + while end < d.len() && d[end] != 0 { + end += 1; + } + if let Ok(name) = std::str::from_utf8(&d[name_start..end]) { + visit(name, header); + } + } + Ok(()) + } + + /// Iterate the messages of a v1 object header, following continuation + /// blocks (message type 0x0010). + fn walk_object_header( + &self, + header_addr: u64, + visit: &mut impl FnMut(u16, &[u8]), + ) -> Result<(), String> { + let d = self.data; + let o = header_addr as usize; + if d.len() < o + 16 || d[o] != 1 { + return Err("unsupported object header version".into()); + } + let mut remaining_msgs = u16le(d, o + 2) as usize; + // v1 header: version(1) pad(1) nmsgs(2) refcount(4) header_size(4) + // then padding to 8-byte alignment: messages start at +16. + let mut blocks: Vec<(usize, usize)> = Vec::new(); + let first_size = u32le(d, o + 8) as usize; + blocks.push((o + 16, first_size)); + let mut block_index = 0; + while block_index < blocks.len() { + let (mut pos, size) = blocks[block_index]; + let block_end = pos + size; + while pos + 8 <= block_end && remaining_msgs > 0 { + let msg_type = u16le(d, pos); + let msg_size = u16le(d, pos + 2) as usize; + let body = &d[pos + 8..(pos + 8 + msg_size).min(d.len())]; + if msg_type == 0x0010 && body.len() >= 16 { + blocks.push((u64le(body, 0) as usize, u64le(body, 8) as usize)); + } else { + visit(msg_type, body); + } + remaining_msgs -= 1; + pos += 8 + msg_size; + } + block_index += 1; + } + Ok(()) + } + + /// Layout + dataspace + filters of a dataset object header. + pub fn dataset_info(&self, header_addr: u64) -> Result { + let mut dims: Option<(u64, u64)> = None; + let mut deflated = false; + let mut chunk: Option<(u64, (u64, u64))> = None; // btree addr, chunk dims + let mut contiguous: Option<(u64, u64)> = None; + self.walk_object_header(header_addr, &mut |msg_type, body| match msg_type { + 0x0001 => { + // Dataspace v1: version(1) rank(1) flags(1) reserved(5) dims... + if body.len() >= 8 && body[0] == 1 { + let rank = body[1] as usize; + if rank == 2 && body.len() >= 8 + 16 { + dims = Some((u64le(body, 8), u64le(body, 16))); + } + } + } + 0x0008 => { + // Layout v3. + if body.len() >= 2 && body[0] == 3 { + match body[1] { + 1 if body.len() >= 18 => { + contiguous = Some((u64le(body, 2), u64le(body, 10))); + } + 2 => { + // chunked: dimensionality(1) btree(8) dims(4*each) + let rank = body[2] as usize; + if body.len() >= 3 + 8 + rank * 4 { + let btree = u64le(body, 3); + let d0 = u32le(body, 11) as u64; + let d1 = u32le(body, 15) as u64; + chunk = Some((btree, (d0, d1))); + } + } + _ => {} + } + } + } + 0x000B => { + // Filter pipeline: any deflate (filter id 1) counts. + if body.len() >= 2 { + deflated = true; + } + } + _ => {} + })?; + let dims = dims.ok_or("dataset without 2D dataspace")?; + if let Some((btree_addr, _chunk_dims)) = chunk { + // Chunk B-tree v1 (node type 1). KNMI writes ONE chunk per + // dataset, so the root node is a leaf with a single entry. + let d = self.data; + let o = btree_addr as usize; + if d.len() < o + 24 || &d[o..o + 4] != b"TREE" || d[o + 4] != 1 { + return Err("bad chunk btree".into()); + } + let level = d[o + 5]; + let entries = u16le(d, o + 6) as usize; + if level != 0 || entries != 1 { + return Err(format!( + "unexpected chunk btree shape (level {level}, {entries} entries)" + )); + } + // key: chunk_size(4) filter_mask(4) offsets((rank+1)*8) then child(8). + // rank for a 2D dataset's chunk key is 3 (row, col, element). + let key = o + 24; + let chunk_size = u32le(d, key) as u64; + let child = u64le(d, key + 8 + 3 * 8); + Ok(DatasetInfo { + dims, + chunk_offset: child, + chunk_size, + deflated, + }) + } else if let Some((addr, size)) = contiguous { + Ok(DatasetInfo { + dims, + chunk_offset: addr, + chunk_size: size, + deflated: false, + }) + } else { + Err("dataset without layout".into()) + } + } + + /// Read + (if needed) inflate the dataset's single chunk. + pub fn read_dataset(&self, info: &DatasetInfo) -> Result, String> { + let start = info.chunk_offset as usize; + let end = start + info.chunk_size as usize; + if end > self.data.len() { + return Err("chunk out of bounds".into()); + } + let raw = &self.data[start..end]; + if info.deflated { + makepad_fast_inflate::zlib_decompress_vec(raw) + .map_err(|err: DecompressError| format!("inflate: {err:?}")) + } else { + Ok(raw.to_vec()) + } + } +} + +/// One decoded nowcast frame: 765x700 raw pixel values (0.5*PV-32 dBZ). +pub struct KnmiFrame { + pub minutes_offset: u32, + pub rows: usize, + pub cols: usize, + pub values: Vec, +} + +/// Decode every `imageN/image_data` frame of a KNMI radar file, in frame +/// order (image1 = +0 min, each subsequent +5 min). +pub fn decode_frames(data: &[u8]) -> Result, String> { + let file = Hdf5File::open(data)?; + let mut frames = Vec::new(); + for index in 1..=64 { + let group = format!("image{index}"); + let Some(ds) = file.find_path(&[&group, "image_data"])? else { + break; + }; + let info = file.dataset_info(ds)?; + let values = file.read_dataset(&info)?; + let (rows, cols) = (info.dims.0 as usize, info.dims.1 as usize); + if values.len() != rows * cols { + return Err(format!( + "frame {index}: {} bytes for {}x{}", + values.len(), + rows, + cols + )); + } + frames.push(KnmiFrame { + minutes_offset: (index as u32 - 1) * 5, + rows, + cols, + values, + }); + } + if frames.is_empty() { + return Err("no image groups found".into()); + } + Ok(frames) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_cached_forecast_file() { + let path = "../../local/overlays/radar/forecast/RAD_NL25_PCP_FM_202607280900.h5"; + let Ok(data) = std::fs::read(path) else { + return; + }; + let frames = decode_frames(&data).unwrap(); + assert_eq!(frames.len(), 25); + let f1 = &frames[0]; + assert_eq!((f1.rows, f1.cols), (765, 700)); + let sum: u64 = f1.values.iter().map(|&v| v as u64).sum(); + let nonzero = f1.values.iter().filter(|&&v| v > 0).count(); + // Reference values from h5py over the same file. + assert_eq!(sum, 367924); + assert_eq!(nonzero, 5068); + let f25 = &frames[24]; + let sum25: u64 = f25.values.iter().map(|&v| v as u64).sum(); + assert_eq!(sum25, 522342); + } +} diff --git a/libs/geodata/src/layers/buildings.rs b/libs/geodata/src/layers/buildings.rs new file mode 100644 index 000000000..adc069781 --- /dev/null +++ b/libs/geodata/src/layers/buildings.rs @@ -0,0 +1,121 @@ +//! BAG building polygons with construction year, z13-z14 (CC0, PDOK). +//! Output: nl-buildings-age.mbtiles, MVT layer `bag`. +//! +//! This is the one layer that cannot use the in-memory `Tileset`: ~10.9M +//! polygons at two zoom levels. It streams instead: one pass over the GPKG +//! clips features and spools compact per-256x256-block files to disk, then +//! blocks are loaded one at a time (in writer rowid order) and encoded. +//! NL spans only a handful of blocks at z13/z14, so peak memory is one +//! block's features, not the country's. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::spool::SpoolTiler; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use makepad_mbtile_reader::Value; + +const ZMIN: u8 = 13; +const ZMAX: u8 = 14; + +const BAG_LIGHT: SourceSpec = SourceSpec { + id: "bag-light", + url: "https://service.pdok.nl/lv/bag/atom/downloads/bag-light.gpkg", + filename: "bag-light.gpkg", + license: "CC0", + attribution: "Kadaster BAG via PDOK", + // Monthly refresh upstream; a stale building year is harmless. + recheck_days: 45, + limit_rate: None, +}; + +pub struct BuildingsLayer; + +impl Layer for BuildingsLayer { + fn id(&self) -> &'static str { + "buildings-age" + } + fn description(&self) -> &'static str { + "BAG building polygons with construction year (PDOK bag-light, CC0, 7.8 GB)" + } + fn sources(&self) -> Vec { + vec![BAG_LIGHT] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let path = ctx.cached(&BAG_LIGHT); + if !path.exists() { + return Err("source not fetched yet (run: geodata fetch buildings-age)".into()); + } + let mut gpkg = Gpkg::open(&path)?; + let tables = gpkg.feature_tables()?; + let table = tables + .iter() + .find(|t| t.table.eq_ignore_ascii_case("pand")) + .ok_or_else(|| { + format!( + "no 'pand' feature table; tables: {:?}", + tables.iter().map(|t| &t.table).collect::>() + ) + })?; + eprintln!( + " bag: table {} columns {:?} (srs {})", + table.table, table.columns, table.srs_id + ); + let bouwjaar_col = table + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("bouwjaar")) + .ok_or("no bouwjaar column")?; + let status_col = table + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("status")); + + let spool_dir = ctx.cache_dir.join("spool-buildings"); + let mut tiler = SpoolTiler::new(&spool_dir, ZMIN, ZMAX)?; + let mut features = 0u64; + let skipped = gpkg.for_each_feature(table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + match values.get(bouwjaar_col) { + Some(Value::Integer(year)) if *year > 0 => { + attrs.push(("bouwjaar".into(), AttrVal::Int(*year))); + } + _ => {} + } + if let Some(col) = status_col { + if let Some(Value::Text(status)) = values.get(col) { + if !status.is_empty() { + attrs.push(("status".into(), AttrVal::Str(status.clone()))); + } + } + } + if tiler.add("bag", &geom, &attrs).is_ok() { + features += 1; + if features % 1_000_000 == 0 { + eprintln!(" bag: {} M buildings spooled", features / 1_000_000); + } + } + })?; + eprintln!(" bag: {features} buildings, {skipped} without usable geometry"); + + let out = ctx.out_file(self.id()); + let stats = tiler.finish( + &out, + &crate::tiler::TilesetConfig { + name: "nl-buildings-age".into(), + description: "BAG buildings with construction year".into(), + attribution: "Kadaster BAG via PDOK".into(), + license: "CC0".into(), + minzoom: ZMIN, + maxzoom: ZMAX, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/cbs_grid.rs b/libs/geodata/src/layers/cbs_grid.rs new file mode 100644 index 000000000..91db5eb28 --- /dev/null +++ b/libs/geodata/src/layers/cbs_grid.rs @@ -0,0 +1,118 @@ +//! CBS Vierkantstatistieken: 500m and 100m statistics grids (CC-BY 4.0). +//! Output: nl-demographics.mbtiles, MVT layers `vk500` (z8-z11) and +//! `vk100` (z12-z13), square polygons carrying the per-cell statistics. +//! Negative values are CBS suppression sentinels and are omitted. + +use super::{unzip_gpkgs, BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use makepad_mbtile_reader::Value; + +const VK500: SourceSpec = SourceSpec { + id: "cbs-vk500", + url: "https://download.cbs.nl/vierkant/500/2026-cbs_vk500_2025_v1.zip", + filename: "cbs_vk500_2025.zip", + license: "CC BY 4.0", + attribution: "Centraal Bureau voor de Statistiek (CBS)", + recheck_days: 90, + limit_rate: None, +}; + +const VK100: SourceSpec = SourceSpec { + id: "cbs-vk100", + url: "https://download.cbs.nl/vierkant/100/2026-cbs_vk100_2025_v1.zip", + filename: "cbs_vk100_2025.zip", + license: "CC BY 4.0", + attribution: "Centraal Bureau voor de Statistiek (CBS)", + recheck_days: 90, + limit_rate: None, +}; + +pub struct CbsGridLayer; + +impl Layer for CbsGridLayer { + fn id(&self) -> &'static str { + "demographics" + } + fn description(&self) -> &'static str { + "CBS 500m/100m statistics grids: population, age, housing (CC BY 4.0)" + } + fn sources(&self) -> Vec { + vec![VK500, VK100] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let mut tileset = Tileset::new(); + let mut features = 0u64; + for (spec, mvt_layer, zmin, zmax) in + [(&VK500, "vk500", 8u8, 11u8), (&VK100, "vk100", 12u8, 13u8)] + { + let zip_path = ctx.cached(spec); + if !zip_path.exists() { + return Err(format!( + "source {} not fetched yet (run: geodata fetch demographics)", + spec.id + )); + } + let gpkg_path = unzip_gpkgs(&zip_path, &ctx.cache_dir)? + .into_iter() + .next() + .ok_or("no gpkg extracted")?; + let mut gpkg = Gpkg::open(&gpkg_path)?; + for table in gpkg.feature_tables()? { + eprintln!( + " {}: table {} ({} columns, srs {})", + spec.id, + table.table, + table.columns.len(), + table.srs_id + ); + gpkg.for_each_feature(&table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + for (index, column) in table.columns.iter().enumerate() { + if index == table.geom_col { + continue; + } + match values.get(index) { + // Negative values are CBS suppression sentinels. + Some(Value::Integer(i)) if *i >= 0 => { + attrs.push((column.to_lowercase(), AttrVal::Int(*i))); + } + Some(Value::Float(f)) if *f >= 0.0 => { + attrs.push((column.to_lowercase(), AttrVal::Float(*f))); + } + _ => {} + } + } + if attrs.is_empty() { + return; + } + tileset.add(mvt_layer, zmin, zmax, &geom, &attrs); + features += 1; + })?; + } + } + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-demographics".into(), + description: "CBS 500m/100m statistics grids".into(), + attribution: "CBS".into(), + license: "CC BY 4.0".into(), + minzoom: 8, + maxzoom: 13, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + diff --git a/libs/geodata/src/layers/chargers.rs b/libs/geodata/src/layers/chargers.rs new file mode 100644 index 000000000..c5a41da09 --- /dev/null +++ b/libs/geodata/src/layers/chargers.rs @@ -0,0 +1,147 @@ +//! EV charging locations from NDW's national OCPI dump (open data portal). +//! Output: nl-chargers.mbtiles, MVT layer `chargers`, point features, z8-z14. + +use super::{read_gz, BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use crate::wkb::Geometry; + +const ZMIN: u8 = 8; +const ZMAX: u8 = 14; + +const NDW_OCPI: SourceSpec = SourceSpec { + id: "ndw-chargers-ocpi", + url: "https://opendata.ndw.nu/charging_point_locations_ocpi.json.gz", + filename: "charging_point_locations_ocpi.json.gz", + license: "Open (NDW open data portal)", + attribution: "Nationaal Dataportaal Wegverkeer (NDW)", + // The file refreshes about daily; being a national aggregate it does not + // need to be fresher than a couple of days for a map overlay. + recheck_days: 2, + limit_rate: None, +}; + +pub struct ChargersLayer; + +impl Layer for ChargersLayer { + fn id(&self) -> &'static str { + "chargers" + } + fn description(&self) -> &'static str { + "EV charging locations, national OCPI aggregate (NDW open data)" + } + fn sources(&self) -> Vec { + vec![NDW_OCPI] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let path = ctx.cached(&NDW_OCPI); + if !path.exists() { + return Err("source not fetched yet (run: geodata fetch chargers)".into()); + } + let bytes = read_gz(&path)?; + let root: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| format!("parse OCPI json: {e}"))?; + + // OCPI dumps come either as a bare array of locations or wrapped in + // {"data": [...]}. Find the location array defensively. + let locations = if let Some(array) = root.as_array() { + array.clone() + } else if let Some(array) = root.get("data").and_then(|d| d.as_array()) { + array.clone() + } else { + return Err("unrecognized OCPI structure (no location array)".into()); + }; + + let mut tileset = Tileset::new(); + let mut features = 0u64; + let mut skipped = 0u64; + for location in &locations { + let coords = location.get("coordinates"); + let lat = json_f64(coords.and_then(|c| c.get("latitude"))); + let lon = json_f64(coords.and_then(|c| c.get("longitude"))); + let (Some(lat), Some(lon)) = (lat, lon) else { + skipped += 1; + continue; + }; + if !(50.0..54.0).contains(&lat) || !(3.0..8.0).contains(&lon) { + skipped += 1; + continue; + } + + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + if let Some(name) = location.get("name").and_then(|v| v.as_str()) { + if !name.is_empty() { + attrs.push(("name".into(), AttrVal::Str(name.into()))); + } + } + if let Some(op) = location + .get("operator") + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()) + { + attrs.push(("operator".into(), AttrVal::Str(op.into()))); + } + if let Some(city) = location.get("city").and_then(|v| v.as_str()) { + attrs.push(("city".into(), AttrVal::Str(city.into()))); + } + if let Some(evses) = location.get("evses").and_then(|v| v.as_array()) { + let mut connectors = 0u64; + let mut max_kw = 0.0f64; + for evse in evses { + if let Some(cs) = evse.get("connectors").and_then(|v| v.as_array()) { + connectors += cs.len() as u64; + for connector in cs { + let watts = json_f64(connector.get("max_electric_power")) + .or_else(|| { + // OCPI 2.1: voltage * amperage + let v = json_f64(connector.get("voltage"))?; + let a = json_f64(connector.get("amperage"))?; + Some(v * a) + }) + .unwrap_or(0.0); + max_kw = max_kw.max(watts / 1000.0); + } + } + } + attrs.push(("evses".into(), AttrVal::Int(evses.len() as i64))); + attrs.push(("connectors".into(), AttrVal::Int(connectors as i64))); + if max_kw > 0.0 { + attrs.push(("max_kw".into(), AttrVal::Int(max_kw.round() as i64))); + } + } + + tileset.add("chargers", ZMIN, ZMAX, &Geometry::Point(lon, lat), &attrs); + features += 1; + } + eprintln!(" chargers: {features} locations, {skipped} skipped"); + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-chargers".into(), + description: "EV charging locations (NDW OCPI national aggregate)".into(), + attribution: "NDW".into(), + license: "Open data (NDW)".into(), + minzoom: ZMIN, + maxzoom: ZMAX, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + +fn json_f64(value: Option<&serde_json::Value>) -> Option { + let value = value?; + if let Some(f) = value.as_f64() { + return Some(f); + } + value.as_str()?.trim().parse().ok() +} diff --git a/libs/geodata/src/layers/flood.rs b/libs/geodata/src/layers/flood.rs new file mode 100644 index 000000000..450c663ae --- /dev/null +++ b/libs/geodata/src/layers/flood.rs @@ -0,0 +1,98 @@ +//! Flood hazard from the JRC Europe river flood map (CC BY 4.0): maximum +//! water depth for the 1-in-100-year event, binned into depth classes as +//! gray8 class tiles z6-z11 over NL. WGS84 source (~90 m), sampled directly. +//! +//! Follow-ups documented in the README: the JRC `spurious_depth_areas` mask +//! (removes a few known artifacts) and the PDOK ROR official zone polygons +//! (CC0 GML) as a vector companion layer. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::raster::{build_raster, RasterConfig, RasterEncoding}; +use crate::tiff::Tiff; + +const JRC_RP100: SourceSpec = SourceSpec { + id: "jrc-flood-rp100", + url: "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/CEMS-EFAS/flood_hazard/Europe_RP100_filled_depth.tif", + filename: "jrc_europe_rp100_depth.tif", + license: "CC BY 4.0", + attribution: "JRC European Commission, river flood hazard maps", + recheck_days: 365, + limit_rate: None, +}; + +const NL_BOUNDS: (f64, f64, f64, f64) = (3.2, 50.7, 7.3, 53.7); + +/// Depth class edges in meters: class i covers [edges[i-1], edges[i]). +const DEPTH_EDGES: &[f64] = &[0.5, 1.0, 2.0, 4.0]; + +pub struct FloodLayer; + +impl Layer for FloodLayer { + fn id(&self) -> &'static str { + "flood" + } + fn description(&self) -> &'static str { + "JRC 1-in-100y river flood depth as class raster (CC BY 4.0)" + } + fn sources(&self) -> Vec { + vec![JRC_RP100] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let path = ctx.cached(&JRC_RP100); + if !path.exists() { + return Err("source not fetched yet (run: geodata fetch flood)".into()); + } + let mut tiff = Tiff::open(&path)?; + eprintln!( + " flood: {}x{} px, geo {:?}, nodata {:?}", + tiff.width, tiff.height, tiff.geo, tiff.nodata + ); + + let mut sampler = move |lon: f64, lat: f64| -> Option { + let depth = tiff.sample_geo(lon, lat)?; + if depth <= 0.0 { + return None; + } + let mut class = 1u8; + for (i, edge) in DEPTH_EDGES.iter().enumerate() { + if f64::from(depth) >= *edge { + class = i as u8 + 2; + } + } + Some(f32::from(class)) + }; + + let classmap = serde_json::json!([ + {"class": 1, "label": "< 0.5 m", "color": "#c6dbef"}, + {"class": 2, "label": "0.5-1 m", "color": "#9ecae1"}, + {"class": 3, "label": "1-2 m", "color": "#6baed6"}, + {"class": 4, "label": "2-4 m", "color": "#3182bd"}, + {"class": 5, "label": ">= 4 m", "color": "#08519c"} + ]); + + let out = ctx.out_file(self.id()); + let stats = build_raster( + &out, + &RasterConfig { + name: "nl-flood".into(), + description: "JRC river flood depth RP100, classed".into(), + attribution: "JRC European Commission".into(), + license: "CC BY 4.0".into(), + minzoom: 6, + maxzoom: 11, + bounds: NL_BOUNDS, + encoding: RasterEncoding::ClassIndex, + classmap: Some(classmap), + }, + &mut sampler, + )?; + Ok(BuildReport { + out_path: out, + features: stats.tiles, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/mod.rs b/libs/geodata/src/layers/mod.rs new file mode 100644 index 000000000..87437d8ad --- /dev/null +++ b/libs/geodata/src/layers/mod.rs @@ -0,0 +1,159 @@ +//! One module per overlay layer. Each layer declares its bulk sources and +//! knows how to build its own .mbtiles from the cached files. + +pub mod buildings; +pub mod cbs_grid; +pub mod chargers; +pub mod flood; +pub mod nature; +pub mod noise; +pub mod terrain; +pub mod transit; +pub mod wijkbuurt; + +use crate::fetch::SourceSpec; +use std::path::{Path, PathBuf}; + +pub struct BuildCtx { + pub cache_dir: PathBuf, + pub out_dir: PathBuf, +} + +impl BuildCtx { + pub fn cached(&self, spec: &SourceSpec) -> PathBuf { + self.cache_dir.join(spec.filename) + } + pub fn out_file(&self, layer_id: &str) -> PathBuf { + self.out_dir.join(format!("nl-{layer_id}.mbtiles")) + } +} + +pub struct BuildReport { + pub out_path: PathBuf, + pub features: u64, + pub tiles: u64, + pub bytes: u64, +} + +pub trait Layer { + fn id(&self) -> &'static str; + fn description(&self) -> &'static str; + /// Bulk files this layer needs. Empty for not-yet-wired layers. + fn sources(&self) -> Vec; + fn implemented(&self) -> bool { + true + } + fn build(&self, ctx: &BuildCtx) -> Result; +} + +/// Planned layers that are designed (see README) but not built yet. They are +/// listed so `geodata list` shows the roadmap, and refuse to build. +pub struct PlannedLayer { + pub id: &'static str, + pub description: &'static str, +} + +impl Layer for PlannedLayer { + fn id(&self) -> &'static str { + self.id + } + fn description(&self) -> &'static str { + self.description + } + fn sources(&self) -> Vec { + Vec::new() + } + fn implemented(&self) -> bool { + false + } + fn build(&self, _ctx: &BuildCtx) -> Result { + Err(format!( + "layer '{}' is designed but not implemented yet (see libs/geodata/README.md)", + self.id + )) + } +} + +/// Registry of all layers, implemented and planned. +pub fn registry() -> Vec> { + vec![ + Box::new(nature::NatureLayer), + Box::new(chargers::ChargersLayer), + Box::new(cbs_grid::CbsGridLayer), + Box::new(wijkbuurt::WijkBuurtLayer), + Box::new(transit::TransitLayer), + Box::new(buildings::BuildingsLayer), + Box::new(terrain::TerrainLayer), + Box::new(noise::NoiseLayer), + Box::new(flood::FloodLayer), + ] +} + +pub fn find_layer(id: &str) -> Option> { + registry().into_iter().find(|l| l.id() == id) +} + +/// Helper: run a closure over a gzip file's decompressed bytes. +pub fn read_gz(path: &Path) -> Result, String> { + use std::io::Read; + let file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + let mut out = Vec::new(); + flate2::read::GzDecoder::new(file) + .read_to_end(&mut out) + .map_err(|e| format!("gunzip {}: {e}", path.display()))?; + Ok(out) +} + +/// Extract every .gpkg inside a zip into the cache dir (skipping members that +/// are already extracted and newer than the zip). Returns extracted paths. +pub fn unzip_gpkgs(zip_path: &Path, cache_dir: &Path) -> Result, String> { + use std::process::Command; + let listing = Command::new("unzip") + .arg("-Z1") + .arg(zip_path) + .output() + .map_err(|e| format!("unzip -Z1: {e}"))?; + if !listing.status.success() { + return Err(format!("unzip -Z1 failed on {}", zip_path.display())); + } + let names = String::from_utf8_lossy(&listing.stdout); + let gpkg_names: Vec = names + .lines() + .filter(|l| l.to_lowercase().ends_with(".gpkg")) + .map(|l| l.to_string()) + .collect(); + if gpkg_names.is_empty() { + return Err(format!("no .gpkg inside {}", zip_path.display())); + } + let mut out_paths = Vec::new(); + for name in &gpkg_names { + let out_path = cache_dir.join( + Path::new(name) + .file_name() + .ok_or("bad zip entry name")?, + ); + let fresh = match (out_path.metadata(), zip_path.metadata()) { + (Ok(o), Ok(z)) => match (o.modified(), z.modified()) { + (Ok(om), Ok(zm)) => om >= zm, + _ => false, + }, + _ => false, + }; + if !fresh { + let status = Command::new("unzip") + .arg("-o") + .arg("-j") + .arg(zip_path) + .arg(name) + .arg("-d") + .arg(cache_dir) + .status() + .map_err(|e| format!("unzip: {e}"))?; + if !status.success() { + return Err(format!("unzip failed on {}", zip_path.display())); + } + } + out_paths.push(out_path); + } + Ok(out_paths) +} diff --git a/libs/geodata/src/layers/nature.rs b/libs/geodata/src/layers/nature.rs new file mode 100644 index 000000000..8e525321a --- /dev/null +++ b/libs/geodata/src/layers/nature.rs @@ -0,0 +1,110 @@ +//! Protected-nature polygons: Natura 2000 + Ramsar wetlands (PDOK, CC0). +//! Output: nl-nature.mbtiles, MVT layers `natura2000` and `wetlands`, z6-z12. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use makepad_mbtile_reader::Value; + +const ZMIN: u8 = 6; +const ZMAX: u8 = 12; + +const NATURA2000: SourceSpec = SourceSpec { + id: "natura2000", + url: "https://service.pdok.nl/rvo/natura2000/atom/downloads/natura2000.gpkg", + filename: "natura2000.gpkg", + license: "CC0", + attribution: "Rijksdienst voor Ondernemend Nederland via PDOK", + recheck_days: 30, + limit_rate: Some("10M"), +}; + +const WETLANDS: SourceSpec = SourceSpec { + id: "wetlands", + url: "https://service.pdok.nl/rvo/wetlands/atom/downloads/wetlands.gpkg", + filename: "wetlands.gpkg", + license: "CC0", + attribution: "Rijksdienst voor Ondernemend Nederland via PDOK", + recheck_days: 30, + limit_rate: Some("10M"), +}; + +pub struct NatureLayer; + +impl Layer for NatureLayer { + fn id(&self) -> &'static str { + "nature" + } + fn description(&self) -> &'static str { + "Protected nature polygons: Natura 2000 + Ramsar wetlands (PDOK, CC0)" + } + fn sources(&self) -> Vec { + vec![NATURA2000, WETLANDS] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let mut tileset = Tileset::new(); + tileset.query_rings(&["natura2000", "wetlands"]); + let mut features = 0u64; + for (spec, mvt_layer) in [(&NATURA2000, "natura2000"), (&WETLANDS, "wetlands")] { + let path = ctx.cached(spec); + if !path.exists() { + return Err(format!( + "source {} not fetched yet (run: geodata fetch nature)", + spec.id + )); + } + let mut gpkg = Gpkg::open(&path)?; + for table in gpkg.feature_tables()? { + eprintln!( + " {}: table {} columns {:?} (srs {})", + spec.id, table.table, table.columns, table.srs_id + ); + gpkg.for_each_feature(&table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + for (index, column) in table.columns.iter().enumerate() { + if index == table.geom_col { + continue; + } + match values.get(index) { + Some(Value::Text(text)) if !text.is_empty() && text.len() < 120 => { + attrs.push(( + column.to_lowercase(), + AttrVal::Str(text.clone()), + )); + } + Some(Value::Integer(i)) => { + attrs.push((column.to_lowercase(), AttrVal::Int(*i))); + } + _ => {} + } + } + attrs.push(("kind".into(), AttrVal::Str(mvt_layer.into()))); + tileset.add(mvt_layer, ZMIN, ZMAX, &geom, &attrs); + features += 1; + })?; + } + } + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-nature".into(), + description: "Protected nature: Natura 2000 + Ramsar wetlands (NL)".into(), + attribution: "RVO via PDOK".into(), + license: "CC0".into(), + minzoom: ZMIN, + maxzoom: ZMAX, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/noise.rs b/libs/geodata/src/layers/noise.rs new file mode 100644 index 000000000..574a7fa48 --- /dev/null +++ b/libs/geodata/src/layers/noise.rs @@ -0,0 +1,136 @@ +//! Environmental noise from RIVM "Geluid in Nederland" (CC0): the nationwide +//! 10 m Lden all-sources raster, binned into 5 dB classes as gray8 class +//! tiles z6-z13. The class table ships in `geodata_classmap` metadata so the +//! renderer colormaps in the shader and the query side can name the class. +//! Source GeoTIFF is EPSG:28992 — sampled via the WGS84->RD polynomial. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::raster::{build_raster, RasterConfig, RasterEncoding}; +use crate::tiff::Tiff; +use std::path::PathBuf; +use std::process::Command; + +const RIVM_NOISE: SourceSpec = SourceSpec { + id: "rivm-lden", + url: "https://data.rivm.nl/data/alo/rivm_20250801_Geluid_lden_allebronnen_2022.zip", + filename: "rivm_geluid_lden_2022.zip", + license: "CC0", + attribution: "RIVM Geluid in Nederland (Lden 2022)", + recheck_days: 365, + limit_rate: Some("10M"), +}; + +const NL_BOUNDS: (f64, f64, f64, f64) = (3.2, 50.7, 7.3, 53.7); + +/// Class thresholds in dB Lden: class i covers [edges[i-1], edges[i]). +const DB_EDGES: &[f64] = &[45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0]; + +pub struct NoiseLayer; + +impl Layer for NoiseLayer { + fn id(&self) -> &'static str { + "noise" + } + fn description(&self) -> &'static str { + "RIVM 10m Lden noise (all sources) as 5 dB class raster (CC0)" + } + fn sources(&self) -> Vec { + vec![RIVM_NOISE] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let zip = ctx.cached(&RIVM_NOISE); + if !zip.exists() { + return Err("source not fetched yet (run: geodata fetch noise)".into()); + } + let tif_path = unzip_first_tif(&zip, &ctx.cache_dir)?; + let mut tiff = Tiff::open(&tif_path)?; + eprintln!( + " noise: {}x{} px, geo {:?}, nodata {:?}", + tiff.width, tiff.height, tiff.geo, tiff.nodata + ); + + let mut sampler = move |lon: f64, lat: f64| -> Option { + let (x, y) = crate::geo::wgs84_to_rd(lon, lat); + let db = tiff.sample_geo(x, y)?; + if db <= 0.0 { + return None; + } + let mut class = 1u8; + for (i, edge) in DB_EDGES.iter().enumerate() { + if f64::from(db) >= *edge { + class = i as u8 + 2; + } + } + Some(f32::from(class)) + }; + + let classmap = serde_json::json!([ + {"class": 1, "label": "< 45 dB", "color": "#00000000"}, + {"class": 2, "label": "45-50 dB", "color": "#4575b4"}, + {"class": 3, "label": "50-55 dB", "color": "#91bfdb"}, + {"class": 4, "label": "55-60 dB", "color": "#e0f382"}, + {"class": 5, "label": "60-65 dB", "color": "#fee090"}, + {"class": 6, "label": "65-70 dB", "color": "#fc8d59"}, + {"class": 7, "label": "70-75 dB", "color": "#d73027"}, + {"class": 8, "label": ">= 75 dB", "color": "#a50026"} + ]); + + let out = ctx.out_file(self.id()); + let stats = build_raster( + &out, + &RasterConfig { + name: "nl-noise".into(), + description: "RIVM Lden all-sources noise, 5 dB classes".into(), + attribution: "RIVM".into(), + license: "CC0".into(), + minzoom: 6, + maxzoom: 13, + bounds: NL_BOUNDS, + encoding: RasterEncoding::ClassIndex, + classmap: Some(classmap), + }, + &mut sampler, + )?; + Ok(BuildReport { + out_path: out, + features: stats.tiles, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + +fn unzip_first_tif(zip: &PathBuf, cache_dir: &PathBuf) -> Result { + let listing = Command::new("unzip") + .arg("-Z1") + .arg(zip) + .output() + .map_err(|e| format!("unzip -Z1: {e}"))?; + let names = String::from_utf8_lossy(&listing.stdout); + let tif = names + .lines() + .find(|l| { + let lower = l.to_lowercase(); + lower.ends_with(".tif") || lower.ends_with(".tiff") + }) + .ok_or_else(|| format!("no .tif inside {} (members: {})", zip.display(), names))? + .to_string(); + let out = cache_dir.join(std::path::Path::new(&tif).file_name().ok_or("bad name")?); + if !out.exists() { + let status = Command::new("unzip") + .arg("-o") + .arg("-j") + .arg(zip) + .arg(&tif) + .arg("-d") + .arg(cache_dir) + .status() + .map_err(|e| format!("unzip: {e}"))?; + if !status.success() { + return Err("unzip failed".into()); + } + } + Ok(out) +} diff --git a/libs/geodata/src/layers/terrain.rs b/libs/geodata/src/layers/terrain.rs new file mode 100644 index 000000000..cfaded81f --- /dev/null +++ b/libs/geodata/src/layers/terrain.rs @@ -0,0 +1,129 @@ +//! Elevation from Copernicus GLO-30 (30 m DSM, ESA/Airbus, attribution +//! license) as terrarium-encoded raster tiles, z6-z12 over NL. +//! Consumers: renderer hillshade / 3D terrain, and map_nav's per-edge +//! climb/descent baking for EV routing (both read the same file). +//! +//! Source: anonymous AWS open-data COGs, one 1x1 degree tile each. Tiles +//! that are all-ocean don't exist upstream — those fetches are expected to +//! fail and the build treats missing cells as sea level. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::{fetch_source, FetchOptions, SourceSpec}; +use crate::raster::{build_raster, RasterConfig, RasterEncoding}; +use crate::tiff::Tiff; +use std::collections::HashMap; + +const LAT_RANGE: std::ops::RangeInclusive = 50..=53; +const LON_RANGE: std::ops::RangeInclusive = 3..=7; +const NL_BOUNDS: (f64, f64, f64, f64) = (3.0, 50.7, 7.3, 53.8); + +fn tile_specs() -> Vec { + let mut specs = Vec::new(); + for lat in LAT_RANGE { + for lon in LON_RANGE { + let stem = format!("Copernicus_DSM_COG_10_N{lat:02}_00_E{lon:03}_00_DEM"); + let url: &'static str = Box::leak( + format!("https://copernicus-dem-30m.s3.amazonaws.com/{stem}/{stem}.tif") + .into_boxed_str(), + ); + let filename: &'static str = + Box::leak(format!("glo30_N{lat:02}_E{lon:03}.tif").into_boxed_str()); + let id: &'static str = + Box::leak(format!("glo30-N{lat:02}-E{lon:03}").into_boxed_str()); + specs.push(SourceSpec { + id, + url, + filename, + license: "Copernicus DEM (ESA/Airbus, attribution)", + attribution: "Copernicus DEM GLO-30 (c) ESA / Airbus", + recheck_days: 3650, // static dataset + limit_rate: None, + }); + } + } + specs +} + +pub struct TerrainLayer; + +impl Layer for TerrainLayer { + fn id(&self) -> &'static str { + "terrain" + } + fn description(&self) -> &'static str { + "Copernicus GLO-30 elevation as terrarium tiles (hillshade + EV grades)" + } + fn sources(&self) -> Vec { + tile_specs() + } + + fn build(&self, ctx: &BuildCtx) -> Result { + // Fetch tolerantly here: all-ocean 1-degree cells 404 upstream. + let opts = FetchOptions { + cache_dir: ctx.cache_dir.clone(), + force: false, + }; + let mut cells: HashMap<(i32, i32), Option> = HashMap::new(); + for (index, spec) in tile_specs().iter().enumerate() { + let lat = 50 + (index as i32) / 5; + let lon = 3 + (index as i32) % 5; + let path = ctx.cached(spec); + if !path.exists() { + match fetch_source(&opts, spec) { + Ok(_) => {} + Err(error) => { + eprintln!(" terrain: {} unavailable ({error}) — treating as sea", spec.id); + cells.insert((lat, lon), None); + continue; + } + } + } + match Tiff::open(&path) { + Ok(tiff) => { + cells.insert((lat, lon), Some(tiff)); + } + Err(error) => { + eprintln!(" terrain: {} unreadable ({error})", spec.id); + cells.insert((lat, lon), None); + } + } + } + let available = cells.values().filter(|c| c.is_some()).count(); + eprintln!(" terrain: {available} of {} degree cells available", cells.len()); + if available == 0 { + return Err("no GLO-30 cells available".into()); + } + + let mut sampler = move |lon: f64, lat: f64| -> Option { + let cell = (lat.floor() as i32, lon.floor() as i32); + match cells.get_mut(&cell) { + Some(Some(tiff)) => tiff.sample_geo(lon, lat).or(Some(0.0)), + Some(None) => Some(0.0), // known-ocean cell: sea level + None => None, // outside our cell set + } + }; + + let out = ctx.out_file(self.id()); + let stats = build_raster( + &out, + &RasterConfig { + name: "nl-terrain".into(), + description: "Copernicus GLO-30 elevation, terrarium encoding".into(), + attribution: "Copernicus DEM GLO-30 (c) ESA / Airbus".into(), + license: "Copernicus DEM licence (attribution)".into(), + minzoom: 6, + maxzoom: 12, + bounds: NL_BOUNDS, + encoding: RasterEncoding::Terrarium, + classmap: None, + }, + &mut sampler, + )?; + Ok(BuildReport { + out_path: out, + features: stats.tiles, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/transit.rs b/libs/geodata/src/layers/transit.rs new file mode 100644 index 000000000..9cd0c48c2 --- /dev/null +++ b/libs/geodata/src/layers/transit.rs @@ -0,0 +1,261 @@ +//! Public transport from the OVapi static GTFS bundle (CC0): all NL stops as +//! points plus rail/tram/metro/ferry route shapes as lines (bus shapes are +//! skipped — they follow the roads that are already on the map and triple the +//! archive size). Output: nl-transit.mbtiles, MVT layers `stops` (z10-z14) +//! and `routes` (z7-z12). +//! +//! Live vehicle positions (GTFS-RT) are deliberately NOT here: that is a +//! runtime concern for the map app. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use crate::wkb::Geometry; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Command, Stdio}; + +const OVAPI_GTFS: SourceSpec = SourceSpec { + id: "ovapi-gtfs", + url: "https://gtfs.openov.nl/gtfs-rt/gtfs-openov-nl.zip", + filename: "gtfs-openov-nl.zip", + license: "CC0", + attribution: "OVapi / NDOV loket", + // Refreshed daily upstream; weekly is plenty for stop/route geometry. + recheck_days: 7, + limit_rate: None, +}; + +pub struct TransitLayer; + +impl Layer for TransitLayer { + fn id(&self) -> &'static str { + "transit" + } + fn description(&self) -> &'static str { + "All NL transit stops + rail/tram/metro/ferry route lines (OVapi GTFS, CC0)" + } + fn sources(&self) -> Vec { + vec![OVAPI_GTFS] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let zip = ctx.cached(&OVAPI_GTFS); + if !zip.exists() { + return Err("source not fetched yet (run: geodata fetch transit)".into()); + } + + // routes.txt: route_id -> (route_type, name) + let mut route_info: HashMap = HashMap::new(); + stream_gtfs_csv(&zip, "routes.txt", |row| { + let (Some(id), Some(rtype)) = (row.get("route_id"), row.get("route_type")) else { + return; + }; + let name = row + .get("route_short_name") + .filter(|s| !s.is_empty()) + .or_else(|| row.get("route_long_name")) + .cloned() + .unwrap_or_default(); + route_info.insert(id.clone(), (rtype.parse().unwrap_or(3), name)); + })?; + + // trips.txt: shape_id -> route_id (first trip wins; direction variants + // produce distinct shape_ids so nothing is lost). + let mut shape_route: HashMap = HashMap::new(); + stream_gtfs_csv(&zip, "trips.txt", |row| { + let (Some(shape), Some(route)) = (row.get("shape_id"), row.get("route_id")) else { + return; + }; + if shape.is_empty() { + return; + } + shape_route + .entry(shape.clone()) + .or_insert_with(|| route.clone()); + })?; + + // Which shapes do we keep? Everything except bus (route_type 3 and the + // extended 700-series bus codes). + let keep_shape: HashMap<&String, (&i64, &String)> = shape_route + .iter() + .filter_map(|(shape, route)| { + let (rtype, name) = route_info.get(route)?; + let is_bus = *rtype == 3 || (700..800).contains(rtype); + if is_bus { + None + } else { + Some((shape, (rtype, name))) + } + }) + .collect(); + + let mut tileset = Tileset::new(); + let mut features = 0u64; + + // stops.txt -> points + stream_gtfs_csv(&zip, "stops.txt", |row| { + let (Some(lat), Some(lon)) = (row.get("stop_lat"), row.get("stop_lon")) else { + return; + }; + let (Ok(lat), Ok(lon)) = (lat.parse::(), lon.parse::()) else { + return; + }; + if !(50.0..54.2).contains(&lat) || !(2.5..7.5).contains(&lon) { + return; + } + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + if let Some(name) = row.get("stop_name") { + if !name.is_empty() { + attrs.push(("name".into(), AttrVal::Str(name.clone()))); + } + } + let is_station = row.get("location_type").map(|s| s.as_str()) == Some("1"); + if is_station { + attrs.push(("station".into(), AttrVal::Bool(true))); + } + // Stations get a wider zoom range than local stops. + let zmin = if is_station { 8 } else { 10 }; + tileset.add("stops", zmin, 14, &Geometry::Point(lon, lat), &attrs); + features += 1; + })?; + + // shapes.txt -> route lines (streamed; shapes arrive grouped by id, + // but don't rely on it — accumulate per shape id, flush at the end). + let mut shapes: HashMap> = HashMap::new(); + stream_gtfs_csv(&zip, "shapes.txt", |row| { + let Some(id) = row.get("shape_id") else { return }; + if !keep_shape.contains_key(id) { + return; + } + let (Some(lat), Some(lon), Some(seq)) = ( + row.get("shape_pt_lat"), + row.get("shape_pt_lon"), + row.get("shape_pt_sequence"), + ) else { + return; + }; + let (Ok(lat), Ok(lon), Ok(seq)) = + (lat.parse::(), lon.parse::(), seq.parse::()) + else { + return; + }; + shapes.entry(id.clone()).or_default().push((lon, lat, seq)); + })?; + let shape_count = shapes.len(); + for (shape_id, mut pts) in shapes { + let Some((rtype, name)) = keep_shape.get(&shape_id) else { + continue; + }; + pts.sort_by_key(|&(_, _, seq)| seq); + let line: Vec<(f64, f64)> = pts.iter().map(|&(lon, lat, _)| (lon, lat)).collect(); + if line.len() < 2 { + continue; + } + let mode = match **rtype { + 0 | 900..=906 => "tram", + 1 | 400..=404 => "metro", + 2 | 100..=117 => "rail", + 4 | 1000 | 1200 => "ferry", + _ => "other", + }; + let mut attrs = vec![("mode".to_string(), AttrVal::Str(mode.into()))]; + if !name.is_empty() { + attrs.push(("ref".into(), AttrVal::Str((*name).clone()))); + } + tileset.add("routes", 7, 14, &Geometry::LineString(line), &attrs); + features += 1; + } + eprintln!(" transit: {features} features ({shape_count} non-bus shapes)"); + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-transit".into(), + description: "NL transit stops + non-bus route shapes (OVapi GTFS)".into(), + attribution: "OVapi / NDOV".into(), + license: "CC0".into(), + minzoom: 7, + maxzoom: 14, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + +/// Stream one CSV member of a zip through a row callback without extracting +/// to disk. Handles quoted fields and the UTF-8 BOM. +fn stream_gtfs_csv( + zip: &Path, + member: &str, + mut callback: impl FnMut(&HashMap), +) -> Result<(), String> { + let mut child = Command::new("unzip") + .arg("-p") + .arg(zip) + .arg(member) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("unzip -p {member}: {e}"))?; + let stdout = child.stdout.take().ok_or("no stdout")?; + let reader = BufReader::with_capacity(1 << 20, stdout); + + let mut header: Vec = Vec::new(); + let mut row: HashMap = HashMap::new(); + for line in reader.lines() { + let line = line.map_err(|e| format!("read {member}: {e}"))?; + let fields = parse_csv_line(&line); + if header.is_empty() { + header = fields + .into_iter() + .map(|f| f.trim_start_matches('\u{feff}').to_string()) + .collect(); + continue; + } + row.clear(); + for (i, field) in fields.into_iter().enumerate() { + if let Some(key) = header.get(i) { + row.insert(key.clone(), field); + } + } + callback(&row); + } + let status = child.wait().map_err(|e| format!("unzip wait: {e}"))?; + if !status.success() { + return Err(format!("unzip -p {member} failed (missing member?)")); + } + Ok(()) +} + +fn parse_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut field = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if in_quotes => { + if chars.peek() == Some(&'"') { + chars.next(); + field.push('"'); + } else { + in_quotes = false; + } + } + '"' => in_quotes = true, + ',' if !in_quotes => fields.push(std::mem::take(&mut field)), + _ => field.push(c), + } + } + fields.push(field); + fields +} diff --git a/libs/geodata/src/layers/wijkbuurt.rs b/libs/geodata/src/layers/wijkbuurt.rs new file mode 100644 index 000000000..622eb1908 --- /dev/null +++ b/libs/geodata/src/layers/wijkbuurt.rs @@ -0,0 +1,108 @@ +//! CBS Wijk- en Buurtkaart: municipality/district/neighborhood polygons with +//! kerncijfers (CC BY 4.0). Output: nl-wijkbuurt.mbtiles with MVT layers +//! `gemeenten` (z6-z8), `wijken` (z9-z10), `buurten` (z11-z13). + +use super::{unzip_gpkgs, BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use makepad_mbtile_reader::Value; + +const WIJKBUURT: SourceSpec = SourceSpec { + id: "cbs-wijkbuurt", + url: "https://geodata.cbs.nl/files/Wijkenbuurtkaart/WijkBuurtkaart_2025_v1.zip", + filename: "wijkbuurtkaart_2025.zip", + license: "CC BY 4.0", + attribution: "CBS / Kadaster", + recheck_days: 90, + limit_rate: None, +}; + +pub struct WijkBuurtLayer; + +impl Layer for WijkBuurtLayer { + fn id(&self) -> &'static str { + "wijkbuurt" + } + fn description(&self) -> &'static str { + "CBS municipality/district/neighborhood polygons with key statistics (CC BY 4.0)" + } + fn sources(&self) -> Vec { + vec![WIJKBUURT] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let zip_path = ctx.cached(&WIJKBUURT); + if !zip_path.exists() { + return Err("source not fetched yet (run: geodata fetch wijkbuurt)".into()); + } + let gpkgs = unzip_gpkgs(&zip_path, &ctx.cache_dir)?; + let mut tileset = Tileset::new(); + tileset.query_rings(&["gemeenten", "wijken", "buurten"]); + let mut features = 0u64; + for gpkg_path in &gpkgs { + let mut gpkg = Gpkg::open(gpkg_path)?; + for table in gpkg.feature_tables()? { + let lower = table.table.to_lowercase(); + let (mvt_layer, zmin, zmax) = if lower.contains("gemeente") { + ("gemeenten", 6u8, 8u8) + } else if lower.contains("wijk") { + ("wijken", 9, 10) + } else if lower.contains("buurt") { + ("buurten", 11, 13) + } else { + continue; + }; + eprintln!( + " wijkbuurt: table {} -> layer {} ({} columns)", + table.table, + mvt_layer, + table.columns.len() + ); + gpkg.for_each_feature(&table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + for (index, column) in table.columns.iter().enumerate() { + if index == table.geom_col { + continue; + } + match values.get(index) { + // Negative values are CBS suppression sentinels. + Some(Value::Integer(i)) if *i >= 0 => { + attrs.push((column.to_lowercase(), AttrVal::Int(*i))); + } + Some(Value::Float(f)) if *f >= 0.0 => { + attrs.push((column.to_lowercase(), AttrVal::Float(*f))); + } + Some(Value::Text(t)) if !t.is_empty() && t.len() < 80 => { + attrs.push((column.to_lowercase(), AttrVal::Str(t.clone()))); + } + _ => {} + } + } + tileset.add(mvt_layer, zmin, zmax, &geom, &attrs); + features += 1; + })?; + } + } + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-wijkbuurt".into(), + description: "CBS Wijk- en Buurtkaart with kerncijfers".into(), + attribution: "CBS / Kadaster".into(), + license: "CC BY 4.0".into(), + minzoom: 6, + maxzoom: 13, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/lib.rs b/libs/geodata/src/lib.rs new file mode 100644 index 000000000..90fafa18d --- /dev/null +++ b/libs/geodata/src/lib.rs @@ -0,0 +1,31 @@ +//! makepad-geodata: bulk open-geodata fetching and per-layer overlay database +//! building for the map stack. +//! +//! Every overlay layer becomes its own .mbtiles file (never merged into the +//! base map archive), built from bulk-downloadable open datasets only. The +//! fetch module enforces the politeness rules; the tiler writes standard +//! gzipped MVT the renderer's existing decoder already understands. +//! +//! This is a library so the maps app can embed the same fetch/cache/build +//! machinery later for periodically synced sources (e.g. the NDW charger +//! file); the `geodata` binary is a thin CLI over it. + +pub mod fetch; +pub mod geo; +pub mod gpkg; +pub mod layers; +pub mod mvt; +pub mod png; +pub mod query; +pub mod knmi_hdf5; +pub mod radar_raster; +pub mod radar; +pub mod raster; +pub mod sidecar; +pub mod tiff; +pub mod spool; +pub mod tiler; +pub mod wkb; + +pub use fetch::{fetch_source, FetchOptions, FetchOutcome, SourceSpec}; +pub use layers::{find_layer, registry, BuildCtx, BuildReport, Layer}; diff --git a/libs/geodata/src/main.rs b/libs/geodata/src/main.rs new file mode 100644 index 000000000..d154e6448 --- /dev/null +++ b/libs/geodata/src/main.rs @@ -0,0 +1,272 @@ +use makepad_geodata::{fetch_source, find_layer, registry, BuildCtx, FetchOptions}; +use std::path::PathBuf; + +fn usage() -> ! { + eprintln!( + "geodata — bulk open-geodata fetcher / overlay database builder + +USAGE: + geodata list show all layers and their sources + geodata fetch download (or revalidate) a layer's bulk sources + geodata build build the layer's .mbtiles (fetches if missing) + geodata status show cache and output state + geodata query query the features sidecar (LLM surface) + +OPTIONS: + --cache-dir default: local/overlays/cache + --out-dir default: local/overlays + --force re-download even if the cache is fresh + --radius query: search radius in meters (default: point query) + --limit query: max results (default 10)" + ); + std::process::exit(2); +} + +struct Args { + command: String, + target: String, + positional: Vec, + cache_dir: PathBuf, + out_dir: PathBuf, + force: bool, + radius: Option, + limit: usize, +} + +fn parse_args() -> Args { + let mut args = Args { + command: String::new(), + target: String::new(), + positional: Vec::new(), + cache_dir: PathBuf::from("local/overlays/cache"), + out_dir: PathBuf::from("local/overlays"), + force: false, + radius: None, + limit: 10, + }; + let mut positional = Vec::new(); + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--cache-dir" => args.cache_dir = PathBuf::from(iter.next().unwrap_or_default()), + "--out-dir" => args.out_dir = PathBuf::from(iter.next().unwrap_or_default()), + "--force" => args.force = true, + "--radius" => args.radius = iter.next().and_then(|v| v.parse().ok()), + "--limit" => args.limit = iter.next().and_then(|v| v.parse().ok()).unwrap_or(10), + "-h" | "--help" => usage(), + other => positional.push(other.to_string()), + } + } + if positional.is_empty() { + usage(); + } + args.command = positional[0].clone(); + args.target = positional.get(1).cloned().unwrap_or_else(|| "all".into()); + args.positional = positional; + args +} + +fn main() { + let args = parse_args(); + match args.command.as_str() { + "list" => { + for layer in registry() { + let state = if layer.implemented() { + "ready" + } else { + "planned" + }; + println!("{:<14} [{}] {}", layer.id(), state, layer.description()); + for source in layer.sources() { + println!( + " source {:<20} {} ({})", + source.id, source.url, source.license + ); + } + } + } + "fetch" => { + let opts = FetchOptions { + cache_dir: args.cache_dir.clone(), + force: args.force, + }; + for layer in select(&args.target) { + for source in layer.sources() { + match fetch_source(&opts, &source) { + Ok(outcome) => println!( + "{:<14} {:<20} {:?}", + layer.id(), + source.id, + outcome + ), + Err(error) => { + eprintln!("{:<14} {:<20} ERROR {error}", layer.id(), source.id); + std::process::exit(1); + } + } + } + } + } + "build" => { + let opts = FetchOptions { + cache_dir: args.cache_dir.clone(), + force: false, + }; + let ctx = BuildCtx { + cache_dir: args.cache_dir.clone(), + out_dir: args.out_dir.clone(), + }; + std::fs::create_dir_all(&ctx.out_dir).expect("create out dir"); + for layer in select(&args.target) { + if !layer.implemented() { + if args.target != "all" { + eprintln!("{}: planned, not implemented yet", layer.id()); + } + continue; + } + // Make sure sources exist (fresh cache is fine, no re-download). + // A failed fetch is a warning; the build decides whether the + // missing file is fatal (e.g. ocean-only DEM cells are not). + for source in layer.sources() { + if let Err(error) = fetch_source(&opts, &source) { + eprintln!("{}: fetch {} failed: {error}", layer.id(), source.id); + } + } + let start = std::time::Instant::now(); + match layer.build(&ctx) { + Ok(report) => println!( + "{:<14} {} features -> {} tiles, {:.1} MB, {:.1}s -> {}", + layer.id(), + report.features, + report.tiles, + report.bytes as f64 / 1e6, + start.elapsed().as_secs_f64(), + report.out_path.display() + ), + Err(error) => { + eprintln!("{}: build failed: {error}", layer.id()); + std::process::exit(1); + } + } + } + } + "radar-sync" => { + let dataset = if args.target == "reflectivity" { + makepad_geodata::radar::RadarDataset::ReflectivityComposite + } else { + makepad_geodata::radar::RadarDataset::Forecast + }; + let config = makepad_geodata::radar::RadarConfig::for_dataset( + args.out_dir.join("radar"), + dataset, + ); + match makepad_geodata::radar::RadarSync::new(config).sync() { + Ok(state) => { + println!( + "polled: {}, downloaded: {}, frames on disk: {}", + state.polled, + state.downloaded, + state.frames.len() + ); + for frame in &state.frames { + println!( + " {} ({:.1} MB, created {})", + frame.filename, + frame.bytes as f64 / 1e6, + frame.created_unix + ); + } + } + Err(error) => { + eprintln!("radar sync failed: {error}"); + std::process::exit(1); + } + } + } + "query" => { + let (Some(lon), Some(lat)) = ( + args.positional.get(2).and_then(|v| v.parse::().ok()), + args.positional.get(3).and_then(|v| v.parse::().ok()), + ) else { + usage(); + }; + let path = if args.target.ends_with(".mbtiles") { + PathBuf::from(&args.target) + } else { + args.out_dir.join(format!("nl-{}.mbtiles", args.target)) + }; + let mut db = match makepad_geodata::query::LayerDb::open(&path) { + Ok(db) => db, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + let result = match args.radius { + Some(radius) => db.query_radius(lon, lat, radius, args.limit), + None => db.query_point(lon, lat, args.limit), + }; + match result { + Ok(hits) => { + for hit in hits { + let mut attrs = hit.attrs.clone(); + if let Some(map) = attrs.as_object_mut() { + map.remove("__ring"); + } + let line = serde_json::json!({ + "layer": hit.layer, + "name": hit.name, + "distance_m": hit.distance_m.map(|d| d.round()), + "center": [hit.center.0, hit.center.1], + "attrs": attrs, + }); + println!("{line}"); + } + } + Err(error) => { + eprintln!("query failed: {error}"); + std::process::exit(1); + } + } + } + "status" => { + println!("cache: {}", args.cache_dir.display()); + for layer in registry() { + for source in layer.sources() { + let path = args.cache_dir.join(source.filename); + let state = match path.metadata() { + Ok(meta) => format!("{:.1} MB", meta.len() as f64 / 1e6), + Err(_) => "missing".into(), + }; + println!(" {:<24} {}", source.filename, state); + } + } + println!("outputs: {}", args.out_dir.display()); + for layer in registry() { + let path = args.out_dir.join(format!("nl-{}.mbtiles", layer.id())); + if let Ok(meta) = path.metadata() { + println!( + " nl-{}.mbtiles {:.1} MB", + layer.id(), + meta.len() as f64 / 1e6 + ); + } + } + } + _ => usage(), + } +} + +fn select(target: &str) -> Vec> { + if target == "all" { + registry() + } else { + match find_layer(target) { + Some(layer) => vec![layer], + None => { + eprintln!("unknown layer '{target}' (see: geodata list)"); + std::process::exit(2); + } + } + } +} diff --git a/libs/geodata/src/mvt.rs b/libs/geodata/src/mvt.rs new file mode 100644 index 000000000..f9d458ee9 --- /dev/null +++ b/libs/geodata/src/mvt.rs @@ -0,0 +1,219 @@ +//! Minimal Mapbox Vector Tile (MVT 2.1) encoder. Extent 4096, gzip applied by +//! the tiler so the map renderer's payload sniffing works unchanged. + +use std::collections::HashMap; + +pub const EXTENT: u32 = 4096; + +#[derive(Debug, Clone, PartialEq)] +pub enum AttrVal { + Str(String), + Int(i64), + Float(f64), + Bool(bool), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GeomType { + Point = 1, + Line = 2, + Polygon = 3, +} + +pub struct PreFeature { + pub geom_type: GeomType, + /// Fully encoded geometry command stream (zigzag deltas included). + pub commands: Vec, + pub attrs: Vec<(String, AttrVal)>, +} + +struct LayerEnc { + name: String, + keys: Vec, + key_index: HashMap, + values: Vec, + value_index: HashMap, + features: Vec>, +} + +impl LayerEnc { + fn new(name: &str) -> Self { + LayerEnc { + name: name.to_string(), + keys: Vec::new(), + key_index: HashMap::new(), + values: Vec::new(), + value_index: HashMap::new(), + features: Vec::new(), + } + } + + fn key_id(&mut self, key: &str) -> u32 { + if let Some(&id) = self.key_index.get(key) { + return id; + } + let id = self.keys.len() as u32; + self.keys.push(key.to_string()); + self.key_index.insert(key.to_string(), id); + id + } + + fn value_id(&mut self, value: &AttrVal) -> u32 { + let dedup_key = match value { + AttrVal::Str(s) => format!("s\u{1}{s}"), + AttrVal::Int(i) => format!("i\u{1}{i}"), + AttrVal::Float(f) => format!("f\u{1}{:016x}", f.to_bits()), + AttrVal::Bool(b) => format!("b\u{1}{b}"), + }; + if let Some(&id) = self.value_index.get(&dedup_key) { + return id; + } + let id = self.values.len() as u32; + self.values.push(value.clone()); + self.value_index.insert(dedup_key, id); + id + } + + fn add_feature(&mut self, feature: &PreFeature) { + let mut tags = Vec::with_capacity(feature.attrs.len() * 2); + for (key, value) in &feature.attrs { + tags.push(self.key_id(key)); + tags.push(self.value_id(value)); + } + let mut buf = Vec::with_capacity(feature.commands.len() * 2 + tags.len() * 2 + 8); + // tags (field 2, packed) + if !tags.is_empty() { + let mut packed = Vec::with_capacity(tags.len() * 2); + for tag in &tags { + write_varint(u64::from(*tag), &mut packed); + } + write_tag(2, 2, &mut buf); + write_varint(packed.len() as u64, &mut buf); + buf.extend_from_slice(&packed); + } + // type (field 3) + write_tag(3, 0, &mut buf); + write_varint(feature.geom_type as u64, &mut buf); + // geometry (field 4, packed) + let mut packed = Vec::with_capacity(feature.commands.len() * 2); + for command in &feature.commands { + write_varint(u64::from(*command), &mut packed); + } + write_tag(4, 2, &mut buf); + write_varint(packed.len() as u64, &mut buf); + buf.extend_from_slice(&packed); + + self.features.push(buf); + } + + fn encode(&self, out: &mut Vec) { + let mut layer = Vec::new(); + // version (field 15) + write_tag(15, 0, &mut layer); + write_varint(2, &mut layer); + // name (field 1) + write_tag(1, 2, &mut layer); + write_varint(self.name.len() as u64, &mut layer); + layer.extend_from_slice(self.name.as_bytes()); + // features (field 2) + for feature in &self.features { + write_tag(2, 2, &mut layer); + write_varint(feature.len() as u64, &mut layer); + layer.extend_from_slice(feature); + } + // keys (field 3) + for key in &self.keys { + write_tag(3, 2, &mut layer); + write_varint(key.len() as u64, &mut layer); + layer.extend_from_slice(key.as_bytes()); + } + // values (field 4) + for value in &self.values { + let mut vbuf = Vec::new(); + match value { + AttrVal::Str(s) => { + write_tag(1, 2, &mut vbuf); + write_varint(s.len() as u64, &mut vbuf); + vbuf.extend_from_slice(s.as_bytes()); + } + AttrVal::Float(f) => { + write_tag(3, 1, &mut vbuf); + vbuf.extend_from_slice(&f.to_le_bytes()); + } + AttrVal::Int(i) => { + write_tag(4, 0, &mut vbuf); + write_varint(*i as u64, &mut vbuf); + } + AttrVal::Bool(b) => { + write_tag(7, 0, &mut vbuf); + write_varint(u64::from(*b), &mut vbuf); + } + } + write_tag(4, 2, &mut layer); + write_varint(vbuf.len() as u64, &mut layer); + layer.extend_from_slice(&vbuf); + } + // extent (field 5) + write_tag(5, 0, &mut layer); + write_varint(u64::from(EXTENT), &mut layer); + + // Tile.layers is field 3 + write_tag(3, 2, out); + write_varint(layer.len() as u64, out); + out.extend_from_slice(&layer); + } +} + +/// One tile's worth of layers being assembled. +pub struct TileEnc { + layers: Vec, +} + +impl TileEnc { + pub fn new() -> Self { + TileEnc { layers: Vec::new() } + } + + pub fn add_feature(&mut self, layer_name: &str, feature: &PreFeature) { + let layer = match self.layers.iter_mut().find(|l| l.name == layer_name) { + Some(l) => l, + None => { + self.layers.push(LayerEnc::new(layer_name)); + self.layers.last_mut().unwrap() + } + }; + layer.add_feature(feature); + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + for layer in &self.layers { + layer.encode(&mut out); + } + out + } +} + +pub fn zigzag(value: i64) -> u32 { + ((value << 1) ^ (value >> 63)) as u32 +} + +pub fn command(id: u32, count: u32) -> u32 { + (id & 0x7) | (count << 3) +} + +fn write_tag(field: u32, wire_type: u32, out: &mut Vec) { + write_varint(u64::from((field << 3) | wire_type), out); +} + +fn write_varint(mut value: u64, out: &mut Vec) { + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } +} diff --git a/libs/geodata/src/png.rs b/libs/geodata/src/png.rs new file mode 100644 index 000000000..661a85beb --- /dev/null +++ b/libs/geodata/src/png.rs @@ -0,0 +1,221 @@ +//! Minimal PNG codec for raster overlay tiles: 8-bit grayscale (class-index +//! rasters like noise/flood) and 8-bit RGB (terrarium elevation). Encoder +//! writes filter-0 rows + zlib (flate2); decoder handles exactly what any +//! standard encoder emits for these formats (all five row filters, +//! non-interlaced). CRC32 is the 30-line table version — not worth a dep. + +use flate2::write::ZlibEncoder; +use flate2::Compression; +use std::io::Write; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PngFormat { + Gray8, + Rgb8, +} + +impl PngFormat { + fn color_type(&self) -> u8 { + match self { + PngFormat::Gray8 => 0, + PngFormat::Rgb8 => 2, + } + } + pub fn bytes_per_pixel(&self) -> usize { + match self { + PngFormat::Gray8 => 1, + PngFormat::Rgb8 => 3, + } + } +} + +pub fn encode(width: u32, height: u32, format: PngFormat, pixels: &[u8]) -> Vec { + let bpp = format.bytes_per_pixel(); + assert_eq!(pixels.len(), width as usize * height as usize * bpp); + + let mut raw = Vec::with_capacity(pixels.len() + height as usize); + for row in pixels.chunks_exact(width as usize * bpp) { + raw.push(0); // filter type 0 + raw.extend_from_slice(row); + } + let mut z = ZlibEncoder::new(Vec::new(), Compression::new(6)); + z.write_all(&raw).unwrap(); + let idat = z.finish().unwrap(); + + let mut out = Vec::with_capacity(idat.len() + 64); + out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); // bit depth + ihdr.push(format.color_type()); + ihdr.extend_from_slice(&[0, 0, 0]); // deflate, filter 0, no interlace + write_chunk(&mut out, b"IHDR", &ihdr); + write_chunk(&mut out, b"IDAT", &idat); + write_chunk(&mut out, b"IEND", &[]); + out +} + +pub struct DecodedPng { + pub width: u32, + pub height: u32, + pub format: PngFormat, + pub pixels: Vec, +} + +pub fn decode(data: &[u8]) -> Result { + if data.len() < 8 || &data[0..8] != &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] { + return Err("not a png".into()); + } + let mut pos = 8usize; + let mut width = 0u32; + let mut height = 0u32; + let mut format = PngFormat::Gray8; + let mut idat = Vec::new(); + while pos + 8 <= data.len() { + let len = u32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as usize; + let kind = &data[pos + 4..pos + 8]; + let body = data + .get(pos + 8..pos + 8 + len) + .ok_or("truncated png chunk")?; + match kind { + b"IHDR" => { + width = u32::from_be_bytes(body[0..4].try_into().unwrap()); + height = u32::from_be_bytes(body[4..8].try_into().unwrap()); + if body[8] != 8 || body[12] != 0 { + return Err("unsupported png (need 8-bit non-interlaced)".into()); + } + format = match body[9] { + 0 => PngFormat::Gray8, + 2 => PngFormat::Rgb8, + other => return Err(format!("unsupported png color type {other}")), + }; + } + b"IDAT" => idat.extend_from_slice(body), + b"IEND" => break, + _ => {} + } + pos += 12 + len; // len + type + crc + } + if width == 0 || height == 0 { + return Err("png missing IHDR".into()); + } + + let mut raw = Vec::new(); + flate2::read::ZlibDecoder::new(&idat[..]) + .read_to_end(&mut raw) + .map_err(|e| format!("png inflate: {e}"))?; + + let bpp = format.bytes_per_pixel(); + let stride = width as usize * bpp; + if raw.len() < height as usize * (stride + 1) { + return Err("png data too short".into()); + } + let mut pixels = vec![0u8; height as usize * stride]; + for y in 0..height as usize { + let filter = raw[y * (stride + 1)]; + let row_in = &raw[y * (stride + 1) + 1..y * (stride + 1) + 1 + stride]; + for x in 0..stride { + let a = if x >= bpp { + pixels[y * stride + x - bpp] + } else { + 0 + }; + let b = if y > 0 { pixels[(y - 1) * stride + x] } else { 0 }; + let c = if y > 0 && x >= bpp { + pixels[(y - 1) * stride + x - bpp] + } else { + 0 + }; + let value = match filter { + 0 => row_in[x], + 1 => row_in[x].wrapping_add(a), + 2 => row_in[x].wrapping_add(b), + 3 => row_in[x].wrapping_add(((u16::from(a) + u16::from(b)) / 2) as u8), + 4 => row_in[x].wrapping_add(paeth(a, b, c)), + other => return Err(format!("unsupported png filter {other}")), + }; + pixels[y * stride + x] = value; + } + } + Ok(DecodedPng { + width, + height, + format, + pixels, + }) +} + +use std::io::Read; + +fn paeth(a: u8, b: u8, c: u8) -> u8 { + let (a, b, c) = (i16::from(a), i16::from(b), i16::from(c)); + let p = a + b - c; + let (pa, pb, pc) = ((p - a).abs(), (p - b).abs(), (p - c).abs()); + if pa <= pb && pa <= pc { + a as u8 + } else if pb <= pc { + b as u8 + } else { + c as u8 + } +} + +fn write_chunk(out: &mut Vec, kind: &[u8; 4], body: &[u8]) { + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(body); + let mut crc = Crc32::new(); + crc.update(kind); + crc.update(body); + out.extend_from_slice(&crc.finish().to_be_bytes()); +} + +struct Crc32 { + table: [u32; 256], + value: u32, +} + +impl Crc32 { + fn new() -> Self { + let mut table = [0u32; 256]; + for (n, entry) in table.iter_mut().enumerate() { + let mut c = n as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }; + } + *entry = c; + } + Crc32 { + table, + value: 0xffff_ffff, + } + } + fn update(&mut self, data: &[u8]) { + for &byte in data { + self.value = self.table[((self.value ^ u32::from(byte)) & 0xff) as usize] + ^ (self.value >> 8); + } + } + fn finish(self) -> u32 { + self.value ^ 0xffff_ffff + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn png_round_trip() { + let width = 32u32; + let height = 16u32; + let pixels: Vec = (0..width * height * 3).map(|i| (i % 251) as u8).collect(); + let encoded = encode(width, height, PngFormat::Rgb8, &pixels); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.width, width); + assert_eq!(decoded.height, height); + assert_eq!(decoded.format, PngFormat::Rgb8); + assert_eq!(decoded.pixels, pixels); + } +} diff --git a/libs/geodata/src/query.rs b/libs/geodata/src/query.rs new file mode 100644 index 000000000..b1bbde2aa --- /dev/null +++ b/libs/geodata/src/query.rs @@ -0,0 +1,204 @@ +//! Structured queries against a layer database's `features` sidecar table — +//! the "reason with the map" surface. The map app exposes these as LLM tool +//! calls ("what is at/near this location?") and for tap-to-inspect UI. +//! +//! Queries are b-tree range scans over the grid-indexed rowids; a typical +//! radius query touches a few dozen pages of the .mbtiles file. + +use crate::sidecar::{CELL_AXIS, FEATURES_TABLE}; +use makepad_mbtile_reader::{MbtilesReader, Value}; +use makepad_map_nav::geo::{haversine_m, LonLat}; +use std::path::Path; + +#[derive(Debug)] +pub struct FeatureHit { + pub layer: String, + pub name: Option, + /// Feature attributes as parsed JSON. + pub attrs: serde_json::Value, + /// Feature bbox center. + pub center: (f64, f64), + pub bbox: (f64, f64, f64, f64), + /// Distance from the query point (radius/point queries). + pub distance_m: Option, + /// For point queries on ring-carrying layers: exact containment. + pub contains_point: bool, +} + +pub struct LayerDb { + db: MbtilesReader, +} + +impl LayerDb { + pub fn open(path: &Path) -> Result { + let db = MbtilesReader::open_sqlite(path) + .map_err(|e| format!("open {}: {e:?}", path.display()))?; + Ok(LayerDb { db }) + } + + /// All features whose bbox intersects the query bbox. + pub fn query_bbox( + &mut self, + min_lon: f64, + min_lat: f64, + max_lon: f64, + max_lat: f64, + limit: usize, + ) -> Result, String> { + let mut hits = Vec::new(); + // Note y grows south in normalized mercator: max_lat -> min cy. + let (nx0, ny0) = crate::geo::wgs84_to_norm(min_lon, max_lat); + let (nx1, ny1) = crate::geo::wgs84_to_norm(max_lon, min_lat); + let clamp = |v: f64| (v * f64::from(CELL_AXIS)) as i64; + let cx0 = clamp(nx0).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cx1 = clamp(nx1).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cy0 = clamp(ny0).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cy1 = clamp(ny1).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + + 'rows: for cy in cy0..=cy1 { + let lo = (i64::from(cy * CELL_AXIS + cx0)) << 24; + let hi = ((i64::from(cy * CELL_AXIS + cx1)) << 24) | 0x00ff_ffff; + let mut scan_err = None; + let result = self.db.for_each_row_in_range( + FEATURES_TABLE, + lo, + hi, + |_rowid, values| { + if hits.len() >= limit { + return; + } + match parse_hit(&values) { + Ok(hit) => { + let bb = hit.bbox; + if bb.0 <= max_lon && bb.2 >= min_lon && bb.1 <= max_lat && bb.3 >= min_lat { + hits.push(hit); + } + } + Err(e) => scan_err = Some(e), + } + }, + ); + result.map_err(|e| format!("range scan: {e:?}"))?; + if let Some(e) = scan_err { + return Err(e); + } + if hits.len() >= limit { + break 'rows; + } + } + Ok(hits) + } + + /// Features within `radius_m` of a point, nearest first. + pub fn query_radius( + &mut self, + lon: f64, + lat: f64, + radius_m: f64, + limit: usize, + ) -> Result, String> { + // Convert the radius to a degree bbox (safe overestimate at NL lat). + let dlat = radius_m / 111_320.0; + let dlon = radius_m / (111_320.0 * lat.to_radians().cos().max(0.2)); + let mut hits = self.query_bbox( + lon - dlon, + lat - dlat, + lon + dlon, + lat + dlat, + usize::MAX, + )?; + let origin = LonLat::new(lon, lat); + for hit in &mut hits { + let distance = haversine_m(origin, LonLat::new(hit.center.0, hit.center.1)); + hit.distance_m = Some(distance); + } + hits.retain(|h| h.distance_m.unwrap_or(f64::MAX) <= radius_m); + hits.sort_by(|a, b| a.distance_m.unwrap().total_cmp(&b.distance_m.unwrap())); + hits.truncate(limit); + Ok(hits) + } + + /// Features covering a point. Exact for layers that store rings (and for + /// grid layers whose bbox equals the cell); bbox containment otherwise. + pub fn query_point(&mut self, lon: f64, lat: f64, limit: usize) -> Result, String> { + let epsilon = 1e-9; + let mut hits = self.query_bbox( + lon - epsilon, + lat - epsilon, + lon + epsilon, + lat + epsilon, + usize::MAX, + )?; + for hit in &mut hits { + hit.contains_point = match hit.attrs.get("__ring") { + Some(serde_json::Value::Array(ring)) => point_in_ring(lon, lat, ring), + _ => true, // bbox containment already established + }; + } + hits.retain(|h| h.contains_point); + hits.truncate(limit); + Ok(hits) + } +} + +fn parse_hit(values: &[Value]) -> Result { + let text = |i: usize| -> Option { + values.get(i).and_then(|v| v.as_text()).map(str::to_string) + }; + let float = |i: usize| -> f64 { + match values.get(i) { + Some(Value::Float(f)) => *f, + Some(Value::Integer(n)) => *n as f64, + _ => f64::NAN, + } + }; + let layer = text(1).unwrap_or_default(); + let name = text(2); + let bbox = (float(3), float(4), float(5), float(6)); + let mut attrs: serde_json::Value = text(7) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(serde_json::Value::Null); + if let Some(ring_text) = text(8) { + if let Ok(ring) = serde_json::from_str::(&ring_text) { + if let Some(map) = attrs.as_object_mut() { + map.insert("__ring".into(), ring); + } + } + } + Ok(FeatureHit { + layer, + name, + attrs, + center: ((bbox.0 + bbox.2) / 2.0, (bbox.1 + bbox.3) / 2.0), + bbox, + distance_m: None, + contains_point: false, + }) +} + +/// Ray-cast point-in-polygon on a JSON ring [[lon,lat],...]. +fn point_in_ring(lon: f64, lat: f64, ring: &[serde_json::Value]) -> bool { + let pts: Vec<(f64, f64)> = ring + .iter() + .filter_map(|p| { + let arr = p.as_array()?; + Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?)) + }) + .collect(); + if pts.len() < 3 { + return true; + } + let mut inside = false; + let mut j = pts.len() - 1; + for i in 0..pts.len() { + let (xi, yi) = pts[i]; + let (xj, yj) = pts[j]; + if ((yi > lat) != (yj > lat)) + && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) + { + inside = !inside; + } + j = i; + } + inside +} diff --git a/libs/geodata/src/radar.rs b/libs/geodata/src/radar.rs new file mode 100644 index 000000000..8f1c1550e --- /dev/null +++ b/libs/geodata/src/radar.rs @@ -0,0 +1,397 @@ +//! Rain radar sync — the app-embeddable "updated downloader". +//! +//! Unlike the static layers, radar is a rolling time series: KNMI publishes a +//! new 5-minute reflectivity composite every 5 minutes and a +2h nowcast +//! (`radar_forecast`) every 5 minutes. `RadarSync` maintains a small local +//! cache of the newest frames and is designed to be owned by the map app: +//! call `sync()` as often as you like — it never touches the network more +//! than once per `min_poll_secs`, downloads only frames it doesn't have, +//! and prunes old ones. `state()` returns the cached frames without any +//! network contact at all. +//! +//! Files are KNMI HDF5 (polar stereographic). Decoding to a renderable / +//! queryable raster is the next step and lives outside this module; the sync +//! layer's contract is just: freshest N frames on disk + an index. +//! +//! Auth: KNMI's Open Data API wants an API key. Priority: explicit config > +//! `KNMI_API_KEY` env var > the public anonymous key KNMI documents on the +//! developer portal (shared, 50 req/min across all anonymous users — fine +//! for one poll per 5 minutes, but register a free personal key for real +//! deployments). + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Public anonymous key from developer.dataplatform.knmi.nl (shared quota). +pub const KNMI_ANONYMOUS_KEY: &str = "eyJvcmciOiI1ZTU1NGUxOTI3NGE5NjAwMDEyYTNlYjEiLCJpZCI6IjUzYTg1ZDBhMmQ5YzRkYzJiYWNlNzQ4NTQ2Zjk4ODExIiwiaCI6Im11cm11cjEyOCJ9"; + +const API_BASE: &str = "https://api.dataplatform.knmi.nl/open-data/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadarDataset { + /// 5-minute real-time reflectivity composite (one frame per file). + ReflectivityComposite, + /// +2h precipitation nowcast, refreshed every 5 minutes (whole animation + /// in one file) — the most useful single file for the map app. + Forecast, +} + +impl RadarDataset { + fn api_path(&self) -> (&'static str, &'static str) { + match self { + RadarDataset::ReflectivityComposite => ("radar_reflectivity_composites", "2.0"), + RadarDataset::Forecast => ("radar_forecast", "1.0"), + } + } + fn cache_subdir(&self) -> &'static str { + match self { + RadarDataset::ReflectivityComposite => "reflectivity", + RadarDataset::Forecast => "forecast", + } + } +} + +pub struct RadarConfig { + pub cache_dir: PathBuf, + pub dataset: RadarDataset, + pub api_key: Option, + /// Never contact the network more often than this. The data itself + /// refreshes every 300 s, so the default 240 s guarantees at most one + /// poll per new upstream file. + pub min_poll_secs: u64, + /// Keep at most this many newest frames on disk. + pub max_frames: usize, +} + +impl RadarConfig { + pub fn new(cache_dir: impl Into) -> Self { + RadarConfig { + cache_dir: cache_dir.into(), + dataset: RadarDataset::Forecast, + api_key: None, + min_poll_secs: 240, + // One forecast file holds the whole +2h animation; keeping the + // previous one covers the swap window. Reflectivity wants more + // (one frame per file) — see `for_dataset`. + max_frames: 2, + } + } + + pub fn for_dataset(cache_dir: impl Into, dataset: RadarDataset) -> Self { + let mut config = Self::new(cache_dir); + config.dataset = dataset; + if dataset == RadarDataset::ReflectivityComposite { + config.max_frames = 13; // ~1 hour of 5-min frames + } + config + } + fn resolved_key(&self) -> String { + self.api_key + .clone() + .or_else(|| std::env::var("KNMI_API_KEY").ok().filter(|k| !k.is_empty())) + .unwrap_or_else(|| KNMI_ANONYMOUS_KEY.to_string()) + } + fn dir(&self) -> PathBuf { + self.cache_dir.join(self.dataset.cache_subdir()) + } +} + +#[derive(Debug, Clone)] +pub struct RadarFrame { + pub filename: String, + pub path: PathBuf, + pub created_unix: u64, + pub bytes: u64, +} + +#[derive(Debug, Default)] +pub struct RadarState { + /// Frames on disk, oldest first. + pub frames: Vec, + pub last_poll_unix: u64, + /// Whether this call actually contacted the server. + pub polled: bool, + pub downloaded: usize, +} + +pub struct RadarSync { + config: RadarConfig, +} + +impl RadarSync { + pub fn new(config: RadarConfig) -> Self { + RadarSync { config } + } + + /// Cached frames without any network contact. + pub fn state(&self) -> RadarState { + let (last_poll, frames) = self.read_index(); + RadarState { + frames, + last_poll_unix: last_poll, + polled: false, + downloaded: 0, + } + } + + /// Poll for new frames if the poll gate allows; otherwise return cache. + pub fn sync(&self) -> Result { + let dir = self.config.dir(); + std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let (last_poll, cached_frames) = self.read_index(); + let now = now_unix(); + if now.saturating_sub(last_poll) < self.config.min_poll_secs { + return Ok(RadarState { + frames: cached_frames, + last_poll_unix: last_poll, + polled: false, + downloaded: 0, + }); + } + + // Record the attempt BEFORE any network contact: even a failing sync + // must arm the poll gate, or a broken API would get hammered. + self.write_index(now, &cached_frames)?; + + let (dataset, version) = self.config.dataset.api_path(); + let key = self.config.resolved_key(); + let max_keys = self.config.max_frames.max(1); + let list_url = format!( + "{API_BASE}/datasets/{dataset}/versions/{version}/files?maxKeys={max_keys}&orderBy=created&sorting=desc" + ); + let listing: serde_json::Value = api_get_json(&list_url, &key)?; + let files = listing + .get("files") + .and_then(|f| f.as_array()) + .ok_or_else(|| format!("unexpected KNMI listing: {listing}"))?; + + let mut downloaded = 0usize; + let mut frames: Vec = Vec::new(); + for file in files { + let Some(filename) = file.get("filename").and_then(|f| f.as_str()) else { + continue; + }; + let created = file + .get("created") + .and_then(|c| c.as_str()) + .and_then(parse_iso8601_unix) + .unwrap_or(0); + let path = dir.join(filename); + if !path.exists() { + let url_url = format!( + "{API_BASE}/datasets/{dataset}/versions/{version}/files/{filename}/url" + ); + let url_response: serde_json::Value = api_get_json(&url_url, &key)?; + let Some(signed) = url_response + .get("temporaryDownloadUrl") + .and_then(|u| u.as_str()) + else { + return Err(format!("no download url for {filename}: {url_response}")); + }; + download(signed, &path)?; + downloaded += 1; + } + let bytes = path.metadata().map(|m| m.len()).unwrap_or(0); + frames.push(RadarFrame { + filename: filename.to_string(), + path, + created_unix: created, + bytes, + }); + } + frames.sort_by_key(|f| f.created_unix); + + // Prune files no longer in the newest set. + let keep: std::collections::HashSet<&str> = + frames.iter().map(|f| f.filename.as_str()).collect(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".json") || keep.contains(name.as_ref()) { + continue; + } + let _ = std::fs::remove_file(entry.path()); + } + } + + self.write_index(now, &frames)?; + Ok(RadarState { + frames, + last_poll_unix: now, + polled: true, + downloaded, + }) + } + + fn index_path(&self) -> PathBuf { + self.config.dir().join("index.json") + } + + fn read_index(&self) -> (u64, Vec) { + let Ok(text) = std::fs::read_to_string(self.index_path()) else { + return (0, Vec::new()); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return (0, Vec::new()); + }; + let last_poll = value + .get("last_poll_unix") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let dir = self.config.dir(); + let frames = value + .get("frames") + .and_then(|f| f.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|f| { + let filename = f.get("filename")?.as_str()?.to_string(); + let path = dir.join(&filename); + if !path.exists() { + return None; + } + Some(RadarFrame { + bytes: path.metadata().ok()?.len(), + path, + created_unix: f.get("created_unix")?.as_u64()?, + filename, + }) + }) + .collect() + }) + .unwrap_or_default(); + (last_poll, frames) + } + + fn write_index(&self, last_poll: u64, frames: &[RadarFrame]) -> Result<(), String> { + let value = serde_json::json!({ + "last_poll_unix": last_poll, + "dataset": self.config.dataset.cache_subdir(), + "frames": frames.iter().map(|f| serde_json::json!({ + "filename": f.filename, + "created_unix": f.created_unix, + "bytes": f.bytes, + })).collect::>(), + }); + std::fs::write( + self.index_path(), + serde_json::to_string_pretty(&value).unwrap(), + ) + .map_err(|e| format!("write index: {e}")) + } +} + +fn api_get_json(url: &str, key: &str) -> Result { + // Pace every request; on a 429 (the shared anonymous key saturates), back + // off once and retry before giving up. + for attempt in 0..2 { + std::thread::sleep(std::time::Duration::from_secs(1)); + let output = Command::new("curl") + .arg("-fsS") + .arg("--connect-timeout") + .arg("15") + .arg("--max-time") + .arg("60") + .arg("-A") + .arg(crate::fetch::USER_AGENT) + .arg("-H") + .arg(format!("Authorization: {key}")) + .arg(url) + .output() + .map_err(|e| format!("curl: {e}"))?; + if output.status.success() { + return serde_json::from_slice(&output.stdout) + .map_err(|e| format!("KNMI API json: {e}")); + } + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if attempt == 0 && stderr.contains("429") { + std::thread::sleep(std::time::Duration::from_secs(8)); + continue; + } + return Err(format!("KNMI API {url}: {} {stderr}", output.status)); + } + unreachable!() +} + +fn download(url: &str, dest: &Path) -> Result<(), String> { + let part = dest.with_extension("part"); + let status = Command::new("curl") + .arg("-fsSL") + .arg("--connect-timeout") + .arg("15") + .arg("-A") + .arg(crate::fetch::USER_AGENT) + .arg("-o") + .arg(&part) + .arg(url) + .status() + .map_err(|e| format!("curl: {e}"))?; + if !status.success() { + return Err(format!("download failed: {status}")); + } + std::fs::rename(&part, dest).map_err(|e| format!("rename: {e}")) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Parse "2026-07-28T12:00:00+00:00" (KNMI `created`) to unix seconds. +/// Offsets other than +00:00/Z are honored. +fn parse_iso8601_unix(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.len() < 19 { + return None; + } + let num = |range: std::ops::Range| -> Option { + s.get(range)?.parse().ok() + }; + let year = num(0..4)?; + let month = num(5..7)?; + let day = num(8..10)?; + let hour = num(11..13)?; + let minute = num(14..16)?; + let second = num(17..19)?; + // Days-from-civil (Howard Hinnant's algorithm). + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (month + 9) % 12; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + let mut seconds = days * 86_400 + hour * 3_600 + minute * 60 + second; + // Timezone offset + let rest = &s[19..]; + if let Some(sign_pos) = rest.find(['+', '-']) { + let sign = if rest.as_bytes()[sign_pos] == b'+' { 1 } else { -1 }; + let tz = &rest[sign_pos + 1..]; + if tz.len() >= 5 { + let hours: i64 = tz[0..2].parse().ok()?; + let minutes: i64 = tz[3..5].parse().ok()?; + seconds -= sign * (hours * 3_600 + minutes * 60); + } + } + u64::try_from(seconds).ok() +} + +#[cfg(test)] +mod tests { + use super::parse_iso8601_unix; + + #[test] + fn iso8601_epoch_math() { + assert_eq!(parse_iso8601_unix("1970-01-01T00:00:00+00:00"), Some(0)); + assert_eq!(parse_iso8601_unix("2026-07-28T12:00:00+00:00"), Some(1_785_240_000)); + // +02:00 is two hours earlier in UTC + assert_eq!( + parse_iso8601_unix("2026-07-28T12:00:00+02:00"), + Some(1_785_240_000 - 7_200) + ); + } +} diff --git a/libs/geodata/src/radar_raster.rs b/libs/geodata/src/radar_raster.rs new file mode 100644 index 000000000..de8112991 --- /dev/null +++ b/libs/geodata/src/radar_raster.rs @@ -0,0 +1,209 @@ +//! KNMI radar frames → web-mercator RGBA overlays. +//! +//! The RAD_NL25 grid is polar stereographic (lat_ts 60°N, lon_0 0, GRS-ish +//! a=6378.14 km b=6356.75 km, 1 km pixels, row offset 3649.9795 — verified +//! against the file's `geo_product_corners` to ~20 m). Rendering wants a +//! north-up mercator-aligned texture, so we precompute one output→source +//! lookup table and apply it per frame; the colormap turns raw pixel values +//! (dBZ = 0.5·PV − 32) into translucent rain colors. + +use crate::knmi_hdf5::KnmiFrame; + +const A_KM: f64 = 6378.14; +const B_KM: f64 = 6356.75; +const ROW_OFFSET: f64 = 3649.9795; +const COL_OFFSET: f64 = 0.0; +const GRID_COLS: usize = 700; +const GRID_ROWS: usize = 765; + +/// Geographic cover of the produced texture (the radar grid's bounding box +/// in lon/lat, slightly inset to skip out-of-image corners). +pub const RASTER_WEST: f64 = 0.0; +pub const RASTER_EAST: f64 = 10.86; +pub const RASTER_SOUTH: f64 = 48.89; +pub const RASTER_NORTH: f64 = 55.98; + +fn mercator_y(lat_deg: f64) -> f64 { + let lat = lat_deg.to_radians(); + (lat.tan() + 1.0 / lat.cos()).ln() +} + +struct Stereo { + e: f64, + k0m: f64, +} + +impl Stereo { + fn new() -> Self { + let e2 = 1.0 - (B_KM * B_KM) / (A_KM * A_KM); + let e = e2.sqrt(); + let lat_ts = 60.0_f64.to_radians(); + let t_ts = (std::f64::consts::FRAC_PI_4 - lat_ts / 2.0).tan() + / ((1.0 - e * lat_ts.sin()) / (1.0 + e * lat_ts.sin())).powf(e / 2.0); + let m_ts = lat_ts.cos() / (1.0 - e2 * lat_ts.sin() * lat_ts.sin()).sqrt(); + Stereo { + e, + k0m: A_KM * m_ts / t_ts, + } + } + + /// lon/lat (deg) → radar grid col/row (f64). + fn forward(&self, lon_deg: f64, lat_deg: f64) -> (f64, f64) { + let lam = lon_deg.to_radians(); + let phi = lat_deg.to_radians(); + let t = (std::f64::consts::FRAC_PI_4 - phi / 2.0).tan() + / ((1.0 - self.e * phi.sin()) / (1.0 + self.e * phi.sin())).powf(self.e / 2.0); + let rho = self.k0m * t; + let x = rho * lam.sin(); + let y = -rho * lam.cos(); + (x - COL_OFFSET, -y - ROW_OFFSET) + } +} + +/// Precomputed output-pixel → source-index mapping for a mercator-aligned +/// texture of `width`×`height` covering the RASTER_* bbox. +pub struct RadarProjection { + pub width: usize, + pub height: usize, + /// Fractional source coordinates per output pixel (f32::NAN = outside + /// the radar grid) — bilinear sampling smooths the 1 km cells into + /// curved isolines instead of visible blocks. + src_col: Vec, + src_row: Vec, +} + +impl RadarProjection { + pub fn new(width: usize, height: usize) -> Self { + let stereo = Stereo::new(); + let merc_n = mercator_y(RASTER_NORTH); + let merc_s = mercator_y(RASTER_SOUTH); + let mut src_col = vec![f32::NAN; width * height]; + let mut src_row = vec![f32::NAN; width * height]; + for py in 0..height { + // Mercator-linear in screen y so the texture maps 1:1 onto the + // renderer's mercator plane with a simple quad. + let merc = merc_n + (merc_s - merc_n) * ((py as f64 + 0.5) / height as f64); + let lat = (merc.sinh()).atan().to_degrees(); + for px in 0..width { + let lon = + RASTER_WEST + (RASTER_EAST - RASTER_WEST) * ((px as f64 + 0.5) / width as f64); + let (col, row) = stereo.forward(lon, lat); + if col >= 0.0 + && col < GRID_COLS as f64 + && row >= 0.0 + && row < GRID_ROWS as f64 + { + src_col[py * width + px] = col as f32; + src_row[py * width + px] = row as f32; + } + } + } + Self { + width, + height, + src_col, + src_row, + } + } + + /// Bilinear sample of the raw value field; 255 (out of image) reads as + /// dry so coastal cells don't smear a phantom band. + fn sample_value(frame: &KnmiFrame, col: f32, row: f32) -> f32 { + let value_at = |c: i64, r: i64| -> f32 { + if c < 0 || c >= frame.cols as i64 || r < 0 || r >= frame.rows as i64 { + return 0.0; + } + let v = frame.values[r as usize * frame.cols + c as usize]; + if v == 255 { + 0.0 + } else { + v as f32 + } + }; + let c0 = (col - 0.5).floor(); + let r0 = (row - 0.5).floor(); + let fx = (col - 0.5) - c0; + let fy = (row - 0.5) - r0; + let (c0, r0) = (c0 as i64, r0 as i64); + let top = value_at(c0, r0) * (1.0 - fx) + value_at(c0 + 1, r0) * fx; + let bottom = value_at(c0, r0 + 1) * (1.0 - fx) + value_at(c0 + 1, r0 + 1) * fx; + top * (1.0 - fy) + bottom * fy + } + + /// Rain colormap over (interpolated) KNMI pixel values + /// (dBZ = 0.5·PV − 32). + fn colorize(value: f32) -> [u8; 4] { + if value < 1.0 { + return [0, 0, 0, 0]; + } + let dbz = 0.5 * value - 32.0; + // Premultiplied-ish straight RGBA; renderer treats it as straight + // alpha. Thresholds follow common rain-intensity ramps. + let (r, g, b, a) = if dbz < 5.0 { + (150, 200, 255, 85) + } else if dbz < 15.0 { + (90, 160, 250, 125) + } else if dbz < 25.0 { + (35, 105, 235, 160) + } else if dbz < 33.0 { + (25, 170, 90, 185) + } else if dbz < 40.0 { + (245, 185, 35, 205) + } else if dbz < 47.0 { + (235, 80, 30, 225) + } else { + (205, 25, 160, 245) + }; + [r, g, b, a] + } + + /// Apply the LUT + bilinear sample + colormap to one frame → RGBA. + pub fn frame_to_rgba(&self, frame: &KnmiFrame) -> Vec { + let mut out = vec![0u8; self.width * self.height * 4]; + if frame.cols != GRID_COLS || frame.rows != GRID_ROWS { + return out; + } + for i in 0..self.src_col.len() { + let col = self.src_col[i]; + if col.is_nan() { + continue; + } + let value = Self::sample_value(frame, col, self.src_row[i]); + let rgba = Self::colorize(value); + out[i * 4..i * 4 + 4].copy_from_slice(&rgba); + } + out + } +} + +/// RGBA bytes → BGRA u32 texels (makepad VecBGRAu8_32 layout). +pub fn rgba_to_bgra_texels(rgba: &[u8]) -> Vec { + rgba.chunks_exact(4) + .map(|px| { + (px[2] as u32) | ((px[1] as u32) << 8) | ((px[0] as u32) << 16) | ((px[3] as u32) << 24) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projects_cached_forecast_frame() { + let path = "../../local/overlays/radar/forecast/RAD_NL25_PCP_FM_202607280900.h5"; + let Ok(data) = std::fs::read(path) else { + return; + }; + let frames = crate::knmi_hdf5::decode_frames(&data).unwrap(); + let projection = RadarProjection::new(512, 640); + let rgba = projection.frame_to_rgba(&frames[0]); + let visible = rgba.chunks(4).filter(|px| px[3] > 0).count(); + // Frame 1 has 5068 wet source pixels; the mercator resample of the + // NL bbox should land in the same order of magnitude. + assert!(visible > 500, "visible rain pixels: {visible}"); + let mapped = projection.src_col.iter().filter(|c| !c.is_nan()).count(); + // Most of the output bbox lies inside the radar grid. + assert!(mapped > 512 * 640 / 2, "mapped: {mapped}"); + } +} diff --git a/libs/geodata/src/raster.rs b/libs/geodata/src/raster.rs new file mode 100644 index 000000000..6944f4d1b --- /dev/null +++ b/libs/geodata/src/raster.rs @@ -0,0 +1,180 @@ +//! Raster overlay tiles: sample any (lon, lat) -> value function into 256px +//! PNG tiles inside an .mbtiles (format=png). Two encodings: +//! - Terrarium RGB (elevation: e = h + 32768; R=e>>8, G=e&255, B=frac*256), +//! the de-facto standard the renderer's future hillshade/3D terrain and +//! map_nav's EV grade baking both read. +//! - Gray8 class index (noise/flood): pixel = class byte, 0 = no data; the +//! class -> meaning/color table ships in metadata as `geodata_classmap`. + +use crate::geo::tile_order_key; +use crate::png::{self, PngFormat}; +use makepad_mbtile_reader::MbtilesWriter; +use std::collections::BTreeMap; +use std::path::Path; + +pub const TILE_PX: u32 = 256; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RasterEncoding { + Terrarium, + ClassIndex, +} + +impl RasterEncoding { + fn as_str(&self) -> &'static str { + match self { + RasterEncoding::Terrarium => "terrarium", + RasterEncoding::ClassIndex => "class-index", + } + } +} + +pub struct RasterConfig { + pub name: String, + pub description: String, + pub attribution: String, + pub license: String, + pub minzoom: u8, + pub maxzoom: u8, + /// lon/lat bbox to cover. + pub bounds: (f64, f64, f64, f64), + pub encoding: RasterEncoding, + /// For ClassIndex: JSON array describing each class (index 1..). + pub classmap: Option, +} + +pub struct RasterStats { + pub tiles: u64, + pub bytes: u64, + pub skipped_empty: u64, +} + +/// Build the raster pyramid. `sample(lon, lat)` returns the source value or +/// None for no-data; tiles that are entirely no-data are skipped. +pub fn build_raster( + out_path: &Path, + config: &RasterConfig, + sample: &mut dyn FnMut(f64, f64) -> Option, +) -> Result { + let (min_lon, min_lat, max_lon, max_lat) = config.bounds; + let mut tiles: BTreeMap)> = BTreeMap::new(); + let mut stats = RasterStats { + tiles: 0, + bytes: 0, + skipped_empty: 0, + }; + + for zoom in config.minzoom..=config.maxzoom { + let scale = f64::from(1u32 << zoom); + let (nx0, ny0) = crate::geo::wgs84_to_norm(min_lon, max_lat); + let (nx1, ny1) = crate::geo::wgs84_to_norm(max_lon, min_lat); + let tx0 = ((nx0 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + let tx1 = ((nx1 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + let ty0 = ((ny0 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + let ty1 = ((ny1 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + for ty in ty0..=ty1 { + for tx in tx0..=tx1 { + let (format, pixels, any) = render_tile(zoom, tx, ty, config.encoding, sample); + if !any { + stats.skipped_empty += 1; + continue; + } + let data = png::encode(TILE_PX, TILE_PX, format, &pixels); + stats.tiles += 1; + stats.bytes += data.len() as u64; + tiles.insert(tile_order_key(zoom, tx, ty), (zoom, tx, ty, data)); + } + } + eprintln!(" raster z{zoom}: {} tiles so far", stats.tiles); + } + + let mut writer = + MbtilesWriter::create(out_path).map_err(|e| format!("create mbtiles: {e:?}"))?; + writer.set_metadata("name", &config.name); + writer.set_metadata("description", &config.description); + writer.set_metadata("format", "png"); + writer.set_metadata("type", "overlay"); + writer.set_metadata("minzoom", config.minzoom.to_string()); + writer.set_metadata("maxzoom", config.maxzoom.to_string()); + writer.set_metadata("attribution", &config.attribution); + writer.set_metadata("license", &config.license); + writer.set_metadata( + "bounds", + format!("{min_lon:.6},{min_lat:.6},{max_lon:.6},{max_lat:.6}"), + ); + writer.set_metadata("geodata_encoding", config.encoding.as_str()); + if let Some(classmap) = &config.classmap { + writer.set_metadata("geodata_classmap", classmap.to_string()); + } + writer.set_metadata( + "geodata_built_unix", + format!( + "{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ), + ); + for (_, (zoom, x, y, data)) in tiles { + writer + .write_tile_xyz(zoom, x, y, &data) + .map_err(|e| format!("write tile z{zoom}/{x}/{y}: {e:?}"))?; + } + writer.finish().map_err(|e| format!("finish: {e:?}"))?; + Ok(stats) +} + +fn render_tile( + zoom: u8, + tx: u32, + ty: u32, + encoding: RasterEncoding, + sample: &mut dyn FnMut(f64, f64) -> Option, +) -> (PngFormat, Vec, bool) { + let scale = f64::from(1u32 << zoom); + let mut any = false; + match encoding { + RasterEncoding::Terrarium => { + let mut pixels = vec![0u8; (TILE_PX * TILE_PX * 3) as usize]; + for py in 0..TILE_PX { + for px in 0..TILE_PX { + let nx = (f64::from(tx) + (f64::from(px) + 0.5) / f64::from(TILE_PX)) / scale; + let ny = (f64::from(ty) + (f64::from(py) + 0.5) / f64::from(TILE_PX)) / scale; + let p = makepad_map_nav::geo::norm_to_lon_lat(nx, ny); + let height = match sample(p.lon, p.lat) { + Some(h) => { + any = true; + f64::from(h) + } + None => 0.0, + }; + let e = (height + 32_768.0).clamp(0.0, 65_535.996); + let i = ((py * TILE_PX + px) * 3) as usize; + pixels[i] = (e / 256.0) as u8; + pixels[i + 1] = (e as u32 % 256) as u8; + pixels[i + 2] = (e.fract() * 256.0) as u8; + } + } + (PngFormat::Rgb8, pixels, any) + } + RasterEncoding::ClassIndex => { + let mut pixels = vec![0u8; (TILE_PX * TILE_PX) as usize]; + for py in 0..TILE_PX { + for px in 0..TILE_PX { + let nx = (f64::from(tx) + (f64::from(px) + 0.5) / f64::from(TILE_PX)) / scale; + let ny = (f64::from(ty) + (f64::from(py) + 0.5) / f64::from(TILE_PX)) / scale; + let p = makepad_map_nav::geo::norm_to_lon_lat(nx, ny); + if let Some(class) = sample(p.lon, p.lat) { + let class = (class as i64).clamp(0, 255) as u8; + if class > 0 { + any = true; + } + pixels[(py * TILE_PX + px) as usize] = class; + } + } + } + (PngFormat::Gray8, pixels, any) + } + } +} diff --git a/libs/geodata/src/sidecar.rs b/libs/geodata/src/sidecar.rs new file mode 100644 index 000000000..cd6f8d9e2 --- /dev/null +++ b/libs/geodata/src/sidecar.rs @@ -0,0 +1,256 @@ +//! The `features` sidecar table: a grid-indexed, JSON-attributed copy of +//! every feature, written into the same .mbtiles as the render tiles. +//! +//! Purpose: the render `tiles` are for pixels; this table is for *questions*. +//! The map app (or an LLM tool call routed through it) asks "what is here / +//! near here" via `query::LayerDb` and gets structured features back without +//! touching MVT. +//! +//! Spatial index: rowid = (z12 grid cell << 24) | seq, so a bbox query turns +//! into a handful of b-tree range scans (`for_each_row_in_range`) — no SQL, +//! no separate index. Polygon layers can opt into storing a simplified +//! exterior ring for exact point-in-polygon answers ("which buurt am I in"). + +use crate::mvt::AttrVal; +use crate::wkb::Geometry; +use makepad_mbtile_reader::{MbtilesWriter, WriterValue}; + +/// Grid zoom for the cell index: 4096 x 4096 cells world-wide, ~10 km cells +/// at NL latitude — small enough to prune, big enough to keep ranges few. +pub const CELL_ZOOM: u8 = 12; +pub const CELL_AXIS: u32 = 1 << CELL_ZOOM; + +pub const FEATURES_TABLE: &str = "features"; +pub const FEATURES_SQL: &str = "CREATE TABLE features (cell INTEGER, layer TEXT, name TEXT, \ + min_lon REAL, min_lat REAL, max_lon REAL, max_lat REAL, attrs TEXT, ring TEXT)"; + +const NAME_KEYS: &[&str] = &[ + "name", "naam", "naam_n2k", "statnaam", "gemeentenaam", "wijknaam", "buurtnaam", "ref", +]; +const MAX_RING_POINTS: usize = 96; +const RING_TOLERANCE_DEG: f64 = 1e-4; // ~8-11 m + +pub fn cell_for(lon: f64, lat: f64) -> u32 { + let (nx, ny) = crate::geo::wgs84_to_norm(lon, lat); + let cx = ((nx * f64::from(CELL_AXIS)) as i64).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cy = ((ny * f64::from(CELL_AXIS)) as i64).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + cy * CELL_AXIS + cx +} + +pub fn rowid_for(cell: u32, seq: u32) -> i64 { + (i64::from(cell) << 24) | i64::from(seq & 0x00ff_ffff) +} + +struct Record { + cell: u32, + layer: String, + name: Option, + bbox: (f64, f64, f64, f64), + attrs_json: String, + ring_json: Option, +} + +#[derive(Default)] +pub struct SidecarBuilder { + records: Vec, +} + +impl SidecarBuilder { + pub fn new() -> Self { + SidecarBuilder::default() + } + + pub fn len(&self) -> usize { + self.records.len() + } + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + pub fn add( + &mut self, + layer: &str, + geometry: &Geometry, + attrs: &[(String, AttrVal)], + want_ring: bool, + ) { + let Some(bbox) = geometry_bbox(geometry) else { + return; + }; + let center = ((bbox.0 + bbox.2) / 2.0, (bbox.1 + bbox.3) / 2.0); + let name = attrs + .iter() + .find(|(k, _)| NAME_KEYS.contains(&k.as_str())) + .and_then(|(_, v)| match v { + AttrVal::Str(s) => Some(s.clone()), + _ => None, + }); + let mut map = serde_json::Map::with_capacity(attrs.len()); + for (key, value) in attrs { + let json = match value { + AttrVal::Str(s) => serde_json::Value::String(s.clone()), + AttrVal::Int(i) => serde_json::Value::from(*i), + AttrVal::Float(f) => serde_json::Value::from(*f), + AttrVal::Bool(b) => serde_json::Value::Bool(*b), + }; + map.insert(key.clone(), json); + } + let ring_json = if want_ring { + exterior_ring(geometry).map(|ring| { + let simplified = simplify_ring(&ring); + let pts: Vec = simplified + .iter() + .map(|&(lon, lat)| { + serde_json::json!([ + (lon * 1e5).round() / 1e5, + (lat * 1e5).round() / 1e5 + ]) + }) + .collect(); + serde_json::Value::Array(pts).to_string() + }) + } else { + None + }; + self.records.push(Record { + cell: cell_for(center.0, center.1), + layer: layer.to_string(), + name, + bbox, + attrs_json: serde_json::Value::Object(map).to_string(), + ring_json, + }); + } + + /// Sort by grid cell and stream into the writer's `features` table. + pub fn write(mut self, writer: &mut MbtilesWriter) -> Result { + if self.records.is_empty() { + return Ok(0); + } + writer + .begin_extra_table(FEATURES_TABLE, FEATURES_SQL) + .map_err(|e| format!("declare features table: {e:?}"))?; + self.records.sort_by_key(|r| r.cell); + let mut count = 0u64; + let mut seq = 0u32; + let mut last_cell = u32::MAX; + for record in &self.records { + if record.cell != last_cell { + seq = 0; + last_cell = record.cell; + } else { + seq += 1; + if seq > 0x00ff_ffff { + continue; // absurd density; drop rather than corrupt order + } + } + let rowid = rowid_for(record.cell, seq); + let values = [ + WriterValue::Integer(i64::from(record.cell)), + WriterValue::Text(&record.layer), + match &record.name { + Some(name) => WriterValue::Text(name), + None => WriterValue::Null, + }, + WriterValue::Float(record.bbox.0), + WriterValue::Float(record.bbox.1), + WriterValue::Float(record.bbox.2), + WriterValue::Float(record.bbox.3), + WriterValue::Text(&record.attrs_json), + match &record.ring_json { + Some(ring) => WriterValue::Text(ring), + None => WriterValue::Null, + }, + ]; + writer + .write_extra_row(FEATURES_TABLE, rowid, &values) + .map_err(|e| format!("write feature row: {e:?}"))?; + count += 1; + } + Ok(count) + } +} + +fn geometry_bbox(geometry: &Geometry) -> Option<(f64, f64, f64, f64)> { + let mut bbox = ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + let mut grow = |lon: f64, lat: f64| { + bbox.0 = bbox.0.min(lon); + bbox.1 = bbox.1.min(lat); + bbox.2 = bbox.2.max(lon); + bbox.3 = bbox.3.max(lat); + }; + match geometry { + Geometry::Point(lon, lat) => grow(*lon, *lat), + Geometry::MultiPoint(pts) | Geometry::LineString(pts) => { + pts.iter().for_each(|&(lon, lat)| grow(lon, lat)) + } + Geometry::MultiLineString(lines) => lines + .iter() + .flatten() + .for_each(|&(lon, lat)| grow(lon, lat)), + Geometry::Polygon(rings) => rings + .iter() + .flatten() + .for_each(|&(lon, lat)| grow(lon, lat)), + Geometry::MultiPolygon(polys) => polys + .iter() + .flatten() + .flatten() + .for_each(|&(lon, lat)| grow(lon, lat)), + } + if bbox.0.is_finite() { + Some(bbox) + } else { + None + } +} + +/// Exterior ring of the largest polygon part, by bbox area. +fn exterior_ring(geometry: &Geometry) -> Option> { + match geometry { + Geometry::Polygon(rings) => rings.first().cloned(), + Geometry::MultiPolygon(polys) => polys + .iter() + .filter_map(|rings| rings.first()) + .max_by(|a, b| { + let area = |ring: &[(f64, f64)]| { + let bb = geometry_bbox(&Geometry::LineString(ring.to_vec())) + .unwrap_or((0.0, 0.0, 0.0, 0.0)); + (bb.2 - bb.0) * (bb.3 - bb.1) + }; + area(a).total_cmp(&area(b)) + }) + .cloned(), + _ => None, + } +} + +fn simplify_ring(ring: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out: Vec<(f64, f64)> = Vec::new(); + let tol2 = RING_TOLERANCE_DEG * RING_TOLERANCE_DEG; + for &pt in ring { + if let Some(&last) = out.last() { + let dx = pt.0 - last.0; + let dy = pt.1 - last.1; + if dx * dx + dy * dy < tol2 { + continue; + } + } + out.push(pt); + } + if out.len() > MAX_RING_POINTS { + let step = out.len().div_ceil(MAX_RING_POINTS); + let mut thinned: Vec<(f64, f64)> = + out.iter().step_by(step).copied().collect(); + if thinned.last() != out.last() { + thinned.push(*out.last().unwrap()); + } + return thinned; + } + out +} diff --git a/libs/geodata/src/spool.rs b/libs/geodata/src/spool.rs new file mode 100644 index 000000000..c2886c196 --- /dev/null +++ b/libs/geodata/src/spool.rs @@ -0,0 +1,309 @@ +//! Disk-spooling tiler for layers too large for the in-memory `Tileset` +//! (BAG: ~11M polygons). One pass clips features and appends compact records +//! to one spool file per (zoom, 256x256 block); NL covers only a handful of +//! blocks per zoom, so the second pass loads one block at a time — in the +//! mbtiles writer's required order — and encodes its tiles. Peak memory is +//! one block, not the whole country. + +use crate::mvt::{AttrVal, GeomType, PreFeature, TileEnc}; +use crate::tiler::{ + create_writer, empty_lonlat_bounds, geometry_to_tiles, gzip_tile, note_fields, + TilesetConfig, TilesetStats, +}; +use crate::wkb::Geometry; +use std::collections::{BTreeMap, HashMap}; +use std::io::{BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; + +pub struct SpoolTiler { + dir: PathBuf, + zmin: u8, + zmax: u8, + writers: HashMap<(u8, u32, u32), BufWriter>, + strings: Vec, + string_index: HashMap, + fields: HashMap>, + bounds: (f64, f64, f64, f64), + features_in: u64, + tile_features: u64, + sidecar: crate::sidecar::SidecarBuilder, + ring_layers: Vec, +} + +impl SpoolTiler { + pub fn new(dir: &Path, zmin: u8, zmax: u8) -> Result { + if dir.exists() { + std::fs::remove_dir_all(dir).map_err(|e| format!("clear spool dir: {e}"))?; + } + std::fs::create_dir_all(dir).map_err(|e| format!("create spool dir: {e}"))?; + Ok(SpoolTiler { + dir: dir.to_path_buf(), + zmin, + zmax, + writers: HashMap::new(), + strings: Vec::new(), + string_index: HashMap::new(), + fields: HashMap::new(), + bounds: empty_lonlat_bounds(), + features_in: 0, + tile_features: 0, + sidecar: crate::sidecar::SidecarBuilder::new(), + ring_layers: Vec::new(), + }) + } + + /// See [`crate::tiler::Tileset::query_rings`]. + pub fn query_rings(&mut self, layers: &[&str]) { + self.ring_layers = layers.iter().map(|s| s.to_string()).collect(); + } + + fn intern(&mut self, s: &str) -> u32 { + if let Some(&id) = self.string_index.get(s) { + return id; + } + let id = self.strings.len() as u32; + self.strings.push(s.to_string()); + self.string_index.insert(s.to_string(), id); + id + } + + pub fn add( + &mut self, + layer: &str, + geometry: &Geometry, + attrs: &[(String, AttrVal)], + ) -> Result<(), String> { + self.features_in += 1; + note_fields(&mut self.fields, layer, attrs); + let want_ring = self.ring_layers.iter().any(|l| l == layer); + self.sidecar.add(layer, geometry, attrs, want_ring); + let layer_id = self.intern(layer); + + // Pre-encode attrs once per source feature (shared by all its tiles). + let mut attr_buf = Vec::new(); + write_varint(attrs.len() as u64, &mut attr_buf); + for (key, value) in attrs { + let key_id = self.intern(key); + write_varint(u64::from(key_id), &mut attr_buf); + match value { + AttrVal::Int(i) => { + attr_buf.push(0); + write_varint(zigzag64(*i), &mut attr_buf); + } + AttrVal::Float(f) => { + attr_buf.push(1); + attr_buf.extend_from_slice(&f.to_le_bytes()); + } + AttrVal::Str(s) => { + attr_buf.push(2); + let sid = self.intern(s); + write_varint(u64::from(sid), &mut attr_buf); + } + AttrVal::Bool(b) => { + attr_buf.push(3); + attr_buf.push(u8::from(*b)); + } + } + } + + let zmin = self.zmin; + let zmax = self.zmax; + let mut emitted: Vec<(u8, u32, u32, PreFeature)> = Vec::new(); + geometry_to_tiles(geometry, zmin, zmax, &[], &mut self.bounds, &mut |z, x, y, f| { + emitted.push((z, x, y, f)); + }); + for (zoom, x, y, feature) in emitted { + self.tile_features += 1; + let block = (zoom, x >> 8, y >> 8); + let writer = match self.writers.entry(block) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + let path = self + .dir + .join(format!("z{}-bx{}-by{}.spool", block.0, block.1, block.2)); + let file = std::fs::File::create(&path) + .map_err(|e| format!("create {}: {e}", path.display()))?; + e.insert(BufWriter::with_capacity(1 << 20, file)) + } + }; + let local = (((y & 255) << 8) | (x & 255)) as u16; + let mut rec = Vec::with_capacity(feature.commands.len() * 3 + attr_buf.len() + 8); + rec.extend_from_slice(&local.to_le_bytes()); + write_varint(u64::from(layer_id), &mut rec); + rec.push(feature.geom_type as u8); + write_varint(feature.commands.len() as u64, &mut rec); + for c in &feature.commands { + write_varint(u64::from(*c), &mut rec); + } + rec.extend_from_slice(&attr_buf); + let mut framed = Vec::with_capacity(rec.len() + 4); + write_varint(rec.len() as u64, &mut framed); + framed.extend_from_slice(&rec); + writer + .write_all(&framed) + .map_err(|e| format!("spool write: {e}"))?; + } + Ok(()) + } + + pub fn finish(mut self, out_path: &Path, config: &TilesetConfig) -> Result { + let mut blocks: Vec<(u8, u32, u32)> = self.writers.keys().copied().collect(); + for (_, writer) in self.writers.iter_mut() { + writer.flush().map_err(|e| format!("spool flush: {e}"))?; + } + self.writers.clear(); + // Writer rowid order: zoom asc, then block row-major, then local + // row-major (handled by the BTreeMap below). + blocks.sort_by_key(|&(z, bx, by)| (z, by, bx)); + eprintln!(" spool: {} blocks, {} tile-features", blocks.len(), self.tile_features); + + let mut writer = create_writer(out_path, config, &self.fields, self.bounds)?; + let sidecar = std::mem::take(&mut self.sidecar); + let feature_rows = sidecar.write(&mut writer)?; + eprintln!(" sidecar: {feature_rows} queryable features"); + let mut stats = TilesetStats { + features_in: self.features_in, + tile_features: self.tile_features, + ..Default::default() + }; + for (zoom, bx, by) in blocks { + let path = self + .dir + .join(format!("z{zoom}-bx{bx}-by{by}.spool")); + let mut data = Vec::new(); + std::fs::File::open(&path) + .and_then(|mut f| f.read_to_end(&mut data)) + .map_err(|e| format!("read {}: {e}", path.display()))?; + + let mut tiles: BTreeMap> = BTreeMap::new(); + let mut pos = 0usize; + while pos < data.len() { + let (rec_len, n) = read_varint(&data[pos..]).ok_or("corrupt spool")?; + pos += n; + let rec = &data[pos..pos + rec_len as usize]; + pos += rec_len as usize; + + let local = u16::from_le_bytes([rec[0], rec[1]]); + let mut p = 2usize; + let (layer_id, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let geom_type = match rec[p] { + 1 => GeomType::Point, + 2 => GeomType::Line, + 3 => GeomType::Polygon, + _ => return Err("corrupt spool geom type".into()), + }; + p += 1; + let (n_cmds, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let mut commands = Vec::with_capacity(n_cmds as usize); + for _ in 0..n_cmds { + let (c, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + commands.push(c as u32); + } + let (n_attrs, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let mut attrs = Vec::with_capacity(n_attrs as usize); + for _ in 0..n_attrs { + let (key_id, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let key = self.strings[key_id as usize].clone(); + let tag = rec[p]; + p += 1; + let value = match tag { + 0 => { + let (v, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + AttrVal::Int(unzigzag64(v)) + } + 1 => { + let bytes: [u8; 8] = + rec[p..p + 8].try_into().map_err(|_| "corrupt spool")?; + p += 8; + AttrVal::Float(f64::from_le_bytes(bytes)) + } + 2 => { + let (sid, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + AttrVal::Str(self.strings[sid as usize].clone()) + } + 3 => { + let v = rec[p] != 0; + p += 1; + AttrVal::Bool(v) + } + _ => return Err("corrupt spool attr tag".into()), + }; + attrs.push((key, value)); + } + tiles.entry(local).or_default().push(( + layer_id as u32, + PreFeature { + geom_type, + commands, + attrs, + }, + )); + } + drop(data); + + for (local, features) in tiles { + let lx = u32::from(local & 255); + let ly = u32::from(local >> 8); + let mut enc = TileEnc::new(); + for (layer_id, feature) in &features { + enc.add_feature(&self.strings[*layer_id as usize], feature); + } + let tile_data = gzip_tile(&enc.encode())?; + let x = (bx << 8) | lx; + let y = (by << 8) | ly; + writer + .write_tile_xyz(zoom, x, y, &tile_data) + .map_err(|e| format!("write tile z{zoom}/{x}/{y}: {e:?}"))?; + stats.tiles += 1; + stats.bytes += tile_data.len() as u64; + } + let _ = std::fs::remove_file(&path); + } + writer.finish().map_err(|e| format!("finish mbtiles: {e:?}"))?; + let _ = std::fs::remove_dir_all(&self.dir); + Ok(stats) + } +} + +fn zigzag64(v: i64) -> u64 { + ((v << 1) ^ (v >> 63)) as u64 +} + +fn unzigzag64(v: u64) -> i64 { + ((v >> 1) as i64) ^ -((v & 1) as i64) +} + +fn write_varint(mut value: u64, out: &mut Vec) { + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } +} + +fn read_varint(data: &[u8]) -> Option<(u64, usize)> { + let mut value = 0u64; + let mut shift = 0u32; + for (i, &byte) in data.iter().enumerate() { + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some((value, i + 1)); + } + shift += 7; + if shift > 63 { + return None; + } + } + None +} diff --git a/libs/geodata/src/tiff.rs b/libs/geodata/src/tiff.rs new file mode 100644 index 000000000..243c9d39b --- /dev/null +++ b/libs/geodata/src/tiff.rs @@ -0,0 +1,535 @@ +//! GeoTIFF subset reader for the raster sources we actually use (Copernicus +//! GLO-30 COGs, JRC flood hazard, RIVM noise): classic TIFF, single image +//! (first IFD), tiled or striped, compression none/LZW/deflate, predictor +//! 1/2/3, sample formats uint/int/float 8-64 bit, single band (extra bands +//! ignored). Everything is surfaced as f32 with an optional nodata value. + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +pub struct Tiff { + file: std::fs::File, + le: bool, + pub width: u32, + pub height: u32, + bits: u16, + sample_format: u16, + samples_per_pixel: u16, + compression: u16, + predictor: u16, + tiled: bool, + pub block_w: u32, + pub block_h: u32, + offsets: Vec, + counts: Vec, + /// (origin_x, origin_y, scale_x, scale_y) in model (geo) coordinates; + /// pixel (0,0) top-left corner maps to (origin_x, origin_y), y decreasing. + pub geo: Option<(f64, f64, f64, f64)>, + pub nodata: Option, + cache: HashMap>, + cache_order: Vec, +} + +const CACHE_BLOCKS: usize = 64; + +impl Tiff { + pub fn open(path: &Path) -> Result { + let mut file = + std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + let mut header = [0u8; 8]; + file.read_exact(&mut header) + .map_err(|e| format!("tiff header: {e}"))?; + let le = match &header[0..2] { + b"II" => true, + b"MM" => false, + _ => return Err("not a tiff".into()), + }; + let magic = read_u16(&header[2..4], le); + if magic == 43 { + return Err("BigTIFF not supported yet".into()); + } + if magic != 42 { + return Err("bad tiff magic".into()); + } + let ifd_offset = u64::from(read_u32(&header[4..8], le)); + + let mut tags: HashMap)> = HashMap::new(); + file.seek(SeekFrom::Start(ifd_offset)) + .map_err(|e| format!("seek ifd: {e}"))?; + let mut count_buf = [0u8; 2]; + file.read_exact(&mut count_buf) + .map_err(|e| format!("ifd count: {e}"))?; + let entry_count = read_u16(&count_buf, le); + let mut entries = vec![0u8; entry_count as usize * 12]; + file.read_exact(&mut entries) + .map_err(|e| format!("ifd entries: {e}"))?; + for chunk in entries.chunks_exact(12) { + let tag = read_u16(&chunk[0..2], le); + let field_type = read_u16(&chunk[2..4], le); + let count = u64::from(read_u32(&chunk[4..8], le)); + let type_size = tiff_type_size(field_type); + let total = count * type_size; + let inline = &chunk[8..12]; + let data = if total <= 4 { + inline[..total.min(4) as usize].to_vec() + } else { + let offset = u64::from(read_u32(inline, le)); + let mut buf = vec![0u8; total as usize]; + file.seek(SeekFrom::Start(offset)) + .map_err(|e| format!("seek tag {tag}: {e}"))?; + file.read_exact(&mut buf) + .map_err(|e| format!("read tag {tag}: {e}"))?; + buf + }; + tags.insert(tag, (field_type, count, data)); + } + + let scalar = |tag: u16| -> Option { + let (t, _, data) = tags.get(&tag)?; + read_tag_values(*t, data, le).first().copied().map(|v| v as u64) + }; + let values = |tag: u16| -> Vec { + tags.get(&tag) + .map(|(t, _, data)| { + read_tag_values(*t, data, le) + .into_iter() + .map(|v| v as u64) + .collect() + }) + .unwrap_or_default() + }; + let doubles = |tag: u16| -> Vec { + tags.get(&tag) + .map(|(t, _, data)| read_tag_doubles(*t, data, le)) + .unwrap_or_default() + }; + + let width = scalar(256).ok_or("no width")? as u32; + let height = scalar(257).ok_or("no height")? as u32; + let bits = scalar(258).unwrap_or(8) as u16; + let compression = scalar(259).unwrap_or(1) as u16; + let samples_per_pixel = scalar(277).unwrap_or(1) as u16; + let sample_format = scalar(339).unwrap_or(1) as u16; + let predictor = scalar(317).unwrap_or(1) as u16; + + let (tiled, block_w, block_h, offsets, counts) = if tags.contains_key(&322) { + ( + true, + scalar(322).unwrap_or(256) as u32, + scalar(323).unwrap_or(256) as u32, + values(324), + values(325), + ) + } else { + let rows_per_strip = scalar(278).unwrap_or(u64::from(height)) as u32; + (false, width, rows_per_strip, values(273), values(279)) + }; + if offsets.is_empty() { + return Err("tiff has no data blocks".into()); + } + + // GeoTIFF georeferencing: ModelPixelScale (33550) + ModelTiepoint (33922). + let scale = doubles(33550); + let tiepoint = doubles(33922); + let geo = if scale.len() >= 2 && tiepoint.len() >= 6 { + let origin_x = tiepoint[3] - tiepoint[0] * scale[0]; + let origin_y = tiepoint[4] + tiepoint[1] * scale[1]; + Some((origin_x, origin_y, scale[0], scale[1])) + } else { + None + }; + // GDAL_NODATA (42113) is ASCII. + let nodata = tags.get(&42113).and_then(|(_, _, data)| { + std::str::from_utf8(data) + .ok()? + .trim_end_matches('\0') + .trim() + .parse() + .ok() + }); + + Ok(Tiff { + file, + le, + width, + height, + bits, + sample_format, + samples_per_pixel, + compression, + predictor, + tiled, + block_w, + block_h, + offsets, + counts, + geo, + nodata, + cache: HashMap::new(), + cache_order: Vec::new(), + }) + } + + pub fn blocks_across(&self) -> u32 { + self.width.div_ceil(self.block_w) + } + pub fn blocks_down(&self) -> u32 { + self.height.div_ceil(self.block_h) + } + + /// Sample the first band at pixel coords; None outside or nodata. + pub fn sample(&mut self, x: i64, y: i64) -> Option { + if x < 0 || y < 0 || x >= i64::from(self.width) || y >= i64::from(self.height) { + return None; + } + let (x, y) = (x as u32, y as u32); + let block_index = if self.tiled { + (y / self.block_h) * self.blocks_across() + (x / self.block_w) + } else { + y / self.block_h + }; + let block_w = self.block_w as usize; + let local_x = (x % self.block_w) as usize; + let local_y = (y % self.block_h) as usize; + let block = self.block(block_index).ok()?; + let value = *block.get(local_y * block_w + local_x)?; + if let Some(nodata) = self.nodata { + if (f64::from(value) - nodata).abs() < 1e-6 || value.is_nan() { + return None; + } + } else if value.is_nan() { + return None; + } + Some(value) + } + + /// Sample at model (geo) coordinates with bilinear interpolation. + pub fn sample_geo(&mut self, gx: f64, gy: f64) -> Option { + let (ox, oy, sx, sy) = self.geo?; + let fx = (gx - ox) / sx - 0.5; + let fy = (oy - gy) / sy - 0.5; + let x0 = fx.floor() as i64; + let y0 = fy.floor() as i64; + let tx = (fx - x0 as f64) as f32; + let ty = (fy - y0 as f64) as f32; + let p00 = self.sample(x0, y0); + let p10 = self.sample(x0 + 1, y0); + let p01 = self.sample(x0, y0 + 1); + let p11 = self.sample(x0 + 1, y0 + 1); + match (p00, p10, p01, p11) { + (Some(a), Some(b), Some(c), Some(d)) => { + Some(a * (1.0 - tx) * (1.0 - ty) + b * tx * (1.0 - ty) + + c * (1.0 - tx) * ty + d * tx * ty) + } + // Fall back to nearest if some corners are nodata. + _ => self.sample(fx.round() as i64, fy.round() as i64), + } + } + + fn block(&mut self, index: u32) -> Result<&Vec, String> { + if !self.cache.contains_key(&index) { + let data = self.decode_block(index)?; + if self.cache.len() >= CACHE_BLOCKS { + if let Some(evict) = self.cache_order.first().copied() { + self.cache.remove(&evict); + self.cache_order.remove(0); + } + } + self.cache.insert(index, data); + self.cache_order.push(index); + } + Ok(self.cache.get(&index).unwrap()) + } + + fn decode_block(&mut self, index: u32) -> Result, String> { + let offset = *self.offsets.get(index as usize).ok_or("block index oob")?; + let count = *self.counts.get(index as usize).ok_or("block index oob")? as usize; + let mut raw = vec![0u8; count]; + self.file + .seek(SeekFrom::Start(offset)) + .map_err(|e| format!("seek block: {e}"))?; + self.file + .read_exact(&mut raw) + .map_err(|e| format!("read block: {e}"))?; + + let rows = if self.tiled { + self.block_h as usize + } else { + // last strip may be short + let strip_row = index * self.block_h; + (self.height - strip_row).min(self.block_h) as usize + }; + let sample_bytes = usize::from(self.bits / 8); + let pixel_bytes = sample_bytes * usize::from(self.samples_per_pixel); + let row_bytes = self.block_w as usize * pixel_bytes; + let expected = rows * row_bytes; + + let mut data = match self.compression { + 1 => raw, + 8 | 32946 => { + let mut out = Vec::with_capacity(expected); + flate2::read::ZlibDecoder::new(&raw[..]) + .read_to_end(&mut out) + .map_err(|e| format!("tiff deflate: {e}"))?; + out + } + 5 => lzw_decode(&raw, expected)?, + other => return Err(format!("unsupported tiff compression {other}")), + }; + if data.len() < expected { + return Err(format!( + "tiff block short: {} < {expected}", + data.len() + )); + } + data.truncate(expected); + + match self.predictor { + 1 => {} + 2 => { + // horizontal differencing per row, per sample channel + for row in data.chunks_exact_mut(row_bytes) { + match sample_bytes { + 1 => { + for i in pixel_bytes..row.len() { + row[i] = row[i].wrapping_add(row[i - pixel_bytes]); + } + } + 2 => { + for i in (pixel_bytes..row.len()).step_by(2) { + let prev = read_u16(&row[i - pixel_bytes..], self.le); + let cur = read_u16(&row[i..], self.le); + let sum = cur.wrapping_add(prev); + let bytes = if self.le { + sum.to_le_bytes() + } else { + sum.to_be_bytes() + }; + row[i..i + 2].copy_from_slice(&bytes); + } + } + 4 => { + for i in (pixel_bytes..row.len()).step_by(4) { + let prev = read_u32(&row[i - pixel_bytes..], self.le); + let cur = read_u32(&row[i..], self.le); + let sum = cur.wrapping_add(prev); + let bytes = if self.le { + sum.to_le_bytes() + } else { + sum.to_be_bytes() + }; + row[i..i + 4].copy_from_slice(&bytes); + } + } + _ => return Err("predictor 2 with odd sample size".into()), + } + } + } + 3 => { + // floating-point predictor: per row, bytes are stored + // plane-separated and horizontally differenced. + let mut fixed = vec![0u8; data.len()]; + for (row_index, row) in data.chunks_exact(row_bytes).enumerate() { + let mut undiff = row.to_vec(); + for i in 1..undiff.len() { + undiff[i] = undiff[i].wrapping_add(undiff[i - 1]); + } + let out_row = + &mut fixed[row_index * row_bytes..(row_index + 1) * row_bytes]; + let n = self.block_w as usize * usize::from(self.samples_per_pixel); + for pixel in 0..n { + for byte in 0..sample_bytes { + // planes are stored big-endian-first + out_row[pixel * sample_bytes + byte] = + undiff[byte * n + pixel]; + } + } + } + // predictor-3 output is big-endian IEEE floats by definition + let mut values = Vec::with_capacity(rows * self.block_w as usize); + for pixel in fixed.chunks_exact(pixel_bytes) { + values.push(f32::from_be_bytes( + pixel[0..4].try_into().map_err(|_| "pred3 not f32")?, + )); + } + return Ok(self.pad_block(values, rows)); + } + other => return Err(format!("unsupported tiff predictor {other}")), + } + + // Convert first band to f32. + let mut values = Vec::with_capacity(rows * self.block_w as usize); + for pixel in data.chunks_exact(pixel_bytes) { + let sample = &pixel[0..sample_bytes]; + let value = match (self.sample_format, self.bits) { + (1, 8) => f32::from(sample[0]), + (1, 16) => f32::from(read_u16(sample, self.le)), + (1, 32) => read_u32(sample, self.le) as f32, + (2, 8) => f32::from(sample[0] as i8), + (2, 16) => f32::from(read_u16(sample, self.le) as i16), + (2, 32) => (read_u32(sample, self.le) as i32) as f32, + (3, 32) => { + let bits = read_u32(sample, self.le); + f32::from_bits(bits) + } + (3, 64) => { + let mut b = [0u8; 8]; + b.copy_from_slice(&pixel[0..8]); + let bits = if self.le { + u64::from_le_bytes(b) + } else { + u64::from_be_bytes(b) + }; + f64::from_bits(bits) as f32 + } + (f, b) => return Err(format!("unsupported sample format {f}/{b}")), + }; + values.push(value); + } + Ok(self.pad_block(values, rows)) + } + + /// Pad a short (edge) block to full block_w*block_h with NaN. + fn pad_block(&self, values: Vec, rows: usize) -> Vec { + let full = self.block_w as usize * self.block_h as usize; + if values.len() >= full { + return values; + } + let mut padded = values; + padded.resize(rows * self.block_w as usize, f32::NAN); + padded.resize(full, f32::NAN); + padded + } +} + +/// TIFF-flavor LZW (MSB-first bit order, early code-size change). +fn lzw_decode(input: &[u8], expected: usize) -> Result, String> { + const CLEAR: u16 = 256; + const EOI: u16 = 257; + let mut out = Vec::with_capacity(expected); + let mut dict: Vec> = Vec::new(); + let reset_dict = |dict: &mut Vec>| { + dict.clear(); + for i in 0..258u16 { + dict.push(if i < 256 { vec![i as u8] } else { Vec::new() }); + } + }; + reset_dict(&mut dict); + let mut code_size = 9u32; + let mut bit_pos = 0usize; + let mut prev: Option = None; + + let read_code = |bit_pos: &mut usize, code_size: u32| -> Option { + let mut code = 0u32; + for _ in 0..code_size { + let byte = *input.get(*bit_pos / 8)?; + let bit = (byte >> (7 - (*bit_pos % 8))) & 1; + code = (code << 1) | u32::from(bit); + *bit_pos += 1; + } + Some(code as u16) + }; + + while out.len() < expected { + let Some(code) = read_code(&mut bit_pos, code_size) else { + break; + }; + if code == EOI { + break; + } + if code == CLEAR { + reset_dict(&mut dict); + code_size = 9; + prev = None; + continue; + } + let entry = if (code as usize) < dict.len() && !(code != CLEAR && dict[code as usize].is_empty() && code >= 258) { + dict[code as usize].clone() + } else if let Some(p) = prev { + let mut e = dict[p as usize].clone(); + e.push(dict[p as usize][0]); + e + } else { + return Err("lzw: bad first code".into()); + }; + out.extend_from_slice(&entry); + if let Some(p) = prev { + let mut new_entry = dict[p as usize].clone(); + new_entry.push(entry[0]); + dict.push(new_entry); + } + prev = Some(code); + // TIFF early change: bump code size one code early. + if dict.len() + 1 >= (1 << code_size) && code_size < 12 { + code_size += 1; + } + } + Ok(out) +} + +fn tiff_type_size(field_type: u16) -> u64 { + match field_type { + 1 | 2 | 6 | 7 => 1, // byte, ascii, sbyte, undefined + 3 | 8 => 2, // short + 4 | 9 | 11 => 4, // long, slong, float + 5 | 10 | 12 => 8, // rational, srational, double + _ => 1, + } +} + +fn read_tag_values(field_type: u16, data: &[u8], le: bool) -> Vec { + let size = tiff_type_size(field_type) as usize; + data.chunks_exact(size) + .map(|chunk| match field_type { + 1 | 2 | 6 | 7 => f64::from(chunk[0]), + 3 | 8 => f64::from(read_u16(chunk, le)), + 4 | 9 => f64::from(read_u32(chunk, le)), + 11 => { + let bits = read_u32(chunk, le); + f64::from(f32::from_bits(bits)) + } + 12 => { + let mut b = [0u8; 8]; + b.copy_from_slice(chunk); + f64::from_bits(if le { + u64::from_le_bytes(b) + } else { + u64::from_be_bytes(b) + }) + } + 5 | 10 => { + let num = read_u32(&chunk[0..4], le); + let den = read_u32(&chunk[4..8], le); + if den == 0 { + 0.0 + } else { + f64::from(num) / f64::from(den) + } + } + _ => 0.0, + }) + .collect() +} + +fn read_tag_doubles(field_type: u16, data: &[u8], le: bool) -> Vec { + read_tag_values(field_type, data, le) +} + +fn read_u16(data: &[u8], le: bool) -> u16 { + let bytes: [u8; 2] = data[0..2].try_into().unwrap(); + if le { + u16::from_le_bytes(bytes) + } else { + u16::from_be_bytes(bytes) + } +} + +fn read_u32(data: &[u8], le: bool) -> u32 { + let bytes: [u8; 4] = data[0..4].try_into().unwrap(); + if le { + u32::from_le_bytes(bytes) + } else { + u32::from_be_bytes(bytes) + } +} diff --git a/libs/geodata/src/tiler.rs b/libs/geodata/src/tiler.rs new file mode 100644 index 000000000..68a408585 --- /dev/null +++ b/libs/geodata/src/tiler.rs @@ -0,0 +1,698 @@ +//! Feature -> tile pyramid -> .mbtiles. +//! +//! `geometry_to_tiles` turns one WGS84 geometry into per-(zoom, x, y) MVT +//! features (clip, simplify, winding). Two consumers: the in-memory `Tileset` +//! (fine up to a few million features) and `spool::SpoolTiler` (streams +//! country-scale layers through per-block disk files). Both flush through +//! `MbtilesWriter` in its required deterministic order. + +use crate::geo::{self, NormBBox}; +use crate::mvt::{command, zigzag, AttrVal, GeomType, PreFeature, TileEnc, EXTENT}; +use crate::wkb::Geometry; +use flate2::write::GzEncoder; +use flate2::Compression; +use makepad_mbtile_reader::MbtilesWriter; +use std::collections::{BTreeMap, HashMap}; +use std::io::Write; +use std::path::Path; + +/// Clip buffer around each tile, in MVT units (64 = 1.5% of the extent). +const TILE_BUFFER: f64 = 64.0; +/// Drop polygon rings smaller than this many square MVT units after scaling. +const MIN_RING_AREA: f64 = 8.0; + +pub struct TilesetConfig { + pub name: String, + pub description: String, + pub attribution: String, + pub license: String, + pub minzoom: u8, + pub maxzoom: u8, +} + +#[derive(Default)] +pub struct TilesetStats { + pub features_in: u64, + pub tile_features: u64, + pub tiles: u64, + pub bytes: u64, +} + +// --------------------------------------------------------------------------- +// Shared geometry -> tile-feature machinery +// --------------------------------------------------------------------------- + +/// Track WGS84 bounds as (min_lon, min_lat, max_lon, max_lat). +pub fn grow_lonlat_bounds(bounds: &mut (f64, f64, f64, f64), lon: f64, lat: f64) { + bounds.0 = bounds.0.min(lon); + bounds.1 = bounds.1.min(lat); + bounds.2 = bounds.2.max(lon); + bounds.3 = bounds.3.max(lat); +} + +pub fn empty_lonlat_bounds() -> (f64, f64, f64, f64) { + ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ) +} + +/// Clip/scale one WGS84 geometry into MVT features for every covered tile in +/// the zoom range, delivering them to `sink(zoom, x, y, feature)`. +pub fn geometry_to_tiles( + geometry: &Geometry, + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + match geometry { + Geometry::Point(lon, lat) => { + tile_points(&[(*lon, *lat)], zmin, zmax, attrs, lonlat_bounds, sink) + } + Geometry::MultiPoint(pts) => tile_points(pts, zmin, zmax, attrs, lonlat_bounds, sink), + Geometry::LineString(pts) => tile_lines( + std::slice::from_ref(pts), + zmin, + zmax, + attrs, + lonlat_bounds, + sink, + ), + Geometry::MultiLineString(lines) => { + tile_lines(lines, zmin, zmax, attrs, lonlat_bounds, sink) + } + Geometry::Polygon(rings) => tile_polygons( + std::slice::from_ref(rings), + zmin, + zmax, + attrs, + lonlat_bounds, + sink, + ), + Geometry::MultiPolygon(polys) => { + tile_polygons(polys, zmin, zmax, attrs, lonlat_bounds, sink) + } + } +} + +fn tile_points( + pts: &[(f64, f64)], + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + let norm: Vec<(f64, f64)> = pts + .iter() + .map(|&(lon, lat)| { + grow_lonlat_bounds(lonlat_bounds, lon, lat); + geo::wgs84_to_norm(lon, lat) + }) + .collect(); + for zoom in zmin..=zmax { + let scale = f64::from(1u32 << zoom); + let mut by_tile: HashMap<(u32, u32), Vec<(i64, i64)>> = HashMap::new(); + for &(nx, ny) in &norm { + let wx = nx * scale; + let wy = ny * scale; + let tx = (wx.floor() as i64).clamp(0, i64::from(1u32 << zoom) - 1) as u32; + let ty = (wy.floor() as i64).clamp(0, i64::from(1u32 << zoom) - 1) as u32; + let lx = ((wx - f64::from(tx)) * f64::from(EXTENT)).round() as i64; + let ly = ((wy - f64::from(ty)) * f64::from(EXTENT)).round() as i64; + by_tile.entry((tx, ty)).or_default().push((lx, ly)); + } + for ((tx, ty), pts) in by_tile { + let mut commands = Vec::with_capacity(1 + pts.len() * 2); + commands.push(command(1, pts.len() as u32)); + let (mut cx, mut cy) = (0i64, 0i64); + for (px, py) in pts { + commands.push(zigzag(px - cx)); + commands.push(zigzag(py - cy)); + cx = px; + cy = py; + } + sink( + zoom, + tx, + ty, + PreFeature { + geom_type: GeomType::Point, + commands, + attrs: attrs.to_vec(), + }, + ); + } + } +} + +fn tile_lines( + lines: &[Vec<(f64, f64)>], + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + let mut bbox = NormBBox::empty(); + let norm: Vec> = lines + .iter() + .map(|pts| { + pts.iter() + .map(|&(lon, lat)| { + grow_lonlat_bounds(lonlat_bounds, lon, lat); + let p = geo::wgs84_to_norm(lon, lat); + bbox.add(p.0, p.1); + p + }) + .collect() + }) + .collect(); + if bbox.is_empty() { + return; + } + for zoom in zmin..=zmax { + let scale = f64::from(1u32 << zoom); + let buffer = TILE_BUFFER / f64::from(EXTENT); + for (tx, ty) in tiles_covering(&bbox, zoom, buffer) { + let mut parts: Vec> = Vec::new(); + for line in &norm { + let local: Vec<(f64, f64)> = line + .iter() + .map(|&(nx, ny)| (nx * scale - f64::from(tx), ny * scale - f64::from(ty))) + .collect(); + clip_line_parts(&local, buffer, &mut parts); + } + if parts.is_empty() { + continue; + } + let mut commands = Vec::new(); + let (mut cx, mut cy) = (0i64, 0i64); + let mut any = false; + for part in &parts { + let part = dedup(part); + if part.len() < 2 { + continue; + } + any = true; + commands.push(command(1, 1)); + commands.push(zigzag(part[0].0 - cx)); + commands.push(zigzag(part[0].1 - cy)); + cx = part[0].0; + cy = part[0].1; + commands.push(command(2, (part.len() - 1) as u32)); + for &(px, py) in &part[1..] { + commands.push(zigzag(px - cx)); + commands.push(zigzag(py - cy)); + cx = px; + cy = py; + } + } + if any { + sink( + zoom, + tx, + ty, + PreFeature { + geom_type: GeomType::Line, + commands, + attrs: attrs.to_vec(), + }, + ); + } + } + } +} + +fn tile_polygons( + polys: &[Vec>], + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + let mut bbox = NormBBox::empty(); + let norm: Vec>> = polys + .iter() + .map(|rings| { + rings + .iter() + .map(|ring| { + ring.iter() + .map(|&(lon, lat)| { + grow_lonlat_bounds(lonlat_bounds, lon, lat); + let p = geo::wgs84_to_norm(lon, lat); + bbox.add(p.0, p.1); + p + }) + .collect() + }) + .collect() + }) + .collect(); + if bbox.is_empty() { + return; + } + for zoom in zmin..=zmax { + let scale = f64::from(1u32 << zoom); + let buffer = TILE_BUFFER / f64::from(EXTENT); + for (tx, ty) in tiles_covering(&bbox, zoom, buffer) { + let mut commands = Vec::new(); + let (mut cx, mut cy) = (0i64, 0i64); + let mut any = false; + for rings in &norm { + for (ring_index, ring) in rings.iter().enumerate() { + let local: Vec<(f64, f64)> = ring + .iter() + .map(|&(nx, ny)| { + (nx * scale - f64::from(tx), ny * scale - f64::from(ty)) + }) + .collect(); + let clipped = clip_ring(&local, buffer); + if clipped.len() < 3 { + continue; + } + let mut pts: Vec<(i64, i64)> = clipped + .iter() + .map(|&(x, y)| { + ( + (x * f64::from(EXTENT)).round() as i64, + (y * f64::from(EXTENT)).round() as i64, + ) + }) + .collect(); + if pts.len() >= 2 && pts.first() == pts.last() { + pts.pop(); + } + let pts = dedup(&pts); + if pts.len() < 3 { + continue; + } + let area2 = signed_area2(&pts); + if (area2.abs() as f64) < MIN_RING_AREA * 2.0 { + continue; + } + // MVT spec: in tile coords (y down) the exterior ring has + // positive shoelace area, interior rings negative. + let want_positive = ring_index == 0; + let ordered: Vec<(i64, i64)> = if (area2 > 0) == want_positive { + pts + } else { + pts.into_iter().rev().collect() + }; + + any = true; + commands.push(command(1, 1)); + commands.push(zigzag(ordered[0].0 - cx)); + commands.push(zigzag(ordered[0].1 - cy)); + cx = ordered[0].0; + cy = ordered[0].1; + commands.push(command(2, (ordered.len() - 1) as u32)); + for &(px, py) in &ordered[1..] { + commands.push(zigzag(px - cx)); + commands.push(zigzag(py - cy)); + cx = px; + cy = py; + } + commands.push(command(7, 1)); + } + } + if any { + sink( + zoom, + tx, + ty, + PreFeature { + geom_type: GeomType::Polygon, + commands, + attrs: attrs.to_vec(), + }, + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// In-memory tileset +// --------------------------------------------------------------------------- + +type TileKey = (u8, u32, u32); + +pub struct Tileset { + tiles: BTreeMap)>, + fields: HashMap>, + bounds: (f64, f64, f64, f64), + stats: TilesetStats, + sidecar: crate::sidecar::SidecarBuilder, + ring_layers: Vec, +} + +impl Tileset { + pub fn new() -> Self { + Tileset { + tiles: BTreeMap::new(), + fields: HashMap::new(), + bounds: empty_lonlat_bounds(), + stats: TilesetStats::default(), + sidecar: crate::sidecar::SidecarBuilder::new(), + ring_layers: Vec::new(), + } + } + + /// Layers whose features store a simplified exterior ring in the sidecar, + /// enabling exact point-in-polygon queries. + pub fn query_rings(&mut self, layers: &[&str]) { + self.ring_layers = layers.iter().map(|s| s.to_string()).collect(); + } + + /// Add any WGS84 geometry across a zoom range. + pub fn add( + &mut self, + layer: &str, + zmin: u8, + zmax: u8, + geometry: &Geometry, + attrs: &[(String, AttrVal)], + ) { + self.stats.features_in += 1; + note_fields(&mut self.fields, layer, attrs); + let want_ring = self.ring_layers.iter().any(|l| l == layer); + self.sidecar.add(layer, geometry, attrs, want_ring); + let tiles = &mut self.tiles; + let stats_tile_features = &mut self.stats.tile_features; + geometry_to_tiles( + geometry, + zmin, + zmax, + attrs, + &mut self.bounds, + &mut |zoom, x, y, feature| { + let key = geo::tile_order_key(zoom, x, y); + tiles + .entry(key) + .or_insert_with(|| ((zoom, x, y), Vec::new())) + .1 + .push((layer.to_string(), feature)); + *stats_tile_features += 1; + }, + ); + } + + /// Encode, gzip and write everything to a fresh .mbtiles file. + pub fn finish(mut self, path: &Path, config: &TilesetConfig) -> Result { + let mut writer = create_writer(path, config, &self.fields, self.bounds)?; + let sidecar = std::mem::take(&mut self.sidecar); + let feature_rows = sidecar.write(&mut writer)?; + eprintln!(" sidecar: {feature_rows} queryable features"); + for (_, ((zoom, x, y), features)) in std::mem::take(&mut self.tiles) { + let mut enc = TileEnc::new(); + for (layer_name, feature) in &features { + enc.add_feature(layer_name, feature); + } + let data = gzip_tile(&enc.encode())?; + writer + .write_tile_xyz(zoom, x, y, &data) + .map_err(|e| format!("write tile z{zoom}/{x}/{y}: {e:?}"))?; + self.stats.tiles += 1; + self.stats.bytes += data.len() as u64; + } + writer.finish().map_err(|e| format!("finish mbtiles: {e:?}"))?; + Ok(self.stats) + } +} + +// --------------------------------------------------------------------------- +// Shared writer helpers (also used by spool.rs) +// --------------------------------------------------------------------------- + +pub(crate) fn note_fields( + fields: &mut HashMap>, + layer: &str, + attrs: &[(String, AttrVal)], +) { + let layer_fields = fields.entry(layer.to_string()).or_default(); + for (key, value) in attrs { + let type_name = match value { + AttrVal::Str(_) => "String", + AttrVal::Int(_) => "Number", + AttrVal::Float(_) => "Number", + AttrVal::Bool(_) => "Boolean", + }; + layer_fields.insert(key.clone(), type_name); + } +} + +pub(crate) fn gzip_tile(raw: &[u8]) -> Result, String> { + let mut gz = GzEncoder::new(Vec::new(), Compression::new(6)); + gz.write_all(raw).map_err(|e| format!("gzip: {e}"))?; + gz.finish().map_err(|e| format!("gzip: {e}")) +} + +pub(crate) fn create_writer( + path: &Path, + config: &TilesetConfig, + fields: &HashMap>, + bounds: (f64, f64, f64, f64), +) -> Result { + let mut writer = MbtilesWriter::create(path).map_err(|e| format!("create mbtiles: {e:?}"))?; + writer.set_metadata("name", &config.name); + writer.set_metadata("description", &config.description); + writer.set_metadata("format", "pbf"); + writer.set_metadata("type", "overlay"); + writer.set_metadata("minzoom", config.minzoom.to_string()); + writer.set_metadata("maxzoom", config.maxzoom.to_string()); + writer.set_metadata("attribution", &config.attribution); + writer.set_metadata("license", &config.license); + if bounds.0.is_finite() { + writer.set_metadata( + "bounds", + format!( + "{:.6},{:.6},{:.6},{:.6}", + bounds.0, bounds.1, bounds.2, bounds.3 + ), + ); + } + let vector_layers: Vec = fields + .iter() + .map(|(layer_name, layer_fields)| { + serde_json::json!({ + "id": layer_name, + "fields": layer_fields, + "minzoom": config.minzoom, + "maxzoom": config.maxzoom, + }) + }) + .collect(); + writer.set_metadata( + "json", + serde_json::json!({ "vector_layers": vector_layers }).to_string(), + ); + writer.set_metadata( + "geodata_built_unix", + format!( + "{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ), + ); + Ok(writer) +} + +// --------------------------------------------------------------------------- +// Clip helpers +// --------------------------------------------------------------------------- + +/// Tiles whose (buffered) extent intersects the bbox at this zoom. +fn tiles_covering(bbox: &NormBBox, zoom: u8, buffer: f64) -> Vec<(u32, u32)> { + let scale = f64::from(1u32 << zoom); + let axis = i64::from(1u32 << zoom); + let min_tx = ((bbox.min_x * scale - buffer).floor() as i64).clamp(0, axis - 1); + let max_tx = ((bbox.max_x * scale + buffer).floor() as i64).clamp(0, axis - 1); + let min_ty = ((bbox.min_y * scale - buffer).floor() as i64).clamp(0, axis - 1); + let max_ty = ((bbox.max_y * scale + buffer).floor() as i64).clamp(0, axis - 1); + let mut tiles = Vec::new(); + for ty in min_ty..=max_ty { + for tx in min_tx..=max_tx { + tiles.push((tx as u32, ty as u32)); + } + } + tiles +} + +/// Sutherland-Hodgman clip of a ring against the buffered unit square. +fn clip_ring(ring: &[(f64, f64)], buffer: f64) -> Vec<(f64, f64)> { + let lo = -buffer; + let hi = 1.0 + buffer; + let mut pts = ring.to_vec(); + if pts.len() >= 2 && pts.first() == pts.last() { + pts.pop(); + } + for &(axis_x, bound, keep_greater) in &[ + (true, lo, true), + (true, hi, false), + (false, lo, true), + (false, hi, false), + ] { + if pts.is_empty() { + return pts; + } + let inside = |p: &(f64, f64)| { + let v = if axis_x { p.0 } else { p.1 }; + if keep_greater { + v >= bound + } else { + v <= bound + } + }; + let intersect = |a: &(f64, f64), b: &(f64, f64)| -> (f64, f64) { + let (av, bv) = if axis_x { (a.0, b.0) } else { (a.1, b.1) }; + let t = if (bv - av).abs() < f64::EPSILON { + 0.0 + } else { + (bound - av) / (bv - av) + }; + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }; + let mut out = Vec::with_capacity(pts.len() + 4); + for i in 0..pts.len() { + let current = pts[i]; + let previous = pts[(i + pts.len() - 1) % pts.len()]; + let current_in = inside(¤t); + let previous_in = inside(&previous); + if current_in { + if !previous_in { + out.push(intersect(&previous, ¤t)); + } + out.push(current); + } else if previous_in { + out.push(intersect(&previous, ¤t)); + } + } + pts = out; + } + pts +} + +/// Clip a polyline to the buffered unit square, appending surviving sub-parts +/// (scaled to MVT integer units) to `parts`. +fn clip_line_parts(line: &[(f64, f64)], buffer: f64, parts: &mut Vec>) { + let lo = -buffer; + let hi = 1.0 + buffer; + let inside = |p: &(f64, f64)| p.0 >= lo && p.0 <= hi && p.1 >= lo && p.1 <= hi; + let to_units = |p: (f64, f64)| -> (i64, i64) { + ( + (p.0 * f64::from(EXTENT)).round() as i64, + (p.1 * f64::from(EXTENT)).round() as i64, + ) + }; + let mut current: Vec<(i64, i64)> = Vec::new(); + for window in line.windows(2) { + let (a, b) = (window[0], window[1]); + match (inside(&a), inside(&b)) { + (true, true) => { + if current.is_empty() { + current.push(to_units(a)); + } + current.push(to_units(b)); + } + (true, false) => { + if current.is_empty() { + current.push(to_units(a)); + } + if let Some(exit) = clip_segment(a, b, lo, hi) { + current.push(to_units(exit.1)); + } + if current.len() >= 2 { + parts.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + } + (false, true) => { + if let Some(entry) = clip_segment(a, b, lo, hi) { + current.push(to_units(entry.0)); + } + current.push(to_units(b)); + } + (false, false) => { + if let Some((entry, exit)) = clip_segment(a, b, lo, hi) { + let part = vec![to_units(entry), to_units(exit)]; + if part[0] != part[1] { + parts.push(part); + } + } + } + } + } + if current.len() >= 2 { + parts.push(current); + } +} + +/// Liang-Barsky segment clip against the buffered square. +fn clip_segment( + a: (f64, f64), + b: (f64, f64), + lo: f64, + hi: f64, +) -> Option<((f64, f64), (f64, f64))> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let mut t0 = 0.0f64; + let mut t1 = 1.0f64; + for &(p, q) in &[ + (-dx, a.0 - lo), + (dx, hi - a.0), + (-dy, a.1 - lo), + (dy, hi - a.1), + ] { + if p.abs() < f64::EPSILON { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + if t0 > t1 { + return None; + } + } + } + Some(( + (a.0 + dx * t0, a.1 + dy * t0), + (a.0 + dx * t1, a.1 + dy * t1), + )) +} + +fn dedup(pts: &[(i64, i64)]) -> Vec<(i64, i64)> { + let mut out: Vec<(i64, i64)> = Vec::with_capacity(pts.len()); + for &p in pts { + if out.last() != Some(&p) { + out.push(p); + } + } + out +} + +/// Twice the shoelace signed area on tile coordinates (y down). +/// Positive = clockwise on screen = MVT exterior ring. +fn signed_area2(pts: &[(i64, i64)]) -> i64 { + let mut sum = 0i64; + for i in 0..pts.len() { + let (x1, y1) = pts[i]; + let (x2, y2) = pts[(i + 1) % pts.len()]; + sum += x1 * y2 - x2 * y1; + } + sum +} diff --git a/libs/geodata/src/wkb.rs b/libs/geodata/src/wkb.rs new file mode 100644 index 000000000..be93c0189 --- /dev/null +++ b/libs/geodata/src/wkb.rs @@ -0,0 +1,209 @@ +//! GeoPackage geometry blob + (ISO/EWKB) WKB parser. XY only; Z/M dropped. + +#[derive(Debug, Clone)] +pub enum Geometry { + Point(f64, f64), + MultiPoint(Vec<(f64, f64)>), + LineString(Vec<(f64, f64)>), + MultiLineString(Vec>), + /// Rings: first exterior, rest holes. + Polygon(Vec>), + MultiPolygon(Vec>>), +} + +impl Geometry { + /// Apply a coordinate transform to every vertex. + pub fn map_coords(&self, f: &impl Fn(f64, f64) -> (f64, f64)) -> Geometry { + let m1 = |pts: &Vec<(f64, f64)>| pts.iter().map(|&(x, y)| f(x, y)).collect::>(); + match self { + Geometry::Point(x, y) => { + let (x, y) = f(*x, *y); + Geometry::Point(x, y) + } + Geometry::MultiPoint(p) => Geometry::MultiPoint(m1(p)), + Geometry::LineString(p) => Geometry::LineString(m1(p)), + Geometry::MultiLineString(l) => { + Geometry::MultiLineString(l.iter().map(m1).collect()) + } + Geometry::Polygon(r) => Geometry::Polygon(r.iter().map(m1).collect()), + Geometry::MultiPolygon(polys) => Geometry::MultiPolygon( + polys.iter().map(|r| r.iter().map(m1).collect()).collect(), + ), + } + } +} + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn u8(&mut self) -> Option { + let v = *self.data.get(self.pos)?; + self.pos += 1; + Some(v) + } + fn u32(&mut self, le: bool) -> Option { + let bytes: [u8; 4] = self.data.get(self.pos..self.pos + 4)?.try_into().ok()?; + self.pos += 4; + Some(if le { + u32::from_le_bytes(bytes) + } else { + u32::from_be_bytes(bytes) + }) + } + fn f64(&mut self, le: bool) -> Option { + let bytes: [u8; 8] = self.data.get(self.pos..self.pos + 8)?.try_into().ok()?; + self.pos += 8; + Some(if le { + f64::from_le_bytes(bytes) + } else { + f64::from_be_bytes(bytes) + }) + } + fn skip(&mut self, n: usize) -> Option<()> { + if self.pos + n > self.data.len() { + return None; + } + self.pos += n; + Some(()) + } +} + +/// Parse a GeoPackage geometry blob (GP header + WKB). +pub fn parse_gpkg_geometry(blob: &[u8]) -> Option { + if blob.len() < 8 || blob[0] != b'G' || blob[1] != b'P' { + return parse_wkb_geometry(blob); // tolerate raw WKB + } + let flags = blob[3]; + let header_le = flags & 0x01 != 0; + let envelope_kind = (flags >> 1) & 0x07; + if flags & 0x10 != 0 { + return None; // declared-empty geometry + } + let envelope_len = match envelope_kind { + 0 => 0, + 1 => 32, + 2 | 3 => 48, + 4 => 64, + _ => return None, + }; + let mut cur = Cursor { + data: blob, + pos: 4, + }; + let _srs_id = cur.u32(header_le)?; + cur.skip(envelope_len)?; + parse_wkb_geometry(&blob[cur.pos..]) +} + +/// Parse ISO WKB / EWKB. Returns XY geometry. +pub fn parse_wkb_geometry(data: &[u8]) -> Option { + let mut cur = Cursor { data, pos: 0 }; + parse_wkb_inner(&mut cur) +} + +fn parse_wkb_inner(cur: &mut Cursor) -> Option { + let le = match cur.u8()? { + 0 => false, + 1 => true, + _ => return None, + }; + let raw_type = cur.u32(le)?; + + // EWKB flags + let has_z_flag = raw_type & 0x8000_0000 != 0; + let has_m_flag = raw_type & 0x4000_0000 != 0; + let has_srid = raw_type & 0x2000_0000 != 0; + let base_iso = raw_type & 0x0FFF_FFFF; + // ISO encodes Z/M as +1000/+2000/+3000 + let iso_dim = base_iso / 1000; + let base = base_iso % 1000; + let has_z = has_z_flag || iso_dim == 1 || iso_dim == 3; + let has_m = has_m_flag || iso_dim == 2 || iso_dim == 3; + let extra_dims = usize::from(has_z) + usize::from(has_m); + + if has_srid { + cur.u32(le)?; + } + + let read_pt = |cur: &mut Cursor| -> Option<(f64, f64)> { + let x = cur.f64(le)?; + let y = cur.f64(le)?; + cur.skip(extra_dims * 8)?; + Some((x, y)) + }; + let read_ring = |cur: &mut Cursor| -> Option> { + let n = cur.u32(le)? as usize; + let mut pts = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + pts.push(read_pt(cur)?); + } + Some(pts) + }; + + match base { + 1 => { + let (x, y) = read_pt(cur)?; + Some(Geometry::Point(x, y)) + } + 2 => Some(Geometry::LineString(read_ring(cur)?)), + 3 => { + let n = cur.u32(le)? as usize; + let mut rings = Vec::with_capacity(n.min(1 << 16)); + for _ in 0..n { + rings.push(read_ring(cur)?); + } + Some(Geometry::Polygon(rings)) + } + 4 => { + let n = cur.u32(le)? as usize; + let mut pts = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::Point(x, y) => pts.push((x, y)), + _ => return None, + } + } + Some(Geometry::MultiPoint(pts)) + } + 5 => { + let n = cur.u32(le)? as usize; + let mut lines = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::LineString(pts) => lines.push(pts), + _ => return None, + } + } + Some(Geometry::MultiLineString(lines)) + } + 6 => { + let n = cur.u32(le)? as usize; + let mut polys = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::Polygon(rings) => polys.push(rings), + _ => return None, + } + } + Some(Geometry::MultiPolygon(polys)) + } + 7 => { + // GeometryCollection: merge whatever is inside into a MultiPolygon + // if all polygons, else give up (none of our sources need more). + let n = cur.u32(le)? as usize; + let mut polys = Vec::new(); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::Polygon(rings) => polys.push(rings), + Geometry::MultiPolygon(mut more) => polys.append(&mut more), + _ => return None, + } + } + Some(Geometry::MultiPolygon(polys)) + } + _ => None, + } +} diff --git a/libs/llama/Cargo.toml b/libs/llama/Cargo.toml index 90a2e187c..770e0856a 100644 --- a/libs/llama/Cargo.toml +++ b/libs/llama/Cargo.toml @@ -27,3 +27,11 @@ path = "src/bin/llama_tokenize.rs" [[bin]] name = "llama-batch-probe" path = "src/bin/llama_batch_probe.rs" + +[[bin]] +name = "vlm-vision-probe" +path = "src/bin/vlm_vision_probe.rs" + +[[bin]] +name = "vlm-probe" +path = "src/bin/vlm_probe.rs" diff --git a/libs/llama/src/bin/vlm_probe.rs b/libs/llama/src/bin/vlm_probe.rs new file mode 100644 index 000000000..7be1f2067 --- /dev/null +++ b/libs/llama/src/bin/vlm_probe.rs @@ -0,0 +1,148 @@ +// End-to-end VLM probe: image + question -> answer, fully on makepad-ggml. +// +// Pipeline: preprocess -> vision tower -> <|vision_start|> [embeddings] +// <|vision_end|> ChatML wrap -> M-RoPE prefill -> greedy generation. +// Compare against: llama-mtmd-cli -m --mmproj --image -p +// +// usage: vlm-probe [--max-new-tokens N] [--max-context N] + +use makepad_llama::{ + preprocess_rgb8, LlamaSession, LlamaSessionConfig, VisionConfig, VisionTower, +}; +use std::io::Write; +use std::time::Instant; + +fn read_ppm(path: &str) -> (Vec, usize, usize) { + let data = std::fs::read(path).expect("cannot read image"); + let mut fields = Vec::new(); + let mut pos = 0usize; + while fields.len() < 4 { + while pos < data.len() && (data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + if data[pos] == b'#' { + while pos < data.len() && data[pos] != b'\n' { + pos += 1; + } + continue; + } + let start = pos; + while pos < data.len() && !(data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + fields.push(String::from_utf8_lossy(&data[start..pos]).into_owned()); + } + pos += 1; + assert_eq!(fields[0], "P6"); + let w: usize = fields[1].parse().unwrap(); + let h: usize = fields[2].parse().unwrap(); + assert_eq!(fields[3], "255"); + (data[pos..pos + w * h * 3].to_vec(), w, h) +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 5 { + eprintln!( + "usage: {} [--max-new-tokens N] [--max-context N]", + args[0] + ); + std::process::exit(1); + } + let model_path = &args[1]; + let mmproj_path = &args[2]; + let image_path = &args[3]; + let question = &args[4]; + let mut max_new_tokens = 256usize; + let mut max_context = 8192u32; + let mut i = 5; + while i < args.len() { + match args[i].as_str() { + "--max-new-tokens" => { + i += 1; + max_new_tokens = args[i].parse().unwrap(); + } + "--max-context" => { + i += 1; + max_context = args[i].parse().unwrap(); + } + other => panic!("unknown arg {other}"), + } + i += 1; + } + + // 1. vision leg + let (rgb, w, h) = read_ppm(image_path); + let gguf = makepad_llama::GgufFile::open(mmproj_path).expect("open mmproj"); + let vision_config = VisionConfig::from_gguf(&gguf).expect("vision config"); + let t0 = Instant::now(); + let prepared = preprocess_rgb8(&rgb, w, h, &vision_config).expect("preprocess"); + let mut tower = VisionTower::load(mmproj_path, prepared.n_patches()).expect("vision tower"); + let embeddings = tower.encode(&prepared).expect("encode image"); + eprintln!( + "vision: {}x{} -> {}x{} tokens ({}) in {:.2}s", + w, + h, + prepared.tokens_w(), + prepared.tokens_h(), + prepared.n_tokens(), + t0.elapsed().as_secs_f64() + ); + + // 2. text leg + let t1 = Instant::now(); + let mut session = LlamaSession::load( + model_path, + LlamaSessionConfig { + max_context: Some(max_context), + ..LlamaSessionConfig::default() + }, + ) + .expect("load llama session"); + eprintln!("session loaded in {:.2}s", t1.elapsed().as_secs_f64()); + + // 3. ChatML prefill with the image span; non-thinking prefill for + // deterministic short answers + let prefix = "<|im_start|>user\n<|vision_start|>"; + let suffix = format!( + "<|vision_end|>{question}<|im_end|>\n<|im_start|>assistant\n\n\n\n\n" + ); + + let t2 = Instant::now(); + let prefix_ids = session.vocab().tokenize(prefix, false, true).expect("tokenize prefix"); + session.append_tokens(&prefix_ids).expect("prefill prefix"); + session + .append_image_embeddings(&embeddings, prepared.tokens_w(), prepared.tokens_h()) + .expect("prefill image"); + let suffix_ids = session.vocab().tokenize(&suffix, false, true).expect("tokenize suffix"); + session.append_tokens(&suffix_ids).expect("prefill suffix"); + eprintln!( + "prefill: {} tokens ({} image) in {:.2}s", + session.token_count(), + prepared.n_tokens(), + t2.elapsed().as_secs_f64() + ); + + // 4. greedy generation + let t3 = Instant::now(); + let mut generated = 0usize; + let mut decoder = session.vocab().text_decoder(); + while generated < max_new_tokens { + let Some(token) = session.next_greedy_token().expect("generate") else { + break; + }; + generated += 1; + if let Some(text) = decoder.push_token(session.vocab(), token) { + print!("{text}"); + std::io::stdout().flush().ok(); + } + } + println!(); + let dt = t3.elapsed().as_secs_f64(); + eprintln!( + "generated {} tokens in {:.2}s ({:.1} tok/s)", + generated, + dt, + generated as f64 / dt.max(1e-9) + ); +} diff --git a/libs/llama/src/bin/vlm_vision_probe.rs b/libs/llama/src/bin/vlm_vision_probe.rs new file mode 100644 index 000000000..d5d8fecbb --- /dev/null +++ b/libs/llama/src/bin/vlm_vision_probe.rs @@ -0,0 +1,173 @@ +// Parity probe: run the makepad vision tower on a PPM image and compare the +// preprocessed tensor + output embeddings against clip.cpp oracle dumps +// produced by tools/vlm_oracle/clip_dump. +// +// usage: vlm-vision-probe [reference_prefix] +// reference_prefix.preproc.bin / reference_prefix.embd.bin as written by clip_dump + +use makepad_llama::{preprocess_rgb8, VisionConfig, VisionTower}; + +use std::fs; +use std::time::Instant; + +fn read_ppm(path: &str) -> (Vec, usize, usize) { + let data = fs::read(path).expect("cannot read image"); + let mut fields = Vec::new(); + let mut pos = 0usize; + // P6 header: magic, width, height, maxval, single whitespace, then pixels + while fields.len() < 4 { + while pos < data.len() && (data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + if data[pos] == b'#' { + while pos < data.len() && data[pos] != b'\n' { + pos += 1; + } + continue; + } + let start = pos; + while pos < data.len() && !(data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + fields.push(String::from_utf8_lossy(&data[start..pos]).into_owned()); + } + pos += 1; // single whitespace after maxval + assert_eq!(fields[0], "P6", "not a P6 ppm"); + let w: usize = fields[1].parse().unwrap(); + let h: usize = fields[2].parse().unwrap(); + assert_eq!(fields[3], "255", "maxval must be 255"); + let rgb = data[pos..pos + w * h * 3].to_vec(); + (rgb, w, h) +} + +fn read_u32(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +fn read_f32s(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +fn compare(label: &str, ours: &[f32], theirs: &[f32]) { + assert_eq!(ours.len(), theirs.len(), "{label}: length mismatch"); + let mut max_abs = 0f32; + let mut max_at = 0usize; + let mut dot = 0f64; + let mut na = 0f64; + let mut nb = 0f64; + let mut sum_sq = 0f64; + for (i, (&x, &y)) in ours.iter().zip(theirs).enumerate() { + let d = (x - y).abs(); + if d > max_abs { + max_abs = d; + max_at = i; + } + dot += (x as f64) * (y as f64); + na += (x as f64) * (x as f64); + nb += (y as f64) * (y as f64); + sum_sq += (d as f64) * (d as f64); + } + let cos = dot / (na.sqrt() * nb.sqrt()).max(1e-30); + let rms = (sum_sq / ours.len() as f64).sqrt(); + println!( + "{label}: n {} max_abs {:.3e} (at {}: ours {:.5} ref {:.5}) rms {:.3e} cosine {:.8}", + ours.len(), + max_abs, + max_at, + ours[max_at], + theirs[max_at], + rms, + cos + ); +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: {} [reference_prefix]", args[0]); + std::process::exit(1); + } + let mmproj_path = &args[1]; + let image_path = &args[2]; + let ref_prefix = args.get(3); + + let (rgb, w, h) = read_ppm(image_path); + println!("image: {w} x {h}"); + + let gguf = makepad_llama::GgufFile::open(mmproj_path).expect("open mmproj"); + let config = VisionConfig::from_gguf(&gguf).expect("vision config"); + println!( + "config: {} layers, embd {}, heads {}, proj {}, image {} patch {} merge {}", + config.n_layers, + config.n_embd, + config.n_heads, + config.proj_dim, + config.image_size, + config.patch_size, + config.n_merge + ); + + let t0 = Instant::now(); + let prepared = preprocess_rgb8(&rgb, w, h, &config).expect("preprocess"); + println!( + "preprocessed: {} x {} ({} patches, {} tokens) in {:.1} ms", + prepared.width, + prepared.height, + prepared.n_patches(), + prepared.n_tokens(), + t0.elapsed().as_secs_f64() * 1000.0 + ); + + if let Some(prefix) = ref_prefix { + let bytes = fs::read(format!("{prefix}.preproc.bin")).expect("read ref preproc"); + let rnx = read_u32(&bytes, 0) as usize; + let rny = read_u32(&bytes, 4) as usize; + assert_eq!( + (rnx, rny), + (prepared.width, prepared.height), + "preprocessed size mismatch vs reference" + ); + let ref_pixels = read_f32s(&bytes[8..]); + compare("preproc", &prepared.pixels, &ref_pixels); + } + + let t1 = Instant::now(); + let mut tower = + VisionTower::load(mmproj_path, prepared.n_patches()).expect("load vision tower"); + println!("tower loaded in {:.2} s", t1.elapsed().as_secs_f64()); + + let t2 = Instant::now(); + let embd = tower.encode(&prepared).expect("encode"); + println!( + "encoded {} tokens x {} in {:.1} ms (includes graph compile)", + prepared.n_tokens(), + tower.config.proj_dim, + t2.elapsed().as_secs_f64() * 1000.0 + ); + let t3 = Instant::now(); + let embd2 = tower.encode(&prepared).expect("encode 2"); + println!("second encode {:.1} ms", t3.elapsed().as_secs_f64() * 1000.0); + assert_eq!(embd.len(), embd2.len()); + + let mean = embd.iter().map(|&v| v as f64).sum::() / embd.len() as f64; + let rms = (embd.iter().map(|&v| (v as f64) * (v as f64)).sum::() / embd.len() as f64) + .sqrt(); + print!("embd mean {mean:.6} rms {rms:.6} first8:"); + for v in &embd[..8] { + print!(" {v:.5}"); + } + println!(); + + if let Some(prefix) = ref_prefix { + let bytes = fs::read(format!("{prefix}.embd.bin")).expect("read ref embd"); + let n_tokens = read_u32(&bytes, 0) as usize; + let n_embd = read_u32(&bytes, 4) as usize; + assert_eq!(n_tokens, prepared.n_tokens(), "token count mismatch"); + assert_eq!(n_embd, tower.config.proj_dim, "embd dim mismatch"); + let ref_embd = read_f32s(&bytes[16..]); + compare("embd", &embd, &ref_embd); + } +} diff --git a/libs/llama/src/lib.rs b/libs/llama/src/lib.rs index 5052aac73..f3c437e0d 100644 --- a/libs/llama/src/lib.rs +++ b/libs/llama/src/lib.rs @@ -10,6 +10,7 @@ pub mod qwen35moe; pub mod qwen35moe_runtime; pub mod runtime; pub mod session; +pub mod vision; pub mod vocab; pub mod weights; @@ -115,5 +116,9 @@ pub use runtime::{ RmsNormSpec, }; pub use session::{LlamaGeneration, LlamaSession, LlamaSessionConfig, LlamaStopReason}; +pub use vision::{ + calc_size_preserved_ratio, preprocess_rgb8, vision_rope_positions, PreparedImage, + VisionConfig, VisionTower, +}; pub use vocab::{LlamaTextDecoder, LlamaTokenizerKind, LlamaVocab}; pub use weights::{GgufWeightLayout, LoadedGgufWeights}; diff --git a/libs/llama/src/model.rs b/libs/llama/src/model.rs index faad4eaa6..2a6fda47d 100644 --- a/libs/llama/src/model.rs +++ b/libs/llama/src/model.rs @@ -180,6 +180,22 @@ impl LlamaModel { ))) } + pub fn embedding_length(&self) -> Result { + if let Some(cfg) = &self.qwen35 { + return Ok(cfg.embedding_length); + } + if let Some(cfg) = &self.qwen35moe { + return Ok(cfg.embedding_length); + } + if let Some(cfg) = &self.gemma4 { + return Ok(cfg.embedding_length); + } + Err(LlamaError::unsupported(format!( + "embedding length is not implemented for architecture '{}'", + self.architecture.name() + ))) + } + pub fn hybrid_decode_spec( &self, max_context: u32, diff --git a/libs/llama/src/runtime.rs b/libs/llama/src/runtime.rs index 45e58a229..0bb6e98ad 100644 --- a/libs/llama/src/runtime.rs +++ b/libs/llama/src/runtime.rs @@ -836,6 +836,12 @@ pub struct HybridDecodeBatchLayout { pub attention_key_count: usize, pub recurrent_state_rows: Vec, pub output_ids: Vec, + /// Pre-expanded M-RoPE positions, `[c0 over all tokens][c1][c2][c3]`. + /// When absent, rope uses `positions` broadcast across components (the + /// text-only behavior). Image spans need this: their h/w components + /// diverge from the linear sequence index, while `positions` stays the + /// linear index used for cache writes and attention masking. + pub rope_positions: Option>, } impl HybridDecodeBatchLayout { @@ -887,6 +893,7 @@ impl HybridDecodeBatchLayout { attention_key_count, recurrent_state_rows: vec![0], output_ids: output_ids.to_vec(), + rope_positions: None, }) } @@ -7776,7 +7783,10 @@ pub fn execute_prepared_hybrid_decode_metal( "hybrid decode has rope position input without an attention rope spec", ) })?; - Some(encode_rope_positions(rope, positions, positions.len())?) + // Image spans carry pre-expanded 4-component M-RoPE positions on the + // layout; text batches fall back to broadcasting the linear positions. + let rope_source = layout.rope_positions.as_deref().unwrap_or(positions); + Some(encode_rope_positions(rope, rope_source, positions.len())?) } else { None }; @@ -8714,6 +8724,7 @@ mod tests { }, embedding_length: (s_v * h_v) as u32, input_norm_name: String::new(), + merged_input_proj_name: None, qkv_proj_name: String::new(), qkv_proj_scale_name: None, z_proj_name: String::new(), diff --git a/libs/llama/src/session.rs b/libs/llama/src/session.rs index 929c9762b..7c426e43e 100644 --- a/libs/llama/src/session.rs +++ b/libs/llama/src/session.rs @@ -13,7 +13,7 @@ use crate::runtime::{ create_metal_context_buffer_with_runtime, reserve_hybrid_decode_main_buffer_size, CompiledHybridDecodeMetal, HybridCacheLayout, HybridCacheShape, HybridCacheTypes, HybridDecodeBatchLayout, HybridDecodeRun, HybridDecodeSpec, HybridLayerSpec, - HybridSharedCacheTensorIds, LogitsProbeInput, + HybridSharedCacheTensorIds, LogitsProbeInput, ProbeInputKind, }; use crate::vocab::LlamaVocab; use crate::weights::LoadedGgufWeights; @@ -73,6 +73,8 @@ struct SessionGraphParams { n_tokens: usize, n_outputs: usize, attention_key_count: usize, + /// Graph takes precomputed embeddings instead of token ids (image spans). + embeddings_input: bool, } impl SessionGraphParams { @@ -81,6 +83,7 @@ impl SessionGraphParams { n_tokens, n_outputs, attention_key_count, + embeddings_input: false, } } @@ -88,6 +91,12 @@ impl SessionGraphParams { Self::new(n_tokens, 1, attention_key_count) } + fn greedy_embeddings(n_tokens: usize, attention_key_count: usize) -> Self { + let mut params = Self::greedy(n_tokens, attention_key_count); + params.embeddings_input = true; + params + } + fn token_generation(max_context: usize) -> Self { Self::greedy(1, max_context) } @@ -126,12 +135,17 @@ pub struct LlamaSession { vocab: LlamaVocab, plan: ModelExecutionPlan, spec: HybridDecodeSpec, + spec_embeddings: HybridDecodeSpec, config: LlamaSessionConfig, max_context: usize, context_extra_bytes: usize, weights: LoadedGgufWeights, graphs: SessionGraphSet, token_ids: Vec, + /// Next M-RoPE position. Tracks token count for pure text; falls behind it + /// after an image span, whose n_pos is max(tokens_w, tokens_h) rather than + /// its token count. + rope_pos_next: i64, last_run: Option, } @@ -191,6 +205,7 @@ impl LlamaSession { self.weights = weights; self.graphs = graphs; self.token_ids.clear(); + self.rope_pos_next = 0; self.last_run = None; Ok(()) } @@ -284,6 +299,14 @@ impl LlamaSession { spec.layers.truncate(max_blocks); } } + // Same graph with precomputed-embedding input for image spans; both + // specs share the cache tensors, so batches can alternate freely. + let mut spec_embeddings = spec.clone(); + spec_embeddings.input = ProbeInputKind::Embeddings { + hidden_size: model.embedding_length()?, + input_type: TensorType::F32, + }; + let cache_bytes = if let Some(template) = plan.hybrid_cache.as_ref() { HybridCacheLayout::new(template.materialize(cache_shape, cache_types))?.total_bytes } else { @@ -307,12 +330,14 @@ impl LlamaSession { vocab, plan, spec, + spec_embeddings, config, max_context: max_context_usize, context_extra_bytes, weights, graphs, token_ids: Vec::new(), + rope_pos_next: 0, last_run: None, }) } @@ -360,6 +385,23 @@ impl LlamaSession { let cache_tokens = start .checked_add(batch_size) .ok_or_else(|| LlamaError::format("overflow computing session cache length"))?; + // After an image span, rope positions run behind the linear sequence + // index — text continues from the image's pos_0 + max(w, h). + let rope_positions = if self.rope_pos_next != start as i64 { + let base = self.rope_pos_next; + let mut planes = vec![0i32; batch_size * 4]; + for i in 0..batch_size { + let p = i32::try_from(base + i as i64) + .map_err(|_| LlamaError::format("rope position does not fit in i32"))?; + planes[i] = p; + planes[batch_size + i] = p; + planes[2 * batch_size + i] = p; + // fourth component stays 0 (unused section) + } + Some(planes) + } else { + None + }; let graph_params = SessionGraphParams::greedy(batch_size, cache_tokens); self.ensure_compiled_graph(graph_params)?; let run = { @@ -374,6 +416,7 @@ impl LlamaSession { cache_tokens, &output_ids, )?; + layout.rope_positions = rope_positions; if compiled.decode().input_recurrent_state_rows.is_none() { layout.recurrent_state_rows.clear(); } @@ -381,10 +424,106 @@ impl LlamaSession { .execute_logits_only_with_layout(LogitsProbeInput::TokenIds(token_ids), &layout)? }; self.token_ids.extend_from_slice(token_ids); + self.rope_pos_next += batch_size as i64; self.last_run = Some(collapse_last_token_run(run)?); Ok(()) } + /// Append an image span: precomputed vision embeddings for a grid of + /// `tokens_w` x `tokens_h` merged tokens (row-major), as produced by + /// `VisionTower::encode`. Occupies `tokens_w * tokens_h` sequence slots + /// but advances the rope position by only `max(tokens_w, tokens_h)`, + /// with Qwen-VL 2D positions `[pos0, pos0+y, pos0+x, 0]` per token. + /// Callers surround this with the `<|vision_start|>` / `<|vision_end|>` + /// text tokens via `append_tokens`. + pub fn append_image_embeddings( + &mut self, + embeddings: &[f32], + tokens_w: usize, + tokens_h: usize, + ) -> Result<()> { + let n_tokens = tokens_w * tokens_h; + if n_tokens == 0 { + return Ok(()); + } + let hidden = usize::try_from(self.model.embedding_length()?) + .map_err(|_| LlamaError::format("embedding length does not fit in usize"))?; + if embeddings.len() != n_tokens * hidden { + return Err(LlamaError::format(format!( + "image embeddings length {} does not match {}x{} tokens x {} hidden", + embeddings.len(), + tokens_w, + tokens_h, + hidden + ))); + } + self.ensure_capacity(n_tokens)?; + let pad_token = self.vocab.token_id("<|image_pad|>").unwrap_or(-1); + let pos0 = self.rope_pos_next; + + let prefill_batch_size = self.config.prefill_batch_size.max(1); + let mut offset = 0usize; + while offset < n_tokens { + let batch_size = (n_tokens - offset).min(prefill_batch_size); + let start = self.token_ids.len(); + let positions = (start..start + batch_size) + .map(|position| { + i32::try_from(position) + .map_err(|_| LlamaError::format("token position does not fit in i32")) + }) + .collect::>>()?; + let cache_tokens = start + batch_size; + + let mut planes = vec![0i32; batch_size * 4]; + for i in 0..batch_size { + let token_index = offset + i; + let y = (token_index / tokens_w) as i64; + let x = (token_index % tokens_w) as i64; + let clamp = |v: i64| { + i32::try_from(v) + .map_err(|_| LlamaError::format("rope position does not fit in i32")) + }; + planes[i] = clamp(pos0)?; + planes[batch_size + i] = clamp(pos0 + y)?; + planes[2 * batch_size + i] = clamp(pos0 + x)?; + // fourth component stays 0 (unused section) + } + + let graph_params = SessionGraphParams::greedy_embeddings(batch_size, cache_tokens); + self.ensure_compiled_graph(graph_params)?; + let run = { + let compiled = self + .graphs + .graph_for_mut(graph_params) + .ok_or_else(|| LlamaError::format("compiled graph params were not cached"))?; + let output_ids = [i32::try_from(batch_size - 1) + .map_err(|_| LlamaError::format("session output id does not fit in i32"))?]; + let mut layout = HybridDecodeBatchLayout::from_contiguous_positions_and_outputs( + &positions, + cache_tokens, + &output_ids, + )?; + layout.rope_positions = Some(planes); + if compiled.decode().input_recurrent_state_rows.is_none() { + layout.recurrent_state_rows.clear(); + } + compiled.execute_logits_only_with_layout( + LogitsProbeInput::EmbeddingsF32 { + data: &embeddings[offset * hidden..(offset + batch_size) * hidden], + n_tokens: batch_size, + }, + &layout, + )? + }; + self.token_ids + .extend(std::iter::repeat(pad_token).take(batch_size)); + self.last_run = Some(collapse_last_token_run(run)?); + offset += batch_size; + } + self.rope_pos_next = pos0 + tokens_w.max(tokens_h) as i64; + Ok(()) + } + fn ensure_compiled_graph(&mut self, params: SessionGraphParams) -> Result<()> { if self.graphs.has_graph(params) { return Ok(()); @@ -398,9 +537,14 @@ impl LlamaSession { if attempt > 0 { self.graphs.evict_graphs_except(params); } + let spec = if params.embeddings_input { + &self.spec_embeddings + } else { + &self.spec + }; match compile_hybrid_decode_metal_with_shared_runtime_and_state_and_outputs_and_attention_key_count( &mut self.weights, - &self.spec, + spec, &self.graphs.shared_runtime, &self.graphs.shared_cache, &self.graphs.shared_main_buffer, diff --git a/libs/llama/src/vision.rs b/libs/llama/src/vision.rs new file mode 100644 index 000000000..5283857be --- /dev/null +++ b/libs/llama/src/vision.rs @@ -0,0 +1,803 @@ +// Qwen3.5 vision tower (clip arch, projector "qwen3vl_merger") on makepad-ggml metal. +// +// Loads the separate mmproj GGUF, preprocesses an RGB image (smart-resize + +// normalize + block-major patch unfold), runs the 27-block SigLIP-style ViT +// with 2D vision rope and full bidirectional flash attention, and returns the +// projected [n_tokens x proj_dim] embeddings ready for LLM prefill injection. +// +// Reference implementation: llama.cpp tools/mtmd (clip.cpp models/qwen3vl.cpp, +// mtmd-image.cpp mtmd_image_preprocessor_dyn_size). Preprocessing here is an +// exact port; the graph mirrors the reference node-for-node with flash +// attention enabled. Parity is validated by the vlm-vision-probe bin against +// dumps from tools/vlm_oracle/clip_dump. + +use std::collections::BTreeMap; + +use makepad_ggml::{ + backend::metal::{ + prepare_graph, BufferStorageMode, MetalGraphSession, MetalGraphTensorWrite, MetalRuntime, + }, + BufferUsage, Context, Graph, Op, Prec, ScaleMode, TensorId, TensorType, UnaryOp, + GGML_ROPE_TYPE_VISION, +}; + +use crate::error::{LlamaError, Result}; +use crate::gguf::{GgufArray, GgufFile, GgufValue}; +use crate::weights::{GgufWeightLayout, LoadedGgufWeights}; + +#[derive(Clone, Debug)] +pub struct VisionConfig { + pub image_size: usize, + pub patch_size: usize, + pub n_embd: usize, + pub n_ffn: usize, + pub n_layers: usize, + pub n_heads: usize, + pub proj_dim: usize, + pub n_merge: usize, + pub eps: f32, + pub image_mean: [f32; 3], + pub image_std: [f32; 3], + pub min_pixels: usize, + pub max_pixels: usize, +} + +impl VisionConfig { + pub fn from_gguf(gguf: &GgufFile) -> Result { + let arch = required_string(gguf, "general.architecture")?; + if arch != "clip" { + return Err(LlamaError::unsupported(format!( + "mmproj general.architecture must be 'clip', got '{arch}'" + ))); + } + let projector = required_string(gguf, "clip.projector_type")?; + if projector != "qwen3vl_merger" { + return Err(LlamaError::unsupported(format!( + "unsupported clip.projector_type '{projector}' (expected qwen3vl_merger)" + ))); + } + let patch_size = required_usize(gguf, "clip.vision.patch_size")?; + let n_merge = optional_usize(gguf, "clip.vision.spatial_merge_size")?.unwrap_or(2); + // reference defaults: set_limit_image_tokens(8, 4096) — one output token + // covers (patch_size * n_merge)^2 pixels + let token_pixels = (patch_size * n_merge) * (patch_size * n_merge); + let min_pixels = + optional_usize(gguf, "clip.vision.image_min_pixels")?.unwrap_or(8 * token_pixels); + let max_pixels = + optional_usize(gguf, "clip.vision.image_max_pixels")?.unwrap_or(4096 * token_pixels); + Ok(Self { + image_size: required_usize(gguf, "clip.vision.image_size")?, + patch_size, + n_embd: required_usize(gguf, "clip.vision.embedding_length")?, + n_ffn: required_usize(gguf, "clip.vision.feed_forward_length")?, + n_layers: required_usize(gguf, "clip.vision.block_count")?, + n_heads: required_usize(gguf, "clip.vision.attention.head_count")?, + proj_dim: required_usize(gguf, "clip.vision.projection_dim")?, + n_merge, + eps: optional_f32(gguf, "clip.vision.attention.layer_norm_epsilon")?.unwrap_or(1e-6), + image_mean: required_f32_3(gguf, "clip.vision.image_mean")?, + image_std: required_f32_3(gguf, "clip.vision.image_std")?, + min_pixels, + max_pixels, + }) + } + + pub fn head_dim(&self) -> usize { + self.n_embd / self.n_heads + } + + /// Pixels per merged output token edge (32 for qwen3.5). + pub fn align_size(&self) -> usize { + self.patch_size * self.n_merge + } +} + +/// A preprocessed image ready for the vision tower. +pub struct PreparedImage { + /// Resized width/height in pixels (multiples of align_size). + pub width: usize, + pub height: usize, + /// Normalized resized image, interleaved RGB f32 — layout identical to the + /// reference clip_image_f32 buffer, kept for parity testing. + pub pixels: Vec, + /// Patch matrix [n_patches rows x (3*patch*patch) cols] in block-major + /// token order; each row is (channel, ky, kx) with kx fastest, matching + /// the conv weight layout. + pub patches: Vec, + /// Grid size in patches. + pub grid_w: usize, + pub grid_h: usize, +} + +impl PreparedImage { + pub fn n_patches(&self) -> usize { + self.grid_w * self.grid_h + } + + /// Output tokens after 2x2 spatial merge. + pub fn n_tokens(&self) -> usize { + self.n_patches() / 4 + } + + pub fn tokens_w(&self) -> usize { + self.grid_w / 2 + } + + pub fn tokens_h(&self) -> usize { + self.grid_h / 2 + } +} + +/// Port of mtmd-image.cpp img_tool::calc_size_preserved_ratio. +pub fn calc_size_preserved_ratio( + width: usize, + height: usize, + align_size: usize, + min_pixels: usize, + max_pixels: usize, +) -> (usize, usize) { + let f = align_size as f32; + let round_by = |x: f32| ((x / f).round() as usize) * align_size; + let ceil_by = |x: f32| ((x / f).ceil() as usize) * align_size; + let floor_by = |x: f32| ((x / f).floor() as usize) * align_size; + + let mut w_bar = round_by(width as f32).max(align_size); + let mut h_bar = round_by(height as f32).max(align_size); + + if h_bar * w_bar > max_pixels { + let beta = ((height * width) as f32 / max_pixels as f32).sqrt(); + h_bar = floor_by(height as f32 / beta).max(align_size); + w_bar = floor_by(width as f32 / beta).max(align_size); + } else if h_bar * w_bar < min_pixels { + let beta = (min_pixels as f32 / (height * width) as f32).sqrt(); + h_bar = ceil_by(height as f32 * beta); + w_bar = ceil_by(width as f32 * beta); + } + (w_bar, h_bar) +} + +/// Port of mtmd-image.cpp img_tool::resize_bilinear with the (src-1)/target +/// ratio convention. Interior arithmetic is f64: the reference binary promotes +/// to double, and matching it keeps u8 truncation boundaries identical +/// (validated: 7 of 589824 values off by one step on the resize test image, +/// vs 1131 with f32). +fn resize_bilinear_u8(src: &[u8], sw: usize, sh: usize, tw: usize, th: usize) -> Vec { + assert!(sw >= 2 && sh >= 2); + let mut dst = vec![0u8; tw * th * 3]; + let x_ratio = (sw - 1) as f64 / tw as f64; + let y_ratio = (sh - 1) as f64 / th as f64; + for y in 0..th { + for x in 0..tw { + let px = x_ratio * x as f64; + let py = y_ratio * y as f64; + let x0 = (px as usize).min(sw - 2); + let y0 = (py as usize).min(sh - 2); + let xl = px - x0 as f64; + let yl = py - y0 as f64; + for c in 0..3 { + let s00 = src[3 * (y0 * sw + x0) + c] as f64; + let s01 = src[3 * (y0 * sw + x0 + 1) + c] as f64; + let s10 = src[3 * ((y0 + 1) * sw + x0) + c] as f64; + let s11 = src[3 * ((y0 + 1) * sw + x0 + 1) + c] as f64; + let top = s00 + (s01 - s00) * xl; + let bottom = s10 + (s11 - s10) * xl; + dst[3 * (y * tw + x) + c] = (top + (bottom - top) * yl) as u8; + } + } + } + dst +} + +/// Port of img_tool::resize with add_padding=true (the qwen path): scale to +/// fit, center, pad with black. For aspect-matching targets this reduces to a +/// plain resize. +fn resize_pad_u8(src: &[u8], sw: usize, sh: usize, tw: usize, th: usize) -> Vec { + if sw == tw && sh == th { + return src.to_vec(); + } + let scale_w = tw as f32 / sw as f32; + let scale_h = th as f32 / sh as f32; + let scale = scale_w.min(scale_h); + let new_w = ((sw as f32 * scale).ceil() as usize).min(tw); + let new_h = ((sh as f32 * scale).ceil() as usize).min(th); + let resized = resize_bilinear_u8(src, sw, sh, new_w, new_h); + if new_w == tw && new_h == th { + return resized; + } + let mut dst = vec![0u8; tw * th * 3]; + let off_x = (tw - new_w) / 2; + let off_y = (th - new_h) / 2; + for y in 0..new_h { + let src_row = &resized[3 * y * new_w..3 * (y + 1) * new_w]; + let dst_start = 3 * ((y + off_y) * tw + off_x); + dst[dst_start..dst_start + 3 * new_w].copy_from_slice(src_row); + } + dst +} + +/// Preprocess an interleaved RGB8 image for the vision tower. +pub fn preprocess_rgb8( + rgb: &[u8], + width: usize, + height: usize, + config: &VisionConfig, +) -> Result { + if rgb.len() != width * height * 3 { + return Err(LlamaError::format(format!( + "rgb buffer size {} does not match {}x{}x3", + rgb.len(), + width, + height + ))); + } + let align = config.align_size(); + let (tw, th) = calc_size_preserved_ratio( + width, + height, + align, + config.min_pixels, + config.max_pixels, + ); + let resized = resize_pad_u8(rgb, width, height, tw, th); + + // normalize: (v/255 - mean) / std, interleaved layout kept for parity checks + let mut pixels = vec![0f32; tw * th * 3]; + for i in 0..tw * th { + for c in 0..3 { + let v = resized[3 * i + c] as f32 / 255.0; + pixels[3 * i + c] = (v - config.image_mean[c]) / config.image_std[c]; + } + } + + let patch = config.patch_size; + let grid_w = tw / patch; + let grid_h = th / patch; + let patch_len = 3 * patch * patch; + let n_patches = grid_w * grid_h; + let mut patches = vec![0f32; n_patches * patch_len]; + + // block-major token order: iterate 2x2 blocks row-major, then the 4 + // patches within each block row-major — matches the qwen3vl conv output + // rearrangement, so 4 consecutive rows here form one merged output token. + // Row layout is (c, ky, kx) with kx fastest, matching the flattened + // [16,16,3,out] conv weight. + let mut row = 0usize; + for by in (0..grid_h).step_by(2) { + for bx in (0..grid_w).step_by(2) { + for dy in 0..2 { + for dx in 0..2 { + let px0 = (bx + dx) * patch; + let py0 = (by + dy) * patch; + let out = &mut patches[row * patch_len..(row + 1) * patch_len]; + let mut k = 0usize; + for c in 0..3 { + for ky in 0..patch { + let src_row = 3 * ((py0 + ky) * tw + px0); + for kx in 0..patch { + out[k] = pixels[src_row + 3 * kx + c]; + k += 1; + } + } + } + row += 1; + } + } + } + } + + Ok(PreparedImage { + width: tw, + height: th, + pixels, + patches, + grid_w, + grid_h, + }) +} + +/// M-RoPE position ids for the vision graph: 4 planes [y, x, y, x] over the +/// block-major patch sequence — port of the clip.cpp position filling. +pub fn vision_rope_positions(grid_w: usize, grid_h: usize) -> Vec { + let n = grid_w * grid_h; + let mut positions = vec![0i32; n * 4]; + let mut ptr = 0usize; + for y in (0..grid_h).step_by(2) { + for x in (0..grid_w).step_by(2) { + for dy in 0..2 { + for dx in 0..2 { + positions[ptr] = (y + dy) as i32; + positions[n + ptr] = (x + dx) as i32; + positions[2 * n + ptr] = (y + dy) as i32; + positions[3 * n + ptr] = (x + dx) as i32; + ptr += 1; + } + } + } + } + positions +} + +/// Row-major patch index for each block-major token position — used to gather +/// interpolated position embeddings into sequence order. +fn block_order_row_indices(grid_w: usize, grid_h: usize) -> Vec { + let mut idx = Vec::with_capacity(grid_w * grid_h); + for by in (0..grid_h).step_by(2) { + for bx in (0..grid_w).step_by(2) { + for dy in 0..2 { + for dx in 0..2 { + idx.push(((by + dy) * grid_w + (bx + dx)) as i32); + } + } + } + } + idx +} + +struct VisionGraph { + session: MetalGraphSession, + input_patches: TensorId, + input_positions: TensorId, + input_block_index: TensorId, + output: TensorId, + grid_w: usize, + grid_h: usize, +} + +pub struct VisionTower { + pub config: VisionConfig, + weights: LoadedGgufWeights, + runtime: MetalRuntime, + graphs: BTreeMap<(usize, usize), VisionGraph>, +} + +impl VisionTower { + /// Load the mmproj GGUF. `max_patches` bounds the activation arena that is + /// reserved up front (patches = output tokens * 4). + pub fn load(path: &str, max_patches: usize) -> Result { + let gguf = GgufFile::open(path)?; + let config = VisionConfig::from_gguf(&gguf)?; + let layout = GgufWeightLayout::from_tensors(gguf.tensors.iter().cloned())?; + let extra = Self::activation_bytes_estimate(&config, max_patches); + let weights = layout.allocate_and_load_with_extra(&gguf, extra)?; + let runtime = MetalRuntime::new().map_err(LlamaError::format)?; + Ok(Self { + config, + weights, + runtime, + graphs: BTreeMap::new(), + }) + } + + fn activation_bytes_estimate(config: &VisionConfig, max_patches: usize) -> usize { + // per patch per layer: ~18 full-width f32 nodes + 3 ffn-width nodes + // + 2 f16 casts; generous 1.5x headroom on top + let full = 4 * config.n_embd; + let ffn = 4 * config.n_ffn; + let per_patch_layer = 18 * full + 3 * ffn + config.n_embd; + let per_patch = per_patch_layer * config.n_layers + 16 * full + 8 * config.proj_dim; + (max_patches * per_patch) * 3 / 2 + (64 << 20) + } + + fn tensor(&self, name: &str) -> Result { + self.weights.require_tensor_id(name) + } + + /// Encode a preprocessed image; returns [n_tokens * proj_dim] f32. + pub fn encode(&mut self, image: &PreparedImage) -> Result> { + let key = (image.grid_w, image.grid_h); + if !self.graphs.contains_key(&key) { + let graph = self.build_graph(image.grid_w, image.grid_h)?; + self.graphs.insert(key, graph); + } + let graph = self.graphs.get(&key).unwrap(); + + let positions = vision_rope_positions(image.grid_w, image.grid_h); + let block_index = block_order_row_indices(image.grid_w, image.grid_h); + + let patches_bytes = f32s_as_bytes(&image.patches); + let positions_bytes = i32s_as_bytes(&positions); + let block_index_bytes = i32s_as_bytes(&block_index); + let writes = vec![ + MetalGraphTensorWrite { + tensor_id: graph.input_patches, + bytes: &patches_bytes, + }, + MetalGraphTensorWrite { + tensor_id: graph.input_positions, + bytes: &positions_bytes, + }, + MetalGraphTensorWrite { + tensor_id: graph.input_block_index, + bytes: &block_index_bytes, + }, + ]; + let execution = graph + .session + .execute(&self.weights.ctx, &writes, &[graph.output]) + .map_err(LlamaError::format)?; + let bytes = execution + .outputs + .get(&graph.output) + .ok_or_else(|| LlamaError::format("vision graph returned no output"))?; + Ok(bytes_to_f32s(bytes)) + } + + fn build_graph(&mut self, grid_w: usize, grid_h: usize) -> Result { + let cfg = self.config.clone(); + let n_patches = grid_w * grid_h; + let n_embd = cfg.n_embd as i64; + let n_heads = cfg.n_heads as i64; + let d_head = cfg.head_dim() as i64; + let patch_len = (3 * cfg.patch_size * cfg.patch_size) as i64; + let n = n_patches as i64; + let eps = cfg.eps; + + let w_patch0 = self.tensor("v.patch_embd.weight")?; + let w_patch1 = self.tensor("v.patch_embd.weight.1")?; + let b_patch = self.tensor("v.patch_embd.bias")?; + let pos_embd = self.tensor("v.position_embd.weight")?; + let post_ln_w = self.tensor("v.post_ln.weight")?; + let post_ln_b = self.tensor("v.post_ln.bias")?; + let mm0_w = self.tensor("mm.0.weight")?; + let mm0_b = self.tensor("mm.0.bias")?; + let mm2_w = self.tensor("mm.2.weight")?; + let mm2_b = self.tensor("mm.2.bias")?; + + let ctx = &mut self.weights.ctx; + let a = BufferUsage::Activations; + let err = LlamaError::format; + + let input_patches = ctx + .new_named_tensor( + "vision.inp_patches", + TensorType::F32, + 2, + &[patch_len, n], + a, + ) + .map_err(err)?; + let input_positions = ctx + .new_named_tensor("vision.inp_positions", TensorType::I32, 1, &[n * 4], a) + .map_err(err)?; + let input_block_index = ctx + .new_named_tensor("vision.inp_block_index", TensorType::I32, 1, &[n], a) + .map_err(err)?; + + // patchify: the two temporal conv kernels both apply to the same still + // frame; conv == matmul against the flattened [patch_len, n_embd] weight + let w0 = ctx.reshape(w_patch0, &[patch_len, n_embd]).map_err(err)?; + let w1 = ctx.reshape(w_patch1, &[patch_len, n_embd]).map_err(err)?; + let e0 = ctx.mul_mat(w0, input_patches, a).map_err(err)?; + let e1 = ctx.mul_mat(w1, input_patches, a).map_err(err)?; + let mut cur = ctx.binary_like_a(Op::Add, e0, e1, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, b_patch, a).map_err(err)?; + + // learned position embeddings, bilinearly interpolated from the native + // grid, gathered into block-major sequence order + { + let n_per_side = { + let t = ctx + .tensor(pos_embd) + .ok_or_else(|| LlamaError::format("missing position embedding tensor"))?; + (t.ne[1] as f64).sqrt() as i64 + }; + let mut pos = pos_embd; + if n_per_side != grid_w as i64 || n_per_side != grid_h as i64 { + pos = ctx + .reshape(pos, &[n_embd, n_per_side, n_per_side]) + .map_err(err)?; + pos = ctx.permute(pos, [2, 0, 1, 3]).map_err(err)?; + pos = ctx + .cont_3d(pos, n_per_side, n_per_side, n_embd) + .map_err(err)?; + pos = ctx + .upscale_ext( + pos, + grid_w as i64, + grid_h as i64, + n_embd, + 1, + ScaleMode::Bilinear, + false, + true, + a, + ) + .map_err(err)?; + pos = ctx.permute(pos, [1, 2, 0, 3]).map_err(err)?; + pos = ctx.cont_2d(pos, n_embd, n).map_err(err)?; + } + let pos_seq = ctx.get_rows(pos, input_block_index, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, pos_seq, a).map_err(err)?; + } + ctx.set_tensor_name(cur, "vision.inp_pos_emb").map_err(err)?; + + let scale = 1.0 / (d_head as f32).sqrt(); + let rope_sections = [(d_head / 4) as i32; 4]; + + for il in 0..cfg.n_layers { + let name = |suffix: &str| format!("v.blk.{il}.{suffix}"); + let ln1_w = self_tensor(&self.weights, &name("ln1.weight"))?; + let ln1_b = self_tensor(&self.weights, &name("ln1.bias"))?; + let ln2_w = self_tensor(&self.weights, &name("ln2.weight"))?; + let ln2_b = self_tensor(&self.weights, &name("ln2.bias"))?; + let qkv_w = self_tensor(&self.weights, &name("attn_qkv.weight"))?; + let qkv_b = self_tensor(&self.weights, &name("attn_qkv.bias"))?; + let out_w = self_tensor(&self.weights, &name("attn_out.weight"))?; + let out_b = self_tensor(&self.weights, &name("attn_out.bias"))?; + let up_w = self_tensor(&self.weights, &name("ffn_up.weight"))?; + let up_b = self_tensor(&self.weights, &name("ffn_up.bias"))?; + let down_w = self_tensor(&self.weights, &name("ffn_down.weight"))?; + let down_b = self_tensor(&self.weights, &name("ffn_down.bias"))?; + + let ctx = &mut self.weights.ctx; + let residual = cur; + + // ln1 (LayerNorm with weight + bias) + let mut x = ctx.norm_eps(cur, eps, a).map_err(err)?; + x = ctx.binary_like_a(Op::Mul, x, ln1_w, a).map_err(err)?; + x = ctx.binary_like_a(Op::Add, x, ln1_b, a).map_err(err)?; + + // fused qkv, split via weight/bias row slices + let (q, k, v) = { + let row_bytes_w = weight_row_bytes(ctx, qkv_w)?; + let bias_elem_bytes = 4usize; // qkv bias is f32 + let mut parts = Vec::with_capacity(3); + for part in 0..3 { + let w = ctx + .view_2d( + qkv_w, + n_embd, + n_embd, + row_bytes_w, + part * n_embd as usize * row_bytes_w, + ) + .map_err(err)?; + let b = ctx + .view_1d(qkv_b, n_embd, part * n_embd as usize * bias_elem_bytes) + .map_err(err)?; + let mut p = ctx.mul_mat(w, x, a).map_err(err)?; + p = ctx.binary_like_a(Op::Add, p, b, a).map_err(err)?; + parts.push(p); + } + (parts[0], parts[1], parts[2]) + }; + + // [n_embd, n] -> [d_head, n_heads, n], 2D vision rope on q/k + let q3 = ctx.reshape(q, &[d_head, n_heads, n]).map_err(err)?; + let k3 = ctx.reshape(k, &[d_head, n_heads, n]).map_err(err)?; + let v3 = ctx.reshape(v, &[d_head, n_heads, n]).map_err(err)?; + let q3 = ctx + .rope_multi( + q3, + input_positions, + None, + (d_head / 2) as i32, + rope_sections, + GGML_ROPE_TYPE_VISION, + 32768, + 10000.0, + 1.0, + 0.0, + 1.0, + 32.0, + 1.0, + a, + ) + .map_err(err)?; + let k3 = ctx + .rope_multi( + k3, + input_positions, + None, + (d_head / 2) as i32, + rope_sections, + GGML_ROPE_TYPE_VISION, + 32768, + 10000.0, + 1.0, + 0.0, + 1.0, + 32.0, + 1.0, + a, + ) + .map_err(err)?; + + // full bidirectional flash attention (no mask), f16 K/V + let q_p = ctx.permute(q3, [0, 2, 1, 3]).map_err(err)?; + let k_p = ctx.permute(k3, [0, 2, 1, 3]).map_err(err)?; + let v_p = ctx.permute(v3, [0, 2, 1, 3]).map_err(err)?; + let k_h = new_f16_like(ctx, k_p, &[d_head, n, n_heads, 1])?; + let k_h = ctx.cpy(k_p, k_h, a).map_err(err)?; + let v_h = new_f16_like(ctx, v_p, &[d_head, n, n_heads, 1])?; + let v_h = ctx.cpy(v_p, v_h, a).map_err(err)?; + let mut attn = ctx + .flash_attn_ext(q_p, k_h, v_h, None, scale, 0.0, 0.0, a) + .map_err(err)?; + ctx.flash_attn_ext_set_prec(attn, Prec::F32).map_err(err)?; + attn = ctx.reshape(attn, &[n_embd, n]).map_err(err)?; + + let mut o = ctx.mul_mat(out_w, attn, a).map_err(err)?; + o = ctx.binary_like_a(Op::Add, o, out_b, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, o, residual, a).map_err(err)?; + + // ln2 + ffn + let residual2 = cur; + let mut f = ctx.norm_eps(cur, eps, a).map_err(err)?; + f = ctx.binary_like_a(Op::Mul, f, ln2_w, a).map_err(err)?; + f = ctx.binary_like_a(Op::Add, f, ln2_b, a).map_err(err)?; + f = ctx.mul_mat(up_w, f, a).map_err(err)?; + f = ctx.binary_like_a(Op::Add, f, up_b, a).map_err(err)?; + f = ctx.unary(f, UnaryOp::Gelu, a).map_err(err)?; + f = ctx.mul_mat(down_w, f, a).map_err(err)?; + f = ctx.binary_like_a(Op::Add, f, down_b, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, f, residual2, a).map_err(err)?; + ctx.set_tensor_name(cur, &format!("vision.blk{il}.out")) + .map_err(err)?; + } + + let ctx = &mut self.weights.ctx; + + // post layernorm + cur = ctx.norm_eps(cur, eps, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Mul, cur, post_ln_w, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, post_ln_b, a).map_err(err)?; + + // merger: concat each 2x2 block (4 consecutive tokens) then 2-layer MLP + let merge = (cfg.n_merge * cfg.n_merge) as i64; + cur = ctx.reshape(cur, &[n_embd * merge, n / merge]).map_err(err)?; + cur = ctx.mul_mat(mm0_w, cur, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, mm0_b, a).map_err(err)?; + cur = ctx.unary(cur, UnaryOp::Gelu, a).map_err(err)?; + cur = ctx.mul_mat(mm2_w, cur, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, mm2_b, a).map_err(err)?; + ctx.set_tensor_name(cur, "vision.output").map_err(err)?; + + let mut graph = Graph::new(); + graph + .build_forward_expand(&self.weights.ctx, cur) + .map_err(err)?; + let prepared = prepare_graph(&self.weights.ctx, &graph, self.runtime.features()) + .map_err(err)?; + let session = MetalGraphSession::from_runtime( + self.runtime.clone(), + &self.weights.ctx, + &prepared, + BufferStorageMode::Shared, + BufferStorageMode::Shared, + ) + .map_err(err)?; + + Ok(VisionGraph { + session, + input_patches, + input_positions, + input_block_index, + output: cur, + grid_w, + grid_h, + }) + } +} + +fn self_tensor(weights: &LoadedGgufWeights, name: &str) -> Result { + weights.require_tensor_id(name) +} + +fn weight_row_bytes(ctx: &Context, id: TensorId) -> Result { + let t = ctx + .tensor(id) + .ok_or_else(|| LlamaError::format("missing qkv weight tensor"))?; + Ok(t.nb[1]) +} + +fn new_f16_like(ctx: &mut Context, _src: TensorId, ne: &[i64]) -> Result { + ctx.new_tensor(TensorType::F16, ne.len(), ne, BufferUsage::Activations) + .map_err(LlamaError::format) +} + +fn f32s_as_bytes(values: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(values.len() * 4); + for v in values { + bytes.extend_from_slice(&v.to_le_bytes()); + } + bytes +} + +fn i32s_as_bytes(values: &[i32]) -> Vec { + let mut bytes = Vec::with_capacity(values.len() * 4); + for v in values { + bytes.extend_from_slice(&v.to_le_bytes()); + } + bytes +} + +fn bytes_to_f32s(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +fn required_string(gguf: &GgufFile, key: &str) -> Result { + match gguf.get_value(key) { + Some(GgufValue::String(s)) => Ok(String::from_utf8_lossy(s.as_bytes()).into_owned()), + Some(_) => Err(LlamaError::format(format!("key '{key}' is not a string"))), + None => Err(LlamaError::format(format!("missing key '{key}'"))), + } +} + +fn required_usize(gguf: &GgufFile, key: &str) -> Result { + optional_usize(gguf, key)? + .ok_or_else(|| LlamaError::format(format!("missing key '{key}'"))) +} + +fn optional_usize(gguf: &GgufFile, key: &str) -> Result> { + match gguf.get_value(key) { + Some(GgufValue::Uint32(v)) => Ok(Some(*v as usize)), + Some(GgufValue::Int32(v)) => Ok(Some(*v as usize)), + Some(GgufValue::Uint64(v)) => Ok(Some(*v as usize)), + Some(_) => Err(LlamaError::format(format!("key '{key}' is not an integer"))), + None => Ok(None), + } +} + +fn optional_f32(gguf: &GgufFile, key: &str) -> Result> { + match gguf.get_value(key) { + Some(GgufValue::Float32(v)) => Ok(Some(*v)), + Some(_) => Err(LlamaError::format(format!("key '{key}' is not a float"))), + None => Ok(None), + } +} + +fn required_f32_3(gguf: &GgufFile, key: &str) -> Result<[f32; 3]> { + match gguf.get_value(key) { + Some(GgufValue::Array(GgufArray::Float32(v))) if v.len() >= 3 => Ok([v[0], v[1], v[2]]), + Some(_) => Err(LlamaError::format(format!( + "key '{key}' is not a float array of 3" + ))), + None => Err(LlamaError::format(format!("missing key '{key}'"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calc_size_matches_reference_examples() { + // 512x384 already aligned -> unchanged + assert_eq!(calc_size_preserved_ratio(512, 384, 32, 8192, 4194304), (512, 384)); + // 500x375 rounds to nearest multiple of 32 (both round up here) + assert_eq!(calc_size_preserved_ratio(500, 375, 32, 8192, 4194304), (512, 384)); + // huge image scales down under max_pixels + let (w, h) = calc_size_preserved_ratio(3840, 2160, 32, 8192, 4194304); + assert!(w * h <= 4194304); + assert_eq!(w % 32, 0); + assert_eq!(h % 32, 0); + } + + #[test] + fn block_order_covers_all_patches_once() { + let idx = block_order_row_indices(6, 4); + let mut seen = vec![false; 24]; + for &i in &idx { + assert!(!seen[i as usize]); + seen[i as usize] = true; + } + assert!(seen.iter().all(|&s| s)); + // first block: (0,0) (0,1) (1,0) (1,1) in row-major patch coords + assert_eq!(&idx[0..4], &[0, 1, 6, 7]); + } + + #[test] + fn rope_positions_follow_block_order() { + let pos = vision_rope_positions(4, 2); + let n = 8; + // token 0..3 = block at (0,0): (y,x) = (0,0) (0,1) (1,0) (1,1) + assert_eq!(&pos[0..4], &[0, 0, 1, 1]); // y plane + assert_eq!(&pos[n..n + 4], &[0, 1, 0, 1]); // x plane + assert_eq!(pos[2 * n], pos[0]); + assert_eq!(pos[3 * n], pos[n]); + } +} diff --git a/libs/llama/src/vocab.rs b/libs/llama/src/vocab.rs index 4add898e0..194db6211 100644 --- a/libs/llama/src/vocab.rs +++ b/libs/llama/src/vocab.rs @@ -651,6 +651,11 @@ impl LlamaVocab { self.token_to_id.get(piece).copied() } + /// Resolve a token piece (e.g. `<|image_pad|>`) to its id. + pub fn token_id(&self, piece: &str) -> Option { + self.lookup_token(piece) + } + fn token_score(&self, token_id: i32) -> Option { usize::try_from(token_id) .ok() diff --git a/libs/map_nav/src/search.rs b/libs/map_nav/src/search.rs index 9b46a31a9..df7743f13 100644 --- a/libs/map_nav/src/search.rs +++ b/libs/map_nav/src/search.rs @@ -435,6 +435,11 @@ pub fn category_from_osm_tags(tags: &HashMap) -> Option, ) -> f64 { - // Entity tier: what KIND of thing dominates what the name looks like. - let (tier, locality) = match category { - Category::City => (5.0, 0.10), - Category::Town => (4.6, 0.15), - Category::Airport => (4.3, 0.25), + // Entity tier + "reach": how far away this kind of thing stays + // relevant. The penalty is log2(1 + d/reach), so a zoo 3km away loses + // ~2 points while a same-named hamlet 900km away loses ~7 — the old + // locality MULTIPLIER did the opposite (punished the nearby POI harder + // than the distant settlement, which is how searching "artis" in + // Amsterdam flew to a hamlet in France). + let (tier, reach_m) = match category { + Category::City => (5.0, 50_000.0), + Category::Town => (4.6, 20_000.0), + Category::Airport => (4.3, 30_000.0), Category::Village | Category::Suburb | Category::Hamlet | Category::Neighbourhood => { - (4.0, 0.35) + (4.0, 8_000.0) } - Category::Station => (3.8, 0.5), - Category::Street => (2.0, 1.0), - Category::Address => (1.6, 1.0), - _ => (3.0, 1.0), + Category::Station => (3.8, 5_000.0), + Category::Street => (2.0, 800.0), + Category::Address => (1.6, 500.0), + _ => (3.0, 1_500.0), }; let mut score = tier * 30.0 + rank as f64 * 0.15; if via_category { @@ -1005,9 +1015,8 @@ pub fn score_search_hit( score += 55.0; } if let Some(d) = distance_m { - // log2 falloff: being 10x closer is worth a constant bonus; scaled - // by how inherently local the entity kind is. - score -= 7.0 * (1.0 + d / 30.0).log2() * locality; + // log2 falloff against the tier's reach. + score -= 7.0 * (1.0 + d / reach_m).log2(); } score } diff --git a/libs/mbtile_reader/src/lib.rs b/libs/mbtile_reader/src/lib.rs index 07b808538..df41eb928 100644 --- a/libs/mbtile_reader/src/lib.rs +++ b/libs/mbtile_reader/src/lib.rs @@ -19,7 +19,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::path::Path; mod writer; -pub use writer::{MbtilesWriter, MbtilesWriterStats}; +pub use writer::{MbtilesWriter, MbtilesWriterStats, WriterValue}; // --------------------------------------------------------------------------- // Error @@ -1075,6 +1075,150 @@ impl MbtilesReader { pub fn header(&self) -> &DbHeader { &self.header } + + /// Open any SQLite database (e.g. a GeoPackage) for generic table access. + /// Unlike [`MbtilesReader::open`] this does not require the mbtiles schema; + /// the tile-specific methods will fail on such a database, but + /// [`MbtilesReader::schema_entries`] and [`MbtilesReader::for_each_row`] + /// work on any table. + pub fn open_sqlite(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut header_buf = [0u8; 100]; + file.read_exact(&mut header_buf)?; + let header = parse_db_header(&header_buf)?; + let usable_size = header.page_size as usize - header.reserved_space as usize; + Ok(MbtilesReader { + file, + header, + usable_size, + tiles_root_page: 0, + metadata_root_page: 0, + tile_index_root_page: None, + makepad_block_rowids: false, + btree_page_cache: HashMap::new(), + btree_page_cache_order: VecDeque::new(), + }) + } + + /// All objects recorded in sqlite_master: tables, indexes, views, triggers. + pub fn schema_entries(&mut self) -> Result> { + let mut entries = Vec::new(); + self.scan_table_pages(1, &mut |reader, _rowid, local, total, overflow| { + let payload = reader.assemble_payload(local, total, overflow)?; + let record = parse_record(&payload, reader.header.text_encoding)?; + if record.len() >= 5 { + entries.push(SchemaEntry { + obj_type: record[0].as_text().unwrap_or("").to_string(), + name: record[1].as_text().unwrap_or("").to_string(), + tbl_name: record[2].as_text().unwrap_or("").to_string(), + root_page: record[3].as_integer().unwrap_or(0) as u32, + sql: record[4].as_text().unwrap_or("").to_string(), + }); + } + Ok(()) + })?; + Ok(entries) + } + + /// Walk every row of the named table, decoding each record's values. + /// A column declared INTEGER PRIMARY KEY is the rowid alias and appears as + /// [`Value::Null`] in the record; use the callback's rowid for it. + pub fn for_each_row( + &mut self, + table: &str, + mut callback: impl FnMut(i64, Vec), + ) -> Result<()> { + let root = self + .schema_entries()? + .into_iter() + .find(|e| e.obj_type == "table" && e.name == table) + .map(|e| e.root_page) + .ok_or(Error::TableNotFound("requested table"))?; + if root == 0 { + return Err(Error::TableNotFound("requested table")); + } + self.scan_table_pages(root, &mut |reader, rowid, local, total, overflow| { + let payload = reader.assemble_payload(local, total, overflow)?; + let record = parse_record(&payload, reader.header.text_encoding)?; + callback(rowid, record); + Ok(()) + }) + } + + /// Walk rows of the named table whose rowid lies in `lo..=hi`, pruning + /// b-tree subtrees outside the range — an indexed range query without SQL. + pub fn for_each_row_in_range( + &mut self, + table: &str, + lo: i64, + hi: i64, + mut callback: impl FnMut(i64, Vec), + ) -> Result<()> { + let root = self + .schema_entries()? + .into_iter() + .find(|e| e.obj_type == "table" && e.name == table) + .map(|e| e.root_page) + .ok_or(Error::TableNotFound("requested table"))?; + if root == 0 { + return Err(Error::TableNotFound("requested table")); + } + let mut page_stack = vec![root]; + while let Some(page_num) = page_stack.pop() { + let page = self.read_btree_page(page_num)?; + let header_offset = if page_num == 1 { 100 } else { 0 }; + let (page_type, cell_ptrs, rightmost_ptr) = self.cell_pointers(&page, header_offset)?; + match page_type { + PageType::TableLeaf => { + for &ptr in &cell_ptrs { + let (rowid, local, total, overflow) = + self.parse_table_leaf_cell(&page, ptr)?; + if rowid < lo || rowid > hi { + continue; + } + let payload = self.assemble_payload(local, total, overflow)?; + let record = parse_record(&payload, self.header.text_encoding)?; + callback(rowid, record); + } + } + PageType::TableInterior => { + // Interior cells hold (child, max_rowid_of_child) in + // ascending order; the rightmost pointer covers the rest. + let mut prev_max = i64::MIN; + let mut include_right = true; + for &ptr in &cell_ptrs { + let (child, key) = self.parse_table_interior_cell(&page, ptr)?; + if key >= lo && prev_max <= hi { + page_stack.push(child); + } + if key > hi { + include_right = false; + // later siblings are entirely above the range + break; + } + prev_max = key; + } + if include_right { + if let Some(right) = rightmost_ptr { + page_stack.push(right); + } + } + } + _ => {} + } + } + Ok(()) + } +} + +/// One row of sqlite_master, describing a schema object. +#[derive(Debug, Clone)] +pub struct SchemaEntry { + pub obj_type: String, + pub name: String, + pub tbl_name: String, + pub root_page: u32, + pub sql: String, } /// Quick parse of the first 3 integer columns from a record's local payload. diff --git a/libs/mbtile_reader/src/writer.rs b/libs/mbtile_reader/src/writer.rs index f6e23404d..de8c6f76f 100644 --- a/libs/mbtile_reader/src/writer.rs +++ b/libs/mbtile_reader/src/writer.rs @@ -35,6 +35,22 @@ pub struct MbtilesWriter { tiles: TableStream, tile_count: u64, tile_bytes: u64, + extra_tables: Vec, +} + +struct ExtraTable { + name: String, + sql: String, + stream: TableStream, +} + +/// Value for rows in extra (non-tiles) tables. +pub enum WriterValue<'a> { + Null, + Integer(i64), + Float(f64), + Text(&'a str), + Blob(&'a [u8]), } impl MbtilesWriter { @@ -48,9 +64,57 @@ impl MbtilesWriter { tiles, tile_count: 0, tile_bytes: 0, + extra_tables: Vec::new(), }) } + /// Declare an additional table. Rows are supplied via + /// [`MbtilesWriter::write_extra_row`] in strictly ascending rowid order + /// (per table). The CREATE TABLE statement is recorded verbatim in + /// sqlite_master, so standard SQLite tooling sees proper column names. + pub fn begin_extra_table(&mut self, name: &str, create_sql: &str) -> Result<()> { + if self.extra_tables.iter().any(|t| t.name == name) { + return Err(Error::InvalidInput(format!( + "extra table {name} already declared" + ))); + } + let stream = TableStream::new(&mut self.db)?; + self.extra_tables.push(ExtraTable { + name: name.to_string(), + sql: create_sql.to_string(), + stream, + }); + Ok(()) + } + + /// Append one row to a declared extra table. + pub fn write_extra_row( + &mut self, + table: &str, + rowid: i64, + values: &[WriterValue<'_>], + ) -> Result<()> { + let record: Vec> = values + .iter() + .map(|v| match v { + WriterValue::Null => RecordValue::Null, + WriterValue::Integer(i) => RecordValue::Integer(*i), + WriterValue::Float(f) => RecordValue::Float(*f), + WriterValue::Text(s) => RecordValue::Text(s), + WriterValue::Blob(b) => RecordValue::Blob(b), + }) + .collect(); + let payload = encode_record(&record)?; + let index = self + .extra_tables + .iter() + .position(|t| t.name == table) + .ok_or_else(|| Error::InvalidInput(format!("extra table {table} not declared")))?; + self.extra_tables[index] + .stream + .push(&mut self.db, rowid, &payload) + } + /// Set an MBTiles metadata value. Repeated keys replace the previous value. pub fn set_metadata(&mut self, name: impl Into, value: impl Into) { self.metadata.insert(name.into(), value.into()); @@ -107,7 +171,14 @@ impl MbtilesWriter { } let metadata_root = metadata_table.finish(&mut self.db)?; - self.db.write_sqlite_master(metadata_root, tiles_root)?; + let mut extra_roots = Vec::new(); + for table in std::mem::take(&mut self.extra_tables) { + let root = table.stream.finish(&mut self.db)?; + extra_roots.push((table.name, table.sql, root)); + } + + self.db + .write_sqlite_master(metadata_root, tiles_root, &extra_roots)?; self.db.finish()?; Ok(MbtilesWriterStats { @@ -181,12 +252,17 @@ impl RawDbWriter { Ok(()) } - fn write_sqlite_master(&mut self, metadata_root: u32, tiles_root: u32) -> Result<()> { + fn write_sqlite_master( + &mut self, + metadata_root: u32, + tiles_root: u32, + extra_tables: &[(String, String, u32)], + ) -> Result<()> { let metadata_sql = "CREATE TABLE metadata (name TEXT, value TEXT)"; let tiles_sql = "CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)"; - let rows = [ + let mut rows = vec![ encode_record(&[ RecordValue::Text("table"), RecordValue::Text("metadata"), @@ -202,8 +278,22 @@ impl RawDbWriter { RecordValue::Text(tiles_sql), ])?, ]; + for (name, sql, root) in extra_tables { + rows.push(encode_record(&[ + RecordValue::Text("table"), + RecordValue::Text(name), + RecordValue::Text(name), + RecordValue::Integer(i64::from(*root)), + RecordValue::Text(sql), + ])?); + } - let mut page = build_leaf_page(100, &[(1, &rows[0]), (2, &rows[1])])?; + let indexed: Vec<(i64, &[u8])> = rows + .iter() + .enumerate() + .map(|(i, r)| (i as i64 + 1, r.as_slice())) + .collect(); + let mut page = build_leaf_page(100, &indexed)?; write_database_header(&mut page, self.page_count); self.write_page(1, &page) } @@ -468,7 +558,9 @@ fn write_interior_page(db: &mut RawDbWriter, children: &[PageRef]) -> Result { + Null, Integer(i64), + Float(f64), Blob(&'a [u8]), Text(&'a str), } @@ -479,6 +571,11 @@ fn encode_record(values: &[RecordValue<'_>]) -> Result> { for value in values { let serial_type = match value { + RecordValue::Null => 0, + RecordValue::Float(value) => { + body.extend_from_slice(&value.to_be_bytes()); + 7 + } RecordValue::Integer(value) => encode_integer(*value, &mut body), RecordValue::Blob(value) => { body.extend_from_slice(value); diff --git a/libs/tesla/Cargo.toml b/libs/tesla/Cargo.toml new file mode 100644 index 000000000..d2938ce1a --- /dev/null +++ b/libs/tesla/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "makepad-tesla" +version = "0.1.0" +edition = "2021" +description = "Tesla Fleet API client on the makepad network layer" +license = "MIT OR Apache-2.0" + +[dependencies] +makepad-widgets = { path = "../../widgets" } +makepad-micro-serde = { path = "../../libs/micro_serde" } +makepad-live-id = { path = "../../libs/live_id" } diff --git a/libs/tesla/README.md b/libs/tesla/README.md new file mode 100644 index 000000000..55dcb4a13 --- /dev/null +++ b/libs/tesla/README.md @@ -0,0 +1,115 @@ +# makepad-tesla + +Event-driven Tesla Fleet API client on the makepad network layer. Purpose: poll +battery / charge state of your own car so the GPS app can do charger-aware routing. + +## One-time setup for your own car + +Tesla killed the old unofficial owner API; the official Fleet API needs a (free) +developer app registration, even for your own single car. Steps: + +### 1. Developer app + +1. Tesla account needs a verified email + multi-factor auth enabled. +2. Go to → request app access. Fill in a name and + "personal use: vehicle data for private navigation app" as purpose. +3. OAuth Grant Type: **Authorization Code and Machine-to-Machine**. +4. Allowed Origin: a domain you control (e.g. `https://n4.io/`). +5. Allowed Redirect URI: something you can read the address bar on, e.g. + `https://n4.io/tesla-callback` — the page does not need to exist, you just + copy the `?code=` out of the URL after login. +6. Scopes: enable at least `vehicle_device_data` (Vehicle Information). + `vehicle_location` if the app should also read the car's own GPS position. +7. You get a **client_id** and **client_secret**. + +### 2. Host the public key + register the partner account + +Fleet API refuses all calls until the app's domain is registered: + +```bash +# generate an EC key pair (the private key is only needed for vehicle *commands*, +# but the public half must be hosted for registration) +openssl ecparam -name prime256v1 -genkey -noout -out tesla_private.pem +openssl ec -in tesla_private.pem -pubout -out tesla_public.pem +``` + +Host `tesla_public.pem` at: + +``` +https:///.well-known/appspecific/com.tesla.3p.public-key.pem +``` + +Then register (one time, with a machine-to-machine "partner token"): + +```bash +# partner token +curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ + -d grant_type=client_credentials \ + -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \ + -d scope='openid vehicle_device_data' \ + -d audience=https://fleet-api.prd.eu.vn.cloud.tesla.com + +# register the domain (use the access_token from above) +curl -s https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/partner_accounts \ + -H "Authorization: Bearer $PARTNER_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"domain":""}' +``` + +(Use the `na` host instead of `eu` if the car is North-American.) + +### 3. Log in as yourself, get the refresh token + +Open this in a browser (fill in client_id + redirect_uri), log in with the +Tesla account that owns the car: + +``` +https://auth.tesla.com/oauth2/v3/authorize?response_type=code&client_id=&redirect_uri=&scope=openid+offline_access+vehicle_device_data+vehicle_location&state=makepad +``` + +Copy the `code=` value from the redirect URL, then exchange it: + +```bash +curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ + -d grant_type=authorization_code \ + -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \ + -d code=$CODE \ + -d redirect_uri=$REDIRECT_URI \ + -d audience=https://fleet-api.prd.eu.vn.cloud.tesla.com +``` + +The response contains `access_token` (valid 8h) and `refresh_token`. + +### 4. Credentials file + +Put the result in `tesla_credentials.json` in the repo root (it is untracked, +same pattern as `GOOGLE_API_KEY`): + +```json +{ + "client_id": "…", + "refresh_token": "…", + "region": "eu" +} +``` + +(Optionally add `"client_secret": "…"` — only needed if Tesla rejects the +token refresh without it; the library sends it when present.) + +That's all the library needs. It refreshes the access token itself and +**rewrites this file** on every refresh, because Tesla rotates refresh tokens — +don't hand-edit it afterwards, and don't reuse the same refresh token elsewhere. + +## Costs / rate limits + +Personal accounts get a small monthly usage credit (about $10). A +`vehicle_data` poll costs a fraction of a cent; polling every few minutes while +driving stays comfortably inside the free credit. Wake-ups are the expensive +call — the library never wakes the car unless explicitly asked to. + +## Library usage + +See `src/lib.rs` docs. Everything is event-driven on the makepad network layer: +you call `request_*` methods with a `&mut Cx`, and route +`Event::NetworkResponses` through `handle_event`, which yields typed +`TeslaClientAction`s. diff --git a/libs/tesla/src/client.rs b/libs/tesla/src/client.rs new file mode 100644 index 000000000..a7612ac9f --- /dev/null +++ b/libs/tesla/src/client.rs @@ -0,0 +1,533 @@ +use crate::data::*; +use makepad_micro_serde::*; +use makepad_widgets::*; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const AUTH_TOKEN_URL: &str = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token"; + +/// Refresh the access token this many seconds before it actually expires. +const TOKEN_EXPIRY_MARGIN: u64 = 120; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum TeslaRegion { + Na, + #[default] + Eu, + Cn, +} + +impl TeslaRegion { + pub fn from_str(s: &str) -> Option { + match s { + "na" => Some(Self::Na), + "eu" => Some(Self::Eu), + "cn" => Some(Self::Cn), + _ => None, + } + } + + pub fn base_url(self) -> &'static str { + match self { + Self::Na => "https://fleet-api.prd.na.vn.cloud.tesla.com", + Self::Eu => "https://fleet-api.prd.eu.vn.cloud.tesla.com", + Self::Cn => "https://fleet-api.prd.cn.vn.cloud.tesla.cn", + } + } +} + +/// The credentials file. See libs/tesla/README.md for how to obtain the values. +/// The client rewrites this file whenever Tesla rotates the refresh token, and +/// caches the short-lived access token in it across restarts. +#[derive(SerJson, DeJson, Debug, Clone)] +pub struct TeslaCredentials { + pub client_id: String, + /// Only needed if Tesla rejects refresh without it (confidential clients). + pub client_secret: Option, + pub refresh_token: String, + /// "na" | "eu" | "cn" + pub region: String, + pub access_token: Option, + /// Unix seconds after which access_token is stale. + pub access_token_expires: Option, +} + +#[derive(Debug, Clone)] +pub enum TeslaError { + Credentials(String), + Auth(String), + Http { status: u16, message: String }, + Network(String), + Parse(String), +} + +impl std::fmt::Display for TeslaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Credentials(m) => write!(f, "tesla credentials: {}", m), + Self::Auth(m) => write!(f, "tesla auth: {}", m), + Self::Http { status, message } => write!(f, "tesla http {}: {}", status, message), + Self::Network(m) => write!(f, "tesla network: {}", m), + Self::Parse(m) => write!(f, "tesla parse: {}", m), + } + } +} + +/// What the client hands back from handle_event. +#[derive(Debug, Clone)] +pub enum TeslaAction { + /// Result of request_vehicles. + Vehicles(Vec), + /// Result of request_charge_state / request_vehicle_data. + VehicleData { vin: String, data: VehicleData }, + /// Result of request_wake_up; state is e.g. "waking"/"online". + WakeUp { vin: String, state: Option }, + /// The car is asleep/offline; issue request_wake_up (costs credits) or retry later. + VehicleAsleep { vin: String }, + /// A fresh access token was obtained (and the credentials file rewritten). + TokenRefreshed, + Error(TeslaError), +} + +#[derive(Clone, Debug)] +enum ApiCall { + Vehicles, + VehicleData { vin: String, endpoints: String }, + WakeUp { vin: String }, +} + +impl ApiCall { + fn vin(&self) -> Option<&str> { + match self { + Self::Vehicles => None, + Self::VehicleData { vin, .. } | Self::WakeUp { vin } => Some(vin), + } + } +} + +#[derive(Clone, Debug)] +struct PendingCall { + call: ApiCall, + auth_retried: bool, +} + +/// Event-driven Tesla Fleet API client. +/// +/// Call the request_* methods from anywhere you have a Cx, then route every +/// event through handle_event and act on the returned TeslaActions. +/// Requests issued while the access token is stale are queued behind an +/// automatic token refresh. +pub struct TeslaClient { + creds_path: PathBuf, + creds: TeslaCredentials, + region: TeslaRegion, + refresh_id: Option, + queued: Vec, + in_flight: HashMap, +} + +impl TeslaClient { + /// Loads credentials from a JSON file (see README.md for the schema). + pub fn load(creds_path: impl Into) -> Result { + let creds_path = creds_path.into(); + let text = std::fs::read_to_string(&creds_path).map_err(|e| { + TeslaError::Credentials(format!("cannot read {}: {}", creds_path.display(), e)) + })?; + let creds = TeslaCredentials::deserialize_json_lenient(&text) + .map_err(|e| TeslaError::Credentials(format!("{}: {:?}", creds_path.display(), e)))?; + let region = TeslaRegion::from_str(&creds.region).ok_or_else(|| { + TeslaError::Credentials(format!("region must be na/eu/cn, got '{}'", creds.region)) + })?; + Ok(Self { + creds_path, + creds, + region, + refresh_id: None, + queued: Vec::new(), + in_flight: HashMap::new(), + }) + } + + pub fn region(&self) -> TeslaRegion { + self.region + } + + pub fn has_in_flight(&self) -> bool { + !self.in_flight.is_empty() || self.refresh_id.is_some() || !self.queued.is_empty() + } + + /// GET /api/1/vehicles — list vehicles on the account (vin, name, awake state). + pub fn request_vehicles(&mut self, cx: &mut Cx) { + self.submit(cx, ApiCall::Vehicles); + } + + /// Battery/charging status only — the cheapest useful poll for routing. + pub fn request_charge_state(&mut self, cx: &mut Cx, vin: &str) { + self.request_vehicle_data(cx, vin, &[VehicleDataEndpoint::ChargeState]); + } + + /// Charging status plus the car's own GPS position (needs the + /// vehicle_location scope on the developer app). + pub fn request_charge_and_location(&mut self, cx: &mut Cx, vin: &str) { + self.request_vehicle_data( + cx, + vin, + &[VehicleDataEndpoint::ChargeState, VehicleDataEndpoint::LocationData], + ); + } + + /// GET /api/1/vehicles/{vin}/vehicle_data with an explicit endpoint set. + pub fn request_vehicle_data( + &mut self, + cx: &mut Cx, + vin: &str, + endpoints: &[VehicleDataEndpoint], + ) { + let endpoints = endpoints + .iter() + .map(|e| e.as_str()) + .collect::>() + .join("%3B"); + self.submit(cx, ApiCall::VehicleData { vin: vin.to_string(), endpoints }); + } + + /// POST /api/1/vehicles/{vin}/wake_up. Costs usage credits; the car takes + /// ~10-30s to come online, poll request_charge_state afterwards. + pub fn request_wake_up(&mut self, cx: &mut Cx, vin: &str) { + self.submit(cx, ApiCall::WakeUp { vin: vin.to_string() }); + } + + /// Route all events through this; returns domain actions for the app. + pub fn handle_event(&mut self, cx: &mut Cx, event: &Event) -> Vec { + let mut out = Vec::new(); + let Event::NetworkResponses(responses) = event else { + return out; + }; + for response in responses { + match response { + NetworkResponse::HttpResponse { request_id, response } => { + if Some(*request_id) == self.refresh_id { + self.refresh_id = None; + self.handle_token_response(cx, response, &mut out); + } else if let Some(pending) = self.in_flight.remove(request_id) { + self.handle_api_response(cx, pending, response, &mut out); + } + } + NetworkResponse::HttpError { request_id, error } => { + if Some(*request_id) == self.refresh_id { + self.refresh_id = None; + self.fail_queue(&mut out, TeslaError::Network(error.message.clone())); + } else if self.in_flight.remove(request_id).is_some() { + out.push(TeslaAction::Error(TeslaError::Network(error.message.clone()))); + } + } + _ => {} + } + } + out + } + + // === internals === + + fn submit(&mut self, cx: &mut Cx, call: ApiCall) { + self.submit_pending(cx, PendingCall { call, auth_retried: false }); + } + + fn submit_pending(&mut self, cx: &mut Cx, pending: PendingCall) { + if self.token_is_fresh() { + self.send_api_call(cx, pending); + } else { + self.queued.push(pending); + self.start_token_refresh(cx); + } + } + + fn token_is_fresh(&self) -> bool { + let Some(expires) = self.creds.access_token_expires else { + return false; + }; + self.creds.access_token.is_some() && unix_now() + TOKEN_EXPIRY_MARGIN < expires + } + + fn start_token_refresh(&mut self, cx: &mut Cx) { + if self.refresh_id.is_some() { + return; + } + let mut body = format!( + "grant_type=refresh_token&client_id={}&refresh_token={}", + form_urlencode(&self.creds.client_id), + form_urlencode(&self.creds.refresh_token) + ); + if let Some(secret) = &self.creds.client_secret { + body.push_str("&client_secret="); + body.push_str(&form_urlencode(secret)); + } + let mut request = HttpRequest::new(AUTH_TOKEN_URL.to_string(), HttpMethod::POST); + request.set_header( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ); + request.set_string_body(body); + let request_id = LiveId::unique(); + self.refresh_id = Some(request_id); + cx.http_request(request_id, request); + } + + fn send_api_call(&mut self, cx: &mut Cx, pending: PendingCall) { + let base = self.region.base_url(); + let (url, method) = match &pending.call { + ApiCall::Vehicles => (format!("{}/api/1/vehicles", base), HttpMethod::GET), + ApiCall::VehicleData { vin, endpoints } => ( + format!("{}/api/1/vehicles/{}/vehicle_data?endpoints={}", base, vin, endpoints), + HttpMethod::GET, + ), + ApiCall::WakeUp { vin } => { + (format!("{}/api/1/vehicles/{}/wake_up", base, vin), HttpMethod::POST) + } + }; + let mut request = HttpRequest::new(url, method); + request.set_header( + "Authorization".to_string(), + format!("Bearer {}", self.creds.access_token.as_deref().unwrap_or("")), + ); + request.set_header("Accept".to_string(), "application/json".to_string()); + let request_id = LiveId::unique(); + self.in_flight.insert(request_id, pending); + cx.http_request(request_id, request); + } + + fn handle_token_response( + &mut self, + cx: &mut Cx, + response: &HttpResponse, + out: &mut Vec, + ) { + let body = response.get_string_body().unwrap_or_default(); + if response.status_code != 200 { + self.fail_queue( + out, + TeslaError::Auth(format!( + "token refresh failed, http {}: {}", + response.status_code, + error_excerpt(&body) + )), + ); + return; + } + let token = match TokenResponse::deserialize_json_lenient(&body) { + Ok(t) => t, + Err(e) => { + self.fail_queue(out, TeslaError::Parse(format!("token response: {:?}", e))); + return; + } + }; + let Some(access_token) = token.access_token else { + self.fail_queue( + out, + TeslaError::Auth(token.error_description.or(token.error).unwrap_or_else(|| { + "token response missing access_token".to_string() + })), + ); + return; + }; + self.creds.access_token = Some(access_token); + self.creds.access_token_expires = Some(unix_now() + token.expires_in.unwrap_or(28800)); + // Tesla rotates refresh tokens: persist the new one or lose access. + if let Some(refresh_token) = token.refresh_token { + self.creds.refresh_token = refresh_token; + } + if let Err(e) = std::fs::write(&self.creds_path, self.creds.serialize_json()) { + out.push(TeslaAction::Error(TeslaError::Credentials(format!( + "cannot rewrite {}: {} — the rotated refresh token only lives in memory now", + self.creds_path.display(), + e + )))); + } + out.push(TeslaAction::TokenRefreshed); + for pending in std::mem::take(&mut self.queued) { + self.send_api_call(cx, pending); + } + } + + fn handle_api_response( + &mut self, + cx: &mut Cx, + pending: PendingCall, + response: &HttpResponse, + out: &mut Vec, + ) { + let body = response.get_string_body().unwrap_or_default(); + match response.status_code { + 200 => self.parse_api_body(pending, &body, out), + 401 if !pending.auth_retried => { + // Stale/revoked access token: force a refresh and retry once. + self.creds.access_token = None; + self.creds.access_token_expires = None; + self.submit_pending(cx, PendingCall { auth_retried: true, ..pending }); + } + 408 => { + if let Some(vin) = pending.call.vin() { + out.push(TeslaAction::VehicleAsleep { vin: vin.to_string() }); + } else { + out.push(TeslaAction::Error(TeslaError::Http { + status: 408, + message: error_excerpt(&body), + })); + } + } + status => out.push(TeslaAction::Error(TeslaError::Http { + status, + message: error_excerpt(&body), + })), + } + } + + fn parse_api_body(&mut self, pending: PendingCall, body: &str, out: &mut Vec) { + match &pending.call { + ApiCall::Vehicles => match VehiclesResponse::deserialize_json_lenient(body) { + Ok(parsed) => match parsed.response { + Some(vehicles) => out.push(TeslaAction::Vehicles(vehicles)), + None => out.push(TeslaAction::Error(TeslaError::Http { + status: 200, + message: parsed.error.unwrap_or_else(|| "empty vehicle list response".to_string()), + })), + }, + Err(e) => out.push(TeslaAction::Error(TeslaError::Parse(format!("vehicles: {:?}", e)))), + }, + ApiCall::VehicleData { vin, .. } => { + match VehicleDataResponse::deserialize_json_lenient(body) { + Ok(parsed) => match parsed.response { + Some(data) => { + let vin = data.vin.clone().unwrap_or_else(|| vin.clone()); + out.push(TeslaAction::VehicleData { vin, data }); + } + None => out.push(TeslaAction::Error(TeslaError::Http { + status: 200, + message: parsed + .error + .unwrap_or_else(|| "empty vehicle_data response".to_string()), + })), + }, + Err(e) => out.push(TeslaAction::Error(TeslaError::Parse(format!( + "vehicle_data: {:?}", + e + )))), + } + } + ApiCall::WakeUp { vin } => match WakeUpResponse::deserialize_json_lenient(body) { + Ok(parsed) => out.push(TeslaAction::WakeUp { + vin: vin.clone(), + state: parsed.response.and_then(|v| v.state), + }), + Err(e) => out.push(TeslaAction::Error(TeslaError::Parse(format!("wake_up: {:?}", e)))), + }, + } + } + + fn fail_queue(&mut self, out: &mut Vec, error: TeslaError) { + self.queued.clear(); + out.push(TeslaAction::Error(error)); + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn form_urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +fn error_excerpt(body: &str) -> String { + if let Ok(parsed) = ErrorBody::deserialize_json_lenient(body) { + if let Some(error) = parsed.error { + return match parsed.error_description { + Some(desc) if !desc.is_empty() => format!("{}: {}", error, desc), + _ => error, + }; + } + } + let mut excerpt: String = body.chars().take(200).collect(); + if excerpt.is_empty() { + excerpt.push_str("(empty body)"); + } + excerpt +} + +/// Loads credentials, looking upward from the working directory so apps run +/// from example subdirectories still find the repo-root file. +pub fn load_credentials_search(file_name: &str) -> Result { + let mut dir = std::env::current_dir() + .map_err(|e| TeslaError::Credentials(format!("current_dir: {}", e)))?; + loop { + let candidate = dir.join(file_name); + if candidate.is_file() { + return TeslaClient::load(candidate); + } + if !dir.pop() { + return Err(TeslaError::Credentials(format!( + "{} not found in working directory or any parent — see libs/tesla/README.md", + file_name + ))); + } + } +} + +impl TeslaClient { + /// Convenience: loads `tesla_credentials.json` from the working directory + /// or any parent (repo root when running via cargo). + pub fn load_default() -> Result { + load_credentials_search("tesla_credentials.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_urlencode_escapes() { + assert_eq!(form_urlencode("abc-XYZ_0.~"), "abc-XYZ_0.~"); + assert_eq!(form_urlencode("a b+c/d="), "a%20b%2Bc%2Fd%3D"); + } + + #[test] + fn credentials_roundtrip() { + let creds = TeslaCredentials { + client_id: "abcd-1234".to_string(), + client_secret: None, + refresh_token: "NA_deadbeef".to_string(), + region: "eu".to_string(), + access_token: None, + access_token_expires: None, + }; + let json = creds.serialize_json(); + let back = TeslaCredentials::deserialize_json_lenient(&json).unwrap(); + assert_eq!(back.client_id, creds.client_id); + assert_eq!(back.refresh_token, creds.refresh_token); + assert_eq!(back.region, "eu"); + assert!(back.access_token.is_none()); + } + + #[test] + fn token_response_parse() { + let json = r#"{"access_token":"at","refresh_token":"rt","id_token":"x","expires_in":28800,"token_type":"Bearer"}"#; + let t = TokenResponse::deserialize_json_lenient(json).unwrap(); + assert_eq!(t.access_token.as_deref(), Some("at")); + assert_eq!(t.refresh_token.as_deref(), Some("rt")); + assert_eq!(t.expires_in, Some(28800)); + } +} diff --git a/libs/tesla/src/data.rs b/libs/tesla/src/data.rs new file mode 100644 index 000000000..c15eeb931 --- /dev/null +++ b/libs/tesla/src/data.rs @@ -0,0 +1,230 @@ +use makepad_micro_serde::*; + +pub const MILES_TO_KM: f64 = 1.609344; + +/// Subset of the Fleet API `vehicle_data` endpoints query values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VehicleDataEndpoint { + ChargeState, + ClimateState, + DriveState, + LocationData, + VehicleState, + VehicleConfig, + GuiSettings, + ChargeScheduleData, +} + +impl VehicleDataEndpoint { + pub fn as_str(self) -> &'static str { + match self { + Self::ChargeState => "charge_state", + Self::ClimateState => "climate_state", + Self::DriveState => "drive_state", + Self::LocationData => "location_data", + Self::VehicleState => "vehicle_state", + Self::VehicleConfig => "vehicle_config", + Self::GuiSettings => "gui_settings", + Self::ChargeScheduleData => "charge_schedule_data", + } + } +} + +#[derive(DeJson, Debug, Clone)] +pub struct VehiclesResponse { + pub response: Option>, + pub error: Option, + pub error_description: Option, +} + +#[derive(DeJson, Debug, Clone)] +pub struct Vehicle { + pub vin: Option, + pub display_name: Option, + /// "online" | "asleep" | "offline" + pub state: Option, + pub in_service: Option, +} + +impl Vehicle { + pub fn is_online(&self) -> bool { + self.state.as_deref() == Some("online") + } +} + +#[derive(DeJson, Debug, Clone)] +pub struct VehicleDataResponse { + pub response: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(DeJson, Debug, Clone)] +pub struct VehicleData { + pub vin: Option, + pub state: Option, + pub charge_state: Option, + pub drive_state: Option, +} + +/// The `charge_state` block of `vehicle_data`. Everything routing cares about. +/// All fields optional: Tesla adds/removes fields between firmware versions. +#[derive(DeJson, Debug, Clone, Default)] +pub struct ChargeState { + /// Displayed percent 0-100. + pub battery_level: Option, + /// Percent corrected for cold battery; use this for range planning. + pub usable_battery_level: Option, + /// Rated range in miles. + pub battery_range: Option, + /// Range estimated from recent consumption, miles. + pub est_battery_range: Option, + /// "Charging" | "Complete" | "Disconnected" | "Stopped" | "NoPower" | "Starting" + pub charging_state: Option, + /// Charge limit percent. + pub charge_limit_soc: Option, + /// mi of range added per hour while charging. + pub charge_rate: Option, + /// Current charging power in kW. + pub charger_power: Option, + pub charger_voltage: Option, + pub charger_actual_current: Option, + pub minutes_to_full_charge: Option, + /// Hours, fractional. + pub time_to_full_charge: Option, + pub fast_charger_present: Option, + pub fast_charger_type: Option, + /// Cable type when plugged in, e.g. "IEC" / "SAE"; "" when not. + pub conn_charge_cable: Option, + /// kWh added this session. + pub charge_energy_added: Option, + pub battery_heater_on: Option, + /// Milliseconds since epoch, set by the car. + pub timestamp: Option, +} + +impl ChargeState { + pub fn battery_range_km(&self) -> Option { + self.battery_range.map(|mi| mi * MILES_TO_KM) + } + + pub fn est_battery_range_km(&self) -> Option { + self.est_battery_range.map(|mi| mi * MILES_TO_KM) + } + + pub fn is_charging(&self) -> bool { + self.charging_state.as_deref() == Some("Charging") + } + + pub fn is_plugged_in(&self) -> bool { + matches!( + self.charging_state.as_deref(), + Some("Charging") | Some("Complete") | Some("Stopped") | Some("NoPower") | Some("Starting") + ) + } +} + +/// Only populated with lat/long when `location_data` is in the requested endpoints +/// (plain `drive_state` no longer carries the position). +#[derive(DeJson, Debug, Clone, Default)] +pub struct DriveState { + pub latitude: Option, + pub longitude: Option, + pub heading: Option, + /// mph; null when parked. + pub speed: Option, + /// kW; negative while regen/charging. + pub power: Option, + pub shift_state: Option, + pub timestamp: Option, +} + +#[derive(DeJson, Debug, Clone)] +pub struct WakeUpResponse { + pub response: Option, + pub error: Option, + pub error_description: Option, +} + +/// Response of the oauth2 token endpoint (refresh flow). +#[derive(DeJson, Debug, Clone)] +pub struct TokenResponse { + pub access_token: Option, + pub refresh_token: Option, + /// Seconds. + pub expires_in: Option, + pub error: Option, + pub error_description: Option, +} + +/// Generic Fleet API error body, for calls that failed with a non-2xx status. +#[derive(DeJson, Debug, Clone)] +pub struct ErrorBody { + pub error: Option, + pub error_description: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_charge_state_ignores_unknown_fields() { + let json = r#"{ + "response": { + "vin": "LRW3E7EK4NC000000", + "state": "online", + "charge_state": { + "battery_level": 72, + "usable_battery_level": 70, + "battery_range": 224.47, + "est_battery_range": 171.24, + "charging_state": "Charging", + "charge_limit_soc": 80, + "charge_rate": 27.1, + "charger_power": 11, + "charger_voltage": 229, + "charger_actual_current": 16, + "minutes_to_full_charge": 42, + "time_to_full_charge": 0.7, + "fast_charger_present": false, + "fast_charger_type": "", + "conn_charge_cable": "IEC", + "charge_energy_added": 12.3, + "battery_heater_on": false, + "charge_port_door_open": true, + "scheduled_charging_mode": "Off", + "timestamp": 1604977209418 + }, + "drive_state": { + "latitude": 52.379189, + "longitude": 4.899431, + "heading": 194, + "speed": null, + "power": -11, + "shift_state": null, + "timestamp": 1604977209418 + } + } + }"#; + let parsed = VehicleDataResponse::deserialize_json_lenient(json).unwrap(); + let data = parsed.response.unwrap(); + let charge = data.charge_state.unwrap(); + assert_eq!(charge.battery_level, Some(72)); + assert_eq!(charge.usable_battery_level, Some(70)); + assert!(charge.is_charging()); + assert!(charge.is_plugged_in()); + assert!((charge.battery_range_km().unwrap() - 361.25).abs() < 0.1); + let drive = data.drive_state.unwrap(); + assert!((drive.latitude.unwrap() - 52.379189).abs() < 1e-9); + assert_eq!(drive.speed, None); + } + + #[test] + fn parse_error_body() { + let json = r#"{"response":null,"error":"vehicle unavailable: vehicle is offline or asleep","error_description":""}"#; + let parsed = VehicleDataResponse::deserialize_json_lenient(json).unwrap(); + assert!(parsed.response.is_none()); + assert!(parsed.error.unwrap().contains("unavailable")); + } +} diff --git a/libs/tesla/src/lib.rs b/libs/tesla/src/lib.rs new file mode 100644 index 000000000..2ee18290a --- /dev/null +++ b/libs/tesla/src/lib.rs @@ -0,0 +1,48 @@ +//! Tesla Fleet API client for makepad apps, built on the makepad network +//! layer (Cx::http_request / Event::NetworkResponses). Data-oriented: made to +//! poll battery + charge status of your own car for charger-aware routing. +//! +//! Credentials setup (one-time, per car owner): see libs/tesla/README.md. +//! +//! ```ignore +//! // in your App +//! #[rust] tesla: Option, +//! +//! // startup +//! match TeslaClient::load_default() { +//! Ok(client) => self.tesla = Some(client), +//! Err(e) => log!("{}", e), +//! } +//! // kick off a poll (auto-refreshes the oauth token as needed) +//! if let Some(tesla) = &mut self.tesla { +//! tesla.request_charge_state(cx, "LRW3E7EK4NC000000"); +//! } +//! +//! // in AppMain::handle_event +//! if let Some(tesla) = &mut self.tesla { +//! for action in tesla.handle_event(cx, event) { +//! match action { +//! TeslaAction::VehicleData { vin, data } => { +//! if let Some(charge) = &data.charge_state { +//! log!("{}: {}% / {} km", vin, +//! charge.usable_battery_level.unwrap_or(0), +//! charge.battery_range_km().unwrap_or(0.0) as i64); +//! } +//! } +//! TeslaAction::VehicleAsleep { vin } => { /* wake or retry later */ } +//! TeslaAction::Error(e) => log!("{}", e), +//! _ => {} +//! } +//! } +//! } +//! ``` + +pub mod client; +pub mod data; + +pub use client::{ + TeslaAction, TeslaClient, TeslaCredentials, TeslaError, TeslaRegion, AUTH_TOKEN_URL, +}; +pub use data::{ + ChargeState, DriveState, Vehicle, VehicleData, VehicleDataEndpoint, MILES_TO_KM, +}; diff --git a/platform/src/live_reload.rs b/platform/src/live_reload.rs index a7481b023..200f4f6b6 100644 --- a/platform/src/live_reload.rs +++ b/platform/src/live_reload.rs @@ -289,14 +289,22 @@ fn handle_cx_live_edit_files(cx: &mut Cx) -> bool { } *cx.script_data.live_reload.script_mod_overrides.borrow_mut() = next_overrides; + // Name the files: a PERSISTENT override at every cold launch means some + // file permanently differs from its compiled-in copy — that's a build + // staleness bug to chase, not a feature. + let override_files: Vec = cx + .script_data + .live_reload + .script_mod_overrides + .borrow() + .keys() + .map(|key| format!("{:?}", key)) + .collect(); crate::log!( - "hot reload applied {} override(s) from {} file change(s)", - cx.script_data - .live_reload - .script_mod_overrides - .borrow() - .len(), - processed_files + "hot reload applied {} override(s) from {} file change(s): {}", + override_files.len(), + processed_files, + override_files.join(", ") ); true } diff --git a/platform/src/os/windows/wasapi.rs b/platform/src/os/windows/wasapi.rs index 544e3015e..485bd71e3 100644 --- a/platform/src/os/windows/wasapi.rs +++ b/platform/src/os/windows/wasapi.rs @@ -130,6 +130,12 @@ impl WasapiAccess { Self::enumerate_loopback_devices(&enumerator, &mut out); self.descs = out; } + // Match Linux/macOS: once open fails, mark the device so default_* skips it + // and apps stop retrying forever via change_signal → use_audio_outputs. + let failed = self.failed_devices.lock().unwrap(); + for d in &mut self.descs { + d.has_failed = failed.contains(&d.device_id); + } self.descs.clone() } @@ -143,12 +149,14 @@ impl WasapiAccess { } }); // create the new ones + let failed = self.failed_devices.lock().unwrap(); let mut new = Vec::new(); for (index, device_id) in devices.iter().enumerate() { if audio_inputs .iter() .find(|v| v.device_id == *device_id) .is_none() + && !failed.contains(device_id) { let is_loopback = self.is_loopback_device(*device_id); let channel_count = self @@ -173,6 +181,7 @@ impl WasapiAccess { std::thread::spawn(move || { let _mmcss_handle = elevate_audio_thread_priority(); if let Ok(mut wasapi) = WasapiLoopback::new(device_id, channel_count) { + let sample_rate = wasapi.base.sample_rate; audio_inputs.lock().unwrap().push(wasapi.get_ref()); while let Ok(buffer) = wasapi.wait_for_buffer() { // Use try_lock to avoid blocking the audio thread @@ -192,7 +201,7 @@ impl WasapiAccess { AudioInfo { device_id, time: None, - sample_rate: 48000.0, + sample_rate, }, &buffer, ); @@ -213,6 +222,7 @@ impl WasapiAccess { std::thread::spawn(move || { let _mmcss_handle = elevate_audio_thread_priority(); if let Ok(mut wasapi) = WasapiInput::new(device_id, channel_count) { + let sample_rate = wasapi.base.sample_rate; audio_inputs.lock().unwrap().push(wasapi.base.get_ref()); while let Ok(buffer) = wasapi.wait_for_buffer() { // Use try_lock to avoid blocking the audio thread @@ -232,7 +242,7 @@ impl WasapiAccess { AudioInfo { device_id, time: None, - sample_rate: 48000.0, + sample_rate, }, &buffer, ); @@ -262,12 +272,14 @@ impl WasapiAccess { } }); // create the new ones + let failed = self.failed_devices.lock().unwrap(); let mut new = Vec::new(); for (index, device_id) in devices.iter().enumerate() { if audio_outputs .iter() .find(|v| v.device_id == *device_id) .is_none() + && !failed.contains(device_id) { let channel_count = self .descs @@ -289,6 +301,7 @@ impl WasapiAccess { std::thread::spawn(move || { let _mmcss_handle = elevate_audio_thread_priority(); if let Ok(mut wasapi) = WasapiOutput::new(device_id, channel_count) { + let sample_rate = wasapi.base.sample_rate; audio_outputs.lock().unwrap().push(wasapi.base.get_ref()); while let Ok(mut buffer) = wasapi.wait_for_buffer() { // Use try_lock to avoid blocking the audio thread @@ -308,7 +321,7 @@ impl WasapiAccess { AudioInfo { device_id, time: None, - sample_rate: 48000.0, + sample_rate, }, &mut buffer.audio_buffer, ); @@ -532,6 +545,7 @@ struct WasapiBase { event: HANDLE, client: IAudioClient, channel_count: usize, + sample_rate: f64, audio_buffer: Option, } @@ -554,59 +568,104 @@ impl WasapiBase { pub fn new(device_id: AudioDeviceId, channel_count: usize) -> Result { unsafe { let channel_count = channel_count.min(2); - CoInitializeEx(None, COINIT_APARTMENTTHREADED).unwrap(); + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - let device = WasapiAccess::find_device_by_id(device_id).unwrap(); - let client3: IAudioClient3 = if let Ok(client) = device.Activate(CLSCTX_ALL, None) { - client - } else { - return Err(()); + let device = WasapiAccess::find_device_by_id(device_id).ok_or(())?; + + // Shared-mode streams must match (or autoconvert toward) the mix format. + // Hardcoding 48000 fails on devices whose engine runs at 44100/96000/etc. + let sample_rate = { + let probe: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|_| ())?; + let mix = probe.GetMixFormat().map_err(|_| ())?; + let rate = (*mix).nSamplesPerSec as usize; + crate::windows::Win32::System::Com::CoTaskMemFree(Some( + mix as *const _ as *const _, + )); + if rate == 0 { + 48000 + } else { + rate + } }; + let wave_format = + WasapiAccess::new_float_waveformatextensible(sample_rate, channel_count); + let wave_ptr = &wave_format as *const _ + as *const crate::windows::Win32::Media::Audio::WAVEFORMATEX; - let wave_format = WasapiAccess::new_float_waveformatextensible(48000, channel_count); - - let mut default_period_frames = 0u32; - let mut fundamental_period_frames = 0u32; - let mut min_period_frames = 0u32; - let mut max_period_frames = 0u32; - if client3 - .GetSharedModeEnginePeriod( - &wave_format as *const _ - as *const crate::windows::Win32::Media::Audio::WAVEFORMATEX, - &mut default_period_frames, - &mut fundamental_period_frames, - &mut min_period_frames, - &mut max_period_frames, - ) - .is_err() - { - return Err(()); + // Prefer IAudioClient3 low-latency shared stream when available. + if let Ok(client3) = device.Activate::(CLSCTX_ALL, None) { + let mut default_period_frames = 0u32; + let mut fundamental_period_frames = 0u32; + let mut min_period_frames = 0u32; + let mut max_period_frames = 0u32; + if client3 + .GetSharedModeEnginePeriod( + wave_ptr, + &mut default_period_frames, + &mut fundamental_period_frames, + &mut min_period_frames, + &mut max_period_frames, + ) + .is_ok() + && client3 + .InitializeSharedAudioStream( + AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + default_period_frames, + wave_ptr, + None, + ) + .is_ok() + { + let event = CreateEventA(None, false, false, None).map_err(|_| ())?; + client3.SetEventHandle(event).map_err(|_| ())?; + client3.Start().map_err(|_| ())?; + let client: IAudioClient = client3.cast().map_err(|_| ())?; + return Ok(Self { + device_id, + frames: default_period_frames.max(1), + device, + channel_count, + sample_rate: sample_rate as f64, + audio_buffer: Some(Default::default()), + event, + client, + }); + } } - if client3 - .InitializeSharedAudioStream( - AUDCLNT_STREAMFLAGS_EVENTCALLBACK, - default_period_frames, - &wave_format as *const _ - as *const crate::windows::Win32::Media::Audio::WAVEFORMATEX, + + // Fallback: classic shared-mode client with PCM autoconvert. + let client: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|_| ())?; + let mut def_period = 0i64; + let mut min_period = 0i64; + client + .GetDevicePeriod(Some(&mut def_period), Some(&mut min_period)) + .map_err(|_| ())?; + if client + .Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK + | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM + | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, + def_period, + 0, + wave_ptr, None, ) .is_err() { return Err(()); } - - let event = CreateEventA(None, false, false, None).unwrap(); - client3.SetEventHandle(event).unwrap(); - client3.Start().unwrap(); - - // Cast IAudioClient3 to IAudioClient for storage - let client: IAudioClient = client3.cast().unwrap(); - + let event = CreateEventA(None, false, false, None).map_err(|_| ())?; + client.SetEventHandle(event).map_err(|_| ())?; + client.Start().map_err(|_| ())?; + let frames = + (((def_period as f64 / 10_000_000.0) * sample_rate as f64) as u32).max(1); Ok(Self { device_id, - frames: default_period_frames, + frames, device, channel_count, + sample_rate: sample_rate as f64, audio_buffer: Some(Default::default()), event, client, @@ -616,7 +675,7 @@ impl WasapiBase { pub fn new_loopback(device_id: AudioDeviceId, channel_count: usize) -> Result { unsafe { - CoInitializeEx(None, COINIT_APARTMENTTHREADED).unwrap(); + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); let channel_count = channel_count.min(2); // Find the output device that corresponds to this loopback device let device = WasapiAccess::find_loopback_device_by_id(device_id).ok_or(())?; @@ -626,17 +685,32 @@ impl WasapiBase { let mut min_period = 0i64; client .GetDevicePeriod(Some(&mut def_period), Some(&mut min_period)) - .unwrap(); + .map_err(|_| ())?; // Force at least 20ms buffer for loopback if def_period < 200_000 { def_period = 200_000; } - // Calculate frames from period (100-nanosecond units to frames at 48kHz) - let frames = ((def_period as f64 / 10_000_000.0) * 48000.0) as u32; + let sample_rate = { + let mix = client.GetMixFormat().map_err(|_| ())?; + let rate = (*mix).nSamplesPerSec as usize; + crate::windows::Win32::System::Com::CoTaskMemFree(Some( + mix as *const _ as *const _, + )); + if rate == 0 { + 48000 + } else { + rate + } + }; - let wave_format = WasapiAccess::new_float_waveformatextensible(48000, channel_count); + // Calculate frames from period (100-nanosecond units) + let frames = + (((def_period as f64 / 10_000_000.0) * sample_rate as f64) as u32).max(1); + + let wave_format = + WasapiAccess::new_float_waveformatextensible(sample_rate, channel_count); // Use AUDCLNT_STREAMFLAGS_LOOPBACK to capture from the output device if client @@ -657,15 +731,16 @@ impl WasapiBase { return Err(()); } - let event = CreateEventA(None, false, false, None).unwrap(); - client.SetEventHandle(event).unwrap(); - client.Start().unwrap(); + let event = CreateEventA(None, false, false, None).map_err(|_| ())?; + client.SetEventHandle(event).map_err(|_| ())?; + client.Start().map_err(|_| ())?; Ok(Self { device_id, frames, device, channel_count, + sample_rate: sample_rate as f64, audio_buffer: Some(Default::default()), event, client, @@ -713,17 +788,11 @@ impl WasapiOutput { let device_buffer = self.render_client.GetBuffer(req_size).unwrap(); let mut audio_buffer = self.base.audio_buffer.take().unwrap(); let channel_count = self.base.channel_count; - let frame_count = (req_size / channel_count as u32) as usize; + // GetBuffer / GetCurrentPadding sizes are in frames, not samples. + let frame_count = req_size as usize; audio_buffer.clear_final_size(); audio_buffer.resize(frame_count, channel_count); audio_buffer.set_final_size(); - if (frame_count as u32) < self.base.frames { - println!( - "Wasapi glitch detected, resettting output device {}<{}", - frame_count, self.base.frames - ); - return Err(()); - } return Ok(WasapiAudioOutputBuffer { frame_count, channel_count, diff --git a/tools/map_tiles/src/nav_build.rs b/tools/map_tiles/src/nav_build.rs index 95fbade1b..7507b992e 100644 --- a/tools/map_tiles/src/nav_build.rs +++ b/tools/map_tiles/src/nav_build.rs @@ -39,6 +39,11 @@ pub struct NavBuildOptions { /// Build a `.searchdb` disk-backed index of EVERY searchable /// string (all named features + addresses) instead of the RAM formats. pub searchdb: bool, + /// Graph-only build over MAJOR roads (motorway..secondary + car + /// ferries). Ways pass runs FIRST so node retention stays bounded at + /// continent scale — the long-haul fallback graph for routing beyond + /// the detailed regional graph. + pub major_roads_only: bool, } /// Way tags that matter for routing or search; everything else is dropped @@ -67,6 +72,8 @@ const KEPT_WAY_TAGS: &[&str] = &[ "railway", "aeroway", "natural", + "attraction", + "zoo", "place", "population", "addr:street", @@ -197,6 +204,9 @@ fn collect_all_tags<'a>( } pub fn nav_build(options: NavBuildOptions) -> Result<(), String> { + if options.major_roads_only { + return build_major_roads_graph(&options); + } if options.places_only { return build_places_index(&options); } @@ -451,6 +461,189 @@ pub fn nav_build(options: NavBuildOptions) -> Result<(), String> { Ok(()) } +fn is_major_road(tags: &HashMap) -> bool { + if matches!( + tags.get("highway").map(|v| v.as_str()), + Some( + "motorway" + | "motorway_link" + | "trunk" + | "trunk_link" + | "primary" + | "primary_link" + | "secondary" + | "secondary_link" + ) + ) { + return true; + } + tags.get("route").map(|r| r.as_str()) == Some("ferry") + && tags.get("motor_vehicle").map(|v| v.as_str()) != Some("no") +} + +/// Continent-scale graph-only build: ways first (major roads are ~3% of +/// ways), then only their nodes — Europe fits in a few GB of RAM where the +/// all-nodes pass would need >100 GB. +fn build_major_roads_graph(options: &NavBuildOptions) -> Result<(), String> { + let total_start = Instant::now(); + let bbox = options.bbox; + eprintln!( + "nav-build --major-roads: pass 1/2 (ways) over {}", + options.source.display() + ); + let pass1_start = Instant::now(); + let reader = ElementReader::from_path(&options.source) + .map_err(|err| format!("open {}: {err}", options.source.display()))?; + let way_pass = reader + .par_map_reduce( + |element| { + let mut acc = WayPass::default(); + match element { + Element::Way(way) => { + let tags = collect_tags(way.tags()); + if !is_major_road(&tags) { + return acc; + } + let refs: Vec = way.refs().collect(); + if refs.len() < 2 { + return acc; + } + acc.ways.push((way.id(), refs, tags)); + } + Element::Relation(relation) => { + let mut is_restriction = false; + let mut restriction_value = String::new(); + for (k, v) in relation.tags() { + if k == "type" && v == "restriction" { + is_restriction = true; + } + if k == "restriction" { + restriction_value = v.to_string(); + } + } + if !is_restriction || restriction_value.is_empty() { + return acc; + } + let only = restriction_value.starts_with("only_"); + let banned = restriction_value.starts_with("no_"); + if !only && !banned { + return acc; + } + let mut from_way = None; + let mut to_way = None; + let mut via_node = None; + for member in relation.members() { + let role = member.role().unwrap_or(""); + match (role, member.member_type) { + ("from", RelMemberType::Way) => from_way = Some(member.member_id), + ("to", RelMemberType::Way) => to_way = Some(member.member_id), + ("via", RelMemberType::Node) => via_node = Some(member.member_id), + _ => {} + } + } + if let (Some(from_way), Some(via_node), Some(to_way)) = + (from_way, via_node, to_way) + { + acc.restrictions.push(BuildRestriction { + from_way, + via_node, + to_way, + only, + }); + } + } + _ => {} + } + acc + }, + WayPass::default, + WayPass::merge, + ) + .map_err(|err| format!("pbf way pass: {err}"))?; + let needed: std::collections::HashSet = way_pass + .ways + .iter() + .flat_map(|(_, refs, _)| refs.iter().copied()) + .collect(); + eprintln!( + "nav-build --major-roads: pass 1 done in {:.1}s — {} major ways, {} nodes needed, {} restrictions", + pass1_start.elapsed().as_secs_f64(), + way_pass.ways.len(), + needed.len(), + way_pass.restrictions.len() + ); + + eprintln!("nav-build --major-roads: pass 2/2 (nodes)"); + let pass2_start = Instant::now(); + let needed_ref = &needed; + let reader = ElementReader::from_path(&options.source) + .map_err(|err| format!("open {}: {err}", options.source.display()))?; + let nodes = reader + .par_map_reduce( + |element| { + let mut acc: Vec<(i64, f64, f64)> = Vec::new(); + let (id, lon, lat) = match &element { + Element::Node(node) => (node.id(), node.lon(), node.lat()), + Element::DenseNode(node) => (node.id(), node.lon(), node.lat()), + _ => return acc, + }; + if !needed_ref.contains(&id) { + return acc; + } + if let Some(bbox) = &bbox { + if !bbox.contains(lon, lat) { + return acc; + } + } + acc.push((id, lon, lat)); + acc + }, + Vec::new, + |mut a, mut b| { + a.append(&mut b); + a + }, + ) + .map_err(|err| format!("pbf node pass: {err}"))?; + eprintln!( + "nav-build --major-roads: pass 2 done in {:.1}s — {} nodes kept", + pass2_start.elapsed().as_secs_f64(), + nodes.len() + ); + + let build_start = Instant::now(); + let mut graph_builder = GraphBuilder::new(); + for &(id, lon, lat) in &nodes { + graph_builder.add_node(id, lon, lat); + } + for (id, refs, tags) in way_pass.ways { + graph_builder.add_way(id, refs, tags); + } + for r in way_pass.restrictions { + if needed.contains(&r.via_node) { + graph_builder.add_restriction(r); + } + } + let graph = graph_builder.build(); + eprintln!( + "nav-build --major-roads: graph built in {:.1}s — {} vertices, {} directed edges", + build_start.elapsed().as_secs_f64(), + graph.vertices.len(), + graph.edges.len() + ); + let graph_path = options.output_basename.with_extension("graph"); + let graph_bytes = graph.serialize(); + std::fs::write(&graph_path, &graph_bytes) + .map_err(|err| format!("write {}: {err}", graph_path.display()))?; + eprintln!( + "nav-build --major-roads: done in {:.1}s\n {} ({:.1} MB)", + total_start.elapsed().as_secs_f64(), + graph_path.display(), + graph_bytes.len() as f64 / 1e6, + ); + Ok(()) +} + /// One parallel pass collecting settlement place nodes into a search index: /// city/town/village/suburb/hamlet with a name. ~500k docs for Europe. fn build_places_index(options: &NavBuildOptions) -> Result<(), String> { @@ -968,6 +1161,7 @@ pub fn parse_nav_build_options(args: &[String]) -> Result Result skip_addresses = true, "--places-only" => places_only = true, "--searchdb" => searchdb = true, + "--major-roads" => major_roads_only = true, other => return Err(format!("unknown nav-build option {:?}", other)), } i += 1; @@ -1003,5 +1198,6 @@ pub fn parse_nav_build_options(args: &[String]) -> Result +#include +#include +#include +#include + +static std::vector read_ppm(const char * path, int & w, int & h) { + FILE * f = fopen(path, "rb"); + if (!f) { fprintf(stderr, "cannot open %s\n", path); exit(1); } + char magic[3] = {0}; + int maxval = 0; + if (fscanf(f, "%2s", magic) != 1 || strcmp(magic, "P6") != 0) { + fprintf(stderr, "not a P6 ppm: %s\n", path); exit(1); + } + // skip whitespace/comments + auto next_int = [&]() { + int c; + do { + c = fgetc(f); + if (c == '#') { while (c != '\n' && c != EOF) c = fgetc(f); } + } while (c == ' ' || c == '\n' || c == '\r' || c == '\t'); + ungetc(c, f); + int v; fscanf(f, "%d", &v); return v; + }; + w = next_int(); h = next_int(); maxval = next_int(); + fgetc(f); // single whitespace after maxval + if (maxval != 255) { fprintf(stderr, "maxval must be 255\n"); exit(1); } + std::vector rgb((size_t)w * h * 3); + if (fread(rgb.data(), 1, rgb.size(), f) != rgb.size()) { + fprintf(stderr, "short read on %s\n", path); exit(1); + } + fclose(f); + return rgb; +} + +int main(int argc, char ** argv) { + if (argc < 4) { + fprintf(stderr, "usage: %s [--cpu] [--max-tokens N]\n", argv[0]); + return 1; + } + const char * mmproj_path = argv[1]; + const char * image_path = argv[2]; + std::string out_prefix = argv[3]; + bool use_gpu = true; + bool use_flash = false; + int max_tokens = 0; + for (int i = 4; i < argc; i++) { + if (strcmp(argv[i], "--cpu") == 0) use_gpu = false; + if (strcmp(argv[i], "--flash") == 0) use_flash = true; + if (strcmp(argv[i], "--max-tokens") == 0 && i + 1 < argc) max_tokens = atoi(argv[++i]); + } + + clip_context_params cparams = {}; + cparams.use_gpu = use_gpu; + cparams.flash_attn_type = + use_flash ? CLIP_FLASH_ATTN_TYPE_ENABLED : CLIP_FLASH_ATTN_TYPE_DISABLED; + if (max_tokens > 0) cparams.image_max_tokens = max_tokens; + + clip_init_result res = clip_init(mmproj_path, cparams); + if (!res.ctx_v) { fprintf(stderr, "clip_init failed\n"); return 1; } + clip_ctx * ctx = res.ctx_v; + + int w = 0, h = 0; + std::vector rgb = read_ppm(image_path, w, h); + fprintf(stderr, "input image: %d x %d\n", w, h); + + clip_image_u8 * img_u8 = clip_image_u8_init(); + clip_build_img_from_pixels(rgb.data(), w, h, img_u8); + + clip_image_f32_batch batch; + mtmd_image_preprocessor_dyn_size preproc(ctx); + if (!preproc.preprocess(*img_u8, batch)) { + fprintf(stderr, "preprocess failed\n"); return 1; + } + if (batch.entries.size() != 1) { + fprintf(stderr, "expected 1 preprocessed image, got %zu\n", batch.entries.size()); return 1; + } + clip_image_f32 * img = batch.entries[0].get(); + fprintf(stderr, "preprocessed: %d x %d (buf %zu floats)\n", img->nx, img->ny, img->buf.size()); + + // dump preprocessed tensor + { + std::string p = out_prefix + ".preproc.bin"; + FILE * f = fopen(p.c_str(), "wb"); + uint32_t nx = img->nx, ny = img->ny; + fwrite(&nx, 4, 1, f); fwrite(&ny, 4, 1, f); + fwrite(img->buf.data(), 4, img->buf.size(), f); + fclose(f); + fprintf(stderr, "wrote %s\n", p.c_str()); + } + + const int n_tokens = clip_n_output_tokens(ctx, img); + const int n_embd = clip_n_mmproj_embd(ctx); + const int tx = clip_n_output_tokens_x(ctx, img); + const int ty = clip_n_output_tokens_y(ctx, img); + fprintf(stderr, "output tokens: %d (%d x %d), embd %d\n", n_tokens, tx, ty, n_embd); + + std::vector embd((size_t)n_tokens * n_embd); + if (!clip_image_encode(ctx, 4, img, embd.data())) { + fprintf(stderr, "encode failed\n"); return 1; + } + + { + std::string p = out_prefix + ".embd.bin"; + FILE * f = fopen(p.c_str(), "wb"); + uint32_t vals[4] = { (uint32_t)n_tokens, (uint32_t)n_embd, (uint32_t)img->nx, (uint32_t)img->ny }; + fwrite(vals, 4, 4, f); + fwrite(embd.data(), 4, embd.size(), f); + fclose(f); + fprintf(stderr, "wrote %s\n", p.c_str()); + } + + // quick stats so runs are comparable at a glance + double sum = 0, sum2 = 0; + for (float v : embd) { sum += v; sum2 += (double)v * v; } + fprintf(stderr, "embd mean %.6f rms %.6f first8:", sum / embd.size(), + sqrt(sum2 / embd.size())); + for (int i = 0; i < 8; i++) fprintf(stderr, " %.5f", embd[i]); + fprintf(stderr, "\n"); + + clip_image_u8_free(img_u8); + clip_free(ctx); + return 0; +} diff --git a/tools/vlm_oracle/gen_test_image.py b/tools/vlm_oracle/gen_test_image.py new file mode 100644 index 000000000..fa612e032 --- /dev/null +++ b/tools/vlm_oracle/gen_test_image.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Deterministic P6 PPM test images for VLM oracle comparison (stdlib only).""" +import struct, sys, math, os + +def write_ppm(path, w, h, pixel_fn): + buf = bytearray() + for y in range(h): + for x in range(w): + r, g, b = pixel_fn(x, y) + buf += bytes((max(0, min(255, int(r))), max(0, min(255, int(g))), max(0, min(255, int(b))))) + with open(path, 'wb') as f: + f.write(b'P6\n%d %d\n255\n' % (w, h)) + f.write(buf) + print(path, w, 'x', h) + +out_dir = os.path.dirname(os.path.abspath(__file__)) + +# 1. radar-like: map-ish background, green/yellow/red rain blobs (512x384, multiple of 32) +def radar(x, y): + # pale map background with faint road grid + r, g, b = 232, 236, 240 + if x % 64 < 2 or y % 64 < 2: + r, g, b = 200, 200, 205 + # rain cells: three gaussian blobs of increasing intensity + for (cx, cy, s, col) in [(140, 120, 55, (120, 200, 120)), + (300, 200, 70, (240, 220, 100)), + (330, 180, 30, (220, 80, 60))]: + d2 = (x - cx) ** 2 + (y - cy) ** 2 + w = math.exp(-d2 / (2 * s * s)) + if w > 0.25: + r = r * (1 - w) + col[0] * w + g = g * (1 - w) + col[1] * w + b = b * (1 - w) + col[2] * w + return r, g, b +write_ppm(os.path.join(out_dir, 'radar_512x384.ppm'), 512, 384, radar) + +# 2. small gradient + circle, exercises nothing fancy (256x256) +def grad(x, y): + inside = (x - 128) ** 2 + (y - 96) ** 2 < 48 ** 2 + return (255, 64, 32) if inside else (x % 256, y % 256, (x + y) % 256) +write_ppm(os.path.join(out_dir, 'grad_256x256.ppm'), 256, 256, grad) + +# 3. non-32-aligned size to exercise smart-resize (500x375) +write_ppm(os.path.join(out_dir, 'radar_500x375.ppm'), 500, 375, + lambda x, y: radar(x * 512 // 500, y * 384 // 375)) diff --git a/tools/vlm_oracle/grad_256x256.ppm b/tools/vlm_oracle/grad_256x256.ppm new file mode 100644 index 000000000..c4997122a Binary files /dev/null and b/tools/vlm_oracle/grad_256x256.ppm differ diff --git a/tools/vlm_oracle/radar_500x375.ppm b/tools/vlm_oracle/radar_500x375.ppm new file mode 100644 index 000000000..2d0603934 --- /dev/null +++ b/tools/vlm_oracle/radar_500x375.ppm @@ -0,0 +1,4 @@ +P6 +500 375 +255 +ѳǷȷвȶȶϲȵȵαȵȵͰȴȴ̰ȳȳ˯ȳȳʮȲȲɮȱȱȭȰȰǬȰȰƬȯǯſſĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿſſĿĿľþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿſĿĿľþþþý½½½©ȬȬ½½½¾þþþÿĿĿѳȷſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿгȷȷſſĿľþþý½½¼ݿȪȪݿݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݿݿݿݿݿ½½¾þþÿĿϲȶȶſĿĿľþý½½¼ݿݿݿݾݾȩȩܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽݾݾݾݾݾݿݿݿ½½¾þÿĿβȵȶſĿľþþý½¼ݿݿݿݾݾݾݾܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þÿĿαȵȵſĿľþý½½ݿݿݿݾݾݾܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿͱȴȵſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ̰ȴȴſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿ½¾þÿĿ˰ȳȴſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݿݿ½¾ÿĿʯȳȳſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽݾݾݿݿ¾þÿĿʯȲȲſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܽܽݾݾݿݿ½¾þĿɮDZDzſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܼܺܽܽݾݾݿݿ¾þÿĿȮȱȱſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ܻܻܼܺܽܽݾݾݿ½¾ÿĿǭǰȱſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹ۺܻܼܼܺܽܽݾݿݿ¾þÿƬȰȰſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۹۹ۺܻܻܼܼܽݾݾݿ½¾ÿĿŬǯǰſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذدȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿ½¾ÿĿīȯȯſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿ¾ÿīȮȮſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخححȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݿ¾êȭȮƿľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽݾݿªȭȭͳȷȶȶȵȵȴȴȳȳȲȲȱȱȰǯȯȮȭȭȬȬȫȪȪȩȩȨȨȧȧȦȦȥȥȤȤȣȣȢȢȡȡȠȠȠȟȟȞȞȞȝȝȝȝȜȜȜțțțțțȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚțțțțțțȜȜȜȝȝȝȝȞȞȞȟȟȠȠȠȡȢȢȣȣȤȤȥȥȦȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯǯȰȱȱȲȲȳȳȴȴȵȵȶȶȷͳȷȷȶȶȵǴȴȳȳȲȲȱȱȰȰȯȮȮȭȬȬȫȪȪȩȩȨȨȧȧȦȥȥȤȤȣȣȢȢȡȡȡȠȠȟȟȞȞȞȝȝȝȜȜȜțțțțȚȚȚȚșșșșșșșșșșșșșșșșșȚȚȚȚȚțțțțȜȜȜȝȝȝȞȞȞȟȟȠȡȡȡȢȢȣȣȤȤȥȥȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯȰȰȱȱȲȲȳȳȴǴȵȶȶȷſľý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܼܺܽݾݾݿȫȬ¾þÿſľþüݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨ȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܻܼܺܽݾݾȪȫ¾þÿͳȷſľþý»ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭خددذٱٲٲٳڴڵڵڶ۷۸۹۹ܻܼܺܽݾȪȪ¾þÿȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۺܻܼܽȩȪݿ¾ÿĿȷȶſſľý¼ݾݾܼܻܽۺ۹۸۸ڷڶڵڴٳٳٲٱٰددخح׬׬׫תת֧֧֦֩֩֨զեեդդգգԣȖȕԡԡԡԡԡԠԠԠԠԠԠԠԠԠԠԠԡԡԡԡԡԢԢԢԣգգդդեեզ֦֧֧֨֩֩תת׬׬حخددٰٱٲٳٳڴڵڶڷ۸۸۹ۺܻܼȩȩݾݿ¾ÿĿѳȶǶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷۸۹ۺܻȨȩݾݿݿ¾ÿѲȶȶſľý¼ݿܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨תת׫׬׭حخدذٱٱٲٳڴڵڶڷ۷۸۹ۺȧȨܽݾݿ¾ÿвȶȵſľý½¼ݿݾܼܻܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ۷۸۹ȧȧܼܽݾݿ½¾ÿвȵȵſſľý¼ݿݾܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֨֩תת׫׬حخددٰٱٲٳڴڵڶڷ۸۸Ȧȧܻܼܽݾݿ¾ÿĿϱȵȴſľý¼ݿݾܼܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդե֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵڶڷ۸ȦȦܻܼܺܽݾݿ¾ÿϱȵȴſľý¼ݿݾݾܼܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜқȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵڶڷȥȦۺܻܼܽݾݾݿ¾ÿαȴȴſľľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵڶȤȥ۹ۺܻܼܽݾݿ¾þĿΰȴȳſľý¼ݿݾܼܻܽۺ۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵȤȥ۸۹ۺܻܼܽݾݿ¾ÿͰȴȳſľý¼ݿݾܼܻܽܺ۹۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڴȣȤ۷۸۹ܻܼܺܽݾݿ¾ÿͰȳȳƿſľý¼ݿݾܼܻܽۺ۹ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїіȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڴȣȣڷ۸۹ۺܻܼܽݾݿ¾ÿĿ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲٳȢȣڶ۷۸۹ۺܻܼܽݾݿ¾ÿ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲȢȢڵڶ۷۸۹ܻܼܺܽݾݿ¾ÿ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱȡȢڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡȡڴڵڶ۷۸۹ۺܻܼܽݾݿ¾ÿʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠȠٲٳڴڶڷ۸۹ۺܻܼܽݾݿ¾ÿͲͲʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠٲٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͱͱɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٱٲٳڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿͰͱɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰٱٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͰͰɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕіїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٱٲٳڴڵڶ۸۹ۺܻܼܽݾݿÿͯͯȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕіїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذٱٲڴڵڶ۷۸۹ܼܺܽݾݿ¾ͯͯȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٱٲٳڴڵڷ۸۹ۺܻܼܽݾ¾ͲͮͮȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذٱٲڴڵڶ۷۸۹ܻܼܽݾݿ¿ͲͱͭͭǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٱٲٳڴڵڷ۸۹ۺܻܼݾݿ¿ͱͱάέǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰد׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰٱٳڴڵڶ۷۹ۺܻܼܽݾݿ¿ͱͰάάǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذد׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٱٲٳڵڶ۷۸۹ܼܺܽݾݿÿͰͰΫΫƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰٲٳڴڵڶ۸۹ۺܻܼܽݿÿͯͯΪΪƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٱٲڴڵڶ۷۸ۺܻܼܽݾݿÿͯͮΪΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱٲٳڴڶڷ۸۹ܼܺܽݾݿ¾ÿͮͮΩΩūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿͭͭΨΨŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿέέϧϨŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲٳڴڶ۷۸۹ܻܼܽݾݿÿάάϧϧƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋ΌΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲٳڴڵڷ۸۹ܻܺܽݾݿ¾ÿάΫϦϦſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿΫΫϥϥſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿΪΪϤϥſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڴڶ۷۸۹ܻܼܽݾ¾ÿΪΩϤϤſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڵڷ۸۹ܼܺܽݾÿΩΩϣϣƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܽݾ¾ĿΨΨТТƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶ۷۹ۺܻܼݾ¾ÿϧϧССſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶ۷۹ۺܻܼܽ¾ÿϧϦРСſľéȬȫݾܼܻܽۺ۸۷ڶڵٳٲٱدخح׬ת֦֩եդԢԡԠӟӝҜқҚјїіЕГВϑϐΏΎ΍Ό͉͈͊̇̇̆˅˄˃˂ʁʁʀ~~~}}}}z{zz{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{||||}}}}~~~ʀʁʁ˂˃˄˅͈̆̇̇͊Ό΍ΎΏϐϑВГЕіїјҚқҜӝӟԠԡԢȗȘ֦֨֩ת׬حخدٱٲٳڵڶ۷۸ۺܻܼܽ¾ÿ߱ϦϦРРſľéȬȫݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخ׭׫ת֦֩եդԢԡԠӞӝҜқҙјїіДГϒϑϐΏ΍Ό͉͈͋͊̇̆̅˄˄˃˂ʁʀʀ~~~}~}}|}zzzzz{zzzzzzyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzz{{{{||||}}}}~~~~ʀʀʁ˂˃˄˄͈̅̆̇͊͋Ό΍ΏϐϑϒГДіїјҙқҜӝӞԠԡԢȗȘ֦֧֩ת׫׭خدٱٲٳڴڶ۷۸۹ܻܼܽݿ¾ÿ߱߱ϥϥППſľéȬȫݿݾܼܻܽ۹۸ڷڶڴٳٲٰدخ׭׫ת֩զեգԢԡӟӞӝҜҚҙјїЕДГϒϑϏΎ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~~}}|}||zzzzzzzzzzyyyyyyyyyyxyxxxxxxxyyyyyyyyyyyzzzzzz{{{{{||||}}}~~~ʀʁ˂˂˃˄̅̆̇̈͊͋Ό΍ΎϏϑϒГДЕїјҙҚҜӝӞӟԡԢȗȘզ֧֩ת׫׭خدٰٲٳڴڶڷ۸۹ܻܼܽݿ¾ÿ߰߰߰ϥϤООſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܼܺܽݿ¾ÿ߯߯߰߰߰ϤϤѝНſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܼܺܽݿÿޮ߮߯߯߯߰ϣϣќќſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܽݿÿޭޭ߮߮߮߯߯ϣТћќſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽݿÿެޭޭ߭߮߮߮߯ТСћћſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿޫެެެ߭߭߭߮߮ССњњſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿݪޫޫެެ߬߭߭߭߮РРљљſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{yyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇͊͋Ό΍ΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿݪݪުޫޫެެ߬߬߭߭߭РПјјͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿݩݩݪުުޫޫޫ߬߬߬߭߭ППҗҗͱͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݨݨݩݩުުުޫޫ߫߬߬߬߬ООҖҗͱͱſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽݨݨݨݩީުުުޫ߫߫߫߬߬НѝҕҖͱͱſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܦݧݨݨݨީީުުުߪ߫߫߫߫ѝќҕҕͰͱſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܺܥܦݧݧݨݨިީީީުߪߪߪ߫߫ќќҔҔͰͰſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܥܥܦݧݧݧݨިިީީީߪߪߪߪߪћћғғͯͰͳȷȶȶȵȴȴȳDzȲȱȰȯǯȮȭȬȫȪȪȩȨȧȦȥȤȤȣȢȡȠȟȞȝȜțȚșȘȗȖȕȔȓȒȑȐȏȏȎȍȌȋȊȉȉȈȇȆȅȅȄȃȃȂȁȁȀ~~~}~}}|}||||{{{{zzzzzzyzyyyyyyyyyyxyxxxxxxxxxxxyyyyyyyyyyyyzzzzzzz{{{{{{|||||}}}}~~~~ȀȁȁȂȃȃȅȅȆȇȈȉȉȊȋȌȍȎȏȏȐȑȒȓȔȕȖȗȘșșȚțȜȝȞȟȠȡȢȣȤȤȥ͕͖͖͖͗͗͗͘͘ΘΘΘΘΘΙΙΙΙΙϙϙϙϦϥϥϤϤϣϣТТССРРППООѝѝќќћћњљљјҘҘҗҗҖҖҖҕҕҔҔҔғғғӒӒӒӒӑӑӑӑӐӐӐӐӐӐӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӐӐӐӐӐӐӑӑӑӑӒӒӒӒғғғҔҔҔҕҕҖҖҖҗҗҘҘљљњњћћќќѝѝООППРРССТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͭͮͯͯͰͰͱͱͲͳȷȷȶȵȴȴȳȲȲȱȰȯȯȮȭȬȫȫȪȩȨȧȦȥȥȤȣȢȡȠȟȞȝȜțțșȘȗȖȕȔȓȒȑȐȐȏȎȍȌȋȊȊȉȈȇȆȆȅȄȄȃȂȁȁȀȀ~~~}}}}|}||{|{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{{||||}}}}}~~~ȀȀȁȁȂȃȄȅȆȆȇȈȉȊȊȋȌȍȎȏȐȐȑȒȓȔȕȖȗȘșȚțțȜȝȞȟȠȡȢȣȤȥ͕͕͕͖͖͗͗͗͗ΗΘΘΘΘΘΘΘΘΘϘϘϘϘϥϥϤϤϣϣТТСРРППООНѝќќћћњњљљјҘҗҗҖҖҖҕҕҔҔҔғғӓӒӒӒӑӑӑӐӐӐӐӐӏӏӏӏӏӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӏӏӏӏӏӐӐӐӐӐӑӑӑӒӒӒӓғғҔҔҔҕҕҖҖҖҗҗјљљљњњћћќќѝНООППРРСТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͮͮͯͰͰͱͱͲͲſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶۣۣۢܤܤܥݥݦݦݦާާާާިߨߨߨߨߨߨјҘӏӐͮͮƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶۣۣۢܤܤܤݥݦݦݦަާާާާߧߨߨߨߨߨҗҗӎ޲z轅轅輅輅輅輅輆輆輆輇輇輇輈轈轉轊辊辋ͭͮƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵۣۡۢۢܣܤܤݥݥݦݦަަާާާߧߧߧߧߧߧҗҖ轃輂輂ޱxްx軂躂躃躃躃躃纃纄纄纄纅纅纆纆织织终缉缉轋辌έͭſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڡۡۢۢܣܣܤܤݥݥݥݦަަަަާߧߧߧߧߧߧҖҖ輁輁軁軀躀躀߯v߯v踀踀縀縀緁緁緁緁緂緂緂縃縃縄縄繅繆纆纇绉缊缋罌羍άέſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڠۡۡۢۢܣܣܣܤݤݥݥݥަަަަަߦߦߦߦߧߧҕҕ轀~~~~߭t߭t~~~~~~絀絀絁絁綂綂綃緃緄縅繇纇纈绉缊缋罌美άάſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڠۡۡۢۢܢܣܣܤݤݥݥޥޥޥަަަߦߦߦߦߦߦҕҔ~~~}}}|||||߫s߫s|||||||}}}~~紀紁紁終絃緄緅縆繇纈纉绊缋罍美ΫάſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڠڠۡۡۢۢܢܣܣܣݤݤݥޥޥޥޥޥߥߦߦߦߦߦߦҔғ}}|||{{{zzzzzߩqߩqyyyzzzzz{{{||}~~糀糀紂絃綄緅緆縇繈纉绊缌罍美ΫΫƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋ΌΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲڟڠڠۡۡۡܢܢܣܣܣݤݤݤޥޥޥޥޥߥߥߥߥߥߥߥғӓ||{{zzzyyxxxxxwߧoߧowwwwwxxxxyyzz{{|}}~粀糁糂紃組綅緆縇繈纊绋缌罍羏ΪΫŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲڟڠ۠۠ۡۡܢܢܢܣݣݤݤݤޤޤޥޥޥߥߥߥߥߥߥߥӒӒ|{zzyyxxwwwvvvvuuߥmߥmuuuuuuuvvvwwxxyzz{|~~沀糂紃組綅緆縇繉纊绋缍罎翐ΪΫŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱڟڟڠ۠۠ۡۡܢܢܢܣݣݣݤݤޤޤޤޤޤߤߥߥߥߤߤߤӒӑzzyyxwwvvuuutttsssߣkߣksssssssstttuuvwwxyy{|}~汀沂紃組綅緇縈繉纋缌罎羏ΪΪūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲڟڟڠ۠۠ۡۡܢܢܢܣݣݣݣޤޤޤޤޤޤߤߤߤߤߤߤߤӑӐyxxwwvuuttsssrrrqqqiߠippppqqqqqrrssttuvvwyz{|}~汁沂紃組綆緇縉纊绌缍羏ΩΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱڞڟڟڠ۠۠ۡܡܢܢܢܢݣݣݣޣޤޤޤޤޤߤߤߤߤߤߤߣӐӐyxwwvuutssrrqqpppoooohgnnnnnnooooppqqrsstuwxyz{|}~氀汁泂約絅綇縈繊纋缍罎羐ΩΩƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٞڞڟڟ۠۠۠ۡܡܡܢܢݢݣݣݣޣޣޣޤޤޤߤߤߣߣߣߣߣӏӏwvvutssrrqppoonnnmmmlfflllllllmmmnnnoppqrstuvwyz{|~氀沂泃紅綆緈繉纋绌罎羐ΨΩƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰڞڞڟڟ۠۠۠ۡܡܡܢܢݢݢݣݣޣޣޣޣޣߣߣߣߣߣߣߣߣӏӎwuttsrrqpponnmmlllkkkjddjjjjjjjjkkkllmmnooprstuvxyz{}~氀汁沃洄絆緇縉纋绌罎羐ΨΨǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذد׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٝڞڞڟ۟۠۠۠ܡܡܡܢܢݢݢݣޣޣޣޣޣޣߣߣߣߣߣߣߢߢӎԍvussrqpponnmmlkkjjjiihhbbhhgghhhhhiiijjkllmnpqrstuwxy{|~氁沂泄絅綇縉繊绌缎羐ϧΨǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰد׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰڞڞڟڟ۟۠۠۠ܡܡܡܢݢݢݢݢޣޣޣޣޣޣߣߣߣߢߢߢߢߢԍԍutsrqpoonmmlkkjiihhgggff``feeeeeffffgghhiijklnopqrstvwyz|}氀求泃紅綇縉繊绌缎羐ϧϨǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٝڞڞڟڟ۟۠۠ۡܡܡܡܢݢݢݢݢޢޣޣޣޣޣߢߢߢߢߢߢߢԍԌtssrponnmlkkjiihhgffeeedd__cccccccdddeeefgghijklnopqrtuvxy{}~毀求沃洅綇緉繊绌缎羐ϧϧȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذڞڞڞڟ۟۠۠۠ܡܡܡܡܢݢݢݢޢޢޢޢޢޢޢߢߢߢߢߢߢߡԌԋtsrqpnnmlkjjihhgffeeddccbb]]aaaaaaaabbbccddefggijklnoprstvwy{|~毀汁沃洅綇緉繊绌缎羐ϦϧȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٝڞڞڟڟ۟۠۠۠ܡܡܡܡݢݢݢݢޢޢޢޢޢޢߢߢߢߢߢߡߡߡԋԋ߆߆srqpoomlkjiihgffeddccbbaa``[[________```aabbcddeghijlmnoqrtuwyz|~毀氁沃洅綇緉繋绍罎羐ϦϦȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕіїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذڞڞڞڟ۟۟۠۠ܠܡܡܡܡݢݢݢݢޢޢޢޢޢޢߢߢߢߡߡߡߡԊԊ߆߆߆߅߅srqponmkjiihgfeedccbaa``__^^ZY]]]]]]]]]^^^__`aabcefghiklmoprsuwxz|~毀氁沃洅綇縉繋绍罏翑ϥϦɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕіїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢޢޢޢޢޢޢߢߡߡߡߡߡߡԊԉ߆߆߅߅߅߅߄߄qponmlkjihgfeedcbba``_^^]]]\XX[[[[[[[[[\\\]]^__`acdefgijkmnprsuwxz|~毀氂沃洅綇縉纋绍罏ϥϦɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰڞڞڞڟ۟۟۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޢߢߡߡߡߡߡߠߠԉԈ߆߆߅߅߅߄߄߄߄߃qponmlkjhgfeedcbaa`_^^]]\\[[ZV߆VYYYYYYYYYZZZ[[\]]^_abcdeghiklnpqsuvxz|~毀求泄洆綈縊續缎羐ϤϥɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢݢޢޢޢޢޡߡߡߡߡߡߠߠԈՈ߆߆߅߅߄߄߄߃߃߃߂ponmlkjihgfedcba``_^]]\[[ZZYYXU߃UXWWWWWWWWXXXYYZ[[\]_`abcefgijlnoqsuwxz|~毀求泄浆緈繊绌缎羐ϤϥʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠڞڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޡޡߡߡߡߡߠߠߠՈՇ߆߆߅߅߄߄߃߃߃߂߂߂ponmlkjihgedcbaa`_^]]\[ZZYYXXWW߁S߁SVVUUUUUUUVVVWWXYYZ[]^_`acdfgijlnoqsuwy{}毁汃泅絇緉繋绍罏翑ϤϤʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠ͑ڞڞڟڟ۟۠۠۠ܡܡܡܡݡݡݡݢݢݢޢޢޡޡޡߡߡߡߡߠߠߠߠՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁onmlkihhgfdcba`_^]]\[ZZYXXWVVUUR~RTT~T~S}S}S}S}S}T}T}T}U~U~UVWWXY[\]^`abdeghjlmoqsuwy{}氁沃洅綇縉續缎羐ϣϤˮȲȱſľý¼ݿݾܼܻܽ۹۸۷ڶڵٳٲٱذدخ׭׫ת֧֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙљјїііЕДДГГϒϒϑϑȉȈϏΏΏΎΎΎΎΎΎΎ΍ΎΎΎΎΎΎΎΏΏϏϏϐϐϑϑϒϒГГДДЕііїјљҙҚҜӝӞӟӟԠԡԢգդե֦֧֨֩ת׫׭خدذȠ͑ڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޡޡޡޡߡߡߡߠߠߠߠՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀nmlkjihgfedba`__^]\[ZYYXWWVUUTTS|P{P}R|R|R{R{RzRzRzRzRzRzRzS{S{T|T}U~U~VWYZ[\^_`bceghjlnoqsuwy{}殀氂沄浆緈繊绌缎羑ϣϤ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡ͒ڟڟ۟۠۠۠۠ܡܡܡܡݡݡݢݢݢݢޢޢޡޡޡߡߡߡߠߠߠߠߟՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀mlkjihgfedca`_^]\[ZZYXWVVUTTSSR~RzOyOzQzPyPxPxPwPwPwPwPwPwQxQxQxRyRzS{T|T}UWXY[\]_`bceghjlnprtvxz|~毀求泅絇緉繋绍罏ϣϣ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱ͒͒ڟڟ۠۠۠۠ܡܡܡܡܡݡݢݢݢݢޢޢޢޡޡޡߡߡߠߠߠߠߟՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~mlkjihgfedcb`_^]\[ZYXWWVUTTSRR~Q|Q{PxNwMxOwOvOvNuNuNtNtNtNtOtOuOuPvPvQwQxRySzT}U~WXYZ[]^`aceghjlnprtvx{}氁沃洅綈縊續缎羐Тϣ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲ͒͒ڟ۠۠۠ۡܡܡܡܡܡݡݢݢݢݢݢޢޢޢޡޡߡߡߡߠߠߠߠߟՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}lkjihgedcba`^]\[ZZYXWVUTTSRR~Q|P{PzOxOuLtLuNtMsMsMrMrMrMqMqMqMrMrNrNsNtOuPuPvQxRzT|U}VWXZ[]^`acegikmoqsuwy{}殀求泄絆緉繋绍罏Тϣ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲڟ͒͒۠۠۠ۡۡܡܡܡܡܢݢݢݢݢݢޢޢޢޢޡޡߡߡߠߠߠߠߟՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}lkjigfedcba`_]\[ZYXWVVUTSRRQ}P|PzOyNwNvMsKrKsLrLqLpKpKoKoKoKoKoKoLoLpLpMqMrNsOtOuPxRyS{T|V~WXZ[]^`bcegikmoqsvxz|~毁沃洅綇縊續缎羐СТͰȳȳƿſľý¼ݿݾܼܻܽۺ۹ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїіȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڟ͓͒۠۠ۡۡܡܡܡܢܢݢݢݢݢݢޢޢޢޢޢޡߡߡߡߠߠߠߟߟՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||kjigfedcba`_^\[ZYXWVUTTSRQP}P{OyNxNvMuLsLqJoJpKoJnJnJmJmJlJlJlJlJlJmJmKnKnLoLpMqNsOuQwRxSzT|U~WXY[]^`bdfhjlnprtvy{}氂泄絆緈繋绍罏СТͰȴȳſľý¼ݿݾܼܻܽܺ۹۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڠ͓͓ۡۡۡܡܡܢܢܢݢݢݢݢݢݢޢޢޢޢޢޡߡߡߠߠߠߠߟփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{kjigfedcba`_^][ZYXWVUTSRQQP}O{NyNwMuLtLrKqKnImHnImIlIkIkHjHjHjHjHjHjIjIkIkJlJmKnLoLpMsOtPvQxRzT{U}VXZ[]_`bdfhjlnqsuwz|~毀沃洅綇縊續缎羐ССΰȴȳſľý¼ݿݾܼܻܽۺ۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڠڠ͓͓ۡۡۡܡܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢߡߡߡߠߠߠߟߟւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zjihfedcba`_^]\ZYXWVUTSRQPP}O{NyMwLuLsKrKpJoIlHkGkHjHiGiGhGhGgGgGgGgGgGhHhHiHjIjJkJmKnLqNrOtPvQwRyT{U}WXZ[]_acegikmortvx{}氂泄絆緉繋缍羏РСαȴȴſľľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڠ͓͓ۡۡۡܢܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢޢߡߡߡߠߠߠߟւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyihgedcba`_^]\[YXWVUTSRQPO}N{NyMwLuKsKqJpInImHjGiFiGhGgFfFfFeFeFeFeFeFeFeFfGgGgHhHiIjJlJnLpMrNsPuQwRyT{U~WXZ\^`bdfhjlnpsuwy|~毀沃洅綇繊绌罎РСϱȵȴſľý¼ݿݾݾܼܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜқȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܢݢݢݢݢݢݢޢޢޢޢޢޢޢߡߡߡߠߠߠߟցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyihgfdcba`_^]\[ZXWVUTSRQPO}N{MyMwLuKsJqJoImHlHjGhFgEgFfEeEdEdEcEcDcDbEcEcEcEdEdFeFfGgHhHiIlKnLpMqNsPuQwRzT|U~WY[\^`bdfikmoqtvx{}求泄絇縉纋缍羐РРϱȵȴſľý¼ݿݾܼܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդե֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܣݣݣݣݣݣݣޣޣޢޢޢޢޢߢߡߡߡߠߠߟրր߆߅߄߃߂߂߁߀~}}||{zzyyxxhgfedba`_^]\[ZYWVUTSRQPO~N{MyLwLuKsJqIoImHkGjGhFfEeDeEdDcDbDaDaCaC`C`C`DaDaDaDbEcEdFeFfGgHjJlKnLoMqNsPvQxSzT|VXY[]_acegjlnpsuwz|~氁沃絆緈繊绍罏ПРвȵȵſſľý¼ݿݾܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֨֩תת׫׬حخددٰٱٲٳڴڵڶ͔͔ۢۢۢܢܣܣܣݣݣݣݣݣݣޣޣޣޣޢޢޢߢߢߡߡߠߠߠߟր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwgfedcb`_^]\[ZYXVUTSRQPON|MzLxLuKsJqIoHmHkGjFhFfEdDcCcDbCaC`C_C_B_B^B^B^B_C_C_C`DaDbEcEdFeGhIjJlKnLpMrOtPvQxS{U}VXZ\^`bdfhkmortvy{}毀求洅綇縉绌罎ПРвȶȵſľý½¼ݿݾܼܻܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ͔͕ۢۢۢܣܣܣܣݣݣݣݣݣݣޣޣޣޣޣޢޢߢߢߡߡߠߠߠ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwgfedcba`_]\[ZYXWUTSRQPON}M{LxLvKtJqIoHmHkGjFhEfEeDcCaCaC`B_B^B]B]A]A\A\A\A]B]B]B^C_C`DaDbEcFfHhIjJlKnLpMrOtPwRyT|U~WY[]_acegjlnqsuxz}汁泄絆縉纋缍羐ПРѲȶȶſľý¼ݿܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨תת׫׬׭حخدذٱٱٲٳڴڵڶڷۣۣۣ͕͕ۢܣܣܣݣݤݤݤݣݣޣޣޣޣޣޣޢߢߢߢߡߡߠߠ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvgedcba`_^]\ZYXWVTSRQPON~M|MyLwKtJrIpHnGlGjFhEfEeDcDaB`B_B^B]A\A\A[A[A[@Z@[A[A[A\A\B]B^C_C`DbEeGfHhIjJlKnLqNsOuQxSzT}VXZ\^`bdgikmprtwy|~氁沃絅緈繊缍羏ОПѳȶǶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷ۣۣۣܣ͕͕ܤܤݤݤݤݤݤݤޤޤޣޣޣޣޣߣߢߢߢߡߡߠߠ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvufedba`_^]\[ZYXWVSRQPPON}MzLxKuJsIqHoHlGjFhEfEeDcCaC_B^A]A\A[A[@Z@Y@Y@Y@Y@Y@Y@Y@Z@[A[A\B^B_C`DcFeGgHiIkJmLoMqNtPvRyS{U~WY[]_acfhjloqtvx{}毀沂洅綇繉绌罎ОПȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۤܤܤܤ͖͖ݥݥݥݥݤݤޤޤޤޤޤޤޤߣߣߣߢߢߢߡߡߠ}}߄߃߂߁߁߀~}}|{zzyxxwvvuutfdcba`_^]\ZYXWVUTRQPONM}L{KxJvJsIqHnGlFjEhEfDdCbC`B_A]A[@Z@Y@X?X?W?V>V>V>V>V>V>V?W?X?Y@Y@[A\B]B`DbEdFfGhIjJmKoMqNtPwRyT|UWY[]`bdfikmpruwy|~汁泃絆縈纋缍羏ООͳȷſľþý»ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭خددذٱٲٲٳڴڵڵڶ۷۸۹۹ܤܥܥܥܥ͖͖ݥݥݥݥݥޥޥޥޤޤޤޤޤߣߣߣߣߢߢߡߡ}|߄߃߂߁߀~}}|{zzyxxwvvuttsedca`_^]\[ZYXWVUTRQPONM|LzKwJuIrHpGmGkFiEgDdCcCaB_A]A[@Z@Y?X?W?V>V>U>U>U>T>U>U>U>V>V?W?X@Y@[A\B_CaDcEeGgHiIkKnLpNsOvQxS{U~WY[]_acfhjmoqtvy{~氀沃絅緈纊缌羏НОſľþüݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨ȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܺܥܥܥܥܥ͖͖ݥݥݥݥޥޥޥޥޥޥޤޤߤߤߣߣߣߢߢߡ}|߃߂߁߀~~}|{zzyxxwvvuttssdcba`_^\[ZYXWVUTSQPONM~L|KyJvItIqHoGlFjEhDfDcCbB`B^A\@Z@Y?X?W?V>U>U>T=T=S=S=S=T=T>U>U>V?W?X@Y@[A^C`DbEdFfGhIjJmKoMrOuPwRzT}VXZ\^`cegjlnqsvx{}氀沂紅緇繉绌美ѝОſľý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܺܦܦܦܦݦ͗͗ݦݦݦަަޥޥޥޥޥޥߤߤߤߣߣߣߢߢ߆|{߂߁߁߀~}|{{zyxxwvvuttssrdba`_^]\[ZYXWVUSRPPONM~L{KxJvIsHpGnFkFiEgDeCcCaB_A]A[@Z?X?W?V>U>T=T=S=S=R=R=R=S=S=T=T>U>V?W?Y@ZA]B_CaDcEeGgHjIlKoLqNtPwRyS|UWY[^`bdgiknpsuxz}沂約緆繉绋罎ѝОſľý¼ݿݿݾܼܻܻܽۺ۹۸۸۷ڶڵڵڴٳٳٲٱٱذذدخخح׭׬׬׫׫׫תת֩֩ȚȚ֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֨֨֨֨֨֨֩֩֩תת׫׫׫׬׬׭حخخدذذٱٲٳٳڴڵڵڶ۷۸۸۹ۺܻܻܼܦܦܦݦݦ͗͗ݦݦަަަަަޥޥޥߥߥߤߤߤߣߣߣߢ߆|{߂߁߀~}||{zyxxwvvuttsrrqcba`_^\[ZYXWVUTSRPONML}KzJxIuIrHpGmFkEhDfDdCbB`B^A\@[@Y?W>V>U>T=S=S=R=RU>V?X?Y@\B^C`DbEdFgHiIkJnLpNsOvQyS{U~WY[]_bdfhkmpruwz|汁約綆繈绋罍ѝНžþý¼ݿݿݾܼܼܻܽۺ۹۹۸۷ڷڶڵڴڴٳٳٲٱٱذذددخخح׭׬׬׫׫׫תțț֩֩֩֩֨֨֨֨֨֨֨֨֨֨֨֨֨֩֩֩֩֩תתת׫׫׫׬׬׭حخخددذذٱٱٳٳڴڴڵڶڷ۷۸۹۹ۺܻܼܼܧܧܧݧݧݧ͗͗ݧާަަަަަަߥߥߥߥߤߤߤߣߣߣߢ߆߅{{߁߀~~}|{zyyxwvvuttsrrqqba`_^]\[ZYXWVUTSRPONML|KzJwItHrGoGmFjEhDfCcCaB_A]A\@Z?X?W>V>U=T=S=R=QV>W?X@\A^B_CbEdFfGhIkJmLpMsOuQxR{T~VXZ]_acfhjmortwy|~汁紃綆縈绊罍ќѝƿľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽܧݧݧݧݧݧ͘͘ާާާާާަަߦߦߦߥߥߥߤߤߤߣߣ߆߅{z߁߀~}|{zzyxwvvuttsrrqqpba`_^][ZYXWVUTSRQONMLK|KyJwItHqGoFlEjEgDeCcBaB_A]@[@Y?X>V>U>T=S=R=QU>V?X?[A]B_CaDcFeGhHjJmKoMrOuPxR{T}VXZ\^acehjloqtvy{~汀糃綅縈纊罍ќѝſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخححȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݨݨݨݨݨݨ͘͘ިާާާާާߧߦߦߦߥߥߥߥߤߤߣ߆߅߄zz߀~}}|{zyxwwvuttsrrqqppb`_^]\[ZYXWVUTSRQONMLK|JyIvItHqGnFlEiDgDeCbB`A^A\@[@Y?W>V>U=T=S=RV?X?[A]B_CaDcEeGgHjIlKoMrNuPwRzT}VXZ\^`cegjloqtvx{}汀糂綅縇纊罌ќѝſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿݨݨݨݨݨި͘͘ިިިާާߧߧߧߦߦߦߥߥߥߤߤ߆߅߄zy߀~}|{zyyxwvuutssrqqppoa`_^]\[ZYWVUTSRQQONML~K|JyIvHsGqGnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?[A\B^C`DcEeFgHjIlKoLrNtPwRzT}UWZ\^`begilnqsvx{}汀糂綅縇纊缌ќќſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذدȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿݩݩݩݩީީ͙͘ިިިިߨߧߧߧߧߦߦߦߥߥ߆߅߄߃zy~}||{zyxwvvutssrqqppona`^]\[ZYXWVUTSRQPNMML~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>T=S=R=QV>W?[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY\^`begilnqsuxz}糂組縇纉缌ћќſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۹۹ۺܻܻܼܼܽݾݾݿݪݪݪީީީ͙͙ީީߨߨߨߨߨߧߧߧߦߦߦ߆߅߄߃yy~}|{zyxxwvuttsrrqppoon`_^]\[ZYXWVUTSRQPNMLK~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY[^`bdgilnpsuxz}糂組縇纉缌ћќſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹ۺܻܼܼܺܽܽݾݿݿݪݪުުުުު͙Ιީߩߩߩߨߨߨߨߧߧߧ߅߄߃߂yx~}||{zyxwvuutsrrqppoonm`_^]\[ZYXWVUTSRQPNMLKJ|JyIvHtGqFnFlEiDgCeCbB`A^A\@[?Y?W>V>U=T=S=RV>X?[A]B_CaDcEeFgHjIlKoLrNuPwQzS}UWY[^`bdgiknpsuxz}糂綄縇纉缋ћќſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ܻܻܼܺܽܽݾݾݿ½ݫޫޫޫޫުު͚Ιߪߪߩߩߩߨߨߨߨߧ߆߅߄߃߂yx~}|{zyxxwvutssrqqpoonnm`_^\[ZYXWVUTSRRQPNMLKJ|JyIvHtGqFoElEjDgCeCcBaA_A]@[?Y?X>V>U>T=S=RV?X?[A]B_CaDcEeFhHjImKoLrNuPxQzS}UWY[^`bdgiknpsuxz}糂綄縆纉罋ћќſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܼܺܽܽݾݾݿݿ¾þެޫޫޫޫޫޫ͚Κߪߪߪߩߩߩߩߨ߆߅߄߃߂yx~}|{zyxwvuutsrrqpponnmm_^]\[ZYXWVUTSRQPPNMLKJ|IzIwHtGrFoFmEjDhCeCcBaA_A]@[@Z?X>W>V>U=T=S=RV>W?X@\A]B_CaDdEfGhHkImKpLrNuPxR{S~UWY[^`bdgiknpsuxz}糁綄縆纉罋ћќſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܽܽݾݾݿݿ½¾þĿެެެެެޫ͚߫Κ߫ߪߪߪߪߩߩ߅߄߃߂߁xx}|{zyyxwvutssrqpponnmml_^]\[ZYXWVUTSRQPONMLKJ}IzIwHuGrFpFmEkDhCfCdBbB`A^@\@Z?Y?W>V>U>T=S=S=RU>V>X?Y@\A^B`CbDdFfGiHkJnKpMsNvPxR{T~VWZ\^`bdgiknpsuxz}紁綄縆绉罋ћћſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽݾݾݿݿ¾þÿĿޭޭެެ߬߬߬͛Κ߫߫߫ߪߪ߆߅߄߃߂߁xw}|{zyxwvvutsrrqpoonmmll_^]\[ZYXWVUTSRQPONMLKJ}J{IxHuGsFpFnEkDiDgCeBbBaA_A]@[?Z?X>W>V>U=T=T=S=S=R=R=R=S=S=T=T=U>V>W?X?Z@]B_CaDcEeFgGjHlJnKqMtOvPyR|TVXZ\^`begilnpsuxz}紂綄繆绉罋њћſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݿݿ½¾ÿĿޭޭ߭߭߭߬߬͛Λ߬߫߫߫߆߅߄߃߂߁xw}|{zyxwvuttsrqpponnmmlk_^]\[ZYXWVUTSRQPONMLKJ~J{IyHvGsGqFoElDjDhCeCcBaA`A^@\@[?Y?X>W>V>U>U=T=T=S=S=S=T=T=U>U>V>W?X?Y@[A^B`CbDdEfFhHjImJoLrMtOwQzR}TVXZ\^`cegilnqsuxz}紂緄繆绉轋њћſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿ½¾þÿĿޮ߮߮߭߭߭߭͛Λ߬߬߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllk_^]\[ZYXWVUTSRQPONMLKKJ|IyHwGtGrFoEmEkDiCfCdBbBaA_A]@\?Z?Y?X>W>V>V>U>U=U=T=U=U>U>V>V>W?X?Y@[@\A_CaCcDeFgGiHkInKpLsNuOxQ{S}UVXZ\_acegjlnqsvxz}終緄繇绉辋њћſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ߯߯߮߮߮߮߮߭ΜΛ߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkk^]\[ZYXXWVUTSRQPONMLKKJ}IzHxHuGsFpFnElDjDgCeCdBbA`A^@]@[?Z?Y?X?X>W>V>V>V>V>V>V>V>W>X?X?Y@[@\A]A`CbDdEfFhGjHlJoKqMtNvPyQ|S~UWY[]_acehjloqsvx{}糀終緄纇缉辋њћſĿľþý½½ݿݿݿݾݾݾܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿ߯߯߯߯߯߮߮߮ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjj]\[ZYXXWVUTSRQPPNMLLKJ~I{IyHvGtGrFoEmEkDiCgCeBcBaA`A^@]@\@[?Z?Y?X?X?W>W>W>W>W?X?X?Y?Z@[@\A]A^BbDcEeFgGiHkInJpLrMuOwPzR}TUWY[]_adfhjmoqtvy{}糀終縅纇缉њћſĿľþþý½¼ݿݿݿݾݾݾݾܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þÿĿ߰߰߰߯߯߯ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjj]\[ZYXXWVUTSRQQPNMMLKJJ}IzHxHuGsFpFnElDjDhCfCdBcBaA`A^@]@\@[@Z?Z?Y?Y?Y?Y?Y?Y?Y?Z@[@[@\A]A_B`CcDeEgFiGkHmJoKqLtNvOyQ{R~TVXZ\^`bdfikmortvy{~紀綂縅纇轉њћͳȷȶȶȵȵȵȴȴȳȳȳDzȲȲȱȱȰǰȰǯȯǯȮȮȮȭȭȭȭȬȬȬȫȫȫȫȫȪȪȪȪȪȩȩȩȩȩȩȩȩȩȨȨȨȨȨȨȨȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȫȫȫȫȫȬȬȬȭȭȭȮȮȮǯȯǯȰǰȰȱȱȲȲ͞͞͞͞͞͝͝͝ΝΜΜΜΛΩΨϨϧϦϥϥϤϣТССРПОѝћћњљҘҗҖҕҔҔӓӒӑӐӏӎԍԌԋԋԊԉՈՇՆՅՄՃււցր~}}|{zyxwvuutsrrqppoonmmllkkjjiihh\[[ZYXWVUUTSRQQPNMMLKJJ~I{HyHvGtFrFoEmEkDiCgCfBdBcBaA`A_@^@]@\@[@[?Z?Z?Z?Z?Z@[@[@\@]A]A^A`BaCdDeEgFiGkHmIoJqKsLuNwOzP|R~S߁U߃V߆X߈Zߊ\ߍ]ߏ_ߒaߔcߖeߙgޛhޝjޟlޡnޤpަrިtݩvݫxݭzݯ|ݱ~ҕҖҗҘњћћќѝОПРССТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲͳȷȶȶȶȵȵȴȴȴȳȳȳȲȲDZȱȱȰȰȰȯȯȯȮȮȮȮȭȭȭȬȬȬȬȬȫȫȫȫȫȪȪȪȪȪȪȪȩȩȩȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȪȪȪȫȫȫȫȫȬȬȬȬȬȭȭȭȮȮȮȯȯȯȰȰȰȱȱDZȲȲȳ͟͟͞͞͞͞͞͝ΝΝΜΜΪΩΨϨϧϦϥϥϤϣТСРРПОѝћћњљҘҗҖҕҔғӓӒӑӐӏӎԍԌԋԊԊԉՈՇՆՅՄՃււցր~}||{zyxwvuttsrrqpponnmmllkjjjiihh\[[ZYXWVVUTSRQQPNNMLKKJI|IzHxGuGsFqFoEmDkDiCgCfCdBcBbA`A_A^A^@]@]@\@\@\@\@\@\@]A^A^A_B`BaCcCeEgFiFjGlHnIpJrLtMvNyO{Q}R߀T߂U߄W߇Y߉Zߋ\ߎ^ߐ`ߓaߕcߗeߙgޜiޞkޠmޢoޤqަrިtݪvݬxݮzݯ|ݱ~ҕҖҗҘњћћќѝОПРРСТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿΝΝ߆߅߃߂߁߀wv{zyxwvuttsrqpoonmmlkkjjii\[ZZYXWVUTSSRQPONMMLKJJ~I|IyHwGuGsFqFoEmEkDiDhCfCeBcBbBaBaA`A_A_A^A^A^A^A^A_A_B`BaBbCcDdDeEhFjGkHmIoKqLsMvNxPzQ}STVXZ\^`bdfhjloqsuxz|絁縃纆輈њњſĿĿľþþþý½½½©ȬȬ½½½¾þþþÿĿĿΞά߆߄߃߂߁߀wv{zyxwvutssrqpoonmllkkjiih\[[ZYXWVUTTSRQPONNMLKKJJ}I{HyHvGtGrFpFoEmEkDjDhCfCeCdBcBcBbBaBaB`B`B`B`B`BaBaCbCcCdDeDfEgFjGlHmIoJqKsMuNwOzQ|R~TUWYZ\^`bdfikmoqtvx{}綁縄细轈њњſſĿĿľþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿέά߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiih]\[ZYXWVUUTSRQQOONMMLKKJI|IzHxHvGtGrFpFoEmElDjDhCgCfCeCeCdCcBcBbBbBbBbCcCcCcCdDeDfEgEhFiFlHnIoJqKsLuMwOyP{Q~STVXY[]_acegiknprtvy{}絀緂繄軇轉љњſſĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿέά߆߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiihh\[ZYXWWVUTSSRQPONNMLLKJJ~I|IzHxHvGtGrFqFoEnElEkDiDiDhDgCfCeCeCeCeCdCeCeDeDfDfEgEhEiFjGkGnIpJqKsLuMwNyP{Q}RTUWYZ\^`bdfhjlnpsuwy|~絀縂纅輇љњūȮȮĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihh\[ZYXXWVUTTSRQPOONMMLKKJJ~I|IzHxHvGtGsFqFpFnEmElDkEjDiDhDhDgDgDgDgDgDgDgEhEiEiFjFkGlHmHpJrKsLuMwNyO{P}RSUVXY[]_acdfikmoqsuxz|~綁繃軅轇љњƬȯǯέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihh\[ZYYXWVUUTSRRPPONNMMLKKJJ~I|IzHxHwHuGsGrFqFoEnEmElEkEkEjEjEiEiEiEiEiEjFjFkFlGlGmHnIpIrKtLvMwNyO{P}QSTVWYZ\^`acegiknprtvx{}緁繄軆轈љњǬȰȰέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmlkkjjiihhg[[ZYXWVVUTSSRQPOONNMLLKKJJ~I|IzIyHwHvGtGsGqFpFoFoFnFmFmFlFlFlFlFlFlFlGmGmGnHoHpIqJrJuLvMxNyO{P}QRTUWXZ[]_abdfhjlnpsuwy{}綀縂躄輆љњȭȰȰέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihhg\[ZYXXWVUUTSRQQPOONNMLLKKKJ~J}I{IyIxHvHuHtGsGrGqGpGpGoGoGnGnGnGnGoGoHoHpHqIqIrJsKtKwMyNzO|P}QRTUVXY[\^`bcegikmoqsuxz|~緀繃軅轇љњɮȱȱέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihhgg[ZZYXWVVUTTSRQPPOONNMMLLKKJJ}J|IzIyIxHvHuHtHtHsHrGrGqHqHqHqHqHqHqIrIrIsJtJuKvLwLzN{O|P~QRSUVWYZ\]_abdfhjlnprtvx{}縁躃輅љњʮȲȲέά߆߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiihhgg[[ZYXXWVUUTSRQQPPOONNMMLLKKKJ~J}J{IzIyIwHwIvIuHuHtHtHtItItItItItJuJuJvKwLwLxMzN|O}PQRSTVWXZ[]^`bdegikmoqsuwy{~緀蹂軄轆љњ˯ȳȳέά߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiihhggf[ZYYXWWVUUTSRQQPPOONNMMMLLKKKK~J}J{IzIzJyIxIxIwIwIvJvJvJvJwJwKwKxLxLyMzM{N|OPQRSTVWXZ[\^`acefhjlnprtvxz|~縀躃輅љњ̰ȳȳέά߆߄߃߂߁߀wv{zyxwvutssrqpoonmllkkjiihhggg[[ZYXXWVVUTSSRQQPPOONNNMMMLLLKKK~J}J|J|J{JzJzJyJyKyKyKyKyKzLzL{M{M|N}N~OPRRSUVWXY[\^_abdfgikmoqsuwy{}蹁軃轅њњͰȴȴέά߆߅߃߂߁߀wv{zyxwvuttsrqpoonmmlkkjjiihhggf[ZZYXWWVVUTSSRRQQPPOONNNMMMLLLLKKK~K~K}K}K|K|L|L|L|L|M|M}M}N~NOPPQSTUVWXY[\]_`bcegijlnprtvxz|~蹀躂輄њњαȵȵέά߆߅߄߂߁߀wv{zyxwvuutsrqpponmmllkjjiihhggg\[ZYYXWWVUTTSSRRQQPPOOONNNNMMMMLLLLLLLMMMMMNNOOPPQRRTUVWXYZ\]^`acefhjkmoqsuwy{}躁較轅њћϲȵȵέά߆߅߄߃߂߀wv{zyxwwvutsrqqponnmllkkjiihhhggf[[ZYYXWWVUTTSSRRQQQPPPOOONNNNNMMMMMMNNNNNOOOPPQQRSTUVWXYZ\]^`acdfgikmnprtvxz|~蹀軂轄њћвȶȶέά߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjjiihhgggf[ZZYXXWWVUTTTSSRRQQQPPPPOOOOONNNNNNOOOOOPPPQQRSSTUVWXY[\]^`abdegijlnoqsuwy{}躁較њћѳǷȷέά߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjjiihhhgggf[ZZYXXWVVUUTTSSSRRRQQQPPPPPPOOOOPPPPPPQQQRRSSTUUVXYZ[\]^`abdeghjkmoqrtvxz|~躀輂њћέά߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkkjjiihhhggg\[ZZYYXWVVUUUTTSSSRRRRQQQQQQPPQQQQQQQQRRSSSTUUVWWYZ[\]^`abdefhikmnprtuwy{}軁轃њћͭά߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllkkjjiihhhggg\[ZZYYXWWVVUUUTTTSSSRRRRRRRQQRRRRRRRSSSTTUUVVWXYZ[\]_`abcefhiklnpqsuvxz|~軀轂њћͭέ߆߅߄߃߂߁xw}|{zyxwvvutsrrqpoonmmllkkjjiiihhhggg\[[ZYYXXWWWVVVUUUUTTTTTTTSSTTTTTUUUUVVWWXXYZ[[]^_`abdefgijlmoprtuwy{|~輀њћͭέ߅߄߃߂߁xx}|{zyyxwvutssrqpponnmmllkkjjiiihhhhgg\[[ZYYYXXWWWWVVVVUUUUUUUTTUUUUVVVVWWXXYYZZ[\]__`bcdefgijlmoprsuwxz|}ћћͭέ߆߅߄߃߂yx~}|{zyxwvuutsrrqpponnmmllkkjjiiihhhhhg\\[ZZYYYXXXWWWWWVVVVVVVUUVVVWWWWXXXYYZ[[\]]^`abcdefhijlmnpqsuvxy{}ћќͭέ߆߅߄߃߂yx~}|{zyxxwvutssrqqpoonnmlllkkjjjiiihhhhh\\[[ZZZYYYXXXXXWWWWWWWVVWWXXXXYYYZZ[[\]]^_`abcdeghijlmnpqstvwy{|~ћќͮέ߅߄߃߂yx~}||{zyxwvuutsrrqppoonmmlllkkjjjiiiiihhh\\\[[ZZZZYYYYYXXXXXXXWWXYYYYZZZ[[\\]]^__`acdefghijlmnpqstvwyz|~ћќͮͭ߆߅߄߃yy~}|{zyxxwvuttsrrqppoonmmlllkkkjjjiiiiii]]\\\[[[[ZZZZZZYYYYYYXXZZZZ[[[\\\]]^__`aabdefghiklmnpqrtuwxz{}ћќͮͭ߆߅߄߃zy~}||{zyxwvvutssrqqpponnmmmllkkkjjjjjiiiii]]]\\\\[[[[[[[ZZZ[[YY[[[\\\\]]^^__`aabcdffgijklmnpqrtuwxz{ћќͮͭ߆߅߄zy߀~}|{zyyxwvuutssrqqppoonnmmlllkkkkjjjjjjjj^^]]]]\\\\\\\\\\\\ZZ\\]]]]^^__``aabcddeghijklmopqrtuwxy{ќќͮͭ߆߅߄zz߀~}}|{zyxwwvuttsrrqqppoonnmmmlllkkkkkjjjjjj_^^^^]]]]]]]]]]]][[]^^^^__```abbccdefghijklnopqrtuvxy{ќѝͮͮ߆߅{z߁߀~}|{zzyxwvvuttsrrqqppoonnmmmlllllkkkkkkkkkk____^^^^^^^^^^^\\____```aabbcddeffghjklmnopqstuvxyќѝͮͮ߆߅{{߁߀~~}|{zyyxwvvuttsrrqqppoonnnmmmmlllllllkllllll``___________]]```aaabbccddeffghiiklmnoprstuwxќѝͯͮ߆|{߂߁߀~}||{zyxxwvvuttsrrqqpppoonnnmmmmmllllllllllmmma````````aa^_abbbbccddeeffghhijkmnopqrstuwѝНͯͮ߆|{߂߁߁߀~}|{{zyxxwvvuttssrrqqppooonnnnmmmmmmmmmmmmmmnnnbbbbbbbbb``ccccddeeffgghhijkllnopqrstvѝОͯͮ}|߃߂߁߀~~}|{zzyxxwvvuttssrrqqpppoooonnnnnnnnnnnnnnnnoooopccccccaadddeefffgghiijkklmnpqrstѝОͯͮ}|߄߃߂߁߀~}}|{zzyxxwvvuttssrrrqqppppooooonnnnnnnnoooooppppqqrddebbeeffggghhiijkklmnnoqr߅߆НОͯͯ}}߄߃߂߁߁߀~}}|{zzyxxwvvuuttssrrrqqqppppooooooooooooppppqqqrrrsstpcggghhhiijjkklmnnop߂߄߅߆ООͯͯ~}߅߄߃߂߁߀߀~}||{zzyxxwvvuuttsssrrrqqqqppppppppppppppqqqqrrrsssttqqvvwxxyzz{||}~߀߀߁߂߃߅߆ОПͰͯ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvuuttsssrrrrqqqqqqqqqqqqqqqqrrrrsssttuuqqwwxxyzz{||}~߀߁߂߃߄߅߆ОПͰͯ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvuuutttsssrrrrrrrqqqqrrrrrrrssstttuuuvrrwxyyzz{||}~߀߁߂߃߄߄߆ОПͰͯ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwvvuuutttssssssrrrrrrrrsssssstttuuuvvwrsxyyz{{|}}~߀߁߂߃߃߄߅ПРͰͯր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwwvvvuuutttttsssssssssstttttuuuvvvwwxssyzz{{|}}~߀߁߂߂߃߄߅߆ПРͰͰրր߆߅߄߃߂߂߁߀~}}||{zzyyxxxwwvvvuuuuuttttttttttttuuuuuvvvwwxxxttzz{||}}~߀߁߂߂߃߄߅߆ПРͱͰցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyxxxwwwvvvvuuuuuuuuuuuuuuvvvvwwwxxxyytu{{||}~~߀߀߁߂߂߃߄߅߆߆РРͱͰւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyyyxxxwwwwvvvvvvvvvvvvvvwwwwxxxyyyzzuu||}}~~߀߀߁߂߃߃߄߅߆߆РСͱͰւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zzyyyxxxxwwwwwwwwwwwwwwwwxxxxyyyzz{{vv}}~~߀߁߁߂߃߃߄߅߆߆РСͱͱփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{{zzzyyyyxxxxxxxxxxxxxxxxyyyyzzz{{||vw}~~߀߀߁߁߂߃߄߄߅߆߆ССͱͱՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||{{{zzzzyyyyyyyyyyyyyyyyzzzz{{{|||}ww~߀߁߁߂߂߃߄߄߅߆СТͲͱՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}|||{{{{zzzzzzzzzzzzzzzz{{{{|||}}}~xx߀߀߁߁߂߃߃߄߅߅߆СТͲͱՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}}}||||{{{{{{{{{{{{{{{{||||}}}~~~xy߀߁߁߂߂߃߄߄߅߅߆ТϣͲͱՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~~~}}}}||||||||||||||||}}}}~~~߀yy߁߂߂߃߃߄߄߅߆߆ТϣͲՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀~~~~}}}}}}}}}}}}}}}}~~~~߀߀߀߁zz߂߃߃߄߄߅߅߆ϣϣͲՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀߀~~~~~~~~~~~~~~~~߀߀߀߁߁߁߂{{߃߄߄߅߅߆߆ϣϤͲՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁߀߀߀߀߀߀߀߀߁߁߁߂߂߂߃{|߄߅߅߆߆ϣϤՈՇ߆߆߅߅߄߄߃߃߃߂߂߂߁߁߁߁߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߁߁߁߁߂߂߂߃߃߃߄||߅߆߆ϤϤԈՈ߆߆߅߅߄߄߄߃߃߃߂߂߂߂߂߁߁߁߁߁߁߁߁߁߁߁߁߁߁߂߂߂߂߂߃߃߃߄߄߄߅}}߆ϤϥԉԈ߆߆߅߅߅߄߄߄߄߃߃߃߃߂߂߂߂߂߂߂߂߂߂߂߂߂߂߃߃߃߃߄߄߄߄߅߅߅߆~~ϤϥԊԉ߆߆߅߅߅߅߄߄߄߄߄߃߃߃߃߃߃߃߃߃߃߃߃߄߄߄߄߄߅߅߅߅߆߆~ϥϦԊԊ߆߆߆߅߅߅߅߅߅߄߄߄߄߄߄߄߄߄߄߅߅߅߅߅߅߆߆߆րϥϦԋԋ߆߆߆߆߆߆߅߅߅߅߅߅߅߅߆߆߆߆߆߆րրϦϦԌԋ߆߆߆߆߆߆ցցϦϧԍԌււϧϧͲͲͱͰͰͯͯͮͮέάάΫΫΪΩΨϧϧϦϥϥϤϤϣТТСРРПООѝќќћњњљјҘҗҖҖҕҕҔғӓӒӑӑӐӐӏӎԍԍԌԌԋԋԊԊԉԉԈՈՇՇՇՆՆՅՅՅՄՄՄՃՃփւււււցցցցցցցրրրրրրրրրրցցցցցցցւււււփՃՃՄՄՄՅՅՅՆՆՇՇՇՈԈԉԉԊԊԋԋԌԍԍӎӎӏӐӐӑӑӒӓғҔҕҕҖҖҗҘјљњњћќќѝООПРРСТТϣϤϤϥϥϦϧΨΩΩΪΫΫάάέͮͮͯͯͰͰͱͲͲͲͱͱͰͰͯͮͮͭέάάΫΪΩΨΨϧϧϦϥϥϤϣϣТССРППОНѝќќћњњљјҘҗҖҖҕҕҔғӓӒӒӑӐӐӏӎӎԍԌԌԋԋԊԊԊԉԉՈՈՇՇՇՆՆՅՅՅՄՄՄՄՃՃփփւււււււցցցցցցցցցցցցւււււււփփՃՃՄՄՄՄՅՅՅՆՆՇՇՇՈՈԉԉԊԊԊԋԋԌԌӎӎӏӏӐӐӑӒӒӓғҔҕҕҖҖҗҘјљњњћќќѝНОППРССТϣϣϤϥϥϦϧΨΨΩΪΪΫάάέͭͮͮͯͰͰͱͱͲӏӏՅՅΨΩӐӐՆՆΩΩӑӐՇՇΩΪӒӑՈՈΪΪӒӒԈԉΪΫғӓԉԊΪΫҔғԊԊΫΫҕҔԋԋΫάҕҕԌԌάάҖҖԍԍάέҗҖӎӎέͭҗҗӎӏͭͮјҘӏӐͮͮљљӐӐͮͮњљӑӑͮͯњњӒӒͯͯћћғғͯͰќќҔҔͰͰѝќҕҕͰͱНѝҕҖͱͱООҖҗͱͱППҗҗͱͲРПјјͲРРљљССњњТСћћϣТћќϣϣќќϤϤѝНϥϤООϥϥППϦϦРРϧϦРСϧϧССΨΨТТΩΩϣϣΪΩϤϤΪΪϤϥΫΫϥϥάΫϦϦέέϧϨͭͭΨΨͮͮΩΩͯͮΪΪͯͯΪΪͰͰΫΫͱͰάάͱͱάέͲͱͭͭͲͮͮͯͯͯͯͰͰͰͱͱͱͲͲ \ No newline at end of file diff --git a/tools/vlm_oracle/radar_512x384.ppm b/tools/vlm_oracle/radar_512x384.ppm new file mode 100644 index 000000000..eee57ad3f --- /dev/null +++ b/tools/vlm_oracle/radar_512x384.ppm @@ -0,0 +1,4 @@ +P6 +512 384 +255 +ѳǷȷвȶȶϲȵȵαȵȵͰȴȴ̰ȳȳ˯ȳȳʮȲȲɮȱȱȭȰȰǬȰȰƬȯǯūȮȮĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿſſĿĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿſſĿĿľþþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿſĿĿľþþþý½½½½ȬȬ½½½¾þþþÿĿĿѳȷſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿгȷȷſſĿľþþý½½¼ݿݿȪȪݿݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݿݿݿݿݿ½½¾þþÿĿĿϲȶȶſĿĿľþý½½¼ݿݿݿݾݾݾȩȩܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽݾݾݾݾݾݿݿݿ½½¾þÿĿĿβȵȶſĿľþþý½¼ݿݿݿݾݾݾݾܽܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þþÿĿαȵȵſĿľþý½½ݿݿݿݾݾݾܼܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿͱȴȵſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽܺȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ̰ȴȴſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿݿ½¾þÿĿ˰ȳȴſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݾݿݿ½¾ÿĿʯȳȳſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ڷȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽܽݾݾݿݿ¾þÿĿʯȲȲſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶڵȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܼܽܽݾݾݿݿ½¾þĿɮDZDzſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܻܼܺܽܽݾݾݿݿ¾þÿĿȮȱȱſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ۺܻܻܼܺܽܽݾݾݿ½¾ÿĿǭǰȱſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹۹ۺܻܼܼܺܽܽݾݿݿ¾þÿƬȰȰſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱٰȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۸۹۹ۺܻܻܼܼܽݾݾݿ½¾ÿĿŬǯǰſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذددȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿ½¾ÿĿīȯȯſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿ¾ÿīȮȮſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخحح׭ȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݿ¾êȭȮſľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬׫ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲٳڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽݾݿªȭȭͳȷȶȶȵȵȴȴȳȳȲȲȱȱȰǯȯȮȮȭȭȬȬȫȪȪȩȩȨȨȧȧȦȦȥȥȤȤȣȣȢȢȡȡȠȠȠȟȟȞȞȞȝȝȝȝȜȜȜțțțțțțȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚțțțțțțȜȜȜȝȝȝȝȞȞȞȟȟȠȠȠȡȡȢȢȣȣȤȤȥȥȦȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯǯȰȱȱȲȲȳȳȴȴȵȵȶȶȷͳȷȷȶȶȵǴȴȳȳȲȲȱȱȰȰȯȮȮȭȭȬȬȫȪȪȩȩȨȨȧȧȦȥȥȤȤȣȣȢȢȡȡȡȠȠȟȟȞȞȞȝȝȝȜȜȜțțțțȚȚȚȚȚșșșșșșșșșșșșșșșșșȚȚȚȚȚțțțțȜȜȜȝȝȝȞȞȞȟȟȠȠȡȡȡȢȢȣȣȤȤȥȥȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯȰȰȱȱȲȲȳȳȴǴȵȶȶȷȷſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֧֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدذٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܼܺܽݾݾݿȫȬ¾þÿſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨զȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܻܼܺܽݾݾȪȫ¾þÿͳȷſľþý¼ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭حخددذٱٲٲٳڴڵڵڶ۷۸۹۹ܻܼܺܽݾȪȪ¾þÿȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۺܻܼܽȩȪݿ¾ÿĿȷȶſſľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴٳٳٲٱٰددخح׬׬׫תת֧֧֦֩֩֨զեեդդգգԣԢȖȕԡԡԡԡԡԠԠԠԠԠԠԠԠԠԠԠԡԡԡԡԡԢԢԢԣգգդդեեզ֦֧֧֨֩֩תת׫׬׬حخددٰٱٲٳٳڴڵڶڷ۸۸۹ۺܻܼȩȩݾݿ¾ÿĿѳȶǶſľý¼ݿݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩ת׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷۸۹ۺܻȨȩݾݿݿ¾ÿѲȶȶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨֩תת׫׬׭حخدذٱٱٲٳڴڵڶڷ۷۸۹ۺȧȨܽݾݿ¾ÿвȶȵſľý½¼ݿݾܼܻܽܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ۷۸۹ȧȧܼܽݾݿ½¾ÿвȵȵſſľý¼ݿݾܼܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞӝȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֧֨֩תת׫׬حخددٰٱٲٳڴڵڶڷ۸۸Ȧȧܻܼܽݾݿ¾ÿĿϱȵȴſľý¼ݿݾܼܻܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜҜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդեզ֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵڶڷ۸ȦȦܻܼܺܽݾݿ¾ÿϱȵȴſľý¼ݿݾݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜққȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵڶڷȥȦۺܻܼܽݾݾݿ¾ÿαȴȴſľľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣգդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵڶȤȥ۹ۺܻܼܽݾݿ¾þĿΰȴȳſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљјȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡԢգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵȤȥ۸۹ۺܻܼܽݾݿ¾ÿͰȴȳſľý¼ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјїȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԡԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڴȣȤ۷۸۹ܻܼܺܽݾݿ¾ÿͰȳȳƿſľý¼ݿݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїііȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԠԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڴȣȣڷ۸۹ۺܻܼܽݾݿ¾ÿĿ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞӟԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲٳȢȣڶ۷۸۹ۺܻܼܽݾݿ¾ÿ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӞӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲȢȢڵڶ۷۸۹ܻܼܺܽݾݿ¾ÿ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГВȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӝӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱȡȢڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒϑȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқҜӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡȡڴڵڶ۷۸۹ۺܻܼܽݾݿ¾ÿˮȲȱſľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱذدخ׭׫ת֧֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙљјїііЕДДГГϒϒϑϑϐȉȈϏΏΏΎΎΎΎΎΎΎ΍ΎΎΎΎΎΎΎΏΏϏϏϐϐϑϑϒϒГГДДЕііїјљҙҚқҜӝӞӟӟԠԡԢգդե֦֧֨֩ת׫׭خدذȠȡٳڴڵڶ۷۸۹ܻܼܽݾݿ¾ÿʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٳٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐϏȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙҚқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠȠٲٳڴڶڷ۸۹ۺܻܼܽݾݿ¾ÿͲͲʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠٲٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͱͱɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٱٲٳڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿͰͱɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٱٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііїјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰٱٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͰͰɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕііїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٱٲٳڴڵڶ۸۹ۺܻܼܽݾݿÿĿͯͯȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱذدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕііїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذٱٲڴڵڶ۷۸۹ܼܺܽݾݿ¾ÿͯͯȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДЕііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٱٲٳڴڵڷ۸۹ۺܻܼܽݾ¾ÿͲͮͮȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذدخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊̈ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГДЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذٱٲڴڵڶ۷۸۹ܻܼܽݾݿ¾ÿͲͱͭͭǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈̇ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒГДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٱٲٳڴڵڷ۸۹ۺܻܼݾݿ¾ÿͱͱάέǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰدخ׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇̆Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑВГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰٱٳڴڵڶ۷۹ۺܻܼܽݾݿ¾ÿͱͰάάǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذدح׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٱٲٳڵڶ۷۸۹ܼܺܽݾݿ¾ÿͰͰΫΫƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׭׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅˅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰٲٳڴڵڶ۸۹ۺܻܼܽݿ¾ÿͯͯΪΪƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׬׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٱٲڴڵڶ۷۸ۺܻܼܽݾݿ¾ÿͯͮΪΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϏϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱٲٳڴڶڷ۸۹ܼܺܽݾݿ¾ÿͮͮΩΩūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃˂~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎΏϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿͭͭΨΨŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬׫ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ΎϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿέέϧϨŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲٳڴڶ۷۸۹ܻܼܽݾݿÿάάϧϧƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁʀ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋Ό΍ΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲٳڴڵڷ۸۹ܻܺܽݾݿ¾ÿάΫϦϦſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋Ό΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿΫΫϥϥſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֩֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋Ό΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿΪΪϤϥſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֩֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ~||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊͋Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڴڶ۷۸۹ܻܼܽݾݿ¾ÿΪΩϤϤſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧֩զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~~~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊͋Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڵڷ۸۹ܼܺܽݾݿÿΩΩϣϣƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܽݾݿ¾ĿΨΨТТƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩֨զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~}}{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͊͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶ۷۹ۺܻܼݾݿ¾ÿϧϧССſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩֨եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}|}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶ۷۹ۺܻܼܽݿ¾ÿϧϦРСſľéȬȫݾܼܻܽۺ۸۷ڶڵٳٲٱدخح׬ת֦֩֨եդԢԡԠӟӝҜқҚјїіЕГВϑϐΏΎ΍Ό͉͈͊̇̇̆˅˄˃˂ʁʁʀ~~~}}}}||z{zz{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{||||}}}}~~~ʀʁʁ˂˃˄˅͈͉̆̇̇͊Ό΍ΎΏϐϑВГЕіїјҚқҜӝӟԠԡԢȗȘ֦֨֩ת׬حخدٱٲٳڵڶ۷۸ۺܻܼܽݾ¾ÿ߱ϦϦРРſľéȬȫݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخ׭׫ת֧֦֩եդԢԡԠӞӝҜқҙјїіДГϒϑϐΏ΍Ό͉͈͋͊̇̆̅˄˄˃˂ʁʀʀ~~~}~}}|}||zzzzz{zzzzzzyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzz{{{{||||}}}}~~~~ʀʀʁ˂˃˄˄͈͉̅̆̇͊͋Ό΍ΏϐϑϒГДіїјҙқҜӝӞԠԡԢȗȘ֦֧֩ת׫׭خدٱٲٳڴڶ۷۸۹ܻܼܽݾݿ¾ÿ߱߱ϥϥППſľéȬȫݿݾܼܻܽ۹۸ڷڶڴٳٲٰدخ׭׫ת֧֩զեգԢԡӟӞӝҜҚҙјїЕДГϒϑϏΎ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~~}}|}||{|zzzzzzzzzzyyyyyyyyyyxyxxxxxxxyyyyyyyyyyyzzzzzz{{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋Ό΍ΎϏϑϒГДЕїјҙҚҜӝӞӟԡԢȗȘզ֧֩ת׫׭خدٰٲٳڴڶڷ۸۹ܻܼܽݾݿ¾ÿ߰߰߰ϥϤООſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֧֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||{{zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܼܺܽݾݿ¾ÿ߯߯߰߰߰ϤϤѝНſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֧֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||{{zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܼܺܽݾݿÿޮ߮߯߯߯߰ϣϣќќſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֧֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|{{zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͈͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܽݾݿÿޭޭ߮߮߮߯߯ϣТћќſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͈͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽݾݿÿެޭޭ߭߮߮߮߯ТСћћſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿޫެެެ߭߭߭߮߮ССњњſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿݪޫޫެެ߬߭߭߭߮РРљљſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{{{yyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋Ό΍ΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿݪݪުޫޫެެ߬߬߭߭߭РПјјͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿݩݩݪުުޫޫޫ߬߬߬߭߭ППҗҗͱͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݨݨݩݩުުުޫޫ߫߬߬߬߬ООҖҗͱͱſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͈͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽܧݨݨݨݩީުުުޫ߫߫߫߬߬НѝҕҖͱͱſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֧֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|{{zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͈͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܦܧݧݨݨݨީީުުުߪ߫߫߫߫ѝќҕҕͰͱſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֧֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||{{zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܺܥܦܦݧݧݨݨިީީީުߪߪߪ߫߫ќќҔҔͰͰſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֧֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||{{zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܥܥܦܦݧݧݧݨިިީީީߪߪߪߪߪћћғғͯͰſľéȬȫݿݾܼܻܽ۹۸ڷڶڴٳٲٰدخ׭׫ת֧֩զեգԢԡӟӞӝҜҚҙјїЕДГϒϑϏΎ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~~}}|}||{|zzzzzzzzzzyyyyyyyyyyxyxxxxxxxyyyyyyyyyyyzzzzzz{{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋Ό΍ΎϏϑϒГДЕїјҙҚҜӝӞӟԡԢȗȘզ֧֩ת׫׭خدٰٲٳڴڶڷ۸۹ܤܥܥܦݦݧݧݧިިިީީߩߩߩߪߪߪњњӒӒͯͯͳȷȶȶȵȴȴȳDzȲȱȰȯǯȮȭȬȫȪȪȩȨȧȦȥȤȤȣȢȡȠȟȞȝȜțȚșșȘȗȖȕȔȓȒȑȐȏȏȎȍȌȋȊȉȉȈȇȆȅȅȄȃȃȂȁȁȀ~~~}~}}|}||||{{{{{{zzzzzzyzyyyyyyyyyyxyxxxxxxxxxxxyyyyyyyyyyyyzzzzzzz{{{{{{|||||}}}}~~~~ȀȁȁȂȃȃȄȅȅȆȇȈȉȉȊȋȌȍȎȏȏȐȑȒȓȔȕȖȗȘșșȚțȜȝȞȟȠȡȢȣȤȤȥ͕͖͖͖͗͗͗͗͘͘ΘΘΘΘΘΙΙΙΙΙϙϙϙϦϥϥϤϤϣϣТТССРРППООѝѝќќћћњњљљјҘҘҗҗҖҖҖҕҕҔҔҔғғғӒӒӒӒӑӑӑӑӐӐӐӐӐӐӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӐӐӐӐӐӐӑӑӑӑӒӒӒӒғғғҔҔҔҕҕҖҖҖҗҗҘҘјљљњњћћќќѝѝООППРРССТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͭͮͮͯͯͰͰͱͱͲͳȷȷȶȵȴȴȳȲȲȱȰȯȯȮȭȬȫȫȪȩȨȧȦȥȥȤȣȢȡȠȟȞȝȜțțȚșȘȗȖȕȔȓȒȑȐȐȏȎȍȌȋȊȊȉȈȇȆȆȅȄȄȃȂȁȁȀȀ~~~}}}}|}||{|{{{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{{||||}}}}}~~~ȀȀȁȁȂȃȄȄȅȆȆȇȈȉȊȊȋȌȍȎȏȐȐȑȒȓȔȕȖȗȘșȚțțȜȝȞȟȠȡȢȣȤȥ͕͕͕͖͖͖͗͗͗͗ΗΘΘΘΘΘΘΘΘΘϘϘϘϘϥϥϤϤϣϣТТСРРППООНѝќќћћњњљљљјҘҗҗҖҖҖҕҕҔҔҔғғӓӒӒӒӑӑӑӐӐӐӐӐӏӏӏӏӏӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӏӏӏӏӏӐӐӐӐӐӑӑӑӒӒӒӓғғҔҔҔҕҕҖҖҖҗҗҘјљљљњњћћќќѝНООППРРСТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͭͮͮͯͰͰͱͱͲͲſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩֨եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}|}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶۣۣۢܤܤܥܥݥݦݦݦާާާާިߨߨߨߨߨߨјҘӏӐͮͮƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩֨զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~}}{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͊͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶۣۣۢܤܤܤܥݥݦݦݦަާާާާߧߨߨߨߨߨҗҗӎ޲z轅轅輅輅輅輅輆輆輆輇輇輇輈轈轉轊辊辋ͭͮƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵۣۡۢۢܣܤܤݥݥݥݦݦަަާާާߧߧߧߧߧߧҗҖ轃輂輂ޱxްx軂躂躃躃躃躃纃纄纄纄纅纅纆纆织织终缉缉罊轋辌έͭſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧֩զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~~~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊͋Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڡۡۢۢܣܣܤܤݤݥݥݥݦަަަަާߧߧߧߧߧߧҖҖ輁輁軁軀躀躀߯v߯v踀踀縀縀緁緁緁緁緂緂緂縃縃縄縄繅繆纆纇终绉缊缋罌羍άέſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֩֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ~||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊͋Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڠۡۡۢۢܣܣܣܤݤݤݥݥݥަަަަަߦߦߦߦߧߧҕҕ轀~~~~߭t߭t~~~~~~絀絀絁絁綂綂綃緃緄縅縆繇纇纈绉缊缋罌美άάſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֩֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋Ό΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڠۡۡۢۢܢܣܣܤݤݤݥݥޥޥޥަަަߦߦߦߦߦߦҕҔ~~~}}}|||||߫s߫s|||||||}}}~~紀紁紁終絃綃緄緅縆繇纈纉绊缋罍美ΫάſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋Ό΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڠڠۡۡۢۢܢܣܣܣݤݤݤݥޥޥޥޥޥߥߦߦߦߦߦߦҔғ}}|||{{{zzzzzߩqߩqyyyzzzzz{{{||}~~糀糀紁紂絃綄緅緆縇繈纉绊缌罍美ΫΫƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁʀ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋Ό΍ΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲڟڠڠۡۡۡܢܢܣܣܣݤݤݤݤޥޥޥޥޥߥߥߥߥߥߥߥғӓ||{{zzzyyxxxxxwߧoߧowwwwwxxxxyyzz{{|}}~粀糁糂紃組綅緆縇繈纊绋缌罍羏ΪΫŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲڟڠ۠۠ۡۡܢܢܢܣݣݣݤݤݤޤޤޥޥޥߥߥߥߥߥߥߥӒӒ|{zzyyxxwwwvvvvuuߥmߥmuuuuuuuvvvwwxxyzz{|}~~沀糂紃組綅緆縇繉纊绋缍罎翐ΪΫŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬׫ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ΎϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱڟڟڠ۠۠ۡۡܢܢܢܣݣݣݣݤݤޤޤޤޤޤߤߥߥߥߤߤߤӒӑzzyyxwwvvuuutttsssߣkߣksssssssstttuuvwwxyyz{|}~汀沂紃組綅緇縈繉纋缌罎羏ΪΪūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃˂~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎΏϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲڟڟڠ۠۠ۡۡܢܢܢܣݣݣݣݣޤޤޤޤޤޤߤߤߤߤߤߤߤӑӐyxxwwvuuttsssrrrqqqiߠippppqqqqqrrssttuvvwxyz{|}~汁沂紃組綆緇縉纊绌缍羏ΩΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϏϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱڞڟڟڠ۠۠ۡܡܢܢܢܢݣݣݣݣޣޤޤޤޤޤߤߤߤߤߤߤߣӐӐyxwwvuutssrrqqpppoooohgnnnnnnooooppqqrsstuvwxyz{|}~氀汁泂約絅綇縈繊纋缍罎羐ΩΩƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׬׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٞڞڟڟ۠۠۠ۡܡܡܢܢݢݣݣݣݣޣޣޣޤޤޤߤߤߣߣߣߣߣӏӏxwvvutssrrqppoonnnmmmlfflllllllmmmnnnoppqrsstuvwyz{|~氀沂泃紅綆緈繉纋绌罎羐ΨΩƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׭׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅˅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰڞڞڟڟ۠۠۠ۡܡܡܢܢݢݢݣݣݣޣޣޣޣޣߣߣߣߣߣߣߣߣӏӎwvuttsrrqpponnmmlllkkkjddjjjjjjjjkkkllmmnoopqrstuvxyz{}~氀汁沃洄絆緇縉纋绌罎羐ΨΨǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذدح׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٝڞڞڟ۟۠۠۠ܡܡܡܢܢݢݢݢݣޣޣޣޣޣޣߣߣߣߣߣߣߢߢӎԍvutssrqpponnmmlkkjjjiihhbbhhgghhhhhiiijjkllmnopqrstuwxy{|~氁沂泄絅綇縉繊绌缎羐ϧΨǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰدخ׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇̆Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑВГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰڞڞڟڟ۟۠۠۠ܡܡܡܢݢݢݢݢݢޣޣޣޣޣޣߣߣߣߢߢߢߢߢԍԍutssrqpoonmmlkkjiihhgggff``feeeeeffffgghhiijklmnopqrstvwyz|}氀求泃紅綇縉繊绌缎羐ϧϨǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈̇ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒГДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٝڞڞڟڟ۟۠۠ۡܡܡܡܢݢݢݢݢݢޢޣޣޣޣޣߢߢߢߢߢߢߢԍԌtssrqponnmlkkjiihhgffeeedd__cccccccdddeeefgghijjklnopqrtuvxy{}~毀求沃洅綇緉繊绌缎羐ϧϧȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذدخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊̈ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГДЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذڞڞڞڟ۟۠۠۠ܡܡܡܡܢݢݢݢݢޢޢޢޢޢޢޢߢߢߢߢߢߢߡԌԋtsrqponnmlkjjihhgffeeddccbb]]aaaaaaaabbbccddefgghijklnoprstvwy{|~毀汁沃洅綇緉繊绌缎羐ϦϧȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДЕііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٝڞڞڟڟ۟۠۠۠ܡܡܡܡݢݢݢݢݢޢޢޢޢޢޢߢߢߢߢߢߡߡߡԋԋ߆߆srqpoonmlkjiihgffeddccbbaa``[[________```aabbcddefghijlmnoqrtuwyz|~毀氁沃洅綇緉繋绍罎羐ϦϦȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱذدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕііїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذڞڞڞڟ۟۟۠۠ܠܡܡܡܡݢݢݢݢݢޢޢޢޢޢޢߢߢߢߡߡߡߡԊԊ߆߆߆߅߅srqponmlkjiihgfeedccbaa``__^^ZY]]]]]]]]]^^^__`aabcdefghiklmoprsuwxz|~毀氁沃洅綇縉繋绍罏翑ϥϦɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕііїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢޢޢޢޢޢޢޢߢߡߡߡߡߡߡԊԉ߆߆߅߅߅߅߄߄qponmlkjjihgfeedcbba``_^^]]]\XX[[[[[[[[[\\\]]^__`abcdefgijkmnprsuwxz|~毀氂沃洅綇縉纋绍罏ϥϦɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٱٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііїјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰڞڞڞڟ۟۟۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޢޢߢߡߡߡߡߡߠߠԉԈ߆߆߅߅߅߄߄߄߄߃qponmlkjihgfeedcbaa`_^^]]\\[[ZV߆VYYYYYYYYYZZZ[[\]]^_`abcdeghiklnpqsuvxz|~毀求泄洆綈縊續缎羐ϤϥɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢݢޢޢޢޢޢޡߡߡߡߡߡߠߠԈՈ߆߆߅߅߄߄߄߃߃߃߂ponmlkjihggfedcba``_^]]\[[ZZYYXU߃UXWWWWWWWWXXXYYZ[[\]^_`abcefgijlnoqsuwxz|~毀求泄浆緈繊绌缎羐ϤϥʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠڞڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޢޡޡߡߡߡߡߠߠߠՈՇ߆߆߅߅߄߄߃߃߃߂߂߂ponmlkjihgfedcbaa`_^]]\[ZZYYXXWW߁S߁SVVUUUUUUUVVVWWXYYZ[\]^_`acdfgijlnoqsuwy{}毁汃泅絇緉繋绍罏翑ϤϤʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٳٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐϏȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙҚқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠ͑ڞڞڟڟ۟۠۠۠ܡܡܡܡݡݡݡݢݢݢޢޢޢޡޡޡߡߡߡߡߠߠߠߠՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁onmlkihhgfedcba`_^]]\[ZZYXXWVVUUR~RTT~T~S}S}S}S}S}T}T}T}U~U~UVWWXYZ[\]^`abdeghjlmoqsuwy{}氁沃洅綇縉續缎羐ϣϤˮȲȱſľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱذدخ׭׫ת֧֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙљјїііЕДДГГϒϒϑϑϐȉȈϏΏΏΎΎΎΎΎΎΎ΍ΎΎΎΎΎΎΎΏΏϏϏϐϐϑϑϒϒГГДДЕііїјљҙҚқҜӝӞӟӟԠԡԢգդե֦֧֨֩ת׫׭خدذȠ͑ڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޡޡޡޡߡߡߡߠߠߠߠՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀nmlkjihgfedcba`__^]\[ZYYXWWVUUTTS|P{P}R|R|R{R{RzRzRzRzRzRzRzS{S{T|T}U~U~VWXYZ[\^_`bceghjlnoqsuwy{}殀氂沄浆緈繊绌缎羑ϣϤ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒϑȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқҜӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡ͒ڟڟ۟۠۠۠۠ܡܡܡܡݡݡݢݢݢݢޢޢޡޡޡޡߡߡߡߠߠߠߠߟՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀mlkjihgfedcba`_^]\[ZZYXWVVUTTSSR~RzOyOzQzPyPxPxPwPwPwPwPwPwQxQxQxRyRzS{T|T}U~VWXY[\]_`bceghjlnprtvxz|~毀求泅絇緉繋绍罏ϣϣ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГВȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӝӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱ͒͒ڟڟ۠۠۠۠ܡܡܡܡܡݡݢݢݢݢޢޢޢޡޡޡޡߡߡߠߠߠߠߟՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~mlkjihgfedcba`_^]\[ZYXWWVUTTSRR~Q|Q{PxNwMxOwOvOvNuNuNtNtNtNtOtOuOuPvPvQwQxRySzT{T}U~WXYZ[]^`aceghjlnprtvx{}氁沃洅綈縊續缎羐Тϣ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӞӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲ͒͒ڟ۠۠۠ۡܡܡܡܡܡݡݢݢݢݢݢޢޢޢޡޡޡߡߡߡߠߠߠߠߟՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}lkjihgedcba`_^]\[ZZYXWVUTTSRR~Q|P{PzOxOuLtLuNtMsMsMrMrMrMqMqMqMrMrNrNsNtOuPuPvQxRySzT|U}VWXZ[]^`acegikmoqsuwy{}殀求泄絆緉繋绍罏Тϣ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞӟԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲڟ͒͒۠۠۠ۡۡܡܡܡܡܢݢݢݢݢݢޢޢޢޢޡޡޡߡߡߠߠߠߠߟՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}lkjigfedcba`_^]\[ZYXWVVUTSRRQ}P|PzOyNwNvMsKrKsLrLqLpKpKoKoKoKoKoKoLoLpLpMqMrNsOtOuPvQxRyS{T|V~WXZ[]^`bcegikmoqsvxz|~毁沃洅綇縊續缎羐СТͰȳȳƿſľý¼ݿݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїііȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԠԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڟ͓͒۠۠ۡۡܡܡܡܢܢݢݢݢݢݢޢޢޢޢޢޡޡߡߡߡߠߠߠߟߟՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||kjigfedcba`_^]\[ZYXWVUTTSRQP}P{OyNxNvMuLsLqJoJpKoJnJnJmJmJlJlJlJlJlJmJmKnKnLoLpMqNsOtPuQwRxSzT|U~WXY[]^`bdfhjlnprtvy{}氂泄絆緈繋绍罏СТͰȴȳſľý¼ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјїȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԡԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڠ͓͓ۡۡۡܡܡܢܢܢݢݢݢݢݢݢޢޢޢޢޢޡޡߡߡߠߠߠߠߟփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{kjigfedcba`_^]\[ZYXWVUTSRQQP}O{NyNwMuLtLrKqKnImHnImIlIkIkHjHjHjHjHjHjIjIkIkJlJmKnLoLpMqNsOtPvQxRzT{U}VXZ[]_`bdfhjlnqsuwz|~毀沃洅綇縊續缎羐ССΰȴȳſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљјȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡԢգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڠڠ͓͓ۡۡۡܡܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢޡߡߡߡߠߠߠߟߟւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zjihfedcba`_^]\[ZYXWVUTSRQPP}O{NyMwLuLsKrKpJoIlHkGkHjHiGiGhGhGgGgGgGgGgGhHhHiHjIjJkJmKnLoMqNrOtPvQwRyT{U}WXZ[]_acegikmortvx{}氂泄絆緉繋缍羏РСαȴȴſľľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣգդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڠ͓͓ۡۡۡܢܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢޢޡߡߡߡߠߠߠߟւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyihgedcba`_^]\[ZYXWVUTSRQPO}N{NyMwLuKsKqJpInImHjGiFiGhGgFfFfFeFeFeFeFeFeFeFfGgGgHhHiIjJlJmKnLpMrNsPuQwRyT{U~WXZ\^`bdfhjlnpsuwy|~毀沃洅綇繊绌罎РСϱȵȴſľý¼ݿݾݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜққȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܢݢݢݢݢݢݢޢޢޢޢޢޢޢߡߡߡߡߠߠߠߟցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyihgfdcba`_^]\[ZYXWVUTSRQPO}N{MyMwLuKsJqJoImHlHjGhFgEgFfEeEdEdEcEcDcDbEcEcEcEdEdFeFfGgHhHiIkJlKnLpMqNsPuQwRzT|U~WY[\^`bdfikmoqtvx{}求泄絇縉纋缍羐РРϱȵȴſľý¼ݿݾܼܻܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜҜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդեզ֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܣݣݣݣݣݣݣޣޣޢޢޢޢޢߢߡߡߡߡߠߠߟրր߆߅߄߃߂߂߁߀~}}||{zzyyxxhgfedba`_^]\[ZYXWVUTSRQPO~N{MyLwLuKsJqIoImHkGjGhFfEeDeEdDcDbDaDaCaC`C`C`DaDaDaDbEcEdFeFfGgHiIjJlKnLoMqNsPvQxSzT|VXY[]_acegjlnpsuwz|~氁沃絆緈繊绍罏ПРвȵȵſſľý¼ݿݾܼܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞӝȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֧֨֩תת׫׬حخددٰٱٲٳڴڵڶ͔͔ۢۢۢܢܣܣܣݣݣݣݣݣݣޣޣޣޣޢޢޢߢߢߡߡߡߠߠߠߟր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwgfedcb`_^]\[ZYXWVUTSRQPON|MzLxLuKsJqIoHmHkGjFhFfEdDcCcDbCaC`C_C_B_B^B^B^B_C_C_C`DaDbEcEdFeGgHhIjJlKnLpMrOtPvQxS{U}VXZ\^`bdfhkmortvy{}毀求洅綇縉绌罎ПРвȶȵſľý½¼ݿݾܼܻܽܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ͔͕ۢۢۢܣܣܣܣݣݣݣݣݣݣޣޣޣޣޣޢޢߢߢߡߡߡߠߠߠ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwgfedcba`_]\[ZYXWVUTSRQPON}M{LxLvKtJqIoHmHkGjFhEfEeDcCaCaC`B_B^B]B]A]A\A\A\A]B]B]B^C_C`DaDbEcFeGfHhIjJlKnLpMrOtPwRyT|U~WY[]_acegjlnqsuxz}汁泄絆縉纋缍羐ПРѲȶȶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨֩תת׫׬׭حخدذٱٱٲٳڴڵڶڷۣۣۣ͕͕ۢܣܣܣݣݤݤݤݣݣޣޣޣޣޣޣޢߢߢߢߡߡߡߠߠ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvgedcba`_^]\ZYXWVUTSRQPON~M|MyLwKtJrIpHnGlGjFhEfEeDcDaB`B_B^B]A\A\A[A[A[@Z@[A[A[A\A\B]B^C_C`DbEcFeGfHhIjJlKnLqNsOuQxSzT}VXZ\^`bdgikmprtwy|~氁沃絅緈繊缍羏ОПѳȶǶſľý¼ݿݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩ת׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷ۣۣۣܣ͕͕ܤܤݤݤݤݤݤݤޤޤޣޣޣޣޣߣߢߢߢߡߡߡߠߠ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvufedba`_^]\[ZYXWVUSRQPPON}MzLxKuJsIqHoHlGjFhEfEeDcCaC_B^A]A\A[A[@Z@Y@Y@Y@Y@Y@Y@Y@Z@[A[A\B^B_C`DbEcFeGgHiIkJmLoMqNtPvRyS{U~WY[]_acfhjloqtvx{}毀沂洅綇繉绌罎ОПȷȶſſľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴٳٳٲٱٰددخح׬׬׫תת֧֧֦֩֩֨զեեդդգգԣԢȖȕԡԡԡԡԡԠԠԠԠԠԠԠԠԠԠԠԡԡԡԡԡԢԢԢԣգգդդեեզ֦֧֧֨֩֩תת׫׬׬حخددٰٱٲٳٳڴڵڶڷ۸۸ۤۤܤܤ͕͕ܤݤݤݤݤݤݤޤޤޤޤޤޣޣߣߣߢߢߢߡߡߡߠ~}߅߄߃߂߁߀߀~}||{zzyxxwvvuutedcba`^]\[ZYXWVUTSRQPON~M|LyKwJtIrHoHmGkFiEgEeDcCaC`B^A]A\A[@Z@Y@X?X?X?W?W?W?X?X?X@Y@Z@[A\B]B_C`DbEcFeGgHiIlKnLpNsOuQxSzT}VXZ\^`cegilnpsuxz}求洄綆縉纋罎ОПȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۤܤܤܤ͖͖ݥݥݥݥݤݤޤޤޤޤޤޤޤߣߣߣߢߢߢߢߡߡߠ}}߄߃߂߁߁߀~}}|{zzyxxwvvuutfdcba`_^]\ZYXWVUTSRQPONM}L{KxJvJsIqHnGlFjEhEfDdCbC`B_A]A[@Z@Y@X?X?W?V>V>V>V>V>V>V?W?X?Y@Y@[A\B]B_C`DbEdFfGhIjJmKoMqNtPwRyT|UWY[]`bdfikmpruwy|~汁泃絆縈纋缍羏ООͳȷſľþý¼ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭حخددذٱٲٲٳڴڵڵڶ۷۸۹۹ܤܥܥܥܥ͖͖ݥݥݥݥݥޥޥޥޤޤޤޤޤߣߣߣߣߢߢߢߡߡ߆}|߄߃߂߁߀~}}|{zzyxxwvvuttsedca`_^]\[ZYXWVUTSRQPONM|LzKwJuIrHpGmGkFiEgDdCcCaB_A]A[@Z@Y?X?W?V>V>U>U>U>T>U>U>U>V>V?W?X@Y@[A\B^B_CaDcEeGgHiIkKnLpNsOvQxS{U~WY[]_acfhjmoqtvy{~氀沃絅緈纊缌羏НОſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨զȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܺܥܥܥܥܥ͖͖ݥݥݥݥޥޥޥޥޥޥޤޤߤߤߣߣߣߢߢߢߡ߆}|߃߂߁߀~~}|{zzyxxwvvuttssdcba`_^\[ZYXWVUTSRQPONM~L|KyJvItIqHoGlFjEhDfDcCbB`B^A\@Z@Y?X?W?V>U>U>T=T=S=S=S=T=T>U>U>V?W?X@Y@[A\B^C`DbEdFfGhIjJmKoMrOuPwRzT}VXZ\^`cegjlnqsvx{}氀沂紅緇繉绌美ѝОſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֧֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدذٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܺܦܦܦܦݦ͗͗ݦݦݦަަޥޥޥޥޥޥߤߤߤߣߣߣߢߢߢ߆߅|{߂߁߁߀~}|{{zyxxwvvuttssrdba`_^]\[ZYXWVUSRQPPONM~L{KxJvIsHpGnFkFiEgDeCcCaB_A]A[@Z?X?W?V>U>T=T=S=S=R=R=R=S=S=T=T>U>V?W?Y@ZA[A]B_CaDcEeGgHjIlKoLqNtPwRyS|UWY[^`bdgiknpsuxz}沂約緆繉绋罎ѝОſľþý¼ݿݿݾܼܻܻܽۺ۹۸۸۷ڶڵڵڴٳٳٲٱٱذذدخخح׭׬׬׫׫׫תת֩֩֩ȚȚ֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֨֨֨֨֨֨֩֩֩תת׫׫׫׬׬׭حخخدذذٱٱٲٳٳڴڵڵڶ۷۸۸۹ۺܻܻܼܦܦܦݦݦ͗͗ݦݦަަަަަޥޥޥߥߥߤߤߤߣߣߣߢߢ߆߅|{߂߁߀~}||{zyxxwvvuttsrrqcba`_^\[ZYXWVUTSRQPONML}KzJxIuIrHpGmFkEhDfDdCbB`B^A\@[@Y?W>V>U>T=S=S=R=RU>V?X?Y@[A\B^C`DbEdFgHiIkJnLpNsOvQyS{U~WY[]_bdfhkmpruwz|汁約綆繈绋罍ѝНſľþý¼ݿݿݾܼܼܻܽۺ۹۹۸۷ڷڶڵڴڴٳٳٲٱٱذذددخخح׭׬׬׫׫׫תתțț֩֩֩֩֨֨֨֨֨֨֨֨֨֨֨֨֨֩֩֩֩֩תתת׫׫׫׬׬׭حخخددذذٱٱٲٳٳڴڴڵڶڷ۷۸۹۹ۺܻܼܼܧܧܧݧݧݧ͗͗ݧާަަަަަަߥߥߥߥߤߤߤߣߣߣߢ߆߅߄{{߁߀~~}|{zyyxwvvuttsrrqqba`_^]\[ZYXWVUTSRQPONML|KzJwItHrGoGmFjEhDfCcCaB_A]A\@Z?X?W>V>U=T=S=R=QV>W?X@ZA\A^B_CbEdFfGhIkJmLpMsOuQxR{T~VXZ]_acfhjmortwy|~汁紃綆縈绊罍ќѝſľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬׫ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲٳڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽܧݧݧݧݧݧ͘͘ާާާާާަަߦߦߦߥߥߥߤߤߤߣߣ߆߅߄{z߁߀~}|{zzyxwvvuttsrrqqpba`_^][ZYXWVUTSRQPONMLK|KyJwItHqGoFlEjEgDeCcBaB_A]@[@Y?X>V>U>T=S=R=QU>V?X?Y@[A]B_CaDcFeGhHjJmKoMrOuPxR{T}VXZ\^acehjloqtvy{~汀糃綅縈纊罍ќѝſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخحح׭ȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݨݨݨݨݨݨ͘͘ިާާާާާߧߦߦߦߥߥߥߥߤߤߣ߆߅߄߃zz߀~}}|{zyxwwvuttsrrqqppb`_^]\[ZYXWVUTSRQPONMLK|JyIvItHqGnFlEiDgDeCbB`A^A\@[@Y?W>V>U=T=S=RV?X?Y@[A]B_CaDcEeGgHjIlKoMrNuPwRzT}VXZ\^`cegjloqtvx{}汀糂綅縇纊罌ќѝſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿݨݨݨݨݨި͘͘ިިިާާߧߧߧߦߦߦߥߥߥߤߤ߆߅߄߃zy߀~}|{zyyxwvuutssrqqppoa`_^]\[ZYWVUTSRQQPONML~K|JyIvHsGqGnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?Y@[A\B^C`DcEeFgHjIlKoLrNtPwRzT}UWZ\^`begilnqsvx{}汀糂綅縇纊缌ќќſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذددȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿݩݩݩݩީީ͙͘ިިިިߨߧߧߧߧߦߦߦߥߥ߆߅߄߃߂zy~}||{zyxwvvutssrqqppona`^]\[ZYXWVUTSRQPONMML~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>T=S=R=QV>W?Y@[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY\^`begilnqsuxz}糂組縇纉缌ћќſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱٰȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۸۹۹ۺܻܻܼܼܽݾݾݿݪݪݪީީީ͙͙ީީߨߨߨߨߨߧߧߧߦߦߦ߆߅߄߃߂yy~}|{zyxxwvuttsrrqppoon`_^]\[ZYXWVUTSRQPONMLK~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?Y@[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY[^`bdgilnpsuxz}糂組縇纉缌ћќſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹۹ۺܻܼܼܺܽܽݾݿݿݪݪުުުުު͙Ιީߩߩߩߨߨߨߨߧߧߧ߅߄߃߂߁yx~}||{zyxwvuutsrrqppoonm`_^]\[ZYXWVUTSRQPONMLKJ|JyIvHtGqFnFlEiDgCeCbB`A^A\@[?Y?W>V>U=T=S=RV>X?Y@[A]B_CaDcEeFgHjIlKoLrNuPwQzS}UWY[^`bdgiknpsuxz}糂綄縇纉缋ћќſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ۺܻܻܼܺܽܽݾݾݿ½ݫޫޫޫޫުު͚Ιߪߪߩߩߩߨߨߨߨߧ߆߅߄߃߂߁yx~}|{zyxxwvutssrqqpoonnm`_^\[ZYXWVUTSRRQPONMLKJ|JyIvHtGqFoElEjDgCeCcBaA_A]@[?Y?X>V>U>T=S=RV?X?Y@[A]B_CaDcEeFhHjImKoLrNuPxQzS}UWY[^`bdgiknpsuxz}糂綄縆纉罋ћќſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܻܼܺܽܽݾݾݿݿ¾þެޫޫޫޫޫޫ͚Κߪߪߪߩߩߩߩߨ߆߅߄߃߂߁yx~}|{zyxwvuutsrrqpponnmm_^]\[ZYXWVUTSRQPPONMLKJ|IzIwHtGrFoFmEjDhCeCcBaA_A]@[@Z?X>W>V>U=T=S=RV>W?X@Z@\A]B_CaDdEfGhHkImKpLrNuPxR{S~UWY[^`bdgiknpsuxz}糁綄縆纉罋ћќſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶڵȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܼܽܽݾݾݿݿ½¾þĿެެެެެޫ͚߫Κ߫ߪߪߪߪߩߩ߅߄߃߂߁߀xx}|{zyyxwvutssrqpponnmml_^]\[ZYXWVUTSRQPOONMLKJ}IzIwHuGrFpFmEkDhCfCdBbB`A^@\@Z?Y?W>V>U>T=S=S=RU>V>X?Y@[A\A^B`CbDdFfGiHkJnKpMsNvPxR{T~VWZ\^`bdgiknpsuxz}紁綄縆绉罋ћћſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ڷȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽܽݾݾݿݿ¾þÿĿޭޭެެ߬߬߬͛Κ߫߫߫ߪߪ߆߅߄߃߂߁߀xw}|{zyxwvvutsrrqpoonmmll_^]\[ZYXWVUTSRQPOONMLKJ}J{IxHuGsFpFnEkDiDgCeBbBaA_A]@[?Z?X>W>V>U=T=T=S=S=R=R=R=S=S=T=T=U>V>W?X?Z@[A]B_CaDcEeFgGjHlJnKqMtOvPyR|TVXZ\^`begilnpsuxz}紂綄繆绉罋њћſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݾݿݿ½¾ÿĿޭޭ߭߭߭߬߬͛Λ߬߫߫߫߆߅߄߃߂߁߀xw}|{zyxwvuttsrqpponnmmlk_^]\[ZYXWVUTSRQPOONMLKJ~J{IyHvGsGqFoElDjDhCeCcBaA`A^@\@[?Y?X>W>V>U>U=T=T=S=S=S=T=T=U>U>V>W?X?Y@[A\A^B`CbDdEfFhHjImJoLrMtOwQzR}TVXZ\^`cegilnqsuxz}紂緄繆绉轋њћſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿݿ½¾þÿĿޮ߮߮߭߭߭߭͛Λ߬߬߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllk_^]\[ZYXWVUTSRQPOONMLKKJ|IyHwGtGrFoEmEkDiCfCdBbBaA_A]@\?Z?Y?X>W>V>V>U>U=U=T=U=U>U>V>V>W?X?Y@[@\A]B_CaCcDeFgGiHkInKpLsNuOxQ{S}UVXZ\_acegjlnqsvxz}終緄繇绉辋њћſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽܺȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ߯߯߮߮߮߮߮߭ΜΛ߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkk^]\[ZYXXWVUTSRQPOONMLKKJ}IzHxHuGsFpFnElDjDgCeCdBbA`A^@]@[?Z?Y?X?X>W>V>V>V>V>V>V>V>W>X?X?Y@[@\A]A_B`CbDdEfFhGjHlJoKqMtNvPyQ|S~UWY[]_acehjloqsvx{}糀終緄纇缉辋њћſĿľþý½½ݿݿݿݾݾݾܼܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿ߯߯߯߯߯߮߮߮ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjj]\[ZYXXWVUTSRQPPONMLLKJ~I{IyHvGtGrFoEmEkDiCgCeBcBaA`A^@]@\@[?Z?Y?X?X?W>W>W>W>W?X?X?Y?Z@[@\A]A^B`CbDcEeFgGiHkInJpLrMuOwPzR}TUWY[]_adfhjmoqtvy{}糀終縅纇缉њћſĿľþþý½¼ݿݿݿݾݾݾݾܽܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þþÿĿ߰߰߰߯߯߯ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjj]\[ZYXXWVUTSRQQPONMMLKJJ}IzHxHuGsFpFnElDjDhCfCdBcBaA`A^@]@\@[@Z?Z?Y?Y?Y?Y?Y?Y?Y?Z@[@[@\A]A_B`CaCcDeEgFiGkHmJoKqLtNvOyQ{R~TVXZ\^`bdfikmortvy{~紀綂縅纇轉њћͳȷȷȶȶȵȵȵȴȴȳȳȳDzȲȲȱȱȰǰȰǯȯǯȮȮȮȭȭȭȭȬȬȬȫȫȫȫȫȪȪȪȪȪȩȩȩȩȩȩȩȩȩȩȨȨȨȨȨȨȨȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȫȫȫȫȫȬȬȬȭȭȭȭȮȮȮǯȯǯȰǰȰȱȱȲȲ͞͞͞͞͞͝͝͝ΝΜΜΜΛΩΨϨϧϦϥϥϤϣТССРПОѝќћћњљҘҗҖҕҔҔӓӒӑӐӏӎԍԌԋԋԊԉՈՇՆՅՄՃււցր~}}|{zyxxwvuutsrrqppoonmmllkkjjiihh\[[ZYXWVUUTSRQQPONMMLKJJ~I{HyHvGtFrFoEmEkDiCgCfBdBcBaA`A_@^@]@\@[@[?Z?Z?Z?Z?Z@[@[@\@]A]A^A`BaCbCdDeEgFiGkHmIoJqKsLuNwOzP|R~S߁U߃V߆X߈Zߊ\ߍ]ߏ_ߒaߔcߖeߙgޛhޝjޟlޡnޤpަrިtݩvݫxݭzݯ|ݱ~ҕҖҗҘљњћћќѝОПРССТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲͳȷȷȶȶȶȵȵȴȴȴȳȳȳȲȲDZȱȱȰȰȰȯȯȯȮȮȮȮȭȭȭȬȬȬȬȬȫȫȫȫȫȪȪȪȪȪȪȪȪȩȩȩȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȪȪȪȫȫȫȫȫȬȬȬȬȬȭȭȭȮȮȮȮȯȯȯȰȰȰȱȱDZȲȲȳ͟͟͞͞͞͞͞͝ΝΝΜΜΪΩΨϨϧϦϥϥϤϣТСРРПОѝќћћњљҘҗҖҕҔғӓӒӑӐӏӎԍԌԋԊԊԉՈՇՆՅՄՃււցր~}||{zyxxwvuttsrrqpponnmmllkjjjiihh\[[ZYXWVVUTSRQQPONNMLKKJI|IzHxGuGsFqFoEmDkDiCgCfCdBcBbA`A_A^A^@]@]@\@\@\@\@\@\@]A^A^A_B`BaCcCdDeEgFiFjGlHnIpJrLtMvNyO{Q}R߀T߂U߄W߇Y߉Zߋ\ߎ^ߐ`ߓaߕcߗeߙgޜiޞkޠmޢoޤqަrިtݪvݬxݮzݯ|ݱ~ҕҖҗҘљњћћќѝОПРРСТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿΝΝ߆߅߃߂߁߀~wv{zyxwvuttsrqpoonmmlkkjjii\[ZZYXWVUTSSRQPOONMMLKJJ~I|IyHwGuGsFqFoEmEkDiDhCfCeBcBbBaBaA`A_A_A^A^A^A^A^A_A_B`BaBbCcDdDeEgFhFjGkHmIoKqLsMvNxPzQ}STVXZ\^`bdfhjloqsuxz|絁縃纆輈њњſĿĿľþþþý½½½½ȬȬ½½½¾þþþÿĿĿΞά߆߄߃߂߁߀~wv{zyxwvutssrqpoonmllkkjiih\[[ZYXWVUTTSRQPPONNMLKKJJ}I{HyHvGtGrFpFoEmEkDjDhCfCeCdBcBcBbBaBaB`B`B`B`B`BaBaCbCcCdDeDfEgFhFjGlHmIoJqKsMuNwOzQ|R~TUWYZ\^`bdfikmoqtvx{}綁縄细轈њњſſĿĿľþþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿέά߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiih]\[ZYXWVUUTSRQQPOONMMLKKJI|IzHxHvGtGrFpFoEmElDjDhCgCfCeCeCdCcBcBbBbBbBbCcCcCcCdDeDfEgEhFiFjGlHnIoJqKsLuMwOyP{Q~STVXY[]_acegiknprtvy{}絀緂繄軇轉љњſſĿĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿέά߆߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiihh\[ZYXWWVUTSSRQPPONNMLLKJJ~I|IzHxHvGtGrFqFoEnElEkDiDiDhDgCfCeCeCeCeCdCeCeDeDfDfEgEhEiFjGkGmHnIpJqKsLuMwNyP{Q}RTUWYZ\^`bdfhjlnpsuwy|~絀縂纅輇љњūȮȮĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihh\[ZYXXWVUTTSRQQPOONMMLKKJJ~I|IzHxHvGtGsFqFpFnEmElDkEjDiDhDhDgDgDgDgDgDgDgEhEiEiFjFkGlHmHoIpJrKsLuMwNyO{P}RSUVXY[]_acdfikmoqsuxz|~綁繃軅轇љњƬȯǯέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihh\[ZYYXWVUUTSRRQPPONNMMLKKJJ~I|IzHxHwHuGsGrFqFoEnEmElEkEkEjEjEiEiEiEiEiEjFjFkFlGlGmHnIpIqJrKtLvMwNyO{P}QSTVWYZ\^`acegiknprtvx{}緁繄軆轈љњǬȰȰέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmlkkjjiihhg[[ZYXWVVUTSSRQQPOONNMLLKKJJ~I|IzIyHwHvGtGsGqFpFoFoFnFmFmFlFlFlFlFlFlFlGmGmGnHoHpIqJrJsKuLvMxNyO{P}QRTUWXZ[]_abdfhjlnpsuwy{}綀縂躄輆љњȭȰȰέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihhg\[ZYXXWVUUTSRRQQPOONNMLLKKKJ~J}I{IyIxHvHuHtGsGrGqGpGpGoGoGnGnGnGnGoGoHoHpHqIqIrJsKtKvLwMyNzO|P}QRTUVXY[\^`bcegikmoqsuxz|~緀繃軅轇љњɮȱȱέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihhgg[ZZYXWVVUTTSRRQPPOONNMMLLKKJJ}J|IzIyIxHvHuHtHtHsHrGrGqHqHqHqHqHqHqIrIrIsJtJuKvLwLxMzN{O|P~QRSUVWYZ\]_abdfhjlnprtvx{}縁躃輅љњʮȲȲέά߆߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiihhgg[[ZYXXWVUUTSSRQQPPOONNMMLLKKKJ~J}J{IzIyIwHwIvIuHuHtHtHtItItItItItJuJuJvKwLwLxMzN{N|O}PQRSTVWXZ[]^`bdegikmoqsuwy{~緀蹂軄轆љњ˯ȳȳέά߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiihhggf[ZYYXWWVUUTSSRQQPPOONNMMMLLKKKK~J}J{IzIzJyIxIxIwIwIvJvJvJvJwJwKwKxLxLyMzM{N|O}OPQRSTVWXZ[\^`acefhjlnprtvxz|~縀躃輅љњ̰ȳȳέά߆߄߃߂߁߀~wv{zyxwvutssrqpoonmllkkjiihhggg[[ZYXXWVVUTTSSRQQPPOONNNMMMLLLKKK~J}J|J|J{JzJzJyJyKyKyKyKyKzLzL{M{M|N}N~OPQRRSUVWXY[\^_abdfgikmoqsuwy{}蹁軃轅њњͰȴȴέά߆߅߃߂߁߀~wv{zyxwvuttsrqpoonmmlkkjjiihhggf[ZZYXWWVVUTTSSRRQQPPOONNNMMMLLLLKKK~K~K}K}K|K|L|L|L|L|M|M}M}N~NOPPQRSTUVWXY[\]_`bcegijlnprtvxz|~蹀躂輄њњαȵȵέά߆߅߄߂߁߀~wv{zyxwvuutsrqpponmmllkjjiihhggg\[ZYYXWWVUUTTSSRRQQPPOOONNNNMMMMLLLLLLLMMMMMNNOOPPQRRSTUVWXYZ\]^`acefhjkmoqsuwy{}躁較轅њћϲȵȵέά߆߅߄߃߂߀~wv{zyxwwvutsrqqponnmllkkjiihhhggf[[ZYYXWWVUUTTSSRRQQQPPPOOONNNNNMMMMMMNNNNNOOOPPQQRSTTUVWXYZ\]^`acdfgikmnprtvxz|~蹀軂轄њћвȶȶέά߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjjiihhgggf[ZZYXXWWVVUTTTSSRRQQQPPPPOOOOONNNNNNOOOOOPPPQQRSSTUVVWXY[\]^`abdegijlnoqsuwy{}躁較њћѳǷȷέά߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjjiihhhgggf[ZZYXXWWVVUUTTSSSRRRQQQPPPPPPOOOOPPPPPPQQQRRSSTUUVWXYZ[\]^`abdeghjkmoqrtvxz|~躀輂њћέά߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkkjjiihhhggg\[ZZYYXWWVVUUUTTSSSRRRRQQQQQQPPQQQQQQQQRRSSSTUUVWWXYZ[\]^`abdefhikmnprtuwy{}軁轃њћͭά߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllkkjjiihhhggg\[ZZYYXXWWVVUUUTTTSSSRRRRRRRQQRRRRRRRSSSTTUUVVWXYZZ[\]_`abcefhiklnpqsuvxz|~軀轂њћͭά߆߅߄߃߂߁߀xw}|{zyxwvuttsrqpponnmmlkkjjiiihhhggg\[ZZYYXXWWWVVUUUTTTTSSSSSSSRRSSSSSSTTTUUUVWWXXYZ[\]^_`abcefhijlnoqrtvxy{}輁њћͭέ߆߅߄߃߂߁߀xw}|{zyxwvvutsrrqpoonmmllkkjjiiihhhggg\[[ZZYYXXWWWVVVUUUUTTTTTTTSSTTTTTUUUUVVWWXXYZ[[\]^_`abdefgijlmoprtuwy{|~輀њћͭέ߅߄߃߂߁߀xx}|{zyyxwvutssrqpponnmmllkkjjiiihhhhgg\[[ZZYYYXXWWWWVVVVUUUUUUUTTUUUUVVVVWWXXYYZZ[\]^__`bcdefgijlmoprsuwxz|}ћћͭέ߆߅߄߃߂߁yx~}|{zyxwvuutsrrqpponnmmllkkjjiiihhhhhg\\[[ZZYYYXXXWWWWWVVVVVVVUUVVVWWWWXXXYYZ[[\]]^_`abcdefhijlmnpqsuvxy{}ћќͭέ߆߅߄߃߂߁yx~}|{zyxxwvutssrqqpoonnmlllkkjjjiiihhhhh\\\[[ZZZYYYXXXXXWWWWWWWVVWWXXXXYYYZZ[[\]]^_``abcdeghijlmnpqstvwy{|~ћќͮέ߅߄߃߂߁yx~}||{zyxwvuutsrrqppoonmmlllkkjjjiiiiihhh]\\\[[ZZZZYYYYYXXXXXXXWWXYYYYZZZ[[\\]]^__`abcdefghijlmnpqstvwyz|~ћќͮͭ߆߅߄߃߂yy~}|{zyxxwvuttsrrqppoonmmlllkkkjjjiiiiiii]]\\\[[[[ZZZZZZYYYYYYXXZZZZ[[[\\\]]^__`aabcdefghiklmnpqrtuwxz{}ћќͮͭ߆߅߄߃߂zy~}||{zyxwvvutssrqqpponnmmmllkkkjjjjjiiiiii]]]\\\\[[[[[[[ZZZ[[YY[[[\\\\]]^^__`aabcdeffgijklmnpqrtuwxz{ћќͮͭ߆߅߄߃zy߀~}|{zyyxwvuutssrqqppoonnmmlllkkkkjjjjjjjjj^^]]]]\\\\\\\\\\\\ZZ\\]]]]^^__``aabcddefghijklmopqrtuwxy{ќќͮͭ߆߅߄߃zz߀~}}|{zyxwwvuttsrrqqppoonnmmmlllkkkkkjjjjjjj_^^^^]]]]]]]]]]]][[]^^^^__```abbccdefgghijklnopqrtuvxy{ќѝͮͮ߆߅߄{z߁߀~}|{zzyxwvvuttsrrqqppoonnmmmlllllkkkkkkkkkkk____^^^^^^^^^^^\\____```aabbcddeffghijklmnopqstuvxyќѝͮͮ߆߅߄{{߁߀~~}|{zyyxwvvuttsrrqqppoonnnmmmmllllllllkllllll``___________]]```aaabbccddeffghiijklmnoprstuwxќѝͯͮ߆߅|{߂߁߀~}||{zyxxwvvuttsrrqqpppoonnnmmmmmlllllllllllmmma````````aa^_abbbbccddeeffghhijklmnopqrstuwѝНͯͮ߆߅|{߂߁߁߀~}|{{zyxxwvvuttssrrqqppooonnnnmmmmmmmmmmmmmmmnnnbbbbbbbbb``ccccddeeffgghhijkllmnopqrstvѝОͯͮ߆}|߃߂߁߀~~}|{zzyxxwvvuttssrrqqpppoooonnnnnnnnnnnnnnnnnoooopccccccaadddeefffgghiijkklmnopqrstѝОͯͮ߆}|߄߃߂߁߀~}}|{zzyxxwvvuttssrrrqqppppooooonnnnnnnnnoooooppppqqrddebbeeffggghhiijkklmnnopqr߅߆НОͯͯ}}߄߃߂߁߁߀~}}|{zzyxxwvvuuttssrrrqqqppppoooooooooooooppppqqqrrrsstpcggghhhiijjkklmnnop߂߃߄߅߆ООͯͯ~}߅߄߃߂߁߀߀~}||{zzyxxwvvuuttsssrrrqqqqpppppppppppppppqqqqrrrsssttqqvvwxxyzz{||}~߀߀߁߂߃߄߅߆ОПͰͯ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvuuttsssrrrrqqqqqqqqqqqqqqqqqrrrrsssttuuqqwwxxyzz{||}~߀߁߂߃߄߅߅߆ОПͰͯ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvuuutttsssrrrrrrrqqqqqrrrrrrrssstttuuuvrrwxyyzz{||}~߀߁߂߃߄߄߅߆ОПͰͯ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwvvuuutttssssssrrrrrrrrrsssssstttuuuvvwrsxyyz{{|}}~߀߁߂߃߃߄߅߆ПРͰͯր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwwvvvuuutttttssssssssssstttttuuuvvvwwxssyzz{{|}}~߀߁߂߂߃߄߅߆ПРͰͰրր߆߅߄߃߂߂߁߀~}}||{zzyyxxxwwvvvuuuuutttttttttttttuuuuuvvvwwxxxttzz{||}}~߀߁߂߂߃߄߅߆ПРͱͰցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyxxxwwwvvvvuuuuuuuuuuuuuuuvvvvwwwxxxyytu{{||}~~߀߀߁߂߂߃߄߅߆߆РРͱͰւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyyyxxxwwwwvvvvvvvvvvvvvvvwwwwxxxyyyzzuu||}}~~߀߀߁߂߃߃߄߅߆߆РСͱͰւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zzyyyxxxxwwwwwwwwwwwwwwwwwxxxxyyyzz{{vv}}~~߀߁߁߂߃߃߄߅߆߆РСͱͱփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{{zzzyyyyxxxxxxxxxxxxxxxxxyyyyzzz{{||vw}~~߀߀߁߁߂߃߄߄߅߆߆ССͱͱՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||{{{zzzzyyyyyyyyyyyyyyyyyzzzz{{{|||}ww~߀߁߁߂߂߃߄߄߅߆СТͲͱՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}|||{{{{zzzzzzzzzzzzzzzzz{{{{|||}}}~xx߀߀߁߁߂߃߃߄߅߅߆СТͲͱՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}}}||||{{{{{{{{{{{{{{{{{||||}}}~~~xy߀߁߁߂߂߃߄߄߅߅߆ТϣͲͱՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~~~}}}}|||||||||||||||||}}}}~~~߀yy߁߂߂߃߃߄߄߅߆߆ТϣͲՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀~~~~}}}}}}}}}}}}}}}}}~~~~߀߀߀߁zz߂߃߃߄߄߅߅߆ϣϣͲՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀߀~~~~~~~~~~~~~~~~~߀߀߀߁߁߁߂{{߃߄߄߅߅߆߆ϣϤͲՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁߀߀߀߀߀߀߀߀߁߁߁߂߂߂߃{|߄߅߅߆߆ϣϤՈՇ߆߆߅߅߄߄߃߃߃߂߂߂߁߁߁߁߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߁߁߁߁߂߂߂߃߃߃߄||߅߆߆ϤϤԈՈ߆߆߅߅߄߄߄߃߃߃߂߂߂߂߂߁߁߁߁߁߁߁߁߁߁߁߁߁߁߁߂߂߂߂߂߃߃߃߄߄߄߅}}߆ϤϥԉԈ߆߆߅߅߅߄߄߄߄߃߃߃߃߂߂߂߂߂߂߂߂߂߂߂߂߂߂߂߃߃߃߃߄߄߄߄߅߅߅߆~~ϤϥԊԉ߆߆߅߅߅߅߄߄߄߄߄߃߃߃߃߃߃߃߃߃߃߃߃߃߄߄߄߄߄߅߅߅߅߆߆~ϥϦԊԊ߆߆߆߅߅߅߅߅߅߄߄߄߄߄߄߄߄߄߄߄߅߅߅߅߅߅߆߆߆րϥϦԋԋ߆߆߆߆߆߆߅߅߅߅߅߅߅߅߅߆߆߆߆߆߆րրϦϦԌԋ߆߆߆߆߆߆߆ցցϦϧԍԌււϧϧԍԍւփϧϨͲͲͱͰͰͯͯͮͮέάάΫΫΪΩΩΨϧϧϦϥϥϤϤϣТТСРРПООѝќќћњњљјҘҗҖҖҕҕҔғӓӒӑӑӐӐӏӎӎԍԍԌԌԋԋԊԊԉԉԈՈՇՇՇՆՆՅՅՅՄՄՄՃՃփւււււցցցցցցցրրրրրրրրրրրցցցցցցցւււււփՃՃՄՄՄՅՅՅՆՆՇՇՇՈԈԉԉԊԊԋԋԌԌԍԍӎӎӏӐӐӑӑӒӓғҔҕҕҖҖҗҘјљњњћќќѝООПРРСТТϣϤϤϥϥϦϧϧΨΩΩΪΫΫάάέͮͮͯͯͰͰͱͲͲͲͱͱͰͰͯͮͮͭέάάΫΪΪΩΨΨϧϧϦϥϥϤϣϣТССРППОНѝќќћњњљјҘҗҖҖҕҕҔғӓӒӒӑӐӐӏӏӎӎԍԌԌԋԋԊԊԊԉԉՈՈՇՇՇՆՆՅՅՅՄՄՄՄՃՃփփւււււււցցցցցցցցցցցցցւււււււփփՃՃՄՄՄՄՅՅՅՆՆՇՇՇՈՈԉԉԊԊԊԋԋԌԌԍӎӎӏӏӐӐӑӒӒӓғҔҕҕҖҖҗҘјљњњћќќѝНОППРССТϣϣϤϥϥϦϧϧΨΨΩΪΪΫάάέͭͮͮͯͰͰͱͱͲӏӏՅՅΨΩӐӐՆՆΩΩӑӐՇՇΩΪӒӑՈՈΪΪӒӒԈԉΪΫғӓԉԊΪΫҔғԊԊΫΫҕҔԋԋΫάҕҕԌԌάάҖҖԍԍάέҗҖӎӎέͭҗҗӎӏͭͮјҘӏӐͮͮљљӐӐͮͮњљӑӑͮͯњњӒӒͯͯћћғғͯͰќќҔҔͰͰѝќҕҕͰͱНѝҕҖͱͱООҖҗͱͱППҗҗͱͲРПјјͲРРљљССњњТСћћϣТћќϣϣќќϤϤѝНϥϤООϥϥППϦϦРРϧϦРСϧϧССΨΨТТΩΩϣϣΪΩϤϤΪΪϤϥΫΫϥϥάΫϦϦάάϧϧέέϧϨͭͭΨΨͮͮΩΩͯͮΪΪͯͯΪΪͰͰΫΫͱͰάάͱͱάέͲͱͭͭͲͮͮͯͯͯͯͰͰͰͱͱͱͲͲ \ No newline at end of file diff --git a/widgets/Cargo.toml b/widgets/Cargo.toml index ec6cf63c2..ba93da3e4 100644 --- a/widgets/Cargo.toml +++ b/widgets/Cargo.toml @@ -15,6 +15,11 @@ makepad-draw = { path = "../draw", version = "2.0.0" } makepad-derive-widget = {path = "./derive_widget", version="2.0.0"} makepad-mbtile-reader = { path = "../libs/mbtile_reader", version = "1.0.0", optional = true } makepad-fast-inflate = { path = "../libs/fast_inflate", optional = true } +# Optional sibling workspace crates re-exported by makepad-widgets for +# downstream applications that intentionally use a single Makepad dependency. +makepad-gltf = { path = "../libs/gltf", optional = true } +makepad-csg = { path = "../libs/csg/csg", optional = true } +makepad-test = { path = "../libs/makepad_test", optional = true } makepad-voice = { path = "../libs/voice", version = "0.1.0", optional = true } makepad-cef = { path = "../libs/cef", optional = true } @@ -32,6 +37,9 @@ default = [] voice = ["dep:makepad-voice"] maps = ["dep:makepad-mbtile-reader", "dep:makepad-fast-inflate"] +gltf = ["dep:makepad-gltf"] +csg = ["dep:makepad-csg"] +test = ["dep:makepad-test"] pdf = ["dep:makepad-pdf-parse"] cef = ["dep:makepad-cef"] diff --git a/widgets/src/lib.rs b/widgets/src/lib.rs index 6837c5d50..bb0307f4e 100644 --- a/widgets/src/lib.rs +++ b/widgets/src/lib.rs @@ -15,6 +15,20 @@ pub use makepad_pdf_parse; pub use makepad_draw::makepad_zune_jpeg; pub use makepad_draw::makepad_zune_png; +// Optional sibling Makepad workspace crates. These re-exports permit a +// downstream application to depend on makepad-widgets as the single Makepad +// source while keeping all extra APIs feature-gated. +#[cfg(feature = "maps")] +pub use makepad_fast_inflate; +#[cfg(feature = "maps")] +pub use makepad_mbtile_reader; +#[cfg(feature = "gltf")] +pub use makepad_gltf; +#[cfg(feature = "csg")] +pub use makepad_csg; +#[cfg(feature = "test")] +pub use makepad_test; + // Core modules (used internally first) pub mod animator; pub mod theme_desktop_dark; diff --git a/widgets/src/map/geometry.rs b/widgets/src/map/geometry.rs index 30b319f07..25aeb3354 100644 --- a/widgets/src/map/geometry.rs +++ b/widgets/src/map/geometry.rs @@ -585,11 +585,46 @@ pub fn merge_stroke_polylines(polylines: &[Vec<(f32, f32)>]) -> Vec= 2) - .cloned() - .collect::>(); + // Forked ways (rail switches, dual-carriageway splits) duplicate their + // shared segments exactly; drawing a segment twice at the same depth + // rank z-fight-shimmers in tilt mode. Keep the FIRST occurrence of + // every quantized segment, splitting a polyline where a duplicate is + // dropped. + let mut seen_segments = + std::collections::HashSet::<(StrokeEndpointKey, StrokeEndpointKey)>::new(); + let mut lines = Vec::>::new(); + for line in polylines.iter().filter(|line| line.len() >= 2) { + let mut current = Vec::<(f32, f32)>::new(); + for pair in line.windows(2) { + let (a, b) = (pair[0], pair[1]); + let (ka, kb) = (stroke_endpoint_key(a), stroke_endpoint_key(b)); + // Sub-quantum micro segments never dedup (they'd collide + // across the whole tile). + let keep = if ka == kb { + true + } else { + let seg = if (ka.x, ka.y) <= (kb.x, kb.y) { + (ka, kb) + } else { + (kb, ka) + }; + seen_segments.insert(seg) + }; + if keep { + if current.is_empty() { + current.push(a); + } + current.push(b); + } else if current.len() >= 2 { + lines.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + } + if current.len() >= 2 { + lines.push(current); + } + } if lines.is_empty() { return Vec::new(); } diff --git a/widgets/src/map/icons.rs b/widgets/src/map/icons.rs index 1ba8b673c..e37359130 100644 --- a/widgets/src/map/icons.rs +++ b/widgets/src/map/icons.rs @@ -19,10 +19,6 @@ use std::sync::OnceLock; /// On-screen symbol size; carto icons are authored at 14x14. pub const ICON_SIZE_PX: f32 = 14.0; -pub const DIGIT_NAMES: [&str; 10] = [ - "digit_0", "digit_1", "digit_2", "digit_3", "digit_4", "digit_5", "digit_6", "digit_7", - "digit_8", "digit_9", -]; /// Symbols appear from this view-zoom bucket (carto shows the full POI /// symbol set from z17). pub const ICON_MIN_ZOOM: u32 = 17; @@ -100,32 +96,37 @@ fn icons() -> &'static HashMap<&'static str, IconMesh> { out.insert("tree_core", mesh); } // Tesla-style charger pins: wide badge (bolt + kW text) for fast - // sites, small badge for street AC; white bolt overlays. - if let Some(mesh) = build_icon_mesh_sized(include_str!("icons/charger_pin_wide.svg"), 30.0) + // sites, small badge for street AC; white bolt overlays. The mesh + // is shifted so the TAIL TIP sits exactly on the anchor (the site): + // a centered mesh made the tip sweep across the ground when the + // camera rotated in a tilted view. + // wide: viewBox 34x30, tip (17,24), scale 30/34 -> tip at +7.94 + if let Some(mesh) = + build_icon_mesh_sized_offset(include_str!("icons/charger_pin_wide.svg"), 30.0, 0.0, -7.94) { out.insert("charger_pin_fast", mesh); } - if let Some(mesh) = build_icon_mesh_sized(include_str!("icons/charger_pin.svg"), 16.0) { + // small: viewBox 22x22, tip (11,20.5), scale 16/22 -> tip at +6.91 + if let Some(mesh) = + build_icon_mesh_sized_offset(include_str!("icons/charger_pin.svg"), 16.0, 0.0, -6.91) + { out.insert("charger_pin_ac", mesh); } + // Bolt overlays carry their in-pin offset IN THE MESH (screen px): // offsetting the anchor instead scales with the map and the pin // composite smears apart at fractional zooms. if let Some(mesh) = - build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, -8.5, -3.5) + build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, -8.5, -12.35) { out.insert("charger_bolt_fast", mesh); } if let Some(mesh) = - build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, 0.0, -2.5) + build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, 0.0, -8.36) { out.insert("charger_bolt_ac", mesh); } - for digit in 0u8..10 { - if let Some(mesh) = build_digit_mesh(digit) { - out.insert(DIGIT_NAMES[digit as usize], mesh); - } - } + out }) } @@ -192,56 +193,10 @@ fn build_icon_mesh_sized_offset( /// Seven-segment digit meshes: pin badges draw their kW number as PART /// of the icon composite — same anchor, same billboard transform, so the -/// text can never detach, double, or re-layout while zooming/rotating. -fn build_digit_mesh(digit: u8) -> Option { - // Segment layout in a 6x10 box centered at origin; thickness 1.5. - // (A top, B tr, C br, D bottom, E bl, F tl, G middle) - const ON: [[bool; 7]; 10] = [ - [true, true, true, true, true, true, false], // 0 - [false, true, true, false, false, false, false], // 1 - [true, true, false, true, true, false, true], // 2 - [true, true, true, true, false, false, true], // 3 - [false, true, true, false, false, true, true], // 4 - [true, false, true, true, false, true, true], // 5 - [true, false, true, true, true, true, true], // 6 - [true, true, true, false, false, false, false], // 7 - [true, true, true, true, true, true, true], // 8 - [true, true, true, true, false, true, true], // 9 - ]; - let flags = ON.get(digit as usize)?; - let (w, h, t) = (5.4f32, 9.6f32, 1.5f32); - let (hw, hh) = (w * 0.5, h * 0.5); - // (x, y, width, height) per segment, y-down. - let segments = [ - (-hw, -hh, w, t), // A - (hw - t, -hh, t, hh + t * 0.5), // B - (hw - t, -t * 0.5, t, hh + t * 0.5), // C - (-hw, hh - t, w, t), // D - (-hw, -t * 0.5, t, hh + t * 0.5), // E - (-hw, -hh, t, hh + t * 0.5), // F - (-hw, -t * 0.5, w, t), // G - ]; - let mut verts = Vec::new(); - let mut indices = Vec::new(); - for (on, (x, y, sw, sh)) in flags.iter().zip(segments) { - if !on { - continue; - } - let base = verts.len() as u32; - for (px, py) in [(x, y), (x + sw, y), (x + sw, y + sh), (x, y + sh)] { - verts.push(VVertex { - x: px, - y: py, - u: 0.0, - v: 0.0, - stroke_dist: 0.0, - clip_radius: 12.0, - }); - } - indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); - } - Some(IconMesh { verts, indices }) -} + +/// "/" separator for the in-pin "kW/stalls" text: a diagonal bar in the + +/// Small multiplication cross for the stall-count line ("x5"): two fn build_icon_mesh_sized(svg: &str, size_px: f32) -> Option { let doc = parse_svg(svg); @@ -375,15 +330,19 @@ pub fn micro_icon_for_tags(tags: &HashMap) -> Option<(&'static s pub fn icon_for_tags(tags: &HashMap) -> Option<(&'static str, u8)> { match tags.get("layer").map(|v| v.as_str()) { Some("micro_pois") => return micro_icon_for_tags(tags), - // Geodata overlays (layers.md). Chargers tier by max_kw — this is - // a Tesla-style EV navigator, charging power is first-class: - // ultra-fast/Supercharger red, fast DC amber, street AC blue. + // Geodata overlays (layers.md). Charger pins color by BRAND — + // red is exclusively Tesla Superchargers; other brands split + // fast DC amber / street AC blue (kW still shows in the bubble). Some("chargers") => { let kw = tags .get("max_kw") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); - let class = if kw >= 150.0 { + let is_tesla = tags + .get("operator") + .or_else(|| tags.get("brand")) + .is_some_and(|value| value.to_lowercase().contains("tesla")); + let class = if is_tesla { LABEL_CLASS_HEALTH } else if kw >= 50.0 { LABEL_CLASS_AMENITY diff --git a/widgets/src/map/icons/charger_pin.svg b/widgets/src/map/icons/charger_pin.svg index afff50c2d..ed754f529 100644 --- a/widgets/src/map/icons/charger_pin.svg +++ b/widgets/src/map/icons/charger_pin.svg @@ -1 +1 @@ - + diff --git a/widgets/src/map/icons/charger_pin_wide.svg b/widgets/src/map/icons/charger_pin_wide.svg index 3fb240cbc..9547e4861 100644 --- a/widgets/src/map/icons/charger_pin_wide.svg +++ b/widgets/src/map/icons/charger_pin_wide.svg @@ -1 +1 @@ - + diff --git a/widgets/src/map/label.rs b/widgets/src/map/label.rs index 7a7cd7fff..2cc9a75b6 100644 --- a/widgets/src/map/label.rs +++ b/widgets/src/map/label.rs @@ -18,7 +18,11 @@ pub const LABEL_CURVE_MAX_SAMPLES: usize = 192; pub const LABEL_CURVE_SMOOTH_PASSES: usize = 2; pub const LABEL_BASELINE_SHIFT_FACTOR: f64 = 1.0; pub const LABEL_LAYOUT_MAX_CURVATURE: f32 = 1.0; -pub const LABEL_VERTICAL_AXIS_EPSILON: f32 = 0.22; +// cos threshold for the "treat as vertical" band around 90 degrees. Keep it +// TIGHT (~±4°): inside the band the deterministic top-to-bottom rule wins, +// which SKIPS the upside-down flip — at 0.22 the band reached 103° and +// labels tilted past 90° rendered inverted (IJpromenade bug). +pub const LABEL_VERTICAL_AXIS_EPSILON: f32 = 0.07; // One overzoomed z14 tile can hold a whole city's addresses; house numbers // are lowest-priority and must survive this per-tile cap to ever reach the // viewport filter. @@ -47,6 +51,10 @@ pub const LABEL_CLASS_TREE: u8 = 8; pub const LABEL_CLASS_WATER: u8 = 9; /// Text drawn INSIDE a colored pin badge (white, both themes). pub const LABEL_CLASS_PIN: u8 = 10; +/// Motorway exit (junction) labels — carto junction red. +pub const LABEL_CLASS_EXIT: u8 = 11; +/// Administrative district names (gemeente/wijk/buurt) — muted purple. +pub const LABEL_CLASS_ADMIN: u8 = 12; #[derive(Clone, Debug)] pub struct TileLabel { @@ -62,6 +70,8 @@ pub struct TileLabel { /// point transforms. pub name_key: String, pub bbox: (f32, f32, f32, f32), + /// 3D marker lift in meters (flying pins); labels ride the same stalk. + pub lift_m: f32, } #[derive(Clone, Debug)] @@ -76,6 +86,12 @@ pub struct LabelCandidate { pub center: Vec2d, pub repeat_distance: f64, pub font_scale: f32, + /// Straightened point label (place name, POI, pin text): the re-place + /// keeps it horizontal, so the live camera-delta must translate its + /// anchor without rotating the glyphs. + pub screen_point: bool, + /// Screen-px marker lift this candidate rides (0 when grounded/2D). + pub lift_px: f32, pub screen_path: Vec, } @@ -110,6 +126,38 @@ pub fn extract_way_label( return None; } let source_layer = tags.get("layer").cloned().unwrap_or_default(); + // Transit route lines label with their line ref ("5", "52", "A") along + // the way, like street names. + if source_layer == "routes" { + let line_ref = tags + .get("ref") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && value.len() <= 6)?; + let path_points = simplify_label_path(points); + if path_points.len() < 2 { + return None; + } + let mode = tags.get("mode").cloned().unwrap_or_default(); + // "Tram 7" / "Metro 52" — the mode makes the number meaningful. + let text = match mode.as_str() { + "tram" => format!("Tram {line_ref}"), + "metro" => format!("Metro {line_ref}"), + "ferry" => format!("Ferry {line_ref}"), + "rail" => format!("Rail {line_ref}"), + _ => line_ref.clone(), + }; + return Some(TileLabel { + text, + priority: 2, + source_layer, + road_kind: format!("transit:{}:{}", mode, line_ref), + color_class: LABEL_CLASS_TRANSPORT, + path_points, + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, + }); + } // Waterway names follow their line like street names do. if source_layer == "water_lines_labels" { let name = select_label_text(tags)?; @@ -126,6 +174,7 @@ pub fn extract_way_label( path_points, name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } if !tags.contains_key("highway") { @@ -156,6 +205,7 @@ pub fn extract_way_label( path_points, name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }) } @@ -206,6 +256,37 @@ pub fn extract_area_label( tags: &HashMap, centroid: (f32, f32), ) -> Option { + // CBS district overlays: gemeente / wijk / buurt names at the area + // centroid, staged by zoom at candidate time (tier in road_kind). + if let Some(layer) = tags.get("layer") { + if matches!(layer.as_str(), "gemeenten" | "wijken" | "buurten") { + let (name_field, tier) = match layer.as_str() { + "gemeenten" => ("gemeentenaam", 'g'), + "wijken" => ("wijknaam", 'w'), + _ => ("buurtnaam", 'b'), + }; + let name = tags + .get(name_field) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + return Some(TileLabel { + text: name, + priority: 2, + source_layer: layer.clone(), + road_kind: format!( + "adm{}{:.0}x{:.0}", + tier, + centroid.0 * 4.0, + centroid.1 * 4.0 + ), + color_class: LABEL_CLASS_ADMIN, + path_points: point_label_path(centroid), + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, + }); + } + } // Geodata nature overlays name their areas with Dutch source columns. if let Some(layer) = tags.get("layer") { if matches!(layer.as_str(), "natura2000" | "wetlands") { @@ -223,6 +304,7 @@ pub fn extract_area_label( path_points: point_label_path(centroid), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } } @@ -261,6 +343,7 @@ pub fn extract_area_label( path_points: point_label_path(centroid), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }) } @@ -283,6 +366,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Detail-layer offices and named parkings carry their name (base @@ -313,6 +397,31 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, + }); + } + // Transit stops label with their name; stations lead (bigger zoom + // range at candidate time via the roadkind flag). + "stops" => { + let name = select_label_text(tags)?; + let is_station = tags + .get("station") + .is_some_and(|v| v == "true" || v == "1" || v == "yes"); + return Some(TileLabel { + text: name, + priority: if is_station { 2 } else { 3 }, + source_layer, + road_kind: format!( + "{}{:.0}x{:.0}", + if is_station { "stS" } else { "stp" }, + point.0 * 4.0, + point.1 * 4.0 + ), + color_class: LABEL_CLASS_TRANSPORT, + path_points: point_label_path(point), + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Charger sites label their power (and Superchargers their brand): @@ -364,6 +473,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Water body names (lakes, the IJ, canals-as-polygons) come as @@ -379,6 +489,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } "pois" => { @@ -393,6 +504,33 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, + }); + } + // Shortbread street_labels_points is EXCLUSIVELY motorway junctions + // (exits): name + exit ref number, carto-red, navigation-critical. + "street_labels_points" => { + let name = select_label_text(tags); + let exit_ref = tags + .get("ref") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && value.len() <= 6); + let text = match (&name, &exit_ref) { + (Some(name), Some(exit_ref)) => format!("{name} {exit_ref}"), + (Some(name), None) => name.clone(), + (None, Some(exit_ref)) => exit_ref.clone(), + (None, None) => return None, + }; + return Some(TileLabel { + text, + priority: 1, + source_layer, + road_kind: "exit".to_string(), + color_class: LABEL_CLASS_EXIT, + path_points: point_label_path(point), + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } _ => {} @@ -424,6 +562,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }) } @@ -512,7 +651,9 @@ pub fn label_source_rank(layer: &str) -> Option { return Some(4); } Some(match layer { - "street_labels" | "street_labels_points" => 7, + "street_labels" => 7, + // Motorway exits: sparse and navigation-critical — beat street names. + "street_labels_points" => 8, "streets_polygons_labels" => 6, "transportation_name" => 6, // Settlement names outrank everything. @@ -521,6 +662,11 @@ pub fn label_source_rank(layer: &str) -> Option { "water_polygons_labels" => 5, "water_lines_labels" => 4, "micro_pois" => 3, + // Transit line refs sit with street names in prominence. + "routes" => 6, + "stops" => 4, + // Admin district names: visible but under settlements/streets. + "gemeenten" | "wijken" | "buurten" => 5, // Charger kW labels outrank street names — this is an EV navigator. "chargers" => 8, "charger_brand" => 8, diff --git a/widgets/src/map/style.rs b/widgets/src/map/style.rs index 9a7ec1025..c9dfd8aa5 100644 --- a/widgets/src/map/style.rs +++ b/widgets/src/map/style.rs @@ -474,6 +474,7 @@ pub fn fill_alpha_for_tags(tags: &HashMap) -> f32 { match tags.get("layer").map(|value| value.as_str()) { Some("natura2000" | "wetlands") => 0.22, Some("vk100" | "vk500") => 0.45, + Some("gemeenten" | "wijken" | "buurten") => 0.32, Some("bag") => 0.85, _ => 1.0, } @@ -498,10 +499,31 @@ pub fn fill_pattern_shape(tags: &HashMap) -> f32 { 0.0 } +/// Building-age color from BAG bouwjaar — shared by the flat choropleth +/// fill and the 3D building tint. +pub fn bag_year_color(tags: &HashMap) -> Option { + let bouwjaar = tags + .get("bouwjaar") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) as i32; + Some(match bouwjaar { + 0 => 0xbdbdbd, + year if year < 1800 => 0x8c2d04, + year if year < 1900 => 0xcc4c02, + year if year < 1930 => 0xec7014, + year if year < 1960 => 0xfe9929, + year if year < 1980 => 0xfec44f, + year if year < 2000 => 0x78c679, + year if year < 2010 => 0x41b6c4, + _ => 0x225ea8, + }) +} + pub fn fill_color_for_tags( theme: &CompiledMapTheme, tags: &HashMap, closed: bool, + render_zoom: u32, ) -> Option { if !closed { return None; @@ -527,21 +549,35 @@ pub fn fill_color_for_tags( } // Building-age choropleth (BAG bouwjaar): rust = old, blue = new. if layer == "bag" { - let bouwjaar = tags - .get("bouwjaar") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0.0) as i32; - return Some(match bouwjaar { - 0 => 0xbdbdbd, - year if year < 1800 => 0x8c2d04, - year if year < 1900 => 0xcc4c02, - year if year < 1930 => 0xec7014, - year if year < 1960 => 0xfe9929, - year if year < 1980 => 0xfec44f, - year if year < 2000 => 0x78c679, - year if year < 2010 => 0x41b6c4, - _ => 0x225ea8, - }); + return bag_year_color(tags); + } + // Districts tint as translucent AREA shapes, one tier per zoom band so + // gemeente/wijk/buurt tints never stack into mud; stable per-district + // hue from the CBS code. + if matches!(layer, "gemeenten" | "wijken" | "buurten") { + let tier_active = match layer { + "gemeenten" => render_zoom < 11, + "wijken" => (11..13).contains(&render_zoom), + _ => render_zoom >= 13, + }; + if !tier_active { + return None; + } + const DISTRICT_PALETTE: [u32; 8] = [ + 0xe57373, 0x64b5f6, 0x81c784, 0xffb74d, 0xba68c8, 0x4db6ac, + 0xf06292, 0xa1887f, + ]; + let code = tags + .get("buurtcode") + .or_else(|| tags.get("wijkcode")) + .or_else(|| tags.get("gemeentecode")) + .map(|value| value.as_str()) + .unwrap_or(""); + let mut h: u32 = 5381; + for b in code.bytes() { + h = h.wrapping_mul(33) ^ b as u32; + } + return Some(DISTRICT_PALETTE[(h % DISTRICT_PALETTE.len() as u32) as usize]); } // Population choropleth (CBS grid cells), yellow -> deep blue. if matches!(layer, "vk100" | "vk500") { @@ -622,6 +658,11 @@ pub fn fill_layer_rank(tags: &HashMap) -> u8 { if layer == "bag" { return 41; } + // District tints paint OVER everything ground-level (roads included) + // so the area reads as one marked shape. + if matches!(layer, "gemeenten" | "wijken" | "buurten") { + return 60; + } // Green areas (parks/gardens/grass) rank above generic landuse and sites: // they share the `land` layer with huge residential polygons and would // otherwise lose to protobuf feature order (Bellamyplein rendered gray). @@ -805,15 +846,42 @@ pub fn stroke_style_for_tags( // road network; nature and admin boundaries as outlines. match layer { "routes" => { + // Transit-map look: each tram/metro line gets its own strong, + // stable color (hash of the line ref) over a white casing so + // routes read as a network, not faint threads under the roads. + const LINE_PALETTE: [u32; 10] = [ + 0xd7263d, 0x1b9e4b, 0x2456d7, 0xf2760c, 0x8e2bbf, 0x0b8f8f, + 0xc72b8e, 0x8a5a2b, 0x5a7d00, 0x364fc7, + ]; + let mode = tags.get("mode").map(|v| v.as_str()).unwrap_or(""); + let line_ref = tags.get("ref").map(|v| v.as_str()).unwrap_or(""); + let (color, width) = match mode { + "rail" => (0x37474f, 2.0), + "ferry" => (0x1b78c4, 2.0), + _ => { + // tram/metro: stable per-line color + let mut h: u32 = 5381; + for b in line_ref.bytes() { + h = h.wrapping_mul(33) ^ b as u32; + } + (LINE_PALETTE[(h % LINE_PALETTE.len() as u32) as usize], 2.6) + } + }; return Some(StrokeStyle { - sort_rank: 400, - casing: None, - center: StrokePassStyle { - color: 0x0a6cc8, - width: 1.5 * px_to_units, + sort_rank: 730, + casing: Some(StrokePassStyle { + color: 0xffffff, + width: (width + 2.0) * px_to_units, shape_id: 0.0, expand_class: EXPAND_CLASS_CONST_PX, - depth_micro: 400.0 * DEPTH_MICRO_PER_RANK, + depth_micro: 729.0 * DEPTH_MICRO_PER_RANK, + }), + center: StrokePassStyle { + color, + width: width * px_to_units, + shape_id: 0.0, + expand_class: EXPAND_CLASS_CONST_PX, + depth_micro: 730.0 * DEPTH_MICRO_PER_RANK, }, }); } @@ -831,11 +899,17 @@ pub fn stroke_style_for_tags( }); } "gemeenten" | "wijken" | "buurten" => { - let width = match layer { - "gemeenten" => 1.6, - "wijken" => 1.1, - _ => 0.8, + // Administrative boundary look: purple-gray, weight by tier, + // finer tiers only appear as you zoom in. + let (width, min_zoom) = match layer { + "gemeenten" => (1.8, 6), + "wijken" => (1.2, 11), + _ => (0.9, 13), }; + if render_zoom < min_zoom { + return None; + } + let width: f32 = width; return Some(StrokeStyle { sort_rank: 380, casing: None, diff --git a/widgets/src/map/tile.rs b/widgets/src/map/tile.rs index cf6f6822a..1271e290e 100644 --- a/widgets/src/map/tile.rs +++ b/widgets/src/map/tile.rs @@ -74,6 +74,8 @@ pub struct TileEntry { /// View-zoom bucket the geometry was styled for; stale buckets stay /// drawable while a rebuild is in flight. pub bucket: u32, + /// This bake carries 3D extrusions (buildings/trees/signals). + pub baked_3d: bool, /// Cross-fade state: the replaced generation's geometry stays drawable /// underneath while the new one fades in. pub fade: Option, @@ -85,6 +87,10 @@ pub struct TileFade { /// Render bucket the outgoing geometry was styled for, so its stroke /// widths can be corrected while it fades out. pub bucket: u32, + /// This fade is the flat->3D transition: the incoming bake grows its + /// heights with the fade. 3D->3D rebakes keep full height (alpha-only + /// crossfade) so zoom regens never replay the animation. + pub grow_heights: bool, pub fill_geometry: Option, pub casing_geometry: Option, pub stroke_geometry: Option, @@ -142,6 +148,8 @@ struct WayData { pub struct PinHit { pub norm: (f64, f64), pub info: Vec<(String, String)>, + /// 3D stalk height of this pin's marker (0 = grounded). + pub lift_m: f32, } #[derive(Debug)] @@ -390,6 +398,9 @@ pub fn build_tile_buffers_from_body( tagged_points, theme, render_zoom, + false, + + false, )) } @@ -407,6 +418,7 @@ pub fn build_tile_buffers_from_mvt( render_zoom: u32, buildings_3d: bool, ) -> Result { + let have_charger_overlay = overlay_tiles.iter().any(|overlay| overlay.has_chargers); let pbf_data = decode_vector_tile_payload(raw_tile_data)?; let render_scale = 2.0_f64 .powi(render_zoom as i32 - tile_key.z as i32) @@ -461,6 +473,9 @@ pub fn build_tile_buffers_from_mvt( collector.points, theme, render_zoom, + buildings_3d, + + have_charger_overlay, )) } @@ -676,12 +691,24 @@ fn merge_detail_features( } continue; } + let is_building = way + .tags + .get("building") + .is_some_and(|value| value != "no"); + let is_building_part = way + .tags + .get("building:part") + .is_some_and(|value| value != "no"); // Named zoo enclosures / attractions label at their centroid - // (and fill if they carry a surface like sand). + // (and fill if they carry a surface like sand). Famous BUILDINGS + // also carry tourism=attraction (Westerkerk, Munttoren…) — in 3D + // mode they must fall through to the extrusion path, not get + // swallowed as a flat attraction fill. let is_attraction = way.tags.contains_key("name") && (way.tags.contains_key("attraction") || way.tags.contains_key("zoo") - || way.tags.get("tourism").map(|v| v.as_str()) == Some("attraction")); + || way.tags.get("tourism").map(|v| v.as_str()) == Some("attraction")) + && !(want_buildings && (is_building || is_building_part)); if is_attraction { if want_platforms { way.tags @@ -715,11 +742,17 @@ fn merge_detail_features( if !want_buildings { continue; } - let is_building = way - .tags - .get("building") - .is_some_and(|value| value != "no"); - if !is_building { + if !is_building && !is_building_part { + continue; + } + // Underground volumes (metro halls mapped as building:part, + // parking cellars) must never extrude above ground. + if way.tags.get("location").map(|v| v.as_str()) == Some("underground") + || way + .tags + .get("osm_layer") + .is_some_and(|value| value.starts_with('-')) + { continue; } way.tags @@ -829,11 +862,139 @@ fn building_height_m(tags: &HashMap) -> f32 { 8.0 } +/// Base height (bottom of the volume) for building:part features: +/// `min_height` meters, else `building:min_level` x 3m. +fn building_min_height_m(tags: &HashMap) -> f32 { + if let Some(min_height) = tags.get("min_height") { + let digits: String = min_height + .trim() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if let Ok(h) = digits.parse::() { + return h.clamp(0.0, 220.0); + } + } + if let Some(levels) = tags.get("building:min_level") { + if let Ok(n) = levels.trim().parse::() { + return (n * 3.0).clamp(0.0, 220.0); + } + } + 0.0 +} + +/// Ray-cast point-in-polygon on a tile-local ring. +fn point_in_ring(point: (f32, f32), ring: &[(f32, f32)]) -> bool { + let mut inside = false; + let n = ring.len(); + if n < 3 { + return false; + } + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = ring[i]; + let (xj, yj) = ring[j]; + if (yi > point.1) != (yj > point.1) { + let x_cross = xi + (point.1 - yi) / (yj - yi) * (xj - xi); + if point.0 < x_cross { + inside = !inside; + } + } + j = i; + } + inside +} + +/// A low-poly SPHERE: horizontal rings in map units, per-vertex height in +/// param4 — the tilt shader's per-meter lift renders a true ball silhouette +/// (stacked flat discs read as separate pancakes). +#[allow(clippy::too_many_arguments)] +fn append_ball( + center: (f32, f32), + radius_units: f32, + radius_m: f32, + center_h_m: f32, + color: [f32; 4], + segs: u32, + rings: u32, + out_vertices: &mut Vec, + out_indices: &mut Vec, + zbias: &mut f32, +) { + let (segs, rings) = (segs.max(3), rings.max(2)); + // Phong-ish per-vertex lighting (Gouraud across the triangles): the + // same NW sun as the building walls plus a tight glossy highlight, so + // canopies and lights read as lit volumes instead of flat blobs. + // Map coords: x east, y SOUTH (screen down), z up. + let light = { + let (lx, ly, lz) = (-0.55f32, -0.835, 1.05); + let len = (lx * lx + ly * ly + lz * lz).sqrt(); + (lx / len, ly / len, lz / len) + }; + let view = { + let (vx, vy, vz) = (0.0f32, 0.62, 0.79); + let len = (vx * vx + vy * vy + vz * vz).sqrt(); + (vx / len, vy / len, vz / len) + }; + let half = { + let (hx, hy, hz) = (light.0 + view.0, light.1 + view.1, light.2 + view.2); + let len = (hx * hx + hy * hy + hz * hz).sqrt(); + (hx / len, hy / len, hz / len) + }; + let lit = |nx: f32, ny: f32, nz: f32| -> [f32; 4] { + let ndl = (nx * light.0 + ny * light.1 + nz * light.2).max(0.0); + let ndh = (nx * half.0 + ny * half.1 + nz * half.2).max(0.0); + let diffuse = 0.45 + 0.55 * ndl; + let spec = ndh.powi(32) * 0.85; + [ + (color[0] * diffuse + spec).min(1.0), + (color[1] * diffuse + spec).min(1.0), + (color[2] * diffuse + spec).min(1.0), + color[3], + ] + }; + let base = (out_vertices.len() / VECTOR_FLOATS_PER_VERTEX) as u32; + let mut push_vertex = |x: f32, y: f32, h: f32, shade: [f32; 4]| { + out_vertices.extend_from_slice(&[ + x, y, 0.5, 1.0, shade[0], shade[1], shade[2], shade[3], 1e6, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, h, 0.05, 24.0, *zbias, + ]); + }; + // rings from south pole (phi -90) to north pole (phi +90) + for ring in 0..=rings { + let phi = (ring as f32 / rings as f32 - 0.5) * std::f32::consts::PI; + let ring_r = radius_units * phi.cos(); + let h = center_h_m + radius_m * phi.sin(); + for seg in 0..segs { + let a = seg as f32 / segs as f32 * std::f32::consts::TAU; + let shade = lit(phi.cos() * a.cos(), phi.cos() * a.sin(), phi.sin()); + push_vertex( + center.0 + a.cos() * ring_r, + center.1 + a.sin() * ring_r, + h, + shade, + ); + } + } + for ring in 0..rings { + for seg in 0..segs { + let next = (seg + 1) % segs; + let a = base + ring * segs + seg; + let b = base + ring * segs + next; + let c = base + (ring + 1) * segs + seg; + let d = base + (ring + 1) * segs + next; + out_indices.extend_from_slice(&[a, b, c, b, d, c]); + } + } + *zbias += VECTOR_ZBIAS_STEP; +} + /// One flat-shaded wall quad: two ground vertices and two roof vertices /// whose height rides in param4 for the tilt shader to lift. fn append_wall_quad( a: (f32, f32), b: (f32, f32), + base_m: f32, height_m: f32, color: [f32; 4], out_vertices: &mut Vec, @@ -841,7 +1002,7 @@ fn append_wall_quad( zbias: &mut f32, ) { let base = (out_vertices.len() / VECTOR_FLOATS_PER_VERTEX) as u32; - for (p, h) in [(a, 0.0), (b, 0.0), (b, height_m), (a, height_m)] { + for (p, h) in [(a, base_m), (b, base_m), (b, height_m), (a, height_m)] { out_vertices.extend_from_slice(&[ p.0, p.1, 0.5, 1.0, color[0], color[1], color[2], color[3], 1e6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, h, 0.05, 90.0, *zbias, @@ -864,6 +1025,8 @@ fn build_tile_buffers_from_features( tagged_points: Vec<((f32, f32), HashMap)>, theme: &CompiledMapTheme, render_zoom: u32, + buildings_3d: bool, + have_charger_overlay: bool, ) -> TileBuffers { // How much this tile gets magnified on screen at the styled view zoom. let render_scale = 2.0_f64 @@ -877,7 +1040,10 @@ fn build_tile_buffers_from_features( let mut labels = Vec::::new(); let mut pin_hits = Vec::::new(); - let mut icon_jobs = Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32)>::new(); + let mut icon_jobs = + Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32, f32, f32, f32)>::new(); + let mut tree_points_3d = Vec::<(f32, f32)>::new(); + let mut signal_points_3d = Vec::<(f32, f32)>::new(); for (point, tags) in &tagged_points { let mut label_point = *point; let layer = tags.get("layer").map(|value| value.as_str()).unwrap_or(""); @@ -891,11 +1057,11 @@ fn build_tile_buffers_from_features( .and_then(|value| value.parse::().ok()) .unwrap_or(0.0); if kw >= 150.0 { - 9 + 8 } else if kw >= 50.0 { - 11 + 10 } else { - 13 + 12 } } "stops" => 13, @@ -910,7 +1076,10 @@ fn build_tile_buffers_from_features( // Chargers place before everything (EV navigator) and // are never collided away by shop/POI symbols. let priority = match icon_name { - "charger" => 0, + // Overlay charger pins are never collided away — + // base-map charging_station icons yield to them. + "charger" if layer == "chargers" => 0, + "charger" => 2, "entrance" => 3, "dot" => 2, _ => 1, @@ -929,6 +1098,15 @@ fn build_tile_buffers_from_features( .unwrap_or(0.0) }) .unwrap_or(0.0); + // Stall count (OCPI EVSEs) rides along for the in-pin + // "kW/stalls" text at close zooms. + let charger_stalls = (layer == "chargers") + .then(|| { + tags.get("evses") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) + }) + .unwrap_or(0.0); // Chargers render as Tesla-style pin badges: wide badge // (bolt + kW text inside) for fast sites, small badge // for street AC. @@ -943,6 +1121,54 @@ fn build_tile_buffers_from_features( 3 => icon_mesh("charger_pin_ac").unwrap_or(mesh), _ => mesh, }; + // The icon's own zoom floor rides into the vertex data + // (param4): the shader hides the icon the instant the + // LIVE view zoom drops below it, so stale deeper-bucket + // tiles never flash markers while zooming out. + // Overlay layers (chargers, stops) use their TIER floor; + // micro_icon_min_zoom("charger") is the 16.5 street-level + // gate for BASE-map charging posts and must not apply to + // overlay pins (it hid every pin below z16 — the + // "chargers disappeared" bug). + let zoom_floor = if layer == "chargers" || layer == "stops" { + icon_zoom_floor as f32 + } else { + micro_icon_min_zoom(icon_name).max(icon_zoom_floor as f32) + }; + // 3D mode: markers fly on stalks above the skyline — + // chargers highest, then shops/cafés (base pois), then + // transit stops. Street furniture (benches, entrances, + // micro POIs) stays on the ground where it belongs. + let pin_lift_m = if buildings_3d { + if layer == "chargers" { + if charger_kw >= 50.0 { 26.0f32 } else { 20.0 } + } else if layer == "stops" { + 12.0 + } else if tags.get("layer").map(|v| v.as_str()) == Some("pois") { + 18.0 + } else { + 0.0 + } + } else { + 0.0 + }; + // In 3D mode trees become little REAL 3D trees (trunk + + // canopy blob lifted by the building height mechanism) + // instead of flat billboard discs. + // With the charger overlay active the base-map + // charging_station icons are duplicates of overlay + // pins — drop them instead of letting them collide. + if icon_name == "charger" && layer != "chargers" && have_charger_overlay { + continue; + } + if buildings_3d && icon_name == "tree" { + tree_points_3d.push(*point); + continue; + } + if buildings_3d && icon_name == "traffic_signals" { + signal_points_3d.push(*point); + continue; + } icon_jobs.push(( *point, mesh, @@ -951,6 +1177,9 @@ fn build_tile_buffers_from_features( dist_factor, two_tone, charger_kw as f32, + charger_stalls as f32, + zoom_floor, + pin_lift_m, )); if two_tone == 2 || two_tone == 3 { // Tappable: record position + info for the bubble. @@ -967,9 +1196,45 @@ fn build_tile_buffers_from_features( } } } - pin_hits.push(PinHit { norm, info }); + pin_hits.push(PinHit { norm, info, lift_m: 0.0 }); } if two_tone == 2 { + // In-pin text via the NORMAL text renderer (drawn in + // the post-icon pin phase, billboard-anchored): + // Tesla pins show the stall count (the kW is implied + // by the brand, like the Tesla app), other brands + // show the peak kW. + let is_tesla = tags + .get("operator") + .or_else(|| tags.get("brand")) + .is_some_and(|v| v.to_lowercase().contains("tesla")); + let pin_text = if is_tesla && charger_stalls >= 1.0 { + format!("{:.0}", charger_stalls.min(99.0)) + } else if charger_kw >= 1.0 { + format!("{:.0}", charger_kw.min(999.0)) + } else { + String::new() + }; + if !pin_text.is_empty() { + labels.push(TileLabel { + text: pin_text, + priority: 1, + source_layer: "chargers".to_string(), + road_kind: format!( + "chp{}_{:.0}x{:.0}", + icon_zoom_floor, + point.0 * 4.0, + point.1 * 4.0 + ), + color_class: crate::map::label::LABEL_CLASS_PIN, + path_points: crate::map::label::point_label_path_pub(( + point.0, point.1, + )), + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, + }); + } // brand reads below the pin from z13; the kW digits // are part of the icon composite itself. if render_zoom >= 13 { @@ -989,17 +1254,23 @@ fn build_tile_buffers_from_features( point.0 * 4.0, point.1 * 4.0 ), - color_class: if charger_kw >= 150.0 { + color_class: if operator.to_lowercase().contains("tesla") + { crate::map::label::LABEL_CLASS_HEALTH } else { crate::map::label::LABEL_CLASS_AMENITY }, + // Anchor AT the charger point; the + // below-the-pin offset is applied in + // SCREEN space at candidate time so it + // doesn't tilt-compress or orbit the + // billboard pin when the camera moves. path_points: crate::map::label::point_label_path_pub(( - point.0, - point.1 + 12.0 / render_scale, + point.0, point.1, )), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } } @@ -1022,7 +1293,7 @@ fn build_tile_buffers_from_features( let icon_min_dist = (ICON_SIZE_PX + 3.0) / render_scale; let icon_min_dist_sq = icon_min_dist * icon_min_dist; let mut accepted_icons = Vec::<(f32, f32)>::new(); - icon_jobs.retain(|(point, _, _, _, dist_factor, _, _)| { + icon_jobs.retain(|(point, _, _, _, dist_factor, _, _, _, _, _)| { let collides = accepted_icons.iter().any(|other| { let dx = other.0 - point.0; let dy = other.1 - point.1; @@ -1076,9 +1347,27 @@ fn build_tile_buffers_from_features( struct BuildingGroup { rings: Vec, height_m: f32, + min_height_m: f32, + is_part: bool, } let mut building_groups = Vec::::new(); let mut building_group_lookup = HashMap::::new(); + // Building-age layer active: index BAG polygons by quantized centroid + // so extruded buildings can pick up their bouwjaar tint (BAG footprints + // match OSM buildings nearly 1:1). + let mut bag_centroid_colors = HashMap::<(i32, i32), u32>::new(); + for way in tile_ways.iter() { + if way.tags.get("layer").map(|v| v.as_str()) == Some("bag") + && way.closed + && way.points.len() >= 3 + { + if let Some(color) = crate::map::style::bag_year_color(&way.tags) { + let c = ring_centroid(&way.points); + bag_centroid_colors + .insert(((c.0 / 6.0).round() as i32, (c.1 / 6.0).round() as i32), color); + } + } + } // Fill pass let mut fill_groups = Vec::::new(); @@ -1114,6 +1403,11 @@ fn build_tile_buffers_from_features( building_groups.push(BuildingGroup { rings: Vec::new(), height_m: building_height_m(&way.tags), + min_height_m: building_min_height_m(&way.tags), + is_part: way + .tags + .get("building:part") + .is_some_and(|value| value != "no"), }); index }; @@ -1135,7 +1429,7 @@ fn build_tile_buffers_from_features( } // Labels are independent of fills: a named zoo enclosure with no // distinctive surface still gets its name at the centroid. - let fill_color = fill_color_for_tags(theme, &way.tags, way.closed); + let fill_color = fill_color_for_tags(theme, &way.tags, way.closed, render_zoom); let Some(mut ring_points) = normalize_polygon_ring(&prepared_way.points) else { continue; }; @@ -1318,8 +1612,46 @@ fn build_tile_buffers_from_features( struct BuildingJob { polygon: Vec>, height_m: f32, + base_m: f32, + tint: Option, min_y: f32, } + // Simple 3D Buildings: an outline whose interior holds + // building:parts must NOT extrude — the parts carry the true + // volumes (Westerkerk's nave + 85m Westertoren); the outline + // keeps only a flat footprint fill beneath them. + let part_centroids: Vec<(f32, f32)> = building_groups + .iter() + .filter(|group| group.is_part) + .filter_map(|group| { + group + .rings + .iter() + .max_by(|a, b| { + a.signed_area + .abs() + .partial_cmp(&b.signed_area.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|ring| ring_centroid(&ring.points)) + }) + .collect(); + if !part_centroids.is_empty() { + for group in building_groups.iter_mut() { + if group.is_part { + continue; + } + let covers = group.rings.iter().any(|ring| { + part_centroids + .iter() + .any(|c| point_in_ring(*c, &ring.points)) + }); + if covers { + group.height_m = 0.0; + group.min_height_m = 0.0; + } + } + } let mut building_jobs = Vec::::new(); for group in &building_groups { for polygon in classify_polygon_rings(&group.rings, EARCUT_MAX_RINGS) { @@ -1330,9 +1662,31 @@ fn build_tile_buffers_from_features( .iter() .flat_map(|ring| ring.iter()) .fold(f32::MAX, |acc, p| acc.min(p.1)); + let tint = if bag_centroid_colors.is_empty() { + None + } else { + polygon.first().and_then(|ring| { + let c = ring_centroid(ring); + let (qx, qy) = ((c.0 / 6.0).round() as i32, (c.1 / 6.0).round() as i32); + let mut found = None; + 'search: for dy in -1..=1 { + for dx in -1..=1 { + if let Some(color) = + bag_centroid_colors.get(&(qx + dx, qy + dy)) + { + found = Some(*color); + break 'search; + } + } + } + found + }) + }; building_jobs.push(BuildingJob { polygon, height_m: group.height_m, + base_m: group.min_height_m.clamp(0.0, group.height_m), + tint, min_y, }); } @@ -1343,10 +1697,15 @@ fn build_tile_buffers_from_features( .unwrap_or(std::cmp::Ordering::Equal) }); let base_color = theme.building_fill_color().unwrap_or(0xd9d0c9); - let roof_color = hex_to_premul_rgba(base_color, 1.0); // Light from the north-west; walls shade by their outward normal. let (light_x, light_y) = (-0.55_f32, -0.835_f32); for job in &building_jobs { + // Building-age layer tints the 3D model itself (walls shade + // from the same hue via the normal lighting math). + let roof_color = hex_to_premul_rgba(job.tint.unwrap_or(base_color), 1.0); + if job.height_m <= 0.05 { + // Flattened outline: footprint fill only, no walls. + } else { for ring in &job.polygon { // Outward normal needs ring orientation; positive shoelace // in y-down tile space = exterior winding, holes come @@ -1384,6 +1743,7 @@ fn build_tile_buffers_from_features( append_wall_quad( a, b, + job.base_m, job.height_m, wall_color, &mut fill_vertices, @@ -1392,6 +1752,7 @@ fn build_tile_buffers_from_features( ); } } + } for ring in &job.polygon { emit_path(&mut path, ring, true); } @@ -1424,6 +1785,227 @@ fn build_tile_buffers_from_features( } } + // Little 3D trees (tilt mode): two crossed trunk quads (visible from + // any camera heading) + two stacked canopy discs lifted by the same + // per-meter height mechanism as building roofs — the tilt compression + // turns them into oval blobs. + if !tree_points_3d.is_empty() { + let n = (1u32 << tile_key.z) as f64; + let lat = (std::f64::consts::PI * (1.0 - 2.0 * (tile_key.y as f64 + 0.5) / n)) + .sinh() + .atan(); + // tile-local units per meter at this latitude + let units_per_m = + (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; + let trunk_color = hex_to_premul_rgba(0x8a6b4a, 1.0); + let canopy_color = hex_to_premul_rgba(0x4a7d44, 1.0); + let arm = 0.7 * units_per_m; + + for (x, y) in &tree_points_3d { + append_wall_quad( + (*x - arm, *y), + (*x + arm, *y), + 0.0, + 7.5, + trunk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + append_wall_quad( + (*x, *y - arm), + (*x, *y + arm), + 0.0, + 7.5, + trunk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + // Street-tree proportions vs buildings: ~11.5m total. The + // canopy is a PROLATE ellipsoid (taller than wide) on a tall + // trunk — scaling the ball uniformly reads as a bush. + append_ball( + (*x, *y), + 2.9 * units_per_m, + 4.0, + 7.5, + canopy_color, + 16, + 8, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + feature_count += 1; + } + } + + // Dynamic stalk heights: every flying marker clears the building under + // it by ~8 m (a 100 m tower gets a 108 m pin), plus a small + // deterministic stagger so clustered pins don't form one flat plane. + let job_lifts: Vec = icon_jobs + .iter() + .map(|job| { + let base = job.9; + if base <= 0.0 { + return 0.0; + } + let (px, py) = job.0; + let mut clearance = 0.0f32; + for group in &building_groups { + if group.height_m <= clearance { + continue; + } + for ring in &group.rings { + if ring.signed_area <= 0.0 { + continue; + } + if point_in_ring((px, py), &ring.points) { + clearance = clearance.max(group.height_m); + break; + } + } + } + base.max(clearance + 8.0) + }) + .collect(); + // Propagate the FINAL lifts into the labels and tap zones that belong + // to these markers, so text and hit-testing ride the same stalk. + for (job, lift) in icon_jobs.iter().zip(job_lifts.iter()) { + if *lift <= 0.0 { + continue; + } + let (jx, jy) = job.0; + for label in labels.iter_mut() { + let eligible = label.color_class == crate::map::label::LABEL_CLASS_PIN + || label.road_kind.starts_with("chb") + || label.road_kind.starts_with("poi") + || label.road_kind.starts_with("stS") + || label.road_kind.starts_with("stp"); + if !eligible || label.path_points.is_empty() { + continue; + } + let (lx, ly) = label.path_points[0]; + let (mx, my) = label + .path_points + .last() + .map(|p| ((lx + p.0) * 0.5, (ly + p.1) * 0.5)) + .unwrap_or((lx, ly)); + if (mx - jx).abs() < 2.5 && (my - jy).abs() < 2.5 { + label.lift_m = *lift; + } + } + let world = (1u32 << tile_key.z) as f64; + let jnorm = ( + (tile_key.x as f64 + jx as f64 / crate::map::geometry::TILE_SIZE) / world, + (tile_key.y as f64 + jy as f64 / crate::map::geometry::TILE_SIZE) / world, + ); + for hit in pin_hits.iter_mut() { + if (hit.norm.0 - jnorm.0).abs() < 1e-7 && (hit.norm.1 - jnorm.1).abs() < 1e-7 { + hit.lift_m = *lift; + } + } + } + // Marker stalks (3D mode): thin dark lines from the ground point up to + // every floating marker. + if buildings_3d { + let has_pins = icon_jobs.iter().any(|job| job.9 > 0.0); + if has_pins { + let n = (1u32 << tile_key.z) as f64; + let lat = (std::f64::consts::PI * (1.0 - 2.0 * (tile_key.y as f64 + 0.5) / n)) + .sinh() + .atan(); + let units_per_m = + (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; + let stalk_color = hex_to_premul_rgba(0x4a5058, 1.0); + for (job_index, job) in icon_jobs.iter().enumerate() { + let lift = job_lifts[job_index]; + if lift <= 0.0 { + continue; + } + // Chargers get a slightly heavier stalk than POI markers. + let arm = if job.5 == 2 || job.5 == 3 { 0.22 } else { 0.14 } * units_per_m; + let (x, y) = job.0; + append_wall_quad( + (x - arm, y), + (x + arm, y), + 0.0, + lift, + stalk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + append_wall_quad( + (x, y - arm), + (x, y + arm), + 0.0, + lift, + stalk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + } + } + } + + // Little 3D stoplights (tilt mode): a slim dark pole with the classic + // three lights stacked on top — red above amber above green. + if !signal_points_3d.is_empty() { + let n = (1u32 << tile_key.z) as f64; + let lat = (std::f64::consts::PI * (1.0 - 2.0 * (tile_key.y as f64 + 0.5) / n)) + .sinh() + .atan(); + let units_per_m = + (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; + let pole_color = hex_to_premul_rgba(0x3c4046, 1.0); + let lights = [ + (hex_to_premul_rgba(0x2ecc40, 1.0), 3.5f32), + (hex_to_premul_rgba(0xf5a623, 1.0), 4.35), + (hex_to_premul_rgba(0xd7263d, 1.0), 5.2), + ]; + let arm = 0.32 * units_per_m; + for (x, y) in &signal_points_3d { + append_wall_quad( + (*x - arm, *y), + (*x + arm, *y), + 0.0, + 3.2, + pole_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + append_wall_quad( + (*x, *y - arm), + (*x, *y + arm), + 0.0, + 3.2, + pole_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + for (color, height_m) in lights { + append_ball( + (*x, *y), + 0.5 * units_per_m, + 0.5, + height_m, + color, + 8, + 4, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + } + feature_count += 1; + } + } + // Stroke pass let mut stroke_jobs = Vec::::new(); let mut arrow_jobs = Vec::<(Vec<(f32, f32)>, bool)>::new(); @@ -1439,7 +2021,9 @@ fn build_tile_buffers_from_features( way.tags.get("junction").map(|v| v.as_str()), Some("roundabout") | Some("circular") ); - if render_zoom >= ICON_MIN_ZOOM + // Oneway arrows read from mid zoom, not just street level — + // direction matters while route-planning zoomed out. + if render_zoom >= 15 && (tag_is_truthy(&way.tags, "oneway") || implicit_oneway) && way.tags.contains_key("highway") && !tag_is_truthy(&way.tags, "rail") @@ -1591,11 +2175,18 @@ fn build_tile_buffers_from_features( } // Pass 3: POI symbols — zoom-constant vector icons, drawn above strokes. - for (anchor, mesh, color_class, _, _, two_tone, kw) in &icon_jobs { + for (job_index, (anchor, mesh, color_class, _, _, two_tone, kw, stalls, zoom_floor, _)) in + icon_jobs.iter().enumerate() + { + let pin_lift_m = job_lifts[job_index]; + // The lift rides in param4's hundreds so the zoom floor keeps its + // low digits. + let param4_encoded = zoom_floor + pin_lift_m * 100.0; append_icon_mesh( mesh, *anchor, hex_to_premul_rgba(poi_class_hex(*color_class), 1.0), + param4_encoded, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -1607,6 +2198,7 @@ fn build_tile_buffers_from_features( core, *anchor, hex_to_premul_rgba(0x4c7a4c, 1.0), + param4_encoded, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -1624,33 +2216,14 @@ fn build_tile_buffers_from_features( bolt, *anchor, hex_to_premul_rgba(0xffffff, 1.0), + param4_encoded, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, ); } } - if *two_tone == 2 { - let text = format!("{:.0}", kw.min(999.0)); - let advance = 6.4f32; - let total = text.len() as f32 * advance; - // Text zone: right of the bolt inside the wide bubble. - let start_x = 3.0 - total * 0.5 + advance * 0.5; - for (index, ch) in text.chars().enumerate() { - let Some(digit) = ch.to_digit(10) else { continue }; - if let Some(mesh) = icon_mesh(super::icons::DIGIT_NAMES[digit as usize]) { - append_icon_mesh_offset( - mesh, - *anchor, - (start_x + index as f32 * advance, -3.5), - hex_to_premul_rgba(0xffffff, 1.0), - &mut icon_vertices, - &mut icon_indices, - &mut icon_zbias, - ); - } - } - } + let _ = (kw, stalls); feature_count += 1; } @@ -1780,21 +2353,58 @@ fn append_icon_mesh( mesh: &IconMesh, anchor: (f32, f32), color: [f32; 4], + min_zoom: f32, out_vertices: &mut Vec, out_indices: &mut Vec, zbias: &mut f32, ) { - append_icon_mesh_offset(mesh, anchor, (0.0, 0.0), color, out_vertices, out_indices, zbias) + append_icon_mesh_offset( + mesh, + anchor, + (0.0, 0.0), + color, + min_zoom, + out_vertices, + out_indices, + zbias, + ) } /// Like append_icon_mesh with an extra SCREEN-px offset added to every /// vertex — lets shared meshes (digits) compose inside a pin badge while /// staying zoom-constant with it. +#[allow(clippy::too_many_arguments)] fn append_icon_mesh_offset( mesh: &IconMesh, anchor: (f32, f32), screen_offset: (f32, f32), color: [f32; 4], + min_zoom: f32, + out_vertices: &mut Vec, + out_indices: &mut Vec, + zbias: &mut f32, +) { + append_icon_mesh_offset_scaled( + mesh, + anchor, + screen_offset, + 1.0, + color, + min_zoom, + out_vertices, + out_indices, + zbias, + ) +} + +#[allow(clippy::too_many_arguments)] +fn append_icon_mesh_offset_scaled( + mesh: &IconMesh, + anchor: (f32, f32), + screen_offset: (f32, f32), + scale: f32, + color: [f32; 4], + min_zoom: f32, out_vertices: &mut Vec, out_indices: &mut Vec, zbias: &mut f32, @@ -1814,11 +2424,18 @@ fn append_icon_mesh_offset( vertex.stroke_dist, ICON_SHAPE_ID, 0.0, // param0: solid color - vertex.x + screen_offset.0, // param1/2: screen-px offset from the anchor - vertex.y + screen_offset.1, + // param1/2: screen-px offset from the anchor + vertex.x * scale + screen_offset.0, + vertex.y * scale + screen_offset.1, 0.0, - 0.0, - 90.0, // tilt depth: markers NEVER clip into the tilted ground + // param4: this icon's view-zoom floor; the shader collapses the + // vertex when the live view zoom is below it (no stale flash). + min_zoom, + // Tilt depth: a SMALL camera-ward bias -- enough to clear the + // marker's own ground pixel (fill/stroke micro-ranks are tiny), + // small enough that buildings meaningfully in FRONT occlude + // the marker, keeping the 3D illusion honest. + 0.35, 24.0, // clip_radius: generous, avoids pop-in at view edges *zbias, ]); @@ -1879,6 +2496,9 @@ pub struct OverlayTileData { pub quadrant_y: u32, /// 0 = all features, 1 = fast chargers (>=50 kW), 2 = slow chargers. pub filter: u8, + /// Source is a charger overlay: base-map charging_station icons are + /// suppressed as duplicates while one is active. + pub has_chargers: bool, } fn overlay_zoom_range(reader: &mut MbtilesReader) -> (u32, u32) { @@ -1906,7 +2526,7 @@ pub fn load_local_tile_batch( } // Path entries may carry a "?fast" / "?slow" charger-power filter. - let mut overlay_readers: Vec<(MbtilesReader, u32, u32, u8)> = overlay_paths + let mut overlay_readers: Vec<(MbtilesReader, u32, u32, u8, bool)> = overlay_paths .iter() .filter(|path| !path.is_empty()) .filter_map(|path| { @@ -1916,17 +2536,20 @@ pub fn load_local_tile_batch( Some((file, _)) => (file, 0), None => (path.as_str(), 0), }; - MbtilesReader::open(Path::new(file)).ok().map(|reader| (reader, filter)) + let has_chargers = file.contains("chargers"); + MbtilesReader::open(Path::new(file)) + .ok() + .map(|reader| (reader, filter, has_chargers)) }) - .map(|(mut reader, filter)| { + .map(|(mut reader, filter, has_chargers)| { let (min_zoom, max_zoom) = overlay_zoom_range(&mut reader); - (reader, min_zoom, max_zoom, filter) + (reader, min_zoom, max_zoom, filter, has_chargers) }) .collect(); let mut fetch_overlays = |tile_key: TileKey| -> Vec { let mut out = Vec::new(); - for (reader, min_zoom, max_zoom, filter) in overlay_readers.iter_mut() { + for (reader, min_zoom, max_zoom, filter, has_chargers) in overlay_readers.iter_mut() { if tile_key.z < *min_zoom { continue; } @@ -1942,6 +2565,7 @@ pub fn load_local_tile_batch( quadrant_x: (tile_key.x as u32) - ((fetch_x as u32) << shift), quadrant_y: (tile_key.y as u32) - ((fetch_y as u32) << shift), filter: *filter, + has_chargers: *has_chargers, }); } } @@ -2809,6 +3433,41 @@ fn skip_pb_field(bytes: &[u8], pos: &mut usize, wire: u8) -> Result<(), String> mod bridge_probe_tests { use super::*; + #[test] + #[ignore] + fn westerkerk_probe() { + let detail = std::path::Path::new("../local/maps/europe-osm-detail.mbtiles"); + if !detail.exists() { + return; + } + let mut reader = makepad_mbtile_reader::MbtilesReader::open(detail).unwrap(); + let (z, x, y) = (14i64, 8414i64, 5384i64); + let raw = reader.get_tile(z, x, (1 << z) - 1 - y).unwrap().unwrap(); + let key = TileKey { z: z as u32, x: x as i32, y: y as i32 }; + let pbf = decode_vector_tile_payload(&raw).unwrap(); + let mut collector = MvtLocalCollector::new(4.0); + parse_mvt_tile(&pbf, key, &mut collector).unwrap(); + let mut by_layer = std::collections::HashMap::::new(); + for way in &collector.ways { + let layer = way.tags.get("layer").cloned().unwrap_or_default(); + *by_layer.entry(layer).or_default() += 1; + if way.tags.contains_key("building:part") { + println!( + "PART layer={} closed={} pts={} id={:?} h={:?} min={:?}", + way.tags.get("layer").cloned().unwrap_or_default(), + way.closed, + way.points.len(), + way.tags.get("__makepad_osm_id"), + way.tags.get("height"), + way.tags.get("min_height"), + ); + } + } + let mut stats: Vec<_> = by_layer.into_iter().collect(); + stats.sort(); + println!("LAYER STATS {:?}", stats); + } + #[test] #[ignore] fn place_labels_probe() { @@ -2921,6 +3580,8 @@ mod bridge_probe_tests { shift: 0, quadrant_x: 0, quadrant_y: 0, + filter: 0, + has_chargers: true, }]; let theme = CompiledMapTheme::default(); let buffers = diff --git a/widgets/src/map/view.rs b/widgets/src/map/view.rs index a8e8973c6..db1c60acd 100644 --- a/widgets/src/map/view.rs +++ b/widgets/src/map/view.rs @@ -27,6 +27,13 @@ script_mod! { map_offset: uniform(vec2(0.0, 0.0)) tile_fade: uniform(1.0) width_correction: uniform(vec4(1.0, 1.0, 1.0, 1.0)) + // Live view zoom for per-icon zoom floors (param4 on shape 20): + // stale deeper-bucket tiles must not flash markers on zoom-out. + icon_zoom: uniform(24.0) + // 2D->3D transition: scales the per-meter height lift so buildings + // (and trees/signals) GROW out of the ground as their 3D bake fades + // in, and sink back when leaving 3D — instead of crossfading. + height_grow: uniform(1.0) // Heading-up camera: cos/sin of the screen rotation and its pivot // (the view center). Identity when north-up. view_rot: uniform(vec2(1.0, 0.0)) @@ -79,14 +86,34 @@ script_mod! { // extrude toward screen-top. The pre-tilt (ground) y doubles as // the view depth so the depth buffer resolves occlusion. let ground_rel_y = transformed.y - self.rot_pivot.y; + // param4 is building height in meters — EXCEPT on shape 20 + // icons, where it carries the icon's zoom floor and must not + // lift the marker off the ground. + var lift_m = self.geom.param4 * self.height_grow; + if shape_id > 19.5 && shape_id < 20.5 { + // Icon param4 = zoom_floor + pin_lift_m*100: markers fly at + // their encoded height (0 for grounded icons). + let icon_floor = modf(self.geom.param4, 100.0); + lift_m = (self.geom.param4 - icon_floor) * 0.01 * self.height_grow; + } transformed.y = self.rot_pivot.y + ground_rel_y * self.tilt_params.x - - self.geom.param4 * self.tilt_params.y; + - lift_m * self.tilt_params.y; // shape 20: zoom-constant symbol — position is the anchor point, // param1/2 the vertex offset in screen px added after the // transform. POI symbols stay upright; map-aligned glyphs like // oneway arrows (param3 flag) rotate with the camera. if shape_id > 19.5 && shape_id < 20.5 { + // 0.6 grace below the floor: markers fade out on a zoom + // gesture instead of vanishing the instant the tier line + // is crossed (still far above the stale-carpet zone). + // FAIL-OPEN: if the icon_zoom uniform hasn't landed (reads + // ~0 — seen when a startup DSL override re-parses this + // shader), the gate disarms instead of hiding every icon. + if self.icon_zoom > 1.0 && modf(self.geom.param4, 100.0) > self.icon_zoom + 0.6 { + self.vertex_pos = vec4(0.0, 0.0, 0.0, 0.0); + return + } var off = vec2(self.geom.param1, self.geom.param2); if self.geom.param3 > 0.5 { off = vec2( @@ -235,6 +262,57 @@ script_mod! { } } + // Rain radar raster overlay: one textured quad whose four SCREEN-space + // corners come from the overlay camera (so it pans/zooms/rotates/tilts + // with the map); texture is a mercator-aligned RGBA nowcast frame. + mod.draw.DrawRainOverlay = mod.std.set_type_default() do #(DrawRainOverlay::script_shader(vm)){ + ..mod.draw.DrawQuad + tex: texture_2d(float) + // c0..c3 + rain_alpha come from the Rust struct's #[live] fields + // (auto-registered as instance inputs; declaring them here too + // collides, as with DrawRotatedText.upright). + uv: varying(vec2f) + + vertex: fn() { + let top = mix(self.c0, self.c1, self.geom.pos.x) + let bottom = mix(self.c3, self.c2, self.geom.pos.x) + let p = mix(top, bottom, self.geom.pos.y) + self.uv = self.geom.pos + let shifted = p + self.draw_list.view_shift + self.vertex_pos = self.draw_pass.camera_projection * (self.draw_pass.camera_view * ( + self.draw_list.view_transform * vec4( + shifted.x, + shifted.y, + self.draw_depth + self.draw_call.zbias, + 1. + ) + )) + } + + pixel: fn() { + let color = self.tex.sample_as_bgra(self.uv) + // Isosurface look: each intensity band has a UNIQUE alpha, so a + // differing neighbor alpha marks a band boundary -> draw a + // darker contour line there (weather-radar isopleths). + let e1 = self.tex.sample_as_bgra(self.uv + vec2(self.texel.x, 0.0)) + let e2 = self.tex.sample_as_bgra(self.uv - vec2(self.texel.x, 0.0)) + let e3 = self.tex.sample_as_bgra(self.uv + vec2(0.0, self.texel.y)) + let e4 = self.tex.sample_as_bgra(self.uv - vec2(0.0, self.texel.y)) + var edge = 0.0 + if abs(e1.w - color.w) > 0.01 || abs(e2.w - color.w) > 0.01 + || abs(e3.w - color.w) > 0.01 || abs(e4.w - color.w) > 0.01 { + edge = 1.0 + } + var rgb = color.xyz + var alpha = color.w * self.rain_alpha + if edge > 0.5 && color.w > 0.01 { + rgb = rgb * 0.55 + alpha = min(alpha * 1.7 + 0.12, 0.95) + } + return vec4(rgb * alpha, alpha) + } + } + mod.widgets.MapViewBase = #(MapView::register_widget(vm)) mod.widgets.MapView = set_type_default() do mod.widgets.MapViewBase{ @@ -402,6 +480,11 @@ pub enum MapViewAction { lat: f64, zoom: f64, }, + /// Camera tilt changed via the rotate/tilt gesture — lets the app + /// keep its 2D/3D mode state in sync with manual tilting. + TiltChanged { + tilt: f64, + }, /// A charger pin was tapped: position + the attributes we know. PinTapped { lon: f64, @@ -440,6 +523,25 @@ struct FlyTo { // --- Draw shaders --- +#[derive(Script, ScriptHook)] +#[repr(C)] +pub struct DrawRainOverlay { + #[deref] + pub draw_super: DrawQuad, + #[live(vec2(0.0, 0.0))] + pub c0: Vec2f, + #[live(vec2(0.0, 0.0))] + pub c1: Vec2f, + #[live(vec2(0.0, 0.0))] + pub c2: Vec2f, + #[live(vec2(0.0, 0.0))] + pub c3: Vec2f, + #[live(0.85)] + pub rain_alpha: f32, + #[live(vec2(0.0015625, 0.00125))] + pub texel: Vec2f, +} + #[derive(Script, ScriptHook, Debug)] #[repr(C)] pub struct DrawMapVector { @@ -466,6 +568,8 @@ impl DrawMapVector { view_rot: [f32; 2], rot_pivot: [f32; 2], tilt_params: [f32; 4], + icon_zoom: f32, + height_grow: f32, ) { self.map_scale = map_scale; self.map_offset = map_offset; @@ -497,6 +601,12 @@ impl DrawMapVector { self.draw_super .draw_vars .set_uniform(cx.cx, live_id!(tilt_params), &tilt_params); + self.draw_super + .draw_vars + .set_uniform(cx.cx, live_id!(icon_zoom), &[icon_zoom]); + self.draw_super + .draw_vars + .set_uniform(cx.cx, live_id!(height_grow), &[height_grow]); self.draw_super.draw_vars.geometry_id = Some(geometry_id); cx.new_draw_call(&self.draw_super.draw_vars); if self.draw_super.draw_vars.can_instance() { @@ -531,6 +641,9 @@ pub struct MapView { draw_label: DrawRotatedText, #[redraw] #[live] + draw_rain: DrawRainOverlay, + #[redraw] + #[live] draw_text: DrawText, #[live(4.9041)] @@ -625,6 +738,23 @@ pub struct MapView { applied_dark_theme: Option, #[rust] style_epoch: u64, + /// Rain radar nowcast: one mercator-aligned RGBA texture per +5 min + /// frame, animated on a timer while enabled. + #[rust] + rain_frames: Vec, + #[rust] + rain_frame_index: usize, + #[rust] + rain_timer: Timer, + /// (west, south, east, north) of the rain textures in lon/lat. + #[rust] + rain_bbox: (f64, f64, f64, f64), + #[rust] + rain_tex_size: (usize, usize), + /// The 2D/3D mode the current tile set was baked with — a flip + /// re-bakes tiles (extrusions only exist in the 3D bake). + #[rust] + baked_3d_mode: bool, #[rust] compiled_style_light: CompiledMapTheme, #[rust] @@ -645,7 +775,7 @@ pub struct MapView { #[rust] scratch_accepted_bounds: Vec, #[rust] - scratch_accepted_plans: Vec<(f64, usize, usize, u8)>, + scratch_accepted_plans: Vec<(f64, usize, usize, u8, bool, bool, Vec2f)>, // Labels drawn last frame (hashed name+position key); kept to stabilize // placement while panning instead of flickering between candidates. #[rust] @@ -681,6 +811,8 @@ pub struct MapView { label_cache_generation: u64, #[rust((1.0, Vec2f { x: 0.0, y: 0.0 }, 0.0, Vec2f { x: 0.0, y: 0.0 }, 1.0))] label_draw_transform: (f32, Vec2f, f32, Vec2f, f32), + #[rust(1.0)] + label_cache_tilt_cos_for_delta: f32, #[rust] tiles_generation: u64, #[rust] @@ -806,6 +938,10 @@ impl Widget for MapView { self.handle_tile_worker_messages(cx); self.widget_match_event(cx, event, scope); + if self.rain_timer.is_event(event).is_some() && !self.rain_frames.is_empty() { + self.rain_frame_index = (self.rain_frame_index + 1) % self.rain_frames.len(); + self.redraw(cx); + } if self.tile_fade_timer.is_event(event).is_some() { self.redraw(cx); if self.tiles.values().any(|entry| entry.fade.is_some()) { @@ -862,7 +998,12 @@ impl Widget for MapView { if let Some((start_abs, start_rotation, start_tilt)) = self.rotate_drag { let delta = fe.abs - start_abs; self.rotation = (start_rotation - delta.x * 0.35).rem_euclid(360.0); - self.tilt = (start_tilt + delta.y * 0.25).clamp(0.0, 65.0); + // Snap-to-2D dead zone: dragging the camera back to + // straight above lands on EXACTLY 0 so the renderer + // re-enters the flat classic path (2D mode). + let raw_tilt = (start_tilt + delta.y * 0.25).clamp(0.0, 65.0); + self.tilt = if raw_tilt < 6.0 { 0.0 } else { raw_tilt }; + cx.widget_action(self.uid, MapViewAction::TiltChanged { tilt: self.tilt }); self.redraw(cx); } else if let Some(start_abs) = self.drag_start_abs { let delta = fe.abs - start_abs; @@ -887,6 +1028,21 @@ impl Widget for MapView { } Hit::FingerUp(fe) => { if self.rotate_drag.take().is_some() { + // Releasing near straight-above settles on EXACTLY 0: + // a 5-10 degree residual reads as "3D mode stuck on". + if self.tilt > 0.0 && self.tilt < 10.0 { + self.tilt = 0.0; + cx.widget_action(self.uid, MapViewAction::TiltChanged { tilt: 0.0 }); + } + // Guarantee a full label re-place AFTER the rate-limit + // window: without this, a fast spin that ends inside the + // window leaves the cached placement rigidly rotated + // (180° = upside-down labels) with no frame scheduled + // to true it up. + cx.stop_timer(self.zoom_settle_timer); + self.zoom_settle_timer = + cx.start_timeout(LABEL_REPLACE_MIN_SECONDS + 0.05); + self.redraw(cx); self.sync_camera_fields(); self.emit_viewport_changed(cx); return; @@ -1033,7 +1189,7 @@ impl Widget for MapView { // view drops below icon level, instead of splattering // hundreds of full-size shop icons across the region. if pass == 3 - && (view_zoom < 8.75 + && (view_zoom < 7.75 || (entry.bucket >= ICON_MIN_ZOOM && view_zoom < ICON_MIN_ZOOM as f64 - 0.25)) { @@ -1082,6 +1238,8 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, + 1.0, ); } } @@ -1099,6 +1257,12 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, + if entry.fade.as_ref().is_some_and(|fade| fade.grow_heights) { + fade_alpha + } else { + 1.0 + }, ); } } @@ -1135,7 +1299,7 @@ impl Widget for MapView { // view drops below icon level, instead of splattering // hundreds of full-size shop icons across the region. if pass == 3 - && (view_zoom < 8.75 + && (view_zoom < 7.75 || (entry.bucket >= ICON_MIN_ZOOM && view_zoom < ICON_MIN_ZOOM as f64 - 0.25)) { @@ -1184,6 +1348,8 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, + 1.0, ); } } @@ -1201,12 +1367,55 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, + if entry.fade.as_ref().is_some_and(|fade| fade.grow_heights) { + fade_alpha + } else { + 1.0 + }, ); } } // Pin-class label text (white kW numbers) over the pins. + // Rain radar overlay: over all map content, under labels/UI. The + // quad's corners go through the overlay camera so it sticks to the + // map in every projection. + if !self.rain_frames.is_empty() { + let camera = self.overlay_camera(); + let (west, south, east, north) = self.rain_bbox; + let nw = lon_lat_to_normalized(west, north); + let ne = lon_lat_to_normalized(east, north); + let se = lon_lat_to_normalized(east, south); + let sw = lon_lat_to_normalized(west, south); + let c0 = camera.norm_to_screen(nw); + let c1 = camera.norm_to_screen(ne); + let c2 = camera.norm_to_screen(se); + let c3 = camera.norm_to_screen(sw); + let texture = self.rain_frames[self.rain_frame_index % self.rain_frames.len()].clone(); + self.draw_rain.draw_super.draw_vars.set_texture(0, &texture); + self.draw_rain.c0 = Vec2f { x: c0.x as f32, y: c0.y as f32 }; + self.draw_rain.c1 = Vec2f { x: c1.x as f32, y: c1.y as f32 }; + self.draw_rain.c2 = Vec2f { x: c2.x as f32, y: c2.y as f32 }; + self.draw_rain.c3 = Vec2f { x: c3.x as f32, y: c3.y as f32 }; + self.draw_rain.texel = Vec2f { + x: 1.0 / self.rain_tex_size.0 as f32, + y: 1.0 / self.rain_tex_size.1 as f32, + }; + let min_x = c0.x.min(c1.x).min(c2.x).min(c3.x); + let min_y = c0.y.min(c1.y).min(c2.y).min(c3.y); + let max_x = c0.x.max(c1.x).max(c2.x).max(c3.x); + let max_y = c0.y.max(c1.y).max(c2.y).max(c3.y); + self.draw_rain.draw_abs( + cx, + Rect { + pos: dvec2(min_x, min_y), + size: dvec2(max_x - min_x, max_y - min_y), + }, + ); + } + self.draw_pin_label_phase(cx); @@ -1493,6 +1702,7 @@ impl MapView { // Cross-fade: keep the replaced generation's geometry under the new // one for TILE_FADE_SECONDS instead of popping. + let new_baked_3d = self.baked_3d_mode; let fade = match self.tiles.remove(&tile_key) { Some(TileEntry { state: @@ -1504,10 +1714,12 @@ impl MapView { .. }, bucket: old_bucket, + baked_3d: old_baked_3d, .. }) => Some(TileFade { started: std::time::Instant::now(), bucket: old_bucket, + grow_heights: new_baked_3d && !old_baked_3d, fill_geometry: old_fill, casing_geometry: old_casing, stroke_geometry: old_stroke, @@ -1516,6 +1728,7 @@ impl MapView { _ => Some(TileFade { started: std::time::Instant::now(), bucket: buffers.render_zoom, + grow_heights: new_baked_3d, fill_geometry: None, casing_geometry: None, stroke_geometry: None, @@ -1540,6 +1753,7 @@ impl MapView { last_used: self.frame_counter, attempts: 0, bucket: buffers.render_zoom, + baked_3d: self.baked_3d_mode, fade, }, ); @@ -1765,6 +1979,7 @@ impl MapView { last_used: self.frame_counter, attempts: 0, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -1784,9 +1999,6 @@ impl MapView { .filter(|p| !p.trim().is_empty()) .map(|p| p.trim().to_string()) .collect(); - if !overlay_paths.is_empty() { - log!("tile fetch {:?} with {} overlays", key, overlay_paths.len()); - } // Extruded buildings only bake while the camera is tilted; flat // mode keeps the classic 2D building style with outlines. let buildings_3d = self.buildings_3d && self.tilt > 0.0; @@ -1837,6 +2049,7 @@ impl MapView { last_used: self.frame_counter, attempts, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -1907,6 +2120,14 @@ impl MapView { fn ensure_visible_tiles(&mut self, cx: &mut Cx, rect: Rect) { self.frame_counter = self.frame_counter.wrapping_add(1); + // Tiles bake differently in 2D vs 3D (building extrusions replace + // the flat fills). Crossing tilt 0 must re-bake, or leaving 3D + // keeps the extruded set until a zoom forces a bucket rebuild. + let mode_3d = self.buildings_3d && self.tilt > 0.0; + if mode_3d != self.baked_3d_mode { + self.baked_3d_mode = mode_3d; + self.restyle_tiles_keep_stale(cx); + } // Read the archive's declared zoom range BEFORE computing visible // tile keys — request_zoom_level clamps to it, and reading it after // meant the very first frame requested impossible zoom levels. @@ -2192,6 +2413,7 @@ impl MapView { last_used: self.frame_counter, attempts: 0, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -2247,6 +2469,7 @@ impl MapView { last_used: self.frame_counter, attempts, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -2307,12 +2530,31 @@ impl MapView { let pivot = rect.pos + rect.size * 0.5; shift += (pivot - camera_vec(pivot)) * (1.0 - k); } - // Screen-space delta rotation about the view pivot (phi = -rotation) - // plus a tilt-compression ratio: cached glyphs follow the camera - // every frame — ANY delta — and the async re-place trues up. + // The GPU camera-delta matrix transforms everything AFTER the + // CPU offsets are applied — pre-invert the pan shift so it + // lands where intended: shift_pre = M^-1 * shift. + { + let (dc, ds) = ((-rot_delta).to_radians().cos(), (-rot_delta).to_radians().sin()); + let t0 = self + .label_cache_tilt + .clamp(0.0, 65.0) + .to_radians() + .cos() + .max(1e-6); + let t1 = self.tilt_cos().max(1e-6); + let (a, b, c, d) = (dc, -ds / t0, t1 * ds, t1 * dc / t0); + let det = a * d - b * c; + if det.abs() > 1e-9 { + let (sx, sy) = (shift.x, shift.y); + shift = dvec2((d * sx - b * sy) / det, (-c * sx + a * sy) / det); + } + } + // Screen-space delta rotation about the view pivot (phi = -rotation); + // the cached placement's tilt_cos rides along so the draw can + // build the exact non-commuting delta matrix. let rot_rad = (-rot_delta).to_radians() as f32; - let cached_tilt_cos = self.label_cache_tilt.clamp(0.0, 65.0).to_radians().cos(); - let tilt_ratio = (self.tilt_cos() / cached_tilt_cos.max(1e-6)) as f32; + let cached_tilt_cos = + (self.label_cache_tilt.clamp(0.0, 65.0).to_radians().cos() as f32).max(1e-4); let pivot = rect.pos + rect.size * 0.5; self.draw_label_plans_scaled( cx, @@ -2326,7 +2568,7 @@ impl MapView { x: pivot.x as f32, y: pivot.y as f32, }, - tilt_ratio, + cached_tilt_cos, false, ); return false; @@ -2380,7 +2622,11 @@ impl MapView { break; } let candidate = &self.scratch_candidates[candidate_index]; - let close_repeat = self + // Every pin needs ITS number: two 120kW sites near each other + // are different chargers, not a repeated street name — the + // name-key repeat suppression must not blank the second pin. + let close_repeat = candidate.color_class != LABEL_CLASS_PIN + && self .scratch_accepted_centers .get(&candidate.name_key) .is_some_and(|centers| { @@ -2424,9 +2670,17 @@ impl MapView { label_perf.rejected_outside += 1; continue; } - if self.scratch_accepted_bounds.iter().any(|placed| { - rects_overlap_with_padding(*placed, placement.bounds, LABEL_COLLISION_PADDING) - }) { + // In-pin text never collision-culls: it sits INSIDE the pin + // bubble (which already icon-collides), so losing to a nearby + // place/street label just blanked the pin. It still RESERVES + // its box so street text avoids the area. + let is_pin_text = self.scratch_candidates[candidate_index].color_class + == LABEL_CLASS_PIN; + if !is_pin_text + && self.scratch_accepted_bounds.iter().any(|placed| { + rects_overlap_with_padding(*placed, placement.bounds, LABEL_COLLISION_PADDING) + }) + { self.path_glyphs.truncate(placement.glyph_start); label_perf.rejected_collision += 1; continue; @@ -2443,18 +2697,58 @@ impl MapView { .or_default() .push(placement.center); } - self.scratch_accepted_bounds.push(placement.bounds); + // Pin text reserves the pin BUBBLE's box (not its own glyph + // box): POI/street labels then place beside the pin instead of + // under it, while the brand label below the tail stays legal. + if is_pin_text { + let anchor_x = placement.center.x - 3.0; + let anchor_y = placement.center.y + 12.35; + self.scratch_accepted_bounds.push(Rect { + pos: dvec2(anchor_x - 16.0, anchor_y - 27.0), + size: dvec2(32.0, 28.0), + }); + } else { + self.scratch_accepted_bounds.push(placement.bounds); + } let glyph_count = placement.glyph_end - placement.glyph_start; label_perf.drawn_labels += 1; label_perf.drawn_glyphs += glyph_count; let score = candidate.score + candidate.source_rank as f64 * 2.0; self.scratch_accepted_hashes .push(stable_label_key(&candidate.name_key, &candidate.road_kind)); + // Post-icon phase: in-pin text and charger brand draw AFTER + // the symbol pass so they sit on the pins, not under them. + let post_icon = candidate.color_class == LABEL_CLASS_PIN + || candidate.road_kind.starts_with("chb"); + // Billboard pin-phase plans anchor at the SITE point (the pin's + // baked anchor): back the screen-px layout shift out of the + // placement center so glyph offsets carry the layout instead. + let lift_px = candidate.lift_px; + let layout_shift = if candidate.color_class == LABEL_CLASS_PIN { + (3.0f32, -12.35f32 - lift_px) + } else if candidate.road_kind.starts_with("chb") { + (0.0, 9.0 - lift_px) + } else if candidate.road_kind.starts_with("poi") && lift_px > 0.0 { + (0.0, -lift_px - 12.0) + } else if (candidate.road_kind.starts_with("stS") + || candidate.road_kind.starts_with("stp")) + && lift_px > 0.0 + { + (0.0, -lift_px - 10.0) + } else { + (0.0, 0.0) + }; self.scratch_accepted_plans.push(( score, placement.glyph_start, placement.glyph_end, candidate.color_class, + post_icon, + candidate.screen_point, + Vec2f { + x: placement.center.x as f32 - layout_shift.0, + y: placement.center.y as f32 - layout_shift.1, + }, )); } @@ -2476,13 +2770,14 @@ impl MapView { /// as one glyph instance batch, optionally shifted by a screen offset /// (used to redraw the cached placement while panning). fn draw_label_plans(&mut self, cx: &mut Cx2d, extra_offset: Vec2f) { + let current_tilt_cos = (self.tilt_cos() as f32).max(1e-4); self.draw_label_plans_scaled( cx, 1.0, extra_offset, 0.0, Vec2f { x: 0.0, y: 0.0 }, - 1.0, + current_tilt_cos, false, ); } @@ -2490,8 +2785,8 @@ impl MapView { /// Redraw only the pin-class (in-bubble) label plans — called after /// the icon pass so kW text sits on top of the charger pins. fn draw_pin_label_phase(&mut self, cx: &mut Cx2d) { - let (scale, offset, rot, pivot, tilt_ratio) = self.label_draw_transform; - self.draw_label_plans_scaled(cx, scale, offset, rot, pivot, tilt_ratio, true); + let (scale, offset, rot, pivot, cached_tilt_cos) = self.label_draw_transform; + self.draw_label_plans_scaled(cx, scale, offset, rot, pivot, cached_tilt_cos, true); } fn draw_label_plans_scaled( @@ -2501,12 +2796,13 @@ impl MapView { extra_offset: Vec2f, rot: f32, pivot: Vec2f, - tilt_ratio: f32, + cached_tilt_cos: f32, pin_phase: bool, ) { // Remember the transform so the pin-text phase redraws with the // exact same mapping after the icon pass. - self.label_draw_transform = (scale, extra_offset, rot, pivot, tilt_ratio); + self.label_draw_transform = (scale, extra_offset, rot, pivot, cached_tilt_cos); + self.label_cache_tilt_cos_for_delta = cached_tilt_cos; // 4 diagonal offsets read as a solid halo at map label sizes and // halve the glyph volume vs an 8-direction ring const HALO_OFFSETS: [(f32, f32); 4] = [ @@ -2522,76 +2818,56 @@ impl MapView { }; // Rigid delta-rotation of the cached placement about the pivot // (heading-up nav): transform a copy once, draw slices from it. - // Screen-point (pin) plans stay UPRIGHT: rotating them with the - // map while their billboard pins stay screen-aligned reads as - // doubled, garbled text during rotation. Collect their glyph - // ranges and exempt them from the rigid delta-rotation. - let mut pin_ranges: Vec<(usize, usize)> = Vec::new(); - for &(_, start, end, color_class) in &self.scratch_accepted_plans { - if color_class == LABEL_CLASS_PIN { - pin_ranges.push((start, end)); - } - } - let in_pin_range = - |index: usize| pin_ranges.iter().any(|&(s0, e0)| index >= s0 && index < e0); - let transform_active = rot != 0.0 || (tilt_ratio - 1.0).abs() > 1e-4; - let rotated: Vec = if transform_active { - let (c, s) = (rot.cos(), rot.sin()); - self.path_glyphs - .iter() - .enumerate() - .map(|(glyph_index, glyph)| { - if in_pin_range(glyph_index) { - return glyph.clone(); - } - let mut glyph = glyph.clone(); - // Rotate about the pivot, then compress y by the tilt - // ratio — cached glyphs track the camera best-effort; - // the async re-place trues everything up. - let spin = |p: crate::makepad_draw::text::geom::Point| { - let dx = p.x - pivot.x; - let dy = p.y - pivot.y; - crate::makepad_draw::text::geom::Point::new( - pivot.x + dx * c - dy * s, - pivot.y + (dx * s + dy * c) * tilt_ratio, - ) - }; - glyph.glyph_origin = spin(glyph.glyph_origin); - glyph.rotation_origin = spin(glyph.rotation_origin); - let a = glyph.angle + rot; - glyph.angle = (a.sin() * tilt_ratio).atan2(a.cos()); - glyph - }) - .collect() - } else { - Vec::new() - }; + // Camera-delta on the GPU: the EXACT delta between the cached + // placement's camera and now. The placement maps world points as + // rotate-about-pivot THEN y-compress by tilt_cos; the delta from + // (r0, t0) to (r1, t1) is S(t1)*R(d)*S(1/t0) — a general 2x2 (S + // and R do not commute), which is why a plain rotate+scale + // snapped visibly at every re-place in 2.5D. + let (dc, ds) = (rot.cos(), rot.sin()); + let t0 = self.label_cache_tilt_cos_for_delta; + let t1 = self.tilt_cos() as f32; + let m = [dc, -ds / t0, t1 * ds, t1 * dc / t0]; + self.draw_label.set_camera_delta(cx.cx, m, pivot); self.draw_label.begin_glyph_batch(cx); for i in 0..self.scratch_accepted_plans.len() { - let (_, start, end, color_class) = self.scratch_accepted_plans[i]; - if (color_class == LABEL_CLASS_PIN) != pin_phase { + let (_, start, end, color_class, post_icon, upright, anchor) = + self.scratch_accepted_plans[i]; + if post_icon != pin_phase { continue; } - let glyphs = if transform_active { - &rotated[start..end] - } else { - &self.path_glyphs[start..end] - }; - self.draw_label.draw_super.color = halo_color; - for offset in HALO_OFFSETS { - self.draw_label.draw_path_glyphs_scaled( - cx, - glyphs, - scale, - Vec2f { + let glyphs = &self.path_glyphs[start..end]; + let billboard = pin_phase && upright; + // In-pin text sits on a solid pin color: no halo underdraw. + if color_class != LABEL_CLASS_PIN { + self.draw_label.draw_super.color = halo_color; + for offset in HALO_OFFSETS { + let off = Vec2f { x: offset.0 + extra_offset.x, y: offset.1 + extra_offset.y, - }, - ); + }; + if billboard { + self.draw_label + .draw_path_glyphs_billboard(cx, glyphs, scale, off, anchor); + } else if upright { + self.draw_label + .draw_path_glyphs_upright(cx, glyphs, scale, off, anchor); + } else { + self.draw_label.draw_path_glyphs_scaled(cx, glyphs, scale, off); + } + } } self.draw_label.draw_super.color = label_class_color(color_class, label_color, dark_theme); - self.draw_label.draw_path_glyphs_scaled(cx, glyphs, scale, extra_offset); + if billboard { + self.draw_label + .draw_path_glyphs_billboard(cx, glyphs, scale, extra_offset, anchor); + } else if upright { + self.draw_label + .draw_path_glyphs_upright(cx, glyphs, scale, extra_offset, anchor); + } else { + self.draw_label.draw_path_glyphs_scaled(cx, glyphs, scale, extra_offset); + } } self.draw_label.end_glyph_batch(cx); } @@ -2684,6 +2960,40 @@ impl MapView { if is_address && view_zoom < ADDRESS_LABEL_MIN_ZOOM { continue; } + // Charger pin text carries the pin's zoom floor in its key + // ("chp11_..."): stale deeper tiles must not flash numbers + // for pins the icon shader is hiding at this view zoom. + if let Some(rest) = label.road_kind.strip_prefix("chp") { + let floor: f64 = rest + .split('_') + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.0); + if view_zoom < floor - 0.6 { + continue; + } + } + if label.road_kind.starts_with("chb") && view_zoom < 12.75 { + continue; + } + // District names: gemeente wide-out, wijk mid, buurt close. + if let Some(rest) = label.road_kind.strip_prefix("adm") { + let (floor, ceil) = match rest.chars().next() { + Some('g') => (8.0, 12.0), + Some('w') => (11.5, 14.0), + _ => (13.5, 17.0), + }; + if view_zoom < floor || view_zoom > ceil { + continue; + } + } + // Stop names: stations from z13, local tram/bus stops z15+. + if label.source_layer == "stops" { + let floor = if label.road_kind.starts_with("stS") { 13.0 } else { 15.0 }; + if view_zoom < floor { + continue; + } + } if is_poi && view_zoom < POI_LABEL_MIN_ZOOM { continue; } @@ -2709,7 +3019,9 @@ impl MapView { } // precomputed at tile build; no per-frame allocation let name_key = &label.name_key; - if name_key.len() < if is_address { 1 } else { 2 } { + let is_exit = label.source_layer == "street_labels_points"; + let is_pin_text = label.color_class == LABEL_CLASS_PIN; + if name_key.len() < if is_address || is_exit || is_pin_text { 1 } else { 2 } { continue; } @@ -2730,7 +3042,11 @@ impl MapView { || is_poi || matches!( label.source_layer.as_str(), - "chargers" | "charger_brand" | "place_labels" | "micro_pois" + "chargers" + | "charger_brand" + | "place_labels" + | "micro_pois" + | "street_labels_points" ); if rotated && is_screen_point && self.scratch_screen_path.len() == 2 { let a = self.scratch_screen_path[0]; @@ -2740,6 +3056,36 @@ impl MapView { self.scratch_screen_path[0] = dvec2(mid.x - half, mid.y); self.scratch_screen_path[1] = dvec2(mid.x + half, mid.y); } + // Charger brand reads just under the billboard pin: a fixed + // SCREEN-space drop below the site anchor (a map-space offset + // would tilt-compress and orbit the pin under rotation). + // Flying-marker labels ride their marker's BAKED stalk + // height (dynamic: each pin clears its own building). + let lift_px = self.lift_screen_px(label.lift_m, view_zoom); + if is_poi && lift_px > 0.0 { + // Above the floating icon. + for p in self.scratch_screen_path.iter_mut() { + p.y -= lift_px + 12.0; + } + } + if label.source_layer == "stops" && lift_px > 0.0 { + for p in self.scratch_screen_path.iter_mut() { + p.y -= lift_px + 10.0; + } + } + if label.road_kind.starts_with("chb") { + for p in self.scratch_screen_path.iter_mut() { + p.y += 9.0 - lift_px; + } + } + // In-pin text: center in the droplet's text zone (right of + // the bolt, above the tail); rides the stalk in 3D. + if label.color_class == LABEL_CLASS_PIN { + for p in self.scratch_screen_path.iter_mut() { + p.x += 3.0; + p.y += -12.35 - lift_px; + } + } if self.scratch_screen_path.len() < 2 || polyline_outside_rect(&self.scratch_screen_path, rect, LABEL_VIEW_MARGIN) { @@ -2785,7 +3131,9 @@ impl MapView { } else if is_poi { font_scale = 0.72; } else if label.source_layer == "chargers" { - font_scale = 0.85; + font_scale = 0.78; + } else if is_exit { + font_scale = 0.80; } else if let Some((kind, population)) = place { // Kind sets the class, population separates Amsterdam // from Purmerend within it. @@ -2842,6 +3190,8 @@ impl MapView { c.center = center; c.repeat_distance = repeat_distance; c.font_scale = font_scale; + c.screen_point = is_screen_point; + c.lift_px = lift_px as f32; c.screen_path.extend_from_slice(&self.scratch_screen_path); } else { self.scratch_candidates.push(LabelCandidate { @@ -2855,6 +3205,8 @@ impl MapView { center, repeat_distance, font_scale, + screen_point: is_screen_point, + lift_px: lift_px as f32, screen_path: self.scratch_screen_path.clone(), }); } @@ -2977,7 +3329,7 @@ impl MapView { * 0.5 * LABEL_BASELINE_SHIFT_FACTOR as f32; - let result = self.draw_label.place_text_along_path( + let mut result = self.draw_label.place_text_along_path( &run, &smooth_a, &cum, @@ -2990,6 +3342,35 @@ impl MapView { candidate.center, &mut self.path_glyphs, ); + // HARD invariant instead of trusting the chord heuristic: if the + // REALIZED glyph run reads leftward (net upside-down — hairpin + // ramps fool any pre-placement guess, e.g. the inverted "A1" + // motorway ref), throw it away and place flipped. + if let Some(placed) = &result { + if placed.glyph_end > placed.glyph_start + 1 { + let a = self.path_glyphs[placed.glyph_start].glyph_origin; + let b = self.path_glyphs[placed.glyph_end - 1].glyph_origin; + let (dx, dy) = (b.x - a.x, b.y - a.y); + let len = (dx * dx + dy * dy).sqrt(); + if len > 1.0 && dx / len < -LABEL_VERTICAL_AXIS_EPSILON { + let glyph_start = placed.glyph_start; + self.path_glyphs.truncate(glyph_start); + result = self.draw_label.place_text_along_path( + &run, + &smooth_a, + &cum, + start_distance, + !reverse, + baseline_shift, + if reverse { 0.0 } else { std::f32::consts::PI }, + LABEL_MAX_GLYPH_TURN_RADIANS, + LABEL_GLYPH_ANGLE_BLEND, + candidate.center, + &mut self.path_glyphs, + ); + } + } + } self.scratch_smooth_a = smooth_a; self.scratch_smooth_b = smooth_b; @@ -3174,6 +3555,19 @@ impl MapView { impl MapView { /// Hit-test the tappable charger pins of ready tiles against a screen /// point (billboard rect around the pin anchor, camera-transformed). + /// Screen-px height of a flying marker above its ground anchor (0 in + /// 2D) — the baked per-marker lift converted through the current tilt + /// and meters-per-pixel. + fn lift_screen_px(&self, lift_m: f32, view_zoom: f64) -> f64 { + if !self.buildings_3d || self.tilt <= 0.0 || lift_m <= 0.0 { + return 0.0; + } + let world_size = TILE_SIZE * 2f64.powf(view_zoom); + let (_, lat) = normalized_to_lon_lat(self.center_norm); + let px_per_meter = world_size / (40_075_016.686 * lat.to_radians().cos()); + lift_m as f64 * px_per_meter * self.tilt.clamp(0.0, 65.0).to_radians().sin() + } + fn pin_at(&self, abs: Vec2d) -> Option<(f64, f64, Vec<(String, String)>)> { let camera = self.overlay_camera(); let mut best: Option<(f64, &PinHit)> = None; @@ -3185,7 +3579,9 @@ impl MapView { let screen = camera.norm_to_screen(dvec2(hit.norm.0, hit.norm.1)); let dx = abs.x - screen.x; let dy = abs.y - screen.y; - if dx.abs() <= 18.0 && dy >= -18.0 && dy <= 16.0 { + let lift = self.lift_screen_px(hit.lift_m, self.view_zoom()); + let dy = dy + lift; + if dx.abs() <= 18.0 && dy >= -26.0 && dy <= 6.0 { let dist = dx * dx + dy * dy; if best.as_ref().is_none_or(|(d, _)| dist < *d) { best = Some((dist, hit)); @@ -3306,7 +3702,6 @@ impl MapView { return; } self.overlay_mbtiles_paths = paths.to_string(); - log!("set_overlay_paths: {:?} -> restyle", paths); self.restyle_tiles_keep_stale(cx); } @@ -3330,6 +3725,38 @@ impl MapView { self.tilt } + /// Install the rain nowcast animation frames (BGRA u32 texels) covering + /// the given lon/lat bbox; empty = disable. Frames advance every 220 ms. + pub fn set_rain_frames( + &mut self, + cx: &mut Cx, + frames: Vec>, + width: usize, + height: usize, + bbox: (f64, f64, f64, f64), + ) { + cx.stop_timer(self.rain_timer); + self.rain_frames.clear(); + self.rain_frame_index = 0; + self.rain_bbox = bbox; + self.rain_tex_size = (width.max(1), height.max(1)); + for data in frames { + self.rain_frames.push(Texture::new_with_format( + cx, + TextureFormat::VecBGRAu8_32 { + data: Some(data), + width, + height, + updated: TextureUpdated::Full, + }, + )); + } + if !self.rain_frames.is_empty() { + self.rain_timer = cx.start_interval(0.22); + } + self.redraw(cx); + } + pub fn set_map_zoom(&mut self, cx: &mut Cx, zoom: f64) { let min_zoom = self.min_zoom.max(0.0); let max_zoom = self.max_zoom.max(min_zoom); @@ -3471,6 +3898,15 @@ impl MapViewRef { None } + pub fn tilt_changed(&self, actions: &Actions) -> Option { + if let Some(item) = actions.find_widget_action(self.widget_uid()) { + if let MapViewAction::TiltChanged { tilt } = item.cast() { + return Some(tilt); + } + } + None + } + pub fn marker_clicked(&self, actions: &Actions) -> Option { if let Some(item) = actions.find_widget_action(self.widget_uid()) { if let MapViewAction::MarkerClicked { id } = item.cast() { @@ -3515,6 +3951,19 @@ impl MapViewRef { } } + pub fn set_rain_frames( + &self, + cx: &mut Cx, + frames: Vec>, + width: usize, + height: usize, + bbox: (f64, f64, f64, f64), + ) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_rain_frames(cx, frames, width, height, bbox); + } + } + pub fn set_rotation(&self, cx: &mut Cx, rotation_deg: f64) { if let Some(mut inner) = self.borrow_mut() { inner.set_rotation(cx, rotation_deg); @@ -3601,6 +4050,10 @@ fn label_class_color(color_class: u8, default_color: Vec4f, dark_theme: bool) -> (LABEL_CLASS_WATER, false) => Vec4f::from_u32(0x39688fff), (LABEL_CLASS_WATER, true) => Vec4f::from_u32(0x7fb2d9ff), (LABEL_CLASS_PIN, _) => Vec4f::from_u32(0xffffffff), + (LABEL_CLASS_EXIT, false) => Vec4f::from_u32(0x960000ff), + (LABEL_CLASS_EXIT, true) => Vec4f::from_u32(0xe07070ff), + (LABEL_CLASS_ADMIN, false) => Vec4f::from_u32(0x6a5b8eff), + (LABEL_CLASS_ADMIN, true) => Vec4f::from_u32(0xb3a5d6ff), _ => default_color, } }