Version initiale
This commit is contained in:
commit
c94656d87e
151
README.md
Normal file
151
README.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Remplissage par cercles (circle packing) — extension Inkscape
|
||||
|
||||
Extension Inkscape 1.x qui remplit la surface d'une forme fermée avec des cercles
|
||||
non chevauchants, selon la technique du *greedy random circle packing* : des points
|
||||
sont tirés au hasard dans la forme, et sur chacun on place le plus grand cercle
|
||||
possible qui ne déborde pas du contour et ne touche aucun cercle déjà posé.
|
||||
|
||||
## Table of content
|
||||
|
||||
- [Fonctionnalités](#fonctionnalités)
|
||||
- [Installation](#installation)
|
||||
- [Utilisation](#utilisation)
|
||||
- [Paramètres](#paramètres)
|
||||
- [Structure du projet](#structure-du-projet)
|
||||
- [Développement](#développement)
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Remplissage de `path`, `rect`, `circle`, `ellipse`, `polygon` (les groupes
|
||||
sélectionnés sont explorés pour en extraire les formes ; `line` et `polyline`
|
||||
sont ignorées)
|
||||
- Gestion des trous : les sous-chemins internes sont exclus (règle pair-impair)
|
||||
- Rayons minimal et maximal réglables, écart entre cercles (*gap*) et marge
|
||||
intérieure au contour, exprimés dans l'unité de votre choix
|
||||
- Les cercles héritent du style (fond, contour) de la forme source
|
||||
- Résultat placé dans un groupe nommé « Circle packing » ; forme d'origine
|
||||
conservée par défaut
|
||||
- Transformations respectées : le groupe généré annule celle de son parent
|
||||
- Graine aléatoire pour un résultat reproductible
|
||||
- Grille de hachage spatial : des dizaines de milliers de tentatives restent rapides
|
||||
- Script de déploiement PowerShell (installation, mise à jour, désinstallation)
|
||||
|
||||
## Installation
|
||||
|
||||
Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape.
|
||||
|
||||
### Windows — script de déploiement
|
||||
|
||||
Inkscape fermé, depuis le dossier du projet :
|
||||
|
||||
```powershell
|
||||
.\deploy.ps1 # copie vers %APPDATA%\inkscape\extensions\circlePacking
|
||||
.\deploy.ps1 -Uninstall # retire le sous-dossier de l'extension
|
||||
.\deploy.ps1 -Force # passe outre le contrôle « Inkscape ouvert »
|
||||
.\deploy.ps1 -FolderName 'mes-cercles' -ExtensionsDir 'D:\autre\chemin'
|
||||
```
|
||||
|
||||
Le script crée le sous-dossier, remplace les fichiers existants, nettoie le
|
||||
`__pycache__` obsolète et supprime les traces d'une ancienne installation posée
|
||||
à plat à la racine des extensions. Redémarrer Inkscape ensuite.
|
||||
|
||||
### Installation manuelle
|
||||
|
||||
Copier `circle_packing.inx`, `circle_packing.py` et `packing_core.py` dans un
|
||||
sous-dossier (par exemple `circlePacking`) du dossier des extensions utilisateur,
|
||||
puis redémarrer Inkscape :
|
||||
|
||||
- Windows : `%APPDATA%\inkscape\extensions`
|
||||
- Linux : `~/.config/inkscape/extensions`
|
||||
- macOS : `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions`
|
||||
|
||||
Inkscape explore récursivement ce dossier ; les trois fichiers doivent rester
|
||||
ensemble, `circle_packing.py` important `packing_core.py`.
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. Sélectionner une ou plusieurs formes fermées.
|
||||
2. **Extensions → Rendu → Remplissage par cercles (circle packing)…**
|
||||
3. Régler les paramètres, puis **Appliquer**.
|
||||
|
||||
Le fichier `tests/data/shapes.svg` contient quatre cas d'essai : rectangle, cercle,
|
||||
chemin courbe et chemin avec trou.
|
||||
|
||||
Conseils de réglage :
|
||||
|
||||
- densité insuffisante → augmenter le nombre de tentatives, réduire le rayon
|
||||
minimal, l'écart ou la marge
|
||||
- calcul trop long → réduire le nombre de tentatives ou plafonner le nombre de
|
||||
cercles
|
||||
- aucun cercle placé → un message l'indique ; le rayon minimal + la marge dépassent
|
||||
probablement la largeur de la forme
|
||||
|
||||
## Paramètres
|
||||
|
||||
| Paramètre | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| Rayon minimal | 1.0 | En dessous, le cercle candidat est rejeté |
|
||||
| Rayon maximal | 20.0 | Taille maximale d'un cercle |
|
||||
| Écart entre cercles | 0.5 | Distance imposée entre deux cercles voisins |
|
||||
| Marge intérieure au contour | 0.5 | Distance imposée entre un cercle et le bord |
|
||||
| Unité | mm | Unité des quatre longueurs ci-dessus (mm, cm, px, pt, in) |
|
||||
| Nombre de tentatives | 20000 | Plus il est élevé, plus le remplissage est dense (et lent) |
|
||||
| Nombre maximal de cercles | 5000 | Garde-fou ; 0 = illimité |
|
||||
| Graine aléatoire | 0 | 0 = aléatoire ; toute autre valeur rend le résultat reproductible |
|
||||
| Finesse d'échantillonnage | 0.2 | Précision de l'aplatissement des courbes de Bézier |
|
||||
| Conserver la forme d'origine | oui | Sinon la forme source est supprimée |
|
||||
|
||||
Le remplissage greedy atteint typiquement 60 à 75 % de couverture.
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
circle_packing.inx Description de la boîte de dialogue Inkscape
|
||||
circle_packing.py Extension : lecture de la sélection, aplatissement des
|
||||
chemins, écriture des <circle> dans le SVG
|
||||
packing_core.py Noyau géométrique sans dépendance à inkex
|
||||
deploy.ps1 Déploiement / désinstallation sous Windows
|
||||
test_packing.py Tests pytest
|
||||
tests/data/shapes.svg Formes d'exemple
|
||||
```
|
||||
|
||||
API de `packing_core.py` (utilisable seule, hors Inkscape) :
|
||||
|
||||
| Fonction | Rôle |
|
||||
|---|---|
|
||||
| `rings_bbox(rings)` | Boîte englobante d'un contour |
|
||||
| `point_in_rings(x, y, rings)` | Point dans la forme, règle pair-impair |
|
||||
| `distance_point_segment(px, py, x1, y1, x2, y2)` | Distance point → segment |
|
||||
| `distance_to_rings(x, y, rings)` | Distance au contour le plus proche |
|
||||
| `SpatialGrid(cell_size)` | Grille de hachage des cercles posés |
|
||||
| `pack_circles(rings, min_radius, max_radius, …)` | Boucle de packing, renvoie `[(x, y, r), …]` |
|
||||
|
||||
Un contour (`rings`) est une liste d'anneaux, chaque anneau étant une liste de
|
||||
points `(x, y)` fermée implicitement.
|
||||
|
||||
## Développement
|
||||
|
||||
Le noyau `packing_core.py` ne dépend pas d'Inkscape et se teste avec un Python
|
||||
standard :
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Linux/macOS : source .venv/bin/activate
|
||||
pip install pytest
|
||||
python -m pytest test_packing.py -q
|
||||
```
|
||||
|
||||
Résultat attendu : **14 passés, 1 ignoré**. Le test ignoré est celui de bout en
|
||||
bout, sauté quand `inkex` n'est pas importable. Pour l'exécuter, ajouter le dossier
|
||||
des extensions d'Inkscape au `PYTHONPATH` :
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
|
||||
python -m pytest test_packing.py -q
|
||||
```
|
||||
|
||||
Les tests couvrent l'appartenance à une forme trouée, les distances au contour, la
|
||||
grille spatiale, et les invariants du packing : aucun chevauchement, rayons dans
|
||||
les bornes, cercles tous intérieurs, reproductibilité à graine fixée.
|
||||
|
||||
Aucun outil de lint n'est configuré dans le projet ; le code suit PEP 8.
|
||||
BIN
__pycache__/circle_packing.cpython-312.pyc
Normal file
BIN
__pycache__/circle_packing.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/packing_core.cpython-312.pyc
Normal file
BIN
__pycache__/packing_core.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/packing_core.cpython-313.pyc
Normal file
BIN
__pycache__/packing_core.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
__pycache__/test_packing.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
63
circle_packing.inx
Normal file
63
circle_packing.inx
Normal file
@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||
<name>Remplissage par cercles (circle packing)</name>
|
||||
<id>fr.alexp.circlepacking</id>
|
||||
|
||||
<param name="tab" type="notebook">
|
||||
<page name="options" gui-text="Cercles">
|
||||
<param name="min_radius" type="float" precision="3" min="0.001" max="10000"
|
||||
gui-text="Rayon minimal :">1.0</param>
|
||||
<param name="max_radius" type="float" precision="3" min="0.001" max="10000"
|
||||
gui-text="Rayon maximal :">20.0</param>
|
||||
<param name="gap" type="float" precision="3" min="0" max="10000"
|
||||
gui-text="Écart entre cercles :">0.5</param>
|
||||
<param name="margin" type="float" precision="3" min="0" max="10000"
|
||||
gui-text="Marge intérieure au contour :">0.5</param>
|
||||
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unité :">
|
||||
<option value="mm">mm</option>
|
||||
<option value="cm">cm</option>
|
||||
<option value="px">px</option>
|
||||
<option value="pt">pt</option>
|
||||
<option value="in">in</option>
|
||||
</param>
|
||||
</page>
|
||||
|
||||
<page name="algo" gui-text="Algorithme">
|
||||
<param name="attempts" type="int" min="1" max="10000000"
|
||||
gui-text="Nombre de tentatives :">20000</param>
|
||||
<param name="max_circles" type="int" min="0" max="1000000"
|
||||
gui-text="Nombre maximal de cercles (0 = illimité) :">5000</param>
|
||||
<param name="seed" type="int" min="0" max="2147483647"
|
||||
gui-text="Graine aléatoire (0 = aléatoire) :">0</param>
|
||||
<param name="flatness" type="float" precision="3" min="0.001" max="10"
|
||||
gui-text="Finesse d'échantillonnage des courbes :">0.2</param>
|
||||
<param name="keep_original" type="bool"
|
||||
gui-text="Conserver la forme d'origine">true</param>
|
||||
</page>
|
||||
|
||||
<page name="help" gui-text="Aide">
|
||||
<label xml:space="preserve">Sélectionnez une ou plusieurs formes fermées
|
||||
(chemin, rectangle, cercle, ellipse, polygone),
|
||||
puis lancez l'extension.
|
||||
|
||||
Les cercles générés héritent du style (fond, contour)
|
||||
de la forme source et sont placés dans un groupe.
|
||||
|
||||
Un plus grand nombre de tentatives augmente la densité
|
||||
de remplissage mais allonge le temps de calcul.
|
||||
Les sous-chemins internes sont traités comme des trous
|
||||
(règle pair-impair).</label>
|
||||
</page>
|
||||
</param>
|
||||
|
||||
<effect>
|
||||
<object-type>all</object-type>
|
||||
<effects-menu>
|
||||
<submenu name="Rendu"/>
|
||||
</effects-menu>
|
||||
</effect>
|
||||
|
||||
<script>
|
||||
<command location="inx" interpreter="python">circle_packing.py</command>
|
||||
</script>
|
||||
</inkscape-extension>
|
||||
158
circle_packing.py
Normal file
158
circle_packing.py
Normal file
@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""
|
||||
Extension Inkscape : remplit une forme fermee par circle packing.
|
||||
|
||||
Pour chaque forme selectionnee, des points sont tires au hasard dans la boite
|
||||
englobante de la forme ; si le point est interieur, on y place le plus grand
|
||||
cercle possible qui ne deborde pas du contour et ne chevauche aucun cercle deja
|
||||
pose (greedy random placement).
|
||||
|
||||
La geometrie vit dans `packing_core`, sans dependance a inkex.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
import inkex
|
||||
from inkex import bezier
|
||||
|
||||
# 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 packing_core.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from packing_core import pack_circles # noqa: E402
|
||||
|
||||
|
||||
def flatten_path(path, flatness):
|
||||
"""Convertit un inkex.Path en liste d'anneaux (listes de points)."""
|
||||
csp = path.to_superpath()
|
||||
bezier.cspsubdiv(csp, flatness)
|
||||
rings = []
|
||||
for subpath in csp:
|
||||
ring = [(float(knot[1][0]), float(knot[1][1])) for knot in subpath]
|
||||
# Un contour ferme repete son premier point : on le retire, la
|
||||
# fermeture etant implicite dans packing_core.
|
||||
if (len(ring) > 1
|
||||
and math.isclose(ring[0][0], ring[-1][0], abs_tol=1e-9)
|
||||
and math.isclose(ring[0][1], ring[-1][1], abs_tol=1e-9)):
|
||||
ring.pop()
|
||||
if len(ring) >= 3:
|
||||
rings.append(ring)
|
||||
return rings
|
||||
|
||||
|
||||
class CirclePacking(inkex.EffectExtension):
|
||||
"""Remplit chaque forme selectionnee avec des cercles non chevauchants."""
|
||||
|
||||
def add_arguments(self, pars):
|
||||
pars.add_argument("--tab", default="options")
|
||||
pars.add_argument("--min_radius", type=float, default=1.0)
|
||||
pars.add_argument("--max_radius", type=float, default=20.0)
|
||||
pars.add_argument("--gap", type=float, default=0.5)
|
||||
pars.add_argument("--margin", type=float, default=0.5)
|
||||
pars.add_argument("--unit", default="mm")
|
||||
pars.add_argument("--attempts", type=int, default=20000)
|
||||
pars.add_argument("--max_circles", type=int, default=5000)
|
||||
pars.add_argument("--seed", type=int, default=0)
|
||||
pars.add_argument("--flatness", type=float, default=0.2)
|
||||
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
|
||||
|
||||
def effect(self):
|
||||
opt = self.options
|
||||
|
||||
shapes = []
|
||||
for elem in self.svg.selection.values():
|
||||
if isinstance(elem, (inkex.Group, inkex.Layer)):
|
||||
# Un groupe selectionne est explore pour en extraire les formes.
|
||||
shapes.extend(child for child in elem.descendants()
|
||||
if self.is_fillable(child))
|
||||
elif self.is_fillable(elem):
|
||||
shapes.append(elem)
|
||||
|
||||
if not shapes:
|
||||
inkex.errormsg("Sélectionnez au moins une forme fermée "
|
||||
"(chemin, rectangle, cercle, ellipse, polygone).")
|
||||
return
|
||||
|
||||
def to_uu(value):
|
||||
return self.svg.unittouu("{}{}".format(value, opt.unit))
|
||||
|
||||
min_radius = to_uu(opt.min_radius)
|
||||
max_radius = to_uu(opt.max_radius)
|
||||
gap = to_uu(opt.gap)
|
||||
margin = to_uu(opt.margin)
|
||||
|
||||
if min_radius <= 0 or max_radius <= 0:
|
||||
inkex.errormsg("Les rayons doivent être strictement positifs.")
|
||||
return
|
||||
if min_radius > max_radius:
|
||||
inkex.errormsg("Le rayon minimal doit être inférieur au rayon maximal.")
|
||||
return
|
||||
|
||||
rng = random.Random(opt.seed) if opt.seed else random.Random()
|
||||
|
||||
total = 0
|
||||
for elem in shapes:
|
||||
total += self.pack_shape(elem, min_radius, max_radius, gap,
|
||||
margin, rng)
|
||||
|
||||
if total == 0:
|
||||
inkex.errormsg("Aucun cercle n'a pu être placé. Réduisez le rayon "
|
||||
"minimal, l'écart ou la marge, ou augmentez le "
|
||||
"nombre de tentatives.")
|
||||
|
||||
@staticmethod
|
||||
def is_fillable(elem):
|
||||
"""Une forme dont on peut extraire un contour ferme exploitable."""
|
||||
return (isinstance(elem, inkex.ShapeElement)
|
||||
and not isinstance(elem, (inkex.Group, inkex.Layer,
|
||||
inkex.Line, inkex.Polyline)))
|
||||
|
||||
def pack_shape(self, elem, min_radius, max_radius, gap, margin, rng):
|
||||
"""Genere le groupe de cercles d'une forme. Renvoie leur nombre."""
|
||||
opt = self.options
|
||||
try:
|
||||
path = elem.path.transform(elem.composed_transform())
|
||||
except (AttributeError, TypeError):
|
||||
return 0
|
||||
|
||||
rings = flatten_path(path, opt.flatness)
|
||||
if not rings:
|
||||
return 0
|
||||
|
||||
circles = pack_circles(rings, min_radius, max_radius, gap, margin,
|
||||
opt.attempts, opt.max_circles, rng)
|
||||
if not circles:
|
||||
return 0
|
||||
|
||||
style = elem.specified_style()
|
||||
parent = elem.getparent()
|
||||
|
||||
group = inkex.Group()
|
||||
group.label = "Circle packing"
|
||||
if parent is not None:
|
||||
# Les cercles sont calcules 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)
|
||||
else:
|
||||
self.svg.add(group)
|
||||
|
||||
for x, y, r in circles:
|
||||
circle = inkex.Circle(cx=str(x), cy=str(y), r=str(r))
|
||||
circle.style = style
|
||||
group.add(circle)
|
||||
|
||||
if not opt.keep_original:
|
||||
elem.delete()
|
||||
|
||||
return len(circles)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
CirclePacking().run()
|
||||
108
deploy.ps1
Normal file
108
deploy.ps1
Normal file
@ -0,0 +1,108 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Déploie l'extension « Remplissage par cercles » dans Inkscape.
|
||||
|
||||
.DESCRIPTION
|
||||
Copie circle_packing.inx, circle_packing.py et packing_core.py 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 : circlePacking
|
||||
|
||||
.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 'mes-cercles'
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
|
||||
[string] $FolderName = 'circlePacking',
|
||||
[switch] $Uninstall,
|
||||
[switch] $Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$files = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py')
|
||||
$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
|
||||
}
|
||||
# Fichiers d'une installation « à plat » antérieure, à la racine des extensions
|
||||
foreach ($file in $files) {
|
||||
$legacy = Join-Path $ExtensionsDir $file
|
||||
if (Test-Path -LiteralPath $legacy) {
|
||||
Remove-Item -LiteralPath $legacy -Force
|
||||
Write-Host " supprimé (ancienne installation) $file"
|
||||
}
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Vérification des sources ------------------------------------------------
|
||||
$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) }
|
||||
if ($missing) {
|
||||
Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Nettoyage d'une installation « à plat » antérieure ----------------------
|
||||
foreach ($file in $files) {
|
||||
$legacy = Join-Path $ExtensionsDir $file
|
||||
if (Test-Path -LiteralPath $legacy) {
|
||||
Remove-Item -LiteralPath $legacy -Force
|
||||
Write-Host " nettoyé (ancienne installation à plat) $file" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# --- 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é ' }
|
||||
Copy-Item -LiteralPath $from -Destination $to -Force
|
||||
Write-Host " $action $file"
|
||||
}
|
||||
|
||||
# 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 > Rendu > Remplissage par cercles (circle packing)..."
|
||||
146
packing_core.py
Normal file
146
packing_core.py
Normal file
@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""
|
||||
Noyau geometrique du circle packing.
|
||||
|
||||
Ce module ne depend pas d'inkex : il peut etre importe et teste sans Inkscape.
|
||||
Un contour est represente par une liste d'anneaux (`rings`), chaque anneau
|
||||
etant une liste de points (x, y) decrivant un polygone ferme implicitement
|
||||
(le dernier point est relie au premier).
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
|
||||
def rings_bbox(rings):
|
||||
"""Boite englobante (xmin, ymin, xmax, ymax), ou None si vide."""
|
||||
xs = [p[0] for ring in rings for p in ring]
|
||||
ys = [p[1] for ring in rings for p in ring]
|
||||
if not xs:
|
||||
return None
|
||||
return min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def point_in_rings(x, y, rings):
|
||||
"""Test d'appartenance par crossing number, regle pair-impair.
|
||||
|
||||
La parite du nombre de traversees sur l'ensemble des anneaux donne
|
||||
directement la gestion des trous : un point situe dans un trou traverse un
|
||||
nombre pair de bords depuis l'infini.
|
||||
"""
|
||||
inside = False
|
||||
for ring in rings:
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % n]
|
||||
if (y1 > y) != (y2 > y):
|
||||
# abscisse de l'intersection du segment avec la droite Y = y
|
||||
xint = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
|
||||
if x < xint:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def distance_point_segment(px, py, x1, y1, x2, y2):
|
||||
"""Distance du point (px, py) au segment [(x1, y1), (x2, y2)]."""
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
seg_len2 = dx * dx + dy * dy
|
||||
if seg_len2 == 0.0:
|
||||
return math.hypot(px - x1, py - y1)
|
||||
t = ((px - x1) * dx + (py - y1) * dy) / seg_len2
|
||||
t = max(0.0, min(1.0, t))
|
||||
return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
|
||||
|
||||
|
||||
def distance_to_rings(x, y, rings):
|
||||
"""Distance minimale du point au contour (bord exterieur ou trous)."""
|
||||
best = float('inf')
|
||||
for ring in rings:
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % n]
|
||||
d = distance_point_segment(x, y, x1, y1, x2, y2)
|
||||
if d < best:
|
||||
best = d
|
||||
return best
|
||||
|
||||
|
||||
class SpatialGrid:
|
||||
"""Grille de hachage spatial pour retrouver rapidement les cercles voisins.
|
||||
|
||||
Sans elle, chaque tentative comparerait le point candidat a tous les cercles
|
||||
deja poses, ce qui rend les dizaines de milliers de tentatives inutilisables.
|
||||
"""
|
||||
|
||||
def __init__(self, cell_size):
|
||||
self.cell_size = max(cell_size, 1e-9)
|
||||
self.cells = {}
|
||||
self.circles = []
|
||||
|
||||
def _key(self, x, y):
|
||||
return (int(math.floor(x / self.cell_size)),
|
||||
int(math.floor(y / self.cell_size)))
|
||||
|
||||
def add(self, x, y, r):
|
||||
index = len(self.circles)
|
||||
self.circles.append((x, y, r))
|
||||
self.cells.setdefault(self._key(x, y), []).append(index)
|
||||
|
||||
def neighbors(self, x, y, reach):
|
||||
"""Cercles dont le centre tombe dans les cellules couvrant `reach`."""
|
||||
span = int(math.ceil(reach / self.cell_size))
|
||||
ci, cj = self._key(x, y)
|
||||
for i in range(ci - span, ci + span + 1):
|
||||
for j in range(cj - span, cj + span + 1):
|
||||
for index in self.cells.get((i, j), ()):
|
||||
yield self.circles[index]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.circles)
|
||||
|
||||
|
||||
def pack_circles(rings, min_radius, max_radius, gap=0.0, margin=0.0,
|
||||
attempts=20000, max_circles=0, rng=None):
|
||||
"""Greedy random circle packing. Renvoie la liste des (x, y, r) places.
|
||||
|
||||
A chaque tentative, un point est tire au hasard dans la boite englobante ;
|
||||
s'il est interieur, on y place le plus grand cercle qui respecte `margin`
|
||||
vis-a-vis du contour et `gap` vis-a-vis des cercles deja poses.
|
||||
"""
|
||||
rng = rng or random
|
||||
bbox = rings_bbox(rings)
|
||||
if bbox is None:
|
||||
return []
|
||||
xmin, ymin, xmax, ymax = bbox
|
||||
if xmax - xmin <= 0 or ymax - ymin <= 0:
|
||||
return []
|
||||
|
||||
grid = SpatialGrid(max_radius + gap)
|
||||
# Un voisin ne peut contraindre le rayon que si son centre est a moins de
|
||||
# son rayon (<= max_radius) + max_radius + gap.
|
||||
reach = 2.0 * max_radius + gap
|
||||
|
||||
for _ in range(attempts):
|
||||
if max_circles and len(grid) >= max_circles:
|
||||
break
|
||||
x = rng.uniform(xmin, xmax)
|
||||
y = rng.uniform(ymin, ymax)
|
||||
if not point_in_rings(x, y, rings):
|
||||
continue
|
||||
|
||||
radius = min(max_radius, distance_to_rings(x, y, rings) - margin)
|
||||
if radius < min_radius:
|
||||
continue
|
||||
|
||||
for cx, cy, cr in grid.neighbors(x, y, reach):
|
||||
radius = min(radius, math.hypot(x - cx, y - cy) - cr - gap)
|
||||
if radius < min_radius:
|
||||
break
|
||||
if radius >= min_radius:
|
||||
grid.add(x, y, radius)
|
||||
|
||||
return list(grid.circles)
|
||||
149
test_packing.py
Normal file
149
test_packing.py
Normal file
@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding=utf-8
|
||||
"""Tests du noyau geometrique, executables sans Inkscape : pytest test_packing.py"""
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from packing_core import (
|
||||
SpatialGrid,
|
||||
distance_point_segment,
|
||||
distance_to_rings,
|
||||
pack_circles,
|
||||
point_in_rings,
|
||||
rings_bbox,
|
||||
)
|
||||
|
||||
SQUARE = [[(0, 0), (100, 0), (100, 100), (0, 100)]]
|
||||
SQUARE_WITH_HOLE = SQUARE + [[(40, 40), (60, 40), (60, 60), (40, 60)]]
|
||||
|
||||
|
||||
# --- appartenance -----------------------------------------------------------
|
||||
|
||||
def test_point_inside_square():
|
||||
assert point_in_rings(50, 50, SQUARE)
|
||||
|
||||
|
||||
def test_point_outside_square():
|
||||
assert not point_in_rings(150, 50, SQUARE)
|
||||
assert not point_in_rings(-1, 50, SQUARE)
|
||||
assert not point_in_rings(50, 200, SQUARE)
|
||||
|
||||
|
||||
def test_hole_is_outside():
|
||||
assert not point_in_rings(50, 50, SQUARE_WITH_HOLE)
|
||||
assert point_in_rings(20, 20, SQUARE_WITH_HOLE)
|
||||
|
||||
|
||||
# --- distances --------------------------------------------------------------
|
||||
|
||||
def test_distance_point_segment_projection_clamped():
|
||||
# projection hors du segment : la distance est celle de l'extremite
|
||||
assert distance_point_segment(-3, 0, 0, 0, 10, 0) == pytest.approx(3)
|
||||
assert distance_point_segment(5, 4, 0, 0, 10, 0) == pytest.approx(4)
|
||||
|
||||
|
||||
def test_distance_to_rings_center_of_square():
|
||||
assert distance_to_rings(50, 50, SQUARE) == pytest.approx(50)
|
||||
assert distance_to_rings(5, 50, SQUARE) == pytest.approx(5)
|
||||
|
||||
|
||||
def test_distance_to_rings_accounts_for_hole():
|
||||
# au centre du carre troue, le bord le plus proche est celui du trou
|
||||
assert distance_to_rings(50, 50, SQUARE_WITH_HOLE) == pytest.approx(10)
|
||||
|
||||
|
||||
def test_rings_bbox():
|
||||
assert rings_bbox(SQUARE) == (0, 0, 100, 100)
|
||||
assert rings_bbox([]) is None
|
||||
|
||||
|
||||
# --- grille spatiale --------------------------------------------------------
|
||||
|
||||
def test_grid_returns_near_circle_only():
|
||||
grid = SpatialGrid(cell_size=10)
|
||||
grid.add(0, 0, 5)
|
||||
assert list(grid.neighbors(3, 3, reach=10)) == [(0, 0, 5)]
|
||||
assert list(grid.neighbors(500, 500, reach=10)) == []
|
||||
assert len(grid) == 1
|
||||
|
||||
|
||||
# --- packing ----------------------------------------------------------------
|
||||
|
||||
def _invariants(circles, rings, min_radius, max_radius, gap, margin):
|
||||
eps = 1e-9
|
||||
for x, y, r in circles:
|
||||
assert min_radius - eps <= r <= max_radius + eps
|
||||
assert point_in_rings(x, y, rings)
|
||||
assert distance_to_rings(x, y, rings) >= r + margin - eps
|
||||
for i, (x1, y1, r1) in enumerate(circles):
|
||||
for x2, y2, r2 in circles[i + 1:]:
|
||||
assert math.hypot(x1 - x2, y1 - y2) >= r1 + r2 + gap - eps
|
||||
|
||||
|
||||
def test_pack_circles_respects_invariants():
|
||||
circles = pack_circles(SQUARE, min_radius=2, max_radius=15, gap=1,
|
||||
margin=1, attempts=3000, rng=random.Random(42))
|
||||
assert len(circles) > 20
|
||||
_invariants(circles, SQUARE, 2, 15, 1, 1)
|
||||
|
||||
|
||||
def test_pack_circles_avoids_hole():
|
||||
circles = pack_circles(SQUARE_WITH_HOLE, min_radius=2, max_radius=15,
|
||||
gap=0.5, margin=0.5, attempts=3000,
|
||||
rng=random.Random(7))
|
||||
assert circles
|
||||
_invariants(circles, SQUARE_WITH_HOLE, 2, 15, 0.5, 0.5)
|
||||
for x, y, _ in circles:
|
||||
assert not (40 < x < 60 and 40 < y < 60)
|
||||
|
||||
|
||||
def test_same_seed_gives_same_result():
|
||||
args = dict(min_radius=2, max_radius=12, gap=0.5, margin=0.5, attempts=800)
|
||||
first = pack_circles(SQUARE, rng=random.Random(123), **args)
|
||||
second = pack_circles(SQUARE, rng=random.Random(123), **args)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_max_circles_caps_output():
|
||||
circles = pack_circles(SQUARE, min_radius=1, max_radius=10, gap=0.2,
|
||||
margin=0.2, attempts=5000, max_circles=10,
|
||||
rng=random.Random(1))
|
||||
assert len(circles) == 10
|
||||
|
||||
|
||||
def test_larger_gap_places_fewer_circles():
|
||||
args = dict(min_radius=2, max_radius=10, margin=0.5, attempts=2000)
|
||||
tight = pack_circles(SQUARE, gap=0.0, rng=random.Random(5), **args)
|
||||
loose = pack_circles(SQUARE, gap=6.0, rng=random.Random(5), **args)
|
||||
assert len(loose) < len(tight)
|
||||
|
||||
|
||||
def test_empty_rings():
|
||||
assert pack_circles([], min_radius=1, max_radius=5) == []
|
||||
|
||||
|
||||
# --- bout en bout dans Inkscape (saute si inkex absent) ---------------------
|
||||
|
||||
def test_extension_runs_on_sample_svg(tmp_path):
|
||||
pytest.importorskip("inkex")
|
||||
from circle_packing import CirclePacking
|
||||
|
||||
svg = tmp_path / "in.svg"
|
||||
svg.write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="100mm" '
|
||||
'viewBox="0 0 100 100">'
|
||||
'<rect id="r" x="10" y="10" width="80" height="80" '
|
||||
'style="fill:#3366cc;stroke:none"/></svg>',
|
||||
encoding="utf-8")
|
||||
|
||||
ext = CirclePacking()
|
||||
ext.run(["--id=r", "--unit=px", "--min_radius=2", "--max_radius=10",
|
||||
"--attempts=2000", "--seed=1", str(svg)],
|
||||
output=str(tmp_path / "out.svg"))
|
||||
|
||||
out = (tmp_path / "out.svg").read_text(encoding="utf-8")
|
||||
assert out.count("<circle") > 10
|
||||
assert "#3366cc" in out
|
||||
11
tests/data/shapes.svg
Normal file
11
tests/data/shapes.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="100mm" viewBox="0 0 210 100">
|
||||
<rect id="rect" x="5" y="20" width="45" height="60" style="fill:#3366cc;stroke:none"/>
|
||||
<circle id="cercle" cx="82" cy="50" r="28" style="fill:#33aa66;stroke:none"/>
|
||||
<path id="courbe" d="M 115,80 C 115,20 160,10 175,45 C 185,70 150,90 115,80 Z"
|
||||
style="fill:#cc7733;stroke:none"/>
|
||||
<g transform="translate(0,0)">
|
||||
<path id="trou" d="M 185,15 L 205,15 L 205,85 L 185,85 Z M 191,35 L 199,35 L 199,65 L 191,65 Z"
|
||||
style="fill:#cc3366;fill-rule:evenodd;stroke:none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 634 B |
Loading…
Reference in New Issue
Block a user