xslope 0.1.11__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/_version.py +1 -1
- xslope/fileio.py +130 -60
- xslope/mesh.py +109 -4
- xslope/plot.py +607 -49
- xslope/plot_fem.py +1 -69
- xslope/plot_seep.py +10 -68
- xslope/seep.py +11 -6
- {xslope-0.1.11.dist-info → xslope-0.1.12.dist-info}/METADATA +1 -1
- xslope-0.1.12.dist-info/RECORD +21 -0
- xslope-0.1.11.dist-info/RECORD +0 -21
- {xslope-0.1.11.dist-info → xslope-0.1.12.dist-info}/LICENSE +0 -0
- {xslope-0.1.11.dist-info → xslope-0.1.12.dist-info}/NOTICE +0 -0
- {xslope-0.1.11.dist-info → xslope-0.1.12.dist-info}/WHEEL +0 -0
- {xslope-0.1.11.dist-info → xslope-0.1.12.dist-info}/top_level.txt +0 -0
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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,
|
|
682
|
+
bbox=[xloc, yloc, table_width, table_height])
|
|
683
|
+
table.auto_set_font_size(False)
|
|
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])
|
|
515
797
|
table.auto_set_font_size(False)
|
|
516
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,7 +1104,19 @@ def plot_reinforcement_lines(ax, slope_data):
|
|
|
717
1104
|
tension_points_plotted = True
|
|
718
1105
|
|
|
719
1106
|
|
|
720
|
-
def plot_inputs(
|
|
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
|
|
|
@@ -726,11 +1125,32 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
726
1125
|
title: Title for the plot
|
|
727
1126
|
figsize: Tuple of (width, height) in inches for the plot
|
|
728
1127
|
mat_table: Controls material table display. Can be:
|
|
729
|
-
- True:
|
|
1128
|
+
- True: Use tab_loc for positioning (default)
|
|
730
1129
|
- False: Don't show material table
|
|
731
|
-
- 'auto':
|
|
732
|
-
- String: Specific location
|
|
733
|
-
|
|
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).
|
|
734
1154
|
|
|
735
1155
|
Returns:
|
|
736
1156
|
None
|
|
@@ -741,6 +1161,8 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
741
1161
|
plot_profile_lines(ax, slope_data['profile_lines'])
|
|
742
1162
|
plot_max_depth(ax, slope_data['profile_lines'], slope_data['max_depth'])
|
|
743
1163
|
plot_piezo_line(ax, slope_data)
|
|
1164
|
+
if mode == "seep":
|
|
1165
|
+
plot_seepage_bc_lines(ax, slope_data)
|
|
744
1166
|
plot_dloads(ax, slope_data)
|
|
745
1167
|
plot_tcrack_surface(ax, slope_data['tcrack_surface'])
|
|
746
1168
|
plot_reinforcement_lines(ax, slope_data)
|
|
@@ -752,32 +1174,116 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
752
1174
|
|
|
753
1175
|
# Handle material table display
|
|
754
1176
|
if mat_table:
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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)),
|
|
760
1238
|
'upper center': (0.35, 0.70),
|
|
761
1239
|
'lower left': (0.05, 0.05),
|
|
762
1240
|
'lower right': (0.70, 0.05),
|
|
763
1241
|
'lower center': (0.35, 0.05),
|
|
764
1242
|
'center left': (0.05, 0.35),
|
|
765
1243
|
'center right': (0.70, 0.35),
|
|
766
|
-
'center': (0.35, 0.35)
|
|
1244
|
+
'center': (0.35, 0.35),
|
|
1245
|
+
'top': ((1.0 - width) / 2.0, 1.16)
|
|
767
1246
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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)
|
|
779
1279
|
|
|
780
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)
|
|
781
1287
|
ax.set_xlabel("x")
|
|
782
1288
|
ax.set_ylabel("y")
|
|
783
1289
|
ax.grid(False)
|
|
@@ -791,16 +1297,38 @@ def plot_inputs(slope_data, title="Slope Geometry and Inputs", figsize=(12, 6),
|
|
|
791
1297
|
handles.append(dummy_line)
|
|
792
1298
|
labels.append('Distributed Load')
|
|
793
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
|
+
|
|
794
1321
|
ax.legend(
|
|
795
1322
|
handles=handles,
|
|
796
1323
|
labels=labels,
|
|
797
|
-
loc=
|
|
1324
|
+
loc="upper center",
|
|
798
1325
|
bbox_to_anchor=(0.5, -0.12),
|
|
799
|
-
ncol=
|
|
1326
|
+
ncol=ncol,
|
|
800
1327
|
)
|
|
801
1328
|
|
|
802
1329
|
ax.set_title(title)
|
|
803
1330
|
|
|
1331
|
+
plt.subplots_adjust(bottom=bottom_margin)
|
|
804
1332
|
plt.tight_layout()
|
|
805
1333
|
|
|
806
1334
|
if save_png:
|
|
@@ -892,6 +1420,8 @@ def plot_solution(slope_data, slice_df, failure_surface, results, figsize=(12, 7
|
|
|
892
1420
|
title = f'Corps Engineers: FS = {fs:.3f}, θ = {theta:.2f}°'
|
|
893
1421
|
elif method == 'lowe_karafiath':
|
|
894
1422
|
title = f'Lowe & Karafiath: FS = {fs:.3f}'
|
|
1423
|
+
else:
|
|
1424
|
+
title = f'{method}: FS = {fs:.3f}'
|
|
895
1425
|
ax.set_title(title)
|
|
896
1426
|
|
|
897
1427
|
# zoom y‐axis to just cover the slope and depth, with a little breathing room (thrust line can be outside)
|
|
@@ -1360,12 +1890,16 @@ def plot_mesh(mesh, materials=None, figsize=(14, 6), pad_frac=0.05, show_nodes=T
|
|
|
1360
1890
|
plt.show()
|
|
1361
1891
|
|
|
1362
1892
|
|
|
1363
|
-
def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=300):
|
|
1893
|
+
def plot_polygons(polygons, materials=None, nodes=False, legend=True, title="Material Zone Polygons", save_png=False, dpi=300):
|
|
1364
1894
|
"""
|
|
1365
1895
|
Plot all material zone polygons in a single figure.
|
|
1366
1896
|
|
|
1367
1897
|
Parameters:
|
|
1368
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.
|
|
1369
1903
|
title: Plot title
|
|
1370
1904
|
"""
|
|
1371
1905
|
import matplotlib.pyplot as plt
|
|
@@ -1374,12 +1908,24 @@ def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=
|
|
|
1374
1908
|
for i, polygon in enumerate(polygons):
|
|
1375
1909
|
xs = [x for x, y in polygon]
|
|
1376
1910
|
ys = [y for x, y in polygon]
|
|
1377
|
-
|
|
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)
|
|
1378
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)
|
|
1379
1924
|
ax.set_xlabel('X Coordinate')
|
|
1380
1925
|
ax.set_ylabel('Y Coordinate')
|
|
1381
1926
|
ax.set_title(title)
|
|
1382
|
-
|
|
1927
|
+
if legend:
|
|
1928
|
+
ax.legend()
|
|
1383
1929
|
ax.grid(True, alpha=0.3)
|
|
1384
1930
|
ax.set_aspect('equal')
|
|
1385
1931
|
plt.tight_layout()
|
|
@@ -1391,13 +1937,14 @@ def plot_polygons(polygons, title="Material Zone Polygons", save_png=False, dpi=
|
|
|
1391
1937
|
plt.show()
|
|
1392
1938
|
|
|
1393
1939
|
|
|
1394
|
-
def plot_polygons_separately(polygons,
|
|
1940
|
+
def plot_polygons_separately(polygons, materials=None, save_png=False, dpi=300):
|
|
1395
1941
|
"""
|
|
1396
1942
|
Plot each polygon in a separate matplotlib frame (subplot), with vertices as round dots.
|
|
1397
1943
|
|
|
1398
1944
|
Parameters:
|
|
1399
1945
|
polygons: List of polygon coordinate lists
|
|
1400
|
-
|
|
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.
|
|
1401
1948
|
"""
|
|
1402
1949
|
import matplotlib.pyplot as plt
|
|
1403
1950
|
|
|
@@ -1412,14 +1959,25 @@ def plot_polygons_separately(polygons, title_prefix='Material Zone', save_png=Fa
|
|
|
1412
1959
|
ax.scatter(xs, ys, color='k', s=30, marker='o', zorder=3, label='Vertices')
|
|
1413
1960
|
ax.set_xlabel('X Coordinate')
|
|
1414
1961
|
ax.set_ylabel('Y Coordinate')
|
|
1415
|
-
|
|
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}')
|
|
1416
1973
|
ax.grid(True, alpha=0.3)
|
|
1417
1974
|
ax.set_aspect('equal')
|
|
1418
|
-
|
|
1975
|
+
# Intentionally no legend: these plots are typically used for debugging geometry,
|
|
1976
|
+
# and legends can obscure key vertices/edges.
|
|
1419
1977
|
plt.tight_layout()
|
|
1420
1978
|
|
|
1421
1979
|
if save_png:
|
|
1422
|
-
filename = '
|
|
1980
|
+
filename = 'plot_polygons_separately.png'
|
|
1423
1981
|
plt.savefig(filename, dpi=dpi, bbox_inches='tight')
|
|
1424
1982
|
|
|
1425
1983
|
plt.show()
|