improved version
This commit is contained in:
commit
f38bb4d136
5
.idea/.gitignore
generated
vendored
Normal file
5
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.8" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/parcelFiles.iml" filepath="$PROJECT_DIR$/.idea/parcelFiles.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/parcelFiles.iml
generated
Normal file
12
.idea/parcelFiles.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.8" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
226
README.md
Normal file
226
README.md
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# 🗺 Parcel Subdivision Tool
|
||||||
|
|
||||||
|
A full-stack GIS application for automatic land parcel generation from user-drawn site boundaries and road networks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Docker Network │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Nginx │────▶│ FastAPI Backend │ │
|
||||||
|
│ │ (port 80) │ │ (port 8000) │ │
|
||||||
|
│ │ + Frontend │ │ Shapely/GIS engine │ │
|
||||||
|
│ └─────────────┘ └──────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ :8080
|
||||||
|
Browser
|
||||||
|
(OpenLayers UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Draw Boundary** | Freehand polygon drawing on map |
|
||||||
|
| **Draw Roads** | Road centerline drawing (buffered to configured width) |
|
||||||
|
| **Auto Road Grid** | Automatic internal road generation if no roads drawn |
|
||||||
|
| **Block Detection** | Remaining buildable areas become blocks |
|
||||||
|
| **Cul-de-sacs** | Auto-generated for blocks exceeding max length |
|
||||||
|
| **Parcel Subdivision** | Rectangular parcel generation respecting min frontage/depth |
|
||||||
|
| **Shape Optimization** | Bad/small parcels absorbed by neighbors |
|
||||||
|
| **Addressing** | Auto address assignment per block/plot |
|
||||||
|
| **GeoJSON Export** | Download all results |
|
||||||
|
| **Layer Controls** | Toggle parcels, roads, blocks, cul-de-sacs |
|
||||||
|
| **Hover Tooltips** | Area, frontage, depth, address per parcel |
|
||||||
|
|
||||||
|
## Default Parameters
|
||||||
|
|
||||||
|
```
|
||||||
|
min_frontage = 12 m
|
||||||
|
min_depth = 25 m
|
||||||
|
road_width = 9 m
|
||||||
|
max_block_length = 120 m
|
||||||
|
allow_culdesac = true
|
||||||
|
corner_radius = 3 m
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker & Docker Compose installed
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone / extract project
|
||||||
|
cd parcel-tool
|
||||||
|
|
||||||
|
# Build and start all services
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Access at:
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Guide
|
||||||
|
|
||||||
|
### Step 1 — Draw Boundary
|
||||||
|
1. Click **Draw Boundary** in the toolbar
|
||||||
|
2. Click on the map to add polygon vertices
|
||||||
|
3. **Double-click** to finish the polygon
|
||||||
|
4. Press **Escape** to cancel
|
||||||
|
|
||||||
|
### Step 2 — (Optional) Draw Roads
|
||||||
|
1. Click **Draw Road**
|
||||||
|
2. Click to add road centerline vertices
|
||||||
|
3. Double-click to finish
|
||||||
|
4. Repeat for additional roads
|
||||||
|
|
||||||
|
> If no roads are drawn, the engine auto-generates an internal road grid based on `max_block_length`.
|
||||||
|
|
||||||
|
### Step 3 — Configure Parameters
|
||||||
|
Adjust the configuration panel:
|
||||||
|
- **Min Frontage** — minimum plot road frontage
|
||||||
|
- **Min Depth** — minimum plot depth
|
||||||
|
- **Road Width** — road right-of-way width
|
||||||
|
- **Max Block Length** — triggers cul-de-sac or cross-road insertion
|
||||||
|
- **Corner Radius** — road intersection corner rounding
|
||||||
|
- **Allow Cul-de-sacs** — toggle cul-de-sac generation
|
||||||
|
|
||||||
|
### Step 4 — Generate
|
||||||
|
Click **⚡ Generate Subdivision**
|
||||||
|
|
||||||
|
Results appear color-coded:
|
||||||
|
- 🔵 **Blue** — parcels
|
||||||
|
- ⚫ **Dark grey** — roads
|
||||||
|
- 🟢 **Green dashed** — block boundaries
|
||||||
|
- 🟡 **Yellow** — cul-de-sacs
|
||||||
|
|
||||||
|
### Step 5 — Inspect & Export
|
||||||
|
- Hover over parcels to see attributes
|
||||||
|
- Click parcels to highlight/select
|
||||||
|
- Export all data as GeoJSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### POST `/api/subdivide`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"boundary": { /* GeoJSON Polygon */ },
|
||||||
|
"roads": [ /* GeoJSON LineStrings */ ],
|
||||||
|
"config": {
|
||||||
|
"min_frontage": 12,
|
||||||
|
"min_depth": 25,
|
||||||
|
"road_width": 9,
|
||||||
|
"max_block_length": 120,
|
||||||
|
"allow_culdesac": true,
|
||||||
|
"corner_radius": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parcels": [ /* GeoJSON Features with properties */ ],
|
||||||
|
"roads": [ /* GeoJSON Features */ ],
|
||||||
|
"blocks": [ /* GeoJSON Features */ ],
|
||||||
|
"culdesacs": [ /* GeoJSON Features */ ],
|
||||||
|
"stats": {
|
||||||
|
"total_parcels": 42,
|
||||||
|
"total_blocks": 4,
|
||||||
|
"avg_parcel_area_m2": 340.5,
|
||||||
|
"culdesacs": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/api/health`
|
||||||
|
Returns `{"status": "ok"}`
|
||||||
|
|
||||||
|
### GET `/api/config/defaults`
|
||||||
|
Returns default configuration values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subdivision Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Parse boundary polygon (EPSG:4326 → WGS84)
|
||||||
|
2. Buffer user roads → road polygons
|
||||||
|
└─ If no roads: auto-generate grid roads at max_block_length intervals
|
||||||
|
3. Subtract roads from boundary → buildable blocks
|
||||||
|
4. For each oversized block (> max_block_length):
|
||||||
|
└─ Insert cul-de-sac if allow_culdesac=true
|
||||||
|
5. For each block:
|
||||||
|
a. Detect dominant orientation via OBB
|
||||||
|
b. Determine double/single frontage layout
|
||||||
|
c. Calculate parcel columns and rows
|
||||||
|
d. Clip each parcel cell to block boundary
|
||||||
|
6. Shape QC:
|
||||||
|
- Compactness check (reject triangular/complex shapes)
|
||||||
|
- Minimum area check
|
||||||
|
- Absorb rejected parcels into neighboring plots
|
||||||
|
7. Assign addresses: "Plot N, Block B Road"
|
||||||
|
8. Return GeoJSON FeatureCollection
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Backend (Python/FastAPI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
Pure HTML/JS — just open `frontend/index.html` in a browser (update API_BASE to `http://localhost:8000`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
parcel-tool/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── README.md
|
||||||
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── main.py ← Subdivision engine
|
||||||
|
├── frontend/
|
||||||
|
│ └── index.html ← OpenLayers UI
|
||||||
|
└── nginx/
|
||||||
|
└── nginx.conf ← Reverse proxy config
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The map uses CartoDB Dark Matter basemap (no API key needed)
|
||||||
|
- Coordinate system: EPSG:4326 (WGS84) for I/O, EPSG:3857 for display
|
||||||
|
- Geometry engine: Shapely 2.x with GEOS backend
|
||||||
|
- For large sites (>100ha), processing may take a few seconds
|
||||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libgeos-dev \
|
||||||
|
libgeos-c1v5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY main.py .
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
|
|
||||||
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
4
backend/entrypoint.sh
Normal file
4
backend/entrypoint.sh
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "Starting Parcel Subdivision Backend on port 5000..."
|
||||||
|
exec uvicorn main:app --host 0.0.0.0 --port 5000
|
||||||
651
backend/main.py
Normal file
651
backend/main.py
Normal 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()
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.30.1
|
||||||
|
pydantic==2.7.4
|
||||||
|
shapely==2.0.4
|
||||||
|
numpy==1.26.4
|
||||||
|
python-multipart==0.0.9
|
||||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: parcel-backend
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- FLASK_DEBUG=true
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- LOG_LEVEL=DEBUG
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
networks:
|
||||||
|
- parcel-network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: parcel-frontend
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- parcel-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
parcel-network:
|
||||||
|
driver: bridge
|
||||||
25
frontend/Dockerfile
Normal file
25
frontend/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
RUN mkdir -p /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy all frontend files
|
||||||
|
COPY index.html /usr/share/nginx/html/
|
||||||
|
#COPY styles.css /usr/share/nginx/html/
|
||||||
|
#COPY parcel-ui.js /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Ensure correct permissions
|
||||||
|
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||||
|
chmod -R 755 /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
1603
frontend/index.html
Normal file
1603
frontend/index.html
Normal file
File diff suppressed because it is too large
Load Diff
92
frontend/nginx.conf
Normal file
92
frontend/nginx.conf
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
|
application/javascript application/xml+rss
|
||||||
|
application/json;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers (adjusted)
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Frontend - serve index.html for all routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets with correct MIME types
|
||||||
|
location ~* \.(css|js)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
# Ensure correct MIME types
|
||||||
|
types {
|
||||||
|
text/css css;
|
||||||
|
application/javascript js;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Images and fonts
|
||||||
|
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API Proxy - remove from frontend nginx
|
||||||
|
# Backend will be accessed directly by frontend JavaScript
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error pages
|
||||||
|
error_page 404 /index.html;
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
nginx/nginx.conf
Normal file
40
nginx/nginx.conf
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Upstream servers
|
||||||
|
upstream backend {
|
||||||
|
server backend:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user