Version initiale
This commit is contained in:
commit
046c9839a7
143
README.md
Normal file
143
README.md
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# Boîte à créneaux pour Inkscape
|
||||||
|
|
||||||
|
Extension Inkscape 1.x générant des pièces de boîte à découper au laser. Les parois s'assemblent par queues d'aronde droites (finger joints) à bords complémentaires, garantissant des ajustements serrés et réguliers.
|
||||||
|
|
||||||
|
## Caractéristiques
|
||||||
|
|
||||||
|
- **Boîte fermée ou ouverte** : 6 pièces (avant, arrière, gauche, droite, fond, couvercle) ou 5 (sans couvercle)
|
||||||
|
- **Dimensions intérieures ou extérieures** : le calcul compense automatiquement l'épaisseur
|
||||||
|
- **Créneaux paramétrables** : largeur 0 (défaut = 3 × épaisseur) ou valeur explicite, ajustée à un nombre impair par bord
|
||||||
|
- **Compensation du kerf** : défaut 0,1 mm, élargit les créneaux, rétrécit les encoches pour un assemblage serré
|
||||||
|
- **Mise en page** : espacement entre pièces configurable, centrage automatique sur la vue
|
||||||
|
- **Apparence** : couleur et épaisseur du trait réglables
|
||||||
|
- **Étiquettes optionnelles** : nom de chaque pièce en texte bleu, gravé avec les créneaux
|
||||||
|
|
||||||
|
## Structure des pièces
|
||||||
|
|
||||||
|
Chaque pièce est un chemin SVG indépendant, placé dans le calque courant :
|
||||||
|
- Sélectionnable et déplaçable individuellement
|
||||||
|
- Nommée selon sa fonction (Avant, Arrière, etc.)
|
||||||
|
- Polygone fermé à bords orthogonaux
|
||||||
|
|
||||||
|
### Convention d'emboîtement
|
||||||
|
|
||||||
|
Garantit des bords complémentaires jumeaux :
|
||||||
|
|
||||||
|
| Pièce | Haut | Droite | Bas | Gauche |
|
||||||
|
|-------|------|--------|-----|--------|
|
||||||
|
| Avant | Mâle | Mâle | Mâle | Mâle |
|
||||||
|
| Arrière | Mâle | Mâle | Mâle | Mâle |
|
||||||
|
| Gauche | Mâle\* | Femelle | Mâle | Femelle |
|
||||||
|
| Droite | Mâle\* | Femelle | Mâle | Femelle |
|
||||||
|
| Fond | Femelle | Femelle | Femelle | Femelle |
|
||||||
|
| Couvercle | Femelle | Femelle | Femelle | Femelle |
|
||||||
|
|
||||||
|
\* Haut mâle si couvercle présent, bord lisse si boîte ouverte.
|
||||||
|
|
||||||
|
- **Bord mâle** : créneaux extrêmes en saillie
|
||||||
|
- **Bord femelle** : créneaux extrêmes en creux
|
||||||
|
- **Coin plein** : si aucun bord adjacent n'est femelle
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Via script PowerShell (recommandé)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Fermer Inkscape au préalable
|
||||||
|
.\deploy.ps1
|
||||||
|
# ou avec options :
|
||||||
|
.\deploy.ps1 -Uninstall
|
||||||
|
.\deploy.ps1 -Force # Ignore la vérification Inkscape ouvert
|
||||||
|
.\deploy.ps1 -FolderName 'ma-boite' # Dossier personnalisé
|
||||||
|
```
|
||||||
|
|
||||||
|
Copie `box_maker.inx`, `box_maker.py`, `box_core.py` dans `%APPDATA%\inkscape\extensions\boxMaker/`.
|
||||||
|
|
||||||
|
### Installation manuelle
|
||||||
|
|
||||||
|
Copier les trois fichiers dans `%APPDATA%\inkscape\extensions\boxMaker/` (créer le dossier s'il n'existe pas). Inkscape explore récursivement ce répertoire.
|
||||||
|
|
||||||
|
## Utilisation dans Inkscape
|
||||||
|
|
||||||
|
1. Démarrer Inkscape
|
||||||
|
2. Menu **Extensions > Découpe laser > Boîte à créneaux…**
|
||||||
|
3. Configurer les paramètres (voir tableau ci-dessous)
|
||||||
|
4. Cliquer **Appliquer**
|
||||||
|
|
||||||
|
### Paramètres
|
||||||
|
|
||||||
|
| Paramètre | Défaut | Plage | Description |
|
||||||
|
|-----------|--------|-------|-------------|
|
||||||
|
| **Longueur (mm)** | 100 | 5–5000 | Dimension avant/arrière |
|
||||||
|
| **Largeur (mm)** | 80 | 5–5000 | Dimension gauche/droite |
|
||||||
|
| **Hauteur (mm)** | 50 | 5–5000 | Dimension haut/bas |
|
||||||
|
| **Les dimensions sont** | Extérieures | Extérieures / Intérieures | Mode calcul |
|
||||||
|
| **Boîte fermée** | ✓ | ✓ / ☐ | Avec couvercle |
|
||||||
|
| **Épaisseur matière (mm)** | 3 | 0,5–50 | MDF, plexiglas, etc. |
|
||||||
|
| **Largeur créneaux (mm)** | 0 | 0–500 | 0 = auto (3 × épaisseur) |
|
||||||
|
| **Kerf (mm)** | 0,1 | 0–2 | Compensation laser |
|
||||||
|
| **Espacement (mm)** | 5 | 0–100 | Entre les pièces |
|
||||||
|
| **Épaisseur trait (mm)** | 0,1 | 0,001–5 | Largeur du vecteur |
|
||||||
|
| **Couleur trait** | #FF0000 (rouge) | Couleur RGB | Pour la découpe laser |
|
||||||
|
| **Nommer les pièces** | ☐ | ✓ / ☐ | Texte gravé (bleu) |
|
||||||
|
|
||||||
|
**Notes sur les paramètres :**
|
||||||
|
- Largeur créneaux 0 calcule automatiquement la meilleure approximation pour un nombre impair de créneaux par bord
|
||||||
|
- Kerf = 0,1 mm typique pour lasers CO₂ ; ajuster selon le matériau et la machine
|
||||||
|
- Les dimensions trop petites relatif à l'épaisseur lèvent une erreur (minimum 5 × épaisseur recommandé)
|
||||||
|
- Étiquettes : texte gravé en bleu, pas de contour, à vérifier avant découpe
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Validations :
|
||||||
|
- Nombre impair de créneaux par bord
|
||||||
|
- Complémentarité des bords mâles/femelles
|
||||||
|
- Dimensions et orthogonalité des pièces
|
||||||
|
- Compensation du kerf
|
||||||
|
- Absence de chevauchements en mise en page
|
||||||
|
|
||||||
|
## Utilisation CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Environnement Python d'Inkscape
|
||||||
|
$env:PYTHONPATH = "C:\Program Files\Inkscape\share\inkscape\extensions"
|
||||||
|
|
||||||
|
# Génération SVG
|
||||||
|
& "C:\Program Files\Inkscape\bin\python.exe" box_maker.py `
|
||||||
|
--length=100 `
|
||||||
|
--width=80 `
|
||||||
|
--height=50 `
|
||||||
|
--thickness=3 `
|
||||||
|
--finger=9 `
|
||||||
|
--kerf=0.1 `
|
||||||
|
--dim_type=exterior `
|
||||||
|
--lid `
|
||||||
|
--gap=5 `
|
||||||
|
--stroke_color=0xff0000ff `
|
||||||
|
--stroke_width=0.1 `
|
||||||
|
--labels `
|
||||||
|
entree.svg > sortie.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
Le module `box_core` est indépendant d'Inkscape (Python pur, longueurs en mm).
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---------|------|
|
||||||
|
| `box_maker.inx` | Définition de l'interface Inkscape |
|
||||||
|
| `box_maker.py` | Extension Inkscape, intégration SVG |
|
||||||
|
| `box_core.py` | Géométrie pur Python, indépendant d'Inkscape |
|
||||||
|
| `deploy.ps1` | Script d'installation PowerShell |
|
||||||
|
| `tests/test_box_core.py` | Tests unitaires géométrie |
|
||||||
|
|
||||||
|
## Repère de coordonnées
|
||||||
|
|
||||||
|
- Origine (0, 0) : coin haut-gauche de la pièce
|
||||||
|
- **x** : vers la droite
|
||||||
|
- **y** : vers le bas (SVG)
|
||||||
|
- Tous les bords sont orthogonaux (horizontaux/verticaux uniquement)
|
||||||
BIN
__pycache__/box_core.cpython-312.pyc
Normal file
BIN
__pycache__/box_core.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/box_core.cpython-313.pyc
Normal file
BIN
__pycache__/box_core.cpython-313.pyc
Normal file
Binary file not shown.
196
box_core.py
Normal file
196
box_core.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
"""Géométrie d'une boîte à créneaux (queues d'aronde droites) pour découpe laser.
|
||||||
|
|
||||||
|
Module pur Python, indépendant d'Inkscape : toutes les longueurs sont en mm.
|
||||||
|
Repère SVG : x vers la droite, y vers le bas.
|
||||||
|
|
||||||
|
Convention d'emboîtement (garantit des bords jumeaux complémentaires) :
|
||||||
|
- avant / arrière : 4 bords mâles ;
|
||||||
|
- gauche / droite : bords verticaux femelles, haut et bas mâles ;
|
||||||
|
- fond / couvercle : 4 bords femelles ;
|
||||||
|
- boîte ouverte : bord haut des côtés sans créneaux, pas de couvercle.
|
||||||
|
Un bord mâle a ses créneaux extrêmes en saillie, un bord femelle en creux.
|
||||||
|
Un coin (carré t × t) est plein si aucun des deux bords adjacents n'est femelle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
MALE, FEMALE, PLAIN = "male", "female", "plain"
|
||||||
|
|
||||||
|
_EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Panel:
|
||||||
|
name: str
|
||||||
|
points: list # [(x, y), ...] polygone fermé, dernier point ≠ premier
|
||||||
|
|
||||||
|
def bbox(self):
|
||||||
|
xs = [p[0] for p in self.points]
|
||||||
|
ys = [p[1] for p in self.points]
|
||||||
|
return min(xs), min(ys), max(xs), max(ys)
|
||||||
|
|
||||||
|
def translated(self, dx, dy):
|
||||||
|
return Panel(self.name, [(x + dx, y + dy) for x, y in self.points])
|
||||||
|
|
||||||
|
|
||||||
|
def finger_count(middle, finger):
|
||||||
|
"""Nombre impair (≥ 3) de créneaux sur une longueur utile `middle`."""
|
||||||
|
n = max(1, round(middle / finger))
|
||||||
|
if n % 2 == 0:
|
||||||
|
below, above = n - 1, n + 1
|
||||||
|
n = below if abs(middle / below - finger) <= abs(middle / above - finger) else above
|
||||||
|
return max(n, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def edge_segments(length, t, finger, kind):
|
||||||
|
"""Segments (début, fin, niveau) de la partie utile d'un bord, coins exclus.
|
||||||
|
|
||||||
|
Le niveau vaut 0 (sur le contour extérieur) ou t (en retrait vers l'intérieur).
|
||||||
|
"""
|
||||||
|
middle = length - 2 * t
|
||||||
|
if kind == PLAIN:
|
||||||
|
return [(t, length - t, 0.0)]
|
||||||
|
n = finger_count(middle, finger)
|
||||||
|
w = middle / n
|
||||||
|
first_out = kind == MALE
|
||||||
|
segs = []
|
||||||
|
for i in range(n):
|
||||||
|
out = (i % 2 == 0) == first_out
|
||||||
|
segs.append((t + i * w, t + (i + 1) * w, 0.0 if out else t))
|
||||||
|
return segs
|
||||||
|
|
||||||
|
|
||||||
|
def _to_xy(k, pos, level, w, h):
|
||||||
|
"""Coordonnées d'un point (position le long du bord k, retrait) ; parcours horaire."""
|
||||||
|
if k == 0: # haut, de gauche à droite
|
||||||
|
return (pos, level)
|
||||||
|
if k == 1: # droite, de haut en bas
|
||||||
|
return (w - level, pos)
|
||||||
|
if k == 2: # bas, de droite à gauche
|
||||||
|
return (w - pos, h - level)
|
||||||
|
return (level, h - pos) # gauche, de bas en haut
|
||||||
|
|
||||||
|
|
||||||
|
def _simplify(points):
|
||||||
|
"""Supprime les doublons consécutifs et les sommets alignés."""
|
||||||
|
pts = []
|
||||||
|
for p in points:
|
||||||
|
if not pts or abs(p[0] - pts[-1][0]) > _EPS or abs(p[1] - pts[-1][1]) > _EPS:
|
||||||
|
pts.append(p)
|
||||||
|
if len(pts) > 1 and abs(pts[0][0] - pts[-1][0]) < _EPS and abs(pts[0][1] - pts[-1][1]) < _EPS:
|
||||||
|
pts.pop()
|
||||||
|
changed = True
|
||||||
|
while changed and len(pts) > 3:
|
||||||
|
changed = False
|
||||||
|
for i in range(len(pts)):
|
||||||
|
a, b, c = pts[i - 1], pts[i], pts[(i + 1) % len(pts)]
|
||||||
|
cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])
|
||||||
|
if abs(cross) < _EPS:
|
||||||
|
del pts[i]
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def panel_outline(w, h, t, finger, edges):
|
||||||
|
"""Contour d'une pièce w × h ; edges = types des bords (haut, droite, bas, gauche)."""
|
||||||
|
lengths = (w, h, w, h)
|
||||||
|
points = []
|
||||||
|
for k in range(4):
|
||||||
|
full = FEMALE not in (edges[k - 1], edges[k])
|
||||||
|
if full:
|
||||||
|
points.append(_to_xy(k, 0.0, 0.0, w, h))
|
||||||
|
else:
|
||||||
|
points.append(_to_xy(k, t, t, w, h))
|
||||||
|
for start, end, level in edge_segments(lengths[k], t, finger, edges[k]):
|
||||||
|
points.append(_to_xy(k, start, level, w, h))
|
||||||
|
points.append(_to_xy(k, end, level, w, h))
|
||||||
|
return _simplify(points)
|
||||||
|
|
||||||
|
|
||||||
|
def box_panels(length, width, height, thickness, finger=0.0, interior=False, lid=True):
|
||||||
|
"""Pièces de la boîte, non placées (chacune avec son coin haut-gauche en 0, 0)."""
|
||||||
|
t = thickness
|
||||||
|
if t <= 0:
|
||||||
|
raise ValueError("L'épaisseur doit être strictement positive.")
|
||||||
|
finger = finger if finger > 0 else 3 * t
|
||||||
|
if interior:
|
||||||
|
length += 2 * t
|
||||||
|
width += 2 * t
|
||||||
|
height += 2 * t if lid else t
|
||||||
|
|
||||||
|
for label, size in (("longueur", length), ("largeur", width), ("hauteur", height)):
|
||||||
|
middle = size - 2 * t
|
||||||
|
if middle <= 0 or middle / finger_count(middle, finger) < t:
|
||||||
|
raise ValueError(
|
||||||
|
f"La {label} ({size:.1f} mm extérieure) est trop petite pour "
|
||||||
|
f"l'épaisseur ({t:g} mm) : il faut au moins {5 * t:g} mm."
|
||||||
|
)
|
||||||
|
|
||||||
|
top = MALE if lid else PLAIN
|
||||||
|
front = (top, MALE, MALE, MALE)
|
||||||
|
side = (top, FEMALE, MALE, FEMALE)
|
||||||
|
flat = (FEMALE, FEMALE, FEMALE, FEMALE)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
Panel("Avant", panel_outline(length, height, t, finger, front)),
|
||||||
|
Panel("Arrière", panel_outline(length, height, t, finger, front)),
|
||||||
|
Panel("Gauche", panel_outline(width, height, t, finger, side)),
|
||||||
|
Panel("Droite", panel_outline(width, height, t, finger, side)),
|
||||||
|
Panel("Fond", panel_outline(length, width, t, finger, flat)),
|
||||||
|
]
|
||||||
|
if lid:
|
||||||
|
panels.append(Panel("Couvercle", panel_outline(length, width, t, finger, flat)))
|
||||||
|
return panels
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_area(points):
|
||||||
|
return sum(
|
||||||
|
points[i - 1][0] * points[i][1] - points[i][0] * points[i - 1][1]
|
||||||
|
for i in range(len(points))
|
||||||
|
) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def offset_orthogonal(points, d):
|
||||||
|
"""Décale vers l'extérieur de d un polygone à côtés horizontaux/verticaux."""
|
||||||
|
if abs(d) < _EPS:
|
||||||
|
return list(points)
|
||||||
|
sign = 1 if _signed_area(points) > 0 else -1
|
||||||
|
|
||||||
|
def normal(a, b):
|
||||||
|
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||||
|
length = abs(dx) + abs(dy) # côté axial : norme = somme des valeurs absolues
|
||||||
|
return (sign * dy / length, -sign * dx / length)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
n = len(points)
|
||||||
|
for i in range(n):
|
||||||
|
prev, cur, nxt = points[i - 1], points[i], points[(i + 1) % n]
|
||||||
|
n1, n2 = normal(prev, cur), normal(cur, nxt)
|
||||||
|
result.append((cur[0] + d * (n1[0] + n2[0]), cur[1] + d * (n1[1] + n2[1])))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def layout(panels, gap, columns=2):
|
||||||
|
"""Place les pièces en grille, coin haut-gauche de l'ensemble en 0, 0."""
|
||||||
|
placed = []
|
||||||
|
y = 0.0
|
||||||
|
for i in range(0, len(panels), columns):
|
||||||
|
x = 0.0
|
||||||
|
row_height = 0.0
|
||||||
|
for p in panels[i:i + columns]:
|
||||||
|
min_x, min_y, max_x, max_y = p.bbox()
|
||||||
|
placed.append(p.translated(x - min_x, y - min_y))
|
||||||
|
x += max_x - min_x + gap
|
||||||
|
row_height = max(row_height, max_y - min_y)
|
||||||
|
y += row_height + gap
|
||||||
|
return placed
|
||||||
|
|
||||||
|
|
||||||
|
def make_box(length, width, height, thickness, finger=0.0, kerf=0.0,
|
||||||
|
interior=False, lid=True, gap=5.0):
|
||||||
|
"""Pièces prêtes à découper : créneaux, compensation du kerf et placement."""
|
||||||
|
panels = box_panels(length, width, height, thickness, finger, interior, lid)
|
||||||
|
if kerf:
|
||||||
|
panels = [Panel(p.name, offset_orthogonal(p.points, kerf / 2)) for p in panels]
|
||||||
|
return layout(panels, gap)
|
||||||
45
box_maker.inx
Normal file
45
box_maker.inx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||||
|
<name>Boîte à créneaux</name>
|
||||||
|
<id>fr.alex.box_maker</id>
|
||||||
|
|
||||||
|
<param name="tab" type="notebook">
|
||||||
|
<page name="dimensions" gui-text="Dimensions">
|
||||||
|
<param name="length" type="float" min="5" max="5000" precision="1" gui-text="Longueur (mm) :">100</param>
|
||||||
|
<param name="width" type="float" min="5" max="5000" precision="1" gui-text="Largeur (mm) :">80</param>
|
||||||
|
<param name="height" type="float" min="5" max="5000" precision="1" gui-text="Hauteur (mm) :">50</param>
|
||||||
|
<param name="dim_type" type="optiongroup" appearance="combo" gui-text="Les dimensions sont :">
|
||||||
|
<option value="exterior">Extérieures</option>
|
||||||
|
<option value="interior">Intérieures</option>
|
||||||
|
</param>
|
||||||
|
<param name="lid" type="bool" gui-text="Boîte fermée (avec couvercle)">true</param>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page name="material" gui-text="Matière et créneaux">
|
||||||
|
<param name="thickness" type="float" min="0.5" max="50" precision="2" gui-text="Épaisseur de la matière (mm) :">3</param>
|
||||||
|
<param name="finger" type="float" min="0" max="500" precision="2" gui-text="Largeur des créneaux (mm, 0 = 3 × épaisseur) :">0</param>
|
||||||
|
<param name="kerf" type="float" min="0" max="2" precision="3" gui-text="Compensation du trait laser, kerf (mm) :">0.1</param>
|
||||||
|
<label appearance="header">Remarques</label>
|
||||||
|
<label>La largeur des créneaux est ajustée pour obtenir un nombre impair de créneaux sur chaque bord.</label>
|
||||||
|
<label>Le kerf élargit les créneaux et rétrécit les encoches pour un assemblage serré.</label>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page name="drawing" gui-text="Tracé">
|
||||||
|
<param name="gap" type="float" min="0" max="100" precision="1" gui-text="Espacement entre les pièces (mm) :">5</param>
|
||||||
|
<param name="stroke_width" type="float" min="0.001" max="5" precision="3" gui-text="Épaisseur du trait (mm) :">0.1</param>
|
||||||
|
<param name="stroke_color" type="color" gui-text="Couleur du trait :">0xff0000ff</param>
|
||||||
|
<param name="labels" type="bool" gui-text="Nommer les pièces (texte gravé !)">false</param>
|
||||||
|
</page>
|
||||||
|
</param>
|
||||||
|
|
||||||
|
<effect>
|
||||||
|
<object-type>all</object-type>
|
||||||
|
<effects-menu>
|
||||||
|
<submenu name="AlexDesign"/>
|
||||||
|
</effects-menu>
|
||||||
|
</effect>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
<command location="inx" interpreter="python">box_maker.py</command>
|
||||||
|
</script>
|
||||||
|
</inkscape-extension>
|
||||||
118
box_maker.py
Normal file
118
box_maker.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extension Inkscape : génère une boîte à créneaux à découper au laser.
|
||||||
|
|
||||||
|
Chaque pièce est un objet indépendant, placé directement dans le calque courant,
|
||||||
|
pour pouvoir être sélectionnée et déplacée seule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inkex
|
||||||
|
from inkex import Group, PathElement, TextElement
|
||||||
|
|
||||||
|
import box_core
|
||||||
|
|
||||||
|
|
||||||
|
class BoxMaker(inkex.EffectExtension):
|
||||||
|
def add_arguments(self, pars):
|
||||||
|
pars.add_argument("--tab")
|
||||||
|
pars.add_argument("--length", type=float, default=100.0)
|
||||||
|
pars.add_argument("--width", type=float, default=80.0)
|
||||||
|
pars.add_argument("--height", type=float, default=50.0)
|
||||||
|
pars.add_argument("--dim_type", default="exterior")
|
||||||
|
pars.add_argument("--thickness", type=float, default=3.0)
|
||||||
|
pars.add_argument("--finger", type=float, default=0.0)
|
||||||
|
pars.add_argument("--kerf", type=float, default=0.1)
|
||||||
|
pars.add_argument("--lid", type=inkex.Boolean, default=True)
|
||||||
|
pars.add_argument("--gap", type=float, default=5.0)
|
||||||
|
pars.add_argument("--stroke_color", type=inkex.Color, default=inkex.Color("red"))
|
||||||
|
pars.add_argument("--stroke_width", type=float, default=0.1)
|
||||||
|
pars.add_argument("--labels", type=inkex.Boolean, default=False)
|
||||||
|
|
||||||
|
def effect(self):
|
||||||
|
opt = self.options
|
||||||
|
try:
|
||||||
|
panels = box_core.make_box(
|
||||||
|
opt.length, opt.width, opt.height, opt.thickness,
|
||||||
|
finger=opt.finger, kerf=opt.kerf,
|
||||||
|
interior=opt.dim_type == "interior", lid=opt.lid, gap=opt.gap,
|
||||||
|
)
|
||||||
|
except ValueError as err:
|
||||||
|
raise inkex.AbortExtension(str(err))
|
||||||
|
|
||||||
|
mm = self._uu_per_mm()
|
||||||
|
origin_x, origin_y = self._origin(panels, mm)
|
||||||
|
layer = self.svg.get_current_layer()
|
||||||
|
# Les coordonnées sont calculées dans le repère du document : on annule
|
||||||
|
# la transformation éventuelle du calque (et de ses parents).
|
||||||
|
to_layer = -layer.composed_transform()
|
||||||
|
style = {
|
||||||
|
"fill": "none",
|
||||||
|
"stroke": str(inkex.Color(opt.stroke_color).to_rgb()),
|
||||||
|
"stroke-width": opt.stroke_width * mm,
|
||||||
|
}
|
||||||
|
|
||||||
|
for panel in panels:
|
||||||
|
path = PathElement()
|
||||||
|
path.set_id(self.svg.get_unique_id("boite-"))
|
||||||
|
path.label = panel.name
|
||||||
|
path.style = style
|
||||||
|
path.path = "M " + " L ".join(
|
||||||
|
f"{origin_x + x * mm:.4f},{origin_y + y * mm:.4f}" for x, y in panel.points
|
||||||
|
) + " Z"
|
||||||
|
|
||||||
|
if opt.labels:
|
||||||
|
group = Group()
|
||||||
|
group.set_id(self.svg.get_unique_id("boite-"))
|
||||||
|
group.label = panel.name
|
||||||
|
group.transform = to_layer
|
||||||
|
group.add(path)
|
||||||
|
group.add(self._label(panel, origin_x, origin_y, mm))
|
||||||
|
layer.add(group)
|
||||||
|
else:
|
||||||
|
path.transform = to_layer
|
||||||
|
layer.add(path)
|
||||||
|
|
||||||
|
def _uu_per_mm(self):
|
||||||
|
"""Unités utilisateur par mm, comme Inkscape les affiche.
|
||||||
|
|
||||||
|
svg.unittouu() se trompe quand le viewBox n'a pas les proportions de la
|
||||||
|
page : Inkscape applique alors une échelle uniforme (preserveAspectRatio
|
||||||
|
« meet »), soit la plus petite des deux échelles page / viewBox.
|
||||||
|
"""
|
||||||
|
svg = self.svg
|
||||||
|
vb_w, vb_h = svg.viewbox_width, svg.viewbox_height
|
||||||
|
page_w = svg.to_dimensional(svg.viewport_width, "mm")
|
||||||
|
page_h = svg.to_dimensional(svg.viewport_height, "mm")
|
||||||
|
if not (vb_w and vb_h and page_w and page_h):
|
||||||
|
return svg.unittouu("1mm")
|
||||||
|
return 1 / min(page_w / vb_w, page_h / vb_h)
|
||||||
|
|
||||||
|
def _origin(self, panels, mm):
|
||||||
|
"""Centre l'ensemble des pièces sur la vue (ou la page à défaut)."""
|
||||||
|
max_x = max(p.bbox()[2] for p in panels) * mm
|
||||||
|
max_y = max(p.bbox()[3] for p in panels) * mm
|
||||||
|
namedview = self.svg.namedview
|
||||||
|
if namedview is not None and namedview.get("inkscape:cx") is not None:
|
||||||
|
cx, cy = namedview.center
|
||||||
|
else:
|
||||||
|
cx, cy = self.svg.get_page_bbox().center
|
||||||
|
return cx - max_x / 2, cy - max_y / 2
|
||||||
|
|
||||||
|
def _label(self, panel, origin_x, origin_y, mm):
|
||||||
|
min_x, min_y, max_x, max_y = panel.bbox()
|
||||||
|
size = min(5.0, (max_y - min_y) / 4) * mm
|
||||||
|
text = TextElement()
|
||||||
|
text.set_id(self.svg.get_unique_id("boite-"))
|
||||||
|
text.set("x", str(origin_x + (min_x + max_x) / 2 * mm))
|
||||||
|
text.set("y", str(origin_y + (min_y + max_y) / 2 * mm + size / 3))
|
||||||
|
text.style = {
|
||||||
|
"font-size": size,
|
||||||
|
"text-anchor": "middle",
|
||||||
|
"fill": "#0000ff",
|
||||||
|
"stroke": "none",
|
||||||
|
}
|
||||||
|
text.text = panel.name
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
BoxMaker().run()
|
||||||
108
deploy.ps1
Normal file
108
deploy.ps1
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Déploie l'extension « Boîte à créneaux » dans Inkscape.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Copie box_maker.inx, box_maker.py et box_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 : boxMaker
|
||||||
|
|
||||||
|
.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 'ma-boite'
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
|
||||||
|
[string] $FolderName = 'boxMaker',
|
||||||
|
[switch] $Uninstall,
|
||||||
|
[switch] $Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$files = @('box_maker.inx', 'box_maker.py', 'box_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 > Découpe laser > Boîte à créneaux..."
|
||||||
BIN
tests/__pycache__/test_box_core.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_box_core.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
103
tests/test_box_core.py
Normal file
103
tests/test_box_core.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
import box_core # noqa: E402
|
||||||
|
from box_core import FEMALE, MALE, PLAIN # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _is_orthogonal(points):
|
||||||
|
n = len(points)
|
||||||
|
for i in range(n):
|
||||||
|
a, b = points[i], points[(i + 1) % n]
|
||||||
|
if abs(a[0] - b[0]) > 1e-9 and abs(a[1] - b[1]) > 1e-9:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _size(panel):
|
||||||
|
x0, y0, x1, y1 = panel.bbox()
|
||||||
|
return round(x1 - x0, 6), round(y1 - y0, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_finger_is_three_times_thickness():
|
||||||
|
segs = box_core.edge_segments(100 + 6, 3, 9, MALE)
|
||||||
|
widths = {round(e - s, 6) for s, e, _ in segs}
|
||||||
|
assert len(widths) == 1
|
||||||
|
assert len(segs) % 2 == 1
|
||||||
|
# 0 = auto : même résultat qu'une largeur explicite de 3 × t
|
||||||
|
auto = box_core.box_panels(100, 80, 50, 3, finger=0)
|
||||||
|
explicit = box_core.box_panels(100, 80, 50, 3, finger=9)
|
||||||
|
assert [p.points for p in auto] == [p.points for p in explicit]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("middle,finger", [(94, 9), (50, 9), (10, 3), (200, 7)])
|
||||||
|
def test_finger_count_is_odd(middle, finger):
|
||||||
|
n = box_core.finger_count(middle, finger)
|
||||||
|
assert n % 2 == 1 and n >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_male_and_female_edges_are_complementary():
|
||||||
|
male = box_core.edge_segments(106, 3, 9, MALE)
|
||||||
|
female = box_core.edge_segments(106, 3, 9, FEMALE)
|
||||||
|
assert len(male) == len(female)
|
||||||
|
for (s1, e1, l1), (s2, e2, l2) in zip(male, female):
|
||||||
|
assert (s1, e1) == (s2, e2)
|
||||||
|
assert {l1, l2} == {0.0, 3.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_box_has_six_orthogonal_panels_with_exterior_size():
|
||||||
|
panels = box_core.box_panels(100, 80, 50, 3)
|
||||||
|
assert [p.name for p in panels] == ["Avant", "Arrière", "Gauche", "Droite", "Fond", "Couvercle"]
|
||||||
|
sizes = {p.name: _size(p) for p in panels}
|
||||||
|
assert sizes["Avant"] == (100, 50)
|
||||||
|
# Côtés et fond : coins en retrait, mais les créneaux atteignent le bord extérieur
|
||||||
|
assert sizes["Gauche"] == (80, 50)
|
||||||
|
assert sizes["Fond"] == (100, 80)
|
||||||
|
for p in panels:
|
||||||
|
assert _is_orthogonal(p.points)
|
||||||
|
|
||||||
|
|
||||||
|
def test_interior_dimensions_add_thickness():
|
||||||
|
closed = box_core.box_panels(100, 80, 50, 3, interior=True, lid=True)
|
||||||
|
assert _size(closed[0]) == (106, 56)
|
||||||
|
opened = box_core.box_panels(100, 80, 50, 3, interior=True, lid=False)
|
||||||
|
assert _size(opened[0]) == (106, 53)
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_box_has_five_panels_and_straight_top_edge():
|
||||||
|
panels = box_core.box_panels(100, 80, 50, 3, lid=False)
|
||||||
|
assert len(panels) == 5
|
||||||
|
front = panels[0]
|
||||||
|
top_points = [p for p in front.points if abs(p[1]) < 1e-9]
|
||||||
|
assert sorted(top_points) == [(0.0, 0.0), (100.0, 0.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kerf_offsets_outline_outward():
|
||||||
|
raw = box_core.box_panels(100, 80, 50, 3)[0]
|
||||||
|
kerfed = box_core.offset_orthogonal(raw.points, 0.05)
|
||||||
|
x0, y0, x1, y1 = box_core.Panel("k", kerfed).bbox()
|
||||||
|
assert round(x1 - x0, 6) == 100.1
|
||||||
|
assert round(y1 - y0, 6) == 50.1
|
||||||
|
assert _is_orthogonal(kerfed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_layout_does_not_overlap():
|
||||||
|
panels = box_core.make_box(100, 80, 50, 3, gap=5)
|
||||||
|
boxes = [p.bbox() for p in panels]
|
||||||
|
for i, a in enumerate(boxes):
|
||||||
|
for b in boxes[i + 1:]:
|
||||||
|
overlap = a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
|
||||||
|
assert not overlap
|
||||||
|
|
||||||
|
|
||||||
|
def test_too_small_dimension_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
box_core.box_panels(10, 80, 50, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_edge_is_single_segment():
|
||||||
|
assert box_core.edge_segments(100, 3, 9, PLAIN) == [(3, 97, 0.0)]
|
||||||
Loading…
Reference in New Issue
Block a user