182 lines
6.4 KiB
Python
182 lines
6.4 KiB
Python
from typing import Any, Literal
|
|
from collections.abc import Iterable
|
|
import logging
|
|
|
|
import numpy
|
|
from numpy.typing import NDArray
|
|
|
|
from ..shapes.polygon import Polygon
|
|
from ..error import PatternError
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]:
|
|
"""
|
|
Bridge multiple holes into an outer boundary using zero-width slits.
|
|
"""
|
|
current_outer = outer_path
|
|
|
|
# Sort holes by max X to potentially minimize bridge lengths or complexity
|
|
# (though not strictly necessary for correctness)
|
|
holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True)
|
|
|
|
for hole in holes:
|
|
# Find max X vertex of hole
|
|
max_idx = numpy.argmax(hole[:, 0])
|
|
m = hole[max_idx]
|
|
|
|
# Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges
|
|
best_t = numpy.inf
|
|
best_pt = None
|
|
best_edge_idx = -1
|
|
|
|
n = len(current_outer)
|
|
for i in range(n):
|
|
p1 = current_outer[i]
|
|
p2 = current_outer[(i + 1) % n]
|
|
|
|
# Check if edge (p1, p2) spans m.y
|
|
if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]):
|
|
# Intersection x:
|
|
# x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y)
|
|
t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0]
|
|
if 0 <= t < best_t:
|
|
best_t = t
|
|
best_pt = numpy.array([m[0] + t, m[1]])
|
|
best_edge_idx = i
|
|
|
|
if best_edge_idx == -1:
|
|
# Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole)
|
|
dists = numpy.linalg.norm(current_outer - m, axis=1)
|
|
best_edge_idx = int(numpy.argmin(dists))
|
|
best_pt = current_outer[best_edge_idx]
|
|
# Adjust best_edge_idx to insert AFTER this vertex
|
|
# (treating it as a degenerate edge)
|
|
|
|
assert best_pt is not None
|
|
|
|
# Reorder hole vertices to start at m
|
|
hole_reordered = numpy.roll(hole, -max_idx, axis=0)
|
|
|
|
# Construct new outer:
|
|
# 1. Start of outer up to best_edge_idx
|
|
# 2. Intersection point
|
|
# 3. Hole vertices (starting and ending at m)
|
|
# 4. Intersection point (to close slit)
|
|
# 5. Rest of outer
|
|
|
|
new_outer: list[NDArray[numpy.float64]] = []
|
|
new_outer.extend(current_outer[:best_edge_idx + 1])
|
|
new_outer.append(best_pt)
|
|
new_outer.extend(hole_reordered)
|
|
new_outer.append(hole_reordered[0]) # close hole loop at m
|
|
new_outer.append(best_pt) # back to outer
|
|
new_outer.extend(current_outer[best_edge_idx + 1:])
|
|
|
|
current_outer = numpy.array(new_outer)
|
|
|
|
return current_outer
|
|
|
|
def boolean(
|
|
subjects: Iterable[Any],
|
|
clips: Iterable[Any] | None = None,
|
|
operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union',
|
|
scale: float = 1e6,
|
|
) -> list[Polygon]:
|
|
"""
|
|
Perform a boolean operation on two sets of polygons.
|
|
|
|
Args:
|
|
subjects: List of subjects (Polygons or vertex arrays).
|
|
clips: List of clips (Polygons or vertex arrays).
|
|
operation: The boolean operation to perform.
|
|
scale: Scaling factor for integer conversion (pyclipper uses integers).
|
|
|
|
Returns:
|
|
A list of result Polygons.
|
|
"""
|
|
try:
|
|
import pyclipper #noqa: PLC0415
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Boolean operations require 'pyclipper'. "
|
|
"Install it with 'pip install pyclipper' or 'pip install masque[boolean]'."
|
|
) from None
|
|
|
|
op_map = {
|
|
'union': pyclipper.PT_UNION,
|
|
'intersection': pyclipper.PT_INTERSECTION,
|
|
'difference': pyclipper.PT_DIFFERENCE,
|
|
'xor': pyclipper.PT_XOR,
|
|
}
|
|
|
|
def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]:
|
|
if objs is None:
|
|
return []
|
|
verts = []
|
|
for obj in objs:
|
|
if hasattr(obj, 'to_polygons'):
|
|
for p in obj.to_polygons():
|
|
verts.append(p.vertices)
|
|
elif isinstance(obj, numpy.ndarray):
|
|
verts.append(obj)
|
|
elif isinstance(obj, Polygon):
|
|
verts.append(obj.vertices)
|
|
else:
|
|
# Try to iterate if it's an iterable of shapes
|
|
try:
|
|
for sub in obj:
|
|
if hasattr(sub, 'to_polygons'):
|
|
for p in sub.to_polygons():
|
|
verts.append(p.vertices)
|
|
elif isinstance(sub, Polygon):
|
|
verts.append(sub.vertices)
|
|
except TypeError:
|
|
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
|
|
return verts
|
|
|
|
subject_verts = to_vertices(subjects)
|
|
clip_verts = to_vertices(clips)
|
|
|
|
pc = pyclipper.Pyclipper()
|
|
pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True)
|
|
if clip_verts:
|
|
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
|
|
|
|
# Use GetPolyTree to distinguish between outers and holes
|
|
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
|
|
|
|
result_polygons = []
|
|
|
|
def process_node(node: Any) -> None:
|
|
if not node.IsHole:
|
|
# This is an outer boundary
|
|
outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale))
|
|
|
|
# Find immediate holes
|
|
holes = []
|
|
for child in node.Childs:
|
|
if child.IsHole:
|
|
holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale)))
|
|
|
|
if holes:
|
|
combined_vertices = _bridge_holes(outer_path, holes)
|
|
result_polygons.append(Polygon(combined_vertices))
|
|
else:
|
|
result_polygons.append(Polygon(outer_path))
|
|
|
|
# Recursively process children of holes (which are nested outers)
|
|
for child in node.Childs:
|
|
if child.IsHole:
|
|
for grandchild in child.Childs:
|
|
process_node(grandchild)
|
|
else:
|
|
# Holes are processed as children of outers
|
|
pass
|
|
|
|
for top_node in polytree.Childs:
|
|
process_node(top_node)
|
|
|
|
return result_polygons
|