From 745b238668dfa4c690d14803b5217877e71d9f35 Mon Sep 17 00:00:00 2001 From: oscar Date: Mon, 8 Dec 2025 14:40:43 +0800 Subject: [PATCH 1/6] added optional simplification and densification argument for lines, but still not implemented the last one --- src/vorflow/blueprint.py | 86 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/src/vorflow/blueprint.py b/src/vorflow/blueprint.py index f8d7e3e..fca094b 100644 --- a/src/vorflow/blueprint.py +++ b/src/vorflow/blueprint.py @@ -29,7 +29,8 @@ def __init__(self, crs="EPSG:4326"): self.clean_lines = gpd.GeoDataFrame() self.clean_points = gpd.GeoDataFrame() - def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refinement=True, dist_min=None, dist_max=None, dist_max_in=None, dist_max_out=None, border_density=None): + def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refinement=True, dist_min=None, + dist_max=None, dist_max_in=None, dist_max_out=None, border_density=None, simplify_tolerance=None): """ Adds a polygon feature, such as a model boundary or a refinement zone. @@ -51,6 +52,8 @@ def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refine mesh transitions to the background resolution. border_density (float, optional): If set, densifies the polygon's boundary by adding vertices, ensuring no segment is longer than this value. + simplify_tolerance (float, optional): Tolerance for simplifying the polygon geometry, no simplification is applied + if None. """ if not geometry.is_valid: geometry = make_valid(geometry) @@ -68,10 +71,12 @@ def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refine 'dist_min': dist_min, 'dist_max_in': dist_max_in, 'dist_max_out': dist_max_out, - 'border_density': border_density + 'border_density': border_density, + 'simplify_tolerance': simplify_tolerance }) - def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barrier=False, dist_min=None, dist_max=None, straddle_width=None): + def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barrier=False, + dist_min=None, dist_max=None, straddle_width=None, densify=None, simplify_tolerance=None): """ Adds a line feature, such as a river, fault, or other linear boundary. @@ -89,6 +94,9 @@ def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barr transitions to the background resolution. straddle_width (float, optional): If set, forces Voronoi cell edges to align perfectly with the line by creating a "virtual straddle" of mesh nodes. + densify (float, optional): If set, densifies the line by adding vertices, + ensuring no segment is longer than this value. + simplify_tolerance (float, optional): Tolerance for simplifying the line geometry. """ if not geometry.is_valid: geometry = make_valid(geometry) @@ -100,10 +108,11 @@ def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barr 'is_barrier': is_barrier, 'dist_min': dist_min, 'dist_max': dist_max, - 'straddle_width': straddle_width + 'straddle_width': straddle_width, + 'densify': densify }) - def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None): + def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None, simplify_tolerance=None): """ Adds a point feature, such as a well or an observation point. @@ -121,8 +130,70 @@ def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None 'point_id': point_id, 'lc': resolution, 'dist_min': dist_min, - 'dist_max': dist_max + 'dist_max': dist_max, + 'simplify_tolerance': simplify_tolerance }) + def _apply_simplification(self): + """ + Applies geometry simplification to raw polygons, lines, and points + based on their specified tolerances. + """ + # Simplify Polygons + for i, poly_data in enumerate(self.raw_polygons): + tol = poly_data.get('simplify_tolerance') + if tol is True: + lc = poly_data.get('lc') + tol = lc * 0.5 if lc is not None else None + + if tol is not None and tol > 0: + self.raw_polygons[i]['geometry'] = poly_data['geometry'].simplify(tol, preserve_topology=True) + + # Simplify Lines + for i, line_data in enumerate(self.raw_lines): + tol = line_data.get('simplify_tolerance') + if tol is True: + lc = line_data.get('lc') + tol = lc * 0.5 if lc is not None else None + if tol is not None and tol > 0: + simplified_geom = line_data['geometry'].simplify(tol, preserve_topology=True) + self.raw_lines[i]['geometry'] = simplified_geom + + # lets merge points that are very close to each other + if self.raw_points: + #lets sort by resolution first + sorted_points = sorted( + self.raw_points, + key=lambda x: x['lc'] if x['lc'] is not None else float('inf')) + final_points = [] + + for point_data in sorted_points: + current_geom = point_data['geometry'] + # Use specific tolerance if provided, otherwise default to a small value or skip + tol = point_data.get('simplify_tolerance') + if tol is None or tol <= 0: + final_points.append(point_data) + continue + + + if tol is True: + lc = point_data.get('lc') + tol = lc * 0.5 if lc is not None else 1e-6 + is_merged = False + + for kept in final_points: + dist = kept['geometry'].distance(current_geom) + if dist < tol: + is_merged = True + break + if not is_merged: + final_points.append(point_data) + + if len(self.raw_points) != len(final_points): + print(f"Simplification merged {len(self.raw_points) - len(final_points)} points.") + + self.raw_points = final_points + + def _resolve_overlaps(self): """ @@ -228,6 +299,9 @@ def generate(self): ensures topological connectivity, and prepares clean GeoDataFrames for the mesher. """ + print("Applying optional geometry simplification...") + self._apply_simplification() + print("Resolving polygon overlaps...") self._resolve_overlaps() From decd562e2b6f1eb66439c199c56a73e30aa4609e Mon Sep 17 00:00:00 2001 From: oscar Date: Mon, 8 Dec 2025 15:29:28 +0800 Subject: [PATCH 2/6] added option for no densification of lines or custom densification if desired --- src/vorflow/blueprint.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/vorflow/blueprint.py b/src/vorflow/blueprint.py index fca094b..3a44802 100644 --- a/src/vorflow/blueprint.py +++ b/src/vorflow/blueprint.py @@ -76,7 +76,7 @@ def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refine }) def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barrier=False, - dist_min=None, dist_max=None, straddle_width=None, densify=None, simplify_tolerance=None): + dist_min=None, dist_max=None, straddle_width=None, densify=True, simplify_tolerance=None): """ Adds a line feature, such as a river, fault, or other linear boundary. @@ -94,8 +94,8 @@ def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barr transitions to the background resolution. straddle_width (float, optional): If set, forces Voronoi cell edges to align perfectly with the line by creating a "virtual straddle" of mesh nodes. - densify (float, optional): If set, densifies the line by adding vertices, - ensuring no segment is longer than this value. + densify (float or bool, optional): If set, densifies the line by adding vertices, + ensuring no segment is longer than this value. If False, disables densification. simplify_tolerance (float, optional): Tolerance for simplifying the line geometry. """ if not geometry.is_valid: @@ -390,6 +390,20 @@ def _apply_densification(self): # Densify lines based on their target resolution ('lc'). if not self.clean_lines.empty: + # Helper to determine the target resolution for a line row + def get_line_resolution(row): + d = row.get('densify') + + # 1. Explicitly disabled (densify=False) + if d is False: + return None + + # 2. Explicit custom resolution (e.g., densify=5.0) + if isinstance(d, (int, float)) and d > 0: + return d + + # 3. Default behavior (True or None): use the mesh resolution (lc) + return row.get('lc') self.clean_lines['geometry'] = self.clean_lines.apply( - lambda row: self._densify_geometry(row['geometry'], row['lc']), axis=1 - ) \ No newline at end of file + lambda row: self._densify_geometry(row['geometry'], get_line_resolution(row)) + if get_line_resolution(row) is not None else row['geometry'], axis=1) \ No newline at end of file From 28d83240b12699813b4e58893c28ca351700c1b8 Mon Sep 17 00:00:00 2001 From: oscar Date: Mon, 8 Dec 2025 16:50:40 +0800 Subject: [PATCH 3/6] added test and debugged the tests, fixed bug on line implementation --- src/vorflow/blueprint.py | 11 +++- tests/test_conceptual_mesh.py | 97 +++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/vorflow/blueprint.py b/src/vorflow/blueprint.py index 3a44802..add3535 100644 --- a/src/vorflow/blueprint.py +++ b/src/vorflow/blueprint.py @@ -109,7 +109,8 @@ def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barr 'dist_min': dist_min, 'dist_max': dist_max, 'straddle_width': straddle_width, - 'densify': densify + 'densify': densify, + 'simplify_tolerance': simplify_tolerance }) def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None, simplify_tolerance=None): @@ -146,7 +147,7 @@ def _apply_simplification(self): tol = lc * 0.5 if lc is not None else None if tol is not None and tol > 0: - self.raw_polygons[i]['geometry'] = poly_data['geometry'].simplify(tol, preserve_topology=True) + self.raw_polygons[i]['geometry'] = poly_data['geometry'].simplify(tol, preserve_topology=False) # Simplify Lines for i, line_data in enumerate(self.raw_lines): @@ -202,6 +203,12 @@ def _resolve_overlaps(self): lower ones. """ # Sort polygons by priority, with the highest z_order processed first. + if self.raw_polygons == []: + #if this is empty, just create an empty GeoDataFrame + self.clean_polygons = gpd.GeoDataFrame(columns=['geometry', 'zone_id', 'lc', 'z_order', 'refine', + 'dist_min', 'dist_max_in', 'dist_max_out', + 'border_density', 'simplify_tolerance'], crs=self.crs) + return df = pd.DataFrame(self.raw_polygons) df = df.sort_values(by='z_order', ascending=False) diff --git a/tests/test_conceptual_mesh.py b/tests/test_conceptual_mesh.py index 6c8388b..c611d69 100644 --- a/tests/test_conceptual_mesh.py +++ b/tests/test_conceptual_mesh.py @@ -49,3 +49,100 @@ def test_lines_and_points_snap_to_polygons(): tolerance = 1e-3 assert snapped_line.distance(boundary) <= tolerance assert snapped_point.distance(boundary) <= tolerance + +def test_polygon_simplification(): + """Test that polygons are simplified when tolerance is provided.""" + cm = ConceptualMesh() + + # Create a "noisy" square with a tiny bump on the top edge + # (0,1) -> (0.5, 1.001) -> (1,1) + poly = Polygon([ + (0, 0), (1, 0), + (1, 1), (0.5, 1.001), (0, 1) + ]) + + # Add with a tolerance larger than the noise (0.001) + cm.add_polygon(poly, zone_id=1, simplify_tolerance=0.01) + + clean_polys, _, _ = cm.generate() + + simplified_geom = clean_polys.iloc[0].geometry + + # The original polygon has 5 vertices + closing = 6 points in exterior ring + # The simplified one should remove the bump, leaving 4 corners + closing = 5 points + assert len(simplified_geom.exterior.coords) == 5 + assert len(simplified_geom.exterior.coords) < len(poly.exterior.coords) + +def test_line_simplification(): + """Test that lines are simplified when tolerance is provided.""" + cm = ConceptualMesh() + # Noisy line: straight but with a midpoint slightly off + line = LineString([(0, 0), (0.5, 0.001), (1, 0)]) + + cm.add_line(line, line_id="noisy_line", resolution=0.1, simplify_tolerance=0.01, densify=False) + + _, clean_lines, _ = cm.generate() + + simplified_line = clean_lines.iloc[0].geometry + # Should be simplified to just start and end points + assert len(simplified_line.coords) == 2 + +def test_point_deduplication(): + """Test that close points are merged and the finest resolution is kept.""" + cm = ConceptualMesh() + p1 = Point(0, 0) + p2 = Point(0.0001, 0) # Very close to p1 + + # Case 1: No simplification (default) -> Should keep both + cm.add_point(p1, "p1", resolution=1.0) + cm.add_point(p2, "p2", resolution=0.5) + + _, _, clean_points = cm.generate() + assert len(clean_points) == 2 + + # Case 2: With simplification -> Should merge + cm2 = ConceptualMesh() + # p2 has finer resolution (0.5), so it should be the one kept + cm2.add_point(p1, "p1", resolution=1.0, simplify_tolerance=0.01) + cm2.add_point(p2, "p2", resolution=0.5, simplify_tolerance=0.01) + + _, _, clean_points_merged = cm2.generate() + + assert len(clean_points_merged) == 1 + + # Verify we kept the point with the finer resolution (0.5) + kept_point = clean_points_merged.iloc[0] + assert kept_point['lc'] == 0.5 + assert kept_point['point_id'] == "p2" + +def test_line_densification_options(): + """Test the three modes of line densification: False, True, and float.""" + cm = ConceptualMesh() + # A line of length 10 + line = LineString([(0, 0), (10, 0)]) + + # 1. densify=False: Should NOT add vertices + cm.add_line(line, "no_densify", resolution=1.0, densify=False) + + # 2. densify=True (default): Should use resolution (1.0) -> ~10 segments + cm.add_line(line, "default_densify", resolution=1.0, densify=True) + + # 3. densify=5.0: Should use custom spacing (5.0) -> ~2 segments + cm.add_line(line, "custom_densify", resolution=1.0, densify=5.0) + + _, clean_lines, _ = cm.generate() + + # Check 1: No densification + l1 = clean_lines[clean_lines['line_id'] == "no_densify"].iloc[0].geometry + assert len(l1.coords) == 2 # Just start and end + + # Check 2: Default densification (lc=1.0) + l2 = clean_lines[clean_lines['line_id'] == "default_densify"].iloc[0].geometry + # Should have roughly 11 points (10 segments) + assert len(l2.coords) >= 11 + + # Check 3: Custom densification (val=5.0) + l3 = clean_lines[clean_lines['line_id'] == "custom_densify"].iloc[0].geometry + # Should have roughly 3 points (2 segments) + assert len(l3.coords) == 3 + \ No newline at end of file From 145bd2137d78891adc21c90c9886f7df0dbe806d Mon Sep 17 00:00:00 2001 From: oscar Date: Tue, 9 Dec 2025 16:27:44 +0800 Subject: [PATCH 4/6] added fixes and recommendations to pull request of optional densify and simplify --- src/vorflow/blueprint.py | 110 ++++++++++++++++++++++++++-------- tests/test_conceptual_mesh.py | 9 +-- 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/src/vorflow/blueprint.py b/src/vorflow/blueprint.py index 7acdda8..999a16e 100644 --- a/src/vorflow/blueprint.py +++ b/src/vorflow/blueprint.py @@ -4,6 +4,7 @@ from shapely.geometry import Polygon, LineString, Point, box, MultiPolygon from shapely.ops import unary_union, snap, linemerge from shapely.validation import make_valid +from shapely.strtree import STRtree class ConceptualMesh: def __init__(self, crs="EPSG:4326"): @@ -52,11 +53,16 @@ def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refine mesh transitions to the background resolution. border_density (float, optional): If set, densifies the polygon's boundary by adding vertices, ensuring no segment is longer than this value. - simplify_tolerance (float, optional): Tolerance for simplifying the polygon geometry, no simplification is applied - if None. + simplify_tolerance (float or bool, optional): If a float > 0, applies Douglas-Peucker + simplification with this tolerance. If True, computes an automatic tolerance from + the polygon's resolution ('lc') using a heuristic (currently lc * 0.5). + If None or 0, no simplification is applied. Raises ValueError if negative. """ if not geometry.is_valid: geometry = make_valid(geometry) + + if isinstance(simplify_tolerance, (int, float)) and simplify_tolerance < 0: + raise ValueError(f"simplify_tolerance must be non-negative. Got {simplify_tolerance}.") # For backward compatibility, allow 'dist_max' to function as 'dist_max_out'. if dist_max is not None and dist_max_out is None: @@ -94,13 +100,25 @@ def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barr transitions to the background resolution. straddle_width (float, optional): If set, forces Voronoi cell edges to align perfectly with the line by creating a "virtual straddle" of mesh nodes. - densify (float or bool, optional): If set, densifies the line by adding vertices, - ensuring no segment is longer than this value. If False, disables densification. - simplify_tolerance (float, optional): Tolerance for simplifying the line geometry. + densify (float or bool, optional): Controls line densification: + - If False, disables densification. + - If True, densifies the line using the `resolution` value. + - If a float, densifies the line so that no segment is longer than this value. + Raises ValueError if negative or zero. + simplify_tolerance (float or bool, optional): If a float > 0, simplifies the line with + this tolerance using Douglas-Peucker algorithm. + If True, computes an automatic tolerance from 'lc' (currently lc * 0.5). + If None or 0, no simplification is applied. Raises ValueError if negative. """ if not geometry.is_valid: geometry = make_valid(geometry) + if isinstance(simplify_tolerance, (int, float)) and simplify_tolerance < 0: + raise ValueError(f"simplify_tolerance must be non-negative. Got {simplify_tolerance}.") + + if isinstance(densify, (int, float)) and not isinstance(densify, bool) and densify <= 0: + raise ValueError(f"densify must be positive when specified as a float. Got {densify}.") + self.raw_lines.append({ 'geometry': geometry, 'line_id': line_id, @@ -125,7 +143,13 @@ def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None held constant at the point's resolution. dist_max (float, optional): Distance from the point over which the mesh transitions to the background resolution. + simplify_tolerance (float or bool, optional): If a float > 0, merges points + that are closer than this tolerance. If True, computes an automatic tolerance + from 'lc' (currently lc * 0.5). If None or 0, no simplification is applied. + Raises ValueError if negative. """ + if isinstance(simplify_tolerance, (int, float)) and simplify_tolerance < 0: + raise ValueError(f"simplify_tolerance must be non-negative. Got {simplify_tolerance}.") self.raw_points.append({ 'geometry': geometry, 'point_id': point_id, @@ -137,7 +161,16 @@ def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None def _apply_simplification(self): """ Applies geometry simplification to raw polygons, lines, and points - based on their specified tolerances. + based on their specified tolerances to reduce + geometric complexity.For polygon and lines + the Douglas-Peucker algorithm is used. + For points, this method performs deduplication: points that are within + a specified tolerance of each other are merged, and only the point with the + finest (smallest) resolution is kept. The tolerance for merging is taken from + the 'simplify_tolerance' attribute of each point, or derived from the point's + resolution if set to True. This ensures that closely spaced points do not + result in redundant mesh nodes, and that the most restrictive mesh size is + preserved at each location. """ # Simplify Polygons for i, poly_data in enumerate(self.raw_polygons): @@ -147,8 +180,15 @@ def _apply_simplification(self): tol = lc * 0.5 if lc is not None else None if tol is not None and tol > 0: - self.raw_polygons[i]['geometry'] = poly_data['geometry'].simplify(tol, preserve_topology=False) - + org_area = poly_data['geometry'].area + simplified_geom = poly_data['geometry'].simplify(tol, preserve_topology=True) + self.raw_polygons[i]['geometry'] = simplified_geom + new_area = simplified_geom.area + if new_area < org_area and org_area > 0: + reduction_pct = 100 * (org_area - new_area) / org_area + if reduction_pct > 1.0: + print(f"Simplified polygon (zone_id={poly_data['zone_id']}) " + f"reduced area by {reduction_pct:.2f}% using tolerance {tol}.") # Simplify Lines for i, line_data in enumerate(self.raw_lines): tol = line_data.get('simplify_tolerance') @@ -156,23 +196,33 @@ def _apply_simplification(self): lc = line_data.get('lc') tol = lc * 0.5 if lc is not None else None if tol is not None and tol > 0: + org_length = line_data['geometry'].length simplified_geom = line_data['geometry'].simplify(tol, preserve_topology=True) self.raw_lines[i]['geometry'] = simplified_geom - - # lets merge points that are very close to each other + new_length = simplified_geom.length + if new_length < org_length and org_length > 0: + reduction_pct = 100 * (org_length - new_length) / org_length + if reduction_pct > 1.0: + print(f"Simplified line (line_id={line_data['line_id']}) " + f"reduced length by {reduction_pct:.2f}% using tolerance {tol}.") + + # Let's merge points that are very close to each other if self.raw_points: - #lets sort by resolution first + # Let's sort by resolution first sorted_points = sorted( self.raw_points, - key=lambda x: x['lc'] if x['lc'] is not None else float('inf')) + key=lambda x: x['lc'] if x['lc'] is not None else float('inf')) final_points = [] - - for point_data in sorted_points: + geoms = [p['geometry'] for p in sorted_points] + tree = STRtree(geoms) + kept_indices = set() + for i, point_data in enumerate(sorted_points): current_geom = point_data['geometry'] # Use specific tolerance if provided, otherwise default to a small value or skip tol = point_data.get('simplify_tolerance') if tol is None or tol <= 0: final_points.append(point_data) + kept_indices.add(i) continue @@ -181,17 +231,23 @@ def _apply_simplification(self): tol = lc * 0.5 if lc is not None else 1e-6 is_merged = False - for kept in final_points: - dist = kept['geometry'].distance(current_geom) - if dist < tol: - is_merged = True - break + #query tree for potential neighbors + # tree.query returns indices of geometries that intersect the buffer + search_area = current_geom.buffer(tol) + candidate_indices = tree.query(search_area) + for candidate_idx in candidate_indices: + if candidate_idx in kept_indices: + dist = geoms[candidate_idx].distance(current_geom) + if dist < tol: + is_merged = True + break if not is_merged: final_points.append(point_data) + kept_indices.add(i) if len(self.raw_points) != len(final_points): - print(f"Simplification merged {len(self.raw_points) - len(final_points)} points.") - + print(f"Simplification merged {len(self.raw_points) - len(final_points)} " + f"points out of {len(self.raw_points)}") self.raw_points = final_points @@ -203,8 +259,8 @@ def _resolve_overlaps(self): lower ones. """ # Sort polygons by priority, with the highest z_order processed first. - if self.raw_polygons == []: - #if this is empty, just create an empty GeoDataFrame + if not self.raw_polygons: + # If this is empty, just create an empty GeoDataFrame. self.clean_polygons = gpd.GeoDataFrame(columns=['geometry', 'zone_id', 'lc', 'z_order', 'refine', 'dist_min', 'dist_max_in', 'dist_max_out', 'border_density', 'simplify_tolerance'], crs=self.crs) @@ -414,6 +470,8 @@ def get_line_resolution(row): # 3. Default behavior (True or None): use the mesh resolution (lc) return row.get('lc') - self.clean_lines['geometry'] = self.clean_lines.apply( - lambda row: self._densify_geometry(row['geometry'], get_line_resolution(row)) - if get_line_resolution(row) is not None else row['geometry'], axis=1) \ No newline at end of file + def _line_densify(row): + res = get_line_resolution(row) + return self._densify_geometry(row['geometry'], res) if res is not None else row['geometry'] + + self.clean_lines['geometry'] = self.clean_lines.apply(_line_densify, axis=1) \ No newline at end of file diff --git a/tests/test_conceptual_mesh.py b/tests/test_conceptual_mesh.py index c611d69..68a074d 100644 --- a/tests/test_conceptual_mesh.py +++ b/tests/test_conceptual_mesh.py @@ -56,10 +56,7 @@ def test_polygon_simplification(): # Create a "noisy" square with a tiny bump on the top edge # (0,1) -> (0.5, 1.001) -> (1,1) - poly = Polygon([ - (0, 0), (1, 0), - (1, 1), (0.5, 1.001), (0, 1) - ]) + poly = Polygon([(0, 0), (1, 0), (1, 1), (0.5, 1.001), (0, 1)]) # Add with a tolerance larger than the noise (0.001) cm.add_polygon(poly, zone_id=1, simplify_tolerance=0.01) @@ -138,8 +135,8 @@ def test_line_densification_options(): # Check 2: Default densification (lc=1.0) l2 = clean_lines[clean_lines['line_id'] == "default_densify"].iloc[0].geometry - # Should have roughly 11 points (10 segments) - assert len(l2.coords) >= 11 + # Should have 11 points (10 segments) + assert len(l2.coords) == 11 # Check 3: Custom densification (val=5.0) l3 = clean_lines[clean_lines['line_id'] == "custom_densify"].iloc[0].geometry From a848493187e54bdc401bc89ea249c0b91aa8ce84 Mon Sep 17 00:00:00 2001 From: oscar Date: Wed, 10 Dec 2025 11:12:55 +0800 Subject: [PATCH 5/6] adding second round of refinements from copilot, mainly grammar and set of variables for magic numbers --- src/vorflow/blueprint.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/vorflow/blueprint.py b/src/vorflow/blueprint.py index 999a16e..8f67f66 100644 --- a/src/vorflow/blueprint.py +++ b/src/vorflow/blueprint.py @@ -6,6 +6,11 @@ from shapely.validation import make_valid from shapely.strtree import STRtree +# Constants for geometry simplification and reporting +SIGNIFICANT_REDUCTION_PCT = 1.0 +AUTO_SIMPLIFY_FACTOR = 0.5 +DEFAULT_TOLERANCE = 1e-3 + class ConceptualMesh: def __init__(self, crs="EPSG:4326"): """ @@ -162,7 +167,7 @@ def _apply_simplification(self): """ Applies geometry simplification to raw polygons, lines, and points based on their specified tolerances to reduce - geometric complexity.For polygon and lines + geometric complexity.For polygons and lines the Douglas-Peucker algorithm is used. For points, this method performs deduplication: points that are within a specified tolerance of each other are merged, and only the point with the @@ -177,7 +182,7 @@ def _apply_simplification(self): tol = poly_data.get('simplify_tolerance') if tol is True: lc = poly_data.get('lc') - tol = lc * 0.5 if lc is not None else None + tol = lc * AUTO_SIMPLIFY_FACTOR if lc is not None else None if tol is not None and tol > 0: org_area = poly_data['geometry'].area @@ -186,7 +191,7 @@ def _apply_simplification(self): new_area = simplified_geom.area if new_area < org_area and org_area > 0: reduction_pct = 100 * (org_area - new_area) / org_area - if reduction_pct > 1.0: + if reduction_pct > SIGNIFICANT_REDUCTION_PCT: print(f"Simplified polygon (zone_id={poly_data['zone_id']}) " f"reduced area by {reduction_pct:.2f}% using tolerance {tol}.") # Simplify Lines @@ -194,7 +199,7 @@ def _apply_simplification(self): tol = line_data.get('simplify_tolerance') if tol is True: lc = line_data.get('lc') - tol = lc * 0.5 if lc is not None else None + tol = lc * AUTO_SIMPLIFY_FACTOR if lc is not None else None if tol is not None and tol > 0: org_length = line_data['geometry'].length simplified_geom = line_data['geometry'].simplify(tol, preserve_topology=True) @@ -202,7 +207,7 @@ def _apply_simplification(self): new_length = simplified_geom.length if new_length < org_length and org_length > 0: reduction_pct = 100 * (org_length - new_length) / org_length - if reduction_pct > 1.0: + if reduction_pct > SIGNIFICANT_REDUCTION_PCT: print(f"Simplified line (line_id={line_data['line_id']}) " f"reduced length by {reduction_pct:.2f}% using tolerance {tol}.") @@ -228,10 +233,10 @@ def _apply_simplification(self): if tol is True: lc = point_data.get('lc') - tol = lc * 0.5 if lc is not None else 1e-6 + tol = lc * AUTO_SIMPLIFY_FACTOR if lc is not None else DEFAULT_TOLERANCE is_merged = False - #query tree for potential neighbors + # Query tree for potential neighbors # tree.query returns indices of geometries that intersect the buffer search_area = current_geom.buffer(tol) candidate_indices = tree.query(search_area) @@ -314,7 +319,7 @@ def _resolve_overlaps(self): self.clean_polygons = gpd.GeoDataFrame(final_features, crs=self.crs) - def _enforce_connectivity(self, tolerance=1e-3): + def _enforce_connectivity(self, tolerance=DEFAULT_TOLERANCE): """ Snaps features together to ensure they are topologically connected before being passed to the mesher. This is crucial for Gmsh to correctly @@ -465,7 +470,7 @@ def get_line_resolution(row): return None # 2. Explicit custom resolution (e.g., densify=5.0) - if isinstance(d, (int, float)) and d > 0: + if isinstance(d, (int, float)) and not isinstance(d, bool) and d > 0: return d # 3. Default behavior (True or None): use the mesh resolution (lc) From 728765dd7e5e5bf1001e79c562ccd38fcaa343f6 Mon Sep 17 00:00:00 2001 From: oscarfasanchez Date: Sun, 14 Dec 2025 13:00:20 +0800 Subject: [PATCH 6/6] deleted the simplify default and standardized the densify keyword --- src/vorflow/blueprint.py | 209 ++++++++++++++++++++++------------ src/vorflow/engine.py | 10 +- tests/test_conceptual_mesh.py | 17 ++- tests/test_pipeline.py | 2 +- 4 files changed, 163 insertions(+), 75 deletions(-) diff --git a/src/vorflow/blueprint.py b/src/vorflow/blueprint.py index 8f67f66..bb524b6 100644 --- a/src/vorflow/blueprint.py +++ b/src/vorflow/blueprint.py @@ -8,7 +8,6 @@ # Constants for geometry simplification and reporting SIGNIFICANT_REDUCTION_PCT = 1.0 -AUTO_SIMPLIFY_FACTOR = 0.5 DEFAULT_TOLERANCE = 1e-3 class ConceptualMesh: @@ -35,8 +34,20 @@ def __init__(self, crs="EPSG:4326"): self.clean_lines = gpd.GeoDataFrame() self.clean_points = gpd.GeoDataFrame() - def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refinement=True, dist_min=None, - dist_max=None, dist_max_in=None, dist_max_out=None, border_density=None, simplify_tolerance=None): + def add_polygon( + self, + geometry, + zone_id, + resolution=None, + z_order=0, + mesh_refinement=True, + dist_min=None, + dist_max=None, + dist_max_in=None, + dist_max_out=None, + densify=None, + simplify_tolerance=None, + ): """ Adds a polygon feature, such as a model boundary or a refinement zone. @@ -56,35 +67,49 @@ def add_polygon(self, geometry, zone_id, resolution=None, z_order=0, mesh_refine transitions from the boundary resolution to the internal resolution. dist_max_out (float, optional): Distance outside the polygon over which the mesh transitions to the background resolution. - border_density (float, optional): If set, densifies the polygon's boundary - by adding vertices, ensuring no segment is longer than this value. - simplify_tolerance (float or bool, optional): If a float > 0, applies Douglas-Peucker - simplification with this tolerance. If True, computes an automatic tolerance from - the polygon's resolution ('lc') using a heuristic (currently lc * 0.5). - If None or 0, no simplification is applied. Raises ValueError if negative. + densify (float|bool|None, optional): Controls polygon boundary densification: + - If False, disables densification. + - If True, densifies using `resolution` (lc). Requires `resolution` to be set. + - If a float, densifies so no boundary segment is longer than this value. + Raises ValueError if non-positive when specified as a float. + simplify_tolerance (float|int|None, optional): If a number > 0, applies Douglas-Peucker + simplification with this tolerance. If None or 0, no simplification is applied. + Raises ValueError if negative. Boolean values are not supported. """ if not geometry.is_valid: geometry = make_valid(geometry) + if isinstance(simplify_tolerance, bool): + raise ValueError( + "simplify_tolerance must be a non-negative number (or None/0 to disable). " + "Boolean values are not supported." + ) if isinstance(simplify_tolerance, (int, float)) and simplify_tolerance < 0: raise ValueError(f"simplify_tolerance must be non-negative. Got {simplify_tolerance}.") - + + if isinstance(densify, (int, float)) and not isinstance(densify, bool) and densify <= 0: + raise ValueError(f"densify must be positive when specified as a float. Got {densify}.") + if densify is True and (resolution is None or resolution <= 0): + raise ValueError("densify=True for polygons requires a positive `resolution` (lc).") + # For backward compatibility, allow 'dist_max' to function as 'dist_max_out'. if dist_max is not None and dist_max_out is None: dist_max_out = dist_max - self.raw_polygons.append({ - 'geometry': geometry, - 'zone_id': zone_id, - 'lc': resolution, - 'z_order': z_order, - 'refine': mesh_refinement, - 'dist_min': dist_min, - 'dist_max_in': dist_max_in, - 'dist_max_out': dist_max_out, - 'border_density': border_density, - 'simplify_tolerance': simplify_tolerance - }) + self.raw_polygons.append( + { + "geometry": geometry, + "zone_id": zone_id, + "lc": resolution, + "z_order": z_order, + "refine": mesh_refinement, + "dist_min": dist_min, + "dist_max_in": dist_max_in, + "dist_max_out": dist_max_out, + "densify": densify, + "simplify_tolerance": simplify_tolerance, + } + ) def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barrier=False, dist_min=None, dist_max=None, straddle_width=None, densify=True, simplify_tolerance=None): @@ -110,14 +135,18 @@ def add_line(self, geometry, line_id, resolution, snap_to_polygons=True, is_barr - If True, densifies the line using the `resolution` value. - If a float, densifies the line so that no segment is longer than this value. Raises ValueError if negative or zero. - simplify_tolerance (float or bool, optional): If a float > 0, simplifies the line with - this tolerance using Douglas-Peucker algorithm. - If True, computes an automatic tolerance from 'lc' (currently lc * 0.5). - If None or 0, no simplification is applied. Raises ValueError if negative. + simplify_tolerance (float|int|None, optional): If a number > 0, simplifies the line with + this tolerance using Douglas-Peucker algorithm. If None or 0, no simplification is applied. + Raises ValueError if negative. Boolean values are not supported. """ if not geometry.is_valid: geometry = make_valid(geometry) - + + if isinstance(simplify_tolerance, bool): + raise ValueError( + "simplify_tolerance must be a non-negative number (or None/0 to disable). " + "Boolean values are not supported." + ) if isinstance(simplify_tolerance, (int, float)) and simplify_tolerance < 0: raise ValueError(f"simplify_tolerance must be non-negative. Got {simplify_tolerance}.") @@ -148,11 +177,15 @@ def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None held constant at the point's resolution. dist_max (float, optional): Distance from the point over which the mesh transitions to the background resolution. - simplify_tolerance (float or bool, optional): If a float > 0, merges points - that are closer than this tolerance. If True, computes an automatic tolerance - from 'lc' (currently lc * 0.5). If None or 0, no simplification is applied. - Raises ValueError if negative. + simplify_tolerance (float|int|None, optional): If a number > 0, merges points that are closer + than this tolerance. If None or 0, no merging is applied. Raises ValueError if negative. + Boolean values are not supported. """ + if isinstance(simplify_tolerance, bool): + raise ValueError( + "simplify_tolerance must be a non-negative number (or None/0 to disable). " + "Boolean values are not supported." + ) if isinstance(simplify_tolerance, (int, float)) and simplify_tolerance < 0: raise ValueError(f"simplify_tolerance must be non-negative. Got {simplify_tolerance}.") self.raw_points.append({ @@ -166,23 +199,21 @@ def add_point(self, geometry, point_id, resolution, dist_min=None, dist_max=None def _apply_simplification(self): """ Applies geometry simplification to raw polygons, lines, and points - based on their specified tolerances to reduce - geometric complexity.For polygons and lines - the Douglas-Peucker algorithm is used. + based on their specified tolerances to reduce geometric complexity. + + For polygons and lines the Douglas-Peucker algorithm is used. For points, this method performs deduplication: points that are within a specified tolerance of each other are merged, and only the point with the - finest (smallest) resolution is kept. The tolerance for merging is taken from - the 'simplify_tolerance' attribute of each point, or derived from the point's - resolution if set to True. This ensures that closely spaced points do not - result in redundant mesh nodes, and that the most restrictive mesh size is - preserved at each location. + finest (smallest) resolution is kept. """ # Simplify Polygons for i, poly_data in enumerate(self.raw_polygons): tol = poly_data.get('simplify_tolerance') - if tol is True: - lc = poly_data.get('lc') - tol = lc * AUTO_SIMPLIFY_FACTOR if lc is not None else None + if isinstance(tol, bool): + raise ValueError( + "simplify_tolerance must be a non-negative number (or None/0 to disable). " + "Boolean values are not supported." + ) if tol is not None and tol > 0: org_area = poly_data['geometry'].area @@ -192,14 +223,20 @@ def _apply_simplification(self): if new_area < org_area and org_area > 0: reduction_pct = 100 * (org_area - new_area) / org_area if reduction_pct > SIGNIFICANT_REDUCTION_PCT: - print(f"Simplified polygon (zone_id={poly_data['zone_id']}) " - f"reduced area by {reduction_pct:.2f}% using tolerance {tol}.") + print( + f"Simplified polygon (zone_id={poly_data['zone_id']}) " + f"reduced area by {reduction_pct:.2f}% using tolerance {tol}." + ) + # Simplify Lines for i, line_data in enumerate(self.raw_lines): tol = line_data.get('simplify_tolerance') - if tol is True: - lc = line_data.get('lc') - tol = lc * AUTO_SIMPLIFY_FACTOR if lc is not None else None + if isinstance(tol, bool): + raise ValueError( + "simplify_tolerance must be a non-negative number (or None/0 to disable). " + "Boolean values are not supported." + ) + if tol is not None and tol > 0: org_length = line_data['geometry'].length simplified_geom = line_data['geometry'].simplify(tol, preserve_topology=True) @@ -208,54 +245,62 @@ def _apply_simplification(self): if new_length < org_length and org_length > 0: reduction_pct = 100 * (org_length - new_length) / org_length if reduction_pct > SIGNIFICANT_REDUCTION_PCT: - print(f"Simplified line (line_id={line_data['line_id']}) " - f"reduced length by {reduction_pct:.2f}% using tolerance {tol}.") + print( + f"Simplified line (line_id={line_data['line_id']}) " + f"reduced length by {reduction_pct:.2f}% using tolerance {tol}." + ) - # Let's merge points that are very close to each other + # Merge points that are very close to each other (deduplication) if self.raw_points: # Let's sort by resolution first sorted_points = sorted( self.raw_points, - key=lambda x: x['lc'] if x['lc'] is not None else float('inf')) + key=lambda x: x['lc'] if x['lc'] is not None else float('inf') + ) final_points = [] geoms = [p['geometry'] for p in sorted_points] tree = STRtree(geoms) kept_indices = set() + for i, point_data in enumerate(sorted_points): current_geom = point_data['geometry'] - # Use specific tolerance if provided, otherwise default to a small value or skip tol = point_data.get('simplify_tolerance') + + if isinstance(tol, bool): + raise ValueError( + "simplify_tolerance must be a non-negative number (or None/0 to disable). " + "Boolean values are not supported." + ) + + # None or <=0 => no merging for this point (keep as-is) if tol is None or tol <= 0: final_points.append(point_data) kept_indices.add(i) continue - - if tol is True: - lc = point_data.get('lc') - tol = lc * AUTO_SIMPLIFY_FACTOR if lc is not None else DEFAULT_TOLERANCE is_merged = False # Query tree for potential neighbors # tree.query returns indices of geometries that intersect the buffer search_area = current_geom.buffer(tol) candidate_indices = tree.query(search_area) + for candidate_idx in candidate_indices: if candidate_idx in kept_indices: - dist = geoms[candidate_idx].distance(current_geom) - if dist < tol: + if geoms[candidate_idx].distance(current_geom) < tol: is_merged = True break + if not is_merged: final_points.append(point_data) kept_indices.add(i) if len(self.raw_points) != len(final_points): - print(f"Simplification merged {len(self.raw_points) - len(final_points)} " - f"points out of {len(self.raw_points)}") + print( + f"Simplification merged {len(self.raw_points) - len(final_points)} " + f"points out of {len(self.raw_points)}" + ) self.raw_points = final_points - - def _resolve_overlaps(self): """ @@ -266,9 +311,21 @@ def _resolve_overlaps(self): # Sort polygons by priority, with the highest z_order processed first. if not self.raw_polygons: # If this is empty, just create an empty GeoDataFrame. - self.clean_polygons = gpd.GeoDataFrame(columns=['geometry', 'zone_id', 'lc', 'z_order', 'refine', - 'dist_min', 'dist_max_in', 'dist_max_out', - 'border_density', 'simplify_tolerance'], crs=self.crs) + self.clean_polygons = gpd.GeoDataFrame( + columns=[ + "geometry", + "zone_id", + "lc", + "z_order", + "refine", + "dist_min", + "dist_max_in", + "dist_max_out", + "densify", + "simplify_tolerance", + ], + crs=self.crs, + ) return df = pd.DataFrame(self.raw_polygons) df = df.sort_values(by='z_order', ascending=False) @@ -452,12 +509,24 @@ def densify_line(line, max_segment_length): def _apply_densification(self): """Applies densification to the clean polygon and line features.""" - # Densify polygon boundaries where a 'border_density' is specified. + # Densify polygon boundaries based on `densify`. if not self.clean_polygons.empty: - self.clean_polygons['geometry'] = self.clean_polygons.apply( - lambda row: self._densify_geometry(row['geometry'], row['border_density']) - if pd.notna(row.get('border_density')) else row['geometry'], axis=1 - ) + + def get_poly_resolution(row): + d = row.get("densify") + if d is False or pd.isna(d): + return None + if d is True: + return row.get("lc") + if isinstance(d, (int, float)) and not isinstance(d, bool) and d > 0: + return d + return None + + def _poly_densify(row): + res = get_poly_resolution(row) + return self._densify_geometry(row["geometry"], res) if res is not None else row["geometry"] + + self.clean_polygons["geometry"] = self.clean_polygons.apply(_poly_densify, axis=1) # Densify lines based on their target resolution ('lc'). if not self.clean_lines.empty: diff --git a/src/vorflow/engine.py b/src/vorflow/engine.py index 942cde2..ed66d4d 100644 --- a/src/vorflow/engine.py +++ b/src/vorflow/engine.py @@ -494,10 +494,14 @@ def add_refinement(entity_dim, entity_tags, size_target, dist_min, dist_max, siz for idx, row in polygons_gdf.iterrows(): if idx in gmsh_map['surfaces']: tags = extract_tags(gmsh_map['surfaces'][idx]) - + target_lc = get_row_param(row, 'lc', global_max_lc) - border_dens = get_row_param(row, 'border_density', target_lc) - boundary_lc = min(target_lc, border_dens) + + densify_val = row.get("densify", None) + if isinstance(densify_val, (int, float)) and not isinstance(densify_val, bool) and densify_val > 0: + boundary_lc = min(target_lc, float(densify_val)) + else: + boundary_lc = target_lc if self.verbosity > 1: print(f"Poly {idx}: Target={target_lc}, Border={boundary_lc}, Global={global_max_lc}") diff --git a/tests/test_conceptual_mesh.py b/tests/test_conceptual_mesh.py index 68a074d..2ced0bb 100644 --- a/tests/test_conceptual_mesh.py +++ b/tests/test_conceptual_mesh.py @@ -142,4 +142,19 @@ def test_line_densification_options(): l3 = clean_lines[clean_lines['line_id'] == "custom_densify"].iloc[0].geometry # Should have roughly 3 points (2 segments) assert len(l3.coords) == 3 - \ No newline at end of file + +@pytest.mark.parametrize("bool_tol", [True, False]) +def test_simplify_tolerance_bool_is_rejected(bool_tol): + cm = ConceptualMesh() + + poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]) + with pytest.raises(ValueError): + cm.add_polygon(poly, zone_id=1, simplify_tolerance=bool_tol) + + line = LineString([(0, 0), (1, 0)]) + with pytest.raises(ValueError): + cm.add_line(line, line_id="l1", resolution=0.1, simplify_tolerance=bool_tol, densify=False) + + pt = Point(0, 0) + with pytest.raises(ValueError): + cm.add_point(pt, point_id="p1", resolution=0.1, simplify_tolerance=bool_tol) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a814166..e477a26 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -55,7 +55,7 @@ def __init__(self): def _build_simple_conceptual_mesh(): cm = ConceptualMesh(crs="EPSG:3857") square = Polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) - cm.add_polygon(square, zone_id=99, border_density=0.5) + cm.add_polygon(square, zone_id=99, densify=0.5) return cm