Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
"""Generative Design — Stage 1c DXF export via ezdxf.
|
||
|
||
Renders the parcel context (boundary + buildable area) and one variant's placed
|
||
building footprints into a binary DXF for hand-off to architects. Geometry is drawn
|
||
in the parcel's *metric* CRS (metres) — architects work in metres, and DXF has no
|
||
geographic CRS concept, so emitting WGS84 degrees would be unusable.
|
||
|
||
Layers:
|
||
* ``PARCEL`` — границы участка (синий).
|
||
* ``BUILDABLE`` — пятно застройки после отступов (зелёный, пунктир-цвет).
|
||
* ``BUILDINGS`` — секции варианта (красный), с текстовой подписью номера секции.
|
||
|
||
Returns ``bytes`` (binary DXF, R2010) ready for an HTTP response / file write.
|
||
Deterministic, no DB / no network. ``ezdxf`` is a light import, so it stays at
|
||
module level (unlike WeasyPrint in :mod:`pdf`).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
|
||
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||
from ezdxf.enums import TextEntityAlignment
|
||
from ezdxf.filemanagement import new as ezdxf_new
|
||
from shapely.geometry import Polygon
|
||
|
||
from app.schemas.concept import ConceptVariant
|
||
from app.services.generative.geometry import Parcel
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# AutoCAD Color Index (ACI) per layer.
|
||
_ACI_PARCEL = 5 # blue
|
||
_ACI_BUILDABLE = 3 # green
|
||
_ACI_BUILDINGS = 1 # red
|
||
|
||
_LAYER_PARCEL = "PARCEL"
|
||
_LAYER_BUILDABLE = "BUILDABLE"
|
||
_LAYER_BUILDINGS = "BUILDINGS"
|
||
|
||
# Высота текста подписи секции (метры в модельном пространстве).
|
||
_LABEL_HEIGHT_M = 2.0
|
||
|
||
|
||
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||
pts = list(coords)
|
||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||
pts = pts[:-1]
|
||
return [(float(x), float(y)) for x, y in pts]
|
||
|
||
|
||
def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
|
||
"""Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
|
||
|
||
LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
|
||
замкнутой полилинией на том же слое — иначе легальные вырезы (двор, сервитут,
|
||
охранная зона) терялись бы и заливались сплошняком.
|
||
"""
|
||
msp.add_lwpolyline(
|
||
_ring_points(poly.exterior.coords),
|
||
close=True,
|
||
dxfattribs={"layer": layer},
|
||
)
|
||
for interior in poly.interiors:
|
||
msp.add_lwpolyline(
|
||
_ring_points(interior.coords),
|
||
close=True,
|
||
dxfattribs={"layer": layer},
|
||
)
|
||
|
||
|
||
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
||
"""Собрать binary DXF: участок + пятно застройки + секции одного варианта.
|
||
|
||
Args:
|
||
parcel: Stage 1a участок (метрическая геометрия parcel/buildable).
|
||
variant: вариант, чьи секции рисуем (footprints берём из его geojson —
|
||
но геометрию рисуем из метрического parcel-space через свежий парсинг
|
||
geojson обратно нельзя без CRS, поэтому секции восстанавливаем ниже).
|
||
|
||
Returns:
|
||
bytes: бинарный DXF R2010.
|
||
"""
|
||
doc = ezdxf_new("R2010")
|
||
doc.layers.add(_LAYER_PARCEL, color=_ACI_PARCEL)
|
||
doc.layers.add(_LAYER_BUILDABLE, color=_ACI_BUILDABLE)
|
||
doc.layers.add(_LAYER_BUILDINGS, color=_ACI_BUILDINGS)
|
||
msp = doc.modelspace()
|
||
|
||
# Участок и пятно застройки — из метрической геометрии Parcel.
|
||
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
|
||
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
|
||
|
||
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
||
features = variant.buildings_geojson.get("features", [])
|
||
section_count = 0
|
||
if isinstance(features, list):
|
||
for feature in features:
|
||
footprint = _feature_to_metric_polygon(parcel, feature)
|
||
if footprint is None:
|
||
continue
|
||
section_count += 1
|
||
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
|
||
centroid = footprint.centroid
|
||
label = str(_feature_section_id(feature, section_count))
|
||
text = msp.add_text(
|
||
label,
|
||
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
|
||
)
|
||
text.set_placement(
|
||
(float(centroid.x), float(centroid.y)),
|
||
align=TextEntityAlignment.MIDDLE_CENTER,
|
||
)
|
||
|
||
stream = io.BytesIO()
|
||
doc.write(stream, fmt="bin")
|
||
data = stream.getvalue()
|
||
logger.info(
|
||
"DXF export: strategy=%s sections=%d bytes=%d",
|
||
variant.strategy,
|
||
section_count,
|
||
len(data),
|
||
)
|
||
return data
|
||
|
||
|
||
def _feature_section_id(feature: object, fallback: int) -> int:
|
||
"""Достать section_id из properties Feature, иначе fallback-счётчик."""
|
||
if isinstance(feature, dict):
|
||
props = feature.get("properties")
|
||
if isinstance(props, dict):
|
||
sid = props.get("section_id")
|
||
if isinstance(sid, int):
|
||
return sid
|
||
return fallback
|
||
|
||
|
||
def _feature_to_metric_polygon(parcel: Parcel, feature: object) -> Polygon | None:
|
||
"""WGS84 GeoJSON Feature -> метрический Shapely Polygon (через обратный трансформер).
|
||
|
||
Возвращает None для невалидных/непригодных фич (graceful — экспорт не падает).
|
||
"""
|
||
if not isinstance(feature, dict):
|
||
return None
|
||
geometry = feature.get("geometry")
|
||
if not isinstance(geometry, dict) or geometry.get("type") != "Polygon":
|
||
return None
|
||
coords = geometry.get("coordinates")
|
||
# Shapely mapping() emits nested tuples; accept both tuple and list.
|
||
if not isinstance(coords, (list, tuple)) or not coords:
|
||
return None
|
||
ring = coords[0]
|
||
if not isinstance(ring, (list, tuple)) or len(ring) < 4:
|
||
return None
|
||
|
||
metric_pts: list[tuple[float, float]] = []
|
||
for pt in ring:
|
||
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
||
return None
|
||
lon, lat = float(pt[0]), float(pt[1])
|
||
x, y = parcel.wgs84_to_metric(lon, lat)
|
||
metric_pts.append((x, y))
|
||
|
||
try:
|
||
poly = Polygon(metric_pts)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
return poly if (poly.is_valid and not poly.is_empty) else None
|
||
|
||
|
||
__all__ = ["export_concept_dxf"]
|