simulation_view_qt.py 84 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051
  1. # -*- coding: utf-8 -*-
  2. """
  3. A.L.I.G. - SimulationViewQt
  4. Migration PyQt6 — v2 optimisée
  5. Améliorations vs CTk original :
  6. • Rendu batch vectorisé :
  7. - Tous les polygones du frame sont pré-calculés en NumPy
  8. - Une seule passe QPainter sur le QImage (zéro boucle Python dans le hot path)
  9. - Rasterisation directe dans le buffer uint8 via QPainter (pas de cv2 ni PIL)
  10. • Zoom molette (centré sur la souris) + pan clic gauche
  11. • Loupe supprimée
  12. • Génération G-Code dans un QThread (UI non bloquante)
  13. • QTimer fixe 16 ms (~60 fps) au lieu de after(16) Tkinter
  14. """
  15. import os
  16. import time
  17. import numpy as np
  18. from PyQt6.QtWidgets import (
  19. QWidget, QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
  20. QProgressBar, QSizePolicy, QMessageBox, QPlainTextEdit,
  21. )
  22. from PyQt6.QtCore import (
  23. Qt, QTimer, QThread, pyqtSignal, QRect, QRectF, QPointF, QLineF, QSize
  24. )
  25. from PyQt6.QtGui import (
  26. QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFont,
  27. QLinearGradient, QPainterPath, QPolygonF, QTransform, QIcon
  28. )
  29. from engine.gcode_parser import GCodeParser
  30. from core.utils import save_dashboard_data, truncate_path
  31. from core.translations import TRANSLATIONS
  32. from core.themes import get_theme
  33. from utils.paths import SVG_ICONS
  34. from gui.utils_qt import get_svg_pixmap
  35. from gui.utils_qt import get_save_file
  36. from gui.switch import Switch
  37. # ══════════════════════════════════════════════════════════════════════════════
  38. # WORKER : génération G-Code hors thread UI
  39. # ══════════════════════════════════════════════════════════════════════════════
  40. class _GenWorker(QThread):
  41. done = pyqtSignal(dict)
  42. error = pyqtSignal(str)
  43. def __init__(self, engine, payload, parser):
  44. super().__init__()
  45. self.engine = engine
  46. self.payload = payload
  47. self.parser = parser
  48. def run(self):
  49. try:
  50. p = self.payload['params']
  51. raster_mode = p.get('raster_mode', 'horizontal')
  52. est_size_str = self.payload.get('estimated_size', 'N/A')
  53. # A — Framing
  54. framing_gcode = self.engine.prepare_framing(
  55. self.payload['framing'],
  56. (self.payload['metadata']['real_w'],
  57. self.payload['metadata']['real_h']),
  58. self.payload['offsets'],
  59. )
  60. # B — Métadonnées enrichies
  61. meta = self.payload['metadata'].copy()
  62. meta.update({
  63. 'framing_code': framing_gcode,
  64. 'gray_steps': p.get('gray_steps'),
  65. 'use_s_mode': p.get('use_s_mode'),
  66. 'raster_mode': raster_mode,
  67. 'scan_axis': 'X' if raster_mode == 'horizontal' else 'Y',
  68. })
  69. # C — G-Code final
  70. final_gcode, latence_mm = self.engine.build_final_gcode(
  71. self.payload['matrix'],
  72. self.payload['dims'],
  73. self.payload['offsets'],
  74. p,
  75. self.payload['text_blocks'],
  76. meta,
  77. )
  78. latence_mm = float(latence_mm)
  79. # D — Parsing
  80. f_pts, f_dur, f_lim = self.parser.parse(framing_gcode)
  81. framing_end = len(f_pts) if f_pts is not None else 0
  82. pts, _, lim = self.parser.parse(final_gcode)
  83. valid = [l for l in [f_lim, lim]
  84. if l is not None and not all(abs(v) < 1e-9 for v in l)]
  85. if valid:
  86. bx0 = min(l[0] for l in valid)
  87. bx1 = max(l[1] for l in valid)
  88. by0 = min(l[2] for l in valid)
  89. by1 = max(l[3] for l in valid)
  90. else:
  91. bx0 = bx1 = by0 = by1 = 0.0
  92. # E — Timestamps cumulés
  93. dur = 0.0
  94. if pts is not None and len(pts) > 1:
  95. deltas = np.diff(pts[:, :2], axis=0)
  96. distances = np.hypot(deltas[:, 0], deltas[:, 1])
  97. rates = pts[1:, 4] / 60.0
  98. times = np.divide(distances, rates,
  99. out=np.zeros_like(distances),
  100. where=rates > 0)
  101. pts[0, 4] = 0.0
  102. pts[1:, 4] = np.cumsum(times)
  103. m = self.payload.get('metadata', {})
  104. dims = self.payload.get('dims', (0, 0, 0, 0))
  105. h_px, w_px = dims[0], dims[1]
  106. if raster_mode == 'vertical':
  107. nb = float(w_px); dist = float(m.get('real_h', 0))
  108. else:
  109. nb = float(h_px); dist = float(m.get('real_w', 0))
  110. feedrate = float(p.get('feedrate', 3000))
  111. overscan = float(p.get('premove', 2.0))
  112. lstep = float(p.get('line_step', p.get('l_step', 0.1)))
  113. theo = (nb * (dist + 2*overscan) + (nb-1)*lstep) / (feedrate/60)
  114. dur = max(pts[-1, 4], theo)
  115. self.done.emit({
  116. 'pts': pts,
  117. 'framing_end': framing_end,
  118. 'total_dur': dur,
  119. 'meta': meta,
  120. 'latence_mm': latence_mm,
  121. 'est_size': est_size_str,
  122. 'bounds': (bx0, bx1, by0, by1),
  123. 'final_gcode': final_gcode,
  124. 'framing_gcode': framing_gcode,
  125. })
  126. except Exception as e:
  127. import traceback; traceback.print_exc()
  128. self.error.emit(str(e))
  129. # ══════════════════════════════════════════════════════════════════════════════
  130. # RENDERER — logique de rasterisation batch (100 % NumPy + une passe QPainter)
  131. # ══════════════════════════════════════════════════════════════════════════════
  132. class _Renderer:
  133. """
  134. Stratégie haute perf :
  135. 1. _compute_segments() vectorise TOUS les segments éligibles en une fois
  136. via NumPy → tableau de QPolygonF groupés par couleur.
  137. 2. _rasterize() ouvre UN QPainter sur le QImage et dessine tous les
  138. polygones en une seule boucle Python sans calcul dans la boucle.
  139. 3. Résultat copié dans display_data uint8.
  140. """
  141. def __init__(self, rect_w, rect_h, scale, total_px_h,
  142. min_x, min_y, laser_width_px, ctrl_max,
  143. pwr_min=0.0, pwr_max=None, l_step_mm=None):
  144. self.rect_w = rect_w
  145. self.rect_h = rect_h
  146. self.scale = scale
  147. self.total_px_h = total_px_h
  148. self.min_x = min_x
  149. self.min_y = min_y
  150. self.laser_width_px = max(1, int(round(float(laser_width_px))))
  151. self.ctrl_max = float(ctrl_max)
  152. self.pwr_min = float(pwr_min)
  153. self.pwr_max = float(pwr_max) if pwr_max is not None else self.ctrl_max
  154. # l_step en mm — pour calculer top par index et éviter l'accumulation d'erreurs
  155. self.l_step_mm = float(l_step_mm) if l_step_mm else None
  156. self.l_step_px = float(l_step_mm) * scale if l_step_mm else None
  157. self.display_data = np.full((rect_h, rect_w), 255, dtype=np.uint8)
  158. self._qi_ref = None
  159. def reset(self):
  160. self.display_data.fill(255)
  161. # ─── Calcul des segments à rasteriser ───────────────────────────────────
  162. def _compute_segments(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
  163. """
  164. Retourne {gray: [(x1,y1,x2,y2)...]} en coordonnées pixels flottantes.
  165. Les lignes raster horizontales sont stabilisées par index de ligne.
  166. """
  167. if pts_arr is None or len(pts_arr) < 2:
  168. return None
  169. if start >= end or start >= len(pts_arr) - 1:
  170. return None
  171. safe_end = min(end, len(pts_arr) - 1)
  172. p1 = pts_arr[start:safe_end].copy()
  173. p2 = pts_arr[start+1:safe_end+1].copy()
  174. if len(p1) == 0:
  175. return None
  176. # ── filtre puissance
  177. laser_threshold = max(self.pwr_min, self.ctrl_max * 0.001)
  178. mask = p2[:,2] > laser_threshold
  179. if not mask.any():
  180. return None
  181. p1 = p1[mask]
  182. p2 = p2[mask]
  183. x1 = p1[:,0].copy()
  184. y1_mm = p1[:,1].copy()
  185. x2 = p2[:,0].copy()
  186. y2_mm = p2[:,1].copy()
  187. # ── correction latence
  188. if use_lat and lat_mm != 0:
  189. if scan_axis == 'X':
  190. d = x2 - x1
  191. x1[d>1e-6] += lat_mm
  192. x2[d>1e-6] += lat_mm
  193. x1[d<-1e-6] -= lat_mm
  194. x2[d<-1e-6] -= lat_mm
  195. else:
  196. d = y2_mm - y1_mm
  197. y1_mm[d>1e-6] += lat_mm
  198. y2_mm[d>1e-6] += lat_mm
  199. y1_mm[d<-1e-6] -= lat_mm
  200. y2_mm[d<-1e-6] -= lat_mm
  201. # ── conversion mm → pixels
  202. sc = self.scale
  203. mnx = self.min_x
  204. mny = self.min_y
  205. th = self.total_px_h
  206. fx1 = (x1 - mnx) * sc
  207. fy1 = th - (y1_mm - mny) * sc
  208. fx2 = (x2 - mnx) * sc
  209. fy2 = th - (y2_mm - mny) * sc
  210. # ── rejet hors buffer
  211. lw = self.laser_width_px
  212. bw = float(self.rect_w)
  213. bh = float(self.rect_h)
  214. ok = (
  215. (np.maximum(fx1,fx2) >= -lw) &
  216. (np.minimum(fx1,fx2) < bw+lw) &
  217. (np.maximum(fy1,fy2) >= -lw) &
  218. (np.minimum(fy1,fy2) < bh+lw)
  219. )
  220. if not ok.any():
  221. return None
  222. fx1=fx1[ok]; fy1=fy1[ok]
  223. fx2=fx2[ok]; fy2=fy2[ok]
  224. pwr=p2[:,2][ok]
  225. y1_mm=y1_mm[ok]; y2_mm=y2_mm[ok]
  226. # ── filtre longueur
  227. dx = fx2 - fx1
  228. dy = fy2 - fy1
  229. vis = (dx*dx + dy*dy) >= 0.25
  230. if not vis.any():
  231. return None
  232. fx1=fx1[vis]; fy1=fy1[vis]
  233. fx2=fx2[vis]; fy2=fy2[vis]
  234. pwr=pwr[vis]
  235. y1_mm=y1_mm[vis]; y2_mm=y2_mm[vis]
  236. # ── couleur
  237. pwr_range = max(self.pwr_max - self.pwr_min,1.0)
  238. t = np.clip((pwr - self.pwr_min)/pwr_range,0.0,1.0)
  239. gray = (200.0*(1.0-t)).astype(np.uint8)
  240. # ─────────────────────────────
  241. # SNAP RASTER HORIZONTAL STABLE
  242. # ─────────────────────────────
  243. is_horiz = np.abs(fx2-fx1) >= np.abs(fy2-fy1)
  244. if self.l_step_mm and self.l_step_px:
  245. step_mm = self.l_step_mm
  246. step_px = self.l_step_px
  247. yc_mm = (y1_mm + y2_mm) * 0.5
  248. # index stable (pas de round)
  249. row_idx = np.floor((yc_mm - mny)/step_mm + 0.5).astype(np.int32)
  250. row_idx = np.maximum(row_idx,0)
  251. # centre exact de ligne
  252. fy_center = th - (row_idx + 0.5) * step_px
  253. fy1 = np.where(is_horiz, fy_center, fy1)
  254. fy2 = np.where(is_horiz, fy_center, fy2)
  255. else:
  256. fy_center = np.floor((fy1+fy2)*0.5)+0.5
  257. fy1 = np.where(is_horiz,fy_center,fy1)
  258. fy2 = np.where(is_horiz,fy_center,fy2)
  259. # ── snap X pour segments verticaux
  260. fx_center = np.floor((fx1+fx2)*0.5)+0.5
  261. fx1 = np.where(~is_horiz,fx_center,fx1)
  262. fx2 = np.where(~is_horiz,fx_center,fx2)
  263. # ── regroupement par gris
  264. result={}
  265. for i in range(len(gray)):
  266. c=int(gray[i])
  267. if c not in result:
  268. result[c]=[]
  269. result[c].append((fx1[i],fy1[i],fx2[i],fy2[i]))
  270. return result
  271. # ─── Rasterisation via fillRect (pixel-perfect) ──────────────────────────
  272. def _rasterize(self, segs_by_color):
  273. """Dessine les segments sous forme de rectangles pixel-parfaits."""
  274. if not segs_by_color:
  275. return
  276. h, w = self.display_data.shape
  277. # épaisseur réelle du raster = line_step
  278. step = self.l_step_px if self.l_step_px else self.laser_width_px
  279. half = step / 2.0
  280. qi = QImage(w, h, QImage.Format.Format_Grayscale8)
  281. qi.fill(QColor(255, 255, 255))
  282. qp = QPainter(qi)
  283. qp.setRenderHint(QPainter.RenderHint.Antialiasing, False)
  284. qp.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
  285. qp.setPen(Qt.PenStyle.NoPen)
  286. for c, segs in segs_by_color.items():
  287. qp.setBrush(QBrush(QColor(c, c, c)))
  288. for (x1, y1, x2, y2) in segs:
  289. # segment horizontal
  290. if abs(y2 - y1) < abs(x2 - x1):
  291. left = int(np.floor(min(x1, x2)))
  292. right = int(np.ceil(max(x1, x2)))
  293. top = int(np.floor(y1 - half))
  294. qp.fillRect(
  295. left,
  296. top,
  297. max(1, right - left),
  298. int(round(step)),
  299. QColor(c, c, c)
  300. )
  301. # segment vertical
  302. else:
  303. top = int(np.floor(min(y1, y2)))
  304. bottom = int(np.ceil(max(y1, y2)))
  305. left = int(np.floor(x1 - half))
  306. qp.fillRect(
  307. left,
  308. top,
  309. int(round(step)),
  310. max(1, bottom - top),
  311. QColor(c, c, c)
  312. )
  313. qp.end()
  314. bpl = qi.bytesPerLine()
  315. ptr = qi.bits()
  316. ptr.setsize(bpl * h)
  317. new_arr = np.frombuffer(ptr, dtype=np.uint8).reshape(h, bpl)[:, :w]
  318. np.minimum(self.display_data, new_arr, out=self.display_data)
  319. self._qi_ref = qi
  320. # ─── API publique ────────────────────────────────────────────────────────
  321. def redraw_range(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
  322. """Repart d'un fond blanc et dessine [start, end)."""
  323. self.display_data.fill(255)
  324. polys = self._compute_segments(pts_arr, start, end, use_lat, lat_mm, scan_axis)
  325. self._rasterize(polys)
  326. def draw_incremental(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
  327. """Ajoute les segments [start, end) sur l'état existant (animation)."""
  328. polys = self._compute_segments(pts_arr, start, end, use_lat, lat_mm, scan_axis)
  329. self._rasterize(polys)
  330. # ══════════════════════════════════════════════════════════════════════════════
  331. # CANVAS DE SIMULATION — rendu + zoom/pan
  332. # ══════════════════════════════════════════════════════════════════════════════
  333. class _SimCanvas(QWidget):
  334. """
  335. Affiche display_data (QPixmap mis à jour si dirty) + couche vectorielle
  336. (grille, laser). Zoom molette, pan clic-gauche.
  337. """
  338. def __init__(self, parent=None):
  339. super().__init__(parent)
  340. self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
  341. self._bg_color = '#050505'
  342. self.setStyleSheet(f'background:{self._bg_color};')
  343. self.setSizePolicy(QSizePolicy.Policy.Expanding,
  344. QSizePolicy.Policy.Expanding)
  345. self.setMouseTracking(True)
  346. self._pixmap = None
  347. self._dirty = False
  348. self._img_buf = None
  349. self._x0 = self._y0 = 0.0
  350. self._pw = self._ph = 0.0
  351. self._sc = 1.0
  352. self._mnx = self._mxx = 0.0
  353. self._mny = self._mxy = 0.0
  354. self._lx = self._ly = 0.0
  355. self._zoom = 1.0
  356. self._pan = QPointF(0, 0)
  357. self._p0 = None
  358. self._p0_pan = None
  359. self._mouse_mm = None
  360. self._overlay_h = 150
  361. def set_theme(self, bg: str):
  362. self._bg_color = bg
  363. self.setStyleSheet(f'background:{bg};')
  364. self.update()
  365. # ─── API ─────────────────────────────
  366. def setup(self, img_buf, x0, y0, pw, ph, sc, mnx, mxx, mny, mxy, l_step=0.1, overlay_h=150):
  367. self._img_buf = img_buf
  368. self._x0, self._y0 = x0, y0
  369. self._pw, self._ph = pw, ph
  370. self._sc = sc
  371. self._mnx, self._mxx = mnx, mxx
  372. self._mny, self._mxy = mny, mxy
  373. self._l_step = l_step
  374. self._overlay_h = overlay_h
  375. self._rebuild()
  376. self._dirty = False
  377. self.reset_view()
  378. def notify_dirty(self):
  379. self._dirty = True
  380. self.update()
  381. def set_laser(self, sx, sy):
  382. self._lx, self._ly = sx, sy
  383. self.update()
  384. def reset_view(self):
  385. """Fit-to-view : zoom et pan pour que l'image remplisse la zone utile."""
  386. if self._pw <= 0 or self._ph <= 0:
  387. self._zoom = 1.0
  388. self._pan = QPointF(0, 0)
  389. self.update()
  390. return
  391. cw, ch = self.width(), self.height()
  392. if cw <= 1 or ch <= 1:
  393. return
  394. overlay_h = getattr(self, '_overlay_h', 150)
  395. usable_w = cw
  396. usable_h = max(ch - overlay_h, int(ch * 0.5))
  397. margin = 12
  398. zoom_x = (usable_w - 2 * margin) / self._pw
  399. zoom_y = (usable_h - 2 * margin) / self._ph
  400. self._zoom = min(zoom_x, zoom_y)
  401. img_screen_w = self._pw * self._zoom
  402. img_screen_h = self._ph * self._zoom
  403. pan_x = (usable_w - img_screen_w) / 2.0 - self._x0 * self._zoom
  404. pan_y = margin - self._y0 * self._zoom
  405. self._pan = QPointF(pan_x, pan_y)
  406. self.update()
  407. # ─── Rendu ───────────────────────────
  408. def _rebuild(self):
  409. if self._img_buf is None:
  410. self._pixmap = None; return
  411. h, w = self._img_buf.shape
  412. qi = QImage(self._img_buf.tobytes(), w, h, w,
  413. QImage.Format.Format_Grayscale8)
  414. self._pixmap = QPixmap.fromImage(qi)
  415. def paintEvent(self, _):
  416. if self._dirty:
  417. self._rebuild()
  418. self._dirty = False
  419. qp = QPainter(self)
  420. qp.setRenderHint(QPainter.RenderHint.Antialiasing, False)
  421. w, h = self.width(), self.height()
  422. qp.fillRect(0, 0, w, h, QColor(self._bg_color))
  423. if self._img_buf is None:
  424. qp.setPen(QColor('#444'))
  425. qp.setFont(QFont('Arial', 13))
  426. qp.drawText(QRect(0, 0, w, h),
  427. Qt.AlignmentFlag.AlignCenter, 'Generating…')
  428. qp.end(); return
  429. # Zoom / pan
  430. t = QTransform()
  431. t.translate(self._pan.x(), self._pan.y())
  432. t.scale(self._zoom, self._zoom)
  433. qp.setTransform(t)
  434. # Fond blanc
  435. qp.fillRect(QRectF(self._x0, self._y0, self._pw, self._ph),
  436. QColor('white'))
  437. # Image simulation
  438. if self._pixmap:
  439. qp.drawPixmap(int(self._x0), int(self._y0), self._pixmap)
  440. # Grille
  441. pen_g = QPen(QColor(180, 180, 180, 140), 0.5, Qt.PenStyle.DashLine)
  442. pen_g.setDashPattern([4, 6])
  443. qp.setFont(QFont('Arial', 7))
  444. step = 10
  445. sx0 = int(np.ceil(self._mnx / step) * step)
  446. for mx in range(sx0, int(self._mxx) + 1, step):
  447. sx = self._x0 + (mx - self._mnx) * self._sc
  448. qp.setPen(pen_g)
  449. qp.drawLine(QLineF(sx, self._y0, sx, self._y0 + self._ph))
  450. qp.setPen(QColor('#777'))
  451. qp.drawText(QRectF(sx-15, self._y0+self._ph+1, 30, 13),
  452. Qt.AlignmentFlag.AlignCenter, str(mx))
  453. sy0 = int(np.ceil(self._mny / step) * step)
  454. for my in range(sy0, int(self._mxy) + 1, step):
  455. sy = self._y0 + self._ph - (my - self._mny) * self._sc
  456. qp.setPen(pen_g)
  457. qp.drawLine(QLineF(self._x0, sy, self._x0 + self._pw, sy))
  458. qp.setPen(QColor('#777'))
  459. qp.drawText(QRectF(self._x0-33, sy-7, 30, 13),
  460. Qt.AlignmentFlag.AlignRight |
  461. Qt.AlignmentFlag.AlignVCenter, str(my))
  462. # Laser
  463. lx, ly = self._lx, self._ly
  464. qp.setRenderHint(QPainter.RenderHint.Antialiasing, True)
  465. qp.setPen(QPen(QColor('#3385ff'), 1))
  466. qp.setBrush(QBrush(QColor(26, 117, 255, 100)))
  467. qp.drawEllipse(QPointF(lx, ly), 9, 9)
  468. qp.setPen(QPen(QColor('white'), 1))
  469. qp.setBrush(QBrush(QColor('#00ffff')))
  470. qp.drawEllipse(QPointF(lx, ly), 4, 4)
  471. # Coordonnées souris
  472. if self._mouse_mm:
  473. qp.resetTransform()
  474. qp.setPen(QColor('#666'))
  475. qp.setFont(QFont('Consolas', 9))
  476. mx_mm, my_mm = self._mouse_mm
  477. qp.drawText(QRect(6, h-18, 220, 15),
  478. Qt.AlignmentFlag.AlignLeft,
  479. f'X={mx_mm:.2f} Y={my_mm:.2f} mm')
  480. qp.end()
  481. # ─── Zoom / Pan ──────────────────────
  482. def wheelEvent(self, e):
  483. if self._img_buf is None or not hasattr(self, '_l_step'):
  484. return
  485. # Facteur de zoom symétrique — même ratio dans les deux sens
  486. # pour que N crans de dézoom + N crans de rezoom revienne au point de départ
  487. FACTOR = 1.15
  488. if e.angleDelta().y() > 0:
  489. new_zoom = self._zoom * FACTOR
  490. else:
  491. new_zoom = self._zoom / FACTOR
  492. # Sécurité mini/maxi
  493. new_zoom = max(0.01, min(200.0, new_zoom))
  494. # (gardé pour compatibilité avec la suite du code)
  495. new_zoom = new_zoom
  496. # 4. Point fixe sous la souris (ton code de navigation)
  497. pos = e.position()
  498. cx, cy = pos.x(), pos.y()
  499. wx = (cx - self._pan.x()) / self._zoom
  500. wy = (cy - self._pan.y()) / self._zoom
  501. self._zoom = new_zoom
  502. self._pan = QPointF(cx - wx*self._zoom, cy - wy*self._zoom)
  503. self.update()
  504. def mousePressEvent(self, e):
  505. if e.button() in (Qt.MouseButton.LeftButton,
  506. Qt.MouseButton.MiddleButton):
  507. self._p0 = e.pos()
  508. self._p0_pan = QPointF(self._pan)
  509. self.setCursor(Qt.CursorShape.ClosedHandCursor)
  510. def mouseMoveEvent(self, e):
  511. if self._p0 is not None:
  512. d = e.pos() - self._p0
  513. self._pan = self._p0_pan + QPointF(d.x(), d.y())
  514. self.update()
  515. pos = e.position()
  516. cx, cy = pos.x(), pos.y()
  517. if self._sc > 0 and self._ph > 0:
  518. ix = (cx - self._pan.x()) / self._zoom - self._x0
  519. iy = (cy - self._pan.y()) / self._zoom - self._y0
  520. self._mouse_mm = (ix/self._sc + self._mnx,
  521. self._mny + (self._ph - iy)/self._sc)
  522. self.update()
  523. def mouseReleaseEvent(self, e):
  524. self._p0 = None
  525. self.setCursor(Qt.CursorShape.ArrowCursor)
  526. # ══════════════════════════════════════════════════════════════════════════════
  527. # VUE PRINCIPALE
  528. # ══════════════════════════════════════════════════════════════════════════════
  529. class SimulationViewQt(QWidget):
  530. def __init__(self, parent, controller, engine, payload,
  531. return_view='dashboard'):
  532. super().__init__(parent)
  533. self.controller = controller
  534. self.engine = engine
  535. self.payload = payload
  536. self.return_view = return_view
  537. lang = controller.config_manager.get_item('machine_settings', 'language')
  538. if not lang or lang not in TRANSLATIONS:
  539. lang = 'English'
  540. self.t = TRANSLATIONS[lang]['simulation']
  541. # ── état ──────────────────────────────────────────────────
  542. self.final_gcode = ''
  543. self.framing_gcode = ''
  544. self.full_metadata = {}
  545. self.points_list = None
  546. self.framing_end = 0
  547. self.total_sec = 0.0
  548. self.latence_mm = 0.0
  549. self.latence_enabled = False
  550. self.ctrl_max = float(payload.get('params', {}).get('ctrl_max', 255))
  551. # ── animation ─────────────────────────────────────────────
  552. self.sim_running = False
  553. self.current_idx = 0
  554. self.current_sim_time = 0.0
  555. self.last_frame_time = 0.0
  556. self.sim_speed = 1.0
  557. self._last_drawn_idx = -1
  558. self._anim_timer = QTimer(self)
  559. self._anim_timer.setInterval(16)
  560. self._anim_timer.timeout.connect(self._tick)
  561. # ── renderer ──────────────────────────────────────────────
  562. self._renderer: _Renderer | None = None
  563. self._px_w = self._px_h = 0.0
  564. self._x0 = self._y0 = 0.0
  565. self._scale = 1.0
  566. self._mnx = self._mxx = 0.0
  567. self._mny = self._mxy = 0.0
  568. self.setStyleSheet('QWidget#SimulationRoot{background:#2b2b2b;color:white;}')
  569. self.setObjectName('SimulationRoot')
  570. # Couleurs thème — dark par défaut, mis à jour par apply_theme
  571. self._theme_colors = get_theme('Dark')
  572. self._build_ui()
  573. self._show_loading()
  574. QTimer.singleShot(80, self._start_gen)
  575. # ══════════════════════════════════════════════════════════════
  576. # CONSTRUCTION UI
  577. # ══════════════════════════════════════════════════════════════
  578. def _build_ui(self):
  579. root = QHBoxLayout(self)
  580. root.setContentsMargins(2, 2, 2, 2)
  581. root.setSpacing(2)
  582. root.addWidget(self._make_left_panel())
  583. root.addWidget(self._make_right_panel(), stretch=1)
  584. # ─── Panneau gauche ──────────────────────────────────────────
  585. def _make_left_panel(self):
  586. self.left = QFrame()
  587. # Largeur réduite de 20% (420 → 336)
  588. self.left.setFixedWidth(336)
  589. self.left.setStyleSheet(
  590. 'QFrame{background:#1e1e1e; border-right:1px solid #333;}')
  591. lo = QVBoxLayout(self.left)
  592. lo.setContentsMargins(8, 8, 8, 8)
  593. lo.setSpacing(6)
  594. # Titre supprimé — la topbar l'écrit déjà
  595. lo.addWidget(self._make_stats_widget())
  596. gl = QLabel(self.t.get('live_gcode', 'Live G-Code'))
  597. gl.setStyleSheet('color:white;font-weight:bold;font-size:11px;border:none;')
  598. self._lbl_gcode = gl
  599. lo.addWidget(gl)
  600. self.gcode_view = QPlainTextEdit()
  601. self.gcode_view.setReadOnly(True)
  602. self.gcode_view.setStyleSheet(
  603. 'QPlainTextEdit{background:#1a1a1a;color:#00ff00;'
  604. 'font-family:Consolas;'
  605. 'border:1px solid #333;border-radius:3px;}')
  606. self._update_gcode_font()
  607. # Click sur une ligne G-Code → seek à ce point
  608. self.gcode_view.mousePressEvent = self._on_gcode_click
  609. # Flèches clavier → navigation par ligne / par 20 lignes
  610. self.gcode_view.keyPressEvent = self._on_gcode_key
  611. lo.addWidget(self.gcode_view, stretch=1)
  612. lo.addWidget(self._make_options_widget())
  613. lo.addWidget(self._make_action_buttons())
  614. return self.left
  615. def _make_stats_widget(self):
  616. dims = self.payload.get('dims', (0, 0, 0, 0))
  617. h_px, w_px, sy, sx = dims
  618. w_mm = (w_px-1)*sx; h_mm = (h_px-1)*sy
  619. meta = self.payload.get('metadata', {})
  620. path = os.path.join(
  621. meta.get('output_dir', ''),
  622. f'{meta.get("file_name","export")}{meta.get("file_extension",".nc")}'
  623. ).replace('\\', '/')
  624. self.quick_export_path = path
  625. self.full_export_path = path
  626. f = QFrame()
  627. f.setStyleSheet('QFrame{background:#252525;border-radius:6px;'
  628. 'border:1px solid #333;}')
  629. self._stats_frame = f
  630. self._stat_key_labels = []
  631. self._stat_val_labels = []
  632. lo = QVBoxLayout(f)
  633. lo.setContentsMargins(8, 6, 8, 6)
  634. lo.setSpacing(4)
  635. def add_row(lbl_txt, val_txt, color='#ecf0f1'):
  636. r = QHBoxLayout()
  637. l = QLabel(lbl_txt)
  638. l.setStyleSheet('color:gray;font-size:10px;font-weight:bold;border:none;')
  639. v = QLabel(val_txt)
  640. v.setStyleSheet(f'color:{color};font-family:Consolas;'
  641. f'font-size:11px;border:none;')
  642. v.setAlignment(Qt.AlignmentFlag.AlignRight)
  643. r.addWidget(l); r.addWidget(v)
  644. lo.addLayout(r)
  645. self._stat_key_labels.append(l)
  646. self._stat_val_labels.append(v)
  647. add_row(self.t.get('final_size', 'Size'), f'{w_mm:.2f} x {h_mm:.2f} mm')
  648. add_row(self.t.get('output_file', 'Output'),
  649. truncate_path(path, 38), color='#2ecc71')
  650. pbar = self._make_power_bar()
  651. self._power_bar = pbar
  652. lo.addWidget(pbar)
  653. return f
  654. def _make_power_bar(self):
  655. p = self.payload.get('params', {})
  656. p_min = float(p.get('min_power', 0))
  657. p_max = float(p.get('max_power', 100))
  658. class _PBar(QWidget):
  659. def __init__(s, mn, mx):
  660. super().__init__()
  661. s._mn = mn; s._mx = mx
  662. s._bg_color = '#252525'
  663. s._txt_color = '#777777'
  664. s._brd_color = '#555555'
  665. s.setFixedHeight(55)
  666. def set_theme(s, bg, txt, brd):
  667. s._bg_color = bg
  668. s._txt_color = txt
  669. s._brd_color = brd
  670. s.update()
  671. def paintEvent(s, _):
  672. qp = QPainter(s)
  673. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  674. w, h = s.width(), s.height()
  675. qp.fillRect(0, 0, w, h, QColor(s._bg_color))
  676. m, by, bh = 20, 25, 10
  677. bw = w - 2*m
  678. x_min = int(m + s._mn / 100.0 * bw)
  679. x_max = int(m + s._mx / 100.0 * bw)
  680. if x_min > m:
  681. qp.fillRect(m, by, x_min - m, bh, QColor(255, 255, 255))
  682. if x_max > x_min:
  683. g = QLinearGradient(x_min, 0, x_max, 0)
  684. g.setColorAt(0, QColor(200, 200, 200))
  685. g.setColorAt(1, QColor(0, 0, 0))
  686. qp.fillRect(x_min, by, x_max - x_min, bh, QBrush(g))
  687. if x_max < m + bw:
  688. qp.fillRect(x_max, by, m + bw - x_max, bh, QColor(0, 0, 0))
  689. qp.setPen(QPen(QColor(s._brd_color), 1))
  690. qp.drawRect(m, by, bw, bh)
  691. qp.setPen(QColor(s._txt_color))
  692. qp.setFont(QFont('Arial', 8))
  693. qp.drawText(m, by+bh+12, '0%')
  694. qp.drawText(m+bw-22, by+bh+12, '100%')
  695. for val, lbl in [(s._mn, f'Min:{int(s._mn)}%'),
  696. (s._mx, f'Max:{int(s._mx)}%')]:
  697. x = int(m + val/100*bw)
  698. tri = QPolygonF([QPointF(x, by-1),
  699. QPointF(x-5, by-9),
  700. QPointF(x+5, by-9)])
  701. qp.setBrush(QBrush(QColor('#ff9f43')))
  702. qp.setPen(Qt.PenStyle.NoPen)
  703. qp.drawPolygon(tri)
  704. qp.setPen(QColor('#ff9f43'))
  705. qp.setFont(QFont('Arial', 8, QFont.Weight.Bold))
  706. qp.drawText(x-20, by-10, lbl)
  707. qp.end()
  708. return _PBar(p_min, p_max)
  709. def _make_options_widget(self):
  710. f = QFrame()
  711. f.setStyleSheet('QFrame{background:#222;border:1px solid #444;'
  712. 'border-radius:6px;}')
  713. self._opts_frame = f
  714. lo = QVBoxLayout(f)
  715. lo.setContentsMargins(8, 6, 8, 6)
  716. lo.setSpacing(4)
  717. lbl = QLabel(self.t.get('active_options', 'Active Options'))
  718. lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  719. lbl.setStyleSheet('color:white;font-weight:bold;font-size:11px;border:none;')
  720. self._opts_lbl = lbl
  721. lo.addWidget(lbl)
  722. br = QHBoxLayout()
  723. self._opts_badges = []
  724. for key, txt in [('is_pointing', self.t.get('pointing_opt', 'POINTING')),
  725. ('is_framing', self.t.get('framing_opt', 'FRAMING'))]:
  726. active = self.payload.get('framing', {}).get(key, False)
  727. col = '#ff9f43' if active else '#555'
  728. bg = '#3d2b1f' if active else '#282828'
  729. b = QLabel(txt)
  730. b.setAlignment(Qt.AlignmentFlag.AlignCenter)
  731. b.setStyleSheet(f'color:{col};background:{bg};border-radius:5px;'
  732. f'font-size:9px;font-weight:bold;'
  733. f'padding:3px 6px;border:none;')
  734. br.addWidget(b)
  735. self._opts_badges.append((b, active))
  736. lo.addLayout(br)
  737. # lat_btn retiré d'ici — déplacé dans _make_action_buttons
  738. self.lat_btn = None
  739. return f
  740. def _make_action_buttons(self):
  741. f = QFrame()
  742. f.setStyleSheet('QFrame{border:none;background:transparent;}')
  743. lo = QVBoxLayout(f)
  744. lo.setContentsMargins(0, 0, 0, 0)
  745. lo.setSpacing(4)
  746. # Switch Simulate Latency — identique au checker
  747. self.lat_btn = None
  748. lat_val = float(self.payload.get('params', {}).get('laser_latency', 0))
  749. if lat_val != 0:
  750. lat_row = QHBoxLayout()
  751. self.lbl_lat = QLabel(self.t.get('simulate_latency', 'Simulate latency'))
  752. self.lbl_lat.setStyleSheet('color:#aaa;font-size:10px;border:none;')
  753. lat_row.addWidget(self.lbl_lat)
  754. lat_row.addStretch()
  755. self.sw_latency = Switch()
  756. self.sw_latency.setChecked(False)
  757. self.sw_latency.toggled.connect(self._on_lat_toggle)
  758. lat_row.addWidget(self.sw_latency)
  759. lo.addLayout(lat_row)
  760. self.lat_btn = self.sw_latency # compatibilité avec le reste du code
  761. er = QHBoxLayout()
  762. self.btn_export = QPushButton(self.t.get('quick_export', 'Quick Export'))
  763. self.btn_export.setFixedHeight(40)
  764. self.btn_export.setStyleSheet(self._gbtn('#2ecc71', '#27ae60'))
  765. self.btn_export.clicked.connect(self.on_export)
  766. self.btn_export_as = QPushButton(self.t.get('export_as', 'Export As…'))
  767. self.btn_export_as.setFixedSize(110, 40)
  768. self.btn_export_as.setStyleSheet(self._gbtn('#7ac99b', '#27ae60'))
  769. self.btn_export_as.clicked.connect(self.on_export_as)
  770. er.addWidget(self.btn_export)
  771. er.addWidget(self.btn_export_as)
  772. lo.addLayout(er)
  773. self.btn_cancel = QPushButton(self.t.get('cancel', 'Cancel'))
  774. self.btn_cancel.setFixedHeight(30)
  775. self.btn_cancel.setStyleSheet(self._gbtn('#333', '#444'))
  776. self.btn_cancel.clicked.connect(self.on_cancel)
  777. lo.addWidget(self.btn_cancel)
  778. return f
  779. # ─── Panneau droit ───────────────────────────────────────────
  780. def _make_right_panel(self):
  781. self._right_widget = QWidget()
  782. self._right_widget.setStyleSheet('background:#111;')
  783. lo = QVBoxLayout(self._right_widget)
  784. lo.setContentsMargins(0, 0, 0, 0)
  785. lo.setSpacing(0)
  786. # Canvas occupe tout l'espace
  787. self.canvas = _SimCanvas()
  788. lo.addWidget(self.canvas, stretch=1)
  789. # Les contrôles de lecture et la barre de progression sont créés
  790. # mais restent hors du layout — ils seront positionnés en overlay
  791. self._playback_frame = self._make_playback_bar()
  792. self._playback_frame.setParent(self._right_widget)
  793. self._playback_frame.setStyleSheet(
  794. 'QFrame{background:transparent;border:none;}')
  795. self._progress_frame = self._make_progress_bar()
  796. self._progress_frame.setParent(self._right_widget)
  797. self._progress_frame.setStyleSheet(
  798. 'QFrame{background:transparent;border:none;}')
  799. return self._right_widget
  800. def _make_playback_bar(self):
  801. f = QFrame()
  802. lo = QVBoxLayout(f)
  803. lo.setContentsMargins(8, 6, 8, 6)
  804. lo.setSpacing(5)
  805. # Transport
  806. tr = QHBoxLayout()
  807. tr.setAlignment(Qt.AlignmentFlag.AlignCenter)
  808. self.btn_rew = QPushButton() # '⏮'
  809. rewind_pixmap = get_svg_pixmap(SVG_ICONS["REWIND"], QSize(24, 24), "#ffffff")
  810. self.btn_rew.setIcon(QIcon(rewind_pixmap))
  811. self.btn_rew.setFixedSize(60, 40)
  812. self.btn_rew.setFont(QFont('Arial', 16))
  813. self.btn_rew.setStyleSheet(self._gbtn('#444', '#555'))
  814. self.btn_rew.clicked.connect(self.rewind_sim)
  815. self.btn_play = QPushButton() # '▶'
  816. self.play_pixmap = get_svg_pixmap(SVG_ICONS["PLAY"], QSize(24, 24), "#ffffff")
  817. self.pause_pixmap = get_svg_pixmap(SVG_ICONS["PAUSE"], QSize(24, 24), "#ffffff")
  818. self.rerun_pixmap = get_svg_pixmap(SVG_ICONS["RERUN"], QSize(24, 24), "#ffffff")
  819. self.btn_play.setIcon(QIcon(self.play_pixmap))
  820. self.btn_play.setFixedSize(100, 40)
  821. # self.btn_play.setFont(QFont('Arial', 16))
  822. self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
  823. self.btn_play.clicked.connect(self.toggle_pause)
  824. btn_end = QPushButton()
  825. skiptoend_pixmap = get_svg_pixmap(SVG_ICONS["SKIPTOEND"], QSize(24, 24), "#ffffff")
  826. btn_end.setIcon(QIcon(skiptoend_pixmap))
  827. btn_end.setFixedSize(60, 40)
  828. btn_end.setStyleSheet(self._gbtn('#444', '#555'))
  829. btn_end.clicked.connect(self.skip_to_end)
  830. self._btn_end = btn_end
  831. btn_fit = QPushButton()
  832. fit_pixmap = get_svg_pixmap(SVG_ICONS["FIT"], QSize(50, 50), "#ffffff")
  833. btn_fit.setIcon(QIcon(fit_pixmap))
  834. btn_fit.setFixedSize(40, 40)
  835. btn_fit.setToolTip(self.t.get('reset_zoom', 'Reset zoom / pan'))
  836. btn_fit.setStyleSheet(self._gbtn('#333', '#444'))
  837. btn_fit.clicked.connect(self.canvas.reset_view)
  838. self._btn_fit = btn_fit
  839. for b in [self.btn_rew, self.btn_play, btn_end, btn_fit]:
  840. tr.addWidget(b)
  841. lo.addLayout(tr)
  842. # Sélecteur vitesse
  843. sf = QFrame()
  844. sf.setStyleSheet('QFrame{background:#222;border:1px solid #444;'
  845. 'border-radius:5px;}')
  846. self._spd_frame = sf
  847. sr = QHBoxLayout(sf)
  848. sr.setContentsMargins(5, 2, 5, 2)
  849. sr.setSpacing(2)
  850. spd_lbl = QLabel(self.t.get('speed', 'Speed:'))
  851. spd_lbl.setStyleSheet('color:white;font-weight:bold;font-size:9px;border:none;')
  852. self._spd_lbl = spd_lbl
  853. sr.addWidget(spd_lbl)
  854. self._spd_btns: dict[str, QPushButton] = {}
  855. spd_style = ('QPushButton{background:#3a3a3a;color:#aaa;'
  856. 'border:1px solid #555;border-radius:3px;'
  857. 'padding:0px 4px;font-size:8px;min-width:18px;max-width:30px;}'
  858. 'QPushButton:checked{background:#1f538d;color:white;'
  859. 'border-color:#2a6dbd;}'
  860. 'QPushButton:hover:!checked{background:#444;color:white;}')
  861. for v in ['0.5', '1', '5', '10', '50']:
  862. b = QPushButton(v)
  863. b.setCheckable(True)
  864. b.setFixedHeight(14)
  865. b.setStyleSheet(spd_style)
  866. b.clicked.connect(lambda _, val=v: self._set_speed(val))
  867. sr.addWidget(b)
  868. self._spd_btns[v] = b
  869. self._spd_btns['1'].setChecked(True)
  870. lo.addWidget(sf, alignment=Qt.AlignmentFlag.AlignHCenter)
  871. return f
  872. def _make_progress_bar(self):
  873. f = QFrame()
  874. lo = QVBoxLayout(f)
  875. lo.setContentsMargins(0, 0, 0, 0) # On laisse le resizeEvent gérer les bords
  876. lo.setSpacing(0)
  877. container = QWidget()
  878. container.setFixedHeight(30) # Un peu plus haut pour faciliter le clic
  879. container.setStyleSheet('background:transparent;')
  880. self.prog_bar = QProgressBar(container)
  881. # Supprimez le setGeometry fixe ici, ou mettez une valeur bidon
  882. self.prog_bar.setRange(0, 10000)
  883. self.prog_bar.setValue(0)
  884. self.prog_bar.setTextVisible(False)
  885. self.prog_bar.setStyleSheet(
  886. 'QProgressBar{background:#333;border-radius:7px;border:none;}'
  887. 'QProgressBar::chunk{background:#27ae60;border-radius:7px;}')
  888. # IMPORTANT : On lie le clic
  889. self.prog_bar.mousePressEvent = self._on_prog_click
  890. self.lbl_time = QLabel('00:00:00 / 00:00:00', container)
  891. self.lbl_time.setStyleSheet(
  892. 'color:white;font-size:9px;font-weight:bold;'
  893. 'background:transparent;border:none;')
  894. self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignCenter)
  895. self.lbl_time.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  896. lo.addWidget(container)
  897. self.lbl_prog = QLabel('')
  898. self.lbl_prog.hide()
  899. lo.addWidget(self.lbl_prog)
  900. self._prog_container = container
  901. return f
  902. # ══════════════════════════════════════════════════════════════
  903. # LOADING OVERLAY
  904. # ══════════════════════════════════════════════════════════════
  905. def _show_loading(self):
  906. self._ov = QWidget(self)
  907. self._ov.setStyleSheet('background:rgba(20,20,20,220);')
  908. self._ov.resize(self.size())
  909. self._ov.show()
  910. self._ov.raise_()
  911. lo = QVBoxLayout(self._ov)
  912. lo.setAlignment(Qt.AlignmentFlag.AlignCenter)
  913. box = QFrame()
  914. box.setFixedWidth(340)
  915. box.setStyleSheet('QFrame{background:#3a3a3a;border-radius:12px;'
  916. 'border:1px solid #555;}')
  917. bl = QVBoxLayout(box)
  918. bl.setContentsMargins(30, 20, 30, 25)
  919. bl.setSpacing(12)
  920. lbl = QLabel(self.t.get('generating', 'Generating G-Code & Trajectory…'))
  921. lbl.setStyleSheet('color:white;font-size:14px;font-weight:bold;border:none;')
  922. lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  923. bl.addWidget(lbl)
  924. pb = QProgressBar()
  925. pb.setFixedHeight(10)
  926. pb.setRange(0, 0)
  927. pb.setStyleSheet('QProgressBar{background:#555;border-radius:5px;border:none;}'
  928. 'QProgressBar::chunk{background:#27ae60;border-radius:5px;}')
  929. bl.addWidget(pb)
  930. lo.addWidget(box)
  931. def _hide_loading(self):
  932. if hasattr(self, '_ov'):
  933. self._ov.hide()
  934. self._ov.deleteLater()
  935. del self._ov
  936. def _update_gcode_font(self):
  937. """Adapte la taille de la police du G-Code à la largeur du panneau gauche."""
  938. if not hasattr(self, 'gcode_view'):
  939. return
  940. panel_w = self.left.width() if hasattr(self, 'left') else 336
  941. # Viser ~55 caractères lisibles — Consolas ratio ≈ 0.55
  942. usable = max(1, panel_w - 24)
  943. pt = max(8, min(16, int(usable / (55 * 0.55))))
  944. fnt = QFont('Consolas', pt)
  945. self.gcode_view.setFont(fnt)
  946. def resizeEvent(self, e):
  947. super().resizeEvent(e)
  948. self._update_gcode_font()
  949. if hasattr(self, '_ov'):
  950. self._ov.resize(self.size())
  951. # Repositionnement des blocs (Playback et Progress)
  952. if hasattr(self, '_playback_frame') and hasattr(self, '_progress_frame'):
  953. rw = self._right_widget
  954. rw_w = rw.width()
  955. rw_h = rw.height()
  956. margin_side = 40
  957. margin_bottom = 2 # px depuis le bas
  958. pb_hint = self._playback_frame.sizeHint()
  959. pr_hint = self._progress_frame.sizeHint()
  960. pb_h = max(pb_hint.height(), 70)
  961. pr_h = max(pr_hint.height(), 26)
  962. total_h = pb_h + pr_h + 2
  963. bottom_y = rw_h - total_h - margin_bottom
  964. # 1. On donne d'abord sa position au cadre transparent global
  965. prog_frame_w = rw_w - 2 * margin_side
  966. self._progress_frame.setGeometry(margin_side, bottom_y,
  967. prog_frame_w, pr_h)
  968. self._playback_frame.setGeometry(margin_side, bottom_y + pr_h + 2,
  969. rw_w - 2 * margin_side, pb_h)
  970. # 2. On dimensionne la barre ET le texte immédiatement
  971. # (Plus aucun décalage temporel)
  972. if hasattr(self, 'prog_bar'):
  973. bar_w = int(prog_frame_w * 0.8)
  974. bar_x = (prog_frame_w - bar_w) // 2
  975. self.prog_bar.setGeometry(bar_x, 3, bar_w, 14)
  976. # Le label prend toute la largeur, Qt le centrera parfaitement au-dessus de la barre
  977. self.lbl_time.setGeometry(0, 3, prog_frame_w, 14)
  978. self._playback_frame.raise_()
  979. self._progress_frame.raise_()
  980. # Mémoriser la hauteur occupée par les overlays pour _init_canvas
  981. self._overlay_h = rw_h - bottom_y + 8
  982. # ══════════════════════════════════════════════════════════════
  983. # GÉNÉRATION (QThread)
  984. # ══════════════════════════════════════════════════════════════
  985. def _start_gen(self):
  986. self._parser = GCodeParser(self.payload)
  987. self._worker = _GenWorker(self.engine, self.payload, self._parser)
  988. self._worker.done.connect(self._on_done)
  989. self._worker.error.connect(self._on_error)
  990. self._worker.start()
  991. def _on_error(self, msg):
  992. self._hide_loading()
  993. self._msgbox(QMessageBox.Icon.Critical, 'Engine Error',
  994. f'Failed to generate G-code:\n{msg}')
  995. def _on_done(self, d):
  996. self._hide_loading()
  997. self.points_list = d['pts']
  998. self.total_sec = d.get('total_dur', 0.0)
  999. self.latence_mm = d.get('latence_mm', 0.0)
  1000. self.final_gcode = d.get('final_gcode', '')
  1001. self.framing_gcode = d.get('framing_gcode', '')
  1002. self.full_metadata = d.get('meta', {})
  1003. self.framing_end = d.get('framing_end', 0)
  1004. self._last_drawn_idx = -1
  1005. bx0, bx1, by0, by1 = d.get('bounds', (0, 0, 0, 0))
  1006. self._mnx, self._mxx = bx0, bx1
  1007. self._mny, self._mxy = by0, by1
  1008. if self.lat_btn:
  1009. visible = abs(self.latence_mm) > 1e-6
  1010. self.lat_btn.setVisible(visible)
  1011. if hasattr(self, 'lbl_lat'):
  1012. self.lbl_lat.setVisible(visible)
  1013. if self.final_gcode:
  1014. self.gcode_view.setPlainText(self.final_gcode)
  1015. self._update_gcode_font()
  1016. # 1. Mise à jour du texte
  1017. self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
  1018. # 2. FIX : Forcer le calcul des géométries AVANT le rendu du canvas
  1019. # On s'assure que le panneau droit a bien pris sa place finale
  1020. self._right_widget.updateGeometry()
  1021. # 3. On appelle _init_canvas qui contient déjà self.resizeEvent(None)
  1022. # QTimer.singleShot(60, self._init_canvas)
  1023. self._init_canvas()
  1024. # ══════════════════════════════════════════════════════════════
  1025. # INITIALISATION CANVAS
  1026. # ══════════════════════════════════════════════════════════════
  1027. def _init_canvas(self):
  1028. cw, ch = self.canvas.width(), self.canvas.height()
  1029. # Attendre que le widget soit réellement dimensionné
  1030. if cw <= 1 or ch <= 1:
  1031. QTimer.singleShot(60, self._init_canvas)
  1032. return
  1033. if self.points_list is None or len(self.points_list) == 0:
  1034. return
  1035. # 1. On récupère le l_step immédiatement pour calibrer l'échelle
  1036. dims = self.payload.get('dims', (0, 0, 0.1, 0.1))
  1037. raster_mode = self.payload.get('params', {}).get('raster_mode', 'horizontal')
  1038. if len(dims) >= 4:
  1039. l_step = float(dims[2]) if raster_mode == 'horizontal' else float(dims[3])
  1040. else:
  1041. l_step = 0.1
  1042. # ─────────────────────────────
  1043. # Dimensions réelles pièce (mm)
  1044. # ─────────────────────────────
  1045. # On agrandit tw/th d'une ligne pour le calcul du buffer uniquement.
  1046. # self._mnx/y restent inchangés : le renderer et le canvas utilisent
  1047. # les vraies coordonnées pour le mapping mm→pixel.
  1048. # Sans cette marge, un fichier de N lignes produit un buffer de
  1049. # (N-1)*lw_px px → la dernière ligne tombe hors buffer (fy_center < 0).
  1050. margin = l_step
  1051. tw = max(0.1, self._mxx - self._mnx) + margin
  1052. th = max(0.1, self._mxy - self._mny) + margin
  1053. # Espace utile : réserver la zone des contrôles flottants (hauteur réelle mémorisée)
  1054. overlay_h = getattr(self, '_overlay_h', 150)
  1055. ch_usable = max(ch - overlay_h, int(ch * 0.5))
  1056. # Scale écran théorique (pour tenir dans la vue)
  1057. sc = min((cw * 0.80) / tw, (ch_usable * 0.80) / th)
  1058. # --- CORRECTION ARRONDI : Snapping de l'échelle ---
  1059. # On force l'échelle pour que l_step mm correspondent à un nombre ENTIER de pixels.
  1060. if l_step > 0:
  1061. lw_px = max(1.0, np.round(l_step * sc))
  1062. sc = lw_px / l_step # On recalcule l'échelle exacte basée sur cet entier
  1063. else:
  1064. lw_px = 1.0
  1065. # Dimensions projetées écran (maintenant parfaitement alignées sur le pas)
  1066. pw = tw * sc
  1067. ph = th * sc
  1068. # Dimensions buffer image (entiers)
  1069. rw = max(1, int(round(pw)))
  1070. rh = max(1, int(round(ph)))
  1071. # Position : centré horizontalement, calé en haut de la zone utile
  1072. x0 = (cw - pw) / 2.0
  1073. y0 = (ch_usable - ph) * 0.20
  1074. # ─────────────────────────────
  1075. # Initialisation renderer
  1076. # ─────────────────────────────
  1077. p_params = self.payload.get('params', {})
  1078. p_min_pct = float(p_params.get('min_power', 0))
  1079. p_max_pct = float(p_params.get('max_power', 100))
  1080. pwr_min_s = self.ctrl_max * p_min_pct / 100.0
  1081. pwr_max_s = self.ctrl_max * p_max_pct / 100.0
  1082. # Note : On passe lw_px (l'entier) pour garantir la largeur constante
  1083. self._renderer = _Renderer(
  1084. rw,
  1085. rh,
  1086. sc,
  1087. rh,
  1088. self._mnx,
  1089. self._mny,
  1090. int(lw_px),
  1091. self.ctrl_max,
  1092. pwr_min=pwr_min_s,
  1093. pwr_max=pwr_max_s,
  1094. l_step_mm=l_step
  1095. )
  1096. # Stockage géométrie
  1097. self._px_w, self._px_h = pw, ph
  1098. self._x0, self._y0 = x0, y0
  1099. self._scale = sc
  1100. # Index sécurisé
  1101. # self._last_drawn_idx = max(0, self.framing_end - 1)
  1102. self._last_drawn_idx = -1
  1103. # ─────────────────────────────
  1104. # Position initiale laser
  1105. # ─────────────────────────────
  1106. p0 = self.points_list[0]
  1107. lx = x0 + (p0[0] - self._mnx) * sc
  1108. ly = y0 + ph - (p0[1] - self._mny) * sc
  1109. # ─────────────────────────────
  1110. # Setup canvas (AVEC l_step en dernier argument)
  1111. # ─────────────────────────────
  1112. self.canvas.setup(
  1113. self._renderer.display_data,
  1114. x0, y0, pw, ph, sc,
  1115. self._mnx, self._mxx,
  1116. self._mny, self._mxy,
  1117. l_step,
  1118. overlay_h
  1119. )
  1120. self.canvas.set_laser(lx, ly)
  1121. # Forcer le repositionnement des overlays
  1122. self.resizeEvent(None)
  1123. # ══════════════════════════════════════════════════════════════
  1124. # ANIMATION — hot path
  1125. # ══════════════════════════════════════════════════════════════
  1126. def _tick(self):
  1127. """
  1128. Boucle animation (16 ms).
  1129. Rasterisation incrémentale sécurisée.
  1130. """
  1131. if not self.sim_running:
  1132. return
  1133. if self.points_list is None or len(self.points_list) < 2:
  1134. return
  1135. pts = self.points_list
  1136. total_pts = len(pts)
  1137. last_idx = total_pts - 1
  1138. # ───────────────────────────────
  1139. # Temps simulation
  1140. # ───────────────────────────────
  1141. now = time.perf_counter()
  1142. if self.last_frame_time == 0:
  1143. self.last_frame_time = now
  1144. dt = (now - self.last_frame_time) * self.sim_speed
  1145. self.last_frame_time = now
  1146. self.current_sim_time += dt
  1147. # Clamp temps
  1148. if self.current_sim_time >= self.total_sec:
  1149. self.current_sim_time = self.total_sec
  1150. # ───────────────────────────────
  1151. # Avancement index via searchsorted
  1152. # ───────────────────────────────
  1153. idx = np.searchsorted(
  1154. pts[:, 4],
  1155. self.current_sim_time,
  1156. side='right'
  1157. ) - 1
  1158. idx = max(0, min(int(idx), last_idx))
  1159. self.current_idx = idx
  1160. # ───────────────────────────────
  1161. # Rasterisation incrémentale
  1162. # ───────────────────────────────
  1163. if self._renderer is not None:
  1164. # start_idx = max(0, self.framing_end - 1)
  1165. start_idx = 0
  1166. if self.current_idx > self._last_drawn_idx:
  1167. seg_start = max(self._last_drawn_idx, start_idx)
  1168. if seg_start < self.current_idx:
  1169. self._renderer.draw_incremental(
  1170. pts,
  1171. seg_start,
  1172. self.current_idx,
  1173. self.latence_enabled,
  1174. self.latence_mm,
  1175. self.full_metadata.get('scan_axis', 'X')
  1176. )
  1177. self._last_drawn_idx = self.current_idx
  1178. self.canvas.notify_dirty()
  1179. # ───────────────────────────────
  1180. # Fin animation
  1181. # ───────────────────────────────
  1182. if self.current_idx >= last_idx:
  1183. self._finish_anim()
  1184. return
  1185. # ───────────────────────────────
  1186. # Interpolation laser fluide
  1187. # ───────────────────────────────
  1188. pc = pts[self.current_idx]
  1189. pn = pts[self.current_idx + 1]
  1190. td = pn[4] - pc[4]
  1191. if td > 0:
  1192. r = (self.current_sim_time - pc[4]) / td
  1193. r = max(0.0, min(1.0, r))
  1194. else:
  1195. r = 0.0
  1196. lx_mm = pc[0] + (pn[0] - pc[0]) * r
  1197. ly_mm = pc[1] + (pn[1] - pc[1]) * r
  1198. self.canvas.set_laser(*self._mm_to_screen(lx_mm, ly_mm))
  1199. # ───────────────────────────────
  1200. # UI sync
  1201. # ───────────────────────────────
  1202. self._update_ui(self.current_idx)
  1203. # ══════════════════════════════════════════════════════════════
  1204. # REDRAW COMPLET (scrub / seek / latence toggle)
  1205. # ══════════════════════════════════════════════════════════════
  1206. def _redraw_to(self, target_idx):
  1207. """
  1208. Redessine complètement la simulation jusqu'à target_idx.
  1209. Utilisé pour :
  1210. - scrub barre de progression
  1211. - skip_to_end
  1212. - toggle latence
  1213. """
  1214. if self._renderer is None:
  1215. return
  1216. if self.points_list is None or len(self.points_list) == 0:
  1217. return
  1218. total_pts = len(self.points_list)
  1219. target_idx = max(0, min(int(target_idx), total_pts - 1))
  1220. # start_idx = max(0, self.framing_end - 1)
  1221. start_idx = 0
  1222. if target_idx <= start_idx:
  1223. self._renderer.reset()
  1224. self._last_drawn_idx = start_idx
  1225. else:
  1226. # Fond blanc
  1227. self._renderer.display_data.fill(255)
  1228. scan_axis = self.full_metadata.get('scan_axis', 'X')
  1229. # Dessiner le framing en premier (il sera le plus foncé si puissance forte)
  1230. if self.framing_end > 0:
  1231. polys = self._renderer._compute_segments(
  1232. self.points_list, 0, self.framing_end, False, 0.0, scan_axis)
  1233. self._renderer._rasterize(polys)
  1234. # Dessiner le raster — np.minimum protège les pixels du framing
  1235. if target_idx > self.framing_end:
  1236. polys = self._renderer._compute_segments(
  1237. self.points_list, self.framing_end, target_idx,
  1238. self.latence_enabled, self.latence_mm, scan_axis)
  1239. self._renderer._rasterize(polys)
  1240. self._last_drawn_idx = target_idx
  1241. self.canvas.notify_dirty()
  1242. mx = float(self.points_list[target_idx][0])
  1243. my = float(self.points_list[target_idx][1])
  1244. self.canvas.set_laser(*self._mm_to_screen(mx, my))
  1245. # ══════════════════════════════════════════════════════════════
  1246. # CONTRÔLES PLAYBACK
  1247. # ══════════════════════════════════════════════════════════════
  1248. def toggle_pause(self):
  1249. if self.points_list is None or self.points_list.size == 0:
  1250. return
  1251. if self.current_idx >= self.points_list.shape[0] - 1: # fin atteinte → replay
  1252. self.rewind_sim(); self._start_play(); return
  1253. if self.sim_running:
  1254. self._stop_play()
  1255. else:
  1256. self._start_play()
  1257. def _start_play(self):
  1258. self.sim_running = True
  1259. self.last_frame_time = time.perf_counter()
  1260. self.btn_play.setIcon(QIcon(self.pause_pixmap))
  1261. # self.btn_play.setText('⏸')
  1262. self.btn_play.setStyleSheet(self._gbtn('#e67e22', '#ca6f1e'))
  1263. self._anim_timer.start()
  1264. def _stop_play(self):
  1265. self.sim_running = False
  1266. self._anim_timer.stop()
  1267. self.btn_play.setIcon(QIcon(self.play_pixmap))
  1268. self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
  1269. def _finish_anim(self):
  1270. self.sim_running = False
  1271. self._anim_timer.stop()
  1272. self.btn_play.setIcon(QIcon(self.rerun_pixmap ))
  1273. # self.btn_play.setText('🔄')
  1274. self.btn_play.setStyleSheet(self._gbtn('#2980b9', '#1a6090'))
  1275. self._update_ui(len(self.points_list) - 1)
  1276. def rewind_sim(self):
  1277. self._stop_play()
  1278. self.current_idx = 0
  1279. self.current_sim_time = 0.0
  1280. self.last_frame_time = 0.0
  1281. self._last_drawn_idx = -1
  1282. if self._renderer:
  1283. self._renderer.reset()
  1284. self.canvas.notify_dirty()
  1285. self.prog_bar.setValue(0)
  1286. self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
  1287. self.btn_play.setIcon(QIcon(self.play_pixmap))
  1288. self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
  1289. self._highlight_gcode(0)
  1290. if self.points_list is not None and self.points_list.size > 0:
  1291. lx, ly = self._mm_to_screen(
  1292. self.points_list[0][0], self.points_list[0][1])
  1293. self.canvas.set_laser(lx, ly)
  1294. def skip_to_end(self):
  1295. if self.points_list is None: return
  1296. self._stop_play()
  1297. self.current_idx = len(self.points_list) - 1
  1298. self.current_sim_time = self.total_sec
  1299. self._redraw_to(self.current_idx)
  1300. self._update_ui(self.current_idx)
  1301. self._finish_anim()
  1302. def _set_speed(self, val):
  1303. try:
  1304. self.sim_speed = float(val)
  1305. self.last_frame_time = time.perf_counter()
  1306. except ValueError: pass
  1307. for v, b in self._spd_btns.items():
  1308. b.setChecked(v == val)
  1309. def _on_lat_toggle(self, checked):
  1310. self.latence_enabled = checked
  1311. self._redraw_to(self.current_idx)
  1312. def _on_prog_click(self, e):
  1313. # Sécurités de base
  1314. if self.points_list is None or self.total_sec <= 0:
  1315. return
  1316. total_pts = len(self.points_list)
  1317. if total_pts == 0:
  1318. return
  1319. # 1. RÉCUPÉRATION DE LA GÉOMÉTRIE RÉELLE
  1320. # On récupère la largeur actuelle de la barre (celle affichée à l'écran)
  1321. bar_w = self.prog_bar.width()
  1322. if bar_w <= 1:
  1323. return
  1324. # 2. CALCUL DU RATIO (Pixel-Perfect)
  1325. # mapFromGlobal convertit la position absolue de la souris sur l'écran
  1326. # vers le référentiel interne de la prog_bar (0 = bord gauche exact).
  1327. # Cela élimine tout décalage du aux marges ou au centrage (le bar_x).
  1328. local_pos = self.prog_bar.mapFromGlobal(e.globalPosition().toPoint())
  1329. pos_x = local_pos.x()
  1330. # On sature le ratio entre 0.0 (début) et 1.0 (fin)
  1331. ratio = max(0.0, min(1.0, pos_x / bar_w))
  1332. # 3. LOGIQUE DE SAUT DANS LA SIMULATION
  1333. # On mémorise si la lecture était en cours pour la reprendre après
  1334. was_running = self.sim_running
  1335. if was_running:
  1336. self._stop_play()
  1337. # Calcul du nouveau temps cible basé sur le ratio cliqué
  1338. self.current_sim_time = ratio * self.total_sec
  1339. # Recherche de l'index correspondant au temps dans les données NumPy
  1340. # (on suppose que self.points_list[:, 4] contient les timestamps cumulés)
  1341. idx = np.searchsorted(
  1342. self.points_list[:, 4],
  1343. self.current_sim_time,
  1344. side='right'
  1345. ) - 1
  1346. # Sécurisation de l'index
  1347. idx = max(0, min(int(idx), total_pts - 1))
  1348. self.current_idx = idx
  1349. # 4. MISE À JOUR VISUELLE IMMÉDIATE
  1350. # Redessine le canvas jusqu'à ce point
  1351. self._redraw_to(self.current_idx)
  1352. # Met à jour les labels, le curseur laser et la valeur de la barre
  1353. self._update_ui(self.current_idx)
  1354. # 5. REPRISE DE LA LECTURE
  1355. if was_running:
  1356. # On réinitialise le timer de frame pour éviter un bond temporel
  1357. self.last_frame_time = time.perf_counter()
  1358. self._start_play()
  1359. # ══════════════════════════════════════════════════════════════
  1360. # UI SYNC
  1361. # ══════════════════════════════════════════════════════════════
  1362. # ══════════════════════════════════════════════════════════════
  1363. # NAVIGATION G-CODE (click + clavier)
  1364. # ══════════════════════════════════════════════════════════════
  1365. def _seek_to_gcode_line(self, line_num):
  1366. """Seek la simulation au premier point dont la ligne G-Code == line_num."""
  1367. if self.points_list is None or len(self.points_list) == 0:
  1368. return
  1369. pts = self.points_list
  1370. # pts[:,3] contient le numéro de ligne G-Code
  1371. matches = np.where(pts[:, 3].astype(int) >= line_num)[0]
  1372. if len(matches) == 0:
  1373. idx = len(pts) - 1
  1374. else:
  1375. idx = int(matches[0])
  1376. was_running = self.sim_running
  1377. self._stop_play()
  1378. self.current_idx = idx
  1379. self.current_sim_time = float(pts[idx][4])
  1380. self._redraw_to(idx)
  1381. self._update_ui(idx)
  1382. if was_running:
  1383. self.last_frame_time = time.perf_counter()
  1384. self._start_play()
  1385. def _on_gcode_click(self, e):
  1386. """Click sur le G-Code : positionne le curseur ET seek la simulation."""
  1387. # Laisser le comportement normal de QPlainTextEdit (déplace le curseur)
  1388. QPlainTextEdit.mousePressEvent(self.gcode_view, e)
  1389. line_num = self.gcode_view.textCursor().blockNumber() + 1
  1390. self._seek_to_gcode_line(line_num)
  1391. def _on_gcode_key(self, e):
  1392. """Flèches clavier sur le G-Code : navigation ±1 ligne ou ±20 lignes."""
  1393. from PyQt6.QtCore import Qt as _Qt
  1394. key = e.key()
  1395. if key in (_Qt.Key.Key_Left, _Qt.Key.Key_Right,
  1396. _Qt.Key.Key_Up, _Qt.Key.Key_Down):
  1397. cur_line = self.gcode_view.textCursor().blockNumber() + 1
  1398. if key == _Qt.Key.Key_Left:
  1399. new_line = max(1, cur_line - 1)
  1400. elif key == _Qt.Key.Key_Right:
  1401. doc_lines = self.gcode_view.document().blockCount()
  1402. new_line = min(doc_lines, cur_line + 1)
  1403. elif key == _Qt.Key.Key_Up:
  1404. new_line = max(1, cur_line - 20)
  1405. else: # Down
  1406. doc_lines = self.gcode_view.document().blockCount()
  1407. new_line = min(doc_lines, cur_line + 20)
  1408. # Déplacer le curseur du gcode_view
  1409. block = self.gcode_view.document().findBlockByLineNumber(new_line - 1)
  1410. if block.isValid():
  1411. cur = self.gcode_view.textCursor()
  1412. cur.setPosition(block.position())
  1413. cur.select(cur.SelectionType.LineUnderCursor)
  1414. self.gcode_view.setTextCursor(cur)
  1415. self.gcode_view.ensureCursorVisible()
  1416. self._seek_to_gcode_line(new_line)
  1417. else:
  1418. # Comportement normal pour toutes les autres touches
  1419. QPlainTextEdit.keyPressEvent(self.gcode_view, e)
  1420. def _update_ui(self, idx):
  1421. if self.points_list is None or idx >= len(self.points_list): return
  1422. ts = float(self.points_list[idx][4])
  1423. # On base le rendu visuel sur l'écoulement du TEMPS, pas sur l'index des points
  1424. pct = ts / max(0.001, self.total_sec)
  1425. pct = max(0.0, min(1.0, pct))
  1426. self.prog_bar.setValue(int(pct * 10000))
  1427. self.lbl_time.setText(
  1428. f'{self._fmt(ts)} / {self._fmt(self.total_sec)}')
  1429. self._highlight_gcode(idx)
  1430. def _highlight_gcode(self, idx):
  1431. if self.points_list is None or idx >= len(self.points_list): return
  1432. try:
  1433. line_num = int(self.points_list[idx][3])
  1434. block = self.gcode_view.document().findBlockByLineNumber(line_num-1)
  1435. if block.isValid():
  1436. cur = self.gcode_view.textCursor()
  1437. cur.setPosition(block.position())
  1438. cur.select(cur.SelectionType.LineUnderCursor)
  1439. self.gcode_view.setTextCursor(cur)
  1440. self.gcode_view.ensureCursorVisible()
  1441. except Exception: pass
  1442. # ══════════════════════════════════════════════════════════════
  1443. # EXPORT
  1444. # ══════════════════════════════════════════════════════════════
  1445. def on_export(self):
  1446. self._stop_all()
  1447. meta = self.payload.get('metadata', {})
  1448. ext = self.controller.config_manager.get_item(
  1449. 'machine_settings', 'gcode_extension', '.nc')
  1450. name = os.path.splitext(
  1451. os.path.basename(str(meta.get('file_name', 'output'))))[0]
  1452. self._save(os.path.join(meta.get('output_dir', ''), f'{name}{ext}')
  1453. .replace('\\', '/'))
  1454. def on_export_as(self):
  1455. self._stop_all()
  1456. meta = self.payload.get('metadata', {})
  1457. ext = meta.get('file_extension', '.nc').lower()
  1458. if not ext.startswith('.'): ext = f'.{ext}'
  1459. name = os.path.splitext(
  1460. os.path.basename(str(meta.get('file_name', 'output'))))[0]
  1461. stds = [('.nc', 'NC'), ('.gcode', 'G-Code'), ('.gc', 'GC'),
  1462. ('.tap', 'Tap'), ('.txt', 'Text')]
  1463. parts = [f'{l} (Default) (*{e})' if e == ext else f'{l} (*{e})'
  1464. for e, l in stds]
  1465. parts.append('All files (*.*)')
  1466. path, _ = get_save_file(
  1467. self, self.t.get('export_as', 'Export G-Code As…'),
  1468. os.path.join(meta.get('output_dir', ''), f'{name}{ext}'),
  1469. ';;'.join(parts))
  1470. if path: self._save(path.replace('\\', '/'))
  1471. # ── Helper : QMessageBox isolé du stylesheet parent ──────────────────────
  1472. def _msgbox(self, icon: QMessageBox.Icon, title: str, text: str) -> None:
  1473. """
  1474. Affiche un QMessageBox avec un stylesheet complet et autonome.
  1475. Pourquoi : le stylesheet appliqué sur les widgets parents (background,
  1476. border, color…) est hérité par les QDialog/QMessageBox enfants sous Qt,
  1477. ce qui provoque un fond corrompu ou transparent. En appliquant un
  1478. stylesheet *complet* directement sur la boîte de dialogue — et en
  1479. utilisant setParent(None) pour couper l'héritage — on neutralise cet effet.
  1480. """
  1481. colors = getattr(self, '_theme_colors', {})
  1482. bg = colors.get('bg_card_alt', '#2b2b2b')
  1483. bg_btn = colors.get('btn_neutral', '#3a3a3a')
  1484. bg_hov = colors.get('btn_neutral_hover', '#4a4a4a')
  1485. fg = colors.get('text', '#ffffff') # renommé fg pour éviter
  1486. border = colors.get('border', '#555555') # la collision avec le param text
  1487. mb = QMessageBox() # Pas de parent → aucun héritage de stylesheet
  1488. mb.setIcon(icon)
  1489. mb.setWindowTitle(title)
  1490. mb.setText(text) # <-- param text (message), pas fg (couleur)
  1491. mb.setStyleSheet(f"""
  1492. QMessageBox {{
  1493. background-color: {bg};
  1494. color: {fg};
  1495. }}
  1496. QMessageBox QLabel {{
  1497. color: {fg};
  1498. background: transparent;
  1499. border: none;
  1500. font-size: 13px;
  1501. padding: 4px;
  1502. }}
  1503. QPushButton {{
  1504. background-color: {bg_btn};
  1505. color: {fg};
  1506. border: 1px solid {border};
  1507. border-radius: 5px;
  1508. padding: 5px 18px;
  1509. font-size: 12px;
  1510. min-width: 70px;
  1511. }}
  1512. QPushButton:hover {{
  1513. background-color: {bg_hov};
  1514. }}
  1515. """)
  1516. mb.exec()
  1517. def _save(self, path):
  1518. """Sauvegarde le G-Code physiquement et met à jour les statistiques du dashboard."""
  1519. try:
  1520. if not hasattr(self, 'final_gcode') or not self.final_gcode:
  1521. self._msgbox(QMessageBox.Icon.Critical, 'Error',
  1522. self.t.get('no_gcode', 'No G-Code to save.'))
  1523. return
  1524. with open(path, 'w') as f:
  1525. f.write(self.final_gcode)
  1526. matrix = self.payload.get('matrix')
  1527. if matrix is not None:
  1528. from core.utils import save_dashboard_data
  1529. estimated_time = getattr(self, 'total_sec', 0)
  1530. save_dashboard_data(
  1531. config_manager=self.controller.config_manager,
  1532. matrix=matrix,
  1533. gcode_content=self.final_gcode,
  1534. estimated_time=estimated_time
  1535. )
  1536. print(estimated_time)
  1537. self._msgbox(QMessageBox.Icon.Information, 'Success',
  1538. f'{self.t.get("save_success", "G-Code saved successfully:")}\n{path}')
  1539. self._navigate_back()
  1540. except Exception as e:
  1541. self._msgbox(QMessageBox.Icon.Critical, 'Error',
  1542. f'{self.t.get("save_failed", "Save failed:")}\n{str(e)}')
  1543. # ══════════════════════════════════════════════════════════════
  1544. # NAVIGATION / FERMETURE
  1545. # ══════════════════════════════════════════════════════════════
  1546. def on_cancel(self):
  1547. self._stop_all(); self._navigate_back()
  1548. def _navigate_back(self):
  1549. if self.return_view == 'raster': self.controller.show_raster_mode()
  1550. elif self.return_view == 'infill': self.controller.show_infill_mode()
  1551. else: self.controller.show_dashboard()
  1552. def _stop_all(self):
  1553. self.sim_running = False
  1554. self._anim_timer.stop()
  1555. if hasattr(self, '_worker') and self._worker.isRunning():
  1556. self._worker.quit(); self._worker.wait(500)
  1557. def closeEvent(self, e):
  1558. self._stop_all(); super().closeEvent(e)
  1559. # ══════════════════════════════════════════════════════════════
  1560. # THEME
  1561. # ══════════════════════════════════════════════════════════════
  1562. def apply_theme(self, colors: dict):
  1563. self._theme_colors = colors
  1564. text = colors['text']
  1565. text_sec = colors['text_secondary']
  1566. bg_card = colors['bg_card_alt']
  1567. border = colors['border']
  1568. bg_left = colors['bg_main']
  1569. bg_right = colors['bg_deep']
  1570. bg_stats = colors['bg_stats']
  1571. bg_opts = colors['bg_opts']
  1572. bg_entry = colors['bg_entry']
  1573. brd_left = colors['border']
  1574. canvas_bg = colors['bg_canvas']
  1575. # ── Racine ────────────────────────────────────────────────
  1576. self.setStyleSheet(
  1577. f'QWidget#SimulationRoot{{background:{bg_card};color:{text};}}')
  1578. # ── Panneau gauche ────────────────────────────────────────
  1579. if hasattr(self, 'left'):
  1580. self.left.setStyleSheet(
  1581. f'QFrame{{background:{bg_left};border-right:1px solid {brd_left};}}')
  1582. # ── Label "Live G-Code" ───────────────────────────────────
  1583. if hasattr(self, '_lbl_gcode'):
  1584. self._lbl_gcode.setStyleSheet(
  1585. f'color:{text};font-weight:bold;font-size:11px;border:none;')
  1586. # ── gcode_view ────────────────────────────────────────────
  1587. if hasattr(self, 'gcode_view'):
  1588. self.gcode_view.setStyleSheet(
  1589. f'QPlainTextEdit{{background:{bg_entry};color:{colors["text_code"]};'
  1590. f'font-family:Consolas;border:1px solid {brd_left};border-radius:3px;}}' # noqa
  1591. )
  1592. self.gcode_view.verticalScrollBar().setStyleSheet(f"""
  1593. QScrollBar:vertical {{
  1594. border: none;
  1595. background: {colors['scrollbar_bg']};
  1596. width: 10px;
  1597. margin: 0px;
  1598. }}
  1599. QScrollBar::handle:vertical {{
  1600. background: {colors['scrollbar_handle']};
  1601. min-height: 20px;
  1602. border-radius: 5px;
  1603. }}
  1604. QScrollBar::handle:vertical:hover {{
  1605. background: #1F6AA5;
  1606. }}
  1607. QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
  1608. height: 0px;
  1609. }}
  1610. QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
  1611. background: none;
  1612. }}
  1613. """)
  1614. # ── Stats frame ───────────────────────────────────────────
  1615. if hasattr(self, '_stats_frame'):
  1616. self._stats_frame.setStyleSheet(
  1617. f'QFrame{{background:{bg_stats};border-radius:6px;'
  1618. f'border:1px solid {brd_left};}}')
  1619. for lbl in getattr(self, '_stat_key_labels', []):
  1620. lbl.setStyleSheet(f'color:{text_sec};font-size:10px;font-weight:bold;border:none;')
  1621. for lbl in getattr(self, '_stat_val_labels', []):
  1622. lbl.setStyleSheet(f'color:{text};font-family:Consolas;font-size:11px;border:none;')
  1623. # ── Options frame ─────────────────────────────────────────
  1624. if hasattr(self, '_opts_frame'):
  1625. self._opts_frame.setStyleSheet(
  1626. f'QFrame{{background:{bg_opts};border:1px solid {border};border-radius:6px;}}')
  1627. if hasattr(self, '_opts_lbl'):
  1628. self._opts_lbl.setStyleSheet(
  1629. f'color:{text};font-weight:bold;font-size:11px;border:none;')
  1630. # ── Label latency ─────────────────────────────────────────
  1631. if hasattr(self, 'lbl_lat') and self.lbl_lat:
  1632. self.lbl_lat.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
  1633. # ── Boutons export/cancel ─────────────────────────────────
  1634. if hasattr(self, 'btn_cancel'):
  1635. self.btn_cancel.setStyleSheet(
  1636. self._gbtn(colors['btn_cancel'], colors['btn_neutral_hover']))
  1637. # ── Boutons transport ─────────────────────────────────────
  1638. if hasattr(self, 'btn_rew'):
  1639. self.btn_rew.setStyleSheet(self._gbtn(colors['btn_neutral'], colors['btn_neutral_hover']))
  1640. if hasattr(self, '_btn_end'):
  1641. self._btn_end.setStyleSheet(self._gbtn(colors['btn_neutral'], colors['btn_neutral_hover']))
  1642. if hasattr(self, '_btn_fit'):
  1643. self._btn_fit.setStyleSheet(self._gbtn(colors['btn_cancel'], colors['btn_neutral_hover']))
  1644. # ── Sélecteur vitesse ─────────────────────────────────────
  1645. if hasattr(self, '_spd_frame'):
  1646. self._spd_frame.setStyleSheet(
  1647. f'QFrame{{background:{colors["bg_speed"]};border:1px solid {border};border-radius:5px;}}')
  1648. if hasattr(self, '_spd_lbl'):
  1649. self._spd_lbl.setStyleSheet(f'color:{text};font-weight:bold;font-size:9px;border:none;')
  1650. if hasattr(self, '_spd_btns'):
  1651. for b in self._spd_btns.values():
  1652. b.setStyleSheet(
  1653. f'QPushButton{{background:{colors["bg_speed_btn"]};color:{colors["speed_text"]};'
  1654. f'border:1px solid {brd_left};border-radius:3px;'
  1655. f'padding:0px 4px;font-size:8px;min-width:18px;max-width:30px;}}'
  1656. f'QPushButton:checked{{background:{colors["btn_speed_checked"]};color:white;'
  1657. f'border-color:{colors["btn_speed_checked_hov"]};}}'
  1658. f'QPushButton:hover:!checked{{background:{colors["btn_neutral"]};color:white;}}')
  1659. # ── Barre de puissance ────────────────────────────────────
  1660. if hasattr(self, '_power_bar'):
  1661. self._power_bar.set_theme(colors['pbar_bg'], colors['seg_text'], colors['border_light'])
  1662. # ── Badges options actives ────────────────────────────────
  1663. if hasattr(self, '_opts_badges'):
  1664. for badge, active in self._opts_badges:
  1665. if active:
  1666. badge.setStyleSheet(
  1667. 'color:#ff9f43;background:#3d2b1f;border-radius:5px;'
  1668. 'font-size:9px;font-weight:bold;padding:3px 6px;border:none;')
  1669. else:
  1670. badge.setStyleSheet(
  1671. f'color:{colors["seg_text"]};background:{colors["bg_card"]};border-radius:5px;'
  1672. 'font-size:9px;font-weight:bold;padding:3px 6px;border:none;')
  1673. # ── Barre de progression ──────────────────────────────────
  1674. if hasattr(self, 'prog_bar'):
  1675. self.prog_bar.setStyleSheet(
  1676. f'QProgressBar{{background:{colors["progress_bg"]};border-radius:7px;border:none;}}'
  1677. f'QProgressBar::chunk{{background:#27ae60;border-radius:7px;}}')
  1678. if hasattr(self, 'lbl_time'):
  1679. self.lbl_time.setStyleSheet(
  1680. f'color:{text};font-size:9px;font-weight:bold;'
  1681. f'background:transparent;border:none;')
  1682. # ── Panneau droit + canvas ────────────────────────────────
  1683. if hasattr(self, '_right_widget'):
  1684. self._right_widget.setStyleSheet(f'background:{bg_right};')
  1685. if hasattr(self, 'canvas'):
  1686. self.canvas.set_theme(canvas_bg)
  1687. # ══════════════════════════════════════════════════════════════
  1688. # UTILITAIRES
  1689. # ══════════════════════════════════════════════════════════════
  1690. def _mm_to_screen(self, mx, my):
  1691. sx = self._x0 + (mx - self._mnx) * self._scale
  1692. sy = self._y0 + self._px_h - (my - self._mny) * self._scale
  1693. return sx, sy
  1694. @staticmethod
  1695. def _fmt(s):
  1696. s = float(s)
  1697. return f'{int(s//3600):02d}:{int((s%3600)//60):02d}:{int(s%60):02d}'
  1698. @staticmethod
  1699. def _gbtn(bg, hov):
  1700. return (f'QPushButton{{background:{bg};color:white;border-radius:6px;'
  1701. f'border:none;}}'
  1702. f'QPushButton:hover{{background:{hov};}}')
  1703. def _c(self, dark_val, light_val):
  1704. # Conservé pour compatibilité
  1705. return dark_val if self._theme_colors.get('suffix', '_DARK') == '_DARK' else light_val
  1706. def _t(self, key='text'):
  1707. return self._theme_colors.get(key, '#ffffff')