"""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.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 _polygon_points(poly: Polygon) -> list[tuple[float, float]]: """Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки).""" coords = list(poly.exterior.coords) # Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам. if len(coords) > 1 and coords[0] == coords[-1]: coords = coords[:-1] return [(float(x), float(y)) for x, y in coords] 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. msp.add_lwpolyline( _polygon_points(parcel.polygon_m), close=True, dxfattribs={"layer": _LAYER_PARCEL}, ) msp.add_lwpolyline( _polygon_points(parcel.buildable_m), close=True, dxfattribs={"layer": _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 msp.add_lwpolyline( _polygon_points(footprint), close=True, dxfattribs={"layer": _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))) 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"]