|
|
""" |
|
|
AIOptimize™ MVP API |
|
|
FastAPI backend for industrial estate planning optimization |
|
|
Per MVP-24h.md specification |
|
|
""" |
|
|
import os |
|
|
import io |
|
|
import json |
|
|
import zipfile |
|
|
from datetime import datetime |
|
|
from typing import Optional, List, Dict, Any |
|
|
from pathlib import Path |
|
|
|
|
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Form |
|
|
from fastapi.middleware.cors import CORSMiddleware |
|
|
from fastapi.responses import StreamingResponse, JSONResponse |
|
|
from pydantic import BaseModel |
|
|
from shapely.geometry import Polygon, shape |
|
|
|
|
|
|
|
|
from src.services.session_manager import session_manager |
|
|
from src.services.gemini_service import gemini_service |
|
|
from src.algorithms.ga_optimizer import SimpleGAOptimizer |
|
|
from src.export.dxf_exporter import DXFExporter |
|
|
from src.models.domain import Layout, Plot, PlotType, SiteBoundary, LayoutMetrics |
|
|
|
|
|
|
|
|
SAMPLE_BOUNDARY = { |
|
|
"type": "Feature", |
|
|
"geometry": { |
|
|
"type": "Polygon", |
|
|
"coordinates": [[ |
|
|
[0, 0], [500, 0], [500, 400], [0, 400], [0, 0] |
|
|
]] |
|
|
}, |
|
|
"properties": {"name": "Sample Industrial Site"} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UploadResponse(BaseModel): |
|
|
session_id: str |
|
|
boundary: Dict[str, Any] |
|
|
metadata: Dict[str, Any] |
|
|
|
|
|
|
|
|
class GenerateRequest(BaseModel): |
|
|
session_id: str |
|
|
target_plots: int = 8 |
|
|
setback: float = 50.0 |
|
|
|
|
|
|
|
|
class ChatRequest(BaseModel): |
|
|
session_id: str |
|
|
message: str |
|
|
|
|
|
|
|
|
class ChatResponse(BaseModel): |
|
|
message: str |
|
|
model: str |
|
|
|
|
|
|
|
|
class ExportRequest(BaseModel): |
|
|
session_id: str |
|
|
option_id: int |
|
|
|
|
|
|
|
|
class HealthResponse(BaseModel): |
|
|
status: str |
|
|
version: str |
|
|
gemini_available: bool |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI( |
|
|
title="AIOptimize™ API", |
|
|
description="AI-Powered Industrial Estate Planning Engine", |
|
|
version="1.0.0" |
|
|
) |
|
|
|
|
|
|
|
|
app.add_middleware( |
|
|
CORSMiddleware, |
|
|
allow_origins=["*"], |
|
|
allow_credentials=True, |
|
|
allow_methods=["*"], |
|
|
allow_headers=["*"], |
|
|
) |
|
|
|
|
|
|
|
|
from src.api.agent_api import router as agent_router |
|
|
app.include_router(agent_router) |
|
|
|
|
|
|
|
|
ga_optimizer = SimpleGAOptimizer() |
|
|
dxf_exporter = DXFExporter() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/health", response_model=HealthResponse) |
|
|
async def health_check(): |
|
|
"""Health check endpoint""" |
|
|
return HealthResponse( |
|
|
status="healthy", |
|
|
version="1.0.0", |
|
|
gemini_available=gemini_service.is_available |
|
|
) |
|
|
|
|
|
|
|
|
@app.get("/api/sample-data") |
|
|
async def get_sample_data(): |
|
|
"""Get sample GeoJSON boundary data""" |
|
|
return SAMPLE_BOUNDARY |
|
|
|
|
|
|
|
|
@app.post("/api/upload-boundary", response_model=UploadResponse) |
|
|
async def upload_boundary(file: UploadFile = File(None), geojson: str = Form(None)): |
|
|
""" |
|
|
Upload site boundary (GeoJSON) |
|
|
|
|
|
Accepts either file upload or JSON string |
|
|
""" |
|
|
try: |
|
|
|
|
|
if file and file.filename: |
|
|
content = await file.read() |
|
|
geojson_data = json.loads(content) |
|
|
elif geojson: |
|
|
geojson_data = json.loads(geojson) |
|
|
else: |
|
|
raise HTTPException(400, "No boundary data provided") |
|
|
|
|
|
|
|
|
if geojson_data.get("type") == "Feature": |
|
|
geometry = geojson_data.get("geometry", {}) |
|
|
elif geojson_data.get("type") == "FeatureCollection": |
|
|
features = geojson_data.get("features", []) |
|
|
if features: |
|
|
geometry = features[0].get("geometry", {}) |
|
|
else: |
|
|
raise HTTPException(400, "No features in FeatureCollection") |
|
|
elif geojson_data.get("type") == "Polygon": |
|
|
geometry = geojson_data |
|
|
else: |
|
|
raise HTTPException(400, "Invalid GeoJSON format") |
|
|
|
|
|
|
|
|
coords = geometry.get("coordinates", [[]])[0] |
|
|
if not coords: |
|
|
raise HTTPException(400, "No coordinates found") |
|
|
|
|
|
|
|
|
polygon = Polygon(coords) |
|
|
if not polygon.is_valid: |
|
|
polygon = polygon.buffer(0) |
|
|
|
|
|
|
|
|
metadata = { |
|
|
"area": polygon.area, |
|
|
"perimeter": polygon.length, |
|
|
"bounds": list(polygon.bounds), |
|
|
"centroid": [polygon.centroid.x, polygon.centroid.y] |
|
|
} |
|
|
|
|
|
|
|
|
session = session_manager.create_session() |
|
|
session_manager.set_boundary( |
|
|
session.id, |
|
|
boundary=geojson_data, |
|
|
coords=coords, |
|
|
metadata=metadata |
|
|
) |
|
|
|
|
|
return UploadResponse( |
|
|
session_id=session.id, |
|
|
boundary=geojson_data, |
|
|
metadata=metadata |
|
|
) |
|
|
|
|
|
except json.JSONDecodeError as e: |
|
|
raise HTTPException(400, f"Invalid JSON format: {str(e)}") |
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
raise HTTPException(500, f"Error processing boundary: {str(e)}") |
|
|
|
|
|
|
|
|
|
|
|
class UploadBoundaryRequest(BaseModel): |
|
|
geojson: Dict[str, Any] |
|
|
|
|
|
|
|
|
@app.post("/api/upload-boundary-json", response_model=UploadResponse) |
|
|
async def upload_boundary_json(request: UploadBoundaryRequest): |
|
|
"""Upload site boundary via JSON body""" |
|
|
try: |
|
|
geojson_data = request.geojson |
|
|
|
|
|
|
|
|
if geojson_data.get("type") == "Feature": |
|
|
geometry = geojson_data.get("geometry", {}) |
|
|
elif geojson_data.get("type") == "FeatureCollection": |
|
|
features = geojson_data.get("features", []) |
|
|
if features: |
|
|
geometry = features[0].get("geometry", {}) |
|
|
else: |
|
|
raise HTTPException(400, "No features in FeatureCollection") |
|
|
elif geojson_data.get("type") == "Polygon": |
|
|
geometry = geojson_data |
|
|
else: |
|
|
raise HTTPException(400, "Invalid GeoJSON format") |
|
|
|
|
|
coords = geometry.get("coordinates", [[]])[0] |
|
|
if not coords: |
|
|
raise HTTPException(400, "No coordinates found") |
|
|
|
|
|
polygon = Polygon(coords) |
|
|
if not polygon.is_valid: |
|
|
polygon = polygon.buffer(0) |
|
|
|
|
|
metadata = { |
|
|
"area": polygon.area, |
|
|
"perimeter": polygon.length, |
|
|
"bounds": list(polygon.bounds), |
|
|
"centroid": [polygon.centroid.x, polygon.centroid.y] |
|
|
} |
|
|
|
|
|
session = session_manager.create_session() |
|
|
session_manager.set_boundary( |
|
|
session.id, |
|
|
boundary=geojson_data, |
|
|
coords=coords, |
|
|
metadata=metadata |
|
|
) |
|
|
|
|
|
return UploadResponse( |
|
|
session_id=session.id, |
|
|
boundary=geojson_data, |
|
|
metadata=metadata |
|
|
) |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
raise HTTPException(500, f"Error: {str(e)}") |
|
|
|
|
|
|
|
|
@app.post("/api/upload-dxf", response_model=UploadResponse) |
|
|
async def upload_dxf(file: UploadFile = File(...)): |
|
|
""" |
|
|
Upload site boundary from DXF or DWG file |
|
|
|
|
|
Parses LWPOLYLINE entities to extract site boundary polygon |
|
|
Supports both DXF and DWG formats (AutoCAD R13-R2021) |
|
|
""" |
|
|
import ezdxf |
|
|
import tempfile |
|
|
|
|
|
if not file.filename: |
|
|
raise HTTPException(400, "No file provided") |
|
|
|
|
|
filename_lower = file.filename.lower() |
|
|
if not (filename_lower.endswith('.dxf') or filename_lower.endswith('.dwg')): |
|
|
raise HTTPException(400, "Please upload a valid .dxf or .dwg file") |
|
|
|
|
|
try: |
|
|
|
|
|
content = await file.read() |
|
|
suffix = '.dwg' if filename_lower.endswith('.dwg') else '.dxf' |
|
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: |
|
|
tmp.write(content) |
|
|
tmp_path = tmp.name |
|
|
|
|
|
|
|
|
try: |
|
|
doc = ezdxf.readfile(tmp_path) |
|
|
except IOError as e: |
|
|
|
|
|
if filename_lower.endswith('.dwg'): |
|
|
raise HTTPException( |
|
|
400, |
|
|
"DWG file format requires conversion. " |
|
|
"Please convert your DWG file to DXF using AutoCAD, LibreCAD, or an online converter, " |
|
|
"then upload the DXF file. " |
|
|
"Alternatively, most CAD software can export/save as DXF format." |
|
|
) |
|
|
raise HTTPException(400, f"Failed to read file: {str(e)}") |
|
|
msp = doc.modelspace() |
|
|
|
|
|
|
|
|
polygons = [] |
|
|
for entity in msp: |
|
|
if entity.dxftype() == 'LWPOLYLINE': |
|
|
if entity.closed: |
|
|
points = list(entity.get_points()) |
|
|
if len(points) >= 3: |
|
|
coords = [(p[0], p[1]) for p in points] |
|
|
coords.append(coords[0]) |
|
|
poly = Polygon(coords) |
|
|
if poly.is_valid: |
|
|
polygons.append((poly, coords)) |
|
|
elif entity.dxftype() == 'POLYLINE': |
|
|
if entity.is_closed: |
|
|
points = list(entity.points()) |
|
|
if len(points) >= 3: |
|
|
coords = [(p[0], p[1]) for p in points] |
|
|
coords.append(coords[0]) |
|
|
poly = Polygon(coords) |
|
|
if poly.is_valid: |
|
|
polygons.append((poly, coords)) |
|
|
|
|
|
|
|
|
os.unlink(tmp_path) |
|
|
|
|
|
if not polygons: |
|
|
raise HTTPException(400, "No closed polygons found in DXF file") |
|
|
|
|
|
|
|
|
polygon, coords = max(polygons, key=lambda x: x[0].area) |
|
|
|
|
|
|
|
|
geojson_data = { |
|
|
"type": "Feature", |
|
|
"geometry": { |
|
|
"type": "Polygon", |
|
|
"coordinates": [coords] |
|
|
}, |
|
|
"properties": {"source": file.filename} |
|
|
} |
|
|
|
|
|
|
|
|
metadata = { |
|
|
"area": polygon.area, |
|
|
"perimeter": polygon.length, |
|
|
"bounds": list(polygon.bounds), |
|
|
"centroid": [polygon.centroid.x, polygon.centroid.y], |
|
|
"dxf_source": file.filename |
|
|
} |
|
|
|
|
|
|
|
|
session = session_manager.create_session() |
|
|
session_manager.set_boundary( |
|
|
session.id, |
|
|
boundary=geojson_data, |
|
|
coords=coords, |
|
|
metadata=metadata |
|
|
) |
|
|
|
|
|
print(f"[{suffix.upper()[1:]}] Parsed {file.filename}: {len(coords)-1} vertices, area={polygon.area:.0f}m²") |
|
|
|
|
|
return UploadResponse( |
|
|
session_id=session.id, |
|
|
boundary=geojson_data, |
|
|
metadata=metadata |
|
|
) |
|
|
|
|
|
except HTTPException: |
|
|
raise |
|
|
except Exception as e: |
|
|
import traceback |
|
|
traceback.print_exc() |
|
|
raise HTTPException(500, f"DXF parsing error: {str(e)}") |
|
|
|
|
|
|
|
|
@app.post("/api/generate-layouts") |
|
|
async def generate_layouts(request: GenerateRequest): |
|
|
""" |
|
|
Generate optimized layout options using Genetic Algorithm |
|
|
|
|
|
Returns 3 diverse layout options: |
|
|
1. Maximum Profit |
|
|
2. Balanced |
|
|
3. Premium |
|
|
""" |
|
|
|
|
|
session = session_manager.get_session(request.session_id) |
|
|
if not session: |
|
|
raise HTTPException(404, "Session not found") |
|
|
|
|
|
if not session.boundary_coords: |
|
|
raise HTTPException(400, "No boundary uploaded for this session") |
|
|
|
|
|
try: |
|
|
|
|
|
optimizer = SimpleGAOptimizer( |
|
|
setback=request.setback, |
|
|
target_plots=request.target_plots |
|
|
) |
|
|
|
|
|
options = optimizer.optimize(session.boundary_coords) |
|
|
|
|
|
|
|
|
session_manager.set_layouts(request.session_id, options) |
|
|
|
|
|
return { |
|
|
"session_id": request.session_id, |
|
|
"options": options, |
|
|
"count": len(options) |
|
|
} |
|
|
|
|
|
except Exception as e: |
|
|
raise HTTPException(500, f"Optimization failed: {str(e)}") |
|
|
|
|
|
|
|
|
@app.post("/api/chat", response_model=ChatResponse) |
|
|
async def chat(request: ChatRequest): |
|
|
""" |
|
|
Chat with AI about layout options |
|
|
|
|
|
Uses Gemini Flash 2.0 if available, otherwise falls back to hardcoded responses |
|
|
""" |
|
|
|
|
|
session = session_manager.get_session(request.session_id) |
|
|
if not session: |
|
|
raise HTTPException(404, "Session not found") |
|
|
|
|
|
|
|
|
session_manager.add_chat_message(request.session_id, "user", request.message) |
|
|
|
|
|
|
|
|
response = gemini_service.chat( |
|
|
message=request.message, |
|
|
layouts=session.layouts, |
|
|
boundary_metadata=session.metadata |
|
|
) |
|
|
|
|
|
|
|
|
session_manager.add_chat_message( |
|
|
request.session_id, |
|
|
"assistant", |
|
|
response["message"], |
|
|
response["model"] |
|
|
) |
|
|
|
|
|
return ChatResponse(**response) |
|
|
|
|
|
|
|
|
@app.post("/api/export-dxf") |
|
|
async def export_dxf(request: ExportRequest): |
|
|
""" |
|
|
Export single layout option to DXF |
|
|
|
|
|
Returns DXF file as download |
|
|
""" |
|
|
|
|
|
session = session_manager.get_session(request.session_id) |
|
|
if not session: |
|
|
raise HTTPException(404, "Session not found") |
|
|
|
|
|
if not session.layouts: |
|
|
raise HTTPException(400, "No layouts generated") |
|
|
|
|
|
|
|
|
option = None |
|
|
for layout in session.layouts: |
|
|
if layout.get("id") == request.option_id: |
|
|
option = layout |
|
|
break |
|
|
|
|
|
if not option: |
|
|
raise HTTPException(404, f"Option {request.option_id} not found") |
|
|
|
|
|
try: |
|
|
|
|
|
layout_obj = _create_layout_from_option(option, session) |
|
|
|
|
|
|
|
|
dxf_bytes = _export_layout_to_bytes(layout_obj, option) |
|
|
|
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
filename = f"option_{request.option_id}_{timestamp}.dxf" |
|
|
|
|
|
return StreamingResponse( |
|
|
io.BytesIO(dxf_bytes), |
|
|
media_type="application/x-autocad-dxf", |
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"} |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
raise HTTPException(500, f"Export failed: {str(e)}") |
|
|
|
|
|
|
|
|
@app.post("/api/export-all-dxf") |
|
|
async def export_all_dxf(session_id: str = Form(...)): |
|
|
""" |
|
|
Export all layout options as ZIP file |
|
|
|
|
|
Returns ZIP containing 3 DXF files |
|
|
""" |
|
|
|
|
|
session = session_manager.get_session(session_id) |
|
|
if not session: |
|
|
raise HTTPException(404, "Session not found") |
|
|
|
|
|
if not session.layouts: |
|
|
raise HTTPException(400, "No layouts generated") |
|
|
|
|
|
try: |
|
|
|
|
|
zip_buffer = io.BytesIO() |
|
|
|
|
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: |
|
|
for option in session.layouts: |
|
|
|
|
|
layout_obj = _create_layout_from_option(option, session) |
|
|
|
|
|
|
|
|
dxf_bytes = _export_layout_to_bytes(layout_obj, option) |
|
|
|
|
|
|
|
|
filename = f"option_{option.get('id', 0)}_{option.get('name', 'layout').replace(' ', '_')}.dxf" |
|
|
zf.writestr(filename, dxf_bytes) |
|
|
|
|
|
zip_buffer.seek(0) |
|
|
|
|
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
|
|
filename = f"layouts_{timestamp}.zip" |
|
|
|
|
|
return StreamingResponse( |
|
|
zip_buffer, |
|
|
media_type="application/zip", |
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"} |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
raise HTTPException(500, f"Export failed: {str(e)}") |
|
|
|
|
|
|
|
|
@app.get("/api/session/{session_id}") |
|
|
async def get_session(session_id: str): |
|
|
"""Get session info""" |
|
|
session = session_manager.get_session(session_id) |
|
|
if not session: |
|
|
raise HTTPException(404, "Session not found") |
|
|
|
|
|
return session.to_dict() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_layout_from_option(option: Dict, session) -> Layout: |
|
|
"""Convert GA option to Layout object for DXF export""" |
|
|
from shapely.geometry import box, Polygon |
|
|
|
|
|
|
|
|
boundary_poly = Polygon(session.boundary_coords) |
|
|
site = SiteBoundary( |
|
|
geometry=boundary_poly, |
|
|
area_sqm=boundary_poly.area |
|
|
) |
|
|
site.buildable_area_sqm = boundary_poly.buffer(-50).area |
|
|
|
|
|
|
|
|
layout = Layout(site_boundary=site) |
|
|
|
|
|
|
|
|
plots = [] |
|
|
for i, plot_data in enumerate(option.get("plots", [])): |
|
|
coords = plot_data.get("coords", []) |
|
|
if coords: |
|
|
plot_geom = Polygon(coords) |
|
|
else: |
|
|
plot_geom = box( |
|
|
plot_data["x"], |
|
|
plot_data["y"], |
|
|
plot_data["x"] + plot_data["width"], |
|
|
plot_data["y"] + plot_data["height"] |
|
|
) |
|
|
|
|
|
plot = Plot( |
|
|
id=f"P{i+1}", |
|
|
geometry=plot_geom, |
|
|
area_sqm=plot_data.get("area", plot_geom.area), |
|
|
type=PlotType.INDUSTRIAL, |
|
|
width_m=plot_data.get("width", 50), |
|
|
depth_m=plot_data.get("height", 50) |
|
|
) |
|
|
plots.append(plot) |
|
|
|
|
|
layout.plots = plots |
|
|
|
|
|
|
|
|
metrics = option.get("metrics", {}) |
|
|
layout.metrics = LayoutMetrics( |
|
|
total_area_sqm=site.area_sqm, |
|
|
sellable_area_sqm=metrics.get("total_area", 0), |
|
|
green_space_area_sqm=0, |
|
|
road_area_sqm=0, |
|
|
num_plots=metrics.get("total_plots", len(plots)), |
|
|
is_compliant=True |
|
|
) |
|
|
layout.metrics.sellable_ratio = layout.metrics.sellable_area_sqm / layout.metrics.total_area_sqm if layout.metrics.total_area_sqm > 0 else 0 |
|
|
|
|
|
return layout |
|
|
|
|
|
|
|
|
def _export_layout_to_bytes(layout: Layout, option: Dict) -> bytes: |
|
|
"""Export layout to DXF bytes""" |
|
|
import ezdxf |
|
|
from ezdxf.enums import TextEntityAlignment |
|
|
|
|
|
|
|
|
doc = ezdxf.new(dxfversion="R2010") |
|
|
msp = doc.modelspace() |
|
|
|
|
|
|
|
|
layers = { |
|
|
'BOUNDARY': {'color': 7}, |
|
|
'SETBACK': {'color': 1}, |
|
|
'PLOTS': {'color': 5}, |
|
|
'LABELS': {'color': 7}, |
|
|
'ANNOTATIONS': {'color': 2}, |
|
|
'TITLEBLOCK': {'color': 7} |
|
|
} |
|
|
|
|
|
for name, props in layers.items(): |
|
|
doc.layers.add(name, color=props['color']) |
|
|
|
|
|
|
|
|
if layout.site_boundary and layout.site_boundary.geometry: |
|
|
coords = list(layout.site_boundary.geometry.exterior.coords) |
|
|
msp.add_lwpolyline(coords, dxfattribs={'layer': 'BOUNDARY', 'closed': True}) |
|
|
|
|
|
|
|
|
setback = layout.site_boundary.geometry.buffer(-50) |
|
|
if not setback.is_empty: |
|
|
setback_coords = list(setback.exterior.coords) |
|
|
msp.add_lwpolyline(setback_coords, dxfattribs={'layer': 'SETBACK', 'closed': True}) |
|
|
|
|
|
|
|
|
for plot in layout.plots: |
|
|
if plot.geometry: |
|
|
coords = list(plot.geometry.exterior.coords) |
|
|
msp.add_lwpolyline(coords, dxfattribs={'layer': 'PLOTS', 'closed': True}) |
|
|
|
|
|
|
|
|
centroid = plot.geometry.centroid |
|
|
msp.add_text( |
|
|
plot.id, |
|
|
dxfattribs={ |
|
|
'layer': 'LABELS', |
|
|
'height': 5, |
|
|
'insert': (centroid.x, centroid.y) |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
msp.add_text( |
|
|
f"{plot.area_sqm:.0f}m²", |
|
|
dxfattribs={ |
|
|
'layer': 'ANNOTATIONS', |
|
|
'height': 3, |
|
|
'insert': (centroid.x, centroid.y - 8) |
|
|
} |
|
|
) |
|
|
|
|
|
|
|
|
if layout.site_boundary: |
|
|
bounds = layout.site_boundary.geometry.bounds |
|
|
minx, miny = bounds[0], bounds[1] |
|
|
|
|
|
title_lines = [ |
|
|
f"AIOptimize™ - {option.get('name', 'Layout')}", |
|
|
f"Plots: {option.get('metrics', {}).get('total_plots', 0)}", |
|
|
f"Total Area: {option.get('metrics', {}).get('total_area', 0):.0f} m²", |
|
|
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}" |
|
|
] |
|
|
|
|
|
y = miny - 20 |
|
|
for line in title_lines: |
|
|
msp.add_text( |
|
|
line, |
|
|
dxfattribs={ |
|
|
'layer': 'TITLEBLOCK', |
|
|
'height': 4, |
|
|
'insert': (minx, y) |
|
|
} |
|
|
) |
|
|
y -= 8 |
|
|
|
|
|
|
|
|
stream = io.StringIO() |
|
|
doc.write(stream) |
|
|
return stream.getvalue().encode('utf-8') |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
uvicorn.run(app, host="0.0.0.0", port=8000) |
|
|
|