Fitting a pattern to a boundary#
The candidate-field and finite-pattern families of Layout families generate
geometry that a fitter scales into the boundary. Pass fitter= to replace it.
A candidate fitter scales a field, keeps what lies inside and selects an exact
count. A rigid fitter finds the largest single scale and translation that puts
all \(N\) points inside.
import matplotlib.pyplot as plt
import numpy as np
import oowl
from oowl import boundaries as B
from oowl import layouts as L
from oowl import strings as S
boundary = B.SmoothBlob(area=np.pi * 3000**2, roughness=0.2, seed=5)
ring = B.Annulus(1.0, 0.55, area=30_000_000)
ring_span = ring.bounds[2] - ring.bounds[0]
The five fitter classes#
RigidPatternFitter is the rigid strategy behind PoissonDisc and String.
SelectedCandidateFitter is the candidate strategy. SelectedGridFitter and
SelectedCircularFitter only change the recorded method, and
SelectedContourFitter also defaults to three phases. The candidate field belongs to the
layout, so Grid gets the same scale from either candidate fitter.
roster = {
"RigidPatternFitter": L.PoissonDisc(80, seed=0, fitter=L.RigidPatternFitter()),
"SelectedCandidateFitter": L.Grid(80, seed=0, fitter=L.SelectedCandidateFitter()),
"SelectedGridFitter": L.Grid(80, seed=0, fitter=L.SelectedGridFitter()),
"SelectedCircularFitter": L.Circular(80, seed=0, fitter=L.SelectedCircularFitter()),
"SelectedContourFitter": L.Contour(80, seed=0, fitter=L.SelectedContourFitter()),
}
for name, layout in roster.items():
fit = oowl.Sampler(boundary, layout).sample()[0].metadata["fit"]
print(f"{name:24s} records {fit['method']:20s} scale {fit['scale']:7.1f} m")
RigidPatternFitter records poisson_disc scale 6178.3 m
SelectedCandidateFitter records selected_candidates scale 611.4 m
SelectedGridFitter records selected_grid scale 611.4 m
SelectedCircularFitter records selected_circular scale 603.6 m
SelectedContourFitter records selected_contours scale 660.7 m
Where the strategy is visible#
An annulus is the hard case for a rigid fit, since every point adds a shifted
copy of the hole to the intersection of feasible translations. Grid and
Contour use candidates that already lie inside the ring, and the refit of
Grid starts from them. PoissonDisc passes the fitter a verified hint from
its hole-aware generation. The straight rows of String cannot straddle the
hole at any larger scale, so it keeps to one side of the ring.
guide = S.Parallel(
S.Straight(), n_strings=4, relative_spacing=0.14, rotation_spread=5, seed=6
)
ring_layouts = {
"Grid": L.Grid(60, seed=4),
"Contour": L.Contour(60, seed=4),
"PoissonDisc": L.PoissonDisc(60, seed=4),
"String": L.String(guide, 60, seed=4),
}
fig, axes = plt.subplots(1, 4, figsize=(18, 4.8))
for ax, (name, layout) in zip(axes, ring_layouts.items()):
farm = oowl.Sampler(ring, layout).sample()[0]
farm.plot(
ax=ax,
s=14,
title=(
f"{name}: spans {100 * np.ptp(farm.x) / ring_span:.0f}% of the ring\n"
f"closest pair {farm.minimum_spacing():.0f} m"
),
)
fig.tight_layout()
Rigid pattern fitting#
For normalized pattern points \(\mathbf p_i\) and admissible region \(B\), a scale \(s\) is feasible when some common translation \(\mathbf t\) satisfies \(s\mathbf p_i+\mathbf t\in B\) for every point. The feasible translations are
RigidPatternFitter scans \(s\) down from the largest scale the bounding box
allows, since on concave, holed or multipart boundaries \(T(s)\) can be empty at
one scale and not at a larger one, and then refines the scale within the step
that first fits. A placement counts only once every point passes the
boundary’s containment test, and its translation is a point inside \(T(s)\). For
Grid and Circular, candidate fitters reuse it as pattern_fitter for a
final refit, which can only enlarge the selected pattern.