commit eb9308a8d46adcc7f37b2dacb458ca5f3cedd7fc Author: Alex Date: Thu Oct 1 15:21:24 2026 +0200 Version initiale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aa9a2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Cache Python et outils +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ + +# Archives de distribution +*.zip + +# SVG intermediaires du schema (regeneres par docs/schema_parametres.py) +docs/*.svg + +# Page de presentation : hors depot (publiee sur le NAS par site/deploy.ps1 ; +# sa configuration contient hote, compte et chemin de cle SSH) +site/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6614f79 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Remplissage découpe flexible — extension Inkscape + + + + + + +
+Rectangle, disque, chemin courbe, rectangle troué et ellipse remplis de lumières oblongues en quinconce + + +Extension Inkscape 1.x qui remplit les formes sélectionnées avec un motif de +**découpe flexible** (*living hinge*) : des lumières oblongues rangées en +colonnes décalées en quinconce. Une fois le motif découpé au laser, le panneau +(contreplaqué, MDF, acrylique…) se plie autour d'un axe parallèle aux lumières. + +Près du bord et autour des trous, les lumières sont raccourcies et ré-arrondies +pour rester à la marge demandée, comme sur le motif d'origine +([doc/Patern.jpg](doc/Patern.jpg)). Un mode aléatoire tire la longueur de +chaque lumière au hasard, du trou rond à la lumière entière +([doc/patern2.jpg](doc/patern2.jpg)). + +
+ +## Sommaire + +- [Fonctionnalités](#fonctionnalités) +- [Installation](#installation) +- [Utilisation](#utilisation) +- [Paramètres](#paramètres) +- [Structure du projet](#structure-du-projet) +- [Développement](#développement) + +## Fonctionnalités + +- Remplit rectangles, cercles, ellipses, polygones et chemins, y compris les + formes trouées (règle pair-impair) et les formes rangées dans des groupes ou + des calques transformés. Textes, images et clones sont ignorés. +- Lumières à bouts arrondis tracées avec de vrais arcs de cercle (pas de + polylignes), toutes réunies en **un seul chemin** par forme. +- Lumières raccourcies au bord : elles s'arrêtent à la marge, gardent leurs + bouts ronds et disparaissent sous une longueur minimale réglable. +- **Longueurs aléatoires** en option : chaque lumière prend une longueur au + hasard entre un minimum et la longueur réglée, pont et pas constants. + Chaque colonne est remplie d'une marge à l'autre : une lumière commence pile + en haut, une autre finit pile en bas, toutes les colonnes débutent et + s'arrêtent donc au même niveau. Le tirage est reproductible (graine) : mêmes + réglages, même motif. +- Décalage des colonnes réglable (50 % = quinconce) et motif orientable à + n'importe quel angle. +- Longueurs saisies en mm, cm, px, pt ou pouces. +- Interface traduite en français et en anglais, selon la langue d'Inkscape + (anglais pour toute autre langue). +- Script de déploiement et de désinstallation pour Windows. + +## Installation + +Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape. + +### Windows + +Inkscape fermé, depuis le dossier du projet : + +```powershell +.\deploy.ps1 # copie dans %APPDATA%\inkscape\extensions\livingHinge +.\deploy.ps1 -Uninstall # supprime l'extension +.\deploy.ps1 -Force # déploie même si Inkscape est ouvert +``` + +### Manuelle + +Copier dans un sous-dossier du répertoire des extensions utilisateur, en +conservant l'arborescence : + +```text +living_hinge.inx +living_hinge.py +living_hinge_core.py +images/parameters_en.png +locale/ +``` + +| Système | Répertoire des extensions | +| ------- | ------------------------------------------------------- | +| Windows | `%APPDATA%\inkscape\extensions` | +| Linux | `~/.config/inkscape/extensions` | +| macOS | `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` | + +Redémarrer Inkscape : les extensions ne sont lues qu'au démarrage. + +## Utilisation + +1. Dessiner la zone à assouplir (rectangle, ellipse ou chemin fermé) et la + sélectionner. Plusieurs formes peuvent être traitées d'un coup. +2. Ouvrir **Extensions > AlexDesign > Remplissage découpe flexible…** + (*Living Hinge Fill…*). +3. Régler le motif dans l'onglet « Motif », les longueurs aléatoires dans + l'onglet « Aléatoire », la marge et le trait dans l'onglet « Remplissage » ; + cocher « Aperçu en direct » pour voir le résultat. +4. Appliquer : les lumières sont ajoutées dans un groupe « Découpe flexible » + placé juste au-dessus de la forme, centrées sur elle. + +Avec « Longueurs de lumières aléatoires » coché : + +![Formes remplies de lumières de longueurs aléatoires](docs/exemple-aleatoire.png) + +### Conseils + +| Symptôme | Réglage | +| ------------------------------------------ | ----------------------------------------------------------- | +| Le panneau casse en pliant | Augmenter le pont et le pas des colonnes | +| Le panneau est trop raide | Allonger les lumières, réduire le pont ou le pas | +| Le pli doit être horizontal | Angle du motif à 90° | +| Petits trous ronds le long du bord | Augmenter la longueur minimale d'une lumière raccourcie | +| Le tirage aléatoire ne plaît pas | Changer la graine du hasard | +| Trop de petits trous ronds en mode aléatoire | Augmenter la plus courte lumière aléatoire | +| « forme(s) trop petite(s) » | Réduire la largeur des lumières, la longueur minimale ou la marge | +| Découpe d'un simple trait plutôt que d'une lumière | Réduire la largeur au trait de coupe du laser (0,1 à 0,2 mm) | + +## Paramètres + +![Schéma des paramètres](docs/parametres.png) + +| Paramètre | Défaut | Rôle | +| ------------------------------------------------------ | ------ | -------------------------------------------------------------------------------------- | +| Longueur d'une lumière | 20 | Longueur hors tout d'une lumière entière, bouts arrondis compris | +| Largeur d'une lumière | 3 | Largeur de la lumière ; le rayon des bouts en est la moitié | +| Pont (matière entre deux lumières d'une colonne) | 2 | Matière laissée entre deux lumières qui se suivent dans une colonne | +| Pas des colonnes (entraxe) | 5 | Distance entre les axes de deux colonnes voisines (supérieure à la largeur) | +| Unité | mm | Unité de toutes les longueurs : mm, cm, px, pt, in | +| Décalage des colonnes impaires (% de longueur + pont) | 50 | 50 % = quinconce ; 0 % = colonnes alignées | +| Angle du motif (°, 0 = lumières verticales) | 0 | Rotation du motif, sens anti-horaire | +| Longueurs de lumières aléatoires | non | Tire la longueur de chaque lumière au hasard, colonnes remplies d'une marge à l'autre ; décalage et longueur minimale ne servent plus | +| Plus courte lumière aléatoire | 3 | Borne basse du tirage (au moins la largeur, soit un trou rond) ; la borne haute est la longueur d'une lumière | +| Graine du hasard | 1 | Même graine, même motif ; la changer donne un autre tirage | +| Marge entre les lumières et le bord | 2 | Distance minimale entre une lumière et le contour ou un trou | +| Longueur minimale d'une lumière raccourcie | 6 | Sous cette longueur, une lumière raccourcie par le bord est supprimée | +| Couleur du trait | noir | Couleur des lumières | +| Épaisseur du trait | 0,2 | Épaisseur du trait, dans l'unité choisie | +| Conserver la forme d'origine | oui | Décoché, la forme remplie est supprimée et seul le motif reste | + +## Structure du projet + +```text +living_hinge.inx Boîte de dialogue (textes source en anglais) +living_hinge.py Couche inkex : sélection, unités, écriture SVG +living_hinge_core.py Calcul du motif, sans inkex +i18n.py Extraction / mise à jour / compilation des traductions +po/ livinghinge.pot, en.po, fr.po +locale//LC_MESSAGES/ livinghinge.mo (générés, déployés) +images/parameters_en.png Schéma de l'onglet « Aide » (déployé) +doc/Patern.jpg, patern2.jpg Motifs d'origine (régulier, aléatoire) +docs/schema_parametres.py Générateur du schéma (fr pour le README, en pour le dialogue) +docs/parametres.png Schéma du README +docs/exemple.png Rendu de l'extension sur les formes d'exemple +docs/exemple-aleatoire.png Même rendu, longueurs aléatoires +tests/data/shapes.svg Formes d'exemple des tests de bout en bout +test_living_hinge.py Tests pytest +deploy.ps1 Déploiement / désinstallation Windows +``` + +### API de `living_hinge_core` + +Un contour (`rings`) est une liste d'anneaux de points `(x, y)`, trous en +pair-impair, axe y vers le bas. Une lumière est le couple `(p0, p1)` des centres +de ses deux bouts arrondis. + +| Fonction | Rôle | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `hinge_slots(rings, length, width, bridge, pitch, stagger=50, angle=0, margin=0, min_length=0, random_min=None, seed=0)` | Lumières du motif qui remplit le contour ; `random_min` active les longueurs aléatoires | +| `regular_spans(low, high, origin, length, bridge)` | Lumières `(début, longueur)` d'une colonne régulière | +| `random_spans(low, high, shortest, longest, bridge, rng)` | Lumières `(début, longueur)` aléatoires qui remplissent exactement `[low, high]` | +| `clear_intervals(rings, x, clearance=0)` | Portions de la verticale `x` situées dans le contour, à `clearance` du bord | +| `slots_to_d(slots, radius, precision=4)` | Données `d` SVG : un sous-chemin fermé par lumière, bouts en arcs | +| `slot_outline(slot, radius, segments=16)` | Contour d'une lumière en polyligne fermée | +| `slot_length(slot, width)` | Longueur hors tout d'une lumière | +| `rotate(point, angle, center=(0, 0))` | Rotation d'un point, sens anti-horaire à l'écran | +| `bounding_box(rings)` | Boîte englobante d'un contour | +| `parse_color(value)` | Couleur Inkscape (entier RGBA) → `(#rrggbb, opacité)` | +| `polylines_to_d(polylines, precision=4)` | Données `d` SVG d'une liste de polylignes | + +## Développement + +```powershell +python -m venv .venv +.venv\Scripts\pip install pytest +python -m pytest -q +``` + +Résultat attendu : **27 tests passés, 9 ignorés** (les tests de bout en bout +demandent `inkex`). Avec le `inkex` d'Inkscape : + +```powershell +pip install lxml tinycss2 cssselect2 cssselect +$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions' +python -m pytest -q # 36 tests passés +``` + +Les tests couvrent le noyau (intervalles libres, respect de la marge et des +trous, pont, pas, quinconce, angle, longueur minimale, longueurs aléatoires reproductibles et colonnes remplies d'une marge à l'autre, +entrées dégénérées, +tracé en arcs), l'extension de bout en bout (formes, groupes transformés, +unités, mode aléatoire, messages d'erreur, couleur, suppression de l'original) et la cohérence +des traductions et du `.inx` (arguments, valeurs par défaut, images). + +### Traductions + +Les textes source sont en anglais dans `living_hinge.inx` et dans les `_("…")` +de `living_hinge.py`. + +```powershell +python i18n.py # extract + update + compile ; liste les msgstr manquants +``` + +Après modification d'un texte : lancer `python i18n.py`, compléter les `msgstr` +vides de `po/fr.po`, relancer `python i18n.py`. Ne jamais modifier les `.mo` à +la main. Pour ajouter une langue : l'ajouter à `LANGUAGES` dans `i18n.py`, puis +traduire le `po/.po` créé. + +### Schéma des paramètres + +```powershell +python docs/schema_parametres.py # les deux versions +python docs/schema_parametres.py fr # docs/parametres.png (1400 px, README) +python docs/schema_parametres.py en # images/parameters_en.png (900 px, onglet « Aide ») +``` + +Le schéma est dessiné avec les fonctions du noyau, il reste donc fidèle au +rendu. L'export PNG passe par `inkscape.com` (le raccourci `inkscape` rend la +main avant la fin de l'export). diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..b954809 --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Déploie l'extension « Remplissage découpe flexible » dans Inkscape. + +.DESCRIPTION + Copie living_hinge.inx, living_hinge.py, living_hinge_core.py, le schéma de l'onglet + « Help » (images\parameters_en.png) et les traductions + compilées (locale\\LC_MESSAGES\livinghinge.mo) dans un + sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape + (Inkscape explore récursivement ce répertoire). Refuse de s'exécuter si + Inkscape est ouvert : les extensions ne sont chargées qu'au démarrage. + +.PARAMETER ExtensionsDir + Répertoire des extensions Inkscape. Par défaut : %APPDATA%\inkscape\extensions + +.PARAMETER FolderName + Nom du sous-dossier de l'extension. Par défaut : livingHinge + +.PARAMETER Uninstall + Supprime le sous-dossier de l'extension au lieu de le créer. + +.PARAMETER Force + Déploie même si Inkscape est en cours d'exécution. + +.EXAMPLE + .\deploy.ps1 + .\deploy.ps1 -Uninstall + .\deploy.ps1 -FolderName 'autre-nom' +#> + +[CmdletBinding()] +param( + [string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'), + [string] $FolderName = 'livingHinge', + [switch] $Uninstall, + [switch] $Force +) + +$ErrorActionPreference = 'Stop' + +$files = @('living_hinge.inx', 'living_hinge.py', 'living_hinge_core.py', 'images\parameters_en.png') +$localeDir = 'locale' +$source = $PSScriptRoot +$destination = Join-Path $ExtensionsDir $FolderName + +# --- Inkscape doit être fermé : les extensions sont lues au démarrage --------- +$running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue +if ($running -and -not $Force) { + Write-Error "Inkscape est ouvert. Fermez-le puis relancez le script (ou utilisez -Force)." + exit 1 +} + +# --- Désinstallation --------------------------------------------------------- +if ($Uninstall) { + if (Test-Path -LiteralPath $destination) { + Remove-Item -LiteralPath $destination -Recurse -Force + Write-Host "Supprimé : $destination" -ForegroundColor Green + } + else { + Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow + } + exit 0 +} + +# --- Vérification des sources ------------------------------------------------ +$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) } +if (-not (Test-Path -LiteralPath (Join-Path $source $localeDir))) { + $missing = @($missing) + "$localeDir\ (lancer : python i18n.py)" +} +if ($missing) { + Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')" + exit 1 +} + +# --- Copie ------------------------------------------------------------------- +if (-not (Test-Path -LiteralPath $destination)) { + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Write-Host "Dossier créé : $destination" +} + +foreach ($file in $files) { + $from = Join-Path $source $file + $to = Join-Path $destination $file + $action = if (Test-Path -LiteralPath $to) { 'remplacé' } else { 'copié ' } + New-Item -ItemType Directory -Path (Split-Path $to) -Force | Out-Null + Copy-Item -LiteralPath $from -Destination $to -Force + Write-Host " $action $file" +} + +# Traductions : le dossier est remplacé en entier (langue retirée => supprimée) +$localeTo = Join-Path $destination $localeDir +if (Test-Path -LiteralPath $localeTo) { + Remove-Item -LiteralPath $localeTo -Recurse -Force +} +Copy-Item -LiteralPath (Join-Path $source $localeDir) -Destination $localeTo -Recurse +$languages = Get-ChildItem -LiteralPath $localeTo -Directory | ForEach-Object Name +Write-Host " copié $localeDir\ ($($languages -join ', '))" + +# Le cache Python d'une version précédente peut masquer les fichiers copiés +$pycache = Join-Path $destination '__pycache__' +if (Test-Path -LiteralPath $pycache) { + Remove-Item -LiteralPath $pycache -Recurse -Force +} + +Write-Host "" +Write-Host "Extension déployée dans $destination" -ForegroundColor Green +Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Remplissage découpe flexible..." diff --git a/doc/Patern.jpg b/doc/Patern.jpg new file mode 100644 index 0000000..25dcff7 Binary files /dev/null and b/doc/Patern.jpg differ diff --git a/doc/patern2.jpg b/doc/patern2.jpg new file mode 100644 index 0000000..82c3962 Binary files /dev/null and b/doc/patern2.jpg differ diff --git a/docs/exemple-aleatoire.png b/docs/exemple-aleatoire.png new file mode 100644 index 0000000..30b155e Binary files /dev/null and b/docs/exemple-aleatoire.png differ diff --git a/docs/exemple.png b/docs/exemple.png new file mode 100644 index 0000000..c56a653 Binary files /dev/null and b/docs/exemple.png differ diff --git a/docs/parametres.png b/docs/parametres.png new file mode 100644 index 0000000..94f56dc Binary files /dev/null and b/docs/parametres.png differ diff --git a/docs/schema_parametres.py b/docs/schema_parametres.py new file mode 100644 index 0000000..ec19872 --- /dev/null +++ b/docs/schema_parametres.py @@ -0,0 +1,379 @@ +#!/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 formes sont calculees par living_hinge_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 subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +import living_hinge_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": "Lumière : longueur et largeur", + "note1": ["Chaque lumière est un rectangle à bouts arrondis ;", + "la longueur se mesure hors tout."], + "length": "longueur", + "width": "largeur", + "title2": "Pont, pas et décalage", + "note2": ["Pont : matière entre deux lumières d'une colonne. Pas : entraxe", + "des colonnes. Décalage en % de (longueur + pont), 50 % = quinconce."], + "bridge": "pont", + "pitch": "pas", + "title3": "Longueurs aléatoires et angle", + "note3": ["Aléatoire : longueurs au hasard entre le mini et la longueur,", + "colonnes pleines d'une marge à l'autre. L'angle tourne le motif."], + "shortest": "mini", + "longest": "longueur", + "stagger": "50 %", + "angle": "angle", + "title4": "Marge et longueur minimale", + "note4": ["Près du bord et des trous, les lumières sont raccourcies à", + "la marge ; plus courtes que le minimum, elles disparaissent."], + "margin": "marge", + "minimum": "≥ mini", + }, + }, + "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": "Slot: length and width", + "note1": ["Each slot is a rectangle with rounded ends;", + "its length is measured end to end."], + "length": "length", + "width": "width", + "title2": "Bridge, pitch and stagger", + "note2": ["Bridge: material between two slots of a column. Pitch: column", + "spacing. Stagger in % of (length + bridge), 50 % = staggered."], + "bridge": "bridge", + "pitch": "pitch", + "title3": "Random lengths and angle", + "note3": ["Random: lengths drawn between the shortest and the length,", + "columns filled from margin to margin. The angle turns the pattern."], + "shortest": "min", + "longest": "length", + "stagger": "50 %", + "angle": "angle", + "title4": "Margin and minimum length", + "note4": ["Near the edge and holes, slots are shortened to the", + "margin; shorter than the minimum, they are dropped."], + "margin": "margin", + "minimum": "≥ min", + }, + }, +} + +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 motif(polylines, color, width): + for pl in polylines: + polyline(pl, color, width) + + +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 dimension(p, q, offset, label, color=DIM, label_pos=0.5, label_shift=18, size=17): + """Cote entre p et q, decalee de `offset` perpendiculairement.""" + dx, dy = q[0] - p[0], q[1] - p[1] + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + nx, ny = -uy, ux + a = (p[0] + offset * nx, p[1] + offset * ny) + b = (q[0] + offset * nx, q[1] + offset * ny) + sign = 1 if offset >= 0 else -1 + for base, end in ((p, a), (q, b)): + polyline([(base[0] + sign * 4 * nx, base[1] + sign * 4 * ny), + (end[0] + sign * 6 * nx, end[1] + sign * 6 * ny)], color, 1, + 'stroke-dasharray="3,3"') + polyline([a, b], color, 1.6) + arrow_head(a, (-ux, -uy), color) + arrow_head(b, (ux, uy), color) + lx = a[0] + label_pos * (b[0] - a[0]) + sign * label_shift * FS * nx + ly = a[1] + label_pos * (b[1] - a[1]) + sign * label_shift * FS * ny + 6 * FS + text(lx, ly, label, size=size, color=color, anchor="middle", weight="bold") + + +def arc(center, radius, a0, a1, color, label=None, label_radius=None, size=16): + """Arc de a0 a a1 degres (sens anti-horaire a l'ecran), fleche au bout.""" + steps = 40 + points = [] + for k in range(steps + 1): + ux, uy = _unit(a0 + (a1 - a0) * k / steps) + points.append((center[0] + radius * ux, center[1] + radius * uy)) + polyline(points[:-2], color, 1.6) + last, before = points[-1], points[-3] + d = math.hypot(last[0] - before[0], last[1] - before[1]) + arrow_head(last, ((last[0] - before[0]) / d, (last[1] - before[1]) / d), color, 8) + if label: + ux, uy = _unit((a0 + a1) / 2) + r = label_radius or radius + 24 + text(center[0] + r * ux, center[1] + r * uy + 6 * FS, label, size=size, + color=color, anchor="middle", weight="bold") + + +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 panel_title(x, y, number, title): + text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False) + + + +# -------------------------------------------------------------------------- +# Panneaux : un par groupe de parametres, numerotes 1 a 4. Les lumieres sont +# calculees par le noyau (hinge_slots, slot_outline), en pixels du schema. +# -------------------------------------------------------------------------- + +def rect_ring(x0, y0, x1, y1): + return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] + + +def shape(rings, fill="#f1f3f5"): + d = " ".join("M {} Z".format(" L ".join("{},{}".format(fmt(x), fmt(y)) for x, y in ring)) + for ring in rings) + out.append(''.format(d, fill, EDGE)) + + +def draw_slots(slots, radius, color, width=2.5): + for slot in slots: + out.append(''.format( + pts(core.slot_outline(slot, radius)), color, fmt(width))) + + +def guide(p, q, color=DIM): + polyline([p, q], color, 1, 'stroke-dasharray="3,3"') + + +def nearest(slots, point): + """Lumiere dont le milieu est le plus proche de `point`.""" + return min(slots, key=lambda s: math.hypot((s[0][0] + s[1][0]) / 2 - point[0], + (s[0][1] + s[1][1]) / 2 - point[1])) + + +def panel_1(): + panel_title(30, 50, 1, T["title1"]) + slots = core.hinge_slots([rect_ring(140, 100, 560, 540)], length=300, width=60, + bridge=50, pitch=150, min_length=60) + main = nearest(slots, (350, 320)) + draw_slots([s for s in slots if s is not main], 30, GHOST) + draw_slots([main], 30, INK, 3) + (x, y0), (_x, y1) = main + dimension((x - 30, y0 - 30), (x - 30, y1 + 30), 45, T["length"], label_shift=48) + dimension((x - 30, y0), (x + 30, y0), -75, T["width"]) + note(30, 610, T["note1"]) + + +def panel_2(): + ox = 700 + panel_title(ox + 30, 50, 2, T["title2"]) + slots = core.hinge_slots([rect_ring(ox + 90, 90, ox + 590, 500)], length=200, + width=44, bridge=50, pitch=100, min_length=44) + draw_slots(slots, 22, INK) + # Pont : entre la lumiere centrale de la colonne de gauche et celle du dessous. + left = min(x for (x, _y), _p in slots) + column = sorted(s for s in slots if s[0][0] == left) + upper = nearest(column, (left, 295)) + lower = column[column.index(upper) + 1] + dimension((left - 22, upper[1][1] + 22), (left - 22, lower[0][1] - 22), 30, + T["bridge"], label_shift=34) + # Pas : entre les axes de deux colonnes voisines, sous le motif. + dimension((ox + 340, 500), (ox + 440, 500), 25, T["pitch"]) + # Decalage : entre le haut d'une lumiere paire et celui de sa voisine impaire. + right = max(x for (x, _y), _p in slots) + even = nearest([s for s in slots if s[0][0] == right], (right, 295)) + odd = nearest([s for s in slots if s[0][0] == right - 100], (right - 100, 420)) + top_even, top_odd = even[0][1] - 22, odd[0][1] - 22 + guide((right, top_even), (ox + 612, top_even)) + guide((right - 100, top_odd), (ox + 612, top_odd)) + dimension((ox + 612, top_even), (ox + 612, top_odd), 0, T["stagger"], label_shift=-36) + note(ox + 30, 610, T["note2"]) + + +def panel_3(): + oy = 640 + panel_title(30, oy + 50, 3, T["title3"]) + # A gauche : longueurs aleatoires, du trou rond a la lumiere entiere. + slots = core.hinge_slots([rect_ring(40, oy + 100, 290, oy + 520)], length=150, + width=26, bridge=30, pitch=50, random_min=26, seed=157) + draw_slots(slots, 13, INK) + # Cotes sur la plus courte et la plus longue lumiere de la colonne de droite. + right = max(x for (x, _y), _p in slots) + column = sorted((core.slot_length(s, 26), s) for s in slots if s[0][0] == right) + for (_size, slot), key, shift in ((column[0], "shortest", 32), (column[-1], "longest", 52)): + dimension((right + 13, slot[0][1] - 13), (right + 13, slot[1][1] + 13), -22, + T[key], label_shift=shift) + + # A droite : le meme motif tourne. + rings = [rect_ring(420, oy + 100, 660, oy + 520)] + center = (540, oy + 310) + shape(rings) + slots = core.hinge_slots(rings, length=130, width=28, bridge=30, pitch=58, + angle=30, margin=8, min_length=40) + main = nearest(slots, center) + draw_slots([s for s in slots if s is not main], 14, GHOST) + draw_slots([main], 14, INK, 3) + for a in (90, 120): + ux, uy = _unit(a) + polyline([center, (center[0] + 185 * ux, center[1] + 185 * uy)], DIM2, 1, + 'stroke-dasharray="5,4"') + arc(center, 150, 90, 120, DIM2, T["angle"], label_radius=176) + note(30, oy + 610, T["note3"]) + + +def panel_4(): + ox, oy = 700, 640 + panel_title(ox + 30, oy + 50, 4, T["title4"]) + rings = [rect_ring(ox + 135, oy + 90, ox + 525, oy + 530), + circle_ring(ox + 260, oy + 260, 55)] + shape(rings) + slots = core.hinge_slots(rings, length=160, width=34, bridge=36, pitch=70, + margin=36, min_length=60) + draw_slots(slots, 17, INK) + # Marge : du bord haut au bout d'une lumiere raccourcie de la colonne centrale. + cx = ox + 330 + top = min((s for s in slots if abs(s[0][0] - cx) < 1), key=lambda s: s[0][1]) + dimension((cx, oy + 90), (cx, top[0][1] - 17), 0, T["margin"], label_shift=36) + # Longueur minimale : lumiere raccourcie en haut de la colonne de droite. + right = max(x for (x, _y), _p in slots) + short = min((s for s in slots if s[0][0] == right), key=lambda s: s[0][1]) + dimension((right + 17, short[0][1] - 17), (right + 17, short[1][1] + 17), + -(ox + 565 - right - 17), T["minimum"], label_shift=38) + 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:]) diff --git a/i18n.py b/i18n.py new file mode 100644 index 0000000..a800d7f --- /dev/null +++ b/i18n.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Chaine de traduction de l'extension, sans dependance a gettext. + + python i18n.py # extract + update + compile + python i18n.py extract # po/.pot depuis le .inx et les .py + python i18n.py update # reporte le modele dans po/.po + python i18n.py compile # po/.po -> locale//LC_MESSAGES/*.mo + +Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont +le .inx et tous les .py du dossier (hors i18n.py et tests). + +Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce +qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est +rempli automatiquement avec les textes source ; seul fr.po se traduit a la main. + +Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) : +, attributs gui-text et gui-description, contenu de