REMB / src /export /dxf_exporter.py
Cuong2004's picture
Initial commit: REMB - AI-Powered Industrial Estate Master Plan Optimization Engine
b010f1b
"""
DXF Exporter - Engineering Delivery
Export layouts to AutoCAD DXF format with proper layering
"""
import ezdxf
from ezdxf.enums import TextEntityAlignment
from shapely.geometry import Polygon, MultiPolygon, LineString, MultiLineString
from typing import List, Optional, Dict, Tuple
from pathlib import Path
import logging
from datetime import datetime
from src.models.domain import Layout, Plot, PlotType, SiteBoundary, RoadNetwork
logger = logging.getLogger(__name__)
class DXFExporter:
"""
AutoCAD DXF file exporter
Exports industrial estate layouts with proper layering:
- SITE_BOUNDARY: Site boundary polygon
- ROADS_PRIMARY: Primary road network
- ROADS_SECONDARY: Secondary road network
- PLOTS_INDUSTRIAL: Industrial plots
- PLOTS_GREEN: Green space plots
- UTILITIES: Utility corridors
- ANNOTATIONS: Text annotations and dimensions
"""
# Layer configuration
LAYERS = {
'SITE_BOUNDARY': {'color': 7, 'linetype': 'CONTINUOUS'}, # White
'ROADS_PRIMARY': {'color': 1, 'linetype': 'CONTINUOUS'}, # Red
'ROADS_SECONDARY': {'color': 3, 'linetype': 'DASHED'}, # Green
'ROADS_TERTIARY': {'color': 4, 'linetype': 'DASHED'}, # Cyan
'PLOTS_INDUSTRIAL': {'color': 5, 'linetype': 'CONTINUOUS'}, # Blue
'PLOTS_GREEN': {'color': 3, 'linetype': 'CONTINUOUS'}, # Green
'PLOTS_UTILITY': {'color': 6, 'linetype': 'CONTINUOUS'}, # Magenta
'CONSTRAINTS': {'color': 1, 'linetype': 'PHANTOM'}, # Red dashed
'ANNOTATIONS': {'color': 7, 'linetype': 'CONTINUOUS'}, # White
'DIMENSIONS': {'color': 2, 'linetype': 'CONTINUOUS'}, # Yellow
}
def __init__(self, version: str = "R2010"):
"""
Initialize DXF exporter
Args:
version: DXF version ('R12', 'R2000', 'R2004', 'R2007', 'R2010', 'R2013', 'R2018')
"""
self.version = version
self.logger = logging.getLogger(__name__)
def export(
self,
layout: Layout,
filepath: str,
include_annotations: bool = True,
include_dimensions: bool = True
) -> str:
"""
Export layout to DXF file
Args:
layout: Layout to export
filepath: Output file path
include_annotations: Include text annotations
include_dimensions: Include dimensions
Returns:
Path to created file
"""
self.logger.info(f"Exporting layout {layout.id} to DXF: {filepath}")
# Create new DXF document with setup=True to include standard linetypes (DASHED, PHANTOM, etc.)
doc = ezdxf.new(dxfversion=self.version, setup=True)
msp = doc.modelspace()
# Setup layers and dimension style
self._setup_layers(doc)
self._setup_dimension_style(doc)
# Export site boundary
if layout.site_boundary and layout.site_boundary.geometry:
self._export_site_boundary(msp, layout.site_boundary)
# Export road network
if layout.road_network:
self._export_road_network(msp, layout.road_network)
# Export plots
for plot in layout.plots:
self._export_plot(msp, plot)
# Export constraints
if layout.site_boundary:
for constraint in layout.site_boundary.constraints:
self._export_constraint(msp, constraint)
# Add annotations
if include_annotations:
self._add_annotations(msp, layout)
# Add dimensions
if include_dimensions:
self._add_dimensions(msp, layout)
# Add title block
self._add_title_block(msp, layout)
# Save file with error handling for file locking
output_path = Path(filepath)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
doc.saveas(str(filepath))
except PermissionError:
# File is locked (e.g., open in AutoCAD)
self.logger.warning(f"File is locked: {filepath}, trying alternate name")
timestamp = datetime.now().strftime('%H%M%S')
alt_path = output_path.with_stem(f"{output_path.stem}_{timestamp}")
doc.saveas(str(alt_path))
self.logger.info(f"DXF exported to alternate path: {alt_path}")
return str(alt_path)
self.logger.info(f"DXF exported successfully: {filepath}")
return str(filepath)
def _setup_layers(self, doc):
"""Setup DXF layers with colors and linetypes"""
for layer_name, config in self.LAYERS.items():
linetype = config.get('linetype', 'CONTINUOUS')
# Ensure linetype exists (setup=True should provide DASHED, PHANTOM, etc.)
if linetype not in doc.linetypes:
linetype = 'CONTINUOUS'
doc.layers.add(
layer_name,
color=config['color'],
linetype=linetype
)
def _setup_dimension_style(self, doc):
"""
Setup custom dimension style for stability
Prevents crashes from malformed default dimension styles
"""
# Create custom engineering dimension style
if 'ENG_DIM' not in doc.dimstyles:
dim_style = doc.dimstyles.new('ENG_DIM')
dim_style.dxf.dimtxt = 2.5 # Text height
dim_style.dxf.dimasz = 2.5 # Arrow size
dim_style.dxf.dimexe = 1.5 # Extension line extension
dim_style.dxf.dimexo = 0.625 # Extension line offset
dim_style.dxf.dimgap = 0.625 # Gap from dimension line
dim_style.dxf.dimdec = 2 # Decimal places
dim_style.dxf.dimtad = 1 # Text above dimension line
dim_style.dxf.dimclrd = 2 # Dimension line color (yellow)
dim_style.dxf.dimclre = 2 # Extension line color (yellow)
dim_style.dxf.dimclrt = 7 # Text color (white)
def _export_site_boundary(self, msp, site: SiteBoundary):
"""Export site boundary polygon"""
if site.geometry:
coords = self._polygon_to_coords(site.geometry)
msp.add_lwpolyline(
coords,
dxfattribs={'layer': 'SITE_BOUNDARY', 'closed': True}
)
def _export_road_network(self, msp, road_network: RoadNetwork):
"""Export road network lines"""
# Primary roads
if road_network.primary_roads:
self._export_multilinestring(
msp, road_network.primary_roads, 'ROADS_PRIMARY'
)
# Secondary roads
if road_network.secondary_roads:
self._export_multilinestring(
msp, road_network.secondary_roads, 'ROADS_SECONDARY'
)
# Tertiary roads
if road_network.tertiary_roads:
self._export_multilinestring(
msp, road_network.tertiary_roads, 'ROADS_TERTIARY'
)
def _export_multilinestring(self, msp, geometry, layer: str):
"""Export MultiLineString or LineString to DXF"""
if hasattr(geometry, 'geoms'):
lines = geometry.geoms
else:
lines = [geometry]
for line in lines:
if isinstance(line, LineString):
points = [(p[0], p[1]) for p in line.coords]
msp.add_lwpolyline(
points,
dxfattribs={'layer': layer}
)
def _export_plot(self, msp, plot: Plot):
"""Export a plot polygon"""
if not plot.geometry:
return
# Determine layer based on plot type
layer_map = {
PlotType.INDUSTRIAL: 'PLOTS_INDUSTRIAL',
PlotType.GREEN_SPACE: 'PLOTS_GREEN',
PlotType.UTILITY: 'PLOTS_UTILITY',
PlotType.ROAD: 'ROADS_TERTIARY',
PlotType.BUFFER: 'CONSTRAINTS'
}
layer = layer_map.get(plot.type, 'PLOTS_INDUSTRIAL')
# Export polygon
coords = self._polygon_to_coords(plot.geometry)
msp.add_lwpolyline(
coords,
dxfattribs={'layer': layer, 'closed': True}
)
# Add plot ID label at centroid
centroid = plot.geometry.centroid
msp.add_text(
plot.id,
dxfattribs={
'layer': 'ANNOTATIONS',
'height': 2,
'insert': (centroid.x, centroid.y)
}
)
def _export_constraint(self, msp, constraint):
"""Export constraint zone"""
if not constraint.geometry:
return
if isinstance(constraint.geometry, Polygon):
coords = self._polygon_to_coords(constraint.geometry)
msp.add_lwpolyline(
coords,
dxfattribs={'layer': 'CONSTRAINTS', 'closed': True}
)
def _add_annotations(self, msp, layout: Layout):
"""Add text annotations"""
# Summary annotation at top-right
if layout.site_boundary:
bounds = layout.site_boundary.geometry.bounds
maxx, maxy = bounds[2], bounds[3]
# Create summary text
metrics = layout.metrics
summary_lines = [
f"LAYOUT SUMMARY",
f"----------------",
f"Total Area: {metrics.total_area_sqm:.0f} m²",
f"Sellable: {metrics.sellable_area_sqm:.0f} m² ({metrics.sellable_ratio*100:.1f}%)",
f"Green: {metrics.green_space_area_sqm:.0f} m² ({metrics.green_space_ratio*100:.1f}%)",
f"Roads: {metrics.road_area_sqm:.0f} m²",
f"Num Plots: {metrics.num_plots}",
f"Compliant: {'Yes' if metrics.is_compliant else 'No'}"
]
y_offset = maxy + 20
for line in summary_lines:
msp.add_text(
line,
dxfattribs={
'layer': 'ANNOTATIONS',
'height': 3,
'insert': (maxx + 20, y_offset)
}
)
y_offset -= 5
def _add_dimensions(self, msp, layout: Layout):
"""Add dimension annotations"""
if layout.site_boundary and layout.site_boundary.geometry:
bounds = layout.site_boundary.geometry.bounds
minx, miny, maxx, maxy = bounds
# Add site dimensions using custom dimension style
# Width dimension
msp.add_linear_dim(
base=(minx, miny - 10),
p1=(minx, miny),
p2=(maxx, miny),
dimstyle='ENG_DIM',
dxfattribs={'layer': 'DIMENSIONS'}
).render()
# Height dimension
msp.add_linear_dim(
base=(maxx + 10, miny),
p1=(maxx, miny),
p2=(maxx, maxy),
angle=90,
dimstyle='ENG_DIM',
dxfattribs={'layer': 'DIMENSIONS'}
).render()
def _add_title_block(self, msp, layout: Layout):
"""Add title block with project info"""
if not layout.site_boundary:
return
bounds = layout.site_boundary.geometry.bounds
minx, miny = bounds[0], bounds[1]
# Title block at bottom-left
title_lines = [
"INDUSTRIAL ESTATE MASTER PLAN",
f"Layout ID: {layout.id}",
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"REMB Optimization Engine v0.1.0",
"PiXerse.AI"
]
y_offset = miny - 30
for i, line in enumerate(title_lines):
msp.add_text(
line,
dxfattribs={
'layer': 'ANNOTATIONS',
'height': 4 if i == 0 else 2.5,
'insert': (minx, y_offset)
}
)
y_offset -= 6
def _polygon_to_coords(self, polygon: Polygon) -> List[Tuple[float, float]]:
"""Convert Shapely polygon to coordinate list"""
return [(p[0], p[1]) for p in polygon.exterior.coords]
def export_pareto_front(
self,
pareto_front,
output_dir: str,
prefix: str = "layout"
) -> List[str]:
"""
Export all layouts in a Pareto front
Args:
pareto_front: ParetoFront object
output_dir: Output directory
prefix: Filename prefix
Returns:
List of created file paths
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
files = []
for i, layout in enumerate(pareto_front.layouts):
filename = f"{prefix}_{i:02d}_{layout.id[:8]}.dxf"
filepath = output_path / filename
self.export(layout, str(filepath))
files.append(str(filepath))
return files
# Example usage
if __name__ == "__main__":
from shapely.geometry import box
from src.models.domain import Layout, Plot, PlotType, SiteBoundary, RoadNetwork, LayoutMetrics
from shapely.geometry import LineString, MultiLineString
# Create example layout
site_geom = box(0, 0, 500, 500)
site = SiteBoundary(geometry=site_geom, area_sqm=site_geom.area)
site.buildable_area_sqm = site.area_sqm
layout = Layout(site_boundary=site)
# Add some plots
layout.plots = [
Plot(
id="plot_001",
geometry=box(60, 60, 160, 160),
area_sqm=10000,
type=PlotType.INDUSTRIAL,
width_m=100,
depth_m=100
),
Plot(
id="plot_002",
geometry=box(200, 60, 300, 160),
area_sqm=10000,
type=PlotType.INDUSTRIAL,
width_m=100,
depth_m=100
),
Plot(
id="green_001",
geometry=box(60, 200, 160, 300),
area_sqm=10000,
type=PlotType.GREEN_SPACE,
width_m=100,
depth_m=100
)
]
# Add road network
layout.road_network = RoadNetwork(
primary_roads=MultiLineString([
LineString([(0, 250), (500, 250)]),
LineString([(250, 0), (250, 500)])
]),
total_length_m=1000
)
# Calculate metrics
layout.metrics = LayoutMetrics(
total_area_sqm=250000,
sellable_area_sqm=20000,
green_space_area_sqm=10000,
road_area_sqm=24000,
sellable_ratio=0.65,
green_space_ratio=0.15,
num_plots=2,
is_compliant=True
)
# Export
exporter = DXFExporter()
output_file = exporter.export(
layout,
"output/test_layout.dxf",
include_annotations=True,
include_dimensions=True
)
print(f"Exported to: {output_file}")