# 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]