Compare commits

...

10 commits

Author SHA1 Message Date
2c5cd97ad9 feat(widgets): reexport optional Makepad sibling crates from dev 2026-07-28 17:10:00 +00:00
Jason Yau
5bc2e5c129
Fix Windows WASAPI open failures and infinite retry spam (#1148)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-07-28 18:46:52 +02:00
Admin
2492ad3bde map: rain radar overlay, flying 3D markers, Europe routing, geodata crate
- rain radar layer: pure-Rust KNMI HDF5 reader (superblock v0 walk,
  single-chunk deflate datasets, byte-exact vs h5py), ellipsoidal polar
  stereographic reprojection (20 m vs product corners), bilinear value
  sampling -> smooth banded isolines, textured quad overlay through the
  overlay camera, 25-frame nowcast animation; RadarSync polls at most
  once per 4 min through a disk-persisted gate and caches frames on disk
- makepad-geodata + makepad-tesla crates join the workspace (overlay
  builders, radar sync, NL open-data layers; transit routes now z7-14)
- Europe major-roads routing graph: nav-build --major-roads does a
  ways-first scan (5.7M ways / 46M nodes / 194 s / 971 MB) and the app
  falls back to it when a route leaves the regional graph — Amsterdam to
  Paris routes offline (501.8 km)
- 3D flying markers: chargers/POIs/stops ride thin stalks with DYNAMIC
  height (each pin clears its own building +8 m); labels, kW text,
  brand and tap zones all consume the baked per-marker lift; stalks and
  buildings grow together on the 2D->3D transition (per-tile flat->3D
  fade heights, no replay on zoom regens)
- markers depth-honest (small bias, buildings occlude them); phong-lit
  canopy/light spheres matching the buildings' NW sun; buildings tint by
  BAG age in 3D; district area tints (rank 60, alpha .32); transit line
  labels + stop names; follow-mode is an explicit attach/detach toggle;
  rotation release schedules the label re-place (no stuck upside-down
  labels after a fast spin)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:00:27 +02:00
Admin
ef83731b6c map: transit lines/labels, districts, 3D signals+trees, label flip invariant
- transit routes draw as per-line colored strokes (stable ref-hash
  palette) on white casings, labeled "Tram 7"/"Metro 52"; stops get
  name labels (stations z13+, local z15+)
- districts layer: tiered admin boundaries (gemeente/wijk/buurt) and
  centroid name labels staged by zoom, muted admin purple
- upside-down labels fixed structurally: post-placement invariant
  re-places any glyph run that reads net-leftward (hairpin ramps fooled
  every pre-placement chord heuristic); vertical dead band ±4°
- overlay charger pins hold collision priority over base charging_station
  icons, which are suppressed while a charger overlay is active; pins
  reserve their bubble box so POI text places beside them
- per-icon zoom floors: overlay pins use kW tier floors (8/10/12) —
  micro floor 16.5 no longer hides them; 0.6 zoom grace; fail-open when
  the icon_zoom uniform is unset; oneway arrows from z15
- rail z-fighting: stroke merge drops duplicate quantized segments from
  forked ways (switches drew shared segments twice, shimmering in tilt)
- 3D mode: stoplights (pole + red/amber/green lights) and taller
  ellipsoid trees; live_reload names its override files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:57:07 +02:00
Admin
68bc80709a map: charger pin redesign, upright/billboard labels, 3D trees, exits
- Tesla-style droplet pins anchored at the tail tip (rotate around the
  exact site point in tilted views); red exclusively Tesla, amber DC,
  blue AC; Tesla pins show stall count, others peak kW
- in-pin text through the normal text renderer: billboard-anchored
  glyphs (anchor scales with the map, glyph size constant), exempt from
  label collision/repeat culling, no halo
- upright camera-delta labels: straightened labels (place names, POIs,
  brands, pin text) translate with the rotation gesture but stay
  horizontal — no more rotate-then-snap on regen
- per-icon zoom floors baked in vertex param4 + live icon_zoom uniform:
  stale deeper-bucket tiles never flash markers on zoom-out (param4 is
  lift-height only for non-icon shapes — icons stay on the ground)
- motorway exit labels (street_labels_points): carto-red name + ref
- 2D/3D mode: tilt gesture syncs app state (TiltChanged action), tilt
  release near-flat settles to exact 0, mode flip re-bakes tiles
  (extrusions appear/disappear without a zoom nudge)
- Simple 3D Buildings: building:part volumes with min_height bases;
  outlines containing parts flatten to footprints; famous buildings
  with tourism=attraction extrude instead of dying as attraction fills
- little 3D trees in tilt mode: crossed trunk quads + sphere-slice
  ball canopy (architecture-model proportions)
- searchdb/mbtiles support work: reach-based distance ranking, direct
  tile lookup + writer ordering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:02:44 +02:00
Admin
5f81ebd2a8 llama: image-span prefill + M-RoPE — full VLM pipeline matches llama.cpp exactly
LlamaSession gains append_image_embeddings: precomputed vision embeddings
prefill through a second Embeddings-input graph spec sharing the same
cache tensors (SessionGraphParams grows an embeddings_input discriminant).
HybridDecodeBatchLayout gains an optional pre-expanded rope_positions
override, threaded to encode_rope_positions — cache indices and attention
masks stay linear while image tokens get qwen-vl 2D positions
[pos0, pos0+y, pos0+x, 0] and the span advances rope position by
max(w, h). Text after an image continues from the shifted position; pure
text paths are byte-identical to before (verified via llama-generate).

vlm-probe runs the whole thing: ppm -> vision tower -> chatml with
vision_start/end -> greedy. Output is token-for-token identical to
llama-mtmd-cli --temp 0 on both test images (radar scene description and
'A red circle.'). 229-token mixed prefill 0.98s, generation 27.9 tok/s
on the 9B UD-Q4_K_XL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:31:44 +02:00
Admin
6ba9b7da65 llama: qwen3.5 vision tower on makepad-ggml — oracle parity achieved
New vision module: mmproj GGUF load (reuses the arch-agnostic loader),
exact-port preprocessing (calc_size_preserved_ratio, f64 bilinear resize to
match the reference binary's double promotion, block-major patch unfold),
and the 27-block ViT graph: patchify as two matmuls vs the flattened conv
weights, interpolated learned pos-embd (gpu bilinear+antialias, gathered to
block order), 2D vision rope, full bidirectional flash attention (f16 k/v,
f32 prec), layernorm composed as norm-mul-add, and the qwen3vl_merger
2-layer MLP into the LLM's 4096-dim space.

vlm-vision-probe validates against clip.cpp dumps: preproc bit-exact on
aligned images (1e-7), embeddings rms 3e-4..9e-4 / cosine 0.99999+ on all
three test images — inside the oracle's own flash-vs-composed spread
(rms 8.9e-4). 64-token encode 38ms, 192-token 103ms after graph compile.

Also fixes a stale DeltaNetRecurrentBlockSpec test initializer that broke
cargo test compilation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:23:26 +02:00
Admin
2de4fb4f71 map: exact camera-delta matrix — labels land where the regen lands
The snap: tilt does not commute with rotation. The placement maps world
points rotate-then-y-compress, so the true delta between the cached
camera (r0,t0) and now (r1,t1) is S(t1)*R(d)*S(1/t0) — a general 2x2
matrix, not the rotate+scale previously approximated. The shader takes
the full matrix; the pan shift pre-inverts through it so pans during
rotation land correctly. Between-frame tracking now converges on the
exact re-place result — no more rotate-snap in 2.5D.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:13:58 +02:00
Admin
10d165643d map: GPU camera-delta for labels; brand text draws over the pins
- DrawRotatedText gains cam_cs/cam_sn/cam_tilt/cam_pivot uniforms: while
  the camera rotates or tilts between re-places, ALL placed glyphs spin
  and squash about the view pivot on the GPU — whole-quad transform, so
  orientation follows position for free; the CPU per-frame rotated copy
  is gone and the async re-place trues up with identity uniforms
- label plans carry a post-icon flag: in-pin digits' phase now also
  hosts the charger brand text, which was drawn before the symbol pass
  and hid BEHIND its own pin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:10:03 +02:00
Admin
9f851e2d45 vlm: clip.cpp oracle tool — dump qwen3.5 mmproj preproc + embeddings for parity testing
Links the local llama.cpp April build (libmtmd exports clip_* internals);
generates deterministic PPM test images; dumps preprocessed f32 tensor and
projected [n_tokens x 4096] embeddings. Reference stats recorded: radar
512x384 -> 192 tokens, cpu-vs-metal spread ~2e-4 abs on embd values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:07:18 +02:00
70 changed files with 11222 additions and 347 deletions

View file

@ -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",

View file

@ -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).

View file

@ -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]

View file

@ -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<Vec<u32>>,
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<RainUpdate>,
/// Last decoded nowcast, kept so toggling rain back on is instant.
#[rust]
rain_cache: Option<RainUpdate>,
#[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<std::path::PathBuf> = 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<Vec<u32>> = 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 } => {

21
libs/geodata/Cargo.toml Normal file
View file

@ -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"

150
libs/geodata/README.md Normal file
View file

@ -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-<layer>.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 <layer|all> [--force] download / revalidate bulk sources
geodata build <layer|all> build nl-<layer>.mbtiles
geodata status cache + outputs
geodata query <layer> <lon> <lat> [--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".

170
libs/geodata/src/fetch.rs Normal file
View file

@ -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<u64> {
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<FetchOutcome> {
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))
}

231
libs/geodata/src/geo.rs Normal file
View file

@ -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;
}
}
}
}
}
}

202
libs/geodata/src/gpkg.rs Normal file
View file

@ -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<String>,
/// 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<Self, String> {
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<Vec<FeatureTableInfo>, 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<u64, String> {
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<Geometry> {
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<String> {
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<usize, String> {
columns
.iter()
.position(|c| c.eq_ignore_ascii_case(name))
.ok_or_else(|| format!("column {name} not found in {columns:?}"))
}

View file

@ -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<Self, String> {
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<Option<u64>, 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<Option<u64>, 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<DatasetInfo, String> {
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<Vec<u8>, 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<u8>,
}
/// 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<Vec<KnmiFrame>, 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);
}
}

View file

@ -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<SourceSpec> {
vec![BAG_LIGHT]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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::<Vec<_>>()
)
})?;
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,
})
}
}

View file

@ -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<SourceSpec> {
vec![VK500, VK100]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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,
})
}
}

View file

@ -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<SourceSpec> {
vec![NDW_OCPI]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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<f64> {
let value = value?;
if let Some(f) = value.as_f64() {
return Some(f);
}
value.as_str()?.trim().parse().ok()
}

View file

@ -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<SourceSpec> {
vec![JRC_RP100]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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<f32> {
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,
})
}
}

View file

@ -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<SourceSpec>;
fn implemented(&self) -> bool {
true
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String>;
}
/// 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<SourceSpec> {
Vec::new()
}
fn implemented(&self) -> bool {
false
}
fn build(&self, _ctx: &BuildCtx) -> Result<BuildReport, String> {
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<Box<dyn Layer>> {
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<Box<dyn Layer>> {
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<Vec<u8>, 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<Vec<PathBuf>, 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<String> = 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)
}

View file

@ -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<SourceSpec> {
vec![NATURA2000, WETLANDS]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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,
})
}
}

View file

@ -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<SourceSpec> {
vec![RIVM_NOISE]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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<f32> {
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<PathBuf, String> {
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)
}

View file

@ -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<i32> = 50..=53;
const LON_RANGE: std::ops::RangeInclusive<i32> = 3..=7;
const NL_BOUNDS: (f64, f64, f64, f64) = (3.0, 50.7, 7.3, 53.8);
fn tile_specs() -> Vec<SourceSpec> {
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<SourceSpec> {
tile_specs()
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
// 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<Tiff>> = 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<f32> {
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,
})
}
}

View file

@ -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<SourceSpec> {
vec![OVAPI_GTFS]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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<String, (i64, String)> = 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<String, String> = 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::<f64>(), lon.parse::<f64>()) 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<String, Vec<(f64, f64, i64)>> = 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::<f64>(), lon.parse::<f64>(), seq.parse::<i64>())
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<String, String>),
) -> 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<String> = Vec::new();
let mut row: HashMap<String, String> = 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<String> {
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
}

View file

@ -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<SourceSpec> {
vec![WIJKBUURT]
}
fn build(&self, ctx: &BuildCtx) -> Result<BuildReport, String> {
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,
})
}
}

31
libs/geodata/src/lib.rs Normal file
View file

@ -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};

272
libs/geodata/src/main.rs Normal file
View file

@ -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 <layer|all> download (or revalidate) a layer's bulk sources
geodata build <layer|all> build the layer's .mbtiles (fetches if missing)
geodata status show cache and output state
geodata query <layer> <lon> <lat> query the features sidecar (LLM surface)
OPTIONS:
--cache-dir <dir> default: local/overlays/cache
--out-dir <dir> default: local/overlays
--force re-download even if the cache is fresh
--radius <m> query: search radius in meters (default: point query)
--limit <n> query: max results (default 10)"
);
std::process::exit(2);
}
struct Args {
command: String,
target: String,
positional: Vec<String>,
cache_dir: PathBuf,
out_dir: PathBuf,
force: bool,
radius: Option<f64>,
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::<f64>().ok()),
args.positional.get(3).and_then(|v| v.parse::<f64>().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<Box<dyn makepad_geodata::Layer>> {
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);
}
}
}
}

219
libs/geodata/src/mvt.rs Normal file
View file

@ -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<u32>,
pub attrs: Vec<(String, AttrVal)>,
}
struct LayerEnc {
name: String,
keys: Vec<String>,
key_index: HashMap<String, u32>,
values: Vec<AttrVal>,
value_index: HashMap<String, u32>,
features: Vec<Vec<u8>>,
}
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<u8>) {
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<LayerEnc>,
}
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<u8> {
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<u8>) {
write_varint(u64::from((field << 3) | wire_type), out);
}
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
loop {
let byte = (value & 0x7f) as u8;
value >>= 7;
if value == 0 {
out.push(byte);
break;
}
out.push(byte | 0x80);
}
}

221
libs/geodata/src/png.rs Normal file
View file

@ -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<u8> {
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<u8>,
}
pub fn decode(data: &[u8]) -> Result<DecodedPng, String> {
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<u8>, 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<u8> = (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);
}
}

204
libs/geodata/src/query.rs Normal file
View file

@ -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<String>,
/// 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<f64>,
/// 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<Self, String> {
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<Vec<FeatureHit>, 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<Vec<FeatureHit>, 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<Vec<FeatureHit>, 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<FeatureHit, String> {
let text = |i: usize| -> Option<String> {
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::<serde_json::Value>(&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
}

397
libs/geodata/src/radar.rs Normal file
View file

@ -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<String>,
/// 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<PathBuf>) -> 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<PathBuf>, 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<RadarFrame>,
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<RadarState, String> {
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<RadarFrame> = 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<RadarFrame>) {
let Ok(text) = std::fs::read_to_string(self.index_path()) else {
return (0, Vec::new());
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&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::<Vec<_>>(),
});
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<serde_json::Value, String> {
// 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<u64> {
let bytes = s.as_bytes();
if bytes.len() < 19 {
return None;
}
let num = |range: std::ops::Range<usize>| -> Option<i64> {
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)
);
}
}

View file

@ -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<f32>,
src_row: Vec<f32>,
}
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<u8> {
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<u32> {
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}");
}
}

180
libs/geodata/src/raster.rs Normal file
View file

@ -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<serde_json::Value>,
}
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<f32>,
) -> Result<RasterStats, String> {
let (min_lon, min_lat, max_lon, max_lat) = config.bounds;
let mut tiles: BTreeMap<u128, (u8, u32, u32, Vec<u8>)> = 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<f32>,
) -> (PngFormat, Vec<u8>, 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)
}
}
}

256
libs/geodata/src/sidecar.rs Normal file
View file

@ -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<String>,
bbox: (f64, f64, f64, f64),
attrs_json: String,
ring_json: Option<String>,
}
#[derive(Default)]
pub struct SidecarBuilder {
records: Vec<Record>,
}
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<serde_json::Value> = 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<u64, String> {
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<Vec<(f64, f64)>> {
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
}

309
libs/geodata/src/spool.rs Normal file
View file

@ -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<std::fs::File>>,
strings: Vec<String>,
string_index: HashMap<String, u32>,
fields: HashMap<String, HashMap<String, &'static str>>,
bounds: (f64, f64, f64, f64),
features_in: u64,
tile_features: u64,
sidecar: crate::sidecar::SidecarBuilder,
ring_layers: Vec<String>,
}
impl SpoolTiler {
pub fn new(dir: &Path, zmin: u8, zmax: u8) -> Result<Self, String> {
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<TilesetStats, String> {
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<u16, Vec<(u32, PreFeature)>> = 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<u8>) {
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
}

535
libs/geodata/src/tiff.rs Normal file
View file

@ -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<u64>,
counts: Vec<u64>,
/// (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<f64>,
cache: HashMap<u32, Vec<f32>>,
cache_order: Vec<u32>,
}
const CACHE_BLOCKS: usize = 64;
impl Tiff {
pub fn open(path: &Path) -> Result<Self, String> {
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<u16, (u16, u64, Vec<u8>)> = 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<u64> {
let (t, _, data) = tags.get(&tag)?;
read_tag_values(*t, data, le).first().copied().map(|v| v as u64)
};
let values = |tag: u16| -> Vec<u64> {
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<f64> {
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<f32> {
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<f32> {
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<f32>, 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<Vec<f32>, 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<f32>, rows: usize) -> Vec<f32> {
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<Vec<u8>, String> {
const CLEAR: u16 = 256;
const EOI: u16 = 257;
let mut out = Vec::with_capacity(expected);
let mut dict: Vec<Vec<u8>> = Vec::new();
let reset_dict = |dict: &mut Vec<Vec<u8>>| {
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<u16> = None;
let read_code = |bit_pos: &mut usize, code_size: u32| -> Option<u16> {
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<f64> {
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<f64> {
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)
}
}

698
libs/geodata/src/tiler.rs Normal file
View file

@ -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<Vec<(f64, f64)>> = 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<(i64, i64)>> = 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<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<Vec<Vec<(f64, f64)>>> = 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<u128, (TileKey, Vec<(String, PreFeature)>)>,
fields: HashMap<String, HashMap<String, &'static str>>,
bounds: (f64, f64, f64, f64),
stats: TilesetStats,
sidecar: crate::sidecar::SidecarBuilder,
ring_layers: Vec<String>,
}
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<TilesetStats, String> {
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<String, HashMap<String, &'static str>>,
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<Vec<u8>, 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<String, HashMap<String, &'static str>>,
bounds: (f64, f64, f64, f64),
) -> Result<MbtilesWriter, String> {
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<serde_json::Value> = 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(&current);
let previous_in = inside(&previous);
if current_in {
if !previous_in {
out.push(intersect(&previous, &current));
}
out.push(current);
} else if previous_in {
out.push(intersect(&previous, &current));
}
}
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<Vec<(i64, i64)>>) {
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
}

209
libs/geodata/src/wkb.rs Normal file
View file

@ -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<Vec<(f64, f64)>>),
/// Rings: first exterior, rest holes.
Polygon(Vec<Vec<(f64, f64)>>),
MultiPolygon(Vec<Vec<Vec<(f64, f64)>>>),
}
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::<Vec<_>>();
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<u8> {
let v = *self.data.get(self.pos)?;
self.pos += 1;
Some(v)
}
fn u32(&mut self, le: bool) -> Option<u32> {
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<f64> {
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<Geometry> {
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<Geometry> {
let mut cur = Cursor { data, pos: 0 };
parse_wkb_inner(&mut cur)
}
fn parse_wkb_inner(cur: &mut Cursor) -> Option<Geometry> {
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<Vec<(f64, f64)>> {
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,
}
}

View file

@ -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"

View file

@ -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 <model> --mmproj <mmproj> --image <img> -p <prompt>
//
// usage: vlm-probe <model.gguf> <mmproj.gguf> <image.ppm> <question> [--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<u8>, 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<String> = std::env::args().collect();
if args.len() < 5 {
eprintln!(
"usage: {} <model.gguf> <mmproj.gguf> <image.ppm> <question> [--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<think>\n\n</think>\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)
);
}

View file

@ -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 <mmproj.gguf> <image.ppm> [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<u8>, 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<f32> {
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<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: {} <mmproj.gguf> <image.ppm> [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::<f64>() / embd.len() as f64;
let rms = (embd.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / 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);
}
}

View file

@ -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};

View file

@ -180,6 +180,22 @@ impl LlamaModel {
)))
}
pub fn embedding_length(&self) -> Result<u32> {
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,

View file

@ -836,6 +836,12 @@ pub struct HybridDecodeBatchLayout {
pub attention_key_count: usize,
pub recurrent_state_rows: Vec<i32>,
pub output_ids: Vec<i32>,
/// 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<Vec<i32>>,
}
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(),

View file

@ -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<i32>,
/// 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<HybridDecodeRun>,
}
@ -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::<Result<Vec<_>>>()?;
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,

803
libs/llama/src/vision.rs Normal file
View file

@ -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<Self> {
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<f32>,
/// 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<f32>,
/// 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<u8> {
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<u8> {
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<PreparedImage> {
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<i32> {
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<i32> {
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<Self> {
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<TensorId> {
self.weights.require_tensor_id(name)
}
/// Encode a preprocessed image; returns [n_tokens * proj_dim] f32.
pub fn encode(&mut self, image: &PreparedImage) -> Result<Vec<f32>> {
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<VisionGraph> {
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<TensorId> {
weights.require_tensor_id(name)
}
fn weight_row_bytes(ctx: &Context, id: TensorId) -> Result<usize> {
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<TensorId> {
ctx.new_tensor(TensorType::F16, ne.len(), ne, BufferUsage::Activations)
.map_err(LlamaError::format)
}
fn f32s_as_bytes(values: &[f32]) -> Vec<u8> {
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<u8> {
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<f32> {
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<String> {
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<usize> {
optional_usize(gguf, key)?
.ok_or_else(|| LlamaError::format(format!("missing key '{key}'")))
}
fn optional_usize(gguf: &GgufFile, key: &str) -> Result<Option<usize>> {
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<Option<f32>> {
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]);
}
}

View file

@ -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<i32> {
self.lookup_token(piece)
}
fn token_score(&self, token_id: i32) -> Option<f32> {
usize::try_from(token_id)
.ok()

View file

@ -435,6 +435,11 @@ pub fn category_from_osm_tags(tags: &HashMap<String, String>) -> Option<Category
if get("natural") == Some("beach") {
return Some(Category::Beach);
}
// Zoo enclosures / attraction buildings tag attraction=* with no
// tourism key (Artis's Reptielenhuis).
if tags.contains_key("attraction") || tags.contains_key("zoo") {
return Some(Category::Attraction);
}
if tags.contains_key("addr:housenumber") {
return Some(Category::Address);
}
@ -975,18 +980,23 @@ pub fn score_search_hit(
query_has_number: bool,
distance_m: Option<f64>,
) -> 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
}

View file

@ -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<Self> {
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<Vec<SchemaEntry>> {
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<Value>),
) -> 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<Value>),
) -> 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.

View file

@ -35,6 +35,22 @@ pub struct MbtilesWriter {
tiles: TableStream,
tile_count: u64,
tile_bytes: u64,
extra_tables: Vec<ExtraTable>,
}
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<RecordValue<'_>> = 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<String>, value: impl Into<String>) {
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<Pag
}
enum RecordValue<'a> {
Null,
Integer(i64),
Float(f64),
Blob(&'a [u8]),
Text(&'a str),
}
@ -479,6 +571,11 @@ fn encode_record(values: &[RecordValue<'_>]) -> Result<Vec<u8>> {
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);

11
libs/tesla/Cargo.toml Normal file
View file

@ -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" }

115
libs/tesla/README.md Normal file
View file

@ -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 <https://developer.tesla.com> → 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://<your-domain>/.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":"<your-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=<CLIENT_ID>&redirect_uri=<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.

533
libs/tesla/src/client.rs Normal file
View file

@ -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<Self> {
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<String>,
pub refresh_token: String,
/// "na" | "eu" | "cn"
pub region: String,
pub access_token: Option<String>,
/// Unix seconds after which access_token is stale.
pub access_token_expires: Option<u64>,
}
#[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<Vehicle>),
/// 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<String> },
/// 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<LiveId>,
queued: Vec<PendingCall>,
in_flight: HashMap<LiveId, PendingCall>,
}
impl TeslaClient {
/// Loads credentials from a JSON file (see README.md for the schema).
pub fn load(creds_path: impl Into<PathBuf>) -> Result<Self, TeslaError> {
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::<Vec<_>>()
.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<TeslaAction> {
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<TeslaAction>,
) {
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<TeslaAction>,
) {
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<TeslaAction>) {
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<TeslaAction>, 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<TeslaClient, TeslaError> {
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<Self, TeslaError> {
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));
}
}

230
libs/tesla/src/data.rs Normal file
View file

@ -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<Vec<Vehicle>>,
pub error: Option<String>,
pub error_description: Option<String>,
}
#[derive(DeJson, Debug, Clone)]
pub struct Vehicle {
pub vin: Option<String>,
pub display_name: Option<String>,
/// "online" | "asleep" | "offline"
pub state: Option<String>,
pub in_service: Option<bool>,
}
impl Vehicle {
pub fn is_online(&self) -> bool {
self.state.as_deref() == Some("online")
}
}
#[derive(DeJson, Debug, Clone)]
pub struct VehicleDataResponse {
pub response: Option<VehicleData>,
pub error: Option<String>,
pub error_description: Option<String>,
}
#[derive(DeJson, Debug, Clone)]
pub struct VehicleData {
pub vin: Option<String>,
pub state: Option<String>,
pub charge_state: Option<ChargeState>,
pub drive_state: Option<DriveState>,
}
/// 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<i32>,
/// Percent corrected for cold battery; use this for range planning.
pub usable_battery_level: Option<i32>,
/// Rated range in miles.
pub battery_range: Option<f64>,
/// Range estimated from recent consumption, miles.
pub est_battery_range: Option<f64>,
/// "Charging" | "Complete" | "Disconnected" | "Stopped" | "NoPower" | "Starting"
pub charging_state: Option<String>,
/// Charge limit percent.
pub charge_limit_soc: Option<i32>,
/// mi of range added per hour while charging.
pub charge_rate: Option<f64>,
/// Current charging power in kW.
pub charger_power: Option<i32>,
pub charger_voltage: Option<i32>,
pub charger_actual_current: Option<i32>,
pub minutes_to_full_charge: Option<i32>,
/// Hours, fractional.
pub time_to_full_charge: Option<f64>,
pub fast_charger_present: Option<bool>,
pub fast_charger_type: Option<String>,
/// Cable type when plugged in, e.g. "IEC" / "SAE"; "<invalid>" when not.
pub conn_charge_cable: Option<String>,
/// kWh added this session.
pub charge_energy_added: Option<f64>,
pub battery_heater_on: Option<bool>,
/// Milliseconds since epoch, set by the car.
pub timestamp: Option<u64>,
}
impl ChargeState {
pub fn battery_range_km(&self) -> Option<f64> {
self.battery_range.map(|mi| mi * MILES_TO_KM)
}
pub fn est_battery_range_km(&self) -> Option<f64> {
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<f64>,
pub longitude: Option<f64>,
pub heading: Option<i32>,
/// mph; null when parked.
pub speed: Option<f64>,
/// kW; negative while regen/charging.
pub power: Option<i32>,
pub shift_state: Option<String>,
pub timestamp: Option<u64>,
}
#[derive(DeJson, Debug, Clone)]
pub struct WakeUpResponse {
pub response: Option<Vehicle>,
pub error: Option<String>,
pub error_description: Option<String>,
}
/// Response of the oauth2 token endpoint (refresh flow).
#[derive(DeJson, Debug, Clone)]
pub struct TokenResponse {
pub access_token: Option<String>,
pub refresh_token: Option<String>,
/// Seconds.
pub expires_in: Option<u64>,
pub error: Option<String>,
pub error_description: Option<String>,
}
/// Generic Fleet API error body, for calls that failed with a non-2xx status.
#[derive(DeJson, Debug, Clone)]
pub struct ErrorBody {
pub error: Option<String>,
pub error_description: Option<String>,
}
#[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": "<invalid>",
"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"));
}
}

48
libs/tesla/src/lib.rs Normal file
View file

@ -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<TeslaClient>,
//!
//! // 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,
};

View file

@ -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<String> = 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
}

View file

@ -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<AudioBuffer>,
}
@ -554,59 +568,104 @@ impl WasapiBase {
pub fn new(device_id: AudioDeviceId, channel_count: usize) -> Result<Self, ()> {
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::<IAudioClient3>(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<Self, ()> {
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,

View file

@ -39,6 +39,11 @@ pub struct NavBuildOptions {
/// Build a `<basename>.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<String, String>) -> 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<i64> = 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<i64> = 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<NavBuildOptions, Strin
let mut skip_addresses = false;
let mut places_only = false;
let mut searchdb = false;
let mut major_roads_only = false;
let mut i = 3;
while i < args.len() {
match args[i].as_str() {
@ -992,6 +1186,7 @@ pub fn parse_nav_build_options(args: &[String]) -> Result<NavBuildOptions, Strin
"--skip-addresses" => 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<NavBuildOptions, Strin
skip_addresses,
places_only,
searchdb,
major_roads_only,
})
}

19
tools/vlm_oracle/build.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Build the clip.cpp oracle against the local llama.cpp checkout.
set -euo pipefail
LLAMA_CPP="${LLAMA_CPP:-/Users/admin/llama.cpp}"
# NOTE: build/ is stale (Aug 2025); build-arm64-apple-clang-release matches the
# April 2026 source and has qwen3vl mmproj support.
LLAMA_BIN="${LLAMA_BIN:-$LLAMA_CPP/build-arm64-apple-clang-release/bin}"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
clang++ -std=c++17 -O2 \
-I "$LLAMA_CPP/tools/mtmd" \
-I "$LLAMA_CPP/ggml/include" \
-I "$LLAMA_CPP/include" \
-I "$LLAMA_CPP/vendor" \
"$DIR/clip_dump.cpp" \
-L "$LLAMA_BIN" -lmtmd -lggml -lggml-base -lllama \
-Wl,-rpath,"$LLAMA_BIN" \
-o "$DIR/clip_dump"
echo "built $DIR/clip_dump"

BIN
tools/vlm_oracle/clip_dump Executable file

Binary file not shown.

View file

@ -0,0 +1,138 @@
// Oracle: run llama.cpp's clip.cpp vision encoder on an image and dump
// (a) the preprocessed f32 image tensor and (b) the projected output
// embeddings, for validation of the makepad-llama vision implementation.
//
// Build + usage: see build.sh in this directory.
//
// Output format (little-endian):
// preproc dump: u32 nx, u32 ny, then nx*ny*3 f32 (RGBRGB planar per clip's layout, raw buf)
// embd dump: u32 n_tokens, u32 n_embd, u32 nx, u32 ny (preprocessed), then n_tokens*n_embd f32
#include "clip.h"
#include "clip-impl.h"
#include "mtmd-image.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
static std::vector<unsigned char> 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<unsigned char> 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 <mmproj.gguf> <image.ppm> <out_prefix> [--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<unsigned char> 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<float> 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;
}

View file

@ -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))

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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"]

View file

@ -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;

View file

@ -585,11 +585,46 @@ pub fn merge_stroke_polylines(polylines: &[Vec<(f32, f32)>]) -> Vec<Vec<(f32, f3
return Vec::new();
}
let lines = polylines
.iter()
.filter(|line| line.len() >= 2)
.cloned()
.collect::<Vec<_>>();
// 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::<Vec<(f32, f32)>>::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();
}

View file

@ -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<IconMesh> {
// 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<IconMesh> {
let doc = parse_svg(svg);
@ -375,15 +330,19 @@ pub fn micro_icon_for_tags(tags: &HashMap<String, String>) -> Option<(&'static s
pub fn icon_for_tags(tags: &HashMap<String, String>) -> 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::<f64>().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

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="26" viewBox="0 0 22 26"><path d="M4 1 L18 1 L20 3 L21 5 L21 13 L20 15 L18 17 L13.5 17 L11 21.5 L8.5 17 L4 17 L2 15 L1 13 L1 5 L2 3 Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22"><path d="M11 1 C15.4 1 19 4.6 19 9 C19 12 17.5 14.2 14.8 15.6 C12.6 16.8 11.8 18 11 20.5 C10.2 18 9.4 16.8 7.2 15.6 C4.5 14.2 3 12 3 9 C3 4.6 6.6 1 11 1 Z"/></svg>

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 247 B

Before After
Before After

View file

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="26" viewBox="0 0 34 26"><path d="M4 1 L30 1 L32 3 L33 5 L33 13 L32 15 L30 17 L20 17 L17 21.5 L14 17 L4 17 L2 15 L1 13 L1 5 L2 3 Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="30" viewBox="0 0 34 30"><path d="M10 1 L24 1 C29 1 33 5 33 10 C33 15 29 19 24 19 L20.5 19 C19.5 19 18.5 20.5 17 24 C15.5 20.5 14.5 19 13.5 19 L10 19 C5 19 1 15 1 10 C1 5 5 1 10 1 Z"/></svg>

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 249 B

Before After
Before After

View file

@ -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<Vec2d>,
}
@ -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<String, String>,
centroid: (f32, f32),
) -> Option<TileLabel> {
// 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<String, String>, 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<String, String>, 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<String, String>, 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<String, String>, 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<String, String>, 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<String, String>, 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<u8> {
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<u8> {
"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,

View file

@ -474,6 +474,7 @@ pub fn fill_alpha_for_tags(tags: &HashMap<String, String>) -> 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<String, String>) -> 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<String, String>) -> Option<u32> {
let bouwjaar = tags
.get("bouwjaar")
.and_then(|value| value.parse::<f64>().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<String, String>,
closed: bool,
render_zoom: u32,
) -> Option<u32> {
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::<f64>().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<String, String>) -> 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,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff