inkscape.decoupeFlexible/living_hinge_core.py
2026-10-01 15:21:24 +02:00

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)