Source code for oowl.layouts.fitters

"""Strategies for maximally fitting layout geometry into a boundary."""

from __future__ import annotations

import weakref
from abc import ABC, abstractmethod
from functools import cache
from itertools import combinations
from typing import TYPE_CHECKING, Any

import numpy as np
import shapely

from .._random import farthest_point_sample
from .._repr import _ArgumentRepr
from .._validation import _validate_integer, _validate_positive

if TYPE_CHECKING:  # pragma: no cover
    from ..boundaries.base import Boundary
    from .base import LayoutAlgorithm
    from .candidate import _CandidateLayout

__all__ = [
    "Fitter",
    "RigidPatternFitter",
    "SelectedCandidateFitter",
    "SelectedCircularFitter",
    "SelectedContourFitter",
    "SelectedGridFitter",
]

#: Each scale the rigid fit scans is this fraction of the one before it.
_SCAN_RATIO = 0.9
#: Enough scan steps to reach about a thousandth of the upper bound.
_SCAN_STEPS = 64
#: Points of a rigid pattern ordered to spread out before the rest, so the
#: first few cut the region of fitting translations the most.
_SPREAD_POINTS = 32
#: The rigid fit tries the scale its region's edges predict this fraction of
#: the tolerance either side, so a right prediction closes the bracket.
_MEETING_MARGIN = 1e-3
#: Most edge lines of a region the rigid fit predicts a scale from.
_MEETING_LINES = 24


[docs] class Fitter(_ArgumentRepr, ABC): """Base strategy for fitting one layout family to one polygonal farm."""
[docs] @abstractmethod def fit( self, layout: Any, boundary: Boundary, pattern: np.ndarray | None, rng: np.random.Generator, hint: tuple[float, np.ndarray] | None = None, ) -> tuple[np.ndarray, dict]: """Return fitted positions and JSON-compatible fit metadata. Pattern layouts pass their ``(N, 2)`` point ``pattern`` and an optional verified ``(scale, translation)`` ``hint``. Candidate layouts build their own candidate field, so they pass ``pattern=None`` and no hint. """
[docs] class RigidPatternFitter(Fitter): """Maximally scale and translate an exact finite point pattern. The scale is searched for at most ``max_iterations`` steps, stopping when the bracket is within ``tolerance`` relative to the upper bound. A step tries the scale at which the lines of the fitting region's edges meet, just either side of it, or bisects when that does not halve the bracket. """ def __init__(self, *, max_iterations: int = 50, tolerance: float = 1e-6): self.max_iterations = _validate_integer("max_iterations", max_iterations) self.tolerance = _validate_positive("tolerance", tolerance)
[docs] def fit( self, layout: LayoutAlgorithm, boundary: Boundary, pattern: np.ndarray | None, rng: np.random.Generator, hint: tuple[float, np.ndarray] | None = None, ) -> tuple[np.ndarray, dict]: """Return the largest scaled and translated ``pattern`` inside ``boundary``. ``pattern`` has shape ``(N, 2)``. Positions come back in meters with fit metadata. An optional ``(scale, translation)`` ``hint`` that is verified feasible becomes a lower bound on the scale. A missing or empty ``pattern`` raises ``ValueError``. """ if pattern is None: raise ValueError("RigidPatternFitter requires a point pattern") pattern = np.asarray(pattern, dtype=float).reshape(-1, 2) if not len(pattern): raise ValueError("cannot fit an empty pattern") geom = boundary.geometry minx, miny, maxx, maxy = boundary.bounds width, height = maxx - minx, maxy - miny pattern_width = float(np.ptp(pattern[:, 0])) pattern_height = float(np.ptp(pattern[:, 1])) limits = [] if pattern_width > 0: limits.append(width / pattern_width) if pattern_height > 0: limits.append(height / pattern_height) upper = min(limits) if limits else max(width, height) lower = 0.0 best_scale = 0.0 # Use an interior point for the defensive zero-scale fallback. fallback_point = geom.representative_point() best_translation = np.array([fallback_point.x, fallback_point.y]) # The region of fitting translations at ``best_scale`` once built, or # ``None`` where rounding left it empty, the points whose shifted # boundaries were cut to build it, and the scale it was built at. best_region = best_used = None region_scale = None def fits(scale, translation): return bool(boundary._contains(pattern * scale + translation).all()) # The translations that fit are the boundary shifted back by each # scaled point, all intersected. Points far apart cut that region the # most, so they go first, behind those that cut it at the last scale. shapely.prepare(geom) spread = _spread_order(pattern) # Inside a convex boundary the pattern fits exactly where the corners # of its hull do, so only they cut the region, which a farm of 380 # turbines otherwise cut once per turbine. corners = _hull_corners(pattern) if _is_convex(geom) else None if corners is not None: spread = spread[np.isin(spread, corners)] cutting = spread[:0] edges = _edges(geom) def feasible_region(scale): """The translations fitting the scaled pattern inside, or ``None``.""" nonlocal cutting # Only the turbines must fit, not the pattern's centroid, which may # sit over a hole or between the parts of a multipart boundary. order = np.concatenate([cutting, spread[~np.isin(spread, cutting)]]) shifts = scale * pattern region = _shifted(geom, -shifts[order[0]]) used = [order[0]] pending = order[1:] # Points whose shifted boundary holds the region's box cannot cut # it, and the box only shrinks, so once held they are dropped for # good. Checked after every second cut, a pattern of 1,238 along # the edges of a holed boundary checked a million boxes per fit, # so the checks follow cuts 1, 3, 7, 15 and on. check = 1 while len(pending): index, pending = pending[0], pending[1:] region = region.intersection(_shifted(geom, -shifts[index])) used.append(index) if region.is_empty: cutting = np.array(used) return None if len(used) - 1 == check and len(pending): check = 2 * check + 1 pending = pending[~_holds(geom, region.bounds, shifts[pending])] cutting = np.array(used) return region def placement(scale): """A fitting translation and the region it came from, if built.""" # Keep the last verified placement while it still fits, so a # marginally larger scale does not relocate the whole pattern. if best_scale > 0 and fits(scale, best_translation): return best_translation, None region = feasible_region(scale) if region is None: return None, None # representative_point() stays inside non-convex regions, and the # direct check rejects points that rounding left a hair outside. point = region.representative_point() translation = np.array([point.x, point.y]) return (translation, region) if fits(scale, translation) else (None, None) def probe(scale): """Whether the scaled pattern fits, narrowing the bracket either way.""" nonlocal lower, upper, best_scale, best_translation translation, region = placement(scale) if translation is None: upper = scale return False lower = best_scale = scale best_translation = translation if region is not None: built(region) return True def built(region): """Keep ``region`` as the one at ``best_scale``.""" nonlocal best_region, best_used, region_scale best_region, best_used, region_scale = region, cutting, best_scale def best(): """The region at ``best_scale``, built if it was not.""" if region_scale != best_scale: built(feasible_region(best_scale)) return best_region # A hint that passes the direct containment check bounds the scale. if hint is not None: hint_scale, hint_translation = hint hint_scale = float(hint_scale) hint_translation = np.asarray(hint_translation, dtype=float) if 0 < hint_scale < upper: if fits(hint_scale, hint_translation): lower = best_scale = hint_scale best_translation = hint_translation # Feasibility is not monotone in scale on concave, holed or multipart # boundaries, so bisecting from zero can miss the largest fit. Scan # down from the upper bound, then search the step that first fits. scale = upper for _ in range(_SCAN_STEPS): if scale <= lower or probe(scale): break scale *= _SCAN_RATIO # Near the largest scale the region is a small polygon, each edge on a # boundary edge shifted back by one scaled point. Those lines move # with the scale, so where they meet predicts the largest scale. predict = True for _ in range(self.max_iterations): if upper - lower <= self.tolerance * max(upper, 1.0): break meeting = None if predict and best_scale > 0 and best() is not None: meeting = _meeting_scale( best_region, best_scale, pattern, best_used, edges ) if meeting is None or not lower < meeting[0] < upper: probe(0.5 * (lower + upper)) predict = True continue width = upper - lower scale, cutting = meeting # The points whose lines meet cut the region first either side. margin = _MEETING_MARGIN * self.tolerance * max(scale, 1.0) if (scale - margin <= lower or probe(scale - margin)) and ( scale + margin < upper ): probe(scale + margin) # A prediction that does not halve the bracket is followed by a # bisection. predict = upper - lower <= 0.5 * width # The search keeps the first placement that fits, which leaves the # pattern against one edge wherever its region has slack. The centroid # of the region at the final scale shares that slack out. if best_scale > 0: region = best() if region is not None: center = np.asarray(region.centroid.coords[0]) if fits(best_scale, center): best_translation = center positions = pattern * best_scale + best_translation return positions, { "method": "rigid_pattern", "scale": float(best_scale), "translation": best_translation.tolist(), }
[docs] class SelectedCandidateFitter(Fitter): """Fit a scalable candidate field and occupy exactly ``N`` positions. The field is tried at ``n_phases`` by ``n_phases`` offsets. The largest feasible scale is found on ``search_intervals`` geometric steps, down to a bottom on a ladder of quarter octaves so that fits at nearby counts share their steps, then on ``max_iterations`` linear steps inside the best one. A step at which the layout bounds every field below ``N`` candidates is passed over without building its fields. The fields of the last few boundaries are kept, so fitting a nearby count again, as a spacing search does, builds few new ones, and its result is that of a first fit. Fields above ``candidate_cap`` points are skipped. ``max_evaluations`` caps the field evaluations, kept ones included, and defaults to the most the search can need. The selected positions are then enlarged by ``pattern_fitter`` when the layout allows. """ fit_method = "selected_candidates" def __init__( self, *, n_phases: int = 6, max_iterations: int = 40, pattern_fitter: RigidPatternFitter | None = None, candidate_cap: int = 100_000, max_evaluations: int | None = None, search_intervals: int = 128, ): n_phases = _validate_integer("n_phases", n_phases) max_iterations = _validate_integer("max_iterations", max_iterations) candidate_cap = _validate_integer("candidate_cap", candidate_cap) if max_evaluations is not None: max_evaluations = _validate_integer("max_evaluations", max_evaluations) search_intervals = _validate_integer( "search_intervals", search_intervals, minimum=2 ) # Each phase is probed at most once per scale, at the initial estimate, # 32 shrinking floors, the scan's search_intervals + 1 scales and the # max_iterations refinements. required = n_phases**2 * (search_intervals + max_iterations + 34) if max_evaluations is None: max_evaluations = required elif required > max_evaluations: raise ValueError( f"max_evaluations of {max_evaluations} is too small for " f"{n_phases}x{n_phases} phases, which need {required}" ) self.n_phases = n_phases self.max_iterations = max_iterations self.pattern_fitter = pattern_fitter or RigidPatternFitter() self.candidate_cap = candidate_cap self.max_evaluations = max_evaluations self.search_intervals = search_intervals def _phases(self): fractions = (np.arange(self.n_phases) + 0.5) / self.n_phases return tuple((float(x), float(y)) for x in fractions for y in fractions) @staticmethod def _contained(points, boundary) -> bool: """Whether every one of ``points`` lies inside ``boundary``.""" if not len(points): return True candidates = np.asarray(points, dtype=float).reshape(-1, 2) return bool(boundary._contains(candidates).all())
[docs] def fit( self, layout: _CandidateLayout, boundary: Boundary, pattern: np.ndarray | None, rng: np.random.Generator, hint: tuple[float, np.ndarray] | None = None, ) -> tuple[np.ndarray, dict]: """Return exactly ``N`` positions from a bounded candidate-scale search. Candidate count is not monotone in scale on concave polygons, so the whole scale interval is scanned for the largest feasible scale before refining it, passing over the scales no field can fill. Candidate allocation and field evaluations are both capped. """ # The layout supplies the candidate field, so there is no pattern or hint. del pattern, hint n_turbines = layout._required_n if n_turbines > self.candidate_cap: raise ValueError(f"n_turbines exceeds candidate_cap={self.candidate_cap}") phases = self._phases() required = layout._required_candidates(boundary) evaluations = 0 cache: dict[float, list[np.ndarray | None]] = {} memo = _field_memo(self, layout, boundary, phases) def candidates(scale, hopeful=None): """Return phase fields. ``None`` means the allocation cap was hit. A phase ``hopeful`` rules out holds fewer than ``N`` candidates, so its field is left empty without being built. """ nonlocal evaluations scale = float(scale) if scale in cache: return cache[scale] built = range(len(phases)) if hopeful is None else np.flatnonzero(hopeful) if evaluations + len(built) > self.max_evaluations: raise ValueError( f"candidate search exceeded max_evaluations={self.max_evaluations}" ) # A kept field counts as evaluated, so the search and its record # are those of a first fit. evaluations += len(built) known = memo.get(scale) if known is not None and known[1] <= n_turbines: cache[scale] = known[0] return known[0] if len(built) == len(phases): fields = layout._candidate_fields( boundary, scale, phases, self.candidate_cap, n_turbines ) else: fields: list[np.ndarray | None] = [np.empty((0, 2))] * len(phases) subset = tuple(phases[index] for index in built) for index, field in zip( built, layout._candidate_fields( boundary, scale, subset, self.candidate_cap, n_turbines ), strict=True, ): fields[index] = field # Every field built in full serves any count. One left empty, or # every one cut short below N, shows no more than that N do not fit. whole = hopeful is None and any( field is None or len(field) for field in fields ) memo[scale] = (fields, 0 if whole else n_turbines) cache[scale] = fields return fields def feasible(scale, hopeful=None): return any( points is None or len(points) >= n_turbines for points in candidates(scale, hopeful) ) initial = max(float(layout.estimate_spacing(boundary)), 1e-12) if not np.isfinite(initial): raise ValueError("estimated candidate spacing must be finite") if len(required) == n_turbines and self._contained(required, boundary): origin = layout._candidate_origin(boundary) metadata = { "method": self.fit_method, "scale": initial, "translation": [0.0, 0.0], "phase": list(phases[0]), "candidate_count": int(n_turbines), "candidate_evaluations": 0, "candidate_cap": self.candidate_cap, } if origin is not None: metadata["origin"] = np.asarray(origin, dtype=float).tolist() return np.asarray(required, dtype=float).reshape(-1, 2), metadata floor = initial if not feasible(floor): for _ in range(32): floor /= 1.5 if feasible(floor): break else: raise ValueError(f"could not generate {n_turbines} candidates") minx, miny, maxx, maxy = boundary.bounds diagonal = float(np.hypot(maxx - minx, maxy - miny)) upper = max(initial * 8.0, diagonal * 2.0, floor) # The grid's bottom lies on a ladder of quarter octaves below its top, # so fits at nearby counts share their scales and so their fields. # The feasible floor joins the scan. bottom = upper * 2.0 ** (-np.ceil(4.0 * np.log2(upper / floor)) / 4.0) scales = np.unique( np.append(np.geomspace(bottom, upper, self.search_intervals + 1), floor) ) # Scales at which no phase can hold N candidates are passed over # without building their fields. bound = layout._candidate_bounds(boundary, floor, phases, self.candidate_cap) def possible(scales): """Indices of ``scales`` where a phase can hold ``N`` candidates. Each comes with those phases, or ``None`` where every phase can. """ if bound is None: return [(index, None) for index in range(len(scales))] able = bound(scales) >= n_turbines some, every = able.any(axis=1), able.all(axis=1) return [ (index, None if every[index] else able[index]) for index in np.flatnonzero(some) ] # Only the largest feasible scale matters, so scan from the top and stop # at the first one that fits. best_index = next( ( index for index, hopeful in possible(scales)[::-1] if feasible(scales[index], hopeful) ), None, ) if best_index is None: raise ValueError(f"could not generate {n_turbines} candidates") best_scale = float(scales[best_index]) if best_index < len(scales) - 1: local_scales = np.linspace( best_scale, float(scales[best_index + 1]), self.max_iterations + 2, )[1:-1] for index, hopeful in possible(local_scales)[::-1]: if feasible(local_scales[index], hopeful): best_scale = float(local_scales[index]) break fields = candidates(best_scale) options = [ (index, points) for index, points in enumerate(fields) if points is not None and len(points) >= n_turbines ] if not options: # Fields that span the boundary's bounding box, as a spiral's or # a ring's do, need far more points than turbines on a thin boundary. box = (maxx - minx) * (maxy - miny) covered = float(boundary.area) / box if box > 0 else 0.0 raise ValueError( f"every field that fits {n_turbines} candidates exceeds " f"candidate_cap={self.candidate_cap} points, and the boundary " f"covers {covered:.3g} of its bounding box. " f"Raise the cap with {type(layout).__name__}(fitter=" f"{type(self).__name__}(candidate_cap={2 * self.candidate_cap}))" ) minimum_surplus = min(len(points) - n_turbines for _, points in options) options = [ option for option in options if len(option[1]) - n_turbines == minimum_surplus ] phase_index, points = options[int(rng.integers(len(options)))] selected = _select_candidates(points, n_turbines, rng, required) selected_center = selected.mean(axis=0) selected_scale = best_scale if layout.refine_candidates: normalized = (selected - selected_center) / best_scale # The selected candidates fit at ``best_scale``, so refinement # starts there and can only enlarge them. positions, rigid = self.pattern_fitter.fit( layout, boundary, normalized, rng, hint=(best_scale, selected_center) ) # Rounding can fail the hint's containment check, so keep the # clipped candidates if refinement still shrank them. if float(rigid["scale"]) < selected_scale * (1.0 - 1e-9): positions = selected rigid = {"scale": selected_scale, "translation": [0.0, 0.0]} else: positions = selected rigid = { "scale": best_scale, "translation": [0.0, 0.0], } metadata = { "method": self.fit_method, "scale": rigid["scale"], "translation": rigid["translation"], "phase": list(phases[phase_index]), "candidate_count": int(len(points)), "candidate_evaluations": evaluations, "candidate_cap": self.candidate_cap, } origin = layout._candidate_origin(boundary) if origin is not None: origin = np.asarray(origin, dtype=float) if layout.refine_candidates: origin = (origin - selected_center) / best_scale origin = origin * rigid["scale"] + rigid["translation"] metadata["origin"] = origin.tolist() return positions, metadata
[docs] class SelectedGridFitter(SelectedCandidateFitter): """Select exact, well-distributed intersections from an aligned grid. Arguments are as for :class:`SelectedCandidateFitter`. """ fit_method = "selected_grid"
[docs] class SelectedCircularFitter(SelectedCandidateFitter): """Select exact positions from boundary-clipped concentric rings. Arguments are as for :class:`SelectedCandidateFitter`. """ fit_method = "selected_circular"
[docs] class SelectedContourFitter(SelectedCandidateFitter): """Select exact positions from inward offsets of a farm boundary. Arguments are as for :class:`SelectedCandidateFitter`, with three phases by default. """ fit_method = "selected_contours" def __init__(self, *, n_phases: int = 3, **kwargs: Any) -> None: super().__init__(n_phases=n_phases, **kwargs)
#: Fields each fitter evaluated, per layout and boundary, see :func:`_field_memo`. _FIELD_MEMOS: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() #: Boundaries whose fields one fitter keeps, the most recently fitted. _MEMO_BOUNDARIES = 8 def _field_memo(fitter, layout, boundary, phases) -> dict: """The fields ``fitter`` evaluated before for ``layout`` on ``boundary``. A spacing fits one layout at many counts on one boundary, and the fields do not depend on the count, so they are kept by scale, each with the least count it serves. The memo lives beside the fitter, not on it, so a pickled sampler does not carry it. """ memos = _FIELD_MEMOS.setdefault(fitter, []) key = (layout._field_key(), phases, fitter.candidate_cap) geometry = boundary.geometry for index, (entry_key, entry_geometry, scales) in enumerate(memos): if entry_key == key and ( entry_geometry is geometry or entry_geometry == geometry ): memos.append(memos.pop(index)) return scales scales: dict = {} memos.append((key, geometry, scales)) del memos[:-_MEMO_BOUNDARIES] return scales #: Hull area beyond a polygon's own, as a share of it, below which the #: polygon is taken as convex. Containment is checked directly anyway. _CONVEX_SLACK = 1e-12 def _is_convex(geometry) -> bool: """Whether ``geometry`` is one polygon without holes equal to its hull.""" if geometry.geom_type != "Polygon" or geometry.interiors: return False return geometry.convex_hull.area <= geometry.area * (1.0 + _CONVEX_SLACK) def _hull_corners(points) -> np.ndarray | None: """Indices of the corners of the hull of ``points``, or ``None`` if flat.""" from scipy.spatial import ConvexHull, QhullError if len(points) < 4: return None try: return ConvexHull(points).vertices except (QhullError, ValueError): return None def _select_candidates(points, count, rng, required): """Farthest-point selection while retaining required candidate points.""" points = np.asarray(points, dtype=float).reshape(-1, 2) required = np.asarray(required, dtype=float).reshape(-1, 2) if not len(required): return farthest_point_sample(points, count, rng) if len(required) > count: raise ValueError("required candidates cannot exceed n_turbines") available = np.ones(len(points), dtype=bool) selected = [point.copy() for point in required] minimum_distances = np.full(len(points), np.inf) for required_point in required: deltas = np.abs(points - required_point) tolerance = np.maximum( np.spacing(np.abs(points)), np.spacing(np.abs(required_point)) ) matches = np.all(deltas <= tolerance, axis=1) if not np.any(matches): raise ValueError("a required point is not in the candidate field") available &= ~matches distances = np.linalg.norm(points - required_point, axis=1) minimum_distances = np.minimum(minimum_distances, distances) minimum_distances[~available] = -np.inf # Picks are marked rather than masked again, unavailable candidates are # already below any maximum, and the distances are the norm's own # products, so the picks are those of the plain loop, several times faster. x = np.ascontiguousarray(points[:, 0]) y = np.ascontiguousarray(points[:, 1]) dx = np.empty_like(x) dy = np.empty_like(y) while len(selected) < count: maximum = float(np.max(minimum_distances)) if not np.isfinite(maximum): raise ValueError("candidate field does not contain enough distinct points") lower_ulp = np.nextafter(maximum, -np.inf) choices = np.flatnonzero(minimum_distances >= lower_ulp) choice = int(rng.choice(choices)) selected.append(points[choice].copy()) np.subtract(x, x[choice], out=dx) np.subtract(y, y[choice], out=dy) np.multiply(dx, dx, out=dx) np.multiply(dy, dy, out=dy) np.add(dx, dy, out=dx) np.sqrt(dx, out=dx) np.minimum(minimum_distances, dx, out=minimum_distances) minimum_distances[choice] = -np.inf return np.asarray(selected) def _spread_order(points): """Indices of ``points`` with the first few in farthest-point order. The order starts at the point farthest from the origin, and the points after the first :data:`_SPREAD_POINTS` keep their order. """ count = len(points) head = min(count, _SPREAD_POINTS) order = np.empty(head, dtype=int) order[0] = int(np.argmax(np.einsum("ij,ij->i", points, points))) distances = np.full(count, np.inf) # A chosen point is never chosen again, even among repeated points. distances[order[0]] = -np.inf for position in range(1, head): delta = points - points[order[position - 1]] distances = np.minimum(distances, np.einsum("ij,ij->i", delta, delta)) order[position] = int(np.argmax(distances)) distances[order[position]] = -np.inf rest = np.ones(count, dtype=bool) rest[order] = False return np.concatenate([order, np.flatnonzero(rest)]) def _shifted(geometry, offset): """``geometry`` translated by the ``(dx, dy)`` ``offset``.""" return shapely.transform(geometry, lambda coordinates: coordinates + offset) def _holds(geometry, bounds, shifts): """Whether ``geometry`` holds the box ``bounds`` moved by each of ``shifts``. The box must lie strictly inside, off the edge. """ xmin, ymin, xmax, ymax = bounds x, y = np.asarray(shifts).T boxes = shapely.box(xmin + x, ymin + y, xmax + x, ymax + y) return shapely.contains_properly(geometry, boxes) def _edges(geometry): """Start, direction and squared length of each edge of ``geometry``. The interior lies to the left of every edge. """ rings = shapely.get_rings(shapely.get_parts(shapely.orient_polygons(geometry))) coordinates, ring = shapely.get_coordinates(rings, return_index=True) joined = ring[1:] == ring[:-1] starts = coordinates[:-1][joined] deltas = np.diff(coordinates, axis=0)[joined] return starts, deltas, np.einsum("ij,ij->i", deltas, deltas) def _meeting_scale(region, scale, pattern, used, edges): """The largest scale at which the lines of ``region``'s edges still meet. ``region`` holds the translations that fit ``pattern`` at ``scale``, and each of its edges lies on one of the boundary's ``edges`` shifted back by one scaled point among ``used``. Each such line moves with the scale. Return that scale with the points whose lines meet there, or ``None`` when no three lines meet or ``region`` has more than :data:`_MEETING_LINES` edges. """ if region.geom_type != "Polygon": return None ring = shapely.get_coordinates(region.exterior) if not 3 < len(ring) <= _MEETING_LINES + 1: return None starts, deltas, squared = edges used = np.asarray(used) # The boundary edge and the point that each region edge's middle lies on. middles = 0.5 * (ring[:-1] + ring[1:]) touching = middles[:, None, :] + scale * pattern[used] x = touching[..., 0].reshape(-1, 1) - starts[:, 0] y = touching[..., 1].reshape(-1, 1) - starts[:, 1] along = np.clip((x * deltas[:, 0] + y * deltas[:, 1]) / squared, 0.0, 1.0) x -= along * deltas[:, 0] y -= along * deltas[:, 1] distance = (x * x + y * y).reshape(len(middles), -1) point, edge = np.divmod(np.unique(distance.argmin(axis=1)), len(starts)) if len(point) < 3: return None point = used[point] # Each line keeps the translations t with n . (t + s * p) <= n . start. normals = np.column_stack([deltas[edge, 1], -deltas[edge, 0]]) normals /= np.sqrt(squared[edge])[:, None] lines = np.column_stack([normals, np.einsum("ij,ij->i", normals, pattern[point])]) bounds = np.einsum("ij,ij->i", normals, starts[edge] - middles.mean(axis=0)) # Cramer's rule solves every three lines for the translation and scale. triples = _triples(len(lines)) rows = lines[triples] cofactors = np.cross(rows[:, [1, 2, 0]], rows[:, [2, 0, 1]]) determinant = np.einsum("ij,ij->i", rows[:, 0], cofactors[:, 0]) size = np.sqrt(np.einsum("tij,tij->ti", rows, rows)).prod(axis=1) solvable = np.flatnonzero(np.abs(determinant) > 1e-9 * size) vertices = ( np.einsum("ti,tij->tj", bounds[triples[solvable]], cofactors[solvable]) / determinant[solvable, None] ) tolerance = 1e-9 * max(np.abs(bounds).max(), scale * np.abs(lines[:, 2]).max()) inside = np.flatnonzero((vertices @ lines.T <= bounds + tolerance).all(axis=1)) if not len(inside): return None best = inside[vertices[inside, 2].argmax()] return float(vertices[best, 2]), np.unique(point[triples[solvable[best]]]) @cache def _triples(count): """Every three distinct indices below ``count``, one row each.""" return np.array(list(combinations(range(count), 3)), dtype=int).reshape(-1, 3)