From 046c9839a7cf86b761ef785b6e2f274feb57f817 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 10 Sep 2026 13:54:19 +0200 Subject: [PATCH] Version initiale --- README.md | 143 +++++++++++++ __pycache__/box_core.cpython-312.pyc | Bin 0 -> 11104 bytes __pycache__/box_core.cpython-313.pyc | Bin 0 -> 11078 bytes box_core.py | 196 ++++++++++++++++++ box_maker.inx | 45 ++++ box_maker.py | 118 +++++++++++ deploy.ps1 | 108 ++++++++++ ...test_box_core.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 22706 bytes tests/test_box_core.py | 103 +++++++++ 9 files changed, 713 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/box_core.cpython-312.pyc create mode 100644 __pycache__/box_core.cpython-313.pyc create mode 100644 box_core.py create mode 100644 box_maker.inx create mode 100644 box_maker.py create mode 100644 deploy.ps1 create mode 100644 tests/__pycache__/test_box_core.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/test_box_core.py 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 0000000000000000000000000000000000000000..af9d993a7f358f39c9e7bc75e4e5d5962b83a988 GIT binary patch literal 11104 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 Wnp+|l;5+%{hgyFtSozJYYySuQ?}yF+ literal 0 HcmV?d00001 diff --git a/__pycache__/box_core.cpython-313.pyc b/__pycache__/box_core.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f6f9dfbbdea91125ef13fd43d0c0bca5aef6ed9 GIT binary patch literal 11078 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4bdec69e9513e2eb749bb6f123f93fcc5f8d3d5c GIT binary patch literal 22706 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 M_XDEXA+ksLA5?z>3jhEB literal 0 HcmV?d00001 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)]