Skip to content

Commit 59ec96a

Browse files
Merge pull request #332 from robbievanleeuwen/compound-offset-perimeter-fix
Fix `CompoundGeometry` offset perimeter (dilation creates overlapping geometry)
2 parents 41851f5 + 2067656 commit 59ec96a

File tree

2 files changed

+127
-38
lines changed

2 files changed

+127
-38
lines changed

src/sectionproperties/pre/geometry.py

Lines changed: 98 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
GeometryCollection,
1515
LinearRing,
1616
LineString,
17+
MultiLineString,
1718
MultiPolygon,
1819
Point,
1920
Polygon,
2021
affinity,
2122
box,
23+
line_merge,
24+
shared_paths,
2225
)
2326
from shapely.ops import split, unary_union
2427

@@ -2310,34 +2313,18 @@ def offset_perimeter(
23102313

23112314
return CompoundGeometry(geoms=geoms_acc)
23122315
elif amount > 0: # Ballooning condition
2313-
# This produces predictable results up to a point.
2314-
# That point is when the offset is so great it exceeds the thickness
2315-
# of the material at an interface of two materials.
2316-
# e.g. A 50 deep plate on top of the top flange of an I Section with a
2317-
# flange depth of 10
2318-
# When the offset exceeds 10 (the depth of the flange at the intersection),
2319-
# the meshed material regions will become unpredictable.
2320-
geoms_acc = []
2321-
2322-
for i_idx, geom in enumerate(self.geoms):
2323-
# Offset each geom...
2324-
offset_geom = geom.offset_perimeter(
2325-
amount=amount,
2326-
where=where,
2327-
resolution=resolution,
2328-
)
2329-
2330-
for j_idx, orig_geom in enumerate(self.geoms):
2331-
if i_idx != j_idx:
2332-
# ... then remove the parts that intersect with the other
2333-
# constituents of the compound geometry (because they are
2334-
# occupying that space already)
2335-
offset_geom = offset_geom - orig_geom
2336-
2337-
if not offset_geom.geom.is_empty:
2338-
geoms_acc.append(offset_geom)
2339-
2340-
return CompoundGeometry(geoms=geoms_acc)
2316+
# The algorithm used in the compound_dilation function cannot
2317+
# currently handle re-entrant corners between different
2318+
# geometries (a re-entrant corner in a single geometry is fine).
2319+
# Re-entrant corners will require the creation of a new
2320+
# "interface line" in the overlapping region created during
2321+
# the dilation. I have a thought on how to do this but I just
2322+
# have not gotten to it yet (note for later: it's like rotating
2323+
# the overlap region between shear wall corners except cutting
2324+
# across it from the shared vertex to the opposite vertex)
2325+
# connorferster 2024-07-18
2326+
2327+
return compound_dilation(self.geoms, offset=amount)
23412328
else:
23422329
return self
23432330

@@ -2715,6 +2702,36 @@ def check_geometry_overlaps(
27152702
return not math.isclose(union_area, sum_polygons)
27162703

27172704

2705+
def compound_dilation(geoms: list[Geometry], offset: float) -> CompoundGeometry:
2706+
"""Returns a CompoundGeometry representing the input Geometries, dilated.
2707+
2708+
Args:
2709+
geoms: List of Geometry objects
2710+
offset: A positive ``float`` or ``int``
2711+
2712+
Returns:
2713+
The geometries dilated by ``offset``
2714+
"""
2715+
polys = [geom.geom for geom in geoms]
2716+
geom_network = build_geometry_network(polys)
2717+
acc = []
2718+
2719+
for poly_idx, connectivity in geom_network.items():
2720+
poly_orig = polys[poly_idx]
2721+
poly_orig_exterior = poly_orig.exterior
2722+
connected_polys = [polys[idx].exterior for idx in connectivity]
2723+
mucky_shared_paths = shared_paths(poly_orig_exterior, connected_polys)
2724+
shared_path_geometries = MultiLineString(
2725+
extract_shared_paths(mucky_shared_paths)
2726+
)
2727+
source = line_merge(poly_orig_exterior - shared_path_geometries)
2728+
buff = source.buffer(offset, cap_style="flat")
2729+
new = Geometry(poly_orig | buff, material=geoms[poly_idx].material)
2730+
acc.append(new)
2731+
2732+
return CompoundGeometry(acc)
2733+
2734+
27182735
def check_geometry_disjoint(
27192736
lop: list[Polygon],
27202737
) -> bool:
@@ -2727,15 +2744,7 @@ def check_geometry_disjoint(
27272744
Whether or not there is disjoint geometry
27282745
"""
27292746
# Build polygon connectivity network
2730-
network: dict[int, set[int]] = {}
2731-
2732-
for idx_i, poly1 in enumerate(lop):
2733-
for idx_j, poly2 in enumerate(lop):
2734-
if idx_i != idx_j:
2735-
connectivity = network.get(idx_i, set())
2736-
if poly1.intersection(poly2):
2737-
connectivity.add(idx_j)
2738-
network[idx_i] = connectivity
2747+
network = build_geometry_network(lop)
27392748

27402749
def walk_network(
27412750
node: int,
@@ -2767,3 +2776,55 @@ def walk_network(
27672776
nodes_visited = [0]
27682777
walk_network(0, network, nodes_visited)
27692778
return set(nodes_visited) != set(network.keys())
2779+
2780+
2781+
def build_geometry_network(lop: list[Polygon]) -> dict[int, set[int]]:
2782+
"""Builds a geometry connectivity graph.
2783+
2784+
Returns a graph describing the connectivity of each polygon to each other polygon in
2785+
``lop``. The keys are the indexes of the polygons in ``lop`` and the values are a
2786+
set of indexes that the key is connected to.
2787+
2788+
Args:
2789+
lop: List of Polygon
2790+
2791+
Returns:
2792+
A dictionary describing the connectivity graph of the polygons
2793+
"""
2794+
network: dict[int, set[int]] = {}
2795+
2796+
for idx_i, poly1 in enumerate(lop):
2797+
for idx_j, poly2 in enumerate(lop):
2798+
if idx_i != idx_j:
2799+
connectivity = network.get(idx_i, set())
2800+
2801+
if poly1.intersection(poly2):
2802+
connectivity.add(idx_j)
2803+
2804+
network[idx_i] = connectivity
2805+
2806+
return network
2807+
2808+
2809+
def extract_shared_paths(
2810+
arr_of_geom_coll: npt.ArrayLike,
2811+
) -> list[LineString]:
2812+
"""Extracts a list of LineStrings exported by the shapely ``shared_paths`` method.
2813+
2814+
Args:
2815+
arr_of_geom_coll: An array of geometry collections
2816+
2817+
Returns:
2818+
List of LineStrings.
2819+
"""
2820+
acc = []
2821+
2822+
for geom_col in arr_of_geom_coll: # type: ignore
2823+
for mls in geom_col.geoms: # type: ignore
2824+
if mls.is_empty:
2825+
continue
2826+
2827+
ls = line_merge(mls)
2828+
acc.append(ls)
2829+
2830+
return acc

tests/geometry/test_offset.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sectionproperties.pre.library.primitive_sections as sections
1010
import sectionproperties.pre.library.steel_sections as steel_sections
1111
from sectionproperties.analysis.section import Section
12-
from sectionproperties.pre.geometry import Geometry
12+
from sectionproperties.pre.geometry import Geometry, check_geometry_overlaps
1313

1414

1515
r_tol = 1e-3
@@ -107,6 +107,34 @@ def test_compound_rectangular_offset():
107107
area = 90 * 40
108108
check.almost_equal(section.get_area(), area, rel=r_tol)
109109

110+
# balloon case (two rectangles)
111+
geom = rect1 + rect2
112+
geom = geom.offset_perimeter(amount=5, where="exterior")
113+
114+
# ensure there are no overlaps
115+
assert not check_geometry_overlaps([g.geom for g in geom.geoms])
116+
117+
# calculate area
118+
geom.create_mesh(mesh_sizes=[0])
119+
section = Section(geometry=geom)
120+
section.calculate_geometric_properties()
121+
area = 100 * 50 + 2 * (5 * 100 + 5 * 50) + np.pi * 5**2
122+
check.almost_equal(section.get_area(), area, rel=r_tol)
123+
124+
# balloon case (three rectangles)
125+
geom = rect1 + rect2 + rect1.align_to(rect2, "right")
126+
geom = geom.offset_perimeter(amount=5, where="exterior")
127+
128+
# ensure there are no overlaps
129+
assert not check_geometry_overlaps([g.geom for g in geom.geoms])
130+
131+
# calculate area
132+
geom.create_mesh(mesh_sizes=[0])
133+
section = Section(geometry=geom)
134+
section.calculate_geometric_properties()
135+
area = 150 * 50 + 2 * (5 * 150 + 5 * 50) + np.pi * 5**2
136+
check.almost_equal(section.get_area(), area, rel=r_tol)
137+
110138

111139
def test_compound_rectangular_isection_offset_corrode():
112140
"""Tests offsets for a complex compound section."""

0 commit comments

Comments
 (0)