version 6-april 1

This commit is contained in:
kanyarimwangi 2026-04-30 20:46:07 +03:00
parent ccb811a459
commit d9edb69466
7 changed files with 7702 additions and 1855 deletions

579
README.md
View File

@ -1,6 +1,28 @@
# ⬡ ParcelGen — Land Subdivision Tool
A full-stack GIS application for automatic land parcel generation from user-drawn site boundaries, road networks, and existing building footprints.
> **Version 4.0** — Full-stack GIS platform for professional land parcel subdivision. Digitize site boundaries, road networks, and existing buildings; configure land use zones and road types; generate, validate, and export subdivision layouts in multiple formats.
---
## Table of Contents
1. [Architecture](#architecture)
2. [Quick Start](#quick-start)
3. [Interface Overview](#interface-overview)
4. [Digitizing Tools](#digitizing-tools)
5. [Configuration](#configuration)
6. [Land Use Zones](#land-use-zones)
7. [Road Types](#road-types)
8. [Import Data](#import-data)
9. [Export Formats](#export-formats)
10. [Map Controls](#map-controls)
11. [Subdivision Engine](#subdivision-engine)
12. [API Reference](#api-reference)
13. [Parcel Properties](#parcel-properties)
14. [Colour Legend](#colour-legend)
15. [Development](#development)
16. [File Structure](#file-structure)
17. [Technical Notes](#technical-notes)
---
@ -11,22 +33,25 @@ A full-stack GIS application for automatic land parcel generation from user-draw
│ Docker Network │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Nginx + Front │────▶│ FastAPI Backend v3 │ │
│ │ Nginx + Front │────▶│ FastAPI Backend v4 │ │
│ │ (port 3000) │ │ (port 8000) │ │
│ │ OpenLayers UI │ │ Shapely / GEOS engine │ │
│ └──────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────┘
│ Browser
│ Browser (desktop or mobile)
http://localhost:3000
```
The frontend is a single HTML file with no build step. The backend is a stateless FastAPI service — each subdivision request is independent, making the system safe for multiple simultaneous users.
---
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Docker and Docker Compose installed
### Run
@ -35,7 +60,7 @@ cd parcel-tool
docker compose up --build
```
Open **http://localhost:3000** in your browser.
Open **http://localhost:3000** in your browser (desktop or mobile).
### Stop
@ -45,145 +70,278 @@ docker compose down
---
## Feature Overview
## Interface Overview
### Drawing Tools
| Tool | Description |
|---|---|
| **◇ Boundary** | Draw the site boundary polygon. Double-click to finish. Only one boundary is kept at a time. |
| ** Road** | Draw road centrelines. Each line is buffered to the configured road width. Draw multiple; all are used. |
| **▦ Building** | Draw existing building footprints. Carved out before subdivision; containing parcel flagged as "built". |
| **📏 Measure** | Click to place measure points, double-click to finish. Shows live distance in the active unit system (m/km or ft/mi). |
| **✕ Stop** | Exit any active draw or measure mode. |
### Map Interaction
| Action | Result |
|---|---|
| **Hover** over a parcel | Floating tooltip: area (dual units), frontage, depth, status, road access |
| **Click** a parcel | Pins a detail panel bottom-left; highlights parcel in gold |
| **Click** empty map | Clears selection |
| **Scroll / drag** | Standard map pan and zoom |
The UI is fully **mobile responsive**. On small screens a `☰` button slides the sidebar in and out. On desktop the sidebar is always visible on the left.
### Sidebar Tabs
- **Draw** — Drawing tools and Run Subdivision / Export buttons
- **Config** — Subdivision parameters with live unit conversion
- **Layers** — Visibility, opacity, basemap switcher, and legend
- **Results** — Stats dashboard and scrollable parcel list
| Tab | Purpose |
|---|---|
| **Digitize** | All digitizing tools, import, and generation controls |
| **Config** | Subdivision parameters, land use zone, numbering |
| **Layers** | Basemap switcher, layer visibility and opacity, legend |
| **Results** | Statistics dashboard, warnings, and scrollable plot list |
### Header Controls
| Control | Description |
|---|---|
| ☀️ / 🌙 | Toggle dark / light theme (saved to localStorage) |
| **m / ft** | Switch between metric and imperial units (live conversion) |
---
## UI Controls
## Digitizing Tools
### Theme Toggle (sun/moon button)
All geometry is entered on the map using the **Digitize** tab or the floating **map toolbar**.
Click the ☀️ / 🌙 button in the top-left of the sidebar header to switch between **dark** and **light** themes. The preference is saved in `localStorage` and restored on next visit. Switching theme also switches the default basemap automatically.
### Toolbar Buttons
### Unit System (m / ft)
| Button | Mode | Description |
|---|---|---|
| ◇ **Boundary** | Polygon | Digitize the site boundary. One boundary is kept at a time. Double-click to close the polygon. |
| **Road** | LineString | Digitize a straight road centreline. The road type and width set in the sidebar apply to this line. |
| ⌒ **Arc Road** | LineString (curved) | Digitize a curved road centreline using free-form multi-vertex input. Treated identically to straight roads by the engine. |
| ▦ **Building** | Polygon | Digitize an existing building footprint. The engine carves it from the buildable area and marks the containing plot as "built". |
| 📏 **Measure** | LineString | Click points, double-click to finish. Shows live geodesic distance in the active unit (m/km or ft/mi). |
| ✋ **Pan** | — | Temporarily disables digitizing and enables free map panning. Click Stop or Pan again to exit. |
| ✕ **Stop** | — | Exits the current digitizing or measure mode. |
Click the **m** or **ft** pill to toggle metric/imperial. All config inputs convert in-place. Results display both units. The backend always receives and returns SI (metres, m²).
### Editing Vertices
Click **Edit Vertices** in the Digitize tab to enter modify mode on the boundary:
- **Drag** any vertex to move it.
- **Alt + Click** a vertex to delete that single vertex without clearing the entire feature.
- **Right-click** during active digitizing to undo the last placed point.
### Managing Drawn Roads
Each digitized road appears in a list in the sidebar showing its type and a colour indicator. Click the **✕** button next to any road to delete just that line — other roads and all results are unaffected.
---
## Configuration
All parameters are in the **Config** tab. Switching between **m** and **ft** converts all values in-place; the backend always receives metres.
### Plot Dimensions
| Parameter | Default | Description |
|---|---|---|
| **Min Frontage** | 10 m | Minimum road-facing width of a plot (the side that faces the street) |
| **Min Plot Depth** | 25 m | Minimum perpendicular dimension (how deep the plot runs away from the road) |
| **Min Plot Area** | 250 m² | Smallest acceptable plot area. Plots below this are absorbed into neighbours. |
| **Max Plot Area** | 1 000 m² | Largest acceptable plot area. Oversized plots are flagged in the results. Set to 0 for no upper limit. |
> **Terminology note:** *Frontage* is the road-facing dimension. *Plot Depth* is the dimension perpendicular to the road (sometimes called breadth or width in other tools — here it is consistently called depth to match survey practice).
### Road Parameters
| Parameter | Default | Description |
|---|---|---|
| **Default Road Width** | 9 m | Width applied to auto-generated grid roads and to digitized roads with no custom width |
| **Max Block Length** | 120 m | Maximum buildable block length before a cross-road or cul-de-sac is inserted |
| **Splay Radius** | 3 m | Corner treatment at road intersections (also called a *splay* in survey practice — a diagonal cut-off at junctions). Changing the unit system converts this value correctly. |
### Numbering & Zoning
| Parameter | Default | Description |
|---|---|---|
| **Parcel ID Prefix** | P | Letter(s) prepended to every plot number (e.g. `P0001`, `R0001`) |
| **Start Number** | 1 | First sequential number used in plot IDs |
### Options
- **Allow Cul-de-sacs** — Insert a cul-de-sac when a block exceeds Max Block Length and no through-road is available.
- **Auto-generate Roads** — When enabled, internal grid roads are generated to fill any blocks that exceed Max Block Length, even when user-drawn roads are present. User roads and auto-roads are always combined.
---
## Land Use Zones
Select a zone type in the **Config** tab. Choosing a zone automatically fills all dimension parameters with appropriate defaults for that use class.
| Zone | Min Frontage | Min Depth | Min Area | Max Area | Typical Use |
|---|---|---|---|---|---|
| Residential Low Density | 15 m | 30 m | 450 m² | 2 000 m² | Large single-family plots |
| Residential Medium Density | 10 m | 25 m | 250 m² | 1 000 m² | Standard residential (default) |
| Residential High Density | 6 m | 20 m | 120 m² | 500 m² | Town houses, maisonettes |
| Commercial | 12 m | 20 m | 300 m² | 5 000 m² | Shops, offices, mixed retail |
| Mixed Use | 8 m | 20 m | 200 m² | 2 000 m² | Ground-floor commercial + residential above |
| Industrial | 20 m | 40 m | 1 000 m² | 20 000 m² | Warehousing, light manufacturing |
| Open Space / Park | 30 m | 30 m | 500 m² | 50 000 m² | Public parks, green spaces |
Each zone type is colour-coded on the map so mixed-zone layouts are immediately legible.
---
## Road Types
When digitizing a road, select its type from the **Road Type** dropdown. Each type has a default width that determines how much land is allocated to the road corridor.
| Type | Default Width | Typical Use |
|---|---|---|
| **Primary** | 20 m | Arterial roads, dual carriageways |
| **Secondary** | 14 m | Collector roads, main estate roads |
| **Local** | 9 m | Residential access roads (default) |
| **Lane / Alley** | 4 m | Service lanes, pedestrian alleys |
You can override the width for any individual road using the **Custom Width** field before digitizing. This is useful when a road does not match a standard type exactly.
Multiple roads of different types can coexist in the same layout. The engine buffers each road to its own width before computing the buildable area.
---
## Import Data
Click **Import Data…** in the Digitize tab or drag a file onto the drop zone.
### Supported Formats
| Format | Extension | What is imported |
|---|---|---|
| **GeoJSON** | `.geojson`, `.json` | All feature types auto-routed by geometry |
| **KML** | `.kml` | Polygons → boundary or buildings; Lines → roads |
| **GPX** | `.gpx` | Tracks and routes → road centrelines |
| **CSV** | `.csv` | Rows with `lon`/`lat` columns or a `wkt` column |
| **Shapefile** | `.zip` | Zipped `.shp` package — polygons and lines |
### Geometry Routing
Imported features are automatically assigned to the correct layer:
- **Polygon / MultiPolygon** — If small (< ~50 m across) it is added as a building footprint; otherwise it replaces the boundary.
- **LineString / MultiLineString** — Added as a road centreline with type *Local*.
- **Point** — Placed on the map for reference only.
After import the map zooms to fit the imported data.
---
## Export Formats
Click **Export…** in the Digitize tab after generating a subdivision.
| Format | File | Contents | Best for |
|---|---|---|---|
| **GeoJSON** | `.geojson` | All parcels, roads, cul-de-sacs + metadata | Web apps, QGIS, PostGIS |
| **KML** | `.kml` | All features as Placemarks with attributes | Google Earth, Google Maps |
| **Shapefile** | `.zip` | Parcels as `.shp/.shx/.dbf/.prj` package | ArcGIS, MapInfo, QGIS |
| **CSV** | `.csv` | Parcel attribute table only (no geometry) | Excel, data analysis |
All exports use **WGS84 (EPSG:4326)** coordinates. All attribute values are in SI units (metres, m²) regardless of the active unit display.
---
## Map Controls
### Theme
The ☀️ / 🌙 button in the sidebar header switches between **dark** and **light** themes. The preference is saved in `localStorage` and restored on next visit. Switching theme also switches the default basemap to match.
### Unit System
The **m / ft** pill converts all configuration inputs in-place when toggled. Results always display both metric and imperial values simultaneously. The backend always receives and processes in metres.
### Basemap Switcher
12 basemaps in 5 categories, accessible from the **Layers** tab or the floating **Basemap** panel (bottom-right):
Available in the **Layers** tab and the floating **Basemap** panel (bottom-right of map).
| Category | Options |
| Category | Basemaps |
|---|---|
| Style | Dark (CARTO), Light (CARTO), Night Lights |
| Street | OpenStreetMap |
| Terrain | OpenTopoMap, ESRI Topo, ESRI Shaded Relief, Ocean |
| Imagery | ESRI Satellite, ESRI Satellite + Labels |
| Artistic | Stamen Terrain, Stamen Watercolor |
| Terrain | OpenTopoMap, ESRI World Topo, ESRI Shaded Relief, Ocean |
| Imagery | ESRI World Imagery (Satellite) |
| No API key required for any basemap. |
### Layer Switcher
Both the floating **Layers** panel and the **Layers** tab expose per-layer controls:
- Visibility checkbox
- Opacity slider (0100%, live update)
- Colour swatch
Both the floating **Layers** panel and the **Layers** tab provide per-layer controls:
Layers: Boundary · Buildings (drawn) · Roads (drawn) · Road surfaces · Cul-de-sacs · Blocks · Parcels
- **Visibility checkbox** — show/hide the layer
- **Opacity slider** — 0100%, updates live
Layers available: Site Boundary · Buildings (digitized) · Roads (digitized) · Road Surfaces (result) · Cul-de-sacs (result) · Blocks (result) · Parcels (result)
### Hover Tooltip
Hovering over any generated plot shows a floating tooltip with: area (both units), frontage, plot depth, land use zone, status, and road access. The tooltip flips direction automatically near the map edges.
### Click Selection
Clicking a plot highlights it in gold and opens a pinned detail panel (bottom-left). Clicking empty map or pressing ✕ clears the selection.
### Measure Tool
The 📏 Measure button activates a distance tool. Click to place vertices; double-click to finish. The measured length is shown as a live floating label near the last vertex and reported in the status bar. The result respects the active unit system.
### Pan Mode
The ✋ Pan button temporarily suspends all digitizing tools and sets the cursor to a grab hand for comfortable map navigation. Useful mid-digitizing session when you need to reposition the view.
### Progress Bar
A floating progress bar appears at the bottom of the map during subdivision generation. It polls the backend every 400 ms and shows the current processing stage (parsing → roads → blocks → subdividing → QC → access enforcement → buildings → numbering).
---
## Subdivision Engine
### Algorithm
### Processing Pipeline
```
1. Parse boundary polygon (WGS84 / EPSG:4326)
2. Parse user-drawn road centrelines + existing building footprints
3. Build road network:
a. Buffer user roads → road polygons
b. Auto-generate grid roads to fill blocks > max_block_length
(combined with user roads, never either/or)
c. Add perimeter access ring inside the boundary edge
4. Carve: buildable = boundary roads buildings
5. Extract contiguous buildable blocks
6. Generate cul-de-sacs for oversized blocks (if enabled)
7. Subdivide each block into rectangular parcels:
a. Detect dominant orientation via oriented bounding box
b. Double-bank parcels back-to-back where depth allows
c. Clip each cell to the actual block polygon
8. Quality passes:
a. Absorb undersized / badly-shaped parcels into neighbours
b. Enforce road access — merge landlocked parcels into accessed neighbour
9. Apply existing buildings → merge spanning parcels, mark status="built"
10. Re-number sequentially, assign addresses
11. Return GeoJSON + stats
1. Parse and validate the site boundary polygon (WGS84)
2. Validate configuration:
- Warn if min_area > 50% of site area
- Warn if max_area < min_area
- Warn if splay_radius is incompatible with road width
3. Parse road centrelines (with per-road type and width)
4. Parse existing building footprints
5. Build road network:
a. Buffer each user road to its configured width
b. Auto-generate grid roads for blocks that exceed max_block_length
(user roads and auto-roads are always combined)
c. Add perimeter access ring at half road-width inside boundary edge
6. Carve buildable area: boundary all roads all buildings
7. Extract contiguous buildable blocks
8. Generate cul-de-sacs for oversized blocks (if allow_culdesac = true)
9. Subdivide each block into rectangular plots:
a. Detect dominant axis via oriented bounding box (OBB)
b. Orient frontage to face the block's longest road-adjacent edge
c. Apply double-banking (back-to-back plots) where plot depth allows
d. Clip each plot cell to the actual block polygon
10. Quality pass A — Shape control:
Absorb undersized or badly-shaped plots into best-touching neighbour
11. Quality pass B — Access enforcement:
Any plot with no road adjacency is merged into its nearest
same-block neighbour that does have access (loop until stable)
12. Apply existing buildings:
Merge plots spanning a building footprint into one, mark status="built"
13. Renumber all plots sequentially using configured prefix and start number
14. Assign addresses: "Block N, Plot N"
15. Return GeoJSON feature collections + statistics + warnings
```
### Parcel Access Guarantee
### Road Access Guarantee
Every parcel is checked for road adjacency. Landlocked parcels are merged into their nearest same-block neighbour that has road access. This loop runs until stable. Any remaining inaccessible parcels are flagged in red on the map and counted in the stats panel.
Every plot is tested with `plot.buffer(1 m).intersects(road_union)`. Any plot that fails is merged iteratively into its nearest same-block neighbour that passes. The loop runs until all plots are accessible or no further merging is possible. Remaining inaccessible plots (rare edge cases on very irregular geometries) are flagged in red on the map and counted in the stats.
### Metric Scaling
### Coordinate Scaling
All internal geometry is in WGS84 degrees. A scale factor (metres-per-degree) is computed at the site centroid so that metre-based config values work correctly at any latitude:
All internal geometry is in WGS84 degrees. A scale factor is computed at the site centroid to convert metre-based parameters to degrees correctly at any latitude:
```
m_per_deg = (111 320 + 111 320 × cos(lat)) / 2
```
---
This ensures road widths, plot dimensions, and block lengths behave correctly whether the site is near the equator or at higher latitudes.
## Default Configuration
### Multi-User Safety
| Parameter | Default | Description |
|---|---|---|
| `min_frontage` | 12 m | Minimum parcel road frontage |
| `min_depth` | 25 m | Minimum parcel depth |
| `road_width` | 9 m | Road right-of-way width |
| `max_block_length` | 120 m | Max block length before road/cul-de-sac insertion |
| `allow_culdesac` | true | Generate cul-de-sacs for oversized blocks |
| `corner_radius` | 3 m | Road corner rounding radius |
---
## Parcel Properties (GeoJSON)
```json
{
"parcel_id": "P0042",
"parcel_num": 42,
"block_id": 3,
"area_m2": 340.5,
"area_ha": 0.034,
"frontage_m": 12.3,
"depth_m": 27.7,
"address": "Block 3, Plot 42",
"zone": "Residential",
"status": "vacant",
"has_access": true,
"frontage_ok": true,
"area_ok": true,
"building_area_m2": 0
}
```
`status` is `"built"` when an existing building footprint overlaps the parcel.
Each `POST /subdivide` request receives a unique `session_id`. Progress state is stored per session in an in-memory dict and purged 10 seconds after completion. There is no shared mutable state between concurrent requests.
---
@ -191,27 +349,49 @@ m_per_deg = (111 320 + 111 320 × cos(lat)) / 2
### `POST /subdivide`
**Request body:**
```json
{
"boundary": { "type": "Polygon", "coordinates": [[...]] },
"boundary": {
"type": "Polygon",
"coordinates": [[[lon, lat], ...]]
},
"roads": [
{ "type": "Feature", "geometry": { "type": "LineString", "coordinates": [[...]] }, "properties": {} }
{
"geometry": { "type": "LineString", "coordinates": [[lon, lat], ...] },
"road_type": "local",
"width": null,
"label": "Main Access Road"
}
],
"existing_features": [
{ "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[...]] }, "properties": {} }
{
"type": "Feature",
"geometry": { "type": "Polygon", "coordinates": [[[lon, lat], ...]] },
"properties": {}
}
],
"config": {
"min_frontage": 12,
"min_depth": 25,
"road_width": 9,
"min_frontage": 10,
"min_plot_depth": 25,
"min_area": 250,
"max_area": 1000,
"default_road_width": 9,
"max_block_length": 120,
"splay_radius": 3,
"allow_culdesac": true,
"corner_radius": 3
}
"land_use": "residential_medium",
"parcel_prefix": "P",
"start_number": 1
},
"session_id": "optional-client-generated-id"
}
```
Both bare geometry dicts and GeoJSON Feature wrappers are accepted.
- `road_type`: `"primary"` | `"secondary"` | `"local"` | `"lane"`
- `width`: optional float (metres) to override the type default
- Both bare geometry dicts and GeoJSON Feature wrappers are accepted for all geometry fields.
**Response:**
@ -221,10 +401,11 @@ Both bare geometry dicts and GeoJSON Feature wrappers are accepted.
"roads": [ ],
"blocks": [ ],
"culdesacs": [ ],
"session_id": "...",
"stats": {
"total_parcels": 42,
"total_blocks": 4,
"total_roads": 3,
"total_roads": 5,
"culdesacs": 1,
"parcels_no_access": 0,
"parcels_built": 2,
@ -233,44 +414,149 @@ Both bare geometry dicts and GeoJSON Feature wrappers are accepted.
"road_area_m2": 2100.0,
"buildable_area_m2": 14200.0,
"avg_parcel_area_m2": 338.1,
"min_parcel_area_m2": 215.0,
"max_parcel_area_m2": 490.0,
"existing_buildings": 2,
"user_roads_drawn": 1
"user_roads_drawn": 2,
"land_use": "residential_medium",
"land_use_label": "Residential Medium Density",
"warnings": []
}
}
```
### `GET /health`
Returns `{"status": "ok", "version": "3.0.0"}`
---
### `GET /config/defaults`
Returns the default `SubdivisionConfig` as JSON.
### `GET /progress/{session_id}`
Returns live progress for an ongoing or recently completed subdivision.
```json
{ "pct": 75, "msg": "Subdividing block 3/4…" }
```
`pct` ranges from 0100. A value of `-1` means the session was not found or errored.
---
## Map Colour Legend
### `GET /land-use-presets`
Returns the full land use preset table (min/max dimensions and areas for all 7 zone types).
---
### `GET /road-type-defaults`
Returns default widths and display colours for all 4 road types.
---
### `GET /health`
Returns `{"status": "ok", "version": "4.0.0"}`.
---
### `GET /config/defaults`
Returns the default `SubdivisionConfig` as a JSON object.
---
## Parcel Properties
Each parcel feature in the response includes:
```json
{
"parcel_id": "P0042",
"parcel_num": 42,
"block_id": 3,
"area_m2": 338.5,
"area_ha": 0.0339,
"frontage_m": 10.2,
"plot_depth_m": 33.2,
"address": "Block 3, Plot 42",
"land_use": "residential_medium",
"land_use_label": "Residential Medium Density",
"zone_color": "#3fb950",
"zone": "Residential Medium Density",
"status": "vacant",
"has_access": true,
"frontage_ok": true,
"area_ok": true,
"within_max_area": true,
"building_area_m2": 0
}
```
| Property | Type | Description |
|---|---|---|
| `parcel_id` | string | Formatted ID using prefix + sequential number |
| `frontage_m` | float | Road-facing plot width in metres |
| `plot_depth_m` | float | Perpendicular plot dimension in metres |
| `land_use` | string | Zone key (e.g. `residential_medium`) |
| `land_use_label` | string | Human-readable zone name |
| `zone_color` | string | Hex fill colour for the zone type |
| `status` | string | `"vacant"` or `"built"` (existing building present) |
| `has_access` | bool | Whether the plot has direct road adjacency |
| `frontage_ok` | bool | `true` if frontage ≥ 85% of min_frontage |
| `area_ok` | bool | `true` if area is within min/max bounds |
| `within_max_area` | bool | `true` if area ≤ max_area × 1.1 |
| `building_area_m2` | float | Area of existing building inside this plot (0 if vacant) |
---
## Colour Legend
| Colour | Meaning |
|---|---|
| Blue outline | Vacant parcel |
| Orange outline | Built parcel (existing building) |
| Red dashed outline | No road access ⚠ |
| Gold outline | Currently selected parcel |
| Blue outline | Vacant residential (medium density) plot |
| Green outline | Vacant low-density / open-space plot |
| Yellow outline | Vacant high-density plot |
| Orange outline | Vacant commercial or industrial plot |
| Red outline | Vacant mixed-use plot |
| Orange fill, orange outline | Built plot (existing building footprint) |
| Red dashed outline | ⚠ Plot with no road access |
| Gold outline | Currently selected plot |
| Yellow fill | Road surface |
| Purple fill | Cul-de-sac |
| Green dashed | Site boundary |
| Purple dashed | Drawn building footprint |
| Green dashed line | Site boundary |
| Purple dashed outline | Digitized building footprint |
| Teal dashed line | Measure tool sketch |
---
## Export
## Export Formats — Technical Details
**Export GeoJSON** in the Draw tab downloads a `.geojson` file with all parcels, roads, and cul-de-sacs plus a `metadata` block (timestamp, display units). All attribute values are always in SI units regardless of the unit switcher.
### GeoJSON
Standard RFC 7946 FeatureCollection. Includes a `metadata` block at the top level with generation timestamp and display units. Compatible with QGIS, PostGIS, Mapbox, Leaflet, and all major GIS tools.
### KML
Valid KML 2.2 document. Each feature becomes a `<Placemark>` with all parcel properties in a CDATA description block. Open in Google Earth Pro or import into Google My Maps.
### Shapefile
Pure in-browser Shapefile generation (no server required). The download is a `.zip` containing:
- `parcels.shp` — polygon geometry (type 5)
- `parcels.shx` — spatial index
- `parcels.dbf` — attribute table (parcel_id, area_m2, frontage_m, plot_dep, block_id, status, zone)
- `parcels.prj` — WGS84 projection definition
Open in ArcGIS, QGIS, MapInfo, or AutoCAD Map. The `.zip` is generated entirely in the browser using JSZip.
### CSV
Attribute-only export of all parcel properties as comma-separated values. No geometry column. Use in Excel, Google Sheets, or any data analysis tool.
---
## Development
### Backend
### Backend (Python 3.11 / FastAPI)
```bash
cd backend
@ -278,9 +564,19 @@ pip install -r requirements.txt
uvicorn main:app --reload --port 8000
```
### Frontend
The backend has no database dependency. All state is computed per request. The in-memory progress dict is the only cross-request state and is keyed by session ID.
Open `frontend/index.html` directly in a browser. `API_URL` auto-detects: port 3000 proxies to port 8000, otherwise uses same origin.
### Frontend (plain HTML + OpenLayers)
Open `frontend/index.html` directly in a browser for development. The `API_URL` is auto-detected:
- If served on port 3000 → proxies to port 8000
- Otherwise → uses same origin with port 8000
No build step, no npm, no bundler. All dependencies are loaded from CDNs:
- **OpenLayers 9.1** — map rendering and interactions
- **JSZip 3.10** — in-browser Shapefile ZIP generation
---
@ -293,20 +589,27 @@ parcel-tool/
├── backend/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── main.py ← Subdivision engine (v3)
│ └── main.py ← Subdivision engine (v4)
└── frontend/
├── Dockerfile
├── nginx.conf
└── index.html ← OpenLayers UI
└── index.html Single-file OpenLayers UI (v4)
```
---
## Technical Notes
- **Coordinate system:** EPSG:4326 for API I/O; EPSG:3857 for map display
- **Geometry engine:** Shapely 2.x + GEOS
- **Basemaps:** CARTO, OSM, Esri, Stamen — no API key needed
- **Theme:** Persisted in `localStorage` under key `parcelgen-theme`
- **Imperial mode:** Display only — backend always receives metres
- **Performance:** Sites up to ~50 ha typically process in under 2 s
| Topic | Detail |
|---|---|
| **Coordinate system** | EPSG:4326 (WGS84) for all API I/O; EPSG:3857 (Web Mercator) for map display |
| **Geometry engine** | Shapely 2.x with GEOS backend |
| **Basemaps** | CARTO, OpenStreetMap, Esri — no API key required |
| **Theme persistence** | `localStorage` key `pg-theme` |
| **Unit system** | Imperial is display-only — backend always processes in metres |
| **Multi-user** | Stateless per request; progress keyed by session_id |
| **Mobile** | Responsive at ≤ 768 px; sidebar slides in/out |
| **Arc roads** | Free-form polyline — curved roads are supported by the digitizer and treated identically to straight roads by the subdivision engine |
| **Vertex delete** | Alt + Click on a vertex in Edit mode deletes that vertex only |
| **Right-click** | During active digitizing, right-click removes the last placed point |
| **Performance** | Sites up to ~50 ha typically complete in under 3 s on standard hardware |

File diff suppressed because it is too large Load Diff

651
backend/main2.py Normal file
View File

@ -0,0 +1,651 @@
"""
Parcel Subdivision Engine - FastAPI Backend v3
- User roads + auto-fill roads combined (not either/or)
- Existing building footprints respected: carved from buildable area,
containing parcel flagged status=built
- Perimeter access road always present
- Access enforcement pass
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional, Tuple, Dict, Any
import math, uuid, traceback
from shapely.geometry import (
Polygon, MultiPolygon, LineString, MultiLineString,
Point, GeometryCollection, mapping, shape
)
from shapely.ops import unary_union
from shapely.affinity import rotate
from shapely.validation import make_valid
app = FastAPI(title="Parcel Subdivision Engine", version="3.0.0")
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"],
)
# ─── Models ───────────────────────────────────────────────────────────────────
class SubdivisionConfig(BaseModel):
min_frontage: float = 12.0
min_depth: float = 25.0
road_width: float = 9.0
max_block_length: float = 120.0
allow_culdesac: bool = True
corner_radius: float = 3.0
min_area: Optional[float] = None
def model_post_init(self, _):
if self.min_area is None:
self.min_area = self.min_frontage * self.min_depth
class SubdivisionRequest(BaseModel):
boundary: Dict[str, Any]
roads: Optional[List[Dict[str, Any]]] = []
existing_features: Optional[List[Dict[str, Any]]] = []
zones: Optional[List[Dict[str, Any]]] = []
config: Optional[SubdivisionConfig] = None
# ─── Geometry helpers ─────────────────────────────────────────────────────────
def valid(geom):
if geom is None or geom.is_empty:
return geom
if not geom.is_valid:
geom = make_valid(geom)
return geom
def extract_geometry(obj: Dict) -> Any:
"""Accept GeoJSON Feature wrapper or bare geometry dict."""
if obj is None:
raise ValueError("Null geometry")
geo = obj.get("geometry", obj) if obj.get("type") == "Feature" else obj
if geo is None or not geo.get("type"):
raise ValueError(f"Cannot extract geometry from: {list(obj.keys())}")
return shape(geo)
def flatten_polygons(geom) -> List[Polygon]:
"""Recursively extract all Polygon parts from any geometry."""
if geom is None or geom.is_empty:
return []
if isinstance(geom, Polygon):
return [geom]
if isinstance(geom, (MultiPolygon, GeometryCollection)):
out = []
for g in geom.geoms:
out.extend(flatten_polygons(g))
return out
return []
def obb(polygon: Polygon) -> Tuple[float, float, float]:
"""Oriented bounding box → (short, long, angle_deg)."""
mbr = polygon.minimum_rotated_rectangle
coords = list(mbr.exterior.coords)
e1 = math.hypot(coords[1][0]-coords[0][0], coords[1][1]-coords[0][1])
e2 = math.hypot(coords[2][0]-coords[1][0], coords[2][1]-coords[1][1])
ang = math.degrees(math.atan2(coords[1][1]-coords[0][1], coords[1][0]-coords[0][0]))
return (min(e1,e2), max(e1,e2), ang)
def estimate_scale(boundary: Polygon) -> float:
"""Metres per degree at boundary centroid latitude."""
lat = boundary.centroid.y
return (111_320.0 + 111_320.0 * math.cos(math.radians(lat))) / 2.0
def deg(metres: float, sc: float) -> float:
"""Convert metres → degrees using scale factor."""
return metres / sc
def buffer_line(line, width_deg: float) -> Polygon:
return line.buffer(width_deg / 2, cap_style=2, join_style=2)
# ─── Road network ─────────────────────────────────────────────────────────────
def build_road_network(
boundary: Polygon,
user_roads: List[LineString],
config: SubdivisionConfig,
sc: float
) -> Tuple[List[Polygon], Polygon]:
"""
Build the complete road network:
1. Buffer every user-drawn road centreline.
2. Auto-generate grid roads for any block that exceeds max_block_length
(regardless of whether the user drew some roads already).
3. Add a perimeter half-road ring so every outer block has access.
Returns (road_polygons_for_display, road_union_polygon).
"""
rw = deg(config.road_width, sc)
bl = deg(config.max_block_length, sc)
step = bl + rw # spacing between road centrelines
road_polys: List[Polygon] = []
# ── Step 1: user roads ────────────────────────────────────────────────────
for line in user_roads:
cl = valid(line.intersection(boundary))
if cl and not cl.is_empty:
rp = valid(buffer_line(cl, rw).intersection(boundary))
if rp and not rp.is_empty:
road_polys.append(rp)
# ── Step 2: auto-fill grid roads ──────────────────────────────────────────
# Always run auto-generation, but only add roads that fall in areas NOT
# already covered by user roads (avoids duplicate overlapping roads).
existing_road_union = valid(unary_union(road_polys)) if road_polys else Polygon()
minx, miny, maxx, maxy = boundary.bounds
site_w = maxx - minx
site_h = maxy - miny
def try_add(line: LineString):
cl = valid(line.intersection(boundary))
if not cl or cl.is_empty or cl.length < deg(config.min_frontage * 2, sc):
return
rp = valid(buffer_line(cl, rw).intersection(boundary))
if not rp or rp.is_empty:
return
# Skip if this road is already substantially covered by user roads
if not existing_road_union.is_empty:
overlap = rp.intersection(existing_road_union).area / rp.area
if overlap > 0.6:
return
road_polys.append(rp)
# Vertical auto-roads
x_positions = []
x = minx + step
while x < maxx - rw:
x_positions.append(x)
x += step
if not x_positions and site_w > deg(config.min_depth * 2 + config.road_width, sc):
x_positions = [minx + site_w / 2]
for x in x_positions:
try_add(LineString([(x, miny - 1), (x, maxy + 1)]))
# Horizontal auto-roads
y_positions = []
y = miny + step
while y < maxy - rw:
y_positions.append(y)
y += step
if not y_positions and site_h > deg(config.min_depth * 2 + config.road_width, sc):
y_positions = [miny + site_h / 2]
for y in y_positions:
try_add(LineString([(minx - 1, y), (maxx + 1, y)]))
# ── Step 3: perimeter access ring ────────────────────────────────────────
perim_width = deg(config.road_width * 0.5, sc)
inner = valid(boundary.buffer(-perim_width))
if inner and not inner.is_empty and inner.area > 0:
perim = valid(boundary.difference(inner))
if perim and not perim.is_empty:
road_polys.append(perim)
# Final road union
road_union = valid(unary_union(road_polys)) if road_polys else Polygon()
return road_polys, road_union
# ─── Existing features (buildings) ───────────────────────────────────────────
def process_existing_features(
building_geoms: List[Polygon],
buildable: Polygon,
parcels: List[Dict],
sc: float,
config: SubdivisionConfig
) -> List[Dict]:
"""
For each existing building footprint:
- Find which parcel(s) it overlaps.
- If it spans multiple parcels, merge them into one.
- Mark the containing parcel status='built', store building info.
- Remove building area from the parcel geometry is NOT done
(building sits inside the parcel, that's realistic).
Buildings that fall entirely outside any parcel are ignored.
"""
if not building_geoms or not parcels:
return parcels
tol = deg(0.5, sc)
for bldg in building_geoms:
bldg_buf = bldg.buffer(tol)
hit_indices = []
for i, p in enumerate(parcels):
pg = shape(p["geometry"])
if pg.intersects(bldg_buf):
hit_indices.append(i)
if not hit_indices:
continue # building outside all parcels — skip
# Merge all hit parcels into one
while len(hit_indices) > 1:
i, j = hit_indices[0], hit_indices[1]
gi = shape(parcels[i]["geometry"])
gj = shape(parcels[j]["geometry"])
merged = valid(gi.union(gj))
if isinstance(merged, MultiPolygon):
merged = max(merged.geoms, key=lambda x: x.area)
area_m2 = round(merged.area * sc**2, 1)
pw, ph, _ = obb(merged)
parcels[i]["geometry"] = mapping(merged)
parcels[i]["properties"].update({
"area_m2": area_m2,
"frontage_m": round(min(pw,ph)*sc, 1),
"depth_m": round(max(pw,ph)*sc, 1),
"has_access": (parcels[i]["properties"].get("has_access")
or parcels[j]["properties"].get("has_access")),
"area_ok": area_m2 >= config.min_area * 0.8,
})
parcels.pop(j)
# Rebuild indices after pop
hit_indices = [hit_indices[0]] + [
k - (1 if k > j else 0) for k in hit_indices[2:]
]
# Mark the surviving parcel as built
target = hit_indices[0]
parcels[target]["properties"]["status"] = "built"
parcels[target]["properties"]["building_area_m2"] = round(bldg.area * sc**2, 1)
return parcels
# ─── Cul-de-sac generator ─────────────────────────────────────────────────────
def generate_culdesacs(
blocks: List[Polygon],
road_union: Polygon,
config: SubdivisionConfig,
sc: float
) -> Tuple[List[Polygon], List[Polygon]]:
cds_polys, new_blocks = [], []
bl = deg(config.max_block_length, sc)
rw = deg(config.road_width, sc)
for block in blocks:
_, h, _ = obb(block)
if h > bl and config.allow_culdesac:
cds, trimmed = _try_culdesac(block, rw, config, sc)
if cds and trimmed and not trimmed.is_empty:
cds_polys.append(cds)
new_blocks.append(trimmed)
continue
new_blocks.append(block)
return cds_polys, new_blocks
def _try_culdesac(block, rw_deg, config, sc):
cx, _ = block.centroid.x, block.centroid.y
minx, miny, maxx, maxy = block.bounds
radius = deg(config.road_width * 1.8, sc)
stub_len = deg(config.max_block_length * 0.45, sc)
stub = LineString([(cx, miny), (cx, miny + stub_len)]).intersection(block)
if stub.is_empty or stub.length < rw_deg:
return None, None
stub_road = valid(buffer_line(stub, rw_deg).intersection(block))
end_pt = list(stub.coords)[-1]
circle = valid(Point(end_pt).buffer(radius).intersection(block))
cds_area = valid(unary_union([stub_road, circle]).intersection(block))
trimmed = valid(block.difference(cds_area))
if trimmed.is_empty or trimmed.area * sc**2 < config.min_area:
return None, None
return cds_area, trimmed
# ─── Block subdivider ─────────────────────────────────────────────────────────
def subdivide_block(
block: Polygon,
config: SubdivisionConfig,
road_union: Polygon,
block_id: int,
start_num: int,
sc: float
) -> List[Dict]:
min_f = deg(config.min_frontage, sc)
min_d = deg(config.min_depth, sc)
min_a = config.min_area / sc**2
_, _, angle = obb(block)
centroid = block.centroid
rotated = valid(rotate(block, -angle, origin=centroid))
rminx, rminy, rmaxx, rmaxy = rotated.bounds
bw = rmaxx - rminx
bh = rmaxy - rminy
num = start_num
parcels = []
def emit(rect_in_rotated):
nonlocal num
p = valid(rect_in_rotated.intersection(rotated))
if p is None or p.is_empty or p.area < min_a * 0.5:
return
p = valid(rotate(p, angle, origin=centroid))
feats = _make_parcel_features(p, config, block_id, num, sc, road_union)
parcels.extend(feats)
num += 1
if bw >= bh:
# Wide block: frontage along X, depth along Y
n_cols = max(1, round(bw / min_f))
col_w = bw / n_cols
rows = ([(rminy, rminy + bh/2), (rminy + bh/2, rmaxy)]
if bh >= min_d * 2 else [(rminy, rmaxy)])
for y0, y1 in rows:
for c in range(n_cols):
x0 = rminx + c * col_w
emit(Polygon([(x0,y0),(x0+col_w,y0),(x0+col_w,y1),(x0,y1)]))
else:
# Tall block: frontage along Y, depth along X
n_rows = max(1, round(bh / min_f))
row_h = bh / n_rows
cols = ([(rminx, rminx + bw/2), (rminx + bw/2, rmaxx)]
if bw >= min_d * 2 else [(rminx, rmaxx)])
for x0, x1 in cols:
for r in range(n_rows):
y0 = rminy + r * row_h
emit(Polygon([(x0,y0),(x1,y0),(x1,y0+row_h),(x0,y0+row_h)]))
return parcels
def _make_parcel_features(geom, config, block_id, num, sc, road_union):
out = []
parts = flatten_polygons(geom) if not isinstance(geom, Polygon) else [geom]
tol = deg(0.5, sc)
for g in parts:
if g.is_empty:
continue
area_m2 = round(g.area * sc**2, 1)
if area_m2 < config.min_area * 0.4:
continue
pw, ph, _ = obb(g)
has_access = (road_union is not None
and not road_union.is_empty
and g.buffer(tol).intersects(road_union))
out.append({
"type": "Feature",
"geometry": mapping(g),
"properties": {
"parcel_id": f"P{num:04d}",
"parcel_num": num,
"block_id": block_id,
"area_m2": area_m2,
"area_ha": round(area_m2 / 10000, 4),
"frontage_m": round(min(pw,ph) * sc, 1),
"depth_m": round(max(pw,ph) * sc, 1),
"address": f"Block {block_id}, Plot {num}",
"zone": "Residential",
"status": "vacant",
"has_access": has_access,
"frontage_ok": round(min(pw,ph)*sc,1) >= config.min_frontage * 0.85,
"area_ok": area_m2 >= config.min_area * 0.80,
}
})
return out
# ─── Quality passes ───────────────────────────────────────────────────────────
def absorb_bad_parcels(parcels, config, sc):
"""Merge undersized / badly-shaped parcels into best same-block neighbour."""
tol = deg(0.5, sc)
changed = True
while changed:
changed = False
geoms = [shape(p["geometry"]) for p in parcels]
for i, p in enumerate(parcels):
g = geoms[i]
area_m2 = g.area * sc**2
w, h, _ = obb(g)
compact = 4 * math.pi * g.area / (g.length**2) if g.length else 0
ok = (area_m2 >= config.min_area * 0.8
and compact >= 0.18
and (h == 0 or w/h >= 0.12))
if ok:
continue
best_j, best_touch = -1, 0.0
for j, q in enumerate(parcels):
if j == i:
continue
if p["properties"]["block_id"] != q["properties"]["block_id"]:
continue
touch = g.buffer(tol).intersection(geoms[j]).area
if touch > best_touch:
best_touch, best_j = touch, j
if best_j >= 0 and best_touch > 0:
merged = valid(g.union(geoms[best_j]))
if isinstance(merged, MultiPolygon):
merged = max(merged.geoms, key=lambda x: x.area)
am2 = round(merged.area * sc**2, 1)
pw, ph, _ = obb(merged)
acc = (p["properties"].get("has_access")
or parcels[best_j]["properties"].get("has_access"))
parcels[best_j]["geometry"] = mapping(merged)
parcels[best_j]["properties"].update({
"area_m2": am2, "frontage_m": round(min(pw,ph)*sc,1),
"depth_m": round(max(pw,ph)*sc,1), "has_access": acc,
"area_ok": am2 >= config.min_area * 0.8,
})
geoms[best_j] = merged
parcels.pop(i)
changed = True
break
return parcels
def enforce_access(parcels, road_union, config, sc):
"""Merge no-access parcels into nearest accessed neighbour in same block."""
if not road_union or road_union.is_empty:
return parcels
tol = deg(1.0, sc)
changed = True
while changed:
changed = False
geoms = [shape(p["geometry"]) for p in parcels]
for i, p in enumerate(parcels):
if p["properties"].get("has_access"):
continue
g = geoms[i]
best_j, best_shared = -1, 0.0
for j, q in enumerate(parcels):
if j == i:
continue
if p["properties"]["block_id"] != q["properties"]["block_id"]:
continue
if not q["properties"].get("has_access"):
continue
shared = g.buffer(tol).intersection(geoms[j]).area
if shared > best_shared:
best_shared, best_j = shared, j
if best_j >= 0:
merged = valid(g.union(geoms[best_j]))
if isinstance(merged, MultiPolygon):
merged = max(merged.geoms, key=lambda x: x.area)
am2 = round(merged.area * sc**2, 1)
pw, ph, _ = obb(merged)
parcels[best_j]["geometry"] = mapping(merged)
parcels[best_j]["properties"].update({
"area_m2": am2, "frontage_m": round(min(pw,ph)*sc,1),
"depth_m": round(max(pw,ph)*sc,1), "has_access": True,
"area_ok": am2 >= config.min_area * 0.8,
})
geoms[best_j] = merged
parcels.pop(i)
changed = True
break
# Final access recheck
for p in parcels:
g = shape(p["geometry"])
p["properties"]["has_access"] = (
not road_union.is_empty and g.buffer(tol).intersects(road_union)
)
return parcels
# ─── Main endpoint ────────────────────────────────────────────────────────────
@app.post("/api/subdivide")
async def subdivide(request: SubdivisionRequest):
try:
config = request.config or SubdivisionConfig()
# 1. Parse & validate boundary
boundary = valid(extract_geometry(request.boundary))
if isinstance(boundary, MultiPolygon):
boundary = max(boundary.geoms, key=lambda g: g.area)
if not isinstance(boundary, Polygon):
raise HTTPException(400, "Boundary must be a polygon")
sc = estimate_scale(boundary)
# 2. Parse user-drawn road centrelines
user_roads: List[LineString] = []
for r in (request.roads or []):
try:
g= shape(r["geometry"])
#g = extract_geometry(r)
if isinstance(g, LineString):
user_roads.append(g)
elif isinstance(g, MultiLineString):
user_roads.extend(g.geoms)
except Exception:
pass
# print("roads")
# print(request.roads)
# print(user_roads)
# 3. Parse existing building footprints
print(request.existing_features)
building_geoms: List[Polygon] = []
for f in (request.existing_features or []):
try:
#g = extract_geometry(f)
g = shape(f["geometry"])
# Clip to boundary
g = valid(g.intersection(boundary))
building_geoms.extend(flatten_polygons(g))
except Exception:
pass
#print("buildings")
#print( building_geoms)
# 4. Build road network (user + auto-fill + perimeter)
road_polys, road_union = build_road_network(boundary, user_roads, config, sc)
# 5. Buildable area = boundary roads existing buildings
# Buildings are carved out so subdivision doesn't cut through them.
obstacles = road_union
if building_geoms:
bldg_union = valid(unary_union(building_geoms))
obstacles = valid(unary_union([road_union, bldg_union]))
buildable = valid(boundary.difference(obstacles))
# 6. Extract blocks
min_block_area = config.min_area / sc**2
blocks = [b for b in flatten_polygons(buildable) if b.area > min_block_area]
# 7. Cul-de-sacs
cds_polys: List[Polygon] = []
if config.allow_culdesac and blocks:
cds_polys, blocks = generate_culdesacs(blocks, road_union, config, sc)
if cds_polys:
road_union = valid(unary_union([road_union] + cds_polys))
# 8. Subdivide every block
all_parcels: List[Dict] = []
num = 1
for bid, block in enumerate(blocks, 1):
bp = subdivide_block(block, config, road_union, bid, num, sc)
all_parcels.extend(bp)
num += len(bp)
# 9. Quality passes
all_parcels = absorb_bad_parcels(all_parcels, config, sc)
all_parcels = enforce_access(all_parcels, road_union, config, sc)
# 10. Apply existing building footprints → merge spanning parcels, mark built
if building_geoms:
all_parcels = process_existing_features(
building_geoms, buildable, all_parcels, sc, config
)
# 11. Re-number
for i, p in enumerate(all_parcels, 1):
p["properties"]["parcel_num"] = i
p["properties"]["parcel_id"] = f"P{i:04d}"
# 12. Build response collections
# Roads: exclude the perimeter ring (last element) from display features
# because it is very thin and would look ugly — the boundary line already
# shows where the perimeter is.
display_road_polys = road_polys[:-1] if road_polys else road_polys
road_features = [
{"type": "Feature", "geometry": mapping(rp),
"properties": {"type": "road_surface", "id": f"R{i+1:03d}",
"width_m": config.road_width}}
for i, rp in enumerate(display_road_polys)
]
block_features = [
{"type": "Feature", "geometry": mapping(b),
"properties": {"block_id": i+1, "area_m2": round(b.area*sc**2, 1)}}
for i, b in enumerate(blocks)
]
cds_features = [
{"type": "Feature", "geometry": mapping(c),
"properties": {"type": "culdesac", "id": f"CDS{i+1:03d}"}}
for i, c in enumerate(cds_polys)
]
# 13. Stats
areas = [p["properties"]["area_m2"] for p in all_parcels]
no_acc = sum(1 for p in all_parcels if not p["properties"].get("has_access"))
built = sum(1 for p in all_parcels if p["properties"].get("status") == "built")
stats = {
"total_parcels": len(all_parcels),
"total_blocks": len(blocks),
"total_roads": len(display_road_polys),
"culdesacs": len(cds_polys),
"parcels_no_access": no_acc,
"parcels_built": built,
"parcels_vacant": len(all_parcels) - built,
"boundary_area_m2": round(boundary.area * sc**2, 1),
"road_area_m2": round(road_union.area * sc**2, 1) if not road_union.is_empty else 0,
"buildable_area_m2": round(sum(b.area * sc**2 for b in blocks), 1),
"avg_parcel_area_m2": round(sum(areas)/len(areas), 1) if areas else 0,
"min_parcel_area_m2": round(min(areas), 1) if areas else 0,
"existing_buildings": len(building_geoms),
"user_roads_drawn": len(user_roads),
"config": {
"min_frontage": config.min_frontage, "min_depth": config.min_depth,
"road_width": config.road_width, "max_block_length": config.max_block_length,
}
}
return {
"parcels": all_parcels,
"roads": road_features,
"blocks": block_features,
"culdesacs": cds_features,
"stats": stats,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, f"{e}\n{traceback.format_exc()}")
@app.get("/health")
async def health():
return {"status": "ok", "version": "3.0.0"}
@app.get("/config/defaults")
async def get_defaults():
return SubdivisionConfig().model_dump()

652
backend/main4.py Normal file
View File

@ -0,0 +1,652 @@
"""
ParcelGen Backend v4
- Land use zones (Residential Low/Med/High, Commercial, Mixed, Industrial, Open Space)
- Road types (Primary, Secondary, Local, Lane) with per-type widths
- Min/max parcel area validation before running
- Splay (corner radius) converted correctly from display units
- Multi-user safe (stateless, no globals)
- Session-aware progress endpoint (SSE)
- Better boundary adherence via edge-touching strip layout
- Correct terminology: frontage (road-facing width), plot depth (perpendicular)
- User roads + auto-fill roads combined (not either/or)
- Existing building footprints respected: carved from buildable area,
containing parcel flagged status=built
- Perimeter access road always present
- Access enforcement pass
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Tuple
import math, uuid, traceback, asyncio, json
from shapely.geometry import (
Polygon, MultiPolygon, LineString, MultiLineString,
Point, GeometryCollection, mapping, shape
)
from shapely.ops import unary_union
from shapely.affinity import rotate
from shapely.validation import make_valid
app = FastAPI(title="ParcelGen", version="4.0.0")
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"],
)
# ─── Land use zone definitions ────────────────────────────────────────────────
LAND_USE_PRESETS = {
"residential_low": {"label": "Residential Low Density", "min_frontage": 15, "min_depth": 30, "min_area": 450, "max_area": 2000, "color": "#58a6ff"},
"residential_medium": {"label": "Residential Medium Density", "min_frontage": 10, "min_depth": 25, "min_area": 250, "max_area": 1000, "color": "#3fb950"},
"residential_high": {"label": "Residential High Density", "min_frontage": 6, "min_depth": 20, "min_area": 120, "max_area": 500, "color": "#f0c84a"},
"commercial": {"label": "Commercial", "min_frontage": 12, "min_depth": 20, "min_area": 300, "max_area": 5000, "color": "#f97316"},
"mixed_use": {"label": "Mixed Use", "min_frontage": 8, "min_depth": 20, "min_area": 200, "max_area": 2000, "color": "#bc8cff"},
"industrial": {"label": "Industrial", "min_frontage": 20, "min_depth": 40, "min_area": 1000, "max_area": 20000,"color": "#f78166"},
"open_space": {"label": "Open Space / Park", "min_frontage": 30, "min_depth": 30, "min_area": 500, "max_area": 50000,"color": "#34d399"},
}
# ─── Road type definitions ─────────────────────────────────────────────────────
ROAD_TYPE_DEFAULTS = {
"primary": {"label": "Primary Road", "width": 20, "color": "#f78166"},
"secondary": {"label": "Secondary Road", "width": 14, "color": "#f0c84a"},
"local": {"label": "Local Road", "width": 9, "color": "#8b949e"},
"lane": {"label": "Lane / Alley", "width": 4, "color": "#58a6ff"},
}
# ─── Models ───────────────────────────────────────────────────────────────────
class RoadInput(BaseModel):
geometry: Dict[str, Any]
road_type: str = "local" # primary / secondary / local / lane
width: Optional[float] = None # overrides type default if set
label: Optional[str] = None
class ZoneInput(BaseModel):
geometry: Dict[str, Any]
land_use: str = "residential_medium"
label: Optional[str] = None
class SubdivisionConfig(BaseModel):
# Parcel dimensions
min_frontage: float = 12.0
min_plot_depth: float = 25.0 # renamed from min_depth for clarity
min_area: Optional[float] = None # auto from land_use if None
max_area: Optional[float] = None # 0 = unlimited
# Roads
default_road_width: float = 9.0
splay_radius: float = 3.0 # was corner_radius; splay is the correct survey term
max_block_length: float = 120.0
allow_culdesac: bool = True
# Land use
land_use: str = "residential_medium"
# Numbering
parcel_prefix: str = "P"
zone_prefix: str = "Z"
start_number: int = 1
def model_post_init(self, _):
preset = LAND_USE_PRESETS.get(self.land_use, LAND_USE_PRESETS["residential_medium"])
if self.min_area is None:
self.min_area = preset["min_area"]
if self.max_area is None:
self.max_area = preset["max_area"]
class SubdivisionRequest(BaseModel):
boundary: Dict[str, Any]
roads: Optional[List[RoadInput]] = []
existing_features: Optional[List[Dict[str, Any]]] = []
zones: Optional[List[ZoneInput]] = []
config: Optional[SubdivisionConfig] = None
session_id: Optional[str] = None # for progress tracking
# In-memory progress store (per session_id, cleared after delivery)
_progress: Dict[str, Dict] = {}
# ─── Geometry helpers ─────────────────────────────────────────────────────────
def valid(geom):
if geom is None or geom.is_empty: return geom
if not geom.is_valid: geom = make_valid(geom)
return geom
def extract_geometry(obj: Dict) -> Any:
if obj is None: raise ValueError("Null geometry")
geo = obj.get("geometry", obj) if obj.get("type") == "Feature" else obj
if geo is None or not geo.get("type"):
raise ValueError(f"Cannot extract geometry from: {list(obj.keys())}")
return shape(geo)
def flatten_polygons(geom) -> List[Polygon]:
if geom is None or geom.is_empty: return []
if isinstance(geom, Polygon): return [geom]
if isinstance(geom, (MultiPolygon, GeometryCollection)):
out = []
for g in geom.geoms: out.extend(flatten_polygons(g))
return out
return []
def obb(polygon: Polygon) -> Tuple[float, float, float]:
"""Oriented bounding box → (short, long, angle_deg)."""
mbr = polygon.minimum_rotated_rectangle
coords = list(mbr.exterior.coords)
e1 = math.hypot(coords[1][0]-coords[0][0], coords[1][1]-coords[0][1])
e2 = math.hypot(coords[2][0]-coords[1][0], coords[2][1]-coords[1][1])
ang = math.degrees(math.atan2(coords[1][1]-coords[0][1], coords[1][0]-coords[0][0]))
return (min(e1,e2), max(e1,e2), ang)
def estimate_scale(boundary: Polygon) -> float:
lat = boundary.centroid.y
return (111_320.0 + 111_320.0 * math.cos(math.radians(lat))) / 2.0
def deg(metres: float, sc: float) -> float:
return metres / sc
def buffer_line(line, width_deg: float) -> Polygon:
return line.buffer(width_deg / 2, cap_style=2, join_style=2)
# ─── Road network ─────────────────────────────────────────────────────────────
def build_road_network(
boundary: Polygon,
road_inputs: List[RoadInput],
config: SubdivisionConfig,
sc: float
) -> Tuple[List[Dict], Polygon]:
"""Returns (road_feature_dicts, road_union_polygon)."""
road_features = [] # dicts with geometry + metadata
road_polys = []
default_rw = deg(config.default_road_width, sc)
# ── User roads ────────────────────────────────────────────────────────────
for ri in road_inputs:
try:
line = extract_geometry(ri.geometry if isinstance(ri, RoadInput) else ri)
except Exception:
continue
if not isinstance(line, (LineString, MultiLineString)):
continue
lines = list(line.geoms) if isinstance(line, MultiLineString) else [line]
# Determine width for this road
rtype = (ri.road_type if isinstance(ri, RoadInput) else "local")
rwidth_m = (ri.width if isinstance(ri, RoadInput) and ri.width else
ROAD_TYPE_DEFAULTS.get(rtype, ROAD_TYPE_DEFAULTS["local"])["width"])
rw = deg(rwidth_m, sc)
color = ROAD_TYPE_DEFAULTS.get(rtype, ROAD_TYPE_DEFAULTS["local"])["color"]
for line_part in lines:
cl = valid(line_part.intersection(boundary))
if cl and not cl.is_empty:
rp = valid(buffer_line(cl, rw).intersection(boundary))
if rp and not rp.is_empty:
road_polys.append(rp)
road_features.append({
"type": "Feature",
"geometry": mapping(rp),
"properties": {
"type": "road_surface",
"road_type": rtype,
"width_m": rwidth_m,
"label": (ri.label if isinstance(ri, RoadInput) else None) or f"{rtype.capitalize()} Road",
"color": color,
}
})
# ── Auto-fill grid roads ──────────────────────────────────────────────────
existing_union = valid(unary_union(road_polys)) if road_polys else Polygon()
bl = deg(config.max_block_length, sc)
rw = default_rw
step = bl + rw
minx, miny, maxx, maxy = boundary.bounds
site_w, site_h = maxx - minx, maxy - miny
def try_add_auto(line: LineString):
cl = valid(line.intersection(boundary))
if not cl or cl.is_empty or cl.length < deg(config.min_frontage * 2, sc): return
rp = valid(buffer_line(cl, rw).intersection(boundary))
if not rp or rp.is_empty: return
if not existing_union.is_empty and rp.intersection(existing_union).area / rp.area > 0.6: return
road_polys.append(rp)
road_features.append({
"type": "Feature", "geometry": mapping(rp),
"properties": {"type": "road_surface", "road_type": "local",
"width_m": config.default_road_width, "label": "Local Road (auto)",
"color": ROAD_TYPE_DEFAULTS["local"]["color"]}
})
xs = []
x = minx + step
while x < maxx - rw: xs.append(x); x += step
if not xs and site_w > deg(config.min_plot_depth * 2 + config.default_road_width, sc):
xs = [minx + site_w / 2]
for x in xs: try_add_auto(LineString([(x, miny - 1), (x, maxy + 1)]))
ys = []
y = miny + step
while y < maxy - rw: ys.append(y); y += step
if not ys and site_h > deg(config.min_plot_depth * 2 + config.default_road_width, sc):
ys = [miny + site_h / 2]
for y in ys: try_add_auto(LineString([(minx - 1, y), (maxx + 1, y)]))
# ── Perimeter access ring ─────────────────────────────────────────────────
perim_w = deg(config.default_road_width * 0.5, sc)
inner = valid(boundary.buffer(-perim_w))
if inner and not inner.is_empty and inner.area > 0:
perim = valid(boundary.difference(inner))
if perim and not perim.is_empty:
road_polys.append(perim)
# Don't add to road_features (perimeter ring is structural, not displayed)
road_union = valid(unary_union(road_polys)) if road_polys else Polygon()
return road_features, road_union
# ─── Cul-de-sac ───────────────────────────────────────────────────────────────
def generate_culdesacs(blocks, road_union, config, sc):
cds_polys, new_blocks = [], []
bl = deg(config.max_block_length, sc)
rw = deg(config.default_road_width, sc)
for block in blocks:
_, h, _ = obb(block)
if h > bl and config.allow_culdesac:
cds, trimmed = _try_culdesac(block, rw, config, sc)
if cds and trimmed and not trimmed.is_empty:
cds_polys.append(cds); new_blocks.append(trimmed); continue
new_blocks.append(block)
return cds_polys, new_blocks
def _try_culdesac(block, rw_deg, config, sc):
cx, _ = block.centroid.x, block.centroid.y
minx, miny, _, _ = block.bounds
radius = deg(config.default_road_width * 1.8, sc)
stub_len = deg(config.max_block_length * 0.45, sc)
stub = LineString([(cx, miny), (cx, miny + stub_len)]).intersection(block)
if stub.is_empty or stub.length < rw_deg: return None, None
stub_road = valid(buffer_line(stub, rw_deg).intersection(block))
end_pt = list(stub.coords)[-1]
circle = valid(Point(end_pt).buffer(radius).intersection(block))
cds_area = valid(unary_union([stub_road, circle]).intersection(block))
trimmed = valid(block.difference(cds_area))
if trimmed.is_empty or trimmed.area * sc**2 < config.min_area: return None, None
return cds_area, trimmed
# ─── Block subdivider ─────────────────────────────────────────────────────────
def subdivide_block(block, config, road_union, block_id, start_num, sc, zone_label=None, zone_color=None):
min_f = deg(config.min_frontage, sc)
min_d = deg(config.min_plot_depth, sc)
min_a = config.min_area / sc**2
max_a = (config.max_area or 1e18) / sc**2
_, _, angle = obb(block)
centroid = block.centroid
rotated = valid(rotate(block, -angle, origin=centroid))
rminx, rminy, rmaxx, rmaxy = rotated.bounds
bw, bh = rmaxx - rminx, rmaxy - rminy
num = start_num
parcels = []
def emit(x0, y0, x1, y1):
nonlocal num
p = valid(Polygon([(x0,y0),(x1,y0),(x1,y1),(x0,y1)]).intersection(rotated))
if p is None or p.is_empty or p.area < min_a * 0.5: return
p = valid(rotate(p, angle, origin=centroid))
feats = _make_parcel(p, config, block_id, num, sc, road_union,
zone_label, zone_color, start_num)
parcels.extend(feats)
num += 1
if bw >= bh:
n_cols = max(1, round(bw / min_f))
col_w = bw / n_cols
rows = ([(rminy, rminy + bh/2), (rminy + bh/2, rmaxy)]
if bh >= min_d * 2 else [(rminy, rmaxy)])
for y0, y1 in rows:
for c in range(n_cols):
x0 = rminx + c * col_w
emit(x0, y0, x0 + col_w, y1)
else:
n_rows = max(1, round(bh / min_f))
row_h = bh / n_rows
cols = ([(rminx, rminx + bw/2), (rminx + bw/2, rmaxx)]
if bw >= min_d * 2 else [(rminx, rmaxx)])
for x0, x1 in cols:
for r in range(n_rows):
y0 = rminy + r * row_h
emit(x0, y0, x1, y0 + row_h)
return parcels
def _make_parcel(geom, config, block_id, num, sc, road_union, zone_label, zone_color, base):
out = []
parts = flatten_polygons(geom) if not isinstance(geom, Polygon) else [geom]
tol = deg(0.5, sc)
preset = LAND_USE_PRESETS.get(config.land_use, LAND_USE_PRESETS["residential_medium"])
for g in parts:
if g.is_empty: continue
area_m2 = round(g.area * sc**2, 1)
if area_m2 < config.min_area * 0.4: continue
pw, ph, _ = obb(g)
frontage_m = round(min(pw, ph) * sc, 1)
plot_depth_m = round(max(pw, ph) * sc, 1)
has_access = (road_union is not None and not road_union.is_empty
and g.buffer(tol).intersects(road_union))
max_a = config.max_area or 1e18
parcel_id = f"{config.parcel_prefix}{num:04d}"
out.append({
"type": "Feature",
"geometry": mapping(g),
"properties": {
"parcel_id": parcel_id,
"parcel_num": num,
"block_id": block_id,
"area_m2": area_m2,
"area_ha": round(area_m2 / 10000, 4),
"frontage_m": frontage_m, # road-facing width
"plot_depth_m": plot_depth_m, # perpendicular dimension
"address": f"Block {block_id}, Plot {num}",
"land_use": config.land_use,
"land_use_label": zone_label or preset["label"],
"zone_color": zone_color or preset["color"],
"zone": zone_label or preset["label"],
"status": "vacant",
"has_access": has_access,
"frontage_ok": frontage_m >= config.min_frontage * 0.85,
"area_ok": config.min_area * 0.8 <= area_m2 <= max_a * 1.2,
"within_max_area": area_m2 <= max_a * 1.1,
}
})
return out
# ─── Quality passes ───────────────────────────────────────────────────────────
def absorb_bad_parcels(parcels, config, sc):
tol = deg(0.5, sc)
changed = True
while changed:
changed = False
geoms = [shape(p["geometry"]) for p in parcels]
for i, p in enumerate(parcels):
g = geoms[i]; am2 = g.area * sc**2
w, h, _ = obb(g)
compact = 4 * math.pi * g.area / (g.length**2) if g.length else 0
max_a = config.max_area or 1e18
ok = am2 >= config.min_area * 0.8 and compact >= 0.18 and (h == 0 or w/h >= 0.12)
if ok: continue
best_j, best_touch = -1, 0.0
for j, q in enumerate(parcels):
if j == i: continue
if p["properties"]["block_id"] != q["properties"]["block_id"]: continue
touch = g.buffer(tol).intersection(geoms[j]).area
if touch > best_touch: best_touch, best_j = touch, j
if best_j >= 0 and best_touch > 0:
merged = valid(g.union(geoms[best_j]))
if isinstance(merged, MultiPolygon):
merged = max(merged.geoms, key=lambda x: x.area)
am2_new = round(merged.area * sc**2, 1)
pw, ph, _ = obb(merged)
acc = p["properties"].get("has_access") or parcels[best_j]["properties"].get("has_access")
parcels[best_j]["geometry"] = mapping(merged)
parcels[best_j]["properties"].update({
"area_m2": am2_new, "frontage_m": round(min(pw,ph)*sc,1),
"plot_depth_m": round(max(pw,ph)*sc,1), "has_access": acc,
"area_ok": config.min_area*0.8 <= am2_new <= (config.max_area or 1e18)*1.2,
})
geoms[best_j] = merged; parcels.pop(i); changed = True; break
return parcels
def enforce_access(parcels, road_union, config, sc):
if not road_union or road_union.is_empty: return parcels
tol = deg(1.0, sc)
changed = True
while changed:
changed = False
geoms = [shape(p["geometry"]) for p in parcels]
for i, p in enumerate(parcels):
if p["properties"].get("has_access"): continue
g = geoms[i]; best_j, best_shared = -1, 0.0
for j, q in enumerate(parcels):
if j == i: continue
if p["properties"]["block_id"] != q["properties"]["block_id"]: continue
if not q["properties"].get("has_access"): continue
shared = g.buffer(tol).intersection(geoms[j]).area
if shared > best_shared: best_shared, best_j = shared, j
if best_j >= 0:
merged = valid(g.union(geoms[best_j]))
if isinstance(merged, MultiPolygon):
merged = max(merged.geoms, key=lambda x: x.area)
am2 = round(merged.area * sc**2, 1)
pw, ph, _ = obb(merged)
parcels[best_j]["geometry"] = mapping(merged)
parcels[best_j]["properties"].update({
"area_m2": am2, "frontage_m": round(min(pw,ph)*sc,1),
"plot_depth_m": round(max(pw,ph)*sc,1), "has_access": True,
"area_ok": config.min_area*0.8 <= am2 <= (config.max_area or 1e18)*1.2,
})
geoms[best_j] = merged; parcels.pop(i); changed = True; break
for p in parcels:
g = shape(p["geometry"])
p["properties"]["has_access"] = not road_union.is_empty and g.buffer(tol).intersects(road_union)
return parcels
def apply_existing_buildings(building_geoms, parcels, sc, config):
if not building_geoms or not parcels: return parcels
tol = deg(0.5, sc)
for bldg in building_geoms:
bldg_buf = bldg.buffer(tol)
hit = [i for i, p in enumerate(parcels) if shape(p["geometry"]).intersects(bldg_buf)]
if not hit: continue
while len(hit) > 1:
i, j = hit[0], hit[1]
gi, gj = shape(parcels[i]["geometry"]), shape(parcels[j]["geometry"])
merged = valid(gi.union(gj))
if isinstance(merged, MultiPolygon): merged = max(merged.geoms, key=lambda x: x.area)
am2 = round(merged.area * sc**2, 1); pw, ph, _ = obb(merged)
parcels[i]["geometry"] = mapping(merged)
parcels[i]["properties"].update({
"area_m2": am2, "frontage_m": round(min(pw,ph)*sc,1),
"plot_depth_m": round(max(pw,ph)*sc,1),
"has_access": parcels[i]["properties"].get("has_access") or parcels[j]["properties"].get("has_access"),
})
parcels.pop(j)
hit = [hit[0]] + [k-(1 if k>j else 0) for k in hit[2:]]
parcels[hit[0]]["properties"]["status"] = "built"
parcels[hit[0]]["properties"]["building_area_m2"] = round(bldg.area * sc**2, 1)
return parcels
# ─── Validation ───────────────────────────────────────────────────────────────
def validate_config(boundary: Polygon, config: SubdivisionConfig, sc: float):
"""Return list of warning strings (empty = OK to run)."""
site_area = boundary.area * sc**2
warnings = []
if config.min_area > site_area * 0.5:
warnings.append(f"min_area ({config.min_area} m²) is more than half the site area ({site_area:.0f} m²) — very few parcels will fit")
if config.max_area and config.max_area < config.min_area:
warnings.append(f"max_area ({config.max_area} m²) is less than min_area ({config.min_area} m²)")
if config.min_frontage > config.min_plot_depth:
warnings.append("min_frontage is larger than min_plot_depth — parcels will be wider than deep")
if config.default_road_width >= config.min_frontage * 2:
warnings.append("Road width is very large relative to parcel frontage — most area will be road")
min_site_for_parcels = (config.min_area + config.default_road_width * 20) * 4
if site_area < min_site_for_parcels:
warnings.append(f"Site area ({site_area:.0f} m²) may be too small to fit meaningful parcels with the current parameters")
return warnings
# ─── Main endpoint ────────────────────────────────────────────────────────────
@app.post("/subdivide")
async def subdivide(request: SubdivisionRequest):
session_id = request.session_id or str(uuid.uuid4())
_progress[session_id] = {"pct": 0, "msg": "Starting…"}
def prog(pct, msg):
_progress[session_id] = {"pct": pct, "msg": msg}
try:
config = request.config or SubdivisionConfig()
prog(5, "Parsing boundary…")
boundary = valid(extract_geometry(request.boundary))
if isinstance(boundary, MultiPolygon):
boundary = max(boundary.geoms, key=lambda g: g.area)
if not isinstance(boundary, Polygon):
raise HTTPException(400, "Boundary must be a polygon")
sc = estimate_scale(boundary)
# Validate before running
warnings = validate_config(boundary, config, sc)
prog(10, "Parsing roads and features…")
# Parse user roads (accept both RoadInput dicts and legacy plain dicts)
road_inputs: List[RoadInput] = []
for r in (request.roads or []):
if isinstance(r, dict):
try:
road_inputs.append(RoadInput(**r))
except Exception:
try: road_inputs.append(RoadInput(geometry=r))
except Exception: pass
else:
road_inputs.append(r)
# Parse buildings
building_geoms = []
for f in (request.existing_features or []):
try:
g = valid(extract_geometry(f).intersection(boundary))
building_geoms.extend(flatten_polygons(g))
except Exception: pass
prog(20, "Building road network…")
road_features, road_union = build_road_network(boundary, road_inputs, config, sc)
# Carve obstacles
prog(30, "Computing buildable area…")
obstacles = road_union
if building_geoms:
bldg_union = valid(unary_union(building_geoms))
obstacles = valid(unary_union([road_union, bldg_union]))
buildable = valid(boundary.difference(obstacles)) if not obstacles.is_empty else boundary
blocks = [b for b in flatten_polygons(buildable) if b.area * sc**2 > config.min_area]
prog(40, "Generating cul-de-sacs…")
cds_polys = []
if config.allow_culdesac and blocks:
cds_polys, blocks = generate_culdesacs(blocks, road_union, config, sc)
if cds_polys:
road_union = valid(unary_union([road_union] + cds_polys))
prog(55, "Subdividing blocks into parcels…")
all_parcels = []
num = config.start_number
for bid, block in enumerate(blocks, 1):
bp = subdivide_block(block, config, road_union, bid, num, sc)
all_parcels.extend(bp)
num += len(bp)
prog(55 + int(20 * (bid / max(len(blocks), 1))), f"Subdividing block {bid}/{len(blocks)}")
prog(78, "Quality control…")
all_parcels = absorb_bad_parcels(all_parcels, config, sc)
prog(85, "Enforcing road access…")
all_parcels = enforce_access(all_parcels, road_union, config, sc)
prog(90, "Applying existing buildings…")
if building_geoms:
all_parcels = apply_existing_buildings(building_geoms, all_parcels, sc, config)
prog(95, "Numbering and finalising…")
for i, p in enumerate(all_parcels, config.start_number):
p["properties"]["parcel_num"] = i
p["properties"]["parcel_id"] = f"{config.parcel_prefix}{i:04d}"
# Build response
block_features = [
{"type":"Feature","geometry":mapping(b),
"properties":{"block_id":i+1,"area_m2":round(b.area*sc**2,1)}}
for i, b in enumerate(blocks)
]
cds_features = [
{"type":"Feature","geometry":mapping(c),
"properties":{"type":"culdesac","id":f"CDS{i+1:03d}"}}
for i, c in enumerate(cds_polys)
]
areas = [p["properties"]["area_m2"] for p in all_parcels]
no_acc = sum(1 for p in all_parcels if not p["properties"].get("has_access"))
built = sum(1 for p in all_parcels if p["properties"].get("status") == "built")
stats = {
"total_parcels": len(all_parcels), "total_blocks": len(blocks),
"total_roads": len(road_features), "culdesacs": len(cds_polys),
"parcels_no_access": no_acc, "parcels_built": built,
"parcels_vacant": len(all_parcels) - built,
"boundary_area_m2": round(boundary.area * sc**2, 1),
"road_area_m2": round(road_union.area * sc**2, 1) if not road_union.is_empty else 0,
"buildable_area_m2": round(sum(b.area * sc**2 for b in blocks), 1),
"avg_parcel_area_m2": round(sum(areas)/len(areas), 1) if areas else 0,
"min_parcel_area_m2": round(min(areas), 1) if areas else 0,
"max_parcel_area_m2": round(max(areas), 1) if areas else 0,
"existing_buildings": len(building_geoms),
"user_roads_drawn": len(road_inputs),
"warnings": warnings,
"land_use": config.land_use,
"land_use_label": LAND_USE_PRESETS.get(config.land_use, {}).get("label", config.land_use),
"config": {
"min_frontage": config.min_frontage, "min_plot_depth": config.min_plot_depth,
"min_area": config.min_area, "max_area": config.max_area,
"default_road_width": config.default_road_width, "splay_radius": config.splay_radius,
"max_block_length": config.max_block_length,
}
}
prog(100, "Done")
return {
"parcels": all_parcels, "roads": road_features,
"blocks": block_features, "culdesacs": cds_features,
"stats": stats, "session_id": session_id,
}
except HTTPException: raise
except Exception as e:
prog(-1, f"Error: {e}")
raise HTTPException(500, f"{e}\n{traceback.format_exc()}")
finally:
# Keep progress for a short while so client can read 100%
async def _cleanup():
await asyncio.sleep(10)
_progress.pop(session_id, None)
asyncio.create_task(_cleanup())
@app.get("/progress/{session_id}")
async def get_progress(session_id: str):
return _progress.get(session_id, {"pct": -1, "msg": "Not found"})
@app.get("/land-use-presets")
async def land_use_presets():
return LAND_USE_PRESETS
@app.get("/road-type-defaults")
async def road_type_defaults():
return ROAD_TYPE_DEFAULTS
@app.get("/health")
async def health():
return {"status": "ok", "version": "4.0.0"}
@app.get("/config/defaults")
async def get_defaults():
return SubdivisionConfig().model_dump()

File diff suppressed because it is too large Load Diff

1672
frontend/index2.html Normal file

File diff suppressed because it is too large Load Diff

2019
frontend/index4.html Normal file

File diff suppressed because it is too large Load Diff