"""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)