From f38bb4d13687de53ac88b1d005e2f6b160a29aa9 Mon Sep 17 00:00:00 2001 From: kanyarimwangi Date: Tue, 3 Mar 2026 11:33:31 +0300 Subject: [PATCH] improved version --- .idea/.gitignore | 5 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/parcelFiles.iml | 12 + .idea/vcs.xml | 6 + README.md | 226 +++ backend/Dockerfile | 21 + backend/__pycache__/main.cpython-311.pyc | Bin 0 -> 36496 bytes backend/entrypoint.sh | 4 + backend/main.py | 651 +++++++ backend/requirements.txt | 6 + docker-compose.yml | 40 + frontend/Dockerfile | 25 + frontend/index.html | 1603 +++++++++++++++++ frontend/nginx.conf | 92 + nginx/nginx.conf | 40 + 17 files changed, 2752 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/parcelFiles.iml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/__pycache__/main.cpython-311.pyc create mode 100644 backend/entrypoint.sh create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 nginx/nginx.conf diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..e557d17 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..784219f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/parcelFiles.iml b/.idea/parcelFiles.iml new file mode 100644 index 0000000..f14d577 --- /dev/null +++ b/.idea/parcelFiles.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a61e24c --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0f456de --- /dev/null +++ b/backend/Dockerfile @@ -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"] + diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..802ddff23f2c647b9485b03cccbbb651474aec99 GIT binary patch literal 36496 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 sVTG#eV(dT>sp)cx$Gt{PtHtY&pGe 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 CxxknJ literal 0 HcmV?d00001 diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..d0ae80e --- /dev/null +++ b/backend/entrypoint.sh @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..ada2ade --- /dev/null +++ b/backend/main.py @@ -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() \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7358d7a --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1221c4a --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9666ebf --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c9ed426 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,1603 @@ + + + + + +ParcelGen β€” Land Subdivision Tool + + + + + + +
+ + + + + +
+
+ + +
+
β—‡ Boundary
+
βŸ‹ Road
+
β–¦ Building
+
πŸ“ Measure
+
βœ• Stop
+
+ + +
+ + +
+
β€”βœ•
+
+
+ + +
+ Units: + METRIC (m) +
+ + +
+ +
+
+
+ + Basemap +
+ β–² +
+
+
+
+
+
+
+ +
+
+
+ + Layers +
+ β–² +
+
+
+
+
+
+
+
+ + +
+
+ Draw a boundary polygon to begin. + β€” +
+ + +
+
β€”
+
+
+ + +
+
+
Subdividing parcels…
+
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d7b5f8e --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..5f4f740 --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +}