Upload two LiDAR scans. Get instant 3D change visualization, cut/fill volumes, cross-sections, and more.
No cloud uploads. No subscriptions. Everything runs locally.
Getting Started · Features · Tech Stack · API Reference · Use Cases
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ UPLOAD │ │ ALIGN │ │ DIFF │ │ VISUALIZE │
│ │────▶│ │────▶│ │────▶│ │
│ Two LAS/LAZ │ │ ICP Point │ │ Grid-based │ │ Interactive │
│ epoch files │ │ Cloud Reg. │ │ Elevation │ │ 3D Heatmap │
│ │ │ │ │ Difference │ │ + Analysis │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
|
|
|
Frontend Backend Infrastructure
───────────────────────── ───────────────────────── ─────────────────
React 18 + TypeScript Python 3.10+ Docker Compose
Three.js (WebGL) FastAPI + Uvicorn Nginx (production)
Vite (build tool) NumPy / SciPy Vite dev proxy
Canvas 2D (charts) Open3D (ICP)
CSS custom properties Matplotlib (contours)
laspy (LAS/LAZ I/O)
Browser Server
┌─────────────────────────┐ ┌──────────────────────────────┐
│ React App │ │ FastAPI │
│ ┌───────────────────┐ │ │ ┌────────────────────────┐ │
│ │ Three.js Viewer │ │ ◄──► │ │ Binary Point Streams │ │
│ │ (WebGL, 60fps) │ │ │ │ (Float32, 24 bytes/pt) │ │
│ └───────────────────┘ │ │ └────────────────────────┘ │
│ ┌───────────────────┐ │ │ ┌────────────────────────┐ │
│ │ Canvas Charts │ │ ◄──► │ │ Analysis Endpoints │ │
│ │ (histogram, xsec) │ │ │ │ (cross-sec, contours) │ │
│ └───────────────────┘ │ │ └────────────────────────┘ │
│ ┌───────────────────┐ │ │ ┌────────────────────────┐ │
│ │ Side Panel │ │ ◄──► │ │ Processing Pipeline │ │
│ │ (stats, controls) │ │ │ │ (read→align→diff→pack) │ │
│ └───────────────────┘ │ │ └────────────────────────┘ │
└─────────────────────────┘ └──────────────────────────────┘
| Requirement | Version |
|---|---|
| Python | 3.10+ |
| Node.js | 18+ |
| pip | latest |
Backend
cd backend
python -m venv .venv
# Linux / macOS
source .venv/bin/activate
# Windows
.venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000docker compose up --build| Service | URL |
|---|---|
| Frontend | http://localhost:3000 |
| Backend | http://localhost:8000 |
Step 1 Step 2 Step 3 Step 4 Step 5
┌────────┐ ┌──────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│Upload │ │Processing│ │ Explore │ │ Analyze │ │ Export │
│2 files │───▶│ pipeline │───▶│ 3D view │──▶│ volumes │──▶│screenshot │
│LAS/LAZ │ │read→align│ │ toggle │ │ profiles │ │ & share │
│ │ │→diff │ │ epochs │ │ histogram │ │ │
└────────┘ └──────────┘ └────────────┘ └────────────┘ └────────────┘
- Upload — Drag-drop two LAS/LAZ epoch files
- Process — Automated pipeline: read → ICP align → grid difference
- Explore — Orbit the 3D heatmap, toggle Epoch 1 / Epoch 2 / Diff
- Analyze — Inspect cut/fill volumes, draw cross-sections, view histogram
- Export — Screenshot the current view as PNG
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/health |
Health check |
POST |
/api/upload |
Upload two LAS/LAZ files — starts background processing |
GET |
/api/jobs/{id} |
Job status, statistics, and volumes |
GET |
/api/jobs/{id}/pointcloud/{dataset} |
Binary point cloud stream (epoch1 | epoch2 | diff) |
GET |
/api/jobs/{id}/cross-section |
Elevation profile along a line (x1,y1,x2,y2) |
GET |
/api/jobs/{id}/contours |
Contour polylines extracted from surface grid |
Each point is streamed as interleaved Float32 — no JSON overhead:
Point (epoch1/epoch2): [x, y, z, r, g, b] → 6 × 4 = 24 bytes
Point (diff): [x, y, z, r, g, b, dz] → 7 × 4 = 28 bytes
500K points = 12 MB binary vs ~80 MB JSON
TerraDiff/
│
├── backend/
│ ├── main.py # FastAPI routes (upload, status, cross-section, contours)
│ ├── models.py # Pydantic request/response schemas
│ ├── requirements.txt # Python dependencies
│ ├── Dockerfile
│ └── services/
│ ├── pointcloud.py # LAS/LAZ reading via laspy
│ ├── alignment.py # ICP registration via Open3D
│ ├── differencing.py # Grid interpolation, dZ, colormap, volumes
│ └── pipeline.py # Job state machine & binary packing
│
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Root component — state & layout orchestration
│ │ ├── App.css # Full design system (dark theme, 8px grid)
│ │ ├── types.ts # Shared TypeScript interfaces
│ │ ├── api.ts # Typed API client (fetch + binary parsing)
│ │ └── components/
│ │ ├── PointCloudViewer.tsx # Three.js scene — points, contours, tools
│ │ ├── StatsPanel.tsx # Registration, elevation change, volumes
│ │ ├── CrossSectionChart.tsx # Canvas 2D profile chart
│ │ ├── DzHistogram.tsx # Canvas 2D bar chart (40 bins)
│ │ ├── ViewerToolbar.tsx # Orbit / Measure / Cross-Section / Screenshot
│ │ ├── ViewerControls.tsx # Point size, opacity, contour toggle
│ │ ├── DzFilter.tsx # Cut / Fill / All segmented control
│ │ ├── MeasureResult.tsx # Distance measurement display
│ │ ├── Minimap.tsx # Viewport position indicator
│ │ ├── UploadPanel.tsx # Drag-drop file upload
│ │ ├── ProcessingStatus.tsx # Pipeline progress indicator
│ │ ├── TimeSlider.tsx # Epoch 1 / Epoch 2 / Diff selector
│ │ ├── PointTooltip.tsx # Clicked point coordinates
│ │ └── ColorLegend.tsx # RdBu gradient legend
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── Dockerfile
│
├── docker-compose.yml
└── README.md
| Domain | Application |
|---|---|
| Construction | Site monitoring, earthwork verification, progress tracking |
| Mining | Stockpile volume measurement, pit advancement |
| Coastal | Erosion tracking, sediment transport analysis |
| Archaeology | Excavation documentation, site change recording |
| Geohazards | Landslide assessment, slope stability monitoring |
| Infrastructure | Dam deformation, road surface degradation |
| Forestry | Canopy height change, biomass estimation |
| Agriculture | Terrain leveling verification, drainage analysis |
Why binary streaming instead of JSON?
Point cloud data is dense numerical data. JSON encoding adds quotes, commas, and brackets — inflating 24 bytes per point to ~120 bytes. TerraDiff streams raw Float32 arrays over HTTP with a single header (X-Num-Points) for metadata. The frontend parses the ArrayBuffer directly into Three.js BufferGeometry attributes with zero intermediate copies.
How does volume computation work?
After grid differencing, each cell has a known dZ value and a known area (grid_resolution²). Cut volume is the sum of |dZ| × cell_area for all cells where dZ < 0 (surface went down). Fill volume is the same for dZ > 0. Net volume = fill − cut. This is the prismoidal approximation method, standard in surveying.
How are contour lines placed in 3D?
Matplotlib's contour() extracts 2D isolines from the dZ grid. Each vertex is then lifted into 3D by sampling the mean surface (z1 + z2) / 2 via RegularGridInterpolator for accurate bilinear height placement. NaN gaps in the surface automatically split contours into separate polylines to prevent artifacts.
How does the cross-section profiling work?
When the user clicks two points on the 3D surface, the backend receives the line endpoints and samples both epoch grids (z1_grid, z2_grid) along that line using RegularGridInterpolator with 100 evenly-spaced sample points. The frontend renders a canvas-based 2D chart showing both surfaces with colored fill between them (blue = cut, red = fill).
Design system principles
The UI follows a strict dark theme with warm neutrals (#0e0e11 base) and a single desaturated teal accent (#5bb8a4). Typography uses Instrument Serif for display headings and DM Sans for body text. All spacing follows an 8px base scale. Components use layered elevation (raised → elevated → surface) with subtle borders rather than drop shadows. Animations use a snappy cubic-bezier(0.16, 1, 0.3, 1) easing curve.
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
- Fork the repository
- Create your feature branch (
git checkout -b feature/your-feature) - Commit your changes (
git commit -m 'Add your feature') - Push to the branch (
git push origin feature/your-feature) - Open a Pull Request
This project is licensed under the MIT License — see the LICENSE file for details.
Built with precision for the geospatial community.