Version initiale

This commit is contained in:
Alex 2026-10-01 15:21:24 +02:00
commit eb9308a8d4
21 changed files with 2687 additions and 0 deletions

15
.gitignore vendored Normal file
View 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
View 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é :
![Formes remplies de lumières de longueurs aléatoires](docs/exemple-aleatoire.png)
### Conseils
| Symptôme | Réglage |
| ------------------------------------------ | ----------------------------------------------------------- |
| Le panneau casse en pliant | Augmenter le pont et le pas des colonnes |
| Le panneau est trop raide | Allonger les lumières, réduire le pont ou le pas |
| Le pli doit être horizontal | Angle du motif à 90° |
| Petits trous ronds le long du bord | Augmenter la longueur minimale d'une lumière raccourcie |
| Le tirage aléatoire ne plaît pas | Changer la graine du hasard |
| Trop de petits trous ronds en mode aléatoire | Augmenter la plus courte lumière aléatoire |
| « forme(s) trop petite(s) » | Réduire la largeur des lumières, la longueur minimale ou la marge |
| Découpe d'un simple trait plutôt que d'une lumière | Réduire la largeur au trait de coupe du laser (0,1 à 0,2 mm) |
## Paramètres
![Schéma des paramètres](docs/parametres.png)
| Paramètre | Défaut | Rôle |
| ------------------------------------------------------ | ------ | -------------------------------------------------------------------------------------- |
| Longueur d'une lumière | 20 | Longueur hors tout d'une lumière entière, bouts arrondis compris |
| Largeur d'une lumière | 3 | Largeur de la lumière ; le rayon des bouts en est la moitié |
| Pont (matière entre deux lumières d'une colonne) | 2 | Matière laissée entre deux lumières qui se suivent dans une colonne |
| Pas des colonnes (entraxe) | 5 | Distance entre les axes de deux colonnes voisines (supérieure à la largeur) |
| Unité | mm | Unité de toutes les longueurs : mm, cm, px, pt, in |
| Décalage des colonnes impaires (% de longueur + pont) | 50 | 50 % = quinconce ; 0 % = colonnes alignées |
| Angle du motif (°, 0 = lumières verticales) | 0 | Rotation du motif, sens anti-horaire |
| Longueurs de lumières aléatoires | non | Tire la longueur de chaque lumière au hasard, colonnes remplies d'une marge à l'autre ; décalage et longueur minimale ne servent plus |
| Plus courte lumière aléatoire | 3 | Borne basse du tirage (au moins la largeur, soit un trou rond) ; la borne haute est la longueur d'une lumière |
| Graine du hasard | 1 | Même graine, même motif ; la changer donne un autre tirage |
| Marge entre les lumières et le bord | 2 | Distance minimale entre une lumière et le contour ou un trou |
| Longueur minimale d'une lumière raccourcie | 6 | Sous cette longueur, une lumière raccourcie par le bord est supprimée |
| Couleur du trait | noir | Couleur des lumières |
| Épaisseur du trait | 0,2 | Épaisseur du trait, dans l'unité choisie |
| Conserver la forme d'origine | oui | Décoché, la forme remplie est supprimée et seul le motif reste |
## Structure du projet
```text
living_hinge.inx Boîte de dialogue (textes source en anglais)
living_hinge.py Couche inkex : sélection, unités, écriture SVG
living_hinge_core.py Calcul du motif, sans inkex
i18n.py Extraction / mise à jour / compilation des traductions
po/ livinghinge.pot, en.po, fr.po
locale/<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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
doc/patern2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/exemple-aleatoire.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
docs/exemple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

379
docs/schema_parametres.py Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

97
living_hinge.inx Normal file
View 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
View 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
View 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)

Binary file not shown.

Binary file not shown.

164
po/en.po Normal file
View 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
View 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
View 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
View 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
View 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