A non-destructive Digital Terrain Model (DTM) editor that applies edits on-the-fly during tile rendering. Built with Electron + React + Rust.
Traditional DTM editors require destructive edits or full dataset re-processing. Modify a terrain feature and you either overwrite your source data or wait minutes while the system re-renders megabytes of raster data.
TerraEdit stores edits as vector geometry with parameters, then composites them into tiles as they're requested. Your source GeoTIFF stays untouched, and edits render in <150ms per tile. Draw berms, carve channels, raise regions—iterate instantly without re-processing.
Key insight: The tile server receives a tile request, finds which edits intersect that tile's bounds, applies the elevation changes pixel-by-pixel, and returns the modified image. No pre-processing, no destructive writes, instant feedback.
TerraEdit uses a three-process model:
┌─────────────────────┐
│ Electron Main │ Spawns Rust backend, manages lifecycle
│ (Node.js) │ Handles native dialogs, menus, IPC
└──────────┬──────────┘
│ IPC bridge
┌──────────▼──────────┐ ┌──────────────────────┐
│ React Renderer │────▶│ Rust HTTP Server │
│ (TypeScript) │ API │ (Axum + GDAL) │
│ - OpenLayers WebGL│ │ - Tile rendering │
│ - Zustand store │ │ - Edit application │
│ - UI components │ │ - JSON storage │
└─────────────────────┘ └──────────────────────┘
Data flow: You draw an edit → OpenLayers stores it as vector geometry → Rust backend persists to JSON → Tile requests trigger on-the-fly compositing → OpenLayers renders modified tiles
Rust backend: GDAL (geospatial raster library) is C-based, and Rust's FFI story is excellent. Axum provides async HTTP that scales to thousands of concurrent tile requests. The backend runs on a dynamic port detected at startup—no hardcoded ports, no conflicts.
Electron IPC bridge: The renderer process is sandboxed (no Node.js access), so all native operations go through a typed IPC layer. Main process spawns the Rust binary, captures its stdout to find the port, and exposes it via get_backend_url().
State split: Zustand stores UI layer state (order, names, visibility) while OpenLayers manages geometry. Undo/redo happens at the OpenLayers interaction level, and a SyncQueue coalesces operations before syncing to the backend.
Non-destructive by design: Edits are stored as edits.geojson alongside your source DTM. The original GeoTIFF is never modified. Load the project later and all edits are there, reversible.
Draw a centerline, specify top width and left/right slopes. The system calculates the full elevation buffer and renders proper slope geometry (not just a flat offset). Adjust properties via the panel and see tiles update immediately.
Technical detail: Berm bounds account for width × slope height to ensure tiles correctly detect intersection.
Draw a polygon and either set an absolute elevation or apply a relative offset. Supports holes (donut shapes) for complex terrain modifications.
In progress: Edge blending via blend_distance parameter is modeled but not yet applied.
The most sophisticated edit type: draw a centerline, and the system samples perpendicular cross-sections at start/end points. Edit the elevation profile graphically or in a table, and TerraEdit interpolates between cross-sections along the centerline.
Technical details:
- Backend samples terrain at cross-section locations via
/api/edits/sample_cross_section - Supports cut mode (excavation) and fill mode (embankment)
- Resample cross-sections when adjusting width or after other edits modify terrain
- Full undo/redo for cross-section modifications
Planned: Intermediate cross-sections along the centerline, asymmetric widths, edge blending
The tile server is the core innovation. When OpenLayers requests /tiles/{z}/{x}/{y}:
- Load base tile: Read the corresponding region from the COG (Cloud Optimized GeoTIFF)
- Find relevant edits: Query spatial index for edits intersecting this tile's bounds
- Apply edits: For each intersecting edit, modify pixel elevations
- Berms: Add elevation buffer around line geometry
- Regions: Set or offset elevation within polygon
- Channels: Apply interpolated cross-section profile
- Encode and return: Return modified tile as PNG
Optimizations:
- Spatial filtering: Only edits intersecting tile bounds are considered
- Double-buffered rendering: Crossfade swap prevents tile flashing during edits
- Edit versioning:
editVersionparameter busts tile cache when edits change - Synthetic zoom levels: Extends native COG resolutions to max zoom 26 for deep zoom
Elevation coloring: WebGL tile style variables apply dynamic color ramps based on viewport statistics. Sample min/max from current view, lock the ramp, pan somewhere else—colors stay consistent for comparison.
Hillshade: Toggle shaded relief with adjustable sun position (azimuth/elevation), vertical exaggeration, and ambient light. Computed on-the-fly in the tile server.
Color ramp lock: Lock the elevation range to maintain consistent colors across different viewport locations, or unlock to auto-sample from current view.
Frontend:
- React 19 with TypeScript
- OpenLayers 10 for WebGL tile rendering
- Zustand for global state (UI layer state, not geometry)
- Tailwind CSS 4 for styling
- Radix UI primitives for components
Backend:
- Rust 1.70+ with Axum 0.7 HTTP framework
- GDAL (via georust/gdal) for raster processing and COG conversion
- Tokio async runtime
- Filesystem-based JSON storage
Build:
- Electron + electron-vite for development and packaging
- Playwright for E2E tests
- Vitest for unit tests
- Supports macOS (universal arm64 + x64), Windows, Linux
# Install dependencies
npm install
# Development (with hot reload)
npm run dev # Runs Rust cargo-watch + Electron + Vite HMR
# Run tests
npm test # Vitest unit tests (watch mode)
npm run test:e2e # Playwright E2E tests
# Build for distribution
npm run build:mac # macOS universal binaryPrerequisites:
- GDAL development libraries (
brew install gdalon macOS) - cargo-watch for Rust hot-reload (
cargo install cargo-watch)
Hot reload workflow:
cargo-watchmonitors.rsfiles and triggers rebuilds- electron-vite restarts Electron when the Rust binary changes
- React/TypeScript changes use Vite's HMR (no restart needed)
Save any file and see changes automatically.
Frontend unit tests: Vitest + React Testing Library for components, hooks, and state management logic.
E2E tests: Playwright Electron mode tests the full application flow with real raster fixtures. The app runs in --test-mode to register test IPC handlers that bypass native dialogs.
Test fixtures: Complete sample projects with source GeoTIFF, converted COG, metadata, and edits stored in tests/fixtures/ for reproducible tests.
- Tile load time: <50ms (cached), <150ms (uncached)
- Startup time: <3 seconds (includes Rust backend spawn)
- Memory usage: ~200 MB idle, ~500 MB with large DTM
- Edit application: On-the-fly, no re-processing of source
src/
├── main/ # Electron main process (backend lifecycle, IPC)
├── preload/ # Context bridge (typed API exposure)
└── renderer/ # React frontend
├── components/ # UI components
├── hooks/ # Custom React hooks (map interactions, edits, properties)
├── lib/ # API clients, utilities
└── stores/ # Zustand state management
rust-backend/
├── src/
│ ├── api/ # Axum route handlers
│ ├── editing/ # Edit compilation and tile patching
│ ├── tile_server/ # On-the-fly tile rendering
│ ├── processing/ # COG conversion, hillshade
│ ├── storage/ # JSON persistence
│ └── main.rs # HTTP server entry point
└── Cargo.toml
See docs/feature-map.md for implementation status and planned features.
Planned: Region edge blending, intermediate channel cross-sections, asymmetric channel widths, per-project edit defaults.
MIT
