From 9f851e2d4507697239bee729767c4e0e34ace485 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 14:07:18 +0200 Subject: [PATCH 01/10] =?UTF-8?q?vlm:=20clip.cpp=20oracle=20tool=20?= =?UTF-8?q?=E2=80=94=20dump=20qwen3.5=20mmproj=20preproc=20+=20embeddings?= =?UTF-8?q?=20for=20parity=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/vlm_oracle/build.sh | 19 ++++ tools/vlm_oracle/clip_dump | Bin 0 -> 39480 bytes tools/vlm_oracle/clip_dump.cpp | 135 +++++++++++++++++++++++++++++ tools/vlm_oracle/gen_test_image.py | 45 ++++++++++ tools/vlm_oracle/grad_256x256.ppm | Bin 0 -> 196623 bytes tools/vlm_oracle/radar_500x375.ppm | 4 + tools/vlm_oracle/radar_512x384.ppm | 4 + 7 files changed, 207 insertions(+) create mode 100755 tools/vlm_oracle/build.sh create mode 100755 tools/vlm_oracle/clip_dump create mode 100644 tools/vlm_oracle/clip_dump.cpp create mode 100644 tools/vlm_oracle/gen_test_image.py create mode 100644 tools/vlm_oracle/grad_256x256.ppm create mode 100644 tools/vlm_oracle/radar_500x375.ppm create mode 100644 tools/vlm_oracle/radar_512x384.ppm diff --git a/tools/vlm_oracle/build.sh b/tools/vlm_oracle/build.sh new file mode 100755 index 000000000..1bfe24d9f --- /dev/null +++ b/tools/vlm_oracle/build.sh @@ -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" diff --git a/tools/vlm_oracle/clip_dump b/tools/vlm_oracle/clip_dump new file mode 100755 index 0000000000000000000000000000000000000000..1ddf86a21112f02f68782b5ba7fd917772a51cd3 GIT binary patch literal 39480 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 nk(3GdmXxL}(Y*NS_tSb0= +#include +#include +#include +#include + +static std::vector read_ppm(const char * path, int & w, int & h) { + FILE * f = fopen(path, "rb"); + if (!f) { fprintf(stderr, "cannot open %s\n", path); exit(1); } + char magic[3] = {0}; + int maxval = 0; + if (fscanf(f, "%2s", magic) != 1 || strcmp(magic, "P6") != 0) { + fprintf(stderr, "not a P6 ppm: %s\n", path); exit(1); + } + // skip whitespace/comments + auto next_int = [&]() { + int c; + do { + c = fgetc(f); + if (c == '#') { while (c != '\n' && c != EOF) c = fgetc(f); } + } while (c == ' ' || c == '\n' || c == '\r' || c == '\t'); + ungetc(c, f); + int v; fscanf(f, "%d", &v); return v; + }; + w = next_int(); h = next_int(); maxval = next_int(); + fgetc(f); // single whitespace after maxval + if (maxval != 255) { fprintf(stderr, "maxval must be 255\n"); exit(1); } + std::vector rgb((size_t)w * h * 3); + if (fread(rgb.data(), 1, rgb.size(), f) != rgb.size()) { + fprintf(stderr, "short read on %s\n", path); exit(1); + } + fclose(f); + return rgb; +} + +int main(int argc, char ** argv) { + if (argc < 4) { + fprintf(stderr, "usage: %s [--cpu] [--max-tokens N]\n", argv[0]); + return 1; + } + const char * mmproj_path = argv[1]; + const char * image_path = argv[2]; + std::string out_prefix = argv[3]; + bool use_gpu = true; + int max_tokens = 0; + for (int i = 4; i < argc; i++) { + if (strcmp(argv[i], "--cpu") == 0) use_gpu = false; + 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 = CLIP_FLASH_ATTN_TYPE_DISABLED; // deterministic reference path + if (max_tokens > 0) cparams.image_max_tokens = max_tokens; + + clip_init_result res = clip_init(mmproj_path, cparams); + if (!res.ctx_v) { fprintf(stderr, "clip_init failed\n"); return 1; } + clip_ctx * ctx = res.ctx_v; + + int w = 0, h = 0; + std::vector rgb = read_ppm(image_path, w, h); + fprintf(stderr, "input image: %d x %d\n", w, h); + + clip_image_u8 * img_u8 = clip_image_u8_init(); + clip_build_img_from_pixels(rgb.data(), w, h, img_u8); + + clip_image_f32_batch batch; + mtmd_image_preprocessor_dyn_size preproc(ctx); + if (!preproc.preprocess(*img_u8, batch)) { + fprintf(stderr, "preprocess failed\n"); return 1; + } + if (batch.entries.size() != 1) { + fprintf(stderr, "expected 1 preprocessed image, got %zu\n", batch.entries.size()); return 1; + } + clip_image_f32 * img = batch.entries[0].get(); + fprintf(stderr, "preprocessed: %d x %d (buf %zu floats)\n", img->nx, img->ny, img->buf.size()); + + // dump preprocessed tensor + { + std::string p = out_prefix + ".preproc.bin"; + FILE * f = fopen(p.c_str(), "wb"); + uint32_t nx = img->nx, ny = img->ny; + fwrite(&nx, 4, 1, f); fwrite(&ny, 4, 1, f); + fwrite(img->buf.data(), 4, img->buf.size(), f); + fclose(f); + fprintf(stderr, "wrote %s\n", p.c_str()); + } + + const int n_tokens = clip_n_output_tokens(ctx, img); + const int n_embd = clip_n_mmproj_embd(ctx); + const int tx = clip_n_output_tokens_x(ctx, img); + const int ty = clip_n_output_tokens_y(ctx, img); + fprintf(stderr, "output tokens: %d (%d x %d), embd %d\n", n_tokens, tx, ty, n_embd); + + std::vector embd((size_t)n_tokens * n_embd); + if (!clip_image_encode(ctx, 4, img, embd.data())) { + fprintf(stderr, "encode failed\n"); return 1; + } + + { + std::string p = out_prefix + ".embd.bin"; + FILE * f = fopen(p.c_str(), "wb"); + uint32_t vals[4] = { (uint32_t)n_tokens, (uint32_t)n_embd, (uint32_t)img->nx, (uint32_t)img->ny }; + fwrite(vals, 4, 4, f); + fwrite(embd.data(), 4, embd.size(), f); + fclose(f); + fprintf(stderr, "wrote %s\n", p.c_str()); + } + + // quick stats so runs are comparable at a glance + double sum = 0, sum2 = 0; + for (float v : embd) { sum += v; sum2 += (double)v * v; } + fprintf(stderr, "embd mean %.6f rms %.6f first8:", sum / embd.size(), + sqrt(sum2 / embd.size())); + for (int i = 0; i < 8; i++) fprintf(stderr, " %.5f", embd[i]); + fprintf(stderr, "\n"); + + clip_image_u8_free(img_u8); + clip_free(ctx); + return 0; +} diff --git a/tools/vlm_oracle/gen_test_image.py b/tools/vlm_oracle/gen_test_image.py new file mode 100644 index 000000000..fa612e032 --- /dev/null +++ b/tools/vlm_oracle/gen_test_image.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Deterministic P6 PPM test images for VLM oracle comparison (stdlib only).""" +import struct, sys, math, os + +def write_ppm(path, w, h, pixel_fn): + buf = bytearray() + for y in range(h): + for x in range(w): + r, g, b = pixel_fn(x, y) + buf += bytes((max(0, min(255, int(r))), max(0, min(255, int(g))), max(0, min(255, int(b))))) + with open(path, 'wb') as f: + f.write(b'P6\n%d %d\n255\n' % (w, h)) + f.write(buf) + print(path, w, 'x', h) + +out_dir = os.path.dirname(os.path.abspath(__file__)) + +# 1. radar-like: map-ish background, green/yellow/red rain blobs (512x384, multiple of 32) +def radar(x, y): + # pale map background with faint road grid + r, g, b = 232, 236, 240 + if x % 64 < 2 or y % 64 < 2: + r, g, b = 200, 200, 205 + # rain cells: three gaussian blobs of increasing intensity + for (cx, cy, s, col) in [(140, 120, 55, (120, 200, 120)), + (300, 200, 70, (240, 220, 100)), + (330, 180, 30, (220, 80, 60))]: + d2 = (x - cx) ** 2 + (y - cy) ** 2 + w = math.exp(-d2 / (2 * s * s)) + if w > 0.25: + r = r * (1 - w) + col[0] * w + g = g * (1 - w) + col[1] * w + b = b * (1 - w) + col[2] * w + return r, g, b +write_ppm(os.path.join(out_dir, 'radar_512x384.ppm'), 512, 384, radar) + +# 2. small gradient + circle, exercises nothing fancy (256x256) +def grad(x, y): + inside = (x - 128) ** 2 + (y - 96) ** 2 < 48 ** 2 + return (255, 64, 32) if inside else (x % 256, y % 256, (x + y) % 256) +write_ppm(os.path.join(out_dir, 'grad_256x256.ppm'), 256, 256, grad) + +# 3. non-32-aligned size to exercise smart-resize (500x375) +write_ppm(os.path.join(out_dir, 'radar_500x375.ppm'), 500, 375, + lambda x, y: radar(x * 512 // 500, y * 384 // 375)) diff --git a/tools/vlm_oracle/grad_256x256.ppm b/tools/vlm_oracle/grad_256x256.ppm new file mode 100644 index 0000000000000000000000000000000000000000..c4997122ac4244030a7253101db6d58e26264286 GIT binary patch literal 196623 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 z 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 XET)z)OSxsja%qLKQd?!Lw$}U)V&nNK literal 0 HcmV?d00001 diff --git a/tools/vlm_oracle/radar_500x375.ppm b/tools/vlm_oracle/radar_500x375.ppm new file mode 100644 index 000000000..2d0603934 --- /dev/null +++ b/tools/vlm_oracle/radar_500x375.ppm @@ -0,0 +1,4 @@ +P6 +500 375 +255 +ѳǷȷвȶȶϲȵȵαȵȵͰȴȴ̰ȳȳ˯ȳȳʮȲȲɮȱȱȭȰȰǬȰȰƬȯǯſſĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿſſĿĿľþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿſĿĿľþþþý½½½©ȬȬ½½½¾þþþÿĿĿѳȷſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿгȷȷſſĿľþþý½½¼ݿȪȪݿݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݿݿݿݿݿ½½¾þþÿĿϲȶȶſĿĿľþý½½¼ݿݿݿݾݾȩȩܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽݾݾݾݾݾݿݿݿ½½¾þÿĿβȵȶſĿľþþý½¼ݿݿݿݾݾݾݾܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þÿĿαȵȵſĿľþý½½ݿݿݿݾݾݾܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿͱȴȵſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ̰ȴȴſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿ½¾þÿĿ˰ȳȴſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݿݿ½¾ÿĿʯȳȳſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽݾݾݿݿ¾þÿĿʯȲȲſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܽܽݾݾݿݿ½¾þĿɮDZDzſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܼܺܽܽݾݾݿݿ¾þÿĿȮȱȱſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ܻܻܼܺܽܽݾݾݿ½¾ÿĿǭǰȱſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹ۺܻܼܼܺܽܽݾݿݿ¾þÿƬȰȰſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۹۹ۺܻܻܼܼܽݾݾݿ½¾ÿĿŬǯǰſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذدȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿ½¾ÿĿīȯȯſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿ¾ÿīȮȮſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخححȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݿ¾êȭȮƿľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽݾݿªȭȭͳȷȶȶȵȵȴȴȳȳȲȲȱȱȰǯȯȮȭȭȬȬȫȪȪȩȩȨȨȧȧȦȦȥȥȤȤȣȣȢȢȡȡȠȠȠȟȟȞȞȞȝȝȝȝȜȜȜțțțțțȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚțțțțțțȜȜȜȝȝȝȝȞȞȞȟȟȠȠȠȡȢȢȣȣȤȤȥȥȦȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯǯȰȱȱȲȲȳȳȴȴȵȵȶȶȷͳȷȷȶȶȵǴȴȳȳȲȲȱȱȰȰȯȮȮȭȬȬȫȪȪȩȩȨȨȧȧȦȥȥȤȤȣȣȢȢȡȡȡȠȠȟȟȞȞȞȝȝȝȜȜȜțțțțȚȚȚȚșșșșșșșșșșșșșșșșșȚȚȚȚȚțțțțȜȜȜȝȝȝȞȞȞȟȟȠȡȡȡȢȢȣȣȤȤȥȥȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯȰȰȱȱȲȲȳȳȴǴȵȶȶȷſľý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܼܺܽݾݾݿȫȬ¾þÿſľþüݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨ȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܻܼܺܽݾݾȪȫ¾þÿͳȷſľþý»ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭خددذٱٲٲٳڴڵڵڶ۷۸۹۹ܻܼܺܽݾȪȪ¾þÿȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۺܻܼܽȩȪݿ¾ÿĿȷȶſſľý¼ݾݾܼܻܽۺ۹۸۸ڷڶڵڴٳٳٲٱٰددخح׬׬׫תת֧֧֦֩֩֨զեեդդգգԣȖȕԡԡԡԡԡԠԠԠԠԠԠԠԠԠԠԠԡԡԡԡԡԢԢԢԣգգդդեեզ֦֧֧֨֩֩תת׬׬حخددٰٱٲٳٳڴڵڶڷ۸۸۹ۺܻܼȩȩݾݿ¾ÿĿѳȶǶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷۸۹ۺܻȨȩݾݿݿ¾ÿѲȶȶſľý¼ݿܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨תת׫׬׭حخدذٱٱٲٳڴڵڶڷ۷۸۹ۺȧȨܽݾݿ¾ÿвȶȵſľý½¼ݿݾܼܻܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ۷۸۹ȧȧܼܽݾݿ½¾ÿвȵȵſſľý¼ݿݾܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֨֩תת׫׬حخددٰٱٲٳڴڵڶڷ۸۸Ȧȧܻܼܽݾݿ¾ÿĿϱȵȴſľý¼ݿݾܼܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդե֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵڶڷ۸ȦȦܻܼܺܽݾݿ¾ÿϱȵȴſľý¼ݿݾݾܼܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜқȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵڶڷȥȦۺܻܼܽݾݾݿ¾ÿαȴȴſľľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵڶȤȥ۹ۺܻܼܽݾݿ¾þĿΰȴȳſľý¼ݿݾܼܻܽۺ۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵȤȥ۸۹ۺܻܼܽݾݿ¾ÿͰȴȳſľý¼ݿݾܼܻܽܺ۹۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڴȣȤ۷۸۹ܻܼܺܽݾݿ¾ÿͰȳȳƿſľý¼ݿݾܼܻܽۺ۹ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїіȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڴȣȣڷ۸۹ۺܻܼܽݾݿ¾ÿĿ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲٳȢȣڶ۷۸۹ۺܻܼܽݾݿ¾ÿ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲȢȢڵڶ۷۸۹ܻܼܺܽݾݿ¾ÿ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱȡȢڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡȡڴڵڶ۷۸۹ۺܻܼܽݾݿ¾ÿʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠȠٲٳڴڶڷ۸۹ۺܻܼܽݾݿ¾ÿͲͲʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠٲٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͱͱɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٱٲٳڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿͰͱɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰٱٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͰͰɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕіїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٱٲٳڴڵڶ۸۹ۺܻܼܽݾݿÿͯͯȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕіїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذٱٲڴڵڶ۷۸۹ܼܺܽݾݿ¾ͯͯȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٱٲٳڴڵڷ۸۹ۺܻܼܽݾ¾ͲͮͮȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذٱٲڴڵڶ۷۸۹ܻܼܽݾݿ¿ͲͱͭͭǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٱٲٳڴڵڷ۸۹ۺܻܼݾݿ¿ͱͱάέǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰد׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰٱٳڴڵڶ۷۹ۺܻܼܽݾݿ¿ͱͰάάǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذد׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٱٲٳڵڶ۷۸۹ܼܺܽݾݿÿͰͰΫΫƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰٲٳڴڵڶ۸۹ۺܻܼܽݿÿͯͯΪΪƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٱٲڴڵڶ۷۸ۺܻܼܽݾݿÿͯͮΪΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱٲٳڴڶڷ۸۹ܼܺܽݾݿ¾ÿͮͮΩΩūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿͭͭΨΨŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿέέϧϨŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲٳڴڶ۷۸۹ܻܼܽݾݿÿάάϧϧƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋ΌΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲٳڴڵڷ۸۹ܻܺܽݾݿ¾ÿάΫϦϦſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿΫΫϥϥſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿΪΪϤϥſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڴڶ۷۸۹ܻܼܽݾ¾ÿΪΩϤϤſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڵڷ۸۹ܼܺܽݾÿΩΩϣϣƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܽݾ¾ĿΨΨТТƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶ۷۹ۺܻܼݾ¾ÿϧϧССſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶ۷۹ۺܻܼܽ¾ÿϧϦРСſľéȬȫݾܼܻܽۺ۸۷ڶڵٳٲٱدخح׬ת֦֩եդԢԡԠӟӝҜқҚјїіЕГВϑϐΏΎ΍Ό͉͈͊̇̇̆˅˄˃˂ʁʁʀ~~~}}}}z{zz{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{||||}}}}~~~ʀʁʁ˂˃˄˅͈̆̇̇͊Ό΍ΎΏϐϑВГЕіїјҚқҜӝӟԠԡԢȗȘ֦֨֩ת׬حخدٱٲٳڵڶ۷۸ۺܻܼܽ¾ÿ߱ϦϦРРſľéȬȫݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخ׭׫ת֦֩եդԢԡԠӞӝҜқҙјїіДГϒϑϐΏ΍Ό͉͈͋͊̇̆̅˄˄˃˂ʁʀʀ~~~}~}}|}zzzzz{zzzzzzyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzz{{{{||||}}}}~~~~ʀʀʁ˂˃˄˄͈̅̆̇͊͋Ό΍ΏϐϑϒГДіїјҙқҜӝӞԠԡԢȗȘ֦֧֩ת׫׭خدٱٲٳڴڶ۷۸۹ܻܼܽݿ¾ÿ߱߱ϥϥППſľéȬȫݿݾܼܻܽ۹۸ڷڶڴٳٲٰدخ׭׫ת֩զեգԢԡӟӞӝҜҚҙјїЕДГϒϑϏΎ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~~}}|}||zzzzzzzzzzyyyyyyyyyyxyxxxxxxxyyyyyyyyyyyzzzzzz{{{{{||||}}}~~~ʀʁ˂˂˃˄̅̆̇̈͊͋Ό΍ΎϏϑϒГДЕїјҙҚҜӝӞӟԡԢȗȘզ֧֩ת׫׭خدٰٲٳڴڶڷ۸۹ܻܼܽݿ¾ÿ߰߰߰ϥϤООſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܼܺܽݿ¾ÿ߯߯߰߰߰ϤϤѝНſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܼܺܽݿÿޮ߮߯߯߯߰ϣϣќќſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܽݿÿޭޭ߮߮߮߯߯ϣТћќſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽݿÿެޭޭ߭߮߮߮߯ТСћћſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿޫެެެ߭߭߭߮߮ССњњſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿݪޫޫެެ߬߭߭߭߮РРљљſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{yyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇͊͋Ό΍ΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿݪݪުޫޫެެ߬߬߭߭߭РПјјͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݿݩݩݪުުޫޫޫ߬߬߬߭߭ППҗҗͱͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݨݨݩݩުުުޫޫ߫߬߬߬߬ООҖҗͱͱſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽݨݨݨݩީުުުޫ߫߫߫߬߬НѝҕҖͱͱſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܦݧݨݨݨީީުުުߪ߫߫߫߫ѝќҕҕͰͱſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܺܥܦݧݧݨݨިީީީުߪߪߪ߫߫ќќҔҔͰͰſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܥܥܦݧݧݧݨިިީީީߪߪߪߪߪћћғғͯͰͳȷȶȶȵȴȴȳDzȲȱȰȯǯȮȭȬȫȪȪȩȨȧȦȥȤȤȣȢȡȠȟȞȝȜțȚșȘȗȖȕȔȓȒȑȐȏȏȎȍȌȋȊȉȉȈȇȆȅȅȄȃȃȂȁȁȀ~~~}~}}|}||||{{{{zzzzzzyzyyyyyyyyyyxyxxxxxxxxxxxyyyyyyyyyyyyzzzzzzz{{{{{{|||||}}}}~~~~ȀȁȁȂȃȃȅȅȆȇȈȉȉȊȋȌȍȎȏȏȐȑȒȓȔȕȖȗȘșșȚțȜȝȞȟȠȡȢȣȤȤȥ͕͖͖͖͗͗͗͘͘ΘΘΘΘΘΙΙΙΙΙϙϙϙϦϥϥϤϤϣϣТТССРРППООѝѝќќћћњљљјҘҘҗҗҖҖҖҕҕҔҔҔғғғӒӒӒӒӑӑӑӑӐӐӐӐӐӐӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӐӐӐӐӐӐӑӑӑӑӒӒӒӒғғғҔҔҔҕҕҖҖҖҗҗҘҘљљњњћћќќѝѝООППРРССТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͭͮͯͯͰͰͱͱͲͳȷȷȶȵȴȴȳȲȲȱȰȯȯȮȭȬȫȫȪȩȨȧȦȥȥȤȣȢȡȠȟȞȝȜțțșȘȗȖȕȔȓȒȑȐȐȏȎȍȌȋȊȊȉȈȇȆȆȅȄȄȃȂȁȁȀȀ~~~}}}}|}||{|{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{{||||}}}}}~~~ȀȀȁȁȂȃȄȅȆȆȇȈȉȊȊȋȌȍȎȏȐȐȑȒȓȔȕȖȗȘșȚțțȜȝȞȟȠȡȢȣȤȥ͕͕͕͖͖͗͗͗͗ΗΘΘΘΘΘΘΘΘΘϘϘϘϘϥϥϤϤϣϣТТСРРППООНѝќќћћњњљљјҘҗҗҖҖҖҕҕҔҔҔғғӓӒӒӒӑӑӑӐӐӐӐӐӏӏӏӏӏӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӏӏӏӏӏӐӐӐӐӐӑӑӑӒӒӒӓғғҔҔҔҕҕҖҖҖҗҗјљљљњњћћќќѝНООППРРСТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͮͮͯͰͰͱͱͲͲſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶۣۣۢܤܤܥݥݦݦݦާާާާިߨߨߨߨߨߨјҘӏӐͮͮƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶۣۣۢܤܤܤݥݦݦݦަާާާާߧߨߨߨߨߨҗҗӎ޲z轅轅輅輅輅輅輆輆輆輇輇輇輈轈轉轊辊辋ͭͮƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵۣۡۢۢܣܤܤݥݥݦݦަަާާާߧߧߧߧߧߧҗҖ轃輂輂ޱxްx軂躂躃躃躃躃纃纄纄纄纅纅纆纆织织终缉缉轋辌έͭſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڡۡۢۢܣܣܤܤݥݥݥݦަަަަާߧߧߧߧߧߧҖҖ輁輁軁軀躀躀߯v߯v踀踀縀縀緁緁緁緁緂緂緂縃縃縄縄繅繆纆纇绉缊缋罌羍άέſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڠۡۡۢۢܣܣܣܤݤݥݥݥަަަަަߦߦߦߦߧߧҕҕ轀~~~~߭t߭t~~~~~~絀絀絁絁綂綂綃緃緄縅繇纇纈绉缊缋罌美άάſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڠۡۡۢۢܢܣܣܤݤݥݥޥޥޥަަަߦߦߦߦߦߦҕҔ~~~}}}|||||߫s߫s|||||||}}}~~紀紁紁終絃緄緅縆繇纈纉绊缋罍美ΫάſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڠڠۡۡۢۢܢܣܣܣݤݤݥޥޥޥޥޥߥߦߦߦߦߦߦҔғ}}|||{{{zzzzzߩqߩqyyyzzzzz{{{||}~~糀糀紂絃綄緅緆縇繈纉绊缌罍美ΫΫƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋ΌΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲڟڠڠۡۡۡܢܢܣܣܣݤݤݤޥޥޥޥޥߥߥߥߥߥߥߥғӓ||{{zzzyyxxxxxwߧoߧowwwwwxxxxyyzz{{|}}~粀糁糂紃組綅緆縇繈纊绋缌罍羏ΪΫŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲڟڠ۠۠ۡۡܢܢܢܣݣݤݤݤޤޤޥޥޥߥߥߥߥߥߥߥӒӒ|{zzyyxxwwwvvvvuuߥmߥmuuuuuuuvvvwwxxyzz{|~~沀糂紃組綅緆縇繉纊绋缍罎翐ΪΫŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱڟڟڠ۠۠ۡۡܢܢܢܣݣݣݤݤޤޤޤޤޤߤߥߥߥߤߤߤӒӑzzyyxwwvvuuutttsssߣkߣksssssssstttuuvwwxyy{|}~汀沂紃組綅緇縈繉纋缌罎羏ΪΪūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲڟڟڠ۠۠ۡۡܢܢܢܣݣݣݣޤޤޤޤޤޤߤߤߤߤߤߤߤӑӐyxxwwvuuttsssrrrqqqiߠippppqqqqqrrssttuvvwyz{|}~汁沂紃組綆緇縉纊绌缍羏ΩΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱڞڟڟڠ۠۠ۡܡܢܢܢܢݣݣݣޣޤޤޤޤޤߤߤߤߤߤߤߣӐӐyxwwvuutssrrqqpppoooohgnnnnnnooooppqqrsstuwxyz{|}~氀汁泂約絅綇縈繊纋缍罎羐ΩΩƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٞڞڟڟ۠۠۠ۡܡܡܢܢݢݣݣݣޣޣޣޤޤޤߤߤߣߣߣߣߣӏӏwvvutssrrqppoonnnmmmlfflllllllmmmnnnoppqrstuvwyz{|~氀沂泃紅綆緈繉纋绌罎羐ΨΩƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰڞڞڟڟ۠۠۠ۡܡܡܢܢݢݢݣݣޣޣޣޣޣߣߣߣߣߣߣߣߣӏӎwuttsrrqpponnmmlllkkkjddjjjjjjjjkkkllmmnooprstuvxyz{}~氀汁沃洄絆緇縉纋绌罎羐ΨΨǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذد׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٝڞڞڟ۟۠۠۠ܡܡܡܢܢݢݢݣޣޣޣޣޣޣߣߣߣߣߣߣߢߢӎԍvussrqpponnmmlkkjjjiihhbbhhgghhhhhiiijjkllmnpqrstuwxy{|~氁沂泄絅綇縉繊绌缎羐ϧΨǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰد׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰڞڞڟڟ۟۠۠۠ܡܡܡܢݢݢݢݢޣޣޣޣޣޣߣߣߣߢߢߢߢߢԍԍutsrqpoonmmlkkjiihhgggff``feeeeeffffgghhiijklnopqrstvwyz|}氀求泃紅綇縉繊绌缎羐ϧϨǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٝڞڞڟڟ۟۠۠ۡܡܡܡܢݢݢݢݢޢޣޣޣޣޣߢߢߢߢߢߢߢԍԌtssrponnmlkkjiihhgffeeedd__cccccccdddeeefgghijklnopqrtuvxy{}~毀求沃洅綇緉繊绌缎羐ϧϧȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذڞڞڞڟ۟۠۠۠ܡܡܡܡܢݢݢݢޢޢޢޢޢޢޢߢߢߢߢߢߢߡԌԋtsrqpnnmlkjjihhgffeeddccbb]]aaaaaaaabbbccddefggijklnoprstvwy{|~毀汁沃洅綇緉繊绌缎羐ϦϧȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٝڞڞڟڟ۟۠۠۠ܡܡܡܡݢݢݢݢޢޢޢޢޢޢߢߢߢߢߢߡߡߡԋԋ߆߆srqpoomlkjiihgffeddccbbaa``[[________```aabbcddeghijlmnoqrtuwyz|~毀氁沃洅綇緉繋绍罎羐ϦϦȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕіїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذڞڞڞڟ۟۟۠۠ܠܡܡܡܡݢݢݢݢޢޢޢޢޢޢߢߢߢߡߡߡߡԊԊ߆߆߆߅߅srqponmkjiihgfeedccbaa``__^^ZY]]]]]]]]]^^^__`aabcefghiklmoprsuwxz|~毀氁沃洅綇縉繋绍罏翑ϥϦɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕіїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢޢޢޢޢޢޢߢߡߡߡߡߡߡԊԉ߆߆߅߅߅߅߄߄qponmlkjihgfeedcbba``_^^]]]\XX[[[[[[[[[\\\]]^__`acdefgijkmnprsuwxz|~毀氂沃洅綇縉纋绍罏ϥϦɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰڞڞڞڟ۟۟۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޢߢߡߡߡߡߡߠߠԉԈ߆߆߅߅߅߄߄߄߄߃qponmlkjhgfeedcbaa`_^^]]\\[[ZV߆VYYYYYYYYYZZZ[[\]]^_abcdeghiklnpqsuvxz|~毀求泄洆綈縊續缎羐ϤϥɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢݢޢޢޢޢޡߡߡߡߡߡߠߠԈՈ߆߆߅߅߄߄߄߃߃߃߂ponmlkjihgfedcba``_^]]\[[ZZYYXU߃UXWWWWWWWWXXXYYZ[[\]_`abcefgijlnoqsuwxz|~毀求泄浆緈繊绌缎羐ϤϥʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠڞڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޡޡߡߡߡߡߠߠߠՈՇ߆߆߅߅߄߄߃߃߃߂߂߂ponmlkjihgedcbaa`_^]]\[ZZYYXXWW߁S߁SVVUUUUUUUVVVWWXYYZ[]^_`acdfgijlnoqsuwy{}毁汃泅絇緉繋绍罏翑ϤϤʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠ͑ڞڞڟڟ۟۠۠۠ܡܡܡܡݡݡݡݢݢݢޢޢޡޡޡߡߡߡߡߠߠߠߠՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁onmlkihhgfdcba`_^]]\[ZZYXXWVVUUR~RTT~T~S}S}S}S}S}T}T}T}U~U~UVWWXY[\]^`abdeghjlmoqsuwy{}氁沃洅綇縉續缎羐ϣϤˮȲȱſľý¼ݿݾܼܻܽ۹۸۷ڶڵٳٲٱذدخ׭׫ת֧֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙљјїііЕДДГГϒϒϑϑȉȈϏΏΏΎΎΎΎΎΎΎ΍ΎΎΎΎΎΎΎΏΏϏϏϐϐϑϑϒϒГГДДЕііїјљҙҚҜӝӞӟӟԠԡԢգդե֦֧֨֩ת׫׭خدذȠ͑ڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޡޡޡޡߡߡߡߠߠߠߠՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀nmlkjihgfedba`__^]\[ZYYXWWVUUTTS|P{P}R|R|R{R{RzRzRzRzRzRzRzS{S{T|T}U~U~VWYZ[\^_`bceghjlnoqsuwy{}殀氂沄浆緈繊绌缎羑ϣϤ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡ͒ڟڟ۟۠۠۠۠ܡܡܡܡݡݡݢݢݢݢޢޢޡޡޡߡߡߡߠߠߠߠߟՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀mlkjihgfedca`_^]\[ZZYXWVVUTTSSR~RzOyOzQzPyPxPxPwPwPwPwPwPwQxQxQxRyRzS{T|T}UWXY[\]_`bceghjlnprtvxz|~毀求泅絇緉繋绍罏ϣϣ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱ͒͒ڟڟ۠۠۠۠ܡܡܡܡܡݡݢݢݢݢޢޢޢޡޡޡߡߡߠߠߠߠߟՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~mlkjihgfedcb`_^]\[ZYXWWVUTTSRR~Q|Q{PxNwMxOwOvOvNuNuNtNtNtNtOtOuOuPvPvQwQxRySzT}U~WXYZ[]^`aceghjlnprtvx{}氁沃洅綈縊續缎羐Тϣ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲ͒͒ڟ۠۠۠ۡܡܡܡܡܡݡݢݢݢݢݢޢޢޢޡޡߡߡߡߠߠߠߠߟՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}lkjihgedcba`^]\[ZZYXWVUTTSRR~Q|P{PzOxOuLtLuNtMsMsMrMrMrMqMqMqMrMrNrNsNtOuPuPvQxRzT|U}VWXZ[]^`acegikmoqsuwy{}殀求泄絆緉繋绍罏Тϣ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲڟ͒͒۠۠۠ۡۡܡܡܡܡܢݢݢݢݢݢޢޢޢޢޡޡߡߡߠߠߠߠߟՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}lkjigfedcba`_]\[ZYXWVVUTSRRQ}P|PzOyNwNvMsKrKsLrLqLpKpKoKoKoKoKoKoLoLpLpMqMrNsOtOuPxRyS{T|V~WXZ[]^`bcegikmoqsvxz|~毁沃洅綇縊續缎羐СТͰȳȳƿſľý¼ݿݾܼܻܽۺ۹ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїіȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڟ͓͒۠۠ۡۡܡܡܡܢܢݢݢݢݢݢޢޢޢޢޢޡߡߡߡߠߠߠߟߟՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||kjigfedcba`_^\[ZYXWVUTTSRQP}P{OyNxNvMuLsLqJoJpKoJnJnJmJmJlJlJlJlJlJmJmKnKnLoLpMqNsOuQwRxSzT|U~WXY[]^`bdfhjlnprtvy{}氂泄絆緈繋绍罏СТͰȴȳſľý¼ݿݾܼܻܽܺ۹۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڠ͓͓ۡۡۡܡܡܢܢܢݢݢݢݢݢݢޢޢޢޢޢޡߡߡߠߠߠߠߟփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{kjigfedcba`_^][ZYXWVUTSRQQP}O{NyNwMuLtLrKqKnImHnImIlIkIkHjHjHjHjHjHjIjIkIkJlJmKnLoLpMsOtPvQxRzT{U}VXZ[]_`bdfhjlnqsuwz|~毀沃洅綇縊續缎羐ССΰȴȳſľý¼ݿݾܼܻܽۺ۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڠڠ͓͓ۡۡۡܡܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢߡߡߡߠߠߠߟߟւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zjihfedcba`_^]\ZYXWVUTSRQPP}O{NyMwLuLsKrKpJoIlHkGkHjHiGiGhGhGgGgGgGgGgGhHhHiHjIjJkJmKnLqNrOtPvQwRyT{U}WXZ[]_acegikmortvx{}氂泄絆緉繋缍羏РСαȴȴſľľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڠ͓͓ۡۡۡܢܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢޢߡߡߡߠߠߠߟւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyihgedcba`_^]\[YXWVUTSRQPO}N{NyMwLuKsKqJpInImHjGiFiGhGgFfFfFeFeFeFeFeFeFeFfGgGgHhHiIjJlJnLpMrNsPuQwRyT{U~WXZ\^`bdfhjlnpsuwy|~毀沃洅綇繊绌罎РСϱȵȴſľý¼ݿݾݾܼܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜқȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܢݢݢݢݢݢݢޢޢޢޢޢޢޢߡߡߡߠߠߠߟցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyihgfdcba`_^]\[ZXWVUTSRQPO}N{MyMwLuKsJqJoImHlHjGhFgEgFfEeEdEdEcEcDcDbEcEcEcEdEdFeFfGgHhHiIlKnLpMqNsPuQwRzT|U~WY[\^`bdfikmoqtvx{}求泄絇縉纋缍羐РРϱȵȴſľý¼ݿݾܼܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդե֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܣݣݣݣݣݣݣޣޣޢޢޢޢޢߢߡߡߡߠߠߟրր߆߅߄߃߂߂߁߀~}}||{zzyyxxhgfedba`_^]\[ZYWVUTSRQPO~N{MyLwLuKsJqIoImHkGjGhFfEeDeEdDcDbDaDaCaC`C`C`DaDaDaDbEcEdFeFfGgHjJlKnLoMqNsPvQxSzT|VXY[]_acegjlnpsuwz|~氁沃絆緈繊绍罏ПРвȵȵſſľý¼ݿݾܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֨֩תת׫׬حخددٰٱٲٳڴڵڶ͔͔ۢۢۢܢܣܣܣݣݣݣݣݣݣޣޣޣޣޢޢޢߢߢߡߡߠߠߠߟր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwgfedcb`_^]\[ZYXVUTSRQPON|MzLxLuKsJqIoHmHkGjFhFfEdDcCcDbCaC`C_C_B_B^B^B^B_C_C_C`DaDbEcEdFeGhIjJlKnLpMrOtPvQxS{U}VXZ\^`bdfhkmortvy{}毀求洅綇縉绌罎ПРвȶȵſľý½¼ݿݾܼܻܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ͔͕ۢۢۢܣܣܣܣݣݣݣݣݣݣޣޣޣޣޣޢޢߢߢߡߡߠߠߠ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwgfedcba`_]\[ZYXWUTSRQPON}M{LxLvKtJqIoHmHkGjFhEfEeDcCaCaC`B_B^B]B]A]A\A\A\A]B]B]B^C_C`DaDbEcFfHhIjJlKnLpMrOtPwRyT|U~WY[]_acegjlnqsuxz}汁泄絆縉纋缍羐ПРѲȶȶſľý¼ݿܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨תת׫׬׭حخدذٱٱٲٳڴڵڶڷۣۣۣ͕͕ۢܣܣܣݣݤݤݤݣݣޣޣޣޣޣޣޢߢߢߢߡߡߠߠ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvgedcba`_^]\ZYXWVTSRQPON~M|MyLwKtJrIpHnGlGjFhEfEeDcDaB`B_B^B]A\A\A[A[A[@Z@[A[A[A\A\B]B^C_C`DbEeGfHhIjJlKnLqNsOuQxSzT}VXZ\^`bdgikmprtwy|~氁沃絅緈繊缍羏ОПѳȶǶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷ۣۣۣܣ͕͕ܤܤݤݤݤݤݤݤޤޤޣޣޣޣޣߣߢߢߢߡߡߠߠ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvufedba`_^]\[ZYXWVSRQPPON}MzLxKuJsIqHoHlGjFhEfEeDcCaC_B^A]A\A[A[@Z@Y@Y@Y@Y@Y@Y@Y@Z@[A[A\B^B_C`DcFeGgHiIkJmLoMqNtPvRyS{U~WY[]_acfhjloqtvx{}毀沂洅綇繉绌罎ОПȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۤܤܤܤ͖͖ݥݥݥݥݤݤޤޤޤޤޤޤޤߣߣߣߢߢߢߡߡߠ}}߄߃߂߁߁߀~}}|{zzyxxwvvuutfdcba`_^]\ZYXWVUTRQPONM}L{KxJvJsIqHnGlFjEhEfDdCbC`B_A]A[@Z@Y@X?X?W?V>V>V>V>V>V>V?W?X?Y@Y@[A\B]B`DbEdFfGhIjJmKoMqNtPwRyT|UWY[]`bdfikmpruwy|~汁泃絆縈纋缍羏ООͳȷſľþý»ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭خددذٱٲٲٳڴڵڵڶ۷۸۹۹ܤܥܥܥܥ͖͖ݥݥݥݥݥޥޥޥޤޤޤޤޤߣߣߣߣߢߢߡߡ}|߄߃߂߁߀~}}|{zzyxxwvvuttsedca`_^]\[ZYXWVUTRQPONM|LzKwJuIrHpGmGkFiEgDdCcCaB_A]A[@Z@Y?X?W?V>V>U>U>U>T>U>U>U>V>V?W?X@Y@[A\B_CaDcEeGgHiIkKnLpNsOvQxS{U~WY[]_acfhjmoqtvy{~氀沃絅緈纊缌羏НОſľþüݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨ȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܺܥܥܥܥܥ͖͖ݥݥݥݥޥޥޥޥޥޥޤޤߤߤߣߣߣߢߢߡ}|߃߂߁߀~~}|{zzyxxwvvuttssdcba`_^\[ZYXWVUTSQPONM~L|KyJvItIqHoGlFjEhDfDcCbB`B^A\@Z@Y?X?W?V>U>U>T=T=S=S=S=T=T>U>U>V?W?X@Y@[A^C`DbEdFfGhIjJmKoMrOuPwRzT}VXZ\^`cegjlnqsvx{}氀沂紅緇繉绌美ѝОſľý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܺܦܦܦܦݦ͗͗ݦݦݦަަޥޥޥޥޥޥߤߤߤߣߣߣߢߢ߆|{߂߁߁߀~}|{{zyxxwvvuttssrdba`_^]\[ZYXWVUSRPPONM~L{KxJvIsHpGnFkFiEgDeCcCaB_A]A[@Z?X?W?V>U>T=T=S=S=R=R=R=S=S=T=T>U>V?W?Y@ZA]B_CaDcEeGgHjIlKoLqNtPwRyS|UWY[^`bdgiknpsuxz}沂約緆繉绋罎ѝОſľý¼ݿݿݾܼܻܻܽۺ۹۸۸۷ڶڵڵڴٳٳٲٱٱذذدخخح׭׬׬׫׫׫תת֩֩ȚȚ֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֨֨֨֨֨֨֩֩֩תת׫׫׫׬׬׭حخخدذذٱٲٳٳڴڵڵڶ۷۸۸۹ۺܻܻܼܦܦܦݦݦ͗͗ݦݦަަަަަޥޥޥߥߥߤߤߤߣߣߣߢ߆|{߂߁߀~}||{zyxxwvvuttsrrqcba`_^\[ZYXWVUTSRPONML}KzJxIuIrHpGmFkEhDfDdCbB`B^A\@[@Y?W>V>U>T=S=S=R=RU>V?X?Y@\B^C`DbEdFgHiIkJnLpNsOvQyS{U~WY[]_bdfhkmpruwz|汁約綆繈绋罍ѝНžþý¼ݿݿݾܼܼܻܽۺ۹۹۸۷ڷڶڵڴڴٳٳٲٱٱذذددخخح׭׬׬׫׫׫תțț֩֩֩֩֨֨֨֨֨֨֨֨֨֨֨֨֨֩֩֩֩֩תתת׫׫׫׬׬׭حخخددذذٱٱٳٳڴڴڵڶڷ۷۸۹۹ۺܻܼܼܧܧܧݧݧݧ͗͗ݧާަަަަަަߥߥߥߥߤߤߤߣߣߣߢ߆߅{{߁߀~~}|{zyyxwvvuttsrrqqba`_^]\[ZYXWVUTSRPONML|KzJwItHrGoGmFjEhDfCcCaB_A]A\@Z?X?W>V>U=T=S=R=QV>W?X@\A^B_CbEdFfGhIkJmLpMsOuQxR{T~VXZ]_acfhjmortwy|~汁紃綆縈绊罍ќѝƿľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽܧݧݧݧݧݧ͘͘ާާާާާަަߦߦߦߥߥߥߤߤߤߣߣ߆߅{z߁߀~}|{zzyxwvvuttsrrqqpba`_^][ZYXWVUTSRQONMLK|KyJwItHqGoFlEjEgDeCcBaB_A]@[@Y?X>V>U>T=S=R=QU>V?X?[A]B_CaDcFeGhHjJmKoMrOuPxR{T}VXZ\^acehjloqtvy{~汀糃綅縈纊罍ќѝſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخححȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݨݨݨݨݨݨ͘͘ިާާާާާߧߦߦߦߥߥߥߥߤߤߣ߆߅߄zz߀~}}|{zyxwwvuttsrrqqppb`_^]\[ZYXWVUTSRQONMLK|JyIvItHqGnFlEiDgDeCbB`A^A\@[@Y?W>V>U=T=S=RV?X?[A]B_CaDcEeGgHjIlKoMrNuPwRzT}VXZ\^`cegjloqtvx{}汀糂綅縇纊罌ќѝſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿݨݨݨݨݨި͘͘ިިިާާߧߧߧߦߦߦߥߥߥߤߤ߆߅߄zy߀~}|{zyyxwvuutssrqqppoa`_^]\[ZYWVUTSRQQONML~K|JyIvHsGqGnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?[A\B^C`DcEeFgHjIlKoLrNtPwRzT}UWZ\^`begilnqsvx{}汀糂綅縇纊缌ќќſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذدȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿݩݩݩݩީީ͙͘ިިިިߨߧߧߧߧߦߦߦߥߥ߆߅߄߃zy~}||{zyxwvvutssrqqppona`^]\[ZYXWVUTSRQPNMML~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>T=S=R=QV>W?[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY\^`begilnqsuxz}糂組縇纉缌ћќſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۹۹ۺܻܻܼܼܽݾݾݿݪݪݪީީީ͙͙ީީߨߨߨߨߨߧߧߧߦߦߦ߆߅߄߃yy~}|{zyxxwvuttsrrqppoon`_^]\[ZYXWVUTSRQPNMLK~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY[^`bdgilnpsuxz}糂組縇纉缌ћќſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹ۺܻܼܼܺܽܽݾݿݿݪݪުުުުު͙Ιީߩߩߩߨߨߨߨߧߧߧ߅߄߃߂yx~}||{zyxwvuutsrrqppoonm`_^]\[ZYXWVUTSRQPNMLKJ|JyIvHtGqFnFlEiDgCeCbB`A^A\@[?Y?W>V>U=T=S=RV>X?[A]B_CaDcEeFgHjIlKoLrNuPwQzS}UWY[^`bdgiknpsuxz}糂綄縇纉缋ћќſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ܻܻܼܺܽܽݾݾݿ½ݫޫޫޫޫުު͚Ιߪߪߩߩߩߨߨߨߨߧ߆߅߄߃߂yx~}|{zyxxwvutssrqqpoonnm`_^\[ZYXWVUTSRRQPNMLKJ|JyIvHtGqFoElEjDgCeCcBaA_A]@[?Y?X>V>U>T=S=RV?X?[A]B_CaDcEeFhHjImKoLrNuPxQzS}UWY[^`bdgiknpsuxz}糂綄縆纉罋ћќſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܼܺܽܽݾݾݿݿ¾þެޫޫޫޫޫޫ͚Κߪߪߪߩߩߩߩߨ߆߅߄߃߂yx~}|{zyxwvuutsrrqpponnmm_^]\[ZYXWVUTSRQPPNMLKJ|IzIwHtGrFoFmEjDhCeCcBaA_A]@[@Z?X>W>V>U=T=S=RV>W?X@\A]B_CaDdEfGhHkImKpLrNuPxR{S~UWY[^`bdgiknpsuxz}糁綄縆纉罋ћќſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܽܽݾݾݿݿ½¾þĿެެެެެޫ͚߫Κ߫ߪߪߪߪߩߩ߅߄߃߂߁xx}|{zyyxwvutssrqpponnmml_^]\[ZYXWVUTSRQPONMLKJ}IzIwHuGrFpFmEkDhCfCdBbB`A^@\@Z?Y?W>V>U>T=S=S=RU>V>X?Y@\A^B`CbDdFfGiHkJnKpMsNvPxR{T~VWZ\^`bdgiknpsuxz}紁綄縆绉罋ћћſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽݾݾݿݿ¾þÿĿޭޭެެ߬߬߬͛Κ߫߫߫ߪߪ߆߅߄߃߂߁xw}|{zyxwvvutsrrqpoonmmll_^]\[ZYXWVUTSRQPONMLKJ}J{IxHuGsFpFnEkDiDgCeBbBaA_A]@[?Z?X>W>V>U=T=T=S=S=R=R=R=S=S=T=T=U>V>W?X?Z@]B_CaDcEeFgGjHlJnKqMtOvPyR|TVXZ\^`begilnpsuxz}紂綄繆绉罋њћſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݿݿ½¾ÿĿޭޭ߭߭߭߬߬͛Λ߬߫߫߫߆߅߄߃߂߁xw}|{zyxwvuttsrqpponnmmlk_^]\[ZYXWVUTSRQPONMLKJ~J{IyHvGsGqFoElDjDhCeCcBaA`A^@\@[?Y?X>W>V>U>U=T=T=S=S=S=T=T=U>U>V>W?X?Y@[A^B`CbDdEfFhHjImJoLrMtOwQzR}TVXZ\^`cegilnqsuxz}紂緄繆绉轋њћſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿ½¾þÿĿޮ߮߮߭߭߭߭͛Λ߬߬߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllk_^]\[ZYXWVUTSRQPONMLKKJ|IyHwGtGrFoEmEkDiCfCdBbBaA_A]@\?Z?Y?X>W>V>V>U>U=U=T=U=U>U>V>V>W?X?Y@[@\A_CaCcDeFgGiHkInKpLsNuOxQ{S}UVXZ\_acegjlnqsvxz}終緄繇绉辋њћſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ߯߯߮߮߮߮߮߭ΜΛ߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkk^]\[ZYXXWVUTSRQPONMLKKJ}IzHxHuGsFpFnElDjDgCeCdBbA`A^@]@[?Z?Y?X?X>W>V>V>V>V>V>V>V>W>X?X?Y@[@\A]A`CbDdEfFhGjHlJoKqMtNvPyQ|S~UWY[]_acehjloqsvx{}糀終緄纇缉辋њћſĿľþý½½ݿݿݿݾݾݾܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿ߯߯߯߯߯߮߮߮ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjj]\[ZYXXWVUTSRQPPNMLLKJ~I{IyHvGtGrFoEmEkDiCgCeBcBaA`A^@]@\@[?Z?Y?X?X?W>W>W>W>W?X?X?Y?Z@[@\A]A^BbDcEeFgGiHkInJpLrMuOwPzR}TUWY[]_adfhjmoqtvy{}糀終縅纇缉њћſĿľþþý½¼ݿݿݿݾݾݾݾܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þÿĿ߰߰߰߯߯߯ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjj]\[ZYXXWVUTSRQQPNMMLKJJ}IzHxHuGsFpFnElDjDhCfCdBcBaA`A^@]@\@[@Z?Z?Y?Y?Y?Y?Y?Y?Y?Z@[@[@\A]A_B`CcDeEgFiGkHmJoKqLtNvOyQ{R~TVXZ\^`bdfikmortvy{~紀綂縅纇轉њћͳȷȶȶȵȵȵȴȴȳȳȳDzȲȲȱȱȰǰȰǯȯǯȮȮȮȭȭȭȭȬȬȬȫȫȫȫȫȪȪȪȪȪȩȩȩȩȩȩȩȩȩȨȨȨȨȨȨȨȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȫȫȫȫȫȬȬȬȭȭȭȮȮȮǯȯǯȰǰȰȱȱȲȲ͞͞͞͞͞͝͝͝ΝΜΜΜΛΩΨϨϧϦϥϥϤϣТССРПОѝћћњљҘҗҖҕҔҔӓӒӑӐӏӎԍԌԋԋԊԉՈՇՆՅՄՃււցր~}}|{zyxwvuutsrrqppoonmmllkkjjiihh\[[ZYXWVUUTSRQQPNMMLKJJ~I{HyHvGtFrFoEmEkDiCgCfBdBcBaA`A_@^@]@\@[@[?Z?Z?Z?Z?Z@[@[@\@]A]A^A`BaCdDeEgFiGkHmIoJqKsLuNwOzP|R~S߁U߃V߆X߈Zߊ\ߍ]ߏ_ߒaߔcߖeߙgޛhޝjޟlޡnޤpަrިtݩvݫxݭzݯ|ݱ~ҕҖҗҘњћћќѝОПРССТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲͳȷȶȶȶȵȵȴȴȴȳȳȳȲȲDZȱȱȰȰȰȯȯȯȮȮȮȮȭȭȭȬȬȬȬȬȫȫȫȫȫȪȪȪȪȪȪȪȩȩȩȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȪȪȪȫȫȫȫȫȬȬȬȬȬȭȭȭȮȮȮȯȯȯȰȰȰȱȱDZȲȲȳ͟͟͞͞͞͞͞͝ΝΝΜΜΪΩΨϨϧϦϥϥϤϣТСРРПОѝћћњљҘҗҖҕҔғӓӒӑӐӏӎԍԌԋԊԊԉՈՇՆՅՄՃււցր~}||{zyxwvuttsrrqpponnmmllkjjjiihh\[[ZYXWVVUTSRQQPNNMLKKJI|IzHxGuGsFqFoEmDkDiCgCfCdBcBbA`A_A^A^@]@]@\@\@\@\@\@\@]A^A^A_B`BaCcCeEgFiFjGlHnIpJrLtMvNyO{Q}R߀T߂U߄W߇Y߉Zߋ\ߎ^ߐ`ߓaߕcߗeߙgޜiޞkޠmޢoޤqަrިtݪvݬxݮzݯ|ݱ~ҕҖҗҘњћћќѝОПРРСТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿΝΝ߆߅߃߂߁߀wv{zyxwvuttsrqpoonmmlkkjjii\[ZZYXWVUTSSRQPONMMLKJJ~I|IyHwGuGsFqFoEmEkDiDhCfCeBcBbBaBaA`A_A_A^A^A^A^A^A_A_B`BaBbCcDdDeEhFjGkHmIoKqLsMvNxPzQ}STVXZ\^`bdfhjloqsuxz|絁縃纆輈њњſĿĿľþþþý½½½©ȬȬ½½½¾þþþÿĿĿΞά߆߄߃߂߁߀wv{zyxwvutssrqpoonmllkkjiih\[[ZYXWVUTTSRQPONNMLKKJJ}I{HyHvGtGrFpFoEmEkDjDhCfCeCdBcBcBbBaBaB`B`B`B`B`BaBaCbCcCdDeDfEgFjGlHmIoJqKsMuNwOzQ|R~TUWYZ\^`bdfikmoqtvx{}綁縄细轈њњſſĿĿľþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿέά߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiih]\[ZYXWVUUTSRQQOONMMLKKJI|IzHxHvGtGrFpFoEmElDjDhCgCfCeCeCdCcBcBbBbBbBbCcCcCcCdDeDfEgEhFiFlHnIoJqKsLuMwOyP{Q~STVXY[]_acegiknprtvy{}絀緂繄軇轉љњſſĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿέά߆߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiihh\[ZYXWWVUTSSRQPONNMLLKJJ~I|IzHxHvGtGrFqFoEnElEkDiDiDhDgCfCeCeCeCeCdCeCeDeDfDfEgEhEiFjGkGnIpJqKsLuMwNyP{Q}RTUWYZ\^`bdfhjlnpsuwy|~絀縂纅輇љњūȮȮĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihh\[ZYXXWVUTTSRQPOONMMLKKJJ~I|IzHxHvGtGsFqFpFnEmElDkEjDiDhDhDgDgDgDgDgDgDgEhEiEiFjFkGlHmHpJrKsLuMwNyO{P}RSUVXY[]_acdfikmoqsuxz|~綁繃軅轇љњƬȯǯέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihh\[ZYYXWVUUTSRRPPONNMMLKKJJ~I|IzHxHwHuGsGrFqFoEnEmElEkEkEjEjEiEiEiEiEiEjFjFkFlGlGmHnIpIrKtLvMwNyO{P}QSTVWYZ\^`acegiknprtvx{}緁繄軆轈љњǬȰȰέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmlkkjjiihhg[[ZYXWVVUTSSRQPOONNMLLKKJJ~I|IzIyHwHvGtGsGqFpFoFoFnFmFmFlFlFlFlFlFlFlGmGmGnHoHpIqJrJuLvMxNyO{P}QRTUWXZ[]_abdfhjlnpsuwy{}綀縂躄輆љњȭȰȰέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihhg\[ZYXXWVUUTSRQQPOONNMLLKKKJ~J}I{IyIxHvHuHtGsGrGqGpGpGoGoGnGnGnGnGoGoHoHpHqIqIrJsKtKwMyNzO|P}QRTUVXY[\^`bcegikmoqsuxz|~緀繃軅轇љњɮȱȱέά߆߅߄߃߂߁߀vv{zyxwvutsrqqponnmllkjjiihhgg[ZZYXWVVUTTSRQPPOONNMMLLKKJJ}J|IzIyIxHvHuHtHtHsHrGrGqHqHqHqHqHqHqIrIrIsJtJuKvLwLzN{O|P~QRSUVWYZ\]_abdfhjlnprtvx{}縁躃輅љњʮȲȲέά߆߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiihhgg[[ZYXXWVUUTSRQQPPOONNMMLLKKKJ~J}J{IzIyIwHwIvIuHuHtHtHtItItItItItJuJuJvKwLwLxMzN|O}PQRSTVWXZ[]^`bdegikmoqsuwy{~緀蹂軄轆љњ˯ȳȳέά߅߄߃߂߁߀vv{zyxwvutsrrqponnmllkjjiihhggf[ZYYXWWVUUTSRQQPPOONNMMMLLKKKK~J}J{IzIzJyIxIxIwIwIvJvJvJvJwJwKwKxLxLyMzM{N|OPQRSTVWXZ[\^`acefhjlnprtvxz|~縀躃輅љњ̰ȳȳέά߆߄߃߂߁߀wv{zyxwvutssrqpoonmllkkjiihhggg[[ZYXXWVVUTSSRQQPPOONNNMMMLLLKKK~J}J|J|J{JzJzJyJyKyKyKyKyKzLzL{M{M|N}N~OPRRSUVWXY[\^_abdfgikmoqsuwy{}蹁軃轅њњͰȴȴέά߆߅߃߂߁߀wv{zyxwvuttsrqpoonmmlkkjjiihhggf[ZZYXWWVVUTSSRRQQPPOONNNMMMLLLLKKK~K~K}K}K|K|L|L|L|L|M|M}M}N~NOPPQSTUVWXY[\]_`bcegijlnprtvxz|~蹀躂輄њњαȵȵέά߆߅߄߂߁߀wv{zyxwvuutsrqpponmmllkjjiihhggg\[ZYYXWWVUTTSSRRQQPPOOONNNNMMMMLLLLLLLMMMMMNNOOPPQRRTUVWXYZ\]^`acefhjkmoqsuwy{}躁較轅њћϲȵȵέά߆߅߄߃߂߀wv{zyxwwvutsrqqponnmllkkjiihhhggf[[ZYYXWWVUTTSSRRQQQPPPOOONNNNNMMMMMMNNNNNOOOPPQQRSTUVWXYZ\]^`acdfgikmnprtvxz|~蹀軂轄њћвȶȶέά߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjjiihhgggf[ZZYXXWWVUTTTSSRRQQQPPPPOOOOONNNNNNOOOOOPPPQQRSSTUVWXY[\]^`abdegijlnoqsuwy{}躁較њћѳǷȷέά߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjjiihhhgggf[ZZYXXWVVUUTTSSSRRRQQQPPPPPPOOOOPPPPPPQQQRRSSTUUVXYZ[\]^`abdeghjkmoqrtvxz|~躀輂њћέά߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkkjjiihhhggg\[ZZYYXWVVUUUTTSSSRRRRQQQQQQPPQQQQQQQQRRSSSTUUVWWYZ[\]^`abdefhikmnprtuwy{}軁轃њћͭά߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllkkjjiihhhggg\[ZZYYXWWVVUUUTTTSSSRRRRRRRQQRRRRRRRSSSTTUUVVWXYZ[\]_`abcefhiklnpqsuvxz|~軀轂њћͭέ߆߅߄߃߂߁xw}|{zyxwvvutsrrqpoonmmllkkjjiiihhhggg\[[ZYYXXWWWVVVUUUUTTTTTTTSSTTTTTUUUUVVWWXXYZ[[]^_`abdefgijlmoprtuwy{|~輀њћͭέ߅߄߃߂߁xx}|{zyyxwvutssrqpponnmmllkkjjiiihhhhgg\[[ZYYYXXWWWWVVVVUUUUUUUTTUUUUVVVVWWXXYYZZ[\]__`bcdefgijlmoprsuwxz|}ћћͭέ߆߅߄߃߂yx~}|{zyxwvuutsrrqpponnmmllkkjjiiihhhhhg\\[ZZYYYXXXWWWWWVVVVVVVUUVVVWWWWXXXYYZ[[\]]^`abcdefhijlmnpqsuvxy{}ћќͭέ߆߅߄߃߂yx~}|{zyxxwvutssrqqpoonnmlllkkjjjiiihhhhh\\[[ZZZYYYXXXXXWWWWWWWVVWWXXXXYYYZZ[[\]]^_`abcdeghijlmnpqstvwy{|~ћќͮέ߅߄߃߂yx~}||{zyxwvuutsrrqppoonmmlllkkjjjiiiiihhh\\\[[ZZZZYYYYYXXXXXXXWWXYYYYZZZ[[\\]]^__`acdefghijlmnpqstvwyz|~ћќͮͭ߆߅߄߃yy~}|{zyxxwvuttsrrqppoonmmlllkkkjjjiiiiii]]\\\[[[[ZZZZZZYYYYYYXXZZZZ[[[\\\]]^__`aabdefghiklmnpqrtuwxz{}ћќͮͭ߆߅߄߃zy~}||{zyxwvvutssrqqpponnmmmllkkkjjjjjiiiii]]]\\\\[[[[[[[ZZZ[[YY[[[\\\\]]^^__`aabcdffgijklmnpqrtuwxz{ћќͮͭ߆߅߄zy߀~}|{zyyxwvuutssrqqppoonnmmlllkkkkjjjjjjjj^^]]]]\\\\\\\\\\\\ZZ\\]]]]^^__``aabcddeghijklmopqrtuwxy{ќќͮͭ߆߅߄zz߀~}}|{zyxwwvuttsrrqqppoonnmmmlllkkkkkjjjjjj_^^^^]]]]]]]]]]]][[]^^^^__```abbccdefghijklnopqrtuvxy{ќѝͮͮ߆߅{z߁߀~}|{zzyxwvvuttsrrqqppoonnmmmlllllkkkkkkkkkk____^^^^^^^^^^^\\____```aabbcddeffghjklmnopqstuvxyќѝͮͮ߆߅{{߁߀~~}|{zyyxwvvuttsrrqqppoonnnmmmmlllllllkllllll``___________]]```aaabbccddeffghiiklmnoprstuwxќѝͯͮ߆|{߂߁߀~}||{zyxxwvvuttsrrqqpppoonnnmmmmmllllllllllmmma````````aa^_abbbbccddeeffghhijkmnopqrstuwѝНͯͮ߆|{߂߁߁߀~}|{{zyxxwvvuttssrrqqppooonnnnmmmmmmmmmmmmmmnnnbbbbbbbbb``ccccddeeffgghhijkllnopqrstvѝОͯͮ}|߃߂߁߀~~}|{zzyxxwvvuttssrrqqpppoooonnnnnnnnnnnnnnnnoooopccccccaadddeefffgghiijkklmnpqrstѝОͯͮ}|߄߃߂߁߀~}}|{zzyxxwvvuttssrrrqqppppooooonnnnnnnnoooooppppqqrddebbeeffggghhiijkklmnnoqr߅߆НОͯͯ}}߄߃߂߁߁߀~}}|{zzyxxwvvuuttssrrrqqqppppooooooooooooppppqqqrrrsstpcggghhhiijjkklmnnop߂߄߅߆ООͯͯ~}߅߄߃߂߁߀߀~}||{zzyxxwvvuuttsssrrrqqqqppppppppppppppqqqqrrrsssttqqvvwxxyzz{||}~߀߀߁߂߃߅߆ОПͰͯ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvuuttsssrrrrqqqqqqqqqqqqqqqqrrrrsssttuuqqwwxxyzz{||}~߀߁߂߃߄߅߆ОПͰͯ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvuuutttsssrrrrrrrqqqqrrrrrrrssstttuuuvrrwxyyzz{||}~߀߁߂߃߄߄߆ОПͰͯ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwvvuuutttssssssrrrrrrrrsssssstttuuuvvwrsxyyz{{|}}~߀߁߂߃߃߄߅ПРͰͯր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwwvvvuuutttttsssssssssstttttuuuvvvwwxssyzz{{|}}~߀߁߂߂߃߄߅߆ПРͰͰրր߆߅߄߃߂߂߁߀~}}||{zzyyxxxwwvvvuuuuuttttttttttttuuuuuvvvwwxxxttzz{||}}~߀߁߂߂߃߄߅߆ПРͱͰցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyxxxwwwvvvvuuuuuuuuuuuuuuvvvvwwwxxxyytu{{||}~~߀߀߁߂߂߃߄߅߆߆РРͱͰւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyyyxxxwwwwvvvvvvvvvvvvvvwwwwxxxyyyzzuu||}}~~߀߀߁߂߃߃߄߅߆߆РСͱͰւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zzyyyxxxxwwwwwwwwwwwwwwwwxxxxyyyzz{{vv}}~~߀߁߁߂߃߃߄߅߆߆РСͱͱփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{{zzzyyyyxxxxxxxxxxxxxxxxyyyyzzz{{||vw}~~߀߀߁߁߂߃߄߄߅߆߆ССͱͱՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||{{{zzzzyyyyyyyyyyyyyyyyzzzz{{{|||}ww~߀߁߁߂߂߃߄߄߅߆СТͲͱՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}|||{{{{zzzzzzzzzzzzzzzz{{{{|||}}}~xx߀߀߁߁߂߃߃߄߅߅߆СТͲͱՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}}}||||{{{{{{{{{{{{{{{{||||}}}~~~xy߀߁߁߂߂߃߄߄߅߅߆ТϣͲͱՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~~~}}}}||||||||||||||||}}}}~~~߀yy߁߂߂߃߃߄߄߅߆߆ТϣͲՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀~~~~}}}}}}}}}}}}}}}}~~~~߀߀߀߁zz߂߃߃߄߄߅߅߆ϣϣͲՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀߀~~~~~~~~~~~~~~~~߀߀߀߁߁߁߂{{߃߄߄߅߅߆߆ϣϤͲՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁߀߀߀߀߀߀߀߀߁߁߁߂߂߂߃{|߄߅߅߆߆ϣϤՈՇ߆߆߅߅߄߄߃߃߃߂߂߂߁߁߁߁߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߁߁߁߁߂߂߂߃߃߃߄||߅߆߆ϤϤԈՈ߆߆߅߅߄߄߄߃߃߃߂߂߂߂߂߁߁߁߁߁߁߁߁߁߁߁߁߁߁߂߂߂߂߂߃߃߃߄߄߄߅}}߆ϤϥԉԈ߆߆߅߅߅߄߄߄߄߃߃߃߃߂߂߂߂߂߂߂߂߂߂߂߂߂߂߃߃߃߃߄߄߄߄߅߅߅߆~~ϤϥԊԉ߆߆߅߅߅߅߄߄߄߄߄߃߃߃߃߃߃߃߃߃߃߃߃߄߄߄߄߄߅߅߅߅߆߆~ϥϦԊԊ߆߆߆߅߅߅߅߅߅߄߄߄߄߄߄߄߄߄߄߅߅߅߅߅߅߆߆߆րϥϦԋԋ߆߆߆߆߆߆߅߅߅߅߅߅߅߅߆߆߆߆߆߆րրϦϦԌԋ߆߆߆߆߆߆ցցϦϧԍԌււϧϧͲͲͱͰͰͯͯͮͮέάάΫΫΪΩΨϧϧϦϥϥϤϤϣТТСРРПООѝќќћњњљјҘҗҖҖҕҕҔғӓӒӑӑӐӐӏӎԍԍԌԌԋԋԊԊԉԉԈՈՇՇՇՆՆՅՅՅՄՄՄՃՃփւււււցցցցցցցրրրրրրրրրրցցցցցցցւււււփՃՃՄՄՄՅՅՅՆՆՇՇՇՈԈԉԉԊԊԋԋԌԍԍӎӎӏӐӐӑӑӒӓғҔҕҕҖҖҗҘјљњњћќќѝООПРРСТТϣϤϤϥϥϦϧΨΩΩΪΫΫάάέͮͮͯͯͰͰͱͲͲͲͱͱͰͰͯͮͮͭέάάΫΪΩΨΨϧϧϦϥϥϤϣϣТССРППОНѝќќћњњљјҘҗҖҖҕҕҔғӓӒӒӑӐӐӏӎӎԍԌԌԋԋԊԊԊԉԉՈՈՇՇՇՆՆՅՅՅՄՄՄՄՃՃփփւււււււցցցցցցցցցցցցւււււււփփՃՃՄՄՄՄՅՅՅՆՆՇՇՇՈՈԉԉԊԊԊԋԋԌԌӎӎӏӏӐӐӑӒӒӓғҔҕҕҖҖҗҘјљњњћќќѝНОППРССТϣϣϤϥϥϦϧΨΨΩΪΪΫάάέͭͮͮͯͰͰͱͱͲӏӏՅՅΨΩӐӐՆՆΩΩӑӐՇՇΩΪӒӑՈՈΪΪӒӒԈԉΪΫғӓԉԊΪΫҔғԊԊΫΫҕҔԋԋΫάҕҕԌԌάάҖҖԍԍάέҗҖӎӎέͭҗҗӎӏͭͮјҘӏӐͮͮљљӐӐͮͮњљӑӑͮͯњњӒӒͯͯћћғғͯͰќќҔҔͰͰѝќҕҕͰͱНѝҕҖͱͱООҖҗͱͱППҗҗͱͲРПјјͲРРљљССњњТСћћϣТћќϣϣќќϤϤѝНϥϤООϥϥППϦϦРРϧϦРСϧϧССΨΨТТΩΩϣϣΪΩϤϤΪΪϤϥΫΫϥϥάΫϦϦέέϧϨͭͭΨΨͮͮΩΩͯͮΪΪͯͯΪΪͰͰΫΫͱͰάάͱͱάέͲͱͭͭͲͮͮͯͯͯͯͰͰͰͱͱͱͲͲ \ No newline at end of file diff --git a/tools/vlm_oracle/radar_512x384.ppm b/tools/vlm_oracle/radar_512x384.ppm new file mode 100644 index 000000000..eee57ad3f --- /dev/null +++ b/tools/vlm_oracle/radar_512x384.ppm @@ -0,0 +1,4 @@ +P6 +512 384 +255 +ѳǷȷвȶȶϲȵȵαȵȵͰȴȴ̰ȳȳ˯ȳȳʮȲȲɮȱȱȭȰȰǬȰȰƬȯǯūȮȮĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿſſĿĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿſſĿĿľþþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿſĿĿľþþþý½½½½ȬȬ½½½¾þþþÿĿĿѳȷſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿгȷȷſſĿľþþý½½¼ݿݿȪȪݿݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݾݿݿݿݿݿ½½¾þþÿĿĿϲȶȶſĿĿľþý½½¼ݿݿݿݾݾݾȩȩܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽܽݾݾݾݾݾݿݿݿ½½¾þÿĿĿβȵȶſĿľþþý½¼ݿݿݿݾݾݾݾܽܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þþÿĿαȵȵſĿľþý½½ݿݿݿݾݾݾܼܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿͱȴȵſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽܺȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ̰ȴȴſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿݿ½¾þÿĿ˰ȳȴſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݾݿݿ½¾ÿĿʯȳȳſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ڷȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽܽݾݾݿݿ¾þÿĿʯȲȲſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶڵȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܼܽܽݾݾݿݿ½¾þĿɮDZDzſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܻܼܺܽܽݾݾݿݿ¾þÿĿȮȱȱſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ۺܻܻܼܺܽܽݾݾݿ½¾ÿĿǭǰȱſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹۹ۺܻܼܼܺܽܽݾݿݿ¾þÿƬȰȰſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱٰȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۸۹۹ۺܻܻܼܼܽݾݾݿ½¾ÿĿŬǯǰſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذددȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿ½¾ÿĿīȯȯſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿ¾ÿīȮȮſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخحح׭ȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݿ¾êȭȮſľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬׫ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲٳڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽݾݿªȭȭͳȷȶȶȵȵȴȴȳȳȲȲȱȱȰǯȯȮȮȭȭȬȬȫȪȪȩȩȨȨȧȧȦȦȥȥȤȤȣȣȢȢȡȡȠȠȠȟȟȞȞȞȝȝȝȝȜȜȜțțțțțțȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚȚțțțțțțȜȜȜȝȝȝȝȞȞȞȟȟȠȠȠȡȡȢȢȣȣȤȤȥȥȦȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯǯȰȱȱȲȲȳȳȴȴȵȵȶȶȷͳȷȷȶȶȵǴȴȳȳȲȲȱȱȰȰȯȮȮȭȭȬȬȫȪȪȩȩȨȨȧȧȦȥȥȤȤȣȣȢȢȡȡȡȠȠȟȟȞȞȞȝȝȝȜȜȜțțțțȚȚȚȚȚșșșșșșșșșșșșșșșșșȚȚȚȚȚțțțțȜȜȜȝȝȝȞȞȞȟȟȠȠȡȡȡȢȢȣȣȤȤȥȥȦȧȧȨȨȩȩȪȪȫȬȬȭȭȮȮȯȰȰȱȱȲȲȳȳȴǴȵȶȶȷȷſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֧֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدذٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܼܺܽݾݾݿȫȬ¾þÿſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨զȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܻܼܺܽݾݾȪȫ¾þÿͳȷſľþý¼ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭حخددذٱٲٲٳڴڵڵڶ۷۸۹۹ܻܼܺܽݾȪȪ¾þÿȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۺܻܼܽȩȪݿ¾ÿĿȷȶſſľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴٳٳٲٱٰددخح׬׬׫תת֧֧֦֩֩֨զեեդդգգԣԢȖȕԡԡԡԡԡԠԠԠԠԠԠԠԠԠԠԠԡԡԡԡԡԢԢԢԣգգդդեեզ֦֧֧֨֩֩תת׫׬׬حخددٰٱٲٳٳڴڵڶڷ۸۸۹ۺܻܼȩȩݾݿ¾ÿĿѳȶǶſľý¼ݿݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩ת׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷۸۹ۺܻȨȩݾݿݿ¾ÿѲȶȶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨֩תת׫׬׭حخدذٱٱٲٳڴڵڶڷ۷۸۹ۺȧȨܽݾݿ¾ÿвȶȵſľý½¼ݿݾܼܻܽܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ۷۸۹ȧȧܼܽݾݿ½¾ÿвȵȵſſľý¼ݿݾܼܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞӝȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֧֨֩תת׫׬حخددٰٱٲٳڴڵڶڷ۸۸Ȧȧܻܼܽݾݿ¾ÿĿϱȵȴſľý¼ݿݾܼܻܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜҜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդեզ֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵڶڷ۸ȦȦܻܼܺܽݾݿ¾ÿϱȵȴſľý¼ݿݾݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜққȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵڶڷȥȦۺܻܼܽݾݾݿ¾ÿαȴȴſľľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣգդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵڶȤȥ۹ۺܻܼܽݾݿ¾þĿΰȴȳſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљјȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡԢգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڵȤȥ۸۹ۺܻܼܽݾݿ¾ÿͰȴȳſľý¼ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјїȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԡԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڴȣȤ۷۸۹ܻܼܺܽݾݿ¾ÿͰȳȳƿſľý¼ݿݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїііȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԠԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڴȣȣڷ۸۹ۺܻܼܽݾݿ¾ÿĿ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞӟԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲٳȢȣڶ۷۸۹ۺܻܼܽݾݿ¾ÿ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӞӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲȢȢڵڶ۷۸۹ܻܼܺܽݾݿ¾ÿ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГВȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӝӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱȡȢڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒϑȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқҜӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡȡڴڵڶ۷۸۹ۺܻܼܽݾݿ¾ÿˮȲȱſľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱذدخ׭׫ת֧֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙљјїііЕДДГГϒϒϑϑϐȉȈϏΏΏΎΎΎΎΎΎΎ΍ΎΎΎΎΎΎΎΏΏϏϏϐϐϑϑϒϒГГДДЕііїјљҙҚқҜӝӞӟӟԠԡԢգդե֦֧֨֩ת׫׭خدذȠȡٳڴڵڶ۷۸۹ܻܼܽݾݿ¾ÿʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٳٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐϏȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙҚқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠȠٲٳڴڶڷ۸۹ۺܻܼܽݾݿ¾ÿͲͲʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠٲٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͱͱɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٱٲٳڴڵڷ۸۹ۺܻܼܽݾݿ¾ÿͰͱɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٱٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііїјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰٱٳڴڵڶ۷۸۹ܻܺܽݾݿ¾ÿͰͰɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕііїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٱٲٳڴڵڶ۸۹ۺܻܼܽݾݿÿĿͯͯȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱذدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕііїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذٱٲڴڵڶ۷۸۹ܼܺܽݾݿ¾ÿͯͯȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДЕііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٱٲٳڴڵڷ۸۹ۺܻܼܽݾ¾ÿͲͮͮȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذدخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊̈ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГДЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذٱٲڴڵڶ۷۸۹ܻܼܽݾݿ¾ÿͲͱͭͭǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈̇ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒГДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٱٲٳڴڵڷ۸۹ۺܻܼݾݿ¾ÿͱͱάέǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰدخ׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇̆Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑВГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰٱٳڴڵڶ۷۹ۺܻܼܽݾݿ¾ÿͱͰάάǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذدح׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٱٲٳڵڶ۷۸۹ܼܺܽݾݿ¾ÿͰͰΫΫƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׭׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅˅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰٲٳڴڵڶ۸۹ۺܻܼܽݿ¾ÿͯͯΪΪƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׬׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٱٲڴڵڶ۷۸ۺܻܼܽݾݿ¾ÿͯͮΪΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϏϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱٲٳڴڶڷ۸۹ܼܺܽݾݿ¾ÿͮͮΩΩūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃˂~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎΏϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿͭͭΨΨŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬׫ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ΎϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿέέϧϨŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲٳڴڶ۷۸۹ܻܼܽݾݿÿάάϧϧƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁʀ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋Ό΍ΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲٳڴڵڷ۸۹ܻܺܽݾݿ¾ÿάΫϦϦſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋Ό΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܼݾݿ¾ÿΫΫϥϥſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֩֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋Ό΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڵڶ۷۸ۺܻܼܽݾ¾ÿΪΪϤϥſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֩֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ~||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊͋Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڴڶ۷۸۹ܻܼܽݾݿ¾ÿΪΩϤϤſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧֩զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~~~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊͋Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڵڷ۸۹ܼܺܽݾݿÿΩΩϣϣƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵڶ۸۹ۺܻܽݾݿ¾ĿΨΨТТƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩֨զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~}}{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͊͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶ۷۹ۺܻܼݾݿ¾ÿϧϧССſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩֨եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}|}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶ۷۹ۺܻܼܽݿ¾ÿϧϦРСſľéȬȫݾܼܻܽۺ۸۷ڶڵٳٲٱدخح׬ת֦֩֨եդԢԡԠӟӝҜқҚјїіЕГВϑϐΏΎ΍Ό͉͈͊̇̇̆˅˄˃˂ʁʁʀ~~~}}}}||z{zz{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{||||}}}}~~~ʀʁʁ˂˃˄˅͈͉̆̇̇͊Ό΍ΎΏϐϑВГЕіїјҚқҜӝӟԠԡԢȗȘ֦֨֩ת׬حخدٱٲٳڵڶ۷۸ۺܻܼܽݾ¾ÿ߱ϦϦРРſľéȬȫݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخ׭׫ת֧֦֩եդԢԡԠӞӝҜқҙјїіДГϒϑϐΏ΍Ό͉͈͋͊̇̆̅˄˄˃˂ʁʀʀ~~~}~}}|}||zzzzz{zzzzzzyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzz{{{{||||}}}}~~~~ʀʀʁ˂˃˄˄͈͉̅̆̇͊͋Ό΍ΏϐϑϒГДіїјҙқҜӝӞԠԡԢȗȘ֦֧֩ת׫׭خدٱٲٳڴڶ۷۸۹ܻܼܽݾݿ¾ÿ߱߱ϥϥППſľéȬȫݿݾܼܻܽ۹۸ڷڶڴٳٲٰدخ׭׫ת֧֩զեգԢԡӟӞӝҜҚҙјїЕДГϒϑϏΎ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~~}}|}||{|zzzzzzzzzzyyyyyyyyyyxyxxxxxxxyyyyyyyyyyyzzzzzz{{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋Ό΍ΎϏϑϒГДЕїјҙҚҜӝӞӟԡԢȗȘզ֧֩ת׫׭خدٰٲٳڴڶڷ۸۹ܻܼܽݾݿ¾ÿ߰߰߰ϥϤООſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֧֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||{{zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܼܺܽݾݿ¾ÿ߯߯߰߰߰ϤϤѝНſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֧֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||{{zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܼܺܽݾݿÿޮ߮߯߯߯߰ϣϣќќſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֧֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|{{zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͈͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܽݾݿÿޭޭ߮߮߮߯߯ϣТћќſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͈͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽݾݿÿެޭޭ߭߮߮߮߯ТСћћſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿޫެެެ߭߭߭߮߮ССњњſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿݪޫޫެެ߬߭߭߭߮РРљљſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{{{yyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋Ό΍ΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿݪݪުޫޫެެ߬߬߭߭߭РПјјͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~}~}}||||{{{{yzyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzz{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݿݩݩݪުުޫޫޫ߬߬߬߭߭ППҗҗͱͲſĽ©Ȭȫݿݾܻܽۺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨եդԣԡԠӟӞӜқҚјїіЕГВϑϐΏΎΌ͉͋͊̈̇̆̅˄˃˂˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyxyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋ΌΎΏϐϑВГЕіїјҚқӜӞӟԠԡȖȗե֧֨֩׫׬حدذٱٳڴڵڶ۸۹ۺܻܽݾݨݨݩݩުުުޫޫ߫߬߬߬߬ООҖҗͱͱſľéȬȫݿݾܻܽܺ۹۸ڶڵڴٳٱذدح׬׫֧֩֨զդգԢԠӟӞӜқҚјїіЕДВϑϐΏΎ΍͉͈͋͊̇̆̅˄˃˃˂ʁʀ~}~}}|}||{{{{yzyyyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{|||}}}}~~ʀʁ˂˃˃˄͈͉̅̆̇͊͋΍ΎΏϐϑВДЕіїјҚқӜӞӟԠԢȖȗզ֧֨֩׫׬حدذٱٳڴڵڶ۸۹ܻܺܽܧݨݨݨݩީުުުޫ߫߫߫߬߬НѝҕҖͱͱſľéȬȫݿݾܻܽܺ۹۸ڷڵڴٳٱذدح׬׫ת֧֨զդգԢԠӟӞӜқҚљїіЕДВϑϐϏΎ΍Ό͉͈͊̇̆̅˄˄˃˂ʁʀ~~~}}|}||{|{{zzyyzzyyyyyyxyxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~ʀʁ˂˃˄˄͈͉̅̆̇͊Ό΍ΎϏϐϑВДЕіїљҚқӜӞӟԠԢȖȗզ֧֨ת׫׬حدذٱٳڴڵڷ۸۹ܻܺܦܧݧݨݨݨީީުުުߪ߫߫߫߫ѝќҕҕͰͱſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٱٰدخ׬׫ת֧֨զդգԢԠӟӞӝқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʀʀ~~~}~}}||||{{zzyzzzyzyyyyyyxxxxxxxxxxxxxxxxxxxxxxyyyyyyyzzzzz{{{{||||}}}~~~~ʀʀʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӝӞӟԠԢȖȗզ֧֨ת׫׬خدٰٱٳڴڵڷ۸۹ܺܥܦܦݧݧݨݨިީީީުߪߪߪ߫߫ќќҔҔͰͰſľéȬȫݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׬׫ת֧֨զեգԢԡӟӞӝқҚҙјіЕДГϒϐϏΎ΍Ό͉͋͊̈̇̆˅˄˃˂ʁʁʀ~~}~}}|}||{{zzzzzzzzyyyyyyyyxyxxxxxxxxxxxxxxxyyyyyyyyyzzzzz{{{{{|||}}}}~~~ʀʁʁ˂˃˄˅͉̆̇̈͊͋Ό΍ΎϏϐϒГДЕіјҙҚқӝӞӟԡԢȖȗզ֧֨ת׫׬خدٰٲٳڴڵڷ۸۹ܥܥܦܦݧݧݧݨިިީީީߪߪߪߪߪћћғғͯͰſľéȬȫݿݾܼܻܽ۹۸ڷڶڴٳٲٰدخ׭׫ת֧֩զեգԢԡӟӞӝҜҚҙјїЕДГϒϑϏΎ΍Ό͉͋͊̈̇̆̅˄˃˂˂ʁʀ~~~}}|}||{|zzzzzzzzzzyyyyyyyyyyxyxxxxxxxyyyyyyyyyyyzzzzzz{{{{{||||}}}~~~ʀʁ˂˂˃˄͉̅̆̇̈͊͋Ό΍ΎϏϑϒГДЕїјҙҚҜӝӞӟԡԢȗȘզ֧֩ת׫׭خدٰٲٳڴڶڷ۸۹ܤܥܥܦݦݧݧݧިިިީީߩߩߩߪߪߪњњӒӒͯͯͳȷȶȶȵȴȴȳDzȲȱȰȯǯȮȭȬȫȪȪȩȨȧȦȥȤȤȣȢȡȠȟȞȝȜțȚșșȘȗȖȕȔȓȒȑȐȏȏȎȍȌȋȊȉȉȈȇȆȅȅȄȃȃȂȁȁȀ~~~}~}}|}||||{{{{{{zzzzzzyzyyyyyyyyyyxyxxxxxxxxxxxyyyyyyyyyyyyzzzzzzz{{{{{{|||||}}}}~~~~ȀȁȁȂȃȃȄȅȅȆȇȈȉȉȊȋȌȍȎȏȏȐȑȒȓȔȕȖȗȘșșȚțȜȝȞȟȠȡȢȣȤȤȥ͕͖͖͖͗͗͗͗͘͘ΘΘΘΘΘΙΙΙΙΙϙϙϙϦϥϥϤϤϣϣТТССРРППООѝѝќќћћњњљљјҘҘҗҗҖҖҖҕҕҔҔҔғғғӒӒӒӒӑӑӑӑӐӐӐӐӐӐӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӏӐӐӐӐӐӐӑӑӑӑӒӒӒӒғғғҔҔҔҕҕҖҖҖҗҗҘҘјљљњњћћќќѝѝООППРРССТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͭͮͮͯͯͰͰͱͱͲͳȷȷȶȵȴȴȳȲȲȱȰȯȯȮȭȬȫȫȪȩȨȧȦȥȥȤȣȢȡȠȟȞȝȜțțȚșȘȗȖȕȔȓȒȑȐȐȏȎȍȌȋȊȊȉȈȇȆȆȅȄȄȃȂȁȁȀȀ~~~}}}}|}||{|{{{{z{zzzzzzyzyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyzzzzzzzz{{{{{{||||}}}}}~~~ȀȀȁȁȂȃȄȄȅȆȆȇȈȉȊȊȋȌȍȎȏȐȐȑȒȓȔȕȖȗȘșȚțțȜȝȞȟȠȡȢȣȤȥ͕͕͕͖͖͖͗͗͗͗ΗΘΘΘΘΘΘΘΘΘϘϘϘϘϥϥϤϤϣϣТТСРРППООНѝќќћћњњљљљјҘҗҗҖҖҖҕҕҔҔҔғғӓӒӒӒӑӑӑӐӐӐӐӐӏӏӏӏӏӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӎӏӏӏӏӏӐӐӐӐӐӑӑӑӒӒӒӓғғҔҔҔҕҕҖҖҖҗҗҘјљљљњњћћќќѝНООППРРСТТϣϣϤϤϥϥϦϦϧϨΨΩΩΪΪΫΫάάέͭͮͮͯͰͰͱͱͲͲſľéȬȬݿܼܻܽۺ۹۷ڶڵڴٲٱذخح׬׫֧֩֨եդԣԡԠӟӞӜқҚљїіЕДГϑϐϏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~}}|}{{z{{{{{z{zzzzzzzzyzyzyyyyyyyzyzzzzzzzzzz{{{{{|||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎϏϐϑГДЕіїљҚқӜӞӟԠԡԣȗȘ֧֨֩׫׬حخذٱٲڴڵڶۣۣۢܤܤܥܥݥݦݦݦާާާާިߨߨߨߨߨߨјҘӏӐͮͮƿľéȬȬݿݾܼܻۺ۹۷ڶڵڴٲٱذدح׬׫֧֩֨զդգԢԠӟӞӝқҚҙјіЕДГϒϑϏΎ΍Ό͉͈͋͊̇̆̅˅˄˃˂ʁʁʀ~~~}~}}{{{{||{{{{{{z{zzzzzzzzzzzzzzzzzzzzzzz{{{{{{{|||||}}}}~~~~ʀʁʁ˂˃˄˅͈͉̅̆̇͊͋Ό΍ΎϏϑϒГДЕіјҙҚқӝӞӟԠԢգȗȘ֧֨֩׫׬حدذٱٲڴڵڶۣۣۢܤܤܤܥݥݦݦݦަާާާާߧߨߨߨߨߨҗҗӎ޲z轅轅輅輅輅輅輆輆輆輇輇輇輈轈轉轊辊辋ͭͮƿžĩȭȬݿݾܻܽۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡӟӞӝҜҚҙјїіДГϒϑϐΏΎ΍Ό͉͋͊̈̇̆̅˄˃˃˂ʁʀʀ~~~}~{{{{||||{|{{{{{{z{zzzzzzzzzzzzzzz{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˃˃˄͉̅̆̇̈͊͋Ό΍ΎΏϐϑϒГДіїјҙҚҜӝӞӟԡԢգȗȘ֧֨ת׫׬خدذٱٳڴڵۣۡۢۢܣܤܤݥݥݥݦݦަަާާާߧߧߧߧߧߧҗҖ轃輂輂ޱxްx軂躂躃躃躃躃纃纄纄纄纅纅纆纆织织终缉缉罊轋辌έͭſĪȭȬݿݾܼܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֧֩զեդԢԡԠӟӝҜқҚјїіЕДВϑϐϏΎ΍Ό͉͋͊̈̇̆̅˅˄˃˂˂ʁʀʀ~~~||{{|}||||{|{{{{{{{{{{{{{{{{{{{{{{{{{{{||||||}}}}~~~~ʀʀʁ˂˂˃˄˅͉̅̆̇̈͊͋Ό΍ΎϏϐϑВДЕіїјҚқҜӝӟԠԡԢդȘș֧֩ת׫׭خدٰٲٳڴڡۡۢۢܣܣܤܤݤݥݥݥݦަަަަާߧߧߧߧߧߧҖҖ輁輁軁軀躀躀߯v߯v踀踀縀縀緁緁緁緁緂緂緂縃縃縄縄繅繆纆纇终绉缊缋罌羍άέſĪȭȬݿݾܼܻܽ۹۸۷ڶڴٳٲٱدخح׬ת֦֩֨եդԣԡԠӟӞӜқҚљјіЕДГϒϑϐΎ΍Ό͉͉͋͊̈̇̆̅˄˃˃˂ʁʁʀʀ~||||}}}}|}||||||{|{{{{{{{{{{{{{{{||||||||}}}}}}~~~~ʀʀʁʁ˂˃˃˄͉͉̅̆̇̈͊͋Ό΍ΎϐϑϒГДЕіјљҚқӜӞӟԠԡԣդȘș֨֩ת׬حخدٱٲٳڠۡۡۢۢܣܣܣܤݤݤݥݥݥަަަަަߦߦߦߦߧߧҕҕ轀~~~~߭t߭t~~~~~~絀絀絁絁綂綂綃緃緄縅縆繇纇纈绉缊缋罌美άάſĪȭȬݾܼܻܽۺ۸۷ڶڵڴٲٱذخح׬׫֧֩֨զդգԢԠӟӞӝҜҚҙјїіДГВϑϐϏΎ΍Ό͉͋͊̈̇̆̆˅˄˃˃˂ʁʁʀʀ|}||}~}}}}|}|}|||||||||||||||||||||||}|}}}}}}~~~~ʀʀʁʁ˂˃˃˄˅͉̆̆̇̈͊͋Ό΍ΎϏϐϑВГДіїјҙҚҜӝӞӟԠԢգդȘș֨֩׫׬حخذٱٲڴڠۡۡۢۢܢܣܣܤݤݤݥݥޥޥޥަަަߦߦߦߦߦߦҕҔ~~~}}}|||||߫s߫s|||||||}}}~~紀紁紁終絃綃緄緅縆繇纈纉绊缋罍美ΫάſĪȭȬݿݾܼܻۺ۹۸ڶڵڴٳٱذدخ׬׫ת֧֨զեգԢԡԠӞӝҜқҚјїіЕДГϒϐϏΎ΍Ό͉͋͊͊̈̇̆̅˅˄˃˂˂ʁʁʀʀ}}|}~~~~}~}}}}}}|}|}|}|||||||}|}|}}}}}}}}~~~~~~ʀʀʁʁ˂˂˃˄˅͉̅̆̇̈͊͊͋Ό΍ΎϏϐϒГДЕіїјҚқҜӝӞԠԡԢգեȘș֨ת׫׬خدذٱٳڠڠۡۡۢۢܢܣܣܣݤݤݤݥޥޥޥޥޥߥߦߦߦߦߦߦҔғ}}|||{{{zzzzzߩqߩqyyyzzzzz{{{||}~~糀糀紁紂絃綄緅緆縇繈纉绊缌罍美ΫΫƿŪȭȭݿݾܻܽܺ۹۸ڷڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԡԠӟӞӜқҚҙјїЕДГϒϑϐϏΎ΍Ό͉͈͋͊̇̇̆̅˄˄˃˂˂ʁʁʀ}}}}~~~~~~}~}~}}}}}}}}}}}}}}}}}}}~}~~~~~~~ʀʀʁʁ˂˂˃˄˄͈͉̅̆̇̇͊͋Ό΍ΎϏϐϑϒГДЕїјҙҚқӜӞӟԠԡԣդեșȚ֩ת׫׭خدٰٲڟڠڠۡۡۡܢܢܣܣܣݤݤݤݤޥޥޥޥޥߥߥߥߥߥߥߥғӓ||{{zzzyyxxxxxwߧoߧowwwwwxxxxyyzz{{|}}~粀糁糂紃組綅緆縇繈纊绋缌罍羏ΪΫŪȭȭݿݾܼܻܽ۹۸۷ڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡӟӞӝҜқҙјїіЕДГϒϐϏΎ΍΍Ό͉͋͊̈̇̇̆̅˄˄˃˂˂ʁʁ}~}~ʀ~~~~~~~~}~}~}~}~}~}~}~~~~~~~~~ʀʀʀʁʁ˂˂˃˄˄͉̅̆̇̇̈͊͋Ό΍΍ΎϏϐϒГДЕіїјҙқҜӝӞӟԡԢգդզșȚ֩׫׬حخذٱٲڟڠ۠۠ۡۡܢܢܢܣݣݣݤݤݤޤޤޥޥޥߥߥߥߥߥߥߥӒӒ|{zzyyxxwwwvvvvuuߥmߥmuuuuuuuvvvwwxxyzz{|}~~沀糂紃組綅緆縇繉纊绋缍罎翐ΪΫŪȮȭ¼ݾܼܻܽۺ۸۷ڶڵڴٲٱذدح׬׫ת֧֨զեգԢԡԠӟӝӜқҚљјіЕДГϒϑϐϏΎ΍Ό͉͋͊͊̈̇̆̆̅˄˄˃˃˂˂~~~~ʀʀʀ~~~~~~~~~~~~~~ʀʀʀʁʁ˂˂˃˃˄˄͉̅̆̆̇̈͊͊͋Ό΍ΎϏϐϑϒГДЕіјљҚқӜӝӟԠԡԢգեզșȚת׫׬حدذٱڟڟڠ۠۠ۡۡܢܢܢܣݣݣݣݤݤޤޤޤޤޤߤߥߥߥߤߤߤӒӑzzyyxwwvvuuutttsssߣkߣksssssssstttuuvwwxyyz{|}~汀沂紃組綅緇縈繉纋缌罎羏ΪΪūȮȭ¼ݿݾܼܻۺ۹۸ڶڵڴٳٲٰدخ׭׫ת֦֩֨եդԣԢԠӟӞӝҜҚҙјїіЕДГϒϑϐΏΎ΍Ό͉͉͋͊̈̇̆̆̅˄˄˃˃˂~~ʁʁʀʀʀʀʀʀʀʀʁʁʁ˂˂˃˃˄˄͉͉̅̆̆̇̈͊͋Ό΍ΎΏϐϑϒГДЕіїјҙҚҜӝӞӟԠԢԣդե֦ȚȚת׫׭خدٰٲڟڟڠ۠۠ۡۡܢܢܢܣݣݣݣݣޤޤޤޤޤޤߤߤߤߤߤߤߤӑӐyxxwwvuuttsssrrrqqqiߠippppqqqqqrrssttuvvwxyz{|}~汁沂紃組綆緇縉纊绌缍羏ΩΪƫȮȭ¼ݿݾܼܽܺ۹۸ڷڶڴٳٲٱذخح׬׫֧֩֨զդգԢԡԠӞӝҜқҚљјїЕДГВϑϐϏΎ΍΍Ό͉͉͋͊̈̇̆̆̅˅˄˃˃˂ʁʁʁʁʀʀʀʀʀʀʀʀʀʀʀʁʁʁʁ˂˂˃˃˃˄˅͉͉̅̆̆̇̈͊͋Ό΍΍ΎϏϐϑВГДЕїјљҚқҜӝӞԠԡԢգդզ֧Țț׫׬حخذٱڞڟڟڠ۠۠ۡܡܢܢܢܢݣݣݣݣޣޤޤޤޤޤߤߤߤߤߤߤߣӐӐyxwwvuutssrrqqpppoooohgnnnnnnooooppqqrsstuvwxyz{|}~氀汁泂約絅綇縈繊纋缍罎羐ΩΩƫȮȮý¼ݿݾܼܻܽۺ۸۷ڶڵڴٲٱذدخ׬׫ת֧֦֩եդԣԡԠӟӞӝҜҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̅˅˄˄ȀȀ˃˂˂˂ʁʁʁʁʁʁʁʁʁʁʁʁʁ˂˂˂˃˃˃˄˄˅͉͉̅̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚҜӝӞӟԠԡԣդե֦֧Țț׫׬خدذٞڞڟڟ۠۠۠ۡܡܡܢܢݢݣݣݣݣޣޣޣޤޤޤߤߤߣߣߣߣߣӏӏxwvvutssrrqppoonnnmmmlfflllllllmmmnnnoppqrsstuvwyz{|~氀沂泃紅綆緈繉纋绌罎羐ΨΩƫǯȮý¼ݿܼܻܽۺ۹۸ڶڵڴٳٲٰدخ׭׬ת֧֩֨զդգԢԡԠӟӝӜқҚҙјїіЕДГϒϑϐϏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅˅ȀȀ˃˃˃˂˂˂˂˂˂˂˂˂˂˂˂˂˂˂˃˃˃˄˄˅͉͉̅̆̆̇̇̈͊͋ΌΌ΍ΎϏϐϑϒГДЕіїјҙҚқӜӝӟԠԡԢգդզ֧֨țȜ׬׭خدٰڞڞڟڟ۠۠۠ۡܡܡܢܢݢݢݣݣݣޣޣޣޣޣߣߣߣߣߣߣߣߣӏӎwvuttsrrqpponnmmlllkkkjddjjjjjjjjkkkllmmnoopqrstuvxyz{}~氀汁沃洄絆緇縉纋绌罎羐ΨΨǬȯȮý¼ݿݾܼܽܺ۹۸۷ڶڵٳٲٱذدح׬׫ת֧֩զեդԣԡԠӟӞӝҜқҚјїіЕДГВϑϑϐΏΎ΍ΌΌ͉͉͋͊̈̇̇̆̆̅ȁȁ˄˄˄˃˃˃˃˃˂˂˂˂˂˃˃˃˃˃˄˄˄˅͉͉̅̅̆̆̇̇̈͊͋ΌΌ΍ΎΏϐϑϑВГДЕіїјҚқҜӝӞӟԠԡԣդեզ֧֩țȜ׬حدذٝڞڞڟ۟۠۠۠ܡܡܡܢܢݢݢݢݣޣޣޣޣޣޣߣߣߣߣߣߣߢߢӎԍvutssrqpponnmmlkkjjjiihhbbhhgghhhhhiiijjkllmnopqrstuwxy{|~氁沂泄絅綇縉繊绌缎羐ϧΨǬȯȮľý¼ݿݾܼܻܽۺ۹۷ڶڵڴٳٱٰدخ׭׫ת֧֩֨զդգԢԡԠӟӞӜқҚҙјїіЕДГВϑϐϐΏΎ΍ΌΌ͉͈͋͊͊̈̇̇̆Ȃȁ̅˅˄˄˄˄˄˃˃˃˃˃˃˃˄˄˄˄˄˅͈͉̅̅̆̆̇̇̈͊͊͋ΌΌ΍ΎΏϐϐϑВГДЕіїјҙҚқӜӞӟԠԡԢգդզ֧֨֩țȜ׭خدٰڞڞڟڟ۟۠۠۠ܡܡܡܢݢݢݢݢݢޣޣޣޣޣޣߣߣߣߢߢߢߢߢԍԍutssrqpoonmmlkkjiihhgggff``feeeeeffffgghhiijklmnopqrstvwyz|}氀求泃紅綇縉繊绌缎羐ϧϨǬȯȯľý¼ݿݾܼܻۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդԣԢԠӟӞӝҜқҚҙјїіЕДГϒϑϐϐΏΎ΍ΌΌ͉͉͋͊͊̈̈̇ȂȂ̆̆̅̅˅˅˄˄˄˄˄˄˄˄˄˅˅͉͉̅̅̆̆̆̇̇̈̈͊͊͋ΌΌ΍ΎΏϐϐϑϒГДЕіїјҙҚқҜӝӞӟԠԢԣդե֦֧֩תȜȝحخذٝڞڞڟڟ۟۠۠ۡܡܡܡܢݢݢݢݢݢޢޣޣޣޣޣߢߢߢߢߢߢߢԍԌtssrqponnmlkkjiihhgffeeedd__cccccccdddeeefgghijjklnopqrtuvxy{}~毀求沃洅綇緉繊绌缎羐ϧϧȬȯȯľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٲٱذدخ׭׫ת֧֩֨զեգԢԡԠӟӞӝҜқҚљјїіЕДГϒϑϐϐΏΎ΍΍Ό͉͉͋͋͊͊̈ȃȃ͉͉̇̇̆̆̆̆̅̅̅̅̅̅̅̅̅̆̆̆̆̇̇̇̈̈͊͊͋͋Ό΍΍ΎΏϐϐϑϒГДЕіїјљҚқҜӝӞӟԠԡԢգեզ֧֨֩תȜȝخدذڞڞڞڟ۟۠۠۠ܡܡܡܡܢݢݢݢݢޢޢޢޢޢޢޢߢߢߢߢߢߢߡԌԋtsrqponnmlkjjihhgffeeddccbb]]aaaaaaaabbbccddefgghijklnoprstvwy{|~毀汁沃洅綇緉繊绌缎羐ϦϧȬȰȯſľý¼ݾܼܻܽۺ۹۸ڷڵڴٳٲٱذخح׬׫ת֧֦֩եդգԢԡԠӟӞӜқҚҙјїііЕДГϒϑϐϐϏΎ΍΍ΌΌ͉͉͋͊͊Ȅȃ͈͉͉͉̈̇̇̇̇̇̆̆̆̆̆̆̆̆̆̇̇̇̇̇̈͊͊͋ΌΌ΍΍ΎϏϐϐϑϒГДЕііїјҙҚқӜӞӟԠԡԢգդե֦֧֩ת׫ȝȞخذٝڞڞڟڟ۟۠۠۠ܡܡܡܡݢݢݢݢݢޢޢޢޢޢޢߢߢߢߢߢߡߡߡԋԋ߆߆srqpoonmlkjiihgffeddccbbaa``[[________```aabbcddefghijlmnoqrtuwyz|~毀氁沃洅綇緉繋绍罎羐ϦϦȭȰȯſľý¼ݿݾܼܽܺ۹۸۷ڶڵڴٲٱذدخ׭׬ת֧֩֨զեդԣԡԠӟӞӝӜқҚҙјїііЕДГϒϑϑϐϏΎΎ΍΍Ό͋͋͊͊ȄȄ͉͈͈͈͈͉͉̈̈̈̇̇̇̇̇̇̇̇̇̈̈̈͊͊͊͋͋Ό΍΍ΎΎϏϐϑϑϒГДЕііїјҙҚқӜӝӞӟԠԡԣդեզ֧֨֩ת׬ȝȞدذڞڞڞڟ۟۟۠۠ܠܡܡܡܡݢݢݢݢݢޢޢޢޢޢޢߢߢߢߡߡߡߡԊԊ߆߆߆߅߅srqponmlkjiihgfeedccbaa``__^^ZY]]]]]]]]]^^^__`aabcdefghiklmoprsuwxz|~毀氁沃洅綇縉繋绍罏翑ϥϦɭȰȰƿſľýݿݾܼܻܽۺ۹۸ڶڵڴٳٲٱذخح׬׫ת֧֩֨զդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϑϑϐϏΏΎ΍΍ΌΌ͋͋ȅȅ͉͉͉͉͉͈͈͈͈͉͉͉͉͉͊̈̈̈̈̈͊͊͋͋͋ΌΌ΍΍ΎΏϏϐϑϑВГДЕііїјҙҚқҜӝӞӟԠԡԢգդզ֧֨֩ת׫׬ȞȞذٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢޢޢޢޢޢޢޢߢߡߡߡߡߡߡԊԉ߆߆߅߅߅߅߄߄qponmlkjjihgfeedcbba``_^^]]]\XX[[[[[[[[[\\\]]^__`abcdefgijkmnprsuwxz|~毀氂沃洅綇縉纋绍罏ϥϦɭȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٱٰدخح׬׫ת֧֦֨եդգԢԡԠӟӞӝҜқҚҙјїііЕДГВϒϑϐϐϏΎΎ΍΍ΌΌȆȅ͉͉͉͉͉͉͉͉͉͋͊͊͊͊͊͊͊͊͊͊͋͋ΌΌΌ΍΍ΎΎϏϐϐϑϒВГДЕііїјҙҚқҜӝӞӟԠԡԢգդե֦֧֨ת׫׬حȞȟٰڞڞڞڟ۟۟۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޢޢߢߡߡߡߡߡߠߠԉԈ߆߆߅߅߅߄߄߄߄߃qponmlkjihgfeedcbaa`_^^]]\\[[ZV߆VYYYYYYYYYZZZ[[\]]^_`abcdeghiklnpqsuvxz|~毀求泄洆綈縊續缎羐ϤϥɮȱȰƿľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱذدخ׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїііЕДГГϒϑϑϐϏϏΎΎ΍΍ȆȆΌΌ͋͋͋͋͊͊͊͊͊͊͊͊͊͋͋͋͋ΌΌΌ΍΍΍ΎΎϏϏϐϑϑϒГГДЕііїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬خȟȟٝڞڞڟڟ۟۠۠۠ܠܡܡܡݡݡݢݢݢݢޢޢޢޢޢޡߡߡߡߡߡߠߠԈՈ߆߆߅߅߄߄߄߃߃߃߂ponmlkjihggfedcba``_^]]\[[ZZYYXU߃UXWWWWWWWWXXXYYZ[[\]^_`abcefgijlnoqsuwxz|~毀求泄浆緈繊绌缎羐ϤϥʮȱȰſľý¼ݿݾܻܽܺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդԣԢԡԠӟӞӝҜқҚҙјїїіЕДДГВϒϑϐϐϏϏΎΎȇȇ΍΍ΌΌΌΌΌ͋͋͋͋͋͋͋ΌΌΌΌΌ΍΍΍ΎΎΎϏϏϐϐϑϒВГДДЕіїїјҙҚқҜӝӞӟԠԡԢԣդեզ֧֨֩ת׫׬حخȟȠڞڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޢޢޡޡߡߡߡߡߠߠߠՈՇ߆߆߅߅߄߄߃߃߃߂߂߂ponmlkjihgfedcbaa`_^]]\[ZZYYXXWW߁S߁SVVUUUUUUUVVVWWXYYZ[\]^_`acdfgijlnoqsuwy{}毁汃泅絇緉繋绍罏翑ϤϤʮȱȱſľý¼ݿݾܼܻܽۺ۹۸ڷڶڴٳٲٱذدخح׬׫ת֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙјјїіЕЕДГГϒϒϑϐϐϐϏȈȈΎΎ΍΍΍΍΍΍ΌΌΌΌΌ΍΍΍΍΍΍ΎΎΎΏϏϐϐϐϑϒϒГГДЕЕіїјјҙҚқҜӝӞӟӟԠԡԢգդե֦֨֩ת׫׬حخدȠ͑ڞڞڟڟ۟۠۠۠ܡܡܡܡݡݡݡݢݢݢޢޢޢޡޡޡߡߡߡߡߠߠߠߠՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁onmlkihhgfedcba`_^]]\[ZZYXXWVVUUR~RTT~T~S}S}S}S}S}T}T}T}U~U~UVWWXYZ[\]^`abdeghjlmoqsuwy{}氁沃洅綇縉續缎羐ϣϤˮȲȱſľý¼ݿݾܼܻܽ۹۸۷ڶڵڴٳٲٱذدخ׭׫ת֧֦֩֨եդգԢԡԠӟӟӞӝҜқҚҙљјїііЕДДГГϒϒϑϑϐȉȈϏΏΏΎΎΎΎΎΎΎ΍ΎΎΎΎΎΎΎΏΏϏϏϐϐϑϑϒϒГГДДЕііїјљҙҚқҜӝӞӟӟԠԡԢգդե֦֧֨֩ת׫׭خدذȠ͑ڞڟڟ۟۠۠۠ܠܡܡܡܡݡݡݢݢݢޢޢޢޡޡޡޡߡߡߡߠߠߠߠՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀nmlkjihgfedcba`__^]\[ZYYXWWVUUTTS|P{P}R|R|R{R{RzRzRzRzRzRzRzS{S{T|T}U~U~VWXYZ[\^_`bceghjlnoqsuwy{}殀氂沄浆緈繊绌缎羑ϣϤ˯Ȳȱſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٰدخح׬׫ת֧֩֨զեդգԢԡԠӟӟӞӝҜқҚҚҙјїїіЕЕДДГГВϒϑȊȉϐϐϐϏϏϏϏΏΏΏΏΏΏΏϏϏϏϏϐϐϐϑϑϑϒВГГДДЕЕіїїјҙҚҚқҜӝӞӟӟԠԡԢգդեզ֧֨֩ת׫׬حخدٰȡ͒ڟڟ۟۠۠۠۠ܡܡܡܡݡݡݢݢݢݢޢޢޡޡޡޡߡߡߡߠߠߠߠߟՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀mlkjihgfedcba`_^]\[ZZYXWVVUTTSSR~RzOyOzQzPyPxPxPwPwPwPwPwPwQxQxQxRyRzS{T|T}U~VWXY[\]_`bceghjlnprtvxz|~毀求泅絇緉繋绍罏ϣϣ̯ȲȲſľý¼ݿݾܼܻܽۺ۹۸ڷڵڴٳٲٱٰدخح׬׫ת֧֩֨զեդգԢԡԠԠӟӞӝӜққҚҙјјїїіЕЕДДГГВȊȊϑϑϑϑϐϐϐϐϐϐϐϐϐϐϐϐϐϑϑϑϑϒϒВГГДДЕЕіїїјјҙҚққӜӝӞӟԠԠԡԢգդեզ֧֨֩ת׫׬حخدٰٱ͒͒ڟڟ۠۠۠۠ܡܡܡܡܡݡݢݢݢݢޢޢޢޡޡޡޡߡߡߠߠߠߠߟՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~mlkjihgfedcba`_^]\[ZYXWWVUTTSRR~Q|Q{PxNwMxOwOvOvNuNuNtNtNtNtOtOuOuPvPvQwQxRySzT{T}U~WXYZ[]^`aceghjlnprtvx{}氁沃洅綈縊續缎羐Тϣ̯ȳȲſľý½ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱذدخح׬׫ת֧֩֨զեդգԢԡԡԠӟӞӝӜҜқҚҚҙјјїііЕЕДДДȋȋГВϒϒϒϑϑϑϑϑϑϑϑϑϑϑϒϒϒВГГГДДДЕЕііїјјҙҚҚқҜӜӝӞӟԠԡԡԢգդեզ֧֨֩ת׫׬حخدذٱٲ͒͒ڟ۠۠۠ۡܡܡܡܡܡݡݢݢݢݢݢޢޢޢޡޡޡߡߡߡߠߠߠߠߟՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}lkjihgedcba`_^]\[ZZYXWVUTTSRR~Q|P{PzOxOuLtLuNtMsMsMrMrMrMqMqMqMrMrNrNsNtOuPuPvQxRySzT|U}VWXZ[]^`acegikmoqsuwy{}殀求泄絆緉繋绍罏Тϣ̰ȳȲſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԢԢԡԠӟӞӞӝҜққҚҙљјјїїііЕЕȌȌДГГГГГВВВВВВВВВГГГГГДДДЕЕііїїјјљҙҚққҜӝӞӞӟԠԡԢԢգդեզ֧֨֩ת׫׬׭خدذٱٲڟ͒͒۠۠۠ۡۡܡܡܡܡܢݢݢݢݢݢޢޢޢޢޡޡޡߡߡߠߠߠߠߟՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}lkjigfedcba`_^]\[ZYXWVVUTSRRQ}P|PzOyNwNvMsKrKsLrLqLpKpKoKoKoKoKoKoLoLpLpMqMrNsOtOuPvQxRyS{T|V~WXZ[]^`bcegikmoqsvxz|~毁沃洅綇縊續缎羐СТͰȳȳƿſľý¼ݿݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱذدخ׭׬׫ת֧֩֨զեդգԣԢԡԠӟӟӞӝӜҜқҚҚҙљјјїїііȍȍЕЕДДДДДДГГГГГДДДДДДЕЕЕіііїїјјљҙҚҚқҜӜӝӞӟӟԠԡԢԣգդեզ֧֨֩ת׫׬׭خدذٱٲٳڟ͓͒۠۠ۡۡܡܡܡܢܢݢݢݢݢݢޢޢޢޢޢޡޡߡߡߡߠߠߠߟߟՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||kjigfedcba`_^]\[ZYXWVUTTSRQP}P{OyNxNvMuLsLqJoJpKoJnJnJmJmJlJlJlJlJlJmJmKnKnLoLpMqNsOtPuQwRxSzT|U~WXY[]^`bdfhjlnprtvy{}氂泄絆緈繋绍罏СТͰȴȳſľý¼ݿݾܼܻܽܺ۹۸۷ڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨զեդդԣԢԡԠԠӟӞӞӝӜҜқҚҚҙҙјјјїȎȍіііЕЕЕЕЕЕЕЕЕЕЕЕЕЕЕііііїїјјјҙҙҚҚқҜӜӝӞӞӟԠԠԡԢԣդդեզ֧֨֩ת׫׬׭خخدٰٱٲٳڠ͓͓ۡۡۡܡܡܢܢܢݢݢݢݢݢݢޢޢޢޢޢޡޡߡߡߠߠߠߠߟփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{kjigfedcba`_^]\[ZYXWVUTSRQQP}O{NyNwMuLtLrKqKnImHnImIlIkIkHjHjHjHjHjHjIjIkIkJlJmKnLoLpMqNsOtPvQxRzT{U}VXZ[]_`bdfhjlnqsuwz|~毀沃洅綇縊續缎羐ССΰȴȳſľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨եեդգԢԡԡԠӟӟӞӝӝҜҜққҚҚҙљјȎȎїїїїіііііііііііііїїїїјјјљҙҚҚққҜҜӝӝӞӟӟԠԡԡԢգդեե֦֧֨֩ת׫׬׭حخدٰٱٲٳڠڠ͓͓ۡۡۡܡܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢޡߡߡߡߠߠߠߟߟւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zjihfedcba`_^]\[ZYXWVUTSRQPP}O{NyMwLuLsKrKpJoIlHkGkHjHiGiGhGhGgGgGgGgGgGhHhHiHjIjJkJmKnLoMqNrOtPvQwRyT{U}WXZ[]_acegikmortvx{}氂泄絆緉繋缍羏РСαȴȴſľľý¼ݿݾܼܻܽۺ۹۸۷ڶڵڴٳٲٱٰدخح׭׬׫ת֧֦֩֨զեդգԣԢԡԠԠӟӟӞӝӝӜҜққҚҚҚȏȏљјјјјїїїїїїїїїїїјјјјљҙҙҚҚҚққҜӜӝӝӞӟӟԠԠԡԢԣգդեզ֦֧֨֩ת׫׬׭حخدٰٱٲٳڴڠ͓͓ۡۡۡܢܢܢܢܢݢݢݢݢݢޢޢޢޢޢޢޢޡߡߡߡߠߠߠߟւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyihgedcba`_^]\[ZYXWVUTSRQPO}N{NyMwLuKsKqJpInImHjGiFiGhGgFfFfFeFeFeFeFeFeFeFfGgGgHhHiIjJlJmKnLpMrNsPuQwRyT{U~WXZ\^`bdfhjlnpsuwy|~毀沃洅綇繊绌罎РСϱȵȴſľý¼ݿݾݾܼܻܽۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֩֨֨զեդդգԢԢԡԠԠӟӞӞӝӝӜҜҜққȐȐҚҚҙҙҙљљјјјјјјјљљҙҙҙҚҚҚҚққҜҜӜӝӝӞӞӟԠԠԡԢԢգդդեզ֧֨֨֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܢݢݢݢݢݢݢޢޢޢޢޢޢޢߡߡߡߡߠߠߠߟցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyihgfdcba`_^]\[ZYXWVUTSRQPO}N{MyMwLuKsJqJoImHlHjGhFgEgFfEeEdEdEcEcDcDbEcEcEcEdEdFeFfGgHhHiIkJlKnLpMqNsPuQwRzT|U~WY[\^`bdfikmoqtvx{}求泄絇縉纋缍羐РРϱȵȴſľý¼ݿݾܼܻܽܺۺ۹۸ڷڶڵڴٳٲٱٰدخخ׭׬׫ת֧֦֩֩֨զեդգԣԢԡԡԠԠӟӟӞӞӝӝӜҜȑȑқққҚҚҚҚҚҚҚҚҚҚҚҚҚҚҚққққҜҜӜӝӝӞӞӟӟԠԠԡԡԢԣգդեզ֦֧֨֩֩ת׫׬׭خخدٰٱٲٳڴڵ͔͔ۡۡۢܢܢܢܢܣݣݣݣݣݣݣޣޣޢޢޢޢޢߢߡߡߡߡߠߠߟրր߆߅߄߃߂߂߁߀~}}||{zzyyxxhgfedba`_^]\[ZYXWVUTSRQPO~N{MyLwLuKsJqIoImHkGjGhFfEeDeEdDcDbDaDaCaC`C`C`DaDaDaDbEcEdFeFfGgHiIjJlKnLoMqNsPvQxSzT|VXY[]_acegjlnpsuwz|~氁沃絆緈繊绍罏ПРвȵȵſſľý¼ݿݾܼܻܽܺ۹۸۸ڷڶڵڴٳٲٱٰددخح׬׫תת֧֧֩֨զեեդգԣԢԡԡԠԠӟӟӞӞӞӝȒȒӜҜҜҜқққққққққққққҜҜҜӜӝӝӝӞӞӞӟӟԠԠԡԡԢԣգդեեզ֧֧֨֩תת׫׬حخددٰٱٲٳڴڵڶ͔͔ۢۢۢܢܣܣܣݣݣݣݣݣݣޣޣޣޣޢޢޢߢߢߡߡߡߠߠߠߟր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwgfedcb`_^]\[ZYXWVUTSRQPON|MzLxLuKsJqIoHmHkGjFhFfEdDcCcDbCaC`C_C_B_B^B^B^B_C_C_C`DaDbEcEdFeGgHhIjJlKnLpMrOtPvQxS{U}VXZ\^`bdfhkmortvy{}毀求洅綇縉绌罎ПРвȶȵſľý½¼ݿݾܼܻܽܺ۹۸۷ڷڶڵڴٳٲٱٰذدخح׬׫׫ת֧֦֩֨֨զեդդգԣԢԢԡԡԠԠӟӟӟȓȓӞӝӝӝӝӝӜӜӜӜӜӜӜӜӜӝӝӝӝӝӞӞӞӟӟӟԠԠԡԡԢԢԣգդդեզ֦֧֨֨֩ת׫׫׬حخدذٰٱٲٳڴڵڶڷ͔͕ۢۢۢܣܣܣܣݣݣݣݣݣݣޣޣޣޣޣޢޢߢߢߡߡߡߠߠߠ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwgfedcba`_]\[ZYXWVUTSRQPON}M{LxLvKtJqIoHmHkGjFhEfEeDcCaCaC`B_B^B]B]A]A\A\A\A]B]B]B^C_C`DaDbEcFeGfHhIjJlKnLpMrOtPwRyT|U~WY[]_acegjlnqsuxz}汁泄絆縉纋缍羐ПРѲȶȶſľý¼ݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٱٱذدخح׭׬׫תת֧֧֦֩֨զեդդգԣԢԢԡԡԠԠԠȔȔӟӟӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӞӟӟӟӟԠԠԠԡԡԢԢԣգդդեզ֦֧֧֨֩תת׫׬׭حخدذٱٱٲٳڴڵڶڷۣۣۣ͕͕ۢܣܣܣݣݤݤݤݣݣޣޣޣޣޣޣޢߢߢߢߡߡߡߠߠ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvgedcba`_^]\ZYXWVUTSRQPON~M|MyLwKtJrIpHnGlGjFhEfEeDcDaB`B_B^B]A\A\A[A[A[@Z@[A[A[A\A\B]B^C_C`DbEcFeGfHhIjJlKnLqNsOuQxSzT}VXZ\^`bdgikmprtwy|~氁沃絅緈繊缍羏ОПѳȶǶſľý¼ݿݿݾܼܻܽۺ۹۸۷ڷڶڵڴٳٲٲٱذدخخح׬׫׫ת֧֧֩֩֨զզեդդգգԣԢԢԡԡȕȔԠԠԠӟӟӟӟӟӟӟӟӟӟӟӟӟӟӟԠԠԠԠԡԡԡԢԢԣգգդդեզզ֧֧֨֩֩ת׫׫׬حخخدذٱٲٲٳڴڵڶڷ۷ۣۣۣܣ͕͕ܤܤݤݤݤݤݤݤޤޤޣޣޣޣޣߣߢߢߢߡߡߡߠߠ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvufedba`_^]\[ZYXWVUSRQPPON}MzLxKuJsIqHoHlGjFhEfEeDcCaC_B^A]A\A[A[@Z@Y@Y@Y@Y@Y@Y@Y@Z@[A[A\B^B_C`DbEcFeGgHiIkJmLoMqNtPvRyS{U~WY[]_acfhjloqtvx{}毀沂洅綇繉绌罎ОПȷȶſſľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴٳٳٲٱٰددخح׬׬׫תת֧֧֦֩֩֨զեեդդգգԣԢȖȕԡԡԡԡԡԠԠԠԠԠԠԠԠԠԠԠԡԡԡԡԡԢԢԢԣգգդդեեզ֦֧֧֨֩֩תת׫׬׬حخددٰٱٲٳٳڴڵڶڷ۸۸ۤۤܤܤ͕͕ܤݤݤݤݤݤݤޤޤޤޤޤޣޣߣߣߢߢߢߡߡߡߠ~}߅߄߃߂߁߀߀~}||{zzyxxwvvuutedcba`^]\[ZYXWVUTSRQPON~M|LyKwJtIrHoHmGkFiEgEeDcCaC`B^A]A\A[@Z@Y@X?X?X?W?W?W?X?X?X@Y@Z@[A\B]B_C`DbEcFeGgHiIlKnLpNsOuQxSzT}VXZ\^`cegilnpsuxz}求洄綆縉纋罎ОПȷȷſĿľý¼ݿݾݾܼܻܽۺ۹۸۸ڷڶڵڴڴٳٲٱٰذدخخح׬׬׫תת֧֧֦֩֩֨զզեեդդդȗȖԣԢԢԢԢԢԢԢԡԡԡԡԡԢԢԢԢԢԢԢԣգգդդդեեզզ֦֧֧֨֩֩תת׫׬׬حخخدذٰٱٲٳڴڴڵڶڷ۸۸۹ۤܤܤܤ͖͖ݥݥݥݥݤݤޤޤޤޤޤޤޤߣߣߣߢߢߢߢߡߡߠ}}߄߃߂߁߁߀~}}|{zzyxxwvvuutfdcba`_^]\ZYXWVUTSRQPONM}L{KxJvJsIqHnGlFjEhEfDdCbC`B_A]A[@Z@Y@X?X?W?V>V>V>V>V>V>V?W?X?Y@Y@[A\B]B_C`DbEdFfGhIjJmKoMqNtPwRyT|UWY[]`bdfikmpruwy|~汁泃絆縈纋缍羏ООͳȷſľþý¼ݿݾݾܼܻܽܺ۹۹۸۷ڶڵڵڴٳٲٲٱذددخح׭׬׫׫תת֧֧֦֩֩֨֨զզեեȗȗդդդգգգգգԣԣԣԣԣգգգգգդդդդեեեզզ֦֧֧֨֨֩֩תת׫׫׬׭حخددذٱٲٲٳڴڵڵڶ۷۸۹۹ܤܥܥܥܥ͖͖ݥݥݥݥݥޥޥޥޤޤޤޤޤߣߣߣߣߢߢߢߡߡ߆}|߄߃߂߁߀~}}|{zzyxxwvvuttsedca`_^]\[ZYXWVUTSRQPONM|LzKwJuIrHpGmGkFiEgDdCcCaB_A]A[@Z@Y?X?W?V>V>U>U>U>T>U>U>U>V>V?W?X@Y@[A\B^B_CaDcEeGgHiIkKnLpNsOvQxS{U~WY[]_acfhjmoqtvy{~氀沃絅緈纊缌羏НОſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڶڶڵڴٳٳٲٱٰذدخخح׭׬׫׫תת֧֧֦֩֩֨֨֨զȘȘեեեեեդդդդդդդդդդդեեեեեզզզ֦֧֧֨֨֨֩֩תת׫׫׬׭حخخدذٰٱٲٳٳڴڵڶڶ۷۸۹ۺܺܥܥܥܥܥ͖͖ݥݥݥݥޥޥޥޥޥޥޤޤߤߤߣߣߣߢߢߢߡ߆}|߃߂߁߀~~}|{zzyxxwvvuttssdcba`_^\[ZYXWVUTSRQPONM~L|KyJvItIqHoGlFjEhDfDcCbB`B^A\@Z@Y?X?W?V>U>U>T=T=S=S=S=T=T>U>U>V?W?X@Y@[A\B^C`DbEdFfGhIjJmKoMrOuPwRzT}VXZ\^`cegjlnqsvx{}氀沂紅緇繉绌美ѝОſľþý¼ݿݾݾܼܻܽܺۺ۹۸۷ڷڶڵڴڴٳٲٲٱٰذدخخح׭׬׬׫׫תת֧֩֩֨֨֨șș֧֦֦զզզզզեեեեեզզզզզ֦֦֧֧֧֧֨֨֨֩֩תת׫׫׬׬׭حخخدذٰٱٲٲٳڴڴڵڶڷ۷۸۹ۺܻܺܦܦܦܦݦ͗͗ݦݦݦަަޥޥޥޥޥޥߤߤߤߣߣߣߢߢߢ߆߅|{߂߁߁߀~}|{{zyxxwvvuttssrdba`_^]\[ZYXWVUSRQPPONM~L{KxJvIsHpGnFkFiEgDeCcCaB_A]A[@Z?X?W?V>U>T=T=S=S=R=R=R=S=S=T=T>U>V?W?Y@ZA[A]B_CaDcEeGgHjIlKoLqNtPwRyS|UWY[^`bdgiknpsuxz}沂約緆繉绋罎ѝОſľþý¼ݿݿݾܼܻܻܽۺ۹۸۸۷ڶڵڵڴٳٳٲٱٱذذدخخح׭׬׬׫׫׫תת֩֩֩ȚȚ֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֧֨֨֨֨֨֨֩֩֩תת׫׫׫׬׬׭حخخدذذٱٱٲٳٳڴڵڵڶ۷۸۸۹ۺܻܻܼܦܦܦݦݦ͗͗ݦݦަަަަަޥޥޥߥߥߤߤߤߣߣߣߢߢ߆߅|{߂߁߀~}||{zyxxwvvuttsrrqcba`_^\[ZYXWVUTSRQPONML}KzJxIuIrHpGmFkEhDfDdCbB`B^A\@[@Y?W>V>U>T=S=S=R=RU>V?X?Y@[A\B^C`DbEdFgHiIkJnLpNsOvQyS{U~WY[]_bdfhkmpruwz|汁約綆繈绋罍ѝНſľþý¼ݿݿݾܼܼܻܽۺ۹۹۸۷ڷڶڵڴڴٳٳٲٱٱذذددخخح׭׬׬׫׫׫תתțț֩֩֩֩֨֨֨֨֨֨֨֨֨֨֨֨֨֩֩֩֩֩תתת׫׫׫׬׬׭حخخددذذٱٱٲٳٳڴڴڵڶڷ۷۸۹۹ۺܻܼܼܧܧܧݧݧݧ͗͗ݧާަަަަަަߥߥߥߥߤߤߤߣߣߣߢ߆߅߄{{߁߀~~}|{zyyxwvvuttsrrqqba`_^]\[ZYXWVUTSRQPONML|KzJwItHrGoGmFjEhDfCcCaB_A]A\@Z?X?W>V>U=T=S=R=QV>W?X@ZA\A^B_CbEdFfGhIkJmLpMsOuQxR{T~VXZ]_acfhjmortwy|~汁紃綆縈绊罍ќѝſľþý¼ݿݾܼܻܽܽܺۺ۹۸۸۷ڶڶڵڴڴٳٲٲٱٱٰذددخخحح׭׬׬׬׫ȜȜ׫תתתתתת֩֩֩֩֩֩֩תתתתתת׫׫׫׫׬׬׬׭ححخخددذٰٱٱٲٲٳڴڴڵڶڶ۷۸۸۹ۺܻܼܺܽܽܧݧݧݧݧݧ͘͘ާާާާާަަߦߦߦߥߥߥߤߤߤߣߣ߆߅߄{z߁߀~}|{zzyxwvvuttsrrqqpba`_^][ZYXWVUTSRQPONMLK|KyJwItHqGoFlEjEgDeCcBaB_A]@[@Y?X>V>U>T=S=R=QU>V?X?Y@[A]B_CaDcFeGhHjJmKoMrOuPxR{T}VXZ\^acehjloqtvy{~汀糃綅縈纊罍ќѝſľþý¼ݿݾݾܼܻܻܽۺ۹۹۸۷ڷڶڵڵڴڴٳٲٲٱٱٰذذددخخخحح׭ȝȝ׬׬׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׫׬׬׬׬׭ححخخخددذذٰٱٱٲٲٳڴڴڵڵڶڷ۷۸۹۹ۺܻܻܼܽݾݾݨݨݨݨݨݨ͘͘ިާާާާާߧߦߦߦߥߥߥߥߤߤߣ߆߅߄߃zz߀~}}|{zyxwwvuttsrrqqppb`_^]\[ZYXWVUTSRQPONMLK|JyIvItHqGnFlEiDgDeCbB`A^A\@[@Y?W>V>U=T=S=RV?X?Y@[A]B_CaDcEeGgHjIlKoMrNuPwRzT}VXZ\^`cegjloqtvx{}汀糂綅縇纊罌ќѝſĿľý¼ݿݾݾܼܼܻܽܺۺ۹۸۸۷ڷڶڵڵڴڴٳٳٲٲٱٱٰذذددخخخȞȞحح׭׭׬׬׬׬׬׬׬׬׬׬׬׬׬׭׭حححخخخخددذذٰٱٱٲٲٳٳڴڴڵڵڶڷ۷۸۸۹ۺܻܼܼܺܽݾݾݿݨݨݨݨݨި͘͘ިިިާާߧߧߧߦߦߦߥߥߥߤߤ߆߅߄߃zy߀~}|{zyyxwvuutssrqqppoa`_^]\[ZYWVUTSRQQPONML~K|JyIvHsGqGnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?Y@[A\B^C`DcEeFgHjIlKoLrNtPwRzT}UWZ\^`begilnqsvx{}汀糂綅縇纊缌ќќſĿľý½¼ݿݿݾܼܻܻܽܽۺ۹۹۸۸۷ڶڶڵڵڴڴٳٳٲٲٲٱٱٰذذددȟȟخخخخخخحححححححححخخخخخخددددذذٰٱٱٲٲٲٳٳڴڴڵڵڶڶ۷۸۸۹۹ۺܻܻܼܽܽݾݿݿݩݩݩݩީީ͙͘ިިިިߨߧߧߧߧߦߦߦߥߥ߆߅߄߃߂zy~}||{zyxwvvutssrqqppona`^]\[ZYXWVUTSRQPONMML~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>T=S=R=QV>W?Y@[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY\^`begilnqsuxz}糂組縇纉缌ћќſſľý½¼ݿݾݾܼܼܻܻܽۺ۹۹۸۸۷ڷڶڵڵڵڴڴٳٳٲٲٲٱٱٱٰȠȟذدددددددددددددددددددذذذٰٱٱٱٲٲٲٳٳڴڴڵڵڵڶڷ۷۸۸۹۹ۺܻܻܼܼܽݾݾݿݪݪݪީީީ͙͙ީީߨߨߨߨߨߧߧߧߦߦߦ߆߅߄߃߂yy~}|{zyxxwvuttsrrqppoon`_^]\[ZYXWVUTSRQPONMLK~K|JyIvHsGqFnFlEiDgCdCbB`A^A\@Z?Y?W>V>U=S=R=RV>W?Y@[A\B^C`DcEeFgHjIlKoLrNtPwQzS}UWY[^`bdgilnpsuxz}糂組縇纉缌ћќſľþý¼ݿݿݾܼܼܻܽܽܺۺ۹۹۸۸۷ڷڶڶڵڵڴڴڴٳٳٳٲٲٲȡȠٱٱٱٰٰٰذذذذذذذذذٰٰٰٱٱٱٱٱٲٲٲٳٳٳڴڴڴڵڵڶڶڷ۷۸۸۹۹ۺܻܼܼܺܽܽݾݿݿݪݪުުުުު͙Ιީߩߩߩߨߨߨߨߧߧߧ߅߄߃߂߁yx~}||{zyxwvuutsrrqppoonm`_^]\[ZYXWVUTSRQPONMLKJ|JyIvHtGqFnFlEiDgCeCbB`A^A\@[?Y?W>V>U=T=S=RV>X?Y@[A]B_CaDcEeFgHjIlKoLrNuPwQzS}UWY[^`bdgiknpsuxz}糂綄縇纉缋ћќſĿľý½¼ݿݾݾܼܻܻܽܽܺۺ۹۹۸۸۷۷ڶڶڶڵڵڴڴڴڴٳٳȡȡٲٲٲٲٲٱٱٱٱٱٱٱٱٱٱٱٲٲٲٲٲٲٳٳٳڴڴڴڴڵڵڶڶڶ۷۷۸۸۹۹ۺܻܻܼܺܽܽݾݾݿ½ݫޫޫޫޫުު͚Ιߪߪߩߩߩߨߨߨߨߧ߆߅߄߃߂߁yx~}|{zyxxwvutssrqqpoonnm`_^\[ZYXWVUTSRRQPONMLKJ|JyIvHtGqFoElEjDgCeCcBaA_A]@[?Y?X>V>U>T=S=RV?X?Y@[A]B_CaDcEeFhHjImKoLrNuPxQzS}UWY[^`bdgiknpsuxz}糂綄縆纉罋ћќſſľþý¼ݿݿݾݾܼܻܻܽܽܺۺ۹۹۹۸۸۷ڷڶڶڶڵڵڵڴڴȢȢڴٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳٳڴڴڴڴڴڵڵڵڶڶڶڷ۷۸۸۹۹۹ۺܻܻܼܺܽܽݾݾݿݿ¾þެޫޫޫޫޫޫ͚Κߪߪߪߩߩߩߩߨ߆߅߄߃߂߁yx~}|{zyxwvuutsrrqpponnmm_^]\[ZYXWVUTSRQPPONMLKJ|IzIwHtGrFoFmEjDhCeCcBaA_A]@[@Z?X>W>V>U=T=S=RV>W?X@Z@\A]B_CaDdEfGhHkImKpLrNuPxR{S~UWY[^`bdgiknpsuxz}糁綄縆纉罋ћќſľľý½¼ݿݿݾݾܼܼܻܻܽܽۺۺ۹۹۸۸۸۷۷ڷڶڶڶڵȣȣڵڵڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڴڵڵڵڵڵڶڶڶڷ۷۷۸۸۸۹۹ۺۺܻܻܼܼܽܽݾݾݿݿ½¾þĿެެެެެޫ͚߫Κ߫ߪߪߪߪߩߩ߅߄߃߂߁߀xx}|{zyyxwvutssrqpponnmml_^]\[ZYXWVUTSRQPOONMLKJ}IzIwHuGrFpFmEkDhCfCdBbB`A^@\@Z?Y?W>V>U>T=S=S=RU>V>X?Y@[A\A^B`CbDdFfGiHkJnKpMsNvPxR{T~VWZ\^`bdgiknpsuxz}紁綄縆绉罋ћћſĿľþý½ݿݿݾݾܼܼܻܻܽܽܺۺۺ۹۹۸۸۸۸۷۷ڷȤȤڶڶڶڶڵڵڵڵڵڵڵڵڵڵڵڵڵڶڶڶڶڶڶڷ۷۷۸۸۸۸۹۹ۺۺܻܻܼܼܺܽܽݾݾݿݿ¾þÿĿޭޭެެ߬߬߬͛Κ߫߫߫ߪߪ߆߅߄߃߂߁߀xw}|{zyxwvvutsrrqpoonmmll_^]\[ZYXWVUTSRQPOONMLKJ}J{IxHuGsFpFnEkDiDgCeBbBaA_A]@[?Z?X>W>V>U=T=T=S=S=R=R=R=S=S=T=T=U>V>W?X?Z@[A]B_CaDcEeFgGjHlJnKqMtOvPyR|TVXZ\^`begilnpsuxz}紂綄繆绉罋њћſĿľý½¼ݿݿݾݾܼܼܼܻܻܽܽܺۺۺ۹۹۹۸۸۸ȥȥ۷۷۷ڷڷڷڷڶڶڶڶڶڶڶڷڷڷڷ۷۷۷۷۸۸۸۸۹۹۹ۺۺܻܻܼܼܼܺܽܽݾݾݿݿ½¾ÿĿޭޭ߭߭߭߬߬͛Λ߬߫߫߫߆߅߄߃߂߁߀xw}|{zyxwvuttsrqpponnmmlk_^]\[ZYXWVUTSRQPOONMLKJ~J{IyHvGsGqFoElDjDhCeCcBaA`A^@\@[?Y?X>W>V>U>U=T=T=S=S=S=T=T=U>U>V>W?X?Y@[A\A^B`CbDdEfFhHjImJoLrMtOwQzR}TVXZ\^`cegilnqsuxz}紂緄繆绉轋њћſſľþý½¼ݿݿݾݾݾܼܼܼܻܻܻܽܽܺۺۺ۹۹ȦȦ۹۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۸۹۹۹۹۹ۺۺܻܻܻܼܼܼܺܽܽݾݾݾݿݿ½¾þÿĿޮ߮߮߭߭߭߭͛Λ߬߬߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllk_^]\[ZYXWVUTSRQPOONMLKKJ|IyHwGtGrFoEmEkDiCfCdBbBaA_A]@\?Z?Y?X>W>V>V>U>U=U=T=U=U>U>V>V>W?X?Y@[@\A]B_CaCcDeFgGiHkInKpLsNuOxQ{S}UVXZ\_acegjlnqsvxz}終緄繇绉辋њћſĿľþý½¼ݿݿݾݾܼܼܼܻܻܻܻܽܽܽܺȧȧۺۺ۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹۹ۺۺۺۺܻܻܻܻܼܼܼܺܽܽܽݾݾݿݿ½¾þÿĿ߯߯߮߮߮߮߮߭ΜΛ߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkk^]\[ZYXXWVUTSRQPOONMLKKJ}IzHxHuGsFpFnElDjDgCeCdBbA`A^@]@[?Z?Y?X?X>W>V>V>V>V>V>V>V>W>X?X?Y@[@\A]A_B`CbDdEfFhGjHlJoKqMtNvPyQ|S~UWY[]_acehjloqsvx{}糀終緄纇缉辋њћſĿľþý½½ݿݿݿݾݾݾܼܼܼܼܽܽܽȨȧܻܻܻܻܺܺܺܺۺۺۺۺۺܻܻܻܻܻܻܼܼܼܼܺܺܺܺܽܽܽݾݾݾݿݿݿ½¾þÿĿ߯߯߯߯߯߮߮߮ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjj]\[ZYXXWVUTSRQPPONMLLKJ~I{IyHvGtGrFoEmEkDiCgCeBcBaA`A^@]@\@[?Z?Y?X?X?W>W>W>W>W?X?X?Y?Z@[@\A]A^B`CbDcEeFgGiHkInJpLrMuOwPzR}TUWY[]_adfhjmoqtvy{}糀終縅纇缉њћſĿľþþý½¼ݿݿݿݾݾݾݾܽܽܽȨȨܼܼܼܼܼܼܻܻܻܻܻܻܻܻܻܼܼܼܼܼܼܼܽܽܽܽݾݾݾݾݿݿݿ½¾þþÿĿ߰߰߰߯߯߯ΜΜ߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjj]\[ZYXXWVUTSRQQPONMMLKJJ}IzHxHuGsFpFnElDjDhCfCdBcBaA`A^@]@\@[@Z?Z?Y?Y?Y?Y?Y?Y?Y?Z@[@[@\A]A_B`CaCcDeEgFiGkHmJoKqLtNvOyQ{R~TVXZ\^`bdfikmortvy{~紀綂縅纇轉њћͳȷȷȶȶȵȵȵȴȴȳȳȳDzȲȲȱȱȰǰȰǯȯǯȮȮȮȭȭȭȭȬȬȬȫȫȫȫȫȪȪȪȪȪȩȩȩȩȩȩȩȩȩȩȨȨȨȨȨȨȨȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȫȫȫȫȫȬȬȬȭȭȭȭȮȮȮǯȯǯȰǰȰȱȱȲȲ͞͞͞͞͞͝͝͝ΝΜΜΜΛΩΨϨϧϦϥϥϤϣТССРПОѝќћћњљҘҗҖҕҔҔӓӒӑӐӏӎԍԌԋԋԊԉՈՇՆՅՄՃււցր~}}|{zyxxwvuutsrrqppoonmmllkkjjiihh\[[ZYXWVUUTSRQQPONMMLKJJ~I{HyHvGtFrFoEmEkDiCgCfBdBcBaA`A_@^@]@\@[@[?Z?Z?Z?Z?Z@[@[@\@]A]A^A`BaCbCdDeEgFiGkHmIoJqKsLuNwOzP|R~S߁U߃V߆X߈Zߊ\ߍ]ߏ_ߒaߔcߖeߙgޛhޝjޟlޡnޤpަrިtݩvݫxݭzݯ|ݱ~ҕҖҗҘљњћћќѝОПРССТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲͳȷȷȶȶȶȵȵȴȴȴȳȳȳȲȲDZȱȱȰȰȰȯȯȯȮȮȮȮȭȭȭȬȬȬȬȬȫȫȫȫȫȪȪȪȪȪȪȪȪȩȩȩȩȩȩȩȩȩȩȩȩȩȪȪȪȪȪȪȪȪȫȫȫȫȫȬȬȬȬȬȭȭȭȮȮȮȮȯȯȯȰȰȰȱȱDZȲȲȳ͟͟͞͞͞͞͞͝ΝΝΜΜΪΩΨϨϧϦϥϥϤϣТСРРПОѝќћћњљҘҗҖҕҔғӓӒӑӐӏӎԍԌԋԊԊԉՈՇՆՅՄՃււցր~}||{zyxxwvuttsrrqpponnmmllkjjjiihh\[[ZYXWVVUTSRQQPONNMLKKJI|IzHxGuGsFqFoEmDkDiCgCfCdBcBbA`A_A^A^@]@]@\@\@\@\@\@\@]A^A^A_B`BaCcCdDeEgFiFjGlHnIpJrLtMvNyO{Q}R߀T߂U߄W߇Y߉Zߋ\ߎ^ߐ`ߓaߕcߗeߙgޜiޞkޠmޢoޤqަrިtݪvݬxݮzݯ|ݱ~ҕҖҗҘљњћћќѝОПРРСТϣϤϥϥϦϧϨΨΩΪΫΫάέͭͮͯͯͰͱͱͲſĿĿľþþý½½½ȫȫݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿݿ½½¾þþÿĿĿΝΝ߆߅߃߂߁߀~wv{zyxwvuttsrqpoonmmlkkjjii\[ZZYXWVUTSSRQPOONMMLKJJ~I|IyHwGuGsFqFoEmEkDiDhCfCeBcBbBaBaA`A_A_A^A^A^A^A^A_A_B`BaBbCcDdDeEgFhFjGkHmIoKqLsMvNxPzQ}STVXZ\^`bdfhjloqsuxz|絁縃纆輈њњſĿĿľþþþý½½½½ȬȬ½½½¾þþþÿĿĿΞά߆߄߃߂߁߀~wv{zyxwvutssrqpoonmllkkjiih\[[ZYXWVUTTSRQPPONNMLKKJJ}I{HyHvGtGrFpFoEmEkDjDhCfCeCdBcBcBbBaBaB`B`B`B`B`BaBaCbCcCdDeDfEgFhFjGlHmIoJqKsMuNwOzQ|R~TUWYZ\^`bdfikmoqtvx{}綁縄细轈њњſſĿĿľþþþþéȭȬ½½½½½½½½½½½¾þþþþÿĿĿĿέά߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiih]\[ZYXWVUUTSRQQPOONMMLKKJI|IzHxHvGtGrFpFoEmElDjDhCgCfCeCeCdCcBcBbBbBbBbCcCcCcCdDeDfEgEhFiFjGlHnIoJqKsLuMwOyP{Q~STVXY[]_acegiknprtvy{}絀緂繄軇轉љњſſĿĿĿĪȭȭþþþþþþþý½½½½¾þþþþþþþþþĿĿĿĿĿέά߆߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiihh\[ZYXWWVUTSSRQPPONNMLLKJJ~I|IzHxHvGtGrFqFoEnElEkDiDiDhDgCfCeCeCeCeCdCeCeDeDfDfEgEhEiFjGkGmHnIpJqKsLuMwNyP{Q}RTUWYZ\^`bdfhjlnpsuwy|~絀縂纅輇љњūȮȮĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿĿέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihh\[ZYXXWVUTTSRQQPOONMMLKKJJ~I|IzHxHvGtGsFqFpFnEmElDkEjDiDhDhDgDgDgDgDgDgDgEhEiEiFjFkGlHmHoIpJrKsLuMwNyO{P}RSUVXY[]_acdfikmoqsuxz|~綁繃軅轇љњƬȯǯέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihh\[ZYYXWVUUTSRRQPPONNMMLKKJJ~I|IzHxHwHuGsGrFqFoEnEmElEkEkEjEjEiEiEiEiEiEjFjFkFlGlGmHnIpIqJrKtLvMwNyO{P}QSTVWYZ\^`acegiknprtvx{}緁繄軆轈љњǬȰȰέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmlkkjjiihhg[[ZYXWVVUTSSRQQPOONNMLLKKJJ~I|IzIyHwHvGtGsGqFpFoFoFnFmFmFlFlFlFlFlFlFlGmGmGnHoHpIqJrJsKuLvMxNyO{P}QRTUWXZ[]_abdfhjlnpsuwy{}綀縂躄輆љњȭȰȰέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihhg\[ZYXXWVUUTSRRQQPOONNMLLKKKJ~J}I{IyIxHvHuHtGsGrGqGpGpGoGoGnGnGnGnGoGoHoHpHqIqIrJsKtKvLwMyNzO|P}QRTUVXY[\^`bcegikmoqsuxz|~緀繃軅轇љњɮȱȱέά߆߅߄߃߂߁߀~vv{zyxwvutsrqqponnmllkjjiihhgg[ZZYXWVVUTTSRRQPPOONNMMLLKKJJ}J|IzIyIxHvHuHtHtHsHrGrGqHqHqHqHqHqHqIrIrIsJtJuKvLwLxMzN{O|P~QRSUVWYZ\]_abdfhjlnprtvx{}縁躃輅љњʮȲȲέά߆߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiihhgg[[ZYXXWVUUTSSRQQPPOONNMMLLKKKJ~J}J{IzIyIwHwIvIuHuHtHtHtItItItItItJuJuJvKwLwLxMzN{N|O}PQRSTVWXZ[]^`bdegikmoqsuwy{~緀蹂軄轆љњ˯ȳȳέά߅߄߃߂߁߀~vv{zyxwvutsrrqponnmllkjjiihhggf[ZYYXWWVUUTSSRQQPPOONNMMMLLKKKK~J}J{IzIzJyIxIxIwIwIvJvJvJvJwJwKwKxLxLyMzM{N|O}OPQRSTVWXZ[\^`acefhjlnprtvxz|~縀躃輅љњ̰ȳȳέά߆߄߃߂߁߀~wv{zyxwvutssrqpoonmllkkjiihhggg[[ZYXXWVVUTTSSRQQPPOONNNMMMLLLKKK~J}J|J|J{JzJzJyJyKyKyKyKyKzLzL{M{M|N}N~OPQRRSUVWXY[\^_abdfgikmoqsuwy{}蹁軃轅њњͰȴȴέά߆߅߃߂߁߀~wv{zyxwvuttsrqpoonmmlkkjjiihhggf[ZZYXWWVVUTTSSRRQQPPOONNNMMMLLLLKKK~K~K}K}K|K|L|L|L|L|M|M}M}N~NOPPQRSTUVWXY[\]_`bcegijlnprtvxz|~蹀躂輄њњαȵȵέά߆߅߄߂߁߀~wv{zyxwvuutsrqpponmmllkjjiihhggg\[ZYYXWWVUUTTSSRRQQPPOOONNNNMMMMLLLLLLLMMMMMNNOOPPQRRSTUVWXYZ\]^`acefhjkmoqsuwy{}躁較轅њћϲȵȵέά߆߅߄߃߂߀~wv{zyxwwvutsrqqponnmllkkjiihhhggf[[ZYYXWWVUUTTSSRRQQQPPPOOONNNNNMMMMMMNNNNNOOOPPQQRSTTUVWXYZ\]^`acdfgikmnprtvxz|~蹀軂轄њћвȶȶέά߆߅߄߃߂߁߀wv|{zyxwvutsrrqpoonmmlkkjjiihhgggf[ZZYXXWWVVUTTTSSRRQQQPPPPOOOOONNNNNNOOOOOPPPQQRSSTUVVWXY[\]^`abdegijlnoqsuwy{}躁較њћѳǷȷέά߆߅߄߃߂߁߀wv|{zyxwvutssrqpponmmllkjjiihhhgggf[ZZYXXWWVVUUTTSSSRRRQQQPPPPPPOOOOPPPPPPQQQRRSSTUUVWXYZ[\]^`abdeghjkmoqrtvxz|~躀輂њћέά߅߄߃߂߁߀ww|{zyxwvuutsrqqponnmllkkjjiihhhggg\[ZZYYXWWVVUUUTTSSSRRRRQQQQQQPPQQQQQQQQRRSSSTUUVWWXYZ[\]^`abdefhikmnprtuwy{}軁轃њћͭά߆߅߄߂߁߀xw|{zyxxwvutsrrqpoonmmllkkjjiihhhggg\[ZZYYXXWWVVUUUTTTSSSRRRRRRRQQRRRRRRRSSSTTUUVVWXYZZ[\]_`abcefhiklnpqsuvxz|~軀轂њћͭά߆߅߄߃߂߁߀xw}|{zyxwvuttsrqpponnmmlkkjjiiihhhggg\[ZZYYXXWWWVVUUUTTTTSSSSSSSRRSSSSSSTTTUUUVWWXXYZ[\]^_`abcefhijlnoqrtvxy{}輁њћͭέ߆߅߄߃߂߁߀xw}|{zyxwvvutsrrqpoonmmllkkjjiiihhhggg\[[ZZYYXXWWWVVVUUUUTTTTTTTSSTTTTTUUUUVVWWXXYZ[[\]^_`abdefgijlmoprtuwy{|~輀њћͭέ߅߄߃߂߁߀xx}|{zyyxwvutssrqpponnmmllkkjjiiihhhhgg\[[ZZYYYXXWWWWVVVVUUUUUUUTTUUUUVVVVWWXXYYZZ[\]^__`bcdefgijlmoprsuwxz|}ћћͭέ߆߅߄߃߂߁yx~}|{zyxwvuutsrrqpponnmmllkkjjiiihhhhhg\\[[ZZYYYXXXWWWWWVVVVVVVUUVVVWWWWXXXYYZ[[\]]^_`abcdefhijlmnpqsuvxy{}ћќͭέ߆߅߄߃߂߁yx~}|{zyxxwvutssrqqpoonnmlllkkjjjiiihhhhh\\\[[ZZZYYYXXXXXWWWWWWWVVWWXXXXYYYZZ[[\]]^_``abcdeghijlmnpqstvwy{|~ћќͮέ߅߄߃߂߁yx~}||{zyxwvuutsrrqppoonmmlllkkjjjiiiiihhh]\\\[[ZZZZYYYYYXXXXXXXWWXYYYYZZZ[[\\]]^__`abcdefghijlmnpqstvwyz|~ћќͮͭ߆߅߄߃߂yy~}|{zyxxwvuttsrrqppoonmmlllkkkjjjiiiiiii]]\\\[[[[ZZZZZZYYYYYYXXZZZZ[[[\\\]]^__`aabcdefghiklmnpqrtuwxz{}ћќͮͭ߆߅߄߃߂zy~}||{zyxwvvutssrqqpponnmmmllkkkjjjjjiiiiii]]]\\\\[[[[[[[ZZZ[[YY[[[\\\\]]^^__`aabcdeffgijklmnpqrtuwxz{ћќͮͭ߆߅߄߃zy߀~}|{zyyxwvuutssrqqppoonnmmlllkkkkjjjjjjjjj^^]]]]\\\\\\\\\\\\ZZ\\]]]]^^__``aabcddefghijklmopqrtuwxy{ќќͮͭ߆߅߄߃zz߀~}}|{zyxwwvuttsrrqqppoonnmmmlllkkkkkjjjjjjj_^^^^]]]]]]]]]]]][[]^^^^__```abbccdefgghijklnopqrtuvxy{ќѝͮͮ߆߅߄{z߁߀~}|{zzyxwvvuttsrrqqppoonnmmmlllllkkkkkkkkkkk____^^^^^^^^^^^\\____```aabbcddeffghijklmnopqstuvxyќѝͮͮ߆߅߄{{߁߀~~}|{zyyxwvvuttsrrqqppoonnnmmmmllllllllkllllll``___________]]```aaabbccddeffghiijklmnoprstuwxќѝͯͮ߆߅|{߂߁߀~}||{zyxxwvvuttsrrqqpppoonnnmmmmmlllllllllllmmma````````aa^_abbbbccddeeffghhijklmnopqrstuwѝНͯͮ߆߅|{߂߁߁߀~}|{{zyxxwvvuttssrrqqppooonnnnmmmmmmmmmmmmmmmnnnbbbbbbbbb``ccccddeeffgghhijkllmnopqrstvѝОͯͮ߆}|߃߂߁߀~~}|{zzyxxwvvuttssrrqqpppoooonnnnnnnnnnnnnnnnnoooopccccccaadddeefffgghiijkklmnopqrstѝОͯͮ߆}|߄߃߂߁߀~}}|{zzyxxwvvuttssrrrqqppppooooonnnnnnnnnoooooppppqqrddebbeeffggghhiijkklmnnopqr߅߆НОͯͯ}}߄߃߂߁߁߀~}}|{zzyxxwvvuuttssrrrqqqppppoooooooooooooppppqqqrrrsstpcggghhhiijjkklmnnop߂߃߄߅߆ООͯͯ~}߅߄߃߂߁߀߀~}||{zzyxxwvvuuttsssrrrqqqqpppppppppppppppqqqqrrrsssttqqvvwxxyzz{||}~߀߀߁߂߃߄߅߆ОПͰͯ~~߅߅߄߃߂߁߀~}||{zzyxxwwvvuuttsssrrrrqqqqqqqqqqqqqqqqqrrrrsssttuuqqwwxxyzz{||}~߀߁߂߃߄߅߅߆ОПͰͯ~߆߅߄߄߃߂߁߀~}||{zzyyxwwvvuuutttsssrrrrrrrqqqqqrrrrrrrssstttuuuvrrwxyyzz{||}~߀߁߂߃߄߄߅߆ОПͰͯ߆߅߄߃߃߂߁߀~}}|{{zyyxxwwvvuuutttssssssrrrrrrrrrsssssstttuuuvvwrsxyyz{{|}}~߀߁߂߃߃߄߅߆ПРͰͯր߆߅߄߃߂߂߁߀~}}|{{zzyyxxwwvvvuuutttttssssssssssstttttuuuvvvwwxssyzz{{|}}~߀߁߂߂߃߄߅߆ПРͰͰրր߆߅߄߃߂߂߁߀~}}||{zzyyxxxwwvvvuuuuutttttttttttttuuuuuvvvwwxxxttzz{||}}~߀߁߂߂߃߄߅߆ПРͱͰցր߆߆߅߄߃߂߂߁߀߀~~}||{{zzyyxxxwwwvvvvuuuuuuuuuuuuuuuvvvvwwwxxxyytu{{||}~~߀߀߁߂߂߃߄߅߆߆РРͱͰւց߆߆߅߄߃߃߂߁߀߀~~}}||{{zzyyyxxxwwwwvvvvvvvvvvvvvvvwwwwxxxyyyzzuu||}}~~߀߀߁߂߃߃߄߅߆߆РСͱͰւց߆߆߅߄߃߃߂߁߁߀~~}}||{{zzyyyxxxxwwwwwwwwwwwwwwwwwxxxxyyyzz{{vv}}~~߀߁߁߂߃߃߄߅߆߆РСͱͱփւ߆߆߅߄߄߃߂߁߁߀߀~~}}|||{{zzzyyyyxxxxxxxxxxxxxxxxxyyyyzzz{{||vw}~~߀߀߁߁߂߃߄߄߅߆߆ССͱͱՃփ߆߅߄߄߃߂߂߁߁߀~~}}|||{{{zzzzyyyyyyyyyyyyyyyyyzzzz{{{|||}ww~߀߁߁߂߂߃߄߄߅߆СТͲͱՄՃ߆߅߅߄߃߃߂߁߁߀߀~~}}}|||{{{{zzzzzzzzzzzzzzzzz{{{{|||}}}~xx߀߀߁߁߂߃߃߄߅߅߆СТͲͱՅՄ߆߅߅߄߄߃߂߂߁߁߀߀~~~}}}||||{{{{{{{{{{{{{{{{{||||}}}~~~xy߀߁߁߂߂߃߄߄߅߅߆ТϣͲͱՅՅ߆߆߅߄߄߃߃߂߂߁߁߀߀~~~}}}}|||||||||||||||||}}}}~~~߀yy߁߂߂߃߃߄߄߅߆߆ТϣͲՆՅ߆߅߅߄߄߃߃߂߂߁߁߀߀߀~~~~}}}}}}}}}}}}}}}}}~~~~߀߀߀߁zz߂߃߃߄߄߅߅߆ϣϣͲՆՆ߆߆߅߅߄߄߃߃߂߂߁߁߁߀߀߀~~~~~~~~~~~~~~~~~߀߀߀߁߁߁߂{{߃߄߄߅߅߆߆ϣϤͲՇՆ߆߆߅߅߄߄߃߃߂߂߂߁߁߁߀߀߀߀߀߀߀߀߁߁߁߂߂߂߃{|߄߅߅߆߆ϣϤՈՇ߆߆߅߅߄߄߃߃߃߂߂߂߁߁߁߁߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߀߁߁߁߁߂߂߂߃߃߃߄||߅߆߆ϤϤԈՈ߆߆߅߅߄߄߄߃߃߃߂߂߂߂߂߁߁߁߁߁߁߁߁߁߁߁߁߁߁߁߂߂߂߂߂߃߃߃߄߄߄߅}}߆ϤϥԉԈ߆߆߅߅߅߄߄߄߄߃߃߃߃߂߂߂߂߂߂߂߂߂߂߂߂߂߂߂߃߃߃߃߄߄߄߄߅߅߅߆~~ϤϥԊԉ߆߆߅߅߅߅߄߄߄߄߄߃߃߃߃߃߃߃߃߃߃߃߃߃߄߄߄߄߄߅߅߅߅߆߆~ϥϦԊԊ߆߆߆߅߅߅߅߅߅߄߄߄߄߄߄߄߄߄߄߄߅߅߅߅߅߅߆߆߆րϥϦԋԋ߆߆߆߆߆߆߅߅߅߅߅߅߅߅߅߆߆߆߆߆߆րրϦϦԌԋ߆߆߆߆߆߆߆ցցϦϧԍԌււϧϧԍԍւփϧϨͲͲͱͰͰͯͯͮͮέάάΫΫΪΩΩΨϧϧϦϥϥϤϤϣТТСРРПООѝќќћњњљјҘҗҖҖҕҕҔғӓӒӑӑӐӐӏӎӎԍԍԌԌԋԋԊԊԉԉԈՈՇՇՇՆՆՅՅՅՄՄՄՃՃփւււււցցցցցցցրրրրրրրրրրրցցցցցցցւււււփՃՃՄՄՄՅՅՅՆՆՇՇՇՈԈԉԉԊԊԋԋԌԌԍԍӎӎӏӐӐӑӑӒӓғҔҕҕҖҖҗҘјљњњћќќѝООПРРСТТϣϤϤϥϥϦϧϧΨΩΩΪΫΫάάέͮͮͯͯͰͰͱͲͲͲͱͱͰͰͯͮͮͭέάάΫΪΪΩΨΨϧϧϦϥϥϤϣϣТССРППОНѝќќћњњљјҘҗҖҖҕҕҔғӓӒӒӑӐӐӏӏӎӎԍԌԌԋԋԊԊԊԉԉՈՈՇՇՇՆՆՅՅՅՄՄՄՄՃՃփփւււււււցցցցցցցցցցցցցւււււււփփՃՃՄՄՄՄՅՅՅՆՆՇՇՇՈՈԉԉԊԊԊԋԋԌԌԍӎӎӏӏӐӐӑӒӒӓғҔҕҕҖҖҗҘјљњњћќќѝНОППРССТϣϣϤϥϥϦϧϧΨΨΩΪΪΫάάέͭͮͮͯͰͰͱͱͲӏӏՅՅΨΩӐӐՆՆΩΩӑӐՇՇΩΪӒӑՈՈΪΪӒӒԈԉΪΫғӓԉԊΪΫҔғԊԊΫΫҕҔԋԋΫάҕҕԌԌάάҖҖԍԍάέҗҖӎӎέͭҗҗӎӏͭͮјҘӏӐͮͮљљӐӐͮͮњљӑӑͮͯњњӒӒͯͯћћғғͯͰќќҔҔͰͰѝќҕҕͰͱНѝҕҖͱͱООҖҗͱͱППҗҗͱͲРПјјͲРРљљССњњТСћћϣТћќϣϣќќϤϤѝНϥϤООϥϥППϦϦРРϧϦРСϧϧССΨΨТТΩΩϣϣΪΩϤϤΪΪϤϥΫΫϥϥάΫϦϦάάϧϧέέϧϨͭͭΨΨͮͮΩΩͯͮΪΪͯͯΪΪͰͰΫΫͱͰάάͱͱάέͲͱͭͭͲͮͮͯͯͯͯͰͰͰͱͱͱͲͲ \ No newline at end of file From 10d165643dc7081cf554cdc811bf14595b31002e Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 14:10:03 +0200 Subject: [PATCH 02/10] map: GPU camera-delta for labels; brand text draws over the pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- draw/src/shader/draw_rotated_text.rs | 35 +++++++++++++- widgets/src/map/view.rs | 71 ++++++++-------------------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/draw/src/shader/draw_rotated_text.rs b/draw/src/shader/draw_rotated_text.rs index bad2d4927..ba28ec7f8 100644 --- a/draw/src/shader/draw_rotated_text.rs +++ b/draw/src/shader/draw_rotated_text.rs @@ -17,16 +17,29 @@ script_mod! { rotated_pos: varying(vec2f) + // Camera-delta transform (best-effort label tracking while the + // map rotates/tilts between re-places): whole placed glyphs spin + // about the view pivot on the GPU, the async re-place trues up. + cam_cs: uniform(1.0) + cam_sn: uniform(0.0) + cam_tilt: uniform(1.0) + cam_pivot: uniform(vec2(0.0, 0.0)) + 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 + let cam_rel = rotated - self.cam_pivot + rotated = vec2( + cam_rel.x * self.cam_cs - cam_rel.y * self.cam_sn, + (cam_rel.x * self.cam_sn + cam_rel.y * self.cam_cs) * self.cam_tilt + ) + self.cam_pivot self.pos = self.geom.pos self.t = mix(self.t_min, self.t_max, self.geom.pos.xy) @@ -86,6 +99,26 @@ pub struct DrawRotatedText { pub rotation_origin: Vec2f, } +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, + cos: f32, + sin: f32, + tilt_ratio: f32, + pivot: Vec2f, + ) { + self.draw_vars.set_uniform(cx, live_id!(cam_cs), &[cos]); + self.draw_vars.set_uniform(cx, live_id!(cam_sn), &[sin]); + self.draw_vars.set_uniform(cx, live_id!(cam_tilt), &[tilt_ratio]); + self.draw_vars + .set_uniform(cx, live_id!(cam_pivot), &[pivot.x, pivot.y]); + } +} + /// A single glyph positioned along a path, ready to draw. #[derive(Clone, Copy, Debug)] pub struct PathGlyphInstance { diff --git a/widgets/src/map/view.rs b/widgets/src/map/view.rs index a8e8973c6..f53b8732c 100644 --- a/widgets/src/map/view.rs +++ b/widgets/src/map/view.rs @@ -645,7 +645,7 @@ pub struct MapView { #[rust] scratch_accepted_bounds: Vec, #[rust] - scratch_accepted_plans: Vec<(f64, usize, usize, u8)>, + scratch_accepted_plans: Vec<(f64, usize, usize, u8, bool)>, // Labels drawn last frame (hashed name+position key); kept to stabilize // placement while panning instead of flickering between candidates. #[rust] @@ -2450,11 +2450,16 @@ impl MapView { let score = candidate.score + candidate.source_rank as f64 * 2.0; self.scratch_accepted_hashes .push(stable_label_key(&candidate.name_key, &candidate.road_kind)); + // Post-icon phase: in-pin text and charger brand draw AFTER + // the symbol pass so they sit on the pins, not under them. + let post_icon = candidate.color_class == LABEL_CLASS_PIN + || candidate.road_kind.starts_with("chb"); self.scratch_accepted_plans.push(( score, placement.glyph_start, placement.glyph_end, candidate.color_class, + post_icon, )); } @@ -2522,61 +2527,23 @@ impl MapView { }; // Rigid delta-rotation of the cached placement about the pivot // (heading-up nav): transform a copy once, draw slices from it. - // Screen-point (pin) plans stay UPRIGHT: rotating them with the - // map while their billboard pins stay screen-aligned reads as - // doubled, garbled text during rotation. Collect their glyph - // ranges and exempt them from the rigid delta-rotation. - let mut pin_ranges: Vec<(usize, usize)> = Vec::new(); - for &(_, start, end, color_class) in &self.scratch_accepted_plans { - if color_class == LABEL_CLASS_PIN { - pin_ranges.push((start, end)); - } - } - let in_pin_range = - |index: usize| pin_ranges.iter().any(|&(s0, e0)| index >= s0 && index < e0); - let transform_active = rot != 0.0 || (tilt_ratio - 1.0).abs() > 1e-4; - let rotated: Vec = if transform_active { - let (c, s) = (rot.cos(), rot.sin()); - self.path_glyphs - .iter() - .enumerate() - .map(|(glyph_index, glyph)| { - if in_pin_range(glyph_index) { - return glyph.clone(); - } - let mut glyph = glyph.clone(); - // Rotate about the pivot, then compress y by the tilt - // ratio — cached glyphs track the camera best-effort; - // the async re-place trues everything up. - let spin = |p: crate::makepad_draw::text::geom::Point| { - let dx = p.x - pivot.x; - let dy = p.y - pivot.y; - crate::makepad_draw::text::geom::Point::new( - pivot.x + dx * c - dy * s, - pivot.y + (dx * s + dy * c) * tilt_ratio, - ) - }; - glyph.glyph_origin = spin(glyph.glyph_origin); - glyph.rotation_origin = spin(glyph.rotation_origin); - let a = glyph.angle + rot; - glyph.angle = (a.sin() * tilt_ratio).atan2(a.cos()); - glyph - }) - .collect() - } else { - Vec::new() - }; + // Camera-delta on the GPU: placed glyphs spin/squash about the + // pivot via uniforms — zero per-frame CPU work, the async + // re-place trues everything up (identity uniforms then). + self.draw_label.set_camera_delta( + cx.cx, + rot.cos(), + rot.sin(), + tilt_ratio, + pivot, + ); self.draw_label.begin_glyph_batch(cx); for i in 0..self.scratch_accepted_plans.len() { - let (_, start, end, color_class) = self.scratch_accepted_plans[i]; - if (color_class == LABEL_CLASS_PIN) != pin_phase { + let (_, start, end, color_class, post_icon) = self.scratch_accepted_plans[i]; + if post_icon != pin_phase { continue; } - let glyphs = if transform_active { - &rotated[start..end] - } else { - &self.path_glyphs[start..end] - }; + let glyphs = &self.path_glyphs[start..end]; self.draw_label.draw_super.color = halo_color; for offset in HALO_OFFSETS { self.draw_label.draw_path_glyphs_scaled( From 2de4fb4f71902f9a32708c0f4f10fda2f6204cd2 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 14:13:58 +0200 Subject: [PATCH 03/10] =?UTF-8?q?map:=20exact=20camera-delta=20matrix=20?= =?UTF-8?q?=E2=80=94=20labels=20land=20where=20the=20regen=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- draw/src/shader/draw_rotated_text.rs | 33 +++++++------- widgets/src/map/view.rs | 66 +++++++++++++++++++--------- 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/draw/src/shader/draw_rotated_text.rs b/draw/src/shader/draw_rotated_text.rs index ba28ec7f8..ffc18a510 100644 --- a/draw/src/shader/draw_rotated_text.rs +++ b/draw/src/shader/draw_rotated_text.rs @@ -18,11 +18,14 @@ script_mod! { rotated_pos: varying(vec2f) // Camera-delta transform (best-effort label tracking while the - // map rotates/tilts between re-places): whole placed glyphs spin - // about the view pivot on the GPU, the async re-place trues up. - cam_cs: uniform(1.0) - cam_sn: uniform(0.0) - cam_tilt: uniform(1.0) + // 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)) vertex: fn() { @@ -37,8 +40,8 @@ script_mod! { ) + origin let cam_rel = rotated - self.cam_pivot rotated = vec2( - cam_rel.x * self.cam_cs - cam_rel.y * self.cam_sn, - (cam_rel.x * self.cam_sn + cam_rel.y * self.cam_cs) * self.cam_tilt + 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 @@ -103,17 +106,11 @@ 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, - cos: f32, - sin: f32, - tilt_ratio: f32, - pivot: Vec2f, - ) { - self.draw_vars.set_uniform(cx, live_id!(cam_cs), &[cos]); - self.draw_vars.set_uniform(cx, live_id!(cam_sn), &[sin]); - self.draw_vars.set_uniform(cx, live_id!(cam_tilt), &[tilt_ratio]); + 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]); } diff --git a/widgets/src/map/view.rs b/widgets/src/map/view.rs index f53b8732c..bf7b47f32 100644 --- a/widgets/src/map/view.rs +++ b/widgets/src/map/view.rs @@ -681,6 +681,8 @@ pub struct MapView { label_cache_generation: u64, #[rust((1.0, Vec2f { x: 0.0, y: 0.0 }, 0.0, Vec2f { x: 0.0, y: 0.0 }, 1.0))] label_draw_transform: (f32, Vec2f, f32, Vec2f, f32), + #[rust(1.0)] + label_cache_tilt_cos_for_delta: f32, #[rust] tiles_generation: u64, #[rust] @@ -2307,12 +2309,31 @@ impl MapView { let pivot = rect.pos + rect.size * 0.5; shift += (pivot - camera_vec(pivot)) * (1.0 - k); } - // Screen-space delta rotation about the view pivot (phi = -rotation) - // plus a tilt-compression ratio: cached glyphs follow the camera - // every frame — ANY delta — and the async re-place trues up. + // The GPU camera-delta matrix transforms everything AFTER the + // CPU offsets are applied — pre-invert the pan shift so it + // lands where intended: shift_pre = M^-1 * shift. + { + let (dc, ds) = ((-rot_delta).to_radians().cos(), (-rot_delta).to_radians().sin()); + let t0 = self + .label_cache_tilt + .clamp(0.0, 65.0) + .to_radians() + .cos() + .max(1e-6); + let t1 = self.tilt_cos().max(1e-6); + let (a, b, c, d) = (dc, -ds / t0, t1 * ds, t1 * dc / t0); + let det = a * d - b * c; + if det.abs() > 1e-9 { + let (sx, sy) = (shift.x, shift.y); + shift = dvec2((d * sx - b * sy) / det, (-c * sx + a * sy) / det); + } + } + // Screen-space delta rotation about the view pivot (phi = -rotation); + // the cached placement's tilt_cos rides along so the draw can + // build the exact non-commuting delta matrix. let rot_rad = (-rot_delta).to_radians() as f32; - let cached_tilt_cos = self.label_cache_tilt.clamp(0.0, 65.0).to_radians().cos(); - let tilt_ratio = (self.tilt_cos() / cached_tilt_cos.max(1e-6)) as f32; + let cached_tilt_cos = + (self.label_cache_tilt.clamp(0.0, 65.0).to_radians().cos() as f32).max(1e-4); let pivot = rect.pos + rect.size * 0.5; self.draw_label_plans_scaled( cx, @@ -2326,7 +2347,7 @@ impl MapView { x: pivot.x as f32, y: pivot.y as f32, }, - tilt_ratio, + cached_tilt_cos, false, ); return false; @@ -2481,13 +2502,14 @@ impl MapView { /// as one glyph instance batch, optionally shifted by a screen offset /// (used to redraw the cached placement while panning). fn draw_label_plans(&mut self, cx: &mut Cx2d, extra_offset: Vec2f) { + let current_tilt_cos = (self.tilt_cos() as f32).max(1e-4); self.draw_label_plans_scaled( cx, 1.0, extra_offset, 0.0, Vec2f { x: 0.0, y: 0.0 }, - 1.0, + current_tilt_cos, false, ); } @@ -2495,8 +2517,8 @@ impl MapView { /// Redraw only the pin-class (in-bubble) label plans — called after /// the icon pass so kW text sits on top of the charger pins. fn draw_pin_label_phase(&mut self, cx: &mut Cx2d) { - let (scale, offset, rot, pivot, tilt_ratio) = self.label_draw_transform; - self.draw_label_plans_scaled(cx, scale, offset, rot, pivot, tilt_ratio, true); + let (scale, offset, rot, pivot, cached_tilt_cos) = self.label_draw_transform; + self.draw_label_plans_scaled(cx, scale, offset, rot, pivot, cached_tilt_cos, true); } fn draw_label_plans_scaled( @@ -2506,12 +2528,13 @@ impl MapView { extra_offset: Vec2f, rot: f32, pivot: Vec2f, - tilt_ratio: f32, + cached_tilt_cos: f32, pin_phase: bool, ) { // Remember the transform so the pin-text phase redraws with the // exact same mapping after the icon pass. - self.label_draw_transform = (scale, extra_offset, rot, pivot, tilt_ratio); + self.label_draw_transform = (scale, extra_offset, rot, pivot, cached_tilt_cos); + self.label_cache_tilt_cos_for_delta = cached_tilt_cos; // 4 diagonal offsets read as a solid halo at map label sizes and // halve the glyph volume vs an 8-direction ring const HALO_OFFSETS: [(f32, f32); 4] = [ @@ -2527,16 +2550,17 @@ impl MapView { }; // Rigid delta-rotation of the cached placement about the pivot // (heading-up nav): transform a copy once, draw slices from it. - // Camera-delta on the GPU: placed glyphs spin/squash about the - // pivot via uniforms — zero per-frame CPU work, the async - // re-place trues everything up (identity uniforms then). - self.draw_label.set_camera_delta( - cx.cx, - rot.cos(), - rot.sin(), - tilt_ratio, - pivot, - ); + // Camera-delta on the GPU: the EXACT delta between the cached + // placement's camera and now. The placement maps world points as + // rotate-about-pivot THEN y-compress by tilt_cos; the delta from + // (r0, t0) to (r1, t1) is S(t1)*R(d)*S(1/t0) — a general 2x2 (S + // and R do not commute), which is why a plain rotate+scale + // snapped visibly at every re-place in 2.5D. + let (dc, ds) = (rot.cos(), rot.sin()); + let t0 = self.label_cache_tilt_cos_for_delta; + let t1 = self.tilt_cos() as f32; + let m = [dc, -ds / t0, t1 * ds, t1 * dc / t0]; + self.draw_label.set_camera_delta(cx.cx, m, pivot); self.draw_label.begin_glyph_batch(cx); for i in 0..self.scratch_accepted_plans.len() { let (_, start, end, color_class, post_icon) = self.scratch_accepted_plans[i]; From 6ba9b7da658b669ae735ec2c0d140fa87b0889f8 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 14:23:26 +0200 Subject: [PATCH 04/10] =?UTF-8?q?llama:=20qwen3.5=20vision=20tower=20on=20?= =?UTF-8?q?makepad-ggml=20=E2=80=94=20oracle=20parity=20achieved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- libs/llama/Cargo.toml | 4 + libs/llama/src/bin/vlm_vision_probe.rs | 173 ++++++ libs/llama/src/lib.rs | 5 + libs/llama/src/runtime.rs | 1 + libs/llama/src/vision.rs | 803 +++++++++++++++++++++++++ tools/vlm_oracle/clip_dump.cpp | 5 +- 6 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 libs/llama/src/bin/vlm_vision_probe.rs create mode 100644 libs/llama/src/vision.rs diff --git a/libs/llama/Cargo.toml b/libs/llama/Cargo.toml index 90a2e187c..2737502d5 100644 --- a/libs/llama/Cargo.toml +++ b/libs/llama/Cargo.toml @@ -27,3 +27,7 @@ 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" diff --git a/libs/llama/src/bin/vlm_vision_probe.rs b/libs/llama/src/bin/vlm_vision_probe.rs new file mode 100644 index 000000000..d5d8fecbb --- /dev/null +++ b/libs/llama/src/bin/vlm_vision_probe.rs @@ -0,0 +1,173 @@ +// Parity probe: run the makepad vision tower on a PPM image and compare the +// preprocessed tensor + output embeddings against clip.cpp oracle dumps +// produced by tools/vlm_oracle/clip_dump. +// +// usage: vlm-vision-probe [reference_prefix] +// reference_prefix.preproc.bin / reference_prefix.embd.bin as written by clip_dump + +use makepad_llama::{preprocess_rgb8, VisionConfig, VisionTower}; + +use std::fs; +use std::time::Instant; + +fn read_ppm(path: &str) -> (Vec, usize, usize) { + let data = fs::read(path).expect("cannot read image"); + let mut fields = Vec::new(); + let mut pos = 0usize; + // P6 header: magic, width, height, maxval, single whitespace, then pixels + while fields.len() < 4 { + while pos < data.len() && (data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + if data[pos] == b'#' { + while pos < data.len() && data[pos] != b'\n' { + pos += 1; + } + continue; + } + let start = pos; + while pos < data.len() && !(data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + fields.push(String::from_utf8_lossy(&data[start..pos]).into_owned()); + } + pos += 1; // single whitespace after maxval + assert_eq!(fields[0], "P6", "not a P6 ppm"); + let w: usize = fields[1].parse().unwrap(); + let h: usize = fields[2].parse().unwrap(); + assert_eq!(fields[3], "255", "maxval must be 255"); + let rgb = data[pos..pos + w * h * 3].to_vec(); + (rgb, w, h) +} + +fn read_u32(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +fn read_f32s(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +fn compare(label: &str, ours: &[f32], theirs: &[f32]) { + assert_eq!(ours.len(), theirs.len(), "{label}: length mismatch"); + let mut max_abs = 0f32; + let mut max_at = 0usize; + let mut dot = 0f64; + let mut na = 0f64; + let mut nb = 0f64; + let mut sum_sq = 0f64; + for (i, (&x, &y)) in ours.iter().zip(theirs).enumerate() { + let d = (x - y).abs(); + if d > max_abs { + max_abs = d; + max_at = i; + } + dot += (x as f64) * (y as f64); + na += (x as f64) * (x as f64); + nb += (y as f64) * (y as f64); + sum_sq += (d as f64) * (d as f64); + } + let cos = dot / (na.sqrt() * nb.sqrt()).max(1e-30); + let rms = (sum_sq / ours.len() as f64).sqrt(); + println!( + "{label}: n {} max_abs {:.3e} (at {}: ours {:.5} ref {:.5}) rms {:.3e} cosine {:.8}", + ours.len(), + max_abs, + max_at, + ours[max_at], + theirs[max_at], + rms, + cos + ); +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!("usage: {} [reference_prefix]", args[0]); + std::process::exit(1); + } + let mmproj_path = &args[1]; + let image_path = &args[2]; + let ref_prefix = args.get(3); + + let (rgb, w, h) = read_ppm(image_path); + println!("image: {w} x {h}"); + + let gguf = makepad_llama::GgufFile::open(mmproj_path).expect("open mmproj"); + let config = VisionConfig::from_gguf(&gguf).expect("vision config"); + println!( + "config: {} layers, embd {}, heads {}, proj {}, image {} patch {} merge {}", + config.n_layers, + config.n_embd, + config.n_heads, + config.proj_dim, + config.image_size, + config.patch_size, + config.n_merge + ); + + let t0 = Instant::now(); + let prepared = preprocess_rgb8(&rgb, w, h, &config).expect("preprocess"); + println!( + "preprocessed: {} x {} ({} patches, {} tokens) in {:.1} ms", + prepared.width, + prepared.height, + prepared.n_patches(), + prepared.n_tokens(), + t0.elapsed().as_secs_f64() * 1000.0 + ); + + if let Some(prefix) = ref_prefix { + let bytes = fs::read(format!("{prefix}.preproc.bin")).expect("read ref preproc"); + let rnx = read_u32(&bytes, 0) as usize; + let rny = read_u32(&bytes, 4) as usize; + assert_eq!( + (rnx, rny), + (prepared.width, prepared.height), + "preprocessed size mismatch vs reference" + ); + let ref_pixels = read_f32s(&bytes[8..]); + compare("preproc", &prepared.pixels, &ref_pixels); + } + + let t1 = Instant::now(); + let mut tower = + VisionTower::load(mmproj_path, prepared.n_patches()).expect("load vision tower"); + println!("tower loaded in {:.2} s", t1.elapsed().as_secs_f64()); + + let t2 = Instant::now(); + let embd = tower.encode(&prepared).expect("encode"); + println!( + "encoded {} tokens x {} in {:.1} ms (includes graph compile)", + prepared.n_tokens(), + tower.config.proj_dim, + t2.elapsed().as_secs_f64() * 1000.0 + ); + let t3 = Instant::now(); + let embd2 = tower.encode(&prepared).expect("encode 2"); + println!("second encode {:.1} ms", t3.elapsed().as_secs_f64() * 1000.0); + assert_eq!(embd.len(), embd2.len()); + + let mean = embd.iter().map(|&v| v as f64).sum::() / embd.len() as f64; + let rms = (embd.iter().map(|&v| (v as f64) * (v as f64)).sum::() / embd.len() as f64) + .sqrt(); + print!("embd mean {mean:.6} rms {rms:.6} first8:"); + for v in &embd[..8] { + print!(" {v:.5}"); + } + println!(); + + if let Some(prefix) = ref_prefix { + let bytes = fs::read(format!("{prefix}.embd.bin")).expect("read ref embd"); + let n_tokens = read_u32(&bytes, 0) as usize; + let n_embd = read_u32(&bytes, 4) as usize; + assert_eq!(n_tokens, prepared.n_tokens(), "token count mismatch"); + assert_eq!(n_embd, tower.config.proj_dim, "embd dim mismatch"); + let ref_embd = read_f32s(&bytes[16..]); + compare("embd", &embd, &ref_embd); + } +} diff --git a/libs/llama/src/lib.rs b/libs/llama/src/lib.rs index 5052aac73..f3c437e0d 100644 --- a/libs/llama/src/lib.rs +++ b/libs/llama/src/lib.rs @@ -10,6 +10,7 @@ pub mod qwen35moe; pub mod qwen35moe_runtime; pub mod runtime; pub mod session; +pub mod vision; pub mod vocab; pub mod weights; @@ -115,5 +116,9 @@ pub use runtime::{ RmsNormSpec, }; pub use session::{LlamaGeneration, LlamaSession, LlamaSessionConfig, LlamaStopReason}; +pub use vision::{ + calc_size_preserved_ratio, preprocess_rgb8, vision_rope_positions, PreparedImage, + VisionConfig, VisionTower, +}; pub use vocab::{LlamaTextDecoder, LlamaTokenizerKind, LlamaVocab}; pub use weights::{GgufWeightLayout, LoadedGgufWeights}; diff --git a/libs/llama/src/runtime.rs b/libs/llama/src/runtime.rs index 45e58a229..99be7b2fe 100644 --- a/libs/llama/src/runtime.rs +++ b/libs/llama/src/runtime.rs @@ -8714,6 +8714,7 @@ mod tests { }, embedding_length: (s_v * h_v) as u32, input_norm_name: String::new(), + merged_input_proj_name: None, qkv_proj_name: String::new(), qkv_proj_scale_name: None, z_proj_name: String::new(), diff --git a/libs/llama/src/vision.rs b/libs/llama/src/vision.rs new file mode 100644 index 000000000..5283857be --- /dev/null +++ b/libs/llama/src/vision.rs @@ -0,0 +1,803 @@ +// Qwen3.5 vision tower (clip arch, projector "qwen3vl_merger") on makepad-ggml metal. +// +// Loads the separate mmproj GGUF, preprocesses an RGB image (smart-resize + +// normalize + block-major patch unfold), runs the 27-block SigLIP-style ViT +// with 2D vision rope and full bidirectional flash attention, and returns the +// projected [n_tokens x proj_dim] embeddings ready for LLM prefill injection. +// +// Reference implementation: llama.cpp tools/mtmd (clip.cpp models/qwen3vl.cpp, +// mtmd-image.cpp mtmd_image_preprocessor_dyn_size). Preprocessing here is an +// exact port; the graph mirrors the reference node-for-node with flash +// attention enabled. Parity is validated by the vlm-vision-probe bin against +// dumps from tools/vlm_oracle/clip_dump. + +use std::collections::BTreeMap; + +use makepad_ggml::{ + backend::metal::{ + prepare_graph, BufferStorageMode, MetalGraphSession, MetalGraphTensorWrite, MetalRuntime, + }, + BufferUsage, Context, Graph, Op, Prec, ScaleMode, TensorId, TensorType, UnaryOp, + GGML_ROPE_TYPE_VISION, +}; + +use crate::error::{LlamaError, Result}; +use crate::gguf::{GgufArray, GgufFile, GgufValue}; +use crate::weights::{GgufWeightLayout, LoadedGgufWeights}; + +#[derive(Clone, Debug)] +pub struct VisionConfig { + pub image_size: usize, + pub patch_size: usize, + pub n_embd: usize, + pub n_ffn: usize, + pub n_layers: usize, + pub n_heads: usize, + pub proj_dim: usize, + pub n_merge: usize, + pub eps: f32, + pub image_mean: [f32; 3], + pub image_std: [f32; 3], + pub min_pixels: usize, + pub max_pixels: usize, +} + +impl VisionConfig { + pub fn from_gguf(gguf: &GgufFile) -> Result { + let arch = required_string(gguf, "general.architecture")?; + if arch != "clip" { + return Err(LlamaError::unsupported(format!( + "mmproj general.architecture must be 'clip', got '{arch}'" + ))); + } + let projector = required_string(gguf, "clip.projector_type")?; + if projector != "qwen3vl_merger" { + return Err(LlamaError::unsupported(format!( + "unsupported clip.projector_type '{projector}' (expected qwen3vl_merger)" + ))); + } + let patch_size = required_usize(gguf, "clip.vision.patch_size")?; + let n_merge = optional_usize(gguf, "clip.vision.spatial_merge_size")?.unwrap_or(2); + // reference defaults: set_limit_image_tokens(8, 4096) — one output token + // covers (patch_size * n_merge)^2 pixels + let token_pixels = (patch_size * n_merge) * (patch_size * n_merge); + let min_pixels = + optional_usize(gguf, "clip.vision.image_min_pixels")?.unwrap_or(8 * token_pixels); + let max_pixels = + optional_usize(gguf, "clip.vision.image_max_pixels")?.unwrap_or(4096 * token_pixels); + Ok(Self { + image_size: required_usize(gguf, "clip.vision.image_size")?, + patch_size, + n_embd: required_usize(gguf, "clip.vision.embedding_length")?, + n_ffn: required_usize(gguf, "clip.vision.feed_forward_length")?, + n_layers: required_usize(gguf, "clip.vision.block_count")?, + n_heads: required_usize(gguf, "clip.vision.attention.head_count")?, + proj_dim: required_usize(gguf, "clip.vision.projection_dim")?, + n_merge, + eps: optional_f32(gguf, "clip.vision.attention.layer_norm_epsilon")?.unwrap_or(1e-6), + image_mean: required_f32_3(gguf, "clip.vision.image_mean")?, + image_std: required_f32_3(gguf, "clip.vision.image_std")?, + min_pixels, + max_pixels, + }) + } + + pub fn head_dim(&self) -> usize { + self.n_embd / self.n_heads + } + + /// Pixels per merged output token edge (32 for qwen3.5). + pub fn align_size(&self) -> usize { + self.patch_size * self.n_merge + } +} + +/// A preprocessed image ready for the vision tower. +pub struct PreparedImage { + /// Resized width/height in pixels (multiples of align_size). + pub width: usize, + pub height: usize, + /// Normalized resized image, interleaved RGB f32 — layout identical to the + /// reference clip_image_f32 buffer, kept for parity testing. + pub pixels: Vec, + /// Patch matrix [n_patches rows x (3*patch*patch) cols] in block-major + /// token order; each row is (channel, ky, kx) with kx fastest, matching + /// the conv weight layout. + pub patches: Vec, + /// Grid size in patches. + pub grid_w: usize, + pub grid_h: usize, +} + +impl PreparedImage { + pub fn n_patches(&self) -> usize { + self.grid_w * self.grid_h + } + + /// Output tokens after 2x2 spatial merge. + pub fn n_tokens(&self) -> usize { + self.n_patches() / 4 + } + + pub fn tokens_w(&self) -> usize { + self.grid_w / 2 + } + + pub fn tokens_h(&self) -> usize { + self.grid_h / 2 + } +} + +/// Port of mtmd-image.cpp img_tool::calc_size_preserved_ratio. +pub fn calc_size_preserved_ratio( + width: usize, + height: usize, + align_size: usize, + min_pixels: usize, + max_pixels: usize, +) -> (usize, usize) { + let f = align_size as f32; + let round_by = |x: f32| ((x / f).round() as usize) * align_size; + let ceil_by = |x: f32| ((x / f).ceil() as usize) * align_size; + let floor_by = |x: f32| ((x / f).floor() as usize) * align_size; + + let mut w_bar = round_by(width as f32).max(align_size); + let mut h_bar = round_by(height as f32).max(align_size); + + if h_bar * w_bar > max_pixels { + let beta = ((height * width) as f32 / max_pixels as f32).sqrt(); + h_bar = floor_by(height as f32 / beta).max(align_size); + w_bar = floor_by(width as f32 / beta).max(align_size); + } else if h_bar * w_bar < min_pixels { + let beta = (min_pixels as f32 / (height * width) as f32).sqrt(); + h_bar = ceil_by(height as f32 * beta); + w_bar = ceil_by(width as f32 * beta); + } + (w_bar, h_bar) +} + +/// Port of mtmd-image.cpp img_tool::resize_bilinear with the (src-1)/target +/// ratio convention. Interior arithmetic is f64: the reference binary promotes +/// to double, and matching it keeps u8 truncation boundaries identical +/// (validated: 7 of 589824 values off by one step on the resize test image, +/// vs 1131 with f32). +fn resize_bilinear_u8(src: &[u8], sw: usize, sh: usize, tw: usize, th: usize) -> Vec { + assert!(sw >= 2 && sh >= 2); + let mut dst = vec![0u8; tw * th * 3]; + let x_ratio = (sw - 1) as f64 / tw as f64; + let y_ratio = (sh - 1) as f64 / th as f64; + for y in 0..th { + for x in 0..tw { + let px = x_ratio * x as f64; + let py = y_ratio * y as f64; + let x0 = (px as usize).min(sw - 2); + let y0 = (py as usize).min(sh - 2); + let xl = px - x0 as f64; + let yl = py - y0 as f64; + for c in 0..3 { + let s00 = src[3 * (y0 * sw + x0) + c] as f64; + let s01 = src[3 * (y0 * sw + x0 + 1) + c] as f64; + let s10 = src[3 * ((y0 + 1) * sw + x0) + c] as f64; + let s11 = src[3 * ((y0 + 1) * sw + x0 + 1) + c] as f64; + let top = s00 + (s01 - s00) * xl; + let bottom = s10 + (s11 - s10) * xl; + dst[3 * (y * tw + x) + c] = (top + (bottom - top) * yl) as u8; + } + } + } + dst +} + +/// Port of img_tool::resize with add_padding=true (the qwen path): scale to +/// fit, center, pad with black. For aspect-matching targets this reduces to a +/// plain resize. +fn resize_pad_u8(src: &[u8], sw: usize, sh: usize, tw: usize, th: usize) -> Vec { + if sw == tw && sh == th { + return src.to_vec(); + } + let scale_w = tw as f32 / sw as f32; + let scale_h = th as f32 / sh as f32; + let scale = scale_w.min(scale_h); + let new_w = ((sw as f32 * scale).ceil() as usize).min(tw); + let new_h = ((sh as f32 * scale).ceil() as usize).min(th); + let resized = resize_bilinear_u8(src, sw, sh, new_w, new_h); + if new_w == tw && new_h == th { + return resized; + } + let mut dst = vec![0u8; tw * th * 3]; + let off_x = (tw - new_w) / 2; + let off_y = (th - new_h) / 2; + for y in 0..new_h { + let src_row = &resized[3 * y * new_w..3 * (y + 1) * new_w]; + let dst_start = 3 * ((y + off_y) * tw + off_x); + dst[dst_start..dst_start + 3 * new_w].copy_from_slice(src_row); + } + dst +} + +/// Preprocess an interleaved RGB8 image for the vision tower. +pub fn preprocess_rgb8( + rgb: &[u8], + width: usize, + height: usize, + config: &VisionConfig, +) -> Result { + if rgb.len() != width * height * 3 { + return Err(LlamaError::format(format!( + "rgb buffer size {} does not match {}x{}x3", + rgb.len(), + width, + height + ))); + } + let align = config.align_size(); + let (tw, th) = calc_size_preserved_ratio( + width, + height, + align, + config.min_pixels, + config.max_pixels, + ); + let resized = resize_pad_u8(rgb, width, height, tw, th); + + // normalize: (v/255 - mean) / std, interleaved layout kept for parity checks + let mut pixels = vec![0f32; tw * th * 3]; + for i in 0..tw * th { + for c in 0..3 { + let v = resized[3 * i + c] as f32 / 255.0; + pixels[3 * i + c] = (v - config.image_mean[c]) / config.image_std[c]; + } + } + + let patch = config.patch_size; + let grid_w = tw / patch; + let grid_h = th / patch; + let patch_len = 3 * patch * patch; + let n_patches = grid_w * grid_h; + let mut patches = vec![0f32; n_patches * patch_len]; + + // block-major token order: iterate 2x2 blocks row-major, then the 4 + // patches within each block row-major — matches the qwen3vl conv output + // rearrangement, so 4 consecutive rows here form one merged output token. + // Row layout is (c, ky, kx) with kx fastest, matching the flattened + // [16,16,3,out] conv weight. + let mut row = 0usize; + for by in (0..grid_h).step_by(2) { + for bx in (0..grid_w).step_by(2) { + for dy in 0..2 { + for dx in 0..2 { + let px0 = (bx + dx) * patch; + let py0 = (by + dy) * patch; + let out = &mut patches[row * patch_len..(row + 1) * patch_len]; + let mut k = 0usize; + for c in 0..3 { + for ky in 0..patch { + let src_row = 3 * ((py0 + ky) * tw + px0); + for kx in 0..patch { + out[k] = pixels[src_row + 3 * kx + c]; + k += 1; + } + } + } + row += 1; + } + } + } + } + + Ok(PreparedImage { + width: tw, + height: th, + pixels, + patches, + grid_w, + grid_h, + }) +} + +/// M-RoPE position ids for the vision graph: 4 planes [y, x, y, x] over the +/// block-major patch sequence — port of the clip.cpp position filling. +pub fn vision_rope_positions(grid_w: usize, grid_h: usize) -> Vec { + let n = grid_w * grid_h; + let mut positions = vec![0i32; n * 4]; + let mut ptr = 0usize; + for y in (0..grid_h).step_by(2) { + for x in (0..grid_w).step_by(2) { + for dy in 0..2 { + for dx in 0..2 { + positions[ptr] = (y + dy) as i32; + positions[n + ptr] = (x + dx) as i32; + positions[2 * n + ptr] = (y + dy) as i32; + positions[3 * n + ptr] = (x + dx) as i32; + ptr += 1; + } + } + } + } + positions +} + +/// Row-major patch index for each block-major token position — used to gather +/// interpolated position embeddings into sequence order. +fn block_order_row_indices(grid_w: usize, grid_h: usize) -> Vec { + let mut idx = Vec::with_capacity(grid_w * grid_h); + for by in (0..grid_h).step_by(2) { + for bx in (0..grid_w).step_by(2) { + for dy in 0..2 { + for dx in 0..2 { + idx.push(((by + dy) * grid_w + (bx + dx)) as i32); + } + } + } + } + idx +} + +struct VisionGraph { + session: MetalGraphSession, + input_patches: TensorId, + input_positions: TensorId, + input_block_index: TensorId, + output: TensorId, + grid_w: usize, + grid_h: usize, +} + +pub struct VisionTower { + pub config: VisionConfig, + weights: LoadedGgufWeights, + runtime: MetalRuntime, + graphs: BTreeMap<(usize, usize), VisionGraph>, +} + +impl VisionTower { + /// Load the mmproj GGUF. `max_patches` bounds the activation arena that is + /// reserved up front (patches = output tokens * 4). + pub fn load(path: &str, max_patches: usize) -> Result { + let gguf = GgufFile::open(path)?; + let config = VisionConfig::from_gguf(&gguf)?; + let layout = GgufWeightLayout::from_tensors(gguf.tensors.iter().cloned())?; + let extra = Self::activation_bytes_estimate(&config, max_patches); + let weights = layout.allocate_and_load_with_extra(&gguf, extra)?; + let runtime = MetalRuntime::new().map_err(LlamaError::format)?; + Ok(Self { + config, + weights, + runtime, + graphs: BTreeMap::new(), + }) + } + + fn activation_bytes_estimate(config: &VisionConfig, max_patches: usize) -> usize { + // per patch per layer: ~18 full-width f32 nodes + 3 ffn-width nodes + // + 2 f16 casts; generous 1.5x headroom on top + let full = 4 * config.n_embd; + let ffn = 4 * config.n_ffn; + let per_patch_layer = 18 * full + 3 * ffn + config.n_embd; + let per_patch = per_patch_layer * config.n_layers + 16 * full + 8 * config.proj_dim; + (max_patches * per_patch) * 3 / 2 + (64 << 20) + } + + fn tensor(&self, name: &str) -> Result { + self.weights.require_tensor_id(name) + } + + /// Encode a preprocessed image; returns [n_tokens * proj_dim] f32. + pub fn encode(&mut self, image: &PreparedImage) -> Result> { + let key = (image.grid_w, image.grid_h); + if !self.graphs.contains_key(&key) { + let graph = self.build_graph(image.grid_w, image.grid_h)?; + self.graphs.insert(key, graph); + } + let graph = self.graphs.get(&key).unwrap(); + + let positions = vision_rope_positions(image.grid_w, image.grid_h); + let block_index = block_order_row_indices(image.grid_w, image.grid_h); + + let patches_bytes = f32s_as_bytes(&image.patches); + let positions_bytes = i32s_as_bytes(&positions); + let block_index_bytes = i32s_as_bytes(&block_index); + let writes = vec![ + MetalGraphTensorWrite { + tensor_id: graph.input_patches, + bytes: &patches_bytes, + }, + MetalGraphTensorWrite { + tensor_id: graph.input_positions, + bytes: &positions_bytes, + }, + MetalGraphTensorWrite { + tensor_id: graph.input_block_index, + bytes: &block_index_bytes, + }, + ]; + let execution = graph + .session + .execute(&self.weights.ctx, &writes, &[graph.output]) + .map_err(LlamaError::format)?; + let bytes = execution + .outputs + .get(&graph.output) + .ok_or_else(|| LlamaError::format("vision graph returned no output"))?; + Ok(bytes_to_f32s(bytes)) + } + + fn build_graph(&mut self, grid_w: usize, grid_h: usize) -> Result { + let cfg = self.config.clone(); + let n_patches = grid_w * grid_h; + let n_embd = cfg.n_embd as i64; + let n_heads = cfg.n_heads as i64; + let d_head = cfg.head_dim() as i64; + let patch_len = (3 * cfg.patch_size * cfg.patch_size) as i64; + let n = n_patches as i64; + let eps = cfg.eps; + + let w_patch0 = self.tensor("v.patch_embd.weight")?; + let w_patch1 = self.tensor("v.patch_embd.weight.1")?; + let b_patch = self.tensor("v.patch_embd.bias")?; + let pos_embd = self.tensor("v.position_embd.weight")?; + let post_ln_w = self.tensor("v.post_ln.weight")?; + let post_ln_b = self.tensor("v.post_ln.bias")?; + let mm0_w = self.tensor("mm.0.weight")?; + let mm0_b = self.tensor("mm.0.bias")?; + let mm2_w = self.tensor("mm.2.weight")?; + let mm2_b = self.tensor("mm.2.bias")?; + + let ctx = &mut self.weights.ctx; + let a = BufferUsage::Activations; + let err = LlamaError::format; + + let input_patches = ctx + .new_named_tensor( + "vision.inp_patches", + TensorType::F32, + 2, + &[patch_len, n], + a, + ) + .map_err(err)?; + let input_positions = ctx + .new_named_tensor("vision.inp_positions", TensorType::I32, 1, &[n * 4], a) + .map_err(err)?; + let input_block_index = ctx + .new_named_tensor("vision.inp_block_index", TensorType::I32, 1, &[n], a) + .map_err(err)?; + + // patchify: the two temporal conv kernels both apply to the same still + // frame; conv == matmul against the flattened [patch_len, n_embd] weight + let w0 = ctx.reshape(w_patch0, &[patch_len, n_embd]).map_err(err)?; + let w1 = ctx.reshape(w_patch1, &[patch_len, n_embd]).map_err(err)?; + let e0 = ctx.mul_mat(w0, input_patches, a).map_err(err)?; + let e1 = ctx.mul_mat(w1, input_patches, a).map_err(err)?; + let mut cur = ctx.binary_like_a(Op::Add, e0, e1, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, b_patch, a).map_err(err)?; + + // learned position embeddings, bilinearly interpolated from the native + // grid, gathered into block-major sequence order + { + let n_per_side = { + let t = ctx + .tensor(pos_embd) + .ok_or_else(|| LlamaError::format("missing position embedding tensor"))?; + (t.ne[1] as f64).sqrt() as i64 + }; + let mut pos = pos_embd; + if n_per_side != grid_w as i64 || n_per_side != grid_h as i64 { + pos = ctx + .reshape(pos, &[n_embd, n_per_side, n_per_side]) + .map_err(err)?; + pos = ctx.permute(pos, [2, 0, 1, 3]).map_err(err)?; + pos = ctx + .cont_3d(pos, n_per_side, n_per_side, n_embd) + .map_err(err)?; + pos = ctx + .upscale_ext( + pos, + grid_w as i64, + grid_h as i64, + n_embd, + 1, + ScaleMode::Bilinear, + false, + true, + a, + ) + .map_err(err)?; + pos = ctx.permute(pos, [1, 2, 0, 3]).map_err(err)?; + pos = ctx.cont_2d(pos, n_embd, n).map_err(err)?; + } + let pos_seq = ctx.get_rows(pos, input_block_index, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, pos_seq, a).map_err(err)?; + } + ctx.set_tensor_name(cur, "vision.inp_pos_emb").map_err(err)?; + + let scale = 1.0 / (d_head as f32).sqrt(); + let rope_sections = [(d_head / 4) as i32; 4]; + + for il in 0..cfg.n_layers { + let name = |suffix: &str| format!("v.blk.{il}.{suffix}"); + let ln1_w = self_tensor(&self.weights, &name("ln1.weight"))?; + let ln1_b = self_tensor(&self.weights, &name("ln1.bias"))?; + let ln2_w = self_tensor(&self.weights, &name("ln2.weight"))?; + let ln2_b = self_tensor(&self.weights, &name("ln2.bias"))?; + let qkv_w = self_tensor(&self.weights, &name("attn_qkv.weight"))?; + let qkv_b = self_tensor(&self.weights, &name("attn_qkv.bias"))?; + let out_w = self_tensor(&self.weights, &name("attn_out.weight"))?; + let out_b = self_tensor(&self.weights, &name("attn_out.bias"))?; + let up_w = self_tensor(&self.weights, &name("ffn_up.weight"))?; + let up_b = self_tensor(&self.weights, &name("ffn_up.bias"))?; + let down_w = self_tensor(&self.weights, &name("ffn_down.weight"))?; + let down_b = self_tensor(&self.weights, &name("ffn_down.bias"))?; + + let ctx = &mut self.weights.ctx; + let residual = cur; + + // ln1 (LayerNorm with weight + bias) + let mut x = ctx.norm_eps(cur, eps, a).map_err(err)?; + x = ctx.binary_like_a(Op::Mul, x, ln1_w, a).map_err(err)?; + x = ctx.binary_like_a(Op::Add, x, ln1_b, a).map_err(err)?; + + // fused qkv, split via weight/bias row slices + let (q, k, v) = { + let row_bytes_w = weight_row_bytes(ctx, qkv_w)?; + let bias_elem_bytes = 4usize; // qkv bias is f32 + let mut parts = Vec::with_capacity(3); + for part in 0..3 { + let w = ctx + .view_2d( + qkv_w, + n_embd, + n_embd, + row_bytes_w, + part * n_embd as usize * row_bytes_w, + ) + .map_err(err)?; + let b = ctx + .view_1d(qkv_b, n_embd, part * n_embd as usize * bias_elem_bytes) + .map_err(err)?; + let mut p = ctx.mul_mat(w, x, a).map_err(err)?; + p = ctx.binary_like_a(Op::Add, p, b, a).map_err(err)?; + parts.push(p); + } + (parts[0], parts[1], parts[2]) + }; + + // [n_embd, n] -> [d_head, n_heads, n], 2D vision rope on q/k + let q3 = ctx.reshape(q, &[d_head, n_heads, n]).map_err(err)?; + let k3 = ctx.reshape(k, &[d_head, n_heads, n]).map_err(err)?; + let v3 = ctx.reshape(v, &[d_head, n_heads, n]).map_err(err)?; + let q3 = ctx + .rope_multi( + q3, + input_positions, + None, + (d_head / 2) as i32, + rope_sections, + GGML_ROPE_TYPE_VISION, + 32768, + 10000.0, + 1.0, + 0.0, + 1.0, + 32.0, + 1.0, + a, + ) + .map_err(err)?; + let k3 = ctx + .rope_multi( + k3, + input_positions, + None, + (d_head / 2) as i32, + rope_sections, + GGML_ROPE_TYPE_VISION, + 32768, + 10000.0, + 1.0, + 0.0, + 1.0, + 32.0, + 1.0, + a, + ) + .map_err(err)?; + + // full bidirectional flash attention (no mask), f16 K/V + let q_p = ctx.permute(q3, [0, 2, 1, 3]).map_err(err)?; + let k_p = ctx.permute(k3, [0, 2, 1, 3]).map_err(err)?; + let v_p = ctx.permute(v3, [0, 2, 1, 3]).map_err(err)?; + let k_h = new_f16_like(ctx, k_p, &[d_head, n, n_heads, 1])?; + let k_h = ctx.cpy(k_p, k_h, a).map_err(err)?; + let v_h = new_f16_like(ctx, v_p, &[d_head, n, n_heads, 1])?; + let v_h = ctx.cpy(v_p, v_h, a).map_err(err)?; + let mut attn = ctx + .flash_attn_ext(q_p, k_h, v_h, None, scale, 0.0, 0.0, a) + .map_err(err)?; + ctx.flash_attn_ext_set_prec(attn, Prec::F32).map_err(err)?; + attn = ctx.reshape(attn, &[n_embd, n]).map_err(err)?; + + let mut o = ctx.mul_mat(out_w, attn, a).map_err(err)?; + o = ctx.binary_like_a(Op::Add, o, out_b, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, o, residual, a).map_err(err)?; + + // ln2 + ffn + let residual2 = cur; + let mut f = ctx.norm_eps(cur, eps, a).map_err(err)?; + f = ctx.binary_like_a(Op::Mul, f, ln2_w, a).map_err(err)?; + f = ctx.binary_like_a(Op::Add, f, ln2_b, a).map_err(err)?; + f = ctx.mul_mat(up_w, f, a).map_err(err)?; + f = ctx.binary_like_a(Op::Add, f, up_b, a).map_err(err)?; + f = ctx.unary(f, UnaryOp::Gelu, a).map_err(err)?; + f = ctx.mul_mat(down_w, f, a).map_err(err)?; + f = ctx.binary_like_a(Op::Add, f, down_b, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, f, residual2, a).map_err(err)?; + ctx.set_tensor_name(cur, &format!("vision.blk{il}.out")) + .map_err(err)?; + } + + let ctx = &mut self.weights.ctx; + + // post layernorm + cur = ctx.norm_eps(cur, eps, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Mul, cur, post_ln_w, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, post_ln_b, a).map_err(err)?; + + // merger: concat each 2x2 block (4 consecutive tokens) then 2-layer MLP + let merge = (cfg.n_merge * cfg.n_merge) as i64; + cur = ctx.reshape(cur, &[n_embd * merge, n / merge]).map_err(err)?; + cur = ctx.mul_mat(mm0_w, cur, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, mm0_b, a).map_err(err)?; + cur = ctx.unary(cur, UnaryOp::Gelu, a).map_err(err)?; + cur = ctx.mul_mat(mm2_w, cur, a).map_err(err)?; + cur = ctx.binary_like_a(Op::Add, cur, mm2_b, a).map_err(err)?; + ctx.set_tensor_name(cur, "vision.output").map_err(err)?; + + let mut graph = Graph::new(); + graph + .build_forward_expand(&self.weights.ctx, cur) + .map_err(err)?; + let prepared = prepare_graph(&self.weights.ctx, &graph, self.runtime.features()) + .map_err(err)?; + let session = MetalGraphSession::from_runtime( + self.runtime.clone(), + &self.weights.ctx, + &prepared, + BufferStorageMode::Shared, + BufferStorageMode::Shared, + ) + .map_err(err)?; + + Ok(VisionGraph { + session, + input_patches, + input_positions, + input_block_index, + output: cur, + grid_w, + grid_h, + }) + } +} + +fn self_tensor(weights: &LoadedGgufWeights, name: &str) -> Result { + weights.require_tensor_id(name) +} + +fn weight_row_bytes(ctx: &Context, id: TensorId) -> Result { + let t = ctx + .tensor(id) + .ok_or_else(|| LlamaError::format("missing qkv weight tensor"))?; + Ok(t.nb[1]) +} + +fn new_f16_like(ctx: &mut Context, _src: TensorId, ne: &[i64]) -> Result { + ctx.new_tensor(TensorType::F16, ne.len(), ne, BufferUsage::Activations) + .map_err(LlamaError::format) +} + +fn f32s_as_bytes(values: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(values.len() * 4); + for v in values { + bytes.extend_from_slice(&v.to_le_bytes()); + } + bytes +} + +fn i32s_as_bytes(values: &[i32]) -> Vec { + let mut bytes = Vec::with_capacity(values.len() * 4); + for v in values { + bytes.extend_from_slice(&v.to_le_bytes()); + } + bytes +} + +fn bytes_to_f32s(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +fn required_string(gguf: &GgufFile, key: &str) -> Result { + match gguf.get_value(key) { + Some(GgufValue::String(s)) => Ok(String::from_utf8_lossy(s.as_bytes()).into_owned()), + Some(_) => Err(LlamaError::format(format!("key '{key}' is not a string"))), + None => Err(LlamaError::format(format!("missing key '{key}'"))), + } +} + +fn required_usize(gguf: &GgufFile, key: &str) -> Result { + optional_usize(gguf, key)? + .ok_or_else(|| LlamaError::format(format!("missing key '{key}'"))) +} + +fn optional_usize(gguf: &GgufFile, key: &str) -> Result> { + match gguf.get_value(key) { + Some(GgufValue::Uint32(v)) => Ok(Some(*v as usize)), + Some(GgufValue::Int32(v)) => Ok(Some(*v as usize)), + Some(GgufValue::Uint64(v)) => Ok(Some(*v as usize)), + Some(_) => Err(LlamaError::format(format!("key '{key}' is not an integer"))), + None => Ok(None), + } +} + +fn optional_f32(gguf: &GgufFile, key: &str) -> Result> { + match gguf.get_value(key) { + Some(GgufValue::Float32(v)) => Ok(Some(*v)), + Some(_) => Err(LlamaError::format(format!("key '{key}' is not a float"))), + None => Ok(None), + } +} + +fn required_f32_3(gguf: &GgufFile, key: &str) -> Result<[f32; 3]> { + match gguf.get_value(key) { + Some(GgufValue::Array(GgufArray::Float32(v))) if v.len() >= 3 => Ok([v[0], v[1], v[2]]), + Some(_) => Err(LlamaError::format(format!( + "key '{key}' is not a float array of 3" + ))), + None => Err(LlamaError::format(format!("missing key '{key}'"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calc_size_matches_reference_examples() { + // 512x384 already aligned -> unchanged + assert_eq!(calc_size_preserved_ratio(512, 384, 32, 8192, 4194304), (512, 384)); + // 500x375 rounds to nearest multiple of 32 (both round up here) + assert_eq!(calc_size_preserved_ratio(500, 375, 32, 8192, 4194304), (512, 384)); + // huge image scales down under max_pixels + let (w, h) = calc_size_preserved_ratio(3840, 2160, 32, 8192, 4194304); + assert!(w * h <= 4194304); + assert_eq!(w % 32, 0); + assert_eq!(h % 32, 0); + } + + #[test] + fn block_order_covers_all_patches_once() { + let idx = block_order_row_indices(6, 4); + let mut seen = vec![false; 24]; + for &i in &idx { + assert!(!seen[i as usize]); + seen[i as usize] = true; + } + assert!(seen.iter().all(|&s| s)); + // first block: (0,0) (0,1) (1,0) (1,1) in row-major patch coords + assert_eq!(&idx[0..4], &[0, 1, 6, 7]); + } + + #[test] + fn rope_positions_follow_block_order() { + let pos = vision_rope_positions(4, 2); + let n = 8; + // token 0..3 = block at (0,0): (y,x) = (0,0) (0,1) (1,0) (1,1) + assert_eq!(&pos[0..4], &[0, 0, 1, 1]); // y plane + assert_eq!(&pos[n..n + 4], &[0, 1, 0, 1]); // x plane + assert_eq!(pos[2 * n], pos[0]); + assert_eq!(pos[3 * n], pos[n]); + } +} diff --git a/tools/vlm_oracle/clip_dump.cpp b/tools/vlm_oracle/clip_dump.cpp index 6cce65a10..0bb7a6a69 100644 --- a/tools/vlm_oracle/clip_dump.cpp +++ b/tools/vlm_oracle/clip_dump.cpp @@ -56,15 +56,18 @@ int main(int argc, char ** argv) { 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 = CLIP_FLASH_ATTN_TYPE_DISABLED; // deterministic reference path + 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); From 5f81ebd2a857bbbc11a44d31f2e559790222252c Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 14:31:44 +0200 Subject: [PATCH 05/10] =?UTF-8?q?llama:=20image-span=20prefill=20+=20M-RoP?= =?UTF-8?q?E=20=E2=80=94=20full=20VLM=20pipeline=20matches=20llama.cpp=20e?= =?UTF-8?q?xactly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- libs/llama/Cargo.toml | 4 + libs/llama/src/bin/vlm_probe.rs | 148 ++++++++++++++++++++++++++++++++ libs/llama/src/model.rs | 16 ++++ libs/llama/src/runtime.rs | 12 ++- libs/llama/src/session.rs | 148 +++++++++++++++++++++++++++++++- libs/llama/src/vocab.rs | 5 ++ 6 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 libs/llama/src/bin/vlm_probe.rs diff --git a/libs/llama/Cargo.toml b/libs/llama/Cargo.toml index 2737502d5..770e0856a 100644 --- a/libs/llama/Cargo.toml +++ b/libs/llama/Cargo.toml @@ -31,3 +31,7 @@ path = "src/bin/llama_batch_probe.rs" [[bin]] name = "vlm-vision-probe" path = "src/bin/vlm_vision_probe.rs" + +[[bin]] +name = "vlm-probe" +path = "src/bin/vlm_probe.rs" diff --git a/libs/llama/src/bin/vlm_probe.rs b/libs/llama/src/bin/vlm_probe.rs new file mode 100644 index 000000000..7be1f2067 --- /dev/null +++ b/libs/llama/src/bin/vlm_probe.rs @@ -0,0 +1,148 @@ +// End-to-end VLM probe: image + question -> answer, fully on makepad-ggml. +// +// Pipeline: preprocess -> vision tower -> <|vision_start|> [embeddings] +// <|vision_end|> ChatML wrap -> M-RoPE prefill -> greedy generation. +// Compare against: llama-mtmd-cli -m --mmproj --image -p +// +// usage: vlm-probe [--max-new-tokens N] [--max-context N] + +use makepad_llama::{ + preprocess_rgb8, LlamaSession, LlamaSessionConfig, VisionConfig, VisionTower, +}; +use std::io::Write; +use std::time::Instant; + +fn read_ppm(path: &str) -> (Vec, usize, usize) { + let data = std::fs::read(path).expect("cannot read image"); + let mut fields = Vec::new(); + let mut pos = 0usize; + while fields.len() < 4 { + while pos < data.len() && (data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + if data[pos] == b'#' { + while pos < data.len() && data[pos] != b'\n' { + pos += 1; + } + continue; + } + let start = pos; + while pos < data.len() && !(data[pos] as char).is_ascii_whitespace() { + pos += 1; + } + fields.push(String::from_utf8_lossy(&data[start..pos]).into_owned()); + } + pos += 1; + assert_eq!(fields[0], "P6"); + let w: usize = fields[1].parse().unwrap(); + let h: usize = fields[2].parse().unwrap(); + assert_eq!(fields[3], "255"); + (data[pos..pos + w * h * 3].to_vec(), w, h) +} + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 5 { + eprintln!( + "usage: {} [--max-new-tokens N] [--max-context N]", + args[0] + ); + std::process::exit(1); + } + let model_path = &args[1]; + let mmproj_path = &args[2]; + let image_path = &args[3]; + let question = &args[4]; + let mut max_new_tokens = 256usize; + let mut max_context = 8192u32; + let mut i = 5; + while i < args.len() { + match args[i].as_str() { + "--max-new-tokens" => { + i += 1; + max_new_tokens = args[i].parse().unwrap(); + } + "--max-context" => { + i += 1; + max_context = args[i].parse().unwrap(); + } + other => panic!("unknown arg {other}"), + } + i += 1; + } + + // 1. vision leg + let (rgb, w, h) = read_ppm(image_path); + let gguf = makepad_llama::GgufFile::open(mmproj_path).expect("open mmproj"); + let vision_config = VisionConfig::from_gguf(&gguf).expect("vision config"); + let t0 = Instant::now(); + let prepared = preprocess_rgb8(&rgb, w, h, &vision_config).expect("preprocess"); + let mut tower = VisionTower::load(mmproj_path, prepared.n_patches()).expect("vision tower"); + let embeddings = tower.encode(&prepared).expect("encode image"); + eprintln!( + "vision: {}x{} -> {}x{} tokens ({}) in {:.2}s", + w, + h, + prepared.tokens_w(), + prepared.tokens_h(), + prepared.n_tokens(), + t0.elapsed().as_secs_f64() + ); + + // 2. text leg + let t1 = Instant::now(); + let mut session = LlamaSession::load( + model_path, + LlamaSessionConfig { + max_context: Some(max_context), + ..LlamaSessionConfig::default() + }, + ) + .expect("load llama session"); + eprintln!("session loaded in {:.2}s", t1.elapsed().as_secs_f64()); + + // 3. ChatML prefill with the image span; non-thinking prefill for + // deterministic short answers + let prefix = "<|im_start|>user\n<|vision_start|>"; + let suffix = format!( + "<|vision_end|>{question}<|im_end|>\n<|im_start|>assistant\n\n\n\n\n" + ); + + let t2 = Instant::now(); + let prefix_ids = session.vocab().tokenize(prefix, false, true).expect("tokenize prefix"); + session.append_tokens(&prefix_ids).expect("prefill prefix"); + session + .append_image_embeddings(&embeddings, prepared.tokens_w(), prepared.tokens_h()) + .expect("prefill image"); + let suffix_ids = session.vocab().tokenize(&suffix, false, true).expect("tokenize suffix"); + session.append_tokens(&suffix_ids).expect("prefill suffix"); + eprintln!( + "prefill: {} tokens ({} image) in {:.2}s", + session.token_count(), + prepared.n_tokens(), + t2.elapsed().as_secs_f64() + ); + + // 4. greedy generation + let t3 = Instant::now(); + let mut generated = 0usize; + let mut decoder = session.vocab().text_decoder(); + while generated < max_new_tokens { + let Some(token) = session.next_greedy_token().expect("generate") else { + break; + }; + generated += 1; + if let Some(text) = decoder.push_token(session.vocab(), token) { + print!("{text}"); + std::io::stdout().flush().ok(); + } + } + println!(); + let dt = t3.elapsed().as_secs_f64(); + eprintln!( + "generated {} tokens in {:.2}s ({:.1} tok/s)", + generated, + dt, + generated as f64 / dt.max(1e-9) + ); +} diff --git a/libs/llama/src/model.rs b/libs/llama/src/model.rs index faad4eaa6..2a6fda47d 100644 --- a/libs/llama/src/model.rs +++ b/libs/llama/src/model.rs @@ -180,6 +180,22 @@ impl LlamaModel { ))) } + pub fn embedding_length(&self) -> Result { + if let Some(cfg) = &self.qwen35 { + return Ok(cfg.embedding_length); + } + if let Some(cfg) = &self.qwen35moe { + return Ok(cfg.embedding_length); + } + if let Some(cfg) = &self.gemma4 { + return Ok(cfg.embedding_length); + } + Err(LlamaError::unsupported(format!( + "embedding length is not implemented for architecture '{}'", + self.architecture.name() + ))) + } + pub fn hybrid_decode_spec( &self, max_context: u32, diff --git a/libs/llama/src/runtime.rs b/libs/llama/src/runtime.rs index 99be7b2fe..0bb6e98ad 100644 --- a/libs/llama/src/runtime.rs +++ b/libs/llama/src/runtime.rs @@ -836,6 +836,12 @@ pub struct HybridDecodeBatchLayout { pub attention_key_count: usize, pub recurrent_state_rows: Vec, pub output_ids: Vec, + /// Pre-expanded M-RoPE positions, `[c0 over all tokens][c1][c2][c3]`. + /// When absent, rope uses `positions` broadcast across components (the + /// text-only behavior). Image spans need this: their h/w components + /// diverge from the linear sequence index, while `positions` stays the + /// linear index used for cache writes and attention masking. + pub rope_positions: Option>, } impl HybridDecodeBatchLayout { @@ -887,6 +893,7 @@ impl HybridDecodeBatchLayout { attention_key_count, recurrent_state_rows: vec![0], output_ids: output_ids.to_vec(), + rope_positions: None, }) } @@ -7776,7 +7783,10 @@ pub fn execute_prepared_hybrid_decode_metal( "hybrid decode has rope position input without an attention rope spec", ) })?; - Some(encode_rope_positions(rope, positions, positions.len())?) + // Image spans carry pre-expanded 4-component M-RoPE positions on the + // layout; text batches fall back to broadcasting the linear positions. + let rope_source = layout.rope_positions.as_deref().unwrap_or(positions); + Some(encode_rope_positions(rope, rope_source, positions.len())?) } else { None }; diff --git a/libs/llama/src/session.rs b/libs/llama/src/session.rs index 929c9762b..7c426e43e 100644 --- a/libs/llama/src/session.rs +++ b/libs/llama/src/session.rs @@ -13,7 +13,7 @@ use crate::runtime::{ create_metal_context_buffer_with_runtime, reserve_hybrid_decode_main_buffer_size, CompiledHybridDecodeMetal, HybridCacheLayout, HybridCacheShape, HybridCacheTypes, HybridDecodeBatchLayout, HybridDecodeRun, HybridDecodeSpec, HybridLayerSpec, - HybridSharedCacheTensorIds, LogitsProbeInput, + HybridSharedCacheTensorIds, LogitsProbeInput, ProbeInputKind, }; use crate::vocab::LlamaVocab; use crate::weights::LoadedGgufWeights; @@ -73,6 +73,8 @@ struct SessionGraphParams { n_tokens: usize, n_outputs: usize, attention_key_count: usize, + /// Graph takes precomputed embeddings instead of token ids (image spans). + embeddings_input: bool, } impl SessionGraphParams { @@ -81,6 +83,7 @@ impl SessionGraphParams { n_tokens, n_outputs, attention_key_count, + embeddings_input: false, } } @@ -88,6 +91,12 @@ impl SessionGraphParams { Self::new(n_tokens, 1, attention_key_count) } + fn greedy_embeddings(n_tokens: usize, attention_key_count: usize) -> Self { + let mut params = Self::greedy(n_tokens, attention_key_count); + params.embeddings_input = true; + params + } + fn token_generation(max_context: usize) -> Self { Self::greedy(1, max_context) } @@ -126,12 +135,17 @@ pub struct LlamaSession { vocab: LlamaVocab, plan: ModelExecutionPlan, spec: HybridDecodeSpec, + spec_embeddings: HybridDecodeSpec, config: LlamaSessionConfig, max_context: usize, context_extra_bytes: usize, weights: LoadedGgufWeights, graphs: SessionGraphSet, token_ids: Vec, + /// Next M-RoPE position. Tracks token count for pure text; falls behind it + /// after an image span, whose n_pos is max(tokens_w, tokens_h) rather than + /// its token count. + rope_pos_next: i64, last_run: Option, } @@ -191,6 +205,7 @@ impl LlamaSession { self.weights = weights; self.graphs = graphs; self.token_ids.clear(); + self.rope_pos_next = 0; self.last_run = None; Ok(()) } @@ -284,6 +299,14 @@ impl LlamaSession { spec.layers.truncate(max_blocks); } } + // Same graph with precomputed-embedding input for image spans; both + // specs share the cache tensors, so batches can alternate freely. + let mut spec_embeddings = spec.clone(); + spec_embeddings.input = ProbeInputKind::Embeddings { + hidden_size: model.embedding_length()?, + input_type: TensorType::F32, + }; + let cache_bytes = if let Some(template) = plan.hybrid_cache.as_ref() { HybridCacheLayout::new(template.materialize(cache_shape, cache_types))?.total_bytes } else { @@ -307,12 +330,14 @@ impl LlamaSession { vocab, plan, spec, + spec_embeddings, config, max_context: max_context_usize, context_extra_bytes, weights, graphs, token_ids: Vec::new(), + rope_pos_next: 0, last_run: None, }) } @@ -360,6 +385,23 @@ impl LlamaSession { let cache_tokens = start .checked_add(batch_size) .ok_or_else(|| LlamaError::format("overflow computing session cache length"))?; + // After an image span, rope positions run behind the linear sequence + // index — text continues from the image's pos_0 + max(w, h). + let rope_positions = if self.rope_pos_next != start as i64 { + let base = self.rope_pos_next; + let mut planes = vec![0i32; batch_size * 4]; + for i in 0..batch_size { + let p = i32::try_from(base + i as i64) + .map_err(|_| LlamaError::format("rope position does not fit in i32"))?; + planes[i] = p; + planes[batch_size + i] = p; + planes[2 * batch_size + i] = p; + // fourth component stays 0 (unused section) + } + Some(planes) + } else { + None + }; let graph_params = SessionGraphParams::greedy(batch_size, cache_tokens); self.ensure_compiled_graph(graph_params)?; let run = { @@ -374,6 +416,7 @@ impl LlamaSession { cache_tokens, &output_ids, )?; + layout.rope_positions = rope_positions; if compiled.decode().input_recurrent_state_rows.is_none() { layout.recurrent_state_rows.clear(); } @@ -381,10 +424,106 @@ impl LlamaSession { .execute_logits_only_with_layout(LogitsProbeInput::TokenIds(token_ids), &layout)? }; self.token_ids.extend_from_slice(token_ids); + self.rope_pos_next += batch_size as i64; self.last_run = Some(collapse_last_token_run(run)?); Ok(()) } + /// Append an image span: precomputed vision embeddings for a grid of + /// `tokens_w` x `tokens_h` merged tokens (row-major), as produced by + /// `VisionTower::encode`. Occupies `tokens_w * tokens_h` sequence slots + /// but advances the rope position by only `max(tokens_w, tokens_h)`, + /// with Qwen-VL 2D positions `[pos0, pos0+y, pos0+x, 0]` per token. + /// Callers surround this with the `<|vision_start|>` / `<|vision_end|>` + /// text tokens via `append_tokens`. + pub fn append_image_embeddings( + &mut self, + embeddings: &[f32], + tokens_w: usize, + tokens_h: usize, + ) -> Result<()> { + let n_tokens = tokens_w * tokens_h; + if n_tokens == 0 { + return Ok(()); + } + let hidden = usize::try_from(self.model.embedding_length()?) + .map_err(|_| LlamaError::format("embedding length does not fit in usize"))?; + if embeddings.len() != n_tokens * hidden { + return Err(LlamaError::format(format!( + "image embeddings length {} does not match {}x{} tokens x {} hidden", + embeddings.len(), + tokens_w, + tokens_h, + hidden + ))); + } + self.ensure_capacity(n_tokens)?; + let pad_token = self.vocab.token_id("<|image_pad|>").unwrap_or(-1); + let pos0 = self.rope_pos_next; + + let prefill_batch_size = self.config.prefill_batch_size.max(1); + let mut offset = 0usize; + while offset < n_tokens { + let batch_size = (n_tokens - offset).min(prefill_batch_size); + let start = self.token_ids.len(); + let positions = (start..start + batch_size) + .map(|position| { + i32::try_from(position) + .map_err(|_| LlamaError::format("token position does not fit in i32")) + }) + .collect::>>()?; + let cache_tokens = start + batch_size; + + let mut planes = vec![0i32; batch_size * 4]; + for i in 0..batch_size { + let token_index = offset + i; + let y = (token_index / tokens_w) as i64; + let x = (token_index % tokens_w) as i64; + let clamp = |v: i64| { + i32::try_from(v) + .map_err(|_| LlamaError::format("rope position does not fit in i32")) + }; + planes[i] = clamp(pos0)?; + planes[batch_size + i] = clamp(pos0 + y)?; + planes[2 * batch_size + i] = clamp(pos0 + x)?; + // fourth component stays 0 (unused section) + } + + let graph_params = SessionGraphParams::greedy_embeddings(batch_size, cache_tokens); + self.ensure_compiled_graph(graph_params)?; + let run = { + let compiled = self + .graphs + .graph_for_mut(graph_params) + .ok_or_else(|| LlamaError::format("compiled graph params were not cached"))?; + let output_ids = [i32::try_from(batch_size - 1) + .map_err(|_| LlamaError::format("session output id does not fit in i32"))?]; + let mut layout = HybridDecodeBatchLayout::from_contiguous_positions_and_outputs( + &positions, + cache_tokens, + &output_ids, + )?; + layout.rope_positions = Some(planes); + if compiled.decode().input_recurrent_state_rows.is_none() { + layout.recurrent_state_rows.clear(); + } + compiled.execute_logits_only_with_layout( + LogitsProbeInput::EmbeddingsF32 { + data: &embeddings[offset * hidden..(offset + batch_size) * hidden], + n_tokens: batch_size, + }, + &layout, + )? + }; + self.token_ids + .extend(std::iter::repeat(pad_token).take(batch_size)); + self.last_run = Some(collapse_last_token_run(run)?); + offset += batch_size; + } + self.rope_pos_next = pos0 + tokens_w.max(tokens_h) as i64; + Ok(()) + } + fn ensure_compiled_graph(&mut self, params: SessionGraphParams) -> Result<()> { if self.graphs.has_graph(params) { return Ok(()); @@ -398,9 +537,14 @@ impl LlamaSession { if attempt > 0 { self.graphs.evict_graphs_except(params); } + let spec = if params.embeddings_input { + &self.spec_embeddings + } else { + &self.spec + }; match compile_hybrid_decode_metal_with_shared_runtime_and_state_and_outputs_and_attention_key_count( &mut self.weights, - &self.spec, + spec, &self.graphs.shared_runtime, &self.graphs.shared_cache, &self.graphs.shared_main_buffer, diff --git a/libs/llama/src/vocab.rs b/libs/llama/src/vocab.rs index 4add898e0..194db6211 100644 --- a/libs/llama/src/vocab.rs +++ b/libs/llama/src/vocab.rs @@ -651,6 +651,11 @@ impl LlamaVocab { self.token_to_id.get(piece).copied() } + /// Resolve a token piece (e.g. `<|image_pad|>`) to its id. + pub fn token_id(&self, piece: &str) -> Option { + self.lookup_token(piece) + } + fn token_score(&self, token_id: i32) -> Option { usize::try_from(token_id) .ok() From 68bc80709a1f37d8178802def74b0374a7d87caa Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 16:02:44 +0200 Subject: [PATCH 06/10] map: charger pin redesign, upright/billboard labels, 3D trees, exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- draw/src/shader/draw_rotated_text.rs | 98 ++++- examples/map/src/main.rs | 19 +- libs/map_nav/src/search.rs | 35 +- libs/mbtile_reader/src/lib.rs | 146 +++++++- libs/mbtile_reader/src/writer.rs | 105 +++++- tools/map_tiles/src/nav_build.rs | 2 + widgets/src/map/icons.rs | 95 ++--- widgets/src/map/icons/charger_pin.svg | 2 +- widgets/src/map/icons/charger_pin_wide.svg | 2 +- widgets/src/map/label.rs | 35 +- widgets/src/map/tile.rs | 412 +++++++++++++++++++-- widgets/src/map/view.rs | 203 ++++++++-- 12 files changed, 991 insertions(+), 163 deletions(-) diff --git a/draw/src/shader/draw_rotated_text.rs b/draw/src/shader/draw_rotated_text.rs index ffc18a510..e775f085d 100644 --- a/draw/src/shader/draw_rotated_text.rs +++ b/draw/src/shader/draw_rotated_text.rs @@ -27,6 +27,10 @@ script_mod! { 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) @@ -38,11 +42,20 @@ script_mod! { scaled.x * cs - scaled.y * sn, scaled.x * sn + scaled.y * cs ) + origin - 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 + 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) @@ -100,6 +113,8 @@ 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 { @@ -212,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, @@ -232,6 +248,78 @@ impl DrawRotatedText { self.draw_super.font_scale = saved_font_scale; } + /// Billboard variant for text INSIDE zoom-constant pins: the shared + /// anchor scales/translates with the map (tracking the pin's baked + /// anchor exactly), but glyph offsets and size stay in constant screen + /// px — so the text is rigid on the pin at every gesture zoom, like + /// the pin mesh itself. + pub fn draw_path_glyphs_billboard( + &mut self, + cx: &mut Cx2d, + glyphs: &[PathGlyphInstance], + scale: f32, + offset: Vec2f, + anchor: Vec2f, + ) { + let saved_font_scale = self.draw_super.font_scale; + self.draw_super.font_scale = 1.0; + self.upright = 1.0; + let scaled_anchor = + Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y); + for glyph in glyphs { + self.draw_glyph_at( + cx, + Point::new( + scaled_anchor.x + (glyph.glyph_origin.x - anchor.x), + scaled_anchor.y + (glyph.glyph_origin.y - anchor.y), + ), + scaled_anchor, + glyph.font_size_in_lpxs, + glyph.rasterized, + glyph.angle, + 1.0, + ); + } + self.upright = 0.0; + self.draw_super.font_scale = saved_font_scale; + } + + /// Draw a straightened (screen-upright) label: every glyph carries the + /// SAME anchor (the label's world-anchor in cached screen space) so the + /// camera-delta shader translates the string rigidly to where the next + /// re-place will put it, without rotating the glyphs. Straightened + /// glyphs have angle 0, so hijacking rotation_origin as the anchor is + /// free. + pub fn draw_path_glyphs_upright( + &mut self, + cx: &mut Cx2d, + glyphs: &[PathGlyphInstance], + scale: f32, + offset: Vec2f, + anchor: Vec2f, + ) { + let saved_font_scale = self.draw_super.font_scale; + self.draw_super.font_scale = 1.0; + self.upright = 1.0; + let anchor = Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y); + for glyph in glyphs { + self.draw_glyph_at( + cx, + Point::new( + glyph.glyph_origin.x * scale + offset.x, + glyph.glyph_origin.y * scale + offset.y, + ), + anchor, + glyph.font_size_in_lpxs * scale, + glyph.rasterized, + glyph.angle, + 1.0, + ); + } + self.upright = 0.0; + self.draw_super.font_scale = saved_font_scale; + } + /// Place glyphs from a `PreparedTextRun` along a polyline path. /// /// Glyphs are appended to `out_glyphs` (caller reuses the buffer to avoid allocs). diff --git a/examples/map/src/main.rs b/examples/map/src/main.rs index 4b08d1df3..d13201520 100644 --- a/examples/map/src/main.rs +++ b/examples/map/src/main.rs @@ -104,7 +104,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 } @@ -615,6 +615,13 @@ impl App { self.ui.view(cx, ids!(results_view)).set_visible(cx, false); } + /// 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 +696,6 @@ impl App { .filter(|(_, on)| **on) .map(|(path, _)| *path) .collect(); - log!("overlays: {:?}", paths); self.map(cx).set_overlay_paths(cx, &paths.join(";")); } @@ -998,6 +1004,14 @@ 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; @@ -1094,6 +1108,7 @@ impl MatchEvent for App { 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() { diff --git a/libs/map_nav/src/search.rs b/libs/map_nav/src/search.rs index 9b46a31a9..df7743f13 100644 --- a/libs/map_nav/src/search.rs +++ b/libs/map_nav/src/search.rs @@ -435,6 +435,11 @@ pub fn category_from_osm_tags(tags: &HashMap) -> Option, ) -> f64 { - // Entity tier: what KIND of thing dominates what the name looks like. - let (tier, locality) = match category { - Category::City => (5.0, 0.10), - Category::Town => (4.6, 0.15), - Category::Airport => (4.3, 0.25), + // Entity tier + "reach": how far away this kind of thing stays + // relevant. The penalty is log2(1 + d/reach), so a zoo 3km away loses + // ~2 points while a same-named hamlet 900km away loses ~7 — the old + // locality MULTIPLIER did the opposite (punished the nearby POI harder + // than the distant settlement, which is how searching "artis" in + // Amsterdam flew to a hamlet in France). + let (tier, reach_m) = match category { + Category::City => (5.0, 50_000.0), + Category::Town => (4.6, 20_000.0), + Category::Airport => (4.3, 30_000.0), Category::Village | Category::Suburb | Category::Hamlet | Category::Neighbourhood => { - (4.0, 0.35) + (4.0, 8_000.0) } - Category::Station => (3.8, 0.5), - Category::Street => (2.0, 1.0), - Category::Address => (1.6, 1.0), - _ => (3.0, 1.0), + Category::Station => (3.8, 5_000.0), + Category::Street => (2.0, 800.0), + Category::Address => (1.6, 500.0), + _ => (3.0, 1_500.0), }; let mut score = tier * 30.0 + rank as f64 * 0.15; if via_category { @@ -1005,9 +1015,8 @@ pub fn score_search_hit( score += 55.0; } if let Some(d) = distance_m { - // log2 falloff: being 10x closer is worth a constant bonus; scaled - // by how inherently local the entity kind is. - score -= 7.0 * (1.0 + d / 30.0).log2() * locality; + // log2 falloff against the tier's reach. + score -= 7.0 * (1.0 + d / reach_m).log2(); } score } diff --git a/libs/mbtile_reader/src/lib.rs b/libs/mbtile_reader/src/lib.rs index 07b808538..df41eb928 100644 --- a/libs/mbtile_reader/src/lib.rs +++ b/libs/mbtile_reader/src/lib.rs @@ -19,7 +19,7 @@ use std::io::{Read, Seek, SeekFrom}; use std::path::Path; mod writer; -pub use writer::{MbtilesWriter, MbtilesWriterStats}; +pub use writer::{MbtilesWriter, MbtilesWriterStats, WriterValue}; // --------------------------------------------------------------------------- // Error @@ -1075,6 +1075,150 @@ impl MbtilesReader { pub fn header(&self) -> &DbHeader { &self.header } + + /// Open any SQLite database (e.g. a GeoPackage) for generic table access. + /// Unlike [`MbtilesReader::open`] this does not require the mbtiles schema; + /// the tile-specific methods will fail on such a database, but + /// [`MbtilesReader::schema_entries`] and [`MbtilesReader::for_each_row`] + /// work on any table. + pub fn open_sqlite(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut header_buf = [0u8; 100]; + file.read_exact(&mut header_buf)?; + let header = parse_db_header(&header_buf)?; + let usable_size = header.page_size as usize - header.reserved_space as usize; + Ok(MbtilesReader { + file, + header, + usable_size, + tiles_root_page: 0, + metadata_root_page: 0, + tile_index_root_page: None, + makepad_block_rowids: false, + btree_page_cache: HashMap::new(), + btree_page_cache_order: VecDeque::new(), + }) + } + + /// All objects recorded in sqlite_master: tables, indexes, views, triggers. + pub fn schema_entries(&mut self) -> Result> { + let mut entries = Vec::new(); + self.scan_table_pages(1, &mut |reader, _rowid, local, total, overflow| { + let payload = reader.assemble_payload(local, total, overflow)?; + let record = parse_record(&payload, reader.header.text_encoding)?; + if record.len() >= 5 { + entries.push(SchemaEntry { + obj_type: record[0].as_text().unwrap_or("").to_string(), + name: record[1].as_text().unwrap_or("").to_string(), + tbl_name: record[2].as_text().unwrap_or("").to_string(), + root_page: record[3].as_integer().unwrap_or(0) as u32, + sql: record[4].as_text().unwrap_or("").to_string(), + }); + } + Ok(()) + })?; + Ok(entries) + } + + /// Walk every row of the named table, decoding each record's values. + /// A column declared INTEGER PRIMARY KEY is the rowid alias and appears as + /// [`Value::Null`] in the record; use the callback's rowid for it. + pub fn for_each_row( + &mut self, + table: &str, + mut callback: impl FnMut(i64, Vec), + ) -> Result<()> { + let root = self + .schema_entries()? + .into_iter() + .find(|e| e.obj_type == "table" && e.name == table) + .map(|e| e.root_page) + .ok_or(Error::TableNotFound("requested table"))?; + if root == 0 { + return Err(Error::TableNotFound("requested table")); + } + self.scan_table_pages(root, &mut |reader, rowid, local, total, overflow| { + let payload = reader.assemble_payload(local, total, overflow)?; + let record = parse_record(&payload, reader.header.text_encoding)?; + callback(rowid, record); + Ok(()) + }) + } + + /// Walk rows of the named table whose rowid lies in `lo..=hi`, pruning + /// b-tree subtrees outside the range — an indexed range query without SQL. + pub fn for_each_row_in_range( + &mut self, + table: &str, + lo: i64, + hi: i64, + mut callback: impl FnMut(i64, Vec), + ) -> Result<()> { + let root = self + .schema_entries()? + .into_iter() + .find(|e| e.obj_type == "table" && e.name == table) + .map(|e| e.root_page) + .ok_or(Error::TableNotFound("requested table"))?; + if root == 0 { + return Err(Error::TableNotFound("requested table")); + } + let mut page_stack = vec![root]; + while let Some(page_num) = page_stack.pop() { + let page = self.read_btree_page(page_num)?; + let header_offset = if page_num == 1 { 100 } else { 0 }; + let (page_type, cell_ptrs, rightmost_ptr) = self.cell_pointers(&page, header_offset)?; + match page_type { + PageType::TableLeaf => { + for &ptr in &cell_ptrs { + let (rowid, local, total, overflow) = + self.parse_table_leaf_cell(&page, ptr)?; + if rowid < lo || rowid > hi { + continue; + } + let payload = self.assemble_payload(local, total, overflow)?; + let record = parse_record(&payload, self.header.text_encoding)?; + callback(rowid, record); + } + } + PageType::TableInterior => { + // Interior cells hold (child, max_rowid_of_child) in + // ascending order; the rightmost pointer covers the rest. + let mut prev_max = i64::MIN; + let mut include_right = true; + for &ptr in &cell_ptrs { + let (child, key) = self.parse_table_interior_cell(&page, ptr)?; + if key >= lo && prev_max <= hi { + page_stack.push(child); + } + if key > hi { + include_right = false; + // later siblings are entirely above the range + break; + } + prev_max = key; + } + if include_right { + if let Some(right) = rightmost_ptr { + page_stack.push(right); + } + } + } + _ => {} + } + } + Ok(()) + } +} + +/// One row of sqlite_master, describing a schema object. +#[derive(Debug, Clone)] +pub struct SchemaEntry { + pub obj_type: String, + pub name: String, + pub tbl_name: String, + pub root_page: u32, + pub sql: String, } /// Quick parse of the first 3 integer columns from a record's local payload. diff --git a/libs/mbtile_reader/src/writer.rs b/libs/mbtile_reader/src/writer.rs index f6e23404d..de8c6f76f 100644 --- a/libs/mbtile_reader/src/writer.rs +++ b/libs/mbtile_reader/src/writer.rs @@ -35,6 +35,22 @@ pub struct MbtilesWriter { tiles: TableStream, tile_count: u64, tile_bytes: u64, + extra_tables: Vec, +} + +struct ExtraTable { + name: String, + sql: String, + stream: TableStream, +} + +/// Value for rows in extra (non-tiles) tables. +pub enum WriterValue<'a> { + Null, + Integer(i64), + Float(f64), + Text(&'a str), + Blob(&'a [u8]), } impl MbtilesWriter { @@ -48,9 +64,57 @@ impl MbtilesWriter { tiles, tile_count: 0, tile_bytes: 0, + extra_tables: Vec::new(), }) } + /// Declare an additional table. Rows are supplied via + /// [`MbtilesWriter::write_extra_row`] in strictly ascending rowid order + /// (per table). The CREATE TABLE statement is recorded verbatim in + /// sqlite_master, so standard SQLite tooling sees proper column names. + pub fn begin_extra_table(&mut self, name: &str, create_sql: &str) -> Result<()> { + if self.extra_tables.iter().any(|t| t.name == name) { + return Err(Error::InvalidInput(format!( + "extra table {name} already declared" + ))); + } + let stream = TableStream::new(&mut self.db)?; + self.extra_tables.push(ExtraTable { + name: name.to_string(), + sql: create_sql.to_string(), + stream, + }); + Ok(()) + } + + /// Append one row to a declared extra table. + pub fn write_extra_row( + &mut self, + table: &str, + rowid: i64, + values: &[WriterValue<'_>], + ) -> Result<()> { + let record: Vec> = values + .iter() + .map(|v| match v { + WriterValue::Null => RecordValue::Null, + WriterValue::Integer(i) => RecordValue::Integer(*i), + WriterValue::Float(f) => RecordValue::Float(*f), + WriterValue::Text(s) => RecordValue::Text(s), + WriterValue::Blob(b) => RecordValue::Blob(b), + }) + .collect(); + let payload = encode_record(&record)?; + let index = self + .extra_tables + .iter() + .position(|t| t.name == table) + .ok_or_else(|| Error::InvalidInput(format!("extra table {table} not declared")))?; + self.extra_tables[index] + .stream + .push(&mut self.db, rowid, &payload) + } + /// Set an MBTiles metadata value. Repeated keys replace the previous value. pub fn set_metadata(&mut self, name: impl Into, value: impl Into) { self.metadata.insert(name.into(), value.into()); @@ -107,7 +171,14 @@ impl MbtilesWriter { } let metadata_root = metadata_table.finish(&mut self.db)?; - self.db.write_sqlite_master(metadata_root, tiles_root)?; + let mut extra_roots = Vec::new(); + for table in std::mem::take(&mut self.extra_tables) { + let root = table.stream.finish(&mut self.db)?; + extra_roots.push((table.name, table.sql, root)); + } + + self.db + .write_sqlite_master(metadata_root, tiles_root, &extra_roots)?; self.db.finish()?; Ok(MbtilesWriterStats { @@ -181,12 +252,17 @@ impl RawDbWriter { Ok(()) } - fn write_sqlite_master(&mut self, metadata_root: u32, tiles_root: u32) -> Result<()> { + fn write_sqlite_master( + &mut self, + metadata_root: u32, + tiles_root: u32, + extra_tables: &[(String, String, u32)], + ) -> Result<()> { let metadata_sql = "CREATE TABLE metadata (name TEXT, value TEXT)"; let tiles_sql = "CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)"; - let rows = [ + let mut rows = vec![ encode_record(&[ RecordValue::Text("table"), RecordValue::Text("metadata"), @@ -202,8 +278,22 @@ impl RawDbWriter { RecordValue::Text(tiles_sql), ])?, ]; + for (name, sql, root) in extra_tables { + rows.push(encode_record(&[ + RecordValue::Text("table"), + RecordValue::Text(name), + RecordValue::Text(name), + RecordValue::Integer(i64::from(*root)), + RecordValue::Text(sql), + ])?); + } - let mut page = build_leaf_page(100, &[(1, &rows[0]), (2, &rows[1])])?; + let indexed: Vec<(i64, &[u8])> = rows + .iter() + .enumerate() + .map(|(i, r)| (i as i64 + 1, r.as_slice())) + .collect(); + let mut page = build_leaf_page(100, &indexed)?; write_database_header(&mut page, self.page_count); self.write_page(1, &page) } @@ -468,7 +558,9 @@ fn write_interior_page(db: &mut RawDbWriter, children: &[PageRef]) -> Result { + Null, Integer(i64), + Float(f64), Blob(&'a [u8]), Text(&'a str), } @@ -479,6 +571,11 @@ fn encode_record(values: &[RecordValue<'_>]) -> Result> { for value in values { let serial_type = match value { + RecordValue::Null => 0, + RecordValue::Float(value) => { + body.extend_from_slice(&value.to_be_bytes()); + 7 + } RecordValue::Integer(value) => encode_integer(*value, &mut body), RecordValue::Blob(value) => { body.extend_from_slice(value); diff --git a/tools/map_tiles/src/nav_build.rs b/tools/map_tiles/src/nav_build.rs index 95fbade1b..f2b93a6a3 100644 --- a/tools/map_tiles/src/nav_build.rs +++ b/tools/map_tiles/src/nav_build.rs @@ -67,6 +67,8 @@ const KEPT_WAY_TAGS: &[&str] = &[ "railway", "aeroway", "natural", + "attraction", + "zoo", "place", "population", "addr:street", diff --git a/widgets/src/map/icons.rs b/widgets/src/map/icons.rs index 1ba8b673c..e37359130 100644 --- a/widgets/src/map/icons.rs +++ b/widgets/src/map/icons.rs @@ -19,10 +19,6 @@ use std::sync::OnceLock; /// On-screen symbol size; carto icons are authored at 14x14. pub const ICON_SIZE_PX: f32 = 14.0; -pub const DIGIT_NAMES: [&str; 10] = [ - "digit_0", "digit_1", "digit_2", "digit_3", "digit_4", "digit_5", "digit_6", "digit_7", - "digit_8", "digit_9", -]; /// Symbols appear from this view-zoom bucket (carto shows the full POI /// symbol set from z17). pub const ICON_MIN_ZOOM: u32 = 17; @@ -100,32 +96,37 @@ fn icons() -> &'static HashMap<&'static str, IconMesh> { out.insert("tree_core", mesh); } // Tesla-style charger pins: wide badge (bolt + kW text) for fast - // sites, small badge for street AC; white bolt overlays. - if let Some(mesh) = build_icon_mesh_sized(include_str!("icons/charger_pin_wide.svg"), 30.0) + // sites, small badge for street AC; white bolt overlays. The mesh + // is shifted so the TAIL TIP sits exactly on the anchor (the site): + // a centered mesh made the tip sweep across the ground when the + // camera rotated in a tilted view. + // wide: viewBox 34x30, tip (17,24), scale 30/34 -> tip at +7.94 + if let Some(mesh) = + build_icon_mesh_sized_offset(include_str!("icons/charger_pin_wide.svg"), 30.0, 0.0, -7.94) { out.insert("charger_pin_fast", mesh); } - if let Some(mesh) = build_icon_mesh_sized(include_str!("icons/charger_pin.svg"), 16.0) { + // small: viewBox 22x22, tip (11,20.5), scale 16/22 -> tip at +6.91 + if let Some(mesh) = + build_icon_mesh_sized_offset(include_str!("icons/charger_pin.svg"), 16.0, 0.0, -6.91) + { out.insert("charger_pin_ac", mesh); } + // Bolt overlays carry their in-pin offset IN THE MESH (screen px): // offsetting the anchor instead scales with the map and the pin // composite smears apart at fractional zooms. if let Some(mesh) = - build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, -8.5, -3.5) + build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, -8.5, -12.35) { out.insert("charger_bolt_fast", mesh); } if let Some(mesh) = - build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, 0.0, -2.5) + build_icon_mesh_sized_offset(include_str!("icons/charger.svg"), 9.0, 0.0, -8.36) { out.insert("charger_bolt_ac", mesh); } - for digit in 0u8..10 { - if let Some(mesh) = build_digit_mesh(digit) { - out.insert(DIGIT_NAMES[digit as usize], mesh); - } - } + out }) } @@ -192,56 +193,10 @@ fn build_icon_mesh_sized_offset( /// Seven-segment digit meshes: pin badges draw their kW number as PART /// of the icon composite — same anchor, same billboard transform, so the -/// text can never detach, double, or re-layout while zooming/rotating. -fn build_digit_mesh(digit: u8) -> Option { - // Segment layout in a 6x10 box centered at origin; thickness 1.5. - // (A top, B tr, C br, D bottom, E bl, F tl, G middle) - const ON: [[bool; 7]; 10] = [ - [true, true, true, true, true, true, false], // 0 - [false, true, true, false, false, false, false], // 1 - [true, true, false, true, true, false, true], // 2 - [true, true, true, true, false, false, true], // 3 - [false, true, true, false, false, true, true], // 4 - [true, false, true, true, false, true, true], // 5 - [true, false, true, true, true, true, true], // 6 - [true, true, true, false, false, false, false], // 7 - [true, true, true, true, true, true, true], // 8 - [true, true, true, true, false, true, true], // 9 - ]; - let flags = ON.get(digit as usize)?; - let (w, h, t) = (5.4f32, 9.6f32, 1.5f32); - let (hw, hh) = (w * 0.5, h * 0.5); - // (x, y, width, height) per segment, y-down. - let segments = [ - (-hw, -hh, w, t), // A - (hw - t, -hh, t, hh + t * 0.5), // B - (hw - t, -t * 0.5, t, hh + t * 0.5), // C - (-hw, hh - t, w, t), // D - (-hw, -t * 0.5, t, hh + t * 0.5), // E - (-hw, -hh, t, hh + t * 0.5), // F - (-hw, -t * 0.5, w, t), // G - ]; - let mut verts = Vec::new(); - let mut indices = Vec::new(); - for (on, (x, y, sw, sh)) in flags.iter().zip(segments) { - if !on { - continue; - } - let base = verts.len() as u32; - for (px, py) in [(x, y), (x + sw, y), (x + sw, y + sh), (x, y + sh)] { - verts.push(VVertex { - x: px, - y: py, - u: 0.0, - v: 0.0, - stroke_dist: 0.0, - clip_radius: 12.0, - }); - } - indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); - } - Some(IconMesh { verts, indices }) -} + +/// "/" separator for the in-pin "kW/stalls" text: a diagonal bar in the + +/// Small multiplication cross for the stall-count line ("x5"): two fn build_icon_mesh_sized(svg: &str, size_px: f32) -> Option { let doc = parse_svg(svg); @@ -375,15 +330,19 @@ pub fn micro_icon_for_tags(tags: &HashMap) -> Option<(&'static s pub fn icon_for_tags(tags: &HashMap) -> Option<(&'static str, u8)> { match tags.get("layer").map(|v| v.as_str()) { Some("micro_pois") => return micro_icon_for_tags(tags), - // Geodata overlays (layers.md). Chargers tier by max_kw — this is - // a Tesla-style EV navigator, charging power is first-class: - // ultra-fast/Supercharger red, fast DC amber, street AC blue. + // Geodata overlays (layers.md). Charger pins color by BRAND — + // red is exclusively Tesla Superchargers; other brands split + // fast DC amber / street AC blue (kW still shows in the bubble). Some("chargers") => { let kw = tags .get("max_kw") .and_then(|v| v.parse::().ok()) .unwrap_or(0.0); - let class = if kw >= 150.0 { + let is_tesla = tags + .get("operator") + .or_else(|| tags.get("brand")) + .is_some_and(|value| value.to_lowercase().contains("tesla")); + let class = if is_tesla { LABEL_CLASS_HEALTH } else if kw >= 50.0 { LABEL_CLASS_AMENITY diff --git a/widgets/src/map/icons/charger_pin.svg b/widgets/src/map/icons/charger_pin.svg index afff50c2d..ed754f529 100644 --- a/widgets/src/map/icons/charger_pin.svg +++ b/widgets/src/map/icons/charger_pin.svg @@ -1 +1 @@ - + diff --git a/widgets/src/map/icons/charger_pin_wide.svg b/widgets/src/map/icons/charger_pin_wide.svg index 3fb240cbc..9547e4861 100644 --- a/widgets/src/map/icons/charger_pin_wide.svg +++ b/widgets/src/map/icons/charger_pin_wide.svg @@ -1 +1 @@ - + diff --git a/widgets/src/map/label.rs b/widgets/src/map/label.rs index 7a7cd7fff..9072c4b1c 100644 --- a/widgets/src/map/label.rs +++ b/widgets/src/map/label.rs @@ -47,6 +47,8 @@ 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; #[derive(Clone, Debug)] pub struct TileLabel { @@ -76,6 +78,10 @@ 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, pub screen_path: Vec, } @@ -395,6 +401,31 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> bbox: (0.0, 0.0, 0.0, 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), + }); + } _ => {} } if !tags.contains_key("highway") { @@ -512,7 +543,9 @@ pub fn label_source_rank(layer: &str) -> Option { return Some(4); } Some(match layer { - "street_labels" | "street_labels_points" => 7, + "street_labels" => 7, + // Motorway exits: sparse and navigation-critical — beat street names. + "street_labels_points" => 8, "streets_polygons_labels" => 6, "transportation_name" => 6, // Settlement names outrank everything. diff --git a/widgets/src/map/tile.rs b/widgets/src/map/tile.rs index cf6f6822a..6d6f4d715 100644 --- a/widgets/src/map/tile.rs +++ b/widgets/src/map/tile.rs @@ -390,6 +390,7 @@ pub fn build_tile_buffers_from_body( tagged_points, theme, render_zoom, + false, )) } @@ -461,6 +462,7 @@ pub fn build_tile_buffers_from_mvt( collector.points, theme, render_zoom, + buildings_3d, )) } @@ -676,12 +678,24 @@ fn merge_detail_features( } continue; } + let is_building = way + .tags + .get("building") + .is_some_and(|value| value != "no"); + let is_building_part = way + .tags + .get("building:part") + .is_some_and(|value| value != "no"); // Named zoo enclosures / attractions label at their centroid - // (and fill if they carry a surface like sand). + // (and fill if they carry a surface like sand). Famous BUILDINGS + // also carry tourism=attraction (Westerkerk, Munttoren…) — in 3D + // mode they must fall through to the extrusion path, not get + // swallowed as a flat attraction fill. let is_attraction = way.tags.contains_key("name") && (way.tags.contains_key("attraction") || way.tags.contains_key("zoo") - || way.tags.get("tourism").map(|v| v.as_str()) == Some("attraction")); + || way.tags.get("tourism").map(|v| v.as_str()) == Some("attraction")) + && !(want_buildings && (is_building || is_building_part)); if is_attraction { if want_platforms { way.tags @@ -715,11 +729,17 @@ fn merge_detail_features( if !want_buildings { continue; } - let is_building = way - .tags - .get("building") - .is_some_and(|value| value != "no"); - if !is_building { + if !is_building && !is_building_part { + continue; + } + // Underground volumes (metro halls mapped as building:part, + // parking cellars) must never extrude above ground. + if way.tags.get("location").map(|v| v.as_str()) == Some("underground") + || way + .tags + .get("osm_layer") + .is_some_and(|value| value.starts_with('-')) + { continue; } way.tags @@ -829,11 +849,86 @@ fn building_height_m(tags: &HashMap) -> f32 { 8.0 } +/// Base height (bottom of the volume) for building:part features: +/// `min_height` meters, else `building:min_level` x 3m. +fn building_min_height_m(tags: &HashMap) -> f32 { + if let Some(min_height) = tags.get("min_height") { + let digits: String = min_height + .trim() + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + if let Ok(h) = digits.parse::() { + return h.clamp(0.0, 220.0); + } + } + if let Some(levels) = tags.get("building:min_level") { + if let Ok(n) = levels.trim().parse::() { + return (n * 3.0).clamp(0.0, 220.0); + } + } + 0.0 +} + +/// Ray-cast point-in-polygon on a tile-local ring. +fn point_in_ring(point: (f32, f32), ring: &[(f32, f32)]) -> bool { + let mut inside = false; + let n = ring.len(); + if n < 3 { + return false; + } + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = ring[i]; + let (xj, yj) = ring[j]; + if (yi > point.1) != (yj > point.1) { + let x_cross = xi + (point.1 - yi) / (yj - yi) * (xj - xi); + if point.0 < x_cross { + inside = !inside; + } + } + j = i; + } + inside +} + +/// A flat horizontal disc lifted `height_m` meters (roof-style param4): +/// triangle fan, hard edge (canopy blobs are small; AA fringe not worth it). +fn append_horizontal_disc( + center: (f32, f32), + radius_units: f32, + height_m: f32, + color: [f32; 4], + out_vertices: &mut Vec, + out_indices: &mut Vec, + zbias: &mut f32, +) { + const SEGS: u32 = 14; + let base = (out_vertices.len() / VECTOR_FLOATS_PER_VERTEX) as u32; + let mut push_vertex = |x: f32, y: f32| { + out_vertices.extend_from_slice(&[ + x, y, 0.5, 1.0, color[0], color[1], color[2], color[3], 1e6, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, height_m, 0.05, 24.0, *zbias, + ]); + }; + push_vertex(center.0, center.1); + for i in 0..SEGS { + let a = i as f32 / SEGS as f32 * std::f32::consts::TAU; + push_vertex(center.0 + a.cos() * radius_units, center.1 + a.sin() * radius_units); + } + for i in 0..SEGS { + let next = (i + 1) % SEGS; + out_indices.extend_from_slice(&[base, base + 1 + i, base + 1 + next]); + } + *zbias += VECTOR_ZBIAS_STEP; +} + /// One flat-shaded wall quad: two ground vertices and two roof vertices /// whose height rides in param4 for the tilt shader to lift. fn append_wall_quad( a: (f32, f32), b: (f32, f32), + base_m: f32, height_m: f32, color: [f32; 4], out_vertices: &mut Vec, @@ -841,7 +936,7 @@ fn append_wall_quad( zbias: &mut f32, ) { let base = (out_vertices.len() / VECTOR_FLOATS_PER_VERTEX) as u32; - for (p, h) in [(a, 0.0), (b, 0.0), (b, height_m), (a, height_m)] { + for (p, h) in [(a, base_m), (b, base_m), (b, height_m), (a, height_m)] { out_vertices.extend_from_slice(&[ p.0, p.1, 0.5, 1.0, color[0], color[1], color[2], color[3], 1e6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, h, 0.05, 90.0, *zbias, @@ -864,6 +959,7 @@ fn build_tile_buffers_from_features( tagged_points: Vec<((f32, f32), HashMap)>, theme: &CompiledMapTheme, render_zoom: u32, + buildings_3d: bool, ) -> TileBuffers { // How much this tile gets magnified on screen at the styled view zoom. let render_scale = 2.0_f64 @@ -877,7 +973,9 @@ fn build_tile_buffers_from_features( let mut labels = Vec::::new(); let mut pin_hits = Vec::::new(); - let mut icon_jobs = Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32)>::new(); + let mut icon_jobs = + Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32, f32, f32)>::new(); + let mut tree_points_3d = Vec::<(f32, f32)>::new(); for (point, tags) in &tagged_points { let mut label_point = *point; let layer = tags.get("layer").map(|value| value.as_str()).unwrap_or(""); @@ -929,6 +1027,15 @@ fn build_tile_buffers_from_features( .unwrap_or(0.0) }) .unwrap_or(0.0); + // Stall count (OCPI EVSEs) rides along for the in-pin + // "kW/stalls" text at close zooms. + let charger_stalls = (layer == "chargers") + .then(|| { + tags.get("evses") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) + }) + .unwrap_or(0.0); // Chargers render as Tesla-style pin badges: wide badge // (bolt + kW text inside) for fast sites, small badge // for street AC. @@ -943,6 +1050,19 @@ fn build_tile_buffers_from_features( 3 => icon_mesh("charger_pin_ac").unwrap_or(mesh), _ => mesh, }; + // The icon's own zoom floor rides into the vertex data + // (param4): the shader hides the icon the instant the + // LIVE view zoom drops below it, so stale deeper-bucket + // tiles never flash markers while zooming out. + let zoom_floor = + micro_icon_min_zoom(icon_name).max(icon_zoom_floor as f32); + // In 3D mode trees become little REAL 3D trees (trunk + + // canopy blob lifted by the building height mechanism) + // instead of flat billboard discs. + if buildings_3d && icon_name == "tree" { + tree_points_3d.push(*point); + continue; + } icon_jobs.push(( *point, mesh, @@ -951,6 +1071,8 @@ fn build_tile_buffers_from_features( dist_factor, two_tone, charger_kw as f32, + charger_stalls as f32, + zoom_floor, )); if two_tone == 2 || two_tone == 3 { // Tappable: record position + info for the bubble. @@ -970,6 +1092,41 @@ fn build_tile_buffers_from_features( pin_hits.push(PinHit { norm, info }); } if two_tone == 2 { + // In-pin text via the NORMAL text renderer (drawn in + // the post-icon pin phase, billboard-anchored): + // Tesla pins show the stall count (the kW is implied + // by the brand, like the Tesla app), other brands + // show the peak kW. + let is_tesla = tags + .get("operator") + .or_else(|| tags.get("brand")) + .is_some_and(|v| v.to_lowercase().contains("tesla")); + let pin_text = if is_tesla && charger_stalls >= 1.0 { + format!("{:.0}", charger_stalls.min(99.0)) + } else if charger_kw >= 1.0 { + format!("{:.0}", charger_kw.min(999.0)) + } else { + String::new() + }; + if !pin_text.is_empty() { + labels.push(TileLabel { + text: pin_text, + priority: 1, + source_layer: "chargers".to_string(), + road_kind: format!( + "chp{}_{:.0}x{:.0}", + icon_zoom_floor, + point.0 * 4.0, + point.1 * 4.0 + ), + color_class: crate::map::label::LABEL_CLASS_PIN, + path_points: crate::map::label::point_label_path_pub(( + point.0, point.1, + )), + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + }); + } // brand reads below the pin from z13; the kW digits // are part of the icon composite itself. if render_zoom >= 13 { @@ -989,14 +1146,19 @@ fn build_tile_buffers_from_features( point.0 * 4.0, point.1 * 4.0 ), - color_class: if charger_kw >= 150.0 { + color_class: if operator.to_lowercase().contains("tesla") + { crate::map::label::LABEL_CLASS_HEALTH } else { crate::map::label::LABEL_CLASS_AMENITY }, + // Anchor AT the charger point; the + // below-the-pin offset is applied in + // SCREEN space at candidate time so it + // doesn't tilt-compress or orbit the + // billboard pin when the camera moves. path_points: crate::map::label::point_label_path_pub(( - point.0, - point.1 + 12.0 / render_scale, + point.0, point.1, )), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), @@ -1022,7 +1184,7 @@ fn build_tile_buffers_from_features( let icon_min_dist = (ICON_SIZE_PX + 3.0) / render_scale; let icon_min_dist_sq = icon_min_dist * icon_min_dist; let mut accepted_icons = Vec::<(f32, f32)>::new(); - icon_jobs.retain(|(point, _, _, _, dist_factor, _, _)| { + icon_jobs.retain(|(point, _, _, _, dist_factor, _, _, _, _)| { let collides = accepted_icons.iter().any(|other| { let dx = other.0 - point.0; let dy = other.1 - point.1; @@ -1076,6 +1238,8 @@ fn build_tile_buffers_from_features( struct BuildingGroup { rings: Vec, height_m: f32, + min_height_m: f32, + is_part: bool, } let mut building_groups = Vec::::new(); let mut building_group_lookup = HashMap::::new(); @@ -1114,6 +1278,11 @@ fn build_tile_buffers_from_features( building_groups.push(BuildingGroup { rings: Vec::new(), height_m: building_height_m(&way.tags), + min_height_m: building_min_height_m(&way.tags), + is_part: way + .tags + .get("building:part") + .is_some_and(|value| value != "no"), }); index }; @@ -1318,8 +1487,45 @@ fn build_tile_buffers_from_features( struct BuildingJob { polygon: Vec>, height_m: f32, + base_m: f32, min_y: f32, } + // Simple 3D Buildings: an outline whose interior holds + // building:parts must NOT extrude — the parts carry the true + // volumes (Westerkerk's nave + 85m Westertoren); the outline + // keeps only a flat footprint fill beneath them. + let part_centroids: Vec<(f32, f32)> = building_groups + .iter() + .filter(|group| group.is_part) + .filter_map(|group| { + group + .rings + .iter() + .max_by(|a, b| { + a.signed_area + .abs() + .partial_cmp(&b.signed_area.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|ring| ring_centroid(&ring.points)) + }) + .collect(); + if !part_centroids.is_empty() { + for group in building_groups.iter_mut() { + if group.is_part { + continue; + } + let covers = group.rings.iter().any(|ring| { + part_centroids + .iter() + .any(|c| point_in_ring(*c, &ring.points)) + }); + if covers { + group.height_m = 0.0; + group.min_height_m = 0.0; + } + } + } let mut building_jobs = Vec::::new(); for group in &building_groups { for polygon in classify_polygon_rings(&group.rings, EARCUT_MAX_RINGS) { @@ -1333,6 +1539,7 @@ fn build_tile_buffers_from_features( building_jobs.push(BuildingJob { polygon, height_m: group.height_m, + base_m: group.min_height_m.clamp(0.0, group.height_m), min_y, }); } @@ -1347,6 +1554,9 @@ fn build_tile_buffers_from_features( // Light from the north-west; walls shade by their outward normal. let (light_x, light_y) = (-0.55_f32, -0.835_f32); for job in &building_jobs { + if job.height_m <= 0.05 { + // Flattened outline: footprint fill only, no walls. + } else { for ring in &job.polygon { // Outward normal needs ring orientation; positive shoelace // in y-down tile space = exterior winding, holes come @@ -1384,6 +1594,7 @@ fn build_tile_buffers_from_features( append_wall_quad( a, b, + job.base_m, job.height_m, wall_color, &mut fill_vertices, @@ -1392,6 +1603,7 @@ fn build_tile_buffers_from_features( ); } } + } for ring in &job.polygon { emit_path(&mut path, ring, true); } @@ -1424,6 +1636,63 @@ fn build_tile_buffers_from_features( } } + // Little 3D trees (tilt mode): two crossed trunk quads (visible from + // any camera heading) + two stacked canopy discs lifted by the same + // per-meter height mechanism as building roofs — the tilt compression + // turns them into oval blobs. + if !tree_points_3d.is_empty() { + let n = (1u32 << tile_key.z) as f64; + let lat = (std::f64::consts::PI * (1.0 - 2.0 * (tile_key.y as f64 + 0.5) / n)) + .sinh() + .atan(); + // tile-local units per meter at this latitude + let units_per_m = + (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; + let trunk_color = hex_to_premul_rgba(0x8a6b4a, 1.0); + let canopy_color = hex_to_premul_rgba(0x4a7d44, 1.0); + let arm = 0.55 * units_per_m; + // Canopy = a BALL, not a pancake: same-color sphere slices give the + // blob a round silhouette under tilt. Architecture-model + // proportions: the ball dominates (R 2.6m, center 3.4m), with a + // short visible trunk under it. + const BALL_SLICES: [(f32, f32); 4] = + [(1.9, 1.6), (2.5, 2.8), (2.5, 4.0), (1.9, 5.2)]; + for (x, y) in &tree_points_3d { + append_wall_quad( + (*x - arm, *y), + (*x + arm, *y), + 0.0, + 3.4, + trunk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + append_wall_quad( + (*x, *y - arm), + (*x, *y + arm), + 0.0, + 3.4, + trunk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + for (radius_m, height_m) in BALL_SLICES { + append_horizontal_disc( + (*x, *y), + radius_m * units_per_m, + height_m, + canopy_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + } + feature_count += 1; + } + } + // Stroke pass let mut stroke_jobs = Vec::::new(); let mut arrow_jobs = Vec::<(Vec<(f32, f32)>, bool)>::new(); @@ -1591,11 +1860,12 @@ fn build_tile_buffers_from_features( } // Pass 3: POI symbols — zoom-constant vector icons, drawn above strokes. - for (anchor, mesh, color_class, _, _, two_tone, kw) in &icon_jobs { + for (anchor, mesh, color_class, _, _, two_tone, kw, stalls, zoom_floor) in &icon_jobs { append_icon_mesh( mesh, *anchor, hex_to_premul_rgba(poi_class_hex(*color_class), 1.0), + *zoom_floor, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -1607,6 +1877,7 @@ fn build_tile_buffers_from_features( core, *anchor, hex_to_premul_rgba(0x4c7a4c, 1.0), + *zoom_floor, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -1624,33 +1895,14 @@ fn build_tile_buffers_from_features( bolt, *anchor, hex_to_premul_rgba(0xffffff, 1.0), + *zoom_floor, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, ); } } - if *two_tone == 2 { - let text = format!("{:.0}", kw.min(999.0)); - let advance = 6.4f32; - let total = text.len() as f32 * advance; - // Text zone: right of the bolt inside the wide bubble. - let start_x = 3.0 - total * 0.5 + advance * 0.5; - for (index, ch) in text.chars().enumerate() { - let Some(digit) = ch.to_digit(10) else { continue }; - if let Some(mesh) = icon_mesh(super::icons::DIGIT_NAMES[digit as usize]) { - append_icon_mesh_offset( - mesh, - *anchor, - (start_x + index as f32 * advance, -3.5), - hex_to_premul_rgba(0xffffff, 1.0), - &mut icon_vertices, - &mut icon_indices, - &mut icon_zbias, - ); - } - } - } + let _ = (kw, stalls); feature_count += 1; } @@ -1780,21 +2032,58 @@ fn append_icon_mesh( mesh: &IconMesh, anchor: (f32, f32), color: [f32; 4], + min_zoom: f32, out_vertices: &mut Vec, out_indices: &mut Vec, zbias: &mut f32, ) { - append_icon_mesh_offset(mesh, anchor, (0.0, 0.0), color, out_vertices, out_indices, zbias) + append_icon_mesh_offset( + mesh, + anchor, + (0.0, 0.0), + color, + min_zoom, + out_vertices, + out_indices, + zbias, + ) } /// Like append_icon_mesh with an extra SCREEN-px offset added to every /// vertex — lets shared meshes (digits) compose inside a pin badge while /// staying zoom-constant with it. +#[allow(clippy::too_many_arguments)] fn append_icon_mesh_offset( mesh: &IconMesh, anchor: (f32, f32), screen_offset: (f32, f32), color: [f32; 4], + min_zoom: f32, + out_vertices: &mut Vec, + out_indices: &mut Vec, + zbias: &mut f32, +) { + append_icon_mesh_offset_scaled( + mesh, + anchor, + screen_offset, + 1.0, + color, + min_zoom, + out_vertices, + out_indices, + zbias, + ) +} + +#[allow(clippy::too_many_arguments)] +fn append_icon_mesh_offset_scaled( + mesh: &IconMesh, + anchor: (f32, f32), + screen_offset: (f32, f32), + scale: f32, + color: [f32; 4], + min_zoom: f32, out_vertices: &mut Vec, out_indices: &mut Vec, zbias: &mut f32, @@ -1814,11 +2103,18 @@ fn append_icon_mesh_offset( vertex.stroke_dist, ICON_SHAPE_ID, 0.0, // param0: solid color - vertex.x + screen_offset.0, // param1/2: screen-px offset from the anchor - vertex.y + screen_offset.1, + // param1/2: screen-px offset from the anchor + vertex.x * scale + screen_offset.0, + vertex.y * scale + screen_offset.1, 0.0, - 0.0, - 90.0, // tilt depth: markers NEVER clip into the tilted ground + // param4: this icon's view-zoom floor; the shader collapses the + // vertex when the live view zoom is below it (no stale flash). + min_zoom, + // Tilt depth: markers never clip into the tilted ground or + // building walls (ground-y span + sort ranks stay under ~4), + // but the total map depth (-24 + this) MUST stay negative — + // at 90 the icons rose above 0 and painted over the UI panels. + 16.0, 24.0, // clip_radius: generous, avoids pop-in at view edges *zbias, ]); @@ -2809,6 +3105,41 @@ fn skip_pb_field(bytes: &[u8], pos: &mut usize, wire: u8) -> Result<(), String> mod bridge_probe_tests { use super::*; + #[test] + #[ignore] + fn westerkerk_probe() { + let detail = std::path::Path::new("../local/maps/europe-osm-detail.mbtiles"); + if !detail.exists() { + return; + } + let mut reader = makepad_mbtile_reader::MbtilesReader::open(detail).unwrap(); + let (z, x, y) = (14i64, 8414i64, 5384i64); + let raw = reader.get_tile(z, x, (1 << z) - 1 - y).unwrap().unwrap(); + let key = TileKey { z: z as u32, x: x as i32, y: y as i32 }; + let pbf = decode_vector_tile_payload(&raw).unwrap(); + let mut collector = MvtLocalCollector::new(4.0); + parse_mvt_tile(&pbf, key, &mut collector).unwrap(); + let mut by_layer = std::collections::HashMap::::new(); + for way in &collector.ways { + let layer = way.tags.get("layer").cloned().unwrap_or_default(); + *by_layer.entry(layer).or_default() += 1; + if way.tags.contains_key("building:part") { + println!( + "PART layer={} closed={} pts={} id={:?} h={:?} min={:?}", + way.tags.get("layer").cloned().unwrap_or_default(), + way.closed, + way.points.len(), + way.tags.get("__makepad_osm_id"), + way.tags.get("height"), + way.tags.get("min_height"), + ); + } + } + let mut stats: Vec<_> = by_layer.into_iter().collect(); + stats.sort(); + println!("LAYER STATS {:?}", stats); + } + #[test] #[ignore] fn place_labels_probe() { @@ -2921,6 +3252,7 @@ mod bridge_probe_tests { shift: 0, quadrant_x: 0, quadrant_y: 0, + filter: 0, }]; let theme = CompiledMapTheme::default(); let buffers = diff --git a/widgets/src/map/view.rs b/widgets/src/map/view.rs index bf7b47f32..1192c8ab9 100644 --- a/widgets/src/map/view.rs +++ b/widgets/src/map/view.rs @@ -27,6 +27,9 @@ script_mod! { map_offset: uniform(vec2(0.0, 0.0)) tile_fade: uniform(1.0) width_correction: uniform(vec4(1.0, 1.0, 1.0, 1.0)) + // Live view zoom for per-icon zoom floors (param4 on shape 20): + // stale deeper-bucket tiles must not flash markers on zoom-out. + icon_zoom: uniform(24.0) // Heading-up camera: cos/sin of the screen rotation and its pivot // (the view center). Identity when north-up. view_rot: uniform(vec2(1.0, 0.0)) @@ -79,14 +82,25 @@ script_mod! { // extrude toward screen-top. The pre-tilt (ground) y doubles as // the view depth so the depth buffer resolves occlusion. let ground_rel_y = transformed.y - self.rot_pivot.y; + // param4 is building height in meters — EXCEPT on shape 20 + // icons, where it carries the icon's zoom floor and must not + // lift the marker off the ground. + var lift_m = self.geom.param4; + if shape_id > 19.5 && shape_id < 20.5 { + lift_m = 0.0; + } transformed.y = self.rot_pivot.y + ground_rel_y * self.tilt_params.x - - self.geom.param4 * self.tilt_params.y; + - lift_m * self.tilt_params.y; // shape 20: zoom-constant symbol — position is the anchor point, // param1/2 the vertex offset in screen px added after the // transform. POI symbols stay upright; map-aligned glyphs like // oneway arrows (param3 flag) rotate with the camera. if shape_id > 19.5 && shape_id < 20.5 { + if self.geom.param4 > self.icon_zoom { + self.vertex_pos = vec4(0.0, 0.0, 0.0, 0.0); + return + } var off = vec2(self.geom.param1, self.geom.param2); if self.geom.param3 > 0.5 { off = vec2( @@ -402,6 +416,11 @@ pub enum MapViewAction { lat: f64, zoom: f64, }, + /// Camera tilt changed via the rotate/tilt gesture — lets the app + /// keep its 2D/3D mode state in sync with manual tilting. + TiltChanged { + tilt: f64, + }, /// A charger pin was tapped: position + the attributes we know. PinTapped { lon: f64, @@ -466,6 +485,7 @@ impl DrawMapVector { view_rot: [f32; 2], rot_pivot: [f32; 2], tilt_params: [f32; 4], + icon_zoom: f32, ) { self.map_scale = map_scale; self.map_offset = map_offset; @@ -497,6 +517,9 @@ impl DrawMapVector { self.draw_super .draw_vars .set_uniform(cx.cx, live_id!(tilt_params), &tilt_params); + self.draw_super + .draw_vars + .set_uniform(cx.cx, live_id!(icon_zoom), &[icon_zoom]); self.draw_super.draw_vars.geometry_id = Some(geometry_id); cx.new_draw_call(&self.draw_super.draw_vars); if self.draw_super.draw_vars.can_instance() { @@ -625,6 +648,10 @@ pub struct MapView { applied_dark_theme: Option, #[rust] style_epoch: u64, + /// The 2D/3D mode the current tile set was baked with — a flip + /// re-bakes tiles (extrusions only exist in the 3D bake). + #[rust] + baked_3d_mode: bool, #[rust] compiled_style_light: CompiledMapTheme, #[rust] @@ -645,7 +672,7 @@ pub struct MapView { #[rust] scratch_accepted_bounds: Vec, #[rust] - scratch_accepted_plans: Vec<(f64, usize, usize, u8, bool)>, + scratch_accepted_plans: Vec<(f64, usize, usize, u8, bool, bool, Vec2f)>, // Labels drawn last frame (hashed name+position key); kept to stabilize // placement while panning instead of flickering between candidates. #[rust] @@ -864,7 +891,12 @@ impl Widget for MapView { if let Some((start_abs, start_rotation, start_tilt)) = self.rotate_drag { let delta = fe.abs - start_abs; self.rotation = (start_rotation - delta.x * 0.35).rem_euclid(360.0); - self.tilt = (start_tilt + delta.y * 0.25).clamp(0.0, 65.0); + // Snap-to-2D dead zone: dragging the camera back to + // straight above lands on EXACTLY 0 so the renderer + // re-enters the flat classic path (2D mode). + let raw_tilt = (start_tilt + delta.y * 0.25).clamp(0.0, 65.0); + self.tilt = if raw_tilt < 6.0 { 0.0 } else { raw_tilt }; + cx.widget_action(self.uid, MapViewAction::TiltChanged { tilt: self.tilt }); self.redraw(cx); } else if let Some(start_abs) = self.drag_start_abs { let delta = fe.abs - start_abs; @@ -889,6 +921,13 @@ impl Widget for MapView { } Hit::FingerUp(fe) => { if self.rotate_drag.take().is_some() { + // Releasing near straight-above settles on EXACTLY 0: + // a 5-10 degree residual reads as "3D mode stuck on". + if self.tilt > 0.0 && self.tilt < 10.0 { + self.tilt = 0.0; + cx.widget_action(self.uid, MapViewAction::TiltChanged { tilt: 0.0 }); + self.redraw(cx); + } self.sync_camera_fields(); self.emit_viewport_changed(cx); return; @@ -1084,6 +1123,7 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, ); } } @@ -1101,6 +1141,7 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, ); } } @@ -1186,6 +1227,7 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, ); } } @@ -1203,6 +1245,7 @@ impl Widget for MapView { view_rot_uniform, rot_pivot_uniform, tilt_uniform, + view_zoom as f32, ); } } @@ -1786,9 +1829,6 @@ impl MapView { .filter(|p| !p.trim().is_empty()) .map(|p| p.trim().to_string()) .collect(); - if !overlay_paths.is_empty() { - log!("tile fetch {:?} with {} overlays", key, overlay_paths.len()); - } // Extruded buildings only bake while the camera is tilted; flat // mode keeps the classic 2D building style with outlines. let buildings_3d = self.buildings_3d && self.tilt > 0.0; @@ -1909,6 +1949,14 @@ impl MapView { fn ensure_visible_tiles(&mut self, cx: &mut Cx, rect: Rect) { self.frame_counter = self.frame_counter.wrapping_add(1); + // Tiles bake differently in 2D vs 3D (building extrusions replace + // the flat fills). Crossing tilt 0 must re-bake, or leaving 3D + // keeps the extruded set until a zoom forces a bucket rebuild. + let mode_3d = self.buildings_3d && self.tilt > 0.0; + if mode_3d != self.baked_3d_mode { + self.baked_3d_mode = mode_3d; + self.restyle_tiles_keep_stale(cx); + } // Read the archive's declared zoom range BEFORE computing visible // tile keys — request_zoom_level clamps to it, and reading it after // meant the very first frame requested impossible zoom levels. @@ -2401,7 +2449,11 @@ impl MapView { break; } let candidate = &self.scratch_candidates[candidate_index]; - let close_repeat = self + // Every pin needs ITS number: two 120kW sites near each other + // are different chargers, not a repeated street name — the + // name-key repeat suppression must not blank the second pin. + let close_repeat = candidate.color_class != LABEL_CLASS_PIN + && self .scratch_accepted_centers .get(&candidate.name_key) .is_some_and(|centers| { @@ -2445,9 +2497,17 @@ impl MapView { label_perf.rejected_outside += 1; continue; } - if self.scratch_accepted_bounds.iter().any(|placed| { - rects_overlap_with_padding(*placed, placement.bounds, LABEL_COLLISION_PADDING) - }) { + // In-pin text never collision-culls: it sits INSIDE the pin + // bubble (which already icon-collides), so losing to a nearby + // place/street label just blanked the pin. It still RESERVES + // its box so street text avoids the area. + let is_pin_text = self.scratch_candidates[candidate_index].color_class + == LABEL_CLASS_PIN; + if !is_pin_text + && self.scratch_accepted_bounds.iter().any(|placed| { + rects_overlap_with_padding(*placed, placement.bounds, LABEL_COLLISION_PADDING) + }) + { self.path_glyphs.truncate(placement.glyph_start); label_perf.rejected_collision += 1; continue; @@ -2464,7 +2524,12 @@ impl MapView { .or_default() .push(placement.center); } - self.scratch_accepted_bounds.push(placement.bounds); + // In-pin text reserves NO space: the pin bubble covers whatever + // sits under it (pins draw over street text by design), and its + // box was collision-culling the brand label under the same pin. + if !is_pin_text { + self.scratch_accepted_bounds.push(placement.bounds); + } let glyph_count = placement.glyph_end - placement.glyph_start; label_perf.drawn_labels += 1; label_perf.drawn_glyphs += glyph_count; @@ -2475,12 +2540,27 @@ impl MapView { // the symbol pass so they sit on the pins, not under them. let post_icon = candidate.color_class == LABEL_CLASS_PIN || candidate.road_kind.starts_with("chb"); + // Billboard pin-phase plans anchor at the SITE point (the pin's + // baked anchor): back the screen-px layout shift out of the + // placement center so glyph offsets carry the layout instead. + let layout_shift = if candidate.color_class == LABEL_CLASS_PIN { + (3.0f32, -12.35f32) + } else if candidate.road_kind.starts_with("chb") { + (0.0, 9.0) + } else { + (0.0, 0.0) + }; self.scratch_accepted_plans.push(( score, placement.glyph_start, placement.glyph_end, candidate.color_class, post_icon, + candidate.screen_point, + Vec2f { + x: placement.center.x as f32 - layout_shift.0, + y: placement.center.y as f32 - layout_shift.1, + }, )); } @@ -2563,26 +2643,43 @@ impl MapView { self.draw_label.set_camera_delta(cx.cx, m, pivot); self.draw_label.begin_glyph_batch(cx); for i in 0..self.scratch_accepted_plans.len() { - let (_, start, end, color_class, post_icon) = self.scratch_accepted_plans[i]; + let (_, start, end, color_class, post_icon, upright, anchor) = + self.scratch_accepted_plans[i]; if post_icon != pin_phase { continue; } let glyphs = &self.path_glyphs[start..end]; - self.draw_label.draw_super.color = halo_color; - for offset in HALO_OFFSETS { - self.draw_label.draw_path_glyphs_scaled( - cx, - glyphs, - scale, - Vec2f { + let billboard = pin_phase && upright; + // In-pin text sits on a solid pin color: no halo underdraw. + if color_class != LABEL_CLASS_PIN { + self.draw_label.draw_super.color = halo_color; + for offset in HALO_OFFSETS { + let off = Vec2f { x: offset.0 + extra_offset.x, y: offset.1 + extra_offset.y, - }, - ); + }; + if billboard { + self.draw_label + .draw_path_glyphs_billboard(cx, glyphs, scale, off, anchor); + } else if upright { + self.draw_label + .draw_path_glyphs_upright(cx, glyphs, scale, off, anchor); + } else { + self.draw_label.draw_path_glyphs_scaled(cx, glyphs, scale, off); + } + } } self.draw_label.draw_super.color = label_class_color(color_class, label_color, dark_theme); - self.draw_label.draw_path_glyphs_scaled(cx, glyphs, scale, extra_offset); + if billboard { + self.draw_label + .draw_path_glyphs_billboard(cx, glyphs, scale, extra_offset, anchor); + } else if upright { + self.draw_label + .draw_path_glyphs_upright(cx, glyphs, scale, extra_offset, anchor); + } else { + self.draw_label.draw_path_glyphs_scaled(cx, glyphs, scale, extra_offset); + } } self.draw_label.end_glyph_batch(cx); } @@ -2675,6 +2772,22 @@ impl MapView { if is_address && view_zoom < ADDRESS_LABEL_MIN_ZOOM { continue; } + // Charger pin text carries the pin's zoom floor in its key + // ("chp11_..."): stale deeper tiles must not flash numbers + // for pins the icon shader is hiding at this view zoom. + if let Some(rest) = label.road_kind.strip_prefix("chp") { + let floor: f64 = rest + .split('_') + .next() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.0); + if view_zoom < floor - 0.01 { + continue; + } + } + if label.road_kind.starts_with("chb") && view_zoom < 12.75 { + continue; + } if is_poi && view_zoom < POI_LABEL_MIN_ZOOM { continue; } @@ -2700,7 +2813,9 @@ impl MapView { } // precomputed at tile build; no per-frame allocation let name_key = &label.name_key; - if name_key.len() < if is_address { 1 } else { 2 } { + let is_exit = label.source_layer == "street_labels_points"; + let is_pin_text = label.color_class == LABEL_CLASS_PIN; + if name_key.len() < if is_address || is_exit || is_pin_text { 1 } else { 2 } { continue; } @@ -2721,7 +2836,11 @@ impl MapView { || is_poi || matches!( label.source_layer.as_str(), - "chargers" | "charger_brand" | "place_labels" | "micro_pois" + "chargers" + | "charger_brand" + | "place_labels" + | "micro_pois" + | "street_labels_points" ); if rotated && is_screen_point && self.scratch_screen_path.len() == 2 { let a = self.scratch_screen_path[0]; @@ -2731,6 +2850,22 @@ impl MapView { self.scratch_screen_path[0] = dvec2(mid.x - half, mid.y); self.scratch_screen_path[1] = dvec2(mid.x + half, mid.y); } + // Charger brand reads just under the billboard pin: a fixed + // SCREEN-space drop below the site anchor (a map-space offset + // would tilt-compress and orbit the pin under rotation). + if label.road_kind.starts_with("chb") { + for p in self.scratch_screen_path.iter_mut() { + p.y += 9.0; + } + } + // In-pin text: center in the droplet's text zone (right of + // the bolt, above the tail) — screen-px pin layout. + if label.color_class == LABEL_CLASS_PIN { + for p in self.scratch_screen_path.iter_mut() { + p.x += 3.0; + p.y += -12.35; + } + } if self.scratch_screen_path.len() < 2 || polyline_outside_rect(&self.scratch_screen_path, rect, LABEL_VIEW_MARGIN) { @@ -2776,7 +2911,9 @@ impl MapView { } else if is_poi { font_scale = 0.72; } else if label.source_layer == "chargers" { - font_scale = 0.85; + font_scale = 0.78; + } else if is_exit { + font_scale = 0.80; } else if let Some((kind, population)) = place { // Kind sets the class, population separates Amsterdam // from Purmerend within it. @@ -2833,6 +2970,7 @@ impl MapView { c.center = center; c.repeat_distance = repeat_distance; c.font_scale = font_scale; + c.screen_point = is_screen_point; c.screen_path.extend_from_slice(&self.scratch_screen_path); } else { self.scratch_candidates.push(LabelCandidate { @@ -2846,6 +2984,7 @@ impl MapView { center, repeat_distance, font_scale, + screen_point: is_screen_point, screen_path: self.scratch_screen_path.clone(), }); } @@ -3176,7 +3315,7 @@ impl MapView { let screen = camera.norm_to_screen(dvec2(hit.norm.0, hit.norm.1)); let dx = abs.x - screen.x; let dy = abs.y - screen.y; - if dx.abs() <= 18.0 && dy >= -18.0 && dy <= 16.0 { + if dx.abs() <= 18.0 && dy >= -26.0 && dy <= 6.0 { let dist = dx * dx + dy * dy; if best.as_ref().is_none_or(|(d, _)| dist < *d) { best = Some((dist, hit)); @@ -3297,7 +3436,6 @@ impl MapView { return; } self.overlay_mbtiles_paths = paths.to_string(); - log!("set_overlay_paths: {:?} -> restyle", paths); self.restyle_tiles_keep_stale(cx); } @@ -3462,6 +3600,15 @@ impl MapViewRef { None } + pub fn tilt_changed(&self, actions: &Actions) -> Option { + if let Some(item) = actions.find_widget_action(self.widget_uid()) { + if let MapViewAction::TiltChanged { tilt } = item.cast() { + return Some(tilt); + } + } + None + } + pub fn marker_clicked(&self, actions: &Actions) -> Option { if let Some(item) = actions.find_widget_action(self.widget_uid()) { if let MapViewAction::MarkerClicked { id } = item.cast() { @@ -3592,6 +3739,8 @@ fn label_class_color(color_class: u8, default_color: Vec4f, dark_theme: bool) -> (LABEL_CLASS_WATER, false) => Vec4f::from_u32(0x39688fff), (LABEL_CLASS_WATER, true) => Vec4f::from_u32(0x7fb2d9ff), (LABEL_CLASS_PIN, _) => Vec4f::from_u32(0xffffffff), + (LABEL_CLASS_EXIT, false) => Vec4f::from_u32(0x960000ff), + (LABEL_CLASS_EXIT, true) => Vec4f::from_u32(0xe07070ff), _ => default_color, } } From ef83731b6cab5cfaba4b1bc45db18e831d4a90bb Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 16:57:07 +0200 Subject: [PATCH 07/10] map: transit lines/labels, districts, 3D signals+trees, label flip invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- platform/src/live_reload.rs | 22 +++-- widgets/src/map/geometry.rs | 45 ++++++++- widgets/src/map/label.rs | 97 ++++++++++++++++++- widgets/src/map/style.rs | 53 ++++++++-- widgets/src/map/tile.rs | 186 ++++++++++++++++++++++++++++-------- widgets/src/map/view.rs | 80 ++++++++++++++-- 6 files changed, 412 insertions(+), 71 deletions(-) diff --git a/platform/src/live_reload.rs b/platform/src/live_reload.rs index a7481b023..200f4f6b6 100644 --- a/platform/src/live_reload.rs +++ b/platform/src/live_reload.rs @@ -289,14 +289,22 @@ fn handle_cx_live_edit_files(cx: &mut Cx) -> bool { } *cx.script_data.live_reload.script_mod_overrides.borrow_mut() = next_overrides; + // Name the files: a PERSISTENT override at every cold launch means some + // file permanently differs from its compiled-in copy — that's a build + // staleness bug to chase, not a feature. + let override_files: Vec = cx + .script_data + .live_reload + .script_mod_overrides + .borrow() + .keys() + .map(|key| format!("{:?}", key)) + .collect(); crate::log!( - "hot reload applied {} override(s) from {} file change(s)", - cx.script_data - .live_reload - .script_mod_overrides - .borrow() - .len(), - processed_files + "hot reload applied {} override(s) from {} file change(s): {}", + override_files.len(), + processed_files, + override_files.join(", ") ); true } diff --git a/widgets/src/map/geometry.rs b/widgets/src/map/geometry.rs index 30b319f07..25aeb3354 100644 --- a/widgets/src/map/geometry.rs +++ b/widgets/src/map/geometry.rs @@ -585,11 +585,46 @@ pub fn merge_stroke_polylines(polylines: &[Vec<(f32, f32)>]) -> Vec= 2) - .cloned() - .collect::>(); + // Forked ways (rail switches, dual-carriageway splits) duplicate their + // shared segments exactly; drawing a segment twice at the same depth + // rank z-fight-shimmers in tilt mode. Keep the FIRST occurrence of + // every quantized segment, splitting a polyline where a duplicate is + // dropped. + let mut seen_segments = + std::collections::HashSet::<(StrokeEndpointKey, StrokeEndpointKey)>::new(); + let mut lines = Vec::>::new(); + for line in polylines.iter().filter(|line| line.len() >= 2) { + let mut current = Vec::<(f32, f32)>::new(); + for pair in line.windows(2) { + let (a, b) = (pair[0], pair[1]); + let (ka, kb) = (stroke_endpoint_key(a), stroke_endpoint_key(b)); + // Sub-quantum micro segments never dedup (they'd collide + // across the whole tile). + let keep = if ka == kb { + true + } else { + let seg = if (ka.x, ka.y) <= (kb.x, kb.y) { + (ka, kb) + } else { + (kb, ka) + }; + seen_segments.insert(seg) + }; + if keep { + if current.is_empty() { + current.push(a); + } + current.push(b); + } else if current.len() >= 2 { + lines.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + } + if current.len() >= 2 { + lines.push(current); + } + } if lines.is_empty() { return Vec::new(); } diff --git a/widgets/src/map/label.rs b/widgets/src/map/label.rs index 9072c4b1c..9897b0686 100644 --- a/widgets/src/map/label.rs +++ b/widgets/src/map/label.rs @@ -18,7 +18,11 @@ pub const LABEL_CURVE_MAX_SAMPLES: usize = 192; pub const LABEL_CURVE_SMOOTH_PASSES: usize = 2; pub const LABEL_BASELINE_SHIFT_FACTOR: f64 = 1.0; pub const LABEL_LAYOUT_MAX_CURVATURE: f32 = 1.0; -pub const LABEL_VERTICAL_AXIS_EPSILON: f32 = 0.22; +// cos threshold for the "treat as vertical" band around 90 degrees. Keep it +// TIGHT (~±4°): inside the band the deterministic top-to-bottom rule wins, +// which SKIPS the upside-down flip — at 0.22 the band reached 103° and +// labels tilted past 90° rendered inverted (IJpromenade bug). +pub const LABEL_VERTICAL_AXIS_EPSILON: f32 = 0.07; // One overzoomed z14 tile can hold a whole city's addresses; house numbers // are lowest-priority and must survive this per-tile cap to ever reach the // viewport filter. @@ -49,6 +53,8 @@ pub const LABEL_CLASS_WATER: u8 = 9; 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 { @@ -116,6 +122,37 @@ 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), + }); + } // Waterway names follow their line like street names do. if source_layer == "water_lines_labels" { let name = select_label_text(tags)?; @@ -212,6 +249,36 @@ pub fn extract_area_label( tags: &HashMap, centroid: (f32, f32), ) -> Option { + // CBS district overlays: gemeente / wijk / buurt names at the area + // centroid, staged by zoom at candidate time (tier in road_kind). + if let Some(layer) = tags.get("layer") { + if matches!(layer.as_str(), "gemeenten" | "wijken" | "buurten") { + let (name_field, tier) = match layer.as_str() { + "gemeenten" => ("gemeentenaam", 'g'), + "wijken" => ("wijknaam", 'w'), + _ => ("buurtnaam", 'b'), + }; + let name = tags + .get(name_field) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + return Some(TileLabel { + text: name, + priority: 2, + source_layer: layer.clone(), + road_kind: format!( + "adm{}{:.0}x{:.0}", + tier, + centroid.0 * 4.0, + centroid.1 * 4.0 + ), + color_class: LABEL_CLASS_ADMIN, + path_points: point_label_path(centroid), + name_key: String::new(), + bbox: (0.0, 0.0, 0.0, 0.0), + }); + } + } // Geodata nature overlays name their areas with Dutch source columns. if let Some(layer) = tags.get("layer") { if matches!(layer.as_str(), "natura2000" | "wetlands") { @@ -321,6 +388,29 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> bbox: (0.0, 0.0, 0.0, 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), + }); + } // Charger sites label their power (and Superchargers their brand): // the kW number is the routing-relevant fact for an EV navigator. // Charger kW digits are drawn INSIDE the pin composite by the icon @@ -554,6 +644,11 @@ pub fn label_source_rank(layer: &str) -> Option { "water_polygons_labels" => 5, "water_lines_labels" => 4, "micro_pois" => 3, + // Transit line refs sit with street names in prominence. + "routes" => 6, + "stops" => 4, + // Admin district names: visible but under settlements/streets. + "gemeenten" | "wijken" | "buurten" => 5, // Charger kW labels outrank street names — this is an EV navigator. "chargers" => 8, "charger_brand" => 8, diff --git a/widgets/src/map/style.rs b/widgets/src/map/style.rs index 9a7ec1025..0a2d95b79 100644 --- a/widgets/src/map/style.rs +++ b/widgets/src/map/style.rs @@ -805,15 +805,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 +858,17 @@ pub fn stroke_style_for_tags( }); } "gemeenten" | "wijken" | "buurten" => { - let width = match layer { - "gemeenten" => 1.6, - "wijken" => 1.1, - _ => 0.8, + // Administrative boundary look: purple-gray, weight by tier, + // finer tiers only appear as you zoom in. + let (width, min_zoom) = match layer { + "gemeenten" => (1.8, 6), + "wijken" => (1.2, 11), + _ => (0.9, 13), }; + if render_zoom < min_zoom { + return None; + } + let width: f32 = width; return Some(StrokeStyle { sort_rank: 380, casing: None, diff --git a/widgets/src/map/tile.rs b/widgets/src/map/tile.rs index 6d6f4d715..8a1b2e9ac 100644 --- a/widgets/src/map/tile.rs +++ b/widgets/src/map/tile.rs @@ -391,6 +391,8 @@ pub fn build_tile_buffers_from_body( theme, render_zoom, false, + + false, )) } @@ -408,6 +410,7 @@ pub fn build_tile_buffers_from_mvt( render_zoom: u32, buildings_3d: bool, ) -> Result { + let have_charger_overlay = overlay_tiles.iter().any(|overlay| overlay.has_chargers); let pbf_data = decode_vector_tile_payload(raw_tile_data)?; let render_scale = 2.0_f64 .powi(render_zoom as i32 - tile_key.z as i32) @@ -463,6 +466,8 @@ pub fn build_tile_buffers_from_mvt( theme, render_zoom, buildings_3d, + + have_charger_overlay, )) } @@ -892,33 +897,49 @@ fn point_in_ring(point: (f32, f32), ring: &[(f32, f32)]) -> bool { inside } -/// A flat horizontal disc lifted `height_m` meters (roof-style param4): -/// triangle fan, hard edge (canopy blobs are small; AA fringe not worth it). -fn append_horizontal_disc( +/// A low-poly SPHERE: horizontal rings in map units, per-vertex height in +/// param4 — the tilt shader's per-meter lift renders a true ball silhouette +/// (stacked flat discs read as separate pancakes). +#[allow(clippy::too_many_arguments)] +fn append_ball( center: (f32, f32), radius_units: f32, - height_m: f32, + radius_m: f32, + center_h_m: f32, color: [f32; 4], + segs: u32, + rings: u32, out_vertices: &mut Vec, out_indices: &mut Vec, zbias: &mut f32, ) { - const SEGS: u32 = 14; + let (segs, rings) = (segs.max(3), rings.max(2)); let base = (out_vertices.len() / VECTOR_FLOATS_PER_VERTEX) as u32; - let mut push_vertex = |x: f32, y: f32| { + let mut push_vertex = |x: f32, y: f32, h: f32| { out_vertices.extend_from_slice(&[ x, y, 0.5, 1.0, color[0], color[1], color[2], color[3], 1e6, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, height_m, 0.05, 24.0, *zbias, + 0.0, 0.0, 0.0, h, 0.05, 24.0, *zbias, ]); }; - push_vertex(center.0, center.1); - for i in 0..SEGS { - let a = i as f32 / SEGS as f32 * std::f32::consts::TAU; - push_vertex(center.0 + a.cos() * radius_units, center.1 + a.sin() * radius_units); + // rings from south pole (phi -90) to north pole (phi +90) + for ring in 0..=rings { + let phi = (ring as f32 / rings as f32 - 0.5) * std::f32::consts::PI; + let ring_r = radius_units * phi.cos(); + let h = center_h_m + radius_m * phi.sin(); + for seg in 0..segs { + let a = seg as f32 / segs as f32 * std::f32::consts::TAU; + push_vertex(center.0 + a.cos() * ring_r, center.1 + a.sin() * ring_r, h); + } } - for i in 0..SEGS { - let next = (i + 1) % SEGS; - out_indices.extend_from_slice(&[base, base + 1 + i, base + 1 + next]); + for ring in 0..rings { + for seg in 0..segs { + let next = (seg + 1) % segs; + let a = base + ring * segs + seg; + let b = base + ring * segs + next; + let c = base + (ring + 1) * segs + seg; + let d = base + (ring + 1) * segs + next; + out_indices.extend_from_slice(&[a, b, c, b, d, c]); + } } *zbias += VECTOR_ZBIAS_STEP; } @@ -960,6 +981,7 @@ fn build_tile_buffers_from_features( theme: &CompiledMapTheme, render_zoom: u32, buildings_3d: bool, + have_charger_overlay: bool, ) -> TileBuffers { // How much this tile gets magnified on screen at the styled view zoom. let render_scale = 2.0_f64 @@ -976,6 +998,7 @@ fn build_tile_buffers_from_features( let mut icon_jobs = Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32, f32, f32)>::new(); let mut tree_points_3d = Vec::<(f32, f32)>::new(); + let mut signal_points_3d = Vec::<(f32, f32)>::new(); for (point, tags) in &tagged_points { let mut label_point = *point; let layer = tags.get("layer").map(|value| value.as_str()).unwrap_or(""); @@ -989,11 +1012,11 @@ fn build_tile_buffers_from_features( .and_then(|value| value.parse::().ok()) .unwrap_or(0.0); if kw >= 150.0 { - 9 + 8 } else if kw >= 50.0 { - 11 + 10 } else { - 13 + 12 } } "stops" => 13, @@ -1008,7 +1031,10 @@ fn build_tile_buffers_from_features( // Chargers place before everything (EV navigator) and // are never collided away by shop/POI symbols. let priority = match icon_name { - "charger" => 0, + // Overlay charger pins are never collided away — + // base-map charging_station icons yield to them. + "charger" if layer == "chargers" => 0, + "charger" => 2, "entrance" => 3, "dot" => 2, _ => 1, @@ -1054,15 +1080,33 @@ fn build_tile_buffers_from_features( // (param4): the shader hides the icon the instant the // LIVE view zoom drops below it, so stale deeper-bucket // tiles never flash markers while zooming out. - let zoom_floor = - micro_icon_min_zoom(icon_name).max(icon_zoom_floor as f32); + // Overlay layers (chargers, stops) use their TIER floor; + // micro_icon_min_zoom("charger") is the 16.5 street-level + // gate for BASE-map charging posts and must not apply to + // overlay pins (it hid every pin below z16 — the + // "chargers disappeared" bug). + let zoom_floor = if layer == "chargers" || layer == "stops" { + icon_zoom_floor as f32 + } else { + micro_icon_min_zoom(icon_name).max(icon_zoom_floor as f32) + }; // In 3D mode trees become little REAL 3D trees (trunk + // canopy blob lifted by the building height mechanism) // instead of flat billboard discs. + // With the charger overlay active the base-map + // charging_station icons are duplicates of overlay + // pins — drop them instead of letting them collide. + if icon_name == "charger" && layer != "chargers" && have_charger_overlay { + continue; + } if buildings_3d && icon_name == "tree" { tree_points_3d.push(*point); continue; } + if buildings_3d && icon_name == "traffic_signals" { + signal_points_3d.push(*point); + continue; + } icon_jobs.push(( *point, mesh, @@ -1650,19 +1694,14 @@ fn build_tile_buffers_from_features( (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; let trunk_color = hex_to_premul_rgba(0x8a6b4a, 1.0); let canopy_color = hex_to_premul_rgba(0x4a7d44, 1.0); - let arm = 0.55 * units_per_m; - // Canopy = a BALL, not a pancake: same-color sphere slices give the - // blob a round silhouette under tilt. Architecture-model - // proportions: the ball dominates (R 2.6m, center 3.4m), with a - // short visible trunk under it. - const BALL_SLICES: [(f32, f32); 4] = - [(1.9, 1.6), (2.5, 2.8), (2.5, 4.0), (1.9, 5.2)]; + let arm = 0.7 * units_per_m; + for (x, y) in &tree_points_3d { append_wall_quad( (*x - arm, *y), (*x + arm, *y), 0.0, - 3.4, + 7.5, trunk_color, &mut fill_vertices, &mut fill_indices, @@ -1672,18 +1711,77 @@ fn build_tile_buffers_from_features( (*x, *y - arm), (*x, *y + arm), 0.0, - 3.4, + 7.5, trunk_color, &mut fill_vertices, &mut fill_indices, &mut fill_zbias, ); - for (radius_m, height_m) in BALL_SLICES { - append_horizontal_disc( + // Street-tree proportions vs buildings: ~11.5m total. The + // canopy is a PROLATE ellipsoid (taller than wide) on a tall + // trunk — scaling the ball uniformly reads as a bush. + append_ball( + (*x, *y), + 2.9 * units_per_m, + 4.0, + 7.5, + canopy_color, + 12, + 6, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + feature_count += 1; + } + } + + // Little 3D stoplights (tilt mode): a slim dark pole with the classic + // three lights stacked on top — red above amber above green. + if !signal_points_3d.is_empty() { + let n = (1u32 << tile_key.z) as f64; + let lat = (std::f64::consts::PI * (1.0 - 2.0 * (tile_key.y as f64 + 0.5) / n)) + .sinh() + .atan(); + let units_per_m = + (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; + let pole_color = hex_to_premul_rgba(0x3c4046, 1.0); + let lights = [ + (hex_to_premul_rgba(0x2ecc40, 1.0), 3.5f32), + (hex_to_premul_rgba(0xf5a623, 1.0), 4.35), + (hex_to_premul_rgba(0xd7263d, 1.0), 5.2), + ]; + let arm = 0.32 * units_per_m; + for (x, y) in &signal_points_3d { + append_wall_quad( + (*x - arm, *y), + (*x + arm, *y), + 0.0, + 3.2, + pole_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + append_wall_quad( + (*x, *y - arm), + (*x, *y + arm), + 0.0, + 3.2, + pole_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + for (color, height_m) in lights { + append_ball( (*x, *y), - radius_m * units_per_m, + 0.5 * units_per_m, + 0.5, height_m, - canopy_color, + color, + 8, + 4, &mut fill_vertices, &mut fill_indices, &mut fill_zbias, @@ -1708,7 +1806,9 @@ fn build_tile_buffers_from_features( way.tags.get("junction").map(|v| v.as_str()), Some("roundabout") | Some("circular") ); - if render_zoom >= ICON_MIN_ZOOM + // Oneway arrows read from mid zoom, not just street level — + // direction matters while route-planning zoomed out. + if render_zoom >= 15 && (tag_is_truthy(&way.tags, "oneway") || implicit_oneway) && way.tags.contains_key("highway") && !tag_is_truthy(&way.tags, "rail") @@ -2175,6 +2275,9 @@ pub struct OverlayTileData { pub quadrant_y: u32, /// 0 = all features, 1 = fast chargers (>=50 kW), 2 = slow chargers. pub filter: u8, + /// Source is a charger overlay: base-map charging_station icons are + /// suppressed as duplicates while one is active. + pub has_chargers: bool, } fn overlay_zoom_range(reader: &mut MbtilesReader) -> (u32, u32) { @@ -2202,7 +2305,7 @@ pub fn load_local_tile_batch( } // Path entries may carry a "?fast" / "?slow" charger-power filter. - let mut overlay_readers: Vec<(MbtilesReader, u32, u32, u8)> = overlay_paths + let mut overlay_readers: Vec<(MbtilesReader, u32, u32, u8, bool)> = overlay_paths .iter() .filter(|path| !path.is_empty()) .filter_map(|path| { @@ -2212,17 +2315,20 @@ pub fn load_local_tile_batch( Some((file, _)) => (file, 0), None => (path.as_str(), 0), }; - MbtilesReader::open(Path::new(file)).ok().map(|reader| (reader, filter)) + let has_chargers = file.contains("chargers"); + MbtilesReader::open(Path::new(file)) + .ok() + .map(|reader| (reader, filter, has_chargers)) }) - .map(|(mut reader, filter)| { + .map(|(mut reader, filter, has_chargers)| { let (min_zoom, max_zoom) = overlay_zoom_range(&mut reader); - (reader, min_zoom, max_zoom, filter) + (reader, min_zoom, max_zoom, filter, has_chargers) }) .collect(); let mut fetch_overlays = |tile_key: TileKey| -> Vec { let mut out = Vec::new(); - for (reader, min_zoom, max_zoom, filter) in overlay_readers.iter_mut() { + for (reader, min_zoom, max_zoom, filter, has_chargers) in overlay_readers.iter_mut() { if tile_key.z < *min_zoom { continue; } @@ -2238,6 +2344,7 @@ pub fn load_local_tile_batch( quadrant_x: (tile_key.x as u32) - ((fetch_x as u32) << shift), quadrant_y: (tile_key.y as u32) - ((fetch_y as u32) << shift), filter: *filter, + has_chargers: *has_chargers, }); } } @@ -3253,6 +3360,7 @@ mod bridge_probe_tests { quadrant_x: 0, quadrant_y: 0, filter: 0, + has_chargers: true, }]; let theme = CompiledMapTheme::default(); let buffers = diff --git a/widgets/src/map/view.rs b/widgets/src/map/view.rs index 1192c8ab9..44590336c 100644 --- a/widgets/src/map/view.rs +++ b/widgets/src/map/view.rs @@ -97,7 +97,13 @@ script_mod! { // transform. POI symbols stay upright; map-aligned glyphs like // oneway arrows (param3 flag) rotate with the camera. if shape_id > 19.5 && shape_id < 20.5 { - if self.geom.param4 > self.icon_zoom { + // 0.6 grace below the floor: markers fade out on a zoom + // gesture instead of vanishing the instant the tier line + // is crossed (still far above the stale-carpet zone). + // FAIL-OPEN: if the icon_zoom uniform hasn't landed (reads + // ~0 — seen when a startup DSL override re-parses this + // shader), the gate disarms instead of hiding every icon. + if self.icon_zoom > 1.0 && self.geom.param4 > self.icon_zoom + 0.6 { self.vertex_pos = vec4(0.0, 0.0, 0.0, 0.0); return } @@ -1074,7 +1080,7 @@ impl Widget for MapView { // view drops below icon level, instead of splattering // hundreds of full-size shop icons across the region. if pass == 3 - && (view_zoom < 8.75 + && (view_zoom < 7.75 || (entry.bucket >= ICON_MIN_ZOOM && view_zoom < ICON_MIN_ZOOM as f64 - 0.25)) { @@ -1178,7 +1184,7 @@ impl Widget for MapView { // view drops below icon level, instead of splattering // hundreds of full-size shop icons across the region. if pass == 3 - && (view_zoom < 8.75 + && (view_zoom < 7.75 || (entry.bucket >= ICON_MIN_ZOOM && view_zoom < ICON_MIN_ZOOM as f64 - 0.25)) { @@ -2524,10 +2530,17 @@ impl MapView { .or_default() .push(placement.center); } - // In-pin text reserves NO space: the pin bubble covers whatever - // sits under it (pins draw over street text by design), and its - // box was collision-culling the brand label under the same pin. - if !is_pin_text { + // Pin text reserves the pin BUBBLE's box (not its own glyph + // box): POI/street labels then place beside the pin instead of + // under it, while the brand label below the tail stays legal. + if is_pin_text { + let anchor_x = placement.center.x - 3.0; + let anchor_y = placement.center.y + 12.35; + self.scratch_accepted_bounds.push(Rect { + pos: dvec2(anchor_x - 16.0, anchor_y - 27.0), + size: dvec2(32.0, 28.0), + }); + } else { self.scratch_accepted_bounds.push(placement.bounds); } let glyph_count = placement.glyph_end - placement.glyph_start; @@ -2781,13 +2794,31 @@ impl MapView { .next() .and_then(|v| v.parse().ok()) .unwrap_or(0.0); - if view_zoom < floor - 0.01 { + if view_zoom < floor - 0.6 { continue; } } if label.road_kind.starts_with("chb") && view_zoom < 12.75 { continue; } + // District names: gemeente wide-out, wijk mid, buurt close. + if let Some(rest) = label.road_kind.strip_prefix("adm") { + let (floor, ceil) = match rest.chars().next() { + Some('g') => (8.0, 12.0), + Some('w') => (11.5, 14.0), + _ => (13.5, 17.0), + }; + if view_zoom < floor || view_zoom > ceil { + continue; + } + } + // Stop names: stations from z13, local tram/bus stops z15+. + if label.source_layer == "stops" { + let floor = if label.road_kind.starts_with("stS") { 13.0 } else { 15.0 }; + if view_zoom < floor { + continue; + } + } if is_poi && view_zoom < POI_LABEL_MIN_ZOOM { continue; } @@ -3107,7 +3138,7 @@ impl MapView { * 0.5 * LABEL_BASELINE_SHIFT_FACTOR as f32; - let result = self.draw_label.place_text_along_path( + let mut result = self.draw_label.place_text_along_path( &run, &smooth_a, &cum, @@ -3120,6 +3151,35 @@ impl MapView { candidate.center, &mut self.path_glyphs, ); + // HARD invariant instead of trusting the chord heuristic: if the + // REALIZED glyph run reads leftward (net upside-down — hairpin + // ramps fool any pre-placement guess, e.g. the inverted "A1" + // motorway ref), throw it away and place flipped. + if let Some(placed) = &result { + if placed.glyph_end > placed.glyph_start + 1 { + let a = self.path_glyphs[placed.glyph_start].glyph_origin; + let b = self.path_glyphs[placed.glyph_end - 1].glyph_origin; + let (dx, dy) = (b.x - a.x, b.y - a.y); + let len = (dx * dx + dy * dy).sqrt(); + if len > 1.0 && dx / len < -LABEL_VERTICAL_AXIS_EPSILON { + let glyph_start = placed.glyph_start; + self.path_glyphs.truncate(glyph_start); + result = self.draw_label.place_text_along_path( + &run, + &smooth_a, + &cum, + start_distance, + !reverse, + baseline_shift, + if reverse { 0.0 } else { std::f32::consts::PI }, + LABEL_MAX_GLYPH_TURN_RADIANS, + LABEL_GLYPH_ANGLE_BLEND, + candidate.center, + &mut self.path_glyphs, + ); + } + } + } self.scratch_smooth_a = smooth_a; self.scratch_smooth_b = smooth_b; @@ -3741,6 +3801,8 @@ fn label_class_color(color_class: u8, default_color: Vec4f, dark_theme: bool) -> (LABEL_CLASS_PIN, _) => Vec4f::from_u32(0xffffffff), (LABEL_CLASS_EXIT, false) => Vec4f::from_u32(0x960000ff), (LABEL_CLASS_EXIT, true) => Vec4f::from_u32(0xe07070ff), + (LABEL_CLASS_ADMIN, false) => Vec4f::from_u32(0x6a5b8eff), + (LABEL_CLASS_ADMIN, true) => Vec4f::from_u32(0xb3a5d6ff), _ => default_color, } } From 2492ad3bdedd2740181e85b5b277090442abb5c8 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 28 Jul 2026 18:00:27 +0200 Subject: [PATCH 08/10] map: rain radar overlay, flying 3D markers, Europe routing, geodata crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.toml | 2 + examples/map/Cargo.toml | 1 + examples/map/src/main.rs | 149 +++++- libs/geodata/Cargo.toml | 21 + libs/geodata/README.md | 150 ++++++ libs/geodata/src/fetch.rs | 170 +++++++ libs/geodata/src/geo.rs | 231 +++++++++ libs/geodata/src/gpkg.rs | 202 ++++++++ libs/geodata/src/knmi_hdf5.rs | 380 +++++++++++++++ libs/geodata/src/layers/buildings.rs | 121 +++++ libs/geodata/src/layers/cbs_grid.rs | 118 +++++ libs/geodata/src/layers/chargers.rs | 147 ++++++ libs/geodata/src/layers/flood.rs | 98 ++++ libs/geodata/src/layers/mod.rs | 159 ++++++ libs/geodata/src/layers/nature.rs | 110 +++++ libs/geodata/src/layers/noise.rs | 136 ++++++ libs/geodata/src/layers/terrain.rs | 129 +++++ libs/geodata/src/layers/transit.rs | 261 ++++++++++ libs/geodata/src/layers/wijkbuurt.rs | 108 +++++ libs/geodata/src/lib.rs | 31 ++ libs/geodata/src/main.rs | 272 +++++++++++ libs/geodata/src/mvt.rs | 219 +++++++++ libs/geodata/src/png.rs | 221 +++++++++ libs/geodata/src/query.rs | 204 ++++++++ libs/geodata/src/radar.rs | 397 +++++++++++++++ libs/geodata/src/radar_raster.rs | 209 ++++++++ libs/geodata/src/raster.rs | 180 +++++++ libs/geodata/src/sidecar.rs | 256 ++++++++++ libs/geodata/src/spool.rs | 309 ++++++++++++ libs/geodata/src/tiff.rs | 535 ++++++++++++++++++++ libs/geodata/src/tiler.rs | 698 +++++++++++++++++++++++++++ libs/geodata/src/wkb.rs | 209 ++++++++ libs/tesla/Cargo.toml | 11 + libs/tesla/README.md | 115 +++++ libs/tesla/src/client.rs | 533 ++++++++++++++++++++ libs/tesla/src/data.rs | 230 +++++++++ libs/tesla/src/lib.rs | 48 ++ tools/map_tiles/src/nav_build.rs | 194 ++++++++ widgets/src/map/label.rs | 18 + widgets/src/map/style.rs | 71 ++- widgets/src/map/tile.rs | 259 +++++++++- widgets/src/map/view.rs | 269 ++++++++++- 42 files changed, 8126 insertions(+), 55 deletions(-) create mode 100644 libs/geodata/Cargo.toml create mode 100644 libs/geodata/README.md create mode 100644 libs/geodata/src/fetch.rs create mode 100644 libs/geodata/src/geo.rs create mode 100644 libs/geodata/src/gpkg.rs create mode 100644 libs/geodata/src/knmi_hdf5.rs create mode 100644 libs/geodata/src/layers/buildings.rs create mode 100644 libs/geodata/src/layers/cbs_grid.rs create mode 100644 libs/geodata/src/layers/chargers.rs create mode 100644 libs/geodata/src/layers/flood.rs create mode 100644 libs/geodata/src/layers/mod.rs create mode 100644 libs/geodata/src/layers/nature.rs create mode 100644 libs/geodata/src/layers/noise.rs create mode 100644 libs/geodata/src/layers/terrain.rs create mode 100644 libs/geodata/src/layers/transit.rs create mode 100644 libs/geodata/src/layers/wijkbuurt.rs create mode 100644 libs/geodata/src/lib.rs create mode 100644 libs/geodata/src/main.rs create mode 100644 libs/geodata/src/mvt.rs create mode 100644 libs/geodata/src/png.rs create mode 100644 libs/geodata/src/query.rs create mode 100644 libs/geodata/src/radar.rs create mode 100644 libs/geodata/src/radar_raster.rs create mode 100644 libs/geodata/src/raster.rs create mode 100644 libs/geodata/src/sidecar.rs create mode 100644 libs/geodata/src/spool.rs create mode 100644 libs/geodata/src/tiff.rs create mode 100644 libs/geodata/src/tiler.rs create mode 100644 libs/geodata/src/wkb.rs create mode 100644 libs/tesla/Cargo.toml create mode 100644 libs/tesla/README.md create mode 100644 libs/tesla/src/client.rs create mode 100644 libs/tesla/src/data.rs create mode 100644 libs/tesla/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 799f8e48b..f5c2e0aa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,9 @@ workspace.members = [ "libs/latex_math", "libs/mbtile_reader", "libs/map_nav", + "libs/geodata", "libs/makepad_ai", + "libs/tesla", "libs/converse", "libs/pdf_parse", "libs/regex", diff --git a/examples/map/Cargo.toml b/examples/map/Cargo.toml index 47aa88f79..dbc5ad303 100644 --- a/examples/map/Cargo.toml +++ b/examples/map/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] makepad-widgets = { path = "../../widgets", version = "2.0.0", features = ["maps"] } makepad-map-nav = { path = "../../libs/map_nav" } +makepad-geodata = { path = "../../libs/geodata" } makepad-fast-inflate = { path = "../../libs/fast_inflate" } [dev-dependencies] diff --git a/examples/map/src/main.rs b/examples/map/src/main.rs index d13201520..129d17a3d 100644 --- a/examples/map/src/main.rs +++ b/examples/map/src/main.rs @@ -30,6 +30,9 @@ const NAV_DATA_BASENAME: &str = "local/maps/noord-holland"; /// search so any European place is a fly-to target. const EUROPE_PLACES_PATH: &str = "local/maps/europe-places.search"; const EUROPE_SEARCHDB_PATH: &str = "local/maps/europe.searchdb"; +/// Long-haul fallback graph: Europe major roads (motorway..secondary), +/// used when a route endpoint lies outside the detailed regional graph. +const EUROPE_MAJOR_GRAPH_PATH: &str = "local/maps/europe-major.graph"; /// Simulated drive runs this much faster than real time. const SIM_SPEED_MULT: f64 = 6.0; const MAX_RESULTS: usize = 8; @@ -260,9 +263,10 @@ script_mod! { layer_districts := LayerCheck{text: "Districts"} layer_bag := LayerCheck{text: "Building age"} layer_population := LayerCheck{text: "Population"} + layer_rain := LayerCheck{text: "Rain radar"} PanelText{ margin: Inset{top: 6} - text: "Terrain · Noise · Flood · Rain: soon" + text: "Terrain · Noise · Flood: soon" } } } @@ -276,7 +280,7 @@ script_mod! { recenter_button := AppButton{ visible: false margin: Inset{right: 6, bottom: 18} - text: "Recenter" + text: "Detach" } layers_button := AppButton{ margin: Inset{right: 4, bottom: 18} @@ -355,6 +359,13 @@ enum NavResponse { }, } +#[derive(Clone)] +struct RainUpdate { + frames: Vec>, + width: usize, + height: usize, +} + #[derive(Script, ScriptHook)] pub struct App { #[live] @@ -392,6 +403,15 @@ pub struct App { #[rust] follow: bool, #[rust] + rain_on: bool, + #[rust] + rain_worker_started: bool, + #[rust] + rain_rx: ToUIReceiver, + /// Last decoded nowcast, kept so toggling rain back on is instant. + #[rust] + rain_cache: Option, + #[rust] program_moves: u32, #[rust] sim_progress_m: f64, @@ -479,6 +499,17 @@ impl App { .ok() .and_then(|data| SearchIndex::deserialize(&data).ok()) }; + // Europe-wide major-roads graph: loaded lazily as the long-haul + // fallback (missing file = regional routing only). + let major_graph = std::fs::read(EUROPE_MAJOR_GRAPH_PATH) + .ok() + .and_then(|data| RouteGraph::deserialize(&data).ok()); + if let Some(major) = &major_graph { + log!( + "nav: europe major-roads graph loaded ({} edges)", + major.edges.len() + ); + } let dem_cache = std::sync::Arc::new(std::sync::Mutex::new(dem::DemCache::new( "local/maps/dem", ))); @@ -521,7 +552,14 @@ impl App { }); } NavRequest::Route { id, from, to, mode } => { - let route = graph.route(from, to, mode); + // Detailed regional graph first; beyond its coverage + // (Paris!) fall back to the Europe major-roads graph. + let mut route = graph.route(from, to, mode); + if route.is_none() { + if let Some(major) = &major_graph { + route = major.route(from, to, mode); + } + } let _ = sender.send(NavResponse::RouteDone { id, route: Box::new(route), @@ -615,6 +653,73 @@ impl App { self.ui.view(cx, ids!(results_view)).set_visible(cx, false); } + /// Start the rain radar worker: polls the KNMI +2h nowcast through the + /// on-disk cache (RadarSync never hits the network more than once per + /// 4 min), decodes the 25-frame HDF5 and reprojects to mercator RGBA. + fn ensure_rain_worker(&mut self) { + if self.rain_worker_started { + return; + } + self.rain_worker_started = true; + let sender = self.rain_rx.sender(); + std::thread::spawn(move || { + use makepad_geodata::radar::{RadarConfig, RadarSync}; + use makepad_geodata::{knmi_hdf5, radar_raster}; + let sync = RadarSync::new(RadarConfig::new("local/overlays/radar")); + let projection = radar_raster::RadarProjection::new(1024, 1280); + let mut last_decoded: Option = None; + loop { + if let Ok(state) = sync.sync() { + if let Some(newest) = state.frames.last() { + if last_decoded.as_deref() != Some(newest.path.as_path()) { + if let Ok(data) = std::fs::read(&newest.path) { + if let Ok(frames) = knmi_hdf5::decode_frames(&data) { + let texels: Vec> = frames + .iter() + .map(|frame| { + radar_raster::rgba_to_bgra_texels( + &projection.frame_to_rgba(frame), + ) + }) + .collect(); + let _ = sender.send(RainUpdate { + frames: texels, + width: 1024, + height: 1280, + }); + last_decoded = Some(newest.path.clone()); + } + } + } + } + } + std::thread::sleep(std::time::Duration::from_secs(60)); + } + }); + } + + fn apply_rain(&mut self, cx: &mut Cx) { + let bbox = ( + makepad_geodata::radar_raster::RASTER_WEST, + makepad_geodata::radar_raster::RASTER_SOUTH, + makepad_geodata::radar_raster::RASTER_EAST, + makepad_geodata::radar_raster::RASTER_NORTH, + ); + if self.rain_on { + if let Some(update) = &self.rain_cache { + self.map(cx).set_rain_frames( + cx, + update.frames.clone(), + update.width, + update.height, + bbox, + ); + } + } else { + self.map(cx).set_rain_frames(cx, Vec::new(), 0, 0, bbox); + } + } + /// The 3D button shows the mode a press switches TO ("3D" when flat, /// "2D" when tilted) so the current mode is always visible. fn sync_tilt_button(&mut self, cx: &mut Cx) { @@ -748,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()); @@ -1015,12 +1126,9 @@ impl MatchEvent for App { 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 @@ -1102,6 +1210,13 @@ 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); } @@ -1121,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); + } } } } @@ -1141,6 +1260,12 @@ impl AppMain for App { fn handle_event(&mut self, cx: &mut Cx, event: &Event) { // Worker responses + while let Ok(update) = self.rain_rx.try_recv() { + self.rain_cache = Some(update); + if self.rain_on { + self.apply_rain(cx); + } + } while let Ok(response) = self.nav_rx.try_recv() { match response { NavResponse::Ready { docs, edges } => { diff --git a/libs/geodata/Cargo.toml b/libs/geodata/Cargo.toml new file mode 100644 index 000000000..3877349e8 --- /dev/null +++ b/libs/geodata/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "makepad-geodata" +version = "0.1.0" +edition = "2021" +description = "Bulk open-geodata fetcher and per-layer overlay database builder" +license = "MIT OR Apache-2.0" + +[lib] +name = "makepad_geodata" +path = "src/lib.rs" + +[[bin]] +name = "geodata" +path = "src/main.rs" + +[dependencies] +makepad-fast-inflate = { path = "../fast_inflate", version = "0.1.0" } +makepad-mbtile-reader = { path = "../mbtile_reader" } +makepad-map-nav = { path = "../map_nav" } +flate2 = "1.0" +serde_json = "1.0" diff --git a/libs/geodata/README.md b/libs/geodata/README.md new file mode 100644 index 000000000..dd451238e --- /dev/null +++ b/libs/geodata/README.md @@ -0,0 +1,150 @@ +# makepad-geodata + +Bulk open-geodata fetching, per-layer overlay database building, live-source +syncing (rain radar), and a structured query surface — for the map stack. +Netherlands-first. Companion docs: `datasources.md` (source survey, licenses) +and `gps.md` (interaction layer) at the repo root. + +The two consumers of every layer database: +1. **The renderer** — standard gzipped MVT vector tiles / PNG raster tiles. +2. **An LLM (via the map app)** — the `features` sidecar table + `query` + module answer "what is at/near this location" with structured JSON, and + raster layers carry class tables so values are nameable. + +Design rules, in order: + +1. **Bulk downloads only.** Every static source is a single downloadable + file. No API paging, no WFS spidering, no tile scraping. Live sources + (radar) poll their official file APIs at most once per data refresh. + Search-style APIs (Wikipedia, Overpass, NDW live traffic, GTFS-RT) are + *not* this crate's job — the map app queries those by viewport at runtime. +2. **One .mbtiles per layer**, never merged into the base map archive: + `local/overlays/nl-.mbtiles` — independently rebuildable, + shippable, deletable. +3. **Reuse the existing stack.** SQLite read *and* write via + `makepad-mbtile-reader` (GeoPackages are just SQLite; the reader grew + `open_sqlite` / `schema_entries` / `for_each_row` / + `for_each_row_in_range`, the writer grew extra-table support). Mercator + math from `makepad-map-nav`. New here: RD New <-> WGS84 polynomials, WKB, + MVT encoding, TIFF subset reader, PNG codec, tiling. +4. **Library first.** The `geodata` binary is a thin CLI; the map app embeds + the same machinery (`fetch_source` for periodic re-syncs, `RadarSync` for + live radar, `query::LayerDb` for the LLM/tap-inspect surface). + +## Politeness (enforced in `fetch.rs` / `radar.rs`, not left to callers) + +- descriptive User-Agent with contact address; one transfer at a time; fixed + pause after every network hit +- cached files are not even revalidated before `recheck_days`; afterwards + If-Modified-Since makes an unchanged file cost one 304 +- interrupted downloads resume; per-source `--limit-rate` for small servers +- radar: `min_poll_secs` gate armed *before* the request (a failing API + cannot get hammered), request pacing + one 429 backoff-retry + +## CLI + +``` +geodata list layers + sources + ready/planned +geodata fetch [--force] download / revalidate bulk sources +geodata build build nl-.mbtiles +geodata status cache + outputs +geodata query [--radius m] [--limit n] features JSON +geodata radar-sync [forecast|reflectivity] poll rain radar +``` + +Defaults: cache `local/overlays/cache/`, output `local/overlays/`. + +## Database contract + +Written by `MbtilesWriter` (64 KB pages, deterministic block-major rowids, +passes `PRAGMA integrity_check`). Per file: + +- `tiles` — gzipped MVT 2.1 (vector, `format=pbf`) or PNG (raster, + `format=png`). +- `features` — the query sidecar (vector layers): columns `cell, layer, + name, min_lon, min_lat, max_lon, max_lat, attrs (JSON), ring (JSON|null)`. + rowid = `(z12 grid cell << 24) | seq` so bbox queries become b-tree range + scans. Polygon layers opt into `ring` (simplified exterior) for exact + point-in-polygon ("which buurt am I in"). +- `metadata` — mbtiles standard keys + `attribution`, `license`, TileJSON + `json.vector_layers` (field names/types per MVT layer), and for rasters + `geodata_encoding` (`terrarium` | `class-index`) + `geodata_classmap` + (JSON class -> label/color) so both shader and query side interpret pixels. + +Raster encodings: **terrarium** RGB (elevation, e = h+32768) — read by the +renderer's future hillshade/3D and by map_nav's EV grade baking; **class +index** gray8 (noise dB bands, flood depth bands) — colormapped in the +shader, named via the classmap. + +## Layers (all implemented) + +| layer | content | source (license) | zooms | output | +|---|---|---|---|---| +| `nature` | Natura 2000 + wetlands polygons, rings in sidecar | PDOK (CC0) | 6-12 | 5.9 MB | +| `chargers` | 66k EV charging locations, operator/power | NDW OCPI (open) | 8-14 | 25 MB | +| `demographics` | CBS 500m+100m grid stats per cell | CBS (CC BY) | 8-13 | 262 MB | +| `wijkbuurt` | gemeente/wijk/buurt polygons + kerncijfers, rings | CBS/Kadaster (CC BY) | 6-13 | 87 MB | +| `transit` | all NL stops + rail/tram/metro/ferry shapes | OVapi GTFS (CC0) | 7-14 | 13 MB | +| `buildings-age` | **all 11.4M BAG buildings** with bouwjaar+status | PDOK bag-light (CC0, 7.8 GB) | 13-14 | 1.5 GB | +| `terrain` | GLO-30 elevation, terrarium | Copernicus (attr) | 6-12 | raster | +| `noise` | RIVM 10m Lden binned to 5 dB classes | RIVM (CC0) | 6-13 | raster | +| `flood` | JRC RP100 river flood depth classes | JRC (CC BY) | 6-11 | raster | + +Live source: **rain radar** (`radar.rs`) — KNMI `radar_forecast` (+2h +nowcast, one file per 5 min, the map-app default) and +`radar_reflectivity_composites` (5-min frames, keeps ~1 h). `RadarSync::sync` +is safe to call every frame; it polls at most once per `min_poll_secs` and +returns cached `RadarFrame`s (raw KNMI HDF5 — decode to raster is the next +step). API key: config > `KNMI_API_KEY` env > shared anonymous key (register +a free personal key for real use; the anonymous quota is shared). + +## Query surface (the LLM tool-call backend) + +```rust +let mut db = query::LayerDb::open(path)?; +db.query_point(lon, lat, limit) // exact point-in-polygon w/ rings +db.query_radius(lon, lat, meters, limit) // nearest-first with distances +db.query_bbox(min_lon, min_lat, max_lon, max_lat, limit) +``` + +Verified examples: Dam Square resolves gemeente Amsterdam -> wijk +Burgwallen-Nieuwe Zijde -> buurt Nieuwe Kerk e.o. (with populations); nearest +charger 128 m (operator + kW); Royal Palace bouwjaar 1655 out of 11.4M +buildings. + +## Module map + +``` +src/fetch.rs polite bulk downloader (curl), cache + .meta.json +src/radar.rs RadarSync: KNMI radar poll/cache/prune, app-embeddable +src/geo.rs RD New <-> WGS84 polynomials (round-trip tested < 1 m); + writer-order tile key; mercator via makepad-map-nav +src/wkb.rs GeoPackage blob + ISO/EWKB parser +src/gpkg.rs GeoPackage feature iteration (schema-driven, srs transform) +src/mvt.rs MVT 2.1 encoder (extent 4096, key/value dedup) +src/tiler.rs geometry -> clipped tile features (shared); in-memory + Tileset for <= few-million-feature layers +src/spool.rs SpoolTiler: country-scale layers via per-block disk spool + (BAG: 11.4M polygons in one pass, peak memory = one block) +src/sidecar.rs features table builder (grid rowids, JSON attrs, rings) +src/query.rs LayerDb: point/radius/bbox queries over the sidecar +src/tiff.rs GeoTIFF subset: tiled/striped, deflate/LZW/none, + predictors 1/2/3, int/float samples, geo tags, nodata +src/png.rs PNG encode/decode (gray8 + rgb8) with hand-rolled CRC32 +src/raster.rs sampler -> 256px PNG tile pyramid (terrarium/class-index) +src/layers/ one module per layer + registry +src/main.rs CLI +``` + +## Known limits / follow-ups + +- Radar HDF5 -> renderable raster decode is not in yet (files are cached and + indexed; decode next). OPERA/MeteoGate is the Europe-wide radar follow-up. +- Flood: JRC `spurious_depth_areas` mask not applied; PDOK ROR official zone + polygons (CC0) planned as vector companion. +- Points are not duplicated into neighbor-tile buffers (edge icons may clip). +- No Douglas-Peucker (dedup + area culling only); BigTIFF unsupported; + `unzip` is shelled out. +- Raster query helper (`sample elevation/class at lon/lat` via PNG decode) + belongs in `query.rs` next, so the LLM can ask "how high / how loud / + flood depth here". diff --git a/libs/geodata/src/fetch.rs b/libs/geodata/src/fetch.rs new file mode 100644 index 000000000..159693d35 --- /dev/null +++ b/libs/geodata/src/fetch.rs @@ -0,0 +1,170 @@ +//! Polite bulk downloader. +//! +//! Rules, enforced here so no layer module can violate them: +//! - bulk file downloads only — never API paging, never tile scraping +//! - one transfer at a time, with a fixed pause after every network hit +//! - descriptive User-Agent with a contact address +//! - a source is never re-contacted while the cached copy is younger than its +//! `recheck_days`; after that we revalidate with If-Modified-Since so an +//! unchanged file costs the server a 304 and no bytes +//! - interrupted downloads resume (`.part` + curl `-C -`) +//! +//! Downloads shell out to `curl` (retries, TLS, resume for free). The same +//! functions are usable from the maps app later for live-ish sources: call +//! `fetch_source` with the source's `recheck_days` (e.g. 1-2 days for the NDW +//! charger file) and it does the right thing. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const USER_AGENT: &str = + "makepad-geodata/0.1 (bulk open-data fetcher; contact: rik@n4.io)"; +const PAUSE_AFTER_TRANSFER: Duration = Duration::from_secs(1); + +/// A bulk-downloadable source file. All fields static: the registry is code. +#[derive(Debug, Clone, Copy)] +pub struct SourceSpec { + pub id: &'static str, + pub url: &'static str, + /// Filename inside the cache directory. + pub filename: &'static str, + pub license: &'static str, + pub attribution: &'static str, + /// Do not even revalidate more often than this. + pub recheck_days: u32, + /// Optional curl --limit-rate value (e.g. "10M") for small origin servers. + pub limit_rate: Option<&'static str>, +} + +pub struct FetchOptions { + pub cache_dir: PathBuf, + pub force: bool, +} + +#[derive(Debug)] +pub enum FetchOutcome { + /// Cache is fresh; no network contact was made. + CachedFresh(PathBuf), + /// Revalidated; server said unchanged. + NotModified(PathBuf), + /// Downloaded (new or updated). + Downloaded(PathBuf), +} + +impl FetchOutcome { + pub fn path(&self) -> &Path { + match self { + FetchOutcome::CachedFresh(p) + | FetchOutcome::NotModified(p) + | FetchOutcome::Downloaded(p) => p, + } + } +} + +fn meta_path(cache_dir: &Path, spec: &SourceSpec) -> PathBuf { + cache_dir.join(format!("{}.meta.json", spec.filename)) +} + +fn read_fetched_unix(cache_dir: &Path, spec: &SourceSpec) -> Option { + let text = std::fs::read_to_string(meta_path(cache_dir, spec)).ok()?; + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + value.get("fetched_unix")?.as_u64() +} + +fn write_meta(cache_dir: &Path, spec: &SourceSpec, bytes: u64) -> std::io::Result<()> { + let meta = serde_json::json!({ + "id": spec.id, + "url": spec.url, + "license": spec.license, + "attribution": spec.attribution, + "fetched_unix": now_unix(), + "bytes": bytes, + }); + let mut file = std::fs::File::create(meta_path(cache_dir, spec))?; + file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes()) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Fetch one source into the cache directory, politely. Returns the cached +/// file path. Never contacts the network when the cached copy is fresh. +pub fn fetch_source(opts: &FetchOptions, spec: &SourceSpec) -> std::io::Result { + std::fs::create_dir_all(&opts.cache_dir)?; + let dest = opts.cache_dir.join(spec.filename); + let part = opts.cache_dir.join(format!("{}.part", spec.filename)); + + if dest.exists() && !opts.force { + let fetched = read_fetched_unix(&opts.cache_dir, spec) + .or_else(|| { + dest.metadata() + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + }) + .unwrap_or(0); + let age_days = (now_unix().saturating_sub(fetched)) / 86_400; + if age_days < u64::from(spec.recheck_days) { + return Ok(FetchOutcome::CachedFresh(dest)); + } + } + + let mut cmd = Command::new("curl"); + cmd.arg("-fsSL") + .arg("--retry") + .arg("3") + .arg("--retry-delay") + .arg("2") + .arg("--connect-timeout") + .arg("20") + .arg("-A") + .arg(USER_AGENT) + .arg("-o") + .arg(&part); + if let Some(rate) = spec.limit_rate { + cmd.arg("--limit-rate").arg(rate); + } + if dest.exists() && !opts.force { + // Revalidate: only transfer if newer than what we have. + cmd.arg("-z").arg(&dest); + } else if part.exists() { + // Resume an interrupted download. + cmd.arg("-C").arg("-"); + } + cmd.arg(spec.url); + + let status = cmd.status()?; + std::thread::sleep(PAUSE_AFTER_TRANSFER); + if !status.success() { + return Err(std::io::Error::other(format!( + "curl failed for {} ({})", + spec.url, status + ))); + } + + let part_len = part.metadata().map(|m| m.len()).unwrap_or(0); + if part_len == 0 && dest.exists() { + // 304 Not Modified: curl -z left us an empty output file. + let _ = std::fs::remove_file(&part); + write_meta(&opts.cache_dir, spec, dest.metadata()?.len())?; + return Ok(FetchOutcome::NotModified(dest)); + } + if part_len == 0 { + return Err(std::io::Error::other(format!( + "download of {} produced no data", + spec.url + ))); + } + std::fs::rename(&part, &dest)?; + write_meta(&opts.cache_dir, spec, part_len)?; + Ok(FetchOutcome::Downloaded(dest)) +} diff --git a/libs/geodata/src/geo.rs b/libs/geodata/src/geo.rs new file mode 100644 index 000000000..1c1391c91 --- /dev/null +++ b/libs/geodata/src/geo.rs @@ -0,0 +1,231 @@ +//! Coordinate transforms shared by all layer builders. +//! +//! Conventions: `f64` lon/lat (WGS84, degrees) at module boundaries, +//! normalized web-mercator `(0..1, 0..1)` internally (y grows south, matching +//! tile addressing), matching the map renderer's `center_norm`. + +/// RD New (EPSG:28992) -> WGS84, using the published approximation polynomials +/// (Schreutelkamp & Strang van Hees). Accuracy is decimeter-scale across NL, +/// far below overlay pixel size at any zoom we tile. +pub fn rd_to_wgs84(x: f64, y: f64) -> (f64, f64) { + const X0: f64 = 155_000.0; + const Y0: f64 = 463_000.0; + const PHI0: f64 = 52.155_174_40; + const LAM0: f64 = 5.387_206_21; + + let dx = (x - X0) * 1e-5; + let dy = (y - Y0) * 1e-5; + + // (p, q, coefficient) terms for phi (seconds of arc) + const KPQ: &[(i32, i32, f64)] = &[ + (0, 1, 3235.65389), + (2, 0, -32.58297), + (0, 2, -0.24750), + (2, 1, -0.84978), + (0, 3, -0.06550), + (2, 2, -0.01709), + (1, 0, -0.00738), + (4, 0, 0.00530), + (2, 3, -0.00039), + (4, 1, 0.00033), + (1, 1, -0.00012), + ]; + // (p, q, coefficient) terms for lambda (seconds of arc) + const LPQ: &[(i32, i32, f64)] = &[ + (1, 0, 5260.52916), + (1, 1, 105.94684), + (1, 2, 2.45656), + (3, 0, -0.81885), + (1, 3, 0.05594), + (3, 1, -0.05607), + (0, 1, 0.01199), + (3, 2, -0.00256), + (1, 4, 0.00128), + (0, 2, 0.00022), + (4, 0, -0.00022), + (5, 0, 0.00026), + ]; + + let mut dphi = 0.0; + for &(p, q, k) in KPQ { + dphi += k * dx.powi(p) * dy.powi(q); + } + let mut dlam = 0.0; + for &(p, q, l) in LPQ { + dlam += l * dx.powi(p) * dy.powi(q); + } + let lat = PHI0 + dphi / 3600.0; + let lon = LAM0 + dlam / 3600.0; + (lon, lat) +} + +/// WGS84 -> RD New (EPSG:28992), the published inverse approximation +/// polynomials. Decimeter-scale accuracy — used to sample RD-referenced +/// rasters (RIVM noise) at map coordinates. +pub fn wgs84_to_rd(lon: f64, lat: f64) -> (f64, f64) { + const X0: f64 = 155_000.0; + const Y0: f64 = 463_000.0; + const PHI0: f64 = 52.155_174_40; + const LAM0: f64 = 5.387_206_21; + + let dphi = 0.36 * (lat - PHI0); + let dlam = 0.36 * (lon - LAM0); + + const RPQ: &[(i32, i32, f64)] = &[ + (0, 1, 190_094.945), + (1, 1, -11_832.228), + (2, 1, -114.221), + (0, 3, -32.391), + (1, 0, -0.705), + (3, 1, -2.340), + (1, 3, -0.608), + (0, 2, -0.008), + (2, 3, 0.148), + ]; + const SPQ: &[(i32, i32, f64)] = &[ + (1, 0, 309_056.544), + (0, 2, 3_638.893), + (2, 0, 73.077), + (1, 2, -157.984), + (3, 0, 59.788), + (0, 1, 0.433), + (2, 2, -6.439), + (1, 1, -0.032), + (0, 4, 0.092), + (1, 4, -0.054), + ]; + + let mut x = X0; + for &(p, q, r) in RPQ { + x += r * dphi.powi(p) * dlam.powi(q); + } + let mut y = Y0; + for &(p, q, s) in SPQ { + y += s * dphi.powi(p) * dlam.powi(q); + } + (x, y) +} + +/// WGS84 lon/lat -> normalized web mercator (0..1, 0..1), y growing south. +/// Thin wrapper over the shared projection in `makepad-map-nav` so the math +/// lives in exactly one place across the map stack. +pub fn wgs84_to_norm(lon: f64, lat: f64) -> (f64, f64) { + makepad_map_nav::geo::lon_lat_to_norm(makepad_map_nav::geo::LonLat::new(lon, lat)) +} + +/// Axis-aligned bbox in normalized mercator. +#[derive(Debug, Clone, Copy)] +pub struct NormBBox { + pub min_x: f64, + pub min_y: f64, + pub max_x: f64, + pub max_y: f64, +} + +impl NormBBox { + pub fn empty() -> Self { + NormBBox { + min_x: f64::INFINITY, + min_y: f64::INFINITY, + max_x: f64::NEG_INFINITY, + max_y: f64::NEG_INFINITY, + } + } + pub fn add(&mut self, x: f64, y: f64) { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } + pub fn is_empty(&self) -> bool { + self.min_x > self.max_x + } +} + +/// Deterministic tile ordering key matching the mbtiles writer's rowid scheme +/// (zoom ascending, then 256x256 block row-major, then local row-major). +/// Sorting tile keys by this value yields the exact order `MbtilesWriter` +/// requires. +pub fn tile_order_key(zoom: u8, x: u32, y: u32) -> u128 { + let zoom_capacity = 1_u128 << (u32::from(zoom) * 2); + let prefix = (zoom_capacity - 1) / 3; + let axis = 1_u128 << zoom; + let within = if zoom <= 8 { + u128::from(y) * axis + u128::from(x) + } else { + let blocks_per_axis = 1_u128 << (zoom - 8); + let block_x = u128::from(x >> 8); + let block_y = u128::from(y >> 8); + let local_x = u128::from(x & 255); + let local_y = u128::from(y & 255); + ((block_y * blocks_per_axis + block_x) << 16) + (local_y << 8) + local_x + }; + prefix + within + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rd_origin_is_amersfoort() { + let (lon, lat) = rd_to_wgs84(155_000.0, 463_000.0); + assert!((lat - 52.155_174_40).abs() < 1e-9); + assert!((lon - 5.387_206_21).abs() < 1e-9); + } + + #[test] + fn rd_scale_is_sane() { + // 1 km east of the origin is ~0.0146 degrees of longitude at 52N. + let (lon, _lat) = rd_to_wgs84(156_000.0, 463_000.0); + let dlon = lon - 5.387_206_21; + assert!((dlon - 0.01464).abs() < 0.0005, "dlon = {dlon}"); + // 1 km north is ~0.0090 degrees of latitude. + let (_lon, lat) = rd_to_wgs84(155_000.0, 464_000.0); + let dlat = lat - 52.155_174_40; + assert!((dlat - 0.00899).abs() < 0.0005, "dlat = {dlat}"); + } + + #[test] + fn rd_round_trip_within_a_meter() { + for &(x, y) in &[ + (121_861.0, 487_981.0), // Amsterdam + (92_565.0, 437_428.0), // Rotterdam + (176_500.0, 317_500.0), // Maastricht area + (233_883.0, 582_065.0), // Groningen area + ] { + let (lon, lat) = rd_to_wgs84(x, y); + let (x2, y2) = wgs84_to_rd(lon, lat); + assert!( + (x - x2).abs() < 1.0 && (y - y2).abs() < 1.0, + "round trip drift: ({x},{y}) -> ({x2:.2},{y2:.2})" + ); + } + } + + #[test] + fn amsterdam_lands_in_amsterdam() { + // Amsterdam Centraal is around RD (121861, 487981). + let (lon, lat) = rd_to_wgs84(121_861.0, 487_981.0); + assert!((52.36..52.40).contains(&lat), "lat = {lat}"); + assert!((4.88..4.92).contains(&lon), "lon = {lon}"); + } + + #[test] + fn tile_order_matches_block_major() { + let mut last = 0u128; + for z in [6u8, 9] { + for y in 0..40u32 { + for x in 0..40u32 { + let k = tile_order_key(z, x, y); + assert!(k > last || (x == 0 && y == 0), "order broken at z{z} {x},{y}"); + if x > 0 || y > 0 { + last = k; + } else { + last = k; + } + } + } + } + } +} diff --git a/libs/geodata/src/gpkg.rs b/libs/geodata/src/gpkg.rs new file mode 100644 index 000000000..3d6865782 --- /dev/null +++ b/libs/geodata/src/gpkg.rs @@ -0,0 +1,202 @@ +//! GeoPackage feature reading on top of the generic SQLite table access in +//! `makepad-mbtile-reader` (a GeoPackage is just a SQLite file). + +use crate::geo; +use crate::wkb::{parse_gpkg_geometry, Geometry}; +use makepad_mbtile_reader::{MbtilesReader, Value}; +use std::path::Path; + +pub struct FeatureTableInfo { + pub table: String, + /// Column names in declaration order (matches record value order). + pub columns: Vec, + /// Index of the geometry column within `columns`. + pub geom_col: usize, + pub srs_id: i64, +} + +pub struct Gpkg { + db: MbtilesReader, +} + +impl Gpkg { + pub fn open(path: &Path) -> Result { + let db = MbtilesReader::open_sqlite(path) + .map_err(|e| format!("open {}: {e:?}", path.display()))?; + Ok(Gpkg { db }) + } + + /// Enumerate feature tables via gpkg_contents + gpkg_geometry_columns. + pub fn feature_tables(&mut self) -> Result, String> { + let schema = self + .db + .schema_entries() + .map_err(|e| format!("schema: {e:?}"))?; + let sql_for = |table: &str| -> Option<&str> { + schema + .iter() + .find(|e| e.obj_type == "table" && e.name == table) + .map(|e| e.sql.as_str()) + }; + + let contents_cols = columns_from_sql( + sql_for("gpkg_contents").ok_or("no gpkg_contents table")?, + ); + let c_table = col_index(&contents_cols, "table_name")?; + let c_type = col_index(&contents_cols, "data_type")?; + let c_srs = col_index(&contents_cols, "srs_id")?; + + let mut feature_tables: Vec<(String, i64)> = Vec::new(); + self.db + .for_each_row("gpkg_contents", |_rowid, values| { + let data_type = values.get(c_type).and_then(|v| v.as_text()).unwrap_or(""); + let table = values.get(c_table).and_then(|v| v.as_text()).unwrap_or(""); + let srs = values.get(c_srs).and_then(|v| v.as_integer()).unwrap_or(0); + if data_type == "features" && !table.is_empty() { + feature_tables.push((table.to_string(), srs)); + } + }) + .map_err(|e| format!("gpkg_contents: {e:?}"))?; + + let geom_cols = columns_from_sql( + sql_for("gpkg_geometry_columns").ok_or("no gpkg_geometry_columns table")?, + ); + let g_table = col_index(&geom_cols, "table_name")?; + let g_col = col_index(&geom_cols, "column_name")?; + let mut geom_col_names: Vec<(String, String)> = Vec::new(); + self.db + .for_each_row("gpkg_geometry_columns", |_rowid, values| { + let table = values.get(g_table).and_then(|v| v.as_text()).unwrap_or(""); + let col = values.get(g_col).and_then(|v| v.as_text()).unwrap_or(""); + if !table.is_empty() { + geom_col_names.push((table.to_string(), col.to_string())); + } + }) + .map_err(|e| format!("gpkg_geometry_columns: {e:?}"))?; + + let mut infos = Vec::new(); + for (table, srs_id) in feature_tables { + let Some(sql) = sql_for(&table) else { continue }; + let columns = columns_from_sql(sql); + let geom_name = geom_col_names + .iter() + .find(|(t, _)| *t == table) + .map(|(_, c)| c.clone()) + .unwrap_or_else(|| "geom".to_string()); + let Some(geom_col) = columns.iter().position(|c| *c == geom_name) else { + continue; + }; + infos.push(FeatureTableInfo { + table, + columns, + geom_col, + srs_id, + }); + } + Ok(infos) + } + + /// Iterate all features of a table. The callback gets the rowid, all + /// column values (geometry column included, as a blob), and the parsed + /// geometry already transformed to WGS84 lon/lat. + pub fn for_each_feature( + &mut self, + info: &FeatureTableInfo, + mut callback: impl FnMut(i64, &[Value], Geometry), + ) -> Result { + let srs = info.srs_id; + let geom_col = info.geom_col; + let mut skipped = 0u64; + self.db + .for_each_row(&info.table, |rowid, values| { + let Some(blob) = values.get(geom_col).and_then(|v| v.as_blob()) else { + skipped += 1; + return; + }; + let Some(geom) = parse_gpkg_geometry(blob) else { + skipped += 1; + return; + }; + let Some(geom) = to_wgs84(&geom, srs) else { + skipped += 1; + return; + }; + callback(rowid, &values, geom); + }) + .map_err(|e| format!("scan {}: {e:?}", info.table))?; + Ok(skipped) + } +} + +/// Transform a geometry from the given EPSG srs to WGS84 lon/lat. +pub fn to_wgs84(geom: &Geometry, srs_id: i64) -> Option { + match srs_id { + 4326 => Some(geom.clone()), + 28992 => Some(geom.map_coords(&|x, y| geo::rd_to_wgs84(x, y))), + _ => None, + } +} + +/// Crude but sufficient column-name extraction from a CREATE TABLE statement +/// (GeoPackage SQL is machine-generated and regular). +pub fn columns_from_sql(sql: &str) -> Vec { + let Some(open) = sql.find('(') else { + return Vec::new(); + }; + let Some(close) = sql.rfind(')') else { + return Vec::new(); + }; + let body = &sql[open + 1..close]; + let mut columns = Vec::new(); + let mut depth = 0i32; + let mut part = String::new(); + let mut parts = Vec::new(); + for ch in body.chars() { + match ch { + '(' => { + depth += 1; + part.push(ch); + } + ')' => { + depth -= 1; + part.push(ch); + } + ',' if depth == 0 => { + parts.push(std::mem::take(&mut part)); + } + _ => part.push(ch), + } + } + parts.push(part); + + const CONSTRAINTS: &[&str] = &[ + "PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "CONSTRAINT", + ]; + for part in parts { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + let first = trimmed + .split_whitespace() + .next() + .unwrap_or("") + .trim_matches(|c| c == '"' || c == '`' || c == '[' || c == ']' || c == '\''); + if first.is_empty() + || CONSTRAINTS + .iter() + .any(|k| first.eq_ignore_ascii_case(k)) + { + continue; + } + columns.push(first.to_string()); + } + columns +} + +fn col_index(columns: &[String], name: &str) -> Result { + columns + .iter() + .position(|c| c.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("column {name} not found in {columns:?}")) +} diff --git a/libs/geodata/src/knmi_hdf5.rs b/libs/geodata/src/knmi_hdf5.rs new file mode 100644 index 000000000..78c855284 --- /dev/null +++ b/libs/geodata/src/knmi_hdf5.rs @@ -0,0 +1,380 @@ +//! Minimal pure-Rust HDF5 reader for KNMI radar files. +//! +//! This is NOT a general HDF5 implementation. KNMI's radar products are +//! written by a fixed, old HDF5 writer and always look the same: +//! superblock v0, symbol-table groups (B-tree v1 + local heap), datasets +//! with v1 object headers, contiguous-or-chunked layout v3, and a deflate +//! filter with ONE chunk spanning the whole image. We walk exactly that +//! shape and nothing more; anything unexpected returns an error instead of +//! guessing. Times and geo constants come from the filename and the +//! documented RAD_NL25 grid, so no attribute parsing is needed. + +use makepad_fast_inflate::DecompressError; + +pub struct Hdf5File<'a> { + data: &'a [u8], +} + +#[derive(Debug)] +pub struct DatasetInfo { + /// (rows, cols) from the dataspace message. + pub dims: (u64, u64), + /// Raw (still-compressed if filtered) chunk bytes. + chunk_offset: u64, + chunk_size: u64, + /// Deflate filter present. + deflated: bool, +} + +fn u16le(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +fn u32le(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +fn u64le(b: &[u8], o: usize) -> u64 { + u64::from_le_bytes([ + b[o], + b[o + 1], + b[o + 2], + b[o + 3], + b[o + 4], + b[o + 5], + b[o + 6], + b[o + 7], + ]) +} + +impl<'a> Hdf5File<'a> { + pub fn open(data: &'a [u8]) -> Result { + if data.len() < 96 || &data[0..8] != b"\x89HDF\r\n\x1a\n" { + return Err("not an HDF5 file".into()); + } + // Superblock v0 with 8-byte offsets/lengths is the only layout the + // KNMI writer produces. + if data[8] != 0 { + return Err(format!("unsupported superblock version {}", data[8])); + } + if data[13] != 8 || data[14] != 8 { + return Err("unsupported offset/length size".into()); + } + Ok(Self { data }) + } + + /// Object header address of the root group. + fn root_object_header(&self) -> u64 { + // Superblock v0: root group symbol table entry at offset 24 + 4*8. + // Symbol table entry: link_name_offset(8) object_header_addr(8) ... + u64le(self.data, 24 + 32 + 8) + } + + /// Find a child object (group or dataset) by name inside the group whose + /// object header sits at `header_addr`. + pub fn find_child(&self, header_addr: u64, name: &str) -> Result, String> { + let (btree, heap) = self.group_symbol_table(header_addr)?; + let mut found = None; + self.walk_group_btree(btree, heap, &mut |child_name, child_addr| { + if child_name == name { + found = Some(child_addr); + } + })?; + Ok(found) + } + + pub fn find_path(&self, path: &[&str]) -> Result, String> { + let mut at = self.root_object_header(); + for part in path { + match self.find_child(at, part)? { + Some(next) => at = next, + None => return Ok(None), + } + } + Ok(Some(at)) + } + + /// Parse the SYMBOL_TABLE message (type 0x11) of a group object header. + fn group_symbol_table(&self, header_addr: u64) -> Result<(u64, u64), String> { + let mut result = None; + self.walk_object_header(header_addr, &mut |msg_type, body| { + if msg_type == 0x0011 && body.len() >= 16 { + result = Some((u64le(body, 0), u64le(body, 8))); + } + })?; + result.ok_or_else(|| "object is not a symbol-table group".into()) + } + + /// Iterate a group B-tree (v1, node type 0) yielding (name, header_addr). + fn walk_group_btree( + &self, + btree_addr: u64, + heap_addr: u64, + visit: &mut impl FnMut(&str, u64), + ) -> Result<(), String> { + let d = self.data; + let o = btree_addr as usize; + if d.len() < o + 24 || &d[o..o + 4] != b"TREE" { + return Err("bad group btree node".into()); + } + let node_type = d[o + 4]; + let node_level = d[o + 5]; + let entries = u16le(d, o + 6) as usize; + if node_type != 0 { + return Err("unexpected btree node type".into()); + } + // keys/children start after: sig(4) type(1) level(1) entries(2) + // left(8) right(8) = 24; layout: key0 child0 key1 child1 ... keyN + let mut pos = o + 24 + 8; // skip key0 (length-size offset into heap) + for _ in 0..entries { + let child = u64le(d, pos); + pos += 8 + 8; // child + next key + if node_level > 0 { + self.walk_group_btree(child, heap_addr, visit)?; + } else { + self.walk_snod(child, heap_addr, visit)?; + } + } + Ok(()) + } + + /// Symbol node: list of symbol table entries. + fn walk_snod( + &self, + snod_addr: u64, + heap_addr: u64, + visit: &mut impl FnMut(&str, u64), + ) -> Result<(), String> { + let d = self.data; + let o = snod_addr as usize; + if d.len() < o + 8 || &d[o..o + 4] != b"SNOD" { + return Err("bad symbol node".into()); + } + let count = u16le(d, o + 6) as usize; + // Local heap: signature HEAP, version, data segment address at +24. + let h = heap_addr as usize; + if d.len() < h + 32 || &d[h..h + 4] != b"HEAP" { + return Err("bad local heap".into()); + } + let heap_data = u64le(d, h + 24) as usize; + let mut pos = o + 8; + for _ in 0..count { + let name_off = u64le(d, pos) as usize; + let header = u64le(d, pos + 8); + pos += 40; // symbol table entry is 40 bytes with 8-byte offsets + let name_start = heap_data + name_off; + let mut end = name_start; + while end < d.len() && d[end] != 0 { + end += 1; + } + if let Ok(name) = std::str::from_utf8(&d[name_start..end]) { + visit(name, header); + } + } + Ok(()) + } + + /// Iterate the messages of a v1 object header, following continuation + /// blocks (message type 0x0010). + fn walk_object_header( + &self, + header_addr: u64, + visit: &mut impl FnMut(u16, &[u8]), + ) -> Result<(), String> { + let d = self.data; + let o = header_addr as usize; + if d.len() < o + 16 || d[o] != 1 { + return Err("unsupported object header version".into()); + } + let mut remaining_msgs = u16le(d, o + 2) as usize; + // v1 header: version(1) pad(1) nmsgs(2) refcount(4) header_size(4) + // then padding to 8-byte alignment: messages start at +16. + let mut blocks: Vec<(usize, usize)> = Vec::new(); + let first_size = u32le(d, o + 8) as usize; + blocks.push((o + 16, first_size)); + let mut block_index = 0; + while block_index < blocks.len() { + let (mut pos, size) = blocks[block_index]; + let block_end = pos + size; + while pos + 8 <= block_end && remaining_msgs > 0 { + let msg_type = u16le(d, pos); + let msg_size = u16le(d, pos + 2) as usize; + let body = &d[pos + 8..(pos + 8 + msg_size).min(d.len())]; + if msg_type == 0x0010 && body.len() >= 16 { + blocks.push((u64le(body, 0) as usize, u64le(body, 8) as usize)); + } else { + visit(msg_type, body); + } + remaining_msgs -= 1; + pos += 8 + msg_size; + } + block_index += 1; + } + Ok(()) + } + + /// Layout + dataspace + filters of a dataset object header. + pub fn dataset_info(&self, header_addr: u64) -> Result { + let mut dims: Option<(u64, u64)> = None; + let mut deflated = false; + let mut chunk: Option<(u64, (u64, u64))> = None; // btree addr, chunk dims + let mut contiguous: Option<(u64, u64)> = None; + self.walk_object_header(header_addr, &mut |msg_type, body| match msg_type { + 0x0001 => { + // Dataspace v1: version(1) rank(1) flags(1) reserved(5) dims... + if body.len() >= 8 && body[0] == 1 { + let rank = body[1] as usize; + if rank == 2 && body.len() >= 8 + 16 { + dims = Some((u64le(body, 8), u64le(body, 16))); + } + } + } + 0x0008 => { + // Layout v3. + if body.len() >= 2 && body[0] == 3 { + match body[1] { + 1 if body.len() >= 18 => { + contiguous = Some((u64le(body, 2), u64le(body, 10))); + } + 2 => { + // chunked: dimensionality(1) btree(8) dims(4*each) + let rank = body[2] as usize; + if body.len() >= 3 + 8 + rank * 4 { + let btree = u64le(body, 3); + let d0 = u32le(body, 11) as u64; + let d1 = u32le(body, 15) as u64; + chunk = Some((btree, (d0, d1))); + } + } + _ => {} + } + } + } + 0x000B => { + // Filter pipeline: any deflate (filter id 1) counts. + if body.len() >= 2 { + deflated = true; + } + } + _ => {} + })?; + let dims = dims.ok_or("dataset without 2D dataspace")?; + if let Some((btree_addr, _chunk_dims)) = chunk { + // Chunk B-tree v1 (node type 1). KNMI writes ONE chunk per + // dataset, so the root node is a leaf with a single entry. + let d = self.data; + let o = btree_addr as usize; + if d.len() < o + 24 || &d[o..o + 4] != b"TREE" || d[o + 4] != 1 { + return Err("bad chunk btree".into()); + } + let level = d[o + 5]; + let entries = u16le(d, o + 6) as usize; + if level != 0 || entries != 1 { + return Err(format!( + "unexpected chunk btree shape (level {level}, {entries} entries)" + )); + } + // key: chunk_size(4) filter_mask(4) offsets((rank+1)*8) then child(8). + // rank for a 2D dataset's chunk key is 3 (row, col, element). + let key = o + 24; + let chunk_size = u32le(d, key) as u64; + let child = u64le(d, key + 8 + 3 * 8); + Ok(DatasetInfo { + dims, + chunk_offset: child, + chunk_size, + deflated, + }) + } else if let Some((addr, size)) = contiguous { + Ok(DatasetInfo { + dims, + chunk_offset: addr, + chunk_size: size, + deflated: false, + }) + } else { + Err("dataset without layout".into()) + } + } + + /// Read + (if needed) inflate the dataset's single chunk. + pub fn read_dataset(&self, info: &DatasetInfo) -> Result, String> { + let start = info.chunk_offset as usize; + let end = start + info.chunk_size as usize; + if end > self.data.len() { + return Err("chunk out of bounds".into()); + } + let raw = &self.data[start..end]; + if info.deflated { + makepad_fast_inflate::zlib_decompress_vec(raw) + .map_err(|err: DecompressError| format!("inflate: {err:?}")) + } else { + Ok(raw.to_vec()) + } + } +} + +/// One decoded nowcast frame: 765x700 raw pixel values (0.5*PV-32 dBZ). +pub struct KnmiFrame { + pub minutes_offset: u32, + pub rows: usize, + pub cols: usize, + pub values: Vec, +} + +/// Decode every `imageN/image_data` frame of a KNMI radar file, in frame +/// order (image1 = +0 min, each subsequent +5 min). +pub fn decode_frames(data: &[u8]) -> Result, String> { + let file = Hdf5File::open(data)?; + let mut frames = Vec::new(); + for index in 1..=64 { + let group = format!("image{index}"); + let Some(ds) = file.find_path(&[&group, "image_data"])? else { + break; + }; + let info = file.dataset_info(ds)?; + let values = file.read_dataset(&info)?; + let (rows, cols) = (info.dims.0 as usize, info.dims.1 as usize); + if values.len() != rows * cols { + return Err(format!( + "frame {index}: {} bytes for {}x{}", + values.len(), + rows, + cols + )); + } + frames.push(KnmiFrame { + minutes_offset: (index as u32 - 1) * 5, + rows, + cols, + values, + }); + } + if frames.is_empty() { + return Err("no image groups found".into()); + } + Ok(frames) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_cached_forecast_file() { + let path = "../../local/overlays/radar/forecast/RAD_NL25_PCP_FM_202607280900.h5"; + let Ok(data) = std::fs::read(path) else { + return; + }; + let frames = decode_frames(&data).unwrap(); + assert_eq!(frames.len(), 25); + let f1 = &frames[0]; + assert_eq!((f1.rows, f1.cols), (765, 700)); + let sum: u64 = f1.values.iter().map(|&v| v as u64).sum(); + let nonzero = f1.values.iter().filter(|&&v| v > 0).count(); + // Reference values from h5py over the same file. + assert_eq!(sum, 367924); + assert_eq!(nonzero, 5068); + let f25 = &frames[24]; + let sum25: u64 = f25.values.iter().map(|&v| v as u64).sum(); + assert_eq!(sum25, 522342); + } +} diff --git a/libs/geodata/src/layers/buildings.rs b/libs/geodata/src/layers/buildings.rs new file mode 100644 index 000000000..adc069781 --- /dev/null +++ b/libs/geodata/src/layers/buildings.rs @@ -0,0 +1,121 @@ +//! BAG building polygons with construction year, z13-z14 (CC0, PDOK). +//! Output: nl-buildings-age.mbtiles, MVT layer `bag`. +//! +//! This is the one layer that cannot use the in-memory `Tileset`: ~10.9M +//! polygons at two zoom levels. It streams instead: one pass over the GPKG +//! clips features and spools compact per-256x256-block files to disk, then +//! blocks are loaded one at a time (in writer rowid order) and encoded. +//! NL spans only a handful of blocks at z13/z14, so peak memory is one +//! block's features, not the country's. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::spool::SpoolTiler; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use makepad_mbtile_reader::Value; + +const ZMIN: u8 = 13; +const ZMAX: u8 = 14; + +const BAG_LIGHT: SourceSpec = SourceSpec { + id: "bag-light", + url: "https://service.pdok.nl/lv/bag/atom/downloads/bag-light.gpkg", + filename: "bag-light.gpkg", + license: "CC0", + attribution: "Kadaster BAG via PDOK", + // Monthly refresh upstream; a stale building year is harmless. + recheck_days: 45, + limit_rate: None, +}; + +pub struct BuildingsLayer; + +impl Layer for BuildingsLayer { + fn id(&self) -> &'static str { + "buildings-age" + } + fn description(&self) -> &'static str { + "BAG building polygons with construction year (PDOK bag-light, CC0, 7.8 GB)" + } + fn sources(&self) -> Vec { + vec![BAG_LIGHT] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let path = ctx.cached(&BAG_LIGHT); + if !path.exists() { + return Err("source not fetched yet (run: geodata fetch buildings-age)".into()); + } + let mut gpkg = Gpkg::open(&path)?; + let tables = gpkg.feature_tables()?; + let table = tables + .iter() + .find(|t| t.table.eq_ignore_ascii_case("pand")) + .ok_or_else(|| { + format!( + "no 'pand' feature table; tables: {:?}", + tables.iter().map(|t| &t.table).collect::>() + ) + })?; + eprintln!( + " bag: table {} columns {:?} (srs {})", + table.table, table.columns, table.srs_id + ); + let bouwjaar_col = table + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("bouwjaar")) + .ok_or("no bouwjaar column")?; + let status_col = table + .columns + .iter() + .position(|c| c.eq_ignore_ascii_case("status")); + + let spool_dir = ctx.cache_dir.join("spool-buildings"); + let mut tiler = SpoolTiler::new(&spool_dir, ZMIN, ZMAX)?; + let mut features = 0u64; + let skipped = gpkg.for_each_feature(table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + match values.get(bouwjaar_col) { + Some(Value::Integer(year)) if *year > 0 => { + attrs.push(("bouwjaar".into(), AttrVal::Int(*year))); + } + _ => {} + } + if let Some(col) = status_col { + if let Some(Value::Text(status)) = values.get(col) { + if !status.is_empty() { + attrs.push(("status".into(), AttrVal::Str(status.clone()))); + } + } + } + if tiler.add("bag", &geom, &attrs).is_ok() { + features += 1; + if features % 1_000_000 == 0 { + eprintln!(" bag: {} M buildings spooled", features / 1_000_000); + } + } + })?; + eprintln!(" bag: {features} buildings, {skipped} without usable geometry"); + + let out = ctx.out_file(self.id()); + let stats = tiler.finish( + &out, + &crate::tiler::TilesetConfig { + name: "nl-buildings-age".into(), + description: "BAG buildings with construction year".into(), + attribution: "Kadaster BAG via PDOK".into(), + license: "CC0".into(), + minzoom: ZMIN, + maxzoom: ZMAX, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/cbs_grid.rs b/libs/geodata/src/layers/cbs_grid.rs new file mode 100644 index 000000000..91db5eb28 --- /dev/null +++ b/libs/geodata/src/layers/cbs_grid.rs @@ -0,0 +1,118 @@ +//! CBS Vierkantstatistieken: 500m and 100m statistics grids (CC-BY 4.0). +//! Output: nl-demographics.mbtiles, MVT layers `vk500` (z8-z11) and +//! `vk100` (z12-z13), square polygons carrying the per-cell statistics. +//! Negative values are CBS suppression sentinels and are omitted. + +use super::{unzip_gpkgs, BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use makepad_mbtile_reader::Value; + +const VK500: SourceSpec = SourceSpec { + id: "cbs-vk500", + url: "https://download.cbs.nl/vierkant/500/2026-cbs_vk500_2025_v1.zip", + filename: "cbs_vk500_2025.zip", + license: "CC BY 4.0", + attribution: "Centraal Bureau voor de Statistiek (CBS)", + recheck_days: 90, + limit_rate: None, +}; + +const VK100: SourceSpec = SourceSpec { + id: "cbs-vk100", + url: "https://download.cbs.nl/vierkant/100/2026-cbs_vk100_2025_v1.zip", + filename: "cbs_vk100_2025.zip", + license: "CC BY 4.0", + attribution: "Centraal Bureau voor de Statistiek (CBS)", + recheck_days: 90, + limit_rate: None, +}; + +pub struct CbsGridLayer; + +impl Layer for CbsGridLayer { + fn id(&self) -> &'static str { + "demographics" + } + fn description(&self) -> &'static str { + "CBS 500m/100m statistics grids: population, age, housing (CC BY 4.0)" + } + fn sources(&self) -> Vec { + vec![VK500, VK100] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let mut tileset = Tileset::new(); + let mut features = 0u64; + for (spec, mvt_layer, zmin, zmax) in + [(&VK500, "vk500", 8u8, 11u8), (&VK100, "vk100", 12u8, 13u8)] + { + let zip_path = ctx.cached(spec); + if !zip_path.exists() { + return Err(format!( + "source {} not fetched yet (run: geodata fetch demographics)", + spec.id + )); + } + let gpkg_path = unzip_gpkgs(&zip_path, &ctx.cache_dir)? + .into_iter() + .next() + .ok_or("no gpkg extracted")?; + let mut gpkg = Gpkg::open(&gpkg_path)?; + for table in gpkg.feature_tables()? { + eprintln!( + " {}: table {} ({} columns, srs {})", + spec.id, + table.table, + table.columns.len(), + table.srs_id + ); + gpkg.for_each_feature(&table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + for (index, column) in table.columns.iter().enumerate() { + if index == table.geom_col { + continue; + } + match values.get(index) { + // Negative values are CBS suppression sentinels. + Some(Value::Integer(i)) if *i >= 0 => { + attrs.push((column.to_lowercase(), AttrVal::Int(*i))); + } + Some(Value::Float(f)) if *f >= 0.0 => { + attrs.push((column.to_lowercase(), AttrVal::Float(*f))); + } + _ => {} + } + } + if attrs.is_empty() { + return; + } + tileset.add(mvt_layer, zmin, zmax, &geom, &attrs); + features += 1; + })?; + } + } + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-demographics".into(), + description: "CBS 500m/100m statistics grids".into(), + attribution: "CBS".into(), + license: "CC BY 4.0".into(), + minzoom: 8, + maxzoom: 13, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + diff --git a/libs/geodata/src/layers/chargers.rs b/libs/geodata/src/layers/chargers.rs new file mode 100644 index 000000000..c5a41da09 --- /dev/null +++ b/libs/geodata/src/layers/chargers.rs @@ -0,0 +1,147 @@ +//! EV charging locations from NDW's national OCPI dump (open data portal). +//! Output: nl-chargers.mbtiles, MVT layer `chargers`, point features, z8-z14. + +use super::{read_gz, BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use crate::wkb::Geometry; + +const ZMIN: u8 = 8; +const ZMAX: u8 = 14; + +const NDW_OCPI: SourceSpec = SourceSpec { + id: "ndw-chargers-ocpi", + url: "https://opendata.ndw.nu/charging_point_locations_ocpi.json.gz", + filename: "charging_point_locations_ocpi.json.gz", + license: "Open (NDW open data portal)", + attribution: "Nationaal Dataportaal Wegverkeer (NDW)", + // The file refreshes about daily; being a national aggregate it does not + // need to be fresher than a couple of days for a map overlay. + recheck_days: 2, + limit_rate: None, +}; + +pub struct ChargersLayer; + +impl Layer for ChargersLayer { + fn id(&self) -> &'static str { + "chargers" + } + fn description(&self) -> &'static str { + "EV charging locations, national OCPI aggregate (NDW open data)" + } + fn sources(&self) -> Vec { + vec![NDW_OCPI] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let path = ctx.cached(&NDW_OCPI); + if !path.exists() { + return Err("source not fetched yet (run: geodata fetch chargers)".into()); + } + let bytes = read_gz(&path)?; + let root: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|e| format!("parse OCPI json: {e}"))?; + + // OCPI dumps come either as a bare array of locations or wrapped in + // {"data": [...]}. Find the location array defensively. + let locations = if let Some(array) = root.as_array() { + array.clone() + } else if let Some(array) = root.get("data").and_then(|d| d.as_array()) { + array.clone() + } else { + return Err("unrecognized OCPI structure (no location array)".into()); + }; + + let mut tileset = Tileset::new(); + let mut features = 0u64; + let mut skipped = 0u64; + for location in &locations { + let coords = location.get("coordinates"); + let lat = json_f64(coords.and_then(|c| c.get("latitude"))); + let lon = json_f64(coords.and_then(|c| c.get("longitude"))); + let (Some(lat), Some(lon)) = (lat, lon) else { + skipped += 1; + continue; + }; + if !(50.0..54.0).contains(&lat) || !(3.0..8.0).contains(&lon) { + skipped += 1; + continue; + } + + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + if let Some(name) = location.get("name").and_then(|v| v.as_str()) { + if !name.is_empty() { + attrs.push(("name".into(), AttrVal::Str(name.into()))); + } + } + if let Some(op) = location + .get("operator") + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()) + { + attrs.push(("operator".into(), AttrVal::Str(op.into()))); + } + if let Some(city) = location.get("city").and_then(|v| v.as_str()) { + attrs.push(("city".into(), AttrVal::Str(city.into()))); + } + if let Some(evses) = location.get("evses").and_then(|v| v.as_array()) { + let mut connectors = 0u64; + let mut max_kw = 0.0f64; + for evse in evses { + if let Some(cs) = evse.get("connectors").and_then(|v| v.as_array()) { + connectors += cs.len() as u64; + for connector in cs { + let watts = json_f64(connector.get("max_electric_power")) + .or_else(|| { + // OCPI 2.1: voltage * amperage + let v = json_f64(connector.get("voltage"))?; + let a = json_f64(connector.get("amperage"))?; + Some(v * a) + }) + .unwrap_or(0.0); + max_kw = max_kw.max(watts / 1000.0); + } + } + } + attrs.push(("evses".into(), AttrVal::Int(evses.len() as i64))); + attrs.push(("connectors".into(), AttrVal::Int(connectors as i64))); + if max_kw > 0.0 { + attrs.push(("max_kw".into(), AttrVal::Int(max_kw.round() as i64))); + } + } + + tileset.add("chargers", ZMIN, ZMAX, &Geometry::Point(lon, lat), &attrs); + features += 1; + } + eprintln!(" chargers: {features} locations, {skipped} skipped"); + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-chargers".into(), + description: "EV charging locations (NDW OCPI national aggregate)".into(), + attribution: "NDW".into(), + license: "Open data (NDW)".into(), + minzoom: ZMIN, + maxzoom: ZMAX, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + +fn json_f64(value: Option<&serde_json::Value>) -> Option { + let value = value?; + if let Some(f) = value.as_f64() { + return Some(f); + } + value.as_str()?.trim().parse().ok() +} diff --git a/libs/geodata/src/layers/flood.rs b/libs/geodata/src/layers/flood.rs new file mode 100644 index 000000000..450c663ae --- /dev/null +++ b/libs/geodata/src/layers/flood.rs @@ -0,0 +1,98 @@ +//! Flood hazard from the JRC Europe river flood map (CC BY 4.0): maximum +//! water depth for the 1-in-100-year event, binned into depth classes as +//! gray8 class tiles z6-z11 over NL. WGS84 source (~90 m), sampled directly. +//! +//! Follow-ups documented in the README: the JRC `spurious_depth_areas` mask +//! (removes a few known artifacts) and the PDOK ROR official zone polygons +//! (CC0 GML) as a vector companion layer. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::raster::{build_raster, RasterConfig, RasterEncoding}; +use crate::tiff::Tiff; + +const JRC_RP100: SourceSpec = SourceSpec { + id: "jrc-flood-rp100", + url: "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/CEMS-EFAS/flood_hazard/Europe_RP100_filled_depth.tif", + filename: "jrc_europe_rp100_depth.tif", + license: "CC BY 4.0", + attribution: "JRC European Commission, river flood hazard maps", + recheck_days: 365, + limit_rate: None, +}; + +const NL_BOUNDS: (f64, f64, f64, f64) = (3.2, 50.7, 7.3, 53.7); + +/// Depth class edges in meters: class i covers [edges[i-1], edges[i]). +const DEPTH_EDGES: &[f64] = &[0.5, 1.0, 2.0, 4.0]; + +pub struct FloodLayer; + +impl Layer for FloodLayer { + fn id(&self) -> &'static str { + "flood" + } + fn description(&self) -> &'static str { + "JRC 1-in-100y river flood depth as class raster (CC BY 4.0)" + } + fn sources(&self) -> Vec { + vec![JRC_RP100] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let path = ctx.cached(&JRC_RP100); + if !path.exists() { + return Err("source not fetched yet (run: geodata fetch flood)".into()); + } + let mut tiff = Tiff::open(&path)?; + eprintln!( + " flood: {}x{} px, geo {:?}, nodata {:?}", + tiff.width, tiff.height, tiff.geo, tiff.nodata + ); + + let mut sampler = move |lon: f64, lat: f64| -> Option { + let depth = tiff.sample_geo(lon, lat)?; + if depth <= 0.0 { + return None; + } + let mut class = 1u8; + for (i, edge) in DEPTH_EDGES.iter().enumerate() { + if f64::from(depth) >= *edge { + class = i as u8 + 2; + } + } + Some(f32::from(class)) + }; + + let classmap = serde_json::json!([ + {"class": 1, "label": "< 0.5 m", "color": "#c6dbef"}, + {"class": 2, "label": "0.5-1 m", "color": "#9ecae1"}, + {"class": 3, "label": "1-2 m", "color": "#6baed6"}, + {"class": 4, "label": "2-4 m", "color": "#3182bd"}, + {"class": 5, "label": ">= 4 m", "color": "#08519c"} + ]); + + let out = ctx.out_file(self.id()); + let stats = build_raster( + &out, + &RasterConfig { + name: "nl-flood".into(), + description: "JRC river flood depth RP100, classed".into(), + attribution: "JRC European Commission".into(), + license: "CC BY 4.0".into(), + minzoom: 6, + maxzoom: 11, + bounds: NL_BOUNDS, + encoding: RasterEncoding::ClassIndex, + classmap: Some(classmap), + }, + &mut sampler, + )?; + Ok(BuildReport { + out_path: out, + features: stats.tiles, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/mod.rs b/libs/geodata/src/layers/mod.rs new file mode 100644 index 000000000..87437d8ad --- /dev/null +++ b/libs/geodata/src/layers/mod.rs @@ -0,0 +1,159 @@ +//! One module per overlay layer. Each layer declares its bulk sources and +//! knows how to build its own .mbtiles from the cached files. + +pub mod buildings; +pub mod cbs_grid; +pub mod chargers; +pub mod flood; +pub mod nature; +pub mod noise; +pub mod terrain; +pub mod transit; +pub mod wijkbuurt; + +use crate::fetch::SourceSpec; +use std::path::{Path, PathBuf}; + +pub struct BuildCtx { + pub cache_dir: PathBuf, + pub out_dir: PathBuf, +} + +impl BuildCtx { + pub fn cached(&self, spec: &SourceSpec) -> PathBuf { + self.cache_dir.join(spec.filename) + } + pub fn out_file(&self, layer_id: &str) -> PathBuf { + self.out_dir.join(format!("nl-{layer_id}.mbtiles")) + } +} + +pub struct BuildReport { + pub out_path: PathBuf, + pub features: u64, + pub tiles: u64, + pub bytes: u64, +} + +pub trait Layer { + fn id(&self) -> &'static str; + fn description(&self) -> &'static str; + /// Bulk files this layer needs. Empty for not-yet-wired layers. + fn sources(&self) -> Vec; + fn implemented(&self) -> bool { + true + } + fn build(&self, ctx: &BuildCtx) -> Result; +} + +/// Planned layers that are designed (see README) but not built yet. They are +/// listed so `geodata list` shows the roadmap, and refuse to build. +pub struct PlannedLayer { + pub id: &'static str, + pub description: &'static str, +} + +impl Layer for PlannedLayer { + fn id(&self) -> &'static str { + self.id + } + fn description(&self) -> &'static str { + self.description + } + fn sources(&self) -> Vec { + Vec::new() + } + fn implemented(&self) -> bool { + false + } + fn build(&self, _ctx: &BuildCtx) -> Result { + Err(format!( + "layer '{}' is designed but not implemented yet (see libs/geodata/README.md)", + self.id + )) + } +} + +/// Registry of all layers, implemented and planned. +pub fn registry() -> Vec> { + vec![ + Box::new(nature::NatureLayer), + Box::new(chargers::ChargersLayer), + Box::new(cbs_grid::CbsGridLayer), + Box::new(wijkbuurt::WijkBuurtLayer), + Box::new(transit::TransitLayer), + Box::new(buildings::BuildingsLayer), + Box::new(terrain::TerrainLayer), + Box::new(noise::NoiseLayer), + Box::new(flood::FloodLayer), + ] +} + +pub fn find_layer(id: &str) -> Option> { + registry().into_iter().find(|l| l.id() == id) +} + +/// Helper: run a closure over a gzip file's decompressed bytes. +pub fn read_gz(path: &Path) -> Result, String> { + use std::io::Read; + let file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + let mut out = Vec::new(); + flate2::read::GzDecoder::new(file) + .read_to_end(&mut out) + .map_err(|e| format!("gunzip {}: {e}", path.display()))?; + Ok(out) +} + +/// Extract every .gpkg inside a zip into the cache dir (skipping members that +/// are already extracted and newer than the zip). Returns extracted paths. +pub fn unzip_gpkgs(zip_path: &Path, cache_dir: &Path) -> Result, String> { + use std::process::Command; + let listing = Command::new("unzip") + .arg("-Z1") + .arg(zip_path) + .output() + .map_err(|e| format!("unzip -Z1: {e}"))?; + if !listing.status.success() { + return Err(format!("unzip -Z1 failed on {}", zip_path.display())); + } + let names = String::from_utf8_lossy(&listing.stdout); + let gpkg_names: Vec = names + .lines() + .filter(|l| l.to_lowercase().ends_with(".gpkg")) + .map(|l| l.to_string()) + .collect(); + if gpkg_names.is_empty() { + return Err(format!("no .gpkg inside {}", zip_path.display())); + } + let mut out_paths = Vec::new(); + for name in &gpkg_names { + let out_path = cache_dir.join( + Path::new(name) + .file_name() + .ok_or("bad zip entry name")?, + ); + let fresh = match (out_path.metadata(), zip_path.metadata()) { + (Ok(o), Ok(z)) => match (o.modified(), z.modified()) { + (Ok(om), Ok(zm)) => om >= zm, + _ => false, + }, + _ => false, + }; + if !fresh { + let status = Command::new("unzip") + .arg("-o") + .arg("-j") + .arg(zip_path) + .arg(name) + .arg("-d") + .arg(cache_dir) + .status() + .map_err(|e| format!("unzip: {e}"))?; + if !status.success() { + return Err(format!("unzip failed on {}", zip_path.display())); + } + } + out_paths.push(out_path); + } + Ok(out_paths) +} diff --git a/libs/geodata/src/layers/nature.rs b/libs/geodata/src/layers/nature.rs new file mode 100644 index 000000000..8e525321a --- /dev/null +++ b/libs/geodata/src/layers/nature.rs @@ -0,0 +1,110 @@ +//! Protected-nature polygons: Natura 2000 + Ramsar wetlands (PDOK, CC0). +//! Output: nl-nature.mbtiles, MVT layers `natura2000` and `wetlands`, z6-z12. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use makepad_mbtile_reader::Value; + +const ZMIN: u8 = 6; +const ZMAX: u8 = 12; + +const NATURA2000: SourceSpec = SourceSpec { + id: "natura2000", + url: "https://service.pdok.nl/rvo/natura2000/atom/downloads/natura2000.gpkg", + filename: "natura2000.gpkg", + license: "CC0", + attribution: "Rijksdienst voor Ondernemend Nederland via PDOK", + recheck_days: 30, + limit_rate: Some("10M"), +}; + +const WETLANDS: SourceSpec = SourceSpec { + id: "wetlands", + url: "https://service.pdok.nl/rvo/wetlands/atom/downloads/wetlands.gpkg", + filename: "wetlands.gpkg", + license: "CC0", + attribution: "Rijksdienst voor Ondernemend Nederland via PDOK", + recheck_days: 30, + limit_rate: Some("10M"), +}; + +pub struct NatureLayer; + +impl Layer for NatureLayer { + fn id(&self) -> &'static str { + "nature" + } + fn description(&self) -> &'static str { + "Protected nature polygons: Natura 2000 + Ramsar wetlands (PDOK, CC0)" + } + fn sources(&self) -> Vec { + vec![NATURA2000, WETLANDS] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let mut tileset = Tileset::new(); + tileset.query_rings(&["natura2000", "wetlands"]); + let mut features = 0u64; + for (spec, mvt_layer) in [(&NATURA2000, "natura2000"), (&WETLANDS, "wetlands")] { + let path = ctx.cached(spec); + if !path.exists() { + return Err(format!( + "source {} not fetched yet (run: geodata fetch nature)", + spec.id + )); + } + let mut gpkg = Gpkg::open(&path)?; + for table in gpkg.feature_tables()? { + eprintln!( + " {}: table {} columns {:?} (srs {})", + spec.id, table.table, table.columns, table.srs_id + ); + gpkg.for_each_feature(&table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + for (index, column) in table.columns.iter().enumerate() { + if index == table.geom_col { + continue; + } + match values.get(index) { + Some(Value::Text(text)) if !text.is_empty() && text.len() < 120 => { + attrs.push(( + column.to_lowercase(), + AttrVal::Str(text.clone()), + )); + } + Some(Value::Integer(i)) => { + attrs.push((column.to_lowercase(), AttrVal::Int(*i))); + } + _ => {} + } + } + attrs.push(("kind".into(), AttrVal::Str(mvt_layer.into()))); + tileset.add(mvt_layer, ZMIN, ZMAX, &geom, &attrs); + features += 1; + })?; + } + } + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-nature".into(), + description: "Protected nature: Natura 2000 + Ramsar wetlands (NL)".into(), + attribution: "RVO via PDOK".into(), + license: "CC0".into(), + minzoom: ZMIN, + maxzoom: ZMAX, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/noise.rs b/libs/geodata/src/layers/noise.rs new file mode 100644 index 000000000..574a7fa48 --- /dev/null +++ b/libs/geodata/src/layers/noise.rs @@ -0,0 +1,136 @@ +//! Environmental noise from RIVM "Geluid in Nederland" (CC0): the nationwide +//! 10 m Lden all-sources raster, binned into 5 dB classes as gray8 class +//! tiles z6-z13. The class table ships in `geodata_classmap` metadata so the +//! renderer colormaps in the shader and the query side can name the class. +//! Source GeoTIFF is EPSG:28992 — sampled via the WGS84->RD polynomial. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::raster::{build_raster, RasterConfig, RasterEncoding}; +use crate::tiff::Tiff; +use std::path::PathBuf; +use std::process::Command; + +const RIVM_NOISE: SourceSpec = SourceSpec { + id: "rivm-lden", + url: "https://data.rivm.nl/data/alo/rivm_20250801_Geluid_lden_allebronnen_2022.zip", + filename: "rivm_geluid_lden_2022.zip", + license: "CC0", + attribution: "RIVM Geluid in Nederland (Lden 2022)", + recheck_days: 365, + limit_rate: Some("10M"), +}; + +const NL_BOUNDS: (f64, f64, f64, f64) = (3.2, 50.7, 7.3, 53.7); + +/// Class thresholds in dB Lden: class i covers [edges[i-1], edges[i]). +const DB_EDGES: &[f64] = &[45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0]; + +pub struct NoiseLayer; + +impl Layer for NoiseLayer { + fn id(&self) -> &'static str { + "noise" + } + fn description(&self) -> &'static str { + "RIVM 10m Lden noise (all sources) as 5 dB class raster (CC0)" + } + fn sources(&self) -> Vec { + vec![RIVM_NOISE] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let zip = ctx.cached(&RIVM_NOISE); + if !zip.exists() { + return Err("source not fetched yet (run: geodata fetch noise)".into()); + } + let tif_path = unzip_first_tif(&zip, &ctx.cache_dir)?; + let mut tiff = Tiff::open(&tif_path)?; + eprintln!( + " noise: {}x{} px, geo {:?}, nodata {:?}", + tiff.width, tiff.height, tiff.geo, tiff.nodata + ); + + let mut sampler = move |lon: f64, lat: f64| -> Option { + let (x, y) = crate::geo::wgs84_to_rd(lon, lat); + let db = tiff.sample_geo(x, y)?; + if db <= 0.0 { + return None; + } + let mut class = 1u8; + for (i, edge) in DB_EDGES.iter().enumerate() { + if f64::from(db) >= *edge { + class = i as u8 + 2; + } + } + Some(f32::from(class)) + }; + + let classmap = serde_json::json!([ + {"class": 1, "label": "< 45 dB", "color": "#00000000"}, + {"class": 2, "label": "45-50 dB", "color": "#4575b4"}, + {"class": 3, "label": "50-55 dB", "color": "#91bfdb"}, + {"class": 4, "label": "55-60 dB", "color": "#e0f382"}, + {"class": 5, "label": "60-65 dB", "color": "#fee090"}, + {"class": 6, "label": "65-70 dB", "color": "#fc8d59"}, + {"class": 7, "label": "70-75 dB", "color": "#d73027"}, + {"class": 8, "label": ">= 75 dB", "color": "#a50026"} + ]); + + let out = ctx.out_file(self.id()); + let stats = build_raster( + &out, + &RasterConfig { + name: "nl-noise".into(), + description: "RIVM Lden all-sources noise, 5 dB classes".into(), + attribution: "RIVM".into(), + license: "CC0".into(), + minzoom: 6, + maxzoom: 13, + bounds: NL_BOUNDS, + encoding: RasterEncoding::ClassIndex, + classmap: Some(classmap), + }, + &mut sampler, + )?; + Ok(BuildReport { + out_path: out, + features: stats.tiles, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + +fn unzip_first_tif(zip: &PathBuf, cache_dir: &PathBuf) -> Result { + let listing = Command::new("unzip") + .arg("-Z1") + .arg(zip) + .output() + .map_err(|e| format!("unzip -Z1: {e}"))?; + let names = String::from_utf8_lossy(&listing.stdout); + let tif = names + .lines() + .find(|l| { + let lower = l.to_lowercase(); + lower.ends_with(".tif") || lower.ends_with(".tiff") + }) + .ok_or_else(|| format!("no .tif inside {} (members: {})", zip.display(), names))? + .to_string(); + let out = cache_dir.join(std::path::Path::new(&tif).file_name().ok_or("bad name")?); + if !out.exists() { + let status = Command::new("unzip") + .arg("-o") + .arg("-j") + .arg(zip) + .arg(&tif) + .arg("-d") + .arg(cache_dir) + .status() + .map_err(|e| format!("unzip: {e}"))?; + if !status.success() { + return Err("unzip failed".into()); + } + } + Ok(out) +} diff --git a/libs/geodata/src/layers/terrain.rs b/libs/geodata/src/layers/terrain.rs new file mode 100644 index 000000000..cfaded81f --- /dev/null +++ b/libs/geodata/src/layers/terrain.rs @@ -0,0 +1,129 @@ +//! Elevation from Copernicus GLO-30 (30 m DSM, ESA/Airbus, attribution +//! license) as terrarium-encoded raster tiles, z6-z12 over NL. +//! Consumers: renderer hillshade / 3D terrain, and map_nav's per-edge +//! climb/descent baking for EV routing (both read the same file). +//! +//! Source: anonymous AWS open-data COGs, one 1x1 degree tile each. Tiles +//! that are all-ocean don't exist upstream — those fetches are expected to +//! fail and the build treats missing cells as sea level. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::{fetch_source, FetchOptions, SourceSpec}; +use crate::raster::{build_raster, RasterConfig, RasterEncoding}; +use crate::tiff::Tiff; +use std::collections::HashMap; + +const LAT_RANGE: std::ops::RangeInclusive = 50..=53; +const LON_RANGE: std::ops::RangeInclusive = 3..=7; +const NL_BOUNDS: (f64, f64, f64, f64) = (3.0, 50.7, 7.3, 53.8); + +fn tile_specs() -> Vec { + let mut specs = Vec::new(); + for lat in LAT_RANGE { + for lon in LON_RANGE { + let stem = format!("Copernicus_DSM_COG_10_N{lat:02}_00_E{lon:03}_00_DEM"); + let url: &'static str = Box::leak( + format!("https://copernicus-dem-30m.s3.amazonaws.com/{stem}/{stem}.tif") + .into_boxed_str(), + ); + let filename: &'static str = + Box::leak(format!("glo30_N{lat:02}_E{lon:03}.tif").into_boxed_str()); + let id: &'static str = + Box::leak(format!("glo30-N{lat:02}-E{lon:03}").into_boxed_str()); + specs.push(SourceSpec { + id, + url, + filename, + license: "Copernicus DEM (ESA/Airbus, attribution)", + attribution: "Copernicus DEM GLO-30 (c) ESA / Airbus", + recheck_days: 3650, // static dataset + limit_rate: None, + }); + } + } + specs +} + +pub struct TerrainLayer; + +impl Layer for TerrainLayer { + fn id(&self) -> &'static str { + "terrain" + } + fn description(&self) -> &'static str { + "Copernicus GLO-30 elevation as terrarium tiles (hillshade + EV grades)" + } + fn sources(&self) -> Vec { + tile_specs() + } + + fn build(&self, ctx: &BuildCtx) -> Result { + // Fetch tolerantly here: all-ocean 1-degree cells 404 upstream. + let opts = FetchOptions { + cache_dir: ctx.cache_dir.clone(), + force: false, + }; + let mut cells: HashMap<(i32, i32), Option> = HashMap::new(); + for (index, spec) in tile_specs().iter().enumerate() { + let lat = 50 + (index as i32) / 5; + let lon = 3 + (index as i32) % 5; + let path = ctx.cached(spec); + if !path.exists() { + match fetch_source(&opts, spec) { + Ok(_) => {} + Err(error) => { + eprintln!(" terrain: {} unavailable ({error}) — treating as sea", spec.id); + cells.insert((lat, lon), None); + continue; + } + } + } + match Tiff::open(&path) { + Ok(tiff) => { + cells.insert((lat, lon), Some(tiff)); + } + Err(error) => { + eprintln!(" terrain: {} unreadable ({error})", spec.id); + cells.insert((lat, lon), None); + } + } + } + let available = cells.values().filter(|c| c.is_some()).count(); + eprintln!(" terrain: {available} of {} degree cells available", cells.len()); + if available == 0 { + return Err("no GLO-30 cells available".into()); + } + + let mut sampler = move |lon: f64, lat: f64| -> Option { + let cell = (lat.floor() as i32, lon.floor() as i32); + match cells.get_mut(&cell) { + Some(Some(tiff)) => tiff.sample_geo(lon, lat).or(Some(0.0)), + Some(None) => Some(0.0), // known-ocean cell: sea level + None => None, // outside our cell set + } + }; + + let out = ctx.out_file(self.id()); + let stats = build_raster( + &out, + &RasterConfig { + name: "nl-terrain".into(), + description: "Copernicus GLO-30 elevation, terrarium encoding".into(), + attribution: "Copernicus DEM GLO-30 (c) ESA / Airbus".into(), + license: "Copernicus DEM licence (attribution)".into(), + minzoom: 6, + maxzoom: 12, + bounds: NL_BOUNDS, + encoding: RasterEncoding::Terrarium, + classmap: None, + }, + &mut sampler, + )?; + Ok(BuildReport { + out_path: out, + features: stats.tiles, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/layers/transit.rs b/libs/geodata/src/layers/transit.rs new file mode 100644 index 000000000..9cd0c48c2 --- /dev/null +++ b/libs/geodata/src/layers/transit.rs @@ -0,0 +1,261 @@ +//! Public transport from the OVapi static GTFS bundle (CC0): all NL stops as +//! points plus rail/tram/metro/ferry route shapes as lines (bus shapes are +//! skipped — they follow the roads that are already on the map and triple the +//! archive size). Output: nl-transit.mbtiles, MVT layers `stops` (z10-z14) +//! and `routes` (z7-z12). +//! +//! Live vehicle positions (GTFS-RT) are deliberately NOT here: that is a +//! runtime concern for the map app. + +use super::{BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use crate::wkb::Geometry; +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Command, Stdio}; + +const OVAPI_GTFS: SourceSpec = SourceSpec { + id: "ovapi-gtfs", + url: "https://gtfs.openov.nl/gtfs-rt/gtfs-openov-nl.zip", + filename: "gtfs-openov-nl.zip", + license: "CC0", + attribution: "OVapi / NDOV loket", + // Refreshed daily upstream; weekly is plenty for stop/route geometry. + recheck_days: 7, + limit_rate: None, +}; + +pub struct TransitLayer; + +impl Layer for TransitLayer { + fn id(&self) -> &'static str { + "transit" + } + fn description(&self) -> &'static str { + "All NL transit stops + rail/tram/metro/ferry route lines (OVapi GTFS, CC0)" + } + fn sources(&self) -> Vec { + vec![OVAPI_GTFS] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let zip = ctx.cached(&OVAPI_GTFS); + if !zip.exists() { + return Err("source not fetched yet (run: geodata fetch transit)".into()); + } + + // routes.txt: route_id -> (route_type, name) + let mut route_info: HashMap = HashMap::new(); + stream_gtfs_csv(&zip, "routes.txt", |row| { + let (Some(id), Some(rtype)) = (row.get("route_id"), row.get("route_type")) else { + return; + }; + let name = row + .get("route_short_name") + .filter(|s| !s.is_empty()) + .or_else(|| row.get("route_long_name")) + .cloned() + .unwrap_or_default(); + route_info.insert(id.clone(), (rtype.parse().unwrap_or(3), name)); + })?; + + // trips.txt: shape_id -> route_id (first trip wins; direction variants + // produce distinct shape_ids so nothing is lost). + let mut shape_route: HashMap = HashMap::new(); + stream_gtfs_csv(&zip, "trips.txt", |row| { + let (Some(shape), Some(route)) = (row.get("shape_id"), row.get("route_id")) else { + return; + }; + if shape.is_empty() { + return; + } + shape_route + .entry(shape.clone()) + .or_insert_with(|| route.clone()); + })?; + + // Which shapes do we keep? Everything except bus (route_type 3 and the + // extended 700-series bus codes). + let keep_shape: HashMap<&String, (&i64, &String)> = shape_route + .iter() + .filter_map(|(shape, route)| { + let (rtype, name) = route_info.get(route)?; + let is_bus = *rtype == 3 || (700..800).contains(rtype); + if is_bus { + None + } else { + Some((shape, (rtype, name))) + } + }) + .collect(); + + let mut tileset = Tileset::new(); + let mut features = 0u64; + + // stops.txt -> points + stream_gtfs_csv(&zip, "stops.txt", |row| { + let (Some(lat), Some(lon)) = (row.get("stop_lat"), row.get("stop_lon")) else { + return; + }; + let (Ok(lat), Ok(lon)) = (lat.parse::(), lon.parse::()) else { + return; + }; + if !(50.0..54.2).contains(&lat) || !(2.5..7.5).contains(&lon) { + return; + } + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + if let Some(name) = row.get("stop_name") { + if !name.is_empty() { + attrs.push(("name".into(), AttrVal::Str(name.clone()))); + } + } + let is_station = row.get("location_type").map(|s| s.as_str()) == Some("1"); + if is_station { + attrs.push(("station".into(), AttrVal::Bool(true))); + } + // Stations get a wider zoom range than local stops. + let zmin = if is_station { 8 } else { 10 }; + tileset.add("stops", zmin, 14, &Geometry::Point(lon, lat), &attrs); + features += 1; + })?; + + // shapes.txt -> route lines (streamed; shapes arrive grouped by id, + // but don't rely on it — accumulate per shape id, flush at the end). + let mut shapes: HashMap> = HashMap::new(); + stream_gtfs_csv(&zip, "shapes.txt", |row| { + let Some(id) = row.get("shape_id") else { return }; + if !keep_shape.contains_key(id) { + return; + } + let (Some(lat), Some(lon), Some(seq)) = ( + row.get("shape_pt_lat"), + row.get("shape_pt_lon"), + row.get("shape_pt_sequence"), + ) else { + return; + }; + let (Ok(lat), Ok(lon), Ok(seq)) = + (lat.parse::(), lon.parse::(), seq.parse::()) + else { + return; + }; + shapes.entry(id.clone()).or_default().push((lon, lat, seq)); + })?; + let shape_count = shapes.len(); + for (shape_id, mut pts) in shapes { + let Some((rtype, name)) = keep_shape.get(&shape_id) else { + continue; + }; + pts.sort_by_key(|&(_, _, seq)| seq); + let line: Vec<(f64, f64)> = pts.iter().map(|&(lon, lat, _)| (lon, lat)).collect(); + if line.len() < 2 { + continue; + } + let mode = match **rtype { + 0 | 900..=906 => "tram", + 1 | 400..=404 => "metro", + 2 | 100..=117 => "rail", + 4 | 1000 | 1200 => "ferry", + _ => "other", + }; + let mut attrs = vec![("mode".to_string(), AttrVal::Str(mode.into()))]; + if !name.is_empty() { + attrs.push(("ref".into(), AttrVal::Str((*name).clone()))); + } + tileset.add("routes", 7, 14, &Geometry::LineString(line), &attrs); + features += 1; + } + eprintln!(" transit: {features} features ({shape_count} non-bus shapes)"); + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-transit".into(), + description: "NL transit stops + non-bus route shapes (OVapi GTFS)".into(), + attribution: "OVapi / NDOV".into(), + license: "CC0".into(), + minzoom: 7, + maxzoom: 14, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} + +/// Stream one CSV member of a zip through a row callback without extracting +/// to disk. Handles quoted fields and the UTF-8 BOM. +fn stream_gtfs_csv( + zip: &Path, + member: &str, + mut callback: impl FnMut(&HashMap), +) -> Result<(), String> { + let mut child = Command::new("unzip") + .arg("-p") + .arg(zip) + .arg(member) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("unzip -p {member}: {e}"))?; + let stdout = child.stdout.take().ok_or("no stdout")?; + let reader = BufReader::with_capacity(1 << 20, stdout); + + let mut header: Vec = Vec::new(); + let mut row: HashMap = HashMap::new(); + for line in reader.lines() { + let line = line.map_err(|e| format!("read {member}: {e}"))?; + let fields = parse_csv_line(&line); + if header.is_empty() { + header = fields + .into_iter() + .map(|f| f.trim_start_matches('\u{feff}').to_string()) + .collect(); + continue; + } + row.clear(); + for (i, field) in fields.into_iter().enumerate() { + if let Some(key) = header.get(i) { + row.insert(key.clone(), field); + } + } + callback(&row); + } + let status = child.wait().map_err(|e| format!("unzip wait: {e}"))?; + if !status.success() { + return Err(format!("unzip -p {member} failed (missing member?)")); + } + Ok(()) +} + +fn parse_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut field = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if in_quotes => { + if chars.peek() == Some(&'"') { + chars.next(); + field.push('"'); + } else { + in_quotes = false; + } + } + '"' => in_quotes = true, + ',' if !in_quotes => fields.push(std::mem::take(&mut field)), + _ => field.push(c), + } + } + fields.push(field); + fields +} diff --git a/libs/geodata/src/layers/wijkbuurt.rs b/libs/geodata/src/layers/wijkbuurt.rs new file mode 100644 index 000000000..622eb1908 --- /dev/null +++ b/libs/geodata/src/layers/wijkbuurt.rs @@ -0,0 +1,108 @@ +//! CBS Wijk- en Buurtkaart: municipality/district/neighborhood polygons with +//! kerncijfers (CC BY 4.0). Output: nl-wijkbuurt.mbtiles with MVT layers +//! `gemeenten` (z6-z8), `wijken` (z9-z10), `buurten` (z11-z13). + +use super::{unzip_gpkgs, BuildCtx, BuildReport, Layer}; +use crate::fetch::SourceSpec; +use crate::gpkg::Gpkg; +use crate::mvt::AttrVal; +use crate::tiler::{Tileset, TilesetConfig}; +use makepad_mbtile_reader::Value; + +const WIJKBUURT: SourceSpec = SourceSpec { + id: "cbs-wijkbuurt", + url: "https://geodata.cbs.nl/files/Wijkenbuurtkaart/WijkBuurtkaart_2025_v1.zip", + filename: "wijkbuurtkaart_2025.zip", + license: "CC BY 4.0", + attribution: "CBS / Kadaster", + recheck_days: 90, + limit_rate: None, +}; + +pub struct WijkBuurtLayer; + +impl Layer for WijkBuurtLayer { + fn id(&self) -> &'static str { + "wijkbuurt" + } + fn description(&self) -> &'static str { + "CBS municipality/district/neighborhood polygons with key statistics (CC BY 4.0)" + } + fn sources(&self) -> Vec { + vec![WIJKBUURT] + } + + fn build(&self, ctx: &BuildCtx) -> Result { + let zip_path = ctx.cached(&WIJKBUURT); + if !zip_path.exists() { + return Err("source not fetched yet (run: geodata fetch wijkbuurt)".into()); + } + let gpkgs = unzip_gpkgs(&zip_path, &ctx.cache_dir)?; + let mut tileset = Tileset::new(); + tileset.query_rings(&["gemeenten", "wijken", "buurten"]); + let mut features = 0u64; + for gpkg_path in &gpkgs { + let mut gpkg = Gpkg::open(gpkg_path)?; + for table in gpkg.feature_tables()? { + let lower = table.table.to_lowercase(); + let (mvt_layer, zmin, zmax) = if lower.contains("gemeente") { + ("gemeenten", 6u8, 8u8) + } else if lower.contains("wijk") { + ("wijken", 9, 10) + } else if lower.contains("buurt") { + ("buurten", 11, 13) + } else { + continue; + }; + eprintln!( + " wijkbuurt: table {} -> layer {} ({} columns)", + table.table, + mvt_layer, + table.columns.len() + ); + gpkg.for_each_feature(&table, |_rowid, values, geom| { + let mut attrs: Vec<(String, AttrVal)> = Vec::new(); + for (index, column) in table.columns.iter().enumerate() { + if index == table.geom_col { + continue; + } + match values.get(index) { + // Negative values are CBS suppression sentinels. + Some(Value::Integer(i)) if *i >= 0 => { + attrs.push((column.to_lowercase(), AttrVal::Int(*i))); + } + Some(Value::Float(f)) if *f >= 0.0 => { + attrs.push((column.to_lowercase(), AttrVal::Float(*f))); + } + Some(Value::Text(t)) if !t.is_empty() && t.len() < 80 => { + attrs.push((column.to_lowercase(), AttrVal::Str(t.clone()))); + } + _ => {} + } + } + tileset.add(mvt_layer, zmin, zmax, &geom, &attrs); + features += 1; + })?; + } + } + + let out = ctx.out_file(self.id()); + let stats = tileset.finish( + &out, + &TilesetConfig { + name: "nl-wijkbuurt".into(), + description: "CBS Wijk- en Buurtkaart with kerncijfers".into(), + attribution: "CBS / Kadaster".into(), + license: "CC BY 4.0".into(), + minzoom: 6, + maxzoom: 13, + }, + )?; + Ok(BuildReport { + out_path: out, + features, + tiles: stats.tiles, + bytes: stats.bytes, + }) + } +} diff --git a/libs/geodata/src/lib.rs b/libs/geodata/src/lib.rs new file mode 100644 index 000000000..90fafa18d --- /dev/null +++ b/libs/geodata/src/lib.rs @@ -0,0 +1,31 @@ +//! makepad-geodata: bulk open-geodata fetching and per-layer overlay database +//! building for the map stack. +//! +//! Every overlay layer becomes its own .mbtiles file (never merged into the +//! base map archive), built from bulk-downloadable open datasets only. The +//! fetch module enforces the politeness rules; the tiler writes standard +//! gzipped MVT the renderer's existing decoder already understands. +//! +//! This is a library so the maps app can embed the same fetch/cache/build +//! machinery later for periodically synced sources (e.g. the NDW charger +//! file); the `geodata` binary is a thin CLI over it. + +pub mod fetch; +pub mod geo; +pub mod gpkg; +pub mod layers; +pub mod mvt; +pub mod png; +pub mod query; +pub mod knmi_hdf5; +pub mod radar_raster; +pub mod radar; +pub mod raster; +pub mod sidecar; +pub mod tiff; +pub mod spool; +pub mod tiler; +pub mod wkb; + +pub use fetch::{fetch_source, FetchOptions, FetchOutcome, SourceSpec}; +pub use layers::{find_layer, registry, BuildCtx, BuildReport, Layer}; diff --git a/libs/geodata/src/main.rs b/libs/geodata/src/main.rs new file mode 100644 index 000000000..d154e6448 --- /dev/null +++ b/libs/geodata/src/main.rs @@ -0,0 +1,272 @@ +use makepad_geodata::{fetch_source, find_layer, registry, BuildCtx, FetchOptions}; +use std::path::PathBuf; + +fn usage() -> ! { + eprintln!( + "geodata — bulk open-geodata fetcher / overlay database builder + +USAGE: + geodata list show all layers and their sources + geodata fetch download (or revalidate) a layer's bulk sources + geodata build build the layer's .mbtiles (fetches if missing) + geodata status show cache and output state + geodata query query the features sidecar (LLM surface) + +OPTIONS: + --cache-dir default: local/overlays/cache + --out-dir default: local/overlays + --force re-download even if the cache is fresh + --radius query: search radius in meters (default: point query) + --limit query: max results (default 10)" + ); + std::process::exit(2); +} + +struct Args { + command: String, + target: String, + positional: Vec, + cache_dir: PathBuf, + out_dir: PathBuf, + force: bool, + radius: Option, + limit: usize, +} + +fn parse_args() -> Args { + let mut args = Args { + command: String::new(), + target: String::new(), + positional: Vec::new(), + cache_dir: PathBuf::from("local/overlays/cache"), + out_dir: PathBuf::from("local/overlays"), + force: false, + radius: None, + limit: 10, + }; + let mut positional = Vec::new(); + let mut iter = std::env::args().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--cache-dir" => args.cache_dir = PathBuf::from(iter.next().unwrap_or_default()), + "--out-dir" => args.out_dir = PathBuf::from(iter.next().unwrap_or_default()), + "--force" => args.force = true, + "--radius" => args.radius = iter.next().and_then(|v| v.parse().ok()), + "--limit" => args.limit = iter.next().and_then(|v| v.parse().ok()).unwrap_or(10), + "-h" | "--help" => usage(), + other => positional.push(other.to_string()), + } + } + if positional.is_empty() { + usage(); + } + args.command = positional[0].clone(); + args.target = positional.get(1).cloned().unwrap_or_else(|| "all".into()); + args.positional = positional; + args +} + +fn main() { + let args = parse_args(); + match args.command.as_str() { + "list" => { + for layer in registry() { + let state = if layer.implemented() { + "ready" + } else { + "planned" + }; + println!("{:<14} [{}] {}", layer.id(), state, layer.description()); + for source in layer.sources() { + println!( + " source {:<20} {} ({})", + source.id, source.url, source.license + ); + } + } + } + "fetch" => { + let opts = FetchOptions { + cache_dir: args.cache_dir.clone(), + force: args.force, + }; + for layer in select(&args.target) { + for source in layer.sources() { + match fetch_source(&opts, &source) { + Ok(outcome) => println!( + "{:<14} {:<20} {:?}", + layer.id(), + source.id, + outcome + ), + Err(error) => { + eprintln!("{:<14} {:<20} ERROR {error}", layer.id(), source.id); + std::process::exit(1); + } + } + } + } + } + "build" => { + let opts = FetchOptions { + cache_dir: args.cache_dir.clone(), + force: false, + }; + let ctx = BuildCtx { + cache_dir: args.cache_dir.clone(), + out_dir: args.out_dir.clone(), + }; + std::fs::create_dir_all(&ctx.out_dir).expect("create out dir"); + for layer in select(&args.target) { + if !layer.implemented() { + if args.target != "all" { + eprintln!("{}: planned, not implemented yet", layer.id()); + } + continue; + } + // Make sure sources exist (fresh cache is fine, no re-download). + // A failed fetch is a warning; the build decides whether the + // missing file is fatal (e.g. ocean-only DEM cells are not). + for source in layer.sources() { + if let Err(error) = fetch_source(&opts, &source) { + eprintln!("{}: fetch {} failed: {error}", layer.id(), source.id); + } + } + let start = std::time::Instant::now(); + match layer.build(&ctx) { + Ok(report) => println!( + "{:<14} {} features -> {} tiles, {:.1} MB, {:.1}s -> {}", + layer.id(), + report.features, + report.tiles, + report.bytes as f64 / 1e6, + start.elapsed().as_secs_f64(), + report.out_path.display() + ), + Err(error) => { + eprintln!("{}: build failed: {error}", layer.id()); + std::process::exit(1); + } + } + } + } + "radar-sync" => { + let dataset = if args.target == "reflectivity" { + makepad_geodata::radar::RadarDataset::ReflectivityComposite + } else { + makepad_geodata::radar::RadarDataset::Forecast + }; + let config = makepad_geodata::radar::RadarConfig::for_dataset( + args.out_dir.join("radar"), + dataset, + ); + match makepad_geodata::radar::RadarSync::new(config).sync() { + Ok(state) => { + println!( + "polled: {}, downloaded: {}, frames on disk: {}", + state.polled, + state.downloaded, + state.frames.len() + ); + for frame in &state.frames { + println!( + " {} ({:.1} MB, created {})", + frame.filename, + frame.bytes as f64 / 1e6, + frame.created_unix + ); + } + } + Err(error) => { + eprintln!("radar sync failed: {error}"); + std::process::exit(1); + } + } + } + "query" => { + let (Some(lon), Some(lat)) = ( + args.positional.get(2).and_then(|v| v.parse::().ok()), + args.positional.get(3).and_then(|v| v.parse::().ok()), + ) else { + usage(); + }; + let path = if args.target.ends_with(".mbtiles") { + PathBuf::from(&args.target) + } else { + args.out_dir.join(format!("nl-{}.mbtiles", args.target)) + }; + let mut db = match makepad_geodata::query::LayerDb::open(&path) { + Ok(db) => db, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + let result = match args.radius { + Some(radius) => db.query_radius(lon, lat, radius, args.limit), + None => db.query_point(lon, lat, args.limit), + }; + match result { + Ok(hits) => { + for hit in hits { + let mut attrs = hit.attrs.clone(); + if let Some(map) = attrs.as_object_mut() { + map.remove("__ring"); + } + let line = serde_json::json!({ + "layer": hit.layer, + "name": hit.name, + "distance_m": hit.distance_m.map(|d| d.round()), + "center": [hit.center.0, hit.center.1], + "attrs": attrs, + }); + println!("{line}"); + } + } + Err(error) => { + eprintln!("query failed: {error}"); + std::process::exit(1); + } + } + } + "status" => { + println!("cache: {}", args.cache_dir.display()); + for layer in registry() { + for source in layer.sources() { + let path = args.cache_dir.join(source.filename); + let state = match path.metadata() { + Ok(meta) => format!("{:.1} MB", meta.len() as f64 / 1e6), + Err(_) => "missing".into(), + }; + println!(" {:<24} {}", source.filename, state); + } + } + println!("outputs: {}", args.out_dir.display()); + for layer in registry() { + let path = args.out_dir.join(format!("nl-{}.mbtiles", layer.id())); + if let Ok(meta) = path.metadata() { + println!( + " nl-{}.mbtiles {:.1} MB", + layer.id(), + meta.len() as f64 / 1e6 + ); + } + } + } + _ => usage(), + } +} + +fn select(target: &str) -> Vec> { + if target == "all" { + registry() + } else { + match find_layer(target) { + Some(layer) => vec![layer], + None => { + eprintln!("unknown layer '{target}' (see: geodata list)"); + std::process::exit(2); + } + } + } +} diff --git a/libs/geodata/src/mvt.rs b/libs/geodata/src/mvt.rs new file mode 100644 index 000000000..f9d458ee9 --- /dev/null +++ b/libs/geodata/src/mvt.rs @@ -0,0 +1,219 @@ +//! Minimal Mapbox Vector Tile (MVT 2.1) encoder. Extent 4096, gzip applied by +//! the tiler so the map renderer's payload sniffing works unchanged. + +use std::collections::HashMap; + +pub const EXTENT: u32 = 4096; + +#[derive(Debug, Clone, PartialEq)] +pub enum AttrVal { + Str(String), + Int(i64), + Float(f64), + Bool(bool), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GeomType { + Point = 1, + Line = 2, + Polygon = 3, +} + +pub struct PreFeature { + pub geom_type: GeomType, + /// Fully encoded geometry command stream (zigzag deltas included). + pub commands: Vec, + pub attrs: Vec<(String, AttrVal)>, +} + +struct LayerEnc { + name: String, + keys: Vec, + key_index: HashMap, + values: Vec, + value_index: HashMap, + features: Vec>, +} + +impl LayerEnc { + fn new(name: &str) -> Self { + LayerEnc { + name: name.to_string(), + keys: Vec::new(), + key_index: HashMap::new(), + values: Vec::new(), + value_index: HashMap::new(), + features: Vec::new(), + } + } + + fn key_id(&mut self, key: &str) -> u32 { + if let Some(&id) = self.key_index.get(key) { + return id; + } + let id = self.keys.len() as u32; + self.keys.push(key.to_string()); + self.key_index.insert(key.to_string(), id); + id + } + + fn value_id(&mut self, value: &AttrVal) -> u32 { + let dedup_key = match value { + AttrVal::Str(s) => format!("s\u{1}{s}"), + AttrVal::Int(i) => format!("i\u{1}{i}"), + AttrVal::Float(f) => format!("f\u{1}{:016x}", f.to_bits()), + AttrVal::Bool(b) => format!("b\u{1}{b}"), + }; + if let Some(&id) = self.value_index.get(&dedup_key) { + return id; + } + let id = self.values.len() as u32; + self.values.push(value.clone()); + self.value_index.insert(dedup_key, id); + id + } + + fn add_feature(&mut self, feature: &PreFeature) { + let mut tags = Vec::with_capacity(feature.attrs.len() * 2); + for (key, value) in &feature.attrs { + tags.push(self.key_id(key)); + tags.push(self.value_id(value)); + } + let mut buf = Vec::with_capacity(feature.commands.len() * 2 + tags.len() * 2 + 8); + // tags (field 2, packed) + if !tags.is_empty() { + let mut packed = Vec::with_capacity(tags.len() * 2); + for tag in &tags { + write_varint(u64::from(*tag), &mut packed); + } + write_tag(2, 2, &mut buf); + write_varint(packed.len() as u64, &mut buf); + buf.extend_from_slice(&packed); + } + // type (field 3) + write_tag(3, 0, &mut buf); + write_varint(feature.geom_type as u64, &mut buf); + // geometry (field 4, packed) + let mut packed = Vec::with_capacity(feature.commands.len() * 2); + for command in &feature.commands { + write_varint(u64::from(*command), &mut packed); + } + write_tag(4, 2, &mut buf); + write_varint(packed.len() as u64, &mut buf); + buf.extend_from_slice(&packed); + + self.features.push(buf); + } + + fn encode(&self, out: &mut Vec) { + let mut layer = Vec::new(); + // version (field 15) + write_tag(15, 0, &mut layer); + write_varint(2, &mut layer); + // name (field 1) + write_tag(1, 2, &mut layer); + write_varint(self.name.len() as u64, &mut layer); + layer.extend_from_slice(self.name.as_bytes()); + // features (field 2) + for feature in &self.features { + write_tag(2, 2, &mut layer); + write_varint(feature.len() as u64, &mut layer); + layer.extend_from_slice(feature); + } + // keys (field 3) + for key in &self.keys { + write_tag(3, 2, &mut layer); + write_varint(key.len() as u64, &mut layer); + layer.extend_from_slice(key.as_bytes()); + } + // values (field 4) + for value in &self.values { + let mut vbuf = Vec::new(); + match value { + AttrVal::Str(s) => { + write_tag(1, 2, &mut vbuf); + write_varint(s.len() as u64, &mut vbuf); + vbuf.extend_from_slice(s.as_bytes()); + } + AttrVal::Float(f) => { + write_tag(3, 1, &mut vbuf); + vbuf.extend_from_slice(&f.to_le_bytes()); + } + AttrVal::Int(i) => { + write_tag(4, 0, &mut vbuf); + write_varint(*i as u64, &mut vbuf); + } + AttrVal::Bool(b) => { + write_tag(7, 0, &mut vbuf); + write_varint(u64::from(*b), &mut vbuf); + } + } + write_tag(4, 2, &mut layer); + write_varint(vbuf.len() as u64, &mut layer); + layer.extend_from_slice(&vbuf); + } + // extent (field 5) + write_tag(5, 0, &mut layer); + write_varint(u64::from(EXTENT), &mut layer); + + // Tile.layers is field 3 + write_tag(3, 2, out); + write_varint(layer.len() as u64, out); + out.extend_from_slice(&layer); + } +} + +/// One tile's worth of layers being assembled. +pub struct TileEnc { + layers: Vec, +} + +impl TileEnc { + pub fn new() -> Self { + TileEnc { layers: Vec::new() } + } + + pub fn add_feature(&mut self, layer_name: &str, feature: &PreFeature) { + let layer = match self.layers.iter_mut().find(|l| l.name == layer_name) { + Some(l) => l, + None => { + self.layers.push(LayerEnc::new(layer_name)); + self.layers.last_mut().unwrap() + } + }; + layer.add_feature(feature); + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + for layer in &self.layers { + layer.encode(&mut out); + } + out + } +} + +pub fn zigzag(value: i64) -> u32 { + ((value << 1) ^ (value >> 63)) as u32 +} + +pub fn command(id: u32, count: u32) -> u32 { + (id & 0x7) | (count << 3) +} + +fn write_tag(field: u32, wire_type: u32, out: &mut Vec) { + write_varint(u64::from((field << 3) | wire_type), out); +} + +fn write_varint(mut value: u64, out: &mut Vec) { + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } +} diff --git a/libs/geodata/src/png.rs b/libs/geodata/src/png.rs new file mode 100644 index 000000000..661a85beb --- /dev/null +++ b/libs/geodata/src/png.rs @@ -0,0 +1,221 @@ +//! Minimal PNG codec for raster overlay tiles: 8-bit grayscale (class-index +//! rasters like noise/flood) and 8-bit RGB (terrarium elevation). Encoder +//! writes filter-0 rows + zlib (flate2); decoder handles exactly what any +//! standard encoder emits for these formats (all five row filters, +//! non-interlaced). CRC32 is the 30-line table version — not worth a dep. + +use flate2::write::ZlibEncoder; +use flate2::Compression; +use std::io::Write; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PngFormat { + Gray8, + Rgb8, +} + +impl PngFormat { + fn color_type(&self) -> u8 { + match self { + PngFormat::Gray8 => 0, + PngFormat::Rgb8 => 2, + } + } + pub fn bytes_per_pixel(&self) -> usize { + match self { + PngFormat::Gray8 => 1, + PngFormat::Rgb8 => 3, + } + } +} + +pub fn encode(width: u32, height: u32, format: PngFormat, pixels: &[u8]) -> Vec { + let bpp = format.bytes_per_pixel(); + assert_eq!(pixels.len(), width as usize * height as usize * bpp); + + let mut raw = Vec::with_capacity(pixels.len() + height as usize); + for row in pixels.chunks_exact(width as usize * bpp) { + raw.push(0); // filter type 0 + raw.extend_from_slice(row); + } + let mut z = ZlibEncoder::new(Vec::new(), Compression::new(6)); + z.write_all(&raw).unwrap(); + let idat = z.finish().unwrap(); + + let mut out = Vec::with_capacity(idat.len() + 64); + out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); // bit depth + ihdr.push(format.color_type()); + ihdr.extend_from_slice(&[0, 0, 0]); // deflate, filter 0, no interlace + write_chunk(&mut out, b"IHDR", &ihdr); + write_chunk(&mut out, b"IDAT", &idat); + write_chunk(&mut out, b"IEND", &[]); + out +} + +pub struct DecodedPng { + pub width: u32, + pub height: u32, + pub format: PngFormat, + pub pixels: Vec, +} + +pub fn decode(data: &[u8]) -> Result { + if data.len() < 8 || &data[0..8] != &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] { + return Err("not a png".into()); + } + let mut pos = 8usize; + let mut width = 0u32; + let mut height = 0u32; + let mut format = PngFormat::Gray8; + let mut idat = Vec::new(); + while pos + 8 <= data.len() { + let len = u32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as usize; + let kind = &data[pos + 4..pos + 8]; + let body = data + .get(pos + 8..pos + 8 + len) + .ok_or("truncated png chunk")?; + match kind { + b"IHDR" => { + width = u32::from_be_bytes(body[0..4].try_into().unwrap()); + height = u32::from_be_bytes(body[4..8].try_into().unwrap()); + if body[8] != 8 || body[12] != 0 { + return Err("unsupported png (need 8-bit non-interlaced)".into()); + } + format = match body[9] { + 0 => PngFormat::Gray8, + 2 => PngFormat::Rgb8, + other => return Err(format!("unsupported png color type {other}")), + }; + } + b"IDAT" => idat.extend_from_slice(body), + b"IEND" => break, + _ => {} + } + pos += 12 + len; // len + type + crc + } + if width == 0 || height == 0 { + return Err("png missing IHDR".into()); + } + + let mut raw = Vec::new(); + flate2::read::ZlibDecoder::new(&idat[..]) + .read_to_end(&mut raw) + .map_err(|e| format!("png inflate: {e}"))?; + + let bpp = format.bytes_per_pixel(); + let stride = width as usize * bpp; + if raw.len() < height as usize * (stride + 1) { + return Err("png data too short".into()); + } + let mut pixels = vec![0u8; height as usize * stride]; + for y in 0..height as usize { + let filter = raw[y * (stride + 1)]; + let row_in = &raw[y * (stride + 1) + 1..y * (stride + 1) + 1 + stride]; + for x in 0..stride { + let a = if x >= bpp { + pixels[y * stride + x - bpp] + } else { + 0 + }; + let b = if y > 0 { pixels[(y - 1) * stride + x] } else { 0 }; + let c = if y > 0 && x >= bpp { + pixels[(y - 1) * stride + x - bpp] + } else { + 0 + }; + let value = match filter { + 0 => row_in[x], + 1 => row_in[x].wrapping_add(a), + 2 => row_in[x].wrapping_add(b), + 3 => row_in[x].wrapping_add(((u16::from(a) + u16::from(b)) / 2) as u8), + 4 => row_in[x].wrapping_add(paeth(a, b, c)), + other => return Err(format!("unsupported png filter {other}")), + }; + pixels[y * stride + x] = value; + } + } + Ok(DecodedPng { + width, + height, + format, + pixels, + }) +} + +use std::io::Read; + +fn paeth(a: u8, b: u8, c: u8) -> u8 { + let (a, b, c) = (i16::from(a), i16::from(b), i16::from(c)); + let p = a + b - c; + let (pa, pb, pc) = ((p - a).abs(), (p - b).abs(), (p - c).abs()); + if pa <= pb && pa <= pc { + a as u8 + } else if pb <= pc { + b as u8 + } else { + c as u8 + } +} + +fn write_chunk(out: &mut Vec, kind: &[u8; 4], body: &[u8]) { + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(body); + let mut crc = Crc32::new(); + crc.update(kind); + crc.update(body); + out.extend_from_slice(&crc.finish().to_be_bytes()); +} + +struct Crc32 { + table: [u32; 256], + value: u32, +} + +impl Crc32 { + fn new() -> Self { + let mut table = [0u32; 256]; + for (n, entry) in table.iter_mut().enumerate() { + let mut c = n as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }; + } + *entry = c; + } + Crc32 { + table, + value: 0xffff_ffff, + } + } + fn update(&mut self, data: &[u8]) { + for &byte in data { + self.value = self.table[((self.value ^ u32::from(byte)) & 0xff) as usize] + ^ (self.value >> 8); + } + } + fn finish(self) -> u32 { + self.value ^ 0xffff_ffff + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn png_round_trip() { + let width = 32u32; + let height = 16u32; + let pixels: Vec = (0..width * height * 3).map(|i| (i % 251) as u8).collect(); + let encoded = encode(width, height, PngFormat::Rgb8, &pixels); + let decoded = decode(&encoded).unwrap(); + assert_eq!(decoded.width, width); + assert_eq!(decoded.height, height); + assert_eq!(decoded.format, PngFormat::Rgb8); + assert_eq!(decoded.pixels, pixels); + } +} diff --git a/libs/geodata/src/query.rs b/libs/geodata/src/query.rs new file mode 100644 index 000000000..b1bbde2aa --- /dev/null +++ b/libs/geodata/src/query.rs @@ -0,0 +1,204 @@ +//! Structured queries against a layer database's `features` sidecar table — +//! the "reason with the map" surface. The map app exposes these as LLM tool +//! calls ("what is at/near this location?") and for tap-to-inspect UI. +//! +//! Queries are b-tree range scans over the grid-indexed rowids; a typical +//! radius query touches a few dozen pages of the .mbtiles file. + +use crate::sidecar::{CELL_AXIS, FEATURES_TABLE}; +use makepad_mbtile_reader::{MbtilesReader, Value}; +use makepad_map_nav::geo::{haversine_m, LonLat}; +use std::path::Path; + +#[derive(Debug)] +pub struct FeatureHit { + pub layer: String, + pub name: Option, + /// Feature attributes as parsed JSON. + pub attrs: serde_json::Value, + /// Feature bbox center. + pub center: (f64, f64), + pub bbox: (f64, f64, f64, f64), + /// Distance from the query point (radius/point queries). + pub distance_m: Option, + /// For point queries on ring-carrying layers: exact containment. + pub contains_point: bool, +} + +pub struct LayerDb { + db: MbtilesReader, +} + +impl LayerDb { + pub fn open(path: &Path) -> Result { + let db = MbtilesReader::open_sqlite(path) + .map_err(|e| format!("open {}: {e:?}", path.display()))?; + Ok(LayerDb { db }) + } + + /// All features whose bbox intersects the query bbox. + pub fn query_bbox( + &mut self, + min_lon: f64, + min_lat: f64, + max_lon: f64, + max_lat: f64, + limit: usize, + ) -> Result, String> { + let mut hits = Vec::new(); + // Note y grows south in normalized mercator: max_lat -> min cy. + let (nx0, ny0) = crate::geo::wgs84_to_norm(min_lon, max_lat); + let (nx1, ny1) = crate::geo::wgs84_to_norm(max_lon, min_lat); + let clamp = |v: f64| (v * f64::from(CELL_AXIS)) as i64; + let cx0 = clamp(nx0).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cx1 = clamp(nx1).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cy0 = clamp(ny0).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cy1 = clamp(ny1).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + + 'rows: for cy in cy0..=cy1 { + let lo = (i64::from(cy * CELL_AXIS + cx0)) << 24; + let hi = ((i64::from(cy * CELL_AXIS + cx1)) << 24) | 0x00ff_ffff; + let mut scan_err = None; + let result = self.db.for_each_row_in_range( + FEATURES_TABLE, + lo, + hi, + |_rowid, values| { + if hits.len() >= limit { + return; + } + match parse_hit(&values) { + Ok(hit) => { + let bb = hit.bbox; + if bb.0 <= max_lon && bb.2 >= min_lon && bb.1 <= max_lat && bb.3 >= min_lat { + hits.push(hit); + } + } + Err(e) => scan_err = Some(e), + } + }, + ); + result.map_err(|e| format!("range scan: {e:?}"))?; + if let Some(e) = scan_err { + return Err(e); + } + if hits.len() >= limit { + break 'rows; + } + } + Ok(hits) + } + + /// Features within `radius_m` of a point, nearest first. + pub fn query_radius( + &mut self, + lon: f64, + lat: f64, + radius_m: f64, + limit: usize, + ) -> Result, String> { + // Convert the radius to a degree bbox (safe overestimate at NL lat). + let dlat = radius_m / 111_320.0; + let dlon = radius_m / (111_320.0 * lat.to_radians().cos().max(0.2)); + let mut hits = self.query_bbox( + lon - dlon, + lat - dlat, + lon + dlon, + lat + dlat, + usize::MAX, + )?; + let origin = LonLat::new(lon, lat); + for hit in &mut hits { + let distance = haversine_m(origin, LonLat::new(hit.center.0, hit.center.1)); + hit.distance_m = Some(distance); + } + hits.retain(|h| h.distance_m.unwrap_or(f64::MAX) <= radius_m); + hits.sort_by(|a, b| a.distance_m.unwrap().total_cmp(&b.distance_m.unwrap())); + hits.truncate(limit); + Ok(hits) + } + + /// Features covering a point. Exact for layers that store rings (and for + /// grid layers whose bbox equals the cell); bbox containment otherwise. + pub fn query_point(&mut self, lon: f64, lat: f64, limit: usize) -> Result, String> { + let epsilon = 1e-9; + let mut hits = self.query_bbox( + lon - epsilon, + lat - epsilon, + lon + epsilon, + lat + epsilon, + usize::MAX, + )?; + for hit in &mut hits { + hit.contains_point = match hit.attrs.get("__ring") { + Some(serde_json::Value::Array(ring)) => point_in_ring(lon, lat, ring), + _ => true, // bbox containment already established + }; + } + hits.retain(|h| h.contains_point); + hits.truncate(limit); + Ok(hits) + } +} + +fn parse_hit(values: &[Value]) -> Result { + let text = |i: usize| -> Option { + values.get(i).and_then(|v| v.as_text()).map(str::to_string) + }; + let float = |i: usize| -> f64 { + match values.get(i) { + Some(Value::Float(f)) => *f, + Some(Value::Integer(n)) => *n as f64, + _ => f64::NAN, + } + }; + let layer = text(1).unwrap_or_default(); + let name = text(2); + let bbox = (float(3), float(4), float(5), float(6)); + let mut attrs: serde_json::Value = text(7) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(serde_json::Value::Null); + if let Some(ring_text) = text(8) { + if let Ok(ring) = serde_json::from_str::(&ring_text) { + if let Some(map) = attrs.as_object_mut() { + map.insert("__ring".into(), ring); + } + } + } + Ok(FeatureHit { + layer, + name, + attrs, + center: ((bbox.0 + bbox.2) / 2.0, (bbox.1 + bbox.3) / 2.0), + bbox, + distance_m: None, + contains_point: false, + }) +} + +/// Ray-cast point-in-polygon on a JSON ring [[lon,lat],...]. +fn point_in_ring(lon: f64, lat: f64, ring: &[serde_json::Value]) -> bool { + let pts: Vec<(f64, f64)> = ring + .iter() + .filter_map(|p| { + let arr = p.as_array()?; + Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?)) + }) + .collect(); + if pts.len() < 3 { + return true; + } + let mut inside = false; + let mut j = pts.len() - 1; + for i in 0..pts.len() { + let (xi, yi) = pts[i]; + let (xj, yj) = pts[j]; + if ((yi > lat) != (yj > lat)) + && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) + { + inside = !inside; + } + j = i; + } + inside +} diff --git a/libs/geodata/src/radar.rs b/libs/geodata/src/radar.rs new file mode 100644 index 000000000..8f1c1550e --- /dev/null +++ b/libs/geodata/src/radar.rs @@ -0,0 +1,397 @@ +//! Rain radar sync — the app-embeddable "updated downloader". +//! +//! Unlike the static layers, radar is a rolling time series: KNMI publishes a +//! new 5-minute reflectivity composite every 5 minutes and a +2h nowcast +//! (`radar_forecast`) every 5 minutes. `RadarSync` maintains a small local +//! cache of the newest frames and is designed to be owned by the map app: +//! call `sync()` as often as you like — it never touches the network more +//! than once per `min_poll_secs`, downloads only frames it doesn't have, +//! and prunes old ones. `state()` returns the cached frames without any +//! network contact at all. +//! +//! Files are KNMI HDF5 (polar stereographic). Decoding to a renderable / +//! queryable raster is the next step and lives outside this module; the sync +//! layer's contract is just: freshest N frames on disk + an index. +//! +//! Auth: KNMI's Open Data API wants an API key. Priority: explicit config > +//! `KNMI_API_KEY` env var > the public anonymous key KNMI documents on the +//! developer portal (shared, 50 req/min across all anonymous users — fine +//! for one poll per 5 minutes, but register a free personal key for real +//! deployments). + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Public anonymous key from developer.dataplatform.knmi.nl (shared quota). +pub const KNMI_ANONYMOUS_KEY: &str = "eyJvcmciOiI1ZTU1NGUxOTI3NGE5NjAwMDEyYTNlYjEiLCJpZCI6IjUzYTg1ZDBhMmQ5YzRkYzJiYWNlNzQ4NTQ2Zjk4ODExIiwiaCI6Im11cm11cjEyOCJ9"; + +const API_BASE: &str = "https://api.dataplatform.knmi.nl/open-data/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadarDataset { + /// 5-minute real-time reflectivity composite (one frame per file). + ReflectivityComposite, + /// +2h precipitation nowcast, refreshed every 5 minutes (whole animation + /// in one file) — the most useful single file for the map app. + Forecast, +} + +impl RadarDataset { + fn api_path(&self) -> (&'static str, &'static str) { + match self { + RadarDataset::ReflectivityComposite => ("radar_reflectivity_composites", "2.0"), + RadarDataset::Forecast => ("radar_forecast", "1.0"), + } + } + fn cache_subdir(&self) -> &'static str { + match self { + RadarDataset::ReflectivityComposite => "reflectivity", + RadarDataset::Forecast => "forecast", + } + } +} + +pub struct RadarConfig { + pub cache_dir: PathBuf, + pub dataset: RadarDataset, + pub api_key: Option, + /// Never contact the network more often than this. The data itself + /// refreshes every 300 s, so the default 240 s guarantees at most one + /// poll per new upstream file. + pub min_poll_secs: u64, + /// Keep at most this many newest frames on disk. + pub max_frames: usize, +} + +impl RadarConfig { + pub fn new(cache_dir: impl Into) -> Self { + RadarConfig { + cache_dir: cache_dir.into(), + dataset: RadarDataset::Forecast, + api_key: None, + min_poll_secs: 240, + // One forecast file holds the whole +2h animation; keeping the + // previous one covers the swap window. Reflectivity wants more + // (one frame per file) — see `for_dataset`. + max_frames: 2, + } + } + + pub fn for_dataset(cache_dir: impl Into, dataset: RadarDataset) -> Self { + let mut config = Self::new(cache_dir); + config.dataset = dataset; + if dataset == RadarDataset::ReflectivityComposite { + config.max_frames = 13; // ~1 hour of 5-min frames + } + config + } + fn resolved_key(&self) -> String { + self.api_key + .clone() + .or_else(|| std::env::var("KNMI_API_KEY").ok().filter(|k| !k.is_empty())) + .unwrap_or_else(|| KNMI_ANONYMOUS_KEY.to_string()) + } + fn dir(&self) -> PathBuf { + self.cache_dir.join(self.dataset.cache_subdir()) + } +} + +#[derive(Debug, Clone)] +pub struct RadarFrame { + pub filename: String, + pub path: PathBuf, + pub created_unix: u64, + pub bytes: u64, +} + +#[derive(Debug, Default)] +pub struct RadarState { + /// Frames on disk, oldest first. + pub frames: Vec, + pub last_poll_unix: u64, + /// Whether this call actually contacted the server. + pub polled: bool, + pub downloaded: usize, +} + +pub struct RadarSync { + config: RadarConfig, +} + +impl RadarSync { + pub fn new(config: RadarConfig) -> Self { + RadarSync { config } + } + + /// Cached frames without any network contact. + pub fn state(&self) -> RadarState { + let (last_poll, frames) = self.read_index(); + RadarState { + frames, + last_poll_unix: last_poll, + polled: false, + downloaded: 0, + } + } + + /// Poll for new frames if the poll gate allows; otherwise return cache. + pub fn sync(&self) -> Result { + let dir = self.config.dir(); + std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + let (last_poll, cached_frames) = self.read_index(); + let now = now_unix(); + if now.saturating_sub(last_poll) < self.config.min_poll_secs { + return Ok(RadarState { + frames: cached_frames, + last_poll_unix: last_poll, + polled: false, + downloaded: 0, + }); + } + + // Record the attempt BEFORE any network contact: even a failing sync + // must arm the poll gate, or a broken API would get hammered. + self.write_index(now, &cached_frames)?; + + let (dataset, version) = self.config.dataset.api_path(); + let key = self.config.resolved_key(); + let max_keys = self.config.max_frames.max(1); + let list_url = format!( + "{API_BASE}/datasets/{dataset}/versions/{version}/files?maxKeys={max_keys}&orderBy=created&sorting=desc" + ); + let listing: serde_json::Value = api_get_json(&list_url, &key)?; + let files = listing + .get("files") + .and_then(|f| f.as_array()) + .ok_or_else(|| format!("unexpected KNMI listing: {listing}"))?; + + let mut downloaded = 0usize; + let mut frames: Vec = Vec::new(); + for file in files { + let Some(filename) = file.get("filename").and_then(|f| f.as_str()) else { + continue; + }; + let created = file + .get("created") + .and_then(|c| c.as_str()) + .and_then(parse_iso8601_unix) + .unwrap_or(0); + let path = dir.join(filename); + if !path.exists() { + let url_url = format!( + "{API_BASE}/datasets/{dataset}/versions/{version}/files/{filename}/url" + ); + let url_response: serde_json::Value = api_get_json(&url_url, &key)?; + let Some(signed) = url_response + .get("temporaryDownloadUrl") + .and_then(|u| u.as_str()) + else { + return Err(format!("no download url for {filename}: {url_response}")); + }; + download(signed, &path)?; + downloaded += 1; + } + let bytes = path.metadata().map(|m| m.len()).unwrap_or(0); + frames.push(RadarFrame { + filename: filename.to_string(), + path, + created_unix: created, + bytes, + }); + } + frames.sort_by_key(|f| f.created_unix); + + // Prune files no longer in the newest set. + let keep: std::collections::HashSet<&str> = + frames.iter().map(|f| f.filename.as_str()).collect(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".json") || keep.contains(name.as_ref()) { + continue; + } + let _ = std::fs::remove_file(entry.path()); + } + } + + self.write_index(now, &frames)?; + Ok(RadarState { + frames, + last_poll_unix: now, + polled: true, + downloaded, + }) + } + + fn index_path(&self) -> PathBuf { + self.config.dir().join("index.json") + } + + fn read_index(&self) -> (u64, Vec) { + let Ok(text) = std::fs::read_to_string(self.index_path()) else { + return (0, Vec::new()); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return (0, Vec::new()); + }; + let last_poll = value + .get("last_poll_unix") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let dir = self.config.dir(); + let frames = value + .get("frames") + .and_then(|f| f.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|f| { + let filename = f.get("filename")?.as_str()?.to_string(); + let path = dir.join(&filename); + if !path.exists() { + return None; + } + Some(RadarFrame { + bytes: path.metadata().ok()?.len(), + path, + created_unix: f.get("created_unix")?.as_u64()?, + filename, + }) + }) + .collect() + }) + .unwrap_or_default(); + (last_poll, frames) + } + + fn write_index(&self, last_poll: u64, frames: &[RadarFrame]) -> Result<(), String> { + let value = serde_json::json!({ + "last_poll_unix": last_poll, + "dataset": self.config.dataset.cache_subdir(), + "frames": frames.iter().map(|f| serde_json::json!({ + "filename": f.filename, + "created_unix": f.created_unix, + "bytes": f.bytes, + })).collect::>(), + }); + std::fs::write( + self.index_path(), + serde_json::to_string_pretty(&value).unwrap(), + ) + .map_err(|e| format!("write index: {e}")) + } +} + +fn api_get_json(url: &str, key: &str) -> Result { + // Pace every request; on a 429 (the shared anonymous key saturates), back + // off once and retry before giving up. + for attempt in 0..2 { + std::thread::sleep(std::time::Duration::from_secs(1)); + let output = Command::new("curl") + .arg("-fsS") + .arg("--connect-timeout") + .arg("15") + .arg("--max-time") + .arg("60") + .arg("-A") + .arg(crate::fetch::USER_AGENT) + .arg("-H") + .arg(format!("Authorization: {key}")) + .arg(url) + .output() + .map_err(|e| format!("curl: {e}"))?; + if output.status.success() { + return serde_json::from_slice(&output.stdout) + .map_err(|e| format!("KNMI API json: {e}")); + } + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if attempt == 0 && stderr.contains("429") { + std::thread::sleep(std::time::Duration::from_secs(8)); + continue; + } + return Err(format!("KNMI API {url}: {} {stderr}", output.status)); + } + unreachable!() +} + +fn download(url: &str, dest: &Path) -> Result<(), String> { + let part = dest.with_extension("part"); + let status = Command::new("curl") + .arg("-fsSL") + .arg("--connect-timeout") + .arg("15") + .arg("-A") + .arg(crate::fetch::USER_AGENT) + .arg("-o") + .arg(&part) + .arg(url) + .status() + .map_err(|e| format!("curl: {e}"))?; + if !status.success() { + return Err(format!("download failed: {status}")); + } + std::fs::rename(&part, dest).map_err(|e| format!("rename: {e}")) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Parse "2026-07-28T12:00:00+00:00" (KNMI `created`) to unix seconds. +/// Offsets other than +00:00/Z are honored. +fn parse_iso8601_unix(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.len() < 19 { + return None; + } + let num = |range: std::ops::Range| -> Option { + s.get(range)?.parse().ok() + }; + let year = num(0..4)?; + let month = num(5..7)?; + let day = num(8..10)?; + let hour = num(11..13)?; + let minute = num(14..16)?; + let second = num(17..19)?; + // Days-from-civil (Howard Hinnant's algorithm). + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (month + 9) % 12; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + let mut seconds = days * 86_400 + hour * 3_600 + minute * 60 + second; + // Timezone offset + let rest = &s[19..]; + if let Some(sign_pos) = rest.find(['+', '-']) { + let sign = if rest.as_bytes()[sign_pos] == b'+' { 1 } else { -1 }; + let tz = &rest[sign_pos + 1..]; + if tz.len() >= 5 { + let hours: i64 = tz[0..2].parse().ok()?; + let minutes: i64 = tz[3..5].parse().ok()?; + seconds -= sign * (hours * 3_600 + minutes * 60); + } + } + u64::try_from(seconds).ok() +} + +#[cfg(test)] +mod tests { + use super::parse_iso8601_unix; + + #[test] + fn iso8601_epoch_math() { + assert_eq!(parse_iso8601_unix("1970-01-01T00:00:00+00:00"), Some(0)); + assert_eq!(parse_iso8601_unix("2026-07-28T12:00:00+00:00"), Some(1_785_240_000)); + // +02:00 is two hours earlier in UTC + assert_eq!( + parse_iso8601_unix("2026-07-28T12:00:00+02:00"), + Some(1_785_240_000 - 7_200) + ); + } +} diff --git a/libs/geodata/src/radar_raster.rs b/libs/geodata/src/radar_raster.rs new file mode 100644 index 000000000..de8112991 --- /dev/null +++ b/libs/geodata/src/radar_raster.rs @@ -0,0 +1,209 @@ +//! KNMI radar frames → web-mercator RGBA overlays. +//! +//! The RAD_NL25 grid is polar stereographic (lat_ts 60°N, lon_0 0, GRS-ish +//! a=6378.14 km b=6356.75 km, 1 km pixels, row offset 3649.9795 — verified +//! against the file's `geo_product_corners` to ~20 m). Rendering wants a +//! north-up mercator-aligned texture, so we precompute one output→source +//! lookup table and apply it per frame; the colormap turns raw pixel values +//! (dBZ = 0.5·PV − 32) into translucent rain colors. + +use crate::knmi_hdf5::KnmiFrame; + +const A_KM: f64 = 6378.14; +const B_KM: f64 = 6356.75; +const ROW_OFFSET: f64 = 3649.9795; +const COL_OFFSET: f64 = 0.0; +const GRID_COLS: usize = 700; +const GRID_ROWS: usize = 765; + +/// Geographic cover of the produced texture (the radar grid's bounding box +/// in lon/lat, slightly inset to skip out-of-image corners). +pub const RASTER_WEST: f64 = 0.0; +pub const RASTER_EAST: f64 = 10.86; +pub const RASTER_SOUTH: f64 = 48.89; +pub const RASTER_NORTH: f64 = 55.98; + +fn mercator_y(lat_deg: f64) -> f64 { + let lat = lat_deg.to_radians(); + (lat.tan() + 1.0 / lat.cos()).ln() +} + +struct Stereo { + e: f64, + k0m: f64, +} + +impl Stereo { + fn new() -> Self { + let e2 = 1.0 - (B_KM * B_KM) / (A_KM * A_KM); + let e = e2.sqrt(); + let lat_ts = 60.0_f64.to_radians(); + let t_ts = (std::f64::consts::FRAC_PI_4 - lat_ts / 2.0).tan() + / ((1.0 - e * lat_ts.sin()) / (1.0 + e * lat_ts.sin())).powf(e / 2.0); + let m_ts = lat_ts.cos() / (1.0 - e2 * lat_ts.sin() * lat_ts.sin()).sqrt(); + Stereo { + e, + k0m: A_KM * m_ts / t_ts, + } + } + + /// lon/lat (deg) → radar grid col/row (f64). + fn forward(&self, lon_deg: f64, lat_deg: f64) -> (f64, f64) { + let lam = lon_deg.to_radians(); + let phi = lat_deg.to_radians(); + let t = (std::f64::consts::FRAC_PI_4 - phi / 2.0).tan() + / ((1.0 - self.e * phi.sin()) / (1.0 + self.e * phi.sin())).powf(self.e / 2.0); + let rho = self.k0m * t; + let x = rho * lam.sin(); + let y = -rho * lam.cos(); + (x - COL_OFFSET, -y - ROW_OFFSET) + } +} + +/// Precomputed output-pixel → source-index mapping for a mercator-aligned +/// texture of `width`×`height` covering the RASTER_* bbox. +pub struct RadarProjection { + pub width: usize, + pub height: usize, + /// Fractional source coordinates per output pixel (f32::NAN = outside + /// the radar grid) — bilinear sampling smooths the 1 km cells into + /// curved isolines instead of visible blocks. + src_col: Vec, + src_row: Vec, +} + +impl RadarProjection { + pub fn new(width: usize, height: usize) -> Self { + let stereo = Stereo::new(); + let merc_n = mercator_y(RASTER_NORTH); + let merc_s = mercator_y(RASTER_SOUTH); + let mut src_col = vec![f32::NAN; width * height]; + let mut src_row = vec![f32::NAN; width * height]; + for py in 0..height { + // Mercator-linear in screen y so the texture maps 1:1 onto the + // renderer's mercator plane with a simple quad. + let merc = merc_n + (merc_s - merc_n) * ((py as f64 + 0.5) / height as f64); + let lat = (merc.sinh()).atan().to_degrees(); + for px in 0..width { + let lon = + RASTER_WEST + (RASTER_EAST - RASTER_WEST) * ((px as f64 + 0.5) / width as f64); + let (col, row) = stereo.forward(lon, lat); + if col >= 0.0 + && col < GRID_COLS as f64 + && row >= 0.0 + && row < GRID_ROWS as f64 + { + src_col[py * width + px] = col as f32; + src_row[py * width + px] = row as f32; + } + } + } + Self { + width, + height, + src_col, + src_row, + } + } + + /// Bilinear sample of the raw value field; 255 (out of image) reads as + /// dry so coastal cells don't smear a phantom band. + fn sample_value(frame: &KnmiFrame, col: f32, row: f32) -> f32 { + let value_at = |c: i64, r: i64| -> f32 { + if c < 0 || c >= frame.cols as i64 || r < 0 || r >= frame.rows as i64 { + return 0.0; + } + let v = frame.values[r as usize * frame.cols + c as usize]; + if v == 255 { + 0.0 + } else { + v as f32 + } + }; + let c0 = (col - 0.5).floor(); + let r0 = (row - 0.5).floor(); + let fx = (col - 0.5) - c0; + let fy = (row - 0.5) - r0; + let (c0, r0) = (c0 as i64, r0 as i64); + let top = value_at(c0, r0) * (1.0 - fx) + value_at(c0 + 1, r0) * fx; + let bottom = value_at(c0, r0 + 1) * (1.0 - fx) + value_at(c0 + 1, r0 + 1) * fx; + top * (1.0 - fy) + bottom * fy + } + + /// Rain colormap over (interpolated) KNMI pixel values + /// (dBZ = 0.5·PV − 32). + fn colorize(value: f32) -> [u8; 4] { + if value < 1.0 { + return [0, 0, 0, 0]; + } + let dbz = 0.5 * value - 32.0; + // Premultiplied-ish straight RGBA; renderer treats it as straight + // alpha. Thresholds follow common rain-intensity ramps. + let (r, g, b, a) = if dbz < 5.0 { + (150, 200, 255, 85) + } else if dbz < 15.0 { + (90, 160, 250, 125) + } else if dbz < 25.0 { + (35, 105, 235, 160) + } else if dbz < 33.0 { + (25, 170, 90, 185) + } else if dbz < 40.0 { + (245, 185, 35, 205) + } else if dbz < 47.0 { + (235, 80, 30, 225) + } else { + (205, 25, 160, 245) + }; + [r, g, b, a] + } + + /// Apply the LUT + bilinear sample + colormap to one frame → RGBA. + pub fn frame_to_rgba(&self, frame: &KnmiFrame) -> Vec { + let mut out = vec![0u8; self.width * self.height * 4]; + if frame.cols != GRID_COLS || frame.rows != GRID_ROWS { + return out; + } + for i in 0..self.src_col.len() { + let col = self.src_col[i]; + if col.is_nan() { + continue; + } + let value = Self::sample_value(frame, col, self.src_row[i]); + let rgba = Self::colorize(value); + out[i * 4..i * 4 + 4].copy_from_slice(&rgba); + } + out + } +} + +/// RGBA bytes → BGRA u32 texels (makepad VecBGRAu8_32 layout). +pub fn rgba_to_bgra_texels(rgba: &[u8]) -> Vec { + rgba.chunks_exact(4) + .map(|px| { + (px[2] as u32) | ((px[1] as u32) << 8) | ((px[0] as u32) << 16) | ((px[3] as u32) << 24) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projects_cached_forecast_frame() { + let path = "../../local/overlays/radar/forecast/RAD_NL25_PCP_FM_202607280900.h5"; + let Ok(data) = std::fs::read(path) else { + return; + }; + let frames = crate::knmi_hdf5::decode_frames(&data).unwrap(); + let projection = RadarProjection::new(512, 640); + let rgba = projection.frame_to_rgba(&frames[0]); + let visible = rgba.chunks(4).filter(|px| px[3] > 0).count(); + // Frame 1 has 5068 wet source pixels; the mercator resample of the + // NL bbox should land in the same order of magnitude. + assert!(visible > 500, "visible rain pixels: {visible}"); + let mapped = projection.src_col.iter().filter(|c| !c.is_nan()).count(); + // Most of the output bbox lies inside the radar grid. + assert!(mapped > 512 * 640 / 2, "mapped: {mapped}"); + } +} diff --git a/libs/geodata/src/raster.rs b/libs/geodata/src/raster.rs new file mode 100644 index 000000000..6944f4d1b --- /dev/null +++ b/libs/geodata/src/raster.rs @@ -0,0 +1,180 @@ +//! Raster overlay tiles: sample any (lon, lat) -> value function into 256px +//! PNG tiles inside an .mbtiles (format=png). Two encodings: +//! - Terrarium RGB (elevation: e = h + 32768; R=e>>8, G=e&255, B=frac*256), +//! the de-facto standard the renderer's future hillshade/3D terrain and +//! map_nav's EV grade baking both read. +//! - Gray8 class index (noise/flood): pixel = class byte, 0 = no data; the +//! class -> meaning/color table ships in metadata as `geodata_classmap`. + +use crate::geo::tile_order_key; +use crate::png::{self, PngFormat}; +use makepad_mbtile_reader::MbtilesWriter; +use std::collections::BTreeMap; +use std::path::Path; + +pub const TILE_PX: u32 = 256; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RasterEncoding { + Terrarium, + ClassIndex, +} + +impl RasterEncoding { + fn as_str(&self) -> &'static str { + match self { + RasterEncoding::Terrarium => "terrarium", + RasterEncoding::ClassIndex => "class-index", + } + } +} + +pub struct RasterConfig { + pub name: String, + pub description: String, + pub attribution: String, + pub license: String, + pub minzoom: u8, + pub maxzoom: u8, + /// lon/lat bbox to cover. + pub bounds: (f64, f64, f64, f64), + pub encoding: RasterEncoding, + /// For ClassIndex: JSON array describing each class (index 1..). + pub classmap: Option, +} + +pub struct RasterStats { + pub tiles: u64, + pub bytes: u64, + pub skipped_empty: u64, +} + +/// Build the raster pyramid. `sample(lon, lat)` returns the source value or +/// None for no-data; tiles that are entirely no-data are skipped. +pub fn build_raster( + out_path: &Path, + config: &RasterConfig, + sample: &mut dyn FnMut(f64, f64) -> Option, +) -> Result { + let (min_lon, min_lat, max_lon, max_lat) = config.bounds; + let mut tiles: BTreeMap)> = BTreeMap::new(); + let mut stats = RasterStats { + tiles: 0, + bytes: 0, + skipped_empty: 0, + }; + + for zoom in config.minzoom..=config.maxzoom { + let scale = f64::from(1u32 << zoom); + let (nx0, ny0) = crate::geo::wgs84_to_norm(min_lon, max_lat); + let (nx1, ny1) = crate::geo::wgs84_to_norm(max_lon, min_lat); + let tx0 = ((nx0 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + let tx1 = ((nx1 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + let ty0 = ((ny0 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + let ty1 = ((ny1 * scale).floor() as i64).clamp(0, (1 << zoom) - 1) as u32; + for ty in ty0..=ty1 { + for tx in tx0..=tx1 { + let (format, pixels, any) = render_tile(zoom, tx, ty, config.encoding, sample); + if !any { + stats.skipped_empty += 1; + continue; + } + let data = png::encode(TILE_PX, TILE_PX, format, &pixels); + stats.tiles += 1; + stats.bytes += data.len() as u64; + tiles.insert(tile_order_key(zoom, tx, ty), (zoom, tx, ty, data)); + } + } + eprintln!(" raster z{zoom}: {} tiles so far", stats.tiles); + } + + let mut writer = + MbtilesWriter::create(out_path).map_err(|e| format!("create mbtiles: {e:?}"))?; + writer.set_metadata("name", &config.name); + writer.set_metadata("description", &config.description); + writer.set_metadata("format", "png"); + writer.set_metadata("type", "overlay"); + writer.set_metadata("minzoom", config.minzoom.to_string()); + writer.set_metadata("maxzoom", config.maxzoom.to_string()); + writer.set_metadata("attribution", &config.attribution); + writer.set_metadata("license", &config.license); + writer.set_metadata( + "bounds", + format!("{min_lon:.6},{min_lat:.6},{max_lon:.6},{max_lat:.6}"), + ); + writer.set_metadata("geodata_encoding", config.encoding.as_str()); + if let Some(classmap) = &config.classmap { + writer.set_metadata("geodata_classmap", classmap.to_string()); + } + writer.set_metadata( + "geodata_built_unix", + format!( + "{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ), + ); + for (_, (zoom, x, y, data)) in tiles { + writer + .write_tile_xyz(zoom, x, y, &data) + .map_err(|e| format!("write tile z{zoom}/{x}/{y}: {e:?}"))?; + } + writer.finish().map_err(|e| format!("finish: {e:?}"))?; + Ok(stats) +} + +fn render_tile( + zoom: u8, + tx: u32, + ty: u32, + encoding: RasterEncoding, + sample: &mut dyn FnMut(f64, f64) -> Option, +) -> (PngFormat, Vec, bool) { + let scale = f64::from(1u32 << zoom); + let mut any = false; + match encoding { + RasterEncoding::Terrarium => { + let mut pixels = vec![0u8; (TILE_PX * TILE_PX * 3) as usize]; + for py in 0..TILE_PX { + for px in 0..TILE_PX { + let nx = (f64::from(tx) + (f64::from(px) + 0.5) / f64::from(TILE_PX)) / scale; + let ny = (f64::from(ty) + (f64::from(py) + 0.5) / f64::from(TILE_PX)) / scale; + let p = makepad_map_nav::geo::norm_to_lon_lat(nx, ny); + let height = match sample(p.lon, p.lat) { + Some(h) => { + any = true; + f64::from(h) + } + None => 0.0, + }; + let e = (height + 32_768.0).clamp(0.0, 65_535.996); + let i = ((py * TILE_PX + px) * 3) as usize; + pixels[i] = (e / 256.0) as u8; + pixels[i + 1] = (e as u32 % 256) as u8; + pixels[i + 2] = (e.fract() * 256.0) as u8; + } + } + (PngFormat::Rgb8, pixels, any) + } + RasterEncoding::ClassIndex => { + let mut pixels = vec![0u8; (TILE_PX * TILE_PX) as usize]; + for py in 0..TILE_PX { + for px in 0..TILE_PX { + let nx = (f64::from(tx) + (f64::from(px) + 0.5) / f64::from(TILE_PX)) / scale; + let ny = (f64::from(ty) + (f64::from(py) + 0.5) / f64::from(TILE_PX)) / scale; + let p = makepad_map_nav::geo::norm_to_lon_lat(nx, ny); + if let Some(class) = sample(p.lon, p.lat) { + let class = (class as i64).clamp(0, 255) as u8; + if class > 0 { + any = true; + } + pixels[(py * TILE_PX + px) as usize] = class; + } + } + } + (PngFormat::Gray8, pixels, any) + } + } +} diff --git a/libs/geodata/src/sidecar.rs b/libs/geodata/src/sidecar.rs new file mode 100644 index 000000000..cd6f8d9e2 --- /dev/null +++ b/libs/geodata/src/sidecar.rs @@ -0,0 +1,256 @@ +//! The `features` sidecar table: a grid-indexed, JSON-attributed copy of +//! every feature, written into the same .mbtiles as the render tiles. +//! +//! Purpose: the render `tiles` are for pixels; this table is for *questions*. +//! The map app (or an LLM tool call routed through it) asks "what is here / +//! near here" via `query::LayerDb` and gets structured features back without +//! touching MVT. +//! +//! Spatial index: rowid = (z12 grid cell << 24) | seq, so a bbox query turns +//! into a handful of b-tree range scans (`for_each_row_in_range`) — no SQL, +//! no separate index. Polygon layers can opt into storing a simplified +//! exterior ring for exact point-in-polygon answers ("which buurt am I in"). + +use crate::mvt::AttrVal; +use crate::wkb::Geometry; +use makepad_mbtile_reader::{MbtilesWriter, WriterValue}; + +/// Grid zoom for the cell index: 4096 x 4096 cells world-wide, ~10 km cells +/// at NL latitude — small enough to prune, big enough to keep ranges few. +pub const CELL_ZOOM: u8 = 12; +pub const CELL_AXIS: u32 = 1 << CELL_ZOOM; + +pub const FEATURES_TABLE: &str = "features"; +pub const FEATURES_SQL: &str = "CREATE TABLE features (cell INTEGER, layer TEXT, name TEXT, \ + min_lon REAL, min_lat REAL, max_lon REAL, max_lat REAL, attrs TEXT, ring TEXT)"; + +const NAME_KEYS: &[&str] = &[ + "name", "naam", "naam_n2k", "statnaam", "gemeentenaam", "wijknaam", "buurtnaam", "ref", +]; +const MAX_RING_POINTS: usize = 96; +const RING_TOLERANCE_DEG: f64 = 1e-4; // ~8-11 m + +pub fn cell_for(lon: f64, lat: f64) -> u32 { + let (nx, ny) = crate::geo::wgs84_to_norm(lon, lat); + let cx = ((nx * f64::from(CELL_AXIS)) as i64).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + let cy = ((ny * f64::from(CELL_AXIS)) as i64).clamp(0, i64::from(CELL_AXIS) - 1) as u32; + cy * CELL_AXIS + cx +} + +pub fn rowid_for(cell: u32, seq: u32) -> i64 { + (i64::from(cell) << 24) | i64::from(seq & 0x00ff_ffff) +} + +struct Record { + cell: u32, + layer: String, + name: Option, + bbox: (f64, f64, f64, f64), + attrs_json: String, + ring_json: Option, +} + +#[derive(Default)] +pub struct SidecarBuilder { + records: Vec, +} + +impl SidecarBuilder { + pub fn new() -> Self { + SidecarBuilder::default() + } + + pub fn len(&self) -> usize { + self.records.len() + } + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + pub fn add( + &mut self, + layer: &str, + geometry: &Geometry, + attrs: &[(String, AttrVal)], + want_ring: bool, + ) { + let Some(bbox) = geometry_bbox(geometry) else { + return; + }; + let center = ((bbox.0 + bbox.2) / 2.0, (bbox.1 + bbox.3) / 2.0); + let name = attrs + .iter() + .find(|(k, _)| NAME_KEYS.contains(&k.as_str())) + .and_then(|(_, v)| match v { + AttrVal::Str(s) => Some(s.clone()), + _ => None, + }); + let mut map = serde_json::Map::with_capacity(attrs.len()); + for (key, value) in attrs { + let json = match value { + AttrVal::Str(s) => serde_json::Value::String(s.clone()), + AttrVal::Int(i) => serde_json::Value::from(*i), + AttrVal::Float(f) => serde_json::Value::from(*f), + AttrVal::Bool(b) => serde_json::Value::Bool(*b), + }; + map.insert(key.clone(), json); + } + let ring_json = if want_ring { + exterior_ring(geometry).map(|ring| { + let simplified = simplify_ring(&ring); + let pts: Vec = simplified + .iter() + .map(|&(lon, lat)| { + serde_json::json!([ + (lon * 1e5).round() / 1e5, + (lat * 1e5).round() / 1e5 + ]) + }) + .collect(); + serde_json::Value::Array(pts).to_string() + }) + } else { + None + }; + self.records.push(Record { + cell: cell_for(center.0, center.1), + layer: layer.to_string(), + name, + bbox, + attrs_json: serde_json::Value::Object(map).to_string(), + ring_json, + }); + } + + /// Sort by grid cell and stream into the writer's `features` table. + pub fn write(mut self, writer: &mut MbtilesWriter) -> Result { + if self.records.is_empty() { + return Ok(0); + } + writer + .begin_extra_table(FEATURES_TABLE, FEATURES_SQL) + .map_err(|e| format!("declare features table: {e:?}"))?; + self.records.sort_by_key(|r| r.cell); + let mut count = 0u64; + let mut seq = 0u32; + let mut last_cell = u32::MAX; + for record in &self.records { + if record.cell != last_cell { + seq = 0; + last_cell = record.cell; + } else { + seq += 1; + if seq > 0x00ff_ffff { + continue; // absurd density; drop rather than corrupt order + } + } + let rowid = rowid_for(record.cell, seq); + let values = [ + WriterValue::Integer(i64::from(record.cell)), + WriterValue::Text(&record.layer), + match &record.name { + Some(name) => WriterValue::Text(name), + None => WriterValue::Null, + }, + WriterValue::Float(record.bbox.0), + WriterValue::Float(record.bbox.1), + WriterValue::Float(record.bbox.2), + WriterValue::Float(record.bbox.3), + WriterValue::Text(&record.attrs_json), + match &record.ring_json { + Some(ring) => WriterValue::Text(ring), + None => WriterValue::Null, + }, + ]; + writer + .write_extra_row(FEATURES_TABLE, rowid, &values) + .map_err(|e| format!("write feature row: {e:?}"))?; + count += 1; + } + Ok(count) + } +} + +fn geometry_bbox(geometry: &Geometry) -> Option<(f64, f64, f64, f64)> { + let mut bbox = ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + let mut grow = |lon: f64, lat: f64| { + bbox.0 = bbox.0.min(lon); + bbox.1 = bbox.1.min(lat); + bbox.2 = bbox.2.max(lon); + bbox.3 = bbox.3.max(lat); + }; + match geometry { + Geometry::Point(lon, lat) => grow(*lon, *lat), + Geometry::MultiPoint(pts) | Geometry::LineString(pts) => { + pts.iter().for_each(|&(lon, lat)| grow(lon, lat)) + } + Geometry::MultiLineString(lines) => lines + .iter() + .flatten() + .for_each(|&(lon, lat)| grow(lon, lat)), + Geometry::Polygon(rings) => rings + .iter() + .flatten() + .for_each(|&(lon, lat)| grow(lon, lat)), + Geometry::MultiPolygon(polys) => polys + .iter() + .flatten() + .flatten() + .for_each(|&(lon, lat)| grow(lon, lat)), + } + if bbox.0.is_finite() { + Some(bbox) + } else { + None + } +} + +/// Exterior ring of the largest polygon part, by bbox area. +fn exterior_ring(geometry: &Geometry) -> Option> { + match geometry { + Geometry::Polygon(rings) => rings.first().cloned(), + Geometry::MultiPolygon(polys) => polys + .iter() + .filter_map(|rings| rings.first()) + .max_by(|a, b| { + let area = |ring: &[(f64, f64)]| { + let bb = geometry_bbox(&Geometry::LineString(ring.to_vec())) + .unwrap_or((0.0, 0.0, 0.0, 0.0)); + (bb.2 - bb.0) * (bb.3 - bb.1) + }; + area(a).total_cmp(&area(b)) + }) + .cloned(), + _ => None, + } +} + +fn simplify_ring(ring: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out: Vec<(f64, f64)> = Vec::new(); + let tol2 = RING_TOLERANCE_DEG * RING_TOLERANCE_DEG; + for &pt in ring { + if let Some(&last) = out.last() { + let dx = pt.0 - last.0; + let dy = pt.1 - last.1; + if dx * dx + dy * dy < tol2 { + continue; + } + } + out.push(pt); + } + if out.len() > MAX_RING_POINTS { + let step = out.len().div_ceil(MAX_RING_POINTS); + let mut thinned: Vec<(f64, f64)> = + out.iter().step_by(step).copied().collect(); + if thinned.last() != out.last() { + thinned.push(*out.last().unwrap()); + } + return thinned; + } + out +} diff --git a/libs/geodata/src/spool.rs b/libs/geodata/src/spool.rs new file mode 100644 index 000000000..c2886c196 --- /dev/null +++ b/libs/geodata/src/spool.rs @@ -0,0 +1,309 @@ +//! Disk-spooling tiler for layers too large for the in-memory `Tileset` +//! (BAG: ~11M polygons). One pass clips features and appends compact records +//! to one spool file per (zoom, 256x256 block); NL covers only a handful of +//! blocks per zoom, so the second pass loads one block at a time — in the +//! mbtiles writer's required order — and encodes its tiles. Peak memory is +//! one block, not the whole country. + +use crate::mvt::{AttrVal, GeomType, PreFeature, TileEnc}; +use crate::tiler::{ + create_writer, empty_lonlat_bounds, geometry_to_tiles, gzip_tile, note_fields, + TilesetConfig, TilesetStats, +}; +use crate::wkb::Geometry; +use std::collections::{BTreeMap, HashMap}; +use std::io::{BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; + +pub struct SpoolTiler { + dir: PathBuf, + zmin: u8, + zmax: u8, + writers: HashMap<(u8, u32, u32), BufWriter>, + strings: Vec, + string_index: HashMap, + fields: HashMap>, + bounds: (f64, f64, f64, f64), + features_in: u64, + tile_features: u64, + sidecar: crate::sidecar::SidecarBuilder, + ring_layers: Vec, +} + +impl SpoolTiler { + pub fn new(dir: &Path, zmin: u8, zmax: u8) -> Result { + if dir.exists() { + std::fs::remove_dir_all(dir).map_err(|e| format!("clear spool dir: {e}"))?; + } + std::fs::create_dir_all(dir).map_err(|e| format!("create spool dir: {e}"))?; + Ok(SpoolTiler { + dir: dir.to_path_buf(), + zmin, + zmax, + writers: HashMap::new(), + strings: Vec::new(), + string_index: HashMap::new(), + fields: HashMap::new(), + bounds: empty_lonlat_bounds(), + features_in: 0, + tile_features: 0, + sidecar: crate::sidecar::SidecarBuilder::new(), + ring_layers: Vec::new(), + }) + } + + /// See [`crate::tiler::Tileset::query_rings`]. + pub fn query_rings(&mut self, layers: &[&str]) { + self.ring_layers = layers.iter().map(|s| s.to_string()).collect(); + } + + fn intern(&mut self, s: &str) -> u32 { + if let Some(&id) = self.string_index.get(s) { + return id; + } + let id = self.strings.len() as u32; + self.strings.push(s.to_string()); + self.string_index.insert(s.to_string(), id); + id + } + + pub fn add( + &mut self, + layer: &str, + geometry: &Geometry, + attrs: &[(String, AttrVal)], + ) -> Result<(), String> { + self.features_in += 1; + note_fields(&mut self.fields, layer, attrs); + let want_ring = self.ring_layers.iter().any(|l| l == layer); + self.sidecar.add(layer, geometry, attrs, want_ring); + let layer_id = self.intern(layer); + + // Pre-encode attrs once per source feature (shared by all its tiles). + let mut attr_buf = Vec::new(); + write_varint(attrs.len() as u64, &mut attr_buf); + for (key, value) in attrs { + let key_id = self.intern(key); + write_varint(u64::from(key_id), &mut attr_buf); + match value { + AttrVal::Int(i) => { + attr_buf.push(0); + write_varint(zigzag64(*i), &mut attr_buf); + } + AttrVal::Float(f) => { + attr_buf.push(1); + attr_buf.extend_from_slice(&f.to_le_bytes()); + } + AttrVal::Str(s) => { + attr_buf.push(2); + let sid = self.intern(s); + write_varint(u64::from(sid), &mut attr_buf); + } + AttrVal::Bool(b) => { + attr_buf.push(3); + attr_buf.push(u8::from(*b)); + } + } + } + + let zmin = self.zmin; + let zmax = self.zmax; + let mut emitted: Vec<(u8, u32, u32, PreFeature)> = Vec::new(); + geometry_to_tiles(geometry, zmin, zmax, &[], &mut self.bounds, &mut |z, x, y, f| { + emitted.push((z, x, y, f)); + }); + for (zoom, x, y, feature) in emitted { + self.tile_features += 1; + let block = (zoom, x >> 8, y >> 8); + let writer = match self.writers.entry(block) { + std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), + std::collections::hash_map::Entry::Vacant(e) => { + let path = self + .dir + .join(format!("z{}-bx{}-by{}.spool", block.0, block.1, block.2)); + let file = std::fs::File::create(&path) + .map_err(|e| format!("create {}: {e}", path.display()))?; + e.insert(BufWriter::with_capacity(1 << 20, file)) + } + }; + let local = (((y & 255) << 8) | (x & 255)) as u16; + let mut rec = Vec::with_capacity(feature.commands.len() * 3 + attr_buf.len() + 8); + rec.extend_from_slice(&local.to_le_bytes()); + write_varint(u64::from(layer_id), &mut rec); + rec.push(feature.geom_type as u8); + write_varint(feature.commands.len() as u64, &mut rec); + for c in &feature.commands { + write_varint(u64::from(*c), &mut rec); + } + rec.extend_from_slice(&attr_buf); + let mut framed = Vec::with_capacity(rec.len() + 4); + write_varint(rec.len() as u64, &mut framed); + framed.extend_from_slice(&rec); + writer + .write_all(&framed) + .map_err(|e| format!("spool write: {e}"))?; + } + Ok(()) + } + + pub fn finish(mut self, out_path: &Path, config: &TilesetConfig) -> Result { + let mut blocks: Vec<(u8, u32, u32)> = self.writers.keys().copied().collect(); + for (_, writer) in self.writers.iter_mut() { + writer.flush().map_err(|e| format!("spool flush: {e}"))?; + } + self.writers.clear(); + // Writer rowid order: zoom asc, then block row-major, then local + // row-major (handled by the BTreeMap below). + blocks.sort_by_key(|&(z, bx, by)| (z, by, bx)); + eprintln!(" spool: {} blocks, {} tile-features", blocks.len(), self.tile_features); + + let mut writer = create_writer(out_path, config, &self.fields, self.bounds)?; + let sidecar = std::mem::take(&mut self.sidecar); + let feature_rows = sidecar.write(&mut writer)?; + eprintln!(" sidecar: {feature_rows} queryable features"); + let mut stats = TilesetStats { + features_in: self.features_in, + tile_features: self.tile_features, + ..Default::default() + }; + for (zoom, bx, by) in blocks { + let path = self + .dir + .join(format!("z{zoom}-bx{bx}-by{by}.spool")); + let mut data = Vec::new(); + std::fs::File::open(&path) + .and_then(|mut f| f.read_to_end(&mut data)) + .map_err(|e| format!("read {}: {e}", path.display()))?; + + let mut tiles: BTreeMap> = BTreeMap::new(); + let mut pos = 0usize; + while pos < data.len() { + let (rec_len, n) = read_varint(&data[pos..]).ok_or("corrupt spool")?; + pos += n; + let rec = &data[pos..pos + rec_len as usize]; + pos += rec_len as usize; + + let local = u16::from_le_bytes([rec[0], rec[1]]); + let mut p = 2usize; + let (layer_id, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let geom_type = match rec[p] { + 1 => GeomType::Point, + 2 => GeomType::Line, + 3 => GeomType::Polygon, + _ => return Err("corrupt spool geom type".into()), + }; + p += 1; + let (n_cmds, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let mut commands = Vec::with_capacity(n_cmds as usize); + for _ in 0..n_cmds { + let (c, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + commands.push(c as u32); + } + let (n_attrs, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let mut attrs = Vec::with_capacity(n_attrs as usize); + for _ in 0..n_attrs { + let (key_id, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + let key = self.strings[key_id as usize].clone(); + let tag = rec[p]; + p += 1; + let value = match tag { + 0 => { + let (v, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + AttrVal::Int(unzigzag64(v)) + } + 1 => { + let bytes: [u8; 8] = + rec[p..p + 8].try_into().map_err(|_| "corrupt spool")?; + p += 8; + AttrVal::Float(f64::from_le_bytes(bytes)) + } + 2 => { + let (sid, n) = read_varint(&rec[p..]).ok_or("corrupt spool")?; + p += n; + AttrVal::Str(self.strings[sid as usize].clone()) + } + 3 => { + let v = rec[p] != 0; + p += 1; + AttrVal::Bool(v) + } + _ => return Err("corrupt spool attr tag".into()), + }; + attrs.push((key, value)); + } + tiles.entry(local).or_default().push(( + layer_id as u32, + PreFeature { + geom_type, + commands, + attrs, + }, + )); + } + drop(data); + + for (local, features) in tiles { + let lx = u32::from(local & 255); + let ly = u32::from(local >> 8); + let mut enc = TileEnc::new(); + for (layer_id, feature) in &features { + enc.add_feature(&self.strings[*layer_id as usize], feature); + } + let tile_data = gzip_tile(&enc.encode())?; + let x = (bx << 8) | lx; + let y = (by << 8) | ly; + writer + .write_tile_xyz(zoom, x, y, &tile_data) + .map_err(|e| format!("write tile z{zoom}/{x}/{y}: {e:?}"))?; + stats.tiles += 1; + stats.bytes += tile_data.len() as u64; + } + let _ = std::fs::remove_file(&path); + } + writer.finish().map_err(|e| format!("finish mbtiles: {e:?}"))?; + let _ = std::fs::remove_dir_all(&self.dir); + Ok(stats) + } +} + +fn zigzag64(v: i64) -> u64 { + ((v << 1) ^ (v >> 63)) as u64 +} + +fn unzigzag64(v: u64) -> i64 { + ((v >> 1) as i64) ^ -((v & 1) as i64) +} + +fn write_varint(mut value: u64, out: &mut Vec) { + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } +} + +fn read_varint(data: &[u8]) -> Option<(u64, usize)> { + let mut value = 0u64; + let mut shift = 0u32; + for (i, &byte) in data.iter().enumerate() { + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some((value, i + 1)); + } + shift += 7; + if shift > 63 { + return None; + } + } + None +} diff --git a/libs/geodata/src/tiff.rs b/libs/geodata/src/tiff.rs new file mode 100644 index 000000000..243c9d39b --- /dev/null +++ b/libs/geodata/src/tiff.rs @@ -0,0 +1,535 @@ +//! GeoTIFF subset reader for the raster sources we actually use (Copernicus +//! GLO-30 COGs, JRC flood hazard, RIVM noise): classic TIFF, single image +//! (first IFD), tiled or striped, compression none/LZW/deflate, predictor +//! 1/2/3, sample formats uint/int/float 8-64 bit, single band (extra bands +//! ignored). Everything is surfaced as f32 with an optional nodata value. + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +pub struct Tiff { + file: std::fs::File, + le: bool, + pub width: u32, + pub height: u32, + bits: u16, + sample_format: u16, + samples_per_pixel: u16, + compression: u16, + predictor: u16, + tiled: bool, + pub block_w: u32, + pub block_h: u32, + offsets: Vec, + counts: Vec, + /// (origin_x, origin_y, scale_x, scale_y) in model (geo) coordinates; + /// pixel (0,0) top-left corner maps to (origin_x, origin_y), y decreasing. + pub geo: Option<(f64, f64, f64, f64)>, + pub nodata: Option, + cache: HashMap>, + cache_order: Vec, +} + +const CACHE_BLOCKS: usize = 64; + +impl Tiff { + pub fn open(path: &Path) -> Result { + let mut file = + std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + let mut header = [0u8; 8]; + file.read_exact(&mut header) + .map_err(|e| format!("tiff header: {e}"))?; + let le = match &header[0..2] { + b"II" => true, + b"MM" => false, + _ => return Err("not a tiff".into()), + }; + let magic = read_u16(&header[2..4], le); + if magic == 43 { + return Err("BigTIFF not supported yet".into()); + } + if magic != 42 { + return Err("bad tiff magic".into()); + } + let ifd_offset = u64::from(read_u32(&header[4..8], le)); + + let mut tags: HashMap)> = HashMap::new(); + file.seek(SeekFrom::Start(ifd_offset)) + .map_err(|e| format!("seek ifd: {e}"))?; + let mut count_buf = [0u8; 2]; + file.read_exact(&mut count_buf) + .map_err(|e| format!("ifd count: {e}"))?; + let entry_count = read_u16(&count_buf, le); + let mut entries = vec![0u8; entry_count as usize * 12]; + file.read_exact(&mut entries) + .map_err(|e| format!("ifd entries: {e}"))?; + for chunk in entries.chunks_exact(12) { + let tag = read_u16(&chunk[0..2], le); + let field_type = read_u16(&chunk[2..4], le); + let count = u64::from(read_u32(&chunk[4..8], le)); + let type_size = tiff_type_size(field_type); + let total = count * type_size; + let inline = &chunk[8..12]; + let data = if total <= 4 { + inline[..total.min(4) as usize].to_vec() + } else { + let offset = u64::from(read_u32(inline, le)); + let mut buf = vec![0u8; total as usize]; + file.seek(SeekFrom::Start(offset)) + .map_err(|e| format!("seek tag {tag}: {e}"))?; + file.read_exact(&mut buf) + .map_err(|e| format!("read tag {tag}: {e}"))?; + buf + }; + tags.insert(tag, (field_type, count, data)); + } + + let scalar = |tag: u16| -> Option { + let (t, _, data) = tags.get(&tag)?; + read_tag_values(*t, data, le).first().copied().map(|v| v as u64) + }; + let values = |tag: u16| -> Vec { + tags.get(&tag) + .map(|(t, _, data)| { + read_tag_values(*t, data, le) + .into_iter() + .map(|v| v as u64) + .collect() + }) + .unwrap_or_default() + }; + let doubles = |tag: u16| -> Vec { + tags.get(&tag) + .map(|(t, _, data)| read_tag_doubles(*t, data, le)) + .unwrap_or_default() + }; + + let width = scalar(256).ok_or("no width")? as u32; + let height = scalar(257).ok_or("no height")? as u32; + let bits = scalar(258).unwrap_or(8) as u16; + let compression = scalar(259).unwrap_or(1) as u16; + let samples_per_pixel = scalar(277).unwrap_or(1) as u16; + let sample_format = scalar(339).unwrap_or(1) as u16; + let predictor = scalar(317).unwrap_or(1) as u16; + + let (tiled, block_w, block_h, offsets, counts) = if tags.contains_key(&322) { + ( + true, + scalar(322).unwrap_or(256) as u32, + scalar(323).unwrap_or(256) as u32, + values(324), + values(325), + ) + } else { + let rows_per_strip = scalar(278).unwrap_or(u64::from(height)) as u32; + (false, width, rows_per_strip, values(273), values(279)) + }; + if offsets.is_empty() { + return Err("tiff has no data blocks".into()); + } + + // GeoTIFF georeferencing: ModelPixelScale (33550) + ModelTiepoint (33922). + let scale = doubles(33550); + let tiepoint = doubles(33922); + let geo = if scale.len() >= 2 && tiepoint.len() >= 6 { + let origin_x = tiepoint[3] - tiepoint[0] * scale[0]; + let origin_y = tiepoint[4] + tiepoint[1] * scale[1]; + Some((origin_x, origin_y, scale[0], scale[1])) + } else { + None + }; + // GDAL_NODATA (42113) is ASCII. + let nodata = tags.get(&42113).and_then(|(_, _, data)| { + std::str::from_utf8(data) + .ok()? + .trim_end_matches('\0') + .trim() + .parse() + .ok() + }); + + Ok(Tiff { + file, + le, + width, + height, + bits, + sample_format, + samples_per_pixel, + compression, + predictor, + tiled, + block_w, + block_h, + offsets, + counts, + geo, + nodata, + cache: HashMap::new(), + cache_order: Vec::new(), + }) + } + + pub fn blocks_across(&self) -> u32 { + self.width.div_ceil(self.block_w) + } + pub fn blocks_down(&self) -> u32 { + self.height.div_ceil(self.block_h) + } + + /// Sample the first band at pixel coords; None outside or nodata. + pub fn sample(&mut self, x: i64, y: i64) -> Option { + if x < 0 || y < 0 || x >= i64::from(self.width) || y >= i64::from(self.height) { + return None; + } + let (x, y) = (x as u32, y as u32); + let block_index = if self.tiled { + (y / self.block_h) * self.blocks_across() + (x / self.block_w) + } else { + y / self.block_h + }; + let block_w = self.block_w as usize; + let local_x = (x % self.block_w) as usize; + let local_y = (y % self.block_h) as usize; + let block = self.block(block_index).ok()?; + let value = *block.get(local_y * block_w + local_x)?; + if let Some(nodata) = self.nodata { + if (f64::from(value) - nodata).abs() < 1e-6 || value.is_nan() { + return None; + } + } else if value.is_nan() { + return None; + } + Some(value) + } + + /// Sample at model (geo) coordinates with bilinear interpolation. + pub fn sample_geo(&mut self, gx: f64, gy: f64) -> Option { + let (ox, oy, sx, sy) = self.geo?; + let fx = (gx - ox) / sx - 0.5; + let fy = (oy - gy) / sy - 0.5; + let x0 = fx.floor() as i64; + let y0 = fy.floor() as i64; + let tx = (fx - x0 as f64) as f32; + let ty = (fy - y0 as f64) as f32; + let p00 = self.sample(x0, y0); + let p10 = self.sample(x0 + 1, y0); + let p01 = self.sample(x0, y0 + 1); + let p11 = self.sample(x0 + 1, y0 + 1); + match (p00, p10, p01, p11) { + (Some(a), Some(b), Some(c), Some(d)) => { + Some(a * (1.0 - tx) * (1.0 - ty) + b * tx * (1.0 - ty) + + c * (1.0 - tx) * ty + d * tx * ty) + } + // Fall back to nearest if some corners are nodata. + _ => self.sample(fx.round() as i64, fy.round() as i64), + } + } + + fn block(&mut self, index: u32) -> Result<&Vec, String> { + if !self.cache.contains_key(&index) { + let data = self.decode_block(index)?; + if self.cache.len() >= CACHE_BLOCKS { + if let Some(evict) = self.cache_order.first().copied() { + self.cache.remove(&evict); + self.cache_order.remove(0); + } + } + self.cache.insert(index, data); + self.cache_order.push(index); + } + Ok(self.cache.get(&index).unwrap()) + } + + fn decode_block(&mut self, index: u32) -> Result, String> { + let offset = *self.offsets.get(index as usize).ok_or("block index oob")?; + let count = *self.counts.get(index as usize).ok_or("block index oob")? as usize; + let mut raw = vec![0u8; count]; + self.file + .seek(SeekFrom::Start(offset)) + .map_err(|e| format!("seek block: {e}"))?; + self.file + .read_exact(&mut raw) + .map_err(|e| format!("read block: {e}"))?; + + let rows = if self.tiled { + self.block_h as usize + } else { + // last strip may be short + let strip_row = index * self.block_h; + (self.height - strip_row).min(self.block_h) as usize + }; + let sample_bytes = usize::from(self.bits / 8); + let pixel_bytes = sample_bytes * usize::from(self.samples_per_pixel); + let row_bytes = self.block_w as usize * pixel_bytes; + let expected = rows * row_bytes; + + let mut data = match self.compression { + 1 => raw, + 8 | 32946 => { + let mut out = Vec::with_capacity(expected); + flate2::read::ZlibDecoder::new(&raw[..]) + .read_to_end(&mut out) + .map_err(|e| format!("tiff deflate: {e}"))?; + out + } + 5 => lzw_decode(&raw, expected)?, + other => return Err(format!("unsupported tiff compression {other}")), + }; + if data.len() < expected { + return Err(format!( + "tiff block short: {} < {expected}", + data.len() + )); + } + data.truncate(expected); + + match self.predictor { + 1 => {} + 2 => { + // horizontal differencing per row, per sample channel + for row in data.chunks_exact_mut(row_bytes) { + match sample_bytes { + 1 => { + for i in pixel_bytes..row.len() { + row[i] = row[i].wrapping_add(row[i - pixel_bytes]); + } + } + 2 => { + for i in (pixel_bytes..row.len()).step_by(2) { + let prev = read_u16(&row[i - pixel_bytes..], self.le); + let cur = read_u16(&row[i..], self.le); + let sum = cur.wrapping_add(prev); + let bytes = if self.le { + sum.to_le_bytes() + } else { + sum.to_be_bytes() + }; + row[i..i + 2].copy_from_slice(&bytes); + } + } + 4 => { + for i in (pixel_bytes..row.len()).step_by(4) { + let prev = read_u32(&row[i - pixel_bytes..], self.le); + let cur = read_u32(&row[i..], self.le); + let sum = cur.wrapping_add(prev); + let bytes = if self.le { + sum.to_le_bytes() + } else { + sum.to_be_bytes() + }; + row[i..i + 4].copy_from_slice(&bytes); + } + } + _ => return Err("predictor 2 with odd sample size".into()), + } + } + } + 3 => { + // floating-point predictor: per row, bytes are stored + // plane-separated and horizontally differenced. + let mut fixed = vec![0u8; data.len()]; + for (row_index, row) in data.chunks_exact(row_bytes).enumerate() { + let mut undiff = row.to_vec(); + for i in 1..undiff.len() { + undiff[i] = undiff[i].wrapping_add(undiff[i - 1]); + } + let out_row = + &mut fixed[row_index * row_bytes..(row_index + 1) * row_bytes]; + let n = self.block_w as usize * usize::from(self.samples_per_pixel); + for pixel in 0..n { + for byte in 0..sample_bytes { + // planes are stored big-endian-first + out_row[pixel * sample_bytes + byte] = + undiff[byte * n + pixel]; + } + } + } + // predictor-3 output is big-endian IEEE floats by definition + let mut values = Vec::with_capacity(rows * self.block_w as usize); + for pixel in fixed.chunks_exact(pixel_bytes) { + values.push(f32::from_be_bytes( + pixel[0..4].try_into().map_err(|_| "pred3 not f32")?, + )); + } + return Ok(self.pad_block(values, rows)); + } + other => return Err(format!("unsupported tiff predictor {other}")), + } + + // Convert first band to f32. + let mut values = Vec::with_capacity(rows * self.block_w as usize); + for pixel in data.chunks_exact(pixel_bytes) { + let sample = &pixel[0..sample_bytes]; + let value = match (self.sample_format, self.bits) { + (1, 8) => f32::from(sample[0]), + (1, 16) => f32::from(read_u16(sample, self.le)), + (1, 32) => read_u32(sample, self.le) as f32, + (2, 8) => f32::from(sample[0] as i8), + (2, 16) => f32::from(read_u16(sample, self.le) as i16), + (2, 32) => (read_u32(sample, self.le) as i32) as f32, + (3, 32) => { + let bits = read_u32(sample, self.le); + f32::from_bits(bits) + } + (3, 64) => { + let mut b = [0u8; 8]; + b.copy_from_slice(&pixel[0..8]); + let bits = if self.le { + u64::from_le_bytes(b) + } else { + u64::from_be_bytes(b) + }; + f64::from_bits(bits) as f32 + } + (f, b) => return Err(format!("unsupported sample format {f}/{b}")), + }; + values.push(value); + } + Ok(self.pad_block(values, rows)) + } + + /// Pad a short (edge) block to full block_w*block_h with NaN. + fn pad_block(&self, values: Vec, rows: usize) -> Vec { + let full = self.block_w as usize * self.block_h as usize; + if values.len() >= full { + return values; + } + let mut padded = values; + padded.resize(rows * self.block_w as usize, f32::NAN); + padded.resize(full, f32::NAN); + padded + } +} + +/// TIFF-flavor LZW (MSB-first bit order, early code-size change). +fn lzw_decode(input: &[u8], expected: usize) -> Result, String> { + const CLEAR: u16 = 256; + const EOI: u16 = 257; + let mut out = Vec::with_capacity(expected); + let mut dict: Vec> = Vec::new(); + let reset_dict = |dict: &mut Vec>| { + dict.clear(); + for i in 0..258u16 { + dict.push(if i < 256 { vec![i as u8] } else { Vec::new() }); + } + }; + reset_dict(&mut dict); + let mut code_size = 9u32; + let mut bit_pos = 0usize; + let mut prev: Option = None; + + let read_code = |bit_pos: &mut usize, code_size: u32| -> Option { + let mut code = 0u32; + for _ in 0..code_size { + let byte = *input.get(*bit_pos / 8)?; + let bit = (byte >> (7 - (*bit_pos % 8))) & 1; + code = (code << 1) | u32::from(bit); + *bit_pos += 1; + } + Some(code as u16) + }; + + while out.len() < expected { + let Some(code) = read_code(&mut bit_pos, code_size) else { + break; + }; + if code == EOI { + break; + } + if code == CLEAR { + reset_dict(&mut dict); + code_size = 9; + prev = None; + continue; + } + let entry = if (code as usize) < dict.len() && !(code != CLEAR && dict[code as usize].is_empty() && code >= 258) { + dict[code as usize].clone() + } else if let Some(p) = prev { + let mut e = dict[p as usize].clone(); + e.push(dict[p as usize][0]); + e + } else { + return Err("lzw: bad first code".into()); + }; + out.extend_from_slice(&entry); + if let Some(p) = prev { + let mut new_entry = dict[p as usize].clone(); + new_entry.push(entry[0]); + dict.push(new_entry); + } + prev = Some(code); + // TIFF early change: bump code size one code early. + if dict.len() + 1 >= (1 << code_size) && code_size < 12 { + code_size += 1; + } + } + Ok(out) +} + +fn tiff_type_size(field_type: u16) -> u64 { + match field_type { + 1 | 2 | 6 | 7 => 1, // byte, ascii, sbyte, undefined + 3 | 8 => 2, // short + 4 | 9 | 11 => 4, // long, slong, float + 5 | 10 | 12 => 8, // rational, srational, double + _ => 1, + } +} + +fn read_tag_values(field_type: u16, data: &[u8], le: bool) -> Vec { + let size = tiff_type_size(field_type) as usize; + data.chunks_exact(size) + .map(|chunk| match field_type { + 1 | 2 | 6 | 7 => f64::from(chunk[0]), + 3 | 8 => f64::from(read_u16(chunk, le)), + 4 | 9 => f64::from(read_u32(chunk, le)), + 11 => { + let bits = read_u32(chunk, le); + f64::from(f32::from_bits(bits)) + } + 12 => { + let mut b = [0u8; 8]; + b.copy_from_slice(chunk); + f64::from_bits(if le { + u64::from_le_bytes(b) + } else { + u64::from_be_bytes(b) + }) + } + 5 | 10 => { + let num = read_u32(&chunk[0..4], le); + let den = read_u32(&chunk[4..8], le); + if den == 0 { + 0.0 + } else { + f64::from(num) / f64::from(den) + } + } + _ => 0.0, + }) + .collect() +} + +fn read_tag_doubles(field_type: u16, data: &[u8], le: bool) -> Vec { + read_tag_values(field_type, data, le) +} + +fn read_u16(data: &[u8], le: bool) -> u16 { + let bytes: [u8; 2] = data[0..2].try_into().unwrap(); + if le { + u16::from_le_bytes(bytes) + } else { + u16::from_be_bytes(bytes) + } +} + +fn read_u32(data: &[u8], le: bool) -> u32 { + let bytes: [u8; 4] = data[0..4].try_into().unwrap(); + if le { + u32::from_le_bytes(bytes) + } else { + u32::from_be_bytes(bytes) + } +} diff --git a/libs/geodata/src/tiler.rs b/libs/geodata/src/tiler.rs new file mode 100644 index 000000000..68a408585 --- /dev/null +++ b/libs/geodata/src/tiler.rs @@ -0,0 +1,698 @@ +//! Feature -> tile pyramid -> .mbtiles. +//! +//! `geometry_to_tiles` turns one WGS84 geometry into per-(zoom, x, y) MVT +//! features (clip, simplify, winding). Two consumers: the in-memory `Tileset` +//! (fine up to a few million features) and `spool::SpoolTiler` (streams +//! country-scale layers through per-block disk files). Both flush through +//! `MbtilesWriter` in its required deterministic order. + +use crate::geo::{self, NormBBox}; +use crate::mvt::{command, zigzag, AttrVal, GeomType, PreFeature, TileEnc, EXTENT}; +use crate::wkb::Geometry; +use flate2::write::GzEncoder; +use flate2::Compression; +use makepad_mbtile_reader::MbtilesWriter; +use std::collections::{BTreeMap, HashMap}; +use std::io::Write; +use std::path::Path; + +/// Clip buffer around each tile, in MVT units (64 = 1.5% of the extent). +const TILE_BUFFER: f64 = 64.0; +/// Drop polygon rings smaller than this many square MVT units after scaling. +const MIN_RING_AREA: f64 = 8.0; + +pub struct TilesetConfig { + pub name: String, + pub description: String, + pub attribution: String, + pub license: String, + pub minzoom: u8, + pub maxzoom: u8, +} + +#[derive(Default)] +pub struct TilesetStats { + pub features_in: u64, + pub tile_features: u64, + pub tiles: u64, + pub bytes: u64, +} + +// --------------------------------------------------------------------------- +// Shared geometry -> tile-feature machinery +// --------------------------------------------------------------------------- + +/// Track WGS84 bounds as (min_lon, min_lat, max_lon, max_lat). +pub fn grow_lonlat_bounds(bounds: &mut (f64, f64, f64, f64), lon: f64, lat: f64) { + bounds.0 = bounds.0.min(lon); + bounds.1 = bounds.1.min(lat); + bounds.2 = bounds.2.max(lon); + bounds.3 = bounds.3.max(lat); +} + +pub fn empty_lonlat_bounds() -> (f64, f64, f64, f64) { + ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ) +} + +/// Clip/scale one WGS84 geometry into MVT features for every covered tile in +/// the zoom range, delivering them to `sink(zoom, x, y, feature)`. +pub fn geometry_to_tiles( + geometry: &Geometry, + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + match geometry { + Geometry::Point(lon, lat) => { + tile_points(&[(*lon, *lat)], zmin, zmax, attrs, lonlat_bounds, sink) + } + Geometry::MultiPoint(pts) => tile_points(pts, zmin, zmax, attrs, lonlat_bounds, sink), + Geometry::LineString(pts) => tile_lines( + std::slice::from_ref(pts), + zmin, + zmax, + attrs, + lonlat_bounds, + sink, + ), + Geometry::MultiLineString(lines) => { + tile_lines(lines, zmin, zmax, attrs, lonlat_bounds, sink) + } + Geometry::Polygon(rings) => tile_polygons( + std::slice::from_ref(rings), + zmin, + zmax, + attrs, + lonlat_bounds, + sink, + ), + Geometry::MultiPolygon(polys) => { + tile_polygons(polys, zmin, zmax, attrs, lonlat_bounds, sink) + } + } +} + +fn tile_points( + pts: &[(f64, f64)], + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + let norm: Vec<(f64, f64)> = pts + .iter() + .map(|&(lon, lat)| { + grow_lonlat_bounds(lonlat_bounds, lon, lat); + geo::wgs84_to_norm(lon, lat) + }) + .collect(); + for zoom in zmin..=zmax { + let scale = f64::from(1u32 << zoom); + let mut by_tile: HashMap<(u32, u32), Vec<(i64, i64)>> = HashMap::new(); + for &(nx, ny) in &norm { + let wx = nx * scale; + let wy = ny * scale; + let tx = (wx.floor() as i64).clamp(0, i64::from(1u32 << zoom) - 1) as u32; + let ty = (wy.floor() as i64).clamp(0, i64::from(1u32 << zoom) - 1) as u32; + let lx = ((wx - f64::from(tx)) * f64::from(EXTENT)).round() as i64; + let ly = ((wy - f64::from(ty)) * f64::from(EXTENT)).round() as i64; + by_tile.entry((tx, ty)).or_default().push((lx, ly)); + } + for ((tx, ty), pts) in by_tile { + let mut commands = Vec::with_capacity(1 + pts.len() * 2); + commands.push(command(1, pts.len() as u32)); + let (mut cx, mut cy) = (0i64, 0i64); + for (px, py) in pts { + commands.push(zigzag(px - cx)); + commands.push(zigzag(py - cy)); + cx = px; + cy = py; + } + sink( + zoom, + tx, + ty, + PreFeature { + geom_type: GeomType::Point, + commands, + attrs: attrs.to_vec(), + }, + ); + } + } +} + +fn tile_lines( + lines: &[Vec<(f64, f64)>], + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + let mut bbox = NormBBox::empty(); + let norm: Vec> = lines + .iter() + .map(|pts| { + pts.iter() + .map(|&(lon, lat)| { + grow_lonlat_bounds(lonlat_bounds, lon, lat); + let p = geo::wgs84_to_norm(lon, lat); + bbox.add(p.0, p.1); + p + }) + .collect() + }) + .collect(); + if bbox.is_empty() { + return; + } + for zoom in zmin..=zmax { + let scale = f64::from(1u32 << zoom); + let buffer = TILE_BUFFER / f64::from(EXTENT); + for (tx, ty) in tiles_covering(&bbox, zoom, buffer) { + let mut parts: Vec> = Vec::new(); + for line in &norm { + let local: Vec<(f64, f64)> = line + .iter() + .map(|&(nx, ny)| (nx * scale - f64::from(tx), ny * scale - f64::from(ty))) + .collect(); + clip_line_parts(&local, buffer, &mut parts); + } + if parts.is_empty() { + continue; + } + let mut commands = Vec::new(); + let (mut cx, mut cy) = (0i64, 0i64); + let mut any = false; + for part in &parts { + let part = dedup(part); + if part.len() < 2 { + continue; + } + any = true; + commands.push(command(1, 1)); + commands.push(zigzag(part[0].0 - cx)); + commands.push(zigzag(part[0].1 - cy)); + cx = part[0].0; + cy = part[0].1; + commands.push(command(2, (part.len() - 1) as u32)); + for &(px, py) in &part[1..] { + commands.push(zigzag(px - cx)); + commands.push(zigzag(py - cy)); + cx = px; + cy = py; + } + } + if any { + sink( + zoom, + tx, + ty, + PreFeature { + geom_type: GeomType::Line, + commands, + attrs: attrs.to_vec(), + }, + ); + } + } + } +} + +fn tile_polygons( + polys: &[Vec>], + zmin: u8, + zmax: u8, + attrs: &[(String, AttrVal)], + lonlat_bounds: &mut (f64, f64, f64, f64), + sink: &mut dyn FnMut(u8, u32, u32, PreFeature), +) { + let mut bbox = NormBBox::empty(); + let norm: Vec>> = polys + .iter() + .map(|rings| { + rings + .iter() + .map(|ring| { + ring.iter() + .map(|&(lon, lat)| { + grow_lonlat_bounds(lonlat_bounds, lon, lat); + let p = geo::wgs84_to_norm(lon, lat); + bbox.add(p.0, p.1); + p + }) + .collect() + }) + .collect() + }) + .collect(); + if bbox.is_empty() { + return; + } + for zoom in zmin..=zmax { + let scale = f64::from(1u32 << zoom); + let buffer = TILE_BUFFER / f64::from(EXTENT); + for (tx, ty) in tiles_covering(&bbox, zoom, buffer) { + let mut commands = Vec::new(); + let (mut cx, mut cy) = (0i64, 0i64); + let mut any = false; + for rings in &norm { + for (ring_index, ring) in rings.iter().enumerate() { + let local: Vec<(f64, f64)> = ring + .iter() + .map(|&(nx, ny)| { + (nx * scale - f64::from(tx), ny * scale - f64::from(ty)) + }) + .collect(); + let clipped = clip_ring(&local, buffer); + if clipped.len() < 3 { + continue; + } + let mut pts: Vec<(i64, i64)> = clipped + .iter() + .map(|&(x, y)| { + ( + (x * f64::from(EXTENT)).round() as i64, + (y * f64::from(EXTENT)).round() as i64, + ) + }) + .collect(); + if pts.len() >= 2 && pts.first() == pts.last() { + pts.pop(); + } + let pts = dedup(&pts); + if pts.len() < 3 { + continue; + } + let area2 = signed_area2(&pts); + if (area2.abs() as f64) < MIN_RING_AREA * 2.0 { + continue; + } + // MVT spec: in tile coords (y down) the exterior ring has + // positive shoelace area, interior rings negative. + let want_positive = ring_index == 0; + let ordered: Vec<(i64, i64)> = if (area2 > 0) == want_positive { + pts + } else { + pts.into_iter().rev().collect() + }; + + any = true; + commands.push(command(1, 1)); + commands.push(zigzag(ordered[0].0 - cx)); + commands.push(zigzag(ordered[0].1 - cy)); + cx = ordered[0].0; + cy = ordered[0].1; + commands.push(command(2, (ordered.len() - 1) as u32)); + for &(px, py) in &ordered[1..] { + commands.push(zigzag(px - cx)); + commands.push(zigzag(py - cy)); + cx = px; + cy = py; + } + commands.push(command(7, 1)); + } + } + if any { + sink( + zoom, + tx, + ty, + PreFeature { + geom_type: GeomType::Polygon, + commands, + attrs: attrs.to_vec(), + }, + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// In-memory tileset +// --------------------------------------------------------------------------- + +type TileKey = (u8, u32, u32); + +pub struct Tileset { + tiles: BTreeMap)>, + fields: HashMap>, + bounds: (f64, f64, f64, f64), + stats: TilesetStats, + sidecar: crate::sidecar::SidecarBuilder, + ring_layers: Vec, +} + +impl Tileset { + pub fn new() -> Self { + Tileset { + tiles: BTreeMap::new(), + fields: HashMap::new(), + bounds: empty_lonlat_bounds(), + stats: TilesetStats::default(), + sidecar: crate::sidecar::SidecarBuilder::new(), + ring_layers: Vec::new(), + } + } + + /// Layers whose features store a simplified exterior ring in the sidecar, + /// enabling exact point-in-polygon queries. + pub fn query_rings(&mut self, layers: &[&str]) { + self.ring_layers = layers.iter().map(|s| s.to_string()).collect(); + } + + /// Add any WGS84 geometry across a zoom range. + pub fn add( + &mut self, + layer: &str, + zmin: u8, + zmax: u8, + geometry: &Geometry, + attrs: &[(String, AttrVal)], + ) { + self.stats.features_in += 1; + note_fields(&mut self.fields, layer, attrs); + let want_ring = self.ring_layers.iter().any(|l| l == layer); + self.sidecar.add(layer, geometry, attrs, want_ring); + let tiles = &mut self.tiles; + let stats_tile_features = &mut self.stats.tile_features; + geometry_to_tiles( + geometry, + zmin, + zmax, + attrs, + &mut self.bounds, + &mut |zoom, x, y, feature| { + let key = geo::tile_order_key(zoom, x, y); + tiles + .entry(key) + .or_insert_with(|| ((zoom, x, y), Vec::new())) + .1 + .push((layer.to_string(), feature)); + *stats_tile_features += 1; + }, + ); + } + + /// Encode, gzip and write everything to a fresh .mbtiles file. + pub fn finish(mut self, path: &Path, config: &TilesetConfig) -> Result { + let mut writer = create_writer(path, config, &self.fields, self.bounds)?; + let sidecar = std::mem::take(&mut self.sidecar); + let feature_rows = sidecar.write(&mut writer)?; + eprintln!(" sidecar: {feature_rows} queryable features"); + for (_, ((zoom, x, y), features)) in std::mem::take(&mut self.tiles) { + let mut enc = TileEnc::new(); + for (layer_name, feature) in &features { + enc.add_feature(layer_name, feature); + } + let data = gzip_tile(&enc.encode())?; + writer + .write_tile_xyz(zoom, x, y, &data) + .map_err(|e| format!("write tile z{zoom}/{x}/{y}: {e:?}"))?; + self.stats.tiles += 1; + self.stats.bytes += data.len() as u64; + } + writer.finish().map_err(|e| format!("finish mbtiles: {e:?}"))?; + Ok(self.stats) + } +} + +// --------------------------------------------------------------------------- +// Shared writer helpers (also used by spool.rs) +// --------------------------------------------------------------------------- + +pub(crate) fn note_fields( + fields: &mut HashMap>, + layer: &str, + attrs: &[(String, AttrVal)], +) { + let layer_fields = fields.entry(layer.to_string()).or_default(); + for (key, value) in attrs { + let type_name = match value { + AttrVal::Str(_) => "String", + AttrVal::Int(_) => "Number", + AttrVal::Float(_) => "Number", + AttrVal::Bool(_) => "Boolean", + }; + layer_fields.insert(key.clone(), type_name); + } +} + +pub(crate) fn gzip_tile(raw: &[u8]) -> Result, String> { + let mut gz = GzEncoder::new(Vec::new(), Compression::new(6)); + gz.write_all(raw).map_err(|e| format!("gzip: {e}"))?; + gz.finish().map_err(|e| format!("gzip: {e}")) +} + +pub(crate) fn create_writer( + path: &Path, + config: &TilesetConfig, + fields: &HashMap>, + bounds: (f64, f64, f64, f64), +) -> Result { + let mut writer = MbtilesWriter::create(path).map_err(|e| format!("create mbtiles: {e:?}"))?; + writer.set_metadata("name", &config.name); + writer.set_metadata("description", &config.description); + writer.set_metadata("format", "pbf"); + writer.set_metadata("type", "overlay"); + writer.set_metadata("minzoom", config.minzoom.to_string()); + writer.set_metadata("maxzoom", config.maxzoom.to_string()); + writer.set_metadata("attribution", &config.attribution); + writer.set_metadata("license", &config.license); + if bounds.0.is_finite() { + writer.set_metadata( + "bounds", + format!( + "{:.6},{:.6},{:.6},{:.6}", + bounds.0, bounds.1, bounds.2, bounds.3 + ), + ); + } + let vector_layers: Vec = fields + .iter() + .map(|(layer_name, layer_fields)| { + serde_json::json!({ + "id": layer_name, + "fields": layer_fields, + "minzoom": config.minzoom, + "maxzoom": config.maxzoom, + }) + }) + .collect(); + writer.set_metadata( + "json", + serde_json::json!({ "vector_layers": vector_layers }).to_string(), + ); + writer.set_metadata( + "geodata_built_unix", + format!( + "{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + ), + ); + Ok(writer) +} + +// --------------------------------------------------------------------------- +// Clip helpers +// --------------------------------------------------------------------------- + +/// Tiles whose (buffered) extent intersects the bbox at this zoom. +fn tiles_covering(bbox: &NormBBox, zoom: u8, buffer: f64) -> Vec<(u32, u32)> { + let scale = f64::from(1u32 << zoom); + let axis = i64::from(1u32 << zoom); + let min_tx = ((bbox.min_x * scale - buffer).floor() as i64).clamp(0, axis - 1); + let max_tx = ((bbox.max_x * scale + buffer).floor() as i64).clamp(0, axis - 1); + let min_ty = ((bbox.min_y * scale - buffer).floor() as i64).clamp(0, axis - 1); + let max_ty = ((bbox.max_y * scale + buffer).floor() as i64).clamp(0, axis - 1); + let mut tiles = Vec::new(); + for ty in min_ty..=max_ty { + for tx in min_tx..=max_tx { + tiles.push((tx as u32, ty as u32)); + } + } + tiles +} + +/// Sutherland-Hodgman clip of a ring against the buffered unit square. +fn clip_ring(ring: &[(f64, f64)], buffer: f64) -> Vec<(f64, f64)> { + let lo = -buffer; + let hi = 1.0 + buffer; + let mut pts = ring.to_vec(); + if pts.len() >= 2 && pts.first() == pts.last() { + pts.pop(); + } + for &(axis_x, bound, keep_greater) in &[ + (true, lo, true), + (true, hi, false), + (false, lo, true), + (false, hi, false), + ] { + if pts.is_empty() { + return pts; + } + let inside = |p: &(f64, f64)| { + let v = if axis_x { p.0 } else { p.1 }; + if keep_greater { + v >= bound + } else { + v <= bound + } + }; + let intersect = |a: &(f64, f64), b: &(f64, f64)| -> (f64, f64) { + let (av, bv) = if axis_x { (a.0, b.0) } else { (a.1, b.1) }; + let t = if (bv - av).abs() < f64::EPSILON { + 0.0 + } else { + (bound - av) / (bv - av) + }; + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }; + let mut out = Vec::with_capacity(pts.len() + 4); + for i in 0..pts.len() { + let current = pts[i]; + let previous = pts[(i + pts.len() - 1) % pts.len()]; + let current_in = inside(¤t); + let previous_in = inside(&previous); + if current_in { + if !previous_in { + out.push(intersect(&previous, ¤t)); + } + out.push(current); + } else if previous_in { + out.push(intersect(&previous, ¤t)); + } + } + pts = out; + } + pts +} + +/// Clip a polyline to the buffered unit square, appending surviving sub-parts +/// (scaled to MVT integer units) to `parts`. +fn clip_line_parts(line: &[(f64, f64)], buffer: f64, parts: &mut Vec>) { + let lo = -buffer; + let hi = 1.0 + buffer; + let inside = |p: &(f64, f64)| p.0 >= lo && p.0 <= hi && p.1 >= lo && p.1 <= hi; + let to_units = |p: (f64, f64)| -> (i64, i64) { + ( + (p.0 * f64::from(EXTENT)).round() as i64, + (p.1 * f64::from(EXTENT)).round() as i64, + ) + }; + let mut current: Vec<(i64, i64)> = Vec::new(); + for window in line.windows(2) { + let (a, b) = (window[0], window[1]); + match (inside(&a), inside(&b)) { + (true, true) => { + if current.is_empty() { + current.push(to_units(a)); + } + current.push(to_units(b)); + } + (true, false) => { + if current.is_empty() { + current.push(to_units(a)); + } + if let Some(exit) = clip_segment(a, b, lo, hi) { + current.push(to_units(exit.1)); + } + if current.len() >= 2 { + parts.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + } + (false, true) => { + if let Some(entry) = clip_segment(a, b, lo, hi) { + current.push(to_units(entry.0)); + } + current.push(to_units(b)); + } + (false, false) => { + if let Some((entry, exit)) = clip_segment(a, b, lo, hi) { + let part = vec![to_units(entry), to_units(exit)]; + if part[0] != part[1] { + parts.push(part); + } + } + } + } + } + if current.len() >= 2 { + parts.push(current); + } +} + +/// Liang-Barsky segment clip against the buffered square. +fn clip_segment( + a: (f64, f64), + b: (f64, f64), + lo: f64, + hi: f64, +) -> Option<((f64, f64), (f64, f64))> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let mut t0 = 0.0f64; + let mut t1 = 1.0f64; + for &(p, q) in &[ + (-dx, a.0 - lo), + (dx, hi - a.0), + (-dy, a.1 - lo), + (dy, hi - a.1), + ] { + if p.abs() < f64::EPSILON { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + if t0 > t1 { + return None; + } + } + } + Some(( + (a.0 + dx * t0, a.1 + dy * t0), + (a.0 + dx * t1, a.1 + dy * t1), + )) +} + +fn dedup(pts: &[(i64, i64)]) -> Vec<(i64, i64)> { + let mut out: Vec<(i64, i64)> = Vec::with_capacity(pts.len()); + for &p in pts { + if out.last() != Some(&p) { + out.push(p); + } + } + out +} + +/// Twice the shoelace signed area on tile coordinates (y down). +/// Positive = clockwise on screen = MVT exterior ring. +fn signed_area2(pts: &[(i64, i64)]) -> i64 { + let mut sum = 0i64; + for i in 0..pts.len() { + let (x1, y1) = pts[i]; + let (x2, y2) = pts[(i + 1) % pts.len()]; + sum += x1 * y2 - x2 * y1; + } + sum +} diff --git a/libs/geodata/src/wkb.rs b/libs/geodata/src/wkb.rs new file mode 100644 index 000000000..be93c0189 --- /dev/null +++ b/libs/geodata/src/wkb.rs @@ -0,0 +1,209 @@ +//! GeoPackage geometry blob + (ISO/EWKB) WKB parser. XY only; Z/M dropped. + +#[derive(Debug, Clone)] +pub enum Geometry { + Point(f64, f64), + MultiPoint(Vec<(f64, f64)>), + LineString(Vec<(f64, f64)>), + MultiLineString(Vec>), + /// Rings: first exterior, rest holes. + Polygon(Vec>), + MultiPolygon(Vec>>), +} + +impl Geometry { + /// Apply a coordinate transform to every vertex. + pub fn map_coords(&self, f: &impl Fn(f64, f64) -> (f64, f64)) -> Geometry { + let m1 = |pts: &Vec<(f64, f64)>| pts.iter().map(|&(x, y)| f(x, y)).collect::>(); + match self { + Geometry::Point(x, y) => { + let (x, y) = f(*x, *y); + Geometry::Point(x, y) + } + Geometry::MultiPoint(p) => Geometry::MultiPoint(m1(p)), + Geometry::LineString(p) => Geometry::LineString(m1(p)), + Geometry::MultiLineString(l) => { + Geometry::MultiLineString(l.iter().map(m1).collect()) + } + Geometry::Polygon(r) => Geometry::Polygon(r.iter().map(m1).collect()), + Geometry::MultiPolygon(polys) => Geometry::MultiPolygon( + polys.iter().map(|r| r.iter().map(m1).collect()).collect(), + ), + } + } +} + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn u8(&mut self) -> Option { + let v = *self.data.get(self.pos)?; + self.pos += 1; + Some(v) + } + fn u32(&mut self, le: bool) -> Option { + let bytes: [u8; 4] = self.data.get(self.pos..self.pos + 4)?.try_into().ok()?; + self.pos += 4; + Some(if le { + u32::from_le_bytes(bytes) + } else { + u32::from_be_bytes(bytes) + }) + } + fn f64(&mut self, le: bool) -> Option { + let bytes: [u8; 8] = self.data.get(self.pos..self.pos + 8)?.try_into().ok()?; + self.pos += 8; + Some(if le { + f64::from_le_bytes(bytes) + } else { + f64::from_be_bytes(bytes) + }) + } + fn skip(&mut self, n: usize) -> Option<()> { + if self.pos + n > self.data.len() { + return None; + } + self.pos += n; + Some(()) + } +} + +/// Parse a GeoPackage geometry blob (GP header + WKB). +pub fn parse_gpkg_geometry(blob: &[u8]) -> Option { + if blob.len() < 8 || blob[0] != b'G' || blob[1] != b'P' { + return parse_wkb_geometry(blob); // tolerate raw WKB + } + let flags = blob[3]; + let header_le = flags & 0x01 != 0; + let envelope_kind = (flags >> 1) & 0x07; + if flags & 0x10 != 0 { + return None; // declared-empty geometry + } + let envelope_len = match envelope_kind { + 0 => 0, + 1 => 32, + 2 | 3 => 48, + 4 => 64, + _ => return None, + }; + let mut cur = Cursor { + data: blob, + pos: 4, + }; + let _srs_id = cur.u32(header_le)?; + cur.skip(envelope_len)?; + parse_wkb_geometry(&blob[cur.pos..]) +} + +/// Parse ISO WKB / EWKB. Returns XY geometry. +pub fn parse_wkb_geometry(data: &[u8]) -> Option { + let mut cur = Cursor { data, pos: 0 }; + parse_wkb_inner(&mut cur) +} + +fn parse_wkb_inner(cur: &mut Cursor) -> Option { + let le = match cur.u8()? { + 0 => false, + 1 => true, + _ => return None, + }; + let raw_type = cur.u32(le)?; + + // EWKB flags + let has_z_flag = raw_type & 0x8000_0000 != 0; + let has_m_flag = raw_type & 0x4000_0000 != 0; + let has_srid = raw_type & 0x2000_0000 != 0; + let base_iso = raw_type & 0x0FFF_FFFF; + // ISO encodes Z/M as +1000/+2000/+3000 + let iso_dim = base_iso / 1000; + let base = base_iso % 1000; + let has_z = has_z_flag || iso_dim == 1 || iso_dim == 3; + let has_m = has_m_flag || iso_dim == 2 || iso_dim == 3; + let extra_dims = usize::from(has_z) + usize::from(has_m); + + if has_srid { + cur.u32(le)?; + } + + let read_pt = |cur: &mut Cursor| -> Option<(f64, f64)> { + let x = cur.f64(le)?; + let y = cur.f64(le)?; + cur.skip(extra_dims * 8)?; + Some((x, y)) + }; + let read_ring = |cur: &mut Cursor| -> Option> { + let n = cur.u32(le)? as usize; + let mut pts = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + pts.push(read_pt(cur)?); + } + Some(pts) + }; + + match base { + 1 => { + let (x, y) = read_pt(cur)?; + Some(Geometry::Point(x, y)) + } + 2 => Some(Geometry::LineString(read_ring(cur)?)), + 3 => { + let n = cur.u32(le)? as usize; + let mut rings = Vec::with_capacity(n.min(1 << 16)); + for _ in 0..n { + rings.push(read_ring(cur)?); + } + Some(Geometry::Polygon(rings)) + } + 4 => { + let n = cur.u32(le)? as usize; + let mut pts = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::Point(x, y) => pts.push((x, y)), + _ => return None, + } + } + Some(Geometry::MultiPoint(pts)) + } + 5 => { + let n = cur.u32(le)? as usize; + let mut lines = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::LineString(pts) => lines.push(pts), + _ => return None, + } + } + Some(Geometry::MultiLineString(lines)) + } + 6 => { + let n = cur.u32(le)? as usize; + let mut polys = Vec::with_capacity(n.min(1 << 20)); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::Polygon(rings) => polys.push(rings), + _ => return None, + } + } + Some(Geometry::MultiPolygon(polys)) + } + 7 => { + // GeometryCollection: merge whatever is inside into a MultiPolygon + // if all polygons, else give up (none of our sources need more). + let n = cur.u32(le)? as usize; + let mut polys = Vec::new(); + for _ in 0..n { + match parse_wkb_inner(cur)? { + Geometry::Polygon(rings) => polys.push(rings), + Geometry::MultiPolygon(mut more) => polys.append(&mut more), + _ => return None, + } + } + Some(Geometry::MultiPolygon(polys)) + } + _ => None, + } +} diff --git a/libs/tesla/Cargo.toml b/libs/tesla/Cargo.toml new file mode 100644 index 000000000..d2938ce1a --- /dev/null +++ b/libs/tesla/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "makepad-tesla" +version = "0.1.0" +edition = "2021" +description = "Tesla Fleet API client on the makepad network layer" +license = "MIT OR Apache-2.0" + +[dependencies] +makepad-widgets = { path = "../../widgets" } +makepad-micro-serde = { path = "../../libs/micro_serde" } +makepad-live-id = { path = "../../libs/live_id" } diff --git a/libs/tesla/README.md b/libs/tesla/README.md new file mode 100644 index 000000000..55dcb4a13 --- /dev/null +++ b/libs/tesla/README.md @@ -0,0 +1,115 @@ +# makepad-tesla + +Event-driven Tesla Fleet API client on the makepad network layer. Purpose: poll +battery / charge state of your own car so the GPS app can do charger-aware routing. + +## One-time setup for your own car + +Tesla killed the old unofficial owner API; the official Fleet API needs a (free) +developer app registration, even for your own single car. Steps: + +### 1. Developer app + +1. Tesla account needs a verified email + multi-factor auth enabled. +2. Go to → request app access. Fill in a name and + "personal use: vehicle data for private navigation app" as purpose. +3. OAuth Grant Type: **Authorization Code and Machine-to-Machine**. +4. Allowed Origin: a domain you control (e.g. `https://n4.io/`). +5. Allowed Redirect URI: something you can read the address bar on, e.g. + `https://n4.io/tesla-callback` — the page does not need to exist, you just + copy the `?code=` out of the URL after login. +6. Scopes: enable at least `vehicle_device_data` (Vehicle Information). + `vehicle_location` if the app should also read the car's own GPS position. +7. You get a **client_id** and **client_secret**. + +### 2. Host the public key + register the partner account + +Fleet API refuses all calls until the app's domain is registered: + +```bash +# generate an EC key pair (the private key is only needed for vehicle *commands*, +# but the public half must be hosted for registration) +openssl ecparam -name prime256v1 -genkey -noout -out tesla_private.pem +openssl ec -in tesla_private.pem -pubout -out tesla_public.pem +``` + +Host `tesla_public.pem` at: + +``` +https:///.well-known/appspecific/com.tesla.3p.public-key.pem +``` + +Then register (one time, with a machine-to-machine "partner token"): + +```bash +# partner token +curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ + -d grant_type=client_credentials \ + -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \ + -d scope='openid vehicle_device_data' \ + -d audience=https://fleet-api.prd.eu.vn.cloud.tesla.com + +# register the domain (use the access_token from above) +curl -s https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/partner_accounts \ + -H "Authorization: Bearer $PARTNER_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"domain":""}' +``` + +(Use the `na` host instead of `eu` if the car is North-American.) + +### 3. Log in as yourself, get the refresh token + +Open this in a browser (fill in client_id + redirect_uri), log in with the +Tesla account that owns the car: + +``` +https://auth.tesla.com/oauth2/v3/authorize?response_type=code&client_id=&redirect_uri=&scope=openid+offline_access+vehicle_device_data+vehicle_location&state=makepad +``` + +Copy the `code=` value from the redirect URL, then exchange it: + +```bash +curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ + -d grant_type=authorization_code \ + -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \ + -d code=$CODE \ + -d redirect_uri=$REDIRECT_URI \ + -d audience=https://fleet-api.prd.eu.vn.cloud.tesla.com +``` + +The response contains `access_token` (valid 8h) and `refresh_token`. + +### 4. Credentials file + +Put the result in `tesla_credentials.json` in the repo root (it is untracked, +same pattern as `GOOGLE_API_KEY`): + +```json +{ + "client_id": "…", + "refresh_token": "…", + "region": "eu" +} +``` + +(Optionally add `"client_secret": "…"` — only needed if Tesla rejects the +token refresh without it; the library sends it when present.) + +That's all the library needs. It refreshes the access token itself and +**rewrites this file** on every refresh, because Tesla rotates refresh tokens — +don't hand-edit it afterwards, and don't reuse the same refresh token elsewhere. + +## Costs / rate limits + +Personal accounts get a small monthly usage credit (about $10). A +`vehicle_data` poll costs a fraction of a cent; polling every few minutes while +driving stays comfortably inside the free credit. Wake-ups are the expensive +call — the library never wakes the car unless explicitly asked to. + +## Library usage + +See `src/lib.rs` docs. Everything is event-driven on the makepad network layer: +you call `request_*` methods with a `&mut Cx`, and route +`Event::NetworkResponses` through `handle_event`, which yields typed +`TeslaClientAction`s. diff --git a/libs/tesla/src/client.rs b/libs/tesla/src/client.rs new file mode 100644 index 000000000..a7612ac9f --- /dev/null +++ b/libs/tesla/src/client.rs @@ -0,0 +1,533 @@ +use crate::data::*; +use makepad_micro_serde::*; +use makepad_widgets::*; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const AUTH_TOKEN_URL: &str = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token"; + +/// Refresh the access token this many seconds before it actually expires. +const TOKEN_EXPIRY_MARGIN: u64 = 120; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum TeslaRegion { + Na, + #[default] + Eu, + Cn, +} + +impl TeslaRegion { + pub fn from_str(s: &str) -> Option { + match s { + "na" => Some(Self::Na), + "eu" => Some(Self::Eu), + "cn" => Some(Self::Cn), + _ => None, + } + } + + pub fn base_url(self) -> &'static str { + match self { + Self::Na => "https://fleet-api.prd.na.vn.cloud.tesla.com", + Self::Eu => "https://fleet-api.prd.eu.vn.cloud.tesla.com", + Self::Cn => "https://fleet-api.prd.cn.vn.cloud.tesla.cn", + } + } +} + +/// The credentials file. See libs/tesla/README.md for how to obtain the values. +/// The client rewrites this file whenever Tesla rotates the refresh token, and +/// caches the short-lived access token in it across restarts. +#[derive(SerJson, DeJson, Debug, Clone)] +pub struct TeslaCredentials { + pub client_id: String, + /// Only needed if Tesla rejects refresh without it (confidential clients). + pub client_secret: Option, + pub refresh_token: String, + /// "na" | "eu" | "cn" + pub region: String, + pub access_token: Option, + /// Unix seconds after which access_token is stale. + pub access_token_expires: Option, +} + +#[derive(Debug, Clone)] +pub enum TeslaError { + Credentials(String), + Auth(String), + Http { status: u16, message: String }, + Network(String), + Parse(String), +} + +impl std::fmt::Display for TeslaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Credentials(m) => write!(f, "tesla credentials: {}", m), + Self::Auth(m) => write!(f, "tesla auth: {}", m), + Self::Http { status, message } => write!(f, "tesla http {}: {}", status, message), + Self::Network(m) => write!(f, "tesla network: {}", m), + Self::Parse(m) => write!(f, "tesla parse: {}", m), + } + } +} + +/// What the client hands back from handle_event. +#[derive(Debug, Clone)] +pub enum TeslaAction { + /// Result of request_vehicles. + Vehicles(Vec), + /// Result of request_charge_state / request_vehicle_data. + VehicleData { vin: String, data: VehicleData }, + /// Result of request_wake_up; state is e.g. "waking"/"online". + WakeUp { vin: String, state: Option }, + /// The car is asleep/offline; issue request_wake_up (costs credits) or retry later. + VehicleAsleep { vin: String }, + /// A fresh access token was obtained (and the credentials file rewritten). + TokenRefreshed, + Error(TeslaError), +} + +#[derive(Clone, Debug)] +enum ApiCall { + Vehicles, + VehicleData { vin: String, endpoints: String }, + WakeUp { vin: String }, +} + +impl ApiCall { + fn vin(&self) -> Option<&str> { + match self { + Self::Vehicles => None, + Self::VehicleData { vin, .. } | Self::WakeUp { vin } => Some(vin), + } + } +} + +#[derive(Clone, Debug)] +struct PendingCall { + call: ApiCall, + auth_retried: bool, +} + +/// Event-driven Tesla Fleet API client. +/// +/// Call the request_* methods from anywhere you have a Cx, then route every +/// event through handle_event and act on the returned TeslaActions. +/// Requests issued while the access token is stale are queued behind an +/// automatic token refresh. +pub struct TeslaClient { + creds_path: PathBuf, + creds: TeslaCredentials, + region: TeslaRegion, + refresh_id: Option, + queued: Vec, + in_flight: HashMap, +} + +impl TeslaClient { + /// Loads credentials from a JSON file (see README.md for the schema). + pub fn load(creds_path: impl Into) -> Result { + let creds_path = creds_path.into(); + let text = std::fs::read_to_string(&creds_path).map_err(|e| { + TeslaError::Credentials(format!("cannot read {}: {}", creds_path.display(), e)) + })?; + let creds = TeslaCredentials::deserialize_json_lenient(&text) + .map_err(|e| TeslaError::Credentials(format!("{}: {:?}", creds_path.display(), e)))?; + let region = TeslaRegion::from_str(&creds.region).ok_or_else(|| { + TeslaError::Credentials(format!("region must be na/eu/cn, got '{}'", creds.region)) + })?; + Ok(Self { + creds_path, + creds, + region, + refresh_id: None, + queued: Vec::new(), + in_flight: HashMap::new(), + }) + } + + pub fn region(&self) -> TeslaRegion { + self.region + } + + pub fn has_in_flight(&self) -> bool { + !self.in_flight.is_empty() || self.refresh_id.is_some() || !self.queued.is_empty() + } + + /// GET /api/1/vehicles — list vehicles on the account (vin, name, awake state). + pub fn request_vehicles(&mut self, cx: &mut Cx) { + self.submit(cx, ApiCall::Vehicles); + } + + /// Battery/charging status only — the cheapest useful poll for routing. + pub fn request_charge_state(&mut self, cx: &mut Cx, vin: &str) { + self.request_vehicle_data(cx, vin, &[VehicleDataEndpoint::ChargeState]); + } + + /// Charging status plus the car's own GPS position (needs the + /// vehicle_location scope on the developer app). + pub fn request_charge_and_location(&mut self, cx: &mut Cx, vin: &str) { + self.request_vehicle_data( + cx, + vin, + &[VehicleDataEndpoint::ChargeState, VehicleDataEndpoint::LocationData], + ); + } + + /// GET /api/1/vehicles/{vin}/vehicle_data with an explicit endpoint set. + pub fn request_vehicle_data( + &mut self, + cx: &mut Cx, + vin: &str, + endpoints: &[VehicleDataEndpoint], + ) { + let endpoints = endpoints + .iter() + .map(|e| e.as_str()) + .collect::>() + .join("%3B"); + self.submit(cx, ApiCall::VehicleData { vin: vin.to_string(), endpoints }); + } + + /// POST /api/1/vehicles/{vin}/wake_up. Costs usage credits; the car takes + /// ~10-30s to come online, poll request_charge_state afterwards. + pub fn request_wake_up(&mut self, cx: &mut Cx, vin: &str) { + self.submit(cx, ApiCall::WakeUp { vin: vin.to_string() }); + } + + /// Route all events through this; returns domain actions for the app. + pub fn handle_event(&mut self, cx: &mut Cx, event: &Event) -> Vec { + let mut out = Vec::new(); + let Event::NetworkResponses(responses) = event else { + return out; + }; + for response in responses { + match response { + NetworkResponse::HttpResponse { request_id, response } => { + if Some(*request_id) == self.refresh_id { + self.refresh_id = None; + self.handle_token_response(cx, response, &mut out); + } else if let Some(pending) = self.in_flight.remove(request_id) { + self.handle_api_response(cx, pending, response, &mut out); + } + } + NetworkResponse::HttpError { request_id, error } => { + if Some(*request_id) == self.refresh_id { + self.refresh_id = None; + self.fail_queue(&mut out, TeslaError::Network(error.message.clone())); + } else if self.in_flight.remove(request_id).is_some() { + out.push(TeslaAction::Error(TeslaError::Network(error.message.clone()))); + } + } + _ => {} + } + } + out + } + + // === internals === + + fn submit(&mut self, cx: &mut Cx, call: ApiCall) { + self.submit_pending(cx, PendingCall { call, auth_retried: false }); + } + + fn submit_pending(&mut self, cx: &mut Cx, pending: PendingCall) { + if self.token_is_fresh() { + self.send_api_call(cx, pending); + } else { + self.queued.push(pending); + self.start_token_refresh(cx); + } + } + + fn token_is_fresh(&self) -> bool { + let Some(expires) = self.creds.access_token_expires else { + return false; + }; + self.creds.access_token.is_some() && unix_now() + TOKEN_EXPIRY_MARGIN < expires + } + + fn start_token_refresh(&mut self, cx: &mut Cx) { + if self.refresh_id.is_some() { + return; + } + let mut body = format!( + "grant_type=refresh_token&client_id={}&refresh_token={}", + form_urlencode(&self.creds.client_id), + form_urlencode(&self.creds.refresh_token) + ); + if let Some(secret) = &self.creds.client_secret { + body.push_str("&client_secret="); + body.push_str(&form_urlencode(secret)); + } + let mut request = HttpRequest::new(AUTH_TOKEN_URL.to_string(), HttpMethod::POST); + request.set_header( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ); + request.set_string_body(body); + let request_id = LiveId::unique(); + self.refresh_id = Some(request_id); + cx.http_request(request_id, request); + } + + fn send_api_call(&mut self, cx: &mut Cx, pending: PendingCall) { + let base = self.region.base_url(); + let (url, method) = match &pending.call { + ApiCall::Vehicles => (format!("{}/api/1/vehicles", base), HttpMethod::GET), + ApiCall::VehicleData { vin, endpoints } => ( + format!("{}/api/1/vehicles/{}/vehicle_data?endpoints={}", base, vin, endpoints), + HttpMethod::GET, + ), + ApiCall::WakeUp { vin } => { + (format!("{}/api/1/vehicles/{}/wake_up", base, vin), HttpMethod::POST) + } + }; + let mut request = HttpRequest::new(url, method); + request.set_header( + "Authorization".to_string(), + format!("Bearer {}", self.creds.access_token.as_deref().unwrap_or("")), + ); + request.set_header("Accept".to_string(), "application/json".to_string()); + let request_id = LiveId::unique(); + self.in_flight.insert(request_id, pending); + cx.http_request(request_id, request); + } + + fn handle_token_response( + &mut self, + cx: &mut Cx, + response: &HttpResponse, + out: &mut Vec, + ) { + let body = response.get_string_body().unwrap_or_default(); + if response.status_code != 200 { + self.fail_queue( + out, + TeslaError::Auth(format!( + "token refresh failed, http {}: {}", + response.status_code, + error_excerpt(&body) + )), + ); + return; + } + let token = match TokenResponse::deserialize_json_lenient(&body) { + Ok(t) => t, + Err(e) => { + self.fail_queue(out, TeslaError::Parse(format!("token response: {:?}", e))); + return; + } + }; + let Some(access_token) = token.access_token else { + self.fail_queue( + out, + TeslaError::Auth(token.error_description.or(token.error).unwrap_or_else(|| { + "token response missing access_token".to_string() + })), + ); + return; + }; + self.creds.access_token = Some(access_token); + self.creds.access_token_expires = Some(unix_now() + token.expires_in.unwrap_or(28800)); + // Tesla rotates refresh tokens: persist the new one or lose access. + if let Some(refresh_token) = token.refresh_token { + self.creds.refresh_token = refresh_token; + } + if let Err(e) = std::fs::write(&self.creds_path, self.creds.serialize_json()) { + out.push(TeslaAction::Error(TeslaError::Credentials(format!( + "cannot rewrite {}: {} — the rotated refresh token only lives in memory now", + self.creds_path.display(), + e + )))); + } + out.push(TeslaAction::TokenRefreshed); + for pending in std::mem::take(&mut self.queued) { + self.send_api_call(cx, pending); + } + } + + fn handle_api_response( + &mut self, + cx: &mut Cx, + pending: PendingCall, + response: &HttpResponse, + out: &mut Vec, + ) { + let body = response.get_string_body().unwrap_or_default(); + match response.status_code { + 200 => self.parse_api_body(pending, &body, out), + 401 if !pending.auth_retried => { + // Stale/revoked access token: force a refresh and retry once. + self.creds.access_token = None; + self.creds.access_token_expires = None; + self.submit_pending(cx, PendingCall { auth_retried: true, ..pending }); + } + 408 => { + if let Some(vin) = pending.call.vin() { + out.push(TeslaAction::VehicleAsleep { vin: vin.to_string() }); + } else { + out.push(TeslaAction::Error(TeslaError::Http { + status: 408, + message: error_excerpt(&body), + })); + } + } + status => out.push(TeslaAction::Error(TeslaError::Http { + status, + message: error_excerpt(&body), + })), + } + } + + fn parse_api_body(&mut self, pending: PendingCall, body: &str, out: &mut Vec) { + match &pending.call { + ApiCall::Vehicles => match VehiclesResponse::deserialize_json_lenient(body) { + Ok(parsed) => match parsed.response { + Some(vehicles) => out.push(TeslaAction::Vehicles(vehicles)), + None => out.push(TeslaAction::Error(TeslaError::Http { + status: 200, + message: parsed.error.unwrap_or_else(|| "empty vehicle list response".to_string()), + })), + }, + Err(e) => out.push(TeslaAction::Error(TeslaError::Parse(format!("vehicles: {:?}", e)))), + }, + ApiCall::VehicleData { vin, .. } => { + match VehicleDataResponse::deserialize_json_lenient(body) { + Ok(parsed) => match parsed.response { + Some(data) => { + let vin = data.vin.clone().unwrap_or_else(|| vin.clone()); + out.push(TeslaAction::VehicleData { vin, data }); + } + None => out.push(TeslaAction::Error(TeslaError::Http { + status: 200, + message: parsed + .error + .unwrap_or_else(|| "empty vehicle_data response".to_string()), + })), + }, + Err(e) => out.push(TeslaAction::Error(TeslaError::Parse(format!( + "vehicle_data: {:?}", + e + )))), + } + } + ApiCall::WakeUp { vin } => match WakeUpResponse::deserialize_json_lenient(body) { + Ok(parsed) => out.push(TeslaAction::WakeUp { + vin: vin.clone(), + state: parsed.response.and_then(|v| v.state), + }), + Err(e) => out.push(TeslaAction::Error(TeslaError::Parse(format!("wake_up: {:?}", e)))), + }, + } + } + + fn fail_queue(&mut self, out: &mut Vec, error: TeslaError) { + self.queued.clear(); + out.push(TeslaAction::Error(error)); + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn form_urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +fn error_excerpt(body: &str) -> String { + if let Ok(parsed) = ErrorBody::deserialize_json_lenient(body) { + if let Some(error) = parsed.error { + return match parsed.error_description { + Some(desc) if !desc.is_empty() => format!("{}: {}", error, desc), + _ => error, + }; + } + } + let mut excerpt: String = body.chars().take(200).collect(); + if excerpt.is_empty() { + excerpt.push_str("(empty body)"); + } + excerpt +} + +/// Loads credentials, looking upward from the working directory so apps run +/// from example subdirectories still find the repo-root file. +pub fn load_credentials_search(file_name: &str) -> Result { + let mut dir = std::env::current_dir() + .map_err(|e| TeslaError::Credentials(format!("current_dir: {}", e)))?; + loop { + let candidate = dir.join(file_name); + if candidate.is_file() { + return TeslaClient::load(candidate); + } + if !dir.pop() { + return Err(TeslaError::Credentials(format!( + "{} not found in working directory or any parent — see libs/tesla/README.md", + file_name + ))); + } + } +} + +impl TeslaClient { + /// Convenience: loads `tesla_credentials.json` from the working directory + /// or any parent (repo root when running via cargo). + pub fn load_default() -> Result { + load_credentials_search("tesla_credentials.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_urlencode_escapes() { + assert_eq!(form_urlencode("abc-XYZ_0.~"), "abc-XYZ_0.~"); + assert_eq!(form_urlencode("a b+c/d="), "a%20b%2Bc%2Fd%3D"); + } + + #[test] + fn credentials_roundtrip() { + let creds = TeslaCredentials { + client_id: "abcd-1234".to_string(), + client_secret: None, + refresh_token: "NA_deadbeef".to_string(), + region: "eu".to_string(), + access_token: None, + access_token_expires: None, + }; + let json = creds.serialize_json(); + let back = TeslaCredentials::deserialize_json_lenient(&json).unwrap(); + assert_eq!(back.client_id, creds.client_id); + assert_eq!(back.refresh_token, creds.refresh_token); + assert_eq!(back.region, "eu"); + assert!(back.access_token.is_none()); + } + + #[test] + fn token_response_parse() { + let json = r#"{"access_token":"at","refresh_token":"rt","id_token":"x","expires_in":28800,"token_type":"Bearer"}"#; + let t = TokenResponse::deserialize_json_lenient(json).unwrap(); + assert_eq!(t.access_token.as_deref(), Some("at")); + assert_eq!(t.refresh_token.as_deref(), Some("rt")); + assert_eq!(t.expires_in, Some(28800)); + } +} diff --git a/libs/tesla/src/data.rs b/libs/tesla/src/data.rs new file mode 100644 index 000000000..c15eeb931 --- /dev/null +++ b/libs/tesla/src/data.rs @@ -0,0 +1,230 @@ +use makepad_micro_serde::*; + +pub const MILES_TO_KM: f64 = 1.609344; + +/// Subset of the Fleet API `vehicle_data` endpoints query values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VehicleDataEndpoint { + ChargeState, + ClimateState, + DriveState, + LocationData, + VehicleState, + VehicleConfig, + GuiSettings, + ChargeScheduleData, +} + +impl VehicleDataEndpoint { + pub fn as_str(self) -> &'static str { + match self { + Self::ChargeState => "charge_state", + Self::ClimateState => "climate_state", + Self::DriveState => "drive_state", + Self::LocationData => "location_data", + Self::VehicleState => "vehicle_state", + Self::VehicleConfig => "vehicle_config", + Self::GuiSettings => "gui_settings", + Self::ChargeScheduleData => "charge_schedule_data", + } + } +} + +#[derive(DeJson, Debug, Clone)] +pub struct VehiclesResponse { + pub response: Option>, + pub error: Option, + pub error_description: Option, +} + +#[derive(DeJson, Debug, Clone)] +pub struct Vehicle { + pub vin: Option, + pub display_name: Option, + /// "online" | "asleep" | "offline" + pub state: Option, + pub in_service: Option, +} + +impl Vehicle { + pub fn is_online(&self) -> bool { + self.state.as_deref() == Some("online") + } +} + +#[derive(DeJson, Debug, Clone)] +pub struct VehicleDataResponse { + pub response: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(DeJson, Debug, Clone)] +pub struct VehicleData { + pub vin: Option, + pub state: Option, + pub charge_state: Option, + pub drive_state: Option, +} + +/// The `charge_state` block of `vehicle_data`. Everything routing cares about. +/// All fields optional: Tesla adds/removes fields between firmware versions. +#[derive(DeJson, Debug, Clone, Default)] +pub struct ChargeState { + /// Displayed percent 0-100. + pub battery_level: Option, + /// Percent corrected for cold battery; use this for range planning. + pub usable_battery_level: Option, + /// Rated range in miles. + pub battery_range: Option, + /// Range estimated from recent consumption, miles. + pub est_battery_range: Option, + /// "Charging" | "Complete" | "Disconnected" | "Stopped" | "NoPower" | "Starting" + pub charging_state: Option, + /// Charge limit percent. + pub charge_limit_soc: Option, + /// mi of range added per hour while charging. + pub charge_rate: Option, + /// Current charging power in kW. + pub charger_power: Option, + pub charger_voltage: Option, + pub charger_actual_current: Option, + pub minutes_to_full_charge: Option, + /// Hours, fractional. + pub time_to_full_charge: Option, + pub fast_charger_present: Option, + pub fast_charger_type: Option, + /// Cable type when plugged in, e.g. "IEC" / "SAE"; "" when not. + pub conn_charge_cable: Option, + /// kWh added this session. + pub charge_energy_added: Option, + pub battery_heater_on: Option, + /// Milliseconds since epoch, set by the car. + pub timestamp: Option, +} + +impl ChargeState { + pub fn battery_range_km(&self) -> Option { + self.battery_range.map(|mi| mi * MILES_TO_KM) + } + + pub fn est_battery_range_km(&self) -> Option { + self.est_battery_range.map(|mi| mi * MILES_TO_KM) + } + + pub fn is_charging(&self) -> bool { + self.charging_state.as_deref() == Some("Charging") + } + + pub fn is_plugged_in(&self) -> bool { + matches!( + self.charging_state.as_deref(), + Some("Charging") | Some("Complete") | Some("Stopped") | Some("NoPower") | Some("Starting") + ) + } +} + +/// Only populated with lat/long when `location_data` is in the requested endpoints +/// (plain `drive_state` no longer carries the position). +#[derive(DeJson, Debug, Clone, Default)] +pub struct DriveState { + pub latitude: Option, + pub longitude: Option, + pub heading: Option, + /// mph; null when parked. + pub speed: Option, + /// kW; negative while regen/charging. + pub power: Option, + pub shift_state: Option, + pub timestamp: Option, +} + +#[derive(DeJson, Debug, Clone)] +pub struct WakeUpResponse { + pub response: Option, + pub error: Option, + pub error_description: Option, +} + +/// Response of the oauth2 token endpoint (refresh flow). +#[derive(DeJson, Debug, Clone)] +pub struct TokenResponse { + pub access_token: Option, + pub refresh_token: Option, + /// Seconds. + pub expires_in: Option, + pub error: Option, + pub error_description: Option, +} + +/// Generic Fleet API error body, for calls that failed with a non-2xx status. +#[derive(DeJson, Debug, Clone)] +pub struct ErrorBody { + pub error: Option, + pub error_description: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_charge_state_ignores_unknown_fields() { + let json = r#"{ + "response": { + "vin": "LRW3E7EK4NC000000", + "state": "online", + "charge_state": { + "battery_level": 72, + "usable_battery_level": 70, + "battery_range": 224.47, + "est_battery_range": 171.24, + "charging_state": "Charging", + "charge_limit_soc": 80, + "charge_rate": 27.1, + "charger_power": 11, + "charger_voltage": 229, + "charger_actual_current": 16, + "minutes_to_full_charge": 42, + "time_to_full_charge": 0.7, + "fast_charger_present": false, + "fast_charger_type": "", + "conn_charge_cable": "IEC", + "charge_energy_added": 12.3, + "battery_heater_on": false, + "charge_port_door_open": true, + "scheduled_charging_mode": "Off", + "timestamp": 1604977209418 + }, + "drive_state": { + "latitude": 52.379189, + "longitude": 4.899431, + "heading": 194, + "speed": null, + "power": -11, + "shift_state": null, + "timestamp": 1604977209418 + } + } + }"#; + let parsed = VehicleDataResponse::deserialize_json_lenient(json).unwrap(); + let data = parsed.response.unwrap(); + let charge = data.charge_state.unwrap(); + assert_eq!(charge.battery_level, Some(72)); + assert_eq!(charge.usable_battery_level, Some(70)); + assert!(charge.is_charging()); + assert!(charge.is_plugged_in()); + assert!((charge.battery_range_km().unwrap() - 361.25).abs() < 0.1); + let drive = data.drive_state.unwrap(); + assert!((drive.latitude.unwrap() - 52.379189).abs() < 1e-9); + assert_eq!(drive.speed, None); + } + + #[test] + fn parse_error_body() { + let json = r#"{"response":null,"error":"vehicle unavailable: vehicle is offline or asleep","error_description":""}"#; + let parsed = VehicleDataResponse::deserialize_json_lenient(json).unwrap(); + assert!(parsed.response.is_none()); + assert!(parsed.error.unwrap().contains("unavailable")); + } +} diff --git a/libs/tesla/src/lib.rs b/libs/tesla/src/lib.rs new file mode 100644 index 000000000..2ee18290a --- /dev/null +++ b/libs/tesla/src/lib.rs @@ -0,0 +1,48 @@ +//! Tesla Fleet API client for makepad apps, built on the makepad network +//! layer (Cx::http_request / Event::NetworkResponses). Data-oriented: made to +//! poll battery + charge status of your own car for charger-aware routing. +//! +//! Credentials setup (one-time, per car owner): see libs/tesla/README.md. +//! +//! ```ignore +//! // in your App +//! #[rust] tesla: Option, +//! +//! // startup +//! match TeslaClient::load_default() { +//! Ok(client) => self.tesla = Some(client), +//! Err(e) => log!("{}", e), +//! } +//! // kick off a poll (auto-refreshes the oauth token as needed) +//! if let Some(tesla) = &mut self.tesla { +//! tesla.request_charge_state(cx, "LRW3E7EK4NC000000"); +//! } +//! +//! // in AppMain::handle_event +//! if let Some(tesla) = &mut self.tesla { +//! for action in tesla.handle_event(cx, event) { +//! match action { +//! TeslaAction::VehicleData { vin, data } => { +//! if let Some(charge) = &data.charge_state { +//! log!("{}: {}% / {} km", vin, +//! charge.usable_battery_level.unwrap_or(0), +//! charge.battery_range_km().unwrap_or(0.0) as i64); +//! } +//! } +//! TeslaAction::VehicleAsleep { vin } => { /* wake or retry later */ } +//! TeslaAction::Error(e) => log!("{}", e), +//! _ => {} +//! } +//! } +//! } +//! ``` + +pub mod client; +pub mod data; + +pub use client::{ + TeslaAction, TeslaClient, TeslaCredentials, TeslaError, TeslaRegion, AUTH_TOKEN_URL, +}; +pub use data::{ + ChargeState, DriveState, Vehicle, VehicleData, VehicleDataEndpoint, MILES_TO_KM, +}; diff --git a/tools/map_tiles/src/nav_build.rs b/tools/map_tiles/src/nav_build.rs index f2b93a6a3..7507b992e 100644 --- a/tools/map_tiles/src/nav_build.rs +++ b/tools/map_tiles/src/nav_build.rs @@ -39,6 +39,11 @@ pub struct NavBuildOptions { /// Build a `.searchdb` disk-backed index of EVERY searchable /// string (all named features + addresses) instead of the RAM formats. pub searchdb: bool, + /// Graph-only build over MAJOR roads (motorway..secondary + car + /// ferries). Ways pass runs FIRST so node retention stays bounded at + /// continent scale — the long-haul fallback graph for routing beyond + /// the detailed regional graph. + pub major_roads_only: bool, } /// Way tags that matter for routing or search; everything else is dropped @@ -199,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); } @@ -453,6 +461,189 @@ pub fn nav_build(options: NavBuildOptions) -> Result<(), String> { Ok(()) } +fn is_major_road(tags: &HashMap) -> bool { + if matches!( + tags.get("highway").map(|v| v.as_str()), + Some( + "motorway" + | "motorway_link" + | "trunk" + | "trunk_link" + | "primary" + | "primary_link" + | "secondary" + | "secondary_link" + ) + ) { + return true; + } + tags.get("route").map(|r| r.as_str()) == Some("ferry") + && tags.get("motor_vehicle").map(|v| v.as_str()) != Some("no") +} + +/// Continent-scale graph-only build: ways first (major roads are ~3% of +/// ways), then only their nodes — Europe fits in a few GB of RAM where the +/// all-nodes pass would need >100 GB. +fn build_major_roads_graph(options: &NavBuildOptions) -> Result<(), String> { + let total_start = Instant::now(); + let bbox = options.bbox; + eprintln!( + "nav-build --major-roads: pass 1/2 (ways) over {}", + options.source.display() + ); + let pass1_start = Instant::now(); + let reader = ElementReader::from_path(&options.source) + .map_err(|err| format!("open {}: {err}", options.source.display()))?; + let way_pass = reader + .par_map_reduce( + |element| { + let mut acc = WayPass::default(); + match element { + Element::Way(way) => { + let tags = collect_tags(way.tags()); + if !is_major_road(&tags) { + return acc; + } + let refs: Vec = way.refs().collect(); + if refs.len() < 2 { + return acc; + } + acc.ways.push((way.id(), refs, tags)); + } + Element::Relation(relation) => { + let mut is_restriction = false; + let mut restriction_value = String::new(); + for (k, v) in relation.tags() { + if k == "type" && v == "restriction" { + is_restriction = true; + } + if k == "restriction" { + restriction_value = v.to_string(); + } + } + if !is_restriction || restriction_value.is_empty() { + return acc; + } + let only = restriction_value.starts_with("only_"); + let banned = restriction_value.starts_with("no_"); + if !only && !banned { + return acc; + } + let mut from_way = None; + let mut to_way = None; + let mut via_node = None; + for member in relation.members() { + let role = member.role().unwrap_or(""); + match (role, member.member_type) { + ("from", RelMemberType::Way) => from_way = Some(member.member_id), + ("to", RelMemberType::Way) => to_way = Some(member.member_id), + ("via", RelMemberType::Node) => via_node = Some(member.member_id), + _ => {} + } + } + if let (Some(from_way), Some(via_node), Some(to_way)) = + (from_way, via_node, to_way) + { + acc.restrictions.push(BuildRestriction { + from_way, + via_node, + to_way, + only, + }); + } + } + _ => {} + } + acc + }, + WayPass::default, + WayPass::merge, + ) + .map_err(|err| format!("pbf way pass: {err}"))?; + let needed: std::collections::HashSet = way_pass + .ways + .iter() + .flat_map(|(_, refs, _)| refs.iter().copied()) + .collect(); + eprintln!( + "nav-build --major-roads: pass 1 done in {:.1}s — {} major ways, {} nodes needed, {} restrictions", + pass1_start.elapsed().as_secs_f64(), + way_pass.ways.len(), + needed.len(), + way_pass.restrictions.len() + ); + + eprintln!("nav-build --major-roads: pass 2/2 (nodes)"); + let pass2_start = Instant::now(); + let needed_ref = &needed; + let reader = ElementReader::from_path(&options.source) + .map_err(|err| format!("open {}: {err}", options.source.display()))?; + let nodes = reader + .par_map_reduce( + |element| { + let mut acc: Vec<(i64, f64, f64)> = Vec::new(); + let (id, lon, lat) = match &element { + Element::Node(node) => (node.id(), node.lon(), node.lat()), + Element::DenseNode(node) => (node.id(), node.lon(), node.lat()), + _ => return acc, + }; + if !needed_ref.contains(&id) { + return acc; + } + if let Some(bbox) = &bbox { + if !bbox.contains(lon, lat) { + return acc; + } + } + acc.push((id, lon, lat)); + acc + }, + Vec::new, + |mut a, mut b| { + a.append(&mut b); + a + }, + ) + .map_err(|err| format!("pbf node pass: {err}"))?; + eprintln!( + "nav-build --major-roads: pass 2 done in {:.1}s — {} nodes kept", + pass2_start.elapsed().as_secs_f64(), + nodes.len() + ); + + let build_start = Instant::now(); + let mut graph_builder = GraphBuilder::new(); + for &(id, lon, lat) in &nodes { + graph_builder.add_node(id, lon, lat); + } + for (id, refs, tags) in way_pass.ways { + graph_builder.add_way(id, refs, tags); + } + for r in way_pass.restrictions { + if needed.contains(&r.via_node) { + graph_builder.add_restriction(r); + } + } + let graph = graph_builder.build(); + eprintln!( + "nav-build --major-roads: graph built in {:.1}s — {} vertices, {} directed edges", + build_start.elapsed().as_secs_f64(), + graph.vertices.len(), + graph.edges.len() + ); + let graph_path = options.output_basename.with_extension("graph"); + let graph_bytes = graph.serialize(); + std::fs::write(&graph_path, &graph_bytes) + .map_err(|err| format!("write {}: {err}", graph_path.display()))?; + eprintln!( + "nav-build --major-roads: done in {:.1}s\n {} ({:.1} MB)", + total_start.elapsed().as_secs_f64(), + graph_path.display(), + graph_bytes.len() as f64 / 1e6, + ); + Ok(()) +} + /// One parallel pass collecting settlement place nodes into a search index: /// city/town/village/suburb/hamlet with a name. ~500k docs for Europe. fn build_places_index(options: &NavBuildOptions) -> Result<(), String> { @@ -970,6 +1161,7 @@ pub fn parse_nav_build_options(args: &[String]) -> Result Result skip_addresses = true, "--places-only" => places_only = true, "--searchdb" => searchdb = true, + "--major-roads" => major_roads_only = true, other => return Err(format!("unknown nav-build option {:?}", other)), } i += 1; @@ -1005,5 +1198,6 @@ pub fn parse_nav_build_options(args: &[String]) -> Result, } @@ -151,6 +155,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, }); } // Waterway names follow their line like street names do. @@ -169,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") { @@ -199,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, }) } @@ -276,6 +283,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, }); } } @@ -296,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, }); } } @@ -334,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, }) } @@ -356,6 +366,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Detail-layer offices and named parkings carry their name (base @@ -386,6 +397,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Transit stops label with their name; stations lead (bigger zoom @@ -409,6 +421,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Charger sites label their power (and Superchargers their brand): @@ -460,6 +473,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Water body names (lakes, the IJ, canals-as-polygons) come as @@ -475,6 +489,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } "pois" => { @@ -489,6 +504,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // Shortbread street_labels_points is EXCLUSIVELY motorway junctions @@ -514,6 +530,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } _ => {} @@ -545,6 +562,7 @@ pub fn extract_point_label(tags: &HashMap, point: (f32, f32)) -> path_points: point_label_path(point), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }) } diff --git a/widgets/src/map/style.rs b/widgets/src/map/style.rs index 0a2d95b79..c9dfd8aa5 100644 --- a/widgets/src/map/style.rs +++ b/widgets/src/map/style.rs @@ -474,6 +474,7 @@ pub fn fill_alpha_for_tags(tags: &HashMap) -> f32 { match tags.get("layer").map(|value| value.as_str()) { Some("natura2000" | "wetlands") => 0.22, Some("vk100" | "vk500") => 0.45, + Some("gemeenten" | "wijken" | "buurten") => 0.32, Some("bag") => 0.85, _ => 1.0, } @@ -498,10 +499,31 @@ pub fn fill_pattern_shape(tags: &HashMap) -> f32 { 0.0 } +/// Building-age color from BAG bouwjaar — shared by the flat choropleth +/// fill and the 3D building tint. +pub fn bag_year_color(tags: &HashMap) -> Option { + let bouwjaar = tags + .get("bouwjaar") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0) as i32; + Some(match bouwjaar { + 0 => 0xbdbdbd, + year if year < 1800 => 0x8c2d04, + year if year < 1900 => 0xcc4c02, + year if year < 1930 => 0xec7014, + year if year < 1960 => 0xfe9929, + year if year < 1980 => 0xfec44f, + year if year < 2000 => 0x78c679, + year if year < 2010 => 0x41b6c4, + _ => 0x225ea8, + }) +} + pub fn fill_color_for_tags( theme: &CompiledMapTheme, tags: &HashMap, closed: bool, + render_zoom: u32, ) -> Option { if !closed { return None; @@ -527,21 +549,35 @@ pub fn fill_color_for_tags( } // Building-age choropleth (BAG bouwjaar): rust = old, blue = new. if layer == "bag" { - let bouwjaar = tags - .get("bouwjaar") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0.0) as i32; - return Some(match bouwjaar { - 0 => 0xbdbdbd, - year if year < 1800 => 0x8c2d04, - year if year < 1900 => 0xcc4c02, - year if year < 1930 => 0xec7014, - year if year < 1960 => 0xfe9929, - year if year < 1980 => 0xfec44f, - year if year < 2000 => 0x78c679, - year if year < 2010 => 0x41b6c4, - _ => 0x225ea8, - }); + return bag_year_color(tags); + } + // Districts tint as translucent AREA shapes, one tier per zoom band so + // gemeente/wijk/buurt tints never stack into mud; stable per-district + // hue from the CBS code. + if matches!(layer, "gemeenten" | "wijken" | "buurten") { + let tier_active = match layer { + "gemeenten" => render_zoom < 11, + "wijken" => (11..13).contains(&render_zoom), + _ => render_zoom >= 13, + }; + if !tier_active { + return None; + } + const DISTRICT_PALETTE: [u32; 8] = [ + 0xe57373, 0x64b5f6, 0x81c784, 0xffb74d, 0xba68c8, 0x4db6ac, + 0xf06292, 0xa1887f, + ]; + let code = tags + .get("buurtcode") + .or_else(|| tags.get("wijkcode")) + .or_else(|| tags.get("gemeentecode")) + .map(|value| value.as_str()) + .unwrap_or(""); + let mut h: u32 = 5381; + for b in code.bytes() { + h = h.wrapping_mul(33) ^ b as u32; + } + return Some(DISTRICT_PALETTE[(h % DISTRICT_PALETTE.len() as u32) as usize]); } // Population choropleth (CBS grid cells), yellow -> deep blue. if matches!(layer, "vk100" | "vk500") { @@ -622,6 +658,11 @@ pub fn fill_layer_rank(tags: &HashMap) -> u8 { if layer == "bag" { return 41; } + // District tints paint OVER everything ground-level (roads included) + // so the area reads as one marked shape. + if matches!(layer, "gemeenten" | "wijken" | "buurten") { + return 60; + } // Green areas (parks/gardens/grass) rank above generic landuse and sites: // they share the `land` layer with huge residential polygons and would // otherwise lose to protobuf feature order (Bellamyplein rendered gray). diff --git a/widgets/src/map/tile.rs b/widgets/src/map/tile.rs index 8a1b2e9ac..1271e290e 100644 --- a/widgets/src/map/tile.rs +++ b/widgets/src/map/tile.rs @@ -74,6 +74,8 @@ pub struct TileEntry { /// View-zoom bucket the geometry was styled for; stale buckets stay /// drawable while a rebuild is in flight. pub bucket: u32, + /// This bake carries 3D extrusions (buildings/trees/signals). + pub baked_3d: bool, /// Cross-fade state: the replaced generation's geometry stays drawable /// underneath while the new one fades in. pub fade: Option, @@ -85,6 +87,10 @@ pub struct TileFade { /// Render bucket the outgoing geometry was styled for, so its stroke /// widths can be corrected while it fades out. pub bucket: u32, + /// This fade is the flat->3D transition: the incoming bake grows its + /// heights with the fade. 3D->3D rebakes keep full height (alpha-only + /// crossfade) so zoom regens never replay the animation. + pub grow_heights: bool, pub fill_geometry: Option, pub casing_geometry: Option, pub stroke_geometry: Option, @@ -142,6 +148,8 @@ struct WayData { pub struct PinHit { pub norm: (f64, f64), pub info: Vec<(String, String)>, + /// 3D stalk height of this pin's marker (0 = grounded). + pub lift_m: f32, } #[derive(Debug)] @@ -914,10 +922,41 @@ fn append_ball( zbias: &mut f32, ) { let (segs, rings) = (segs.max(3), rings.max(2)); + // Phong-ish per-vertex lighting (Gouraud across the triangles): the + // same NW sun as the building walls plus a tight glossy highlight, so + // canopies and lights read as lit volumes instead of flat blobs. + // Map coords: x east, y SOUTH (screen down), z up. + let light = { + let (lx, ly, lz) = (-0.55f32, -0.835, 1.05); + let len = (lx * lx + ly * ly + lz * lz).sqrt(); + (lx / len, ly / len, lz / len) + }; + let view = { + let (vx, vy, vz) = (0.0f32, 0.62, 0.79); + let len = (vx * vx + vy * vy + vz * vz).sqrt(); + (vx / len, vy / len, vz / len) + }; + let half = { + let (hx, hy, hz) = (light.0 + view.0, light.1 + view.1, light.2 + view.2); + let len = (hx * hx + hy * hy + hz * hz).sqrt(); + (hx / len, hy / len, hz / len) + }; + let lit = |nx: f32, ny: f32, nz: f32| -> [f32; 4] { + let ndl = (nx * light.0 + ny * light.1 + nz * light.2).max(0.0); + let ndh = (nx * half.0 + ny * half.1 + nz * half.2).max(0.0); + let diffuse = 0.45 + 0.55 * ndl; + let spec = ndh.powi(32) * 0.85; + [ + (color[0] * diffuse + spec).min(1.0), + (color[1] * diffuse + spec).min(1.0), + (color[2] * diffuse + spec).min(1.0), + color[3], + ] + }; let base = (out_vertices.len() / VECTOR_FLOATS_PER_VERTEX) as u32; - let mut push_vertex = |x: f32, y: f32, h: f32| { + let mut push_vertex = |x: f32, y: f32, h: f32, shade: [f32; 4]| { out_vertices.extend_from_slice(&[ - x, y, 0.5, 1.0, color[0], color[1], color[2], color[3], 1e6, 0.0, 0.0, 0.0, + x, y, 0.5, 1.0, shade[0], shade[1], shade[2], shade[3], 1e6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, h, 0.05, 24.0, *zbias, ]); }; @@ -928,7 +967,13 @@ fn append_ball( let h = center_h_m + radius_m * phi.sin(); for seg in 0..segs { let a = seg as f32 / segs as f32 * std::f32::consts::TAU; - push_vertex(center.0 + a.cos() * ring_r, center.1 + a.sin() * ring_r, h); + let shade = lit(phi.cos() * a.cos(), phi.cos() * a.sin(), phi.sin()); + push_vertex( + center.0 + a.cos() * ring_r, + center.1 + a.sin() * ring_r, + h, + shade, + ); } } for ring in 0..rings { @@ -996,7 +1041,7 @@ fn build_tile_buffers_from_features( let mut labels = Vec::::new(); let mut pin_hits = Vec::::new(); let mut icon_jobs = - Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32, f32, f32)>::new(); + Vec::<((f32, f32), &'static IconMesh, u8, u8, f32, u8, f32, f32, f32, f32)>::new(); let mut tree_points_3d = Vec::<(f32, f32)>::new(); let mut signal_points_3d = Vec::<(f32, f32)>::new(); for (point, tags) in &tagged_points { @@ -1090,6 +1135,23 @@ fn build_tile_buffers_from_features( } else { micro_icon_min_zoom(icon_name).max(icon_zoom_floor as f32) }; + // 3D mode: markers fly on stalks above the skyline — + // chargers highest, then shops/cafés (base pois), then + // transit stops. Street furniture (benches, entrances, + // micro POIs) stays on the ground where it belongs. + let pin_lift_m = if buildings_3d { + if layer == "chargers" { + if charger_kw >= 50.0 { 26.0f32 } else { 20.0 } + } else if layer == "stops" { + 12.0 + } else if tags.get("layer").map(|v| v.as_str()) == Some("pois") { + 18.0 + } else { + 0.0 + } + } else { + 0.0 + }; // In 3D mode trees become little REAL 3D trees (trunk + // canopy blob lifted by the building height mechanism) // instead of flat billboard discs. @@ -1117,6 +1179,7 @@ fn build_tile_buffers_from_features( charger_kw as f32, charger_stalls as f32, zoom_floor, + pin_lift_m, )); if two_tone == 2 || two_tone == 3 { // Tappable: record position + info for the bubble. @@ -1133,7 +1196,7 @@ fn build_tile_buffers_from_features( } } } - pin_hits.push(PinHit { norm, info }); + pin_hits.push(PinHit { norm, info, lift_m: 0.0 }); } if two_tone == 2 { // In-pin text via the NORMAL text renderer (drawn in @@ -1169,6 +1232,7 @@ fn build_tile_buffers_from_features( )), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } // brand reads below the pin from z13; the kW digits @@ -1206,6 +1270,7 @@ fn build_tile_buffers_from_features( )), name_key: String::new(), bbox: (0.0, 0.0, 0.0, 0.0), + lift_m: 0.0, }); } } @@ -1228,7 +1293,7 @@ fn build_tile_buffers_from_features( let icon_min_dist = (ICON_SIZE_PX + 3.0) / render_scale; let icon_min_dist_sq = icon_min_dist * icon_min_dist; let mut accepted_icons = Vec::<(f32, f32)>::new(); - icon_jobs.retain(|(point, _, _, _, dist_factor, _, _, _, _)| { + icon_jobs.retain(|(point, _, _, _, dist_factor, _, _, _, _, _)| { let collides = accepted_icons.iter().any(|other| { let dx = other.0 - point.0; let dy = other.1 - point.1; @@ -1287,6 +1352,22 @@ fn build_tile_buffers_from_features( } let mut building_groups = Vec::::new(); let mut building_group_lookup = HashMap::::new(); + // Building-age layer active: index BAG polygons by quantized centroid + // so extruded buildings can pick up their bouwjaar tint (BAG footprints + // match OSM buildings nearly 1:1). + let mut bag_centroid_colors = HashMap::<(i32, i32), u32>::new(); + for way in tile_ways.iter() { + if way.tags.get("layer").map(|v| v.as_str()) == Some("bag") + && way.closed + && way.points.len() >= 3 + { + if let Some(color) = crate::map::style::bag_year_color(&way.tags) { + let c = ring_centroid(&way.points); + bag_centroid_colors + .insert(((c.0 / 6.0).round() as i32, (c.1 / 6.0).round() as i32), color); + } + } + } // Fill pass let mut fill_groups = Vec::::new(); @@ -1348,7 +1429,7 @@ fn build_tile_buffers_from_features( } // Labels are independent of fills: a named zoo enclosure with no // distinctive surface still gets its name at the centroid. - let fill_color = fill_color_for_tags(theme, &way.tags, way.closed); + let fill_color = fill_color_for_tags(theme, &way.tags, way.closed, render_zoom); let Some(mut ring_points) = normalize_polygon_ring(&prepared_way.points) else { continue; }; @@ -1532,6 +1613,7 @@ fn build_tile_buffers_from_features( polygon: Vec>, height_m: f32, base_m: f32, + tint: Option, min_y: f32, } // Simple 3D Buildings: an outline whose interior holds @@ -1580,10 +1662,31 @@ fn build_tile_buffers_from_features( .iter() .flat_map(|ring| ring.iter()) .fold(f32::MAX, |acc, p| acc.min(p.1)); + let tint = if bag_centroid_colors.is_empty() { + None + } else { + polygon.first().and_then(|ring| { + let c = ring_centroid(ring); + let (qx, qy) = ((c.0 / 6.0).round() as i32, (c.1 / 6.0).round() as i32); + let mut found = None; + 'search: for dy in -1..=1 { + for dx in -1..=1 { + if let Some(color) = + bag_centroid_colors.get(&(qx + dx, qy + dy)) + { + found = Some(*color); + break 'search; + } + } + } + found + }) + }; building_jobs.push(BuildingJob { polygon, height_m: group.height_m, base_m: group.min_height_m.clamp(0.0, group.height_m), + tint, min_y, }); } @@ -1594,10 +1697,12 @@ fn build_tile_buffers_from_features( .unwrap_or(std::cmp::Ordering::Equal) }); let base_color = theme.building_fill_color().unwrap_or(0xd9d0c9); - let roof_color = hex_to_premul_rgba(base_color, 1.0); // Light from the north-west; walls shade by their outward normal. let (light_x, light_y) = (-0.55_f32, -0.835_f32); for job in &building_jobs { + // Building-age layer tints the 3D model itself (walls shade + // from the same hue via the normal lighting math). + let roof_color = hex_to_premul_rgba(job.tint.unwrap_or(base_color), 1.0); if job.height_m <= 0.05 { // Flattened outline: footprint fill only, no walls. } else { @@ -1726,8 +1831,8 @@ fn build_tile_buffers_from_features( 4.0, 7.5, canopy_color, - 12, - 6, + 16, + 8, &mut fill_vertices, &mut fill_indices, &mut fill_zbias, @@ -1736,6 +1841,116 @@ fn build_tile_buffers_from_features( } } + // Dynamic stalk heights: every flying marker clears the building under + // it by ~8 m (a 100 m tower gets a 108 m pin), plus a small + // deterministic stagger so clustered pins don't form one flat plane. + let job_lifts: Vec = icon_jobs + .iter() + .map(|job| { + let base = job.9; + if base <= 0.0 { + return 0.0; + } + let (px, py) = job.0; + let mut clearance = 0.0f32; + for group in &building_groups { + if group.height_m <= clearance { + continue; + } + for ring in &group.rings { + if ring.signed_area <= 0.0 { + continue; + } + if point_in_ring((px, py), &ring.points) { + clearance = clearance.max(group.height_m); + break; + } + } + } + base.max(clearance + 8.0) + }) + .collect(); + // Propagate the FINAL lifts into the labels and tap zones that belong + // to these markers, so text and hit-testing ride the same stalk. + for (job, lift) in icon_jobs.iter().zip(job_lifts.iter()) { + if *lift <= 0.0 { + continue; + } + let (jx, jy) = job.0; + for label in labels.iter_mut() { + let eligible = label.color_class == crate::map::label::LABEL_CLASS_PIN + || label.road_kind.starts_with("chb") + || label.road_kind.starts_with("poi") + || label.road_kind.starts_with("stS") + || label.road_kind.starts_with("stp"); + if !eligible || label.path_points.is_empty() { + continue; + } + let (lx, ly) = label.path_points[0]; + let (mx, my) = label + .path_points + .last() + .map(|p| ((lx + p.0) * 0.5, (ly + p.1) * 0.5)) + .unwrap_or((lx, ly)); + if (mx - jx).abs() < 2.5 && (my - jy).abs() < 2.5 { + label.lift_m = *lift; + } + } + let world = (1u32 << tile_key.z) as f64; + let jnorm = ( + (tile_key.x as f64 + jx as f64 / crate::map::geometry::TILE_SIZE) / world, + (tile_key.y as f64 + jy as f64 / crate::map::geometry::TILE_SIZE) / world, + ); + for hit in pin_hits.iter_mut() { + if (hit.norm.0 - jnorm.0).abs() < 1e-7 && (hit.norm.1 - jnorm.1).abs() < 1e-7 { + hit.lift_m = *lift; + } + } + } + // Marker stalks (3D mode): thin dark lines from the ground point up to + // every floating marker. + if buildings_3d { + let has_pins = icon_jobs.iter().any(|job| job.9 > 0.0); + if has_pins { + let n = (1u32 << tile_key.z) as f64; + let lat = (std::f64::consts::PI * (1.0 - 2.0 * (tile_key.y as f64 + 0.5) / n)) + .sinh() + .atan(); + let units_per_m = + (crate::map::geometry::TILE_SIZE * n / (40_075_016.686 * lat.cos())) as f32; + let stalk_color = hex_to_premul_rgba(0x4a5058, 1.0); + for (job_index, job) in icon_jobs.iter().enumerate() { + let lift = job_lifts[job_index]; + if lift <= 0.0 { + continue; + } + // Chargers get a slightly heavier stalk than POI markers. + let arm = if job.5 == 2 || job.5 == 3 { 0.22 } else { 0.14 } * units_per_m; + let (x, y) = job.0; + append_wall_quad( + (x - arm, y), + (x + arm, y), + 0.0, + lift, + stalk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + append_wall_quad( + (x, y - arm), + (x, y + arm), + 0.0, + lift, + stalk_color, + &mut fill_vertices, + &mut fill_indices, + &mut fill_zbias, + ); + } + } + } + // Little 3D stoplights (tilt mode): a slim dark pole with the classic // three lights stacked on top — red above amber above green. if !signal_points_3d.is_empty() { @@ -1960,12 +2175,18 @@ fn build_tile_buffers_from_features( } // Pass 3: POI symbols — zoom-constant vector icons, drawn above strokes. - for (anchor, mesh, color_class, _, _, two_tone, kw, stalls, zoom_floor) in &icon_jobs { + for (job_index, (anchor, mesh, color_class, _, _, two_tone, kw, stalls, zoom_floor, _)) in + icon_jobs.iter().enumerate() + { + let pin_lift_m = job_lifts[job_index]; + // The lift rides in param4's hundreds so the zoom floor keeps its + // low digits. + let param4_encoded = zoom_floor + pin_lift_m * 100.0; append_icon_mesh( mesh, *anchor, hex_to_premul_rgba(poi_class_hex(*color_class), 1.0), - *zoom_floor, + param4_encoded, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -1977,7 +2198,7 @@ fn build_tile_buffers_from_features( core, *anchor, hex_to_premul_rgba(0x4c7a4c, 1.0), - *zoom_floor, + param4_encoded, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -1995,7 +2216,7 @@ fn build_tile_buffers_from_features( bolt, *anchor, hex_to_premul_rgba(0xffffff, 1.0), - *zoom_floor, + param4_encoded, &mut icon_vertices, &mut icon_indices, &mut icon_zbias, @@ -2210,11 +2431,11 @@ fn append_icon_mesh_offset_scaled( // param4: this icon's view-zoom floor; the shader collapses the // vertex when the live view zoom is below it (no stale flash). min_zoom, - // Tilt depth: markers never clip into the tilted ground or - // building walls (ground-y span + sort ranks stay under ~4), - // but the total map depth (-24 + this) MUST stay negative — - // at 90 the icons rose above 0 and painted over the UI panels. - 16.0, + // Tilt depth: a SMALL camera-ward bias -- enough to clear the + // marker's own ground pixel (fill/stroke micro-ranks are tiny), + // small enough that buildings meaningfully in FRONT occlude + // the marker, keeping the 3D illusion honest. + 0.35, 24.0, // clip_radius: generous, avoids pop-in at view edges *zbias, ]); diff --git a/widgets/src/map/view.rs b/widgets/src/map/view.rs index 44590336c..db1c60acd 100644 --- a/widgets/src/map/view.rs +++ b/widgets/src/map/view.rs @@ -30,6 +30,10 @@ script_mod! { // Live view zoom for per-icon zoom floors (param4 on shape 20): // stale deeper-bucket tiles must not flash markers on zoom-out. icon_zoom: uniform(24.0) + // 2D->3D transition: scales the per-meter height lift so buildings + // (and trees/signals) GROW out of the ground as their 3D bake fades + // in, and sink back when leaving 3D — instead of crossfading. + height_grow: uniform(1.0) // Heading-up camera: cos/sin of the screen rotation and its pivot // (the view center). Identity when north-up. view_rot: uniform(vec2(1.0, 0.0)) @@ -85,9 +89,12 @@ script_mod! { // param4 is building height in meters — EXCEPT on shape 20 // icons, where it carries the icon's zoom floor and must not // lift the marker off the ground. - var lift_m = self.geom.param4; + var lift_m = self.geom.param4 * self.height_grow; if shape_id > 19.5 && shape_id < 20.5 { - lift_m = 0.0; + // Icon param4 = zoom_floor + pin_lift_m*100: markers fly at + // their encoded height (0 for grounded icons). + let icon_floor = modf(self.geom.param4, 100.0); + lift_m = (self.geom.param4 - icon_floor) * 0.01 * self.height_grow; } transformed.y = self.rot_pivot.y + ground_rel_y * self.tilt_params.x @@ -103,7 +110,7 @@ script_mod! { // FAIL-OPEN: if the icon_zoom uniform hasn't landed (reads // ~0 — seen when a startup DSL override re-parses this // shader), the gate disarms instead of hiding every icon. - if self.icon_zoom > 1.0 && self.geom.param4 > self.icon_zoom + 0.6 { + if self.icon_zoom > 1.0 && modf(self.geom.param4, 100.0) > self.icon_zoom + 0.6 { self.vertex_pos = vec4(0.0, 0.0, 0.0, 0.0); return } @@ -255,6 +262,57 @@ script_mod! { } } + // Rain radar raster overlay: one textured quad whose four SCREEN-space + // corners come from the overlay camera (so it pans/zooms/rotates/tilts + // with the map); texture is a mercator-aligned RGBA nowcast frame. + mod.draw.DrawRainOverlay = mod.std.set_type_default() do #(DrawRainOverlay::script_shader(vm)){ + ..mod.draw.DrawQuad + tex: texture_2d(float) + // c0..c3 + rain_alpha come from the Rust struct's #[live] fields + // (auto-registered as instance inputs; declaring them here too + // collides, as with DrawRotatedText.upright). + uv: varying(vec2f) + + vertex: fn() { + let top = mix(self.c0, self.c1, self.geom.pos.x) + let bottom = mix(self.c3, self.c2, self.geom.pos.x) + let p = mix(top, bottom, self.geom.pos.y) + self.uv = self.geom.pos + let shifted = p + self.draw_list.view_shift + self.vertex_pos = self.draw_pass.camera_projection * (self.draw_pass.camera_view * ( + self.draw_list.view_transform * vec4( + shifted.x, + shifted.y, + self.draw_depth + self.draw_call.zbias, + 1. + ) + )) + } + + pixel: fn() { + let color = self.tex.sample_as_bgra(self.uv) + // Isosurface look: each intensity band has a UNIQUE alpha, so a + // differing neighbor alpha marks a band boundary -> draw a + // darker contour line there (weather-radar isopleths). + let e1 = self.tex.sample_as_bgra(self.uv + vec2(self.texel.x, 0.0)) + let e2 = self.tex.sample_as_bgra(self.uv - vec2(self.texel.x, 0.0)) + let e3 = self.tex.sample_as_bgra(self.uv + vec2(0.0, self.texel.y)) + let e4 = self.tex.sample_as_bgra(self.uv - vec2(0.0, self.texel.y)) + var edge = 0.0 + if abs(e1.w - color.w) > 0.01 || abs(e2.w - color.w) > 0.01 + || abs(e3.w - color.w) > 0.01 || abs(e4.w - color.w) > 0.01 { + edge = 1.0 + } + var rgb = color.xyz + var alpha = color.w * self.rain_alpha + if edge > 0.5 && color.w > 0.01 { + rgb = rgb * 0.55 + alpha = min(alpha * 1.7 + 0.12, 0.95) + } + return vec4(rgb * alpha, alpha) + } + } + mod.widgets.MapViewBase = #(MapView::register_widget(vm)) mod.widgets.MapView = set_type_default() do mod.widgets.MapViewBase{ @@ -465,6 +523,25 @@ struct FlyTo { // --- Draw shaders --- +#[derive(Script, ScriptHook)] +#[repr(C)] +pub struct DrawRainOverlay { + #[deref] + pub draw_super: DrawQuad, + #[live(vec2(0.0, 0.0))] + pub c0: Vec2f, + #[live(vec2(0.0, 0.0))] + pub c1: Vec2f, + #[live(vec2(0.0, 0.0))] + pub c2: Vec2f, + #[live(vec2(0.0, 0.0))] + pub c3: Vec2f, + #[live(0.85)] + pub rain_alpha: f32, + #[live(vec2(0.0015625, 0.00125))] + pub texel: Vec2f, +} + #[derive(Script, ScriptHook, Debug)] #[repr(C)] pub struct DrawMapVector { @@ -492,6 +569,7 @@ impl DrawMapVector { rot_pivot: [f32; 2], tilt_params: [f32; 4], icon_zoom: f32, + height_grow: f32, ) { self.map_scale = map_scale; self.map_offset = map_offset; @@ -526,6 +604,9 @@ impl DrawMapVector { self.draw_super .draw_vars .set_uniform(cx.cx, live_id!(icon_zoom), &[icon_zoom]); + self.draw_super + .draw_vars + .set_uniform(cx.cx, live_id!(height_grow), &[height_grow]); self.draw_super.draw_vars.geometry_id = Some(geometry_id); cx.new_draw_call(&self.draw_super.draw_vars); if self.draw_super.draw_vars.can_instance() { @@ -560,6 +641,9 @@ pub struct MapView { draw_label: DrawRotatedText, #[redraw] #[live] + draw_rain: DrawRainOverlay, + #[redraw] + #[live] draw_text: DrawText, #[live(4.9041)] @@ -654,6 +738,19 @@ pub struct MapView { applied_dark_theme: Option, #[rust] style_epoch: u64, + /// Rain radar nowcast: one mercator-aligned RGBA texture per +5 min + /// frame, animated on a timer while enabled. + #[rust] + rain_frames: Vec, + #[rust] + rain_frame_index: usize, + #[rust] + rain_timer: Timer, + /// (west, south, east, north) of the rain textures in lon/lat. + #[rust] + rain_bbox: (f64, f64, f64, f64), + #[rust] + rain_tex_size: (usize, usize), /// The 2D/3D mode the current tile set was baked with — a flip /// re-bakes tiles (extrusions only exist in the 3D bake). #[rust] @@ -841,6 +938,10 @@ impl Widget for MapView { self.handle_tile_worker_messages(cx); self.widget_match_event(cx, event, scope); + if self.rain_timer.is_event(event).is_some() && !self.rain_frames.is_empty() { + self.rain_frame_index = (self.rain_frame_index + 1) % self.rain_frames.len(); + self.redraw(cx); + } if self.tile_fade_timer.is_event(event).is_some() { self.redraw(cx); if self.tiles.values().any(|entry| entry.fade.is_some()) { @@ -932,8 +1033,16 @@ impl Widget for MapView { if self.tilt > 0.0 && self.tilt < 10.0 { self.tilt = 0.0; cx.widget_action(self.uid, MapViewAction::TiltChanged { tilt: 0.0 }); - self.redraw(cx); } + // Guarantee a full label re-place AFTER the rate-limit + // window: without this, a fast spin that ends inside the + // window leaves the cached placement rigidly rotated + // (180° = upside-down labels) with no frame scheduled + // to true it up. + cx.stop_timer(self.zoom_settle_timer); + self.zoom_settle_timer = + cx.start_timeout(LABEL_REPLACE_MIN_SECONDS + 0.05); + self.redraw(cx); self.sync_camera_fields(); self.emit_viewport_changed(cx); return; @@ -1130,6 +1239,7 @@ impl Widget for MapView { rot_pivot_uniform, tilt_uniform, view_zoom as f32, + 1.0, ); } } @@ -1148,6 +1258,11 @@ impl Widget for MapView { rot_pivot_uniform, tilt_uniform, view_zoom as f32, + if entry.fade.as_ref().is_some_and(|fade| fade.grow_heights) { + fade_alpha + } else { + 1.0 + }, ); } } @@ -1234,6 +1349,7 @@ impl Widget for MapView { rot_pivot_uniform, tilt_uniform, view_zoom as f32, + 1.0, ); } } @@ -1252,12 +1368,54 @@ impl Widget for MapView { rot_pivot_uniform, tilt_uniform, view_zoom as f32, + if entry.fade.as_ref().is_some_and(|fade| fade.grow_heights) { + fade_alpha + } else { + 1.0 + }, ); } } // Pin-class label text (white kW numbers) over the pins. + // Rain radar overlay: over all map content, under labels/UI. The + // quad's corners go through the overlay camera so it sticks to the + // map in every projection. + if !self.rain_frames.is_empty() { + let camera = self.overlay_camera(); + let (west, south, east, north) = self.rain_bbox; + let nw = lon_lat_to_normalized(west, north); + let ne = lon_lat_to_normalized(east, north); + let se = lon_lat_to_normalized(east, south); + let sw = lon_lat_to_normalized(west, south); + let c0 = camera.norm_to_screen(nw); + let c1 = camera.norm_to_screen(ne); + let c2 = camera.norm_to_screen(se); + let c3 = camera.norm_to_screen(sw); + let texture = self.rain_frames[self.rain_frame_index % self.rain_frames.len()].clone(); + self.draw_rain.draw_super.draw_vars.set_texture(0, &texture); + self.draw_rain.c0 = Vec2f { x: c0.x as f32, y: c0.y as f32 }; + self.draw_rain.c1 = Vec2f { x: c1.x as f32, y: c1.y as f32 }; + self.draw_rain.c2 = Vec2f { x: c2.x as f32, y: c2.y as f32 }; + self.draw_rain.c3 = Vec2f { x: c3.x as f32, y: c3.y as f32 }; + self.draw_rain.texel = Vec2f { + x: 1.0 / self.rain_tex_size.0 as f32, + y: 1.0 / self.rain_tex_size.1 as f32, + }; + let min_x = c0.x.min(c1.x).min(c2.x).min(c3.x); + let min_y = c0.y.min(c1.y).min(c2.y).min(c3.y); + let max_x = c0.x.max(c1.x).max(c2.x).max(c3.x); + let max_y = c0.y.max(c1.y).max(c2.y).max(c3.y); + self.draw_rain.draw_abs( + cx, + Rect { + pos: dvec2(min_x, min_y), + size: dvec2(max_x - min_x, max_y - min_y), + }, + ); + } + self.draw_pin_label_phase(cx); @@ -1544,6 +1702,7 @@ impl MapView { // Cross-fade: keep the replaced generation's geometry under the new // one for TILE_FADE_SECONDS instead of popping. + let new_baked_3d = self.baked_3d_mode; let fade = match self.tiles.remove(&tile_key) { Some(TileEntry { state: @@ -1555,10 +1714,12 @@ impl MapView { .. }, bucket: old_bucket, + baked_3d: old_baked_3d, .. }) => Some(TileFade { started: std::time::Instant::now(), bucket: old_bucket, + grow_heights: new_baked_3d && !old_baked_3d, fill_geometry: old_fill, casing_geometry: old_casing, stroke_geometry: old_stroke, @@ -1567,6 +1728,7 @@ impl MapView { _ => Some(TileFade { started: std::time::Instant::now(), bucket: buffers.render_zoom, + grow_heights: new_baked_3d, fill_geometry: None, casing_geometry: None, stroke_geometry: None, @@ -1591,6 +1753,7 @@ impl MapView { last_used: self.frame_counter, attempts: 0, bucket: buffers.render_zoom, + baked_3d: self.baked_3d_mode, fade, }, ); @@ -1816,6 +1979,7 @@ impl MapView { last_used: self.frame_counter, attempts: 0, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -1885,6 +2049,7 @@ impl MapView { last_used: self.frame_counter, attempts, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -2248,6 +2413,7 @@ impl MapView { last_used: self.frame_counter, attempts: 0, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -2303,6 +2469,7 @@ impl MapView { last_used: self.frame_counter, attempts, bucket, + baked_3d: self.baked_3d_mode, fade: None, }, ); @@ -2556,10 +2723,18 @@ impl MapView { // Billboard pin-phase plans anchor at the SITE point (the pin's // baked anchor): back the screen-px layout shift out of the // placement center so glyph offsets carry the layout instead. + let lift_px = candidate.lift_px; let layout_shift = if candidate.color_class == LABEL_CLASS_PIN { - (3.0f32, -12.35f32) + (3.0f32, -12.35f32 - lift_px) } else if candidate.road_kind.starts_with("chb") { - (0.0, 9.0) + (0.0, 9.0 - lift_px) + } else if candidate.road_kind.starts_with("poi") && lift_px > 0.0 { + (0.0, -lift_px - 12.0) + } else if (candidate.road_kind.starts_with("stS") + || candidate.road_kind.starts_with("stp")) + && lift_px > 0.0 + { + (0.0, -lift_px - 10.0) } else { (0.0, 0.0) }; @@ -2884,17 +3059,31 @@ impl MapView { // Charger brand reads just under the billboard pin: a fixed // SCREEN-space drop below the site anchor (a map-space offset // would tilt-compress and orbit the pin under rotation). + // Flying-marker labels ride their marker's BAKED stalk + // height (dynamic: each pin clears its own building). + let lift_px = self.lift_screen_px(label.lift_m, view_zoom); + if is_poi && lift_px > 0.0 { + // Above the floating icon. + for p in self.scratch_screen_path.iter_mut() { + p.y -= lift_px + 12.0; + } + } + if label.source_layer == "stops" && lift_px > 0.0 { + for p in self.scratch_screen_path.iter_mut() { + p.y -= lift_px + 10.0; + } + } if label.road_kind.starts_with("chb") { for p in self.scratch_screen_path.iter_mut() { - p.y += 9.0; + p.y += 9.0 - lift_px; } } // In-pin text: center in the droplet's text zone (right of - // the bolt, above the tail) — screen-px pin layout. + // the bolt, above the tail); rides the stalk in 3D. if label.color_class == LABEL_CLASS_PIN { for p in self.scratch_screen_path.iter_mut() { p.x += 3.0; - p.y += -12.35; + p.y += -12.35 - lift_px; } } if self.scratch_screen_path.len() < 2 @@ -3002,6 +3191,7 @@ impl MapView { c.repeat_distance = repeat_distance; c.font_scale = font_scale; c.screen_point = is_screen_point; + c.lift_px = lift_px as f32; c.screen_path.extend_from_slice(&self.scratch_screen_path); } else { self.scratch_candidates.push(LabelCandidate { @@ -3016,6 +3206,7 @@ impl MapView { repeat_distance, font_scale, screen_point: is_screen_point, + lift_px: lift_px as f32, screen_path: self.scratch_screen_path.clone(), }); } @@ -3364,6 +3555,19 @@ impl MapView { impl MapView { /// Hit-test the tappable charger pins of ready tiles against a screen /// point (billboard rect around the pin anchor, camera-transformed). + /// Screen-px height of a flying marker above its ground anchor (0 in + /// 2D) — the baked per-marker lift converted through the current tilt + /// and meters-per-pixel. + fn lift_screen_px(&self, lift_m: f32, view_zoom: f64) -> f64 { + if !self.buildings_3d || self.tilt <= 0.0 || lift_m <= 0.0 { + return 0.0; + } + let world_size = TILE_SIZE * 2f64.powf(view_zoom); + let (_, lat) = normalized_to_lon_lat(self.center_norm); + let px_per_meter = world_size / (40_075_016.686 * lat.to_radians().cos()); + lift_m as f64 * px_per_meter * self.tilt.clamp(0.0, 65.0).to_radians().sin() + } + fn pin_at(&self, abs: Vec2d) -> Option<(f64, f64, Vec<(String, String)>)> { let camera = self.overlay_camera(); let mut best: Option<(f64, &PinHit)> = None; @@ -3375,6 +3579,8 @@ impl MapView { let screen = camera.norm_to_screen(dvec2(hit.norm.0, hit.norm.1)); let dx = abs.x - screen.x; let dy = abs.y - screen.y; + let lift = self.lift_screen_px(hit.lift_m, self.view_zoom()); + let dy = dy + lift; if dx.abs() <= 18.0 && dy >= -26.0 && dy <= 6.0 { let dist = dx * dx + dy * dy; if best.as_ref().is_none_or(|(d, _)| dist < *d) { @@ -3519,6 +3725,38 @@ impl MapView { self.tilt } + /// Install the rain nowcast animation frames (BGRA u32 texels) covering + /// the given lon/lat bbox; empty = disable. Frames advance every 220 ms. + pub fn set_rain_frames( + &mut self, + cx: &mut Cx, + frames: Vec>, + width: usize, + height: usize, + bbox: (f64, f64, f64, f64), + ) { + cx.stop_timer(self.rain_timer); + self.rain_frames.clear(); + self.rain_frame_index = 0; + self.rain_bbox = bbox; + self.rain_tex_size = (width.max(1), height.max(1)); + for data in frames { + self.rain_frames.push(Texture::new_with_format( + cx, + TextureFormat::VecBGRAu8_32 { + data: Some(data), + width, + height, + updated: TextureUpdated::Full, + }, + )); + } + if !self.rain_frames.is_empty() { + self.rain_timer = cx.start_interval(0.22); + } + self.redraw(cx); + } + pub fn set_map_zoom(&mut self, cx: &mut Cx, zoom: f64) { let min_zoom = self.min_zoom.max(0.0); let max_zoom = self.max_zoom.max(min_zoom); @@ -3713,6 +3951,19 @@ impl MapViewRef { } } + pub fn set_rain_frames( + &self, + cx: &mut Cx, + frames: Vec>, + width: usize, + height: usize, + bbox: (f64, f64, f64, f64), + ) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_rain_frames(cx, frames, width, height, bbox); + } + } + pub fn set_rotation(&self, cx: &mut Cx, rotation_deg: f64) { if let Some(mut inner) = self.borrow_mut() { inner.set_rotation(cx, rotation_deg); From 5bc2e5c12938fa8b841dad8a9788928d552332ca Mon Sep 17 00:00:00 2001 From: Jason Yau <91023929+jason-yau@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:46:52 +0800 Subject: [PATCH 09/10] Fix Windows WASAPI open failures and infinite retry spam (#1148) Co-authored-by: jasonqiu --- platform/src/os/windows/wasapi.rs | 185 ++++++++++++++++++++---------- 1 file changed, 127 insertions(+), 58 deletions(-) diff --git a/platform/src/os/windows/wasapi.rs b/platform/src/os/windows/wasapi.rs index 544e3015e..485bd71e3 100644 --- a/platform/src/os/windows/wasapi.rs +++ b/platform/src/os/windows/wasapi.rs @@ -130,6 +130,12 @@ impl WasapiAccess { Self::enumerate_loopback_devices(&enumerator, &mut out); self.descs = out; } + // Match Linux/macOS: once open fails, mark the device so default_* skips it + // and apps stop retrying forever via change_signal → use_audio_outputs. + let failed = self.failed_devices.lock().unwrap(); + for d in &mut self.descs { + d.has_failed = failed.contains(&d.device_id); + } self.descs.clone() } @@ -143,12 +149,14 @@ impl WasapiAccess { } }); // create the new ones + let failed = self.failed_devices.lock().unwrap(); let mut new = Vec::new(); for (index, device_id) in devices.iter().enumerate() { if audio_inputs .iter() .find(|v| v.device_id == *device_id) .is_none() + && !failed.contains(device_id) { let is_loopback = self.is_loopback_device(*device_id); let channel_count = self @@ -173,6 +181,7 @@ impl WasapiAccess { std::thread::spawn(move || { let _mmcss_handle = elevate_audio_thread_priority(); if let Ok(mut wasapi) = WasapiLoopback::new(device_id, channel_count) { + let sample_rate = wasapi.base.sample_rate; audio_inputs.lock().unwrap().push(wasapi.get_ref()); while let Ok(buffer) = wasapi.wait_for_buffer() { // Use try_lock to avoid blocking the audio thread @@ -192,7 +201,7 @@ impl WasapiAccess { AudioInfo { device_id, time: None, - sample_rate: 48000.0, + sample_rate, }, &buffer, ); @@ -213,6 +222,7 @@ impl WasapiAccess { std::thread::spawn(move || { let _mmcss_handle = elevate_audio_thread_priority(); if let Ok(mut wasapi) = WasapiInput::new(device_id, channel_count) { + let sample_rate = wasapi.base.sample_rate; audio_inputs.lock().unwrap().push(wasapi.base.get_ref()); while let Ok(buffer) = wasapi.wait_for_buffer() { // Use try_lock to avoid blocking the audio thread @@ -232,7 +242,7 @@ impl WasapiAccess { AudioInfo { device_id, time: None, - sample_rate: 48000.0, + sample_rate, }, &buffer, ); @@ -262,12 +272,14 @@ impl WasapiAccess { } }); // create the new ones + let failed = self.failed_devices.lock().unwrap(); let mut new = Vec::new(); for (index, device_id) in devices.iter().enumerate() { if audio_outputs .iter() .find(|v| v.device_id == *device_id) .is_none() + && !failed.contains(device_id) { let channel_count = self .descs @@ -289,6 +301,7 @@ impl WasapiAccess { std::thread::spawn(move || { let _mmcss_handle = elevate_audio_thread_priority(); if let Ok(mut wasapi) = WasapiOutput::new(device_id, channel_count) { + let sample_rate = wasapi.base.sample_rate; audio_outputs.lock().unwrap().push(wasapi.base.get_ref()); while let Ok(mut buffer) = wasapi.wait_for_buffer() { // Use try_lock to avoid blocking the audio thread @@ -308,7 +321,7 @@ impl WasapiAccess { AudioInfo { device_id, time: None, - sample_rate: 48000.0, + sample_rate, }, &mut buffer.audio_buffer, ); @@ -532,6 +545,7 @@ struct WasapiBase { event: HANDLE, client: IAudioClient, channel_count: usize, + sample_rate: f64, audio_buffer: Option, } @@ -554,59 +568,104 @@ impl WasapiBase { pub fn new(device_id: AudioDeviceId, channel_count: usize) -> Result { unsafe { let channel_count = channel_count.min(2); - CoInitializeEx(None, COINIT_APARTMENTTHREADED).unwrap(); + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - let device = WasapiAccess::find_device_by_id(device_id).unwrap(); - let client3: IAudioClient3 = if let Ok(client) = device.Activate(CLSCTX_ALL, None) { - client - } else { - return Err(()); + let device = WasapiAccess::find_device_by_id(device_id).ok_or(())?; + + // Shared-mode streams must match (or autoconvert toward) the mix format. + // Hardcoding 48000 fails on devices whose engine runs at 44100/96000/etc. + let sample_rate = { + let probe: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|_| ())?; + let mix = probe.GetMixFormat().map_err(|_| ())?; + let rate = (*mix).nSamplesPerSec as usize; + crate::windows::Win32::System::Com::CoTaskMemFree(Some( + mix as *const _ as *const _, + )); + if rate == 0 { + 48000 + } else { + rate + } }; + let wave_format = + WasapiAccess::new_float_waveformatextensible(sample_rate, channel_count); + let wave_ptr = &wave_format as *const _ + as *const crate::windows::Win32::Media::Audio::WAVEFORMATEX; - let wave_format = WasapiAccess::new_float_waveformatextensible(48000, channel_count); - - let mut default_period_frames = 0u32; - let mut fundamental_period_frames = 0u32; - let mut min_period_frames = 0u32; - let mut max_period_frames = 0u32; - if client3 - .GetSharedModeEnginePeriod( - &wave_format as *const _ - as *const crate::windows::Win32::Media::Audio::WAVEFORMATEX, - &mut default_period_frames, - &mut fundamental_period_frames, - &mut min_period_frames, - &mut max_period_frames, - ) - .is_err() - { - return Err(()); + // Prefer IAudioClient3 low-latency shared stream when available. + if let Ok(client3) = device.Activate::(CLSCTX_ALL, None) { + let mut default_period_frames = 0u32; + let mut fundamental_period_frames = 0u32; + let mut min_period_frames = 0u32; + let mut max_period_frames = 0u32; + if client3 + .GetSharedModeEnginePeriod( + wave_ptr, + &mut default_period_frames, + &mut fundamental_period_frames, + &mut min_period_frames, + &mut max_period_frames, + ) + .is_ok() + && client3 + .InitializeSharedAudioStream( + AUDCLNT_STREAMFLAGS_EVENTCALLBACK, + default_period_frames, + wave_ptr, + None, + ) + .is_ok() + { + let event = CreateEventA(None, false, false, None).map_err(|_| ())?; + client3.SetEventHandle(event).map_err(|_| ())?; + client3.Start().map_err(|_| ())?; + let client: IAudioClient = client3.cast().map_err(|_| ())?; + return Ok(Self { + device_id, + frames: default_period_frames.max(1), + device, + channel_count, + sample_rate: sample_rate as f64, + audio_buffer: Some(Default::default()), + event, + client, + }); + } } - if client3 - .InitializeSharedAudioStream( - AUDCLNT_STREAMFLAGS_EVENTCALLBACK, - default_period_frames, - &wave_format as *const _ - as *const crate::windows::Win32::Media::Audio::WAVEFORMATEX, + + // Fallback: classic shared-mode client with PCM autoconvert. + let client: IAudioClient = device.Activate(CLSCTX_ALL, None).map_err(|_| ())?; + let mut def_period = 0i64; + let mut min_period = 0i64; + client + .GetDevicePeriod(Some(&mut def_period), Some(&mut min_period)) + .map_err(|_| ())?; + if client + .Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_EVENTCALLBACK + | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM + | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, + def_period, + 0, + wave_ptr, None, ) .is_err() { return Err(()); } - - let event = CreateEventA(None, false, false, None).unwrap(); - client3.SetEventHandle(event).unwrap(); - client3.Start().unwrap(); - - // Cast IAudioClient3 to IAudioClient for storage - let client: IAudioClient = client3.cast().unwrap(); - + let event = CreateEventA(None, false, false, None).map_err(|_| ())?; + client.SetEventHandle(event).map_err(|_| ())?; + client.Start().map_err(|_| ())?; + let frames = + (((def_period as f64 / 10_000_000.0) * sample_rate as f64) as u32).max(1); Ok(Self { device_id, - frames: default_period_frames, + frames, device, channel_count, + sample_rate: sample_rate as f64, audio_buffer: Some(Default::default()), event, client, @@ -616,7 +675,7 @@ impl WasapiBase { pub fn new_loopback(device_id: AudioDeviceId, channel_count: usize) -> Result { unsafe { - CoInitializeEx(None, COINIT_APARTMENTTHREADED).unwrap(); + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); let channel_count = channel_count.min(2); // Find the output device that corresponds to this loopback device let device = WasapiAccess::find_loopback_device_by_id(device_id).ok_or(())?; @@ -626,17 +685,32 @@ impl WasapiBase { let mut min_period = 0i64; client .GetDevicePeriod(Some(&mut def_period), Some(&mut min_period)) - .unwrap(); + .map_err(|_| ())?; // Force at least 20ms buffer for loopback if def_period < 200_000 { def_period = 200_000; } - // Calculate frames from period (100-nanosecond units to frames at 48kHz) - let frames = ((def_period as f64 / 10_000_000.0) * 48000.0) as u32; + let sample_rate = { + let mix = client.GetMixFormat().map_err(|_| ())?; + let rate = (*mix).nSamplesPerSec as usize; + crate::windows::Win32::System::Com::CoTaskMemFree(Some( + mix as *const _ as *const _, + )); + if rate == 0 { + 48000 + } else { + rate + } + }; - let wave_format = WasapiAccess::new_float_waveformatextensible(48000, channel_count); + // Calculate frames from period (100-nanosecond units) + let frames = + (((def_period as f64 / 10_000_000.0) * sample_rate as f64) as u32).max(1); + + let wave_format = + WasapiAccess::new_float_waveformatextensible(sample_rate, channel_count); // Use AUDCLNT_STREAMFLAGS_LOOPBACK to capture from the output device if client @@ -657,15 +731,16 @@ impl WasapiBase { return Err(()); } - let event = CreateEventA(None, false, false, None).unwrap(); - client.SetEventHandle(event).unwrap(); - client.Start().unwrap(); + let event = CreateEventA(None, false, false, None).map_err(|_| ())?; + client.SetEventHandle(event).map_err(|_| ())?; + client.Start().map_err(|_| ())?; Ok(Self { device_id, frames, device, channel_count, + sample_rate: sample_rate as f64, audio_buffer: Some(Default::default()), event, client, @@ -713,17 +788,11 @@ impl WasapiOutput { let device_buffer = self.render_client.GetBuffer(req_size).unwrap(); let mut audio_buffer = self.base.audio_buffer.take().unwrap(); let channel_count = self.base.channel_count; - let frame_count = (req_size / channel_count as u32) as usize; + // GetBuffer / GetCurrentPadding sizes are in frames, not samples. + let frame_count = req_size as usize; audio_buffer.clear_final_size(); audio_buffer.resize(frame_count, channel_count); audio_buffer.set_final_size(); - if (frame_count as u32) < self.base.frames { - println!( - "Wasapi glitch detected, resettting output device {}<{}", - frame_count, self.base.frames - ); - return Err(()); - } return Ok(WasapiAudioOutputBuffer { frame_count, channel_count, From 2c5cd97ad96a9714af6ed80762130902ebed6bdb Mon Sep 17 00:00:00 2001 From: andodeki Date: Tue, 28 Jul 2026 17:10:00 +0000 Subject: [PATCH 10/10] feat(widgets): reexport optional Makepad sibling crates from dev --- widgets/Cargo.toml | 8 ++++++++ widgets/src/lib.rs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/widgets/Cargo.toml b/widgets/Cargo.toml index ec6cf63c2..ba93da3e4 100644 --- a/widgets/Cargo.toml +++ b/widgets/Cargo.toml @@ -15,6 +15,11 @@ makepad-draw = { path = "../draw", version = "2.0.0" } makepad-derive-widget = {path = "./derive_widget", version="2.0.0"} makepad-mbtile-reader = { path = "../libs/mbtile_reader", version = "1.0.0", optional = true } makepad-fast-inflate = { path = "../libs/fast_inflate", optional = true } +# Optional sibling workspace crates re-exported by makepad-widgets for +# downstream applications that intentionally use a single Makepad dependency. +makepad-gltf = { path = "../libs/gltf", optional = true } +makepad-csg = { path = "../libs/csg/csg", optional = true } +makepad-test = { path = "../libs/makepad_test", optional = true } makepad-voice = { path = "../libs/voice", version = "0.1.0", optional = true } makepad-cef = { path = "../libs/cef", optional = true } @@ -32,6 +37,9 @@ default = [] voice = ["dep:makepad-voice"] maps = ["dep:makepad-mbtile-reader", "dep:makepad-fast-inflate"] +gltf = ["dep:makepad-gltf"] +csg = ["dep:makepad-csg"] +test = ["dep:makepad-test"] pdf = ["dep:makepad-pdf-parse"] cef = ["dep:makepad-cef"] diff --git a/widgets/src/lib.rs b/widgets/src/lib.rs index 6837c5d50..bb0307f4e 100644 --- a/widgets/src/lib.rs +++ b/widgets/src/lib.rs @@ -15,6 +15,20 @@ pub use makepad_pdf_parse; pub use makepad_draw::makepad_zune_jpeg; pub use makepad_draw::makepad_zune_png; +// Optional sibling Makepad workspace crates. These re-exports permit a +// downstream application to depend on makepad-widgets as the single Makepad +// source while keeping all extra APIs feature-gated. +#[cfg(feature = "maps")] +pub use makepad_fast_inflate; +#[cfg(feature = "maps")] +pub use makepad_mbtile_reader; +#[cfg(feature = "gltf")] +pub use makepad_gltf; +#[cfg(feature = "csg")] +pub use makepad_csg; +#[cfg(feature = "test")] +pub use makepad_test; + // Core modules (used internally first) pub mod animator; pub mod theme_desktop_dark;