Version initiale
15
.gitignore
vendored
Normal file
@ -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/
|
||||
230
README.md
Normal file
@ -0,0 +1,230 @@
|
||||
# Remplissage découpe flexible — extension Inkscape
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/exemple.png" alt="Rectangle, disque, chemin courbe, rectangle troué et ellipse remplis de lumières oblongues en quinconce" width="420">
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
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)).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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é :
|
||||
|
||||

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

|
||||
|
||||
| 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/<langue>/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/<langue>.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).
|
||||
107
deploy.ps1
Normal file
@ -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\<langue>\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..."
|
||||
BIN
doc/Patern.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
doc/patern2.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/exemple-aleatoire.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/exemple.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/parametres.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
379
docs/schema_parametres.py
Normal file
@ -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('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
|
||||
'stroke-linejoin="miter" {}/>'.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('<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 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('<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" '
|
||||
'stroke-width="2"/>'.format(d, fill, EDGE))
|
||||
|
||||
|
||||
def draw_slots(slots, radius, color, width=2.5):
|
||||
for slot in slots:
|
||||
out.append('<polygon points="{}" fill="#ffffff" stroke="{}" stroke-width="{}" '
|
||||
'stroke-linejoin="round"/>'.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('<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:])
|
||||
304
i18n.py
Normal file
@ -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/<domaine>.pot depuis le .inx et les .py
|
||||
python i18n.py update # reporte le modele dans po/<langue>.po
|
||||
python i18n.py compile # po/<langue>.po -> locale/<langue>/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) :
|
||||
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
|
||||
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
|
||||
xml:space="preserve".
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LANGUAGES = ["en", "fr"]
|
||||
SOURCE_LANGUAGE = "en"
|
||||
PO_DIR = os.path.join(HERE, "po")
|
||||
LOCALE_DIR = os.path.join(HERE, "locale")
|
||||
|
||||
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
|
||||
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
|
||||
|
||||
|
||||
def _find_inx():
|
||||
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
|
||||
if len(found) != 1:
|
||||
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
|
||||
HERE, found))
|
||||
return found[0]
|
||||
|
||||
|
||||
INX = _find_inx()
|
||||
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
|
||||
DOMAIN = _INX_ROOT.get("translationdomain")
|
||||
if not DOMAIN:
|
||||
sys.exit("Attribut translationdomain absent de " + INX)
|
||||
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
|
||||
SOURCES = [INX] + sorted(
|
||||
name for name in os.listdir(HERE)
|
||||
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Extraction
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _local(tag):
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _normalize(text, preserve):
|
||||
return text if preserve else " ".join(text.split())
|
||||
|
||||
|
||||
def extract_inx(path):
|
||||
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
|
||||
messages = []
|
||||
|
||||
def walk(elem, preserve):
|
||||
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
|
||||
if elem.get("translatable") != "no":
|
||||
for attr in ("gui-text", "gui-description"):
|
||||
if elem.get(attr):
|
||||
messages.append(_normalize(elem.get(attr), preserve))
|
||||
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
|
||||
messages.append(_normalize(elem.text, preserve))
|
||||
for child in elem:
|
||||
walk(child, preserve)
|
||||
|
||||
walk(ET.parse(path).getroot(), False)
|
||||
return [m for m in messages if m]
|
||||
|
||||
|
||||
def extract_py(path):
|
||||
"""Arguments litteraux des appels _("...") d'un script Python."""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read(), path)
|
||||
calls = [node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_" and len(node.args) == 1
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)]
|
||||
calls.sort(key=lambda node: (node.lineno, node.col_offset))
|
||||
return [node.args[0].value for node in calls]
|
||||
|
||||
|
||||
def extract():
|
||||
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
|
||||
found = {}
|
||||
for name in SOURCES:
|
||||
path = os.path.join(HERE, name)
|
||||
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
|
||||
for msgid in strings:
|
||||
found.setdefault(msgid, [])
|
||||
if name not in found[msgid]:
|
||||
found[msgid].append(name)
|
||||
return list(found.items())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lecture / ecriture des fichiers .po
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
|
||||
|
||||
|
||||
def _unquote(line):
|
||||
body = line.strip()[1:-1]
|
||||
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
|
||||
|
||||
|
||||
def _quote(text):
|
||||
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
|
||||
lines = text.split("\n")
|
||||
parts = [line + "\\n" for line in lines[:-1]]
|
||||
if lines[-1]:
|
||||
parts.append(lines[-1])
|
||||
if len(parts) <= 1:
|
||||
return '"{}"'.format(parts[0] if parts else "")
|
||||
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
|
||||
|
||||
|
||||
def read_po(path):
|
||||
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
|
||||
entries = {}
|
||||
if not os.path.exists(path):
|
||||
return entries
|
||||
msgid = msgstr = None
|
||||
field = None
|
||||
fuzzy = False
|
||||
|
||||
def flush():
|
||||
if msgid is not None:
|
||||
entries[msgid] = (msgstr or "", fuzzy)
|
||||
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
line = raw.strip()
|
||||
if line.startswith("#,") and "fuzzy" in line:
|
||||
flush()
|
||||
msgid = msgstr = field = None
|
||||
fuzzy = True
|
||||
elif line.startswith("msgid "):
|
||||
if field == "msgstr":
|
||||
flush()
|
||||
fuzzy = False
|
||||
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
|
||||
elif line.startswith("msgstr "):
|
||||
msgstr, field = _unquote(line[7:]), "msgstr"
|
||||
elif line.startswith('"') and field == "msgid":
|
||||
msgid += _unquote(line)
|
||||
elif line.startswith('"') and field == "msgstr":
|
||||
msgstr += _unquote(line)
|
||||
elif not line and field == "msgstr":
|
||||
flush()
|
||||
msgid = msgstr = field = None
|
||||
fuzzy = False
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _header(language):
|
||||
fields = [
|
||||
("Project-Id-Version", DOMAIN),
|
||||
("Language", language or ""),
|
||||
("MIME-Version", "1.0"),
|
||||
("Content-Type", "text/plain; charset=UTF-8"),
|
||||
("Content-Transfer-Encoding", "8bit"),
|
||||
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
|
||||
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
|
||||
]
|
||||
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
|
||||
|
||||
|
||||
def write_po(path, language, messages, translations):
|
||||
title = ("Modele de traduction" if language is None
|
||||
else "Traduction ({})".format(language))
|
||||
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
|
||||
"# Genere par i18n.py ; ne modifier que les msgstr.",
|
||||
"msgid \"\"",
|
||||
"msgstr " + _quote(_header(language)),
|
||||
""]
|
||||
for msgid, refs in messages:
|
||||
msgstr, fuzzy = translations.get(msgid, ("", False))
|
||||
out.append("#: " + " ".join(refs))
|
||||
if fuzzy:
|
||||
out.append("#, fuzzy")
|
||||
out.append("msgid " + _quote(msgid))
|
||||
out.append("msgstr " + _quote(msgstr))
|
||||
out.append("")
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write("\n".join(out))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compilation .mo (format GNU gettext)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def write_mo(path, catalog):
|
||||
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
|
||||
keys = sorted(catalog)
|
||||
ids = [key.encode("utf-8") for key in keys]
|
||||
strs = [catalog[key].encode("utf-8") for key in keys]
|
||||
count = len(keys)
|
||||
ids_start = 7 * 4 + 16 * count
|
||||
strs_start = ids_start + sum(len(b) + 1 for b in ids)
|
||||
|
||||
table_ids, table_strs = [], []
|
||||
offset = ids_start
|
||||
for data in ids:
|
||||
table_ids += [len(data), offset]
|
||||
offset += len(data) + 1
|
||||
offset = strs_start
|
||||
for data in strs:
|
||||
table_strs += [len(data), offset]
|
||||
offset += len(data) + 1
|
||||
|
||||
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
|
||||
output += struct.pack("<{}I".format(2 * count), *table_ids)
|
||||
output += struct.pack("<{}I".format(2 * count), *table_strs)
|
||||
output += b"".join(data + b"\0" for data in ids)
|
||||
output += b"".join(data + b"\0" for data in strs)
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(output)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Commandes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def po_path(language):
|
||||
return os.path.join(PO_DIR, "{}.po".format(language))
|
||||
|
||||
|
||||
def mo_path(language):
|
||||
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
|
||||
|
||||
|
||||
def cmd_extract():
|
||||
os.makedirs(PO_DIR, exist_ok=True)
|
||||
messages = extract()
|
||||
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
|
||||
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
|
||||
|
||||
|
||||
def cmd_update():
|
||||
messages = extract()
|
||||
for language in LANGUAGES:
|
||||
translations = read_po(po_path(language))
|
||||
if language == SOURCE_LANGUAGE:
|
||||
translations = {msgid: (msgid, False) for msgid, _refs in messages}
|
||||
write_po(po_path(language), language, messages, translations)
|
||||
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
|
||||
print("po/{}.po : {} a traduire".format(language, len(missing)))
|
||||
for msgid in missing:
|
||||
print(" " + msgid.splitlines()[0])
|
||||
|
||||
|
||||
def cmd_compile():
|
||||
for language in LANGUAGES:
|
||||
entries = read_po(po_path(language))
|
||||
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
|
||||
if msgid and msgstr and not fuzzy}
|
||||
catalog[""] = _header(language)
|
||||
write_mo(mo_path(language), catalog)
|
||||
print("{} -> {} ({} chaines)".format(
|
||||
os.path.relpath(po_path(language), HERE),
|
||||
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
|
||||
|
||||
|
||||
def main(argv):
|
||||
commands = {"extract": [cmd_extract], "update": [cmd_update],
|
||||
"compile": [cmd_compile],
|
||||
"all": [cmd_extract, cmd_update, cmd_compile]}
|
||||
name = argv[0] if argv else "all"
|
||||
if name not in commands:
|
||||
print(__doc__)
|
||||
return 1
|
||||
for command in commands[name]:
|
||||
command()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
BIN
images/parameters_en.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
97
living_hinge.inx
Normal file
@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Textes source en anglais, traduits par le catalogue « livinghinge »
|
||||
(locale/<langue>/LC_MESSAGES/livinghinge.mo). Après toute modification
|
||||
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
|
||||
-->
|
||||
<inkscape-extension translationdomain="livinghinge"
|
||||
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||
<name>Living Hinge Fill</name>
|
||||
<id>fr.alexp.livinghinge</id>
|
||||
|
||||
<param name="tab" type="notebook">
|
||||
<page name="pattern" gui-text="Pattern">
|
||||
<param name="slot_length" type="float" precision="3" min="0.01" max="10000"
|
||||
gui-text="Slot length:">20.0</param>
|
||||
<param name="slot_width" type="float" precision="3" min="0.01" max="1000"
|
||||
gui-text="Slot width:">3.0</param>
|
||||
<param name="bridge" type="float" precision="3" min="0" max="10000"
|
||||
gui-text="Bridge (material between two slots of a column):">2.0</param>
|
||||
<param name="pitch" type="float" precision="3" min="0.01" max="10000"
|
||||
gui-text="Column pitch (centre to centre):">5.0</param>
|
||||
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
|
||||
<!-- Les unités ne se traduisent pas -->
|
||||
<option translatable="no" value="mm">mm</option>
|
||||
<option translatable="no" value="cm">cm</option>
|
||||
<option translatable="no" value="px">px</option>
|
||||
<option translatable="no" value="pt">pt</option>
|
||||
<option translatable="no" value="in">in</option>
|
||||
</param>
|
||||
<separator/>
|
||||
<param name="stagger" type="float" precision="1" min="0" max="100"
|
||||
gui-text="Stagger of odd columns (% of length + bridge):"
|
||||
gui-description="50 % gives the classic staggered living hinge; 0 % aligns all the columns.">50.0</param>
|
||||
<param name="angle" type="float" precision="1" min="-180" max="180"
|
||||
gui-text="Pattern angle (°, 0 = vertical slots):"
|
||||
gui-description="The panel bends around an axis parallel to the slots.">0.0</param>
|
||||
</page>
|
||||
|
||||
<page name="random" gui-text="Random">
|
||||
<param name="random_lengths" type="bool"
|
||||
gui-text="Random slot lengths">false</param>
|
||||
<param name="random_min" type="float" precision="3" min="0" max="10000"
|
||||
gui-text="Shortest random slot:"
|
||||
gui-description="Never less than the slot width, which gives a round hole.">3.0</param>
|
||||
<param name="seed" type="int" min="0" max="999999"
|
||||
gui-text="Random seed:"
|
||||
gui-description="Same seed, same pattern; change it to draw another one.">1</param>
|
||||
<separator/>
|
||||
<label>Each slot gets a random length between this shortest value and the slot length of the "Pattern" tab. Every column is filled from margin to margin: a slot starts right at the top and another ends right at the bottom, so all columns begin and end at the same level. Bridge and column pitch stay constant; the stagger and the minimum length of a shortened slot are not used.</label>
|
||||
</page>
|
||||
|
||||
<page name="fill" gui-text="Fill">
|
||||
<param name="margin" type="float" precision="3" min="0" max="10000"
|
||||
gui-text="Margin between the slots and the edge:">2.0</param>
|
||||
<param name="min_length" type="float" precision="3" min="0" max="10000"
|
||||
gui-text="Minimum length of a shortened slot:"
|
||||
gui-description="Slots cut short by the edge are dropped below this length.">6.0</param>
|
||||
<separator/>
|
||||
<param name="stroke_color" type="color" appearance="colorbutton"
|
||||
gui-text="Stroke color:">255</param>
|
||||
<param name="stroke_width" type="float" precision="3" min="0.001" max="100"
|
||||
gui-text="Stroke width:">0.2</param>
|
||||
<param name="keep_original" type="bool"
|
||||
gui-text="Keep the original shape">true</param>
|
||||
</page>
|
||||
|
||||
<page name="help" gui-text="Help">
|
||||
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
|
||||
Inkscape ne traduit pas le chemin d'une image. Affiché aux
|
||||
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
|
||||
<image width="675" height="617">images/parameters_en.png</image>
|
||||
<separator/>
|
||||
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
|
||||
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
|
||||
<label xml:space="preserve">Select one or more closed shapes (rectangles, ellipses, paths, even with holes, or groups of them), then run the extension. Texts, images and clones are ignored.
|
||||
|
||||
Each shape is filled with oblong slots arranged in staggered columns: once cut, the panel bends around an axis parallel to the slots. Longer slots, thinner bridges and a tighter column pitch make it more flexible, and more fragile.
|
||||
|
||||
Near the edge and around holes, slots are shortened and rounded again so that they stay at the margin; those that would become shorter than the minimum length are dropped. The pattern is centred on each shape.
|
||||
|
||||
In the "Random" tab, slots can take random lengths, from a round hole to the full slot length, for a more organic look.
|
||||
|
||||
The slots are drawn as a single path, in a group named "Living hinge" placed just above the shape. All lengths use the unit chosen in the "Pattern" tab.</label>
|
||||
</page>
|
||||
</param>
|
||||
|
||||
<effect>
|
||||
<object-type>all</object-type>
|
||||
<effects-menu>
|
||||
<submenu name="AlexDesign"/>
|
||||
</effects-menu>
|
||||
</effect>
|
||||
|
||||
<script>
|
||||
<command location="inx" interpreter="python">living_hinge.py</command>
|
||||
</script>
|
||||
</inkscape-extension>
|
||||
166
living_hinge.py
Normal file
@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""
|
||||
Extension Inkscape : remplit les formes selectionnees avec un motif de
|
||||
decoupe flexible (« living hinge »).
|
||||
|
||||
Le motif est fait de lumieres oblongues rangees en colonnes decalees en
|
||||
quinconce ; une fois decoupe au laser, il rend le panneau souple dans le sens
|
||||
perpendiculaire aux lumieres. Pres du bord, les lumieres sont raccourcies et
|
||||
re-arrondies pour rester a distance de la marge.
|
||||
|
||||
La logique vit dans `living_hinge_core`, sans dependance a inkex.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import inkex
|
||||
from inkex import bezier
|
||||
from inkex.localization import localize
|
||||
|
||||
# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui
|
||||
# n'est pas forcement dans sys.path : on l'y ajoute pour trouver living_hinge_core.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from living_hinge_core import hinge_slots, parse_color, slots_to_d # noqa: E402
|
||||
|
||||
# Installe _() dans les builtins : catalogue « livinghinge » de la langue
|
||||
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
|
||||
# textes anglais d'origine sinon.
|
||||
localize()
|
||||
|
||||
# Elements dont le contour peut etre rempli ; textes, images et clones sont ignores.
|
||||
SHAPES = (inkex.PathElement, inkex.Rectangle, inkex.Circle, inkex.Ellipse,
|
||||
inkex.Polygon, inkex.Polyline)
|
||||
|
||||
|
||||
class LivingHinge(inkex.EffectExtension):
|
||||
"""Remplit chaque forme selectionnee avec des lumieres oblongues en quinconce."""
|
||||
|
||||
def add_arguments(self, pars):
|
||||
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
|
||||
pars.add_argument("--tab", default="pattern")
|
||||
pars.add_argument("--slot_length", type=float, default=20.0)
|
||||
pars.add_argument("--slot_width", type=float, default=3.0)
|
||||
pars.add_argument("--bridge", type=float, default=2.0)
|
||||
pars.add_argument("--pitch", type=float, default=5.0)
|
||||
pars.add_argument("--unit", default="mm")
|
||||
pars.add_argument("--stagger", type=float, default=50.0)
|
||||
pars.add_argument("--angle", type=float, default=0.0)
|
||||
pars.add_argument("--random_lengths", type=inkex.Boolean, default=False)
|
||||
pars.add_argument("--random_min", type=float, default=3.0)
|
||||
pars.add_argument("--seed", type=int, default=1)
|
||||
pars.add_argument("--margin", type=float, default=2.0)
|
||||
pars.add_argument("--min_length", type=float, default=6.0)
|
||||
pars.add_argument("--stroke_color", default="255")
|
||||
pars.add_argument("--stroke_width", type=float, default=0.2)
|
||||
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
|
||||
|
||||
def effect(self):
|
||||
opt = self.options
|
||||
|
||||
elements = self.shapes()
|
||||
if not elements:
|
||||
inkex.errormsg(_("Select at least one shape (rectangle, ellipse or path)."))
|
||||
return
|
||||
|
||||
def to_uu(value):
|
||||
return self.svg.unittouu("{}{}".format(value, opt.unit))
|
||||
|
||||
dims = {
|
||||
"length": to_uu(opt.slot_length),
|
||||
"width": to_uu(opt.slot_width),
|
||||
"bridge": to_uu(opt.bridge),
|
||||
"pitch": to_uu(opt.pitch),
|
||||
"stagger": opt.stagger,
|
||||
"angle": opt.angle,
|
||||
"margin": to_uu(opt.margin),
|
||||
"min_length": to_uu(opt.min_length),
|
||||
}
|
||||
if dims["width"] <= 0 or dims["pitch"] <= 0:
|
||||
inkex.errormsg(_("The slot width and the column pitch must be strictly positive."))
|
||||
return
|
||||
if dims["length"] < dims["width"]:
|
||||
inkex.errormsg(_("The slot length must be at least equal to the slot width. "
|
||||
"Increase the length or reduce the width."))
|
||||
return
|
||||
if dims["pitch"] <= dims["width"]:
|
||||
inkex.errormsg(_("The column pitch must be larger than the slot width, "
|
||||
"otherwise neighbouring slots merge. Increase the pitch "
|
||||
"or reduce the width."))
|
||||
return
|
||||
if opt.random_lengths:
|
||||
# Le noyau ramene le minimum a la largeur d'une lumiere (trou rond).
|
||||
dims["random_min"] = to_uu(opt.random_min)
|
||||
dims["seed"] = opt.seed
|
||||
if dims["random_min"] > dims["length"]:
|
||||
inkex.errormsg(_("The shortest random slot must not exceed the slot length, "
|
||||
"which is the longest one. Reduce the shortest length or "
|
||||
"increase the slot length."))
|
||||
return
|
||||
|
||||
color, opacity = parse_color(opt.stroke_color, default=("#000000", 1.0))
|
||||
style = inkex.Style({
|
||||
"fill": "none",
|
||||
"stroke": color,
|
||||
"stroke-opacity": str(opacity),
|
||||
"stroke-width": str(to_uu(opt.stroke_width)),
|
||||
})
|
||||
# Finesse d'aplatissement des courbes, petite devant la largeur d'une lumiere.
|
||||
flatness = min(self.svg.unittouu("0.05mm"), dims["width"] / 20.0)
|
||||
|
||||
empty = 0
|
||||
for elem in elements:
|
||||
if not self.process(elem, dims, style, flatness):
|
||||
empty += 1
|
||||
if empty:
|
||||
inkex.errormsg(_("{} shape(s) too small: no slot fits inside. Reduce the "
|
||||
"slot width, the minimum length or the margin.").format(empty))
|
||||
|
||||
def shapes(self):
|
||||
"""Formes de la selection, en descendant dans les groupes."""
|
||||
found = []
|
||||
for elem in self.svg.selection.values():
|
||||
candidates = [elem]
|
||||
if isinstance(elem, inkex.Group):
|
||||
candidates = list(elem.descendants())
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, SHAPES) and candidate not in found:
|
||||
found.append(candidate)
|
||||
return found
|
||||
|
||||
def process(self, elem, dims, style, flatness):
|
||||
"""Remplit un element ; renvoie False si aucune lumiere ne tient dedans."""
|
||||
# Contour en coordonnees absolues du document, courbes aplaties.
|
||||
csp = elem.path.transform(elem.composed_transform()).to_superpath()
|
||||
bezier.cspsubdiv(csp, flatness)
|
||||
rings = [[(node[1][0], node[1][1]) for node in sub] for sub in csp]
|
||||
|
||||
slots = hinge_slots(rings, **dims)
|
||||
if not slots:
|
||||
return False
|
||||
|
||||
parent = elem.getparent()
|
||||
group = inkex.Group()
|
||||
group.label = _("Living hinge")
|
||||
# Resultat calcule en coordonnees absolues du document : on
|
||||
# neutralise la transformation heritee du parent d'accueil.
|
||||
parent_transform = parent.composed_transform()
|
||||
if parent_transform != inkex.Transform():
|
||||
group.transform = -parent_transform
|
||||
parent.insert(parent.index(elem) + 1, group)
|
||||
|
||||
# Un seul chemin pour toutes les lumieres : document leger.
|
||||
node = inkex.PathElement()
|
||||
node.set("d", slots_to_d(slots, dims["width"] / 2.0))
|
||||
node.style = style
|
||||
group.add(node)
|
||||
|
||||
if not self.options.keep_original:
|
||||
elem.delete()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
LivingHinge().run()
|
||||
416
living_hinge_core.py
Normal file
@ -0,0 +1,416 @@
|
||||
# coding=utf-8
|
||||
"""
|
||||
Noyau de calcul de l'extension « Living Hinge Fill », sans dependance a inkex.
|
||||
|
||||
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
|
||||
des parametres).
|
||||
|
||||
Le motif est une decoupe flexible (« living hinge ») : des lumieres oblongues
|
||||
(rectangles a bouts arrondis) rangees en colonnes, separees dans une colonne
|
||||
par un pont de matiere, les colonnes impaires etant decalees en quinconce.
|
||||
Pres du bord de la forme, une lumiere est raccourcie et re-arrondie pour
|
||||
rester a distance de la marge. En mode aleatoire, chaque lumiere prend une
|
||||
longueur tiree au hasard, le pont et le pas restant constants, et chaque
|
||||
colonne commence et finit pile a la marge.
|
||||
|
||||
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 ;
|
||||
- une polyligne est une liste de points (x, y) ;
|
||||
- une lumiere (`slot`) est le couple (p0, p1) des centres de ses deux bouts
|
||||
arrondis ; son rayon est la demi-largeur, sa longueur hors tout vaut
|
||||
distance(p0, p1) + largeur ;
|
||||
- l'axe y est oriente vers le bas (repere SVG) ; les angles sont en degres,
|
||||
sens anti-horaire a l'ecran.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
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 bounding_box(rings):
|
||||
"""Boite englobante (xmin, ymin, xmax, ymax) d'un contour, None s'il est vide."""
|
||||
xs = [x for ring in rings for x, _y in ring]
|
||||
ys = [y for ring in rings for _x, y in ring]
|
||||
if not xs:
|
||||
return None
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def rotate(point, angle, center=(0.0, 0.0)):
|
||||
"""Tourne un point de `angle` degres autour de `center` (anti-horaire a l'ecran)."""
|
||||
a = math.radians(angle)
|
||||
c, s = math.cos(a), math.sin(a)
|
||||
dx, dy = point[0] - center[0], point[1] - center[1]
|
||||
# y vers le bas : le sens anti-horaire a l'ecran inverse le signe du sinus.
|
||||
return center[0] + dx * c + dy * s, center[1] - dx * s + dy * c
|
||||
|
||||
|
||||
def _edges(rings):
|
||||
"""Aretes ((x1, y1), (x2, y2)) des anneaux, fermeture implicite comprise."""
|
||||
edges = []
|
||||
for ring in rings:
|
||||
if len(ring) < 3:
|
||||
continue
|
||||
for k, point in enumerate(ring):
|
||||
edges.append((point, ring[(k + 1) % len(ring)]))
|
||||
return edges
|
||||
|
||||
|
||||
def _inside_intervals(edges, x):
|
||||
"""Intervalles de y ou la verticale d'abscisse x est dans le contour (pair-impair)."""
|
||||
ys = []
|
||||
for (x1, y1), (x2, y2) in edges:
|
||||
# Demi-ouvert : un sommet pile sur la verticale n'est compte qu'une fois.
|
||||
if (x1 <= x) != (x2 <= x):
|
||||
ys.append(y1 + (x - x1) * (y2 - y1) / (x2 - x1))
|
||||
ys.sort()
|
||||
return [(ys[k], ys[k + 1]) for k in range(0, len(ys) - 1, 2)]
|
||||
|
||||
|
||||
def _capsule_interval(a, b, x, distance):
|
||||
"""Intervalle de y ou le point (x, y) est a moins de `distance` du segment ab.
|
||||
|
||||
L'ensemble des points proches d'un segment est convexe (une capsule) : sa
|
||||
trace sur la verticale est un seul intervalle, reunion des traces des deux
|
||||
disques d'extremite et du rectangle. Renvoie None si la verticale le manque.
|
||||
"""
|
||||
low = high = None
|
||||
for px, py in (a, b):
|
||||
dx = x - px
|
||||
if abs(dx) < distance:
|
||||
half = math.sqrt(distance * distance - dx * dx)
|
||||
low = py - half if low is None else min(low, py - half)
|
||||
high = py + half if high is None else max(high, py + half)
|
||||
|
||||
ex, ey = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(ex, ey)
|
||||
if length > EPS:
|
||||
ux, uy = ex / length, ey / length
|
||||
dx = x - a[0]
|
||||
# Point (x, a.y + t) : abscisse le long du segment dans [0, length] et
|
||||
# ecart perpendiculaire dans [-distance, distance], deux contraintes
|
||||
# lineaires en t.
|
||||
t0, t1 = -math.inf, math.inf
|
||||
for coef, const, lo, hi in ((uy, dx * ux, 0.0, length),
|
||||
(ux, -dx * uy, -distance, distance)):
|
||||
if abs(coef) < EPS:
|
||||
if not lo < const < hi:
|
||||
t0, t1 = 1.0, 0.0
|
||||
break
|
||||
continue
|
||||
u0, u1 = (lo - const) / coef, (hi - const) / coef
|
||||
if u0 > u1:
|
||||
u0, u1 = u1, u0
|
||||
t0, t1 = max(t0, u0), min(t1, u1)
|
||||
if t0 < t1:
|
||||
low = a[1] + t0 if low is None else min(low, a[1] + t0)
|
||||
high = a[1] + t1 if high is None else max(high, a[1] + t1)
|
||||
|
||||
if low is None:
|
||||
return None
|
||||
return low, high
|
||||
|
||||
|
||||
def _subtract(intervals, cut):
|
||||
"""Retire l'intervalle `cut` d'une liste d'intervalles disjoints."""
|
||||
low, high = cut
|
||||
result = []
|
||||
for a, b in intervals:
|
||||
if high <= a or low >= b:
|
||||
result.append((a, b))
|
||||
continue
|
||||
if low > a:
|
||||
result.append((a, low))
|
||||
if high < b:
|
||||
result.append((high, b))
|
||||
return result
|
||||
|
||||
|
||||
def clear_intervals(rings, x, clearance=0.0):
|
||||
"""Intervalles de y ou (x, y) est dans le contour, a `clearance` au moins du bord.
|
||||
|
||||
C'est la ou peut passer le centre d'un disque de rayon `clearance` sans
|
||||
mordre le bord : on part des intervalles interieurs et on retire, arete par
|
||||
arete, la bande des points trop proches.
|
||||
"""
|
||||
edges = _edges(rings)
|
||||
return _clear_intervals(edges, x, clearance)
|
||||
|
||||
|
||||
def _clear_intervals(edges, x, clearance):
|
||||
intervals = _inside_intervals(edges, x)
|
||||
if clearance <= 0:
|
||||
return intervals
|
||||
# Leger retrait : un disque exactement tangent au bord est accepte.
|
||||
distance = clearance * (1.0 - 1e-9)
|
||||
for a, b in edges:
|
||||
if not intervals:
|
||||
break
|
||||
cut = _capsule_interval(a, b, x, distance)
|
||||
if cut is not None:
|
||||
intervals = _subtract(intervals, cut)
|
||||
return intervals
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Motif
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def regular_spans(low, high, origin, length, bridge):
|
||||
"""Lumieres (debut, longueur) d'une colonne reguliere qui touchent [low, high].
|
||||
|
||||
Toutes de longueur `length`, separees par `bridge` ; l'une d'elles
|
||||
commence en `origin`.
|
||||
"""
|
||||
period = length + bridge
|
||||
k_min = int(math.floor((low - origin - length) / period)) + 1
|
||||
k_max = int(math.floor((high - origin) / period))
|
||||
return [(origin + k * period, length) for k in range(k_min, k_max + 1)]
|
||||
|
||||
|
||||
def random_spans(low, high, shortest, longest, bridge, rng):
|
||||
"""Lumieres (debut, longueur) aleatoires qui remplissent exactement [low, high].
|
||||
|
||||
La premiere commence en `low`, la derniere finit en `high`, le pont est
|
||||
constant : toutes les colonnes d'une forme demarrent et s'arretent ainsi
|
||||
au meme niveau, sans vide en bout de colonne. Longueurs tirees entre
|
||||
`shortest` et `longest` avec le generateur `rng` (random.Random).
|
||||
|
||||
Quand aucun nombre de lumieres ne tombe juste dans ces bornes (zone trop
|
||||
courte, ou bornes trop serrees), la zone recoit des lumieres egales qui
|
||||
sortent un peu des bornes plutot que de rester vide.
|
||||
"""
|
||||
extent = high - low
|
||||
if extent <= 0:
|
||||
return []
|
||||
# n lumieres et n - 1 ponts : n * (longueur moyenne + pont) = extent + pont.
|
||||
fewest = max(1, int(math.ceil((extent + bridge) / (longest + bridge) - EPS)))
|
||||
most = int(math.floor((extent + bridge) / (shortest + bridge) + EPS))
|
||||
if fewest > most:
|
||||
count = fewest
|
||||
if (extent + bridge) / count - bridge < shortest and most >= 1:
|
||||
count = most
|
||||
sizes = [(extent + bridge) / count - bridge] * count
|
||||
else:
|
||||
# Nombre de lumieres : celui d'un tirage libre, ramene au possible.
|
||||
count, drawn = 0, -bridge
|
||||
while drawn < extent:
|
||||
drawn += rng.uniform(shortest, longest) + bridge
|
||||
count += 1
|
||||
count = min(max(count, fewest), most)
|
||||
sizes = [rng.uniform(shortest, longest) for _k in range(count)]
|
||||
# Ecart a la longueur visee, reparti selon la latitude de chaque
|
||||
# lumiere : chacune reste dans ses bornes et le total tombe juste.
|
||||
excess = extent - (count - 1) * bridge - sum(sizes)
|
||||
if excess > 0:
|
||||
room = [longest - size for size in sizes]
|
||||
else:
|
||||
room = [size - shortest for size in sizes]
|
||||
total = sum(room)
|
||||
if total > EPS:
|
||||
sizes = [size + excess * r / total for size, r in zip(sizes, room)]
|
||||
|
||||
spans = []
|
||||
y = low
|
||||
for size in sizes:
|
||||
spans.append((y, size))
|
||||
y += size + bridge
|
||||
return spans
|
||||
|
||||
|
||||
def hinge_slots(rings, length, width, bridge, pitch, stagger=50.0, angle=0.0,
|
||||
margin=0.0, min_length=0.0, random_min=None, seed=0):
|
||||
"""Lumieres du motif de decoupe flexible qui remplit le contour `rings`.
|
||||
|
||||
- length, width : longueur hors tout et largeur d'une lumiere entiere ;
|
||||
- bridge : pont de matiere entre deux lumieres d'une meme colonne ;
|
||||
- pitch : entraxe de deux colonnes voisines ;
|
||||
- stagger : decalage des colonnes impaires, en % de la periode
|
||||
(length + bridge) ; 50 = quinconce (sans effet en mode aleatoire) ;
|
||||
- angle : rotation du motif en degres (0 = lumieres verticales) ;
|
||||
- margin : distance minimale entre une lumiere et le bord de la forme ;
|
||||
- min_length : une lumiere raccourcie par le bord n'est gardee que si sa
|
||||
longueur hors tout atteint cette valeur (jamais moins que `width`) ;
|
||||
sans effet en mode aleatoire, ou rien n'est raccourci ;
|
||||
- random_min : None pour le motif regulier ; sinon chaque lumiere prend
|
||||
une longueur au hasard entre random_min (ramene dans [width, length])
|
||||
et length, et chaque colonne est remplie d'une marge a l'autre : une
|
||||
lumiere commence pile en haut, une autre finit pile en bas ;
|
||||
- seed : graine du tirage ; la changer donne un autre motif aleatoire.
|
||||
|
||||
Le motif est centre sur la boite englobante de la forme. Renvoie la liste
|
||||
des lumieres (p0, p1), colonne par colonne ; liste vide si rien ne tient
|
||||
ou si les dimensions sont incoherentes.
|
||||
"""
|
||||
rings = [ring for ring in rings if len(ring) >= 3]
|
||||
if not rings:
|
||||
return []
|
||||
if width <= 0 or length < width or pitch <= 0 or bridge < 0:
|
||||
return []
|
||||
if random_min is not None:
|
||||
random_min = min(max(random_min, width), length)
|
||||
|
||||
box = bounding_box(rings)
|
||||
center = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
|
||||
# On tourne la forme a l'envers pour travailler avec des lumieres
|
||||
# verticales, puis on retourne le resultat.
|
||||
if angle:
|
||||
rings = [[rotate(point, -angle, center) for point in ring] for ring in rings]
|
||||
box = bounding_box(rings)
|
||||
|
||||
radius = width / 2.0
|
||||
clearance = radius + max(margin, 0.0)
|
||||
period = length + bridge
|
||||
shortest = max(min_length, width)
|
||||
cx, cy = center
|
||||
|
||||
# Aretes rangees par colonne : chaque colonne ne regarde que les aretes
|
||||
# qui passent a moins de `clearance` de son axe.
|
||||
first = int(math.ceil((box[0] + clearance - cx) / pitch - EPS))
|
||||
last = int(math.floor((box[2] - clearance - cx) / pitch + EPS))
|
||||
if last < first:
|
||||
return []
|
||||
buckets = {}
|
||||
for edge in _edges(rings):
|
||||
x_low = min(edge[0][0], edge[1][0]) - clearance
|
||||
x_high = max(edge[0][0], edge[1][0]) + clearance
|
||||
i0 = max(first, int(math.ceil((x_low - cx) / pitch)))
|
||||
i1 = min(last, int(math.floor((x_high - cx) / pitch)))
|
||||
for i in range(i0, i1 + 1):
|
||||
buckets.setdefault(i, []).append(edge)
|
||||
|
||||
slots = []
|
||||
for i in range(first, last + 1):
|
||||
edges = buckets.get(i)
|
||||
if not edges:
|
||||
continue
|
||||
x = cx + i * pitch
|
||||
intervals = _clear_intervals(edges, x, clearance)
|
||||
if not intervals:
|
||||
continue
|
||||
if random_min is not None:
|
||||
# Graine textuelle : meme tirage d'une version de Python a l'autre,
|
||||
# et propre a la colonne.
|
||||
rng = random.Random("{}:{}".format(seed, i))
|
||||
# Chaque zone libre est remplie d'un bout a l'autre : une lumiere
|
||||
# part de la marge en haut, une autre y arrive en bas.
|
||||
for y0, y1 in intervals:
|
||||
for start, size in random_spans(y0 - radius, y1 + radius, random_min,
|
||||
length, bridge, rng):
|
||||
slots.append(((x, start + radius), (x, start + size - radius)))
|
||||
continue
|
||||
# Une lumiere centree sur la forme, decalee pour les colonnes impaires.
|
||||
origin = cy - length / 2.0 + (i % 2) * stagger / 100.0 * period
|
||||
spans = regular_spans(box[1], box[3], origin, length, bridge)
|
||||
for y0, y1 in intervals:
|
||||
for start, size in spans:
|
||||
u0 = max(y0, start + radius)
|
||||
u1 = min(y1, start + size - radius)
|
||||
# Une lumiere entiere est toujours gardee, meme plus courte
|
||||
# que le minimum impose aux lumieres raccourcies.
|
||||
if u1 - u0 + width < min(shortest, size) - EPS:
|
||||
continue
|
||||
slots.append(((x, u0), (x, max(u0, u1))))
|
||||
|
||||
if angle:
|
||||
slots = [(rotate(p0, angle, center), rotate(p1, angle, center))
|
||||
for p0, p1 in slots]
|
||||
return slots
|
||||
|
||||
|
||||
def slot_length(slot, width):
|
||||
"""Longueur hors tout d'une lumiere."""
|
||||
(x0, y0), (x1, y1) = slot
|
||||
return math.hypot(x1 - x0, y1 - y0) + width
|
||||
|
||||
|
||||
def _slot_corners(slot, radius):
|
||||
"""Les quatre points de raccord droite / arc d'une lumiere, et son axe."""
|
||||
(x0, y0), (x1, y1) = slot
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
# Lumiere reduite a un cercle : direction arbitraire.
|
||||
ux, uy = ((x1 - x0) / length, (y1 - y0) / length) if length > EPS else (0.0, 1.0)
|
||||
nx, ny = -uy, ux
|
||||
return ((x0 + radius * nx, y0 + radius * ny), (x1 + radius * nx, y1 + radius * ny),
|
||||
(x1 - radius * nx, y1 - radius * ny), (x0 - radius * nx, y0 - radius * ny),
|
||||
(ux, uy))
|
||||
|
||||
|
||||
def slot_outline(slot, radius, segments=16):
|
||||
"""Contour d'une lumiere en polyligne fermee (demi-cercles en `segments` pas)."""
|
||||
(x0, y0), (x1, y1) = slot
|
||||
a, _b, _c, _d, (ux, uy) = _slot_corners(slot, radius)
|
||||
nx, ny = -uy, ux
|
||||
points = [a]
|
||||
for (px, py), sign in (((x1, y1), 1.0), ((x0, y0), -1.0)):
|
||||
for k in range(segments + 1):
|
||||
t = math.pi * k / segments
|
||||
# Demi-cercle du cote +n au cote -n en passant par le bout.
|
||||
c, s = math.cos(t) * sign, math.sin(t) * sign
|
||||
points.append((px + radius * (c * nx + s * ux), py + radius * (c * ny + s * uy)))
|
||||
return points
|
||||
|
||||
|
||||
def slots_to_d(slots, radius, precision=4):
|
||||
"""Donnees `d` d'un chemin SVG : un sous-chemin ferme par lumiere, bouts en arcs."""
|
||||
fmt = "{:.%df},{:.%df}" % (precision, precision)
|
||||
arc = "A {0} {0} 0 0 0 ".format(("{:.%df}" % precision).format(radius))
|
||||
parts = []
|
||||
for slot in slots:
|
||||
a, b, c, d, _axis = _slot_corners(slot, radius)
|
||||
parts.append("M " + fmt.format(*a))
|
||||
if b != a:
|
||||
parts.append("L " + fmt.format(*b))
|
||||
parts.append(arc + fmt.format(*c))
|
||||
if d != c:
|
||||
parts.append("L " + fmt.format(*d))
|
||||
parts.append(arc + fmt.format(*a) + " Z")
|
||||
return " ".join(parts)
|
||||
BIN
locale/en/LC_MESSAGES/livinghinge.mo
Normal file
BIN
locale/fr/LC_MESSAGES/livinghinge.mo
Normal file
164
po/en.po
Normal file
@ -0,0 +1,164 @@
|
||||
# Traduction (en) de l'extension Inkscape « Living Hinge Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: livinghinge\n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Living Hinge Fill"
|
||||
msgstr "Living Hinge Fill"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Pattern"
|
||||
msgstr "Pattern"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slot length:"
|
||||
msgstr "Slot length:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slot width:"
|
||||
msgstr "Slot width:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Bridge (material between two slots of a column):"
|
||||
msgstr "Bridge (material between two slots of a column):"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Column pitch (centre to centre):"
|
||||
msgstr "Column pitch (centre to centre):"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Unit:"
|
||||
msgstr "Unit:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stagger of odd columns (% of length + bridge):"
|
||||
msgstr "Stagger of odd columns (% of length + bridge):"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "50 % gives the classic staggered living hinge; 0 % aligns all the columns."
|
||||
msgstr "50 % gives the classic staggered living hinge; 0 % aligns all the columns."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Pattern angle (°, 0 = vertical slots):"
|
||||
msgstr "Pattern angle (°, 0 = vertical slots):"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "The panel bends around an axis parallel to the slots."
|
||||
msgstr "The panel bends around an axis parallel to the slots."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random"
|
||||
msgstr "Random"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random slot lengths"
|
||||
msgstr "Random slot lengths"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Shortest random slot:"
|
||||
msgstr "Shortest random slot:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Never less than the slot width, which gives a round hole."
|
||||
msgstr "Never less than the slot width, which gives a round hole."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random seed:"
|
||||
msgstr "Random seed:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Same seed, same pattern; change it to draw another one."
|
||||
msgstr "Same seed, same pattern; change it to draw another one."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Each slot gets a random length between this shortest value and the slot length of the \"Pattern\" tab. Every column is filled from margin to margin: a slot starts right at the top and another ends right at the bottom, so all columns begin and end at the same level. Bridge and column pitch stay constant; the stagger and the minimum length of a shortened slot are not used."
|
||||
msgstr "Each slot gets a random length between this shortest value and the slot length of the \"Pattern\" tab. Every column is filled from margin to margin: a slot starts right at the top and another ends right at the bottom, so all columns begin and end at the same level. Bridge and column pitch stay constant; the stagger and the minimum length of a shortened slot are not used."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Fill"
|
||||
msgstr "Fill"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Margin between the slots and the edge:"
|
||||
msgstr "Margin between the slots and the edge:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Minimum length of a shortened slot:"
|
||||
msgstr "Minimum length of a shortened slot:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slots cut short by the edge are dropped below this length."
|
||||
msgstr "Slots cut short by the edge are dropped below this length."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stroke color:"
|
||||
msgstr "Stroke color:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stroke width:"
|
||||
msgstr "Stroke width:"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr "Keep the original shape"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Help"
|
||||
msgstr "Help"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (rectangles, ellipses, paths, even with holes, or groups of them), then run the extension. Texts, images and clones are ignored.\n"
|
||||
"\n"
|
||||
"Each shape is filled with oblong slots arranged in staggered columns: once cut, the panel bends around an axis parallel to the slots. Longer slots, thinner bridges and a tighter column pitch make it more flexible, and more fragile.\n"
|
||||
"\n"
|
||||
"Near the edge and around holes, slots are shortened and rounded again so that they stay at the margin; those that would become shorter than the minimum length are dropped. The pattern is centred on each shape.\n"
|
||||
"\n"
|
||||
"In the \"Random\" tab, slots can take random lengths, from a round hole to the full slot length, for a more organic look.\n"
|
||||
"\n"
|
||||
"The slots are drawn as a single path, in a group named \"Living hinge\" placed just above the shape. All lengths use the unit chosen in the \"Pattern\" tab."
|
||||
msgstr ""
|
||||
"Select one or more closed shapes (rectangles, ellipses, paths, even with holes, or groups of them), then run the extension. Texts, images and clones are ignored.\n"
|
||||
"\n"
|
||||
"Each shape is filled with oblong slots arranged in staggered columns: once cut, the panel bends around an axis parallel to the slots. Longer slots, thinner bridges and a tighter column pitch make it more flexible, and more fragile.\n"
|
||||
"\n"
|
||||
"Near the edge and around holes, slots are shortened and rounded again so that they stay at the margin; those that would become shorter than the minimum length are dropped. The pattern is centred on each shape.\n"
|
||||
"\n"
|
||||
"In the \"Random\" tab, slots can take random lengths, from a round hole to the full slot length, for a more organic look.\n"
|
||||
"\n"
|
||||
"The slots are drawn as a single path, in a group named \"Living hinge\" placed just above the shape. All lengths use the unit chosen in the \"Pattern\" tab."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "Select at least one shape (rectangle, ellipse or path)."
|
||||
msgstr "Select at least one shape (rectangle, ellipse or path)."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The slot width and the column pitch must be strictly positive."
|
||||
msgstr "The slot width and the column pitch must be strictly positive."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The slot length must be at least equal to the slot width. Increase the length or reduce the width."
|
||||
msgstr "The slot length must be at least equal to the slot width. Increase the length or reduce the width."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The column pitch must be larger than the slot width, otherwise neighbouring slots merge. Increase the pitch or reduce the width."
|
||||
msgstr "The column pitch must be larger than the slot width, otherwise neighbouring slots merge. Increase the pitch or reduce the width."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The shortest random slot must not exceed the slot length, which is the longest one. Reduce the shortest length or increase the slot length."
|
||||
msgstr "The shortest random slot must not exceed the slot length, which is the longest one. Reduce the shortest length or increase the slot length."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "{} shape(s) too small: no slot fits inside. Reduce the slot width, the minimum length or the margin."
|
||||
msgstr "{} shape(s) too small: no slot fits inside. Reduce the slot width, the minimum length or the margin."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "Living hinge"
|
||||
msgstr "Living hinge"
|
||||
164
po/fr.po
Normal file
@ -0,0 +1,164 @@
|
||||
# Traduction (fr) de l'extension Inkscape « Living Hinge Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: livinghinge\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Living Hinge Fill"
|
||||
msgstr "Remplissage découpe flexible"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Pattern"
|
||||
msgstr "Motif"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slot length:"
|
||||
msgstr "Longueur d'une lumière :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slot width:"
|
||||
msgstr "Largeur d'une lumière :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Bridge (material between two slots of a column):"
|
||||
msgstr "Pont (matière entre deux lumières d'une colonne) :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Column pitch (centre to centre):"
|
||||
msgstr "Pas des colonnes (entraxe) :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Unit:"
|
||||
msgstr "Unité :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stagger of odd columns (% of length + bridge):"
|
||||
msgstr "Décalage des colonnes impaires (% de longueur + pont) :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "50 % gives the classic staggered living hinge; 0 % aligns all the columns."
|
||||
msgstr "50 % donne la découpe flexible classique, en quinconce ; 0 % aligne toutes les colonnes."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Pattern angle (°, 0 = vertical slots):"
|
||||
msgstr "Angle du motif (°, 0 = lumières verticales) :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "The panel bends around an axis parallel to the slots."
|
||||
msgstr "Le panneau se plie autour d'un axe parallèle aux lumières."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random"
|
||||
msgstr "Aléatoire"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random slot lengths"
|
||||
msgstr "Longueurs de lumières aléatoires"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Shortest random slot:"
|
||||
msgstr "Plus courte lumière aléatoire :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Never less than the slot width, which gives a round hole."
|
||||
msgstr "Jamais moins que la largeur d'une lumière, qui donne un trou rond."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random seed:"
|
||||
msgstr "Graine du hasard :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Same seed, same pattern; change it to draw another one."
|
||||
msgstr "Même graine, même motif ; changez-la pour en tirer un autre."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Each slot gets a random length between this shortest value and the slot length of the \"Pattern\" tab. Every column is filled from margin to margin: a slot starts right at the top and another ends right at the bottom, so all columns begin and end at the same level. Bridge and column pitch stay constant; the stagger and the minimum length of a shortened slot are not used."
|
||||
msgstr "Chaque lumière prend une longueur au hasard entre cette valeur minimale et la longueur d'une lumière de l'onglet « Motif ». Chaque colonne est remplie d'une marge à l'autre : une lumière commence pile en haut et une autre finit pile en bas, toutes les colonnes débutent et s'arrêtent donc au même niveau. Le pont et le pas des colonnes restent constants ; le décalage et la longueur minimale d'une lumière raccourcie ne servent pas."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Fill"
|
||||
msgstr "Remplissage"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Margin between the slots and the edge:"
|
||||
msgstr "Marge entre les lumières et le bord :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Minimum length of a shortened slot:"
|
||||
msgstr "Longueur minimale d'une lumière raccourcie :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slots cut short by the edge are dropped below this length."
|
||||
msgstr "Les lumières raccourcies par le bord sont supprimées en dessous de cette longueur."
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stroke color:"
|
||||
msgstr "Couleur du trait :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stroke width:"
|
||||
msgstr "Épaisseur du trait :"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr "Conserver la forme d'origine"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (rectangles, ellipses, paths, even with holes, or groups of them), then run the extension. Texts, images and clones are ignored.\n"
|
||||
"\n"
|
||||
"Each shape is filled with oblong slots arranged in staggered columns: once cut, the panel bends around an axis parallel to the slots. Longer slots, thinner bridges and a tighter column pitch make it more flexible, and more fragile.\n"
|
||||
"\n"
|
||||
"Near the edge and around holes, slots are shortened and rounded again so that they stay at the margin; those that would become shorter than the minimum length are dropped. The pattern is centred on each shape.\n"
|
||||
"\n"
|
||||
"In the \"Random\" tab, slots can take random lengths, from a round hole to the full slot length, for a more organic look.\n"
|
||||
"\n"
|
||||
"The slots are drawn as a single path, in a group named \"Living hinge\" placed just above the shape. All lengths use the unit chosen in the \"Pattern\" tab."
|
||||
msgstr ""
|
||||
"Sélectionnez une ou plusieurs formes fermées (rectangles, ellipses, chemins, même troués, ou des groupes de ces formes), puis lancez l'extension. Les textes, les images et les clones sont ignorés.\n"
|
||||
"\n"
|
||||
"Chaque forme est remplie de lumières oblongues rangées en colonnes décalées en quinconce : une fois découpé, le panneau se plie autour d'un axe parallèle aux lumières. Des lumières plus longues, des ponts plus fins et un pas de colonnes plus serré le rendent plus souple, et plus fragile.\n"
|
||||
"\n"
|
||||
"Près du bord et autour des trous, les lumières sont raccourcies et arrondies à nouveau pour rester à la marge ; celles qui deviendraient plus courtes que la longueur minimale sont supprimées. Le motif est centré sur chaque forme.\n"
|
||||
"\n"
|
||||
"Dans l'onglet « Aléatoire », les lumières peuvent prendre des longueurs au hasard, du trou rond à la longueur entière, pour un rendu plus organique.\n"
|
||||
"\n"
|
||||
"Les lumières sont tracées en un seul chemin, dans un groupe nommé « Découpe flexible » placé juste au-dessus de la forme. Toutes les longueurs utilisent l'unité choisie dans l'onglet « Motif »."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "Select at least one shape (rectangle, ellipse or path)."
|
||||
msgstr "Sélectionnez au moins une forme (rectangle, ellipse ou chemin)."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The slot width and the column pitch must be strictly positive."
|
||||
msgstr "La largeur des lumières et le pas des colonnes doivent être strictement positifs."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The slot length must be at least equal to the slot width. Increase the length or reduce the width."
|
||||
msgstr "La longueur d'une lumière doit être au moins égale à sa largeur. Augmentez la longueur ou réduisez la largeur."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The column pitch must be larger than the slot width, otherwise neighbouring slots merge. Increase the pitch or reduce the width."
|
||||
msgstr "Le pas des colonnes doit être supérieur à la largeur des lumières, sinon les lumières voisines fusionnent. Augmentez le pas ou réduisez la largeur."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The shortest random slot must not exceed the slot length, which is the longest one. Reduce the shortest length or increase the slot length."
|
||||
msgstr "La plus courte lumière aléatoire ne doit pas dépasser la longueur d'une lumière, qui est la plus longue. Réduisez la longueur la plus courte ou augmentez la longueur d'une lumière."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "{} shape(s) too small: no slot fits inside. Reduce the slot width, the minimum length or the margin."
|
||||
msgstr "{} forme(s) trop petite(s) : aucune lumière n'y tient. Réduisez la largeur des lumières, la longueur minimale ou la marge."
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "Living hinge"
|
||||
msgstr "Découpe flexible"
|
||||
153
po/livinghinge.pot
Normal file
@ -0,0 +1,153 @@
|
||||
# Modele de traduction de l'extension Inkscape « Living Hinge Fill ».
|
||||
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: livinghinge\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Living Hinge Fill"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Pattern"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slot length:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slot width:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Bridge (material between two slots of a column):"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Column pitch (centre to centre):"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Unit:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stagger of odd columns (% of length + bridge):"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "50 % gives the classic staggered living hinge; 0 % aligns all the columns."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Pattern angle (°, 0 = vertical slots):"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "The panel bends around an axis parallel to the slots."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random slot lengths"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Shortest random slot:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Never less than the slot width, which gives a round hole."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Random seed:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Same seed, same pattern; change it to draw another one."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Each slot gets a random length between this shortest value and the slot length of the \"Pattern\" tab. Every column is filled from margin to margin: a slot starts right at the top and another ends right at the bottom, so all columns begin and end at the same level. Bridge and column pitch stay constant; the stagger and the minimum length of a shortened slot are not used."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Fill"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Margin between the slots and the edge:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Minimum length of a shortened slot:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Slots cut short by the edge are dropped below this length."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stroke color:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Stroke width:"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Keep the original shape"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.inx
|
||||
msgid ""
|
||||
"Select one or more closed shapes (rectangles, ellipses, paths, even with holes, or groups of them), then run the extension. Texts, images and clones are ignored.\n"
|
||||
"\n"
|
||||
"Each shape is filled with oblong slots arranged in staggered columns: once cut, the panel bends around an axis parallel to the slots. Longer slots, thinner bridges and a tighter column pitch make it more flexible, and more fragile.\n"
|
||||
"\n"
|
||||
"Near the edge and around holes, slots are shortened and rounded again so that they stay at the margin; those that would become shorter than the minimum length are dropped. The pattern is centred on each shape.\n"
|
||||
"\n"
|
||||
"In the \"Random\" tab, slots can take random lengths, from a round hole to the full slot length, for a more organic look.\n"
|
||||
"\n"
|
||||
"The slots are drawn as a single path, in a group named \"Living hinge\" placed just above the shape. All lengths use the unit chosen in the \"Pattern\" tab."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "Select at least one shape (rectangle, ellipse or path)."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The slot width and the column pitch must be strictly positive."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The slot length must be at least equal to the slot width. Increase the length or reduce the width."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The column pitch must be larger than the slot width, otherwise neighbouring slots merge. Increase the pitch or reduce the width."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "The shortest random slot must not exceed the slot length, which is the longest one. Reduce the shortest length or increase the slot length."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "{} shape(s) too small: no slot fits inside. Reduce the slot width, the minimum length or the margin."
|
||||
msgstr ""
|
||||
|
||||
#: living_hinge.py
|
||||
msgid "Living hinge"
|
||||
msgstr ""
|
||||
479
test_living_hinge.py
Normal file
@ -0,0 +1,479 @@
|
||||
# coding=utf-8
|
||||
"""Tests de l'extension « Living Hinge Fill » (pytest)."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from living_hinge_core import (bounding_box, clear_intervals, hinge_slots, parse_color,
|
||||
polylines_to_d, random_spans, regular_spans, rotate,
|
||||
slot_length, slot_outline, slots_to_d)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
||||
|
||||
RECT = [[(0.0, 0.0), (60.0, 0.0), (60.0, 80.0), (0.0, 80.0)]]
|
||||
# Rectangle troue (pair-impair)
|
||||
HOLED = [RECT[0], [(20.0, 30.0), (40.0, 30.0), (40.0, 50.0), (20.0, 50.0)]]
|
||||
DIMS = dict(length=20.0, width=3.0, bridge=2.0, pitch=5.0)
|
||||
|
||||
|
||||
def circle(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 distance_to_segment(p, a, b):
|
||||
ex, ey = b[0] - a[0], b[1] - a[1]
|
||||
norm = ex * ex + ey * ey
|
||||
t = 0.0 if norm == 0 else max(0.0, min(1.0, ((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / norm))
|
||||
return math.hypot(p[0] - a[0] - t * ex, p[1] - a[1] - t * ey)
|
||||
|
||||
|
||||
def distance_to_boundary(p, rings):
|
||||
return min(distance_to_segment(p, ring[k], ring[(k + 1) % len(ring)])
|
||||
for ring in rings for k in range(len(ring)))
|
||||
|
||||
|
||||
def is_inside(p, rings):
|
||||
inside = False
|
||||
for ring in rings:
|
||||
for k in range(len(ring)):
|
||||
(x1, y1), (x2, y2) = ring[k], ring[(k + 1) % len(ring)]
|
||||
if (y1 <= p[1]) != (y2 <= p[1]) and p[0] < x1 + (p[1] - y1) * (x2 - x1) / (y2 - y1):
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Noyau (sans inkex) : utilitaires
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_parse_color_inkscape_integer():
|
||||
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
||||
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
||||
assert parse_color("255") == ("#000000", 1.0)
|
||||
|
||||
|
||||
def test_parse_color_hex_and_invalid():
|
||||
assert parse_color("#123456") == ("#123456", 1.0)
|
||||
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
||||
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
||||
|
||||
|
||||
def test_polylines_to_d():
|
||||
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
|
||||
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
|
||||
|
||||
|
||||
def test_bounding_box():
|
||||
assert bounding_box(HOLED) == (0.0, 0.0, 60.0, 80.0)
|
||||
assert bounding_box([]) is None
|
||||
|
||||
|
||||
def test_rotate_counter_clockwise_on_screen():
|
||||
# y vers le bas : un point a droite du centre monte (y diminue).
|
||||
x, y = rotate((1.0, 0.0), 90)
|
||||
assert abs(x) < 1e-12 and abs(y + 1.0) < 1e-12
|
||||
x, y = rotate((3.0, 2.0), 180, center=(2.0, 2.0))
|
||||
assert abs(x - 1.0) < 1e-12 and abs(y - 2.0) < 1e-12
|
||||
|
||||
|
||||
def test_clear_intervals_rectangle():
|
||||
assert clear_intervals(RECT, 30.0) == [(0.0, 80.0)]
|
||||
(y0, y1), = clear_intervals(RECT, 30.0, 5.0)
|
||||
assert abs(y0 - 5.0) < 1e-6 and abs(y1 - 75.0) < 1e-6
|
||||
# Trop pres du bord gauche, ou hors de la forme
|
||||
assert clear_intervals(RECT, 3.0, 5.0) == []
|
||||
assert clear_intervals(RECT, 70.0) == []
|
||||
|
||||
|
||||
def test_clear_intervals_hole_and_corner():
|
||||
low, high = clear_intervals(HOLED, 30.0, 2.0)
|
||||
assert abs(low[1] - 28.0) < 1e-6 and abs(high[0] - 52.0) < 1e-6
|
||||
# A cote du trou : la zone interdite s'arrondit autour de ses coins.
|
||||
(_y0, y1), (y2, _y3) = clear_intervals(HOLED, 18.0, 4.0)
|
||||
reach = math.sqrt(4.0 ** 2 - 2.0 ** 2)
|
||||
assert abs(y1 - (30.0 - reach)) < 1e-6 and abs(y2 - (50.0 + reach)) < 1e-6
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Noyau (sans inkex) : motif
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_slots_stay_inside_with_margin():
|
||||
for rings in (RECT, HOLED, [circle(50, 50, 40), circle(50, 50, 10)]):
|
||||
for angle in (0.0, 30.0, 90.0):
|
||||
slots = hinge_slots(rings, margin=2.0, angle=angle, **DIMS)
|
||||
assert slots
|
||||
for slot in slots:
|
||||
for point in slot_outline(slot, 1.5):
|
||||
assert is_inside(point, rings)
|
||||
assert distance_to_boundary(point, rings) >= 2.0 - 1e-6
|
||||
|
||||
|
||||
def test_slot_lengths_and_minimum():
|
||||
slots = hinge_slots(RECT, margin=2.0, min_length=6.0, **DIMS)
|
||||
lengths = [slot_length(slot, 3.0) for slot in slots]
|
||||
assert max(lengths) <= 20.0 + 1e-9
|
||||
assert min(lengths) >= 6.0 - 1e-9
|
||||
assert any(abs(value - 20.0) < 1e-9 for value in lengths) # lumieres entieres
|
||||
assert any(value < 20.0 - 1e-6 for value in lengths) # et raccourcies
|
||||
# Un minimum plus haut ecarte des lumieres raccourcies, jamais les entieres.
|
||||
full_only = hinge_slots(RECT, margin=2.0, min_length=20.0, **DIMS)
|
||||
assert 0 < len(full_only) < len(slots)
|
||||
assert all(abs(slot_length(slot, 3.0) - 20.0) < 1e-9 for slot in full_only)
|
||||
|
||||
|
||||
def test_columns_pitch_bridge_and_stagger():
|
||||
slots = hinge_slots(RECT, margin=2.0, **DIMS)
|
||||
columns = {}
|
||||
for (x0, y0), (x1, y1) in slots:
|
||||
assert x0 == x1 and y0 <= y1 # lumieres verticales
|
||||
columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5))
|
||||
xs = sorted(columns)
|
||||
assert all(abs(b - a - 5.0) < 1e-9 for a, b in zip(xs, xs[1:]))
|
||||
assert 30.0 in xs # motif centre sur la forme
|
||||
assert xs[0] >= 3.5 and xs[-1] <= 56.5 # demi-largeur + marge
|
||||
for spans in columns.values():
|
||||
spans.sort()
|
||||
for (_a, end), (start, _b) in zip(spans, spans[1:]):
|
||||
assert abs(start - end - 2.0) < 1e-9 # pont entre deux lumieres
|
||||
# Quinconce : decalage d'une demi-periode (22 / 2) entre colonnes voisines.
|
||||
even = {round(end % 22.0, 6) for _start, end in columns[30.0][:-1]}
|
||||
odd = {round(end % 22.0, 6) for _start, end in columns[35.0][:-1]}
|
||||
assert len(even) == 1 and len(odd) == 1
|
||||
assert abs(abs(even.pop() - odd.pop()) - 11.0) < 1e-6
|
||||
|
||||
|
||||
def test_stagger_zero_aligns_columns():
|
||||
slots = hinge_slots(RECT, margin=2.0, stagger=0.0, **DIMS)
|
||||
spans = {}
|
||||
for (x0, y0), (_x1, y1) in slots:
|
||||
spans.setdefault(round(x0, 6), []).append((round(y0, 6), round(y1, 6)))
|
||||
assert len(spans) > 1
|
||||
assert len(set(map(tuple, spans.values()))) == 1
|
||||
|
||||
|
||||
def test_angle_90_gives_horizontal_slots():
|
||||
slots = hinge_slots(RECT, margin=2.0, angle=90.0, **DIMS)
|
||||
assert slots
|
||||
for (x0, y0), (x1, y1) in slots:
|
||||
assert abs(y0 - y1) < 1e-9 and abs(x1 - x0) > 1.0
|
||||
|
||||
|
||||
def test_hole_is_avoided():
|
||||
slots = hinge_slots(HOLED, margin=2.0, **DIMS)
|
||||
for slot in slots:
|
||||
for x, y in slot_outline(slot, 1.5):
|
||||
assert not (18.0 + 1e-6 < x < 42.0 - 1e-6 and 28.0 + 1e-6 < y < 52.0 - 1e-6)
|
||||
assert len(slots) != len(hinge_slots(RECT, margin=2.0, **DIMS))
|
||||
|
||||
|
||||
def test_regular_spans():
|
||||
spans = regular_spans(0.0, 100.0, 5.0, 20.0, 2.0)
|
||||
assert all(size == 20.0 for _start, size in spans)
|
||||
starts = [start for start, _size in spans]
|
||||
assert 5.0 in starts and starts[0] + 20.0 > 0.0 and starts[-1] <= 100.0
|
||||
assert all(abs(b - a - 22.0) < 1e-9 for a, b in zip(starts, starts[1:]))
|
||||
|
||||
|
||||
def test_random_spans_fill_exactly():
|
||||
# Toutes sortes de hauteurs : la zone est remplie pile, d'un bout a l'autre.
|
||||
for k in range(200):
|
||||
low, high = 10.0, 10.0 + 25.0 + 1.37 * k
|
||||
spans = random_spans(low, high, 3.0, 20.0, 2.0, random.Random(k))
|
||||
assert abs(spans[0][0] - low) < 1e-9
|
||||
assert abs(spans[-1][0] + spans[-1][1] - high) < 1e-9
|
||||
assert all(3.0 - 1e-9 <= size <= 20.0 + 1e-9 for _start, size in spans)
|
||||
for (start, size), (following, _s) in zip(spans, spans[1:]):
|
||||
assert abs(following - start - size - 2.0) < 1e-9 # pont constant
|
||||
# Reproductible, et different d'un tirage a l'autre.
|
||||
args = (0.0, 500.0, 3.0, 20.0, 2.0)
|
||||
assert random_spans(*args, random.Random(1)) == random_spans(*args, random.Random(1))
|
||||
assert random_spans(*args, random.Random(1)) != random_spans(*args, random.Random(2))
|
||||
|
||||
|
||||
def test_random_spans_tight_cases():
|
||||
rng = random.Random(0)
|
||||
# Zone plus courte que la plus courte lumiere : une seule, qui la remplit.
|
||||
assert random_spans(0.0, 4.0, 6.0, 20.0, 2.0, rng) == [(0.0, 4.0)]
|
||||
# Zone d'une lumiere pile.
|
||||
assert random_spans(5.0, 15.0, 3.0, 20.0, 2.0, rng) == [(5.0, 10.0)]
|
||||
# Aucun compte ne tombe juste (bornes egales) : lumieres egales, zone remplie.
|
||||
spans = random_spans(0.0, 76.0, 20.0, 20.0, 2.0, rng)
|
||||
assert len({round(size, 9) for _start, size in spans}) == 1
|
||||
assert spans[0][0] == 0.0 and abs(spans[-1][0] + spans[-1][1] - 76.0) < 1e-9
|
||||
assert random_spans(3.0, 3.0, 3.0, 20.0, 2.0, rng) == []
|
||||
|
||||
|
||||
def test_random_columns_start_and_end_at_margin():
|
||||
# Dans un rectangle, toutes les colonnes commencent et finissent au meme niveau.
|
||||
slots = hinge_slots(RECT, margin=2.0, random_min=3.0, seed=4, **DIMS)
|
||||
columns = {}
|
||||
for (x0, y0), (_x1, y1) in slots:
|
||||
columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5))
|
||||
assert len(columns) == 11
|
||||
for spans in columns.values():
|
||||
assert abs(min(a for a, _b in spans) - 2.0) < 1e-6
|
||||
assert abs(max(b for _a, b in spans) - 78.0) < 1e-6
|
||||
# Autour d'un trou aussi : chaque zone libre est remplie d'un bord a l'autre.
|
||||
slots = hinge_slots(HOLED, margin=2.0, random_min=3.0, seed=4, **DIMS)
|
||||
middle = sorted((y0 - 1.5, y1 + 1.5) for (x0, y0), (_x1, y1) in slots if x0 == 30.0)
|
||||
ends = [b for _a, b in middle]
|
||||
starts = [a for a, _b in middle]
|
||||
assert abs(starts[0] - 2.0) < 1e-6 and abs(ends[-1] - 78.0) < 1e-6
|
||||
assert any(abs(b - 28.0) < 1e-6 for b in ends)
|
||||
assert any(abs(a - 52.0) < 1e-6 for a in starts)
|
||||
|
||||
|
||||
def test_random_lengths():
|
||||
big = [[(0.0, 0.0), (200.0, 0.0), (200.0, 300.0), (0.0, 300.0)]]
|
||||
slots = hinge_slots(big, margin=2.0, random_min=3.0, seed=7, **DIMS)
|
||||
assert slots == hinge_slots(big, margin=2.0, random_min=3.0, seed=7, **DIMS)
|
||||
assert slots != hinge_slots(big, margin=2.0, random_min=3.0, seed=8, **DIMS)
|
||||
lengths = [slot_length(slot, 3.0) for slot in slots]
|
||||
assert min(lengths) >= 3.0 - 1e-9 and max(lengths) <= 20.0 + 1e-9
|
||||
assert min(lengths) < 6.0 and max(lengths) > 17.0 # toute la plage sert
|
||||
assert len({round(value, 3) for value in lengths}) > len(lengths) / 2
|
||||
# Pas des colonnes et pont inchanges, lumieres a la marge.
|
||||
columns = {}
|
||||
for (x0, y0), (x1, y1) in slots:
|
||||
assert x0 == x1
|
||||
assert 3.5 - 1e-6 <= y0 and y1 <= 296.5 + 1e-6
|
||||
columns.setdefault(round(x0, 6), []).append((y0 - 1.5, y1 + 1.5))
|
||||
xs = sorted(columns)
|
||||
assert all(abs(b - a - 5.0) < 1e-9 for a, b in zip(xs, xs[1:]))
|
||||
for spans in columns.values():
|
||||
spans.sort()
|
||||
for (_a, end), (start, _b) in zip(spans, spans[1:]):
|
||||
assert abs(start - end - 2.0) < 1e-9
|
||||
# Les colonnes ne sont pas des copies les unes des autres.
|
||||
assert len({tuple(round(v, 3) for span in spans for v in span)
|
||||
for spans in columns.values()}) == len(columns)
|
||||
|
||||
|
||||
def test_random_min_is_clamped_and_respects_shape():
|
||||
# Minimum sous la largeur : ramene a la largeur (trou rond au plus petit).
|
||||
slots = hinge_slots(HOLED, margin=2.0, random_min=0.0, seed=3, **DIMS)
|
||||
assert slots
|
||||
for slot in slots:
|
||||
assert slot_length(slot, 3.0) >= 3.0 - 1e-9
|
||||
for point in slot_outline(slot, 1.5):
|
||||
assert is_inside(point, HOLED)
|
||||
assert distance_to_boundary(point, HOLED) >= 2.0 - 1e-6
|
||||
# Minimum ramene a la longueur : plus de hasard, lumieres egales d'une marge a l'autre.
|
||||
slots = hinge_slots(RECT, margin=2.0, random_min=50.0, seed=3, **DIMS)
|
||||
assert slots and len({round(slot_length(slot, 3.0), 6) for slot in slots}) == 1
|
||||
|
||||
|
||||
def test_full_slot_shorter_than_minimum_is_kept():
|
||||
# min_length ne vise que les lumieres raccourcies par le bord.
|
||||
slots = hinge_slots(RECT, length=5.0, width=3.0, bridge=2.0, pitch=5.0, min_length=9.0)
|
||||
assert slots and all(abs(slot_length(slot, 3.0) - 5.0) < 1e-9 for slot in slots)
|
||||
|
||||
|
||||
def test_degenerate_inputs():
|
||||
assert hinge_slots([], **DIMS) == []
|
||||
assert hinge_slots([[(0, 0), (1, 1)]], **DIMS) == []
|
||||
assert hinge_slots([[(0, 0), (4, 0), (4, 4), (0, 4)]], margin=2.0, **DIMS) == []
|
||||
assert hinge_slots(RECT, length=2.0, width=3.0, bridge=2.0, pitch=5.0) == []
|
||||
assert hinge_slots(RECT, length=20.0, width=0.0, bridge=2.0, pitch=5.0) == []
|
||||
assert hinge_slots(RECT, length=20.0, width=3.0, bridge=2.0, pitch=0.0) == []
|
||||
|
||||
|
||||
def test_slot_outline_is_at_radius_of_axis():
|
||||
slot = ((10.0, 10.0), (10.0, 30.0))
|
||||
outline = slot_outline(slot, 2.0, segments=8)
|
||||
assert all(abs(a - b) < 1e-9 for a, b in zip(outline[0], outline[-1]))
|
||||
for point in outline:
|
||||
assert abs(distance_to_segment(point, *slot) - 2.0) < 1e-9
|
||||
ys = [y for _x, y in outline]
|
||||
assert abs(min(ys) - 8.0) < 1e-9 and abs(max(ys) - 32.0) < 1e-9
|
||||
|
||||
|
||||
def test_slots_to_d():
|
||||
d = slots_to_d([((10.0, 10.0), (10.0, 30.0))], 2.0, precision=1)
|
||||
assert d == ("M 8.0,10.0 L 8.0,30.0 A 2.0 2.0 0 0 0 12.0,30.0 "
|
||||
"L 12.0,10.0 A 2.0 2.0 0 0 0 8.0,10.0 Z")
|
||||
# Lumiere reduite a un cercle : deux arcs, pas de segment nul.
|
||||
circle_d = slots_to_d([((5.0, 5.0), (5.0, 5.0))], 1.0, precision=0)
|
||||
assert circle_d == "M 4,5 A 1 1 0 0 0 6,5 A 1 1 0 0 0 4,5 Z"
|
||||
assert slots_to_d([], 1.0) == ""
|
||||
assert slots_to_d([((0, 0), (0, 9)), ((5, 0), (5, 9))], 1.0).count("Z") == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bout en bout (necessite inkex)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def run_extension(tmp_path, *args):
|
||||
pytest.importorskip("inkex")
|
||||
from living_hinge import LivingHinge
|
||||
out = tmp_path / "out.svg"
|
||||
if out.exists():
|
||||
out.unlink()
|
||||
LivingHinge().run([*args, "--output={}".format(out), SHAPES])
|
||||
# inkex n'ecrit rien quand le document n'a pas change (erreur utilisateur).
|
||||
return out.read_text(encoding="utf-8") if out.exists() else ""
|
||||
|
||||
|
||||
def generated_paths(svg):
|
||||
"""Chemins ajoutes par l'extension (ceux du fichier d'exemple ont un id connu)."""
|
||||
root = ET.fromstring(svg)
|
||||
return [elem for elem in root.iter()
|
||||
if elem.tag.endswith("}path") and elem.get("id") not in ("path1", "path2")]
|
||||
|
||||
|
||||
def test_end_to_end_default(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1")
|
||||
assert "Living hinge" in svg
|
||||
assert 'id="rect1"' in svg
|
||||
paths = generated_paths(svg)
|
||||
assert len(paths) == 1 # un seul chemin pour toutes les lumieres
|
||||
assert paths[0].get("d").count("Z") >= 10
|
||||
assert "fill:none" in paths[0].get("style")
|
||||
|
||||
|
||||
def test_end_to_end_slots_inside_rect(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--margin=2")
|
||||
d = generated_paths(svg)[0].get("d")
|
||||
points = [(float(x), float(y)) for x, y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", d)]
|
||||
assert points
|
||||
# rect1 : x 10..60, y 10..50 ; les raccords droite / arc restent a la marge
|
||||
# en x, et a marge + rayon en y (le bout arrondi depasse d'un rayon).
|
||||
assert all(12.0 - 1e-3 <= x <= 58.0 + 1e-3 for x, _y in points)
|
||||
assert all(13.5 - 1e-3 <= y <= 46.5 + 1e-3 for _x, y in points)
|
||||
|
||||
|
||||
def test_end_to_end_all_shapes_and_group(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=circle1", "--id=path1", "--id=path2", "--id=group1")
|
||||
assert svg.count("Living hinge") == 4
|
||||
assert len(generated_paths(svg)) == 4
|
||||
# Le groupe d'accueil neutralise la translation du groupe parent.
|
||||
assert re.search(r'transform="translate\(-100,? ?-65\)"', svg)
|
||||
|
||||
|
||||
def test_end_to_end_units_and_angle(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--unit=cm", "--slot_length=2",
|
||||
"--slot_width=0.3", "--bridge=0.2", "--pitch=0.5",
|
||||
"--margin=0.2", "--min_length=0.6", "--stroke_width=0.02",
|
||||
"--angle=90")
|
||||
assert "A 1.5000 1.5000" in svg
|
||||
assert re.search(r"stroke-width:0\.2\d*[;\"]", svg)
|
||||
|
||||
|
||||
def test_end_to_end_errors(tmp_path, capsys):
|
||||
svg = run_extension(tmp_path) # pas de selection
|
||||
assert "Living hinge" not in svg
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--pitch=2") # pas <= largeur
|
||||
assert "Living hinge" not in svg
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--slot_length=1") # longueur < largeur
|
||||
assert "Living hinge" not in svg
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--margin=30") # rien ne tient
|
||||
assert "Living hinge" not in svg
|
||||
errors = capsys.readouterr().err
|
||||
assert "Select at least one shape" in errors
|
||||
assert "column pitch" in errors and "slot length" in errors and "too small" in errors
|
||||
|
||||
|
||||
def arc_starts(svg):
|
||||
"""Debut de chaque lumiere du chemin genere (un « M x,y » par lumiere)."""
|
||||
return re.findall(r"M (-?[\d.]+,-?[\d.]+)", generated_paths(svg)[0].get("d"))
|
||||
|
||||
|
||||
def test_end_to_end_random(tmp_path):
|
||||
regular = run_extension(tmp_path, "--id=path2")
|
||||
first = run_extension(tmp_path, "--id=path2", "--random_lengths=true")
|
||||
again = run_extension(tmp_path, "--id=path2", "--random_lengths=true")
|
||||
other = run_extension(tmp_path, "--id=path2", "--random_lengths=true", "--seed=2")
|
||||
assert arc_starts(first) == arc_starts(again) # meme graine, meme motif
|
||||
assert arc_starts(first) != arc_starts(other)
|
||||
assert arc_starts(first) != arc_starts(regular)
|
||||
assert len(arc_starts(first)) > len(arc_starts(regular)) # lumieres plus courtes
|
||||
|
||||
|
||||
def test_end_to_end_random_error(tmp_path, capsys):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--random_lengths=true", "--random_min=25")
|
||||
assert "Living hinge" not in svg
|
||||
assert "shortest random slot" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_end_to_end_removes_original(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
|
||||
assert 'id="rect1"' not in svg
|
||||
|
||||
|
||||
def test_end_to_end_color(tmp_path):
|
||||
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF))
|
||||
assert re.search(r"stroke:#336699", svg)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Traductions
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_translations_up_to_date_and_complete():
|
||||
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
||||
import gettext
|
||||
import i18n
|
||||
|
||||
msgids = [msgid for msgid, _refs in i18n.extract()]
|
||||
assert "Living Hinge Fill" in msgids
|
||||
assert "mm" not in msgids # unites marquees translatable="no"
|
||||
|
||||
for language in i18n.LANGUAGES:
|
||||
entries = i18n.read_po(i18n.po_path(language))
|
||||
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
||||
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
||||
|
||||
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
||||
for msgid in msgids:
|
||||
assert catalog.gettext(msgid) == entries[msgid][0], \
|
||||
"{} : .mo a recompiler (python i18n.py)".format(language)
|
||||
|
||||
|
||||
def test_po_roundtrip(tmp_path):
|
||||
import i18n
|
||||
|
||||
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
||||
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
||||
path = str(tmp_path / "xx.po")
|
||||
i18n.write_po(path, "fr", messages,
|
||||
{m: ("<" + m + ">", False) for m, _r in messages})
|
||||
entries = i18n.read_po(path)
|
||||
for msgid, _refs in messages:
|
||||
assert entries[msgid] == ("<" + msgid + ">", False)
|
||||
|
||||
|
||||
def test_inx_matches_arguments():
|
||||
"""Chaque <param> du .inx a son add_argument, memes noms et memes defauts."""
|
||||
root = ET.parse(os.path.join(HERE, "living_hinge.inx")).getroot()
|
||||
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
|
||||
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
||||
with open(os.path.join(HERE, "living_hinge.py"), encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
arguments = dict(re.findall(r'add_argument\("--(\w+)",.*?default=([^)]+)\)', source))
|
||||
assert set(params) == set(arguments)
|
||||
for name, default in arguments.items():
|
||||
if name in ("tab", "unit"):
|
||||
continue
|
||||
value = default.strip('"')
|
||||
if params[name] in ("true", "false"):
|
||||
assert value.lower() == params[name], name
|
||||
elif name == "stroke_color":
|
||||
assert value == params[name], name
|
||||
else:
|
||||
assert float(value) == float(params[name]), name
|
||||
|
||||
|
||||
def test_inx_images_exist():
|
||||
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
||||
root = ET.parse(os.path.join(HERE, "living_hinge.inx")).getroot()
|
||||
for elem in root.iter():
|
||||
if elem.tag.rsplit("}", 1)[-1] == "image":
|
||||
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|
||||
13
tests/data/shapes.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200mm" height="120mm" viewBox="0 0 200 120">
|
||||
<rect id="rect1" x="10" y="10" width="50" height="40" fill="#e9ecef" stroke="#212529"/>
|
||||
<circle id="circle1" cx="100" cy="30" r="22" fill="#e9ecef" stroke="#212529"/>
|
||||
<path id="path1" d="M 140,10 C 190,0 200,60 160,55 C 130,52 120,30 140,10 Z"
|
||||
fill="#e9ecef" stroke="#212529"/>
|
||||
<!-- Forme trouee : le sous-chemin interne est un trou (pair-impair) -->
|
||||
<path id="path2" d="M 10,65 H 90 V 115 H 10 Z M 35,80 H 65 V 100 H 35 Z"
|
||||
fill="#e9ecef" fill-rule="evenodd" stroke="#212529"/>
|
||||
<g id="group1" transform="translate(100,65)">
|
||||
<ellipse id="ellipse1" cx="45" cy="25" rx="40" ry="20" fill="#e9ecef" stroke="#212529"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 790 B |