raster_view_qt.py 108 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505
  1. # -*- coding: utf-8 -*-
  2. """
  3. A.L.I.G. - RasterViewQt
  4. Corrections v2 :
  5. • Colorbar fixe (QWidget Qt natif) — non affectée par pan/zoom
  6. • Textes OVERSCAN trackés + supprimés à chaque redraw
  7. • Switch force_dim correctement connecté (lecture directe dans process_logic)
  8. • Bouton Reset View : plus de ligne noire (layout propre)
  9. • Auto-fit image à l'ouverture (reset_pan_zoom centré sur le contenu)
  10. • Histogramme : contours barres, abscisses par pas ronds, ylabel "Pixel count"
  11. """
  12. import os
  13. import sys
  14. import time
  15. import math
  16. import tempfile
  17. import numpy as np
  18. from PyQt6.QtWidgets import (
  19. QWidget, QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
  20. QComboBox, QLineEdit, QSlider, QScrollArea, QSplitter,
  21. QTextEdit, QSizePolicy, QMessageBox, QTabWidget,
  22. )
  23. from PyQt6.QtCore import Qt, QTimer, QSize, QPointF, QRectF
  24. from PyQt6.QtGui import (
  25. QPainter, QColor, QPen, QBrush, QFont, QLinearGradient,
  26. QTransform, QPixmap, QImage, QIcon,
  27. )
  28. from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
  29. from matplotlib.figure import Figure
  30. from matplotlib.patches import Rectangle
  31. from gui.switch import Switch
  32. from gui.utils_qt import (
  33. get_combo_stylesheet,
  34. show_loading_overlay,
  35. hide_loading_overlay,
  36. PanZoomMixin,
  37. translate_ui_widgets,
  38. get_open_file,
  39. get_save_file,
  40. get_existing_directory,
  41. )
  42. from core.translations import TRANSLATIONS
  43. from core.themes import get_theme
  44. from engine.gcode_engine import GCodeEngine
  45. from core.config_manager import save_json_file, load_json_file
  46. from core.utils import get_app_paths
  47. from gui.utils_qt import get_svg_pixmap
  48. try:
  49. from utils.paths import SVG_ICONS
  50. _ARROW_PATH = SVG_ICONS.get("ARROW_DOWN", "")
  51. except Exception:
  52. _ARROW_PATH = ""
  53. # ═══════════════════════════════════════════════════════════════════
  54. # HISTOGRAMME WIDGET
  55. # ═══════════════════════════════════════════════════════════════════
  56. class _HistogramWidget(QWidget):
  57. """
  58. Histogramme de distribution de puissance — rendu QPainter natif.
  59. """
  60. def __init__(self, parent=None):
  61. super().__init__(parent)
  62. self._matrix = None
  63. self._v_min = 0
  64. self._v_max = 255
  65. self._label_power = "Power"
  66. self._label_count = "Pixel count"
  67. self._label_title = "Power Distribution"
  68. self._bg_color = "#202020"
  69. self._fg_color = "#888888"
  70. self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
  71. def set_theme(self, bg: str, fg: str):
  72. self._bg_color = bg
  73. self._fg_color = fg
  74. self.update()
  75. def update_data(self, matrix, v_min, v_max,
  76. label_title="Power Distribution",
  77. label_power="Power",
  78. label_count="Pixel count"):
  79. self._matrix = matrix
  80. self._v_min = float(v_min)
  81. self._v_max = float(v_max)
  82. self._label_title = label_title
  83. self._label_power = label_power
  84. self._label_count = label_count
  85. self.update()
  86. @staticmethod
  87. def _nice_step(value_range, max_ticks=7):
  88. """Retourne un pas « rond » pour ~max_ticks graduations."""
  89. if value_range <= 0:
  90. return 1
  91. raw = value_range / max_ticks
  92. mag = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1
  93. for m in (1, 2, 5, 10):
  94. step = m * mag
  95. if value_range / step <= max_ticks:
  96. return step
  97. return mag * 10
  98. def paintEvent(self, _):
  99. qp = QPainter(self)
  100. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  101. W, H = self.width(), self.height()
  102. qp.fillRect(0, 0, W, H, QColor(self._bg_color))
  103. if self._matrix is None:
  104. qp.setPen(QColor(self._fg_color))
  105. qp.setFont(QFont("Arial", 10))
  106. qp.drawText(0, 0, W, H, Qt.AlignmentFlag.AlignCenter, "—")
  107. qp.end()
  108. return
  109. flat = self._matrix.ravel()[::8]
  110. total = max(flat.size, 1) # Le dénominateur pour le pourcentage
  111. v_min = self._v_min
  112. v_max = self._v_max
  113. data_off = flat[flat == 0]
  114. data_active = flat[flat > 0]
  115. BINS = 64
  116. if len(data_active) > 0:
  117. lo_range = max(v_min, 1e-9)
  118. hi_range = max(v_max, lo_range + 1e-8)
  119. counts, edges = np.histogram(
  120. data_active,
  121. bins=BINS,
  122. range=(lo_range, hi_range),
  123. density=False
  124. )
  125. # Conversion des comptes en pourcentages
  126. counts_pct = (counts / total) * 100.0
  127. else:
  128. counts_pct = np.zeros(BINS, dtype=float)
  129. edges = np.linspace(v_min, v_max, BINS + 1)
  130. # Pourcentage de pixels "éteints" (Laser OFF)
  131. count_off_pct = (data_off.size / total) * 100.0
  132. # Le y_max est maintenant le pourcentage le plus élevé trouvé (max 100)
  133. y_max = max(counts_pct.max() if counts_pct.size else 0, count_off_pct, 1.0)
  134. # ── Marges ─────────────────────────────────────────────────
  135. lm = 56 # axe Y + ylabel vertical
  136. rm = 10
  137. tm = 26 # titre
  138. bm = 28 # axe X + labels + xlabel
  139. plot_w = W - lm - rm
  140. zero_w = max(int(plot_w * 0.06), 8)
  141. act_w = plot_w - zero_w
  142. plot_h = H - tm - bm
  143. base_y = H - bm
  144. if plot_w <= 0 or plot_h <= 0:
  145. qp.end()
  146. return
  147. # ── Titre ──────────────────────────────────────────────────
  148. qp.setPen(QColor("white"))
  149. qp.setFont(QFont("Arial", 9, QFont.Weight.Bold))
  150. qp.drawText(0, 2, W, tm - 2, Qt.AlignmentFlag.AlignCenter, self._label_title)
  151. # ── Axes ───────────────────────────────────────────────────
  152. qp.setPen(QPen(QColor("#555"), 1))
  153. qp.drawLine(lm, base_y, W - rm, base_y)
  154. qp.drawLine(lm, tm, lm, base_y)
  155. # ── Mappings ───────────────────────────────────────────────
  156. def px_x(val):
  157. if v_max == v_min:
  158. return lm + zero_w
  159. return lm + zero_w + ((val - v_min) / (v_max - v_min)) * act_w
  160. def px_y(cnt):
  161. return base_y - min(cnt / y_max, 1.0) * plot_h
  162. # ── Barre OFF ──────────────────────────────────────────────
  163. if count_off_pct > 0:
  164. bh = int(max(base_y - px_y(count_off_pct), 1))
  165. bx0 = lm + 2
  166. bx1 = lm + zero_w - 2
  167. bw = max(bx1 - bx0, 1)
  168. by = base_y - bh
  169. qp.fillRect(bx0, by, bw, bh, QColor("#c0724a"))
  170. qp.setPen(QPen(QColor("#e8956b"), 1))
  171. qp.drawRect(bx0, by, bw - 1, bh - 1)
  172. qp.setPen(QColor("#e8956b"))
  173. qp.setFont(QFont("Arial", 7, QFont.Weight.Bold))
  174. qp.drawText(bx0, base_y + 4, bw, 14, Qt.AlignmentFlag.AlignCenter, "OFF")
  175. # ── Barres actives ─────────────────────────────────────────
  176. fill_col = QColor("#3a80b8")
  177. outline_col = QColor("#6ab0e0")
  178. for i, cnt in enumerate(counts_pct):
  179. if cnt <= 0:
  180. continue
  181. x0 = int(px_x(edges[i]))
  182. x1 = int(px_x(edges[i + 1]))
  183. bh = int(max(base_y - px_y(cnt), 1))
  184. bw = max(x1 - x0, 1)
  185. by = base_y - bh
  186. qp.fillRect(x0, by, bw, bh, fill_col)
  187. if bw >= 3:
  188. qp.setPen(QPen(outline_col, 1))
  189. qp.drawRect(x0, by, bw - 1, bh - 1)
  190. # ── Repères MIN / MAX ──────────────────────────────────────
  191. for val, col, txt in [(v_min, "#ffcc00", "MIN"), (v_max, "#ff4444", "MAX")]:
  192. px = int(px_x(val))
  193. qp.setPen(QPen(QColor(col), 1, Qt.PenStyle.DashLine))
  194. qp.drawLine(px, tm, px, base_y)
  195. qp.setPen(QColor(col))
  196. qp.setFont(QFont("Arial", 7, QFont.Weight.Bold))
  197. qp.drawText(px - 18, base_y + 18, 36, 13, Qt.AlignmentFlag.AlignCenter, txt)
  198. # ── Graduations Y ──────────────────────────────────────────
  199. step_y = self._nice_step(y_max, max_ticks=5)
  200. qp.setFont(QFont("Arial", 7))
  201. tick = 0
  202. while tick <= y_max:
  203. py = int(px_y(tick))
  204. qp.setPen(QPen(QColor("#2a2a2a"), 1, Qt.PenStyle.DotLine))
  205. qp.drawLine(lm + 1, py, W - rm, py)
  206. qp.setPen(QColor("#888"))
  207. lbl_str = f"{int(tick // 1000)}k" if tick >= 1000 else str(int(tick))
  208. qp.drawText(0, py - 7, lm - 4, 14,
  209. Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, lbl_str)
  210. tick += step_y
  211. # ── Graduations X par pas ronds ────────────────────────────
  212. v_range = v_max - v_min
  213. step_x = self._nice_step(v_range, max_ticks=6)
  214. x_start = math.ceil(v_min / step_x) * step_x if step_x else v_min
  215. qp.setFont(QFont("Arial", 7))
  216. cur = x_start
  217. while cur <= v_max + 1e-9:
  218. px = int(px_x(cur))
  219. if lm <= px <= W - rm:
  220. qp.setPen(QPen(QColor("#555"), 1))
  221. qp.drawLine(px, base_y, px, base_y + 4)
  222. qp.setPen(QColor("#888"))
  223. lbl = f"{int(cur)}" if cur == int(cur) else f"{cur:.1f}"
  224. qp.drawText(px - 15, base_y + 5, 30, 13,
  225. Qt.AlignmentFlag.AlignCenter, lbl)
  226. cur += step_x
  227. # ── Label X ────────────────────────────────────────────────
  228. qp.setPen(QColor("#777"))
  229. qp.setFont(QFont("Arial", 8))
  230. qp.drawText(lm, H - 12, int(act_w), 12,
  231. Qt.AlignmentFlag.AlignCenter, self._label_power)
  232. # ── Label Y vertical ───────────────────────────────────────
  233. qp.save()
  234. qp.translate(10, tm + plot_h // 2)
  235. qp.rotate(-90)
  236. qp.setPen(QColor("#777"))
  237. qp.setFont(QFont("Arial", 8))
  238. qp.drawText(-40, -7, 80, 14, Qt.AlignmentFlag.AlignCenter, self._label_count)
  239. qp.restore()
  240. qp.end()
  241. # ═══════════════════════════════════════════════════════════════════
  242. # COLORBAR WIDGET Qt (fixe, hors pan/zoom)
  243. # ═══════════════════════════════════════════════════════════════════
  244. class _ColorbarWidget(QWidget):
  245. """
  246. Colorbar affichée en Qt pur, côte à côte avec le canvas.
  247. Totalement indépendante du pan/zoom matplotlib.
  248. Dégradé gray_r : noir (haut/fort) → blanc (bas/faible).
  249. """
  250. def __init__(self, parent=None):
  251. super().__init__(parent)
  252. self._v_min = 0.0
  253. self._v_max = 255.0
  254. self._label = "Laser Power"
  255. self._bg_color = "#1e1e1e"
  256. self._fg_color = "#888888"
  257. self.setFixedWidth(52)
  258. self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
  259. def set_theme(self, bg: str, fg: str):
  260. self._bg_color = bg
  261. self._fg_color = fg
  262. self.update()
  263. def set_range(self, v_min, v_max, label="Laser Power"):
  264. self._v_min = float(v_min)
  265. self._v_max = float(v_max)
  266. self._label = label
  267. self.update()
  268. def paintEvent(self, _):
  269. qp = QPainter(self)
  270. W, H = self.width(), self.height()
  271. qp.fillRect(0, 0, W, H, QColor(self._bg_color))
  272. tm, bm = 12, 12
  273. bar_x = 14
  274. bar_w = 14
  275. bar_h = H - tm - bm
  276. if bar_h <= 0:
  277. qp.end()
  278. return
  279. # Dégradé : haut = noir (valeur haute = laser fort) / bas = blanc
  280. grad = QLinearGradient(bar_x, tm, bar_x, tm + bar_h)
  281. grad.setColorAt(0.0, QColor("#000000"))
  282. grad.setColorAt(1.0, QColor("#ffffff"))
  283. qp.fillRect(bar_x, tm, bar_w, bar_h, QBrush(grad))
  284. qp.setPen(QPen(QColor(self._fg_color), 1))
  285. qp.drawRect(bar_x, tm, bar_w - 1, bar_h - 1)
  286. # Ticks
  287. qp.setFont(QFont("Arial", 7))
  288. ticks = [(0.0, self._v_max), (0.5, (self._v_max + self._v_min) / 2), (1.0, self._v_min)]
  289. for ratio, val in ticks:
  290. py = int(tm + ratio * bar_h)
  291. qp.setPen(QPen(QColor(self._fg_color), 1))
  292. qp.drawLine(bar_x - 3, py, bar_x, py)
  293. s = f"{int(val)}" if val == int(val) else f"{val:.1f}"
  294. qp.drawText(0, py - 7, bar_x - 4, 14,
  295. Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, s)
  296. # Label vertical
  297. qp.save()
  298. qp.translate(W - 10, tm + bar_h // 2) # décalé 6px à gauche
  299. qp.rotate(-90)
  300. qp.setPen(QColor(self._fg_color))
  301. qp.setFont(QFont("Arial", 8))
  302. qp.drawText(-50, -6, 100, 13, Qt.AlignmentFlag.AlignCenter, self._label)
  303. qp.restore()
  304. qp.end()
  305. # ═══════════════════════════════════════════════════════════════════
  306. # TABBAR CENTRÉ
  307. # ═══════════════════════════════════════════════════════════════════
  308. # ═══════════════════════════════════════════════════════════════════
  309. # CANVAS IMAGE avec Pan / Zoom
  310. # ═══════════════════════════════════════════════════════════════════
  311. class _RasterCanvas(PanZoomMixin, QWidget):
  312. """
  313. Affiche le rendu matplotlib avec pan (clic-gauche) + zoom (molette).
  314. v2 : auto-fit à l'ouverture d'une image.
  315. """
  316. def __init__(self, fig, parent=None):
  317. super().__init__(parent)
  318. self.init_pan_zoom(zoom_min=0.10, zoom_max=30.0, zoom_step=1.15)
  319. self.setMouseTracking(True)
  320. self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
  321. self._bg_color = "#1e1e1e"
  322. self.setStyleSheet(f"background:{self._bg_color};")
  323. self._fig = fig
  324. self._pixmap = None
  325. self._auto_fit = False
  326. self._fit_next = False
  327. self._needs_fit = False
  328. self._mpl_canvas = FigureCanvas(fig)
  329. self._mpl_canvas.setParent(self)
  330. self._mpl_canvas.setVisible(False)
  331. # Overlay QPainter natif (toujours net au zoom)
  332. self._overlay = None # None ou dict
  333. self._after_draw_cb = None # callable(pw, ph) appelé après draw()
  334. def set_overlay(self, ov):
  335. """
  336. ov = {
  337. 'transform': callable (mx, my) -> (px, py) # mm → pixels pixmap
  338. 'overscan_rects': [(x, y, w, h), ...], # mm
  339. 'border_rect': (x, y, w, h), # mm
  340. 'origin': (ox, oy), # mm
  341. 'grid_xs': [float, ...], # abscisses grille (mm)
  342. 'grid_ys': [float, ...], # ordonnées grille (mm)
  343. 'xlim': (x0, x1), # mm, pour borner les lignes
  344. 'ylim': (y0, y1),
  345. 'direction': 'horizontal'|'vertical',
  346. }
  347. """
  348. self._overlay = ov
  349. self.update()
  350. def request_auto_fit(self):
  351. self._auto_fit = True
  352. def redraw(self, fit=False):
  353. """Demande à matplotlib de redessiner et met à jour le pixmap.
  354. Si fit=True, ajuste automatiquement le zoom pour remplir la vue.
  355. """
  356. from PyQt6.QtCore import QTimer
  357. self._fig.tight_layout(pad=0.5)
  358. self._mpl_canvas.draw()
  359. # Capture du buffer matplotlib vers QPixmap
  360. buf = self._mpl_canvas.buffer_rgba()
  361. w, h = self._mpl_canvas.get_width_height()
  362. qi = QImage(bytes(buf), w, h, QImage.Format.Format_RGBA8888)
  363. self._pixmap = QPixmap.fromImage(qi)
  364. # Calcul du mapping mm → pixels pixmap via transData (valide après draw)
  365. # Appelé par set_overlay_from_ax() si fourni
  366. if self._after_draw_cb is not None:
  367. try:
  368. self._after_draw_cb(w, h)
  369. except Exception:
  370. pass
  371. do_fit = fit or self._fit_next
  372. self._fit_next = False
  373. if do_fit:
  374. self._needs_fit = True
  375. QTimer.singleShot(0, self.fit_to_view)
  376. else:
  377. self.update()
  378. def fit_to_view(self):
  379. """Calcule zoom + pan pour que le pixmap remplisse le widget (centré, sans déformation)."""
  380. if self._pixmap is None:
  381. self.reset_pan_zoom()
  382. return
  383. cw, ch = self.width(), self.height()
  384. pw, ph = self._pixmap.width(), self._pixmap.height()
  385. margin = 0.96
  386. zoom = min(cw / pw, ch / ph) * margin
  387. pan_x = (cw - pw * zoom) / 2.0
  388. pan_y = (ch - ph * zoom) / 2.0
  389. self._pz_zoom = zoom
  390. self._pz_pan = QPointF(pan_x, pan_y)
  391. self.update()
  392. self._on_pan_zoom_changed()
  393. def reset_view(self):
  394. self.fit_to_view()
  395. def paintEvent(self, _):
  396. qp = QPainter(self)
  397. qp.fillRect(0, 0, self.width(), self.height(), QColor(self._bg_color))
  398. if self._pixmap is None:
  399. ph_col = "#444444" if self._bg_color[1:3].lower() < "88" else "#aaaaaa"
  400. qp.setPen(QColor(ph_col))
  401. qp.setFont(QFont("Arial", 11))
  402. qp.drawText(0, 0, self.width(), self.height(),
  403. Qt.AlignmentFlag.AlignCenter, "Select an image…")
  404. qp.end()
  405. return
  406. # ── Pixmap matplotlib (image raster + ticks + labels) ───────
  407. self.apply_pan_zoom_transform(qp)
  408. qp.drawPixmap(0, 0, self._pixmap)
  409. # ── Overlay QPainter natif (toujours net au zoom) ────────────
  410. ov = self._overlay
  411. if ov is None:
  412. qp.end()
  413. return
  414. m2p = ov['transform'] # (mm_x, mm_y) → (px_x, px_y) dans pixmap
  415. x0, x1 = ov['xlim']
  416. y0, y1 = ov['ylim']
  417. horiz = ov.get('direction', 'horizontal') == 'horizontal'
  418. def cosmetic_pen(hex_col, alpha=1.0, widthF=0.0,
  419. style=Qt.PenStyle.SolidLine):
  420. c = QColor(hex_col); c.setAlphaF(alpha)
  421. p = QPen(c); p.setWidthF(widthF); p.setStyle(style)
  422. return p
  423. # ── 1. Grille ────────────────────────────────────────────────
  424. qp.setPen(cosmetic_pen("#00ffff", alpha=0.45))
  425. for gx in ov.get('grid_xs', []):
  426. ax1, ay1 = m2p(gx, y0)
  427. ax2, ay2 = m2p(gx, y1)
  428. qp.drawLine(QPointF(ax1, ay1), QPointF(ax2, ay2))
  429. for gy in ov.get('grid_ys', []):
  430. ax1, ay1 = m2p(x0, gy)
  431. ax2, ay2 = m2p(x1, gy)
  432. qp.drawLine(QPointF(ax1, ay1), QPointF(ax2, ay2))
  433. # ── 1b. Labels des ticks (Y gauche+droite, X haut+bas) ──────
  434. ax_left = ov.get('ax_left', 0.0)
  435. ax_right = ov.get('ax_right', float(self._pixmap.width()))
  436. ax_top = ov.get('ax_top', 0.0)
  437. ax_bot = ov.get('ax_bot', float(self._pixmap.height()))
  438. lbl_font = QFont("Arial", 8)
  439. qp.setFont(lbl_font)
  440. lbl_col = QColor("#888888")
  441. qp.setPen(QPen(lbl_col))
  442. lbl_w = 36; lbl_h = 14
  443. for (val, txt) in ov.get('yticks', []):
  444. _, py = m2p(x0, val)
  445. # gauche
  446. qp.drawText(QRectF(ax_left - lbl_w - 4, py - lbl_h / 2, lbl_w, lbl_h),
  447. Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, txt)
  448. # droite
  449. qp.drawText(QRectF(ax_right + 4, py - lbl_h / 2, lbl_w, lbl_h),
  450. Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
  451. for (val, txt) in ov.get('xticks', []):
  452. px, _ = m2p(val, y0)
  453. # bas
  454. qp.drawText(QRectF(px - lbl_w / 2, ax_bot + 3, lbl_w, lbl_h),
  455. Qt.AlignmentFlag.AlignCenter, txt)
  456. # haut
  457. qp.drawText(QRectF(px - lbl_w / 2, ax_top - lbl_h - 3, lbl_w, lbl_h),
  458. Qt.AlignmentFlag.AlignCenter, txt)
  459. # ── 2. Hachures + texte OVERSCAN ────────────────────────────
  460. for (rx, ry, rw, rh) in ov.get('overscan_rects', []):
  461. # y inversé : coin haut = y+h, coin bas = y
  462. tlx, tly = m2p(rx, ry + rh) # top-left pixel
  463. brx, bry = m2p(rx + rw, ry) # bot-right pixel
  464. rpx = min(tlx, brx); rpy = min(tly, bry)
  465. rpw = max(1.0, abs(brx - tlx))
  466. rph = max(1.0, abs(bry - tly))
  467. fill = QColor("#3498db"); fill.setAlphaF(0.10)
  468. qp.fillRect(QRectF(rpx, rpy, rpw, rph), fill)
  469. # hachures manuelles 8 px réels
  470. # horizontal = lignes de gravure horizontales → overscan gauche/droite (zones hautes/étroites)
  471. # → hachures "---" = lignes HORIZONTALES dans la zone
  472. # vertical = lignes de gravure verticales → overscan haut/bas (zones larges/plates)
  473. # → hachures "|||" = lignes VERTICALES dans la zone
  474. qp.setPen(cosmetic_pen("#3498db", alpha=0.35))
  475. step = 8.0
  476. if horiz:
  477. # hachures horizontales (---) dans zones gauche/droite
  478. y = rpy
  479. while y <= rpy + rph:
  480. qp.drawLine(QPointF(rpx, y), QPointF(rpx + rpw, y))
  481. y += step
  482. else:
  483. # hachures verticales (|||) dans zones haut/bas
  484. x = rpx
  485. while x <= rpx + rpw:
  486. qp.drawLine(QPointF(x, rpy), QPointF(x, rpy + rph))
  487. x += step
  488. # texte "OVERSCAN"
  489. # horizontal → zones étroites et hautes → texte tourné 90°
  490. # vertical → zones larges et plates → texte droit
  491. qp.save()
  492. tc = QColor("#3498db"); tc.setAlphaF(0.9)
  493. qp.setPen(QPen(tc))
  494. qp.setFont(QFont("Arial", 7, QFont.Weight.Bold))
  495. cx = rpx + rpw / 2; cy = rpy + rph / 2
  496. qp.translate(cx, cy)
  497. if horiz:
  498. qp.rotate(-90)
  499. qp.drawText(QRectF(-40, -8, 80, 16),
  500. Qt.AlignmentFlag.AlignCenter, "OVERSCAN")
  501. qp.restore()
  502. # ── 3. Rectangle bordure pointillé ──────────────────────────
  503. br = ov.get('border_rect')
  504. if br:
  505. bx, by, bw, bh = br
  506. tlx, tly = m2p(bx, by + bh)
  507. brx2, bry2 = m2p(bx + bw, by)
  508. pen = cosmetic_pen("#3498db", alpha=0.9, widthF=1.5,
  509. style=Qt.PenStyle.DashLine)
  510. qp.setPen(pen)
  511. qp.setBrush(Qt.BrushStyle.NoBrush)
  512. rx_ = min(tlx, brx2); ry_ = min(tly, bry2)
  513. qp.drawRect(QRectF(rx_, ry_, abs(brx2 - tlx), abs(bry2 - tly)))
  514. # ── 4. Point d'origine (disque rouge) ────────────────────────
  515. orig = ov.get('origin')
  516. if orig:
  517. opx, opy = m2p(orig[0], orig[1])
  518. r = 5.0
  519. qp.setPen(QPen(QColor("#cc0000"), 1.5))
  520. qp.setBrush(QBrush(QColor("#ff3333")))
  521. qp.drawEllipse(QRectF(opx - r, opy - r, r * 2, r * 2))
  522. qp.end()
  523. def resizeEvent(self, e):
  524. super().resizeEvent(e)
  525. if self._pixmap is not None:
  526. if self._needs_fit :
  527. self.fit_to_view()
  528. else:
  529. self.update()
  530. # ═══════════════════════════════════════════════════════════════════
  531. # VUE PRINCIPALE
  532. # ═══════════════════════════════════════════════════════════════════
  533. class RasterViewQt(QWidget):
  534. def __init__(self, parent, controller):
  535. super().__init__(parent)
  536. self.controller = controller
  537. lang = controller.config_manager.get_item("machine_settings", "language")
  538. if not lang or lang not in TRANSLATIONS:
  539. lang = "English"
  540. self._lang = lang
  541. self.t = TRANSLATIONS[lang]["common"]
  542. self.t_stats = TRANSLATIONS[lang]["stats"]
  543. self.t_orig = TRANSLATIONS[lang]["origin_options"]
  544. self.engine = GCodeEngine()
  545. self._loading = True
  546. self.input_image_path = ""
  547. self.output_dir = ""
  548. _, self.application_path = get_app_paths()
  549. self.output_dir = self.application_path
  550. self.version = getattr(controller, "version", "1.0")
  551. self.controls = {}
  552. self.translation_map = {}
  553. # Couleurs thème — dark par défaut, mis à jour par apply_theme
  554. self._theme_colors = get_theme('Dark')
  555. self._debounce_timer = QTimer(self)
  556. self._debounce_timer.setSingleShot(True)
  557. self._debounce_timer.timeout.connect(self._do_update_preview)
  558. self._fig = Figure(figsize=(5, 5), facecolor="#111111")
  559. self._ax = None
  560. self._img_plot = None
  561. self._cbar = None
  562. self._origin_marker = None
  563. self._overscan_patches = []
  564. self._overscan_texts = []
  565. self._rect_overscan = None
  566. self._placeholder_text = None
  567. self._last_matrix = None
  568. self._last_geom = None
  569. self.estimated_file_size = "N/A"
  570. self._source_img_cache = None
  571. self._source_img_path = ""
  572. self._build_ui()
  573. self._loading = False
  574. self.load_settings()
  575. # Le render est différé jusqu'au premier affichage (showEvent)
  576. # pour ne pas bloquer l'UI pendant le préchargement en arrière-plan
  577. self._render_pending = True
  578. # ── Construction de l'UI ─────────────────────────────────────────
  579. def _build_ui(self):
  580. root = QHBoxLayout(self)
  581. root.setContentsMargins(4, 4, 4, 4)
  582. root.setSpacing(4)
  583. sidebar = self._build_sidebar()
  584. right = self._build_right_panel()
  585. splitter = QSplitter(Qt.Orientation.Horizontal)
  586. splitter.addWidget(sidebar)
  587. splitter.addWidget(right)
  588. splitter.setStretchFactor(0, 0)
  589. splitter.setStretchFactor(1, 1)
  590. splitter.setSizes([390, 900])
  591. root.addWidget(splitter)
  592. def _build_sidebar(self):
  593. sidebar = QFrame()
  594. self._sidebar = sidebar
  595. sidebar.setObjectName("rasterSidebar")
  596. sidebar.setFixedWidth(390)
  597. sidebar.setStyleSheet(
  598. "QFrame#rasterSidebar{background:#1e1e1e;border-right:1px solid #333;}"
  599. "QFrame#rasterSidebar > QLabel{border:none;background:transparent;color:#ddd;}"
  600. )
  601. lo = QVBoxLayout(sidebar)
  602. lo.setContentsMargins(8, 8, 8, 8)
  603. lo.setSpacing(6)
  604. lo.addWidget(self._make_file_buttons())
  605. lo.addWidget(self._make_profile_buttons())
  606. self._tabs = QTabWidget()
  607. self._tabs.tabBar().setExpanding(False)
  608. self._tabs.tabBar().setDrawBase(False)
  609. self._tabs.setStyleSheet("""
  610. QTabWidget::pane { border: 1px solid #333; background: #252525; }
  611. QTabBar::tab {
  612. background: #2b2b2b; color: #aaa; padding: 5px 18px;
  613. border: 1px solid #333; border-bottom: none; border-radius: 4px 4px 0 0;
  614. }
  615. QTabBar::tab:selected { background: #1F6AA5; color: white; }
  616. """)
  617. # Centrage : décaler la tabBar via son parent layout
  618. from PyQt6.QtCore import QTimer as _QT
  619. def _center_tabs():
  620. tb = self._tabs.tabBar()
  621. total = sum(tb.tabRect(i).width() for i in range(tb.count()))
  622. offset = max(0, (tb.parent().width() - total) // 2)
  623. tb.setGeometry(offset, tb.y(), tb.parent().width() - offset, tb.height())
  624. self._tabs.tabBar().installEventFilter(self)
  625. self._center_tabs_fn = _center_tabs
  626. _QT.singleShot(0, _center_tabs)
  627. self._tab_geom = self._make_scrollable_tab()
  628. self._tab_img = self._make_scrollable_tab()
  629. self._tab_laser = self._make_scrollable_tab()
  630. self._tab_gcode = self._make_scrollable_tab()
  631. self._tabs.addTab(self._tab_geom, self.t.get("geometry", "Geometry"))
  632. self._tabs.addTab(self._tab_img, self.t.get("image", "Image"))
  633. self._tabs.addTab(self._tab_laser, self.t.get("laser", "Laser"))
  634. self._tabs.addTab(self._tab_gcode, self.t.get("gcode", "G-Code"))
  635. self._setup_tab_geom()
  636. self._setup_tab_img()
  637. self._setup_tab_laser()
  638. self._setup_tab_gcode()
  639. lo.addWidget(self._tabs, stretch=1)
  640. self.btn_gen = QPushButton(self.t.get("simulate_gcode", "Simulate G-Code"))
  641. self.btn_gen.setFixedHeight(50)
  642. self.btn_gen.setStyleSheet(
  643. "QPushButton{background:#1f538d;color:white;border-radius:8px;"
  644. "font-size:13px;font-weight:bold;border:none;}"
  645. "QPushButton:hover{background:#2a6dbd;}"
  646. )
  647. self.btn_gen.clicked.connect(self.generate_gcode)
  648. self.translation_map[self.btn_gen] = "simulate_gcode"
  649. lo.addWidget(self.btn_gen)
  650. return sidebar
  651. def _make_scrollable_tab(self):
  652. scroll = QScrollArea()
  653. scroll.setWidgetResizable(True)
  654. scroll.setFrameShape(QFrame.Shape.NoFrame)
  655. scroll.setStyleSheet("background:transparent;")
  656. container = QWidget()
  657. container.setStyleSheet("background:transparent;")
  658. lo = QVBoxLayout(container)
  659. lo.setAlignment(Qt.AlignmentFlag.AlignTop)
  660. lo.setContentsMargins(6, 6, 6, 6)
  661. lo.setSpacing(4)
  662. scroll.setWidget(container)
  663. scroll._inner = lo
  664. return scroll
  665. def _make_file_buttons(self):
  666. f = QFrame()
  667. self._file_buttons_frame = f
  668. f.setStyleSheet("QFrame{background:#333;border-radius:6px;}")
  669. lo = QVBoxLayout(f)
  670. lo.setContentsMargins(6, 6, 6, 6)
  671. lo.setSpacing(4)
  672. self.btn_input = QPushButton(self.t.get("select_image", "Select Image"))
  673. self.btn_input.setFixedHeight(32)
  674. self.btn_input.setStyleSheet(self._btn_style())
  675. self.btn_input.clicked.connect(self.select_input)
  676. self.translation_map[self.btn_input] = "select_image"
  677. self.btn_output = QPushButton(self.t.get("select_output", "Select Output"))
  678. self.btn_output.setFixedHeight(32)
  679. self.btn_output.setStyleSheet(self._btn_style())
  680. self.btn_output.clicked.connect(self.select_output)
  681. self.translation_map[self.btn_output] = "select_output"
  682. lo.addWidget(self.btn_input)
  683. lo.addWidget(self.btn_output)
  684. return f
  685. def _make_profile_buttons(self):
  686. f = QFrame()
  687. f.setStyleSheet("QFrame{background:transparent;border:none;}")
  688. lo = QHBoxLayout(f)
  689. lo.setContentsMargins(0, 0, 0, 0)
  690. lo.setSpacing(6)
  691. self.btn_load_prof = QPushButton(self.t.get("import_profile", "Import Profile"))
  692. self.btn_load_prof.setFixedHeight(28)
  693. self.btn_load_prof.setStyleSheet(self._btn_style(bg="#444"))
  694. self.btn_load_prof.clicked.connect(self.load_profile_from)
  695. self.translation_map[self.btn_load_prof] = "import_profile"
  696. self.btn_save_prof = QPushButton(self.t.get("export_profile", "Export Profile"))
  697. self.btn_save_prof.setFixedHeight(28)
  698. self.btn_save_prof.setStyleSheet(self._btn_style(bg="#444"))
  699. self.btn_save_prof.clicked.connect(self.export_profile)
  700. self.translation_map[self.btn_save_prof] = "export_profile"
  701. lo.addWidget(self.btn_load_prof, 1)
  702. lo.addWidget(self.btn_save_prof, 1)
  703. return f
  704. # ── Onglets ───────────────────────────────────────────────────────
  705. def _setup_tab_geom(self):
  706. lo = self._tab_geom._inner
  707. lbl_mode = QLabel(self.t.get("raster_mode", "raster_mode"))
  708. lbl_mode.setStyleSheet("color:#ddd;font-size:12px;")
  709. lo.addWidget(lbl_mode)
  710. self.translation_map[lbl_mode] = "raster_mode"
  711. self._raster_map = {
  712. "horizontal": "horizontal",
  713. "vertical": "vertical",
  714. }
  715. self._raster_map_inv = {v: k for k, v in self._raster_map.items()}
  716. self._raster_mode = "horizontal"
  717. mode_row = QHBoxLayout()
  718. self._btn_raster = {}
  719. for key, lbl in self._raster_map.items():
  720. b = QPushButton(lbl)
  721. b.setCheckable(True)
  722. b.setFixedHeight(28)
  723. b.setStyleSheet(self._seg_btn_style())
  724. b.clicked.connect(lambda _, k=key: self._on_raster_change(k))
  725. mode_row.addWidget(b)
  726. self._btn_raster[key] = b
  727. self._btn_raster["horizontal"].setChecked(True)
  728. lo.addLayout(mode_row)
  729. lo.addSpacing(6)
  730. self.width_label_widget, _ = self._add_slider_input(
  731. lo, "target_width", 5, 400, 30.0, "width",
  732. unclamped_entry=True)
  733. # Warning mémoire — visible si matrice trop grande
  734. self._mem_warn_label = QLabel()
  735. self._mem_warn_label.setWordWrap(True)
  736. self._mem_warn_label.setStyleSheet(
  737. "color: #FF9500; font-size: 11px; background: transparent; border: none;")
  738. self._mem_warn_label.setVisible(False)
  739. lo.addWidget(self._mem_warn_label)
  740. sw_row = QHBoxLayout()
  741. self.lbl_force_width = QLabel(self.t.get("force_width", "force_width"))
  742. self.lbl_force_width.setStyleSheet("color:#ddd;font-size:12px;")
  743. self.translation_map[self.lbl_force_width] = "force_width"
  744. self.sw_force_width = Switch()
  745. self.sw_force_width.toggled.connect(self._on_switch_with_delay)
  746. sw_row.addWidget(self.lbl_force_width)
  747. sw_row.addStretch()
  748. sw_row.addWidget(self.sw_force_width)
  749. lo.addLayout(sw_row)
  750. # line_step — label + valeur sur la même ligne (non éditable, piloté par Settings)
  751. ls_row = QHBoxLayout()
  752. lbl_ls = QLabel(self.t.get("line_step", "Line Step"))
  753. lbl_ls.setStyleSheet("color:#ddd;font-size:12px;")
  754. self.translation_map[lbl_ls] = "line_step"
  755. self._line_step_value_lbl = QLabel("0.1307")
  756. self._line_step_value_lbl.setStyleSheet(
  757. "color:#aaa;font-size:12px;font-family:Consolas;"
  758. "background:#2a2a2a;border:1px solid #333;"
  759. "border-radius:4px;padding:2px 8px;"
  760. )
  761. self._line_step_value_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  762. ls_row.addWidget(lbl_ls)
  763. ls_row.addStretch()
  764. ls_row.addWidget(self._line_step_value_lbl)
  765. lo.addLayout(ls_row)
  766. # Enregistrer dans controls pour que _get_val("line_step") fonctionne
  767. self.controls["line_step"] = {"label": self._line_step_value_lbl, "precision": 4, "is_int": False}
  768. self._add_slider_input(lo, "dpi_resolution", 10, 1200, 254, "dpi", is_int=True)
  769. lo.addSpacing(6)
  770. ORIGIN_KEYS = ["Lower-Left","Upper-Left","Lower-Right","Upper-Right","Center","Custom"]
  771. origin_opts = [(k, self.t_orig.get(k, k)) for k in ORIGIN_KEYS]
  772. self._add_combo(lo, "origin_point", origin_opts, "origin_mode",
  773. callback=self._on_origin_change)
  774. self.custom_offset_frame = QFrame()
  775. self.custom_offset_frame.setVisible(False)
  776. cf_lo = QVBoxLayout(self.custom_offset_frame)
  777. cf_lo.setContentsMargins(0, 0, 0, 0)
  778. self._add_simple_input(cf_lo, "custom_offset_x", 0.0, "custom_x")
  779. self._add_simple_input(cf_lo, "custom_offset_y", 0.0, "custom_y")
  780. lo.addWidget(self.custom_offset_frame)
  781. def _setup_tab_img(self):
  782. lo = self._tab_img._inner
  783. self._add_slider_input(lo, "contrast", -1.0, 1.0, 0.0, "contrast")
  784. self._add_slider_input(lo, "gamma", 0.1, 6.0, 1.0, "gamma")
  785. self._add_slider_input(lo, "thermal", 0.1, 3.0, 1.5, "thermal")
  786. sw_row = QHBoxLayout()
  787. lbl_inv = QLabel(self.t.get("invert_color", "invert_color"))
  788. lbl_inv.setStyleSheet("color:#ddd;font-size:12px;")
  789. self.translation_map[lbl_inv] = "invert_color"
  790. self.sw_invert = Switch()
  791. self.sw_invert.toggled.connect(self._on_switch_with_delay)
  792. sw_row.addWidget(lbl_inv)
  793. sw_row.addStretch()
  794. sw_row.addWidget(self.sw_invert)
  795. lo.addLayout(sw_row)
  796. def _setup_tab_laser(self):
  797. lo = self._tab_laser._inner
  798. self._add_slider_input(lo, "feedrate", 500, 20000, 3000, "feedrate", is_int=True)
  799. self._add_slider_input(lo, "overscan", 0, 50, 10.0, "premove")
  800. pow_row = QHBoxLayout()
  801. left_p = QVBoxLayout()
  802. self._add_simple_input(left_p, "max_power", 40.0, "max_p")
  803. _, min_p_entry = self._add_simple_input(left_p, "min_power", 10.0, "min_p")
  804. # Re-vérifier le warning framing dès que min_power change
  805. min_p_entry.editingFinished.connect(
  806. lambda: self._check_framing_power_warning()
  807. if hasattr(self, '_framing_power_warning') else None
  808. )
  809. pow_row.addLayout(left_p)
  810. lo.addLayout(pow_row)
  811. self._add_slider_input(lo, "laser_latency", -20, 20, 0, "laser_latency")
  812. self._add_slider_input(lo, "gray_steps", 2, 256, 256, "gray_steps", is_int=True)
  813. def _setup_tab_gcode(self):
  814. lo = self._tab_gcode._inner
  815. sec_frame = QFrame()
  816. self._gcode_sec_frame = sec_frame
  817. sec_frame.setStyleSheet(
  818. "QFrame{background:#2b2b2b;border:1px solid #555;border-radius:8px;}"
  819. "QLabel{border:none;background:transparent;}"
  820. )
  821. sec_lo = QVBoxLayout(sec_frame)
  822. sec_lo.setContentsMargins(10, 8, 10, 8)
  823. sec_lo.setSpacing(4)
  824. hdr_row = QHBoxLayout()
  825. lbl_glob = QLabel(self.t.get("global_machine_params", "Global Machine Params"))
  826. lbl_glob.setStyleSheet("color:#FF9500;font-weight:bold;font-size:11px;")
  827. self.translation_map[lbl_glob] = "global_machine_params"
  828. self.lock_btn = QPushButton("🔒")
  829. self.lock_btn.setFixedSize(30, 26)
  830. self.lock_btn.setStyleSheet(
  831. "QPushButton{background:#444;color:white;border-radius:4px;border:none;font-size:13px;}"
  832. "QPushButton:hover{background:#666;}"
  833. )
  834. self.is_locked = True
  835. self.lock_btn.clicked.connect(self.toggle_machine_lock)
  836. hdr_row.addWidget(lbl_glob)
  837. hdr_row.addStretch()
  838. hdr_row.addWidget(self.lock_btn)
  839. sec_lo.addLayout(hdr_row)
  840. self._machine_container = QWidget()
  841. self._machine_container.setStyleSheet("background:transparent;")
  842. mc_lo = QVBoxLayout(self._machine_container)
  843. mc_lo.setContentsMargins(0, 0, 0, 0)
  844. mc_lo.setSpacing(4)
  845. self._add_combo(mc_lo, "cmd_mode",
  846. ["M67 (Analog)", "S (Spindle)"], "cmd_mode")
  847. row_em = QHBoxLayout()
  848. self._add_simple_input(row_em, "m67_output", 0, "m67_e_num", precision=0, box_length=40)
  849. self._add_simple_input(row_em, "ctrl_max_value", 100, "ctrl_max", precision=0)
  850. mc_lo.addLayout(row_em)
  851. self._add_combo(mc_lo, "firing_mode",
  852. ["M3/M5", "M4/M5"], "firing_mode")
  853. sec_lo.addWidget(self._machine_container)
  854. lo.addWidget(sec_frame)
  855. self.apply_lock_state()
  856. lo.addSpacing(8)
  857. lbl_h = QLabel(self.t.get("gcode_header", "gcode_header"))
  858. lbl_h.setStyleSheet("font-weight:bold;font-size:11px;color:#ddd;")
  859. self.translation_map[lbl_h] = "gcode_header"
  860. lo.addWidget(lbl_h)
  861. self.txt_global_header_preview = QTextEdit()
  862. self.txt_global_header_preview.setFixedHeight(28)
  863. self.txt_global_header_preview.setReadOnly(True)
  864. self.txt_global_header_preview.setStyleSheet(
  865. "QTextEdit{background:#222;color:#666;border:none;font-family:Consolas;font-size:9px;}")
  866. self.txt_global_header_preview.setPlainText("(Machine Settings Header…)")
  867. lo.addWidget(self.txt_global_header_preview)
  868. self.txt_header = QTextEdit()
  869. self.txt_header.setFixedHeight(50)
  870. self.txt_header.setStyleSheet(self._textedit_style())
  871. lo.addWidget(self.txt_header)
  872. lbl_f = QLabel(self.t.get("gcode_footer", "gcode_footer"))
  873. lbl_f.setStyleSheet("font-weight:bold;font-size:11px;color:#ddd;")
  874. self.translation_map[lbl_f] = "gcode_footer"
  875. lo.addWidget(lbl_f)
  876. self.txt_global_footer_preview = QTextEdit()
  877. self.txt_global_footer_preview.setFixedHeight(28)
  878. self.txt_global_footer_preview.setReadOnly(True)
  879. self.txt_global_footer_preview.setStyleSheet(
  880. "QTextEdit{background:#222;color:#666;border:none;font-family:Consolas;font-size:9px;}")
  881. self.txt_global_footer_preview.setPlainText("(Machine Settings Footer…)")
  882. lo.addWidget(self.txt_global_footer_preview)
  883. self.txt_footer = QTextEdit()
  884. self.txt_footer.setFixedHeight(50)
  885. self.txt_footer.setStyleSheet(self._textedit_style())
  886. lo.addWidget(self.txt_footer)
  887. lo.addSpacing(8)
  888. lbl_fr = QLabel(self.t.get("point_fram_options", "Pointing / Framing Options"))
  889. lbl_fr.setStyleSheet("font-weight:bold;font-size:11px;color:#ddd;")
  890. self.translation_map[lbl_fr] = "point_fram_options"
  891. lo.addWidget(lbl_fr)
  892. pause_row = QHBoxLayout()
  893. self._lbl_pause = QLabel(self.t.get("pause_command", "pause_command"))
  894. lbl_pause = self._lbl_pause
  895. lbl_pause.setStyleSheet("color:#ddd;font-size:12px;")
  896. self.translation_map[lbl_pause] = "pause_command"
  897. self.pause_cmd_entry = QLineEdit("M0")
  898. self.pause_cmd_entry.setFixedWidth(60)
  899. self.pause_cmd_entry.setStyleSheet(self._entry_style())
  900. pause_row.addWidget(lbl_pause)
  901. pause_row.addWidget(self.pause_cmd_entry)
  902. pause_row.addStretch()
  903. lo.addLayout(pause_row)
  904. self.origin_pointer_var = False
  905. sw_ptr_row = QHBoxLayout()
  906. lbl_ptr = QLabel(self.t.get("origin_pointing", "origin_pointing"))
  907. lbl_ptr.setStyleSheet("color:#ddd;font-size:12px;")
  908. self.translation_map[lbl_ptr] = "origin_pointing"
  909. self.sw_pointer = Switch()
  910. self.sw_pointer.toggled.connect(self._on_framing_toggle)
  911. sw_ptr_row.addWidget(lbl_ptr)
  912. sw_ptr_row.addStretch()
  913. sw_ptr_row.addWidget(self.sw_pointer)
  914. lo.addLayout(sw_ptr_row)
  915. self.frame_var = False
  916. sw_frm_row = QHBoxLayout()
  917. lbl_frm = QLabel(self.t.get("framing_option", "framing_option"))
  918. lbl_frm.setStyleSheet("color:#ddd;font-size:12px;")
  919. self.translation_map[lbl_frm] = "framing_option"
  920. self.sw_frame = Switch()
  921. self.sw_frame.toggled.connect(self._on_framing_toggle)
  922. sw_frm_row.addWidget(lbl_frm)
  923. sw_frm_row.addStretch()
  924. sw_frm_row.addWidget(self.sw_frame)
  925. lo.addLayout(sw_frm_row)
  926. pwr_row = QHBoxLayout()
  927. self._lbl_framing_power = QLabel(self.t.get("framing_power", "framing_power"))
  928. lbl_fpwr = self._lbl_framing_power
  929. lbl_fpwr.setStyleSheet("color:#ddd;font-size:12px;")
  930. self.translation_map[lbl_fpwr] = "framing_power"
  931. self.frame_power_entry = QLineEdit("0")
  932. self.frame_power_entry.setFixedWidth(60)
  933. self.frame_power_entry.setStyleSheet(self._entry_style())
  934. self.frame_power_entry.editingFinished.connect(self._check_framing_power_warning)
  935. pwr_row.addWidget(lbl_fpwr)
  936. pwr_row.addWidget(self.frame_power_entry)
  937. pwr_row.addStretch()
  938. lo.addLayout(pwr_row)
  939. # ── Warning puissance framing/pointing > min_power ────────────
  940. self._framing_power_warning = QLabel()
  941. self._framing_power_warning.setWordWrap(True)
  942. self._framing_power_warning.setStyleSheet(
  943. "QLabel{"
  944. "background:#5c2a00;color:#ffaa44;"
  945. "border:1px solid #ff7700;"
  946. "border-radius:5px;padding:4px 8px;"
  947. "font-size:11px;font-weight:bold;"
  948. "}"
  949. )
  950. self._framing_power_warning.setVisible(False)
  951. lo.addWidget(self._framing_power_warning)
  952. self._add_combo(lo, "framing_ratio",
  953. ["5%","10%","20%","30%","50%","80%","100%"],
  954. "frame_feed_ratio_menu")
  955. if "frame_feed_ratio_menu" in self.controls:
  956. self.controls["frame_feed_ratio_menu"]["combo"].setCurrentText("20%")
  957. self._update_framing_state()
  958. # ── Panneau droit ─────────────────────────────────────────────────
  959. def _build_right_panel(self):
  960. w = QWidget()
  961. self._right_panel = w
  962. w.setStyleSheet("background:#111;")
  963. lo = QVBoxLayout(w)
  964. lo.setContentsMargins(4, 4, 4, 4)
  965. lo.setSpacing(2)
  966. # Canvas + colorbar fixe côte à côte
  967. canvas_row = QHBoxLayout()
  968. canvas_row.setSpacing(2)
  969. canvas_row.setContentsMargins(0, 0, 0, 0)
  970. self._canvas = _RasterCanvas(self._fig)
  971. self._setup_matplotlib()
  972. canvas_row.addWidget(self._canvas, stretch=1)
  973. self._cbar_widget = _ColorbarWidget()
  974. self._cbar_widget.setVisible(False) # masquée jusqu'à chargement d'une image
  975. canvas_row.addWidget(self._cbar_widget)
  976. lo.addLayout(canvas_row, stretch=5)
  977. # Bouton Reset View — wrapper transparent pour éviter ligne noire
  978. # label_reset_view = "reset_view"
  979. fit_pixmap = get_svg_pixmap(SVG_ICONS["FIT"], QSize(24, 24), "#ffffff")
  980. # self.btn_reset_view = QPushButton(label_reset_view, self._canvas)
  981. self.btn_reset_view = QPushButton(self._canvas)
  982. self.btn_reset_view.setIcon(QIcon(fit_pixmap))
  983. self.btn_reset_view.setFixedSize(30, 30)
  984. self.btn_reset_view.setStyleSheet(
  985. "QPushButton{background:#2c2c2c;color:#aaa;border:none;"
  986. "border-radius:4px;font-size:10px;}"
  987. "QPushButton:hover{background:#3a3a3a;color:white;}"
  988. )
  989. self.btn_reset_view.clicked.connect(self._canvas.reset_view)
  990. self.btn_reset_view.raise_()
  991. # Stats + Histogramme
  992. stats_frame = QFrame()
  993. self._stats_frame = stats_frame
  994. stats_frame.setStyleSheet(
  995. "QFrame{background:#202020;border:1px solid #333;border-radius:6px;}"
  996. )
  997. stats_frame.setFixedHeight(160)
  998. sf_lo = QHBoxLayout(stats_frame)
  999. sf_lo.setContentsMargins(8, 6, 8, 6)
  1000. sf_lo.setSpacing(8)
  1001. stats_text_w = QFrame()
  1002. stats_text_w.setStyleSheet("QFrame{border:none;background:transparent;}")
  1003. stats_text_lo = QVBoxLayout(stats_text_w)
  1004. stats_text_lo.setContentsMargins(8, 6, 8, 6)
  1005. stats_text_lo.setAlignment(Qt.AlignmentFlag.AlignVCenter)
  1006. self.stats_labels = []
  1007. for _ in range(6):
  1008. lbl = QLabel("")
  1009. lbl.setFont(QFont("Consolas", 10))
  1010. lbl.setStyleSheet("color:#aaaaaa;font-family:Consolas;font-size:12px;"
  1011. "background:transparent;border:none;")
  1012. stats_text_lo.addWidget(lbl)
  1013. self.stats_labels.append(lbl)
  1014. sf_lo.addWidget(stats_text_w)
  1015. self._hist_widget = _HistogramWidget()
  1016. sf_lo.addWidget(self._hist_widget, 2)
  1017. lo.addWidget(stats_frame, stretch=2)
  1018. return w
  1019. def _setup_matplotlib(self):
  1020. """Un seul axe, sans colorbar matplotlib — gérée en Qt."""
  1021. self._ax = self._fig.add_subplot(111)
  1022. self._fig.subplots_adjust(left=0.10, right=0.90, top=0.90, bottom=0.10)
  1023. self._ax.set_facecolor("#111111")
  1024. self._ax.tick_params(axis="both", colors="#888888", labelsize=9)
  1025. self._ax.set_axisbelow(False)
  1026. self._ax.grid(False) # masquée jusqu'à chargement d'une image
  1027. for spine in self._ax.spines.values():
  1028. spine.set_edgecolor("#333333")
  1029. # Masquer ticks et labels tant qu'aucune image n'est chargée
  1030. self._ax.tick_params(axis="both", length=0)
  1031. self._ax.set_xticklabels([])
  1032. self._ax.set_yticklabels([])
  1033. _ph_text = self.t.get("choose_image", "PLEASE SELECT AN IMAGE\nTO BEGIN")
  1034. self._placeholder_text = self._ax.text(
  1035. 0.5, 0.5, _ph_text,
  1036. color="#444444", fontsize=12, fontweight="bold",
  1037. ha="center", va="center", transform=self._ax.transAxes
  1038. )
  1039. # ── Helpers construction ──────────────────────────────────────────
  1040. def _add_slider_input(self, layout, label_key, vmin, vmax, default, key,
  1041. is_int=False, precision=2, unclamped_entry=False):
  1042. lbl = QLabel(self.t.get(label_key, label_key))
  1043. lbl.setStyleSheet("color:#ddd;font-size:12px;")
  1044. self.translation_map[lbl] = label_key
  1045. layout.addWidget(lbl)
  1046. row = QHBoxLayout()
  1047. slider = QSlider(Qt.Orientation.Horizontal)
  1048. slider.setRange(int(vmin * 100) if not is_int else int(vmin),
  1049. int(vmax * 100) if not is_int else int(vmax))
  1050. slider.setValue(int(default * 100) if not is_int else int(default))
  1051. entry = QLineEdit()
  1052. entry.setFixedWidth(70)
  1053. entry.setAlignment(Qt.AlignmentFlag.AlignCenter)
  1054. entry.setStyleSheet(self._entry_style())
  1055. fmt = "{:d}" if is_int else f"{{:.{precision}f}}"
  1056. entry.setText(fmt.format(int(default) if is_int else default))
  1057. def on_slider(v):
  1058. real = v if is_int else v / 100.0
  1059. entry.setText(fmt.format(int(real) if is_int else real))
  1060. def on_entry():
  1061. try:
  1062. v = float(entry.text().replace(",", "."))
  1063. # Si unclamped_entry, on borne le slider à vmax mais pas la valeur
  1064. slider_v = min(int(v) if is_int else int(v * 100),
  1065. int(vmax) if is_int else int(vmax * 100))
  1066. slider.setValue(slider_v)
  1067. self._schedule_preview()
  1068. except ValueError:
  1069. pass
  1070. slider.valueChanged.connect(on_slider)
  1071. slider.sliderReleased.connect(self._schedule_preview)
  1072. entry.editingFinished.connect(on_entry)
  1073. row.addWidget(slider)
  1074. row.addWidget(entry)
  1075. layout.addLayout(row)
  1076. self.controls[key] = {
  1077. "slider": slider, "entry": entry,
  1078. "is_int": is_int, "precision": precision,
  1079. "_vmin": vmin, "_vmax": vmax,
  1080. "unclamped": unclamped_entry,
  1081. }
  1082. return lbl, entry
  1083. def _add_simple_input(self, layout, label_key, default, key, precision=2, box_length=80):
  1084. row = QHBoxLayout()
  1085. lbl = QLabel(self.t.get(label_key, label_key))
  1086. lbl.setStyleSheet("color:#ddd;font-size:12px;")
  1087. self.translation_map[lbl] = label_key
  1088. entry = QLineEdit()
  1089. entry.setFixedWidth(box_length)
  1090. entry.setStyleSheet(self._entry_style())
  1091. fmt = "{:d}" if precision == 0 else f"{{:.{precision}f}}"
  1092. entry.setText(fmt.format(int(default) if precision == 0 else default))
  1093. entry.editingFinished.connect(self._schedule_preview)
  1094. row.addWidget(lbl)
  1095. row.addStretch()
  1096. row.addWidget(entry)
  1097. layout.addLayout(row)
  1098. self.controls[key] = {"slider": entry, "entry": entry,
  1099. "is_int": (precision == 0), "precision": precision}
  1100. return lbl, entry
  1101. def _add_combo(self, layout, label_key, options, key, callback=None):
  1102. lbl = QLabel(self.t.get(label_key, label_key))
  1103. lbl.setStyleSheet("color:#ddd;font-size:12px;")
  1104. self.translation_map[lbl] = label_key
  1105. layout.addWidget(lbl)
  1106. combo = QComboBox()
  1107. combo.setFixedHeight(30)
  1108. combo.setStyleSheet(self._combo_style())
  1109. is_tuple = options and isinstance(options[0], tuple)
  1110. if is_tuple:
  1111. for k, v in options:
  1112. combo.addItem(v, userData=k)
  1113. else:
  1114. for o in options:
  1115. combo.addItem(o)
  1116. def on_change():
  1117. if callback:
  1118. ud = combo.currentData()
  1119. callback(ud if ud is not None else combo.currentText())
  1120. self._schedule_preview()
  1121. combo.currentIndexChanged.connect(on_change)
  1122. layout.addWidget(combo)
  1123. self.controls[key] = {"combo": combo, "_is_tuple": is_tuple, "_label": lbl}
  1124. return combo
  1125. # ── Styles ────────────────────────────────────────────────────────
  1126. @staticmethod
  1127. @staticmethod
  1128. def _arrow_svg_path(color: str) -> str:
  1129. src = SVG_ICONS.get("ARROW_DOWN", "")
  1130. if not src or not os.path.isfile(src):
  1131. return src.replace("\\", "/")
  1132. try:
  1133. with open(src, "r", encoding="utf-8") as f:
  1134. svg = f.read()
  1135. import re
  1136. svg = re.sub(r'fill\s*=\s*"[^"]*"', f'fill="{color}"', svg)
  1137. svg = re.sub(r'fill\s*:\s*[^;}"]+', f'fill:{color}', svg)
  1138. svg = re.sub(r'stroke\s*=\s*"[^"]*"', f'stroke="{color}"',svg)
  1139. svg = re.sub(r'stroke\s*:\s*[^;}"]+', f'stroke:{color}', svg)
  1140. tmp = tempfile.NamedTemporaryFile(
  1141. suffix=f'_arrow_{color.strip("#")}.svg',
  1142. delete=False, mode='w', encoding='utf-8')
  1143. tmp.write(svg); tmp.close()
  1144. return tmp.name.replace("\\", "/")
  1145. except Exception:
  1146. return src.replace("\\", "/")
  1147. @staticmethod
  1148. def _btn_style(bg="#1F6AA5", hover="#2a6dbd"):
  1149. return (f"QPushButton{{background:{bg};color:white;border-radius:6px;"
  1150. f"border:none;font-weight:bold;}}"
  1151. f"QPushButton:hover{{background:{hover};}}")
  1152. def _seg_btn_style(self):
  1153. c = self._theme_colors
  1154. return (
  1155. f"QPushButton{{background:{c['seg_bg']};color:{c['seg_text']};"
  1156. f"border:1px solid {c['seg_border']};border-radius:4px;"
  1157. f"padding:2px 8px;min-width:60px;font-size:11px;}}"
  1158. f"QPushButton:checked{{background:#1F6AA5;color:white;border-color:#2a6dbd;}}"
  1159. f"QPushButton:hover:!checked{{background:{c['seg_hover']};color:{c['seg_active_text']};}}"
  1160. )
  1161. def _entry_style(self):
  1162. c = self._theme_colors
  1163. return (f"QLineEdit{{background:{c['bg_entry']};border:1px solid {c['border_strong']};"
  1164. f"border-radius:4px;color:{c['text']};padding:2px;}}")
  1165. def _textedit_style(self):
  1166. c = self._theme_colors
  1167. return (f"QTextEdit{{background:{c['bg_entry_alt']};border:1px solid {c['border_strong']};"
  1168. f"border-radius:6px;color:#8fbdf0;"
  1169. f"font-family:Consolas;font-size:10px;padding:4px;}}")
  1170. def _combo_style(self):
  1171. c = self._theme_colors
  1172. arrow_path = self._arrow_svg_path(c['arrow_color'])
  1173. return (
  1174. f"QComboBox{{background:{c['bg_entry']};border:1px solid {c['border_strong']};"
  1175. f"border-radius:5px;padding:3px 30px 3px 10px;color:{c['text']};}}"
  1176. f"QComboBox::drop-down{{subcontrol-origin:padding;"
  1177. f"subcontrol-position:top right;width:25px;border:none;background:transparent;}}"
  1178. f"QComboBox::down-arrow{{image:url({arrow_path});width:12px;height:8px;}}"
  1179. f"QComboBox QAbstractItemView{{background:{c['bg_entry']};color:{c['text']};"
  1180. f"selection-background-color:{c['combo_selection']};border:1px solid {c['border_strong']};}}"
  1181. )
  1182. # ── Handlers ──────────────────────────────────────────────────────
  1183. def _on_raster_change(self, key):
  1184. self._raster_mode = key
  1185. for k, b in self._btn_raster.items():
  1186. b.setChecked(k == key)
  1187. if key == "vertical":
  1188. self.width_label_widget.setText(self.t.get("target_height", "target_height"))
  1189. self.lbl_force_width.setText(self.t.get("force_height", "force_height"))
  1190. else:
  1191. self.width_label_widget.setText(self.t.get("target_width", "target_width"))
  1192. self.lbl_force_width.setText(self.t.get("force_width", "force_width"))
  1193. self._sync_line_step_from_mode(key)
  1194. self._schedule_preview()
  1195. def _sync_line_step_from_mode(self, mode):
  1196. """Met à jour le label line_step depuis hor_linestep ou ver_linestep."""
  1197. setting_key = "hor_linestep" if mode == "horizontal" else "ver_linestep"
  1198. raw = self.controller.config_manager.get_item("machine_settings", setting_key)
  1199. try:
  1200. val = float(raw) if raw is not None else 0.1307
  1201. except (ValueError, TypeError):
  1202. val = 0.1307
  1203. if hasattr(self, "_line_step_value_lbl"):
  1204. self._line_step_value_lbl.setText(f"{val:.4f}")
  1205. def _on_origin_change(self, value):
  1206. self.custom_offset_frame.setVisible(value == "Custom")
  1207. def _on_framing_toggle(self, _=None):
  1208. self._update_framing_state()
  1209. def _update_framing_state(self):
  1210. is_pointing = self.sw_pointer.isChecked()
  1211. is_framing = self.sw_frame.isChecked()
  1212. any_active = is_pointing or is_framing
  1213. c = self._theme_colors
  1214. # ── Activation fonctionnelle ──────────────────────────────────
  1215. self.pause_cmd_entry.setEnabled(any_active)
  1216. self.frame_power_entry.setEnabled(any_active)
  1217. if "frame_feed_ratio_menu" in self.controls:
  1218. self.controls["frame_feed_ratio_menu"]["combo"].setEnabled(is_framing)
  1219. # ── Styles entrées ────────────────────────────────────────────
  1220. entry_on = self._entry_style()
  1221. entry_off = (
  1222. f"QLineEdit{{background:{c.get('bg_entry','#2b2b2b')};color:#555555;"
  1223. f"border:1px solid #3a3a3a;border-radius:4px;padding:2px;}}"
  1224. )
  1225. self.pause_cmd_entry.setStyleSheet(entry_on if any_active else entry_off)
  1226. self.frame_power_entry.setStyleSheet(entry_on if any_active else entry_off)
  1227. # ── Styles labels associés (widget par widget pour ne pas être écrasé) ──
  1228. lbl_color_on = c.get('text', '#dddddd')
  1229. lbl_color_off = '#555555'
  1230. lbl_style_on = f"color:{lbl_color_on};font-size:12px;border:none;background:transparent;"
  1231. lbl_style_off = "color:#555555;font-size:12px;border:none;background:transparent;"
  1232. for lbl in [
  1233. getattr(self, '_lbl_pause', None),
  1234. getattr(self, '_lbl_framing_power', None),
  1235. ]:
  1236. if lbl is not None:
  1237. lbl.setStyleSheet(lbl_style_on if any_active else lbl_style_off)
  1238. # Label du combo framing_ratio (actif seulement si is_framing)
  1239. if "frame_feed_ratio_menu" in self.controls:
  1240. combo = self.controls["frame_feed_ratio_menu"]["combo"]
  1241. lbl_ratio = self.controls["frame_feed_ratio_menu"].get("_label")
  1242. if is_framing:
  1243. combo.setStyleSheet(self._combo_style())
  1244. if lbl_ratio:
  1245. lbl_ratio.setStyleSheet(lbl_style_on)
  1246. else:
  1247. combo.setStyleSheet(
  1248. f"QComboBox{{background:{c.get('bg_entry','#2b2b2b')};color:#555555;"
  1249. f"border:1px solid #3a3a3a;border-radius:5px;"
  1250. f"padding:3px 30px 3px 10px;}}"
  1251. f"QComboBox::drop-down{{border:none;background:transparent;}}"
  1252. f"QComboBox QAbstractItemView{{background:{c.get('bg_entry','#2b2b2b')};"
  1253. f"color:{c.get('text','#ddd')};}}"
  1254. )
  1255. if lbl_ratio:
  1256. lbl_ratio.setStyleSheet(lbl_style_off)
  1257. # ── Warning puissance ─────────────────────────────────────────
  1258. if hasattr(self, '_framing_power_warning'):
  1259. self._check_framing_power_warning()
  1260. def _check_framing_power_warning(self):
  1261. """Affiche un warning si la puissance pointing/framing >= min_power (risque de gravure)."""
  1262. if not hasattr(self, '_framing_power_warning'):
  1263. return
  1264. is_pointing = self.sw_pointer.isChecked()
  1265. is_framing = self.sw_frame.isChecked()
  1266. if not (is_pointing or is_framing):
  1267. self._framing_power_warning.setVisible(False)
  1268. return
  1269. try:
  1270. frame_pwr = float(self.frame_power_entry.text().replace(",", "."))
  1271. except ValueError:
  1272. self._framing_power_warning.setVisible(False)
  1273. return
  1274. min_pwr = self._get_val("min_p", default=0.0)
  1275. if frame_pwr > 0 and frame_pwr >= min_pwr:
  1276. warn_text = (
  1277. f"⚠ Puissance pointing/framing ({frame_pwr:.1f}) "
  1278. f"≥ puissance min ({min_pwr:.1f}) — "
  1279. f"risque de gravure du matériau !"
  1280. )
  1281. self._framing_power_warning.setText(warn_text)
  1282. self._framing_power_warning.setVisible(True)
  1283. else:
  1284. self._framing_power_warning.setVisible(False)
  1285. def _on_switch_with_delay(self, _=None):
  1286. QTimer.singleShot(120, self._schedule_preview)
  1287. def _schedule_preview(self):
  1288. if not self._loading:
  1289. self._debounce_timer.start(80)
  1290. # ── Loading Overlay ───────────────────────────────────────────────
  1291. def _show_loading(self, msg=None):
  1292. if hasattr(self, "_overlay"):
  1293. return
  1294. if msg is None:
  1295. msg = self.t.get("loading", "Loading…")
  1296. self._overlay = show_loading_overlay(self, msg)
  1297. def _hide_loading(self):
  1298. if hasattr(self, "_overlay"):
  1299. hide_loading_overlay(self._overlay)
  1300. del self._overlay
  1301. def showEvent(self, e):
  1302. super().showEvent(e)
  1303. if getattr(self, '_render_pending', False):
  1304. self._render_pending = False
  1305. # Overlay immédiat visible dès que la vue apparaît
  1306. if self.input_image_path and os.path.exists(self.input_image_path):
  1307. self._show_loading(self.t.get("loading", "Loading…"))
  1308. # Render au prochain tour d'événements — la vue est déjà affichée
  1309. QTimer.singleShot(0, self._initial_render)
  1310. def eventFilter(self, obj, event):
  1311. from PyQt6.QtCore import QEvent
  1312. if (hasattr(self, "_center_tabs_fn")
  1313. and hasattr(self, "_tabs")
  1314. and obj is self._tabs.tabBar()
  1315. and event.type() == QEvent.Type.Resize):
  1316. self._center_tabs_fn()
  1317. return super().eventFilter(obj, event)
  1318. def resizeEvent(self, e):
  1319. super().resizeEvent(e)
  1320. if hasattr(self, "_overlay"):
  1321. self._overlay.resize(self.size())
  1322. # ── Rendu / Preview ───────────────────────────────────────────────
  1323. def _initial_render(self):
  1324. # Afficher l'overlay si pas déjà visible (peut avoir été créé dans __init__)
  1325. if self.input_image_path and os.path.exists(self.input_image_path):
  1326. if not hasattr(self, '_overlay'):
  1327. self._show_loading(self.t.get("loading", "Loading…"))
  1328. try:
  1329. self._do_update_preview(fit=True)
  1330. finally:
  1331. self._hide_loading()
  1332. def _do_update_preview(self, fit=False):
  1333. if self._loading:
  1334. return
  1335. if not self.input_image_path or not os.path.isfile(self.input_image_path):
  1336. self._ax.grid(False)
  1337. self._ax.tick_params(axis="both", length=0)
  1338. self._ax.set_xticklabels([])
  1339. self._ax.set_yticklabels([])
  1340. for spine in self._ax.spines.values():
  1341. spine.set_visible(False)
  1342. self._cbar_widget.setVisible(False)
  1343. self._canvas._after_draw_cb = None
  1344. self._canvas.set_overlay(None)
  1345. self._canvas.redraw(fit=fit)
  1346. return
  1347. res = self.process_logic()
  1348. if not res or res[0] is None:
  1349. if self._img_plot:
  1350. self._img_plot.set_visible(False)
  1351. self._canvas._after_draw_cb = None
  1352. self._canvas.set_overlay(None)
  1353. self._canvas.redraw(fit=fit)
  1354. return
  1355. matrix, geom = res
  1356. real_w = geom["real_w"]
  1357. real_h = geom["real_h"]
  1358. rf = geom["rect_full"]
  1359. offX, offY = self.calculate_offsets(real_w, real_h)
  1360. v_min = self._get_val("min_p") or 0
  1361. v_max = self._get_val("max_p") or 255
  1362. # Nettoyage overscan patches + textes
  1363. for p in self._overscan_patches:
  1364. try: p.remove()
  1365. except Exception: pass
  1366. self._overscan_patches = []
  1367. for t in self._overscan_texts:
  1368. try: t.remove()
  1369. except Exception: pass
  1370. self._overscan_texts = []
  1371. if self._rect_overscan is not None:
  1372. try: self._rect_overscan.remove()
  1373. except Exception: pass
  1374. self._rect_overscan = None
  1375. self._update_image_artist(matrix, offX, offY, real_w, real_h, v_min, v_max)
  1376. # Colorbar Qt fixe
  1377. self._cbar_widget.setVisible(True)
  1378. self._cbar_widget.set_range(v_min, v_max,
  1379. self.t.get("laser_power_level", "Laser Power"))
  1380. # Overscan — calcul des rectangles (coordonnées mm exactes)
  1381. direction = self._raster_mode
  1382. ax = self._ax
  1383. if direction == "horizontal":
  1384. global_y = offY; global_h = real_h
  1385. ow_l = abs(rf[0]); ow_r = rf[2] - real_w
  1386. overscan_rects = []
  1387. if ow_l > 0.1: overscan_rects.append((offX + rf[0], offY, ow_l, real_h))
  1388. if ow_r > 0.1: overscan_rects.append((offX + real_w, offY, ow_r, real_h))
  1389. else:
  1390. oh_b = abs(rf[1]); oh_t = rf[3] - real_h
  1391. global_y = offY + rf[1]; global_h = rf[3] - rf[1]
  1392. overscan_rects = []
  1393. if oh_b > 0.1: overscan_rects.append((offX, offY + rf[1], real_w, oh_b))
  1394. if oh_t > 0.1: overscan_rects.append((offX, offY + real_h, real_w, oh_t))
  1395. border_rect = (offX + rf[0], global_y, rf[2] - rf[0], global_h)
  1396. decal = 0.5
  1397. # Restaurer les spines et ticks maintenant qu'une image est affichée
  1398. for spine in ax.spines.values():
  1399. spine.set_visible(True)
  1400. ax.set_xlim(offX + rf[0] - decal, offX + rf[2] + decal)
  1401. ax.set_ylim(global_y - decal, global_y + global_h + decal)
  1402. ax.tick_params(top=True, right=True, which="both")
  1403. # Labels masqués dans matplotlib → redessinés en QPainter natif (nets au zoom)
  1404. ax.xaxis.set_tick_params(labelbottom=False, labeltop=False)
  1405. ax.yaxis.set_tick_params(labelleft=False, labelright=False)
  1406. ax.tick_params(axis="both", colors="#888888", length=4, width=1)
  1407. ax.set_axisbelow(False)
  1408. ax.grid(False) # grille redessinée en QPainter natif (nette au zoom)
  1409. if self._origin_marker:
  1410. try: self._origin_marker[0].remove()
  1411. except Exception: pass
  1412. self._origin_marker = None # redessiné en QPainter natif
  1413. # Callback appelé par redraw() après tight_layout+draw() — transData valide ici
  1414. def _build_overlay(pw, ph):
  1415. td = ax.transData
  1416. tax = ax.transAxes
  1417. def m2p(mx, my):
  1418. d = td.transform((mx, my))
  1419. return float(d[0]), float(ph - d[1])
  1420. xlim = tuple(ax.get_xlim())
  1421. ylim = tuple(ax.get_ylim())
  1422. # Ticks réels de matplotlib (filtrés dans les limites)
  1423. yticks = [v for v in ax.get_yticks() if ylim[0] - 1e-9 <= v <= ylim[1] + 1e-9]
  1424. xticks = [v for v in ax.get_xticks() if xlim[0] - 1e-9 <= v <= xlim[1] + 1e-9]
  1425. # Bords de l'axe en pixels pixmap
  1426. ax_x0d, ax_y0d = tax.transform((0, 0))
  1427. ax_x1d, ax_y1d = tax.transform((1, 1))
  1428. ax_left = float(ax_x0d)
  1429. ax_right = float(ax_x1d)
  1430. ax_top = float(ph - ax_y1d)
  1431. ax_bot = float(ph - ax_y0d)
  1432. def fmt_val(v):
  1433. if v == int(v):
  1434. return str(int(v))
  1435. return f"{v:.1f}"
  1436. self._canvas.set_overlay({
  1437. 'transform': m2p,
  1438. 'xlim': xlim,
  1439. 'ylim': ylim,
  1440. 'overscan_rects': overscan_rects,
  1441. 'border_rect': border_rect,
  1442. 'origin': (0.0, 0.0),
  1443. 'grid_xs': list(xticks),
  1444. 'grid_ys': list(yticks),
  1445. 'yticks': [(v, fmt_val(v)) for v in yticks],
  1446. 'xticks': [(v, fmt_val(v)) for v in xticks],
  1447. 'ax_left': ax_left,
  1448. 'ax_right': ax_right,
  1449. 'ax_top': ax_top,
  1450. 'ax_bot': ax_bot,
  1451. 'direction': direction,
  1452. })
  1453. self._canvas._after_draw_cb = _build_overlay
  1454. est_min = geom.get("est_min", 0.0)
  1455. ts = int(est_min * 60)
  1456. hh, mm, ss = ts // 3600, (ts % 3600) // 60, ts % 60
  1457. self._update_stats(geom["w_px"], geom["h_px"], real_w, real_h,
  1458. geom["scan_step"], geom["l_step"], hh, mm, ss)
  1459. self._hist_widget.update_data(
  1460. matrix, v_min, v_max,
  1461. label_title=self.t_stats.get("power_distribution", "Power Distribution"),
  1462. label_power=self.t_stats.get("power_value", "Power"),
  1463. label_count=self.t_stats.get("pixel_count", "Pixel count"),
  1464. )
  1465. self._canvas.redraw(fit=fit)
  1466. def _update_image_artist(self, matrix, offX, offY, real_w, real_h, v_min, v_max):
  1467. if self._placeholder_text is not None:
  1468. try: self._placeholder_text.remove()
  1469. except Exception: pass
  1470. self._placeholder_text = None
  1471. img_extent = [offX, offX + real_w, offY, offY + real_h]
  1472. if self._img_plot is None:
  1473. self._img_plot = self._ax.imshow(
  1474. matrix, cmap="gray_r", origin="upper",
  1475. extent=img_extent, aspect="equal",
  1476. vmin=v_min, vmax=v_max,
  1477. interpolation="nearest", zorder=1
  1478. )
  1479. else:
  1480. self._img_plot.set_data(matrix)
  1481. self._img_plot.set_extent(img_extent)
  1482. self._img_plot.set_clim(v_min, v_max)
  1483. self._img_plot.set_visible(True)
  1484. def _update_stats(self, w_px, h_px, real_w, real_h,
  1485. scan_step, line_step, hh, mm, ss):
  1486. ts = self.t_stats
  1487. lines = [
  1488. f"{ts.get('real_dims','REAL DIMS'):<18}: {real_w:.2f} x {real_h:.2f} mm",
  1489. f"{ts.get('est_time','EST. TIME'):<18}: {hh:02d}:{mm:02d}:{ss:02d}",
  1490. f"{ts.get('file_size','FILE SIZE'):<18}: {self.estimated_file_size}",
  1491. f"{ts.get('matrix_size','MATRIX'):<18}: {w_px} x {h_px} px",
  1492. f"{ts.get('scan_step','SCAN STEP'):<18}: {scan_step:.4f} mm",
  1493. f"{ts.get('line_step','LINE STEP'):<18}: {line_step:.4f} mm",
  1494. ]
  1495. for lbl, txt in zip(self.stats_labels, lines):
  1496. lbl.setText(txt)
  1497. # ── Logique moteur ────────────────────────────────────────────────
  1498. def process_logic(self):
  1499. if not self.input_image_path or not os.path.isfile(self.input_image_path):
  1500. return None, None
  1501. try:
  1502. ui_dim = self._get_val("width")
  1503. raster_mode = self._raster_mode
  1504. settings = {
  1505. "line_step": self._get_val("line_step"),
  1506. "gamma": self._get_val("gamma"),
  1507. "contrast": self._get_val("contrast"),
  1508. "thermal": self._get_val("thermal"),
  1509. "min_p": self._get_val("min_p"),
  1510. "max_p": self._get_val("max_p"),
  1511. "dpi": self._get_val("dpi"),
  1512. "gray_steps": self._get_val("gray_steps"),
  1513. "premove": self._get_val("premove"),
  1514. "feedrate": self._get_val("feedrate"),
  1515. "speed": self._get_val("feedrate"),
  1516. "invert": self.sw_invert.isChecked(),
  1517. "ui_dimension": ui_dim,
  1518. "raster_mode": raster_mode,
  1519. "force_dim": self.sw_force_width.isChecked(),
  1520. }
  1521. if settings["force_dim"]:
  1522. if raster_mode == "horizontal":
  1523. settings["width"] = ui_dim
  1524. else:
  1525. settings["height"] = ui_dim
  1526. current_cache = None
  1527. if self._source_img_path == self.input_image_path:
  1528. current_cache = self._source_img_cache
  1529. results = self.engine.process_image_logic(
  1530. self.input_image_path, settings,
  1531. source_img_cache=current_cache
  1532. )
  1533. matrix, img_obj, geom, mem_warn = results
  1534. # Afficher ou masquer le warning mémoire
  1535. if hasattr(self, "_mem_warn_label"):
  1536. self._update_mem_warning(mem_warn, geom)
  1537. if raster_mode == "vertical":
  1538. geom["machine_step_x"] = geom.get("y_step", 0.1)
  1539. geom["machine_step_y"] = geom.get("x_step", 0.1)
  1540. else:
  1541. geom["machine_step_x"] = geom.get("x_step", 0.1)
  1542. geom["machine_step_y"] = geom.get("y_step", 0.1)
  1543. self._source_img_cache = img_obj
  1544. self._source_img_path = self.input_image_path
  1545. self._last_matrix = matrix
  1546. self._last_geom = geom
  1547. self.estimated_file_size = geom.get("file_size_str", "N/A")
  1548. return matrix, geom
  1549. except Exception as e:
  1550. import traceback; traceback.print_exc()
  1551. return None, None
  1552. def calculate_offsets(self, real_w, real_h):
  1553. origin_ctrl = self.controls.get("origin_mode")
  1554. origin_key = "Lower-Left"
  1555. if origin_ctrl:
  1556. combo = origin_ctrl["combo"]
  1557. ud = combo.currentData()
  1558. origin_key = ud if ud else combo.currentText()
  1559. cx = self._get_val("custom_x") or 0.0
  1560. cy = self._get_val("custom_y") or 0.0
  1561. return self.engine.calculate_offsets(origin_key, real_w, real_h, cx, cy)
  1562. def _update_mem_warning(self, mem_warn, geom):
  1563. """Met à jour le label de warning mémoire."""
  1564. if not hasattr(self, "_mem_warn_label"):
  1565. return
  1566. w_px = geom.get("w_px", 0)
  1567. h_px = geom.get("h_px", 0)
  1568. total = w_px * h_px
  1569. rescaled = total < (self._get_val("width") / (25.4 / max(1, self._get_val("dpi"))))
  1570. if total > 10_000_000:
  1571. dpi_now = int(self._get_val("dpi"))
  1572. dpi_sug = max(10, int(dpi_now * 0.7))
  1573. tpl = self.t.get("mem_warn_hard",
  1574. "⚠ Matrix too large ({total:.1f}M px) — image was rescaled."
  1575. "\nTry reducing DPI (e.g. {dpi_sug} instead of {dpi_now}).")
  1576. msg = tpl.format(total=total/1_000_000, dpi_sug=dpi_sug, dpi_now=dpi_now)
  1577. self._mem_warn_label.setStyleSheet(
  1578. "color: #e74c3c; font-size: 11px; background: transparent; border: none;")
  1579. self._mem_warn_label.setText(msg)
  1580. self._mem_warn_label.setVisible(True)
  1581. elif mem_warn:
  1582. dpi_now = int(self._get_val("dpi"))
  1583. dpi_sug = max(10, int(dpi_now * 0.7))
  1584. tpl = self.t.get("mem_warn_soft",
  1585. "⚠ Large matrix ({total:.1f}M px) — may be slow."
  1586. "\nConsider reducing DPI (e.g. {dpi_sug} instead of {dpi_now}).")
  1587. msg = tpl.format(total=total/1_000_000, dpi_sug=dpi_sug, dpi_now=dpi_now)
  1588. self._mem_warn_label.setStyleSheet(
  1589. "color: #FF9500; font-size: 11px; background: transparent; border: none;")
  1590. self._mem_warn_label.setText(msg)
  1591. self._mem_warn_label.setVisible(True)
  1592. else:
  1593. self._mem_warn_label.setVisible(False)
  1594. def _get_val(self, key, default=0.0):
  1595. ctrl = self.controls.get(key)
  1596. if not ctrl: return default
  1597. try:
  1598. # Cas label informatif (ex: line_step)
  1599. if "label" in ctrl and "entry" not in ctrl:
  1600. return float(ctrl["label"].text().replace(",", "."))
  1601. return float(ctrl["entry"].text().replace(",", "."))
  1602. except (ValueError, AttributeError):
  1603. return default
  1604. # ── Génération G-Code ─────────────────────────────────────────────
  1605. # ── Helper : QMessageBox isolé du stylesheet parent ──────────────────────
  1606. def _msgbox(self, icon: QMessageBox.Icon, title: str, text: str) -> None:
  1607. """
  1608. QMessageBox sans héritage de stylesheet parent.
  1609. Évite le fond transparent/corrompu sous Linux causé par les styles
  1610. globaux appliqués sur les widgets parents.
  1611. """
  1612. colors = getattr(self, '_theme_colors', {})
  1613. bg = colors.get('bg_card_alt', '#2b2b2b')
  1614. bg_btn = colors.get('btn_neutral', '#3a3a3a')
  1615. bg_hov = colors.get('btn_neutral_hover', '#4a4a4a')
  1616. fg = colors.get('text', '#ffffff')
  1617. border = colors.get('border', '#555555')
  1618. mb = QMessageBox()
  1619. mb.setIcon(icon)
  1620. mb.setWindowTitle(title)
  1621. mb.setText(text)
  1622. mb.setStyleSheet(f"""
  1623. QMessageBox {{
  1624. background-color: {bg};
  1625. color: {fg};
  1626. }}
  1627. QMessageBox QLabel {{
  1628. color: {fg};
  1629. background: transparent;
  1630. border: none;
  1631. font-size: 13px;
  1632. padding: 4px;
  1633. }}
  1634. QPushButton {{
  1635. background-color: {bg_btn};
  1636. color: {fg};
  1637. border: 1px solid {border};
  1638. border-radius: 5px;
  1639. padding: 5px 18px;
  1640. font-size: 12px;
  1641. min-width: 70px;
  1642. }}
  1643. QPushButton:hover {{
  1644. background-color: {bg_hov};
  1645. }}
  1646. """)
  1647. mb.exec()
  1648. def generate_gcode(self):
  1649. self.save_settings()
  1650. res = self.process_logic()
  1651. if not res or res[0] is None:
  1652. self._msgbox(QMessageBox.Icon.Warning, "No image",
  1653. "Please select a valid image first.")
  1654. return
  1655. matrix, geom = res
  1656. real_w = geom["real_w"]
  1657. real_h = geom["real_h"]
  1658. offX, offY = self.calculate_offsets(real_w, real_h)
  1659. raster_mode = self._raster_mode
  1660. cmd_mode_val = self.controls["cmd_mode"]["combo"].currentText() \
  1661. if "cmd_mode" in self.controls else "M67 (Analog)"
  1662. firing_val = self.controls["firing_mode"]["combo"].currentText() \
  1663. if "firing_mode" in self.controls else "M3/M5"
  1664. origin_val = (self.controls["origin_mode"]["combo"].currentData()
  1665. or self.controls["origin_mode"]["combo"].currentText()) \
  1666. if "origin_mode" in self.controls else "Lower-Left"
  1667. ratio_raw = "20%"
  1668. if "frame_feed_ratio_menu" in self.controls:
  1669. ratio_raw = self.controls["frame_feed_ratio_menu"]["combo"].currentText()
  1670. global_h = self.controller.config_manager.get_item("machine_settings", "custom_header", "").strip()
  1671. global_f = self.controller.config_manager.get_item("machine_settings", "custom_footer", "").strip()
  1672. raster_h = self.txt_header.toPlainText().strip()
  1673. raster_f = self.txt_footer.toPlainText().strip()
  1674. full_header = f"{global_h}\n{raster_h}".strip() if global_h and raster_h else (global_h or raster_h)
  1675. full_footer = f"{raster_f}\n{global_f}".strip() if global_f and raster_f else (global_f or raster_f)
  1676. file_ext = self.controller.config_manager.get_item("machine_settings", "gcode_extension", ".nc")
  1677. file_name = (os.path.splitext(os.path.basename(self.input_image_path))[0]
  1678. if self.input_image_path else "export")
  1679. payload = {
  1680. "matrix": matrix,
  1681. "dims": (geom["h_px"], geom["w_px"], geom["y_step"], geom["x_step"]),
  1682. "estimated_size": self.estimated_file_size,
  1683. "offsets": (offX, offY),
  1684. "params": {
  1685. "e_num": int(self._get_val("m67_e_num")),
  1686. "use_s_mode": "S (Spindle)" in cmd_mode_val,
  1687. "ctrl_max": self._get_val("ctrl_max"),
  1688. "min_power": self._get_val("min_p"),
  1689. "max_power": self._get_val("max_p"),
  1690. "premove": self._get_val("premove"),
  1691. "feedrate": self._get_val("feedrate"),
  1692. "laser_latency": self._get_val("laser_latency"),
  1693. "gray_scales": int(self._get_val("gray_steps")),
  1694. "gray_steps": int(self._get_val("gray_steps")),
  1695. "raster_mode": raster_mode,
  1696. },
  1697. "framing": {
  1698. "is_pointing": self.sw_pointer.isChecked(),
  1699. "is_framing": self.sw_frame.isChecked(),
  1700. "f_pwr": self.frame_power_entry.text(),
  1701. "f_ratio": ratio_raw.replace("%", ""),
  1702. "f_pause": self.pause_cmd_entry.text().strip() or None,
  1703. "use_s_mode": "S (Spindle)" in cmd_mode_val,
  1704. "e_num": int(self._get_val("m67_e_num")),
  1705. "base_feedrate": self._get_val("feedrate"),
  1706. },
  1707. "text_blocks": {"header": full_header, "footer": full_footer},
  1708. "metadata": {
  1709. "version": self.version,
  1710. "mode": cmd_mode_val.split(" ")[0],
  1711. "firing_cmd": firing_val.split("/")[0],
  1712. "file_extension": file_ext,
  1713. "file_name": file_name,
  1714. "output_dir": self.output_dir,
  1715. "origin_mode": origin_val,
  1716. "real_w": real_w,
  1717. "real_h": real_h,
  1718. "est_sec": int(geom.get("est_min", 0) * 60),
  1719. "raster_direction": raster_mode,
  1720. }
  1721. }
  1722. self.controller.show_simulation(self.engine, payload, return_view="raster")
  1723. # ── Sélection fichiers ────────────────────────────────────────────
  1724. def select_input(self):
  1725. path, _ = get_open_file(
  1726. self, "Select Image", "",
  1727. "Image files (*.jpg *.jpeg *.png *.bmp)"
  1728. )
  1729. if path:
  1730. self.input_image_path = path
  1731. self.btn_input.setText(os.path.basename(path).upper())
  1732. self.btn_input.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
  1733. self._canvas.request_auto_fit()
  1734. self._canvas._fit_next = True
  1735. self._schedule_preview()
  1736. def select_output(self):
  1737. directory = get_existing_directory(self, "Select Output Folder")
  1738. if directory:
  1739. self.output_dir = directory
  1740. folder = os.path.basename(directory) or directory
  1741. self.btn_output.setText(f"OUT: {folder.upper()}/")
  1742. self.btn_output.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
  1743. # ── Profils ───────────────────────────────────────────────────────
  1744. def export_profile(self):
  1745. path, _ = get_save_file(
  1746. self, "Export Profile",
  1747. os.path.join(self.application_path, "alig_profile.json"),
  1748. "JSON (*.json)"
  1749. )
  1750. if path:
  1751. data = self._collect_settings()
  1752. machine_keys = ["cmd_mode", "firing_mode", "m67_e_num", "ctrl_max", "laser_latency"]
  1753. export = {
  1754. "machine_settings": {k: data.pop(k) for k in machine_keys if k in data},
  1755. "raster_settings": data
  1756. }
  1757. success, err = save_json_file(path, export)
  1758. if success:
  1759. self._msgbox(QMessageBox.Icon.Information, "Export",
  1760. "Profile saved successfully.")
  1761. else:
  1762. self._msgbox(QMessageBox.Icon.Critical, "Error",
  1763. f"Export failed:\n{err}")
  1764. def load_profile_from(self):
  1765. path, _ = get_open_file(
  1766. self, "Load Profile", self.application_path, "JSON (*.json)"
  1767. )
  1768. if path:
  1769. data, err = load_json_file(path)
  1770. if data:
  1771. if "machine_settings" in data:
  1772. self._apply_settings(data.get("machine_settings", {}), is_machine=True)
  1773. if "raster_settings" in data:
  1774. self._apply_settings(data.get("raster_settings", {}))
  1775. else:
  1776. self._apply_settings(data)
  1777. self._schedule_preview()
  1778. self._msgbox(QMessageBox.Icon.Information, "Profile",
  1779. f"Loaded:\n{os.path.basename(path)}")
  1780. else:
  1781. self._msgbox(QMessageBox.Icon.Critical, "Error",
  1782. f"Could not load:\n{err}")
  1783. # ── Save / Load settings ──────────────────────────────────────────
  1784. def save_settings(self):
  1785. all_data = self._collect_settings()
  1786. machine_keys = ["cmd_mode", "firing_mode", "m67_e_num", "ctrl_max", "laser_latency"]
  1787. machine_updates = {k: all_data.pop(k) for k in machine_keys if k in all_data}
  1788. current_machine = self.controller.config_manager.get_section("machine_settings") or {}
  1789. current_machine.update(machine_updates)
  1790. self.controller.config_manager.set_section("machine_settings", current_machine)
  1791. self.controller.config_manager.set_section("raster_settings", all_data)
  1792. self.controller.config_manager.save()
  1793. def load_settings(self):
  1794. machine_data = self.controller.config_manager.get_section("machine_settings") or {}
  1795. raster_data = self.controller.config_manager.get_section("raster_settings") or {}
  1796. self._loading = True
  1797. self._apply_settings(machine_data, is_machine=True)
  1798. self._apply_settings(raster_data)
  1799. self._loading = False
  1800. # Synchronise line_step depuis hor/ver_linestep selon le mode actif
  1801. self._sync_line_step_from_mode(self._raster_mode)
  1802. # Mettre à jour le placeholder si visible
  1803. if self._placeholder_text is not None:
  1804. self._placeholder_text.set_text(
  1805. self.t.get("choose_image", "PLEASE SELECT AN IMAGE\nTO BEGIN"))
  1806. if not self.input_image_path:
  1807. self._canvas.redraw()
  1808. # Mettre à jour le label de la colorbar selon la langue
  1809. if hasattr(self, "_cbar_widget") and self._cbar_widget.isVisible():
  1810. self._cbar_widget._label = self.t.get("laser_power_level", "Laser Power")
  1811. self._cbar_widget.update()
  1812. self.refresh_global_previews()
  1813. def _collect_settings(self):
  1814. data = {}
  1815. for k, ctrl in self.controls.items():
  1816. if "combo" in ctrl:
  1817. ud = ctrl["combo"].currentData()
  1818. data[k] = ud if ud else ctrl["combo"].currentText()
  1819. elif "entry" in ctrl:
  1820. try:
  1821. data[k] = float(ctrl["entry"].text().replace(",", "."))
  1822. except ValueError:
  1823. data[k] = ctrl["entry"].text()
  1824. data["input_path"] = self.input_image_path
  1825. data["output_dir"] = self.output_dir
  1826. data["custom_header"] = self.txt_header.toPlainText().strip()
  1827. data["custom_footer"] = self.txt_footer.toPlainText().strip()
  1828. data["include_frame"] = self.sw_frame.isChecked()
  1829. data["include_pointer"] = self.sw_pointer.isChecked()
  1830. data["force_width"] = self.sw_force_width.isChecked()
  1831. data["invert_relief"] = self.sw_invert.isChecked()
  1832. data["frame_power"] = self.frame_power_entry.text()
  1833. data["custom_pause_cmd"]= self.pause_cmd_entry.text()
  1834. data["raster_mode"] = self._raster_mode
  1835. return data
  1836. def _apply_settings(self, data, is_machine=False):
  1837. if not data: return
  1838. for k, v in data.items():
  1839. if k not in self.controls: continue
  1840. ctrl = self.controls[k]
  1841. if "combo" in ctrl:
  1842. combo = ctrl["combo"]
  1843. for i in range(combo.count()):
  1844. if combo.itemData(i) == v or combo.itemText(i) == str(v):
  1845. combo.setCurrentIndex(i)
  1846. break
  1847. elif "entry" in ctrl:
  1848. try:
  1849. is_int = ctrl.get("is_int", False)
  1850. prec = ctrl.get("precision", 2)
  1851. fmt = "{:d}" if is_int else f"{{:.{prec}f}}"
  1852. val = float(v)
  1853. ctrl["entry"].setText(fmt.format(int(val) if is_int else val))
  1854. sl = ctrl.get("slider")
  1855. if sl and sl is not ctrl["entry"]:
  1856. sl.setValue(int(val) if is_int else int(val * 100))
  1857. except (ValueError, TypeError):
  1858. pass
  1859. sw_map = {
  1860. "invert_relief": self.sw_invert,
  1861. "include_frame": self.sw_frame,
  1862. "include_pointer": self.sw_pointer,
  1863. "force_width": self.sw_force_width,
  1864. }
  1865. for key, sw in sw_map.items():
  1866. if key in data:
  1867. sw.setChecked(bool(data[key]))
  1868. if "raster_mode" in data:
  1869. self._on_raster_change(data["raster_mode"])
  1870. if not is_machine:
  1871. raw_path = data.get("input_path", "")
  1872. if raw_path and os.path.isfile(raw_path):
  1873. self.input_image_path = raw_path
  1874. self.btn_input.setText(os.path.basename(raw_path).upper())
  1875. self.btn_input.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
  1876. out = data.get("output_dir", "")
  1877. if out:
  1878. self.output_dir = out
  1879. folder = os.path.basename(out) or out
  1880. self.btn_output.setText(f"OUT: {folder.upper()}/")
  1881. self.btn_output.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
  1882. if "custom_header" in data:
  1883. self.txt_header.setPlainText(data["custom_header"])
  1884. if "custom_footer" in data:
  1885. self.txt_footer.setPlainText(data["custom_footer"])
  1886. if "frame_power" in data:
  1887. self.frame_power_entry.setText(str(data["frame_power"]))
  1888. if "custom_pause_cmd" in data:
  1889. self.pause_cmd_entry.setText(str(data["custom_pause_cmd"]))
  1890. if data.get("origin_mode") == "Custom":
  1891. self.custom_offset_frame.setVisible(True)
  1892. self._update_framing_state()
  1893. # ── Lock machine params ───────────────────────────────────────────
  1894. def toggle_machine_lock(self):
  1895. self.is_locked = not self.is_locked
  1896. self.apply_lock_state()
  1897. def apply_lock_state(self):
  1898. self.lock_btn.setText("\U0001f512" if self.is_locked else "\U0001f513")
  1899. self._machine_container.setEnabled(not self.is_locked)
  1900. c = self._theme_colors
  1901. style = c['locked_btn'] if self.is_locked else "#D32F2F"
  1902. self.lock_btn.setStyleSheet(
  1903. f"QPushButton{{background:{style};color:{c['locked_text']};"
  1904. f"border-radius:4px;border:none;font-size:13px;}}"
  1905. f"QPushButton:hover{{background:{c['locked_btn_hover']};}}"
  1906. )
  1907. # Grisage widget par widget (priorité sur les styles inline appliqués par apply_theme)
  1908. from PyQt6.QtWidgets import QLabel as _QL, QLineEdit as _QLE, QComboBox as _QCB
  1909. if self.is_locked:
  1910. for lbl in self._machine_container.findChildren(_QL):
  1911. lbl.setStyleSheet(
  1912. "color:#555555;font-size:12px;border:none;background:transparent;"
  1913. )
  1914. for entry in self._machine_container.findChildren(_QLE):
  1915. entry.setStyleSheet(
  1916. f"QLineEdit{{color:#555555;border:1px solid #3a3a3a;"
  1917. f"background:{c.get('bg_entry','#2b2b2b')};border-radius:4px;padding:2px;}}"
  1918. )
  1919. for combo in self._machine_container.findChildren(_QCB):
  1920. combo.setStyleSheet(
  1921. f"QComboBox{{color:#555555;border:1px solid #3a3a3a;"
  1922. f"background:{c.get('bg_entry','#2b2b2b')};border-radius:5px;"
  1923. f"padding:3px 30px 3px 10px;}}"
  1924. f"QComboBox::drop-down{{border:none;background:transparent;}}"
  1925. f"QComboBox QAbstractItemView{{background:{c.get('bg_entry','#2b2b2b')};"
  1926. f"color:#555555;}}"
  1927. )
  1928. else:
  1929. # Restaurer les styles normaux du thème
  1930. for lbl in self._machine_container.findChildren(_QL):
  1931. lbl.setStyleSheet(
  1932. f"color:{c['text']};font-size:12px;border:none;background:transparent;"
  1933. )
  1934. for entry in self._machine_container.findChildren(_QLE):
  1935. entry.setStyleSheet(self._entry_style())
  1936. for combo in self._machine_container.findChildren(_QCB):
  1937. combo.setStyleSheet(self._combo_style())
  1938. # ── Global previews ───────────────────────────────────────────────
  1939. def refresh_global_previews(self):
  1940. gh = self.controller.config_manager.get_item("machine_settings", "custom_header", "")
  1941. gf = self.controller.config_manager.get_item("machine_settings", "custom_footer", "")
  1942. self.txt_global_header_preview.setPlainText(
  1943. gh if gh else self.t.get("no_global_header", "(Machine Settings Header…)"))
  1944. self.txt_global_footer_preview.setPlainText(
  1945. gf if gf else self.t.get("no_global_footer", "(Machine Settings Footer…)"))
  1946. # ── Traduction dynamique ──────────────────────────────────────────
  1947. def apply_theme(self, colors):
  1948. """Met à jour toutes les couleurs de la vue selon le thème reçu."""
  1949. self._theme_colors = colors
  1950. bg_main = colors['bg_main']
  1951. bg_card = colors['bg_card_alt']
  1952. border = colors['border']
  1953. text = colors['text']
  1954. text_sec = colors['text_secondary']
  1955. bg_tabs = colors['bg_tabs']
  1956. bg_tab = colors['bg_tab']
  1957. bg_right = colors['bg_deep']
  1958. bg_stats = colors['bg_stats']
  1959. bg_prev = colors['bg_preview']
  1960. col_prev = colors['preview_text']
  1961. btn_prof = colors['profile_btn']
  1962. btn_prof_text = colors['profile_btn_text']
  1963. sec_brd = colors['border_light']
  1964. stats_col = colors['stats_text']
  1965. canvas_fc = bg_right
  1966. ax_col = colors['stats_text']
  1967. spine_col = colors['border']
  1968. ph_col = colors['seg_text']
  1969. entry_col = colors['text']
  1970. # ── Sidebar ───────────────────────────────────────────────────
  1971. if hasattr(self, "_sidebar"):
  1972. self._sidebar.setStyleSheet(
  1973. f"QFrame#rasterSidebar{{background:{bg_main};border-right:1px solid {border};}}"
  1974. f"QFrame#rasterSidebar > QLabel{{border:none;background:transparent;color:{text};}}"
  1975. )
  1976. # ── Cadre boutons fichiers ────────────────────────────────────
  1977. if hasattr(self, "_file_buttons_frame"):
  1978. self._file_buttons_frame.setStyleSheet(
  1979. f"QFrame{{background:{btn_prof};border-radius:6px;}}"
  1980. )
  1981. # ── Boutons Import/Export profil ──────────────────────────────
  1982. prof_style = (
  1983. f"QPushButton{{background:{btn_prof};color:{btn_prof_text};"
  1984. f"border-radius:6px;border:none;font-weight:bold;}}"
  1985. f"QPushButton:hover{{background:{bg_card};}}"
  1986. )
  1987. if hasattr(self, "btn_load_prof"):
  1988. self.btn_load_prof.setStyleSheet(prof_style)
  1989. if hasattr(self, "btn_save_prof"):
  1990. self.btn_save_prof.setStyleSheet(prof_style)
  1991. # ── Onglets ───────────────────────────────────────────────────
  1992. tab_col = colors['seg_text']
  1993. if hasattr(self, "_tabs"):
  1994. self._tabs.setStyleSheet(f"""
  1995. QTabWidget::pane {{ border: 1px solid {border}; background: {bg_tabs}; }}
  1996. QTabBar::tab {{
  1997. background: {bg_tab}; color: {tab_col}; padding: 5px 12px;
  1998. border: 1px solid {border}; border-bottom: none;
  1999. border-radius: 4px 4px 0 0;
  2000. }}
  2001. QTabBar::tab:selected {{ background: #1F6AA5; color: white; }}
  2002. """)
  2003. for tab in [self._tab_geom, self._tab_img, self._tab_laser, self._tab_gcode]:
  2004. tab.setStyleSheet(f"background:{bg_tabs};")
  2005. if tab.widget():
  2006. tab.widget().setStyleSheet(f"background:{bg_tabs};")
  2007. tab.verticalScrollBar().setStyleSheet(f"""
  2008. QScrollBar:vertical {{
  2009. border: none;
  2010. background: {colors['scrollbar_bg']};
  2011. width: 10px;
  2012. margin: 0px;
  2013. }}
  2014. QScrollBar::handle:vertical {{
  2015. background: {colors['scrollbar_handle']};
  2016. min-height: 20px;
  2017. border-radius: 5px;
  2018. }}
  2019. QScrollBar::handle:vertical:hover {{
  2020. background: #1F6AA5;
  2021. }}
  2022. QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
  2023. height: 0px;
  2024. }}
  2025. QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
  2026. background: none;
  2027. }}
  2028. """)
  2029. # ── Labels dans les onglets ───────────────────────────────────
  2030. for tab in [self._tab_geom, self._tab_img, self._tab_laser, self._tab_gcode]:
  2031. for lbl in tab.findChildren(QLabel):
  2032. if not lbl.styleSheet().__contains__("font-weight:bold") and \
  2033. not lbl.styleSheet().__contains__("FF9500"):
  2034. lbl.setStyleSheet(f"color:{text};font-size:12px;border:none;background:transparent;")
  2035. # ── Boutons raster mode ───────────────────────────────────────
  2036. for btn in self._btn_raster.values():
  2037. btn.setStyleSheet(self._seg_btn_style())
  2038. # ── QLineEdit des contrôles ───────────────────────────────────
  2039. entry_style = self._entry_style()
  2040. for ctrl in self.controls.values():
  2041. if "entry" in ctrl and isinstance(ctrl["entry"], QLineEdit):
  2042. ctrl["entry"].setStyleSheet(entry_style)
  2043. # ── QComboBox des contrôles ───────────────────────────────────
  2044. combo_style = self._combo_style()
  2045. for ctrl in self.controls.values():
  2046. if "combo" in ctrl:
  2047. ctrl["combo"].setStyleSheet(combo_style)
  2048. # ── Label line_step informatif ────────────────────────────────
  2049. if hasattr(self, "_line_step_value_lbl"):
  2050. self._line_step_value_lbl.setStyleSheet(
  2051. f"color:{text_sec};font-size:12px;font-family:Consolas;"
  2052. f"background:{bg_stats};border:1px solid {border};"
  2053. "border-radius:4px;padding:2px 8px;"
  2054. )
  2055. # ── Champs texte libres (header/footer) ───────────────────────
  2056. for te in [self.txt_header, self.txt_footer]:
  2057. te.setStyleSheet(self._textedit_style())
  2058. # ── Previews header/footer globaux ────────────────────────────
  2059. prev_style = (
  2060. f"QTextEdit{{background:{bg_prev};color:{col_prev};"
  2061. f"border:none;font-family:Consolas;font-size:9px;}}"
  2062. )
  2063. for te in [self.txt_global_header_preview, self.txt_global_footer_preview]:
  2064. te.setStyleSheet(prev_style)
  2065. # ── Entrées simples ───────────────────────────────────────────
  2066. for entry in [self.pause_cmd_entry, self.frame_power_entry]:
  2067. entry.setStyleSheet(self._entry_style())
  2068. # ── Section machine params (gcode tab) ───────────────────────
  2069. if hasattr(self, "_gcode_sec_frame"):
  2070. self._gcode_sec_frame.setStyleSheet(
  2071. f"QFrame{{background:{bg_card};border:1px solid {sec_brd};border-radius:8px;}}"
  2072. f"QLabel{{border:none;background:transparent;}}"
  2073. )
  2074. # ── État framing/pointing (après labels pour ne pas être écrasé) ──
  2075. if hasattr(self, "sw_pointer"):
  2076. self._update_framing_state()
  2077. # ── Bouton cadenas ────────────────────────────────────────────
  2078. if hasattr(self, "lock_btn"):
  2079. self.apply_lock_state()
  2080. # ── Panneau droit ─────────────────────────────────────────────
  2081. if hasattr(self, "_right_panel"):
  2082. self._right_panel.setStyleSheet(f"background:{bg_right};")
  2083. # ── Canvas ────────────────────────────────────────────────────
  2084. if hasattr(self, "_canvas"):
  2085. self._canvas._bg_color = bg_right
  2086. self._canvas.setStyleSheet(f"background:{bg_right};")
  2087. self._canvas.update()
  2088. if hasattr(self, "_stats_frame"):
  2089. self._stats_frame.setStyleSheet(
  2090. f"QFrame{{background:{bg_stats};border:1px solid {border};border-radius:6px;}}"
  2091. )
  2092. for lbl in self.stats_labels:
  2093. lbl.setStyleSheet(
  2094. f"color:{stats_col};font-family:Consolas;font-size:12px;"
  2095. "background:transparent;border:none;"
  2096. )
  2097. if hasattr(self, "_cbar_widget"):
  2098. self._cbar_widget.set_theme(bg_right, text_sec)
  2099. if hasattr(self, "_hist_widget"):
  2100. self._hist_widget.set_theme(bg_stats, text_sec)
  2101. # ── Bouton reset view ─────────────────────────────────────────
  2102. if hasattr(self, "btn_reset_view"):
  2103. self.btn_reset_view.setStyleSheet(
  2104. f"QPushButton{{background:{colors['btn_neutral']};color:{text_sec};border:none;"
  2105. f"border-radius:4px;font-size:10px;}}"
  2106. f"QPushButton:hover{{background:{colors['btn_neutral_hover']};color:{entry_col};}}"
  2107. )
  2108. from gui.utils_qt import get_svg_pixmap
  2109. from PyQt6.QtCore import QSize
  2110. fit_pix = get_svg_pixmap(SVG_ICONS["FIT"], QSize(24, 24), text)
  2111. if not fit_pix.isNull():
  2112. self.btn_reset_view.setIcon(QIcon(fit_pix))
  2113. # ── Matplotlib ────────────────────────────────────────────────
  2114. if hasattr(self, "_fig") and self._ax is not None:
  2115. self._fig.set_facecolor(canvas_fc)
  2116. self._ax.set_facecolor(canvas_fc)
  2117. self._ax.tick_params(axis="both", colors=ax_col)
  2118. for spine in self._ax.spines.values():
  2119. spine.set_edgecolor(spine_col)
  2120. if self._placeholder_text:
  2121. self._placeholder_text.set_color(ph_col)
  2122. self._canvas.redraw()
  2123. def update_texts(self):
  2124. lang = self.controller.config_manager.get_item("machine_settings", "language", "English")
  2125. if not lang or lang not in TRANSLATIONS:
  2126. lang = "English"
  2127. self._lang = lang
  2128. self.t = TRANSLATIONS[lang]["common"]
  2129. self.t_stats = TRANSLATIONS[lang]["stats"]
  2130. self.t_orig = TRANSLATIONS[lang]["origin_options"]
  2131. translate_ui_widgets(self.translation_map, self.t)
  2132. # Onglets
  2133. self._tabs.setTabText(0, self.t.get("geometry", "Geometry"))
  2134. self._tabs.setTabText(1, self.t.get("image", "Image"))
  2135. self._tabs.setTabText(2, self.t.get("laser", "Laser"))
  2136. self._tabs.setTabText(3, self.t.get("gcode", "G-Code"))
  2137. # Recalculer le centrage après changement des textes
  2138. if hasattr(self, "_center_tabs_fn"):
  2139. from PyQt6.QtCore import QTimer
  2140. QTimer.singleShot(0, self._center_tabs_fn)
  2141. # Boutons mode raster
  2142. for key, btn in self._btn_raster.items():
  2143. btn.setText(self.t.get(key, key.capitalize()))
  2144. # Combo origin_mode — les items sont "(key, texte)" fixés à la création
  2145. origin_ctrl = self.controls.get("origin_mode")
  2146. if origin_ctrl:
  2147. combo = origin_ctrl["combo"]
  2148. current_data = combo.currentData()
  2149. ORIGIN_KEYS = ["Lower-Left","Upper-Left","Lower-Right","Upper-Right","Center","Custom"]
  2150. combo.blockSignals(True)
  2151. for i, key in enumerate(ORIGIN_KEYS):
  2152. if i < combo.count():
  2153. combo.setItemText(i, self.t_orig.get(key, key))
  2154. combo.blockSignals(False)
  2155. # Restaurer la sélection par userData
  2156. if current_data:
  2157. for i in range(combo.count()):
  2158. if combo.itemData(i) == current_data:
  2159. combo.setCurrentIndex(i)
  2160. break
  2161. # Mettre à jour le placeholder si visible
  2162. if self._placeholder_text is not None:
  2163. self._placeholder_text.set_text(
  2164. self.t.get("choose_image", "PLEASE SELECT AN IMAGE\nTO BEGIN"))
  2165. if not self.input_image_path:
  2166. self._canvas.redraw()
  2167. # Mettre à jour le label de la colorbar selon la langue
  2168. if hasattr(self, "_cbar_widget") and self._cbar_widget.isVisible():
  2169. self._cbar_widget._label = self.t.get("laser_power_level", "Laser Power")
  2170. self._cbar_widget.update()
  2171. self.refresh_global_previews()