423 lines
16 KiB
Python
423 lines
16 KiB
Python
#!/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('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
|
|
'stroke-linejoin="miter" {}/>'.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('<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" stroke-width="{}" '
|
|
'stroke-linejoin="round"/>'.format(d, fill, stroke, fmt(width)))
|
|
|
|
|
|
def disc(x, y, r, fill=GHOST, stroke=INK, width=1.3, extra=""):
|
|
out.append('<circle cx="{}" cy="{}" r="{}" fill="{}" stroke="{}" stroke-width="{}" '
|
|
'{}/>'.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('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.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('<polygon points="{}" fill="{}"/>'.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('<circle cx="{}" cy="{}" r="3" fill="{}"/>'.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('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
|
|
for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)):
|
|
out.append('<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#dee2e6" '
|
|
'stroke-width="1.5"/>'.format(x1, y1, x2, y2))
|
|
panel_1()
|
|
panel_2()
|
|
panel_3()
|
|
panel_4()
|
|
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
|
|
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\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:])
|