|
| 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 | + |
0 commit comments