Skip to content

Commit 9ebfd2c

Browse files
committed
wip
1 parent 22dab5e commit 9ebfd2c

6 files changed

Lines changed: 315 additions & 18 deletions

File tree

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,14 @@ Thumbs.db
6666
# Environment variables
6767
.env
6868
.env.*
69+
70+
# Autocad files
71+
*.dwg
72+
*.dxf
73+
*.bak
74+
*.sv$
75+
*.dws
76+
*.dwt
77+
*.ac$
78+
*.dwl
79+
*.dwl2

app.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from flask import Flask
1+
from flask import Flask, request, jsonify
2+
3+
from dxf import SurveyDXFManager
4+
from models.plan import PlanProps
25

36
app = Flask(__name__)
47
app.config["SECRET_KEY"] = "secret"
@@ -8,9 +11,54 @@
811
def home():
912
return "<h1>Hello, Flask 👋</h1><p>You're up and running!</p>"
1013

11-
@app.route("/plans", methods=["POST"])
12-
def auto_plan():
13-
return ""
14+
@app.route("/cadastral/plan", methods=["POST"])
15+
def generate_cadastral_plan():
16+
data = request.get_json()
17+
18+
plan = PlanProps(**data)
19+
extent = plan.get_extent()
20+
beacon_size = extent * 0.05
21+
22+
drawer = SurveyDXFManager(plan_name=plan.name, scale=plan.scale)
23+
drawer.setup_beacon_style(type_=plan.beacon_type, size=beacon_size or 1.0)
24+
drawer.setup_font(plan.font)
25+
26+
# Draw beacon and labels
27+
for coord in plan.coordinates:
28+
drawer.add_beacon(coord.easting, coord.northing, 0, beacon_size * 0.5, coord.id)
29+
30+
# create a dictionary of coordinates for easy lookup
31+
coord_dict = {coord.id: coord for coord in plan.coordinates}
32+
33+
# Draw parcels
34+
for parcel in plan.parcels:
35+
parcel_points = []
36+
for point_id in parcel.ids:
37+
if point_id in coord_dict:
38+
coord = coord_dict[point_id]
39+
parcel_points.append((coord.easting, coord.northing, coord.elevation))
40+
if parcel_points:
41+
drawer.add_parcel(parcel.name, parcel_points)
42+
43+
# Compute extent sizes
44+
min_x, min_y, max_x, max_y = plan.get_bounding_box()
45+
width = max_x - min_x
46+
height = max_y - min_y
47+
48+
# Draw frame
49+
margin_x = max(width, height) * 0.4
50+
margin_y = max(height, width) * 0.75
51+
drawer.draw_frame(min_x - margin_x, min_y - margin_y, max_x + margin_x, max_y + margin_y)
52+
53+
# offset frame
54+
offset_x = max(width, height) * 0.43
55+
offset_y = max(height, width) * 0.78
56+
drawer.draw_frame(min_x - offset_x, min_y - offset_y, max_x + offset_x, max_y + offset_y)
57+
58+
# add bearing and distance text
59+
60+
drawer.dxf_to_pdf()
61+
return jsonify({"message": "Cadastral plan generated", "filename": plan.name}), 200
1462

1563
if __name__ == '__main__':
16-
app.run()
64+
app.run(host="0.0.0.0", port=8080, debug=True)

dxf.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import ezdxf
2+
import re
3+
from ezdxf.enums import TextEntityAlignment
4+
import matplotlib
5+
matplotlib.use("Agg") # no GUI
6+
import matplotlib.pyplot as plt
7+
from ezdxf.addons.drawing import Frontend, RenderContext
8+
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
9+
10+
class SurveyDXFManager:
11+
def __init__(self, plan_name: str = "Survey Plan", scale: float = 1.0):
12+
self.plan_name = plan_name
13+
self.scale = scale
14+
self.doc = ezdxf.new(dxfversion="R2010")
15+
self.msp = self.doc.modelspace()
16+
self._setup_layers()
17+
18+
19+
# set units
20+
# self.doc.header["$INSUNITS"] = 6 # meters
21+
22+
# dimstyle = self.doc.dimstyles.get("Standard")
23+
# dimstyle.dxf.dimlunit = 2 # Decimal
24+
# dimstyle.dxf.dimdec = 3 # 3 decimal places
25+
# dimstyle.dxf.dimaunit = 1 # Degrees/minutes/seconds
26+
# dimstyle.dxf.dimadec = 3 # 3 decimal places
27+
# dimstyle.dxf.dimscale = 1.0
28+
29+
def _setup_layers(self):
30+
"""Setup standard survey layers"""
31+
layers = [
32+
('POINTS', 7), # Black/White
33+
('LINES', 1), # Red
34+
('LABELS', 7), # Black/White
35+
('GRID', 8), # Dark gray
36+
('FRAME', 7), # Black/White
37+
('DIMENSIONS', 4), # Cyan
38+
('TITLE_BLOCK', 7), # White
39+
('TRAVERSE', 6), # Magenta
40+
]
41+
42+
for name, color in layers:
43+
self.doc.layers.add(name=name, color=color)
44+
45+
def setup_beacon_style(self, type_: str = "box", size: float = 1.0):
46+
# Point styles (using blocks)
47+
block = self.doc.blocks.new(name='BEACON_POINT')
48+
radius = size * 0.2 # inner hatch radius
49+
half = size / 2 # half-size for square
50+
51+
# Filled (solid hatch) circle
52+
if type_ == "circle":
53+
block.add_circle((0, 0), radius=size * 0.5)
54+
55+
# Hatched inner circle
56+
hatch = block.add_hatch(color=7) # 7 = black/white
57+
path = hatch.paths.add_edge_path()
58+
path.add_arc((0, 0), radius=radius, start_angle=0, end_angle=360)
59+
elif type_ == "box":
60+
# Square boundary
61+
block.add_lwpolyline(
62+
[(-half, -half), (half, -half), (half, half), (-half, half)],
63+
close=True
64+
)
65+
66+
# Hatched inner circle
67+
hatch = block.add_hatch(color=7)
68+
path = hatch.paths.add_edge_path()
69+
path.add_arc((0, 0), radius=radius, start_angle=0, end_angle=360)
70+
elif type_ == "dot":
71+
# Just hatched circle (no boundary)
72+
hatch = block.add_hatch(color=7)
73+
path = hatch.paths.add_edge_path()
74+
path.add_arc((0, 0), radius=radius, start_angle=0, end_angle=360)
75+
76+
def setup_font(self, font_name: str = "Arial"):
77+
# Add a new text style with the specified font
78+
self.doc.styles.add('SURVEY_TEXT', font=f'{font_name}.ttf')
79+
80+
81+
def add_beacon(self, x: float, y: float, z: float = 0, text_height: float = 1.0, label=None):
82+
# Add a beacon point with optional label
83+
self.msp.add_blockref(
84+
'BEACON_POINT',
85+
(x * self.scale, y * self.scale, z * self.scale),
86+
dxfattribs={'layer': 'POINTS'}
87+
)
88+
89+
# add label
90+
if label is not None:
91+
self.msp.add_text(
92+
label,
93+
dxfattribs={
94+
'layer': 'LABELS',
95+
'height': text_height * self.scale,
96+
'style': 'SURVEY_TEXT'
97+
}
98+
).set_placement(
99+
(x * self.scale + 1, y * self.scale + 1)
100+
)
101+
102+
def add_parcel(self, parcel_id: str, points: list):
103+
"""Add a parcel given its ID and list of (x, y) points"""
104+
# scale points
105+
points = [(x * self.scale, y * self.scale) for x, y, *rest in points]
106+
107+
self.msp.add_lwpolyline(points, close=True, dxfattribs={
108+
'layer': 'LINES'
109+
})
110+
111+
# Add parcel ID label at centroid
112+
if points and parcel_id:
113+
centroid_x = sum(p[0] for p in points) / len(points)
114+
centroid_y = sum(p[1] for p in points) / len(points)
115+
self.msp.add_text(
116+
parcel_id,
117+
dxfattribs={
118+
'layer': 'LABELS',
119+
'height': 2.0 * self.scale,
120+
'style': 'SURVEY_TEXT',
121+
'color': 2 # Yellow
122+
}
123+
).set_placement(
124+
(centroid_x, centroid_y),
125+
align=TextEntityAlignment.MIDDLE_CENTER
126+
)
127+
128+
def draw_frame(self, min_x, min_y, max_x, max_y):
129+
"""Draw a rectangle given min and max coordinates"""
130+
self.msp.add_lwpolyline([
131+
(min_x * self.scale, min_y* self.scale),
132+
(max_x* self.scale, min_y* self.scale),
133+
(max_x* self.scale, max_y* self.scale),
134+
(min_x* self.scale, max_y* self.scale)
135+
], close=True, dxfattribs={
136+
'layer': 'FRAME',
137+
})
138+
139+
def get_filename(self):
140+
plan_name = self.plan_name.lower()
141+
plan_name = re.sub(r"\s+", "_",plan_name)
142+
plan_name = re.sub(r"[^a-z0-9._-]", "", plan_name)
143+
plan_name = re.sub(r"_+", "_", plan_name)
144+
return plan_name
145+
146+
def save_dxf(self):
147+
"""Save the DXF document to a file"""
148+
self.doc.saveas(f"{self.get_filename()}.dxf")
149+
150+
151+
def dxf_to_pdf(self, margin_ratio: float = 0.05):
152+
153+
154+
# doc = ezdxf.readfile(dxf_path)
155+
# msp = doc.modelspace()
156+
157+
# compute bounding box
158+
xs, ys = [], []
159+
for e in self.msp:
160+
if e.dxftype() == "LINE":
161+
xs += [e.dxf.start[0], e.dxf.end[0]]
162+
ys += [e.dxf.start[1], e.dxf.end[1]]
163+
elif e.dxftype() == "CIRCLE":
164+
xs += [e.dxf.center[0] - e.dxf.radius, e.dxf.center[0] + e.dxf.radius]
165+
ys += [e.dxf.center[1] - e.dxf.radius, e.dxf.center[1] + e.dxf.radius]
166+
167+
if not xs or not ys:
168+
xs = [0, 10]; ys = [0, 10] # fallback box
169+
170+
min_x, max_x = min(xs), max(xs)
171+
min_y, max_y = min(ys), max(ys)
172+
173+
width = max(max_x - min_x, 1) # avoid zero
174+
height = max(max_y - min_y, 1)
175+
176+
fig, ax = plt.subplots(figsize=(8, 8 * height / width))
177+
ax.set_xlim(min_x, max_x)
178+
ax.set_ylim(min_y, max_y)
179+
ax.axis("off")
180+
181+
ctx = RenderContext(self.doc)
182+
out = MatplotlibBackend(ax)
183+
Frontend(ctx, out).draw_layout(self.msp, finalize=True)
184+
185+
pdf_path = f"{self.get_filename()}.pdf"
186+
fig.savefig(pdf_path, bbox_inches="tight", pad_inches=0)
187+
plt.close(fig)
188+
189+
190+

models/plan.py

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import Enum
22
from typing import List, Optional, Union
33
from datetime import datetime
4-
from pydantic import BaseModel
4+
from pydantic import BaseModel, Field
55

66
# ---------- Enums ----------
77
class PlanType(str, Enum):
@@ -21,22 +21,30 @@ class BeaconType(str, Enum):
2121

2222

2323
# ---------- Supporting models ----------
24-
class ParcelProps(BaseModel):
25-
name: str
26-
ids: List[str]
27-
2824
class CoordinateProps(BaseModel):
29-
x: float
30-
y: float
31-
z: Optional[float] = None
25+
id: str
26+
northing: Optional[float] = 0.0
27+
easting: Optional[float] = 0.0
28+
elevation: Optional[float] = 0.0
29+
30+
class BearingProps(BaseModel):
31+
degrees: Optional[int] = 0.0
32+
minutes: Optional[int] = 0.0
33+
seconds: Optional[float] = 0.0
34+
decimal: Optional[float] = 0.0
3235

3336
class TraverseLegProps(BaseModel):
34-
from_: str # `from` is reserved in Python
35-
to: str
36-
bearing: Optional[float] = None
37-
observed_angle: Optional[float] = None
38-
distance: float
37+
from_: CoordinateProps = Field(alias="from") # 👈 use alias
38+
to: CoordinateProps
39+
bearing: Optional[BearingProps] = None
40+
observed_angle: Optional[BearingProps] = None
41+
distance: Optional[float] = None
3942

43+
class ParcelProps(BaseModel):
44+
name: str
45+
ids: List[str]
46+
area: Optional[float] = None # in square meters
47+
legs: List[TraverseLegProps] = []
4048

4149
# ---------- Computation models ----------
4250
class ForwardComputationData(BaseModel):
@@ -76,3 +84,26 @@ class PlanProps(BaseModel):
7684
surveyor_name: str = ""
7785
forward_computation_data: Optional[ForwardComputationData] = None
7886
traverse_computation_data: Optional[TraverseComputationData] = None
87+
88+
def get_extent(self) -> float:
89+
# get bounding box
90+
min_x, min_y, max_x, max_y = self.get_bounding_box()
91+
if min_x is None or min_y is None or max_x is None or max_y is None:
92+
return 0.0
93+
94+
width = max_x - min_x
95+
height = max_y - min_y
96+
extent = max(width, height)
97+
return extent
98+
99+
def get_bounding_box(self) -> Optional[tuple]:
100+
if len(self.coordinates) == 0:
101+
return None
102+
103+
xs = [p.easting for p in self.coordinates]
104+
ys = [p.northing for p in self.coordinates]
105+
106+
min_x, max_x = min(xs), max(xs)
107+
min_y, max_y = min(ys), max(ys)
108+
109+
return min_x, min_y, max_x, max_y

requirements.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1+
annotated-types==0.7.0
12
blinker==1.9.0
23
click==8.2.1
34
colorama==0.4.6
45
comtypes==1.4.12
6+
contourpy==1.3.3
7+
cycler==0.12.1
8+
ezdxf==1.4.2
59
Flask==3.1.2
10+
fonttools==4.59.2
611
itsdangerous==2.2.0
712
Jinja2==3.1.6
13+
kiwisolver==1.4.9
814
MarkupSafe==3.0.2
15+
matplotlib==3.10.6
16+
numpy==2.3.3
17+
packaging==25.0
18+
pillow==11.3.0
919
pyautocad==0.2.0
20+
pydantic==2.11.9
21+
pydantic_core==2.33.2
22+
pyparsing==3.2.4
23+
python-dateutil==2.9.0.post0
24+
six==1.17.0
25+
typing-inspection==0.4.1
26+
typing_extensions==4.15.0
1027
Werkzeug==3.1.3

utils.py

Whitespace-only changes.

0 commit comments

Comments
 (0)