#!/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').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:])