Taux de remplissage en pourcentage, trajet de la tête en serpentin (plus proche voisin), hachures croisées, interface fr/en, tests et README. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
326 lines
13 KiB
Python
326 lines
13 KiB
Python
# coding=utf-8
|
|
"""
|
|
Noyau de calcul de l'extension « Hatch Fill », sans dependance a inkex.
|
|
|
|
Remplit des contours avec des hachures paralleles et classe les traits dans
|
|
l'ordre ou une tete de laser les parcourt : la fin d'un trait est toujours
|
|
proche du debut du suivant (trajet en serpentin, aller-retour d'un trait au
|
|
suivant), ce qui limite les deplacements a vide.
|
|
|
|
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
|
|
des parametres).
|
|
|
|
Conventions :
|
|
- un contour (`rings`) est une liste d'anneaux, chaque anneau etant une liste
|
|
de points (x, y) fermee implicitement ; les trous suivent la regle pair-impair ;
|
|
- une polyligne est une liste de points (x, y) ;
|
|
- l'axe y est oriente vers le bas (repere SVG) ; les angles sont en degres,
|
|
positifs dans le sens anti-horaire a l'ecran (comme dans Inkscape) ;
|
|
- le « repere des hachures » est le repere ou les traits sont horizontaux :
|
|
x suit le trait, y est perpendiculaire (ligne k a y = (k + phase) * pas).
|
|
"""
|
|
|
|
import math
|
|
from bisect import bisect_left
|
|
|
|
# Garde-fou : au-dela, le SVG produit devient inutilisable (et le calcul long).
|
|
MAX_LINES = 50000
|
|
|
|
|
|
class TooManyLines(ValueError):
|
|
"""Le motif demande plus de traits que MAX_LINES."""
|
|
|
|
|
|
def parse_color(value, default=("#b3b3b3", 1.0)):
|
|
"""Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]).
|
|
|
|
Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre
|
|
« color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode
|
|
nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change
|
|
entre les versions 1.x.
|
|
"""
|
|
text = str(value).strip()
|
|
try:
|
|
if text.startswith("#"):
|
|
digits = text[1:]
|
|
if len(digits) == 3:
|
|
digits = "".join(c * 2 for c in digits)
|
|
if len(digits) == 6:
|
|
digits += "ff"
|
|
if len(digits) != 8:
|
|
return default
|
|
number = int(digits, 16)
|
|
else:
|
|
number = int(text, 0)
|
|
except ValueError:
|
|
return default
|
|
number &= 0xFFFFFFFF
|
|
red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255
|
|
alpha = (number & 255) / 255.0
|
|
return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4)
|
|
|
|
|
|
def polylines_to_d(polylines, precision=4):
|
|
"""Donnees `d` d'un chemin SVG : une polyligne par sous-chemin."""
|
|
fmt = "{:.%df},{:.%df}" % (precision, precision)
|
|
parts = []
|
|
for polyline in polylines:
|
|
if len(polyline) < 2:
|
|
continue
|
|
parts.append("M " + fmt.format(*polyline[0]))
|
|
parts.extend("L " + fmt.format(*point) for point in polyline[1:])
|
|
return " ".join(parts)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Courbes -> anneaux
|
|
# --------------------------------------------------------------------------
|
|
|
|
def flatten_cubic_subpaths(subpaths, tolerance):
|
|
"""Aplatit des sous-chemins de Beziers cubiques en anneaux de points.
|
|
|
|
`subpaths` suit la forme du « superpath » d'inkex : un sous-chemin est une
|
|
liste de noeuds [controle_entrant, point, controle_sortant]. Les segments
|
|
droits ont leurs controles confondus avec les points. `tolerance` est
|
|
l'ecart maximal admis entre la courbe et la polyligne (meme unite que les
|
|
points). Les sous-chemins de moins de trois points sont ignores.
|
|
"""
|
|
rings = []
|
|
for nodes in subpaths:
|
|
ring = []
|
|
for i in range(len(nodes) - 1):
|
|
p0, p1 = nodes[i][1], nodes[i][2]
|
|
p2, p3 = nodes[i + 1][0], nodes[i + 1][1]
|
|
ring.append(tuple(p0))
|
|
# Ecart d'une Bezier a sa corde <= 3/4 * (derivee seconde max) / n^2
|
|
second = max(math.hypot(p0[0] - 2 * p1[0] + p2[0], p0[1] - 2 * p1[1] + p2[1]),
|
|
math.hypot(p1[0] - 2 * p2[0] + p3[0], p1[1] - 2 * p2[1] + p3[1]))
|
|
steps = min(500, max(1, int(math.ceil(math.sqrt(0.75 * second / tolerance)))))
|
|
# Controles sur la corde : segment droit, inutile de le decouper.
|
|
chord = math.hypot(p3[0] - p0[0], p3[1] - p0[1])
|
|
if chord > 0 and max(
|
|
abs((p3[0] - p0[0]) * (q[1] - p0[1]) - (p3[1] - p0[1]) * (q[0] - p0[0]))
|
|
for q in (p1, p2)) / chord <= tolerance:
|
|
steps = 1
|
|
for s in range(1, steps):
|
|
t = s / steps
|
|
mt = 1 - t
|
|
ring.append((
|
|
mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0],
|
|
mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1]))
|
|
if nodes:
|
|
ring.append(tuple(nodes[-1][1]))
|
|
if len(ring) > 1 and ring[0] == ring[-1]:
|
|
ring.pop()
|
|
if len(ring) >= 3:
|
|
rings.append(ring)
|
|
return rings
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Hachures
|
|
# --------------------------------------------------------------------------
|
|
|
|
def spacing_from_density(density, line_width, cross=False):
|
|
"""Pas entre deux traits pour que `density` % de la zone soit noircie.
|
|
|
|
Un trait de largeur w tous les p couvre w / p de la surface : 100 %
|
|
correspond donc a des traits jointifs (p = w). En croise, il y a deux fois
|
|
plus de traits : le pas double pour garder la meme couverture.
|
|
"""
|
|
if not 0 < density <= 100:
|
|
raise ValueError("density must be in ]0, 100]")
|
|
if line_width <= 0:
|
|
raise ValueError("line_width must be positive")
|
|
return line_width * 100.0 / density * (2 if cross else 1)
|
|
|
|
|
|
def _frame_axes(angle):
|
|
"""Vecteurs unitaires (u le long des traits, v perpendiculaire)."""
|
|
a = math.radians(angle)
|
|
return (math.cos(a), -math.sin(a)), (math.sin(a), math.cos(a))
|
|
|
|
|
|
def to_frame(point, angle):
|
|
"""Point du document -> repere des hachures."""
|
|
u, v = _frame_axes(angle)
|
|
return point[0] * u[0] + point[1] * u[1], point[0] * v[0] + point[1] * v[1]
|
|
|
|
|
|
def from_frame(point, angle):
|
|
"""Point du repere des hachures -> document."""
|
|
u, v = _frame_axes(angle)
|
|
return point[0] * u[0] + point[1] * v[0], point[0] * u[1] + point[1] * v[1]
|
|
|
|
|
|
def scan_lines(rings, spacing, phase=0.0):
|
|
"""Intersections des traits horizontaux avec le contour (pair-impair).
|
|
|
|
La ligne k est a y = (k + phase) * spacing. Renvoie {k: [(x0, x1), ...]}
|
|
avec x0 < x1, les segments de chaque ligne etant tries. Une arete couvre
|
|
[ymin, ymax[ : un sommet exactement sur un trait n'est compte qu'une fois.
|
|
"""
|
|
crossings = {}
|
|
for ring in rings:
|
|
count = len(ring)
|
|
for i in range(count):
|
|
(x0, y0), (x1, y1) = ring[i], ring[(i + 1) % count]
|
|
if y0 == y1:
|
|
continue
|
|
if y0 > y1:
|
|
x0, y0, x1, y1 = x1, y1, x0, y0
|
|
first = int(math.ceil(y0 / spacing - phase))
|
|
last = int(math.ceil(y1 / spacing - phase)) - 1
|
|
slope = (x1 - x0) / (y1 - y0)
|
|
for k in range(first, last + 1):
|
|
crossings.setdefault(k, []).append(x0 + ((k + phase) * spacing - y0) * slope)
|
|
lines = {}
|
|
for k, xs in crossings.items():
|
|
xs.sort()
|
|
segments = [(xs[j], xs[j + 1]) for j in range(0, len(xs) - 1, 2)
|
|
if xs[j + 1] - xs[j] > 1e-9]
|
|
if segments:
|
|
lines[k] = segments
|
|
return lines
|
|
|
|
|
|
def order_lines(lines, spacing, phase=0.0, start=None):
|
|
"""Ordonne et oriente les segments pour minimiser les deplacements a vide.
|
|
|
|
Algorithme glouton du plus proche voisin sur les extremites : apres un
|
|
trait, on part de l'extremite non visitee la plus proche de l'endroit ou
|
|
la tete s'est arretee. Les extremites sont rangees par ligne et triees,
|
|
donc la recherche ne regarde que les lignes voisines (de plus en plus
|
|
loin) et s'arrete des que la ligne suivante ne peut plus faire mieux.
|
|
Sur une forme convexe, cela donne le serpentin attendu : on monte sur un
|
|
trait, on redescend sur le suivant, sans jamais revenir au point de depart.
|
|
|
|
`start` est la position initiale de la tete (repere des hachures) ; sans
|
|
elle, on commence par la premiere extremite de la premiere ligne.
|
|
Renvoie [(k, x_debut, x_fin), ...].
|
|
"""
|
|
segments = []
|
|
ends = {}
|
|
for k in sorted(lines):
|
|
for x0, x1 in lines[k]:
|
|
index = len(segments)
|
|
segments.append((k, x0, x1))
|
|
ends.setdefault(k, []).extend([(x0, index), (x1, index)])
|
|
if not segments:
|
|
return []
|
|
for entries in ends.values():
|
|
entries.sort()
|
|
k_min, k_max = min(ends), max(ends)
|
|
|
|
def nearest(x, y):
|
|
k_here = int(round(y / spacing - phase))
|
|
span = max(abs(k_here - k_min), abs(k_here - k_max))
|
|
best = None
|
|
for d in range(span + 1):
|
|
if best is not None and (d - 1) * spacing > best[0]:
|
|
break
|
|
for k in {k_here - d, k_here + d}:
|
|
entries = ends.get(k)
|
|
if not entries:
|
|
continue
|
|
dy = (k + phase) * spacing - y
|
|
at = bisect_left(entries, (x, -1))
|
|
for j in (at - 1, at):
|
|
if 0 <= j < len(entries):
|
|
dist = math.hypot(entries[j][0] - x, dy)
|
|
if best is None or dist < best[0]:
|
|
best = (dist, k, entries[j][0], entries[j][1])
|
|
return best
|
|
|
|
if start is None:
|
|
k0 = k_min
|
|
x0 = ends[k0][0][0]
|
|
found = (0.0, k0, x0, ends[k0][0][1])
|
|
else:
|
|
found = nearest(*start)
|
|
|
|
ordered = []
|
|
while found is not None:
|
|
_dist, k, x, index = found
|
|
_k, a, b = segments[index]
|
|
for end in (a, b):
|
|
entries = ends[k]
|
|
del entries[bisect_left(entries, (end, index))]
|
|
if not ends[k]:
|
|
del ends[k]
|
|
far = b if x == a else a
|
|
ordered.append((k, x, far))
|
|
if not ends:
|
|
break
|
|
k_min, k_max = min(ends), max(ends)
|
|
found = nearest(far, (k + phase) * spacing)
|
|
return ordered
|
|
|
|
|
|
def _phase(ymin, ymax, spacing, anchor):
|
|
"""Decalage des lignes (en fraction de pas).
|
|
|
|
« shape » : un trait au centre de chaque tranche de la forme (marges
|
|
egales en haut et en bas) ; « origin » : les lignes sont alignees sur l'origine du
|
|
document, donc continues d'une forme a l'autre.
|
|
"""
|
|
if anchor == "origin":
|
|
return 0.0
|
|
# Un trait au centre de chaque tranche d'un pas de haut : la couverture
|
|
# reste celle demandee et les marges haut / bas sont egales.
|
|
count = max(1, int(round((ymax - ymin) / spacing)))
|
|
first = ymin + ((ymax - ymin) - (count - 1) * spacing) / 2.0
|
|
return (first / spacing) % 1.0
|
|
|
|
|
|
def hatch_pass(rings, spacing, angle, anchor="shape", start=None):
|
|
"""Un jeu de hachures parallele a `angle`, deja classe dans l'ordre laser.
|
|
|
|
Renvoie une liste de polylignes [(p_debut, p_fin), ...]. Leve TooManyLines
|
|
si le pas est trop petit pour la forme.
|
|
"""
|
|
frame = [[to_frame(p, angle) for p in ring] for ring in rings if len(ring) >= 3]
|
|
if not frame:
|
|
return []
|
|
ys = [p[1] for ring in frame for p in ring]
|
|
ymin, ymax = min(ys), max(ys)
|
|
if (ymax - ymin) / spacing > MAX_LINES:
|
|
raise TooManyLines(int((ymax - ymin) / spacing))
|
|
phase = _phase(ymin, ymax, spacing, anchor)
|
|
lines = scan_lines(frame, spacing, phase)
|
|
if sum(len(segments) for segments in lines.values()) > MAX_LINES:
|
|
raise TooManyLines(sum(len(segments) for segments in lines.values()))
|
|
ordered = order_lines(lines, spacing, phase,
|
|
None if start is None else to_frame(start, angle))
|
|
result = []
|
|
for k, xa, xb in ordered:
|
|
y = (k + phase) * spacing
|
|
result.append([from_frame((xa, y), angle), from_frame((xb, y), angle)])
|
|
return result
|
|
|
|
|
|
def hatch(rings, spacing, angle=45.0, cross=False, anchor="shape"):
|
|
"""Hachures d'un contour, classees dans l'ordre de parcours du laser.
|
|
|
|
`cross` ajoute un second jeu perpendiculaire, parcouru apres le premier en
|
|
partant de la ou la tete s'est arretee. `anchor` : « shape » ou « origin »
|
|
(voir _phase).
|
|
"""
|
|
polylines = hatch_pass(rings, spacing, angle, anchor)
|
|
if cross:
|
|
start = polylines[-1][-1] if polylines else None
|
|
polylines += hatch_pass(rings, spacing, angle + 90.0, anchor, start)
|
|
return polylines
|
|
|
|
|
|
def travel_length(polylines):
|
|
"""Longueur des deplacements a vide : de la fin d'un trait au debut du suivant."""
|
|
return sum(math.hypot(b[0][0] - a[-1][0], b[0][1] - a[-1][1])
|
|
for a, b in zip(polylines, polylines[1:]))
|
|
|
|
|
|
def cutting_length(polylines):
|
|
"""Longueur totale des traits (laser allume)."""
|
|
return sum(math.hypot(q[0] - p[0], q[1] - p[1])
|
|
for polyline in polylines for p, q in zip(polyline, polyline[1:]))
|