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