xslope 0.1.12__py3-none-any.whl → 0.1.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xslope/mesh.py CHANGED
@@ -55,13 +55,13 @@ def _get_gmsh():
55
55
 
56
56
 
57
57
 
58
- def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=None, debug=False, mesh_params=None, target_size_1d=None):
58
+ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=None, debug=False, mesh_params=None, target_size_1d=None, profile_lines=None):
59
59
  """
60
60
  Build a finite element mesh with material regions using Gmsh.
61
61
  Fixed version that properly handles shared boundaries between polygons.
62
62
 
63
63
  Parameters:
64
- polygons : List of lists of (x, y) tuples defining material boundaries
64
+ polygons : List of polygon coordinate lists or dicts with "coords"/"mat_id"
65
65
  target_size : Desired element size
66
66
  element_type : 'tri3' (3-node triangles), 'tri6' (6-node triangles),
67
67
  'quad4' (4-node quadrilaterals), 'quad8' (8-node quadrilaterals),
@@ -70,6 +70,7 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
70
70
  debug : Enable debug output
71
71
  mesh_params : Optional dictionary of GMSH meshing parameters to override defaults
72
72
  target_size_1d : Optional target size for 1D elements (default None, which is set to target_size if None)
73
+ profile_lines: Optional list of profile line dicts with 'mat_id' keys for material assignment
73
74
 
74
75
  Returns:
75
76
  mesh dict containing:
@@ -92,8 +93,35 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
92
93
  if debug:
93
94
  print(f"Using default target_size_1d = target_size = {target_size_1d}")
94
95
 
95
- # build a list of region ids (list of material IDs - one per polygon)
96
- region_ids = [i for i in range(len(polygons))]
96
+ # Normalize polygons to coordinate lists and optional mat_id
97
+ polygon_coords = []
98
+ polygon_mat_ids = []
99
+ for i, polygon in enumerate(polygons):
100
+ if isinstance(polygon, dict):
101
+ polygon_coords.append(polygon.get("coords", []))
102
+ polygon_mat_ids.append(polygon.get("mat_id"))
103
+ else:
104
+ polygon_coords.append(polygon)
105
+ polygon_mat_ids.append(None)
106
+
107
+ # Build a list of region ids (list of material IDs - one per polygon)
108
+ if any(mat_id is not None for mat_id in polygon_mat_ids):
109
+ region_ids = [
110
+ mat_id if mat_id is not None else i
111
+ for i, mat_id in enumerate(polygon_mat_ids)
112
+ ]
113
+ elif profile_lines and len(profile_lines) >= len(polygon_coords):
114
+ region_ids = []
115
+ for i in range(len(polygon_coords)):
116
+ mat_id = profile_lines[i].get('mat_id')
117
+ if mat_id is not None:
118
+ region_ids.append(mat_id)
119
+ else:
120
+ # Fallback to polygon index if no mat_id
121
+ region_ids.append(i)
122
+ else:
123
+ # Fallback to sequential IDs if no profile_lines provided
124
+ region_ids = [i for i in range(len(polygon_coords))]
97
125
 
98
126
  if element_type not in ['tri3', 'tri6', 'quad4', 'quad8', 'quad9']:
99
127
  raise ValueError("element_type must be 'tri3', 'tri6', 'quad4', 'quad8', or 'quad9'")
@@ -160,7 +188,7 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
160
188
  short_edge_points = set() # Points that are endpoints of short edges
161
189
 
162
190
  # Pre-pass to identify short edges - improved logic
163
- for idx, (poly_pts, region_id) in enumerate(zip(polygons, region_ids)):
191
+ for idx, (poly_pts, region_id) in enumerate(zip(polygon_coords, region_ids)):
164
192
  poly_pts_clean = remove_duplicate_endpoint(list(poly_pts))
165
193
  for i in range(len(poly_pts_clean)):
166
194
  p1 = poly_pts_clean[i]
@@ -187,7 +215,7 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
187
215
  print(f"Short edge ignored (major boundary): {p1} to {p2}, length={edge_length:.2f}")
188
216
 
189
217
  # Main pass: Create points with appropriate sizes
190
- for idx, (poly_pts, region_id) in enumerate(zip(polygons, region_ids)):
218
+ for idx, (poly_pts, region_id) in enumerate(zip(polygon_coords, region_ids)):
191
219
  poly_pts_clean = remove_duplicate_endpoint(list(poly_pts)) # make a copy
192
220
  pt_tags = []
193
221
  for x, y in poly_pts_clean:
@@ -1218,7 +1246,9 @@ def build_polygons(slope_data, reinf_lines=None, tol = 0.000001, debug=False):
1218
1246
  slope_data: Dictionary containing slope geometry data
1219
1247
 
1220
1248
  Returns:
1221
- List of polygons, each defined by (x,y) coordinate tuples
1249
+ List of polygons as dicts with keys:
1250
+ "coords": list of (x, y) coordinate tuples
1251
+ "mat_id": optional material ID (0-based) or None
1222
1252
  """
1223
1253
  import numpy as np
1224
1254
  import copy
@@ -1236,7 +1266,7 @@ def build_polygons(slope_data, reinf_lines=None, tol = 0.000001, debug=False):
1236
1266
  raise ValueError("When using only 1 profile line, max_depth must be specified")
1237
1267
 
1238
1268
  n = len(profile_lines)
1239
- lines = [list(line) for line in copy.deepcopy(profile_lines)]
1269
+ lines = [list(line['coords']) for line in copy.deepcopy(profile_lines)]
1240
1270
 
1241
1271
  for i in range(n - 1):
1242
1272
  top = lines[i]
@@ -1666,7 +1696,11 @@ def build_polygons(slope_data, reinf_lines=None, tol = 0.000001, debug=False):
1666
1696
 
1667
1697
  # Clean up polygon (should rarely do anything)
1668
1698
  poly = clean_polygon(poly)
1669
- polygons.append(poly)
1699
+ mat_id = profile_lines[i].get("mat_id") if i < len(profile_lines) else None
1700
+ polygons.append({
1701
+ "coords": poly,
1702
+ "mat_id": mat_id
1703
+ })
1670
1704
 
1671
1705
  # Add distributed load points to polygon edges if coincident
1672
1706
  polygons = add_dload_points_to_polygons(polygons, slope_data)
@@ -1683,7 +1717,7 @@ def add_dload_points_to_polygons(polygons, slope_data):
1683
1717
  but not existing vertices.
1684
1718
 
1685
1719
  Parameters:
1686
- polygons: List of polygons (lists of (x,y) tuples)
1720
+ polygons: List of polygons (list of (x,y) tuples) or dicts with "coords"
1687
1721
  slope_data: Dictionary containing slope data
1688
1722
 
1689
1723
  Returns:
@@ -1708,7 +1742,8 @@ def add_dload_points_to_polygons(polygons, slope_data):
1708
1742
  # Process each polygon
1709
1743
  updated_polygons = []
1710
1744
  for poly in polygons:
1711
- updated_poly = list(poly) # Make a copy
1745
+ coords = poly.get("coords", []) if isinstance(poly, dict) else poly
1746
+ updated_poly = list(coords) # Make a copy
1712
1747
 
1713
1748
  # Check each point against polygon edges
1714
1749
  for check_point in points_to_check:
@@ -1735,7 +1770,12 @@ def add_dload_points_to_polygons(polygons, slope_data):
1735
1770
  updated_poly.insert(i + 1, (round(x_check, 6), round(y_check, 6)))
1736
1771
  break # Only insert once per point
1737
1772
 
1738
- updated_polygons.append(updated_poly)
1773
+ if isinstance(poly, dict):
1774
+ updated_entry = dict(poly)
1775
+ updated_entry["coords"] = updated_poly
1776
+ updated_polygons.append(updated_entry)
1777
+ else:
1778
+ updated_polygons.append(updated_poly)
1739
1779
 
1740
1780
  return updated_polygons
1741
1781
 
@@ -1786,29 +1826,33 @@ def print_polygon_summary(polygons):
1786
1826
  Prints a summary of the generated polygons for diagnostic purposes.
1787
1827
 
1788
1828
  Parameters:
1789
- polygons: List of polygon coordinate lists
1829
+ polygons: List of polygon coordinate lists or dicts with "coords"
1790
1830
  """
1791
1831
  print("=== POLYGON SUMMARY ===")
1792
1832
  print(f"Number of material zones: {len(polygons)}")
1793
1833
  print()
1794
1834
 
1795
1835
  for i, polygon in enumerate(polygons):
1796
- print(f"Material Zone {i+1} (Material ID: {i}):")
1797
- print(f" Number of vertices: {len(polygon)}")
1836
+ coords = polygon.get("coords") if isinstance(polygon, dict) else polygon
1837
+ mat_id = polygon.get("mat_id") if isinstance(polygon, dict) else i
1838
+ if mat_id is None:
1839
+ mat_id = i
1840
+ print(f"Material Zone {i+1} (Material ID: {mat_id}):")
1841
+ print(f" Number of vertices: {len(coords)}")
1798
1842
 
1799
1843
  # Calculate area (simple shoelace formula)
1800
1844
  area = 0
1801
- for j in range(len(polygon) - 1):
1802
- x1, y1 = polygon[j]
1803
- x2, y2 = polygon[j + 1]
1845
+ for j in range(len(coords) - 1):
1846
+ x1, y1 = coords[j]
1847
+ x2, y2 = coords[j + 1]
1804
1848
  area += (x2 - x1) * (y2 + y1) / 2
1805
1849
  area = abs(area)
1806
1850
 
1807
1851
  print(f" Approximate area: {area:.2f} square units")
1808
1852
 
1809
1853
  # Print bounding box
1810
- xs = [x for x, y in polygon]
1811
- ys = [y for x, y in polygon]
1854
+ xs = [x for x, y in coords]
1855
+ ys = [y for x, y in coords]
1812
1856
  print(f" Bounding box: x=[{min(xs):.2f}, {max(xs):.2f}], y=[{min(ys):.2f}, {max(ys):.2f}]")
1813
1857
  print()
1814
1858
 
@@ -2983,7 +3027,7 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2983
3027
  This ensures that polygons have vertices at all intersection points with reinforcement lines.
2984
3028
 
2985
3029
  Parameters:
2986
- polygons: List of polygons (lists of (x,y) tuples)
3030
+ polygons: List of polygons (lists of (x,y) tuples) or dicts with "coords"
2987
3031
  lines: List of reinforcement lines (lists of (x,y) tuples)
2988
3032
  debug: Enable debug output
2989
3033
 
@@ -2999,7 +3043,12 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2999
3043
  # Make a copy of polygons to modify
3000
3044
  updated_polygons = []
3001
3045
  for poly in polygons:
3002
- updated_polygons.append(list(poly)) # Convert to list for modification
3046
+ if isinstance(poly, dict):
3047
+ updated_entry = dict(poly)
3048
+ updated_entry["coords"] = list(poly.get("coords", []))
3049
+ updated_polygons.append(updated_entry)
3050
+ else:
3051
+ updated_polygons.append(list(poly)) # Convert to list for modification
3003
3052
 
3004
3053
  # Find all intersections
3005
3054
  for line_idx, line_pts in enumerate(lines):
@@ -3015,10 +3064,11 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
3015
3064
 
3016
3065
  # Check intersection with each polygon
3017
3066
  for poly_idx, poly in enumerate(updated_polygons):
3067
+ poly_coords = poly.get("coords", []) if isinstance(poly, dict) else poly
3018
3068
  # Check each edge of this polygon
3019
- for j in range(len(poly)):
3020
- poly_edge_start = poly[j]
3021
- poly_edge_end = poly[(j + 1) % len(poly)]
3069
+ for j in range(len(poly_coords)):
3070
+ poly_edge_start = poly_coords[j]
3071
+ poly_edge_end = poly_coords[(j + 1) % len(poly_coords)]
3022
3072
 
3023
3073
  # Find intersection point if it exists
3024
3074
  intersection = line_segment_intersection(
@@ -3032,7 +3082,7 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
3032
3082
 
3033
3083
  # Check if intersection point is already a vertex of this polygon
3034
3084
  is_vertex = False
3035
- for vertex in poly:
3085
+ for vertex in poly_coords:
3036
3086
  if abs(vertex[0] - intersection[0]) < 1e-8 and abs(vertex[1] - intersection[1]) < 1e-8:
3037
3087
  is_vertex = True
3038
3088
  break
@@ -3041,7 +3091,10 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
3041
3091
  # Insert intersection point into polygon at the correct position
3042
3092
  # Insert after vertex j (which is the start of the edge)
3043
3093
  insert_idx = j + 1
3044
- updated_polygons[poly_idx].insert(insert_idx, intersection)
3094
+ if isinstance(updated_polygons[poly_idx], dict):
3095
+ updated_polygons[poly_idx]["coords"].insert(insert_idx, intersection)
3096
+ else:
3097
+ updated_polygons[poly_idx].insert(insert_idx, intersection)
3045
3098
 
3046
3099
  if debug:
3047
3100
  print(f"Added intersection point {intersection} to polygon {poly_idx} at position {insert_idx}")
xslope/plot.py CHANGED
@@ -54,20 +54,142 @@ def get_dload_legend_handler():
54
54
  return None, dummy_line
55
55
 
56
56
 
57
- def plot_profile_lines(ax, profile_lines):
57
+ def plot_profile_lines(ax, profile_lines, materials=None, labels=False):
58
58
  """
59
59
  Plots the profile lines for each material in the slope.
60
60
 
61
61
  Parameters:
62
62
  ax: matplotlib Axes object
63
- profile_lines: List of line coordinates representing material boundaries
63
+ profile_lines: List of profile line dicts, each with 'coords' and 'mat_id' keys
64
+ materials: List of material dictionaries (optional, for color mapping)
65
+ labels: If True, add index labels to each profile line (default: False)
64
66
 
65
67
  Returns:
66
68
  None
67
69
  """
68
70
  for i, line in enumerate(profile_lines):
69
- xs, ys = zip(*line)
70
- ax.plot(xs, ys, color=get_material_color(i), linewidth=1, label=f'Profile {i+1}')
71
+ coords = line['coords']
72
+ xs, ys = zip(*coords)
73
+
74
+ # Get material index from mat_id (already 0-based)
75
+ if materials and line.get('mat_id') is not None:
76
+ mat_idx = line['mat_id']
77
+ if 0 <= mat_idx < len(materials):
78
+ color = get_material_color(mat_idx)
79
+ else:
80
+ # Fallback to index-based color if mat_id out of range
81
+ color = get_material_color(i)
82
+ else:
83
+ # Fallback to index-based color if no materials or mat_id
84
+ color = get_material_color(i)
85
+
86
+ ax.plot(xs, ys, color=color, linewidth=1, label=f'Profile {i+1}')
87
+
88
+ if labels:
89
+ _add_profile_index_label(ax, coords, i + 1, color)
90
+
91
+
92
+ def _add_profile_index_label(ax, line, index, color):
93
+ """
94
+ Adds an index label to a profile line, positioned on a suitable segment.
95
+
96
+ Parameters:
97
+ ax: matplotlib Axes object
98
+ line: List of (x, y) coordinates for the profile line
99
+ index: The index number to display (1-based)
100
+ color: Color for the label text
101
+
102
+ Returns:
103
+ None
104
+ """
105
+ if len(line) < 2:
106
+ return
107
+
108
+ # Build list of segments with their properties
109
+ segments = []
110
+ for j in range(len(line) - 1):
111
+ x1, y1 = line[j]
112
+ x2, y2 = line[j + 1]
113
+ dx = x2 - x1
114
+ dy = y2 - y1
115
+ length = np.sqrt(dx**2 + dy**2)
116
+
117
+ if length < 1e-9:
118
+ continue
119
+
120
+ # Calculate how horizontal the segment is (1.0 = perfectly horizontal)
121
+ horizontalness = abs(dx) / length
122
+
123
+ # Midpoint of the segment
124
+ mid_x = (x1 + x2) / 2
125
+ mid_y = (y1 + y2) / 2
126
+
127
+ segments.append({
128
+ 'length': length,
129
+ 'horizontalness': horizontalness,
130
+ 'mid_x': mid_x,
131
+ 'mid_y': mid_y,
132
+ 'position': j # segment index in the line
133
+ })
134
+
135
+ if not segments:
136
+ return
137
+
138
+ # Calculate the total line length and find segments in the middle third
139
+ total_length = sum(s['length'] for s in segments)
140
+
141
+ # Score segments: prefer longer, more horizontal segments near the middle
142
+ # Avoid very short segments (less than 5% of total length)
143
+ min_length_threshold = 0.05 * total_length
144
+
145
+ n_segments = len(segments)
146
+ best_segment = None
147
+ best_score = -1
148
+
149
+ for seg in segments:
150
+ # Skip very short segments
151
+ if seg['length'] < min_length_threshold:
152
+ continue
153
+
154
+ # Calculate how close to the middle this segment is (0-1 scale, 1 = center)
155
+ position_ratio = (seg['position'] + 0.5) / n_segments
156
+ middle_score = 1.0 - 2.0 * abs(position_ratio - 0.5) # 1.0 at center, 0.0 at ends
157
+
158
+ # Score: weight length, horizontalness, and middle position
159
+ # Length is normalized by total length
160
+ length_score = seg['length'] / total_length
161
+
162
+ # Combined score: prioritize horizontalness, then middle position, then length
163
+ score = (seg['horizontalness'] * 2.0 +
164
+ middle_score * 1.5 +
165
+ length_score * 1.0)
166
+
167
+ if score > best_score:
168
+ best_score = score
169
+ best_segment = seg
170
+
171
+ # Fallback: if no segment passed the threshold, use the longest one
172
+ if best_segment is None:
173
+ best_segment = max(segments, key=lambda s: s['length'])
174
+
175
+ # Place the label at the midpoint of the chosen segment
176
+ ax.text(
177
+ best_segment['mid_x'],
178
+ best_segment['mid_y'],
179
+ str(index),
180
+ fontsize=7,
181
+ color=color,
182
+ fontfamily='monospace',
183
+ ha='center',
184
+ va='center',
185
+ bbox=dict(
186
+ boxstyle='round,pad=0.3',
187
+ facecolor='white',
188
+ edgecolor=color,
189
+ linewidth=0.8
190
+ ),
191
+ zorder=10
192
+ )
71
193
 
72
194
  def plot_max_depth(ax, profile_lines, max_depth):
73
195
  """
@@ -75,7 +197,7 @@ def plot_max_depth(ax, profile_lines, max_depth):
75
197
 
76
198
  Parameters:
77
199
  ax: matplotlib Axes object
78
- profile_lines: List of line coordinates representing material boundaries
200
+ profile_lines: List of profile line dicts, each with 'coords' key containing coordinate tuples
79
201
  max_depth: Maximum allowed depth for analysis
80
202
 
81
203
  Returns:
@@ -83,7 +205,7 @@ def plot_max_depth(ax, profile_lines, max_depth):
83
205
  """
84
206
  if max_depth is None:
85
207
  return
86
- x_vals = [x for line in profile_lines for x, _ in line]
208
+ x_vals = [x for line in profile_lines for x, _ in line['coords']]
87
209
  x_min = min(x_vals)
88
210
  x_max = max(x_vals)
89
211
  ax.hlines(max_depth, x_min, x_max, colors='black', linewidth=1.5, label='Max Depth')
@@ -220,7 +342,7 @@ def plot_piezo_line(ax, slope_data):
220
342
 
221
343
  def plot_seepage_bc_lines(ax, slope_data):
222
344
  """
223
- Plots seepage boundary-condition lines for seepage-only workflows.
345
+ Plots seep boundary-condition lines for seep-only workflows.
224
346
 
225
347
  - Specified head geometry: solid dark blue, thicker than profile lines
226
348
  - Exit face geometry: solid red
@@ -245,7 +367,7 @@ def plot_seepage_bc_lines(ax, slope_data):
245
367
  x_vals = []
246
368
  for line in slope_data.get("profile_lines", []):
247
369
  try:
248
- xs_line, _ = zip(*line)
370
+ xs_line, _ = zip(*line['coords'])
249
371
  x_vals.extend(xs_line)
250
372
  except Exception:
251
373
  pass
@@ -551,11 +673,16 @@ def plot_circles(ax, slope_data):
551
673
  dx /= length
552
674
  dy /= length
553
675
 
554
- # Shorten shaft length slightly
555
- shaft_length = R - 5
556
-
557
- ax.arrow(Xo, Yo, dx * shaft_length, dy * shaft_length,
558
- head_width=5, head_length=5, fc='red', ec='red')
676
+ # Draw arrow with pixel-based head size
677
+ ax.annotate('',
678
+ xy=(Xo + dx * R, Yo + dy * R), # arrow tip
679
+ xytext=(Xo, Yo), # arrow start
680
+ arrowprops=dict(
681
+ arrowstyle='-|>',
682
+ color='red',
683
+ lw=1.0, # shaft width in points
684
+ mutation_scale=20 # head size in points
685
+ ))
559
686
 
560
687
  def plot_non_circ(ax, non_circ):
561
688
  """
@@ -687,14 +814,14 @@ def plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7):
687
814
 
688
815
  def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
689
816
  """
690
- Adds a seepage material properties table to the plot.
817
+ Adds a seep material properties table to the plot.
691
818
 
692
- Displays hydraulic properties for seepage analysis including hydraulic conductivities
819
+ Displays hydraulic properties for seep analysis including hydraulic conductivities
693
820
  (k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).
694
821
 
695
822
  Parameters:
696
823
  ax: matplotlib Axes object to add the table to
697
- seep_data: Dictionary containing seepage material properties with keys:
824
+ seep_data: Dictionary containing seep material properties with keys:
698
825
  - 'k1_by_mat': List of primary hydraulic conductivity values (float)
699
826
  - 'k2_by_mat': List of secondary hydraulic conductivity values (float)
700
827
  - 'angle_by_mat': List of anisotropy angles in degrees (float)
@@ -1036,10 +1163,11 @@ def compute_ylim(data, slice_df, scale_frac=0.5, pad_fraction=0.1):
1036
1163
 
1037
1164
  # 1) collect all profile line elevations
1038
1165
  for line in data.get('profile_lines', []):
1039
- if hasattr(line, "xy"):
1040
- _, ys = line.xy
1166
+ coords = line['coords']
1167
+ if hasattr(coords, "xy"):
1168
+ _, ys = coords.xy
1041
1169
  else:
1042
- _, ys = zip(*line)
1170
+ _, ys = zip(*coords)
1043
1171
  y_vals.extend(ys)
1044
1172
 
1045
1173
  # 2) explicitly include the deepest allowed depth
@@ -1108,11 +1236,11 @@ def plot_inputs(
1108
1236
  slope_data,
1109
1237
  title="Slope Geometry and Inputs",
1110
1238
  figsize=(12, 6),
1111
- mat_table=True,
1239
+ mat_table=False,
1112
1240
  save_png=False,
1113
1241
  dpi=300,
1114
1242
  mode="lem",
1115
- tab_loc="upper left",
1243
+ tab_loc="top",
1116
1244
  legend_ncol="auto",
1117
1245
  legend_max_cols=6,
1118
1246
  legend_max_rows=4,
@@ -1158,7 +1286,7 @@ def plot_inputs(
1158
1286
  fig, ax = plt.subplots(figsize=figsize)
1159
1287
 
1160
1288
  # Plot contents
1161
- plot_profile_lines(ax, slope_data['profile_lines'])
1289
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'), labels=True)
1162
1290
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1163
1291
  plot_piezo_line(ax, slope_data)
1164
1292
  if mode == "seep":
@@ -1358,7 +1486,7 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
1358
1486
  ax.set_ylabel("y")
1359
1487
  ax.grid(False)
1360
1488
 
1361
- plot_profile_lines(ax, slope_data['profile_lines'])
1489
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
1362
1490
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1363
1491
  plot_slices(ax, slice_df, fill=False)
1364
1492
  plot_failure_surface(ax, failure_surface)
@@ -1510,7 +1638,7 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
1510
1638
  """
1511
1639
  fig, ax = plt.subplots(figsize=figsize)
1512
1640
 
1513
- plot_profile_lines(ax, slope_data['profile_lines'])
1641
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
1514
1642
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1515
1643
  plot_piezo_line(ax, slope_data)
1516
1644
  plot_dloads(ax, slope_data)
@@ -1556,7 +1684,7 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
1556
1684
  fig, ax = plt.subplots(figsize=figsize)
1557
1685
 
1558
1686
  # Plot basic profile elements
1559
- plot_profile_lines(ax, slope_data['profile_lines'])
1687
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
1560
1688
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1561
1689
  plot_piezo_line(ax, slope_data)
1562
1690
  plot_dloads(ax, slope_data)
@@ -1623,7 +1751,7 @@ def plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save
1623
1751
  fig, ax = plt.subplots(figsize=figsize)
1624
1752
 
1625
1753
  # Plot basic slope elements (same as other search functions)
1626
- plot_profile_lines(ax, slope_data['profile_lines'])
1754
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
1627
1755
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1628
1756
  plot_piezo_line(ax, slope_data)
1629
1757
  plot_dloads(ax, slope_data)
@@ -1890,34 +2018,48 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
1890
2018
  plt.show()
1891
2019
 
1892
2020
 
1893
- def plot_polygons(polygons, materials=None, nodes=False, legend=True, title="Material Zone Polygons", save_png=False, dpi=300):
2021
+ def plot_polygons(
2022
+ polygons,
2023
+ materials=None,
2024
+ nodes=False,
2025
+ legend=True,
2026
+ title="Material Zone Polygons",
2027
+ figsize=(10, 6),
2028
+ save_png=False,
2029
+ dpi=300,
2030
+ ):
1894
2031
  """
1895
2032
  Plot all material zone polygons in a single figure.
1896
2033
 
1897
2034
  Parameters:
1898
- polygons: List of polygon coordinate lists
2035
+ polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
1899
2036
  materials: Optional list of material dicts (with key "name") or list of material
1900
2037
  name strings. If provided, the material name will be used in the legend.
1901
2038
  nodes: If True, plot each polygon vertex as a dot.
1902
2039
  legend: If True, show the legend.
1903
2040
  title: Plot title
2041
+ figsize: Matplotlib figure size tuple, e.g. (10, 6)
1904
2042
  """
1905
2043
  import matplotlib.pyplot as plt
1906
2044
 
1907
- fig, ax = plt.subplots(figsize=(12, 8))
2045
+ fig, ax = plt.subplots(figsize=figsize)
1908
2046
  for i, polygon in enumerate(polygons):
1909
- xs = [x for x, y in polygon]
1910
- ys = [y for x, y in polygon]
2047
+ coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
2048
+ xs = [x for x, y in coords]
2049
+ ys = [y for x, y in coords]
2050
+ mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
2051
+ if mat_idx is None:
2052
+ mat_idx = i
1911
2053
  mat_name = None
1912
- if materials is not None and i < len(materials):
1913
- item = materials[i]
2054
+ if materials is not None and 0 <= mat_idx < len(materials):
2055
+ item = materials[mat_idx]
1914
2056
  if isinstance(item, dict):
1915
2057
  mat_name = item.get("name", None)
1916
2058
  elif isinstance(item, str):
1917
2059
  mat_name = item
1918
- label = mat_name if mat_name else f"Material {i}"
1919
- ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=label)
1920
- ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
2060
+ label = mat_name if mat_name else f"Material {mat_idx}"
2061
+ ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=label)
2062
+ ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
1921
2063
  if nodes:
1922
2064
  # Avoid legend clutter by not adding a label here.
1923
2065
  ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3)
@@ -1942,7 +2084,7 @@ def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
1942
2084
  Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
1943
2085
 
1944
2086
  Parameters:
1945
- polygons: List of polygon coordinate lists
2087
+ polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
1946
2088
  materials: Optional list of material dicts (with key "name") or list of material
1947
2089
  name strings. If provided, the material name will be included in each subplot title.
1948
2090
  """
@@ -1951,25 +2093,29 @@ def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
1951
2093
  n = len(polygons)
1952
2094
  fig, axes = plt.subplots(n, 1, figsize=(8, 3 * n), squeeze=False)
1953
2095
  for i, polygon in enumerate(polygons):
1954
- xs = [x for x, y in polygon]
1955
- ys = [y for x, y in polygon]
2096
+ coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
2097
+ xs = [x for x, y in coords]
2098
+ ys = [y for x, y in coords]
1956
2099
  ax = axes[i, 0]
1957
- ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
1958
- ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
2100
+ mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
2101
+ if mat_idx is None:
2102
+ mat_idx = i
2103
+ ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=f'Material {mat_idx}')
2104
+ ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
1959
2105
  ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
1960
2106
  ax.set_xlabel('X Coordinate')
1961
2107
  ax.set_ylabel('Y Coordinate')
1962
2108
  mat_name = None
1963
- if materials is not None and i < len(materials):
1964
- item = materials[i]
2109
+ if materials is not None and 0 <= mat_idx < len(materials):
2110
+ item = materials[mat_idx]
1965
2111
  if isinstance(item, dict):
1966
2112
  mat_name = item.get("name", None)
1967
2113
  elif isinstance(item, str):
1968
2114
  mat_name = item
1969
2115
  if mat_name:
1970
- ax.set_title(f'Material {i}: {mat_name}')
2116
+ ax.set_title(f'Material {mat_idx}: {mat_name}')
1971
2117
  else:
1972
- ax.set_title(f'Material {i}')
2118
+ ax.set_title(f'Material {mat_idx}')
1973
2119
  ax.grid(True, alpha=0.3)
1974
2120
  ax.set_aspect('equal')
1975
2121
  # Intentionally no legend: these plots are typically used for debugging geometry,
@@ -2058,8 +2204,9 @@ def get_plot_elements_bounds(ax, slope_data):
2058
2204
  if 'profile_lines' in slope_data:
2059
2205
  for line in slope_data['profile_lines']:
2060
2206
  if line:
2061
- xs = [p[0] for p in line]
2062
- ys = [p[1] for p in line]
2207
+ coords = line['coords']
2208
+ xs = [p[0] for p in coords]
2209
+ ys = [p[1] for p in coords]
2063
2210
  bounds.append((min(xs), max(xs), min(ys), max(ys)))
2064
2211
 
2065
2212
  # Distributed loads bounds