197 lines
6.9 KiB
Python
197 lines
6.9 KiB
Python
"""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)
|