STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
dxf_export.py
Go to the documentation of this file.
1"""
2Robust DXF export utilities for chamber geometry.
3
4This module provides functions to export chamber contours as CAD-compatible DXF files
5with proper handling of:
6- Coordinate quantization (consistent precision)
7- Vertex deduplication (no overlapping points)
8- Zero-length segment removal
9- Endpoint continuity validation
10- Single closed LWPOLYLINE output
11"""
12
13import numpy as np
14from pathlib import Path
15from typing import Optional, Tuple
16
17try:
18 import ezdxf
19 HAS_EZDXF = True
20except ImportError:
21 HAS_EZDXF = False
22
23
24# DXF coordinate precision - 6 decimal places gives ~1 micrometer precision for meter units
25DXF_PRECISION = 6
26
27# Epsilon for vertex comparison - vertices closer than this are considered duplicates
28VERTEX_EPSILON = 1e-9
29
30
31def quantize_coordinates(pts: np.ndarray, precision: int = DXF_PRECISION) -> np.ndarray:
32 """
33 Round coordinates to fixed decimal places for CAD compatibility.
34
35 This ensures consistent coordinate representation and eliminates
36 floating-point precision issues that cause gaps in CAD imports.
37
38 Parameters:
39 -----------
40 pts : np.ndarray
41 Array of (x, y) points, shape (N, 2)
42 precision : int
43 Number of decimal places to round to (default: 6)
44
45 Returns:
46 --------
47 np.ndarray
48 Quantized points array with same shape
49 """
50 return np.round(pts, decimals=precision)
51
52
53def deduplicate_vertices(pts: np.ndarray, epsilon: float = VERTEX_EPSILON) -> np.ndarray:
54 """
55 Remove duplicate/near-duplicate consecutive vertices.
56
57 This handles the case where segment boundaries have overlapping points,
58 ensuring each vertex appears exactly once in the output polyline.
59
60 Parameters:
61 -----------
62 pts : np.ndarray
63 Array of (x, y) points, shape (N, 2)
64 epsilon : float
65 Distance threshold - points closer than this are considered duplicates
66
67 Returns:
68 --------
69 np.ndarray
70 Deduplicated points array
71 """
72 if len(pts) == 0:
73 return pts
74
75 # Always keep the first point
76 keep_mask = np.ones(len(pts), dtype=bool)
77
78 # Check each point against the previous one
79 for i in range(1, len(pts)):
80 dist = np.linalg.norm(pts[i] - pts[i-1])
81 if dist < epsilon:
82 keep_mask[i] = False
83
84 return pts[keep_mask]
85
86
87def remove_zero_length_segments(pts: np.ndarray, epsilon: float = VERTEX_EPSILON) -> np.ndarray:
88 """
89 Remove points that would create zero-length line segments.
90
91 This is similar to deduplicate_vertices but specifically targets
92 zero-length segments that cause CAD import errors.
93
94 Parameters:
95 -----------
96 pts : np.ndarray
97 Array of (x, y) points, shape (N, 2)
98 epsilon : float
99 Length threshold - segments shorter than this are removed
100
101 Returns:
102 --------
103 np.ndarray
104 Cleaned points array with no zero-length segments
105 """
106 # This is essentially the same as deduplicate_vertices
107 # since zero-length segments come from duplicate/near-duplicate points
108 return deduplicate_vertices(pts, epsilon)
109
110
111def validate_continuity(pts: np.ndarray, max_gap: float = 1e-6) -> Tuple[bool, Optional[int], Optional[float]]:
112 """
113 Validate that the polyline forms a continuous path.
114
115 Checks that there are no unexpected gaps between consecutive vertices.
116
117 Parameters:
118 -----------
119 pts : np.ndarray
120 Array of (x, y) points, shape (N, 2)
121 max_gap : float
122 Maximum allowed gap between consecutive points for typical geometry.
123 Gaps larger than this trigger a warning.
124
125 Returns:
126 --------
127 Tuple[bool, Optional[int], Optional[float]]
128 (is_valid, gap_index, gap_distance)
129 - is_valid: True if all consecutive points are reasonably close
130 - gap_index: Index of first large gap (if any)
131 - gap_distance: Distance of first large gap (if any)
132 """
133 if len(pts) < 2:
134 return True, None, None
135
136 # Calculate distances between consecutive points
137 diffs = np.diff(pts, axis=0)
138 distances = np.linalg.norm(diffs, axis=1)
139
140 # Find typical segment length (median)
141 median_dist = np.median(distances)
142
143 # A gap is suspicious if it's more than 100x the median distance
144 # This accounts for variable point density in different sections
145 # (e.g., fine sampling in nozzle curves vs coarse in straight sections)
146 suspicious_threshold = max(max_gap, median_dist * 100)
147
148 for i, dist in enumerate(distances):
149 if dist > suspicious_threshold:
150 return False, i, dist
151
152 return True, None, None
153
154
155def ensure_shared_vertices(pts: np.ndarray, precision: int = DXF_PRECISION) -> np.ndarray:
156 """
157 Ensure vertices at segment boundaries are properly shared.
158
159 After quantization, this ensures that connecting points between
160 segments (cylindrical->contraction->nozzle) are exactly the same.
161
162 Parameters:
163 -----------
164 pts : np.ndarray
165 Array of (x, y) points, shape (N, 2)
166 precision : int
167 Number of decimal places for coordinate quantization
168
169 Returns:
170 --------
171 np.ndarray
172 Points array with shared vertices at boundaries
173 """
174 # Quantize first to ensure consistent representation
175 pts = quantize_coordinates(pts, precision)
176
177 # Then deduplicate to merge any coincident points
178 pts = deduplicate_vertices(pts)
179
180 return pts
181
182
183def prepare_polyline_points(pts: np.ndarray,
184 precision: int = DXF_PRECISION,
185 epsilon: float = VERTEX_EPSILON) -> np.ndarray:
186 """
187 Full preparation pipeline for polyline points.
188
189 Applies all cleaning steps in the correct order:
190 1. Quantize coordinates
191 2. Deduplicate vertices
192 3. Remove zero-length segments
193 4. Validate continuity
194
195 Parameters:
196 -----------
197 pts : np.ndarray
198 Raw array of (x, y) points, shape (N, 2)
199 precision : int
200 Decimal places for coordinate quantization
201 epsilon : float
202 Threshold for vertex deduplication
203
204 Returns:
205 --------
206 np.ndarray
207 Cleaned, validated points ready for DXF export
208 """
209 # Step 1: Quantize coordinates
210 cleaned = quantize_coordinates(pts, precision)
211
212 # Step 2: Deduplicate vertices
213 cleaned = deduplicate_vertices(cleaned, epsilon)
214
215 # Step 3: Remove any remaining zero-length segments
216 cleaned = remove_zero_length_segments(cleaned, epsilon)
217
218 # Step 4: Validate continuity (warning only, don't fail)
219 is_valid, gap_idx, gap_dist = validate_continuity(cleaned)
220 if not is_valid:
221 import warnings
222 warnings.warn(
223 f"Large gap detected in geometry at index {gap_idx}: {gap_dist:.6e} m. "
224 f"This may indicate a geometry generation issue."
225 )
226
227 return cleaned
228
229
230def export_chamber_dxf(chamber_pts: np.ndarray,
231 export_path: str,
232 add_centerline: bool = True,
233 add_mirrored: bool = False,
234 layer_name: str = "CONTOUR",
235 centerline_layer: str = "CENTERLINE",
236 precision: int = DXF_PRECISION) -> None:
237 """
238 Export chamber contour to a CAD-compatible DXF file.
239
240 Creates a single LWPOLYLINE with properly shared vertices,
241 quantized coordinates, and no duplicate or zero-length entities.
242
243 Parameters:
244 -----------
245 chamber_pts : np.ndarray
246 Array of (x, y) points representing the chamber contour
247 export_path : str
248 Path to save the DXF file
249 add_centerline : bool
250 If True, adds a centerline (y=0) as a reference line
251 add_mirrored : bool
252 If True, also adds the mirrored (lower half) contour
253 layer_name : str
254 DXF layer name for the contour
255 centerline_layer : str
256 DXF layer name for the centerline
257 precision : int
258 Decimal places for coordinate quantization
259 """
260 if not HAS_EZDXF:
261 raise ImportError(
262 "ezdxf library is required for DXF export. "
263 "Install it with: pip install ezdxf"
264 )
265
266 # Ensure directory exists
267 dxf_path = Path(export_path)
268 dxf_path.parent.mkdir(parents=True, exist_ok=True)
269
270 # Prepare points (quantize, deduplicate, validate)
271 cleaned_pts = prepare_polyline_points(chamber_pts, precision=precision)
272
273 # Create DXF document - use R2010 format for broad compatibility
274 doc = ezdxf.new('R2010')
275 msp = doc.modelspace()
276
277 # Create layers
278 doc.layers.add(layer_name, color=7) # White/default color
279 if add_centerline:
280 doc.layers.add(centerline_layer, color=1) # Red
281
282 # Convert to list of tuples for ezdxf
283 points = [(float(pt[0]), float(pt[1])) for pt in cleaned_pts]
284
285 # Create the main contour as a single LWPOLYLINE
286 # This is the critical fix - one continuous polyline, not multiple lines
287 polyline = msp.add_lwpolyline(
288 points,
289 dxfattribs={'layer': layer_name}
290 )
291
292 # Add mirrored contour if requested (for full cross-section)
293 if add_mirrored:
294 # Mirror points about x-axis (negate y)
295 mirrored_points = [(float(pt[0]), -float(pt[1])) for pt in reversed(cleaned_pts)]
296 msp.add_lwpolyline(
297 mirrored_points,
298 dxfattribs={'layer': layer_name}
299 )
300
301 # Add centerline as reference
302 if add_centerline:
303 x_min = cleaned_pts[:, 0].min()
304 x_max = cleaned_pts[:, 0].max()
305 # Extend centerline slightly beyond geometry
306 margin = (x_max - x_min) * 0.05
307 msp.add_line(
308 (float(x_min - margin), 0.0),
309 (float(x_max + margin), 0.0),
310 dxfattribs={'layer': centerline_layer}
311 )
312
313 # Save the DXF file
314 doc.saveas(str(dxf_path))
315 print(f"Chamber contour exported to {export_path}")
316 print(f" - Points: {len(cleaned_pts)} (after deduplication)")
317 print(f" - Precision: {precision} decimal places")
318
319
320def export_closed_contour_dxf(chamber_pts: np.ndarray,
321 export_path: str,
322 layer_name: str = "CONTOUR",
323 precision: int = DXF_PRECISION) -> None:
324 """
325 Export chamber contour as a closed LWPOLYLINE (full cross-section).
326
327 Creates a single closed polyline representing the full chamber
328 cross-section by mirroring the upper half contour.
329
330 Parameters:
331 -----------
332 chamber_pts : np.ndarray
333 Array of (x, y) points representing the upper half contour
334 export_path : str
335 Path to save the DXF file
336 layer_name : str
337 DXF layer name for the contour
338 precision : int
339 Decimal places for coordinate quantization
340 """
341 if not HAS_EZDXF:
342 raise ImportError(
343 "ezdxf library is required for DXF export. "
344 "Install it with: pip install ezdxf"
345 )
346
347 # Prepare upper half points
348 upper_pts = prepare_polyline_points(chamber_pts, precision=precision)
349
350 # Create mirrored lower half (reverse order, negate y)
351 lower_pts = upper_pts[::-1].copy()
352 lower_pts[:, 1] = -lower_pts[:, 1]
353
354 # Combine to form closed contour
355 # Skip the first point of lower half (same as last point of upper half at y=0)
356 # Skip the last point of lower half (same as first point of upper half at y=0)
357
358 # Actually, the contour doesn't necessarily start/end at y=0
359 # So we need to connect them properly
360
361 # The full closed contour is: upper + reversed(lower)
362 # where they share the first and last points
363 full_pts = np.vstack([upper_pts, lower_pts[1:-1]])
364
365 # Final cleanup
366 full_pts = prepare_polyline_points(full_pts, precision=precision)
367
368 # Ensure directory exists
369 dxf_path = Path(export_path)
370 dxf_path.parent.mkdir(parents=True, exist_ok=True)
371
372 # Create DXF document
373 doc = ezdxf.new('R2010')
374 msp = doc.modelspace()
375
376 # Create layer
377 doc.layers.add(layer_name, color=7)
378
379 # Convert to list of tuples
380 points = [(float(pt[0]), float(pt[1])) for pt in full_pts]
381
382 # Create closed LWPOLYLINE
383 polyline = msp.add_lwpolyline(
384 points,
385 close=True, # Explicitly close the polyline
386 dxfattribs={'layer': layer_name}
387 )
388
389 # Save
390 doc.saveas(str(dxf_path))
391 print(f"Closed chamber contour exported to {export_path}")
392 print(f" - Points: {len(full_pts)}")
393
394
395# For backwards compatibility and testing
396if __name__ == "__main__":
397 # Test with sample data
398 import numpy as np
399
400 # Create sample chamber geometry
401 x = np.linspace(-0.1, 0.2, 100)
402 y = np.where(x < 0, 0.05, 0.05 - x * 0.1) # Simple cone
403 pts = np.column_stack([x, y])
404
405 # Add some intentional duplicates to test deduplication
406 pts = np.vstack([pts[:50], pts[49:51], pts[50:]]) # Duplicate point 49-50
407
408 print("Testing DXF export utilities...")
409 print(f"Original points: {len(pts)}")
410
412 print(f"Cleaned points: {len(cleaned)}")
413
414 # Export test file
415 export_chamber_dxf(pts, "test_dxf_export.dxf")
416 print("Test complete!")
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
np.ndarray deduplicate_vertices(np.ndarray pts, float epsilon=VERTEX_EPSILON)
Definition dxf_export.py:53
np.ndarray ensure_shared_vertices(np.ndarray pts, int precision=DXF_PRECISION)
np.ndarray prepare_polyline_points(np.ndarray pts, int precision=DXF_PRECISION, float epsilon=VERTEX_EPSILON)
None export_closed_contour_dxf(np.ndarray chamber_pts, str export_path, str layer_name="CONTOUR", int precision=DXF_PRECISION)
np.ndarray remove_zero_length_segments(np.ndarray pts, float epsilon=VERTEX_EPSILON)
Definition dxf_export.py:87
None export_chamber_dxf(np.ndarray chamber_pts, str export_path, bool add_centerline=True, bool add_mirrored=False, str layer_name="CONTOUR", str centerline_layer="CENTERLINE", int precision=DXF_PRECISION)
np.ndarray quantize_coordinates(np.ndarray pts, int precision=DXF_PRECISION)
Definition dxf_export.py:31
Tuple[bool, Optional[int], Optional[float]] validate_continuity(np.ndarray pts, float max_gap=1e-6)