inkscape.voronoi/voronoi_core.py
2026-09-30 22:23:25 +02:00

959 lines
36 KiB
Python

# coding=utf-8
"""
Noyau de calcul de l'extension « Voronoi Fill », sans dependance a inkex.
Remplit une forme avec un pavage de Voronoi dont les cellules sont separees
par un filet de largeur donnee. Les cellules sont des surfaces pleines ; le
filet est le vide qui les separe (ou, en sortie, la forme privee des cellules).
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
des parametres).
Conventions :
- un point est un tuple (x, y) ; l'axe y est oriente vers le bas (repere SVG) ;
- un anneau (`ring`) est une liste de points, ferme implicitement ;
- une region (`rings`) est une liste d'anneaux lue selon la regle pair-impair
(un anneau interieur a un autre est un trou), quelle que soit l'orientation ;
- une cellule produite est une region (liste d'anneaux) : en general un seul
anneau convexe, plusieurs quand la forme la decoupe ou la troue.
"""
import math
import random
# Distance minimale entre germes de Poisson, rapportee a la taille de cellule :
# calibree pour que le nombre de cellules soit celui d'un pavage hexagonal de
# meme pas (voir test_distributions_have_similar_density).
POISSON_RATIO = 0.75
DISTRIBUTIONS = ("random", "poisson", "hexagonal")
class FillError(ValueError):
"""Erreur de parametrage ; `code` permet a la couche inkex de traduire."""
def __init__(self, code, count=0):
ValueError.__init__(self, code)
self.code = code
self.count = count
class _Degenerate(Exception):
"""Contact non transversal (sommet sur une arete, aretes confondues)."""
# --------------------------------------------------------------------------
# Utilitaires geometriques
# --------------------------------------------------------------------------
def parse_color(value, default=("#b3b3b3", 1.0)):
"""Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]).
Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre
« color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode
nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change
entre les versions 1.x.
"""
text = str(value).strip()
try:
if text.startswith("#"):
digits = text[1:]
if len(digits) == 3:
digits = "".join(c * 2 for c in digits)
if len(digits) == 6:
digits += "ff"
if len(digits) != 8:
return default
number = int(digits, 16)
else:
number = int(text, 0)
except ValueError:
return default
number &= 0xFFFFFFFF
red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255
alpha = (number & 255) / 255.0
return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4)
def polylines_to_d(polylines, precision=4):
"""Donnees `d` d'un chemin SVG : une polyligne par sous-chemin."""
fmt = "{:.%df},{:.%df}" % (precision, precision)
parts = []
for polyline in polylines:
if len(polyline) < 2:
continue
parts.append("M " + fmt.format(*polyline[0]))
parts.extend("L " + fmt.format(*point) for point in polyline[1:])
return " ".join(parts)
def rings_to_d(rings, precision=4):
"""Donnees `d` d'un chemin SVG : un sous-chemin ferme par anneau."""
parts = [polylines_to_d([ring], precision) + " Z" for ring in rings if len(ring) >= 3]
return " ".join(parts)
def ring_area(ring):
"""Aire signee (formule du lacet) ; le signe donne l'orientation."""
total = 0.0
n = len(ring)
for k in range(n):
x1, y1 = ring[k]
x2, y2 = ring[(k + 1) % n]
total += x1 * y2 - x2 * y1
return total / 2.0
def region_area(rings):
"""Aire d'une region pair-impair (profondeur d'imbrication de chaque anneau)."""
total = 0.0
for k, ring in enumerate(rings):
others = [r for m, r in enumerate(rings) if m != k]
depth = 1 if point_in_rings(_inner_point(ring), others) else 0
total += abs(ring_area(ring)) * (-1 if depth else 1)
return total
def _inner_point(ring):
"""Point proche du premier sommet, du cote interieur de l'anneau."""
a, b, c = ring[-1], ring[0], ring[1]
sign = 1.0 if ring_area(ring) > 0 else -1.0
# Bissectrice interieure au sommet b, petit pas relatif a la taille locale.
ux, uy = _unit_vec(a[0] - b[0], a[1] - b[1])
vx, vy = _unit_vec(c[0] - b[0], c[1] - b[1])
step = 1e-6 * max(math.hypot(a[0] - b[0], a[1] - b[1]),
math.hypot(c[0] - b[0], c[1] - b[1]))
wx, wy = ux + vx, uy + vy
if math.hypot(wx, wy) < 1e-12:
wx, wy = -vy * sign, vx * sign
wx, wy = _unit_vec(wx, wy)
candidate = (b[0] + step * wx, b[1] + step * wy)
if point_in_rings(candidate, [ring]):
return candidate
return (b[0] - step * wx, b[1] - step * wy)
def _unit_vec(x, y):
length = math.hypot(x, y)
if length == 0:
return 0.0, 0.0
return x / length, y / length
def point_in_rings(point, rings):
"""Regle pair-impair : demi-droite horizontale vers les x croissants."""
x, y = point
inside = False
for ring in rings:
n = len(ring)
for k in range(n):
x1, y1 = ring[k]
x2, y2 = ring[(k + 1) % n]
if (y1 > y) != (y2 > y):
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
def bbox_of(rings):
"""(xmin, ymin, xmax, ymax) de tous les points, None si vide."""
xs = [p[0] for ring in rings for p in ring]
ys = [p[1] for ring in rings for p in ring]
if not xs:
return None
return min(xs), min(ys), max(xs), max(ys)
def _boxes_overlap(a, b):
return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
def clean_rings(rings, eps=1e-9):
"""Retire les points doubles et alignes, et les anneaux degeneres.
Des sommets alignes produiraient, pour le filet sur le contour, des bords
de gelules confondus : on les supprime des le depart.
"""
result = []
for ring in rings:
points = []
for p in ring:
p = (float(p[0]), float(p[1]))
if not points or math.hypot(p[0] - points[-1][0], p[1] - points[-1][1]) > eps:
points.append(p)
while len(points) > 1 and math.hypot(points[0][0] - points[-1][0],
points[0][1] - points[-1][1]) <= eps:
points.pop()
changed = True
while changed and len(points) >= 3:
changed = False
n = len(points)
for k in range(n):
a, b, c = points[k - 1], points[k], points[(k + 1) % n]
cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])
scale = math.hypot(b[0] - a[0], b[1] - a[1]) * math.hypot(c[0] - b[0], c[1] - b[1])
if abs(cross) <= 1e-12 * max(scale, 1e-300):
del points[k]
changed = True
break
if len(points) >= 3 and abs(ring_area(points)) > eps * eps:
result.append(points)
return result
# --------------------------------------------------------------------------
# Germes
# --------------------------------------------------------------------------
def hex_density(size):
"""Nombre de germes par unite d'aire d'un pavage hexagonal de pas `size`."""
return 2.0 / (math.sqrt(3.0) * size * size)
def random_points(bbox, size, rng):
"""Germes uniformes, en nombre egal a celui d'un pavage hexagonal de pas `size`."""
x0, y0, x1, y1 = bbox
count = max(1, int(round((x1 - x0) * (y1 - y0) * hex_density(size))))
return [(rng.uniform(x0, x1), rng.uniform(y0, y1)) for _ in range(count)]
def hex_points(bbox, size, irregularity, rng):
"""Grille hexagonale de pas `size`, chaque germe deplace dans un disque.
`irregularity` en % : rayon du disque rapporte a la moitie du pas ;
0 donne un nid d'abeille parfait.
"""
x0, y0, x1, y1 = bbox
step_y = size * math.sqrt(3.0) / 2.0
radius = max(0.0, irregularity) / 100.0 * size / 2.0
points = []
row = 0
y = y0
while y <= y1 + 1e-9:
x = x0 + (size / 2.0 if row % 2 else 0.0)
while x <= x1 + 1e-9:
if radius > 0:
angle = rng.uniform(0.0, 2.0 * math.pi)
r = radius * math.sqrt(rng.random())
points.append((x + r * math.cos(angle), y + r * math.sin(angle)))
else:
points.append((x, y))
x += size
y += step_y
row += 1
return points
def poisson_points(bbox, size, rng, attempts=30):
"""Echantillonnage de Poisson (Bridson) : germes au moins a POISSON_RATIO*size."""
x0, y0, x1, y1 = bbox
radius = POISSON_RATIO * size
cell = radius / math.sqrt(2.0)
cols = max(1, int(math.ceil((x1 - x0) / cell)))
rows = max(1, int(math.ceil((y1 - y0) / cell)))
grid = [[None] * cols for _ in range(rows)]
points = []
def grid_pos(p):
return (min(rows - 1, int((p[1] - y0) / cell)),
min(cols - 1, int((p[0] - x0) / cell)))
def fits(p):
if not (x0 <= p[0] <= x1 and y0 <= p[1] <= y1):
return False
gi, gj = grid_pos(p)
for i in range(max(0, gi - 2), min(rows, gi + 3)):
for j in range(max(0, gj - 2), min(cols, gj + 3)):
k = grid[i][j]
if k is not None:
q = points[k]
if (q[0] - p[0]) ** 2 + (q[1] - p[1]) ** 2 < radius * radius:
return False
return True
def add(p):
gi, gj = grid_pos(p)
grid[gi][gj] = len(points)
points.append(p)
add((rng.uniform(x0, x1), rng.uniform(y0, y1)))
active = [0]
while active:
index = rng.randrange(len(active))
base = points[active[index]]
for _ in range(attempts):
angle = rng.uniform(0.0, 2.0 * math.pi)
r = radius * (1.0 + rng.random())
candidate = (base[0] + r * math.cos(angle), base[1] + r * math.sin(angle))
if fits(candidate):
add(candidate)
active.append(len(points) - 1)
break
else:
active[index] = active[-1]
active.pop()
return points
def make_points(distribution, bbox, size, irregularity=30.0, seed=1):
"""Germes selon la distribution choisie (graine reproductible)."""
rng = random.Random(seed)
if distribution == "random":
return random_points(bbox, size, rng)
if distribution == "hexagonal":
return hex_points(bbox, size, irregularity, rng)
if distribution == "poisson":
return poisson_points(bbox, size, rng)
raise ValueError("distribution inconnue : {}".format(distribution))
# --------------------------------------------------------------------------
# Cellules de Voronoi (retrecies)
# --------------------------------------------------------------------------
def clip_half_plane(poly, nx, ny, c):
"""Sutherland-Hodgman : garde les points x tels que n.x <= c."""
result = []
n = len(poly)
for k in range(n):
p = poly[k]
q = poly[(k + 1) % n]
dp = nx * p[0] + ny * p[1] - c
dq = nx * q[0] + ny * q[1] - c
if dp <= 0:
result.append(p)
if (dp < 0 < dq) or (dq < 0 < dp):
t = dp / (dp - dq)
result.append((p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])))
return result if len(result) >= 3 else []
def voronoi_cells(points, bbox, gap=0.0):
"""Cellule de chaque germe, retrecie de gap/2 de chaque cote de ses aretes.
Chaque cellule est le rectangle `bbox` decoupe par les mediatrices avec
les germes voisins, decalees de gap/2 vers le germe : deux cellules voisines
sont ainsi separees exactement de `gap`. Les voisins sont parcourus par
couronnes d'une grille ; on s'arrete quand aucun germe plus lointain ne
peut plus couper la cellule (rayon de surete).
Renvoie une liste alignee sur `points` : polygone convexe ou None.
"""
if not points:
return []
x0, y0, x1, y1 = bbox
area = max((x1 - x0) * (y1 - y0), 1e-12)
step = math.sqrt(area / len(points))
cols = max(1, int(math.ceil((x1 - x0) / step)))
rows = max(1, int(math.ceil((y1 - y0) / step)))
grid = {}
positions = []
for k, p in enumerate(points):
gi = min(rows - 1, max(0, int((p[1] - y0) / step)))
gj = min(cols - 1, max(0, int((p[0] - x0) / step)))
grid.setdefault((gi, gj), []).append(k)
positions.append((gi, gj))
max_ring = max(rows, cols)
half_gap = gap / 2.0
cells = []
for k, p in enumerate(points):
poly = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
gi, gj = positions[k]
ring = 0
while poly and ring <= max_ring:
for i in range(gi - ring, gi + ring + 1):
for j in range(gj - ring, gj + ring + 1):
if max(abs(i - gi), abs(j - gj)) != ring:
continue
for m in grid.get((i, j), ()):
if m == k:
continue
q = points[m]
dx, dy = q[0] - p[0], q[1] - p[1]
dist = math.hypot(dx, dy)
if dist == 0:
continue
nx, ny = dx / dist, dy / dist
c = nx * (p[0] + q[0]) / 2.0 + ny * (p[1] + q[1]) / 2.0 - half_gap
poly = clip_half_plane(poly, nx, ny, c)
if not poly:
break
if not poly:
break
if not poly:
break
if not poly:
break
# Germes non visites : a plus de ring*step de p. Ils ne coupent la
# cellule que si dist/2 - gap/2 < rayon de la cellule.
reach = max(math.hypot(v[0] - p[0], v[1] - p[1]) for v in poly)
if ring * step >= 2.0 * reach + gap:
break
ring += 1
cells.append(poly if poly else None)
return cells
def arc_steps(radius, angle, tolerance):
"""Nombre de segments pour qu'une corde s'ecarte de l'arc d'au plus `tolerance`."""
if radius <= tolerance:
return max(1, int(math.ceil(abs(angle) / (math.pi / 2))))
step = 2.0 * math.acos(max(-1.0, 1.0 - tolerance / radius))
return max(1, int(math.ceil(abs(angle) / step)))
def erode_convex(poly, radius):
"""Retrecit un polygone convexe de `radius` (aretes decalees vers l'interieur).
Exact pour un convexe : intersection des demi-plans de ses aretes
reculees de `radius`. Renvoie [] si le polygone disparait.
"""
if radius <= 0 or not poly:
return poly
if ring_area(poly) < 0:
poly = poly[::-1]
result = poly
n = len(poly)
for k in range(n):
a, b = poly[k], poly[(k + 1) % n]
ux, uy = _unit_vec(b[0] - a[0], b[1] - a[1])
nx, ny = uy, -ux # normale exterieure (aire positive)
if nx == 0 and ny == 0:
continue
result = clip_half_plane(result, nx, ny, nx * a[0] + ny * a[1] - radius)
if not result:
return []
return result
def inradius(poly, iterations=18):
"""Rayon du plus grand disque inscrit dans un polygone convexe (dichotomie)."""
if not poly or len(poly) < 3:
return 0.0
cx = sum(p[0] for p in poly) / len(poly)
cy = sum(p[1] for p in poly) / len(poly)
low, high = 0.0, max(math.hypot(p[0] - cx, p[1] - cy) for p in poly)
for _ in range(iterations):
middle = (low + high) / 2.0
if erode_convex(poly, middle):
low = middle
else:
high = middle
return low
def round_cell(poly, corner_radius, roundness, tolerance):
"""Arrondit une cellule convexe sans la faire deborder.
Rayon applique : le plus grand de `corner_radius` (longueur fixe) et de
`roundness` (0 a 1) fois le rayon inscrit de la cellule, ce qui arrondit
chaque cellule en proportion de sa taille ; a 1, la cellule devient la
forme la plus ronde contenue dans la cellule d'origine (un disque pour un
polygone regulier). Erosion puis dilatation du meme rayon : le resultat
reste inclus dans la cellule, l'ecart du filet est donc respecte.
Un rayon fixe plus grand que la cellule la fait disparaitre (None).
"""
radius = corner_radius
if roundness > 0:
rho = inradius(poly)
# Un peu en dessous du rayon inscrit : le polygone erode garde une
# taille non nulle, et donc des normales d'aretes bien definies.
radius = max(radius, min(roundness, 0.97) * rho)
if radius <= 0:
return poly
core = erode_convex(poly, radius)
if not core:
return None
scale = max(abs(p[0]) + abs(p[1]) for p in poly)
core = clean_rings([core], eps=1e-9 * max(scale, 1.0))
if not core:
return None
return round_convex(core[0], radius, tolerance)
def round_convex(poly, radius, tolerance):
"""Dilate un polygone convexe de `radius` : aretes decalees, coins en arcs.
Applique a une cellule deja retrecie de `radius` en plus, on obtient la
cellule attendue avec des coins arrondis de rayon `radius`.
"""
if radius <= 0 or not poly:
return poly
if ring_area(poly) < 0:
poly = poly[::-1]
n = len(poly)
normals = []
for k in range(n):
a, b = poly[k], poly[(k + 1) % n]
ux, uy = _unit_vec(b[0] - a[0], b[1] - a[1])
# Aire positive : l'exterieur est a droite de chaque arete.
normals.append((uy, -ux))
result = []
for k in range(n):
v = poly[k]
n_in = normals[k - 1]
n_out = normals[k]
a0 = math.atan2(n_in[1], n_in[0])
a1 = math.atan2(n_out[1], n_out[0])
while a1 < a0:
a1 += 2.0 * math.pi
steps = arc_steps(radius, a1 - a0, tolerance)
for s in range(steps + 1):
a = a0 + (a1 - a0) * s / steps
result.append((v[0] + radius * math.cos(a), v[1] + radius * math.sin(a)))
return result
# --------------------------------------------------------------------------
# Decoupe d'une region pair-impair par un polygone convexe
# --------------------------------------------------------------------------
class Region(object):
"""Region pair-impair indexee : aretes par grille, test d'appartenance par bandes.
L'index evite de parcourir tous les sommets de la forme pour chaque cellule
(texte vectorise, courbes finement aplaties).
"""
def __init__(self, rings, cell=None):
self.rings = rings
self.bbox = bbox_of(rings)
self.edges = []
for r, ring in enumerate(rings):
n = len(ring)
for i in range(n):
self.edges.append((r, i, ring[i], ring[(i + 1) % n]))
self.indexed = len(self.edges) > 48 and self.bbox is not None
if not self.indexed:
return
x0, y0, x1, y1 = self.bbox
if cell is None:
cell = max(x1 - x0, y1 - y0) / math.sqrt(len(self.edges)) * 2.0
self.cell = max(cell, 1e-9)
self.grid = {}
self.bands = {}
for e, (_r, _i, a, b) in enumerate(self.edges):
j0, j1 = self._col(min(a[0], b[0])), self._col(max(a[0], b[0]))
i0, i1 = self._row(min(a[1], b[1])), self._row(max(a[1], b[1]))
for i in range(i0, i1 + 1):
self.bands.setdefault(i, []).append(e)
for j in range(j0, j1 + 1):
self.grid.setdefault((i, j), []).append(e)
def _col(self, x):
return int(math.floor((x - self.bbox[0]) / self.cell))
def _row(self, y):
return int(math.floor((y - self.bbox[1]) / self.cell))
def edges_in(self, box):
"""Indices des aretes dont la boite rencontre `box`."""
if self.bbox is None or not _boxes_overlap(box, self.bbox):
return []
if not self.indexed:
found = range(len(self.edges))
else:
found = set()
for i in range(self._row(box[1]), self._row(box[3]) + 1):
for j in range(self._col(box[0]), self._col(box[2]) + 1):
found.update(self.grid.get((i, j), ()))
result = []
for e in found:
a, b = self.edges[e][2], self.edges[e][3]
if _boxes_overlap(box, (min(a[0], b[0]), min(a[1], b[1]),
max(a[0], b[0]), max(a[1], b[1]))):
result.append(e)
return sorted(result)
def contains(self, point):
"""Appartenance pair-impair."""
if self.bbox is None:
return False
x, y = point
if not self.indexed:
candidates = range(len(self.edges))
else:
candidates = self.bands.get(self._row(y), ())
inside = False
for e in candidates:
(x1, y1), (x2, y2) = self.edges[e][2], self.edges[e][3]
if (y1 > y) != (y2 > y):
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
def _walk(start_pos, end_pos, n, forward):
"""Indices des sommets rencontres entre deux positions (indice + fraction).
Le sommet k est a la position k ; l'arete k va du sommet k au sommet k+1.
"""
i, t = int(start_pos), start_pos - int(start_pos)
j, s = int(end_pos), end_pos - int(end_pos)
if forward:
count = (j - i) % n
if i == j and s < t:
count = n
return [(i + 1 + m) % n for m in range(count)]
count = (i - j) % n
if i == j and s > t:
count = n
return [(i - m) % n for m in range(count)]
def clip_convex(region, convex, inside=True, eps=1e-9):
"""Intersection (inside=True) ou difference (False) d'une region et d'un convexe.
`region` : Region ou liste d'anneaux (pair-impair). Renvoie une liste
d'anneaux, a lire aussi en pair-impair.
Parcours de type Greiner-Hormann specialise : les croisements du bord de
la region avec le bord du convexe alternent l'appartenance le long de
chacun des deux bords. Le resultat est borde par les morceaux de la region
interieurs (ou exterieurs) au convexe et par les arcs du convexe
interieurs a la region. Un contact non transversal leve _Degenerate ;
on reessaie alors avec un convexe tres legerement reduit et decale.
Si rien n'y fait, on renvoie une region vide : la cellule concernee
disparait (le filet la recouvre) plutot que de produire un contour faux.
"""
if not isinstance(region, Region):
region = Region(region)
if len(convex) < 3:
return [] if inside else [list(r) for r in region.rings]
if ring_area(convex) < 0:
convex = convex[::-1]
cx = sum(p[0] for p in convex) / len(convex)
cy = sum(p[1] for p in convex) / len(convex)
scale = max(max(math.hypot(p[0] - cx, p[1] - cy) for p in convex), 1e-12)
for attempt in range(8):
if attempt:
# Perturbation relative infime, donc invisible : reduction (le
# convexe reste dans la cellule) et decalage selon une direction
# irrationnelle, car une reduction seule garde les symetries
# (sommet d'hexagone pile sur un bord vertical, par exemple).
delta = 1e-9 * 7.3 ** attempt
f = 1.0 - delta
dx, dy = 0.618034 * delta * scale, 0.414214 * delta * scale
shape = [(cx + (p[0] - cx) * f + dx, cy + (p[1] - cy) * f + dy) for p in convex]
else:
shape = convex
try:
return _clip_convex(region, shape, inside, eps * scale)
except _Degenerate:
continue
return []
def _clip_convex(region, convex, inside, eps):
m = len(convex)
box = bbox_of([convex])
edge_ids = region.edges_in(box)
def in_convex(p):
for j in range(m):
a, b = convex[j], convex[(j + 1) % m]
if (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) < 0:
return False
return True
# --- Croisements -------------------------------------------------------
crossings = [] # [point, ring, subject_pos, convex_pos, entering]
touched = set()
for e in edge_ids:
r, i, p, q = region.edges[e]
touched.add(r)
dx, dy = q[0] - p[0], q[1] - p[1]
seg_len = math.hypot(dx, dy)
for j in range(m):
a, b = convex[j], convex[(j + 1) % m]
ex, ey = b[0] - a[0], b[1] - a[1]
denom = dx * ey - dy * ex
wx, wy = a[0] - p[0], a[1] - p[1]
edge_len = math.hypot(ex, ey)
if abs(denom) <= 1e-12 * seg_len * edge_len:
# Paralleles : degenere seulement si confondus et superposes.
if abs(wx * dy - wy * dx) <= eps * seg_len:
t0 = (wx * dx + wy * dy) / (seg_len * seg_len)
t1 = ((b[0] - p[0]) * dx + (b[1] - p[1]) * dy) / (seg_len * seg_len)
if max(t0, t1) >= 0 and min(t0, t1) <= 1:
raise _Degenerate()
continue
t = (wx * ey - wy * ex) / denom
s = (wx * dy - wy * dx) / denom
tol_t = eps / seg_len
tol_s = eps / edge_len
if -tol_t <= t <= 1 + tol_t and -tol_s <= s <= 1 + tol_s:
if t <= tol_t or t >= 1 - tol_t or s <= tol_s or s >= 1 - tol_s:
raise _Degenerate()
point = (p[0] + t * dx, p[1] + t * dy)
entering = ex * dy - ey * dx > 0
crossings.append([point, r, i + t, j + s, entering])
rings = region.rings
result = []
# --- Aucun croisement : anneaux entiers et convexe entier ---------------
if not crossings:
if inside:
for r in sorted(touched):
if in_convex(rings[r][0]):
result.append(list(rings[r]))
# Bord du convexe sans croisement : entierement dans la region
# ou entierement dehors ; son centre, lui, peut tomber dans un trou.
if region.contains(convex[0]):
result.append(list(convex))
else:
for r, ring in enumerate(rings):
if r not in touched or not in_convex(ring[0]):
result.append(list(ring))
# Bord du convexe sans croisement : entierement dans la region
# ou entierement dehors ; son centre, lui, peut tomber dans un trou.
if region.contains(convex[0]):
result.append(list(convex))
return result
# --- Chainages le long de chaque anneau et le long du convexe ----------
by_ring = {}
for c, x in enumerate(crossings):
by_ring.setdefault(x[1], []).append(c)
ring_next, ring_prev = {}, {}
for r, ids in by_ring.items():
ids.sort(key=lambda c: crossings[c][2])
for k, c in enumerate(ids):
ring_next[c] = ids[(k + 1) % len(ids)]
ring_prev[c] = ids[k - 1]
order = sorted(range(len(crossings)), key=lambda c: crossings[c][3])
conv_next, conv_prev = {}, {}
for k, c in enumerate(order):
conv_next[c] = order[(k + 1) % len(order)]
conv_prev[c] = order[k - 1]
# Appartenance a la region de l'arc du convexe qui suit chaque croisement :
# un seul test, puis alternance (chaque croisement franchit le bord).
first, second = order[0], conv_next[order[0]]
arc_points = _walk(crossings[first][3], crossings[second][3], m, True)
if arc_points:
probe = convex[arc_points[0]]
else:
pa, pb = crossings[first][0], crossings[second][0]
probe = ((pa[0] + pb[0]) / 2.0, (pa[1] + pb[1]) / 2.0)
state = region.contains(probe)
arc_in = {}
for c in order:
arc_in[c] = state
state = not state
# --- Parcours ----------------------------------------------------------
visited = set()
for start in range(len(crossings)):
if start in visited:
continue
loop = []
c = start
guard = 0
while True:
guard += 1
if guard > 4 * len(crossings) + 4:
raise _Degenerate()
visited.add(c)
point, r, pos, _cpos, entering = crossings[c]
ring = rings[r]
# Morceau de la region du bon cote du convexe.
forward = entering if inside else not entering
nxt = ring_next[c] if forward else ring_prev[c]
loop.append(point)
loop.extend(ring[k] for k in _walk(pos, crossings[nxt][2], len(ring), forward))
visited.add(nxt)
# Arc du convexe interieur a la region.
c2 = nxt
loop.append(crossings[c2][0])
if arc_in[c2]:
end = conv_next[c2]
loop.extend(convex[k] for k in _walk(crossings[c2][3], crossings[end][3], m, True))
else:
end = conv_prev[c2]
loop.extend(convex[k] for k in _walk(crossings[c2][3], crossings[end][3], m, False))
c = end
if c == start:
break
if c in visited:
raise _Degenerate()
if len(loop) >= 3:
result.append(loop)
# Anneaux sans croisement : entiers, selon leur position.
for r, ring in enumerate(rings):
if r in by_ring:
continue
if inside:
if r in touched and in_convex(ring[0]):
result.append(list(ring))
elif r not in touched or not in_convex(ring[0]):
result.append(list(ring))
return result
# --------------------------------------------------------------------------
# Filet sur le contour : erosion locale de la forme
# --------------------------------------------------------------------------
def _edge_capsule(a, b, radius):
"""Rectangle de demi-largeur `radius` autour de [a, b], raccourci d'un rien.
Le leger raccourcissement evite que ses petits cotes passent exactement
par les sommets de la forme ; le disque du sommet couvre ce vide.
"""
ux, uy = _unit_vec(b[0] - a[0], b[1] - a[1])
length = math.hypot(b[0] - a[0], b[1] - a[1])
shrink = min(1e-6 * radius, length * 1e-3)
a = (a[0] + shrink * ux, a[1] + shrink * uy)
b = (b[0] - shrink * ux, b[1] - shrink * uy)
nx, ny = -uy * radius, ux * radius
return [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny),
(b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]
def _vertex_disk(center, radius, tolerance):
"""Polygone circonscrit au disque (contient le disque vrai)."""
count = max(8, arc_steps(radius, 2.0 * math.pi, tolerance))
outer = radius / math.cos(math.pi / count) * (1.0 + 1e-7)
# Dephasage irrationnel : evite l'alignement avec les rectangles voisins.
phase = 0.318309886
return [(center[0] + outer * math.cos(phase + 2.0 * math.pi * k / count),
center[1] + outer * math.sin(phase + 2.0 * math.pi * k / count))
for k in range(count)]
def erode_near_boundary(piece, region, radius, tolerance):
"""Retire de `piece` tout ce qui est a moins de `radius` du bord de `region`.
`piece` est deja incluse dans la region ; on lui soustrait, pour chaque
arete du bord assez proche, un rectangle et un disque a chaque extremite
(union = voisinage du bord), ce qui revient a l'intersecter avec la forme
erodee sans jamais calculer cette derniere en entier.
"""
box = bbox_of(piece)
if box is None:
return []
near = (box[0] - radius, box[1] - radius, box[2] + radius, box[3] + radius)
vertices = set()
for e in region.edges_in(near):
r, i, a, b = region.edges[e]
vertices.add((r, i))
vertices.add((r, (i + 1) % len(region.rings[r])))
piece = _subtract(piece, _edge_capsule(a, b, radius))
if not piece:
return []
for r, i in sorted(vertices):
piece = _subtract(piece, _vertex_disk(region.rings[r][i], radius, tolerance))
if not piece:
return []
return piece
def _subtract(piece, convex):
"""Difference piece - convexe, ignoree si les boites sont disjointes."""
if not _boxes_overlap(bbox_of(piece), bbox_of([convex])):
return piece
return [ring for ring in clip_convex(piece, convex, inside=False) if len(ring) >= 3]
# --------------------------------------------------------------------------
# Remplissage complet
# --------------------------------------------------------------------------
def fill_shape(rings, cell_size, net_width, distribution="poisson", irregularity=30.0,
seed=1, border=True, corner_radius=0.0, roundness=0.0, tolerance=None,
max_cells=20000, min_area=None, min_thickness=None):
"""Cellules de Voronoi retrecies qui remplissent la region `rings`.
Renvoie une liste de cellules, chacune une liste d'anneaux (pair-impair).
Deux cellules voisines sont separees de `net_width` ; avec `border`, les
cellules restent aussi a net_width du contour de la forme : le filet
borde la forme d'un cadre de meme largeur que ses brins.
`corner_radius` (longueur) et `roundness` (0 a 1, en proportion du rayon
inscrit de chaque cellule) arrondissent les cellules, le plus grand des
deux l'emportant (voir round_cell) ; les coins issus du contour de la
forme restent vifs.
Les eclats trop petits (`min_area`) ou trop fins (`min_thickness`,
epaisseur moyenne 2*aire/perimetre) laisses par le contour sont ecartes :
le filet y est simplement un peu plus large.
Leve FillError("too_many_cells") si le nombre de germes depasse `max_cells`.
"""
if cell_size <= 0:
raise FillError("cell_size")
if net_width < 0 or corner_radius < 0 or roundness < 0:
raise FillError("negative")
rings = clean_rings(rings)
if not rings:
return []
shape_box = bbox_of(rings)
if tolerance is None:
tolerance = cell_size / 200.0
if min_area is None:
min_area = 0.002 * cell_size * cell_size
if min_thickness is None:
min_thickness = 0.5 * net_width
# Germes sur la boite de la forme elargie d'une cellule : les cellules du
# bord ont ainsi des voisins exterieurs et une taille normale.
margin = cell_size
seed_box = (shape_box[0] - margin, shape_box[1] - margin,
shape_box[2] + margin, shape_box[3] + margin)
estimate = (seed_box[2] - seed_box[0]) * (seed_box[3] - seed_box[1]) * hex_density(cell_size)
if estimate > max_cells:
raise FillError("too_many_cells", int(estimate))
points = make_points(distribution, seed_box, cell_size, irregularity, seed)
if len(points) > max_cells:
raise FillError("too_many_cells", len(points))
clip_box = (seed_box[0] - 2 * margin, seed_box[1] - 2 * margin,
seed_box[2] + 2 * margin, seed_box[3] + 2 * margin)
cells = voronoi_cells(points, clip_box, net_width)
region = Region(rings, cell=cell_size)
result = []
for poly in cells:
if not poly:
continue
if corner_radius > 0 or roundness > 0:
poly = round_cell(poly, corner_radius, roundness, tolerance)
if not poly:
continue
if not _boxes_overlap(bbox_of([poly]), shape_box):
continue
piece = clip_convex(region, poly, inside=True)
if border and net_width > 0 and piece:
piece = erode_near_boundary(piece, region, net_width, tolerance)
piece = [ring for ring in piece if _substantial(ring, min_area, min_thickness)]
if piece:
result.append(piece)
return result
def _substantial(ring, min_area, min_thickness):
"""Anneau assez grand et assez epais pour etre garde."""
area = abs(ring_area(ring))
if area < min_area:
return False
n = len(ring)
perimeter = sum(math.hypot(ring[(k + 1) % n][0] - ring[k][0],
ring[(k + 1) % n][1] - ring[k][1]) for k in range(n))
return 2.0 * area / perimeter >= min_thickness
def net_rings(shape_rings, cells):
"""Filet sous forme de region pair-impair : forme + anneaux de toutes les cellules.
Les cellules sont disjointes et interieures a la forme : chaque anneau de
cellule y perce un trou, sans aucune operation booleenne.
"""
result = [list(r) for r in clean_rings(shape_rings)]
for cell in cells:
result.extend(cell)
return result