#!/usr/bin/env python3 # coding=utf-8 """ Genere le schema explicatif des parametres de l'extension. python docs/schema_parametres.py # les deux versions python docs/schema_parametres.py fr # docs/parametres.svg + .png (README) python docs/schema_parametres.py en # docs/parameters_en.svg # + images/parameters_en.png (onglet # « Help » de la boite de dialogue) Les cercles sont calcules par packing_core (memes fonctions que l'extension), le schema reste donc fidele au resultat reel. L'export PNG passe par inkscape.com, qui attend la fin de l'export (contrairement au raccourci Chocolatey « inkscape »). """ import math import os import random import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) sys.path.insert(0, ROOT) import packing_core as core # noqa: E402 W, H = 1400, 1280 # 4 panneaux de 700 x 640 INK = "#343a40" # element principal GHOST = "#dde1e5" # elements secondaires / voisins DIM = "#d9480f" # cotes DIM2 = "#1971c2" # cotes secondaires EDGE = "#212529" # contour des formes FONT = "font-family:Arial,Helvetica,sans-serif" INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape", "/Applications/Inkscape.app/Contents/MacOS/inkscape"] # Version par langue : textes, fichiers produits, largeur du PNG et agrandissement # des textes (la version anglaise est affichee reduite dans la boite de dialogue : # 900 px de large, textes x 1.25 pour rester lisibles). VERSIONS = { "fr": { "svg": os.path.join(HERE, "parametres.svg"), "png": os.path.join(HERE, "parametres.png"), "png_width": 1400, "font_scale": 1.0, "text": { "title1": "Rayon minimal et rayon maximal", "rmax": "rayon maximal", "rmin": "rayon minimal", "note1": ["Chaque cercle est le plus grand possible à cet endroit, sans dépasser", "le rayon maximal. Sous le rayon minimal, il n'est pas posé."], "title2": "Écart et marge intérieure", "gap": "écart", "margin": "marge", "note2": ["L'écart sépare deux cercles voisins ; la marge tient les cercles", "à distance du contour et des trous."], "title3": "Forme déposée : cercle ou motif", "motif": "motif", "disc": "disque calculé", "note3": ["Le motif est le dernier objet sélectionné. Chaque copie est mise à", "l'échelle pour tenir dans le disque calculé, puis orientée au hasard."], "title4": "Nombre de tentatives", "few": "300 tentatives", "many": "20 000 tentatives", "note4": ["Plus de tentatives : les vides se remplissent de petits cercles et", "le calcul est plus long. Les trous de la forme restent vides."], }, }, "en": { "svg": os.path.join(HERE, "parameters_en.svg"), "png": os.path.join(ROOT, "images", "parameters_en.png"), "png_width": 900, "font_scale": 1.25, "text": { "title1": "Minimum and maximum radius", "rmax": "maximum radius", "rmin": "minimum radius", "note1": ["Each circle is as large as it can be there, up to the", "maximum radius. Below the minimum, it is not placed."], "title2": "Gap and inner margin", "gap": "gap", "margin": "margin", "note2": ["The gap separates neighbouring circles; the margin", "keeps them away from the outline and the holes."], "title3": "Placed shape: circle or motif", "motif": "motif", "disc": "computed disc", "note3": ["The motif is the last selected object. Each copy is scaled", "to fit the computed disc, then rotated at random."], "title4": "Number of attempts", "few": "300 attempts", "many": "20,000 attempts", "note4": ["More attempts: the gaps fill up with small circles and it", "takes longer. Holes in the shape stay empty."], }, }, } def _unit(angle): """Vecteur unitaire, angle en degres, sens anti-horaire a l'ecran (y vers le bas).""" a = math.radians(angle) return math.cos(a), -math.sin(a) out = [] T = {} FS = 1.0 def fmt(v): return "{:.2f}".format(v).rstrip("0").rstrip(".") def pts(polyline): return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline) def polyline(points, color, width, extra=""): out.append(''.format(pts(points), color, fmt(width), extra)) def region(rings, fill="#ffffff", stroke=EDGE, width=2.2): """Forme fermee (anneaux, pair-impair) : le contour a remplir.""" d = " ".join("M " + " L ".join("{},{}".format(fmt(x), fmt(y)) for x, y in ring) + " Z" for ring in rings) out.append(''.format(d, fill, stroke, fmt(width))) def disc(x, y, r, fill=GHOST, stroke=INK, width=1.3, extra=""): out.append(''.format(fmt(x), fmt(y), fmt(r), fill, stroke, fmt(width), extra)) def discs(circles, **style): for x, y, r in circles: disc(x, y, r, **style) def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True): style = "{};font-size:{}px;font-weight:{};fill:{}".format( FONT, fmt(size * FS), weight, color) if halo: style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round" out.append('{}'.format( fmt(x), fmt(y), anchor, style, s)) def note(x, y, lines): """Legende de bas de panneau, derniere ligne sur la ligne de base `y`.""" step = 22 * FS for k, line in enumerate(lines): text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False) def arrow_head(tip, direction, color, size=9): ux, uy = direction nx, ny = -uy, ux base = (tip[0] - size * ux, tip[1] - size * uy) out.append(''.format(pts([ tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny), (base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color)) def span(p, q, color, label, label_at, anchor="middle", size=17): """Cote posee directement entre p et q (fleche a chaque bout), texte en `label_at`.""" dx, dy = q[0] - p[0], q[1] - p[1] length = math.hypot(dx, dy) ux, uy = dx / length, dy / length polyline([p, q], color, 2) arrow_head(p, (-ux, -uy), color, 10) arrow_head(q, (ux, uy), color, 10) text(label_at[0], label_at[1], label, size=size, color=color, anchor=anchor, weight="bold") def radius_mark(circle, angle, color, label, label_at, anchor="middle"): """Rayon cote du centre au bord, dans la direction `angle` (degres).""" x, y, r = circle ux, uy = _unit(angle) tip = (x + r * ux, y + r * uy) polyline([(x, y), tip], color, 1.8) arrow_head(tip, (ux, uy), color, 8) out.append(''.format(fmt(x), fmt(y), color)) text(label_at[0], label_at[1], label, color=color, anchor=anchor, weight="bold") def leader(start, end, color): polyline([start, end], color, 1.3) def circle_ring(cx, cy, r, n=180): return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)] def rounded_rect(x, y, w, h, r, n=10): ring = [] corners = ((x + w - r, y + r, -90), (x + w - r, y + h - r, 0), (x + r, y + h - r, 90), (x + r, y + r, 180)) for cx, cy, a0 in corners: for k in range(n + 1): a = math.radians(a0 + 90 * k / n) ring.append((cx + r * math.cos(a), cy + r * math.sin(a))) return ring def star(cx, cy, outer, inner, branches=5, angle=-90.0): ring = [] for k in range(2 * branches): a = math.radians(angle + 180.0 * k / branches) radius = outer if k % 2 == 0 else inner ring.append((cx + radius * math.cos(a), cy + radius * math.sin(a))) return ring def place_motif(ring, x, y, r, angle): """Copie du motif inscrite dans le disque (x, y, r) : meme calcul que CirclePacking.place_motif, a partir du cercle circonscrit du noyau.""" cx, cy, radius = core.bounding_circle([ring]) scale = r / radius cos_a, sin_a = math.cos(math.radians(angle)), math.sin(math.radians(angle)) return [(x + scale * ((px - cx) * cos_a - (py - cy) * sin_a), y + scale * ((px - cx) * sin_a + (py - cy) * cos_a)) for px, py in ring] def nearest_on_rings(x, y, rings): """Point du contour le plus proche de (x, y).""" best, best_point = float("inf"), None for ring in rings: for i, (x1, y1) in enumerate(ring): x2, y2 = ring[(i + 1) % len(ring)] dx, dy = x2 - x1, y2 - y1 seg = dx * dx + dy * dy t = 0.0 if seg == 0 else max(0.0, min(1.0, ((x - x1) * dx + (y - y1) * dy) / seg)) px, py = x1 + t * dx, y1 + t * dy d = math.hypot(x - px, y - py) if d < best: best, best_point = d, (px, py) return best_point def panel_title(x, y, number, title): text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False) # -------------------------------------------------------------------------- # Panneaux : chaque dessin est un vrai resultat de packing_core.pack_circles. # -------------------------------------------------------------------------- def panel_1(): """Rayons : le plus grand cercle atteint le rayon maximal, aucun n'est plus petit que le rayon minimal.""" panel_title(30, 50, 1, T["title1"]) shape = [rounded_rect(60, 85, 580, 450, 40)] circles = core.pack_circles(shape, 10, 85, gap=7, margin=9, attempts=6000, rng=random.Random(3)) region(shape) discs(circles) big = max(circles, key=lambda c: c[2]) disc(*big, fill="#ffe8cc", stroke=DIM, width=1.8) radius_mark(big, 35, DIM, T["rmax"], (big[0], big[1] + 32 * FS)) # le plus petit cercle, assez loin du grand pour que les textes ne se melent pas far = [c for c in circles if math.hypot(c[0] - big[0], c[1] - big[1]) > big[2] + 110 and 150 < c[0] < 520 and 150 < c[1] < 470] small = min(far, key=lambda c: c[2]) disc(*small, fill="#d0ebff", stroke=DIM2, width=1.8) ux, uy = _unit(40) start = (small[0] + small[2] * ux, small[1] + small[2] * uy) end = (small[0] + (small[2] + 34) * ux, small[1] + (small[2] + 34) * uy) leader(start, end, DIM2) text(end[0] + 4, end[1] - 4, T["rmin"], color=DIM2, weight="bold") note(30, 610, T["note1"]) def panel_2(): """Ecart entre deux cercles voisins, marge entre un cercle et le contour.""" ox = 700 panel_title(ox + 30, 50, 2, T["title2"]) shape = [rounded_rect(ox + 60, 85, 580, 450, 40)] gap, margin = 56.0, 52.0 circles = core.pack_circles(shape, 34, 90, gap=gap, margin=margin, attempts=4000, rng=random.Random(8)) region(shape) discs(circles) # paire de cercles au plus pres l'un de l'autre : leur distance vaut l'ecart pairs = [(math.hypot(a[0] - b[0], a[1] - b[1]) - a[2] - b[2], a, b) for i, a in enumerate(circles) for b in circles[i + 1:]] _slack, a, b = min(pairs, key=lambda item: item[0]) dx, dy = b[0] - a[0], b[1] - a[1] dist = math.hypot(dx, dy) ux, uy = dx / dist, dy / dist p = (a[0] + a[2] * ux, a[1] + a[2] * uy) q = (b[0] - b[2] * ux, b[1] - b[2] * uy) nx, ny = -uy, ux if ny > 0: nx, ny = -nx, -ny # texte du cote haut de la cote middle = (0.5 * (p[0] + q[0]), 0.5 * (p[1] + q[1])) span(p, q, DIM, T["gap"], (middle[0] + 24 * FS * nx, middle[1] + 24 * FS * ny + 6 * FS)) # cercle au plus pres du contour : sa distance au bord vaut la marge edge = min(circles, key=lambda c: core.distance_to_rings(c[0], c[1], shape) - c[2]) foot = nearest_on_rings(edge[0], edge[1], shape) dx, dy = foot[0] - edge[0], foot[1] - edge[1] dist = math.hypot(dx, dy) ux, uy = dx / dist, dy / dist rim = (edge[0] + edge[2] * ux, edge[1] + edge[2] * uy) nx, ny = -uy, ux if ny > 0: nx, ny = -nx, -ny middle = (0.5 * (rim[0] + foot[0]), 0.5 * (rim[1] + foot[1])) span(rim, foot, DIM2, T["margin"], (middle[0] + 30 * FS * nx, middle[1] + 30 * FS * ny + 6 * FS)) note(ox + 30, 610, T["note2"]) def panel_3(): """Forme personnalisee : le motif est inscrit dans chaque disque calcule.""" oy = 640 panel_title(30, oy + 50, 3, T["title3"]) motif = star(0, 0, 50, 21) # le motif source, a gauche source = place_motif(motif, 110, oy + 215, 58, 0) region([source], fill="#ffe8cc", stroke=DIM, width=1.8) text(110, oy + 215 + 58 + 26 * FS, T["motif"], color=DIM, anchor="middle", weight="bold") polyline([(180, oy + 215), (208, oy + 215)], INK, 1.8) arrow_head((216, oy + 215), (1, 0), INK, 9) # legende du disque calcule, sous le motif disc(110, oy + 400, 30, fill="none", stroke=DIM2, width=1.8, extra='stroke-dasharray="6,4"') text(110, oy + 430 + 26 * FS, T["disc"], color=DIM2, anchor="middle", weight="bold") shape = [rounded_rect(235, oy + 85, 405, 450, 40)] rng = random.Random(5) circles = core.pack_circles(shape, 14, 70, gap=6, margin=9, attempts=5000, rng=rng) region(shape) for x, y, r in circles: disc(x, y, r, fill="none", stroke="#adb5bd", width=1, extra='stroke-dasharray="4,4"') region([place_motif(motif, x, y, r, rng.uniform(0.0, 360.0))], fill=GHOST, stroke=INK, width=1.3) big = max(circles, key=lambda c: c[2]) disc(*big, fill="none", stroke=DIM2, width=1.8, extra='stroke-dasharray="6,4"') note(30, oy + 610, T["note3"]) def panel_4(): """Tentatives : meme forme trouee, remplissage lache puis dense.""" ox, oy = 700, 640 panel_title(ox + 30, oy + 50, 4, T["title4"]) for k, (attempts, key) in enumerate(((300, "few"), (20000, "many"))): x0 = ox + 45 + k * 315 shape = [rounded_rect(x0, oy + 85, 295, 400, 36), circle_ring(x0 + 147.5, oy + 285, 62)] circles = core.pack_circles(shape, 3.5, 44, gap=3.5, margin=5, attempts=attempts, rng=random.Random(2)) region(shape) discs(circles, width=1.1) text(x0 + 147.5, oy + 485 + 34 * FS, T[key], anchor="middle", weight="bold", halo=False) note(ox + 30, oy + 610, T["note4"]) def export_png(svg_path, png_path, width): exe = next((path for path in INKSCAPE if os.path.exists(path)), None) if exe is None: print("Inkscape introuvable : exporter {} a la main".format(svg_path)) return os.makedirs(os.path.dirname(png_path), exist_ok=True) subprocess.run([exe, svg_path, "--export-type=png", "--export-width={}".format(width), "--export-filename={}".format(png_path)], check=True, stderr=subprocess.DEVNULL) print(png_path) def build(language): global FS version = VERSIONS[language] out.clear() T.clear() T.update(version["text"]) FS = version["font_scale"] out.append(''.format(W, H)) for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)): out.append(''.format(x1, y1, x2, y2)) panel_1() panel_2() panel_3() panel_4() svg = ('\n' '\n{2}\n\n').format(W, H, "\n".join(out)) with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle: handle.write(svg) print(version["svg"]) export_png(version["svg"], version["png"], version["png_width"]) def main(argv): languages = argv or sorted(VERSIONS) for language in languages: build(language) if __name__ == "__main__": main(sys.argv[1:])