checker_view_qt.py 77 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877
  1. # -*- coding: utf-8 -*-
  2. """
  3. A.L.I.G. - CheckerViewQt
  4. Lecteur / visualiseur de G-Code existant.
  5. Ouvre un fichier .nc/.gcode, le parse et lance la simulation de trajectoire.
  6. Aucune génération d'image — uniquement lecture + rendu.
  7. """
  8. import os
  9. import time
  10. import numpy as np
  11. from PyQt6.QtWidgets import (
  12. QWidget, QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
  13. QProgressBar, QSizePolicy, QMessageBox, QPlainTextEdit,
  14. QDoubleSpinBox,
  15. )
  16. from PyQt6.QtCore import (
  17. Qt, QTimer, QThread, pyqtSignal, QRect, QRectF, QPointF, QLineF, QSize
  18. )
  19. from PyQt6.QtGui import (
  20. QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFont,
  21. QLinearGradient, QPainterPath, QPolygonF, QTransform, QIcon
  22. )
  23. from engine.gcode_parser import GCodeParser
  24. from core.utils import truncate_path
  25. from core.translations import TRANSLATIONS
  26. from utils.paths import SVG_ICONS
  27. from gui.utils_qt import get_svg_pixmap
  28. from gui.utils_qt import get_open_file
  29. from gui.switch import Switch
  30. # ══════════════════════════════════════════════════════════════════════════════
  31. # WORKER : génération G-Code hors thread UI
  32. # ══════════════════════════════════════════════════════════════════════════════
  33. # ══════════════════════════════════════════════════════════════════════════════
  34. # RENDERER — logique de rasterisation batch (100 % NumPy + une passe QPainter)
  35. # ══════════════════════════════════════════════════════════════════════════════
  36. class _Renderer:
  37. """
  38. Stratégie haute perf :
  39. 1. _compute_segments() vectorise TOUS les segments éligibles en une fois
  40. via NumPy → tableau de QPolygonF groupés par couleur.
  41. 2. _rasterize() ouvre UN QPainter sur le QImage et dessine tous les
  42. polygones en une seule boucle Python sans calcul dans la boucle.
  43. 3. Résultat copié dans display_data uint8.
  44. """
  45. def __init__(self, rect_w, rect_h, scale, total_px_h,
  46. min_x, min_y, laser_width_px, ctrl_max,
  47. pwr_min=0.0, pwr_max=None, l_step_mm=None, draw_step_mm=None):
  48. self.rect_w = rect_w
  49. self.rect_h = rect_h
  50. self.scale = scale
  51. self.total_px_h = total_px_h
  52. self.min_x = min_x
  53. self.min_y = min_y
  54. self.laser_width_px = max(1, int(round(float(laser_width_px))))
  55. self.ctrl_max = float(ctrl_max)
  56. self.pwr_min = float(pwr_min)
  57. self.pwr_max = float(pwr_max) if pwr_max is not None else self.ctrl_max
  58. # l_step_mm : espacement réel inter-lignes → snap Y (pas de gaps)
  59. self.l_step_mm = float(l_step_mm) if l_step_mm else None
  60. self.l_step_px = float(l_step_mm) * scale if l_step_mm else None
  61. # draw_step_mm : épaisseur du trait laser (valeur utilisateur)
  62. # Si None, utilise l_step_mm (comportement identique à avant)
  63. _draw = draw_step_mm if draw_step_mm else l_step_mm
  64. self.draw_step_px = float(_draw) * scale if _draw else None
  65. self.display_data = np.full((rect_h, rect_w), 255, dtype=np.uint8)
  66. self._qi_ref = None
  67. def reset(self):
  68. self.display_data.fill(255)
  69. # ─── Calcul des segments à rasteriser ───────────────────────────────────
  70. def _compute_segments(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
  71. """
  72. Retourne {gray: [(x1,y1,x2,y2)...]} en coordonnées pixels flottantes.
  73. Les lignes raster horizontales sont stabilisées par index de ligne.
  74. """
  75. if pts_arr is None or len(pts_arr) < 2:
  76. return None
  77. if start >= end or start >= len(pts_arr) - 1:
  78. return None
  79. safe_end = min(end, len(pts_arr) - 1)
  80. p1 = pts_arr[start:safe_end].copy()
  81. p2 = pts_arr[start+1:safe_end+1].copy()
  82. if len(p1) == 0:
  83. return None
  84. # ── filtre puissance
  85. laser_threshold = max(self.pwr_min, self.ctrl_max * 0.001)
  86. mask = p2[:,2] > laser_threshold
  87. if not mask.any():
  88. return None
  89. p1 = p1[mask]
  90. p2 = p2[mask]
  91. x1 = p1[:,0].copy()
  92. y1_mm = p1[:,1].copy()
  93. x2 = p2[:,0].copy()
  94. y2_mm = p2[:,1].copy()
  95. # ── correction latence
  96. if use_lat and lat_mm != 0:
  97. if scan_axis == 'X':
  98. d = x2 - x1
  99. x1[d>1e-6] += lat_mm
  100. x2[d>1e-6] += lat_mm
  101. x1[d<-1e-6] -= lat_mm
  102. x2[d<-1e-6] -= lat_mm
  103. else:
  104. d = y2_mm - y1_mm
  105. y1_mm[d>1e-6] += lat_mm
  106. y2_mm[d>1e-6] += lat_mm
  107. y1_mm[d<-1e-6] -= lat_mm
  108. y2_mm[d<-1e-6] -= lat_mm
  109. # ── conversion mm → pixels
  110. sc = self.scale
  111. mnx = self.min_x
  112. mny = self.min_y
  113. th = self.total_px_h
  114. fx1 = (x1 - mnx) * sc
  115. fy1 = th - (y1_mm - mny) * sc
  116. fx2 = (x2 - mnx) * sc
  117. fy2 = th - (y2_mm - mny) * sc
  118. # ── rejet hors buffer
  119. lw = self.laser_width_px
  120. bw = float(self.rect_w)
  121. bh = float(self.rect_h)
  122. ok = (
  123. (np.maximum(fx1,fx2) >= -lw) &
  124. (np.minimum(fx1,fx2) < bw+lw) &
  125. (np.maximum(fy1,fy2) >= -lw) &
  126. (np.minimum(fy1,fy2) < bh+lw)
  127. )
  128. if not ok.any():
  129. return None
  130. fx1=fx1[ok]; fy1=fy1[ok]
  131. fx2=fx2[ok]; fy2=fy2[ok]
  132. pwr=p2[:,2][ok]
  133. y1_mm=y1_mm[ok]; y2_mm=y2_mm[ok]
  134. # ── filtre longueur : ne rejeter que les segments strictement nuls
  135. dx = fx2 - fx1
  136. dy = fy2 - fy1
  137. vis = (dx*dx + dy*dy) > 0.0
  138. if not vis.any():
  139. return None
  140. fx1=fx1[vis]; fy1=fy1[vis]
  141. fx2=fx2[vis]; fy2=fy2[vis]
  142. pwr=pwr[vis]
  143. y1_mm=y1_mm[vis]; y2_mm=y2_mm[vis]
  144. # ── couleur
  145. pwr_range = max(self.pwr_max - self.pwr_min,1.0)
  146. t = np.clip((pwr - self.pwr_min)/pwr_range,0.0,1.0)
  147. gray = (200.0*(1.0-t)).astype(np.uint8)
  148. # ─────────────────────────────
  149. # SNAP RASTER HORIZONTAL STABLE
  150. # ─────────────────────────────
  151. is_horiz = np.abs(fx2-fx1) >= np.abs(fy2-fy1)
  152. if self.l_step_mm and self.l_step_px:
  153. step_mm = self.l_step_mm
  154. step_px = self.l_step_px
  155. yc_mm = (y1_mm + y2_mm) * 0.5
  156. # index stable (pas de round)
  157. row_idx = np.floor((yc_mm - mny)/step_mm + 0.5).astype(np.int32)
  158. row_idx = np.maximum(row_idx,0)
  159. # centre exact de ligne
  160. fy_center = th - (row_idx + 0.5) * step_px
  161. fy1 = np.where(is_horiz, fy_center, fy1)
  162. fy2 = np.where(is_horiz, fy_center, fy2)
  163. else:
  164. fy_center = np.floor((fy1+fy2)*0.5)+0.5
  165. fy1 = np.where(is_horiz,fy_center,fy1)
  166. fy2 = np.where(is_horiz,fy_center,fy2)
  167. # ── snap X pour segments verticaux
  168. fx_center = np.floor((fx1+fx2)*0.5)+0.5
  169. fx1 = np.where(~is_horiz,fx_center,fx1)
  170. fx2 = np.where(~is_horiz,fx_center,fx2)
  171. # ── regroupement par gris
  172. result={}
  173. for i in range(len(gray)):
  174. c=int(gray[i])
  175. if c not in result:
  176. result[c]=[]
  177. result[c].append((fx1[i],fy1[i],fx2[i],fy2[i]))
  178. return result
  179. # ─── Rasterisation via fillRect (pixel-perfect) ──────────────────────────
  180. def _rasterize(self, segs_by_color):
  181. """Dessine les segments sous forme de rectangles pixel-parfaits."""
  182. if not segs_by_color:
  183. return
  184. h, w = self.display_data.shape
  185. # draw_step_px : épaisseur du trait (valeur utilisateur)
  186. # l_step_px : espacement inter-lignes (snap Y) — peut être différent
  187. step = self.draw_step_px if self.draw_step_px else (self.l_step_px if self.l_step_px else float(self.laser_width_px))
  188. half = step / 2.0
  189. qi = QImage(w, h, QImage.Format.Format_Grayscale8)
  190. qi.fill(QColor(255, 255, 255))
  191. qp = QPainter(qi)
  192. qp.setRenderHint(QPainter.RenderHint.Antialiasing, False)
  193. qp.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
  194. qp.setPen(Qt.PenStyle.NoPen)
  195. for c, segs in segs_by_color.items():
  196. qp.setBrush(QBrush(QColor(c, c, c)))
  197. for (x1, y1, x2, y2) in segs:
  198. # segment horizontal
  199. if abs(y2 - y1) < abs(x2 - x1):
  200. left = int(np.floor(min(x1, x2)))
  201. right = int(np.ceil(max(x1, x2)))
  202. top = int(np.floor(y1 - half))
  203. qp.fillRect(
  204. left,
  205. top,
  206. max(1, right - left),
  207. int(round(step)),
  208. QColor(c, c, c)
  209. )
  210. # segment vertical
  211. else:
  212. top = int(np.floor(min(y1, y2)))
  213. bottom = int(np.ceil(max(y1, y2)))
  214. left = int(np.floor(x1 - half))
  215. qp.fillRect(
  216. left,
  217. top,
  218. int(round(step)),
  219. max(1, bottom - top),
  220. QColor(c, c, c)
  221. )
  222. qp.end()
  223. bpl = qi.bytesPerLine()
  224. ptr = qi.bits()
  225. ptr.setsize(bpl * h)
  226. new_arr = np.frombuffer(ptr, dtype=np.uint8).reshape(h, bpl)[:, :w]
  227. np.minimum(self.display_data, new_arr, out=self.display_data)
  228. self._qi_ref = qi
  229. # ─── API publique ────────────────────────────────────────────────────────
  230. def redraw_range(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
  231. """Repart d'un fond blanc et dessine [start, end)."""
  232. self.display_data.fill(255)
  233. polys = self._compute_segments(pts_arr, start, end, use_lat, lat_mm, scan_axis)
  234. self._rasterize(polys)
  235. def draw_incremental(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
  236. """Ajoute les segments [start, end) sur l'état existant (animation)."""
  237. polys = self._compute_segments(pts_arr, start, end, use_lat, lat_mm, scan_axis)
  238. self._rasterize(polys)
  239. # ══════════════════════════════════════════════════════════════════════════════
  240. # CANVAS DE SIMULATION — rendu + zoom/pan
  241. # ══════════════════════════════════════════════════════════════════════════════
  242. class _SimCanvas(QWidget):
  243. """
  244. Affiche display_data (QPixmap mis à jour si dirty) + couche vectorielle
  245. (grille, laser). Zoom molette, pan clic-gauche.
  246. """
  247. def __init__(self, parent=None):
  248. super().__init__(parent)
  249. self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
  250. self.setStyleSheet('background:#050505;')
  251. self._bg_color = '#050505'
  252. self.setSizePolicy(QSizePolicy.Policy.Expanding,
  253. QSizePolicy.Policy.Expanding)
  254. self.setMouseTracking(True)
  255. self._pixmap = None
  256. self._dirty = False
  257. self._img_buf = None
  258. self._x0 = self._y0 = 0.0
  259. self._pw = self._ph = 0.0
  260. self._sc = 1.0
  261. self._mnx = self._mxx = 0.0
  262. self._mny = self._mxy = 0.0
  263. self._lx = self._ly = 0.0
  264. self._zoom = 1.0
  265. self._pan = QPointF(0, 0)
  266. self._p0 = None
  267. self._p0_pan = None
  268. self._mouse_mm = None
  269. self._overlay_h = 150
  270. self._placeholder_text = 'Open a G-Code file to start'
  271. # ─── API ─────────────────────────────
  272. def setup(self, img_buf, x0, y0, pw, ph, sc, mnx, mxx, mny, mxy, l_step=0.1, overlay_h=150):
  273. self._img_buf = img_buf
  274. self._x0, self._y0 = x0, y0
  275. self._pw, self._ph = pw, ph
  276. self._sc = sc
  277. self._mnx, self._mxx = mnx, mxx
  278. self._mny, self._mxy = mny, mxy
  279. self._l_step = l_step
  280. self._overlay_h = overlay_h
  281. self._rebuild()
  282. self._dirty = False
  283. # Fit-to-view automatique après setup
  284. self.reset_view()
  285. def notify_dirty(self):
  286. self._dirty = True
  287. self.update()
  288. def set_theme(self, bg: str):
  289. self._bg_color = bg
  290. self.setStyleSheet(f'background:{bg};')
  291. self.update()
  292. def set_placeholder(self, text: str):
  293. self._placeholder_text = text
  294. if self._img_buf is None:
  295. self.update()
  296. def set_laser(self, sx, sy):
  297. self._lx, self._ly = sx, sy
  298. self.update()
  299. def reset_view(self):
  300. """Fit-to-view : zoom et pan pour que l'image remplisse la zone utile."""
  301. if self._pw <= 0 or self._ph <= 0:
  302. self._zoom = 1.0
  303. self._pan = QPointF(0, 0)
  304. self.update()
  305. return
  306. cw, ch = self.width(), self.height()
  307. if cw <= 1 or ch <= 1:
  308. return
  309. # Zone utile : toute la largeur, hauteur sans overlay (stockée dans _overlay_h)
  310. overlay_h = getattr(self, '_overlay_h', 150)
  311. usable_w = cw
  312. usable_h = max(ch - overlay_h, int(ch * 0.5))
  313. margin = 12 # px de marge autour de l'image
  314. # Zoom pour que l'image tienne dans la zone utile avec marge
  315. zoom_x = (usable_w - 2 * margin) / self._pw
  316. zoom_y = (usable_h - 2 * margin) / self._ph
  317. self._zoom = min(zoom_x, zoom_y)
  318. # Pan pour centrer l'image dans la zone utile
  319. img_screen_w = self._pw * self._zoom
  320. img_screen_h = self._ph * self._zoom
  321. pan_x = (usable_w - img_screen_w) / 2.0 - self._x0 * self._zoom
  322. pan_y = margin - self._y0 * self._zoom # calé en haut avec marge
  323. self._pan = QPointF(pan_x, pan_y)
  324. self.update()
  325. # ─── Rendu ───────────────────────────
  326. def _rebuild(self):
  327. if self._img_buf is None:
  328. self._pixmap = None; return
  329. h, w = self._img_buf.shape
  330. qi = QImage(self._img_buf.tobytes(), w, h, w,
  331. QImage.Format.Format_Grayscale8)
  332. self._pixmap = QPixmap.fromImage(qi)
  333. def paintEvent(self, _):
  334. if self._dirty:
  335. self._rebuild()
  336. self._dirty = False
  337. qp = QPainter(self)
  338. qp.setRenderHint(QPainter.RenderHint.Antialiasing, False)
  339. w, h = self.width(), self.height()
  340. qp.fillRect(0, 0, w, h, QColor(self._bg_color))
  341. if self._img_buf is None:
  342. qp.setPen(QColor('#888888'))
  343. qp.setFont(QFont('Arial', 13))
  344. qp.drawText(QRect(0, 0, w, h),
  345. Qt.AlignmentFlag.AlignCenter, self._placeholder_text)
  346. qp.end(); return
  347. # Zoom / pan
  348. t = QTransform()
  349. t.translate(self._pan.x(), self._pan.y())
  350. t.scale(self._zoom, self._zoom)
  351. qp.setTransform(t)
  352. # Fond blanc
  353. qp.fillRect(QRectF(self._x0, self._y0, self._pw, self._ph),
  354. QColor('white'))
  355. # Image simulation
  356. if self._pixmap:
  357. qp.drawPixmap(int(self._x0), int(self._y0), self._pixmap)
  358. # Grille
  359. pen_g = QPen(QColor(180, 180, 180, 140), 0.5, Qt.PenStyle.DashLine)
  360. pen_g.setDashPattern([4, 6])
  361. qp.setFont(QFont('Arial', 7))
  362. step = 10
  363. sx0 = int(np.ceil(self._mnx / step) * step)
  364. for mx in range(sx0, int(self._mxx) + 1, step):
  365. sx = self._x0 + (mx - self._mnx) * self._sc
  366. qp.setPen(pen_g)
  367. qp.drawLine(QLineF(sx, self._y0, sx, self._y0 + self._ph))
  368. qp.setPen(QColor('#777'))
  369. qp.drawText(QRectF(sx-15, self._y0+self._ph+1, 30, 13),
  370. Qt.AlignmentFlag.AlignCenter, str(mx))
  371. sy0 = int(np.ceil(self._mny / step) * step)
  372. for my in range(sy0, int(self._mxy) + 1, step):
  373. sy = self._y0 + self._ph - (my - self._mny) * self._sc
  374. qp.setPen(pen_g)
  375. qp.drawLine(QLineF(self._x0, sy, self._x0 + self._pw, sy))
  376. qp.setPen(QColor('#777'))
  377. qp.drawText(QRectF(self._x0-33, sy-7, 30, 13),
  378. Qt.AlignmentFlag.AlignRight |
  379. Qt.AlignmentFlag.AlignVCenter, str(my))
  380. # Laser
  381. lx, ly = self._lx, self._ly
  382. qp.setRenderHint(QPainter.RenderHint.Antialiasing, True)
  383. qp.setPen(QPen(QColor('#3385ff'), 1))
  384. qp.setBrush(QBrush(QColor(26, 117, 255, 100)))
  385. qp.drawEllipse(QPointF(lx, ly), 9, 9)
  386. qp.setPen(QPen(QColor('white'), 1))
  387. qp.setBrush(QBrush(QColor('#00ffff')))
  388. qp.drawEllipse(QPointF(lx, ly), 4, 4)
  389. # Coordonnées souris
  390. if self._mouse_mm:
  391. qp.resetTransform()
  392. qp.setPen(QColor('#666'))
  393. qp.setFont(QFont('Consolas', 9))
  394. mx_mm, my_mm = self._mouse_mm
  395. qp.drawText(QRect(6, h-18, 220, 15),
  396. Qt.AlignmentFlag.AlignLeft,
  397. f'X={mx_mm:.2f} Y={my_mm:.2f} mm')
  398. qp.end()
  399. # ─── Zoom / Pan ──────────────────────
  400. def wheelEvent(self, e):
  401. if self._img_buf is None:
  402. return
  403. factor = 1.15 if e.angleDelta().y() > 0 else (1.0 / 1.15)
  404. new_zoom = max(0.05, min(200.0, self._zoom * factor))
  405. pos = e.position()
  406. cx, cy = pos.x(), pos.y()
  407. wx = (cx - self._pan.x()) / self._zoom
  408. wy = (cy - self._pan.y()) / self._zoom
  409. self._zoom = new_zoom
  410. self._pan = QPointF(cx - wx * self._zoom, cy - wy * self._zoom)
  411. self.update()
  412. def mousePressEvent(self, e):
  413. if e.button() in (Qt.MouseButton.LeftButton,
  414. Qt.MouseButton.MiddleButton):
  415. self._p0 = e.pos()
  416. self._p0_pan = QPointF(self._pan)
  417. self.setCursor(Qt.CursorShape.ClosedHandCursor)
  418. def mouseMoveEvent(self, e):
  419. if self._p0 is not None:
  420. d = e.pos() - self._p0
  421. self._pan = self._p0_pan + QPointF(d.x(), d.y())
  422. self.update()
  423. pos = e.position()
  424. cx, cy = pos.x(), pos.y()
  425. if self._sc > 0 and self._ph > 0:
  426. ix = (cx - self._pan.x()) / self._zoom - self._x0
  427. iy = (cy - self._pan.y()) / self._zoom - self._y0
  428. self._mouse_mm = (ix/self._sc + self._mnx,
  429. self._mny + (self._ph - iy)/self._sc)
  430. self.update()
  431. def mouseReleaseEvent(self, e):
  432. self._p0 = None
  433. self.setCursor(Qt.CursorShape.ArrowCursor)
  434. # ══════════════════════════════════════════════════════════════════════════════
  435. # VUE PRINCIPALE
  436. # ══════════════════════════════════════════════════════════════════════════════
  437. # ══════════════════════════════════════════════════════════════════════════════
  438. # WORKER : parsing G-Code hors thread UI
  439. # ══════════════════════════════════════════════════════════════════════════════
  440. class _ParseWorker(QThread):
  441. done = pyqtSignal(dict)
  442. error = pyqtSignal(str)
  443. def __init__(self, gcode: str):
  444. super().__init__()
  445. self.gcode = gcode
  446. def run(self):
  447. try:
  448. parser = GCodeParser({})
  449. pts, dur, lim = parser.parse(self.gcode)
  450. if lim is not None and not all(abs(v) < 1e-9 for v in lim):
  451. bx0, bx1, by0, by1 = lim
  452. else:
  453. if pts is not None and len(pts):
  454. bx0, bx1 = float(pts[:,0].min()), float(pts[:,0].max())
  455. by0, by1 = float(pts[:,1].min()), float(pts[:,1].max())
  456. else:
  457. bx0 = bx1 = by0 = by1 = 0.0
  458. # Timestamps cumulés
  459. if pts is not None and len(pts) > 1:
  460. deltas = np.diff(pts[:, :2], axis=0)
  461. distances = np.hypot(deltas[:, 0], deltas[:, 1])
  462. rates = pts[1:, 4] / 60.0
  463. # Feedrate moyen (col 4 avant écrasement) — pour la latence
  464. feedrate_mmmin = float(np.median(pts[pts[:, 4] > 0, 4])) if (pts[:, 4] > 0).any() else 3000.0
  465. times = np.divide(distances, rates,
  466. out=np.zeros_like(distances),
  467. where=rates > 0)
  468. pts[0, 4] = 0.0
  469. pts[1:, 4] = np.cumsum(times)
  470. total_dur = float(pts[-1, 4])
  471. else:
  472. total_dur = 0.0
  473. feedrate_mmmin = 3000.0
  474. self.done.emit({
  475. 'gcode': self.gcode,
  476. 'pts': pts,
  477. 'total_dur': total_dur,
  478. 'bounds': (bx0, bx1, by0, by1),
  479. 'feedrate_mmmin': feedrate_mmmin,
  480. })
  481. except Exception as e:
  482. import traceback; traceback.print_exc()
  483. self.error.emit(str(e))
  484. class CheckerViewQt(QWidget):
  485. def __init__(self, parent, controller, return_view='dashboard'):
  486. super().__init__(parent)
  487. self.controller = controller
  488. self.return_view = return_view
  489. lang = controller.config_manager.get_item('machine_settings', 'language')
  490. if not lang or lang not in TRANSLATIONS:
  491. lang = 'English'
  492. self.t = TRANSLATIONS[lang].get('simulation', {})
  493. # ── état ──────────────────────────────────────────────────
  494. self.final_gcode = ''
  495. self.framing_gcode = ''
  496. self.full_metadata = {}
  497. self.points_list = None
  498. self.framing_end = 0
  499. self.total_sec = 0.0
  500. self.latence_mm = 0.0
  501. self.latence_enabled = False
  502. self._loaded_path = ''
  503. # ── animation ─────────────────────────────────────────────
  504. self.sim_running = False
  505. self.current_idx = 0
  506. self.current_sim_time = 0.0
  507. self.last_frame_time = 0.0
  508. self.sim_speed = 1.0
  509. self._last_drawn_idx = -1
  510. self._anim_timer = QTimer(self)
  511. self._anim_timer.setInterval(16)
  512. self._anim_timer.timeout.connect(self._tick)
  513. # ── renderer ──────────────────────────────────────────────
  514. self._renderer: _Renderer | None = None
  515. self._px_w = self._px_h = 0.0
  516. self._x0 = self._y0 = 0.0
  517. self._scale = 1.0
  518. self._mnx = self._mxx = 0.0
  519. self._mny = self._mxy = 0.0
  520. self.setStyleSheet('background:#2b2b2b; color:white;')
  521. self._build_ui()
  522. # ══════════════════════════════════════════════════════════════
  523. # CONSTRUCTION UI
  524. # ══════════════════════════════════════════════════════════════
  525. def _build_ui(self):
  526. root = QHBoxLayout(self)
  527. root.setContentsMargins(2, 2, 2, 2)
  528. root.setSpacing(2)
  529. root.addWidget(self._make_left_panel())
  530. root.addWidget(self._make_right_panel(), stretch=1)
  531. # ─── Panneau gauche ──────────────────────────────────────────
  532. def _make_left_panel(self):
  533. self.left = QFrame()
  534. self.left.setObjectName('checkerLeft')
  535. self.left.setFixedWidth(336)
  536. self.left.setStyleSheet(
  537. 'QFrame#checkerLeft{background:#1e1e1e; border-right:1px solid #333;}')
  538. lo = QVBoxLayout(self.left)
  539. lo.setContentsMargins(8, 8, 8, 8)
  540. lo.setSpacing(6)
  541. lo.addWidget(self._make_file_widget())
  542. self.gl_lbl = QLabel(self.t.get('live_gcode', 'Live G-Code'))
  543. self.gl_lbl.setStyleSheet('color:white;font-weight:bold;font-size:11px;border:none;')
  544. lo.addWidget(self.gl_lbl)
  545. self.gcode_view = QPlainTextEdit()
  546. self.gcode_view.setReadOnly(True)
  547. self.gcode_view.setStyleSheet(
  548. 'QPlainTextEdit{background:#1a1a1a;color:#00ff00;'
  549. 'font-family:Consolas;'
  550. 'border:1px solid #333;border-radius:3px;}')
  551. self._update_gcode_font()
  552. self.gcode_view.mousePressEvent = self._on_gcode_click
  553. self.gcode_view.keyPressEvent = self._on_gcode_key
  554. lo.addWidget(self.gcode_view, stretch=1)
  555. lo.addWidget(self._make_action_buttons())
  556. return self.left
  557. def _make_file_widget(self):
  558. """Bouton d'ouverture de fichier + infos du fichier chargé."""
  559. f = QFrame()
  560. self._file_frame = f
  561. f.setStyleSheet('QFrame{background:#252525;border-radius:6px;'
  562. 'border:1px solid #333;}')
  563. lo = QVBoxLayout(f)
  564. lo.setContentsMargins(8, 8, 8, 8)
  565. lo.setSpacing(6)
  566. self.btn_open = QPushButton(self.t.get('open_file', 'Open G-Code file…'))
  567. self.btn_open.setFixedHeight(38)
  568. self.btn_open.setStyleSheet(self._gbtn('#1f538d', '#2a6dbd'))
  569. self.btn_open.clicked.connect(self._on_open_file)
  570. lo.addWidget(self.btn_open)
  571. self.lbl_file = QLabel(self.t.get('no_file', 'No file loaded'))
  572. self.lbl_file.setStyleSheet('color:#888;font-size:10px;border:none;')
  573. self.lbl_file.setWordWrap(True)
  574. lo.addWidget(self.lbl_file)
  575. row = QHBoxLayout()
  576. self.lbl_size = QLabel('')
  577. self.lbl_size.setStyleSheet('color:#2ecc71;font-size:10px;'
  578. 'font-family:Consolas;border:none;')
  579. self.lbl_dur = QLabel('')
  580. self.lbl_dur.setStyleSheet('color:#f39c12;font-size:10px;'
  581. 'font-family:Consolas;border:none;')
  582. self.lbl_dur.setAlignment(Qt.AlignmentFlag.AlignRight)
  583. row.addWidget(self.lbl_size)
  584. row.addWidget(self.lbl_dur)
  585. lo.addLayout(row)
  586. # ── Champ line_step éditable (non sauvegardé) ──────────────
  587. step_row = QHBoxLayout()
  588. self.lbl_lstep = QLabel(self.t.get('line_step_lbl', 'Line step (mm):'))
  589. self.lbl_lstep.setStyleSheet('color:#aaa;font-size:10px;border:none;')
  590. step_row.addWidget(self.lbl_lstep)
  591. self.spin_lstep = QDoubleSpinBox()
  592. self.spin_lstep.setRange(0.001, 50.0)
  593. self.spin_lstep.setDecimals(4)
  594. self.spin_lstep.setSingleStep(0.01)
  595. self.spin_lstep.setFixedHeight(24)
  596. self.spin_lstep.setStyleSheet(
  597. 'QDoubleSpinBox{background:#1a1a1a;color:white;border:1px solid #555;'
  598. 'border-radius:3px;font-size:10px;padding:1px 4px;}'
  599. 'QDoubleSpinBox::up-button,QDoubleSpinBox::down-button{width:14px;}'
  600. )
  601. # Pré-remplir depuis config (hor_linestep par défaut)
  602. try:
  603. default_step = float(
  604. self.controller.config_manager.get_item('machine_settings', 'hor_linestep', 0.1) or 0.1
  605. )
  606. except Exception:
  607. default_step = 0.1
  608. self.spin_lstep.setValue(default_step)
  609. self.spin_lstep.valueChanged.connect(self._on_lstep_changed)
  610. step_row.addWidget(self.spin_lstep)
  611. lo.addLayout(step_row)
  612. return f
  613. def _make_action_buttons(self):
  614. f = QFrame()
  615. f.setStyleSheet('QFrame{border:none;background:transparent;}')
  616. lo = QVBoxLayout(f)
  617. lo.setContentsMargins(0, 0, 0, 0)
  618. lo.setSpacing(6)
  619. # ── Switch compensation délai laser ───────────────────────
  620. lat_row = QHBoxLayout()
  621. self.lbl_lat = QLabel(self.t.get('simulate_latency', 'Simulate latency'))
  622. self.lbl_lat.setStyleSheet('color:#aaa;font-size:10px;border:none;')
  623. lat_row.addWidget(self.lbl_lat)
  624. lat_row.addStretch()
  625. self.sw_latency = Switch()
  626. self.sw_latency.setChecked(False)
  627. self.sw_latency.toggled.connect(self._on_lat_toggle)
  628. lat_row.addWidget(self.sw_latency)
  629. lo.addLayout(lat_row)
  630. self.btn_cancel = QPushButton(self.t.get('cancel', 'Close'))
  631. self.btn_cancel.setFixedHeight(30)
  632. self.btn_cancel.setStyleSheet(self._gbtn('#333', '#444'))
  633. self.btn_cancel.clicked.connect(self.on_cancel)
  634. lo.addWidget(self.btn_cancel)
  635. return f
  636. # ─── Panneau droit ───────────────────────────────────────────
  637. def _make_right_panel(self):
  638. self._right_widget = QWidget()
  639. self._right_widget.setStyleSheet('background:#111;')
  640. lo = QVBoxLayout(self._right_widget)
  641. lo.setContentsMargins(0, 0, 0, 0)
  642. lo.setSpacing(0)
  643. # Canvas occupe tout l'espace
  644. self.canvas = _SimCanvas()
  645. self.canvas.set_placeholder(self.t.get('open_gcode_hint', 'Open a G-Code file to start'))
  646. lo.addWidget(self.canvas, stretch=1)
  647. # Les contrôles de lecture et la barre de progression sont créés
  648. # mais restent hors du layout — ils seront positionnés en overlay
  649. self._playback_frame = self._make_playback_bar()
  650. self._playback_frame.setParent(self._right_widget)
  651. self._playback_frame.setStyleSheet(
  652. 'QFrame{background:transparent;border:none;}')
  653. self._progress_frame = self._make_progress_bar()
  654. self._progress_frame.setParent(self._right_widget)
  655. self._progress_frame.setStyleSheet(
  656. 'QFrame{background:transparent;border:none;}')
  657. return self._right_widget
  658. def _make_playback_bar(self):
  659. f = QFrame()
  660. lo = QVBoxLayout(f)
  661. lo.setContentsMargins(8, 6, 8, 6)
  662. lo.setSpacing(5)
  663. # Transport
  664. tr = QHBoxLayout()
  665. tr.setAlignment(Qt.AlignmentFlag.AlignCenter)
  666. btn_rew = QPushButton()
  667. rewind_pixmap = get_svg_pixmap(SVG_ICONS["REWIND"], QSize(24, 24), "#ffffff")
  668. btn_rew.setIcon(QIcon(rewind_pixmap))
  669. btn_rew.setFixedSize(60, 40)
  670. btn_rew.setStyleSheet(self._gbtn('#444', '#555'))
  671. btn_rew.clicked.connect(self.rewind_sim)
  672. self.btn_play = QPushButton() # '▶'
  673. self.play_pixmap = get_svg_pixmap(SVG_ICONS["PLAY"], QSize(24, 24), "#ffffff")
  674. self.pause_pixmap = get_svg_pixmap(SVG_ICONS["PAUSE"], QSize(24, 24), "#ffffff")
  675. self.rerun_pixmap = get_svg_pixmap(SVG_ICONS["RERUN"], QSize(24, 24), "#ffffff")
  676. self.btn_play.setIcon(QIcon(self.play_pixmap))
  677. self.btn_play.setFixedSize(100, 40)
  678. # self.btn_play.setFont(QFont('Arial', 16))
  679. self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
  680. self.btn_play.clicked.connect(self.toggle_pause)
  681. btn_end = QPushButton() # '⏭'
  682. skiptoend_pixmap = get_svg_pixmap(SVG_ICONS["SKIPTOEND"], QSize(24, 24), "#ffffff")
  683. btn_end.setIcon(QIcon(skiptoend_pixmap))
  684. btn_end.setFixedSize(60, 40)
  685. # btn_end.setFont(QFont('Arial', 16))
  686. btn_end.setStyleSheet(self._gbtn('#444', '#555'))
  687. btn_end.clicked.connect(self.skip_to_end)
  688. btn_fit = QPushButton() # '⊞'
  689. fit_pixmap = get_svg_pixmap(SVG_ICONS["FIT"], QSize(50, 50), "#ffffff")
  690. btn_fit.setIcon(QIcon(fit_pixmap))
  691. btn_fit.setFixedSize(40, 40)
  692. # btn_fit.setFont(QFont('Arial', 20))
  693. btn_fit.setToolTip(self.t.get('reset_zoom', 'Reset zoom / pan'))
  694. btn_fit.setStyleSheet(self._gbtn('#333', '#444'))
  695. btn_fit.clicked.connect(self.canvas.reset_view)
  696. for b in [btn_rew, self.btn_play, btn_end, btn_fit]:
  697. tr.addWidget(b)
  698. lo.addLayout(tr)
  699. # Sélecteur vitesse
  700. sf = QFrame()
  701. sf.setStyleSheet('QFrame{background:#222;border:1px solid #444;'
  702. 'border-radius:5px;}')
  703. sr = QHBoxLayout(sf)
  704. sr.setContentsMargins(5, 2, 5, 2)
  705. sr.setSpacing(2)
  706. spd_lbl = QLabel(self.t.get('speed', 'Speed:'))
  707. spd_lbl.setStyleSheet('color:white;font-weight:bold;font-size:9px;border:none;')
  708. sr.addWidget(spd_lbl)
  709. self._spd_btns: dict[str, QPushButton] = {}
  710. spd_style = ('QPushButton{background:#3a3a3a;color:#aaa;'
  711. 'border:1px solid #555;border-radius:3px;'
  712. 'padding:0px 4px;font-size:8px;min-width:18px;max-width:30px;}'
  713. 'QPushButton:checked{background:#1f538d;color:white;'
  714. 'border-color:#2a6dbd;}'
  715. 'QPushButton:hover:!checked{background:#444;color:white;}')
  716. for v in ['0.5', '1', '5', '10', '50']:
  717. b = QPushButton(v)
  718. b.setCheckable(True)
  719. b.setFixedHeight(14)
  720. b.setStyleSheet(spd_style)
  721. b.clicked.connect(lambda _, val=v: self._set_speed(val))
  722. sr.addWidget(b)
  723. self._spd_btns[v] = b
  724. self._spd_btns['1'].setChecked(True)
  725. lo.addWidget(sf, alignment=Qt.AlignmentFlag.AlignHCenter)
  726. return f
  727. def _make_progress_bar(self):
  728. f = QFrame()
  729. lo = QVBoxLayout(f)
  730. lo.setContentsMargins(0, 0, 0, 0) # On laisse le resizeEvent gérer les bords
  731. lo.setSpacing(0)
  732. container = QWidget()
  733. container.setFixedHeight(30) # Un peu plus haut pour faciliter le clic
  734. container.setStyleSheet('background:transparent;')
  735. self.prog_bar = QProgressBar(container)
  736. # Supprimez le setGeometry fixe ici, ou mettez une valeur bidon
  737. self.prog_bar.setRange(0, 10000)
  738. self.prog_bar.setValue(0)
  739. self.prog_bar.setTextVisible(False)
  740. self.prog_bar.setStyleSheet(
  741. 'QProgressBar{background:#333;border-radius:7px;border:none;}'
  742. 'QProgressBar::chunk{background:#27ae60;border-radius:7px;}')
  743. # IMPORTANT : On lie le clic
  744. self.prog_bar.mousePressEvent = self._on_prog_click
  745. self.lbl_time = QLabel('00:00:00 / 00:00:00', container)
  746. self.lbl_time.setStyleSheet(
  747. 'color:white;font-size:9px;font-weight:bold;'
  748. 'background:transparent;border:none;')
  749. self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignCenter)
  750. self.lbl_time.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  751. lo.addWidget(container)
  752. self.lbl_prog = QLabel('')
  753. self.lbl_prog.hide()
  754. lo.addWidget(self.lbl_prog)
  755. self._prog_container = container
  756. return f
  757. # ══════════════════════════════════════════════════════════════
  758. # LOADING OVERLAY
  759. # ══════════════════════════════════════════════════════════════
  760. def _show_loading(self):
  761. self._ov = QWidget(self)
  762. self._ov.setStyleSheet('background:rgba(20,20,20,220);')
  763. self._ov.resize(self.size())
  764. self._ov.show()
  765. self._ov.raise_()
  766. lo = QVBoxLayout(self._ov)
  767. lo.setAlignment(Qt.AlignmentFlag.AlignCenter)
  768. box = QFrame()
  769. box.setFixedWidth(340)
  770. box.setStyleSheet('QFrame{background:#3a3a3a;border-radius:12px;'
  771. 'border:1px solid #555;}')
  772. bl = QVBoxLayout(box)
  773. bl.setContentsMargins(30, 20, 30, 25)
  774. bl.setSpacing(12)
  775. lbl = QLabel(self.t.get('parsing', 'Parsing G-Code…'))
  776. lbl.setStyleSheet('color:white;font-size:14px;font-weight:bold;border:none;')
  777. lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  778. bl.addWidget(lbl)
  779. pb = QProgressBar()
  780. pb.setFixedHeight(10)
  781. pb.setRange(0, 0)
  782. pb.setStyleSheet('QProgressBar{background:#555;border-radius:5px;border:none;}'
  783. 'QProgressBar::chunk{background:#27ae60;border-radius:5px;}')
  784. bl.addWidget(pb)
  785. lo.addWidget(box)
  786. def _hide_loading(self):
  787. if hasattr(self, '_ov'):
  788. self._ov.hide()
  789. self._ov.deleteLater()
  790. del self._ov
  791. def _update_gcode_font(self):
  792. """Adapte la taille de la police du G-Code à la largeur du panneau gauche."""
  793. if not hasattr(self, 'gcode_view'):
  794. return
  795. panel_w = self.left.width() if hasattr(self, 'left') else 336
  796. # Viser ~55 caractères lisibles — Consolas ratio ≈ 0.55
  797. usable = max(1, panel_w - 24)
  798. pt = max(8, min(16, int(usable / (55 * 0.55))))
  799. fnt = QFont('Consolas', pt)
  800. self.gcode_view.setFont(fnt)
  801. def resizeEvent(self, e):
  802. super().resizeEvent(e)
  803. self._update_gcode_font()
  804. if hasattr(self, '_ov'):
  805. self._ov.resize(self.size())
  806. # Repositionnement des blocs (Playback et Progress)
  807. if hasattr(self, '_playback_frame') and hasattr(self, '_progress_frame'):
  808. rw = self._right_widget
  809. rw_w = rw.width()
  810. rw_h = rw.height()
  811. margin_side = 40
  812. margin_bottom = 2 # px depuis le bas
  813. pb_hint = self._playback_frame.sizeHint()
  814. pr_hint = self._progress_frame.sizeHint()
  815. pb_h = max(pb_hint.height(), 70)
  816. pr_h = max(pr_hint.height(), 26)
  817. total_h = pb_h + pr_h + 2
  818. bottom_y = rw_h - total_h - margin_bottom
  819. # 1. On donne d'abord sa position au cadre transparent global
  820. prog_frame_w = rw_w - 2 * margin_side
  821. self._progress_frame.setGeometry(margin_side, bottom_y,
  822. prog_frame_w, pr_h)
  823. self._playback_frame.setGeometry(margin_side, bottom_y + pr_h + 2,
  824. rw_w - 2 * margin_side, pb_h)
  825. # 2. On dimensionne la barre ET le texte immédiatement
  826. # (Plus aucun décalage temporel)
  827. if hasattr(self, 'prog_bar'):
  828. bar_w = int(prog_frame_w * 0.8)
  829. bar_x = (prog_frame_w - bar_w) // 2
  830. self.prog_bar.setGeometry(bar_x, 3, bar_w, 14)
  831. # Le label prend toute la largeur, Qt le centrera parfaitement au-dessus de la barre
  832. self.lbl_time.setGeometry(0, 3, prog_frame_w, 14)
  833. self._playback_frame.raise_()
  834. self._progress_frame.raise_()
  835. # Mémoriser la hauteur occupée par les overlays pour _init_canvas
  836. self._overlay_h = rw_h - bottom_y + 8
  837. # ══════════════════════════════════════════════════════════════
  838. # GÉNÉRATION (QThread)
  839. # ══════════════════════════════════════════════════════════════
  840. # ══════════════════════════════════════════════════════════════
  841. # CHARGEMENT FICHIER G-CODE
  842. # ══════════════════════════════════════════════════════════════
  843. def _on_open_file(self):
  844. """Ouvre un QFileDialog et charge le fichier sélectionné."""
  845. stds = 'G-Code (*.nc *.gcode *.gc *.tap *.txt);;All files (*.*)'
  846. path, _ = get_open_file(
  847. self, self.t.get('open_file', 'Open G-Code file'), '', stds)
  848. if path:
  849. self._load_file(path)
  850. def _load_file(self, path):
  851. """Parse le fichier G-Code et lance la simulation."""
  852. self._stop_all()
  853. self._loaded_path = path
  854. self.lbl_file.setText(truncate_path(path, 38))
  855. self.lbl_size.setText('')
  856. self.lbl_dur.setText('')
  857. self._show_loading()
  858. # Lecture
  859. try:
  860. with open(path, 'r', encoding='utf-8', errors='replace') as f:
  861. gcode = f.read()
  862. except Exception as e:
  863. self._hide_loading()
  864. QMessageBox.critical(self,
  865. self.t.get('error_title', 'Error'),
  866. f"{self.t.get('error_read_file', 'Cannot read file:')}\n{e}")
  867. return
  868. # Parsing dans un thread pour ne pas bloquer l'UI
  869. self._parse_worker = _ParseWorker(gcode)
  870. self._parse_worker.done.connect(self._on_parse_done)
  871. self._parse_worker.error.connect(self._on_parse_error)
  872. self._parse_worker.start()
  873. def _on_parse_error(self, msg):
  874. self._hide_loading()
  875. QMessageBox.critical(self, self.t.get('parse_error_title', 'Parse Error'), msg)
  876. def _on_parse_done(self, d):
  877. self._hide_loading()
  878. self.final_gcode = d['gcode']
  879. self.points_list = d['pts']
  880. self.total_sec = d.get('total_dur', 0.0)
  881. self.framing_end = 0
  882. self._last_drawn_idx = -1
  883. # Calculer latence_mm depuis le feedrate réel et la config
  884. try:
  885. lat_ms = float(
  886. self.controller.config_manager.get_item('machine_settings', 'laser_latency', 0.0) or 0.0
  887. )
  888. feedrate_mmmin = d.get('feedrate_mmmin', 3000.0)
  889. # lat_ms * feedrate (mm/min) / 60000 = mm (signe inversé)
  890. self.latence_mm = -abs(lat_ms) * feedrate_mmmin / 60000.0
  891. except Exception:
  892. self.latence_mm = 0.0
  893. # Bounds = tous les points parsés (G0 + G1 Q=0 + G1 Q>0)
  894. # On veut voir l'intégralité des déplacements, overscan inclus
  895. pts = self.points_list
  896. if pts is not None and len(pts):
  897. self._mnx = float(pts[:, 0].min())
  898. self._mxx = float(pts[:, 0].max())
  899. self._mny = float(pts[:, 1].min())
  900. self._mxy = float(pts[:, 1].max())
  901. else:
  902. self._mnx = self._mxx = self._mny = self._mxy = 0.0
  903. # Infos affichées
  904. nb_lines = self.final_gcode.count('\n')
  905. self.lbl_size.setText(f'{nb_lines} lines')
  906. self.lbl_dur.setText(self._fmt(self.total_sec))
  907. if self.final_gcode:
  908. self.gcode_view.setPlainText(self.final_gcode)
  909. self._update_gcode_font()
  910. self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
  911. self._right_widget.updateGeometry()
  912. # Détecter l'axe dominant du raster pour pré-remplir le line_step
  913. if pts is not None and len(pts) > 1:
  914. dx_total = float(np.sum(np.abs(np.diff(pts[:, 0]))))
  915. dy_total = float(np.sum(np.abs(np.diff(pts[:, 1]))))
  916. is_horizontal = dx_total >= dy_total
  917. try:
  918. key = 'hor_linestep' if is_horizontal else 'ver_linestep'
  919. step_val = float(
  920. self.controller.config_manager.get_item('machine_settings', key, 0.1) or 0.1
  921. )
  922. self.spin_lstep.blockSignals(True)
  923. self.spin_lstep.setValue(step_val)
  924. self.spin_lstep.blockSignals(False)
  925. except Exception:
  926. pass
  927. self._init_canvas()
  928. # ══════════════════════════════════════════════════════════════
  929. # INITIALISATION CANVAS
  930. # ══════════════════════════════════════════════════════════════
  931. def _init_canvas(self):
  932. cw, ch = self.canvas.width(), self.canvas.height()
  933. # Attendre que le widget soit réellement dimensionné
  934. if cw <= 1 or ch <= 1:
  935. QTimer.singleShot(60, self._init_canvas)
  936. return
  937. if self.points_list is None or len(self.points_list) == 0:
  938. return
  939. # 1. ctrl_max : lu depuis la config
  940. self.ctrl_max = float(
  941. self.controller.config_manager.get_item('machine_settings', 'ctrl_max', 255) or 255
  942. )
  943. pts = self.points_list
  944. # 2. l_step (épaisseur trait laser) : champ éditable UI — non sauvegardé
  945. try:
  946. l_step = float(self.spin_lstep.value())
  947. if l_step <= 0:
  948. l_step = 0.1
  949. except Exception:
  950. l_step = 0.1
  951. # 3. scan_step (espacement réel entre lignes) : détecté depuis le G-Code
  952. # Sert au snap d'échelle et au snap Y — indépendant de l_step utilisateur
  953. scan_step = self._detect_scan_step(pts)
  954. if scan_step is None or scan_step <= 0:
  955. scan_step = l_step # fallback : utiliser l_step si pas de raster détecté
  956. # ─────────────────────────────
  957. # Dimensions réelles pièce (mm)
  958. # ─────────────────────────────
  959. tw = max(0.1, self._mxx - self._mnx)
  960. th = max(0.1, self._mxy - self._mny)
  961. # Espace utile
  962. overlay_h = getattr(self, '_overlay_h', 150)
  963. ch_usable = max(ch - overlay_h, int(ch * 0.5))
  964. # Scale pour tenir dans la vue
  965. sc = min((cw * 0.90) / tw, (ch_usable * 0.90) / th)
  966. # SNAP ÉCHELLE sur scan_step (pas inter-lignes réel)
  967. # → les lignes tombent exactement sur des pixels entiers → aucun gap
  968. snap_px = max(1, int(np.round(scan_step * sc)))
  969. sc = snap_px / scan_step # échelle recalculée
  970. # lw_px pour les marges du buffer (basé sur l_step affiché)
  971. lw_px = max(1, int(np.round(l_step * sc)))
  972. # Dimensions projetées écran
  973. pw = tw * sc
  974. ph = th * sc
  975. # Dimensions buffer image — marge d'une ligne pour la première et dernière
  976. rw = max(1, int(round(pw)) + lw_px * 2)
  977. rh = max(1, int(round(ph)) + lw_px * 2)
  978. # Position : centré horizontalement, marge en haut
  979. x0 = (cw - pw) / 2.0
  980. y0 = max(8.0, (ch_usable - ph) * 0.10)
  981. # ─────────────────────────────
  982. # Initialisation renderer
  983. # ─────────────────────────────
  984. # pwr_min_s = 0 toujours : si on utilisait le min des valeurs actives
  985. # (ex: 40), laser_threshold = max(40, ctrl_max*0.001) = 40, et la
  986. # condition pwr > threshold serait False pour Q=40 → rien dessiné.
  987. # pwr_max_s = valeur max trouvée dans le fichier (ou ctrl_max par défaut).
  988. pwr_col = pts[:, 2]
  989. pwr_active = pwr_col[pwr_col > 0]
  990. if len(pwr_active):
  991. pwr_min_s = 0.0
  992. pwr_max_s = float(pwr_active.max())
  993. else:
  994. pwr_min_s = 0.0
  995. pwr_max_s = float(self.ctrl_max)
  996. self._renderer = _Renderer(
  997. rw,
  998. rh,
  999. sc,
  1000. rh,
  1001. self._mnx,
  1002. self._mny,
  1003. int(lw_px),
  1004. self.ctrl_max,
  1005. pwr_min=pwr_min_s,
  1006. pwr_max=pwr_max_s,
  1007. l_step_mm=scan_step, # snap Y basé sur espacement réel
  1008. draw_step_mm=l_step # épaisseur du trait (valeur utilisateur)
  1009. )
  1010. # Stockage géométrie
  1011. self._px_w, self._px_h = pw, ph
  1012. self._x0, self._y0 = x0, y0
  1013. self._scale = sc
  1014. # Index sécurisé
  1015. # self._last_drawn_idx = max(0, self.framing_end - 1)
  1016. self._last_drawn_idx = -1
  1017. # ─────────────────────────────
  1018. # Position initiale laser
  1019. # ─────────────────────────────
  1020. p0 = self.points_list[0]
  1021. lx = x0 + (p0[0] - self._mnx) * sc
  1022. ly = y0 + ph - (p0[1] - self._mny) * sc
  1023. # ─────────────────────────────
  1024. # Setup canvas (AVEC l_step en dernier argument)
  1025. # ─────────────────────────────
  1026. self.canvas.setup(
  1027. self._renderer.display_data,
  1028. x0, y0, pw, ph, sc,
  1029. self._mnx, self._mxx,
  1030. self._mny, self._mxy,
  1031. l_step if l_step > 0 else 1.0,
  1032. overlay_h
  1033. )
  1034. self.canvas.set_laser(lx, ly)
  1035. # Forcer le repositionnement des overlays
  1036. self.resizeEvent(None)
  1037. # Afficher l'image complète immédiatement au chargement
  1038. QTimer.singleShot(0, lambda: (
  1039. self._redraw_to(len(self.points_list) - 1),
  1040. self._update_ui(len(self.points_list) - 1)
  1041. ))
  1042. # ══════════════════════════════════════════════════════════════
  1043. # ANIMATION — hot path
  1044. # ══════════════════════════════════════════════════════════════
  1045. def _tick(self):
  1046. """
  1047. Boucle animation (16 ms).
  1048. Rasterisation incrémentale sécurisée.
  1049. """
  1050. if not self.sim_running:
  1051. return
  1052. if self.points_list is None or len(self.points_list) < 2:
  1053. return
  1054. pts = self.points_list
  1055. total_pts = len(pts)
  1056. last_idx = total_pts - 1
  1057. # ───────────────────────────────
  1058. # Temps simulation
  1059. # ───────────────────────────────
  1060. now = time.perf_counter()
  1061. if self.last_frame_time == 0:
  1062. self.last_frame_time = now
  1063. dt = (now - self.last_frame_time) * self.sim_speed
  1064. self.last_frame_time = now
  1065. self.current_sim_time += dt
  1066. # Clamp temps
  1067. if self.current_sim_time >= self.total_sec:
  1068. self.current_sim_time = self.total_sec
  1069. # ───────────────────────────────
  1070. # Avancement index via searchsorted
  1071. # ───────────────────────────────
  1072. idx = np.searchsorted(
  1073. pts[:, 4],
  1074. self.current_sim_time,
  1075. side='right'
  1076. ) - 1
  1077. idx = max(0, min(int(idx), last_idx))
  1078. self.current_idx = idx
  1079. # ───────────────────────────────
  1080. # Rasterisation incrémentale
  1081. # ───────────────────────────────
  1082. if self._renderer is not None:
  1083. # start_idx = max(0, self.framing_end - 1)
  1084. start_idx = 0
  1085. if self.current_idx > self._last_drawn_idx:
  1086. seg_start = max(self._last_drawn_idx, start_idx)
  1087. if seg_start < self.current_idx:
  1088. self._renderer.draw_incremental(
  1089. pts,
  1090. seg_start,
  1091. self.current_idx,
  1092. self.latence_enabled,
  1093. self.latence_mm,
  1094. self.full_metadata.get('scan_axis', 'X')
  1095. )
  1096. self._last_drawn_idx = self.current_idx
  1097. self.canvas.notify_dirty()
  1098. # ───────────────────────────────
  1099. # Fin animation
  1100. # ───────────────────────────────
  1101. if self.current_idx >= last_idx:
  1102. self._finish_anim()
  1103. return
  1104. # ───────────────────────────────
  1105. # Interpolation laser fluide
  1106. # ───────────────────────────────
  1107. pc = pts[self.current_idx]
  1108. pn = pts[self.current_idx + 1]
  1109. td = pn[4] - pc[4]
  1110. if td > 0:
  1111. r = (self.current_sim_time - pc[4]) / td
  1112. r = max(0.0, min(1.0, r))
  1113. else:
  1114. r = 0.0
  1115. lx_mm = pc[0] + (pn[0] - pc[0]) * r
  1116. ly_mm = pc[1] + (pn[1] - pc[1]) * r
  1117. self.canvas.set_laser(*self._mm_to_screen(lx_mm, ly_mm))
  1118. # ───────────────────────────────
  1119. # UI sync
  1120. # ───────────────────────────────
  1121. self._update_ui(self.current_idx)
  1122. # ══════════════════════════════════════════════════════════════
  1123. # REDRAW COMPLET (scrub / seek / latence toggle)
  1124. # ══════════════════════════════════════════════════════════════
  1125. def _redraw_to(self, target_idx):
  1126. """
  1127. Redessine complètement la simulation jusqu'à target_idx.
  1128. Utilisé pour :
  1129. - scrub barre de progression
  1130. - skip_to_end
  1131. - toggle latence
  1132. """
  1133. if self._renderer is None:
  1134. return
  1135. if self.points_list is None or len(self.points_list) == 0:
  1136. return
  1137. total_pts = len(self.points_list)
  1138. target_idx = max(0, min(int(target_idx), total_pts - 1))
  1139. # start_idx = max(0, self.framing_end - 1)
  1140. start_idx = 0
  1141. if target_idx <= start_idx:
  1142. self._renderer.reset()
  1143. self._last_drawn_idx = start_idx
  1144. else:
  1145. # Fond blanc
  1146. self._renderer.display_data.fill(255)
  1147. scan_axis = self.full_metadata.get('scan_axis', 'X')
  1148. # Dessiner le framing en premier (il sera le plus foncé si puissance forte)
  1149. if self.framing_end > 0:
  1150. polys = self._renderer._compute_segments(
  1151. self.points_list, 0, self.framing_end, False, 0.0, scan_axis)
  1152. self._renderer._rasterize(polys)
  1153. # Dessiner le raster — np.minimum protège les pixels du framing
  1154. if target_idx > self.framing_end:
  1155. polys = self._renderer._compute_segments(
  1156. self.points_list, self.framing_end, target_idx,
  1157. self.latence_enabled, self.latence_mm, scan_axis)
  1158. self._renderer._rasterize(polys)
  1159. self._last_drawn_idx = target_idx
  1160. self.canvas.notify_dirty()
  1161. mx = float(self.points_list[target_idx][0])
  1162. my = float(self.points_list[target_idx][1])
  1163. self.canvas.set_laser(*self._mm_to_screen(mx, my))
  1164. # ══════════════════════════════════════════════════════════════
  1165. # CONTRÔLES PLAYBACK
  1166. # ══════════════════════════════════════════════════════════════
  1167. def _detect_scan_step(self, pts):
  1168. """Détecte l'espacement réel entre lignes de scan depuis les points parsés."""
  1169. if pts is None or len(pts) < 2:
  1170. return None
  1171. try:
  1172. # Chercher les transitions laser actif → inactif (fin de ligne)
  1173. pwr = pts[:, 2]
  1174. starts = np.where((pwr[:-1] == 0) & (pwr[1:] > 0))[0] + 1
  1175. if len(starts) < 2:
  1176. # Fallback : diffs Y uniques pour raster horizontal
  1177. y_unique = np.unique(np.round(pts[:, 1], 6))
  1178. if len(y_unique) > 1:
  1179. diffs = np.diff(y_unique)
  1180. diffs = diffs[diffs > 1e-6]
  1181. if len(diffs):
  1182. return float(np.median(diffs))
  1183. return None
  1184. # Distance entre débuts de passes consécutives
  1185. n = min(len(starts) - 1, 30)
  1186. dists = []
  1187. for i in range(n):
  1188. p1 = pts[starts[i], :2]
  1189. p2 = pts[starts[i + 1], :2]
  1190. d = float(np.hypot(p2[0] - p1[0], p2[1] - p1[1]))
  1191. if 1e-4 < d < 10.0:
  1192. dists.append(d)
  1193. if dists:
  1194. return float(np.median(dists))
  1195. except Exception:
  1196. pass
  1197. return None
  1198. def _on_lstep_changed(self, value):
  1199. """Recalcule le canvas quand l'utilisateur change le line_step."""
  1200. if self.points_list is not None and len(self.points_list) > 0:
  1201. self._stop_play()
  1202. self._init_canvas()
  1203. def toggle_pause(self):
  1204. if self.points_list is None or self.points_list.size == 0:
  1205. return
  1206. if self.current_idx >= self.points_list.shape[0] - 1: # fin atteinte → replay
  1207. self.rewind_sim(); self._start_play(); return
  1208. if self.sim_running:
  1209. self._stop_play()
  1210. else:
  1211. self._start_play()
  1212. def _start_play(self):
  1213. # Effacer l'image si déjà dessinée, pour repartir de zéro
  1214. if self._last_drawn_idx > 0:
  1215. if self._renderer:
  1216. self._renderer.reset()
  1217. self.canvas.notify_dirty()
  1218. self._last_drawn_idx = -1
  1219. self.current_idx = 0
  1220. self.current_sim_time = 0.0
  1221. self.last_frame_time = 0.0
  1222. self.prog_bar.setValue(0)
  1223. self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
  1224. if self.points_list is not None and self.points_list.size > 0:
  1225. lx, ly = self._mm_to_screen(self.points_list[0][0], self.points_list[0][1])
  1226. self.canvas.set_laser(lx, ly)
  1227. self.sim_running = True
  1228. self.last_frame_time = time.perf_counter()
  1229. self.btn_play.setIcon(QIcon(self.pause_pixmap))
  1230. self.btn_play.setStyleSheet(self._gbtn('#e67e22', '#ca6f1e'))
  1231. self._anim_timer.start()
  1232. def _stop_play(self):
  1233. self.sim_running = False
  1234. self._anim_timer.stop()
  1235. self.btn_play.setIcon(QIcon(self.play_pixmap))
  1236. self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
  1237. def _finish_anim(self):
  1238. self.sim_running = False
  1239. self._anim_timer.stop()
  1240. self.btn_play.setIcon(QIcon(self.rerun_pixmap ))
  1241. # self.btn_play.setText('🔄')
  1242. self.btn_play.setStyleSheet(self._gbtn('#2980b9', '#1a6090'))
  1243. self._update_ui(len(self.points_list) - 1)
  1244. def rewind_sim(self):
  1245. self._stop_play()
  1246. self.current_idx = 0
  1247. self.current_sim_time = 0.0
  1248. self.last_frame_time = 0.0
  1249. self._last_drawn_idx = -1
  1250. if self._renderer:
  1251. self._renderer.reset()
  1252. self.canvas.notify_dirty()
  1253. self.prog_bar.setValue(0)
  1254. self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
  1255. self.btn_play.setIcon(QIcon(self.play_pixmap))
  1256. self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
  1257. self._highlight_gcode(0)
  1258. if self.points_list is not None and self.points_list.size > 0:
  1259. lx, ly = self._mm_to_screen(
  1260. self.points_list[0][0], self.points_list[0][1])
  1261. self.canvas.set_laser(lx, ly)
  1262. def skip_to_end(self):
  1263. if self.points_list is None: return
  1264. self._stop_play()
  1265. self.current_idx = len(self.points_list) - 1
  1266. self.current_sim_time = self.total_sec
  1267. self._redraw_to(self.current_idx)
  1268. self._update_ui(self.current_idx)
  1269. self._finish_anim()
  1270. def _set_speed(self, val):
  1271. try:
  1272. self.sim_speed = float(val)
  1273. self.last_frame_time = time.perf_counter()
  1274. except ValueError: pass
  1275. for v, b in self._spd_btns.items():
  1276. b.setChecked(v == val)
  1277. def _on_lat_toggle(self, checked):
  1278. self.latence_enabled = checked
  1279. if self.points_list is not None and len(self.points_list) > 0:
  1280. target = len(self.points_list) - 1 if self._last_drawn_idx >= len(self.points_list) - 1 else self.current_idx
  1281. self._redraw_to(target)
  1282. def _on_prog_click(self, e):
  1283. # Sécurités de base
  1284. if self.points_list is None or self.total_sec <= 0:
  1285. return
  1286. total_pts = len(self.points_list)
  1287. if total_pts == 0:
  1288. return
  1289. # 1. RÉCUPÉRATION DE LA GÉOMÉTRIE RÉELLE
  1290. # On récupère la largeur actuelle de la barre (celle affichée à l'écran)
  1291. bar_w = self.prog_bar.width()
  1292. if bar_w <= 1:
  1293. return
  1294. # 2. CALCUL DU RATIO (Pixel-Perfect)
  1295. # mapFromGlobal convertit la position absolue de la souris sur l'écran
  1296. # vers le référentiel interne de la prog_bar (0 = bord gauche exact).
  1297. # Cela élimine tout décalage du aux marges ou au centrage (le bar_x).
  1298. local_pos = self.prog_bar.mapFromGlobal(e.globalPosition().toPoint())
  1299. pos_x = local_pos.x()
  1300. # On sature le ratio entre 0.0 (début) et 1.0 (fin)
  1301. ratio = max(0.0, min(1.0, pos_x / bar_w))
  1302. # 3. LOGIQUE DE SAUT DANS LA SIMULATION
  1303. # On mémorise si la lecture était en cours pour la reprendre après
  1304. was_running = self.sim_running
  1305. if was_running:
  1306. self._stop_play()
  1307. # Calcul du nouveau temps cible basé sur le ratio cliqué
  1308. self.current_sim_time = ratio * self.total_sec
  1309. # Recherche de l'index correspondant au temps dans les données NumPy
  1310. # (on suppose que self.points_list[:, 4] contient les timestamps cumulés)
  1311. idx = np.searchsorted(
  1312. self.points_list[:, 4],
  1313. self.current_sim_time,
  1314. side='right'
  1315. ) - 1
  1316. # Sécurisation de l'index
  1317. idx = max(0, min(int(idx), total_pts - 1))
  1318. self.current_idx = idx
  1319. # 4. MISE À JOUR VISUELLE IMMÉDIATE
  1320. # Redessine le canvas jusqu'à ce point
  1321. self._redraw_to(self.current_idx)
  1322. # Met à jour les labels, le curseur laser et la valeur de la barre
  1323. self._update_ui(self.current_idx)
  1324. # 5. REPRISE DE LA LECTURE
  1325. if was_running:
  1326. # On réinitialise le timer de frame pour éviter un bond temporel
  1327. self.last_frame_time = time.perf_counter()
  1328. self._start_play()
  1329. # ══════════════════════════════════════════════════════════════
  1330. # UI SYNC
  1331. # ══════════════════════════════════════════════════════════════
  1332. # ══════════════════════════════════════════════════════════════
  1333. # NAVIGATION G-CODE (click + clavier)
  1334. # ══════════════════════════════════════════════════════════════
  1335. def _seek_to_gcode_line(self, line_num):
  1336. """Seek la simulation au premier point dont la ligne G-Code == line_num."""
  1337. if self.points_list is None or len(self.points_list) == 0:
  1338. return
  1339. pts = self.points_list
  1340. # pts[:,3] contient le numéro de ligne G-Code
  1341. matches = np.where(pts[:, 3].astype(int) >= line_num)[0]
  1342. if len(matches) == 0:
  1343. idx = len(pts) - 1
  1344. else:
  1345. idx = int(matches[0])
  1346. was_running = self.sim_running
  1347. self._stop_play()
  1348. self.current_idx = idx
  1349. self.current_sim_time = float(pts[idx][4])
  1350. self._redraw_to(idx)
  1351. self._update_ui(idx)
  1352. if was_running:
  1353. self.last_frame_time = time.perf_counter()
  1354. self._start_play()
  1355. def _on_gcode_click(self, e):
  1356. """Click sur le G-Code : positionne le curseur ET seek la simulation."""
  1357. # Laisser le comportement normal de QPlainTextEdit (déplace le curseur)
  1358. QPlainTextEdit.mousePressEvent(self.gcode_view, e)
  1359. line_num = self.gcode_view.textCursor().blockNumber() + 1
  1360. self._seek_to_gcode_line(line_num)
  1361. def _on_gcode_key(self, e):
  1362. """Flèches clavier sur le G-Code : navigation ±1 ligne ou ±20 lignes."""
  1363. from PyQt6.QtCore import Qt as _Qt
  1364. key = e.key()
  1365. if key in (_Qt.Key.Key_Left, _Qt.Key.Key_Right,
  1366. _Qt.Key.Key_Up, _Qt.Key.Key_Down):
  1367. cur_line = self.gcode_view.textCursor().blockNumber() + 1
  1368. if key == _Qt.Key.Key_Left:
  1369. new_line = max(1, cur_line - 1)
  1370. elif key == _Qt.Key.Key_Right:
  1371. doc_lines = self.gcode_view.document().blockCount()
  1372. new_line = min(doc_lines, cur_line + 1)
  1373. elif key == _Qt.Key.Key_Up:
  1374. new_line = max(1, cur_line - 20)
  1375. else: # Down
  1376. doc_lines = self.gcode_view.document().blockCount()
  1377. new_line = min(doc_lines, cur_line + 20)
  1378. # Déplacer le curseur du gcode_view
  1379. block = self.gcode_view.document().findBlockByLineNumber(new_line - 1)
  1380. if block.isValid():
  1381. cur = self.gcode_view.textCursor()
  1382. cur.setPosition(block.position())
  1383. cur.select(cur.SelectionType.LineUnderCursor)
  1384. self.gcode_view.setTextCursor(cur)
  1385. self.gcode_view.ensureCursorVisible()
  1386. self._seek_to_gcode_line(new_line)
  1387. else:
  1388. # Comportement normal pour toutes les autres touches
  1389. QPlainTextEdit.keyPressEvent(self.gcode_view, e)
  1390. def _update_ui(self, idx):
  1391. if self.points_list is None or idx >= len(self.points_list): return
  1392. ts = float(self.points_list[idx][4])
  1393. # On base le rendu visuel sur l'écoulement du TEMPS, pas sur l'index des points
  1394. pct = ts / max(0.001, self.total_sec)
  1395. pct = max(0.0, min(1.0, pct))
  1396. self.prog_bar.setValue(int(pct * 10000))
  1397. self.lbl_time.setText(
  1398. f'{self._fmt(ts)} / {self._fmt(self.total_sec)}')
  1399. self._highlight_gcode(idx)
  1400. def _highlight_gcode(self, idx):
  1401. if self.points_list is None or idx >= len(self.points_list): return
  1402. try:
  1403. line_num = int(self.points_list[idx][3])
  1404. block = self.gcode_view.document().findBlockByLineNumber(line_num-1)
  1405. if block.isValid():
  1406. cur = self.gcode_view.textCursor()
  1407. cur.setPosition(block.position())
  1408. cur.select(cur.SelectionType.LineUnderCursor)
  1409. self.gcode_view.setTextCursor(cur)
  1410. self.gcode_view.ensureCursorVisible()
  1411. except Exception: pass
  1412. # ══════════════════════════════════════════════════════════════
  1413. # EXPORT
  1414. # ══════════════════════════════════════════════════════════════
  1415. # ══════════════════════════════════════════════════════════════
  1416. # LANGUE
  1417. # ══════════════════════════════════════════════════════════════
  1418. def _apply_language(self, lang: str, translations: dict):
  1419. """Appelée par update_ui_language — recharge self.t et met à jour les widgets."""
  1420. from core.translations import TRANSLATIONS as _TR
  1421. repo = translations if translations else _TR.get(lang, _TR['English'])
  1422. self.t = repo.get('simulation', {})
  1423. if hasattr(self, 'btn_open'):
  1424. self.btn_open.setText(self.t.get('open_file', 'Open G-Code file…'))
  1425. if hasattr(self, 'lbl_file') and not self._loaded_path:
  1426. self.lbl_file.setText(self.t.get('no_file', 'No file loaded'))
  1427. if hasattr(self, 'btn_cancel'):
  1428. self.btn_cancel.setText(self.t.get('cancel', 'Close'))
  1429. if hasattr(self, 'lbl_lstep'):
  1430. self.lbl_lstep.setText(self.t.get('line_step_lbl', 'Line step (mm):'))
  1431. if hasattr(self, 'lbl_lat'):
  1432. self.lbl_lat.setText(self.t.get('simulate_latency', 'Simulate latency'))
  1433. if hasattr(self, 'canvas') and self.canvas._img_buf is None:
  1434. self.canvas.set_placeholder(self.t.get('open_gcode_hint', 'Open a G-Code file to start'))
  1435. # ══════════════════════════════════════════════════════════════
  1436. # THÈME
  1437. # ══════════════════════════════════════════════════════════════
  1438. def apply_theme(self, colors: dict):
  1439. text = colors['text']
  1440. text_sec = colors['text_secondary']
  1441. bg_main = colors['bg_main']
  1442. bg_card = colors['bg_card']
  1443. bg_right = colors['bg_deep']
  1444. bg_entry = colors['bg_entry']
  1445. bg_spd = colors['bg_speed']
  1446. border = colors['border']
  1447. border_spd = colors['border_strong']
  1448. gcode_col = colors['text_code']
  1449. btn_dark_bg = colors['btn_dark']
  1450. btn_dark_hov = colors['btn_dark_hover']
  1451. # Scopé pour ne pas cascader sur _SimCanvas
  1452. self.setStyleSheet(
  1453. f'CheckerViewQt {{ background:{bg_main}; color:{text}; }}'
  1454. )
  1455. if hasattr(self, 'left'):
  1456. self.left.setStyleSheet(
  1457. f'QFrame#checkerLeft{{background:{bg_main};border-right:1px solid {border};}}'
  1458. )
  1459. if hasattr(self, '_file_frame'):
  1460. self._file_frame.setStyleSheet(
  1461. f'QFrame{{background:{bg_card};border-radius:6px;border:1px solid {border};}}'
  1462. )
  1463. if hasattr(self, 'gl_lbl'):
  1464. self.gl_lbl.setStyleSheet(f'color:{text};font-weight:bold;font-size:11px;border:none;')
  1465. if hasattr(self, 'lbl_file'):
  1466. self.lbl_file.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
  1467. if hasattr(self, 'lbl_lstep'):
  1468. self.lbl_lstep.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
  1469. if hasattr(self, 'spin_lstep'):
  1470. self.spin_lstep.setStyleSheet(
  1471. f'QDoubleSpinBox{{background:{bg_entry};color:{text};border:1px solid {border_spd};'
  1472. f'border-radius:3px;font-size:10px;padding:1px 4px;}}'
  1473. f'QDoubleSpinBox::up-button,QDoubleSpinBox::down-button{{width:14px;}}'
  1474. )
  1475. if hasattr(self, 'lbl_lat'):
  1476. self.lbl_lat.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
  1477. if hasattr(self, 'lbl_time'):
  1478. self.lbl_time.setStyleSheet(
  1479. f'color:{text};font-size:9px;font-weight:bold;background:transparent;border:none;'
  1480. )
  1481. if hasattr(self, 'gcode_view'):
  1482. self.gcode_view.setStyleSheet(
  1483. f'QPlainTextEdit{{background:{bg_entry};color:{gcode_col};'
  1484. f'font-family:Consolas;border:1px solid {border};border-radius:3px;}}'
  1485. )
  1486. self.gcode_view.verticalScrollBar().setStyleSheet(f"""
  1487. QScrollBar:vertical {{
  1488. border: none;
  1489. background: {colors['scrollbar_bg']};
  1490. width: 10px;
  1491. margin: 0px;
  1492. }}
  1493. QScrollBar::handle:vertical {{
  1494. background: {colors['scrollbar_handle']};
  1495. min-height: 20px;
  1496. border-radius: 5px;
  1497. }}
  1498. QScrollBar::handle:vertical:hover {{
  1499. background: #1F6AA5;
  1500. }}
  1501. QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
  1502. height: 0px;
  1503. }}
  1504. QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
  1505. background: none;
  1506. }}
  1507. """)
  1508. if hasattr(self, 'btn_cancel'):
  1509. self.btn_cancel.setStyleSheet(self._gbtn(colors['btn_cancel'], colors['btn_cancel_hover']))
  1510. if hasattr(self, '_spd_frame') and self._spd_frame:
  1511. self._spd_frame.setStyleSheet(
  1512. f'QFrame{{background:{bg_spd};border:1px solid {border_spd};border-radius:5px;}}'
  1513. )
  1514. for lbl in self._spd_frame.findChildren(QLabel):
  1515. lbl.setStyleSheet(f'color:{text};font-weight:bold;font-size:9px;border:none;')
  1516. if hasattr(self, '_spd_btns'):
  1517. spd_style = (
  1518. f'QPushButton{{background:{bg_card};color:{text_sec};'
  1519. f'border:1px solid {border_spd};border-radius:3px;'
  1520. f'padding:0px 4px;font-size:8px;min-width:18px;max-width:30px;}}'
  1521. f'QPushButton:checked{{background:{colors["btn_speed_checked"]};color:white;'
  1522. f'border-color:{colors["btn_speed_checked_hov"]};}}'
  1523. f'QPushButton:hover:!checked{{background:{btn_dark_bg};color:{text};}}'
  1524. )
  1525. for b in self._spd_btns.values():
  1526. b.setStyleSheet(spd_style)
  1527. if hasattr(self, 'prog_bar'):
  1528. self.prog_bar.setStyleSheet(
  1529. f'QProgressBar{{background:{colors["progress_bg"]};border-radius:7px;border:none;}}'
  1530. f'QProgressBar::chunk{{background:#27ae60;border-radius:7px;}}'
  1531. )
  1532. if hasattr(self, '_right_widget'):
  1533. self._right_widget.setStyleSheet(f'background:{bg_right};')
  1534. if hasattr(self, 'canvas'):
  1535. self.canvas.set_theme(bg_right)
  1536. # ══════════════════════════════════════════════════════════════
  1537. # EXPORT
  1538. # ══════════════════════════════════════════════════════════════
  1539. def on_cancel(self):
  1540. self._stop_all()
  1541. if self.return_view == 'raster': self.controller.show_raster_mode()
  1542. elif self.return_view == 'infill': self.controller.show_infill_mode()
  1543. else: self.controller.show_dashboard()
  1544. def _stop_all(self):
  1545. self.sim_running = False
  1546. self._anim_timer.stop()
  1547. if hasattr(self, '_worker') and self._worker.isRunning():
  1548. self._worker.quit(); self._worker.wait(500)
  1549. def closeEvent(self, e):
  1550. self._stop_all(); super().closeEvent(e)
  1551. # ══════════════════════════════════════════════════════════════
  1552. # UTILITAIRES
  1553. # ══════════════════════════════════════════════════════════════
  1554. def _mm_to_screen(self, mx, my):
  1555. sx = self._x0 + (mx - self._mnx) * self._scale
  1556. sy = self._y0 + self._px_h - (my - self._mny) * self._scale
  1557. return sx, sy
  1558. @staticmethod
  1559. def _fmt(s):
  1560. s = float(s)
  1561. return f'{int(s//3600):02d}:{int((s%3600)//60):02d}:{int(s%60):02d}'
  1562. @staticmethod
  1563. def _gbtn(bg, hov):
  1564. return (f'QPushButton{{background:{bg};color:white;border-radius:6px;'
  1565. f'border:none;}}'
  1566. f'QPushButton:hover{{background:{hov};}}')