296 lines
11 KiB
Python
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]
|