File size: 3,973 Bytes
2f91d7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
from __future__ import annotations

import uuid
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET

from .geometry import calculate_site_metrics
from .models import SiteSelection


def parse_kml_file(path: str | Path) -> SiteSelection:
    source = Path(path)
    xml_text = _read_kml_text(source)
    root = ET.fromstring(xml_text)
    polygons = _extract_polygons(root)
    if not polygons:
        raise ValueError("KML/KMZ must contain at least one Polygon boundary.")
    geometry = _largest_polygon(polygons)
    metrics = calculate_site_metrics(geometry)
    return SiteSelection(
        id=f"S-{uuid.uuid4().hex[:8]}",
        selection_type="kml_boundary",
        coordinate_mode="wgs84",
        geometry_geojson=geometry,
        local_geometry=None,
        anchor_lat=metrics["centroid"][0],
        anchor_lon=metrics["centroid"][1],
        radius_m=None,
        area_sqm=metrics["area_sqm"],
        perimeter_m=metrics["perimeter_m"],
        centroid=metrics["centroid"],
        bbox=metrics["bbox"],
        unit_source="Google Earth / KML WGS84 coordinates",
        accuracy_label="uploaded Google Earth/KML boundary",
        source_files=[source.name],
        selected_boundary_id=None,
        limitations=[
            "KML/KMZ geometry is treated as a user-exported Google Earth or GIS boundary.",
            "It is not legal/cadastral boundary verification.",
            "Verify against faculty CAD, survey, or project documents before plot-level decisions.",
        ],
    )


def _read_kml_text(source: Path) -> str:
    suffix = source.suffix.lower()
    if suffix == ".kml":
        return source.read_text(encoding="utf-8-sig")
    if suffix == ".kmz":
        with zipfile.ZipFile(source) as archive:
            kml_names = [name for name in archive.namelist() if name.lower().endswith(".kml")]
            if not kml_names:
                raise ValueError("KMZ archive does not contain a KML file.")
            with archive.open(kml_names[0]) as handle:
                return handle.read().decode("utf-8-sig")
    raise ValueError("KML parser supports only .kml and .kmz files.")


def _extract_polygons(root: ET.Element) -> list[dict[str, Any]]:
    polygons: list[dict[str, Any]] = []
    for polygon in root.iter():
        if not _tag_endswith(polygon.tag, "Polygon"):
            continue
        coordinates_text = None
        for child in polygon.iter():
            if _tag_endswith(child.tag, "coordinates") and child.text:
                coordinates_text = child.text
                break
        if not coordinates_text:
            continue
        ring = _parse_coordinates(coordinates_text)
        if len(ring) >= 4:
            polygons.append({"type": "Polygon", "coordinates": [ring]})
    return polygons


def _parse_coordinates(text: str) -> list[list[float]]:
    points: list[list[float]] = []
    for token in text.replace("\n", " ").replace("\t", " ").split():
        parts = [part for part in token.split(",") if part != ""]
        if len(parts) < 2:
            continue
        lon = float(parts[0])
        lat = float(parts[1])
        if not (-180 <= lon <= 180 and -90 <= lat <= 90):
            raise ValueError("KML coordinates do not look like WGS84 longitude/latitude.")
        points.append([lon, lat])
    if points and points[0] != points[-1]:
        points.append(points[0])
    return points


def _largest_polygon(polygons: list[dict[str, Any]]) -> dict[str, Any]:
    scored = []
    for geometry in polygons:
        try:
            scored.append((calculate_site_metrics(geometry)["area_sqm"] or 0, geometry))
        except Exception:
            continue
    if not scored:
        raise ValueError("KML polygons could not be converted to valid site geometry.")
    return max(scored, key=lambda item: item[0])[1]


def _tag_endswith(tag: str, suffix: str) -> bool:
    return tag.endswith("}" + suffix) or tag == suffix