xslope 0.1.11__py3-none-any.whl → 0.1.13__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/plot.py CHANGED
@@ -17,6 +17,7 @@ import numpy as np
17
17
  from matplotlib.lines import Line2D
18
18
  from matplotlib.path import Path
19
19
  from shapely.geometry import LineString
20
+ import math
20
21
 
21
22
  from .slice import generate_failure_surface
22
23
 
@@ -29,8 +30,9 @@ plt.rcParams.update({
29
30
 
30
31
  # Consistent color for materials (Tableau tab10)
31
32
  def get_material_color(idx):
32
- tableau_colors = plt.get_cmap('tab10').colors # 10 distinct colors
33
- return tableau_colors[idx % len(tableau_colors)]
33
+ cmap = plt.get_cmap("tab10")
34
+ # Use the colormap callable rather than relying on the (typing-unknown) `.colors` attribute.
35
+ return cmap(idx % getattr(cmap, "N", 10))
34
36
 
35
37
  def get_dload_legend_handler():
36
38
  """
@@ -52,20 +54,142 @@ def get_dload_legend_handler():
52
54
  return None, dummy_line
53
55
 
54
56
 
55
- def plot_profile_lines(ax, profile_lines):
57
+ def plot_profile_lines(ax, profile_lines, materials=None, labels=False):
56
58
  """
57
59
  Plots the profile lines for each material in the slope.
58
60
 
59
61
  Parameters:
60
62
  ax: matplotlib Axes object
61
- 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)
62
66
 
63
67
  Returns:
64
68
  None
65
69
  """
66
70
  for i, line in enumerate(profile_lines):
67
- xs, ys = zip(*line)
68
- 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
+ )
69
193
 
70
194
  def plot_max_depth(ax, profile_lines, max_depth):
71
195
  """
@@ -73,7 +197,7 @@ def plot_max_depth(ax, profile_lines, max_depth):
73
197
 
74
198
  Parameters:
75
199
  ax: matplotlib Axes object
76
- profile_lines: List of line coordinates representing material boundaries
200
+ profile_lines: List of profile line dicts, each with 'coords' key containing coordinate tuples
77
201
  max_depth: Maximum allowed depth for analysis
78
202
 
79
203
  Returns:
@@ -81,7 +205,7 @@ def plot_max_depth(ax, profile_lines, max_depth):
81
205
  """
82
206
  if max_depth is None:
83
207
  return
84
- 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']]
85
209
  x_min = min(x_vals)
86
210
  x_max = max(x_vals)
87
211
  ax.hlines(max_depth, x_min, x_max, colors='black', linewidth=1.5, label='Max Depth')
@@ -173,6 +297,26 @@ def plot_piezo_line(ax, slope_data):
173
297
  None
174
298
  """
175
299
 
300
+ def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=0.0):
301
+ """
302
+ Place an inverted triangle marker so its tip visually touches the line at (x, y).
303
+ We do this in display coordinates (points/pixels) so it scales consistently.
304
+ """
305
+ from matplotlib.markers import MarkerStyle
306
+ from matplotlib.transforms import offset_copy
307
+
308
+ # Compute the distance (in "marker units") from the marker origin to the tip.
309
+ # Matplotlib scales marker vertices by `markersize` (in points) for Line2D.
310
+ ms = MarkerStyle("v")
311
+ path = ms.get_path().transformed(ms.get_transform())
312
+ verts = np.asarray(path.vertices)
313
+ min_y = float(verts[:, 1].min()) # tip is the lowest y
314
+ tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
315
+
316
+ # Offset the marker center upward in point units so the tip lands at (x, y).
317
+ trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
318
+ ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
319
+
176
320
  def plot_single_piezo_line(ax, piezo_line, color, label):
177
321
  """Internal function to plot a single piezometric line"""
178
322
  if not piezo_line:
@@ -182,20 +326,146 @@ def plot_piezo_line(ax, slope_data):
182
326
  ax.plot(piezo_xs, piezo_ys, color=color, linewidth=2, label=label)
183
327
 
184
328
  # Find middle x-coordinate and corresponding y value
185
- x_min, x_max = min(piezo_xs), max(piezo_xs)
186
- mid_x = (x_min + x_max) / 2
187
-
188
- # Interpolate y value at mid_x
189
- from scipy.interpolate import interp1d
190
329
  if len(piezo_xs) > 1:
191
- f = interp1d(piezo_xs, piezo_ys, kind='linear', bounds_error=False, fill_value='extrapolate')
192
- mid_y = f(mid_x)
193
- ax.plot(mid_x, mid_y + 6, marker='v', color=color, markersize=8)
330
+ # Sort by x to ensure monotonic input for interpolation
331
+ pairs = sorted(zip(piezo_xs, piezo_ys), key=lambda p: p[0])
332
+ sx, sy = zip(*pairs)
333
+ x_min, x_max = min(sx), max(sx)
334
+ mid_x = (x_min + x_max) / 2
335
+ mid_y = float(np.interp(mid_x, sx, sy))
336
+ # Slight negative gap so the marker visually "touches" the line (not floating above it)
337
+ _plot_touching_v_marker(ax, mid_x, mid_y, color=color, markersize=8, extra_gap_points=2.0)
194
338
 
195
339
  # Plot both piezometric lines
196
340
  plot_single_piezo_line(ax, slope_data.get('piezo_line'), 'b', "Piezometric Line")
197
341
  plot_single_piezo_line(ax, slope_data.get('piezo_line2'), 'skyblue', "Piezometric Line 2")
198
342
 
343
+ def plot_seepage_bc_lines(ax, slope_data):
344
+ """
345
+ Plots seep boundary-condition lines for seep-only workflows.
346
+
347
+ - Specified head geometry: solid dark blue, thicker than profile lines
348
+ - Exit face geometry: solid red
349
+ - Derived "water level" line for each specified head: y = h
350
+ plotted as a lighter blue solid line with an inverted triangle marker
351
+ (styled similarly to piezometric line markers).
352
+ """
353
+ def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=2.0):
354
+ """Place an inverted triangle so its tip visually sits on the line at (x, y)."""
355
+ from matplotlib.markers import MarkerStyle
356
+ from matplotlib.transforms import offset_copy
357
+
358
+ ms = MarkerStyle("v")
359
+ path = ms.get_path().transformed(ms.get_transform())
360
+ verts = np.asarray(path.vertices)
361
+ min_y = float(verts[:, 1].min())
362
+ tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
363
+ trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
364
+ ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
365
+
366
+ # Geometry x-extent (used for vertical-head-line derived segment length / side)
367
+ x_vals = []
368
+ for line in slope_data.get("profile_lines", []):
369
+ try:
370
+ xs_line, _ = zip(*line['coords'])
371
+ x_vals.extend(xs_line)
372
+ except Exception:
373
+ pass
374
+ gs = slope_data.get("ground_surface", None)
375
+ if not x_vals and gs is not None and hasattr(gs, "coords"):
376
+ x_vals.extend([x for x, _ in gs.coords])
377
+ x_min_geom = min(x_vals) if x_vals else 0.0
378
+ x_max_geom = max(x_vals) if x_vals else 1.0
379
+ geom_width = max(1e-9, x_max_geom - x_min_geom)
380
+
381
+ seepage_bc = slope_data.get("seepage_bc") or {}
382
+ specified_heads = seepage_bc.get("specified_heads") or []
383
+ exit_face = seepage_bc.get("exit_face") or []
384
+
385
+ # --- Specified head lines + derived water-level lines ---
386
+ for i, sh in enumerate(specified_heads):
387
+ coords = sh.get("coords") or []
388
+ if len(coords) < 2:
389
+ continue
390
+
391
+ xs, ys = zip(*coords)
392
+ ax.plot(
393
+ xs,
394
+ ys,
395
+ color="darkblue",
396
+ linewidth=3,
397
+ linestyle="--",
398
+ label="Specified Head Line" if i == 0 else "",
399
+ )
400
+
401
+ # Head values may be scalar (typical) or per-point array-like
402
+ head_val = sh.get("head", None)
403
+ if head_val is None:
404
+ continue
405
+
406
+ if isinstance(head_val, (list, tuple, np.ndarray)):
407
+ if len(head_val) != len(coords):
408
+ continue
409
+ heads = [float(h) for h in head_val]
410
+ else:
411
+ try:
412
+ head_scalar = float(head_val)
413
+ except (TypeError, ValueError):
414
+ continue
415
+ heads = [head_scalar] * len(coords)
416
+
417
+ # If specified-head geometry is vertical, draw a short horizontal derived line outside the boundary.
418
+ # This avoids drawing a (nearly) vertical derived line that doesn't convey a water level.
419
+ tol = 1e-6
420
+ is_vertical = (max(xs) - min(xs)) <= tol
421
+ if is_vertical:
422
+ x0 = float(xs[0])
423
+ y_head = float(heads[0])
424
+ seg_len = 0.04 * geom_width
425
+ gap = 0.01 * geom_width
426
+ is_right = x0 >= 0.5 * (x_min_geom + x_max_geom)
427
+ if is_right:
428
+ wl_xs = [x0 + gap, x0 + gap + seg_len]
429
+ else:
430
+ wl_xs = [x0 - gap - seg_len, x0 - gap]
431
+ wl_ys = [y_head, y_head]
432
+ else:
433
+ wl_xs = list(xs)
434
+ wl_ys = heads
435
+
436
+ ax.plot(
437
+ wl_xs,
438
+ wl_ys,
439
+ color="lightskyblue",
440
+ linewidth=2,
441
+ linestyle="-",
442
+ label="Specified Head Water Level" if i == 0 else "",
443
+ )
444
+
445
+ # Inverted triangle marker near the midpoint (like piezometric lines)
446
+ if len(wl_xs) > 1:
447
+ try:
448
+ pairs = sorted(zip(wl_xs, wl_ys), key=lambda p: p[0])
449
+ sx, sy = zip(*pairs)
450
+ mid_x = 0.5 * (min(sx) + max(sx))
451
+ mid_y = float(np.interp(mid_x, sx, sy))
452
+ _plot_touching_v_marker(ax, mid_x, mid_y, color="lightskyblue", markersize=8, extra_gap_points=2.0)
453
+ except Exception:
454
+ # If interpolation fails for any reason, skip marker
455
+ pass
456
+
457
+ # --- Exit face line ---
458
+ if len(exit_face) >= 2:
459
+ ex_xs, ex_ys = zip(*exit_face)
460
+ ax.plot(
461
+ ex_xs,
462
+ ex_ys,
463
+ color="red",
464
+ linewidth=3,
465
+ linestyle="--",
466
+ label="Exit Face",
467
+ )
468
+
199
469
  def plot_tcrack_surface(ax, tcrack_surface):
200
470
  """
201
471
  Plots the tension crack surface as a thin dashed red line.
@@ -381,6 +651,8 @@ def plot_circles(ax, slope_data):
381
651
  continue # or handle error
382
652
  # result = (x_min, x_max, y_left, y_right, clipped_surface)
383
653
  x_min, x_max, y_left, y_right, clipped_surface = result
654
+ if not isinstance(clipped_surface, LineString):
655
+ clipped_surface = LineString(clipped_surface)
384
656
  x_clip, y_clip = zip(*clipped_surface.coords)
385
657
  ax.plot(x_clip, y_clip, 'r--', label="Circle")
386
658
 
@@ -423,15 +695,28 @@ def plot_non_circ(ax, non_circ):
423
695
  xs, ys = zip(*non_circ)
424
696
  ax.plot(xs, ys, 'r--', label='Non-Circular Surface')
425
697
 
426
- def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
698
+ def plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7):
427
699
  """
428
- Adds a material properties table to the plot.
700
+ Adds a limit equilibrium material properties table to the plot.
701
+
702
+ Displays soil properties for limit equilibrium analysis including unit weight (γ),
703
+ cohesion (c), friction angle (φ), and optionally dilation angle (d) and
704
+ dilatancy angle (ψ). Supports both Mohr-Coulomb (mc) and constant-phi (cp) options.
429
705
 
430
706
  Parameters:
431
- ax: matplotlib Axes object
432
- materials: List of material property dictionaries
433
- xloc: x-location of table (0-1)
434
- yloc: y-location of table (0-1)
707
+ ax: matplotlib Axes object to add the table to
708
+ materials: List of material property dictionaries with keys:
709
+ - 'name': Material name (str)
710
+ - 'gamma': Unit weight (float)
711
+ - 'option': Material model - 'mc' or 'cp' (str)
712
+ - 'c': Cohesion for mc option (float)
713
+ - 'phi': Friction angle for mc option (float)
714
+ - 'cp': Constant phi for cp option (float)
715
+ - 'r_elev': Reference elevation for cp option (float)
716
+ - 'd': Dilation angle, optional (float)
717
+ - 'psi': Dilatancy angle, optional (float)
718
+ xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
719
+ yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
435
720
 
436
721
  Returns:
437
722
  None
@@ -505,15 +790,239 @@ def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
505
790
  # Adjust table width based on number of columns
506
791
  table_width = 0.25 if has_d_psi else 0.2
507
792
 
793
+ # Choose table height based on number of materials (uniform across table types)
794
+ num_rows = max(1, len(materials))
795
+ table_height = 0.06 + 0.035 * num_rows # header + per-row estimate
796
+ table_height = min(0.35, table_height) # cap to avoid overflows for many rows
797
+
508
798
  # Add the table
509
799
  table = ax.table(cellText=table_data,
510
800
  colLabels=col_labels,
511
801
  loc='upper right',
512
802
  colLoc='center',
513
803
  cellLoc='center',
514
- bbox=[xloc, yloc, table_width, 0.25])
804
+ bbox=[xloc, yloc, table_width, table_height])
515
805
  table.auto_set_font_size(False)
516
806
  table.set_fontsize(8)
807
+ # Auto layout based on content (shared method for all table types)
808
+ auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
809
+
810
+ def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
811
+ """
812
+ Adds a seep material properties table to the plot.
813
+
814
+ Displays hydraulic properties for seep analysis including hydraulic conductivities
815
+ (k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).
816
+
817
+ Parameters:
818
+ ax: matplotlib Axes object to add the table to
819
+ seep_data: Dictionary containing seep material properties with keys:
820
+ - 'k1_by_mat': List of primary hydraulic conductivity values (float)
821
+ - 'k2_by_mat': List of secondary hydraulic conductivity values (float)
822
+ - 'angle_by_mat': List of anisotropy angles in degrees (float)
823
+ - 'kr0_by_mat': List of relative permeability at residual saturation (float)
824
+ - 'h0_by_mat': List of pressure head parameters (float)
825
+ - 'material_names': List of material names (str), optional
826
+ xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
827
+ yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
828
+
829
+ Returns:
830
+ None
831
+ """
832
+ k1_by_mat = seep_data.get("k1_by_mat")
833
+ k2_by_mat = seep_data.get("k2_by_mat")
834
+ angle_by_mat = seep_data.get("angle_by_mat")
835
+ kr0_by_mat = seep_data.get("kr0_by_mat")
836
+ h0_by_mat = seep_data.get("h0_by_mat")
837
+ material_names = seep_data.get("material_names", [])
838
+ if k1_by_mat is None or len(k1_by_mat) == 0:
839
+ return
840
+ col_labels = ["Mat", "Name", "k₁", "k₂", "Angle", "kr₀", "h₀"]
841
+ table_data = []
842
+ for idx in range(len(k1_by_mat)):
843
+ k1 = k1_by_mat[idx]
844
+ k2 = k2_by_mat[idx] if k2_by_mat is not None else 0.0
845
+ angle = angle_by_mat[idx] if angle_by_mat is not None else 0.0
846
+ kr0 = kr0_by_mat[idx] if kr0_by_mat is not None else 0.0
847
+ h0 = h0_by_mat[idx] if h0_by_mat is not None else 0.0
848
+ material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
849
+ row = [idx + 1, material_name, f"{k1:.3f}", f"{k2:.3f}", f"{angle:.1f}", f"{kr0:.4f}", f"{h0:.2f}"]
850
+ table_data.append(row)
851
+ # Dimensions
852
+ num_rows = max(1, len(k1_by_mat))
853
+ table_width = 0.45
854
+ table_height = 0.10 + 0.06 * num_rows
855
+ table_height = min(0.50, table_height)
856
+ table = ax.table(cellText=table_data,
857
+ colLabels=col_labels,
858
+ loc='upper right',
859
+ colLoc='center',
860
+ cellLoc='center',
861
+ bbox=[xloc, yloc, table_width, table_height])
862
+ table.auto_set_font_size(False)
863
+ table.set_fontsize(8)
864
+ auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
865
+
866
+ def plot_fem_material_table(ax, fem_data, xloc=0.6, yloc=0.7, width=0.6, height=None):
867
+ """
868
+ Adds a finite element material properties table to the plot.
869
+
870
+ Displays material properties for FEM analysis including unit weight (γ), cohesion (c),
871
+ friction angle (φ), Young's modulus (E), and Poisson's ratio (ν).
872
+
873
+ Parameters:
874
+ ax: matplotlib Axes object to add the table to
875
+ fem_data: Dictionary containing FEM material properties with keys:
876
+ - 'c_by_mat': List of cohesion values (float)
877
+ - 'phi_by_mat': List of friction angle values in degrees (float)
878
+ - 'E_by_mat': List of Young's modulus values (float)
879
+ - 'nu_by_mat': List of Poisson's ratio values (float)
880
+ - 'gamma_by_mat': List of unit weight values (float)
881
+ - 'material_names': List of material names (str), optional
882
+ xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
883
+ yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
884
+ width: Table width in axes coordinates (0-1, default: 0.6)
885
+ height: Table height in axes coordinates (0-1, default: auto-calculated)
886
+
887
+ Returns:
888
+ None
889
+ """
890
+ c_by_mat = fem_data.get("c_by_mat")
891
+ phi_by_mat = fem_data.get("phi_by_mat")
892
+ E_by_mat = fem_data.get("E_by_mat")
893
+ nu_by_mat = fem_data.get("nu_by_mat")
894
+ gamma_by_mat = fem_data.get("gamma_by_mat")
895
+ material_names = fem_data.get("material_names", [])
896
+ if c_by_mat is None or len(c_by_mat) == 0:
897
+ return
898
+ col_labels = ["Mat", "Name", "γ", "c", "φ", "E", "ν"]
899
+ table_data = []
900
+ for idx in range(len(c_by_mat)):
901
+ c = c_by_mat[idx]
902
+ phi = phi_by_mat[idx] if phi_by_mat is not None else 0.0
903
+ E = E_by_mat[idx] if E_by_mat is not None else 0.0
904
+ nu = nu_by_mat[idx] if nu_by_mat is not None else 0.0
905
+ gamma = gamma_by_mat[idx] if gamma_by_mat is not None else 0.0
906
+ material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
907
+ row = [idx + 1, material_name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", f"{E:.0f}", f"{nu:.2f}"]
908
+ table_data.append(row)
909
+ if height is None:
910
+ num_rows = max(1, len(c_by_mat))
911
+ height = 0.06 + 0.035 * num_rows
912
+ height = min(0.32, height)
913
+ table = ax.table(cellText=table_data,
914
+ colLabels=col_labels,
915
+ loc='upper right',
916
+ colLoc='center',
917
+ cellLoc='center',
918
+ bbox=[xloc, yloc, width, height])
919
+ table.auto_set_font_size(False)
920
+ table.set_fontsize(8)
921
+ auto_size_table_to_content(ax, table, col_labels, table_data, width, height)
922
+ def auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height, min_row_frac=0.02, row_pad=1.35, col_min_frac=0.08, col_max_frac=0.15):
923
+ """
924
+ Automatically adjusts table column widths and row heights based on content.
925
+
926
+ Measures text extents using the matplotlib renderer and sets column widths proportional
927
+ to content while enforcing minimum and maximum constraints. The "Name" column gets
928
+ more space (18-30%) while numeric columns are constrained to prevent excessive whitespace.
929
+ Row heights are uniform and based on the tallest content in each row.
930
+
931
+ Parameters:
932
+ ax: matplotlib Axes object containing the table
933
+ table: matplotlib Table object to be resized
934
+ col_labels: List of column header labels (str)
935
+ table_data: List of lists containing table cell data
936
+ table_width: Total table width in axes coordinates (0-1)
937
+ table_height: Total table height in axes coordinates (0-1)
938
+ min_row_frac: Minimum row height as fraction of axes height (default: 0.02)
939
+ row_pad: Padding factor applied to measured row heights (default: 1.35)
940
+ col_min_frac: Minimum column width as fraction of table width for numeric columns (default: 0.08)
941
+ col_max_frac: Maximum column width as fraction of table width for numeric columns (default: 0.15)
942
+
943
+ Returns:
944
+ None
945
+
946
+ Notes:
947
+ - The "Name" column is automatically left-aligned and gets 18-30% of table width
948
+ - Numeric columns are center-aligned and constrained to 8-15% of table width
949
+ - All three material table types (LEM, SEEP, FEM) use the same sizing parameters
950
+ """
951
+ # Force draw to get a valid renderer
952
+ try:
953
+ ax.figure.canvas.draw()
954
+ renderer = ax.figure.canvas.get_renderer()
955
+ except Exception:
956
+ renderer = None
957
+
958
+ ncols = len(col_labels)
959
+ nrows = len(table_data) + 1 # include header
960
+ # Measure text widths per column in pixels
961
+ widths_px = [1.0] * ncols
962
+ if renderer is not None:
963
+ for c in range(ncols):
964
+ max_w = 1.0
965
+ for r in range(nrows):
966
+ cell = table[(r, c)]
967
+ text = cell.get_text()
968
+ try:
969
+ bbox = text.get_window_extent(renderer=renderer)
970
+ max_w = max(max_w, bbox.width)
971
+ except Exception:
972
+ pass
973
+ widths_px[c] = max_w
974
+ total_w = sum(widths_px) if sum(widths_px) > 0 else float(ncols)
975
+ col_fracs = [w / total_w for w in widths_px]
976
+ # Clamp extreme column widths to keep numeric columns from becoming too wide
977
+ clamped = []
978
+ for i, frac in enumerate(col_fracs):
979
+ label = str(col_labels[i]).lower()
980
+ min_frac = col_min_frac
981
+ max_frac = col_max_frac
982
+ if label == "name":
983
+ min_frac = 0.18
984
+ max_frac = 0.30
985
+ clamped.append(min(max(frac, min_frac), max_frac))
986
+ # Re-normalize to sum to 1.0
987
+ s = sum(clamped)
988
+ if s > 0:
989
+ col_fracs = [c / s for c in clamped]
990
+ # Compute per-row pixel heights based on text extents, convert to axes fraction
991
+ axes_h_px = None
992
+ if renderer is not None:
993
+ try:
994
+ axes_h_px = ax.get_window_extent(renderer=renderer).height
995
+ except Exception:
996
+ axes_h_px = None
997
+ # Fallback axes height if needed (avoid division by zero)
998
+ if not axes_h_px or axes_h_px <= 0:
999
+ axes_h_px = 800.0 # arbitrary but reasonable default
1000
+ row_heights_frac = []
1001
+ for r in range(nrows):
1002
+ max_h_px = 1.0
1003
+ if renderer is not None:
1004
+ for c in range(ncols):
1005
+ try:
1006
+ bbox = table[(r, c)].get_text().get_window_extent(renderer=renderer)
1007
+ max_h_px = max(max_h_px, bbox.height)
1008
+ except Exception:
1009
+ pass
1010
+ # padding factor to provide breathing room around text
1011
+ padded_px = max_h_px * row_pad
1012
+ # Convert to axes fraction with minimum clamp
1013
+ rh = max(padded_px / axes_h_px, min_row_frac)
1014
+ row_heights_frac.append(rh)
1015
+
1016
+ # Apply column widths and per-row heights
1017
+ for r in range(nrows):
1018
+ for c in range(ncols):
1019
+ cell = table[(r, c)]
1020
+ cell.set_width(table_width * col_fracs[c])
1021
+ cell.set_height(row_heights_frac[r])
1022
+ # Left-align the "Name" column if present
1023
+ label = str(col_labels[c]).lower()
1024
+ if label == "name":
1025
+ cell.get_text().set_ha('left')
517
1026
 
518
1027
  def plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3):
519
1028
  """
@@ -649,10 +1158,11 @@ def compute_ylim(data, slice_df, scale_frac=0.5, pad_fraction=0.1):
649
1158
 
650
1159
  # 1) collect all profile line elevations
651
1160
  for line in data.get('profile_lines', []):
652
- if hasattr(line, "xy"):
653
- _, ys = line.xy
1161
+ coords = line['coords']
1162
+ if hasattr(coords, "xy"):
1163
+ _, ys = coords.xy
654
1164
  else:
655
- _, ys = zip(*line)
1165
+ _, ys = zip(*coords)
656
1166
  y_vals.extend(ys)
657
1167
 
658
1168
  # 2) explicitly include the deepest allowed depth
@@ -717,7 +1227,19 @@ def plot_reinforcement_lines(ax, slope_data):
717
1227
  tension_points_plotted = True
718
1228
 
719
1229
 
720
- def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6), mat_table=True, save_png=False, dpi=300):
1230
+ def plot_inputs(
1231
+ slope_data,
1232
+ title="Slope Geometry and Inputs",
1233
+ figsize=(12, 6),
1234
+ mat_table=True,
1235
+ save_png=False,
1236
+ dpi=300,
1237
+ mode="lem",
1238
+ tab_loc="upper left",
1239
+ legend_ncol="auto",
1240
+ legend_max_cols=6,
1241
+ legend_max_rows=4,
1242
+ ):
721
1243
  """
722
1244
  Creates a plot showing the slope geometry and input parameters.
723
1245
 
@@ -726,11 +1248,32 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
726
1248
  title: Title for the plot
727
1249
  figsize: Tuple of (width, height) in inches for the plot
728
1250
  mat_table: Controls material table display. Can be:
729
- - True: Auto-position material table to avoid overlaps
1251
+ - True: Use tab_loc for positioning (default)
730
1252
  - False: Don't show material table
731
- - 'auto': Auto-position material table to avoid overlaps
732
- - String: Specific location for material table ('upper left', 'upper right', 'upper center',
733
- 'lower left', 'lower right', 'lower center', 'center left', 'center right', 'center')
1253
+ - 'auto': Use tab_loc for positioning
1254
+ - String: Specific location from valid placements (see tab_loc)
1255
+ save_png: If True, save plot as PNG file (default: False)
1256
+ dpi: Resolution for saved PNG file (default: 300)
1257
+ mode: Which material properties table to display:
1258
+ - "lem": Limit equilibrium materials (γ, c, φ, optional d/ψ)
1259
+ - "seep": Seepage properties (k₁, k₂, Angle, kr₀, h₀)
1260
+ - "fem": FEM properties (γ, c, φ, E, ν)
1261
+ tab_loc: Table placement when mat_table is True or 'auto'. Valid options:
1262
+ - "upper left": Top-left corner of plot area
1263
+ - "upper right": Top-right corner of plot area
1264
+ - "upper center": Top-center of plot area
1265
+ - "lower left": Bottom-left corner of plot area
1266
+ - "lower right": Bottom-right corner of plot area
1267
+ - "lower center": Bottom-center of plot area
1268
+ - "center left": Middle-left of plot area
1269
+ - "center right": Middle-right of plot area
1270
+ - "center": Center of plot area
1271
+ - "top": Above plot area, horizontally centered
1272
+ legend_ncol: Legend column count. Use "auto" (default) to choose a value that
1273
+ keeps the legend from getting too tall, or pass an int to force a width.
1274
+ legend_max_cols: When legend_ncol="auto", cap the number of columns (default: 6).
1275
+ legend_max_rows: When legend_ncol="auto", try to keep legend rows <= this value
1276
+ by increasing columns (default: 4).
734
1277
 
735
1278
  Returns:
736
1279
  None
@@ -738,9 +1281,11 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
738
1281
  fig, ax = plt.subplots(figsize=figsize)
739
1282
 
740
1283
  # Plot contents
741
- plot_profile_lines(ax, slope_data['profile_lines'])
1284
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'), labels=True)
742
1285
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
743
1286
  plot_piezo_line(ax, slope_data)
1287
+ if mode == "seep":
1288
+ plot_seepage_bc_lines(ax, slope_data)
744
1289
  plot_dloads(ax, slope_data)
745
1290
  plot_tcrack_surface(ax, slope_data['tcrack_surface'])
746
1291
  plot_reinforcement_lines(ax, slope_data)
@@ -752,32 +1297,116 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
752
1297
 
753
1298
  # Handle material table display
754
1299
  if mat_table:
755
- if isinstance(mat_table, str) and mat_table != 'auto':
756
- # Convert location string to xloc, yloc coordinates (inside plot area with margins)
757
- location_map = {
758
- 'upper left': (0.05, 0.70),
759
- 'upper right': (0.70, 0.70),
1300
+ # Helpers to adapt slope_data materials into formats expected by table functions
1301
+ def _build_seep_data():
1302
+ materials = slope_data.get('materials', [])
1303
+ return {
1304
+ "k1_by_mat": [m.get('k1', 0.0) for m in materials],
1305
+ "k2_by_mat": [m.get('k2', 0.0) for m in materials],
1306
+ "angle_by_mat": [m.get('alpha', 0.0) for m in materials],
1307
+ "kr0_by_mat": [m.get('kr0', 0.0) for m in materials],
1308
+ "h0_by_mat": [m.get('h0', 0.0) for m in materials],
1309
+ "material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
1310
+ }
1311
+
1312
+ def _build_fem_data():
1313
+ materials = slope_data.get('materials', [])
1314
+ return {
1315
+ "c_by_mat": [m.get('c', 0.0) for m in materials],
1316
+ "phi_by_mat": [m.get('phi', 0.0) for m in materials],
1317
+ "E_by_mat": [m.get('E', 0.0) for m in materials],
1318
+ "nu_by_mat": [m.get('nu', 0.0) for m in materials],
1319
+ "gamma_by_mat": [m.get('gamma', 0.0) for m in materials],
1320
+ "material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
1321
+ }
1322
+
1323
+ def _estimate_table_dims():
1324
+ """Estimate table dimensions based on mode and materials."""
1325
+ materials = slope_data.get('materials', [])
1326
+ num_rows = max(1, len(materials))
1327
+
1328
+ if mode == "lem":
1329
+ has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
1330
+ width = 0.25 if has_d_psi else 0.2
1331
+ height = min(0.35, 0.06 + 0.035 * num_rows)
1332
+ elif mode == "fem":
1333
+ width = 0.60
1334
+ height = min(0.32, 0.06 + 0.035 * num_rows)
1335
+ elif mode == "seep":
1336
+ width = 0.45
1337
+ height = min(0.50, 0.10 + 0.06 * num_rows)
1338
+ else:
1339
+ raise ValueError(f"Unknown mode '{mode}'. Expected one of: 'lem', 'seep', 'fem'.")
1340
+
1341
+ return width, height
1342
+
1343
+ def _plot_table(ax, xloc, yloc):
1344
+ """Plot the appropriate material table based on mode."""
1345
+ if mode == "lem":
1346
+ plot_lem_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
1347
+ elif mode == "seep":
1348
+ plot_seep_material_table(ax, _build_seep_data(), xloc=xloc, yloc=yloc)
1349
+ elif mode == "fem":
1350
+ width, height = _estimate_table_dims()
1351
+ plot_fem_material_table(ax, _build_fem_data(), xloc=xloc, yloc=yloc, width=width, height=height)
1352
+
1353
+ def _calculate_position(location, margin=0.03):
1354
+ """Calculate xloc, yloc for a given location string."""
1355
+ width, height = _estimate_table_dims()
1356
+
1357
+ # Location map with default coordinates
1358
+ position_map = {
1359
+ 'upper left': (margin, max(0.0, 1.0 - margin - height)),
1360
+ 'upper right': (max(0.0, 1.0 - width - margin), max(0.0, 1.0 - margin - height)),
760
1361
  'upper center': (0.35, 0.70),
761
1362
  'lower left': (0.05, 0.05),
762
1363
  'lower right': (0.70, 0.05),
763
1364
  'lower center': (0.35, 0.05),
764
1365
  'center left': (0.05, 0.35),
765
1366
  'center right': (0.70, 0.35),
766
- 'center': (0.35, 0.35)
1367
+ 'center': (0.35, 0.35),
1368
+ 'top': ((1.0 - width) / 2.0, 1.16)
767
1369
  }
768
- if mat_table in location_map:
769
- xloc, yloc = location_map[mat_table]
770
- plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
771
- else:
772
- # Default to upper right if invalid location
773
- plot_material_table(ax, slope_data['materials'], xloc=0.75, yloc=0.75)
774
- else:
775
- # Auto-position or default: find best location
776
- plot_elements_bounds = get_plot_elements_bounds(ax, slope_data)
777
- xloc, yloc = find_best_table_position(ax, slope_data['materials'], plot_elements_bounds)
778
- plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
1370
+
1371
+ return position_map.get(location, position_map['upper right'])
1372
+
1373
+ # Determine which location to use
1374
+ placement = mat_table if isinstance(mat_table, str) and mat_table != 'auto' else tab_loc
1375
+
1376
+ # Validate placement
1377
+ valid_placements = ['upper left', 'upper right', 'upper center', 'lower left',
1378
+ 'lower right', 'lower center', 'center left', 'center right', 'center', 'top']
1379
+ if placement not in valid_placements:
1380
+ raise ValueError(f"Unknown placement '{placement}'. Expected one of: {', '.join(valid_placements)}.")
1381
+
1382
+ # Calculate position and plot table
1383
+ xloc, yloc = _calculate_position(placement)
1384
+ _plot_table(ax, xloc, yloc)
1385
+
1386
+ # Adjust y-limits to prevent table overlap with plot data
1387
+ if placement in ("upper left", "upper right", "upper center"):
1388
+ _, height = _estimate_table_dims()
1389
+ margin = 0.03
1390
+ bottom_fraction = max(0.0, 1.0 - margin - height)
1391
+
1392
+ y_min_curr, y_max_curr = ax.get_ylim()
1393
+ y_range = y_max_curr - y_min_curr
1394
+ if y_range > 0:
1395
+ elem_bounds = get_plot_elements_bounds(ax, slope_data)
1396
+ if elem_bounds:
1397
+ y_top = max(b[3] for b in elem_bounds)
1398
+ y_norm = (y_top - y_min_curr) / y_range
1399
+ if y_norm >= bottom_fraction and bottom_fraction > 0:
1400
+ y_max_new = y_min_curr + (y_top - y_min_curr) / bottom_fraction
1401
+ ax.set_ylim(y_min_curr, y_max_new)
779
1402
 
780
1403
  ax.set_aspect('equal') # ✅ Equal aspect
1404
+
1405
+ # Add a bit of headroom so plotted lines/markers don't touch the top border
1406
+ y0, y1 = ax.get_ylim()
1407
+ if y1 > y0:
1408
+ pad = 0.05 * (y1 - y0)
1409
+ ax.set_ylim(y0, y1 + pad)
781
1410
  ax.set_xlabel("x")
782
1411
  ax.set_ylabel("y")
783
1412
  ax.grid(False)
@@ -791,16 +1420,38 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
791
1420
  handles.append(dummy_line)
792
1421
  labels.append('Distributed Load')
793
1422
 
1423
+ # --- Legend layout ---
1424
+ # Historically this legend used ncol=2 and was anchored below the axes.
1425
+ # If there are many entries, that makes the legend tall and it can fall
1426
+ # off the bottom of the window. We auto-increase columns to cap row count,
1427
+ # and we also reserve bottom margin so the legend stays visible.
1428
+ n_items = len(labels)
1429
+ if legend_ncol == "auto":
1430
+ # Choose enough columns to keep row count <= legend_max_rows (as best we can),
1431
+ # but never exceed legend_max_cols.
1432
+ required_cols = max(1, math.ceil(n_items / max(1, int(legend_max_rows))))
1433
+ ncol = min(int(legend_max_cols), required_cols)
1434
+ # Keep at least 2 columns once there's more than one entry (matches prior look).
1435
+ if n_items > 1:
1436
+ ncol = max(2, ncol)
1437
+ else:
1438
+ ncol = max(1, int(legend_ncol))
1439
+
1440
+ n_rows = max(1, math.ceil(n_items / max(1, ncol)))
1441
+ # Reserve a bit more space as the legend grows so it doesn't get clipped.
1442
+ bottom_margin = min(0.45, 0.10 + 0.04 * n_rows)
1443
+
794
1444
  ax.legend(
795
1445
  handles=handles,
796
1446
  labels=labels,
797
- loc='upper center',
1447
+ loc="upper center",
798
1448
  bbox_to_anchor=(0.5, -0.12),
799
- ncol=2
1449
+ ncol=ncol,
800
1450
  )
801
1451
 
802
1452
  ax.set_title(title)
803
1453
 
1454
+ plt.subplots_adjust(bottom=bottom_margin)
804
1455
  plt.tight_layout()
805
1456
 
806
1457
  if save_png:
@@ -830,7 +1481,7 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
830
1481
  ax.set_ylabel("y")
831
1482
  ax.grid(False)
832
1483
 
833
- plot_profile_lines(ax, slope_data['profile_lines'])
1484
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
834
1485
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
835
1486
  plot_slices(ax, slice_df, fill=False)
836
1487
  plot_failure_surface(ax, failure_surface)
@@ -892,6 +1543,8 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
892
1543
  title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
893
1544
  elif method == 'lowe_karafiath':
894
1545
  title = f'Lowe & Karafiath: FS = {fs:.3f}'
1546
+ else:
1547
+ title = f'{method}: FS = {fs:.3f}'
895
1548
  ax.set_title(title)
896
1549
 
897
1550
  # zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
@@ -980,7 +1633,7 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
980
1633
  """
981
1634
  fig, ax = plt.subplots(figsize=figsize)
982
1635
 
983
- plot_profile_lines(ax, slope_data['profile_lines'])
1636
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
984
1637
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
985
1638
  plot_piezo_line(ax, slope_data)
986
1639
  plot_dloads(ax, slope_data)
@@ -1026,7 +1679,7 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
1026
1679
  fig, ax = plt.subplots(figsize=figsize)
1027
1680
 
1028
1681
  # Plot basic profile elements
1029
- plot_profile_lines(ax, slope_data['profile_lines'])
1682
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
1030
1683
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1031
1684
  plot_piezo_line(ax, slope_data)
1032
1685
  plot_dloads(ax, slope_data)
@@ -1093,7 +1746,7 @@ def plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save
1093
1746
  fig, ax = plt.subplots(figsize=figsize)
1094
1747
 
1095
1748
  # Plot basic slope elements (same as other search functions)
1096
- plot_profile_lines(ax, slope_data['profile_lines'])
1749
+ plot_profile_lines(ax, slope_data['profile_lines'], materials=slope_data.get('materials'))
1097
1750
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
1098
1751
  plot_piezo_line(ax, slope_data)
1099
1752
  plot_dloads(ax, slope_data)
@@ -1360,26 +2013,56 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
1360
2013
  plt.show()
1361
2014
 
1362
2015
 
1363
- def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=300):
2016
+ def plot_polygons(
2017
+ polygons,
2018
+ materials=None,
2019
+ nodes=False,
2020
+ legend=True,
2021
+ title="Material Zone Polygons",
2022
+ figsize=(10, 6),
2023
+ save_png=False,
2024
+ dpi=300,
2025
+ ):
1364
2026
  """
1365
2027
  Plot all material zone polygons in a single figure.
1366
2028
 
1367
2029
  Parameters:
1368
- polygons: List of polygon coordinate lists
2030
+ polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
2031
+ materials: Optional list of material dicts (with key "name") or list of material
2032
+ name strings. If provided, the material name will be used in the legend.
2033
+ nodes: If True, plot each polygon vertex as a dot.
2034
+ legend: If True, show the legend.
1369
2035
  title: Plot title
2036
+ figsize: Matplotlib figure size tuple, e.g. (10, 6)
1370
2037
  """
1371
2038
  import matplotlib.pyplot as plt
1372
2039
 
1373
- fig, ax = plt.subplots(figsize=(12, 8))
2040
+ fig, ax = plt.subplots(figsize=figsize)
1374
2041
  for i, polygon in enumerate(polygons):
1375
- xs = [x for x, y in polygon]
1376
- ys = [y for x, y in polygon]
1377
- ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
1378
- ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
2042
+ coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
2043
+ xs = [x for x, y in coords]
2044
+ ys = [y for x, y in coords]
2045
+ mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
2046
+ if mat_idx is None:
2047
+ mat_idx = i
2048
+ mat_name = None
2049
+ if materials is not None and 0 <= mat_idx < len(materials):
2050
+ item = materials[mat_idx]
2051
+ if isinstance(item, dict):
2052
+ mat_name = item.get("name", None)
2053
+ elif isinstance(item, str):
2054
+ mat_name = item
2055
+ label = mat_name if mat_name else f"Material {mat_idx}"
2056
+ ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=label)
2057
+ ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
2058
+ if nodes:
2059
+ # Avoid legend clutter by not adding a label here.
2060
+ ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3)
1379
2061
  ax.set_xlabel('X Coordinate')
1380
2062
  ax.set_ylabel('Y Coordinate')
1381
2063
  ax.set_title(title)
1382
- ax.legend()
2064
+ if legend:
2065
+ ax.legend()
1383
2066
  ax.grid(True, alpha=0.3)
1384
2067
  ax.set_aspect('equal')
1385
2068
  plt.tight_layout()
@@ -1391,35 +2074,51 @@ def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=
1391
2074
  plt.show()
1392
2075
 
1393
2076
 
1394
- def plot_polygons_separately(polygons, title_prefix='Material Zone', save_png=False, dpi=300):
2077
+ def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
1395
2078
  """
1396
2079
  Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
1397
2080
 
1398
2081
  Parameters:
1399
- polygons: List of polygon coordinate lists
1400
- title_prefix: Prefix for each subplot title
2082
+ polygons: List of polygon coordinate lists or dicts with "coords"/"mat_id"
2083
+ materials: Optional list of material dicts (with key "name") or list of material
2084
+ name strings. If provided, the material name will be included in each subplot title.
1401
2085
  """
1402
2086
  import matplotlib.pyplot as plt
1403
2087
 
1404
2088
  n = len(polygons)
1405
2089
  fig, axes = plt.subplots(n, 1, figsize=(8, 3 * n), squeeze=False)
1406
2090
  for i, polygon in enumerate(polygons):
1407
- xs = [x for x, y in polygon]
1408
- ys = [y for x, y in polygon]
2091
+ coords = polygon.get("coords", []) if isinstance(polygon, dict) else polygon
2092
+ xs = [x for x, y in coords]
2093
+ ys = [y for x, y in coords]
1409
2094
  ax = axes[i, 0]
1410
- ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
1411
- ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
2095
+ mat_idx = polygon.get("mat_id") if isinstance(polygon, dict) else i
2096
+ if mat_idx is None:
2097
+ mat_idx = i
2098
+ ax.fill(xs, ys, color=get_material_color(mat_idx), alpha=0.6, label=f'Material {mat_idx}')
2099
+ ax.plot(xs, ys, color=get_material_color(mat_idx), linewidth=1)
1412
2100
  ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
1413
2101
  ax.set_xlabel('X Coordinate')
1414
2102
  ax.set_ylabel('Y Coordinate')
1415
- ax.set_title(f'{title_prefix} {i}')
2103
+ mat_name = None
2104
+ if materials is not None and 0 <= mat_idx < len(materials):
2105
+ item = materials[mat_idx]
2106
+ if isinstance(item, dict):
2107
+ mat_name = item.get("name", None)
2108
+ elif isinstance(item, str):
2109
+ mat_name = item
2110
+ if mat_name:
2111
+ ax.set_title(f'Material {mat_idx}: {mat_name}')
2112
+ else:
2113
+ ax.set_title(f'Material {mat_idx}')
1416
2114
  ax.grid(True, alpha=0.3)
1417
2115
  ax.set_aspect('equal')
1418
- ax.legend()
2116
+ # Intentionally no legend: these plots are typically used for debugging geometry,
2117
+ # and legends can obscure key vertices/edges.
1419
2118
  plt.tight_layout()
1420
2119
 
1421
2120
  if save_png:
1422
- filename = 'plot_' + title_prefix.lower().replace(' ', '_').replace(':', '').replace(',', '') + '_separate.png'
2121
+ filename = 'plot_polygons_separately.png'
1423
2122
  plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1424
2123
 
1425
2124
  plt.show()
@@ -1500,8 +2199,9 @@ def get_plot_elements_bounds(ax, slope_data):
1500
2199
  if 'profile_lines' in slope_data:
1501
2200
  for line in slope_data['profile_lines']:
1502
2201
  if line:
1503
- xs = [p[0] for p in line]
1504
- ys = [p[1] for p in line]
2202
+ coords = line['coords']
2203
+ xs = [p[0] for p in coords]
2204
+ ys = [p[1] for p in coords]
1505
2205
  bounds.append((min(xs), max(xs), min(ys), max(ys)))
1506
2206
 
1507
2207
  # Distributed loads bounds