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/mesh.py CHANGED
@@ -55,13 +55,13 @@ def _get_gmsh():
55
55
 
56
56
 
57
57
 
58
- def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=None, debug=False, mesh_params=None, target_size_1d=None):
58
+ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=None, debug=False, mesh_params=None, target_size_1d=None, profile_lines=None):
59
59
  """
60
60
  Build a finite element mesh with material regions using Gmsh.
61
61
  Fixed version that properly handles shared boundaries between polygons.
62
62
 
63
63
  Parameters:
64
- polygons : List of lists of (x, y) tuples defining material boundaries
64
+ polygons : List of polygon coordinate lists or dicts with "coords"/"mat_id"
65
65
  target_size : Desired element size
66
66
  element_type : 'tri3' (3-node triangles), 'tri6' (6-node triangles),
67
67
  'quad4' (4-node quadrilaterals), 'quad8' (8-node quadrilaterals),
@@ -70,6 +70,7 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
70
70
  debug : Enable debug output
71
71
  mesh_params : Optional dictionary of GMSH meshing parameters to override defaults
72
72
  target_size_1d : Optional target size for 1D elements (default None, which is set to target_size if None)
73
+ profile_lines: Optional list of profile line dicts with 'mat_id' keys for material assignment
73
74
 
74
75
  Returns:
75
76
  mesh dict containing:
@@ -92,8 +93,35 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
92
93
  if debug:
93
94
  print(f"Using default target_size_1d = target_size = {target_size_1d}")
94
95
 
95
- # build a list of region ids (list of material IDs - one per polygon)
96
- region_ids = [i for i in range(len(polygons))]
96
+ # Normalize polygons to coordinate lists and optional mat_id
97
+ polygon_coords = []
98
+ polygon_mat_ids = []
99
+ for i, polygon in enumerate(polygons):
100
+ if isinstance(polygon, dict):
101
+ polygon_coords.append(polygon.get("coords", []))
102
+ polygon_mat_ids.append(polygon.get("mat_id"))
103
+ else:
104
+ polygon_coords.append(polygon)
105
+ polygon_mat_ids.append(None)
106
+
107
+ # Build a list of region ids (list of material IDs - one per polygon)
108
+ if any(mat_id is not None for mat_id in polygon_mat_ids):
109
+ region_ids = [
110
+ mat_id if mat_id is not None else i
111
+ for i, mat_id in enumerate(polygon_mat_ids)
112
+ ]
113
+ elif profile_lines and len(profile_lines) >= len(polygon_coords):
114
+ region_ids = []
115
+ for i in range(len(polygon_coords)):
116
+ mat_id = profile_lines[i].get('mat_id')
117
+ if mat_id is not None:
118
+ region_ids.append(mat_id)
119
+ else:
120
+ # Fallback to polygon index if no mat_id
121
+ region_ids.append(i)
122
+ else:
123
+ # Fallback to sequential IDs if no profile_lines provided
124
+ region_ids = [i for i in range(len(polygon_coords))]
97
125
 
98
126
  if element_type not in ['tri3', 'tri6', 'quad4', 'quad8', 'quad9']:
99
127
  raise ValueError("element_type must be 'tri3', 'tri6', 'quad4', 'quad8', or 'quad9'")
@@ -160,7 +188,7 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
160
188
  short_edge_points = set() # Points that are endpoints of short edges
161
189
 
162
190
  # Pre-pass to identify short edges - improved logic
163
- for idx, (poly_pts, region_id) in enumerate(zip(polygons, region_ids)):
191
+ for idx, (poly_pts, region_id) in enumerate(zip(polygon_coords, region_ids)):
164
192
  poly_pts_clean = remove_duplicate_endpoint(list(poly_pts))
165
193
  for i in range(len(poly_pts_clean)):
166
194
  p1 = poly_pts_clean[i]
@@ -187,7 +215,7 @@ def build_mesh_from_polygons(polygons, target_size, element_type='tri3', lines=N
187
215
  print(f"Short edge ignored (major boundary): {p1} to {p2}, length={edge_length:.2f}")
188
216
 
189
217
  # Main pass: Create points with appropriate sizes
190
- for idx, (poly_pts, region_id) in enumerate(zip(polygons, region_ids)):
218
+ for idx, (poly_pts, region_id) in enumerate(zip(polygon_coords, region_ids)):
191
219
  poly_pts_clean = remove_duplicate_endpoint(list(poly_pts)) # make a copy
192
220
  pt_tags = []
193
221
  for x, y in poly_pts_clean:
@@ -1206,7 +1234,7 @@ def get_quad_mesh_presets():
1206
1234
 
1207
1235
 
1208
1236
 
1209
- def build_polygons(slope_data, reinf_lines=None, debug=False):
1237
+ def build_polygons(slope_data, reinf_lines=None, tol = 0.000001, debug=False):
1210
1238
  """
1211
1239
  Build material zone polygons from slope_data.
1212
1240
 
@@ -1218,7 +1246,9 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1218
1246
  slope_data: Dictionary containing slope geometry data
1219
1247
 
1220
1248
  Returns:
1221
- List of polygons, each defined by (x,y) coordinate tuples
1249
+ List of polygons as dicts with keys:
1250
+ "coords": list of (x, y) coordinate tuples
1251
+ "mat_id": optional material ID (0-based) or None
1222
1252
  """
1223
1253
  import numpy as np
1224
1254
  import copy
@@ -1236,8 +1266,7 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1236
1266
  raise ValueError("When using only 1 profile line, max_depth must be specified")
1237
1267
 
1238
1268
  n = len(profile_lines)
1239
- lines = [list(line) for line in copy.deepcopy(profile_lines)]
1240
- tol = 1e-8
1269
+ lines = [list(line['coords']) for line in copy.deepcopy(profile_lines)]
1241
1270
 
1242
1271
  for i in range(n - 1):
1243
1272
  top = lines[i]
@@ -1432,6 +1461,93 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1432
1461
  # Return the lowest y value
1433
1462
  y_min = min(y_values)
1434
1463
  return y_min, is_at_endpoint
1464
+
1465
+ def find_projected_y_at_x(line_points, x_query, y_ref, side, tol=1e-8):
1466
+ """
1467
+ For vertical endpoint projections: choose the intersection y at x_query that is
1468
+ closest *below* the point we're projecting from.
1469
+
1470
+ This fixes the case where a candidate profile has a vertical segment at x_query
1471
+ (e.g., (260,229) then (260,202)). In that situation, using the "lowest y" (202)
1472
+ is wrong; we want the first hit when projecting downward (229).
1473
+
1474
+ Behavior is intentionally conservative:
1475
+ - If there is at least one intersection strictly below y_ref, return the highest of those.
1476
+ - Otherwise fall back to the original behavior (lowest y), preserving legacy behavior
1477
+ in edge cases (e.g., coincident/above intersections).
1478
+ """
1479
+ # Reuse the exact same intersection enumeration logic as find_lowest_y_at_x,
1480
+ # but keep the full set of y-values.
1481
+ if not line_points:
1482
+ return None, False
1483
+
1484
+ xs = np.array([x for x, y in line_points])
1485
+ ys = np.array([y for x, y in line_points])
1486
+
1487
+ if xs[0] - tol > x_query or xs[-1] + tol < x_query:
1488
+ return None, False
1489
+
1490
+ is_at_left_endpoint = abs(x_query - xs[0]) < tol
1491
+ is_at_right_endpoint = abs(x_query - xs[-1]) < tol
1492
+ is_at_endpoint = is_at_left_endpoint or is_at_right_endpoint
1493
+
1494
+ y_values = []
1495
+ for k in range(len(line_points)):
1496
+ if abs(xs[k] - x_query) < tol:
1497
+ y_values.append(float(ys[k]))
1498
+
1499
+ for k in range(len(line_points) - 1):
1500
+ x1, y1 = line_points[k]
1501
+ x2, y2 = line_points[k + 1]
1502
+
1503
+ if abs(x1 - x_query) < tol and abs(x2 - x_query) < tol:
1504
+ y_values.append(float(y1))
1505
+ y_values.append(float(y2))
1506
+ elif min(x1, x2) - tol <= x_query <= max(x1, x2) + tol:
1507
+ if abs(x2 - x1) < tol:
1508
+ y_values.append(float(y1))
1509
+ y_values.append(float(y2))
1510
+ else:
1511
+ t = (x_query - x1) / (x2 - x1)
1512
+ if 0 <= t <= 1:
1513
+ y_values.append(float(y1 + t * (y2 - y1)))
1514
+
1515
+ if not y_values:
1516
+ return None, False
1517
+
1518
+ # If the polyline has multiple *vertices* exactly at this x (vertical segment / duplicate-x),
1519
+ # use a deterministic selection based on which side we are projecting from:
1520
+ # - projecting from LEFT endpoint of the upper line: keep the LAST y encountered
1521
+ # - projecting from RIGHT endpoint of the upper line: keep the FIRST y encountered
1522
+ #
1523
+ # This matches the intended "walk along the lower boundary" behavior and fixes cases like:
1524
+ # - right projection at x=260 with vertices (260,229) then (260,202): choose 229 (first)
1525
+ # - left projection at x=240 with vertices (240,140) then (240,190): choose 190 (last)
1526
+ vertex_y_at_x = [float(y) for (x, y) in line_points if abs(x - x_query) < tol]
1527
+ if len(vertex_y_at_x) >= 2:
1528
+ if side == "right":
1529
+ # first encountered vertex at this x
1530
+ y_pick = vertex_y_at_x[0]
1531
+ # If we are exactly on a vertex at y_ref, that is the first hit.
1532
+ if abs(y_pick - y_ref) < tol:
1533
+ return float(y_ref), is_at_endpoint
1534
+ if y_pick < (y_ref - tol):
1535
+ return y_pick, is_at_endpoint
1536
+ elif side == "left":
1537
+ # last encountered vertex at this x
1538
+ y_pick = vertex_y_at_x[-1]
1539
+ # If we are exactly on a vertex at y_ref, that is the first hit.
1540
+ if abs(y_pick - y_ref) < tol:
1541
+ return float(y_ref), is_at_endpoint
1542
+ if y_pick < (y_ref - tol):
1543
+ return y_pick, is_at_endpoint
1544
+
1545
+ y_below = [y for y in y_values if y < (y_ref - tol)]
1546
+ if y_below:
1547
+ return max(y_below), is_at_endpoint
1548
+
1549
+ # Fall back to legacy behavior
1550
+ return min(y_values), is_at_endpoint
1435
1551
 
1436
1552
  # Project endpoints - find highest lower profile or use max_depth
1437
1553
  # When projecting right side: if intersection is at left end of lower line,
@@ -1445,7 +1561,7 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1445
1561
 
1446
1562
  # Check left endpoint projection
1447
1563
  if xs_cand[0] - tol <= left_x <= xs_cand[-1] + tol:
1448
- y_cand, is_at_endpoint = find_lowest_y_at_x(lower_candidate, left_x, tol)
1564
+ y_cand, is_at_endpoint = find_projected_y_at_x(lower_candidate, left_x, left_y, side="left", tol=tol)
1449
1565
  if y_cand is not None:
1450
1566
  # If intersection is at the right end of the lower line, add point but continue
1451
1567
  if is_at_endpoint and abs(left_x - xs_cand[-1]) < tol: # At right endpoint
@@ -1458,7 +1574,7 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1458
1574
 
1459
1575
  # Check right endpoint projection
1460
1576
  if xs_cand[0] - tol <= right_x <= xs_cand[-1] + tol:
1461
- y_cand, is_at_endpoint = find_lowest_y_at_x(lower_candidate, right_x, tol)
1577
+ y_cand, is_at_endpoint = find_projected_y_at_x(lower_candidate, right_x, right_y, side="right", tol=tol)
1462
1578
  if y_cand is not None:
1463
1579
  # If intersection is at the left end of the lower line, add point but continue
1464
1580
  if is_at_endpoint and abs(right_x - xs_cand[0]) < tol: # At left endpoint
@@ -1474,6 +1590,25 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1474
1590
  left_y_bot = max_depth if max_depth is not None else -np.inf
1475
1591
  if right_y_bot == -np.inf:
1476
1592
  right_y_bot = max_depth if max_depth is not None else -np.inf
1593
+
1594
+ # Filter vertical-edge "continue projecting" points so we only keep points that
1595
+ # actually lie on the final vertical edge between the top and bottom of this zone.
1596
+ #
1597
+ # Without this, a deeper left-endpoint intersection (e.g., (240,190) at the left
1598
+ # endpoint of some deeper line) can be appended to right_vertical_points even after
1599
+ # we've already found the correct bottom (e.g., right_y_bot=229). That creates the
1600
+ # dangling vertical segment you observed.
1601
+ if right_y_bot != -np.inf:
1602
+ right_vertical_points = [
1603
+ (x, y) for (x, y) in right_vertical_points
1604
+ if (y < right_y - tol) and (y > right_y_bot + tol)
1605
+ ]
1606
+ if left_y_bot != -np.inf:
1607
+ # Left edge runs from bottom up to top; keep points strictly between bottom and top.
1608
+ left_vertical_points = [
1609
+ (x, y) for (x, y) in left_vertical_points
1610
+ if (y > left_y_bot + tol) and (y < left_y - tol)
1611
+ ]
1477
1612
 
1478
1613
  # Deduplicate vertical points (remove points that are too close to each other)
1479
1614
  def deduplicate_points(points, tol=1e-8):
@@ -1561,7 +1696,11 @@ def build_polygons(slope_data, reinf_lines=None, debug=False):
1561
1696
 
1562
1697
  # Clean up polygon (should rarely do anything)
1563
1698
  poly = clean_polygon(poly)
1564
- polygons.append(poly)
1699
+ mat_id = profile_lines[i].get("mat_id") if i < len(profile_lines) else None
1700
+ polygons.append({
1701
+ "coords": poly,
1702
+ "mat_id": mat_id
1703
+ })
1565
1704
 
1566
1705
  # Add distributed load points to polygon edges if coincident
1567
1706
  polygons = add_dload_points_to_polygons(polygons, slope_data)
@@ -1578,7 +1717,7 @@ def add_dload_points_to_polygons(polygons, slope_data):
1578
1717
  but not existing vertices.
1579
1718
 
1580
1719
  Parameters:
1581
- polygons: List of polygons (lists of (x,y) tuples)
1720
+ polygons: List of polygons (list of (x,y) tuples) or dicts with "coords"
1582
1721
  slope_data: Dictionary containing slope data
1583
1722
 
1584
1723
  Returns:
@@ -1603,7 +1742,8 @@ def add_dload_points_to_polygons(polygons, slope_data):
1603
1742
  # Process each polygon
1604
1743
  updated_polygons = []
1605
1744
  for poly in polygons:
1606
- updated_poly = list(poly) # Make a copy
1745
+ coords = poly.get("coords", []) if isinstance(poly, dict) else poly
1746
+ updated_poly = list(coords) # Make a copy
1607
1747
 
1608
1748
  # Check each point against polygon edges
1609
1749
  for check_point in points_to_check:
@@ -1630,7 +1770,12 @@ def add_dload_points_to_polygons(polygons, slope_data):
1630
1770
  updated_poly.insert(i + 1, (round(x_check, 6), round(y_check, 6)))
1631
1771
  break # Only insert once per point
1632
1772
 
1633
- updated_polygons.append(updated_poly)
1773
+ if isinstance(poly, dict):
1774
+ updated_entry = dict(poly)
1775
+ updated_entry["coords"] = updated_poly
1776
+ updated_polygons.append(updated_entry)
1777
+ else:
1778
+ updated_polygons.append(updated_poly)
1634
1779
 
1635
1780
  return updated_polygons
1636
1781
 
@@ -1681,29 +1826,33 @@ def print_polygon_summary(polygons):
1681
1826
  Prints a summary of the generated polygons for diagnostic purposes.
1682
1827
 
1683
1828
  Parameters:
1684
- polygons: List of polygon coordinate lists
1829
+ polygons: List of polygon coordinate lists or dicts with "coords"
1685
1830
  """
1686
1831
  print("=== POLYGON SUMMARY ===")
1687
1832
  print(f"Number of material zones: {len(polygons)}")
1688
1833
  print()
1689
1834
 
1690
1835
  for i, polygon in enumerate(polygons):
1691
- print(f"Material Zone {i+1} (Material ID: {i}):")
1692
- print(f" Number of vertices: {len(polygon)}")
1836
+ coords = polygon.get("coords") if isinstance(polygon, dict) else polygon
1837
+ mat_id = polygon.get("mat_id") if isinstance(polygon, dict) else i
1838
+ if mat_id is None:
1839
+ mat_id = i
1840
+ print(f"Material Zone {i+1} (Material ID: {mat_id}):")
1841
+ print(f" Number of vertices: {len(coords)}")
1693
1842
 
1694
1843
  # Calculate area (simple shoelace formula)
1695
1844
  area = 0
1696
- for j in range(len(polygon) - 1):
1697
- x1, y1 = polygon[j]
1698
- x2, y2 = polygon[j + 1]
1845
+ for j in range(len(coords) - 1):
1846
+ x1, y1 = coords[j]
1847
+ x2, y2 = coords[j + 1]
1699
1848
  area += (x2 - x1) * (y2 + y1) / 2
1700
1849
  area = abs(area)
1701
1850
 
1702
1851
  print(f" Approximate area: {area:.2f} square units")
1703
1852
 
1704
1853
  # Print bounding box
1705
- xs = [x for x, y in polygon]
1706
- ys = [y for x, y in polygon]
1854
+ xs = [x for x, y in coords]
1855
+ ys = [y for x, y in coords]
1707
1856
  print(f" Bounding box: x=[{min(xs):.2f}, {max(xs):.2f}], y=[{min(ys):.2f}, {max(ys):.2f}]")
1708
1857
  print()
1709
1858
 
@@ -2878,7 +3027,7 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2878
3027
  This ensures that polygons have vertices at all intersection points with reinforcement lines.
2879
3028
 
2880
3029
  Parameters:
2881
- polygons: List of polygons (lists of (x,y) tuples)
3030
+ polygons: List of polygons (lists of (x,y) tuples) or dicts with "coords"
2882
3031
  lines: List of reinforcement lines (lists of (x,y) tuples)
2883
3032
  debug: Enable debug output
2884
3033
 
@@ -2894,7 +3043,12 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2894
3043
  # Make a copy of polygons to modify
2895
3044
  updated_polygons = []
2896
3045
  for poly in polygons:
2897
- updated_polygons.append(list(poly)) # Convert to list for modification
3046
+ if isinstance(poly, dict):
3047
+ updated_entry = dict(poly)
3048
+ updated_entry["coords"] = list(poly.get("coords", []))
3049
+ updated_polygons.append(updated_entry)
3050
+ else:
3051
+ updated_polygons.append(list(poly)) # Convert to list for modification
2898
3052
 
2899
3053
  # Find all intersections
2900
3054
  for line_idx, line_pts in enumerate(lines):
@@ -2910,10 +3064,11 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2910
3064
 
2911
3065
  # Check intersection with each polygon
2912
3066
  for poly_idx, poly in enumerate(updated_polygons):
3067
+ poly_coords = poly.get("coords", []) if isinstance(poly, dict) else poly
2913
3068
  # Check each edge of this polygon
2914
- for j in range(len(poly)):
2915
- poly_edge_start = poly[j]
2916
- poly_edge_end = poly[(j + 1) % len(poly)]
3069
+ for j in range(len(poly_coords)):
3070
+ poly_edge_start = poly_coords[j]
3071
+ poly_edge_end = poly_coords[(j + 1) % len(poly_coords)]
2917
3072
 
2918
3073
  # Find intersection point if it exists
2919
3074
  intersection = line_segment_intersection(
@@ -2927,7 +3082,7 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2927
3082
 
2928
3083
  # Check if intersection point is already a vertex of this polygon
2929
3084
  is_vertex = False
2930
- for vertex in poly:
3085
+ for vertex in poly_coords:
2931
3086
  if abs(vertex[0] - intersection[0]) < 1e-8 and abs(vertex[1] - intersection[1]) < 1e-8:
2932
3087
  is_vertex = True
2933
3088
  break
@@ -2936,7 +3091,10 @@ def add_intersection_points_to_polygons(polygons, lines, debug=False):
2936
3091
  # Insert intersection point into polygon at the correct position
2937
3092
  # Insert after vertex j (which is the start of the edge)
2938
3093
  insert_idx = j + 1
2939
- updated_polygons[poly_idx].insert(insert_idx, intersection)
3094
+ if isinstance(updated_polygons[poly_idx], dict):
3095
+ updated_polygons[poly_idx]["coords"].insert(insert_idx, intersection)
3096
+ else:
3097
+ updated_polygons[poly_idx].insert(insert_idx, intersection)
2940
3098
 
2941
3099
  if debug:
2942
3100
  print(f"Added intersection point {intersection} to polygon {poly_idx} at position {insert_idx}")