commit 046c9839a7cf86b761ef785b6e2f274feb57f817 Author: Alex Date: Thu Sep 10 13:54:19 2026 +0200 Version initiale diff --git a/README.md b/README.md new file mode 100644 index 0000000..96b1ca2 --- /dev/null +++ b/README.md @@ -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) diff --git a/__pycache__/box_core.cpython-312.pyc b/__pycache__/box_core.cpython-312.pyc new file mode 100644 index 0000000..af9d993 Binary files /dev/null and b/__pycache__/box_core.cpython-312.pyc differ diff --git a/__pycache__/box_core.cpython-313.pyc b/__pycache__/box_core.cpython-313.pyc new file mode 100644 index 0000000..9f6f9df Binary files /dev/null and b/__pycache__/box_core.cpython-313.pyc differ diff --git a/box_core.py b/box_core.py new file mode 100644 index 0000000..8a5c075 --- /dev/null +++ b/box_core.py @@ -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) diff --git a/box_maker.inx b/box_maker.inx new file mode 100644 index 0000000..a37aad3 --- /dev/null +++ b/box_maker.inx @@ -0,0 +1,45 @@ + + + Boîte à créneaux + fr.alex.box_maker + + + + 100 + 80 + 50 + + + + + true + + + + 3 + 0 + 0.1 + + + + + + + 5 + 0.1 + 0xff0000ff + false + + + + + all + + + + + + + diff --git a/box_maker.py b/box_maker.py new file mode 100644 index 0000000..7bbcf45 --- /dev/null +++ b/box_maker.py @@ -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() diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..73ed86a --- /dev/null +++ b/deploy.ps1 @@ -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..." diff --git a/tests/__pycache__/test_box_core.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_box_core.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..4bdec69 Binary files /dev/null and b/tests/__pycache__/test_box_core.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_box_core.py b/tests/test_box_core.py new file mode 100644 index 0000000..4a16736 --- /dev/null +++ b/tests/test_box_core.py @@ -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)]