Version initiale

This commit is contained in:
Alex 2026-09-10 13:54:19 +02:00
commit 046c9839a7
9 changed files with 713 additions and 0 deletions

143
README.md Normal file
View 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)

Binary file not shown.

Binary file not shown.

196
box_core.py Normal file
View 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
View 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
View 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
View 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..."

103
tests/test_box_core.py Normal file
View 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)]