inkscape.BoxFingering/box_core.py
2026-09-10 13:54:19 +02:00

197 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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