diff --git a/RFCP-RUST-MIGRATION-PLAN.md b/RFCP-RUST-MIGRATION-PLAN.md new file mode 100644 index 0000000..75884ac --- /dev/null +++ b/RFCP-RUST-MIGRATION-PLAN.md @@ -0,0 +1,1513 @@ +# RFCP Rust Migration Plan + +## Python/React/Electron → Tauri/SvelteKit/Rust + +**Version:** 1.0 +**Date:** February 2026 +**Status:** Planning + +--- + +## Executive Summary + +This document provides a comprehensive migration plan for RFCP (RF Coverage Planner) from the current Python/React/Electron stack to Tauri/SvelteKit/Rust. The migration targets: + +- **Performance:** 50km full-preset calculations (currently times out at ~20km) +- **Bundle Size:** 5-15MB (currently 170MB+) +- **Memory:** Constant memory for any radius (currently OOMs on large areas) + +--- + +## Part 1: Current Architecture Analysis + +### 1.1 High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Electron Shell │ +│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ +│ │ React Frontend │ │ Python Backend (PyInstaller) │ │ +│ │ - Vite/TypeScript │ │ - FastAPI/Uvicorn │ │ +│ │ - Leaflet/WebGL │◄──►│ - NumPy/SciPy │ │ +│ │ - Zustand stores │HTTP│ - Motor (MongoDB) │ │ +│ │ - IndexedDB │ WS │ - ProcessPoolExecutor │ │ +│ └─────────────────────┘ └─────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ SRTM │ │ OSM │ │ MongoDB │ + │ Tiles │ │ Overpass │ │ │ + └──────────┘ └──────────┘ └──────────┘ +``` + +### 1.2 Complete Module Map + +#### Backend (`backend/app/`) + +``` +backend/app/ +├── main.py # FastAPI app, CORS, lifespan +├── core/ +│ ├── config.py # Pydantic Settings (MONGODB_URL, TERRAIN_DIR) +│ ├── database.py # Motor async MongoDB singleton +│ ├── calculator.py # Legacy calc engine (unused) +│ ├── engine.py # Legacy RF engine (unused) +│ ├── grid.py # Legacy grid utils (unused) +│ └── result.py # Legacy result structs (unused) +├── models/ +│ ├── site.py # Site/Sector Pydantic models +│ └── project.py # Project/CoverageSettings models +├── api/ +│ ├── deps.py # FastAPI dependencies +│ ├── websocket.py # WebSocket progress handler +│ └── routes/ +│ ├── coverage.py # POST /calculate, /preview, /link-budget (1600 lines) +│ ├── terrain.py # GET /elevation, /profile +│ ├── health.py # GET /health +│ ├── projects.py # Project CRUD +│ ├── regions.py # Region management +│ ├── system.py # System info +│ └── gpu.py # GPU detection +├── geometry/ +│ ├── haversine.py # Great-circle distance +│ ├── los.py # Line-of-sight with earth curvature +│ ├── diffraction.py # ITU-R P.526 knife-edge +│ ├── intersection.py # Ray-polygon tests +│ └── reflection.py # Specular reflection math +├── propagation/ +│ ├── base.py # PropagationModel ABC +│ ├── free_space.py # FSPL model (>2GHz) +│ ├── okumura_hata.py # 150-1500 MHz +│ ├── cost231_hata.py # 1500-2000 MHz (LTE) +│ ├── cost231_wi.py # Street canyon model +│ ├── itu_r_p1546.py # 30-3000 MHz field strength +│ ├── itu_r_p526.py # Knife-edge diffraction +│ └── longley_rice.py # VHF (<150 MHz) +├── services/ +│ ├── coverage_service.py # MAIN ORCHESTRATOR (1630 lines) +│ ├── parallel_coverage_service.py # ProcessPool/Ray parallelism +│ ├── terrain_service.py # SRTM loading, mmap, bilinear interp +│ ├── buildings_service.py # OSM buildings fetch/cache +│ ├── los_service.py # Terrain LOS analysis +│ ├── dominant_path_service.py # BOTTLENECK: reflection/diffraction paths +│ ├── materials_service.py # Building penetration loss +│ ├── street_canyon_service.py # Urban canyon propagation +│ ├── reflection_service.py # Single/double bounce +│ ├── vegetation_service.py # Forest attenuation +│ ├── water_service.py # Water reflection gain +│ ├── weather_service.py # Rain attenuation (ITU-R P.838) +│ ├── indoor_service.py # Indoor penetration loss +│ ├── atmospheric_service.py # O2/H2O absorption +│ ├── spatial_index.py # Grid-based building index +│ ├── geometry_vectorized.py # NumPy batch geometry ops +│ ├── gpu_service.py # CuPy/NumPy abstraction +│ ├── gpu_backend.py # GPU array manager +│ ├── cache.py # Generic cache utilities +│ ├── cache_db.py # SQLite cache backend +│ ├── osm_client.py # Overpass API client +│ ├── tile_processor.py # Tiled calculation support +│ └── boundary_service.py # Coverage contour generation +└── utils/ + ├── logging.py # Logging config + ├── progress.py # Progress reporting + └── units.py # Unit conversions +``` + +#### Frontend (`frontend/src/`) + +``` +frontend/src/ +├── App.tsx # Main app, layout, state wiring +├── main.tsx # React entry point +├── index.css # Tailwind base +├── components/ +│ ├── map/ +│ │ ├── Map.tsx # Leaflet MapContainer +│ │ ├── SiteMarker.tsx # Site markers with sector wedges +│ │ ├── WebGLRadialCoverageLayer.tsx # GPU radial gradients (NEW) +│ │ ├── WebGLCoverageLayer.tsx # GPU texture interpolation +│ │ ├── GeographicHeatmap.tsx # Canvas fallback +│ │ ├── HeatmapTileRenderer.ts # Tile-based canvas renderer +│ │ ├── CoverageBoundary.tsx # Contour polygon +│ │ ├── ElevationLayer.tsx # Terrain visualization +│ │ ├── ElevationDisplay.tsx # Cursor elevation +│ │ ├── CoordinateGrid.tsx # Lat/lon grid overlay +│ │ ├── MeasurementTool.tsx # Distance measurement +│ │ ├── TerrainProfile.tsx # Elevation profile chart +│ │ ├── LinkBudgetOverlay.tsx # TX-RX link display +│ │ ├── HeatmapLegend.tsx # RSRP color legend +│ │ └── MapExtras.tsx # Miscellaneous overlays +│ ├── panels/ +│ │ ├── SiteList.tsx # Site management (19KB) +│ │ ├── BatchEdit.tsx # Batch operations (14KB) +│ │ ├── SiteForm.tsx # Site form component +│ │ ├── LinkBudgetPanel.tsx # Link budget analysis +│ │ ├── CoverageStats.tsx # Calculation statistics +│ │ ├── ResultsPanel.tsx # Results summary +│ │ ├── HistoryPanel.tsx # Calculation history +│ │ ├── ProjectPanel.tsx # Project save/load +│ │ ├── FrequencyBandPanel.tsx # Frequency presets +│ │ ├── FrequencySelector.tsx # Frequency input +│ │ ├── BatchFrequencyChange.tsx # Batch frequency edit +│ │ ├── SiteImportExport.tsx # Import/export +│ │ └── ExportPanel.tsx # KML/GeoJSON export +│ ├── modals/ +│ │ ├── SiteConfigModal.tsx # Create/edit site dialog +│ │ ├── ModalBackdrop.tsx # Modal backdrop +│ │ └── index.ts # Modal exports +│ └── ui/ +│ ├── Button.tsx # Styled button +│ ├── Input.tsx # Text input +│ ├── NumberInput.tsx # Numeric input +│ ├── Toast.tsx # Toast notifications +│ ├── ThemeToggle.tsx # Dark/light mode +│ ├── GPUIndicator.tsx # GPU status +│ └── ConfirmDialog.tsx # Confirmation modal +├── store/ +│ ├── sites.ts # Site CRUD + IndexedDB +│ ├── coverage.ts # Coverage calculation state +│ ├── settings.ts # UI/map preferences +│ ├── history.ts # Undo/redo stack +│ ├── projects.ts # Project management +│ ├── calcHistory.ts # Calculation history +│ └── tools.ts # Active tool state +├── services/ +│ ├── api.ts # HTTP API client +│ ├── websocket.ts # WebSocket client +│ └── terrain.ts # Terrain utilities +├── hooks/ +│ ├── useWebSocket.ts # WS connection hook +│ ├── useKeyboardShortcuts.ts # Keyboard shortcuts +│ ├── useElevation.ts # Elevation queries +│ └── useUnsavedChanges.ts # Unsaved changes warning +├── types/ +│ ├── site.ts # Site interfaces +│ ├── coverage.ts # Coverage interfaces +│ └── frequency.ts # Frequency types +├── rf/ +│ ├── fspl.ts # Free-space path loss +│ ├── antenna-pattern.ts # Antenna gain patterns +│ ├── calculator.ts # Client-side RF utils +│ └── utils.ts # RF helpers +├── db/ +│ └── schema.ts # Dexie IndexedDB schema +├── lib/ +│ └── desktop.ts # Electron IPC bridge +├── constants/ +│ ├── frequencies.ts # Frequency band presets +│ └── rsrp-thresholds.ts # Signal thresholds +└── utils/ + ├── logger.ts # Logging utility + ├── geographicScale.ts # Geographic calculations + └── colorGradient.ts # Color utilities +``` + +#### Desktop (`desktop/`) + +``` +desktop/ +├── main.js # Electron main process +├── preload.js # IPC bridge +├── splash.html # Loading screen +├── package.json # Electron-builder config +└── assets/ + ├── icon.ico # Windows icon + ├── icon.png # Linux icon + └── icon.icns # macOS icon +``` + +### 1.3 Data Flow Diagram + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Coverage Calculation Pipeline │ +└──────────────────────────────────────────────────────────────────────────────┘ + +User clicks "Calculate" + │ + ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ WebSocket or │ │ Backend │ +│ useCoverageStore│────►│ HTTP POST │────►│ /api/coverage/ │ +│ .calculate() │ │ │ │ calculate │ +└─────────────────┘ └─────────────────┘ └────────┬────────┘ + │ + ┌────────────────────────────────┘ + ▼ + ┌─────────────────────────────────────────────────┐ + │ CoverageService.calculate() │ + │ │ + │ 1. apply_preset() ─────────────────────────┐ │ + │ │ │ + │ 2. _fetch_osm_grid_aligned() │ │ + │ ├── buildings_service.fetch_buildings() │ │ + │ ├── street_canyon_service.fetch_streets()│ │ + │ ├── water_service.fetch_water_bodies() │ │ + │ └── vegetation_service.fetch_areas() │ │ + │ │ │ + │ 3. terrain_service.ensure_tiles() │ │ + │ │ │ + │ 4. GPU precomputation (if available) │ │ + │ ├── precompute_distances() │ │ + │ ├── precompute_path_loss() │ │ + │ ├── batch_terrain_los() │ │ + │ └── batch_antenna_pattern() │ │ + │ │ │ + │ 5. Point loop (parallel or sequential) │ │ + │ │ │ │ + │ │ For each grid point: │ │ + │ │ ┌─────────────────────────────────┐ │ │ + │ │ │ a. Use precomputed distance │ │ │ + │ │ │ b. Use precomputed path_loss │ │ │ + │ │ │ c. Use precomputed antenna_loss │ │ │ + │ │ │ d. Building LOS check │◄──┼──┼── BOTTLENECK + │ │ │ e. Material penetration loss │ │ │ + │ │ │ f. Dominant path analysis │◄──┼──┼── BOTTLENECK + │ │ │ g. Street canyon loss │ │ │ + │ │ │ h. Vegetation attenuation │ │ │ + │ │ │ i. Reflection paths │ │ │ + │ │ │ j. Water reflection │ │ │ + │ │ │ k. Rain attenuation │ │ │ + │ │ │ l. Indoor penetration │ │ │ + │ │ │ m. Atmospheric loss │ │ │ + │ │ │ n. Combine all losses │ │ │ + │ │ │ o. Final RSRP │ │ │ + │ │ └─────────────────────────────────┘ │ │ + │ │ │ │ + │ └── Parallel: ProcessPoolExecutor │ │ + │ - 4-6 workers │ │ + │ - Data pickled per worker │ │ + │ - ~100MB pickle overhead each │ │ + │ │ │ + │ 6. Collect results, compute stats │ │ + └──────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────┐ + │ CoverageResult │ + │ - points: CoveragePoint[] (lat, lon, rsrp, ...) │ + │ - stats: min/max/avg, LOS%, model distribution │ + │ - computation_time, models_used │ + │ - boundary: contour polygon │ + └────────────────────────┬────────────────────────┘ + │ + ┌─────────────┘ + ▼ + ┌─────────────────────────────────────────────────┐ + │ WebGL Rendering │ + │ │ + │ WebGLRadialCoverageLayer (preferred): │ + │ - Pass 1: Point accumulation (Gaussian radial) │ + │ - Pass 2: Normalize + colormap │ + │ - Instanced rendering: 1 draw call for all pts │ + │ │ + │ WebGLCoverageLayer (texture): │ + │ - Grid to texture │ + │ - Quintic Hermite interpolation │ + │ - Single-pass colormap │ + └─────────────────────────────────────────────────┘ +``` + +### 1.4 Performance Bottleneck Analysis + +#### Critical Bottlenecks (Line-Level Detail) + +**1. Dominant Path Service (`dominant_path_service.py:155-350`)** + +The `find_dominant_paths_vectorized()` function is the #1 bottleneck: + +```python +# Line 208-214: Building filtering per point +line_buildings = _filter_buildings_by_distance( + line_buildings, + (tx_lat, tx_lon), (rx_lat, rx_lon), + max_count=MAX_BUILDINGS_FOR_LINE, # 30 + max_distance=MAX_DISTANCE_FROM_PATH, # 200m +) + +# Line 230-234: Building to array conversion (allocates new arrays per point!) +walls_start, walls_end, wall_to_bldg, poly_x, poly_y, poly_lengths = ( + _buildings_to_arrays(line_buildings, ref_lat, ref_lon) +) + +# Line 300-307: Vectorized reflection search (still expensive) +refl_point, refl_length, refl_loss = find_best_reflection_path_vectorized( + tx, rx, r_walls_start, r_walls_end, r_wall_to_bldg, + r_poly_x, r_poly_y, r_poly_lengths, + max_candidates=30, max_walls=100, max_los_checks=5, +) +``` + +**Timing breakdown (from logs):** +- Building query: 0.5-2ms per point +- Array conversion: 1-5ms per point +- LOS polygon test: 0.5-2ms per point +- Reflection search: 5-20ms per point (when LOS blocked) +- **Total: 10-30ms per point with buildings** + +For 6,700 points (50m resolution, 10km): **67-200 seconds just for dominant path** + +**2. ProcessPoolExecutor Overhead (`parallel_coverage_service.py`)** + +```python +# Each worker receives a pickled copy of: +# - buildings list: 10-50MB (5000+ buildings with geometry) +# - terrain cache: Referenced but requires re-mmap +# - vegetation areas: 1-10MB +# - water bodies: 1-5MB + +# Line 180-195: Data serialization +chunk_data = { + 'grid_chunk': grid_points[start:end], + 'site_params': site_params, + 'settings': settings, + 'buildings': buildings, # FULL COPY + 'streets': streets, + 'water_bodies': water_bodies, + 'vegetation_areas': vegetation_areas, +} +``` + +**Memory impact:** 4 workers × 100MB pickle = 400MB overhead just for parallelism + +**3. Terrain Service (`terrain_service.py:180-232`)** + +Bilinear interpolation is called per point: +```python +def _bilinear_sample(self, tile: np.ndarray, lat: float, lon: float) -> float: + # 20+ float operations per sample + # Not vectorized - called in Python loop +``` + +For terrain LOS checks: 50 samples × 6,700 points = 335,000 calls + +**4. Building Intersection (`dominant_path_service.py:674-690`)** + +```python +def _line_intersects_building_3d(self, ...): + # Line 682: 20 samples per line segment + for t in np.linspace(0, 1, 20): + # Point-in-polygon test per sample + if buildings_service.point_in_building(lat, lon, building): + if height < building.height: + return True +``` + +**5. Python GIL and Async Overhead** + +- ProcessPoolExecutor requires pickling all data +- asyncio event loop adds latency for every await +- NumPy releases GIL but building intersection code doesn't + +### 1.5 Memory Usage Patterns + +| Component | Memory (10km) | Memory (50km) | Notes | +|-----------|--------------|---------------|-------| +| Grid points | 2MB | 50MB | 6,700 → 167,000 points | +| SRTM tiles | 25-100MB | 100-400MB | 1-16 tiles × 25MB | +| Buildings | 10-100MB | 50-500MB | Geometry arrays | +| Worker copies | 100-400MB | 400-2GB | Pickle per worker | +| GPU buffers | 50-200MB | 200-800MB | CuPy arrays | +| **Total** | **200-800MB** | **800MB-4GB** | OOM likely at 50km | + +--- + +## Part 2: Target Architecture + +### 2.1 Rust Backend Structure + +``` +src-tauri/ +├── Cargo.toml # Workspace + deps +├── src/ +│ ├── main.rs # Tauri app entry +│ ├── lib.rs # Library exports +│ ├── commands/ # Tauri IPC commands +│ │ ├── mod.rs +│ │ ├── coverage.rs # #[tauri::command] calculate_coverage +│ │ ├── terrain.rs # Elevation queries +│ │ ├── projects.rs # Project CRUD +│ │ └── system.rs # System info, GPU detection +│ │ +│ ├── coverage/ # Coverage calculation engine +│ │ ├── mod.rs +│ │ ├── engine.rs # Main calculation orchestrator +│ │ ├── grid.rs # Grid generation (adaptive) +│ │ ├── point.rs # Per-point calculation +│ │ ├── precompute.rs # Vectorized precomputation +│ │ └── result.rs # CoveragePoint, CoverageResult +│ │ +│ ├── propagation/ # RF propagation models +│ │ ├── mod.rs +│ │ ├── traits.rs # PropagationModel trait +│ │ ├── free_space.rs # FSPL +│ │ ├── okumura_hata.rs # 150-1500 MHz +│ │ ├── cost231_hata.rs # 1500-2000 MHz +│ │ ├── cost231_wi.rs # Street canyon +│ │ ├── itu_r_p1546.rs # Field strength model +│ │ ├── longley_rice.rs # VHF +│ │ └── knife_edge.rs # ITU-R P.526 diffraction +│ │ +│ ├── geometry/ # Geometry operations +│ │ ├── mod.rs +│ │ ├── haversine.rs # Great-circle distance (SIMD) +│ │ ├── los.rs # Line-of-sight (vectorized) +│ │ ├── intersection.rs # Ray-polygon (SIMD) +│ │ ├── reflection.rs # Specular reflection +│ │ └── spatial_index.rs # R-tree or grid index +│ │ +│ ├── terrain/ # Terrain data management +│ │ ├── mod.rs +│ │ ├── srtm.rs # SRTM tile loading (mmap) +│ │ ├── cache.rs # LRU tile cache +│ │ ├── sample.rs # Bilinear interpolation (SIMD) +│ │ └── profile.rs # Elevation profiles +│ │ +│ ├── osm/ # OpenStreetMap data +│ │ ├── mod.rs +│ │ ├── client.rs # Overpass API client +│ │ ├── building.rs # Building struct + parsing +│ │ ├── cache.rs # SQLite OSM cache +│ │ └── spatial.rs # Spatial queries +│ │ +│ ├── environment/ # Environmental effects +│ │ ├── mod.rs +│ │ ├── materials.rs # Building materials/penetration +│ │ ├── vegetation.rs # Forest attenuation +│ │ ├── water.rs # Water reflection +│ │ ├── weather.rs # Rain attenuation +│ │ ├── indoor.rs # Indoor penetration +│ │ └── atmospheric.rs # O2/H2O absorption +│ │ +│ ├── gpu/ # GPU compute (optional) +│ │ ├── mod.rs +│ │ ├── wgpu_backend.rs # wgpu compute shaders +│ │ ├── shaders/ +│ │ │ ├── propagation.wgsl # Path loss compute +│ │ │ ├── terrain_los.wgsl # Batch LOS check +│ │ │ └── antenna.wgsl # Antenna pattern +│ │ └── fallback.rs # CPU SIMD fallback +│ │ +│ ├── db/ # Data persistence +│ │ ├── mod.rs +│ │ ├── sqlite.rs # SQLite for projects/cache +│ │ └── schema.rs # Database schema +│ │ +│ └── utils/ +│ ├── mod.rs +│ ├── progress.rs # Progress reporting +│ └── units.rs # Unit conversions +│ +└── build.rs # Build script (shader compilation) +``` + +### 2.2 SvelteKit Frontend Structure + +``` +src/ +├── app.html # HTML template +├── app.css # Global styles (Tailwind) +├── routes/ +│ ├── +layout.svelte # Root layout +│ └── +page.svelte # Main app page +│ +├── lib/ +│ ├── components/ +│ │ ├── map/ +│ │ │ ├── MapContainer.svelte # Leaflet wrapper +│ │ │ ├── SiteMarker.svelte # Site markers +│ │ │ ├── CoverageLayer.svelte # WebGL coverage (port shaders) +│ │ │ ├── ElevationLayer.svelte +│ │ │ ├── TerrainProfile.svelte +│ │ │ ├── MeasurementTool.svelte +│ │ │ └── Legend.svelte +│ │ │ +│ │ ├── panels/ +│ │ │ ├── SiteList.svelte # Site management +│ │ │ ├── BatchEdit.svelte # Batch operations +│ │ │ ├── Settings.svelte # Coverage settings +│ │ │ ├── LinkBudget.svelte # Link budget analysis +│ │ │ ├── History.svelte # Calculation history +│ │ │ └── Export.svelte # Export options +│ │ │ +│ │ ├── modals/ +│ │ │ ├── SiteConfig.svelte # Site edit modal +│ │ │ └── Confirm.svelte # Confirmation dialog +│ │ │ +│ │ └── ui/ +│ │ ├── Button.svelte +│ │ ├── Input.svelte +│ │ ├── Select.svelte +│ │ ├── Toast.svelte +│ │ └── ThemeToggle.svelte +│ │ +│ ├── stores/ +│ │ ├── sites.ts # Site state (Svelte stores) +│ │ ├── coverage.ts # Coverage state +│ │ ├── settings.ts # UI settings +│ │ ├── history.ts # Undo/redo +│ │ └── projects.ts # Project state +│ │ +│ ├── tauri/ +│ │ ├── commands.ts # Tauri command wrappers +│ │ ├── events.ts # Event listeners +│ │ └── types.ts # Shared types +│ │ +│ ├── webgl/ +│ │ ├── radial-layer.ts # WebGL radial gradients (port) +│ │ ├── texture-layer.ts # WebGL texture (port) +│ │ └── shaders.ts # GLSL shaders (reuse) +│ │ +│ ├── rf/ +│ │ ├── fspl.ts # Client-side FSPL +│ │ └── antenna.ts # Antenna patterns +│ │ +│ └── utils/ +│ ├── colors.ts # Color utilities +│ └── geo.ts # Geographic helpers +│ +├── static/ +│ └── icons/ # App icons +│ +└── tests/ + └── ... +``` + +### 2.3 Tauri IPC Design + +#### Commands (Rust → JS) + +```rust +// src-tauri/src/commands/coverage.rs + +#[tauri::command] +async fn calculate_coverage( + app: AppHandle, + sites: Vec, + settings: CoverageSettings, +) -> Result { + // Spawns calculation on Rayon thread pool + // Emits progress events via app.emit_all() +} + +#[tauri::command] +fn cancel_coverage(app: AppHandle) -> Result<(), String> { + // Sets cancellation flag +} + +#[tauri::command] +async fn get_elevation(lat: f64, lon: f64) -> Result { + // Single point elevation +} + +#[tauri::command] +async fn get_elevation_profile( + lat1: f64, lon1: f64, + lat2: f64, lon2: f64, + num_points: u32, +) -> Result, String> { + // Terrain profile +} + +#[tauri::command] +async fn fetch_osm_data( + min_lat: f64, min_lon: f64, + max_lat: f64, max_lon: f64, + layers: Vec, +) -> Result { + // Pre-fetch buildings, water, vegetation +} +``` + +#### Events (JS → Rust progress updates) + +```typescript +// src/lib/tauri/events.ts + +import { listen } from '@tauri-apps/api/event'; + +interface ProgressPayload { + phase: string; + progress: number; // 0.0-1.0 + eta_seconds?: number; + tile?: { current: number; total: number }; +} + +interface PartialResultPayload { + points: CoveragePoint[]; + tile_index: number; +} + +export function listenCoverageProgress( + onProgress: (p: ProgressPayload) => void, + onPartial: (p: PartialResultPayload) => void, +) { + const unlisten1 = listen('coverage:progress', e => onProgress(e.payload)); + const unlisten2 = listen('coverage:partial', e => onPartial(e.payload)); + return () => { unlisten1.then(f => f()); unlisten2.then(f => f()); }; +} +``` + +#### Data Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ SvelteKit Frontend │ +│ │ +│ coverageStore.calculate() │ +│ │ │ +│ ▼ │ +│ invoke('calculate_coverage', { sites, settings }) │ +│ │ │ +│ listen('coverage:progress', updateProgress) │ +│ listen('coverage:partial', addPartialPoints) │ +│ │ │ +└────────┼─────────────────────────────────────────────────────┘ + │ Tauri IPC (JSON serialization, ~1ms) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Rust Backend │ +│ │ +│ #[tauri::command] │ +│ async fn calculate_coverage(...) { │ +│ // Spawn on Rayon thread pool │ +│ rayon::spawn(move || { │ +│ // Parallel grid processing │ +│ grid_points.par_chunks(256) │ +│ .for_each(|chunk| { │ +│ // SIMD-optimized point calculation │ +│ let results = calculate_chunk(chunk); │ +│ // Emit partial results │ +│ app.emit_all("coverage:partial", results); │ +│ }); │ +│ }); │ +│ } │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.4 Data Management Strategy + +#### Terrain (SRTM) + +```rust +// Memory-mapped tiles with zero-copy access +pub struct TerrainManager { + tiles: HashMap, // Direct mmap, no copy + lru: LruCache, // LRU tracking only +} + +impl TerrainManager { + pub fn get_elevation(&self, lat: f64, lon: f64) -> f32 { + let tile = self.get_tile(lat, lon)?; + // Direct pointer arithmetic on mmap'd data + // SIMD-optimized bilinear interpolation + simd_bilinear_sample(tile.as_ptr(), lat, lon) + } + + pub fn get_elevations_batch(&self, coords: &[(f64, f64)]) -> Vec { + // Process 8 points at a time with AVX2 + coords.par_chunks(8) + .flat_map(|chunk| simd_batch_sample(chunk)) + .collect() + } +} +``` + +#### OSM Data + +```rust +// SQLite cache with FlatBuffers serialization +pub struct OsmCache { + db: Connection, // rusqlite +} + +impl OsmCache { + pub fn get_buildings(&self, bbox: BBox) -> Vec { + // Query by spatial index + // Deserialize with FlatBuffers (zero-copy) + } + + pub fn get_or_fetch(&self, bbox: BBox) -> Vec { + if let Some(cached) = self.get_buildings(bbox) { + return cached; + } + // Fetch from Overpass, cache, return + } +} +``` + +#### Projects + +```rust +// SQLite for projects (simple, portable) +pub struct ProjectStore { + db: Connection, +} + +// Schema +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + data BLOB, -- MessagePack serialized + created_at INTEGER, + updated_at INTEGER +); + +CREATE TABLE sites ( + id TEXT PRIMARY KEY, + project_id TEXT, + data BLOB, + FOREIGN KEY (project_id) REFERENCES projects(id) +); +``` + +--- + +## Part 3: Migration Phases + +### Phase 1: Tauri + SvelteKit Shell (Replace Electron) + +**Goal:** Working desktop app with same UI, backend still Python + +**Duration:** 2-3 weeks + +#### Tasks + +1. **Initialize Tauri project** + ```bash + npm create tauri-app@latest rfcp-tauri -- --template svelte-ts + ``` + +2. **Port UI components to Svelte** + - Start with layout (App.tsx → +page.svelte) + - Port UI components (Button, Input, etc.) + - Port map components (reuse Leaflet) + - Port panels (SiteList, Settings, etc.) + +3. **Port WebGL layers** + - Copy GLSL shaders verbatim + - Adapt TypeScript to Svelte (minimal changes) + - WebGLRadialCoverageLayer → radial-layer.ts + +4. **Implement Tauri commands as HTTP proxies** + ```rust + #[tauri::command] + async fn calculate_coverage(...) -> Result { + // Forward to Python backend via HTTP + let response = reqwest::get("http://localhost:8090/api/coverage/calculate") + .await?; + Ok(response.json().await?) + } + ``` + +5. **Port state management** + - Zustand → Svelte stores + - Keep same persistence (IndexedDB via idb) + +6. **Test parity** + - All features working + - Same performance as Electron version + +**Deliverable:** RFCP desktop app on Tauri, Python backend, ~20MB bundle + +--- + +### Phase 2: Rust Propagation Engine (The Big Win) + +**Goal:** RF calculations in Rust, 50-100x faster for geometry-heavy operations + +**Duration:** 4-6 weeks + +#### Tasks + +1. **Core geometry in Rust** + ```rust + // Haversine with SIMD (8 distances per instruction) + pub fn haversine_batch( + site: (f64, f64), + points: &[(f64, f64)], + ) -> Vec { + points.par_chunks(8) + .flat_map(|chunk| simd_haversine(site, chunk)) + .collect() + } + + // Line-polygon intersection with SIMD + pub fn line_intersects_polygon_batch( + line: (Point, Point), + polygons: &[Polygon], + ) -> Vec { + // Vectorized edge tests + } + ``` + +2. **Propagation models** + ```rust + pub trait PropagationModel: Send + Sync { + fn calculate(&self, input: &PropagationInput) -> PropagationOutput; + fn calculate_batch(&self, inputs: &[PropagationInput]) -> Vec; + } + + // FSPL: trivial, ~1 nanosecond per point + // Okumura-Hata: ~10 nanoseconds per point + // COST-231: ~15 nanoseconds per point + ``` + +3. **Dominant path in Rust** + ```rust + pub fn find_dominant_path( + tx: Point3D, + rx: Point3D, + buildings: &SpatialIndex, + ) -> DominantPath { + // R-tree query: O(log n) + let nearby = buildings.query_line(tx.to_2d(), rx.to_2d()); + + // SIMD polygon tests + let hits = simd_line_polygon_batch(&nearby, tx, rx); + + // Vectorized reflection search + if !hits.is_empty() { + find_best_reflection_simd(&nearby, tx, rx) + } else { + DominantPath::Direct + } + } + ``` + +4. **Parallel grid processing** + ```rust + pub fn calculate_coverage( + sites: &[Site], + settings: &CoverageSettings, + on_progress: impl Fn(f32), + ) -> CoverageResult { + let grid = generate_grid(sites, settings); + + // Rayon parallel iteration + let points: Vec = grid + .par_iter() + .enumerate() + .map(|(i, coord)| { + if i % 1000 == 0 { + on_progress(i as f32 / grid.len() as f32); + } + calculate_point(coord, sites, settings) + }) + .collect(); + + CoverageResult { points, stats: compute_stats(&points) } + } + ``` + +5. **Benchmark against Python** + - Target: 10x faster for basic (terrain only) + - Target: 50x faster for full (buildings + dominant path) + +**Deliverable:** Rust coverage engine, 50km calculations possible + +--- + +### Phase 3: Rust Terrain/OSM Services (Offline-First) + +**Goal:** Complete data layer in Rust, no Python dependency + +**Duration:** 3-4 weeks + +#### Tasks + +1. **Terrain service** + ```rust + pub struct TerrainService { + data_dir: PathBuf, + tiles: RwLock>, + download_queue: Mutex>, + } + + impl TerrainService { + pub async fn ensure_tiles(&self, bbox: BBox) -> Result<()> { + let needed = self.get_needed_tiles(bbox); + for tile in needed { + if !self.has_tile(tile) { + self.download_tile(tile).await?; + } + } + Ok(()) + } + } + ``` + +2. **OSM client with caching** + ```rust + pub struct OsmService { + client: reqwest::Client, + cache: SqliteCache, + } + + impl OsmService { + pub async fn get_buildings(&self, bbox: BBox) -> Vec { + if let Some(cached) = self.cache.get_buildings(bbox) { + return cached; + } + + let response = self.client + .post("https://overpass-api.de/api/interpreter") + .body(overpass_query(bbox, "building")) + .send() + .await?; + + let buildings = parse_overpass_response(response); + self.cache.store_buildings(bbox, &buildings); + buildings + } + } + ``` + +3. **Spatial indexing** + ```rust + // R-tree for fast spatial queries + pub struct BuildingIndex { + rtree: RTree, + } + + impl BuildingIndex { + pub fn query_line(&self, p1: Point, p2: Point) -> Vec<&Building> { + let envelope = LineString::new(vec![p1, p2]).envelope(); + self.rtree + .locate_in_envelope(&envelope) + .filter(|b| b.geometry.intersects_line(p1, p2)) + .collect() + } + } + ``` + +4. **Project persistence** + - SQLite database for projects + - Binary serialization (MessagePack or bincode) + +5. **Remove Python backend** + - All functionality in Rust + - Single executable + +**Deliverable:** Self-contained Rust app, no Python, ~10MB bundle + +--- + +### Phase 4: Advanced Features (GPU Compute, 50km+) + +**Goal:** Handle massive calculations with GPU acceleration + +**Duration:** 4-6 weeks + +#### Tasks + +1. **wgpu compute shaders** + ```wgsl + // propagation.wgsl + @group(0) @binding(0) var grid_points: array>; + @group(0) @binding(1) var site: vec4; // lat, lon, height, freq + @group(0) @binding(2) var path_loss: array; + + @compute @workgroup_size(256) + fn main(@builtin(global_invocation_id) id: vec3) { + let idx = id.x; + if (idx >= arrayLength(&grid_points)) { return; } + + let point = grid_points[idx]; + let dist = haversine(site.xy, point); + + // COST-231 Hata + let freq = site.w; + let height = site.z; + path_loss[idx] = cost231_hata(dist, freq, height); + } + ``` + +2. **Tiled GPU processing** + ```rust + pub async fn calculate_coverage_gpu( + sites: &[Site], + settings: &CoverageSettings, + gpu: &GpuContext, + ) -> CoverageResult { + let grid = generate_grid(sites, settings); + + // Process in tiles that fit in GPU memory + let tile_size = 50_000; // 50k points per tile + let mut all_points = Vec::new(); + + for tile in grid.chunks(tile_size) { + // Upload to GPU + let grid_buffer = gpu.create_buffer(tile); + + // Dispatch compute shader + gpu.dispatch_propagation(grid_buffer, sites); + gpu.dispatch_terrain_los(grid_buffer, terrain); + + // Read back results + let results = gpu.read_buffer(grid_buffer); + all_points.extend(results); + } + + CoverageResult { points: all_points, ... } + } + ``` + +3. **Streaming results** + - Emit partial results per tile + - Progressive rendering in UI + +4. **Memory-mapped result caching** + - Cache calculation results + - Instant re-render on settings change + +**Deliverable:** 50km+ calculations, GPU-accelerated, <30 second full preset + +--- + +## Part 4: Component Migration Map + +### Backend Services + +| Current File | Action | Target | Notes | +|-------------|--------|--------|-------| +| `coverage_service.py` | REWRITE | `src-tauri/src/coverage/engine.rs` | Rayon parallelism, SIMD geometry | +| `parallel_coverage_service.py` | DELETE | N/A | Replaced by Rayon | +| `terrain_service.py` | REWRITE | `src-tauri/src/terrain/srtm.rs` | mmap + SIMD bilinear | +| `buildings_service.py` | REWRITE | `src-tauri/src/osm/building.rs` | R-tree index | +| `los_service.py` | REWRITE | `src-tauri/src/geometry/los.rs` | Vectorized LOS | +| `dominant_path_service.py` | REWRITE | `src-tauri/src/coverage/dominant_path.rs` | SIMD intersection tests | +| `materials_service.py` | REWRITE | `src-tauri/src/environment/materials.rs` | Direct port | +| `street_canyon_service.py` | REWRITE | `src-tauri/src/environment/street_canyon.rs` | Direct port | +| `reflection_service.py` | REWRITE | `src-tauri/src/geometry/reflection.rs` | SIMD specular calc | +| `vegetation_service.py` | REWRITE | `src-tauri/src/environment/vegetation.rs` | Direct port | +| `water_service.py` | REWRITE | `src-tauri/src/environment/water.rs` | Direct port | +| `weather_service.py` | REWRITE | `src-tauri/src/environment/weather.rs` | Direct port | +| `indoor_service.py` | REWRITE | `src-tauri/src/environment/indoor.rs` | Direct port (trivial) | +| `atmospheric_service.py` | REWRITE | `src-tauri/src/environment/atmospheric.rs` | Direct port | +| `spatial_index.py` | REWRITE | `src-tauri/src/geometry/spatial_index.rs` | Use `rstar` crate | +| `geometry_vectorized.py` | REWRITE | `src-tauri/src/geometry/intersection.rs` | SIMD with `packed_simd2` | +| `gpu_service.py` | REWRITE | `src-tauri/src/gpu/wgpu_backend.rs` | wgpu compute shaders | +| `osm_client.py` | REWRITE | `src-tauri/src/osm/client.rs` | reqwest + serde | +| `cache.py` | REWRITE | `src-tauri/src/db/sqlite.rs` | rusqlite | + +### Propagation Models + +| Current File | Action | Target | Notes | +|-------------|--------|--------|-------| +| `free_space.py` | REWRITE | `src-tauri/src/propagation/free_space.rs` | Trivial port | +| `okumura_hata.py` | REWRITE | `src-tauri/src/propagation/okumura_hata.rs` | Same formulas | +| `cost231_hata.py` | REWRITE | `src-tauri/src/propagation/cost231_hata.rs` | Same formulas | +| `cost231_wi.py` | REWRITE | `src-tauri/src/propagation/cost231_wi.rs` | Same formulas | +| `itu_r_p1546.py` | REWRITE | `src-tauri/src/propagation/itu_r_p1546.rs` | Same formulas | +| `longley_rice.py` | REWRITE | `src-tauri/src/propagation/longley_rice.rs` | Same formulas | +| `itu_r_p526.py` | REWRITE | `src-tauri/src/propagation/knife_edge.rs` | Same formulas | + +### Geometry + +| Current File | Action | Target | Notes | +|-------------|--------|--------|-------| +| `geometry/haversine.py` | REWRITE | `src-tauri/src/geometry/haversine.rs` | SIMD batch version | +| `geometry/los.py` | REWRITE | `src-tauri/src/geometry/los.rs` | Vectorized | +| `geometry/diffraction.py` | REWRITE | `src-tauri/src/geometry/diffraction.rs` | Same formulas | +| `geometry/intersection.py` | REWRITE | `src-tauri/src/geometry/intersection.rs` | SIMD | +| `geometry/reflection.py` | REWRITE | `src-tauri/src/geometry/reflection.rs` | SIMD | + +### Frontend Components + +| Current File | Action | Target | Notes | +|-------------|--------|--------|-------| +| `App.tsx` | PORT | `src/routes/+page.svelte` | Svelte syntax | +| `components/map/Map.tsx` | PORT | `src/lib/components/map/MapContainer.svelte` | Svelte + Leaflet | +| `components/map/WebGLRadialCoverageLayer.tsx` | PORT | `src/lib/webgl/radial-layer.ts` | Keep shaders verbatim | +| `components/map/WebGLCoverageLayer.tsx` | PORT | `src/lib/webgl/texture-layer.ts` | Keep shaders verbatim | +| `components/map/SiteMarker.tsx` | PORT | `src/lib/components/map/SiteMarker.svelte` | Svelte syntax | +| `components/map/*.tsx` | PORT | `src/lib/components/map/*.svelte` | Svelte syntax | +| `components/panels/SiteList.tsx` | PORT | `src/lib/components/panels/SiteList.svelte` | Svelte syntax | +| `components/panels/*.tsx` | PORT | `src/lib/components/panels/*.svelte` | Svelte syntax | +| `components/modals/*.tsx` | PORT | `src/lib/components/modals/*.svelte` | Svelte syntax | +| `components/ui/*.tsx` | PORT | `src/lib/components/ui/*.svelte` | Svelte syntax | +| `store/sites.ts` | PORT | `src/lib/stores/sites.ts` | Svelte stores | +| `store/coverage.ts` | PORT | `src/lib/stores/coverage.ts` | + Tauri commands | +| `store/*.ts` | PORT | `src/lib/stores/*.ts` | Svelte stores | +| `services/api.ts` | REWRITE | `src/lib/tauri/commands.ts` | Tauri invoke() | +| `services/websocket.ts` | REWRITE | `src/lib/tauri/events.ts` | Tauri events | +| `hooks/*.ts` | PORT | `src/lib/hooks/*.ts` | Svelte stores | +| `rf/*.ts` | PORT | `src/lib/rf/*.ts` | Direct copy | +| `types/*.ts` | PORT | `src/lib/tauri/types.ts` | Merge + simplify | + +### Desktop + +| Current File | Action | Target | Notes | +|-------------|--------|--------|-------| +| `desktop/main.js` | DELETE | N/A | Tauri handles this | +| `desktop/preload.js` | DELETE | N/A | Tauri IPC | +| `desktop/splash.html` | PORT | `src-tauri/src/splashscreen.rs` | Optional | +| Electron-builder config | REWRITE | `src-tauri/tauri.conf.json` | Tauri config | + +--- + +## Part 5: Risk Assessment + +### 5.1 Hard to Port + +| Component | Difficulty | Risk | Mitigation | +|-----------|------------|------|------------| +| Dominant path geometry | High | Complex vectorization | Extensive testing, benchmark vs Python | +| SRTM bilinear interp | Medium | Numerical precision | Unit tests with known values | +| Overpass API parsing | Medium | OSM data quirks | Use established crate (osmpbf) | +| WebGL shaders | Low | Already GLSL | Copy verbatim | +| Leaflet integration | Low | Same as React | svelte-leaflet exists | + +### 5.2 Needs R&D + +| Topic | Effort | Notes | +|-------|--------|-------| +| wgpu compute shaders | 2-3 weeks | Learn wgpu, write propagation shaders | +| SIMD geometry | 1-2 weeks | Use `packed_simd2` or `std::simd` | +| R-tree spatial index | 1 week | Evaluate `rstar` vs custom | +| SQLite FTS for OSM | 1 week | May need RTree extension | + +### 5.3 Minimum Viable Product (MVP) + +**Phase 1 MVP (Tauri shell):** +- SvelteKit UI with Leaflet +- WebGL coverage rendering +- Site management (CRUD, import/export) +- Python backend via HTTP proxy +- ~20MB bundle + +**Phase 2 MVP (Rust engine):** +- Basic propagation (FSPL, Okumura-Hata, COST-231) +- Terrain LOS (no buildings yet) +- 10km calculations in <10 seconds +- ~12MB bundle + +**Phase 3 MVP (Full Rust):** +- Buildings + dominant path +- All propagation models +- 20km full preset in <30 seconds +- ~10MB bundle + +### 5.4 Estimated Timeline + +| Phase | Duration | Cumulative | Dependencies | +|-------|----------|------------|--------------| +| Phase 1: Tauri Shell | 2-3 weeks | 3 weeks | None | +| Phase 2: Rust Engine | 4-6 weeks | 9 weeks | Phase 1 | +| Phase 3: Full Rust | 3-4 weeks | 13 weeks | Phase 2 | +| Phase 4: GPU/50km | 4-6 weeks | 19 weeks | Phase 3 | + +**Total: ~4-5 months for full migration** + +### 5.5 Key Dependencies (Rust Crates) + +```toml +[dependencies] +# Tauri +tauri = { version = "1.5", features = ["api-all"] } +tauri-plugin-store = "0.1" + +# Async runtime +tokio = { version = "1.0", features = ["full"] } + +# HTTP client +reqwest = { version = "0.11", features = ["json"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +rmp-serde = "1.1" # MessagePack + +# Database +rusqlite = { version = "0.30", features = ["bundled"] } + +# Spatial +rstar = "0.11" # R-tree +geo = "0.27" # Geometry types + +# SIMD +packed_simd2 = "0.3" # or std::simd on nightly + +# GPU (optional) +wgpu = "0.18" + +# Parallelism +rayon = "1.8" + +# Memory mapping +memmap2 = "0.9" + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" +``` + +### 5.6 Performance Targets + +| Scenario | Python Current | Rust Target | Speedup | +|----------|---------------|-------------|---------| +| 5km, fast preset | 5s | 0.5s | 10x | +| 10km, standard | 60s | 3s | 20x | +| 10km, detailed | 180s (timeout) | 10s | 18x | +| 20km, detailed | timeout | 30s | ∞ | +| 50km, full | OOM | 120s | ∞ | + +### 5.7 Bundle Size Targets + +| Component | Python/Electron | Rust/Tauri | +|-----------|----------------|------------| +| Frontend | 10MB | 2MB (Svelte is smaller) | +| Backend executable | 150MB (PyInstaller) | 8MB (Rust binary) | +| Runtime | 10MB (Electron) | 5MB (WebView2 shared) | +| **Total** | **170MB+** | **~15MB** | + +--- + +## Appendix A: Critical Algorithms in Rust + +### A.1 SIMD Haversine Distance + +```rust +use packed_simd2::f64x4; +use std::f64::consts::PI; + +const EARTH_RADIUS: f64 = 6371000.0; + +#[inline] +pub fn haversine_batch( + site_lat: f64, site_lon: f64, + lats: &[f64], lons: &[f64], +) -> Vec { + assert_eq!(lats.len(), lons.len()); + let n = lats.len(); + let mut distances = vec![0.0; n]; + + let site_lat_rad = site_lat * PI / 180.0; + let site_lon_rad = site_lon * PI / 180.0; + + let cos_site_lat = site_lat_rad.cos(); + let sin_site_lat = site_lat_rad.sin(); + + // Process 4 points at a time with SIMD + let chunks = n / 4; + for i in 0..chunks { + let base = i * 4; + + let lat_rad = f64x4::new( + lats[base] * PI / 180.0, + lats[base + 1] * PI / 180.0, + lats[base + 2] * PI / 180.0, + lats[base + 3] * PI / 180.0, + ); + let lon_rad = f64x4::new( + lons[base] * PI / 180.0, + lons[base + 1] * PI / 180.0, + lons[base + 2] * PI / 180.0, + lons[base + 3] * PI / 180.0, + ); + + let dlat = lat_rad - f64x4::splat(site_lat_rad); + let dlon = lon_rad - f64x4::splat(site_lon_rad); + + let sin_dlat_2 = (dlat * f64x4::splat(0.5)).sin_cos().0; + let sin_dlon_2 = (dlon * f64x4::splat(0.5)).sin_cos().0; + + let a = sin_dlat_2 * sin_dlat_2 + + f64x4::splat(cos_site_lat) * lat_rad.cos() + * sin_dlon_2 * sin_dlon_2; + + let c = f64x4::splat(2.0) * a.sqrt().asin(); + let dist = f64x4::splat(EARTH_RADIUS) * c; + + distances[base..base + 4].copy_from_slice(&dist.to_array()); + } + + // Handle remainder + for i in chunks * 4..n { + distances[i] = haversine_scalar(site_lat, site_lon, lats[i], lons[i]); + } + + distances +} +``` + +### A.2 Parallel Grid Calculation + +```rust +use rayon::prelude::*; + +pub fn calculate_coverage_parallel( + sites: &[Site], + settings: &CoverageSettings, + terrain: &TerrainService, + buildings: &BuildingIndex, + progress: impl Fn(f32) + Sync, +) -> Vec { + let grid = generate_grid(sites, settings); + let total = grid.len(); + let counter = std::sync::atomic::AtomicUsize::new(0); + + grid.par_iter() + .map(|coord| { + let i = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if i % 1000 == 0 { + progress(i as f32 / total as f32); + } + + calculate_point(coord, sites, settings, terrain, buildings) + }) + .collect() +} +``` + +### A.3 R-tree Spatial Index + +```rust +use rstar::{RTree, AABB, PointDistance}; + +pub struct BuildingIndex { + rtree: RTree, +} + +#[derive(Clone)] +struct BuildingRef { + id: u64, + envelope: AABB<[f64; 2]>, + height: f32, + geometry: Vec<[f64; 2]>, +} + +impl rstar::RTreeObject for BuildingRef { + type Envelope = AABB<[f64; 2]>; + fn envelope(&self) -> Self::Envelope { self.envelope } +} + +impl BuildingIndex { + pub fn new(buildings: Vec) -> Self { + let refs: Vec = buildings.iter() + .map(|b| BuildingRef { + id: b.id, + envelope: compute_envelope(&b.geometry), + height: b.height, + geometry: b.geometry.clone(), + }) + .collect(); + + Self { rtree: RTree::bulk_load(refs) } + } + + pub fn query_line(&self, p1: [f64; 2], p2: [f64; 2]) -> Vec<&BuildingRef> { + let envelope = AABB::from_corners( + [p1[0].min(p2[0]) - 0.001, p1[1].min(p2[1]) - 0.001], + [p1[0].max(p2[0]) + 0.001, p1[1].max(p2[1]) + 0.001], + ); + + self.rtree + .locate_in_envelope(&envelope) + .filter(|b| line_intersects_polygon(p1, p2, &b.geometry)) + .collect() + } +} +``` + +--- + +## Appendix B: WebGL Shader Reuse + +The GLSL shaders from `WebGLRadialCoverageLayer.tsx` can be used directly in Svelte: + +```typescript +// src/lib/webgl/shaders.ts + +export const POINT_VERTEX_SHADER = ` + attribute vec2 a_position; + attribute vec2 a_pointPos; + attribute float a_pointRsrp; + attribute float a_pointRadius; + + varying vec2 v_localPos; + varying float v_rsrp; + + void main() { + vec2 pos = a_pointPos + a_position * a_pointRadius; + gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); + v_localPos = a_position; + v_rsrp = a_pointRsrp; + } +`; + +export const POINT_FRAGMENT_SHADER = ` + precision highp float; + varying vec2 v_localPos; + varying float v_rsrp; + + void main() { + float dist = length(v_localPos); + if (dist > 1.0) discard; + float weight = exp(-dist * dist * 2.0); + gl_FragColor = vec4(weight * v_rsrp, weight, 0.0, 1.0); + } +`; + +// ... rest of shaders +``` + +--- + +## Appendix C: Tauri Configuration + +```json +// src-tauri/tauri.conf.json +{ + "build": { + "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run dev", + "devPath": "http://localhost:5173", + "distDir": "../build" + }, + "package": { + "productName": "RFCP", + "version": "2.0.0" + }, + "tauri": { + "bundle": { + "active": true, + "targets": ["nsis", "msi", "app", "dmg", "deb", "appimage"], + "identifier": "one.eliah.rfcp", + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.icns", "icons/icon.ico"], + "resources": ["terrain/**/*"], + "windows": { + "wix": null + } + }, + "windows": [ + { + "title": "RFCP - RF Coverage Planner", + "width": 1400, + "height": 900, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": null + } + } +} +``` + +--- + +## Summary + +This migration will transform RFCP from a Python/Electron application into a high-performance Rust/Tauri native app. Key benefits: + +1. **Performance:** 20-50x faster calculations through Rust + SIMD + Rayon +2. **Size:** 10-15MB bundle vs 170MB+ current +3. **Memory:** Constant memory usage via mmap and streaming +4. **Capability:** 50km+ calculations that currently timeout + +The phased approach allows incremental delivery while maintaining functionality throughout the migration. diff --git a/compass_artifact_wf-557af7f1-f9ba-4e64-ac94-f28e56ef6cd8_text_markdown.md b/compass_artifact_wf-557af7f1-f9ba-4e64-ac94-f28e56ef6cd8_text_markdown.md deleted file mode 100644 index 6ec95c0..0000000 --- a/compass_artifact_wf-557af7f1-f9ba-4e64-ac94-f28e56ef6cd8_text_markdown.md +++ /dev/null @@ -1,118 +0,0 @@ -# Smooth RF coverage maps in WebGL: a production playbook - -**Replace one line of shader code and your grid squares vanish.** The single highest-impact fix for pixelated RF coverage overlays is swapping hardware bilinear texture sampling for a **Catmull-Rom bicubic fragment shader**—9 texture fetches instead of 1, near-zero performance cost, and mathematically correct C1-continuous interpolation that passes through your actual RSRP values. Professional RF tools like Atoll and CloudRF sidestep the problem entirely by computing at every grid cell, but when you're rendering a sparse client-side grid (1,975–6,675 points), shader-based interpolation is the correct approach. This report covers exactly how to implement it, what the industry does, and which open-source libraries can accelerate the work. - -## How professional RF tools avoid the problem you're solving - -Professional RF planning tools don't interpolate sparse data—they brute-force compute signal values at every pixel. **CloudRF** runs its SLEIPNIR propagation engine server-side at user-specified resolution (down to 1 m with LiDAR), outputs GeoTIFF or pre-colored PNG, and the client simply overlays the image via `L.imageOverlay` in Leaflet or drapes it on CesiumJS for 3D. There is no client-side interpolation. **Atoll** (Forsk) computes predictions at **5–50 m grid resolution** within its desktop GIS—smoothness comes from grid density, not post-processing. **SPLAT!** does the same with Longley-Rice/ITM, outputting PPM rasters where each pixel maps 1:1 to a DEM grid cell; its `-sc` flag adds smooth color gradients but no spatial interpolation. - -The pattern is universal: propagation model → dense raster → colorize → overlay. Crowdsourced coverage services like **Ookla/Speedtest** (which uses Mapbox GL JS with WebGL rendering) and **OpenSignal** follow a different path: they aggregate sparse measurements into spatial bins, apply kernel density estimation or IDW smoothing server-side, then serve the result as raster tiles. The industry-standard interchange format is **Cloud-Optimized GeoTIFF (COG)** with bilinear or cubic resampling via GDAL at tile-generation time. - -Your situation is fundamentally different from both. You have a moderate-density regular grid arriving from the backend, and you need the client to render it smoothly at arbitrary zoom. This makes shader-based interpolation the right tool—not denser computation, not server-side tiling. - -## Catmull-Rom in a fragment shader: the production solution - -The core insight is that your current pipeline—upload grid as float texture, sample with `GL_LINEAR`, apply colormap—is already 90% correct. Hardware bilinear interpolation produces C0 continuity (values match at grid edges, but derivatives don't), causing visible seams. **Catmull-Rom spline interpolation** provides C1 continuity (smooth first derivatives) while still passing through your exact data values, unlike B-spline bicubic which smooths/blurs peaks. - -The 9-tap Catmull-Rom implementation, widely used in production (ported from TheRealMJP's gist with 108 GitHub stars), replaces your `texture2D()` call: - -```glsl -vec4 SampleTextureCatmullRom(sampler2D tex, vec2 uv, vec2 texSize) { - vec2 samplePos = uv * texSize; - vec2 texPos1 = floor(samplePos - 0.5) + 0.5; - vec2 f = samplePos - texPos1; - - vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f)); - vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f); - vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f)); - vec2 w3 = f * f * (-0.5 + 0.5 * f); - - vec2 w12 = w1 + w2; - vec2 offset12 = w2 / (w1 + w2); - - vec2 texPos0 = (texPos1 - 1.0) / texSize; - vec2 texPos3 = (texPos1 + 2.0) / texSize; - vec2 texPos12 = (texPos1 + offset12) / texSize; - - vec4 result = vec4(0.0); - result += texture2D(tex, vec2(texPos0.x, texPos0.y)) * w0.x * w0.y; - result += texture2D(tex, vec2(texPos12.x, texPos0.y)) * w12.x * w0.y; - result += texture2D(tex, vec2(texPos3.x, texPos0.y)) * w3.x * w0.y; - result += texture2D(tex, vec2(texPos0.x, texPos12.y)) * w0.x * w12.y; - result += texture2D(tex, vec2(texPos12.x, texPos12.y)) * w12.x * w12.y; - result += texture2D(tex, vec2(texPos3.x, texPos12.y)) * w3.x * w12.y; - result += texture2D(tex, vec2(texPos0.x, texPos3.y)) * w0.x * w3.y; - result += texture2D(tex, vec2(texPos12.x, texPos3.y)) * w12.x * w3.y; - result += texture2D(tex, vec2(texPos3.x, texPos3.y)) * w3.x * w3.y; - return result; -} -``` - -**Performance is essentially free.** Benchmarks on a GTX 980 show 9-tap Catmull-Rom at 1920×1080 costs **~0.32 ms** versus ~0.30 ms for single-tap bilinear. Your ~80×85 texel coverage texture is trivial. The critical rule: **interpolate raw scalar RSRP values first, then apply the colormap**. If you interpolate after colorization, you get color-space artifacts (muddy intermediate colors between discrete bands). - -For the absolute quickest improvement with zero extra texture fetches, Inigo Quilez's **smoothstep coordinate remapping trick** eliminates grid edges with a single line change: - -```glsl -vec4 textureSmooth(sampler2D tex, vec2 uv, vec2 texSize) { - vec2 p = uv * texSize + 0.5; - vec2 i = floor(p); - vec2 f = p - i; - f = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); // quintic hermite - return texture2D(tex, (i + f - 0.5) / texSize); -} -``` - -This gives C2 continuity with a single texture read, though it introduces slight positional bias. For a quick visual test before implementing full Catmull-Rom, it's ideal. - -## When to use IDW instead, and how the GPU handles it - -Catmull-Rom works because your data sits on a **regular grid**. If your backend ever returns irregular point distributions (e.g., drive-test measurements, crowdsourced data), **Inverse Distance Weighting (IDW)** on the GPU becomes the right approach. The production technique uses WebGL's additive blending to parallelize across data points rather than looping in a shader: - -For each data point, render a full-screen quad where the fragment shader computes `w = 1/dist^p` and outputs `(w × value, w, 0, 1)` into a framebuffer with `gl.blendFunc(gl.ONE, gl.ONE)`. After N passes (one per point), a final shader reads the accumulated texture and computes `interpolated_value = R_channel / G_channel`. This avoids the O(N) per-pixel loop that would choke a fragment shader at 7,000 points. - -The open-source **`mapbox-gl-interpolate-heatmap`** library implements exactly this pattern, with a `framebufferFactor` parameter (typically 0.3–0.5) that renders the IDW computation at reduced resolution for performance, then upscales for display. The **`temperature-map-gl`** library from ham-systems provides the same approach in a minimal ~3 KB package. - -**For your regular-grid case, IDW is strictly worse than Catmull-Rom**: it's slower (N draw calls vs. 9 texture fetches), creates bull's-eye artifacts around data points, and doesn't exploit grid structure. Reserve it for irregular data. - -## Mapping library internals and the tiled raster alternative - -If you want to move beyond a custom WebGL overlay, understanding how major mapping libraries handle this problem reveals useful architectural patterns: - -**Mapbox GL JS** exposes `raster-resampling: 'linear'` (bilinear) or `'nearest'` for raster tile layers—standard GPU texture filtering, same limitation you're hitting. Its built-in heatmap layer uses Gaussian kernel density estimation with additive blending into an offscreen half-float texture, designed for point density visualization, **not scalar interpolation**. However, Mapbox v3's `raster-array` source with `raster-color` expressions enables client-side colorization of raw data tiles, which is directly applicable. **deck.gl** offers `BitmapLayer` with configurable `textureParameters` (set `magFilter: 'linear'`) and supports full custom shader injection via `fs:DECKGL_FILTER_COLOR` hooks, but its `HeatmapLayer` is again KDE-based, not interpolation-based. - -For a **tiled architecture** (useful if your coverage areas grow large), the proven pipeline is: - -- Backend computes RSRP grid → stores as GeoTIFF -- GDAL resamples with `cubicspline` or `lanczos`: `gdalwarp -r cubicspline -tr 10 10 input.tif upsampled.tif` -- `gdal2tiles.py` generates XYZ tile pyramid -- Client displays via `L.tileLayer` with standard bilinear filtering - -The **IHME `leaflet.tilelayer.glcolorscale`** library offers a sophisticated variant: encode 32-bit float values into PNG RGBA channels server-side, decode in a WebGL fragment shader client-side, and apply dynamic color scales without re-tiling. Its companion `leaflet.tilelayer.gloperations` adds GPU-based convolution smoothing. This pattern preserves raw values for pixel queries while enabling dynamic color ramp changes. - -## Handling boundaries, gaps, and color mapping - -Three practical concerns beyond core interpolation deserve specific solutions. For **coverage boundaries**, apply `smoothstep` fading based on signal strength or a validity mask: - -```glsl -float signalStrength = SampleTextureCatmullRom(u_data, uv, texSize).r; -float boundaryAlpha = smoothstep(-115.0, -105.0, signalStrength); // fade near noise floor -gl_FragColor = vec4(colormap(t), boundaryAlpha * u_opacity); -``` - -For **missing grid cells**, store a validity mask as a second texture channel (or use a sentinel value like -9999). Interpolate both the value and mask with Catmull-Rom; the interpolated mask naturally creates smooth alpha transitions at data boundaries without hard edges. - -For **color mapping**, use a **1D texture lookup** rather than branching `if/else` chains in GLSL. Upload your RSRP→color ramp as a 256×1 RGBA texture, normalize your interpolated value to [0,1], and sample: `vec3 color = texture2D(u_colormap, vec2(t, 0.5)).rgb`. This is faster than arithmetic color functions and trivially supports any color ramp. The `glsl-colormap` package provides standard scientific palettes (viridis, jet, etc.) as pure GLSL functions if you prefer avoiding the extra texture. - -## Recommended implementation path - -The optimal architecture for your Electron + React + Leaflet stack, given 1,975–6,675 grid points: - -**Immediate fix (30 minutes):** Replace `texture2D(u_data, uv)` with the smoothstep trick in your existing fragment shader. This eliminates visible grid squares with zero performance cost and zero architectural changes. - -**Production implementation (1–2 days):** Implement the 9-tap Catmull-Rom `SampleTextureCatmullRom()` function. Pack your RSRP grid into a float texture (R channel = RSRP value, G channel = validity mask). Apply Catmull-Rom to both channels. Use a 1D colormap texture for color mapping. Add smoothstep boundary fading. This produces results visually indistinguishable from CloudRF-quality coverage maps. - -**If you outgrow the single-texture approach** (very large coverage areas, multiple overlapping cells): transition to the float-encoded tile pipeline using `leaflet.tilelayer.glcolorscale` or generate server-side tiles with GDAL cubic resampling. The tile pyramid handles LOD automatically and scales to arbitrarily large coverage areas. - -## Conclusion - -The gap between your current output and professional RF coverage visualization isn't architectural—it's a single shader function. Professional tools achieve smoothness through brute-force grid density (computing at every cell), but shader-based Catmull-Rom interpolation produces equivalent visual quality from sparse grids at negligible GPU cost. The 9-tap implementation requires **9 texture fetches** versus your current 1, adds C1 continuity that eliminates all visible grid edges, and preserves exact RSRP values at grid points—unlike Gaussian blur or B-spline smoothing, which distort the data. For truly irregular point data, GPU-accelerated IDW via additive blending is proven in production libraries like `mapbox-gl-interpolate-heatmap`. The critical implementation principle: always interpolate raw scalar values first, colorize second. \ No newline at end of file diff --git a/RFCP-3.10-LinkBudget-Fresnel-Interference.md b/docs/devlog/gpu_supp/RFCP-3.10-LinkBudget-Fresnel-Interference.md similarity index 100% rename from RFCP-3.10-LinkBudget-Fresnel-Interference.md rename to docs/devlog/gpu_supp/RFCP-3.10-LinkBudget-Fresnel-Interference.md diff --git a/RFCP-3.10.1-UI-Bugfixes.md b/docs/devlog/gpu_supp/RFCP-3.10.1-UI-Bugfixes.md similarity index 100% rename from RFCP-3.10.1-UI-Bugfixes.md rename to docs/devlog/gpu_supp/RFCP-3.10.1-UI-Bugfixes.md diff --git a/RFCP-3.10.2-ToolMode-ClickFixes.md b/docs/devlog/gpu_supp/RFCP-3.10.2-ToolMode-ClickFixes.md similarity index 100% rename from RFCP-3.10.2-ToolMode-ClickFixes.md rename to docs/devlog/gpu_supp/RFCP-3.10.2-ToolMode-ClickFixes.md diff --git a/RFCP-3.10.3-Calculator-Ruler-UX.md b/docs/devlog/gpu_supp/RFCP-3.10.3-Calculator-Ruler-UX.md similarity index 100% rename from RFCP-3.10.3-Calculator-Ruler-UX.md rename to docs/devlog/gpu_supp/RFCP-3.10.3-Calculator-Ruler-UX.md diff --git a/RFCP-3.10.4-TerrainClick-TxHeight.md b/docs/devlog/gpu_supp/RFCP-3.10.4-TerrainClick-TxHeight.md similarity index 100% rename from RFCP-3.10.4-TerrainClick-TxHeight.md rename to docs/devlog/gpu_supp/RFCP-3.10.4-TerrainClick-TxHeight.md diff --git a/RFCP-3.9.1-Terra-Integration.md b/docs/devlog/gpu_supp/RFCP-3.9.1-Terra-Integration.md similarity index 100% rename from RFCP-3.9.1-Terra-Integration.md rename to docs/devlog/gpu_supp/RFCP-3.9.1-Terra-Integration.md diff --git a/RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md b/docs/devlog/gpu_supp/RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md similarity index 100% rename from RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md rename to docs/devlog/gpu_supp/RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md diff --git a/RFCP-WebGL-Radial-Gradients-Task.md b/docs/devlog/gpu_supp/RFCP-WebGL-Radial-Gradients-Task.md similarity index 100% rename from RFCP-WebGL-Radial-Gradients-Task.md rename to docs/devlog/gpu_supp/RFCP-WebGL-Radial-Gradients-Task.md diff --git a/RFCP-WebGL-Smooth-Coverage-Task.md b/docs/devlog/gpu_supp/RFCP-WebGL-Smooth-Coverage-Task.md similarity index 100% rename from RFCP-WebGL-Smooth-Coverage-Task.md rename to docs/devlog/gpu_supp/RFCP-WebGL-Smooth-Coverage-Task.md diff --git a/SESSION-2026-02-04-RFCP-3.9-3.10-terrain-tools.md b/docs/devlog/gpu_supp/SESSION-2026-02-04-RFCP-3.9-3.10-terrain-tools.md similarity index 100% rename from SESSION-2026-02-04-RFCP-3.9-3.10-terrain-tools.md rename to docs/devlog/gpu_supp/SESSION-2026-02-04-RFCP-3.9-3.10-terrain-tools.md diff --git a/SESSION-2026-02-06-WebGL-Radial-Summary.md b/docs/devlog/gpu_supp/SESSION-2026-02-06-WebGL-Radial-Summary.md similarity index 100% rename from SESSION-2026-02-06-WebGL-Radial-Summary.md rename to docs/devlog/gpu_supp/SESSION-2026-02-06-WebGL-Radial-Summary.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15b6b89..3aecf21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -889,11 +889,11 @@ export default function App() { {coverageRenderer === 'canvas' && ( diff --git a/frontend/src/components/map/WebGLRadialCoverageLayer.tsx b/frontend/src/components/map/WebGLRadialCoverageLayer.tsx index 39d721e..055bd8d 100644 --- a/frontend/src/components/map/WebGLRadialCoverageLayer.tsx +++ b/frontend/src/components/map/WebGLRadialCoverageLayer.tsx @@ -195,6 +195,7 @@ export default function WebGLRadialCoverageLayer({ const boundsRef = useRef(null); const initializedRef = useRef(false); const lastPointsHashRef = useRef(''); + const instExtRef = useRef(null); // Track if points need to be re-rendered (expensive pass) const needsPointRenderRef = useRef(true); @@ -301,6 +302,8 @@ export default function WebGLRadialCoverageLayer({ // === Pass 1: Accumulate points into framebuffer (only when needed) === if (needsPointRenderRef.current) { + const t0 = performance.now(); + gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); @@ -310,13 +313,8 @@ export default function WebGLRadialCoverageLayer({ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); // Additive blending - // Bind quad buffer for point rendering - gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + // Get attribute locations const posLoc = gl.getAttribLocation(pointProgram, 'a_position'); - gl.enableVertexAttribArray(posLoc); - gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); - - // Get attribute locations for point data const pointPosLoc = gl.getAttribLocation(pointProgram, 'a_pointPos'); const pointRsrpLoc = gl.getAttribLocation(pointProgram, 'a_pointRsrp'); const pointRadiusLoc = gl.getAttribLocation(pointProgram, 'a_pointRadius'); @@ -339,24 +337,90 @@ export default function WebGLRadialCoverageLayer({ const normalizedRadiusLat = (avgCellLat * radiusMultiplier) / latRange; const normalizedRadiusLon = (avgCellLon * radiusMultiplier) / lonRange; const normalizedRadius = Math.max(normalizedRadiusLat, normalizedRadiusLon); - - log(2, 'Grid estimate:', { points: points.length, gridDim: gridDim.toFixed(1), densityBoost: densityBoost.toFixed(2), radiusMultiplier: radiusMultiplier.toFixed(1), normalizedRadius: normalizedRadius.toFixed(4) }); - - // Draw each point as a quad const rsrpRange = maxRsrp - minRsrp; - for (const p of points) { - const normX = (p.lon - bounds.minLon) / lonRange; - const normY = (p.lat - bounds.minLat) / latRange; - const normRsrp = Math.max(0, Math.min(1, (p.rsrp - minRsrp) / rsrpRange)); - gl.vertexAttrib2f(pointPosLoc, normX, normY); - gl.vertexAttrib1f(pointRsrpLoc, normRsrp); - gl.vertexAttrib1f(pointRadiusLoc, normalizedRadius); + const instExt = instExtRef.current; + const pointBuffer = pointBufferRef.current; - gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + if (instExt && pointBuffer) { + // === INSTANCED RENDERING: 1 draw call for ALL points === + + // Build instance data buffer: [posX, posY, rsrp, radius] × N points + const instanceData = new Float32Array(points.length * 4); + for (let i = 0; i < points.length; i++) { + const p = points[i]; + const normX = (p.lon - bounds.minLon) / lonRange; + const normY = (p.lat - bounds.minLat) / latRange; + const normRsrp = Math.max(0, Math.min(1, (p.rsrp - minRsrp) / rsrpRange)); + instanceData[i * 4 + 0] = normX; + instanceData[i * 4 + 1] = normY; + instanceData[i * 4 + 2] = normRsrp; + instanceData[i * 4 + 3] = normalizedRadius; + } + gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer); + gl.bufferData(gl.ARRAY_BUFFER, instanceData, gl.DYNAMIC_DRAW); + + // Bind quad buffer for a_position (per-vertex) + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + // Bind instance buffer for per-instance attributes + gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer); + const stride = 4 * 4; // 4 floats × 4 bytes + + gl.enableVertexAttribArray(pointPosLoc); + gl.vertexAttribPointer(pointPosLoc, 2, gl.FLOAT, false, stride, 0); + instExt.vertexAttribDivisorANGLE(pointPosLoc, 1); // per-instance + + gl.enableVertexAttribArray(pointRsrpLoc); + gl.vertexAttribPointer(pointRsrpLoc, 1, gl.FLOAT, false, stride, 8); + instExt.vertexAttribDivisorANGLE(pointRsrpLoc, 1); // per-instance + + gl.enableVertexAttribArray(pointRadiusLoc); + gl.vertexAttribPointer(pointRadiusLoc, 1, gl.FLOAT, false, stride, 12); + instExt.vertexAttribDivisorANGLE(pointRadiusLoc, 1); // per-instance + + // ONE draw call for ALL points! + instExt.drawArraysInstancedANGLE(gl.TRIANGLE_STRIP, 0, 4, points.length); + + // Reset divisors + instExt.vertexAttribDivisorANGLE(pointPosLoc, 0); + instExt.vertexAttribDivisorANGLE(pointRsrpLoc, 0); + instExt.vertexAttribDivisorANGLE(pointRadiusLoc, 0); + + gl.disableVertexAttribArray(posLoc); + gl.disableVertexAttribArray(pointPosLoc); + gl.disableVertexAttribArray(pointRsrpLoc); + gl.disableVertexAttribArray(pointRadiusLoc); + + const t1 = performance.now(); + log(2, 'Instanced render:', points.length, 'points in 1 call,', (t1 - t0).toFixed(1) + 'ms'); + } else { + // === FALLBACK: per-point draw calls === + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + for (const p of points) { + const normX = (p.lon - bounds.minLon) / lonRange; + const normY = (p.lat - bounds.minLat) / latRange; + const normRsrp = Math.max(0, Math.min(1, (p.rsrp - minRsrp) / rsrpRange)); + + gl.vertexAttrib2f(pointPosLoc, normX, normY); + gl.vertexAttrib1f(pointRsrpLoc, normRsrp); + gl.vertexAttrib1f(pointRadiusLoc, normalizedRadius); + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + + gl.disableVertexAttribArray(posLoc); + + const t1 = performance.now(); + log(2, 'Fallback render:', points.length, 'points in', points.length, 'calls,', (t1 - t0).toFixed(1) + 'ms'); } - gl.disableVertexAttribArray(posLoc); + log(3, 'Grid estimate:', { points: points.length, gridDim: gridDim.toFixed(1), densityBoost: densityBoost.toFixed(2), radiusMultiplier: radiusMultiplier.toFixed(1), normalizedRadius: normalizedRadius.toFixed(4) }); needsPointRenderRef.current = false; } @@ -426,6 +490,15 @@ export default function WebGLRadialCoverageLayer({ return; } + // Check for instanced rendering support + const instExt = gl.getExtension('ANGLE_instanced_arrays'); + if (instExt) { + log(2, 'Instanced rendering supported'); + instExtRef.current = instExt; + } else { + log(1, 'Instanced rendering NOT supported, using fallback'); + } + gl.enable(gl.BLEND); // Create point program