inkscape.motif.relief/relief_core.py
2026-10-19 20:51:52 +02:00

296 lines
11 KiB
Python

# coding=utf-8
"""
Noyau de calcul de l'extension « Relief Pattern Fill », sans dependance a inkex.
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
des parametres).
Le motif est un pavage isometrique (aretes verticales et a +/-30 degres) de
« Y » en relief, releve sur l'image du dossier doc/. Il vit sur un reseau de
noeuds triangulaire : le noeud (i, j) est en x = i * edge * cos(30), y = j *
edge / 2 (les noeuds valides ont i + j pair). Une arete unitaire va d'un noeud
a un voisin situe a une distance `edge` ; elle est notee (i, j, di, dj),
(di, dj) valant (0, 2), (1, 1) ou (1, -1).
Le motif se repete par translation de 5 aretes : vecteurs (0, 10) et (5, 5)
en coordonnees (i, j), soit une maille hexagonale de cote 5 * edge.
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 ;
- un segment est un couple de points ((x1, y1), (x2, y2)) ;
- l'axe y est oriente vers le bas (repere SVG).
"""
import math
COS30 = math.cos(math.radians(30))
# Nombre maximal de segments avant de refuser le calcul (document fige sinon).
MAX_SEGMENTS = 250000
# Aretes unitaires de la maille de base (39 sur les 75 possibles).
CELL_EDGES = (
(0, 0, 1, 1), (0, 2, 0, 2), (0, 2, 1, 1), (0, 4, 1, 1),
(0, 6, 0, 2), (0, 6, 1, 1), (0, 8, 0, 2), (1, 1, 1, 1),
(1, 3, 0, 2), (1, 3, 1, -1), (1, 5, 1, -1), (1, 5, 1, 1),
(1, 7, 0, 2), (1, 7, 1, -1), (1, 9, 1, -1), (1, 9, 1, 1),
(2, 0, 0, 2), (2, 0, 1, -1), (2, 4, 0, 2), (2, 4, 1, -1),
(2, 4, 1, 1), (2, 6, 0, 2), (2, 8, 1, 1), (3, 1, 0, 2),
(3, 1, 1, -1), (3, 1, 1, 1), (3, 3, 1, 1), (3, 5, 0, 2),
(3, 5, 1, -1), (3, 7, 1, -1), (3, 7, 1, 1), (3, 9, 0, 2),
(3, 9, 1, 1), (4, 0, 1, -1), (4, 2, 0, 2), (4, 2, 1, -1),
(4, 6, 1, -1), (4, 8, 0, 2), (4, 8, 1, -1),
)
PERIOD_EDGES = 5 # periode du motif, en nombre d'aretes
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 rings_bbox(rings):
"""Boite englobante (xmin, ymin, xmax, ymax) d'un contour, None si vide."""
points = [p for ring in rings for p in ring]
if not points:
return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def rotate_point(point, angle, center=(0.0, 0.0)):
"""Rotation de `angle` degres autour de `center` (sens horaire a l'ecran)."""
a = math.radians(angle)
c, s = math.cos(a), math.sin(a)
x, y = point[0] - center[0], point[1] - center[1]
return center[0] + c * x - s * y, center[1] + s * x + c * y
def point_in_rings(point, rings):
"""Appartenance au contour, regle pair-impair (trous compris)."""
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
# --------------------------------------------------------------------------
# Le motif
# --------------------------------------------------------------------------
def _node(i, j, edge):
return i * edge * COS30, j * edge / 2.0
def _merged_runs(origin_cells):
"""Aretes des mailles donnees, fusionnees en segments rectilignes maximaux.
`origin_cells` : iterable de decalages (di0, dj0) de mailles entieres.
Deux aretes bout a bout sur la meme droite deviennent un seul segment :
moins de sous-chemins dans le SVG, et pas de fente aux jonctions.
"""
runs = {} # (direction, droite) -> {debut: fin}
for di0, dj0 in origin_cells:
for i, j, di, dj in CELL_EDGES:
i, j = i + di0, j + dj0
if di == 0:
key, start, end = (0, i), j, j + 2
elif dj == 1:
key, start, end = (1, j - i), i, i + 1
else:
key, start, end = (2, j + i), i, i + 1
runs.setdefault(key, {})[start] = end
for (direction, line), spans in runs.items():
begin = None
previous_end = None
for start in sorted(spans):
if begin is not None and start == previous_end:
previous_end = spans[start]
continue
if begin is not None:
yield direction, line, begin, previous_end
begin, previous_end = start, spans[start]
if begin is not None:
yield direction, line, begin, previous_end
def pattern_segments(bbox, edge):
"""Segments du motif couvrant `bbox` (xmin, ymin, xmax, ymax), cote d'arete `edge`.
Le noeud (0, 0) du reseau est en (0, 0). Les segments debordent un peu de
la boite (on garde ceux qui la touchent) : c'est a l'appelant de decouper.
"""
if edge <= 0:
raise ValueError("edge must be positive")
xmin, ymin, xmax, ymax = bbox
hx, hy = edge * COS30, edge / 2.0
i_lo, i_hi = int(math.floor(xmin / hx)) - 1, int(math.ceil(xmax / hx)) + 1
j_lo, j_hi = int(math.floor(ymin / hy)) - 1, int(math.ceil(ymax / hy)) + 1
# Estimation avant de construire quoi que ce soit.
cells = ((i_hi - i_lo) / PERIOD_EDGES + 3) * ((j_hi - j_lo) / (2.0 * PERIOD_EDGES) + 3)
if cells * len(CELL_EDGES) > MAX_SEGMENTS * 1.5:
raise OverflowError("too many segments")
# Mailles (m, n) : decalage (5 m, 5 m + 10 n) en (i, j). On garde celles
# dont le carre englobe croise la boite.
span = PERIOD_EDGES
origins = []
m_lo, m_hi = i_lo // span - 1, i_hi // span + 1
for m in range(m_lo, m_hi + 1):
n_lo = (j_lo - span * m) // (2 * span) - 1
n_hi = (j_hi - span * m) // (2 * span) + 1
for n in range(n_lo, n_hi + 1):
origins.append((span * m, span * m + 2 * span * n))
segments = []
for direction, line, begin, end in _merged_runs(origins):
if direction == 0:
p, q = _node(line, begin, edge), _node(line, end, edge)
elif direction == 1:
p, q = _node(begin, line + begin, edge), _node(end, line + end, edge)
else:
p, q = _node(begin, line - begin, edge), _node(end, line - end, edge)
if (max(p[0], q[0]) < xmin or min(p[0], q[0]) > xmax
or max(p[1], q[1]) < ymin or min(p[1], q[1]) > ymax):
continue
segments.append((p, q))
if len(segments) > MAX_SEGMENTS:
raise OverflowError("too many segments")
return segments
# --------------------------------------------------------------------------
# Decoupe
# --------------------------------------------------------------------------
def clip_segment(p, q, rings):
"""Parties du segment p-q situees a l'interieur du contour (pair-impair).
On coupe le segment a chaque croisement d'un cote du contour, puis on
garde les morceaux dont le milieu est a l'interieur.
"""
px, py = p
dx, dy = q[0] - px, q[1] - py
sx0, sx1 = min(px, q[0]), max(px, q[0])
sy0, sy1 = min(py, q[1]), max(py, q[1])
cuts = [0.0, 1.0]
for ring in rings:
n = len(ring)
for k in range(n):
ax, ay = ring[k]
bx, by = ring[(k + 1) % n]
if (max(ax, bx) < sx0 or min(ax, bx) > sx1
or max(ay, by) < sy0 or min(ay, by) > sy1):
continue
ex, ey = bx - ax, by - ay
denom = dx * ey - dy * ex
if abs(denom) < EPS:
continue # paralleles : pas de croisement franc
t = ((ax - px) * ey - (ay - py) * ex) / denom
u = ((ax - px) * dy - (ay - py) * dx) / denom
if 0.0 < t < 1.0 and -EPS <= u <= 1.0 + EPS:
cuts.append(t)
cuts.sort()
pieces = []
for t0, t1 in zip(cuts, cuts[1:]):
if t1 - t0 < 1e-7:
continue
mid = (px + dx * (t0 + t1) / 2.0, py + dy * (t0 + t1) / 2.0)
if not point_in_rings(mid, rings):
continue
a = (px + dx * t0, py + dy * t0)
b = (px + dx * t1, py + dy * t1)
if pieces and pieces[-1][1] == a:
pieces[-1] = (pieces[-1][0], b) # morceaux contigus : un seul
else:
pieces.append((a, b))
return pieces
def fill_rings(rings, edge, angle=0.0, anchor=(0.0, 0.0)):
"""Remplit l'interieur du contour avec le motif ; renvoie des segments.
`edge` : longueur de l'arete unitaire du motif ;
`angle` : rotation du motif en degres (sens horaire a l'ecran) ;
`anchor` : point du plan ou tombe un noeud du reseau (centre de rotation).
Leve OverflowError si le motif compterait trop de segments (arete trop
petite pour la forme).
"""
rings = [r for r in rings if len(r) >= 3]
box = rings_bbox(rings)
if box is None:
return []
# On travaille dans le repere du motif (tourne puis translate) : le motif
# est alors aligne sur les axes, et le contour est ramene dans ce repere.
local = [[rotate_point((x - anchor[0], y - anchor[1]), -angle) for x, y in ring]
for ring in rings]
local_box = rings_bbox(local)
out = []
for p, q in pattern_segments(local_box, edge):
for a, b in clip_segment(p, q, local):
out.append((_to_world(a, angle, anchor), _to_world(b, angle, anchor)))
return out
def _to_world(point, angle, anchor):
x, y = rotate_point(point, angle)
return x + anchor[0], y + anchor[1]
def segments_to_polylines(segments):
"""Segments -> polylines a deux points (pour polylines_to_d)."""
return [[p, q] for p, q in segments]