xslope 0.1.10__py3-none-any.whl → 0.1.12__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
  """
@@ -173,6 +175,26 @@ def plot_piezo_line(ax, slope_data):
173
175
  None
174
176
  """
175
177
 
178
+ def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=0.0):
179
+ """
180
+ Place an inverted triangle marker so its tip visually touches the line at (x, y).
181
+ We do this in display coordinates (points/pixels) so it scales consistently.
182
+ """
183
+ from matplotlib.markers import MarkerStyle
184
+ from matplotlib.transforms import offset_copy
185
+
186
+ # Compute the distance (in "marker units") from the marker origin to the tip.
187
+ # Matplotlib scales marker vertices by `markersize` (in points) for Line2D.
188
+ ms = MarkerStyle("v")
189
+ path = ms.get_path().transformed(ms.get_transform())
190
+ verts = np.asarray(path.vertices)
191
+ min_y = float(verts[:, 1].min()) # tip is the lowest y
192
+ tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
193
+
194
+ # Offset the marker center upward in point units so the tip lands at (x, y).
195
+ trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
196
+ ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
197
+
176
198
  def plot_single_piezo_line(ax, piezo_line, color, label):
177
199
  """Internal function to plot a single piezometric line"""
178
200
  if not piezo_line:
@@ -182,20 +204,146 @@ def plot_piezo_line(ax, slope_data):
182
204
  ax.plot(piezo_xs, piezo_ys, color=color, linewidth=2, label=label)
183
205
 
184
206
  # 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
207
  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)
208
+ # Sort by x to ensure monotonic input for interpolation
209
+ pairs = sorted(zip(piezo_xs, piezo_ys), key=lambda p: p[0])
210
+ sx, sy = zip(*pairs)
211
+ x_min, x_max = min(sx), max(sx)
212
+ mid_x = (x_min + x_max) / 2
213
+ mid_y = float(np.interp(mid_x, sx, sy))
214
+ # Slight negative gap so the marker visually "touches" the line (not floating above it)
215
+ _plot_touching_v_marker(ax, mid_x, mid_y, color=color, markersize=8, extra_gap_points=2.0)
194
216
 
195
217
  # Plot both piezometric lines
196
218
  plot_single_piezo_line(ax, slope_data.get('piezo_line'), 'b', "Piezometric Line")
197
219
  plot_single_piezo_line(ax, slope_data.get('piezo_line2'), 'skyblue', "Piezometric Line 2")
198
220
 
221
+ def plot_seepage_bc_lines(ax, slope_data):
222
+ """
223
+ Plots seepage boundary-condition lines for seepage-only workflows.
224
+
225
+ - Specified head geometry: solid dark blue, thicker than profile lines
226
+ - Exit face geometry: solid red
227
+ - Derived "water level" line for each specified head: y = h
228
+ plotted as a lighter blue solid line with an inverted triangle marker
229
+ (styled similarly to piezometric line markers).
230
+ """
231
+ def _plot_touching_v_marker(ax, x, y, color, markersize=8, extra_gap_points=2.0):
232
+ """Place an inverted triangle so its tip visually sits on the line at (x, y)."""
233
+ from matplotlib.markers import MarkerStyle
234
+ from matplotlib.transforms import offset_copy
235
+
236
+ ms = MarkerStyle("v")
237
+ path = ms.get_path().transformed(ms.get_transform())
238
+ verts = np.asarray(path.vertices)
239
+ min_y = float(verts[:, 1].min())
240
+ tip_offset_points = (-min_y) * float(markersize) + float(extra_gap_points)
241
+ trans = offset_copy(ax.transData, fig=ax.figure, x=0.0, y=tip_offset_points, units="points")
242
+ ax.plot([x], [y], marker="v", color=color, markersize=markersize, linestyle="None", transform=trans)
243
+
244
+ # Geometry x-extent (used for vertical-head-line derived segment length / side)
245
+ x_vals = []
246
+ for line in slope_data.get("profile_lines", []):
247
+ try:
248
+ xs_line, _ = zip(*line)
249
+ x_vals.extend(xs_line)
250
+ except Exception:
251
+ pass
252
+ gs = slope_data.get("ground_surface", None)
253
+ if not x_vals and gs is not None and hasattr(gs, "coords"):
254
+ x_vals.extend([x for x, _ in gs.coords])
255
+ x_min_geom = min(x_vals) if x_vals else 0.0
256
+ x_max_geom = max(x_vals) if x_vals else 1.0
257
+ geom_width = max(1e-9, x_max_geom - x_min_geom)
258
+
259
+ seepage_bc = slope_data.get("seepage_bc") or {}
260
+ specified_heads = seepage_bc.get("specified_heads") or []
261
+ exit_face = seepage_bc.get("exit_face") or []
262
+
263
+ # --- Specified head lines + derived water-level lines ---
264
+ for i, sh in enumerate(specified_heads):
265
+ coords = sh.get("coords") or []
266
+ if len(coords) < 2:
267
+ continue
268
+
269
+ xs, ys = zip(*coords)
270
+ ax.plot(
271
+ xs,
272
+ ys,
273
+ color="darkblue",
274
+ linewidth=3,
275
+ linestyle="--",
276
+ label="Specified Head Line" if i == 0 else "",
277
+ )
278
+
279
+ # Head values may be scalar (typical) or per-point array-like
280
+ head_val = sh.get("head", None)
281
+ if head_val is None:
282
+ continue
283
+
284
+ if isinstance(head_val, (list, tuple, np.ndarray)):
285
+ if len(head_val) != len(coords):
286
+ continue
287
+ heads = [float(h) for h in head_val]
288
+ else:
289
+ try:
290
+ head_scalar = float(head_val)
291
+ except (TypeError, ValueError):
292
+ continue
293
+ heads = [head_scalar] * len(coords)
294
+
295
+ # If specified-head geometry is vertical, draw a short horizontal derived line outside the boundary.
296
+ # This avoids drawing a (nearly) vertical derived line that doesn't convey a water level.
297
+ tol = 1e-6
298
+ is_vertical = (max(xs) - min(xs)) <= tol
299
+ if is_vertical:
300
+ x0 = float(xs[0])
301
+ y_head = float(heads[0])
302
+ seg_len = 0.04 * geom_width
303
+ gap = 0.01 * geom_width
304
+ is_right = x0 >= 0.5 * (x_min_geom + x_max_geom)
305
+ if is_right:
306
+ wl_xs = [x0 + gap, x0 + gap + seg_len]
307
+ else:
308
+ wl_xs = [x0 - gap - seg_len, x0 - gap]
309
+ wl_ys = [y_head, y_head]
310
+ else:
311
+ wl_xs = list(xs)
312
+ wl_ys = heads
313
+
314
+ ax.plot(
315
+ wl_xs,
316
+ wl_ys,
317
+ color="lightskyblue",
318
+ linewidth=2,
319
+ linestyle="-",
320
+ label="Specified Head Water Level" if i == 0 else "",
321
+ )
322
+
323
+ # Inverted triangle marker near the midpoint (like piezometric lines)
324
+ if len(wl_xs) > 1:
325
+ try:
326
+ pairs = sorted(zip(wl_xs, wl_ys), key=lambda p: p[0])
327
+ sx, sy = zip(*pairs)
328
+ mid_x = 0.5 * (min(sx) + max(sx))
329
+ mid_y = float(np.interp(mid_x, sx, sy))
330
+ _plot_touching_v_marker(ax, mid_x, mid_y, color="lightskyblue", markersize=8, extra_gap_points=2.0)
331
+ except Exception:
332
+ # If interpolation fails for any reason, skip marker
333
+ pass
334
+
335
+ # --- Exit face line ---
336
+ if len(exit_face) >= 2:
337
+ ex_xs, ex_ys = zip(*exit_face)
338
+ ax.plot(
339
+ ex_xs,
340
+ ex_ys,
341
+ color="red",
342
+ linewidth=3,
343
+ linestyle="--",
344
+ label="Exit Face",
345
+ )
346
+
199
347
  def plot_tcrack_surface(ax, tcrack_surface):
200
348
  """
201
349
  Plots the tension crack surface as a thin dashed red line.
@@ -381,6 +529,8 @@ def plot_circles(ax, slope_data):
381
529
  continue # or handle error
382
530
  # result = (x_min, x_max, y_left, y_right, clipped_surface)
383
531
  x_min, x_max, y_left, y_right, clipped_surface = result
532
+ if not isinstance(clipped_surface, LineString):
533
+ clipped_surface = LineString(clipped_surface)
384
534
  x_clip, y_clip = zip(*clipped_surface.coords)
385
535
  ax.plot(x_clip, y_clip, 'r--', label="Circle")
386
536
 
@@ -423,15 +573,28 @@ def plot_non_circ(ax, non_circ):
423
573
  xs, ys = zip(*non_circ)
424
574
  ax.plot(xs, ys, 'r--', label='Non-Circular Surface')
425
575
 
426
- def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
576
+ def plot_lem_material_table(ax, materials, xloc=0.6, yloc=0.7):
427
577
  """
428
- Adds a material properties table to the plot.
578
+ Adds a limit equilibrium material properties table to the plot.
579
+
580
+ Displays soil properties for limit equilibrium analysis including unit weight (γ),
581
+ cohesion (c), friction angle (φ), and optionally dilation angle (d) and
582
+ dilatancy angle (ψ). Supports both Mohr-Coulomb (mc) and constant-phi (cp) options.
429
583
 
430
584
  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)
585
+ ax: matplotlib Axes object to add the table to
586
+ materials: List of material property dictionaries with keys:
587
+ - 'name': Material name (str)
588
+ - 'gamma': Unit weight (float)
589
+ - 'option': Material model - 'mc' or 'cp' (str)
590
+ - 'c': Cohesion for mc option (float)
591
+ - 'phi': Friction angle for mc option (float)
592
+ - 'cp': Constant phi for cp option (float)
593
+ - 'r_elev': Reference elevation for cp option (float)
594
+ - 'd': Dilation angle, optional (float)
595
+ - 'psi': Dilatancy angle, optional (float)
596
+ xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
597
+ yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
435
598
 
436
599
  Returns:
437
600
  None
@@ -505,15 +668,239 @@ def plot_material_table(ax, materials, xloc=0.6, yloc=0.7):
505
668
  # Adjust table width based on number of columns
506
669
  table_width = 0.25 if has_d_psi else 0.2
507
670
 
671
+ # Choose table height based on number of materials (uniform across table types)
672
+ num_rows = max(1, len(materials))
673
+ table_height = 0.06 + 0.035 * num_rows # header + per-row estimate
674
+ table_height = min(0.35, table_height) # cap to avoid overflows for many rows
675
+
508
676
  # Add the table
509
677
  table = ax.table(cellText=table_data,
510
678
  colLabels=col_labels,
511
679
  loc='upper right',
512
680
  colLoc='center',
513
681
  cellLoc='center',
514
- bbox=[xloc, yloc, table_width, 0.25])
682
+ bbox=[xloc, yloc, table_width, table_height])
515
683
  table.auto_set_font_size(False)
516
684
  table.set_fontsize(8)
685
+ # Auto layout based on content (shared method for all table types)
686
+ auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
687
+
688
+ def plot_seep_material_table(ax, seep_data, xloc=0.6, yloc=0.7):
689
+ """
690
+ Adds a seepage material properties table to the plot.
691
+
692
+ Displays hydraulic properties for seepage analysis including hydraulic conductivities
693
+ (k₁, k₂), anisotropy angle, and unsaturated flow parameters (kr₀, h₀).
694
+
695
+ Parameters:
696
+ ax: matplotlib Axes object to add the table to
697
+ seep_data: Dictionary containing seepage material properties with keys:
698
+ - 'k1_by_mat': List of primary hydraulic conductivity values (float)
699
+ - 'k2_by_mat': List of secondary hydraulic conductivity values (float)
700
+ - 'angle_by_mat': List of anisotropy angles in degrees (float)
701
+ - 'kr0_by_mat': List of relative permeability at residual saturation (float)
702
+ - 'h0_by_mat': List of pressure head parameters (float)
703
+ - 'material_names': List of material names (str), optional
704
+ xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
705
+ yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
706
+
707
+ Returns:
708
+ None
709
+ """
710
+ k1_by_mat = seep_data.get("k1_by_mat")
711
+ k2_by_mat = seep_data.get("k2_by_mat")
712
+ angle_by_mat = seep_data.get("angle_by_mat")
713
+ kr0_by_mat = seep_data.get("kr0_by_mat")
714
+ h0_by_mat = seep_data.get("h0_by_mat")
715
+ material_names = seep_data.get("material_names", [])
716
+ if k1_by_mat is None or len(k1_by_mat) == 0:
717
+ return
718
+ col_labels = ["Mat", "Name", "k₁", "k₂", "Angle", "kr₀", "h₀"]
719
+ table_data = []
720
+ for idx in range(len(k1_by_mat)):
721
+ k1 = k1_by_mat[idx]
722
+ k2 = k2_by_mat[idx] if k2_by_mat is not None else 0.0
723
+ angle = angle_by_mat[idx] if angle_by_mat is not None else 0.0
724
+ kr0 = kr0_by_mat[idx] if kr0_by_mat is not None else 0.0
725
+ h0 = h0_by_mat[idx] if h0_by_mat is not None else 0.0
726
+ material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
727
+ row = [idx + 1, material_name, f"{k1:.3f}", f"{k2:.3f}", f"{angle:.1f}", f"{kr0:.4f}", f"{h0:.2f}"]
728
+ table_data.append(row)
729
+ # Dimensions
730
+ num_rows = max(1, len(k1_by_mat))
731
+ table_width = 0.45
732
+ table_height = 0.10 + 0.06 * num_rows
733
+ table_height = min(0.50, table_height)
734
+ table = ax.table(cellText=table_data,
735
+ colLabels=col_labels,
736
+ loc='upper right',
737
+ colLoc='center',
738
+ cellLoc='center',
739
+ bbox=[xloc, yloc, table_width, table_height])
740
+ table.auto_set_font_size(False)
741
+ table.set_fontsize(8)
742
+ auto_size_table_to_content(ax, table, col_labels, table_data, table_width, table_height)
743
+
744
+ def plot_fem_material_table(ax, fem_data, xloc=0.6, yloc=0.7, width=0.6, height=None):
745
+ """
746
+ Adds a finite element material properties table to the plot.
747
+
748
+ Displays material properties for FEM analysis including unit weight (γ), cohesion (c),
749
+ friction angle (φ), Young's modulus (E), and Poisson's ratio (ν).
750
+
751
+ Parameters:
752
+ ax: matplotlib Axes object to add the table to
753
+ fem_data: Dictionary containing FEM material properties with keys:
754
+ - 'c_by_mat': List of cohesion values (float)
755
+ - 'phi_by_mat': List of friction angle values in degrees (float)
756
+ - 'E_by_mat': List of Young's modulus values (float)
757
+ - 'nu_by_mat': List of Poisson's ratio values (float)
758
+ - 'gamma_by_mat': List of unit weight values (float)
759
+ - 'material_names': List of material names (str), optional
760
+ xloc: x-location of table bottom-left corner in axes coordinates (0-1, default: 0.6)
761
+ yloc: y-location of table bottom-left corner in axes coordinates (0-1, default: 0.7)
762
+ width: Table width in axes coordinates (0-1, default: 0.6)
763
+ height: Table height in axes coordinates (0-1, default: auto-calculated)
764
+
765
+ Returns:
766
+ None
767
+ """
768
+ c_by_mat = fem_data.get("c_by_mat")
769
+ phi_by_mat = fem_data.get("phi_by_mat")
770
+ E_by_mat = fem_data.get("E_by_mat")
771
+ nu_by_mat = fem_data.get("nu_by_mat")
772
+ gamma_by_mat = fem_data.get("gamma_by_mat")
773
+ material_names = fem_data.get("material_names", [])
774
+ if c_by_mat is None or len(c_by_mat) == 0:
775
+ return
776
+ col_labels = ["Mat", "Name", "γ", "c", "φ", "E", "ν"]
777
+ table_data = []
778
+ for idx in range(len(c_by_mat)):
779
+ c = c_by_mat[idx]
780
+ phi = phi_by_mat[idx] if phi_by_mat is not None else 0.0
781
+ E = E_by_mat[idx] if E_by_mat is not None else 0.0
782
+ nu = nu_by_mat[idx] if nu_by_mat is not None else 0.0
783
+ gamma = gamma_by_mat[idx] if gamma_by_mat is not None else 0.0
784
+ material_name = material_names[idx] if idx < len(material_names) else f"Material {idx+1}"
785
+ row = [idx + 1, material_name, f"{gamma:.1f}", f"{c:.1f}", f"{phi:.1f}", f"{E:.0f}", f"{nu:.2f}"]
786
+ table_data.append(row)
787
+ if height is None:
788
+ num_rows = max(1, len(c_by_mat))
789
+ height = 0.06 + 0.035 * num_rows
790
+ height = min(0.32, height)
791
+ table = ax.table(cellText=table_data,
792
+ colLabels=col_labels,
793
+ loc='upper right',
794
+ colLoc='center',
795
+ cellLoc='center',
796
+ bbox=[xloc, yloc, width, height])
797
+ table.auto_set_font_size(False)
798
+ table.set_fontsize(8)
799
+ auto_size_table_to_content(ax, table, col_labels, table_data, width, height)
800
+ 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):
801
+ """
802
+ Automatically adjusts table column widths and row heights based on content.
803
+
804
+ Measures text extents using the matplotlib renderer and sets column widths proportional
805
+ to content while enforcing minimum and maximum constraints. The "Name" column gets
806
+ more space (18-30%) while numeric columns are constrained to prevent excessive whitespace.
807
+ Row heights are uniform and based on the tallest content in each row.
808
+
809
+ Parameters:
810
+ ax: matplotlib Axes object containing the table
811
+ table: matplotlib Table object to be resized
812
+ col_labels: List of column header labels (str)
813
+ table_data: List of lists containing table cell data
814
+ table_width: Total table width in axes coordinates (0-1)
815
+ table_height: Total table height in axes coordinates (0-1)
816
+ min_row_frac: Minimum row height as fraction of axes height (default: 0.02)
817
+ row_pad: Padding factor applied to measured row heights (default: 1.35)
818
+ col_min_frac: Minimum column width as fraction of table width for numeric columns (default: 0.08)
819
+ col_max_frac: Maximum column width as fraction of table width for numeric columns (default: 0.15)
820
+
821
+ Returns:
822
+ None
823
+
824
+ Notes:
825
+ - The "Name" column is automatically left-aligned and gets 18-30% of table width
826
+ - Numeric columns are center-aligned and constrained to 8-15% of table width
827
+ - All three material table types (LEM, SEEP, FEM) use the same sizing parameters
828
+ """
829
+ # Force draw to get a valid renderer
830
+ try:
831
+ ax.figure.canvas.draw()
832
+ renderer = ax.figure.canvas.get_renderer()
833
+ except Exception:
834
+ renderer = None
835
+
836
+ ncols = len(col_labels)
837
+ nrows = len(table_data) + 1 # include header
838
+ # Measure text widths per column in pixels
839
+ widths_px = [1.0] * ncols
840
+ if renderer is not None:
841
+ for c in range(ncols):
842
+ max_w = 1.0
843
+ for r in range(nrows):
844
+ cell = table[(r, c)]
845
+ text = cell.get_text()
846
+ try:
847
+ bbox = text.get_window_extent(renderer=renderer)
848
+ max_w = max(max_w, bbox.width)
849
+ except Exception:
850
+ pass
851
+ widths_px[c] = max_w
852
+ total_w = sum(widths_px) if sum(widths_px) > 0 else float(ncols)
853
+ col_fracs = [w / total_w for w in widths_px]
854
+ # Clamp extreme column widths to keep numeric columns from becoming too wide
855
+ clamped = []
856
+ for i, frac in enumerate(col_fracs):
857
+ label = str(col_labels[i]).lower()
858
+ min_frac = col_min_frac
859
+ max_frac = col_max_frac
860
+ if label == "name":
861
+ min_frac = 0.18
862
+ max_frac = 0.30
863
+ clamped.append(min(max(frac, min_frac), max_frac))
864
+ # Re-normalize to sum to 1.0
865
+ s = sum(clamped)
866
+ if s > 0:
867
+ col_fracs = [c / s for c in clamped]
868
+ # Compute per-row pixel heights based on text extents, convert to axes fraction
869
+ axes_h_px = None
870
+ if renderer is not None:
871
+ try:
872
+ axes_h_px = ax.get_window_extent(renderer=renderer).height
873
+ except Exception:
874
+ axes_h_px = None
875
+ # Fallback axes height if needed (avoid division by zero)
876
+ if not axes_h_px or axes_h_px <= 0:
877
+ axes_h_px = 800.0 # arbitrary but reasonable default
878
+ row_heights_frac = []
879
+ for r in range(nrows):
880
+ max_h_px = 1.0
881
+ if renderer is not None:
882
+ for c in range(ncols):
883
+ try:
884
+ bbox = table[(r, c)].get_text().get_window_extent(renderer=renderer)
885
+ max_h_px = max(max_h_px, bbox.height)
886
+ except Exception:
887
+ pass
888
+ # padding factor to provide breathing room around text
889
+ padded_px = max_h_px * row_pad
890
+ # Convert to axes fraction with minimum clamp
891
+ rh = max(padded_px / axes_h_px, min_row_frac)
892
+ row_heights_frac.append(rh)
893
+
894
+ # Apply column widths and per-row heights
895
+ for r in range(nrows):
896
+ for c in range(ncols):
897
+ cell = table[(r, c)]
898
+ cell.set_width(table_width * col_fracs[c])
899
+ cell.set_height(row_heights_frac[r])
900
+ # Left-align the "Name" column if present
901
+ label = str(col_labels[c]).lower()
902
+ if label == "name":
903
+ cell.get_text().set_ha('left')
517
904
 
518
905
  def plot_base_stresses(ax, slice_df, scale_frac=0.5, alpha=0.3):
519
906
  """
@@ -717,31 +1104,65 @@ def plot_reinforcement_lines(ax, slope_data):
717
1104
  tension_points_plotted = True
718
1105
 
719
1106
 
720
- def plot_inputs(slope_data, title="Slope Geometry and Inputs", width=12, height=6, mat_table=True):
1107
+ def plot_inputs(
1108
+ slope_data,
1109
+ title="Slope Geometry and Inputs",
1110
+ figsize=(12, 6),
1111
+ mat_table=True,
1112
+ save_png=False,
1113
+ dpi=300,
1114
+ mode="lem",
1115
+ tab_loc="upper left",
1116
+ legend_ncol="auto",
1117
+ legend_max_cols=6,
1118
+ legend_max_rows=4,
1119
+ ):
721
1120
  """
722
1121
  Creates a plot showing the slope geometry and input parameters.
723
1122
 
724
1123
  Parameters:
725
1124
  slope_data: Dictionary containing plot data
726
1125
  title: Title for the plot
727
- width: Width of the plot in inches
728
- height: Height of the plot in inches
1126
+ figsize: Tuple of (width, height) in inches for the plot
729
1127
  mat_table: Controls material table display. Can be:
730
- - True: Auto-position material table to avoid overlaps
1128
+ - True: Use tab_loc for positioning (default)
731
1129
  - False: Don't show material table
732
- - 'auto': Auto-position material table to avoid overlaps
733
- - String: Specific location for material table ('upper left', 'upper right', 'upper center',
734
- 'lower left', 'lower right', 'lower center', 'center left', 'center right', 'center')
1130
+ - 'auto': Use tab_loc for positioning
1131
+ - String: Specific location from valid placements (see tab_loc)
1132
+ save_png: If True, save plot as PNG file (default: False)
1133
+ dpi: Resolution for saved PNG file (default: 300)
1134
+ mode: Which material properties table to display:
1135
+ - "lem": Limit equilibrium materials (γ, c, φ, optional d/ψ)
1136
+ - "seep": Seepage properties (k₁, k₂, Angle, kr₀, h₀)
1137
+ - "fem": FEM properties (γ, c, φ, E, ν)
1138
+ tab_loc: Table placement when mat_table is True or 'auto'. Valid options:
1139
+ - "upper left": Top-left corner of plot area
1140
+ - "upper right": Top-right corner of plot area
1141
+ - "upper center": Top-center of plot area
1142
+ - "lower left": Bottom-left corner of plot area
1143
+ - "lower right": Bottom-right corner of plot area
1144
+ - "lower center": Bottom-center of plot area
1145
+ - "center left": Middle-left of plot area
1146
+ - "center right": Middle-right of plot area
1147
+ - "center": Center of plot area
1148
+ - "top": Above plot area, horizontally centered
1149
+ legend_ncol: Legend column count. Use "auto" (default) to choose a value that
1150
+ keeps the legend from getting too tall, or pass an int to force a width.
1151
+ legend_max_cols: When legend_ncol="auto", cap the number of columns (default: 6).
1152
+ legend_max_rows: When legend_ncol="auto", try to keep legend rows <= this value
1153
+ by increasing columns (default: 4).
735
1154
 
736
1155
  Returns:
737
1156
  None
738
1157
  """
739
- fig, ax = plt.subplots(figsize=(width, height))
1158
+ fig, ax = plt.subplots(figsize=figsize)
740
1159
 
741
1160
  # Plot contents
742
1161
  plot_profile_lines(ax, slope_data['profile_lines'])
743
1162
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
744
1163
  plot_piezo_line(ax, slope_data)
1164
+ if mode == "seep":
1165
+ plot_seepage_bc_lines(ax, slope_data)
745
1166
  plot_dloads(ax, slope_data)
746
1167
  plot_tcrack_surface(ax, slope_data['tcrack_surface'])
747
1168
  plot_reinforcement_lines(ax, slope_data)
@@ -753,32 +1174,116 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", width=12, height=
753
1174
 
754
1175
  # Handle material table display
755
1176
  if mat_table:
756
- if isinstance(mat_table, str) and mat_table != 'auto':
757
- # Convert location string to xloc, yloc coordinates (inside plot area with margins)
758
- location_map = {
759
- 'upper left': (0.05, 0.70),
760
- 'upper right': (0.70, 0.70),
1177
+ # Helpers to adapt slope_data materials into formats expected by table functions
1178
+ def _build_seep_data():
1179
+ materials = slope_data.get('materials', [])
1180
+ return {
1181
+ "k1_by_mat": [m.get('k1', 0.0) for m in materials],
1182
+ "k2_by_mat": [m.get('k2', 0.0) for m in materials],
1183
+ "angle_by_mat": [m.get('alpha', 0.0) for m in materials],
1184
+ "kr0_by_mat": [m.get('kr0', 0.0) for m in materials],
1185
+ "h0_by_mat": [m.get('h0', 0.0) for m in materials],
1186
+ "material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
1187
+ }
1188
+
1189
+ def _build_fem_data():
1190
+ materials = slope_data.get('materials', [])
1191
+ return {
1192
+ "c_by_mat": [m.get('c', 0.0) for m in materials],
1193
+ "phi_by_mat": [m.get('phi', 0.0) for m in materials],
1194
+ "E_by_mat": [m.get('E', 0.0) for m in materials],
1195
+ "nu_by_mat": [m.get('nu', 0.0) for m in materials],
1196
+ "gamma_by_mat": [m.get('gamma', 0.0) for m in materials],
1197
+ "material_names": [m.get('name', f"Material {i+1}") for i, m in enumerate(materials)],
1198
+ }
1199
+
1200
+ def _estimate_table_dims():
1201
+ """Estimate table dimensions based on mode and materials."""
1202
+ materials = slope_data.get('materials', [])
1203
+ num_rows = max(1, len(materials))
1204
+
1205
+ if mode == "lem":
1206
+ has_d_psi = any(mat.get('d', 0) > 0 or mat.get('psi', 0) > 0 for mat in materials)
1207
+ width = 0.25 if has_d_psi else 0.2
1208
+ height = min(0.35, 0.06 + 0.035 * num_rows)
1209
+ elif mode == "fem":
1210
+ width = 0.60
1211
+ height = min(0.32, 0.06 + 0.035 * num_rows)
1212
+ elif mode == "seep":
1213
+ width = 0.45
1214
+ height = min(0.50, 0.10 + 0.06 * num_rows)
1215
+ else:
1216
+ raise ValueError(f"Unknown mode '{mode}'. Expected one of: 'lem', 'seep', 'fem'.")
1217
+
1218
+ return width, height
1219
+
1220
+ def _plot_table(ax, xloc, yloc):
1221
+ """Plot the appropriate material table based on mode."""
1222
+ if mode == "lem":
1223
+ plot_lem_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
1224
+ elif mode == "seep":
1225
+ plot_seep_material_table(ax, _build_seep_data(), xloc=xloc, yloc=yloc)
1226
+ elif mode == "fem":
1227
+ width, height = _estimate_table_dims()
1228
+ plot_fem_material_table(ax, _build_fem_data(), xloc=xloc, yloc=yloc, width=width, height=height)
1229
+
1230
+ def _calculate_position(location, margin=0.03):
1231
+ """Calculate xloc, yloc for a given location string."""
1232
+ width, height = _estimate_table_dims()
1233
+
1234
+ # Location map with default coordinates
1235
+ position_map = {
1236
+ 'upper left': (margin, max(0.0, 1.0 - margin - height)),
1237
+ 'upper right': (max(0.0, 1.0 - width - margin), max(0.0, 1.0 - margin - height)),
761
1238
  'upper center': (0.35, 0.70),
762
1239
  'lower left': (0.05, 0.05),
763
1240
  'lower right': (0.70, 0.05),
764
1241
  'lower center': (0.35, 0.05),
765
1242
  'center left': (0.05, 0.35),
766
1243
  'center right': (0.70, 0.35),
767
- 'center': (0.35, 0.35)
1244
+ 'center': (0.35, 0.35),
1245
+ 'top': ((1.0 - width) / 2.0, 1.16)
768
1246
  }
769
- if mat_table in location_map:
770
- xloc, yloc = location_map[mat_table]
771
- plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
772
- else:
773
- # Default to upper right if invalid location
774
- plot_material_table(ax, slope_data['materials'], xloc=0.75, yloc=0.75)
775
- else:
776
- # Auto-position or default: find best location
777
- plot_elements_bounds = get_plot_elements_bounds(ax, slope_data)
778
- xloc, yloc = find_best_table_position(ax, slope_data['materials'], plot_elements_bounds)
779
- plot_material_table(ax, slope_data['materials'], xloc=xloc, yloc=yloc)
1247
+
1248
+ return position_map.get(location, position_map['upper right'])
1249
+
1250
+ # Determine which location to use
1251
+ placement = mat_table if isinstance(mat_table, str) and mat_table != 'auto' else tab_loc
1252
+
1253
+ # Validate placement
1254
+ valid_placements = ['upper left', 'upper right', 'upper center', 'lower left',
1255
+ 'lower right', 'lower center', 'center left', 'center right', 'center', 'top']
1256
+ if placement not in valid_placements:
1257
+ raise ValueError(f"Unknown placement '{placement}'. Expected one of: {', '.join(valid_placements)}.")
1258
+
1259
+ # Calculate position and plot table
1260
+ xloc, yloc = _calculate_position(placement)
1261
+ _plot_table(ax, xloc, yloc)
1262
+
1263
+ # Adjust y-limits to prevent table overlap with plot data
1264
+ if placement in ("upper left", "upper right", "upper center"):
1265
+ _, height = _estimate_table_dims()
1266
+ margin = 0.03
1267
+ bottom_fraction = max(0.0, 1.0 - margin - height)
1268
+
1269
+ y_min_curr, y_max_curr = ax.get_ylim()
1270
+ y_range = y_max_curr - y_min_curr
1271
+ if y_range > 0:
1272
+ elem_bounds = get_plot_elements_bounds(ax, slope_data)
1273
+ if elem_bounds:
1274
+ y_top = max(b[3] for b in elem_bounds)
1275
+ y_norm = (y_top - y_min_curr) / y_range
1276
+ if y_norm >= bottom_fraction and bottom_fraction > 0:
1277
+ y_max_new = y_min_curr + (y_top - y_min_curr) / bottom_fraction
1278
+ ax.set_ylim(y_min_curr, y_max_new)
780
1279
 
781
1280
  ax.set_aspect('equal') # ✅ Equal aspect
1281
+
1282
+ # Add a bit of headroom so plotted lines/markers don't touch the top border
1283
+ y0, y1 = ax.get_ylim()
1284
+ if y1 > y0:
1285
+ pad = 0.05 * (y1 - y0)
1286
+ ax.set_ylim(y0, y1 + pad)
782
1287
  ax.set_xlabel("x")
783
1288
  ax.set_ylabel("y")
784
1289
  ax.grid(False)
@@ -792,22 +1297,49 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", width=12, height=
792
1297
  handles.append(dummy_line)
793
1298
  labels.append('Distributed Load')
794
1299
 
1300
+ # --- Legend layout ---
1301
+ # Historically this legend used ncol=2 and was anchored below the axes.
1302
+ # If there are many entries, that makes the legend tall and it can fall
1303
+ # off the bottom of the window. We auto-increase columns to cap row count,
1304
+ # and we also reserve bottom margin so the legend stays visible.
1305
+ n_items = len(labels)
1306
+ if legend_ncol == "auto":
1307
+ # Choose enough columns to keep row count <= legend_max_rows (as best we can),
1308
+ # but never exceed legend_max_cols.
1309
+ required_cols = max(1, math.ceil(n_items / max(1, int(legend_max_rows))))
1310
+ ncol = min(int(legend_max_cols), required_cols)
1311
+ # Keep at least 2 columns once there's more than one entry (matches prior look).
1312
+ if n_items > 1:
1313
+ ncol = max(2, ncol)
1314
+ else:
1315
+ ncol = max(1, int(legend_ncol))
1316
+
1317
+ n_rows = max(1, math.ceil(n_items / max(1, ncol)))
1318
+ # Reserve a bit more space as the legend grows so it doesn't get clipped.
1319
+ bottom_margin = min(0.45, 0.10 + 0.04 * n_rows)
1320
+
795
1321
  ax.legend(
796
1322
  handles=handles,
797
1323
  labels=labels,
798
- loc='upper center',
1324
+ loc="upper center",
799
1325
  bbox_to_anchor=(0.5, -0.12),
800
- ncol=2
1326
+ ncol=ncol,
801
1327
  )
802
1328
 
803
1329
  ax.set_title(title)
804
1330
 
1331
+ plt.subplots_adjust(bottom=bottom_margin)
805
1332
  plt.tight_layout()
1333
+
1334
+ if save_png:
1335
+ filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '') + '.png'
1336
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1337
+
806
1338
  plt.show()
807
1339
 
808
1340
  # ========== Main Plotting Function =========
809
1341
 
810
- def plot_solution(slope_data, slice_df, failure_surface, results, width=12, height=7, slice_numbers=False):
1342
+ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7), slice_numbers=False, save_png=False, dpi=300):
811
1343
  """
812
1344
  Plots the full solution including slices, numbers, thrust line, and base stresses.
813
1345
 
@@ -816,13 +1348,12 @@ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, heig
816
1348
  slice_df: DataFrame containing slice data
817
1349
  failure_surface: Failure surface geometry
818
1350
  results: Solution results
819
- width: Width of the plot in inches
820
- height: Height of the plot in inches
1351
+ figsize: Tuple of (width, height) in inches for the plot
821
1352
 
822
1353
  Returns:
823
1354
  None
824
1355
  """
825
- fig, ax = plt.subplots(figsize=(width, height))
1356
+ fig, ax = plt.subplots(figsize=figsize)
826
1357
  ax.set_xlabel("x")
827
1358
  ax.set_ylabel("y")
828
1359
  ax.grid(False)
@@ -889,6 +1420,8 @@ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, heig
889
1420
  title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
890
1421
  elif method == 'lowe_karafiath':
891
1422
  title = f'Lowe & Karafiath: FS = {fs:.3f}'
1423
+ else:
1424
+ title = f'{method}: FS = {fs:.3f}'
892
1425
  ax.set_title(title)
893
1426
 
894
1427
  # zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
@@ -896,6 +1429,11 @@ def plot_solution(slope_data, slice_df, failure_surface, results, width=12, heig
896
1429
  ax.set_ylim(ymin, ymax)
897
1430
 
898
1431
  plt.tight_layout()
1432
+
1433
+ if save_png:
1434
+ filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '').replace('°', 'deg') + '.png'
1435
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1436
+
899
1437
  plt.show()
900
1438
 
901
1439
  # ========== Functions for Search Results =========
@@ -956,7 +1494,7 @@ def plot_search_path(ax, search_path):
956
1494
  ax.arrow(start['x'], start['y'], dx, dy,
957
1495
  head_width=1, head_length=2, fc='green', ec='green', length_includes_head=True)
958
1496
 
959
- def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, width=12, height=7):
1497
+ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300):
960
1498
  """
961
1499
  Creates a plot showing the results of a circular failure surface search.
962
1500
 
@@ -965,13 +1503,12 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
965
1503
  fs_cache: List of dictionaries containing failure surface data and FS values
966
1504
  search_path: List of dictionaries containing search path coordinates
967
1505
  highlight_fs: Boolean indicating whether to highlight the critical failure surface
968
- width: Width of the plot in inches
969
- height: Height of the plot in inches
1506
+ figsize: Tuple of (width, height) in inches for the plot
970
1507
 
971
1508
  Returns:
972
1509
  None
973
1510
  """
974
- fig, ax = plt.subplots(figsize=(width, height))
1511
+ fig, ax = plt.subplots(figsize=figsize)
975
1512
 
976
1513
  plot_profile_lines(ax, slope_data['profile_lines'])
977
1514
  plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
@@ -995,9 +1532,14 @@ def plot_circular_search_results(slope_data, fs_cache, search_path=None, highlig
995
1532
  ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")
996
1533
 
997
1534
  plt.tight_layout()
1535
+
1536
+ if save_png:
1537
+ filename = 'plot_circular_search_results.png'
1538
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1539
+
998
1540
  plt.show()
999
1541
 
1000
- def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, width=12, height=7):
1542
+ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, highlight_fs=True, figsize=(12, 7), save_png=False, dpi=300):
1001
1543
  """
1002
1544
  Creates a plot showing the results of a non-circular failure surface search.
1003
1545
 
@@ -1006,13 +1548,12 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
1006
1548
  fs_cache: List of dictionaries containing failure surface data and FS values
1007
1549
  search_path: List of dictionaries containing search path coordinates
1008
1550
  highlight_fs: Boolean indicating whether to highlight the critical failure surface
1009
- width: Width of the plot in inches
1010
- height: Height of the plot in inches
1551
+ figsize: Tuple of (width, height) in inches for the plot
1011
1552
 
1012
1553
  Returns:
1013
1554
  None
1014
1555
  """
1015
- fig, ax = plt.subplots(figsize=(width, height))
1556
+ fig, ax = plt.subplots(figsize=figsize)
1016
1557
 
1017
1558
  # Plot basic profile elements
1018
1559
  plot_profile_lines(ax, slope_data['profile_lines'])
@@ -1060,22 +1601,26 @@ def plot_noncircular_search_results(slope_data, fs_cache, search_path=None, high
1060
1601
  ax.set_title(f"Critical Factor of Safety = {critical_fs:.3f}")
1061
1602
 
1062
1603
  plt.tight_layout()
1604
+
1605
+ if save_png:
1606
+ filename = 'plot_noncircular_search_results.png'
1607
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1608
+
1063
1609
  plt.show()
1064
1610
 
1065
- def plot_reliability_results(slope_data, reliability_data, width=12, height=7):
1611
+ def plot_reliability_results(slope_data, reliability_data, figsize=(12, 7), save_png=False, dpi=300):
1066
1612
  """
1067
1613
  Creates a plot showing the results of reliability analysis.
1068
1614
 
1069
1615
  Parameters:
1070
1616
  slope_data: Dictionary containing plot data
1071
1617
  reliability_data: Dictionary containing reliability analysis results
1072
- width: Width of the plot in inches
1073
- height: Height of the plot in inches
1618
+ figsize: Tuple of (width, height) in inches for the plot
1074
1619
 
1075
1620
  Returns:
1076
1621
  None
1077
1622
  """
1078
- fig, ax = plt.subplots(figsize=(width, height))
1623
+ fig, ax = plt.subplots(figsize=figsize)
1079
1624
 
1080
1625
  # Plot basic slope elements (same as other search functions)
1081
1626
  plot_profile_lines(ax, slope_data['profile_lines'])
@@ -1143,9 +1688,14 @@ def plot_reliability_results(slope_data, reliability_data, width=12, height=7):
1143
1688
  f"Reliability = {reliability*100:.2f}%, $P_f$ = {prob_failure*100:.2f}%")
1144
1689
 
1145
1690
  plt.tight_layout()
1691
+
1692
+ if save_png:
1693
+ filename = 'plot_reliability_results.png'
1694
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1695
+
1146
1696
  plt.show()
1147
1697
 
1148
- def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=True, label_elements=False, label_nodes=False):
1698
+ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=True, label_elements=False, label_nodes=False, save_png=False, dpi=300):
1149
1699
  """
1150
1700
  Plot the finite element mesh with material regions.
1151
1701
 
@@ -1332,15 +1882,24 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
1332
1882
  ax.set_ylim(y_min - y_pad, y_max + y_pad)
1333
1883
 
1334
1884
  plt.tight_layout()
1885
+
1886
+ if save_png:
1887
+ filename = 'plot_mesh.png'
1888
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1889
+
1335
1890
  plt.show()
1336
1891
 
1337
1892
 
1338
- def plot_polygons(polygons, title="Material Zone Polygons"):
1893
+ def plot_polygons(polygons, materials=None, nodes=False, legend=True, title="Material Zone Polygons", save_png=False, dpi=300):
1339
1894
  """
1340
1895
  Plot all material zone polygons in a single figure.
1341
1896
 
1342
1897
  Parameters:
1343
1898
  polygons: List of polygon coordinate lists
1899
+ materials: Optional list of material dicts (with key "name") or list of material
1900
+ name strings. If provided, the material name will be used in the legend.
1901
+ nodes: If True, plot each polygon vertex as a dot.
1902
+ legend: If True, show the legend.
1344
1903
  title: Plot title
1345
1904
  """
1346
1905
  import matplotlib.pyplot as plt
@@ -1349,25 +1908,43 @@ def plot_polygons(polygons, title="Material Zone Polygons"):
1349
1908
  for i, polygon in enumerate(polygons):
1350
1909
  xs = [x for x, y in polygon]
1351
1910
  ys = [y for x, y in polygon]
1352
- ax.fill(xs, ys, color=get_material_color(i), alpha=0.6, label=f'Material {i}')
1911
+ mat_name = None
1912
+ if materials is not None and i < len(materials):
1913
+ item = materials[i]
1914
+ if isinstance(item, dict):
1915
+ mat_name = item.get("name", None)
1916
+ elif isinstance(item, str):
1917
+ 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)
1353
1920
  ax.plot(xs, ys, color=get_material_color(i), linewidth=1)
1921
+ if nodes:
1922
+ # Avoid legend clutter by not adding a label here.
1923
+ ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3)
1354
1924
  ax.set_xlabel('X Coordinate')
1355
1925
  ax.set_ylabel('Y Coordinate')
1356
1926
  ax.set_title(title)
1357
- ax.legend()
1927
+ if legend:
1928
+ ax.legend()
1358
1929
  ax.grid(True, alpha=0.3)
1359
1930
  ax.set_aspect('equal')
1360
1931
  plt.tight_layout()
1932
+
1933
+ if save_png:
1934
+ filename = 'plot_' + title.lower().replace(' ', '_').replace(':', '').replace(',', '') + '.png'
1935
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1936
+
1361
1937
  plt.show()
1362
1938
 
1363
1939
 
1364
- def plot_polygons_separately(polygons, title_prefix='Material Zone'):
1940
+ def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
1365
1941
  """
1366
1942
  Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
1367
1943
 
1368
1944
  Parameters:
1369
1945
  polygons: List of polygon coordinate lists
1370
- title_prefix: Prefix for each subplot title
1946
+ materials: Optional list of material dicts (with key "name") or list of material
1947
+ name strings. If provided, the material name will be included in each subplot title.
1371
1948
  """
1372
1949
  import matplotlib.pyplot as plt
1373
1950
 
@@ -1382,11 +1959,27 @@ def plot_polygons_separately(polygons, title_prefix='Material Zone'):
1382
1959
  ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
1383
1960
  ax.set_xlabel('X Coordinate')
1384
1961
  ax.set_ylabel('Y Coordinate')
1385
- ax.set_title(f'{title_prefix} {i}')
1962
+ mat_name = None
1963
+ if materials is not None and i < len(materials):
1964
+ item = materials[i]
1965
+ if isinstance(item, dict):
1966
+ mat_name = item.get("name", None)
1967
+ elif isinstance(item, str):
1968
+ mat_name = item
1969
+ if mat_name:
1970
+ ax.set_title(f'Material {i}: {mat_name}')
1971
+ else:
1972
+ ax.set_title(f'Material {i}')
1386
1973
  ax.grid(True, alpha=0.3)
1387
1974
  ax.set_aspect('equal')
1388
- ax.legend()
1975
+ # Intentionally no legend: these plots are typically used for debugging geometry,
1976
+ # and legends can obscure key vertices/edges.
1389
1977
  plt.tight_layout()
1978
+
1979
+ if save_png:
1980
+ filename = 'plot_polygons_separately.png'
1981
+ plt.savefig(filename, dpi=dpi, bbox_inches='tight')
1982
+
1390
1983
  plt.show()
1391
1984
 
1392
1985