masque/masque/utils/boolean.py
2026-02-16 20:48:15 -08:00

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