417 lines
16 KiB
Python
417 lines
16 KiB
Python
# coding=utf-8
|
|
"""
|
|
Noyau de calcul de l'extension « Living Hinge Fill », sans dependance a inkex.
|
|
|
|
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
|
|
des parametres).
|
|
|
|
Le motif est une decoupe flexible (« living hinge ») : des lumieres oblongues
|
|
(rectangles a bouts arrondis) rangees en colonnes, separees dans une colonne
|
|
par un pont de matiere, les colonnes impaires etant decalees en quinconce.
|
|
Pres du bord de la forme, une lumiere est raccourcie et re-arrondie pour
|
|
rester a distance de la marge. En mode aleatoire, chaque lumiere prend une
|
|
longueur tiree au hasard, le pont et le pas restant constants, et chaque
|
|
colonne commence et finit pile a la marge.
|
|
|
|
Conventions :
|
|
- un contour (`rings`) est une liste d'anneaux, chaque anneau etant une liste
|
|
de points (x, y) fermee implicitement ; les trous suivent la regle pair-impair ;
|
|
- une polyligne est une liste de points (x, y) ;
|
|
- une lumiere (`slot`) est le couple (p0, p1) des centres de ses deux bouts
|
|
arrondis ; son rayon est la demi-largeur, sa longueur hors tout vaut
|
|
distance(p0, p1) + largeur ;
|
|
- l'axe y est oriente vers le bas (repere SVG) ; les angles sont en degres,
|
|
sens anti-horaire a l'ecran.
|
|
"""
|
|
|
|
import math
|
|
import random
|
|
|
|
EPS = 1e-9
|
|
|
|
|
|
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)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometrie de base
|
|
# --------------------------------------------------------------------------
|
|
|
|
def bounding_box(rings):
|
|
"""Boite englobante (xmin, ymin, xmax, ymax) d'un contour, None s'il est vide."""
|
|
xs = [x for ring in rings for x, _y in ring]
|
|
ys = [y for ring in rings for _x, y in ring]
|
|
if not xs:
|
|
return None
|
|
return min(xs), min(ys), max(xs), max(ys)
|
|
|
|
|
|
def rotate(point, angle, center=(0.0, 0.0)):
|
|
"""Tourne un point de `angle` degres autour de `center` (anti-horaire a l'ecran)."""
|
|
a = math.radians(angle)
|
|
c, s = math.cos(a), math.sin(a)
|
|
dx, dy = point[0] - center[0], point[1] - center[1]
|
|
# y vers le bas : le sens anti-horaire a l'ecran inverse le signe du sinus.
|
|
return center[0] + dx * c + dy * s, center[1] - dx * s + dy * c
|
|
|
|
|
|
def _edges(rings):
|
|
"""Aretes ((x1, y1), (x2, y2)) des anneaux, fermeture implicite comprise."""
|
|
edges = []
|
|
for ring in rings:
|
|
if len(ring) < 3:
|
|
continue
|
|
for k, point in enumerate(ring):
|
|
edges.append((point, ring[(k + 1) % len(ring)]))
|
|
return edges
|
|
|
|
|
|
def _inside_intervals(edges, x):
|
|
"""Intervalles de y ou la verticale d'abscisse x est dans le contour (pair-impair)."""
|
|
ys = []
|
|
for (x1, y1), (x2, y2) in edges:
|
|
# Demi-ouvert : un sommet pile sur la verticale n'est compte qu'une fois.
|
|
if (x1 <= x) != (x2 <= x):
|
|
ys.append(y1 + (x - x1) * (y2 - y1) / (x2 - x1))
|
|
ys.sort()
|
|
return [(ys[k], ys[k + 1]) for k in range(0, len(ys) - 1, 2)]
|
|
|
|
|
|
def _capsule_interval(a, b, x, distance):
|
|
"""Intervalle de y ou le point (x, y) est a moins de `distance` du segment ab.
|
|
|
|
L'ensemble des points proches d'un segment est convexe (une capsule) : sa
|
|
trace sur la verticale est un seul intervalle, reunion des traces des deux
|
|
disques d'extremite et du rectangle. Renvoie None si la verticale le manque.
|
|
"""
|
|
low = high = None
|
|
for px, py in (a, b):
|
|
dx = x - px
|
|
if abs(dx) < distance:
|
|
half = math.sqrt(distance * distance - dx * dx)
|
|
low = py - half if low is None else min(low, py - half)
|
|
high = py + half if high is None else max(high, py + half)
|
|
|
|
ex, ey = b[0] - a[0], b[1] - a[1]
|
|
length = math.hypot(ex, ey)
|
|
if length > EPS:
|
|
ux, uy = ex / length, ey / length
|
|
dx = x - a[0]
|
|
# Point (x, a.y + t) : abscisse le long du segment dans [0, length] et
|
|
# ecart perpendiculaire dans [-distance, distance], deux contraintes
|
|
# lineaires en t.
|
|
t0, t1 = -math.inf, math.inf
|
|
for coef, const, lo, hi in ((uy, dx * ux, 0.0, length),
|
|
(ux, -dx * uy, -distance, distance)):
|
|
if abs(coef) < EPS:
|
|
if not lo < const < hi:
|
|
t0, t1 = 1.0, 0.0
|
|
break
|
|
continue
|
|
u0, u1 = (lo - const) / coef, (hi - const) / coef
|
|
if u0 > u1:
|
|
u0, u1 = u1, u0
|
|
t0, t1 = max(t0, u0), min(t1, u1)
|
|
if t0 < t1:
|
|
low = a[1] + t0 if low is None else min(low, a[1] + t0)
|
|
high = a[1] + t1 if high is None else max(high, a[1] + t1)
|
|
|
|
if low is None:
|
|
return None
|
|
return low, high
|
|
|
|
|
|
def _subtract(intervals, cut):
|
|
"""Retire l'intervalle `cut` d'une liste d'intervalles disjoints."""
|
|
low, high = cut
|
|
result = []
|
|
for a, b in intervals:
|
|
if high <= a or low >= b:
|
|
result.append((a, b))
|
|
continue
|
|
if low > a:
|
|
result.append((a, low))
|
|
if high < b:
|
|
result.append((high, b))
|
|
return result
|
|
|
|
|
|
def clear_intervals(rings, x, clearance=0.0):
|
|
"""Intervalles de y ou (x, y) est dans le contour, a `clearance` au moins du bord.
|
|
|
|
C'est la ou peut passer le centre d'un disque de rayon `clearance` sans
|
|
mordre le bord : on part des intervalles interieurs et on retire, arete par
|
|
arete, la bande des points trop proches.
|
|
"""
|
|
edges = _edges(rings)
|
|
return _clear_intervals(edges, x, clearance)
|
|
|
|
|
|
def _clear_intervals(edges, x, clearance):
|
|
intervals = _inside_intervals(edges, x)
|
|
if clearance <= 0:
|
|
return intervals
|
|
# Leger retrait : un disque exactement tangent au bord est accepte.
|
|
distance = clearance * (1.0 - 1e-9)
|
|
for a, b in edges:
|
|
if not intervals:
|
|
break
|
|
cut = _capsule_interval(a, b, x, distance)
|
|
if cut is not None:
|
|
intervals = _subtract(intervals, cut)
|
|
return intervals
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Motif
|
|
# --------------------------------------------------------------------------
|
|
|
|
def regular_spans(low, high, origin, length, bridge):
|
|
"""Lumieres (debut, longueur) d'une colonne reguliere qui touchent [low, high].
|
|
|
|
Toutes de longueur `length`, separees par `bridge` ; l'une d'elles
|
|
commence en `origin`.
|
|
"""
|
|
period = length + bridge
|
|
k_min = int(math.floor((low - origin - length) / period)) + 1
|
|
k_max = int(math.floor((high - origin) / period))
|
|
return [(origin + k * period, length) for k in range(k_min, k_max + 1)]
|
|
|
|
|
|
def random_spans(low, high, shortest, longest, bridge, rng):
|
|
"""Lumieres (debut, longueur) aleatoires qui remplissent exactement [low, high].
|
|
|
|
La premiere commence en `low`, la derniere finit en `high`, le pont est
|
|
constant : toutes les colonnes d'une forme demarrent et s'arretent ainsi
|
|
au meme niveau, sans vide en bout de colonne. Longueurs tirees entre
|
|
`shortest` et `longest` avec le generateur `rng` (random.Random).
|
|
|
|
Quand aucun nombre de lumieres ne tombe juste dans ces bornes (zone trop
|
|
courte, ou bornes trop serrees), la zone recoit des lumieres egales qui
|
|
sortent un peu des bornes plutot que de rester vide.
|
|
"""
|
|
extent = high - low
|
|
if extent <= 0:
|
|
return []
|
|
# n lumieres et n - 1 ponts : n * (longueur moyenne + pont) = extent + pont.
|
|
fewest = max(1, int(math.ceil((extent + bridge) / (longest + bridge) - EPS)))
|
|
most = int(math.floor((extent + bridge) / (shortest + bridge) + EPS))
|
|
if fewest > most:
|
|
count = fewest
|
|
if (extent + bridge) / count - bridge < shortest and most >= 1:
|
|
count = most
|
|
sizes = [(extent + bridge) / count - bridge] * count
|
|
else:
|
|
# Nombre de lumieres : celui d'un tirage libre, ramene au possible.
|
|
count, drawn = 0, -bridge
|
|
while drawn < extent:
|
|
drawn += rng.uniform(shortest, longest) + bridge
|
|
count += 1
|
|
count = min(max(count, fewest), most)
|
|
sizes = [rng.uniform(shortest, longest) for _k in range(count)]
|
|
# Ecart a la longueur visee, reparti selon la latitude de chaque
|
|
# lumiere : chacune reste dans ses bornes et le total tombe juste.
|
|
excess = extent - (count - 1) * bridge - sum(sizes)
|
|
if excess > 0:
|
|
room = [longest - size for size in sizes]
|
|
else:
|
|
room = [size - shortest for size in sizes]
|
|
total = sum(room)
|
|
if total > EPS:
|
|
sizes = [size + excess * r / total for size, r in zip(sizes, room)]
|
|
|
|
spans = []
|
|
y = low
|
|
for size in sizes:
|
|
spans.append((y, size))
|
|
y += size + bridge
|
|
return spans
|
|
|
|
|
|
def hinge_slots(rings, length, width, bridge, pitch, stagger=50.0, angle=0.0,
|
|
margin=0.0, min_length=0.0, random_min=None, seed=0):
|
|
"""Lumieres du motif de decoupe flexible qui remplit le contour `rings`.
|
|
|
|
- length, width : longueur hors tout et largeur d'une lumiere entiere ;
|
|
- bridge : pont de matiere entre deux lumieres d'une meme colonne ;
|
|
- pitch : entraxe de deux colonnes voisines ;
|
|
- stagger : decalage des colonnes impaires, en % de la periode
|
|
(length + bridge) ; 50 = quinconce (sans effet en mode aleatoire) ;
|
|
- angle : rotation du motif en degres (0 = lumieres verticales) ;
|
|
- margin : distance minimale entre une lumiere et le bord de la forme ;
|
|
- min_length : une lumiere raccourcie par le bord n'est gardee que si sa
|
|
longueur hors tout atteint cette valeur (jamais moins que `width`) ;
|
|
sans effet en mode aleatoire, ou rien n'est raccourci ;
|
|
- random_min : None pour le motif regulier ; sinon chaque lumiere prend
|
|
une longueur au hasard entre random_min (ramene dans [width, length])
|
|
et length, et chaque colonne est remplie d'une marge a l'autre : une
|
|
lumiere commence pile en haut, une autre finit pile en bas ;
|
|
- seed : graine du tirage ; la changer donne un autre motif aleatoire.
|
|
|
|
Le motif est centre sur la boite englobante de la forme. Renvoie la liste
|
|
des lumieres (p0, p1), colonne par colonne ; liste vide si rien ne tient
|
|
ou si les dimensions sont incoherentes.
|
|
"""
|
|
rings = [ring for ring in rings if len(ring) >= 3]
|
|
if not rings:
|
|
return []
|
|
if width <= 0 or length < width or pitch <= 0 or bridge < 0:
|
|
return []
|
|
if random_min is not None:
|
|
random_min = min(max(random_min, width), length)
|
|
|
|
box = bounding_box(rings)
|
|
center = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
|
|
# On tourne la forme a l'envers pour travailler avec des lumieres
|
|
# verticales, puis on retourne le resultat.
|
|
if angle:
|
|
rings = [[rotate(point, -angle, center) for point in ring] for ring in rings]
|
|
box = bounding_box(rings)
|
|
|
|
radius = width / 2.0
|
|
clearance = radius + max(margin, 0.0)
|
|
period = length + bridge
|
|
shortest = max(min_length, width)
|
|
cx, cy = center
|
|
|
|
# Aretes rangees par colonne : chaque colonne ne regarde que les aretes
|
|
# qui passent a moins de `clearance` de son axe.
|
|
first = int(math.ceil((box[0] + clearance - cx) / pitch - EPS))
|
|
last = int(math.floor((box[2] - clearance - cx) / pitch + EPS))
|
|
if last < first:
|
|
return []
|
|
buckets = {}
|
|
for edge in _edges(rings):
|
|
x_low = min(edge[0][0], edge[1][0]) - clearance
|
|
x_high = max(edge[0][0], edge[1][0]) + clearance
|
|
i0 = max(first, int(math.ceil((x_low - cx) / pitch)))
|
|
i1 = min(last, int(math.floor((x_high - cx) / pitch)))
|
|
for i in range(i0, i1 + 1):
|
|
buckets.setdefault(i, []).append(edge)
|
|
|
|
slots = []
|
|
for i in range(first, last + 1):
|
|
edges = buckets.get(i)
|
|
if not edges:
|
|
continue
|
|
x = cx + i * pitch
|
|
intervals = _clear_intervals(edges, x, clearance)
|
|
if not intervals:
|
|
continue
|
|
if random_min is not None:
|
|
# Graine textuelle : meme tirage d'une version de Python a l'autre,
|
|
# et propre a la colonne.
|
|
rng = random.Random("{}:{}".format(seed, i))
|
|
# Chaque zone libre est remplie d'un bout a l'autre : une lumiere
|
|
# part de la marge en haut, une autre y arrive en bas.
|
|
for y0, y1 in intervals:
|
|
for start, size in random_spans(y0 - radius, y1 + radius, random_min,
|
|
length, bridge, rng):
|
|
slots.append(((x, start + radius), (x, start + size - radius)))
|
|
continue
|
|
# Une lumiere centree sur la forme, decalee pour les colonnes impaires.
|
|
origin = cy - length / 2.0 + (i % 2) * stagger / 100.0 * period
|
|
spans = regular_spans(box[1], box[3], origin, length, bridge)
|
|
for y0, y1 in intervals:
|
|
for start, size in spans:
|
|
u0 = max(y0, start + radius)
|
|
u1 = min(y1, start + size - radius)
|
|
# Une lumiere entiere est toujours gardee, meme plus courte
|
|
# que le minimum impose aux lumieres raccourcies.
|
|
if u1 - u0 + width < min(shortest, size) - EPS:
|
|
continue
|
|
slots.append(((x, u0), (x, max(u0, u1))))
|
|
|
|
if angle:
|
|
slots = [(rotate(p0, angle, center), rotate(p1, angle, center))
|
|
for p0, p1 in slots]
|
|
return slots
|
|
|
|
|
|
def slot_length(slot, width):
|
|
"""Longueur hors tout d'une lumiere."""
|
|
(x0, y0), (x1, y1) = slot
|
|
return math.hypot(x1 - x0, y1 - y0) + width
|
|
|
|
|
|
def _slot_corners(slot, radius):
|
|
"""Les quatre points de raccord droite / arc d'une lumiere, et son axe."""
|
|
(x0, y0), (x1, y1) = slot
|
|
length = math.hypot(x1 - x0, y1 - y0)
|
|
# Lumiere reduite a un cercle : direction arbitraire.
|
|
ux, uy = ((x1 - x0) / length, (y1 - y0) / length) if length > EPS else (0.0, 1.0)
|
|
nx, ny = -uy, ux
|
|
return ((x0 + radius * nx, y0 + radius * ny), (x1 + radius * nx, y1 + radius * ny),
|
|
(x1 - radius * nx, y1 - radius * ny), (x0 - radius * nx, y0 - radius * ny),
|
|
(ux, uy))
|
|
|
|
|
|
def slot_outline(slot, radius, segments=16):
|
|
"""Contour d'une lumiere en polyligne fermee (demi-cercles en `segments` pas)."""
|
|
(x0, y0), (x1, y1) = slot
|
|
a, _b, _c, _d, (ux, uy) = _slot_corners(slot, radius)
|
|
nx, ny = -uy, ux
|
|
points = [a]
|
|
for (px, py), sign in (((x1, y1), 1.0), ((x0, y0), -1.0)):
|
|
for k in range(segments + 1):
|
|
t = math.pi * k / segments
|
|
# Demi-cercle du cote +n au cote -n en passant par le bout.
|
|
c, s = math.cos(t) * sign, math.sin(t) * sign
|
|
points.append((px + radius * (c * nx + s * ux), py + radius * (c * ny + s * uy)))
|
|
return points
|
|
|
|
|
|
def slots_to_d(slots, radius, precision=4):
|
|
"""Donnees `d` d'un chemin SVG : un sous-chemin ferme par lumiere, bouts en arcs."""
|
|
fmt = "{:.%df},{:.%df}" % (precision, precision)
|
|
arc = "A {0} {0} 0 0 0 ".format(("{:.%df}" % precision).format(radius))
|
|
parts = []
|
|
for slot in slots:
|
|
a, b, c, d, _axis = _slot_corners(slot, radius)
|
|
parts.append("M " + fmt.format(*a))
|
|
if b != a:
|
|
parts.append("L " + fmt.format(*b))
|
|
parts.append(arc + fmt.format(*c))
|
|
if d != c:
|
|
parts.append("L " + fmt.format(*d))
|
|
parts.append(arc + fmt.format(*a) + " Z")
|
|
return " ".join(parts)
|