polyforge 0.1.0a1__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.
- polyforge/__init__.py +154 -0
- polyforge/clearance/__init__.py +62 -0
- polyforge/clearance/fix_clearance.py +474 -0
- polyforge/core/__init__.py +69 -0
- polyforge/core/cleanup.py +20 -0
- polyforge/core/constraints.py +297 -0
- polyforge/core/errors.py +259 -0
- polyforge/core/geometry_utils.py +395 -0
- polyforge/core/iterative_utils.py +134 -0
- polyforge/core/spatial_utils.py +334 -0
- polyforge/core/types.py +165 -0
- polyforge/merge/__init__.py +10 -0
- polyforge/merge/core.py +252 -0
- polyforge/metrics.py +133 -0
- polyforge/ops/__init__.py +10 -0
- polyforge/ops/cleanup_ops.py +129 -0
- polyforge/ops/clearance/__init__.py +36 -0
- polyforge/ops/clearance/holes.py +177 -0
- polyforge/ops/clearance/passages.py +760 -0
- polyforge/ops/clearance/protrusions.py +283 -0
- polyforge/ops/clearance/remove_protrusions.py +177 -0
- polyforge/ops/clearance/utils.py +251 -0
- polyforge/ops/merge/__init__.py +33 -0
- polyforge/ops/merge_boundary_extension.py +270 -0
- polyforge/ops/merge_common.py +101 -0
- polyforge/ops/merge_convex_bridges.py +124 -0
- polyforge/ops/merge_edge_detection.py +156 -0
- polyforge/ops/merge_ops.py +174 -0
- polyforge/ops/merge_selective_buffer.py +86 -0
- polyforge/ops/merge_simple_buffer.py +52 -0
- polyforge/ops/merge_vertex_movement.py +83 -0
- polyforge/ops/simplify_ops.py +143 -0
- polyforge/overlap.py +412 -0
- polyforge/pipeline.py +133 -0
- polyforge/process.py +140 -0
- polyforge/repair/__init__.py +10 -0
- polyforge/repair/analysis.py +84 -0
- polyforge/repair/core.py +149 -0
- polyforge/repair/robust.py +735 -0
- polyforge/repair/strategies/__init__.py +15 -0
- polyforge/repair/strategies/auto.py +87 -0
- polyforge/repair/strategies/buffer.py +29 -0
- polyforge/repair/strategies/reconstruct.py +46 -0
- polyforge/repair/strategies/simplify.py +39 -0
- polyforge/repair/strategies/strict.py +28 -0
- polyforge/repair/utils.py +99 -0
- polyforge/simplify.py +203 -0
- polyforge/tile.py +89 -0
- polyforge/topology.py +274 -0
- polyforge-0.1.0a1.dist-info/METADATA +153 -0
- polyforge-0.1.0a1.dist-info/RECORD +54 -0
- polyforge-0.1.0a1.dist-info/WHEEL +5 -0
- polyforge-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- polyforge-0.1.0a1.dist-info/top_level.txt +1 -0
polyforge/__init__.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Polyforge - Polygon processing and manipulation library.
|
|
2
|
+
|
|
3
|
+
This library provides utilities for processing, simplifying, and manipulating
|
|
4
|
+
polygon geometries using Shapely.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0a1"
|
|
8
|
+
|
|
9
|
+
# Simplification functions
|
|
10
|
+
from .simplify import (
|
|
11
|
+
simplify_rdp,
|
|
12
|
+
simplify_vw,
|
|
13
|
+
simplify_vwp,
|
|
14
|
+
collapse_short_edges,
|
|
15
|
+
deduplicate_vertices,
|
|
16
|
+
remove_small_holes,
|
|
17
|
+
remove_narrow_holes,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Clearance fixing functions
|
|
21
|
+
from .clearance import (
|
|
22
|
+
fix_clearance,
|
|
23
|
+
fix_hole_too_close,
|
|
24
|
+
fix_narrow_protrusion,
|
|
25
|
+
remove_narrow_protrusions,
|
|
26
|
+
fix_sharp_intrusion,
|
|
27
|
+
fix_narrow_passage,
|
|
28
|
+
fix_near_self_intersection,
|
|
29
|
+
fix_parallel_close_edges,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Overlap handling functions
|
|
33
|
+
from .overlap import (
|
|
34
|
+
split_overlap,
|
|
35
|
+
remove_overlaps,
|
|
36
|
+
count_overlaps,
|
|
37
|
+
find_overlapping_groups,
|
|
38
|
+
resolve_overlap_pair,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Merge functions
|
|
42
|
+
from .merge import merge_close_polygons
|
|
43
|
+
|
|
44
|
+
# Topology functions
|
|
45
|
+
from .topology import align_boundaries
|
|
46
|
+
|
|
47
|
+
# Geometry repair functions
|
|
48
|
+
from .repair import (
|
|
49
|
+
repair_geometry,
|
|
50
|
+
analyze_geometry,
|
|
51
|
+
batch_repair_geometries,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Robust constraint-aware repair
|
|
55
|
+
from .repair.robust import (
|
|
56
|
+
robust_fix_geometry,
|
|
57
|
+
robust_fix_batch,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Core types (enums)
|
|
61
|
+
from .core import (
|
|
62
|
+
OverlapStrategy,
|
|
63
|
+
MergeStrategy,
|
|
64
|
+
RepairStrategy,
|
|
65
|
+
CollapseMode,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Core exceptions
|
|
69
|
+
from .core import (
|
|
70
|
+
PolyforgeError,
|
|
71
|
+
ValidationError,
|
|
72
|
+
RepairError,
|
|
73
|
+
OverlapResolutionError,
|
|
74
|
+
MergeError,
|
|
75
|
+
ClearanceError,
|
|
76
|
+
ConfigurationError,
|
|
77
|
+
FixWarning,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Constraint framework
|
|
81
|
+
from .core import (
|
|
82
|
+
GeometryConstraints,
|
|
83
|
+
ConstraintStatus,
|
|
84
|
+
ConstraintViolation,
|
|
85
|
+
ConstraintType,
|
|
86
|
+
MergeConstraints,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
|
|
91
|
+
# Simplification
|
|
92
|
+
'simplify_rdp',
|
|
93
|
+
'simplify_vw',
|
|
94
|
+
'simplify_vwp',
|
|
95
|
+
'collapse_short_edges',
|
|
96
|
+
'deduplicate_vertices',
|
|
97
|
+
'remove_small_holes',
|
|
98
|
+
'remove_narrow_holes',
|
|
99
|
+
|
|
100
|
+
# Clearance fixing
|
|
101
|
+
'fix_clearance',
|
|
102
|
+
'fix_hole_too_close',
|
|
103
|
+
'fix_narrow_protrusion',
|
|
104
|
+
'remove_narrow_protrusions',
|
|
105
|
+
'fix_sharp_intrusion',
|
|
106
|
+
'fix_narrow_passage',
|
|
107
|
+
'fix_near_self_intersection',
|
|
108
|
+
'fix_parallel_close_edges',
|
|
109
|
+
|
|
110
|
+
# Overlap handling
|
|
111
|
+
'split_overlap',
|
|
112
|
+
'remove_overlaps',
|
|
113
|
+
'count_overlaps',
|
|
114
|
+
'find_overlapping_groups',
|
|
115
|
+
'resolve_overlap_pair',
|
|
116
|
+
|
|
117
|
+
# Merge
|
|
118
|
+
'merge_close_polygons',
|
|
119
|
+
|
|
120
|
+
# Topology
|
|
121
|
+
'align_boundaries',
|
|
122
|
+
|
|
123
|
+
# Geometry repair
|
|
124
|
+
'repair_geometry',
|
|
125
|
+
'analyze_geometry',
|
|
126
|
+
'batch_repair_geometries',
|
|
127
|
+
|
|
128
|
+
# Robust constraint-aware repair
|
|
129
|
+
'robust_fix_geometry',
|
|
130
|
+
'robust_fix_batch',
|
|
131
|
+
|
|
132
|
+
# Core types (enums)
|
|
133
|
+
'OverlapStrategy',
|
|
134
|
+
'MergeStrategy',
|
|
135
|
+
'RepairStrategy',
|
|
136
|
+
'CollapseMode',
|
|
137
|
+
|
|
138
|
+
# Core exceptions
|
|
139
|
+
'PolyforgeError',
|
|
140
|
+
'ValidationError',
|
|
141
|
+
'RepairError',
|
|
142
|
+
'OverlapResolutionError',
|
|
143
|
+
'MergeError',
|
|
144
|
+
'ClearanceError',
|
|
145
|
+
'ConfigurationError',
|
|
146
|
+
'FixWarning',
|
|
147
|
+
|
|
148
|
+
# Constraint framework
|
|
149
|
+
'GeometryConstraints',
|
|
150
|
+
'ConstraintStatus',
|
|
151
|
+
'ConstraintViolation',
|
|
152
|
+
'ConstraintType',
|
|
153
|
+
'MergeConstraints',
|
|
154
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Geometry clearance fixing functions.
|
|
2
|
+
|
|
3
|
+
This module provides functions for fixing geometries with low minimum clearance
|
|
4
|
+
by applying minimal geometric modifications. Each function targets a specific
|
|
5
|
+
type of clearance issue.
|
|
6
|
+
|
|
7
|
+
The minimum_clearance (from Shapely) represents the smallest distance by which
|
|
8
|
+
a vertex could be moved to create an invalid geometry. Higher values indicate
|
|
9
|
+
more robust, stable geometries.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Import public API functions from the ops layer
|
|
13
|
+
from polyforge.ops.clearance import (
|
|
14
|
+
fix_hole_too_close,
|
|
15
|
+
fix_narrow_protrusion,
|
|
16
|
+
fix_sharp_intrusion,
|
|
17
|
+
remove_narrow_protrusions,
|
|
18
|
+
fix_narrow_passage,
|
|
19
|
+
fix_near_self_intersection,
|
|
20
|
+
fix_parallel_close_edges,
|
|
21
|
+
)
|
|
22
|
+
from .fix_clearance import (
|
|
23
|
+
fix_clearance,
|
|
24
|
+
diagnose_clearance,
|
|
25
|
+
ClearanceIssue,
|
|
26
|
+
ClearanceDiagnosis,
|
|
27
|
+
ClearanceFixSummary,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Import utility functions from ops
|
|
31
|
+
from polyforge.ops.clearance import (
|
|
32
|
+
_find_nearest_vertex_index,
|
|
33
|
+
_find_nearest_edge_index,
|
|
34
|
+
_point_to_segment_distance,
|
|
35
|
+
_point_to_line_perpendicular_distance,
|
|
36
|
+
_get_vertex_neighborhood,
|
|
37
|
+
_calculate_curvature_at_vertex,
|
|
38
|
+
_remove_vertices_between,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# Main public API
|
|
43
|
+
'fix_clearance', # Auto-detection and fixing
|
|
44
|
+
'diagnose_clearance', # Diagnosis without fixing
|
|
45
|
+
'ClearanceIssue',
|
|
46
|
+
'ClearanceDiagnosis',
|
|
47
|
+
'ClearanceFixSummary',
|
|
48
|
+
'fix_hole_too_close',
|
|
49
|
+
'fix_narrow_protrusion',
|
|
50
|
+
'remove_narrow_protrusions',
|
|
51
|
+
'fix_sharp_intrusion',
|
|
52
|
+
'fix_narrow_passage',
|
|
53
|
+
'fix_near_self_intersection',
|
|
54
|
+
'fix_parallel_close_edges',
|
|
55
|
+
# Utility functions (for advanced users and tests)
|
|
56
|
+
# '_find_nearest_vertex_index',
|
|
57
|
+
# '_find_nearest_edge_index',
|
|
58
|
+
# '_point_to_segment_distance',
|
|
59
|
+
# '_get_vertex_neighborhood',
|
|
60
|
+
# '_calculate_curvature_at_vertex',
|
|
61
|
+
# '_remove_vertices_between',
|
|
62
|
+
]
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Automatic clearance detection and fixing.
|
|
2
|
+
|
|
3
|
+
This module provides an intelligent function that automatically diagnoses
|
|
4
|
+
clearance issues and applies the most appropriate fixing strategy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Callable, Dict, List, Optional, Tuple, Union
|
|
10
|
+
import numpy as np
|
|
11
|
+
import shapely
|
|
12
|
+
from shapely.geometry import Polygon, MultiPolygon, Point, LineString, LinearRing
|
|
13
|
+
from shapely.geometry.base import BaseGeometry
|
|
14
|
+
|
|
15
|
+
from polyforge.ops.clearance import (
|
|
16
|
+
fix_hole_too_close,
|
|
17
|
+
fix_narrow_protrusion,
|
|
18
|
+
remove_narrow_protrusions,
|
|
19
|
+
fix_narrow_passage,
|
|
20
|
+
fix_near_self_intersection,
|
|
21
|
+
fix_parallel_close_edges,
|
|
22
|
+
_find_nearest_vertex_index,
|
|
23
|
+
_calculate_curvature_at_vertex,
|
|
24
|
+
)
|
|
25
|
+
from polyforge.core.geometry_utils import to_single_polygon
|
|
26
|
+
from polyforge.core.types import HoleStrategy, PassageStrategy, IntersectionStrategy
|
|
27
|
+
from polyforge.core.iterative_utils import iterative_improve
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClearanceIssue(Enum):
|
|
31
|
+
"""Enumerates the types of clearance problems we can detect."""
|
|
32
|
+
|
|
33
|
+
NONE = "none"
|
|
34
|
+
HOLE_TOO_CLOSE = "hole_too_close"
|
|
35
|
+
NARROW_PROTRUSION = "narrow_protrusion"
|
|
36
|
+
NARROW_PASSAGE = "narrow_passage"
|
|
37
|
+
NEAR_SELF_INTERSECTION = "near_self_intersection"
|
|
38
|
+
PARALLEL_CLOSE_EDGES = "parallel_close_edges"
|
|
39
|
+
UNKNOWN = "unknown"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ClearanceDiagnosis:
|
|
44
|
+
"""
|
|
45
|
+
Result of analyzing a polygon's clearance.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
issue: Detected issue type.
|
|
49
|
+
meets_requirement: Whether min_clearance is already satisfied.
|
|
50
|
+
current_clearance: Measured minimum clearance.
|
|
51
|
+
clearance_ratio: current_clearance / min_clearance.
|
|
52
|
+
clearance_line: Location of the bottleneck if available.
|
|
53
|
+
recommended_fix: Name of the suggested fix function.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
issue: ClearanceIssue
|
|
57
|
+
meets_requirement: bool
|
|
58
|
+
current_clearance: float
|
|
59
|
+
clearance_ratio: float
|
|
60
|
+
clearance_line: Optional[LineString]
|
|
61
|
+
recommended_fix: str
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def has_issues(self) -> bool:
|
|
65
|
+
return not self.meets_requirement
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ClearanceFixSummary:
|
|
70
|
+
"""Metadata describing the fix process."""
|
|
71
|
+
|
|
72
|
+
initial_clearance: float
|
|
73
|
+
final_clearance: float
|
|
74
|
+
area_ratio: float
|
|
75
|
+
iterations: int
|
|
76
|
+
issue: ClearanceIssue
|
|
77
|
+
fixed: bool
|
|
78
|
+
valid: bool
|
|
79
|
+
history: List[ClearanceIssue]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fix_clearance(
|
|
83
|
+
geometry: Polygon,
|
|
84
|
+
min_clearance: float,
|
|
85
|
+
max_iterations: int = 10,
|
|
86
|
+
min_area_ratio: float = 0.9,
|
|
87
|
+
return_diagnosis: bool = False,
|
|
88
|
+
) -> Union[Polygon, Tuple[Polygon, ClearanceFixSummary]]:
|
|
89
|
+
"""Automatically diagnose and fix low minimum clearance in a polygon."""
|
|
90
|
+
if not isinstance(geometry, Polygon):
|
|
91
|
+
raise TypeError(f"Expected Polygon, got {type(geometry).__name__}")
|
|
92
|
+
|
|
93
|
+
if min_area_ratio <= 0 or min_area_ratio > 1.0:
|
|
94
|
+
raise ValueError("min_area_ratio must be in (0, 1].")
|
|
95
|
+
|
|
96
|
+
initial_clearance = geometry.minimum_clearance
|
|
97
|
+
original_area = geometry.area
|
|
98
|
+
summary = ClearanceFixSummary(
|
|
99
|
+
initial_clearance=initial_clearance,
|
|
100
|
+
final_clearance=initial_clearance,
|
|
101
|
+
area_ratio=1.0,
|
|
102
|
+
iterations=0,
|
|
103
|
+
issue=ClearanceIssue.NONE,
|
|
104
|
+
fixed=initial_clearance >= min_clearance,
|
|
105
|
+
valid=geometry.is_valid,
|
|
106
|
+
history=[ClearanceIssue.NONE],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if summary.fixed:
|
|
110
|
+
return (geometry, summary) if return_diagnosis else geometry
|
|
111
|
+
|
|
112
|
+
issue_history: List[ClearanceIssue] = []
|
|
113
|
+
best_valid = geometry
|
|
114
|
+
|
|
115
|
+
def improve(poly: Polygon, target: float) -> Optional[Polygon]:
|
|
116
|
+
diagnosis = diagnose_clearance(poly, target)
|
|
117
|
+
issue_history.append(diagnosis.issue)
|
|
118
|
+
if diagnosis.issue == ClearanceIssue.NONE:
|
|
119
|
+
return None
|
|
120
|
+
candidate = _apply_clearance_strategy(poly, target, diagnosis)
|
|
121
|
+
if candidate is None or not candidate.is_valid or candidate.is_empty:
|
|
122
|
+
return None
|
|
123
|
+
nonlocal best_valid
|
|
124
|
+
if candidate.area < min_area_ratio * original_area:
|
|
125
|
+
return None
|
|
126
|
+
if _safe_clearance(candidate) > _safe_clearance(best_valid):
|
|
127
|
+
best_valid = candidate
|
|
128
|
+
return candidate
|
|
129
|
+
|
|
130
|
+
metric = lambda poly: _safe_clearance(poly) # noqa: E731
|
|
131
|
+
improved = iterative_improve(
|
|
132
|
+
geometry,
|
|
133
|
+
target_value=min_clearance,
|
|
134
|
+
improve_func=improve,
|
|
135
|
+
metric_func=metric,
|
|
136
|
+
max_iterations=max_iterations,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if improved is None or not improved.is_valid or improved.is_empty:
|
|
140
|
+
improved = best_valid
|
|
141
|
+
if improved.area < min_area_ratio * original_area:
|
|
142
|
+
improved = best_valid
|
|
143
|
+
|
|
144
|
+
final_clearance = _safe_clearance(improved)
|
|
145
|
+
final_area_ratio = improved.area / original_area if original_area > 0 else float("inf")
|
|
146
|
+
final_diag = diagnose_clearance(improved, min_clearance)
|
|
147
|
+
summary = ClearanceFixSummary(
|
|
148
|
+
initial_clearance=initial_clearance,
|
|
149
|
+
final_clearance=final_clearance,
|
|
150
|
+
area_ratio=final_area_ratio,
|
|
151
|
+
iterations=len(issue_history),
|
|
152
|
+
issue=final_diag.issue,
|
|
153
|
+
fixed=final_diag.meets_requirement,
|
|
154
|
+
valid=improved.is_valid,
|
|
155
|
+
history=issue_history or [final_diag.issue],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return (improved, summary) if return_diagnosis else improved
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
StrategyFunc = Callable[[Polygon, float, ClearanceDiagnosis], Optional[Polygon]]
|
|
162
|
+
|
|
163
|
+
RECOMMENDED_FIXES: Dict[ClearanceIssue, str] = {
|
|
164
|
+
ClearanceIssue.NONE: "none",
|
|
165
|
+
ClearanceIssue.HOLE_TOO_CLOSE: "fix_hole_too_close",
|
|
166
|
+
ClearanceIssue.NARROW_PROTRUSION: "remove_narrow_protrusions",
|
|
167
|
+
ClearanceIssue.NARROW_PASSAGE: "fix_narrow_passage",
|
|
168
|
+
ClearanceIssue.NEAR_SELF_INTERSECTION: "fix_near_self_intersection",
|
|
169
|
+
ClearanceIssue.PARALLEL_CLOSE_EDGES: "fix_parallel_close_edges",
|
|
170
|
+
ClearanceIssue.UNKNOWN: "fix_narrow_passage",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
STRATEGY_REGISTRY: Dict[ClearanceIssue, StrategyFunc] = {}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _strategy(issue: ClearanceIssue) -> StrategyFunc:
|
|
177
|
+
return STRATEGY_REGISTRY.get(issue, _strategy_default)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply_clearance_strategy(
|
|
181
|
+
geometry: Polygon,
|
|
182
|
+
min_clearance: float,
|
|
183
|
+
diagnosis: ClearanceDiagnosis,
|
|
184
|
+
) -> Optional[Polygon]:
|
|
185
|
+
handler = _strategy(diagnosis.issue)
|
|
186
|
+
candidate = handler(geometry, min_clearance, diagnosis)
|
|
187
|
+
return _normalize_polygon(candidate)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _register_strategy(issue: ClearanceIssue):
|
|
191
|
+
def decorator(func: StrategyFunc) -> StrategyFunc:
|
|
192
|
+
STRATEGY_REGISTRY[issue] = func
|
|
193
|
+
return func
|
|
194
|
+
|
|
195
|
+
return decorator
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@_register_strategy(ClearanceIssue.HOLE_TOO_CLOSE)
|
|
199
|
+
def _strategy_hole_too_close(
|
|
200
|
+
geometry: Polygon,
|
|
201
|
+
min_clearance: float,
|
|
202
|
+
_: ClearanceDiagnosis,
|
|
203
|
+
) -> Optional[Polygon]:
|
|
204
|
+
fixed = fix_hole_too_close(geometry, min_clearance, strategy=HoleStrategy.REMOVE)
|
|
205
|
+
return fixed
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@_register_strategy(ClearanceIssue.NARROW_PROTRUSION)
|
|
209
|
+
def _strategy_narrow_protrusion(
|
|
210
|
+
geometry: Polygon,
|
|
211
|
+
min_clearance: float,
|
|
212
|
+
diagnosis: ClearanceDiagnosis,
|
|
213
|
+
) -> Optional[Polygon]:
|
|
214
|
+
baseline = diagnosis.current_clearance
|
|
215
|
+
first_pass = remove_narrow_protrusions(geometry, aspect_ratio_threshold=10.0)
|
|
216
|
+
if first_pass.is_valid and _safe_clearance(first_pass) > baseline:
|
|
217
|
+
return first_pass
|
|
218
|
+
return fix_narrow_protrusion(geometry, min_clearance)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@_register_strategy(ClearanceIssue.NARROW_PASSAGE)
|
|
222
|
+
def _strategy_narrow_passage(
|
|
223
|
+
geometry: Polygon,
|
|
224
|
+
min_clearance: float,
|
|
225
|
+
_: ClearanceDiagnosis,
|
|
226
|
+
) -> Optional[Polygon]:
|
|
227
|
+
return fix_narrow_passage(geometry, min_clearance, strategy=PassageStrategy.WIDEN)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@_register_strategy(ClearanceIssue.NEAR_SELF_INTERSECTION)
|
|
231
|
+
def _strategy_near_self_intersection(
|
|
232
|
+
geometry: Polygon,
|
|
233
|
+
min_clearance: float,
|
|
234
|
+
_: ClearanceDiagnosis,
|
|
235
|
+
) -> Optional[Polygon]:
|
|
236
|
+
return fix_near_self_intersection(
|
|
237
|
+
geometry,
|
|
238
|
+
min_clearance,
|
|
239
|
+
strategy=IntersectionStrategy.SIMPLIFY,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@_register_strategy(ClearanceIssue.PARALLEL_CLOSE_EDGES)
|
|
244
|
+
def _strategy_parallel_edges(
|
|
245
|
+
geometry: Polygon,
|
|
246
|
+
min_clearance: float,
|
|
247
|
+
_: ClearanceDiagnosis,
|
|
248
|
+
) -> Optional[Polygon]:
|
|
249
|
+
return fix_parallel_close_edges(
|
|
250
|
+
geometry,
|
|
251
|
+
min_clearance,
|
|
252
|
+
strategy=IntersectionStrategy.SIMPLIFY,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _strategy_default(
|
|
257
|
+
geometry: Polygon,
|
|
258
|
+
min_clearance: float,
|
|
259
|
+
_: ClearanceDiagnosis,
|
|
260
|
+
) -> Optional[Polygon]:
|
|
261
|
+
return fix_narrow_passage(geometry, min_clearance, strategy=PassageStrategy.WIDEN)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _normalize_polygon(candidate: Optional[BaseGeometry]) -> Optional[Polygon]:
|
|
265
|
+
if candidate is None:
|
|
266
|
+
return None
|
|
267
|
+
if candidate.is_empty or not candidate.is_valid:
|
|
268
|
+
return None
|
|
269
|
+
polygon = to_single_polygon(candidate)
|
|
270
|
+
return polygon if polygon.is_valid and not polygon.is_empty else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _safe_clearance(geometry: Polygon) -> float:
|
|
274
|
+
try:
|
|
275
|
+
return float(geometry.minimum_clearance)
|
|
276
|
+
except Exception:
|
|
277
|
+
return 0.0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _diagnose_clearance_issue(
|
|
281
|
+
geometry: Polygon,
|
|
282
|
+
min_clearance: float
|
|
283
|
+
) -> ClearanceIssue:
|
|
284
|
+
"""Diagnose the type of clearance issue in a polygon.
|
|
285
|
+
|
|
286
|
+
Examines the geometry's minimum_clearance_line and surrounding geometry
|
|
287
|
+
to determine what type of issue is causing low clearance.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
geometry: Input polygon
|
|
291
|
+
min_clearance: Target minimum clearance
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
A :class:`ClearanceIssue` describing the detected problem.
|
|
295
|
+
"""
|
|
296
|
+
if _has_close_hole(geometry, min_clearance):
|
|
297
|
+
return ClearanceIssue.HOLE_TOO_CLOSE
|
|
298
|
+
|
|
299
|
+
context = _build_clearance_context(geometry)
|
|
300
|
+
if context is None:
|
|
301
|
+
return ClearanceIssue.UNKNOWN
|
|
302
|
+
|
|
303
|
+
if "hole" in context.ring_types:
|
|
304
|
+
return ClearanceIssue.HOLE_TOO_CLOSE
|
|
305
|
+
|
|
306
|
+
for heuristic in (
|
|
307
|
+
_looks_like_protrusion,
|
|
308
|
+
_looks_like_near_self_intersection,
|
|
309
|
+
_looks_like_parallel_edges,
|
|
310
|
+
_default_clearance_issue,
|
|
311
|
+
):
|
|
312
|
+
issue = heuristic(context, min_clearance)
|
|
313
|
+
if issue is not None:
|
|
314
|
+
return issue
|
|
315
|
+
return ClearanceIssue.UNKNOWN
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _classify_ring_types(geometry: Polygon, pt1: Tuple[float, float], pt2: Tuple[float, float]) -> Tuple[str, str]:
|
|
319
|
+
"""Return ring type ('exterior' or 'hole') for each endpoint of the clearance line."""
|
|
320
|
+
def classify(point: Tuple[float, float]) -> Tuple[str, Optional[int]]:
|
|
321
|
+
p = Point(point)
|
|
322
|
+
exterior_ring = LinearRing(geometry.exterior.coords)
|
|
323
|
+
d_exterior = p.distance(exterior_ring)
|
|
324
|
+
|
|
325
|
+
min_hole_dist = float("inf")
|
|
326
|
+
hole_idx = None
|
|
327
|
+
for idx, hole in enumerate(geometry.interiors):
|
|
328
|
+
d = p.distance(LinearRing(hole.coords))
|
|
329
|
+
if d < min_hole_dist:
|
|
330
|
+
min_hole_dist = d
|
|
331
|
+
hole_idx = idx
|
|
332
|
+
|
|
333
|
+
if min_hole_dist < d_exterior:
|
|
334
|
+
return "hole", hole_idx
|
|
335
|
+
return "exterior", None
|
|
336
|
+
|
|
337
|
+
t1, _ = classify(pt1)
|
|
338
|
+
t2, _ = classify(pt2)
|
|
339
|
+
return t1, t2
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _has_close_hole(geometry: Polygon, min_clearance: float) -> bool:
|
|
343
|
+
if not geometry.interiors:
|
|
344
|
+
return False
|
|
345
|
+
exterior_ring = LinearRing(geometry.exterior.coords)
|
|
346
|
+
for hole in geometry.interiors:
|
|
347
|
+
hole_ring = LinearRing(hole.coords)
|
|
348
|
+
if hole_ring.distance(exterior_ring) < min_clearance:
|
|
349
|
+
return True
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _build_clearance_context(geometry: Polygon) -> Optional["ClearanceContext"]:
|
|
354
|
+
try:
|
|
355
|
+
clearance_line = shapely.minimum_clearance_line(geometry)
|
|
356
|
+
except Exception:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
if clearance_line.is_empty:
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
coords_2d = np.array(clearance_line.coords)
|
|
363
|
+
if len(coords_2d) < 2:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
pt1, pt2 = coords_2d[:2]
|
|
367
|
+
exterior_coords = np.array(geometry.exterior.coords)
|
|
368
|
+
idx1 = _find_nearest_vertex_index(exterior_coords, pt1)
|
|
369
|
+
idx2 = _find_nearest_vertex_index(exterior_coords, pt2)
|
|
370
|
+
curvature1 = _calculate_curvature_at_vertex(exterior_coords, idx1)
|
|
371
|
+
curvature2 = _calculate_curvature_at_vertex(exterior_coords, idx2)
|
|
372
|
+
n = len(exterior_coords) - 1
|
|
373
|
+
separation = min(abs(idx2 - idx1), n - abs(idx2 - idx1))
|
|
374
|
+
ring_types = _classify_ring_types(geometry, pt1, pt2)
|
|
375
|
+
|
|
376
|
+
return ClearanceContext(
|
|
377
|
+
curvature=(curvature1, curvature2),
|
|
378
|
+
separation=separation,
|
|
379
|
+
vertex_count=n,
|
|
380
|
+
ring_types=ring_types,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass
|
|
385
|
+
class ClearanceContext:
|
|
386
|
+
curvature: Tuple[float, float]
|
|
387
|
+
separation: int
|
|
388
|
+
vertex_count: int
|
|
389
|
+
ring_types: Tuple[str, str]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _looks_like_protrusion(context: ClearanceContext, _: float) -> Optional[ClearanceIssue]:
|
|
393
|
+
if "hole" in context.ring_types:
|
|
394
|
+
return None
|
|
395
|
+
curvature1, curvature2 = context.curvature
|
|
396
|
+
sharp_angle_threshold = 135.0
|
|
397
|
+
if curvature1 > sharp_angle_threshold or curvature2 > sharp_angle_threshold:
|
|
398
|
+
return ClearanceIssue.NARROW_PROTRUSION
|
|
399
|
+
|
|
400
|
+
if context.separation <= 3 and (curvature1 > 90 or curvature2 > 90):
|
|
401
|
+
return ClearanceIssue.NARROW_PROTRUSION
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _looks_like_near_self_intersection(context: ClearanceContext, _: float) -> Optional[ClearanceIssue]:
|
|
406
|
+
if "hole" in context.ring_types:
|
|
407
|
+
return None
|
|
408
|
+
if context.separation <= 3:
|
|
409
|
+
curvature1, curvature2 = context.curvature
|
|
410
|
+
if curvature1 <= 90 and curvature2 <= 90:
|
|
411
|
+
return ClearanceIssue.NEAR_SELF_INTERSECTION
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _looks_like_parallel_edges(context: ClearanceContext, _: float) -> Optional[ClearanceIssue]:
|
|
416
|
+
if "hole" in context.ring_types:
|
|
417
|
+
return None
|
|
418
|
+
if context.vertex_count <= 0:
|
|
419
|
+
return None
|
|
420
|
+
if context.separation >= context.vertex_count // 3:
|
|
421
|
+
return ClearanceIssue.PARALLEL_CLOSE_EDGES
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _default_clearance_issue(_: ClearanceContext, __: float) -> ClearanceIssue:
|
|
426
|
+
return ClearanceIssue.NARROW_PASSAGE
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def diagnose_clearance(
|
|
430
|
+
geometry: Polygon,
|
|
431
|
+
min_clearance: float
|
|
432
|
+
) -> ClearanceDiagnosis:
|
|
433
|
+
"""Diagnose clearance issues without fixing them."""
|
|
434
|
+
if not isinstance(geometry, Polygon):
|
|
435
|
+
raise TypeError(f"Expected Polygon, got {type(geometry).__name__}")
|
|
436
|
+
|
|
437
|
+
current_clearance = geometry.minimum_clearance
|
|
438
|
+
meets_requirement = current_clearance >= min_clearance
|
|
439
|
+
ratio = current_clearance / min_clearance if min_clearance > 0 else float("inf")
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
clearance_line = shapely.minimum_clearance_line(geometry)
|
|
443
|
+
except Exception:
|
|
444
|
+
clearance_line = None
|
|
445
|
+
|
|
446
|
+
if meets_requirement:
|
|
447
|
+
return ClearanceDiagnosis(
|
|
448
|
+
issue=ClearanceIssue.NONE,
|
|
449
|
+
meets_requirement=True,
|
|
450
|
+
current_clearance=current_clearance,
|
|
451
|
+
clearance_ratio=ratio,
|
|
452
|
+
clearance_line=clearance_line,
|
|
453
|
+
recommended_fix=RECOMMENDED_FIXES[ClearanceIssue.NONE],
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
issue = _diagnose_clearance_issue(geometry, min_clearance)
|
|
457
|
+
recommended = RECOMMENDED_FIXES.get(issue, RECOMMENDED_FIXES[ClearanceIssue.UNKNOWN])
|
|
458
|
+
return ClearanceDiagnosis(
|
|
459
|
+
issue=issue,
|
|
460
|
+
meets_requirement=False,
|
|
461
|
+
current_clearance=current_clearance,
|
|
462
|
+
clearance_ratio=ratio,
|
|
463
|
+
clearance_line=clearance_line,
|
|
464
|
+
recommended_fix=recommended,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
__all__ = [
|
|
469
|
+
'fix_clearance',
|
|
470
|
+
'diagnose_clearance',
|
|
471
|
+
'ClearanceIssue',
|
|
472
|
+
'ClearanceDiagnosis',
|
|
473
|
+
'ClearanceFixSummary',
|
|
474
|
+
]
|