calibration_view_qt.py 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331
  1. import os
  2. import tempfile
  3. from PyQt6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QBoxLayout, QFrame,
  4. QLabel, QScrollArea, QStackedWidget, QPushButton,
  5. QLineEdit, QGridLayout, QButtonGroup,
  6. QMessageBox, QComboBox)
  7. from PyQt6.QtCore import Qt, QSize, QTimer
  8. from PyQt6.QtGui import QIcon, QPixmap
  9. from utils.paths import SVG_ICONS, EXPLAIN_PNG
  10. from core.themes import get_theme
  11. from gui.switch import Switch
  12. from core.translations import TRANSLATIONS
  13. from gui.utils_qt import get_svg_pixmap
  14. from gui.utils_qt import get_save_file
  15. from engine.calibrate_engine import CalibrateEngine
  16. class CalibrationView(QWidget):
  17. def __init__(self, parent=None, controller=None):
  18. super().__init__(parent)
  19. self.controller = controller
  20. self.translation_map = {}
  21. self.test_cards = []
  22. self.current_test_id = None
  23. self.calibrate_engine = CalibrateEngine()
  24. self._last_calc_ms = None
  25. # Couleurs thème — dark par défaut, mis à jour par apply_theme
  26. self._theme_colors = get_theme('Dark')
  27. # 1. Chargement initial des textes
  28. self.load_texts()
  29. # 2. Layout principal
  30. self.main_layout = QHBoxLayout(self)
  31. self.main_layout.setContentsMargins(30, 20, 30, 20)
  32. self.main_layout.setSpacing(20)
  33. # --- Sidebar & Colonne de droite ---
  34. self.setup_sidebar()
  35. self.right_column = QFrame()
  36. self.right_column.setObjectName("RightColumn")
  37. self.right_column.setStyleSheet("QFrame#RightColumn { background-color: #202020; border-radius: 15px; }")
  38. self.right_layout = QVBoxLayout(self.right_column)
  39. self.main_layout.addWidget(self.right_column, stretch=1)
  40. self.setup_detail_header()
  41. self.dynamic_params_widget = QWidget()
  42. self.dynamic_layout = QVBoxLayout(self.dynamic_params_widget)
  43. self.right_layout.addWidget(self.dynamic_params_widget)
  44. self.action_btn = None # créé dynamiquement par chaque setup_*_params
  45. # ══════════════════════════════════════════════════════════════
  46. # THEME
  47. # ══════════════════════════════════════════════════════════════
  48. def _c(self, dark_val, light_val):
  49. # Conservé pour compatibilité — préférer colors['key'] directement
  50. return dark_val if self._theme_colors.get('suffix', '_DARK') == '_DARK' else light_val
  51. def _container_style(self):
  52. c = self._theme_colors
  53. return (
  54. f"QFrame#ParamsContainer{{background-color:{c['bg_card_alt']};border-radius:10px;padding:8px;}}"
  55. f"QLabel{{color:{c['text']};font-size:13px;}}"
  56. f"QLineEdit{{background-color:{c['bg_entry_alt']};border:1px solid {c['border_light']};"
  57. f"border-radius:4px;color:{c['text']};padding:5px;}}"
  58. )
  59. def _calc_style(self):
  60. c = self._theme_colors
  61. return (
  62. f"QFrame#CalcContainer{{background-color:{c['bg_card_alt']};border-radius:10px;padding:15px;}}"
  63. f"QLabel{{color:{c['text']};font-size:13px;}}"
  64. f"QLineEdit{{background-color:{c['bg_entry_alt']};border:1px solid {c['border_light']};"
  65. f"border-radius:4px;color:{c['text']};padding:5px;}}"
  66. )
  67. def _select_style(self):
  68. c = self._theme_colors
  69. return (
  70. f"QFrame#SelectContainer{{background-color:{c['bg_select']};"
  71. f"border-radius:10px;padding:12px;margin-top:8px;}}"
  72. )
  73. def _btn_style_step(self):
  74. c = self._theme_colors
  75. return (
  76. f"QPushButton{{background:{c['bg_card']};color:{c['text_secondary']};font-size:10px;"
  77. f"border:1px solid {c['border_strong']};border-radius:6px;text-align:left;"
  78. f"padding-top:2px;padding-bottom:5px;}}"
  79. f"QPushButton:hover{{background:#1f538d;border-color:#3a8fd4;color:white;}}"
  80. )
  81. def apply_theme(self, colors: dict):
  82. self._theme_colors = colors
  83. text = colors['text']
  84. text_sec = colors['text_secondary']
  85. bg_right = colors['bg_scroll']
  86. card_bg = colors['bg_card_alt']
  87. card_brd = colors['border_card']
  88. card_hov = colors['hover_card_alt']
  89. # ── Colonne droite ────────────────────────────────────────
  90. if hasattr(self, 'right_column'):
  91. self.right_column.setStyleSheet(
  92. f"QFrame#RightColumn{{background-color:{bg_right};border-radius:15px;}}")
  93. # ── Titre sidebar ─────────────────────────────────────────
  94. if hasattr(self, 'sidebar_title_label'):
  95. self.sidebar_title_label.setStyleSheet(
  96. "font-size:18px;font-weight:bold;color:#e67e22;")
  97. # ── Cartes test ───────────────────────────────────────────
  98. checked_bg = colors['bg_card']
  99. for card in self.test_cards:
  100. card._bg_normal = card_bg
  101. card._bg_hover = card_hov
  102. card._bd_normal = card_brd
  103. card._bd_hover = '#e67e22'
  104. # Appliquer le style en respectant l'état checked courant
  105. card.setStyleSheet(
  106. f"QPushButton{{background-color:{card_bg};border:1px solid {card_brd};"
  107. f"border-radius:8px;text-align:left;padding:10px;}}"
  108. f"QPushButton:checked{{background-color:{checked_bg};border:2px solid #e67e22;}}"
  109. )
  110. title_lbl = card.findChild(QLabel, "card_title")
  111. desc_lbl = card.findChild(QLabel, "card_desc")
  112. if title_lbl:
  113. title_lbl.setStyleSheet(
  114. f"font-weight:bold;font-size:13px;color:{text};"
  115. "border:none;background:transparent;")
  116. if desc_lbl:
  117. desc_lbl.setStyleSheet(
  118. f"font-size:11px;color:{text_sec};"
  119. "border:none;background:transparent;")
  120. # ── Titre et description détail ───────────────────────────
  121. if hasattr(self, 'detail_title'):
  122. self.detail_title.setStyleSheet(
  123. "font-size:20px;font-weight:bold;color:#e67e22;")
  124. if hasattr(self, 'detail_desc'):
  125. self.detail_desc.setStyleSheet(f"font-size:13px;color:{text};line-height:1.4;")
  126. # ── Bouton action ─────────────────────────────────────────
  127. if hasattr(self, 'action_btn') and self.action_btn and getattr(self, 'current_test_id', None) != 'overscan':
  128. self.action_btn.setStyleSheet(
  129. "background-color:#e67e22;font-weight:bold;border-radius:10px;color:white;")
  130. # ── Icônes des cartes (latency/linestep) selon thème ─────────
  131. color_main = colors['text']
  132. color_active = "#e67e22"
  133. current_id = getattr(self, 'current_test_id', None)
  134. for card in getattr(self, 'test_cards', []):
  135. t_id = card.property("test_id")
  136. test_meta = next((t for t in self.tests_data if t["id"] == t_id), {})
  137. if not test_meta.get("fixed", False):
  138. is_selected = (t_id == current_id)
  139. current_icon_color = color_active if is_selected else color_main
  140. icon_path = test_meta.get("icon") # clé correcte
  141. if icon_path and os.path.exists(icon_path):
  142. pix = get_svg_pixmap(icon_path, size=QSize(35, 35), color_hex=current_icon_color)
  143. if pix and not pix.isNull():
  144. card.setIcon(QIcon(pix))
  145. # ── Widgets dynamiques (si déjà créés) ────────────────────
  146. self._restyle_dynamic()
  147. def _restyle_dynamic(self):
  148. """Restyles les containers dynamiques s'ils existent."""
  149. for w in self.findChildren(QFrame):
  150. if w.objectName() == "ParamsContainer":
  151. w.setStyleSheet(self._container_style())
  152. elif w.objectName() == "CalcContainer":
  153. w.setStyleSheet(self._calc_style())
  154. elif w.objectName() == "SelectContainer":
  155. w.setStyleSheet(self._select_style())
  156. c = self._theme_colors
  157. entry_s = (f"QLineEdit{{background:{c['bg_entry_alt']};border:1px solid {c['border_light']};"
  158. f"border-radius:4px;color:{c['text']};padding:5px;}}")
  159. for w in self.dynamic_params_widget.findChildren(QLineEdit):
  160. w.setStyleSheet(entry_s)
  161. for w in self.dynamic_params_widget.findChildren(QComboBox):
  162. w.setStyleSheet(self._combo_style())
  163. step_s = self._btn_style_step()
  164. for btn in getattr(self, '_step_buttons', []):
  165. btn.setStyleSheet(step_s)
  166. for lbl in btn.findChildren(QLabel):
  167. lbl.setStyleSheet(
  168. f"color:{c['text_secondary']};font-size:10px;border:none;background:transparent;")
  169. # ══════════════════════════════════════════════════════════════
  170. def load_texts(self):
  171. """Met à jour les dictionnaires de textes depuis le controller"""
  172. if hasattr(self.controller, 'translations'):
  173. current_repo = self.controller.translations
  174. else:
  175. lang = self.controller.get_item("machine_settings", "language", "English")
  176. from core.translations import TRANSLATIONS
  177. current_repo = TRANSLATIONS.get(lang, TRANSLATIONS["English"])
  178. self.texts = current_repo.get("calibration", {})
  179. self.common_texts = current_repo.get("common", {})
  180. def setup_sidebar(self):
  181. """Crée la liste des tests à gauche."""
  182. sidebar_container = QVBoxLayout()
  183. # MODIFICATION ICI : on utilise self.sidebar_title_label au lieu de title
  184. self.sidebar_title_label = QLabel(self.texts.get("sidebar_title", "Tests"))
  185. self.sidebar_title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #e67e22;")
  186. sidebar_container.addWidget(self.sidebar_title_label)
  187. self.scroll_area = QScrollArea()
  188. self.scroll_area.setWidgetResizable(True)
  189. self.scroll_area.setFixedWidth(420)
  190. self.scroll_area.setStyleSheet("background: transparent; border: none;")
  191. self.scroll_content = QWidget()
  192. self.scroll_layout = QVBoxLayout(self.scroll_content)
  193. self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
  194. self.button_group = QButtonGroup(self)
  195. self.button_group.setExclusive(True)
  196. self.scroll_area.setWidget(self.scroll_content)
  197. sidebar_container.addWidget(self.scroll_area)
  198. self.main_layout.addLayout(sidebar_container)
  199. self.add_test_cards()
  200. def add_test_cards(self):
  201. """Définit et ajoute les cartes de test à la sidebar"""
  202. # 1. On vide la liste pour éviter les doublons en cas de rappel de la fonction
  203. self.test_cards = []
  204. # 2. Configuration des données de base
  205. self.tests_data = [
  206. {"id": "latency", "icon": SVG_ICONS["LATENCY"], "fixed": False},
  207. {"id": "linestep", "icon": SVG_ICONS["LINESTEP"], "fixed": False},
  208. {"id": "overscan", "icon": SVG_ICONS["OVERSCAN"], "fixed": True},
  209. {"id": "power", "icon": SVG_ICONS["POWER"], "fixed": True}
  210. ]
  211. for data in self.tests_data:
  212. # 3. On prépare le dictionnaire complet pour create_test_card
  213. test_info = {
  214. "id": data["id"],
  215. "title": self.texts.get(f"{data['id']}_title", "Sans titre"),
  216. "desc": self.texts.get(f"{data['id']}_short", ""),
  217. "icon_path": data["icon"],
  218. "fixed_color": data["fixed"]
  219. }
  220. # 4. Création et stockage
  221. card = self.create_test_card(test_info)
  222. self.test_cards.append(card)
  223. self.scroll_layout.addWidget(card)
  224. def create_test_card(self, test_info):
  225. """Crée un bouton stylisé pour la sidebar."""
  226. card = QPushButton()
  227. card.setCheckable(True)
  228. card.setCursor(Qt.CursorShape.PointingHandCursor)
  229. card.setFixedHeight(80)
  230. test_id = test_info.get("id")
  231. card.setProperty("test_id", test_id)
  232. card._test_info = test_info
  233. if hasattr(self, 'button_group'):
  234. self.button_group.addButton(card)
  235. # ── Icône via QIcon — résiste aux state changes (checked/hover/repaint) ──
  236. icon_path = test_info.get("icon_path")
  237. keep_original = test_info.get("fixed_color", False)
  238. if keep_original:
  239. target_color = None
  240. else:
  241. _theme = self.controller.get_item("machine_settings", "theme", "Dark")
  242. target_color = "#000000" if _theme == "Light" else "#EEEEEE"
  243. if icon_path and os.path.exists(icon_path):
  244. pix = get_svg_pixmap(icon_path, size=QSize(35, 35), color_hex=target_color)
  245. if not pix.isNull():
  246. card.setIcon(QIcon(pix))
  247. card.setIconSize(QSize(35, 35))
  248. # ── Textes dans un layout interne ───────────────────────────────────────
  249. card.setText("")
  250. layout = QHBoxLayout(card)
  251. layout.setContentsMargins(12, 8, 12, 8)
  252. layout.setSpacing(12)
  253. layout.addSpacing(42) # réserve la place de l'icône Qt (gérée nativement)
  254. text_container = QVBoxLayout()
  255. text_container.setSpacing(2)
  256. title = QLabel(test_info.get("title", ""))
  257. title.setObjectName("card_title")
  258. title.setStyleSheet("font-weight: bold; font-size: 13px; color: white; "
  259. "border: none; background: transparent;")
  260. title.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  261. desc = QLabel(test_info.get("desc", ""))
  262. desc.setObjectName("card_desc")
  263. desc.setStyleSheet("font-size: 11px; color: #aaaaaa; "
  264. "border: none; background: transparent;")
  265. desc.setWordWrap(True)
  266. desc.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  267. text_container.addWidget(title)
  268. text_container.addWidget(desc)
  269. layout.addLayout(text_container)
  270. # ── Style (sans :hover CSS) ─────────────────────────────────────────────
  271. card.setStyleSheet("""
  272. QPushButton {
  273. background-color: #2b2b2b;
  274. border: 1px solid #3d3d3d;
  275. border-radius: 8px;
  276. text-align: left;
  277. padding: 10px;
  278. }
  279. QPushButton:checked {
  280. background-color: #3d3d3d;
  281. border: 2px solid #e67e22;
  282. }
  283. """)
  284. card._bg_normal = "#2b2b2b"
  285. card._bg_hover = "#353535"
  286. card._bd_normal = "#3d3d3d"
  287. card._bd_hover = "#e67e22"
  288. def _reset_style(c):
  289. """Remet le style normal ou checked selon l'état de la carte."""
  290. if c.isChecked():
  291. c.setStyleSheet(f"""
  292. QPushButton {{
  293. background-color: {c._bg_normal};
  294. border: 1px solid {c._bd_normal};
  295. border-radius: 8px; text-align: left; padding: 10px;
  296. }}
  297. QPushButton:checked {{
  298. background-color: #3d3d3d; border: 2px solid #e67e22;
  299. }}
  300. """)
  301. else:
  302. c.setStyleSheet(f"""
  303. QPushButton {{
  304. background-color: {c._bg_normal};
  305. border: 1px solid {c._bd_normal};
  306. border-radius: 8px; text-align: left; padding: 10px;
  307. }}
  308. QPushButton:checked {{
  309. background-color: #3d3d3d; border: 2px solid #e67e22;
  310. }}
  311. """)
  312. def _enter(e, c=card):
  313. # Remettre le style normal sur toutes les cartes non survolées
  314. for other in self.test_cards:
  315. if other is not c and not other.underMouse():
  316. _reset_style(other)
  317. # Appliquer le hover uniquement si la carte n'est pas sélectionnée
  318. if not c.isChecked():
  319. c.setStyleSheet(f"""
  320. QPushButton {{
  321. background-color: {c._bg_hover};
  322. border: 1px solid {c._bd_hover};
  323. border-radius: 8px; text-align: left; padding: 10px;
  324. }}
  325. QPushButton:checked {{
  326. background-color: #3d3d3d; border: 2px solid #e67e22;
  327. }}
  328. """)
  329. def _leave(e, c=card):
  330. _reset_style(c)
  331. card.enterEvent = _enter
  332. card.leaveEvent = _leave
  333. card.clicked.connect(lambda: self.on_test_selected(test_info))
  334. return card
  335. def on_test_selected(self, test_info):
  336. """
  337. Gère la mise à jour de la vue détaillée lorsqu'un test est sélectionné.
  338. Adapte les couleurs et les images selon le thème (Dark/Light).
  339. """
  340. # --- 0. MISE À JOUR DE L'ÉTAT ---
  341. # Crucial pour que retranslate_ui sache quel test traduire
  342. self.current_test_id = test_info.get("id")
  343. # Récupération du thème actuel pour adapter les icônes
  344. theme = self.controller.get_item("machine_settings", "theme", "Dark")
  345. is_light = (theme == "Light")
  346. color_main = "#000000" if is_light else "#EEEEEE"
  347. color_active = "#e67e22" # Orange pour le highlight
  348. theme_suffix = "_LIGHT" if is_light else "_DARK"
  349. # --- 1. TEXTES (Détails à droite) ---
  350. # Titre et description longue chargée depuis le dictionnaire de langue
  351. self.detail_title.setText(test_info.get("title", ""))
  352. self.detail_desc.setText(self.texts.get(f"{self.current_test_id}_long", ""))
  353. # --- 2. SIDEBAR (Icônes dynamiques) ---
  354. for card in self.test_cards:
  355. t_id = card.property("test_id")
  356. is_selected = (t_id == self.current_test_id)
  357. test_meta = next((t for t in self.tests_data if t["id"] == t_id), {})
  358. # Recoloriage via setIcon — ne provoque pas de repaint des enfants
  359. if not test_meta.get("fixed", False):
  360. current_icon_color = color_active if is_selected else color_main
  361. pix = get_svg_pixmap(
  362. test_meta.get("icon"),
  363. size=QSize(35, 35),
  364. color_hex=current_icon_color
  365. )
  366. if pix and not pix.isNull():
  367. card.setIcon(QIcon(pix))
  368. # --- 3. ILLUSTRATION (Preview PNG) ---
  369. if hasattr(self, 'preview_image_label'):
  370. img_key = f"{self.current_test_id.upper()}{theme_suffix}"
  371. explain_img_path = EXPLAIN_PNG.get(img_key)
  372. if explain_img_path and os.path.exists(explain_img_path):
  373. pixmap = QPixmap(explain_img_path)
  374. if not pixmap.isNull():
  375. scaled_pixmap = pixmap.scaled(
  376. 500, 250,
  377. Qt.AspectRatioMode.KeepAspectRatio,
  378. Qt.TransformationMode.SmoothTransformation
  379. )
  380. self.preview_image_label.setPixmap(scaled_pixmap)
  381. else:
  382. self.preview_image_label.clear()
  383. else:
  384. self.preview_image_label.clear()
  385. # --- 4. PARAMÈTRES DYNAMIQUES ---
  386. self.clear_dynamic_layout()
  387. if self.current_test_id == "latency":
  388. self.setup_latency_params()
  389. self._make_action_btn(
  390. self.texts.get("btn_generate", "Generate G-Code..."),
  391. self.validate_and_generate_latency)
  392. elif self.current_test_id == "linestep":
  393. self.setup_linestep_params()
  394. self._make_action_btn(
  395. self.texts.get("btn_generate", "Generate G-Code..."),
  396. self.validate_and_generate_linestep)
  397. elif self.current_test_id == "overscan":
  398. self.setup_overscan_params()
  399. # Pas de bouton generate pour l'overscan
  400. elif self.current_test_id == "power":
  401. self._make_action_btn(
  402. self.texts.get("btn_prepare", "Prepare"),
  403. lambda: None)
  404. def clear_dynamic_layout(self):
  405. """Supprime tous les widgets de la zone de paramètres dynamique"""
  406. while self.dynamic_layout.count():
  407. item = self.dynamic_layout.takeAt(0)
  408. widget = item.widget()
  409. if widget:
  410. widget.deleteLater()
  411. self.action_btn = None
  412. def _make_action_btn(self, label: str, callback) -> QPushButton:
  413. """Crée et ajoute un bouton action en bas du dynamic_layout."""
  414. btn = QPushButton(label)
  415. btn.setFixedHeight(52)
  416. btn.setStyleSheet(
  417. "background-color:#e67e22;font-weight:bold;border-radius:10px;color:white;")
  418. btn.clicked.connect(callback)
  419. self.dynamic_layout.addWidget(btn)
  420. self.action_btn = btn
  421. return btn
  422. def setup_detail_header(self):
  423. """Prépare la zone de texte en haut de la colonne de droite"""
  424. header_widget = QWidget()
  425. header_widget.setMaximumHeight(320) # cap pour éviter mintrack trop grand
  426. header_layout = QVBoxLayout(header_widget)
  427. header_layout.setContentsMargins(10, 10, 10, 10)
  428. header_layout.setSpacing(10)
  429. # Titre du test
  430. self.detail_title = QLabel(self.texts.get("default_title", "Select a test"))
  431. self.detail_title.setStyleSheet("font-size: 20px; font-weight: bold; color: #e67e22;")
  432. self.detail_title.setWordWrap(True)
  433. self.detail_title.setMaximumHeight(60)
  434. header_layout.addWidget(self.detail_title)
  435. # Description longue
  436. self.detail_desc = QLabel(self.texts.get("default_desc", ""))
  437. self.detail_desc.setStyleSheet("font-size: 13px; color: #DCE4EE; line-height: 1.4;")
  438. self.detail_desc.setWordWrap(True)
  439. self.detail_desc.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
  440. self.detail_desc.setMaximumHeight(100)
  441. header_layout.addWidget(self.detail_desc)
  442. # Image d'illustration
  443. self.preview_image_label = QLabel()
  444. self.preview_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
  445. self.preview_image_label.setFixedHeight(130)
  446. header_layout.addWidget(self.preview_image_label)
  447. self.right_layout.addWidget(header_widget)
  448. def setup_latency_params(self):
  449. """Construit les champs de saisie pour le test de Latence + calculatrice"""
  450. self.clear_dynamic_layout()
  451. # ── Conteneur paramètres ─────────────────────────────────────
  452. container = QFrame()
  453. container.setObjectName("ParamsContainer")
  454. container.setStyleSheet(self._container_style())
  455. grid = QGridLayout(container)
  456. grid.setSpacing(12)
  457. # Ligne 0 : Feedrate
  458. grid.addWidget(QLabel(self.texts.get("feedrate_calc", "Feedrate (mm/min):")), 0, 0)
  459. self.speed_entry = QLineEdit()
  460. self.speed_entry.setPlaceholderText("ex: 1000")
  461. current_speed = self.controller.get_item("machine_settings", "feedrate", "1000")
  462. self.speed_entry.setText(str(current_speed))
  463. grid.addWidget(self.speed_entry, 0, 1)
  464. # Ligne 0 col 2 : Latency
  465. grid.addWidget(QLabel(self.texts.get("latency_calc", "Latency (ms):")), 0, 2)
  466. self.latency_entry = QLineEdit()
  467. self.latency_entry.setPlaceholderText("ex: 0")
  468. current_lat = self.controller.get_item("machine_settings", "laser_latency", "0")
  469. self.latency_entry.setText(str(current_lat))
  470. grid.addWidget(self.latency_entry, 0, 3)
  471. # Ligne 0 col 4 : mm info (calculé dynamiquement)
  472. self.mm_info_label = QLabel("= 0.000 mm")
  473. self.mm_info_label.setStyleSheet("color: #1f538d; font-weight: bold; border: none;")
  474. grid.addWidget(self.mm_info_label, 0, 4)
  475. # Ligne 1 : Power
  476. grid.addWidget(QLabel(self.texts.get("power_pct", "Power (%):")), 1, 0)
  477. self.power_entry = QLineEdit()
  478. self.power_entry.setText("10")
  479. grid.addWidget(self.power_entry, 1, 1)
  480. self.dynamic_layout.addWidget(container)
  481. # ── Calculatrice ──────────────────────────────────────────────
  482. calc_frame = QFrame()
  483. calc_frame.setObjectName("CalcContainer")
  484. calc_frame.setStyleSheet(self._calc_style())
  485. calc_vbox = QVBoxLayout(calc_frame)
  486. calc_vbox.setSpacing(8)
  487. calc_title = QLabel(self.texts.get("latency_calculator", "Latency Calculator (from measurement):"))
  488. calc_title.setStyleSheet("font-weight: bold; font-size: 13px; color: #e67e22; border: none;")
  489. calc_vbox.addWidget(calc_title)
  490. calc_grid = QGridLayout()
  491. calc_grid.setSpacing(10)
  492. calc_grid.addWidget(QLabel(self.texts.get("measured_offset", "Measured Offset (mm):")), 0, 0)
  493. self.measured_mm_entry = QLineEdit()
  494. self.measured_mm_entry.setPlaceholderText("ex: 0.25")
  495. calc_grid.addWidget(self.measured_mm_entry, 0, 1)
  496. self.calc_result_label = QLabel(f"{self.texts.get('latency_results', 'Result:')} -- ms")
  497. self.calc_result_label.setStyleSheet("font-weight: bold; color: #e67e22; border: none;")
  498. calc_grid.addWidget(self.calc_result_label, 0, 2)
  499. save_lat_btn = QPushButton(self.texts.get("apply_save", "Apply & Save"))
  500. save_lat_btn.setFixedHeight(30)
  501. save_lat_btn.setStyleSheet("""
  502. QPushButton { background-color: #1f538d; color: white; border-radius: 6px;
  503. font-size: 12px; border: none; padding: 0 10px; }
  504. QPushButton:hover { background-color: #2a6dbd; }
  505. """)
  506. save_lat_btn.clicked.connect(self.apply_calculated_latency)
  507. calc_grid.addWidget(save_lat_btn, 0, 3)
  508. self.calc_hint_label = QLabel("")
  509. self.calc_hint_label.setStyleSheet("font-size: 11px; font-style: italic; color: gray; border: none;")
  510. calc_grid.addWidget(self.calc_hint_label, 1, 0, 1, 4)
  511. calc_vbox.addLayout(calc_grid)
  512. self.dynamic_layout.addWidget(calc_frame)
  513. # ── Bindings ─────────────────────────────────────────────────
  514. self.measured_mm_entry.textChanged.connect(self.update_latency_calculation)
  515. self.speed_entry.textChanged.connect(lambda: (self.update_latency_calculation(), self.update_mm_display()))
  516. self.latency_entry.textChanged.connect(self.update_mm_display)
  517. self.dynamic_layout.addStretch()
  518. self.update_mm_display()
  519. def setup_linestep_params(self):
  520. """Construit les champs pour le test de résolution (LineStep)"""
  521. self.clear_dynamic_layout()
  522. # ── Conteneur paramètres ─────────────────────────────────────
  523. container = QFrame()
  524. container.setObjectName("ParamsContainer")
  525. container.setStyleSheet(self._container_style())
  526. grid = QGridLayout(container)
  527. grid.setSpacing(6)
  528. # Ligne 0 : Pas Minimum & Multiplicateur
  529. grid.addWidget(QLabel(self.texts.get("min_step", "Min. Machine Step (mm):")), 0, 0)
  530. self.min_step_entry = QLineEdit()
  531. self.min_step_entry.setPlaceholderText("ex: 0.05")
  532. current_step = self.controller.get_item("machine_settings", "min_step", "0.05")
  533. self.min_step_entry.setText(str(current_step))
  534. grid.addWidget(self.min_step_entry, 0, 1)
  535. grid.addWidget(QLabel(self.texts.get("multiplier", "Multiplier:")), 0, 2)
  536. self.multiplier_entry = QLineEdit()
  537. self.multiplier_entry.setText("2")
  538. grid.addWidget(self.multiplier_entry, 0, 3)
  539. # Ligne 1 : Mode de balayage & Feedrate
  540. grid.addWidget(QLabel(self.texts.get("scan_mode", "Scan Mode:")), 1, 0)
  541. self.scan_mode_combo = QComboBox()
  542. self.scan_mode_combo.addItems(["Horizontal", "Vertical"])
  543. self.scan_mode_combo.setStyleSheet(self._combo_style())
  544. grid.addWidget(self.scan_mode_combo, 1, 1)
  545. grid.addWidget(QLabel(self.texts.get("feedrate", "Feedrate (mm/min):")), 1, 2)
  546. self.speed_entry = QLineEdit()
  547. current_speed = self.controller.get_item("machine_settings", "feedrate", "1000")
  548. self.speed_entry.setText(str(current_speed))
  549. grid.addWidget(self.speed_entry, 1, 3)
  550. # Ligne 2 : PUISSANCE (%) et LATENCE (ms)
  551. grid.addWidget(QLabel(self.texts.get("power_pct", "Power (%):")), 2, 0)
  552. self.power_entry = QLineEdit()
  553. self.power_entry.setText("10")
  554. grid.addWidget(self.power_entry, 2, 1)
  555. grid.addWidget(QLabel(self.texts.get("latency_calc", "Latency (ms):")), 2, 2)
  556. self.latency_entry = QLineEdit()
  557. # Récupération de la latence sauvegardée dans les paramètres machine
  558. current_lat = self.controller.get_item("machine_settings", "laser_latency", "0")
  559. self.latency_entry.setText(str(current_lat))
  560. grid.addWidget(self.latency_entry, 2, 3)
  561. self.dynamic_layout.addWidget(container)
  562. self.dynamic_layout.addStretch()
  563. # ── Zone de sélection du résultat — 5 boutons visuels ──────────
  564. select_frame = QFrame()
  565. select_frame.setObjectName("SelectContainer")
  566. select_frame.setStyleSheet(self._select_style())
  567. select_layout = QVBoxLayout(select_frame)
  568. select_layout.setSpacing(8)
  569. title_select = QLabel("Select the sharpest block and save:")
  570. title_select.setStyleSheet("font-weight: bold; color: #3498db; font-size: 12px;")
  571. select_layout.addWidget(title_select)
  572. # Rangée de 5 boutons visuels (un par bloc généré)
  573. self._step_btn_row = QHBoxLayout()
  574. self._step_btn_row.setSpacing(6)
  575. self._step_buttons = [] # liste pour pouvoir les recréer
  576. select_layout.addLayout(self._step_btn_row)
  577. self.multiplier_entry.textChanged.connect(self.update_step_buttons)
  578. self.min_step_entry.textChanged.connect(self.update_step_buttons)
  579. self.scan_mode_combo.currentIndexChanged.connect(self.update_step_buttons)
  580. self.update_step_buttons() # premier rendu
  581. self.dynamic_layout.addWidget(select_frame)
  582. def update_step_buttons(self):
  583. """Recrée les 5 boutons visuels de sélection de line_step."""
  584. try:
  585. center = float(self.multiplier_entry.text().replace(',', '.'))
  586. min_s = float(self.min_step_entry.text().replace(',', '.'))
  587. except ValueError:
  588. return
  589. mode = self.scan_mode_combo.currentText()
  590. is_vertical = "Vertical" in mode
  591. # 1. Nettoyage
  592. for btn in self._step_buttons:
  593. self._step_btn_row.removeWidget(btn)
  594. btn.deleteLater()
  595. self._step_buttons.clear()
  596. # 2. Logique de direction et d'ordre
  597. if not is_vertical:
  598. # Mode Horizontal : boutons l'un au dessus de l'autre
  599. self._step_btn_row.setDirection(QBoxLayout.Direction.TopToBottom)
  600. range_iterator = range(2, -3, -1) # Plus grand en haut, plus petit en bas
  601. else:
  602. # Mode Vertical : boutons côte à côte
  603. self._step_btn_row.setDirection(QBoxLayout.Direction.LeftToRight)
  604. range_iterator = range(-2, 3)
  605. for i in range_iterator:
  606. m = max(0.01, center + i * 0.5)
  607. step = round(m * min_s, 4)
  608. block_num = i + 3
  609. btn = QPushButton()
  610. pix = self._make_step_icon(step, is_vertical, color="#3a8fd4")
  611. if not is_vertical:
  612. # --- MODE HORIZONTAL (Boutons empilés, Icône à gauche du texte) ---
  613. btn.setFixedSize(110, 40)
  614. btn.setIcon(QIcon(pix))
  615. btn.setIconSize(QSize(30, 30))
  616. btn.setText(f" {step:.4f} mm")
  617. btn.setStyleSheet(self._get_btn_style("left", padding_top="0px"))
  618. else:
  619. # --- MODE VERTICAL (Icône EN HAUT, Texte EN BAS) ---
  620. btn.setFixedSize(75, 90)
  621. # On crée un layout interne au bouton pour forcer l'empilement
  622. layout = QVBoxLayout(btn)
  623. layout.setContentsMargins(2, 8, 2, 8)
  624. layout.setSpacing(4) # Espace entre le carré et le texte
  625. # 1. Label pour le carré (icône)
  626. icon_lbl = QLabel()
  627. icon_lbl.setPixmap(pix)
  628. icon_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  629. # Important : pour que le clic passe à travers le label vers le bouton
  630. icon_lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  631. # 2. Label pour la valeur numérique
  632. text_lbl = QLabel(f"{step:.4f} mm")
  633. text_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  634. text_lbl_col = self._c('#cccccc', '#333333')
  635. text_lbl.setStyleSheet(
  636. f"color:{text_lbl_col};font-size:10px;"
  637. "border:none;background:transparent;")
  638. text_lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  639. layout.addWidget(icon_lbl)
  640. layout.addWidget(text_lbl)
  641. # On applique le style au bouton vide
  642. btn.setStyleSheet(self._get_btn_style("center"))
  643. btn.setCursor(Qt.CursorShape.PointingHandCursor)
  644. btn.setToolTip(f"{'Col' if is_vertical else 'Blk'} {block_num} — {step:.4f} mm")
  645. def make_save(s):
  646. return lambda: self._save_linestep(s, is_vertical)
  647. btn.clicked.connect(make_save(step))
  648. self._step_btn_row.addWidget(btn)
  649. self._step_buttons.append(btn)
  650. @staticmethod
  651. def _arrow_svg_path(color: str) -> str:
  652. """Retourne le chemin vers un SVG de flèche recolorisé (tempfile)."""
  653. src = SVG_ICONS.get("ARROW_DOWN", "")
  654. if not src or not os.path.isfile(src):
  655. return src.replace("\\", "/")
  656. try:
  657. import re
  658. with open(src, "r", encoding="utf-8") as f:
  659. svg = f.read()
  660. svg = re.sub(r'fill\s*=\s*"[^"]*"', f'fill="{color}"', svg)
  661. svg = re.sub(r'fill\s*:\s*[^;}"]+', f'fill:{color}', svg)
  662. svg = re.sub(r'stroke\s*=\s*"[^"]*"', f'stroke="{color}"', svg)
  663. svg = re.sub(r'stroke\s*:\s*[^;}"]+', f'stroke:{color}', svg)
  664. tmp = tempfile.NamedTemporaryFile(
  665. suffix=f'_arrow_{color.strip("#")}.svg',
  666. delete=False, mode='w', encoding='utf-8')
  667. tmp.write(svg); tmp.close()
  668. return tmp.name.replace("\\", "/")
  669. except Exception:
  670. return src.replace("\\", "/")
  671. def _combo_style(self):
  672. c = self._theme_colors
  673. arrow_path = self._arrow_svg_path(c['arrow_color'])
  674. return (
  675. f"QComboBox{{background-color:{c['bg_entry_alt']};border:1px solid {c['border_light']};"
  676. f"border-radius:5px;padding:3px 30px 3px 10px;color:{c['text']};}}"
  677. f"QComboBox::drop-down{{subcontrol-origin:padding;subcontrol-position:top right;"
  678. f"width:25px;border:none;background:transparent;}}"
  679. f"QComboBox::down-arrow{{image:url({arrow_path});width:12px;height:8px;}}"
  680. f"QComboBox QAbstractItemView{{background-color:{c['bg_entry_alt']};color:{c['text']};"
  681. f"selection-background-color:{c['combo_selection']};border:1px solid {c['border_light']};}}"
  682. )
  683. def _get_btn_style(self, align, padding_top="2px"):
  684. """Génère le style CSS des boutons avec alignement et padding variables."""
  685. c = self._theme_colors
  686. return (
  687. f"QPushButton{{background:{c['bg_card']};color:{c['text_secondary']};font-size:10px;"
  688. f"border:1px solid {c['border_strong']};border-radius:6px;text-align:{align};"
  689. f"padding-top:{padding_top};padding-bottom:5px;}}"
  690. f"QPushButton:hover{{background:#1f538d;border-color:#3a8fd4;color:white;}}"
  691. )
  692. def _make_step_icon(self, step_mm, is_vertical, color="#3a8fd4"):
  693. """Génère une icône miniature représentant les lignes avec une couleur unique."""
  694. from PyQt6.QtGui import QPixmap, QPainter, QColor, QPen
  695. from PyQt6.QtCore import Qt
  696. w, h = 44, 38
  697. pix = QPixmap(w, h)
  698. # On peut mettre transparent ou garder le fond sombre
  699. pix.fill(QColor(self._theme_colors.get('bg_select', '#1a1a1a')))
  700. qp = QPainter(pix)
  701. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  702. # On utilise la couleur passée en argument (ou celle par défaut)
  703. # L'épaisseur 2 rend les traits plus lisibles
  704. pen = QPen(QColor(color), 2)
  705. qp.setPen(pen)
  706. # Dessiner 5 lignes avec l'espacement relatif
  707. n_lines = 5
  708. margin = 6
  709. if is_vertical:
  710. total_w = w - 2 * margin
  711. for k in range(n_lines):
  712. x = margin + int(k * total_w / (n_lines - 1))
  713. qp.drawLine(x, margin, x, h - margin)
  714. else:
  715. total_h = h - 2 * margin
  716. for k in range(n_lines):
  717. y = margin + int(k * total_h / (n_lines - 1))
  718. qp.drawLine(margin, y, w - margin, y)
  719. qp.end()
  720. return pix
  721. def _save_linestep(self, step_mm, is_vertical):
  722. """Sauvegarde la valeur dans hor_linestep ou ver_linestep."""
  723. try:
  724. # Détermination de la clé en fonction du mode
  725. key = "ver_linestep" if is_vertical else "hor_linestep"
  726. # Sauvegarde dans "machine_settings" comme demandé
  727. self.controller.set_item(
  728. "machine_settings", key, round(step_mm, 4)
  729. )
  730. self.controller.save()
  731. mode_str = "Vertical" if is_vertical else "Horizontal"
  732. QMessageBox.information(
  733. self, "Sauvegarde",
  734. f"Pas {mode_str} enregistré : {step_mm:.4f} mm\n(Clé: {key})"
  735. )
  736. except Exception as e:
  737. QMessageBox.critical(self, "Erreur", f"Échec de sauvegarde :\n{e}")
  738. # ── OVERSCAN ──────────────────────────────────────────────────────────
  739. def setup_overscan_params(self):
  740. """Calcule l'overscan minimal selon vitesse et accélération machine."""
  741. self.clear_dynamic_layout()
  742. # ── Champs d'entrée ───────────────────────────────────────────
  743. container = QFrame()
  744. container.setObjectName("ParamsContainer")
  745. container.setStyleSheet(self._container_style())
  746. grid = QGridLayout(container)
  747. grid.setSpacing(12)
  748. # Feedrate
  749. grid.addWidget(QLabel(self.texts.get("feedrate_calc", "Feedrate (mm/min):")), 0, 0)
  750. self.overscan_feed_entry = QLineEdit()
  751. self.overscan_feed_entry.setPlaceholderText("ex: 3000")
  752. current_feed = self.controller.get_item("machine_settings", "feedrate", "3000")
  753. self.overscan_feed_entry.setText(str(current_feed))
  754. grid.addWidget(self.overscan_feed_entry, 0, 1)
  755. # Accélération max
  756. grid.addWidget(QLabel(self.texts.get("max_accel", "Max Acceleration (mm/s²):")), 1, 0)
  757. self.overscan_accel_entry = QLineEdit()
  758. self.overscan_accel_entry.setPlaceholderText("ex: 500")
  759. self.overscan_accel_entry.setText("500")
  760. grid.addWidget(self.overscan_accel_entry, 1, 1)
  761. # Latence laser (pré-remplie depuis la config, peut être négative)
  762. grid.addWidget(QLabel(self.texts.get("latency_calc", "Latency (ms):")), 2, 0)
  763. self.overscan_lat_entry = QLineEdit()
  764. self.overscan_lat_entry.setPlaceholderText("ex: -11.5")
  765. current_lat = self.controller.get_item("machine_settings", "laser_latency", "0")
  766. self.overscan_lat_entry.setText(str(current_lat))
  767. grid.addWidget(self.overscan_lat_entry, 2, 1)
  768. self.dynamic_layout.addWidget(container)
  769. # ── Zone résultat ─────────────────────────────────────────────
  770. calc_frame = QFrame()
  771. calc_frame.setObjectName("CalcContainer")
  772. calc_frame.setStyleSheet(self._calc_style())
  773. calc_vbox = QVBoxLayout(calc_frame)
  774. calc_vbox.setSpacing(10)
  775. title_lbl = QLabel(self.texts.get("overscan_calculator", "Overscan Calculator:"))
  776. title_lbl.setStyleSheet("font-weight:bold;font-size:13px;color:#e67e22;border:none;")
  777. calc_vbox.addWidget(title_lbl)
  778. # TODO : ajouter le SVG explicatif ici quand disponible
  779. # svg_lbl = QLabel()
  780. # svg_lbl.setPixmap(get_svg_pixmap(SVG_ICONS["OVERSCAN"], QSize(300, 120)))
  781. # calc_vbox.addWidget(svg_lbl)
  782. formula_lbl = QLabel(self.texts.get("overscan_formula", "Formula: overscan = v² / (2 × a)"))
  783. formula_lbl.setStyleSheet("font-size:11px;font-style:italic;color:#888;border:none;")
  784. calc_vbox.addWidget(formula_lbl)
  785. # Résultat
  786. res_row = QHBoxLayout()
  787. res_lbl = QLabel(self.texts.get("overscan_result", "Recommended overscan:"))
  788. res_lbl.setStyleSheet("font-size:13px;border:none;")
  789. self.overscan_result_label = QLabel("-- mm")
  790. self.overscan_result_label.setStyleSheet(
  791. "font-weight:bold;font-size:16px;color:#2ecc71;border:none;")
  792. res_row.addWidget(res_lbl)
  793. res_row.addStretch()
  794. res_row.addWidget(self.overscan_result_label)
  795. calc_vbox.addLayout(res_row)
  796. # Premove actuel
  797. current_premove = self.controller.get_item("machine_settings", "premove", "10.0")
  798. self.overscan_current_lbl = QLabel(
  799. f"{self.texts.get('overscan_current', 'Current premove:')} {current_premove} mm")
  800. self.overscan_current_lbl.setStyleSheet("font-size:11px;color:#888;border:none;")
  801. calc_vbox.addWidget(self.overscan_current_lbl)
  802. # Bouton Sauvegarder (caché jusqu'au premier calcul valide)
  803. self.overscan_save_btn = QPushButton(
  804. self.texts.get("overscan_save_btn", "Save as Premove"))
  805. self.overscan_save_btn.setFixedHeight(34)
  806. self.overscan_save_btn.setStyleSheet("""
  807. QPushButton { background-color: #27ae60; color: white; border-radius: 6px;
  808. font-size: 12px; border: none; padding: 0 10px; }
  809. QPushButton:hover { background-color: #1e8449; }
  810. """)
  811. self.overscan_save_btn.hide()
  812. self.overscan_save_btn.clicked.connect(self._save_overscan)
  813. calc_vbox.addWidget(self.overscan_save_btn)
  814. self.dynamic_layout.addWidget(calc_frame)
  815. self.dynamic_layout.addStretch()
  816. # Recalcul automatique à la saisie
  817. self.overscan_feed_entry.textChanged.connect(self._calc_overscan)
  818. self.overscan_accel_entry.textChanged.connect(self._calc_overscan)
  819. self.overscan_lat_entry.textChanged.connect(self._calc_overscan)
  820. self._calc_overscan() # calcul initial avec les valeurs pré-remplies
  821. def _calc_overscan(self):
  822. """Calcule overscan = (v²/(2a) + |latency_mm|) × 1.2"""
  823. try:
  824. feed_mm_min = float(
  825. self.overscan_feed_entry.text().strip().replace(',', '.'))
  826. accel_mm_s2 = float(
  827. self.overscan_accel_entry.text().strip().replace(',', '.'))
  828. if accel_mm_s2 <= 0:
  829. raise ValueError("Accel must be > 0")
  830. # Latence : optionnelle, 0 si vide ou invalide
  831. try:
  832. lat_ms = float(
  833. self.overscan_lat_entry.text().strip().replace(',', '.'))
  834. except (ValueError, AttributeError):
  835. lat_ms = 0.0
  836. v_mm_s = feed_mm_min / 60.0
  837. accel_mm = (v_mm_s ** 2) / (2.0 * accel_mm_s2)
  838. lat_mm = abs(lat_ms) * v_mm_s / 1000.0
  839. raw_mm = accel_mm + lat_mm
  840. # +20% de marge de sécurité
  841. overscan_mm = round(raw_mm * 1.2 + 0.05, 1)
  842. self._last_overscan_mm = overscan_mm
  843. self.overscan_result_label.setText(f"{overscan_mm:.1f} mm")
  844. self.overscan_result_label.setStyleSheet(
  845. "font-weight:bold;font-size:16px;color:#2ecc71;border:none;")
  846. self.overscan_save_btn.show()
  847. except (ValueError, AttributeError):
  848. self._last_overscan_mm = None
  849. if hasattr(self, 'overscan_result_label'):
  850. self.overscan_result_label.setText("-- mm")
  851. self.overscan_result_label.setStyleSheet(
  852. "font-weight:bold;font-size:16px;color:#888;border:none;")
  853. if hasattr(self, 'overscan_save_btn'):
  854. self.overscan_save_btn.hide()
  855. def _save_overscan(self):
  856. """Sauvegarde la valeur calculée dans machine_settings.premove."""
  857. val = getattr(self, '_last_overscan_mm', None)
  858. if val is None:
  859. return
  860. try:
  861. self.controller.set_item("machine_settings", "premove", round(val, 1))
  862. self.controller.save()
  863. # Mettre à jour le label "premove actuel"
  864. if hasattr(self, 'overscan_current_lbl'):
  865. self.overscan_current_lbl.setText(
  866. f"{self.texts.get('overscan_current', 'Current premove:')} {val:.1f} mm")
  867. # Feedback visuel temporaire sur le bouton
  868. orig_text = self.overscan_save_btn.text()
  869. orig_style = self.overscan_save_btn.styleSheet()
  870. saved_text = "✅ " + self.texts.get("overscan_saved", "Saved!")
  871. self.overscan_save_btn.setText(saved_text)
  872. self.overscan_save_btn.setStyleSheet("""
  873. QPushButton { background-color: #1e8449; color: white; border-radius: 6px;
  874. font-size: 12px; border: none; padding: 0 10px; }
  875. """)
  876. QTimer.singleShot(2000, lambda: (
  877. self.overscan_save_btn.setText(orig_text),
  878. self.overscan_save_btn.setStyleSheet(orig_style)
  879. ))
  880. except Exception as e:
  881. QMessageBox.critical(
  882. self, self.texts.get("error_title", "Error"), str(e))
  883. # ── FIN OVERSCAN ───────────────────────────────────────────────────────
  884. def update_mm_display(self):
  885. """Calcule et affiche le décalage en mm depuis speed + latency."""
  886. try:
  887. speed = float(self.speed_entry.text().strip() or 0)
  888. latency = float(self.latency_entry.text().strip() or 0)
  889. offset = (speed * latency) / 60000.0
  890. self.mm_info_label.setText(f"= {offset:.3f} mm")
  891. color = "#e74c3c" if abs(offset) > 2.0 else "#1f538d"
  892. self.mm_info_label.setStyleSheet(f"color: {color}; font-weight: bold; border: none;")
  893. except (ValueError, AttributeError):
  894. if hasattr(self, 'mm_info_label'):
  895. self.mm_info_label.setText("= --- mm")
  896. self.mm_info_label.setStyleSheet("color: gray; font-weight: bold; border: none;")
  897. def update_latency_calculation(self):
  898. """Calcule la latence depuis offset mesuré + vitesse."""
  899. try:
  900. speed = float(self.speed_entry.text().strip() or 0)
  901. dist_mm = float(self.measured_mm_entry.text().strip() or 0)
  902. if speed > 0 and dist_mm != 0:
  903. calc_ms = (dist_mm * 60000.0) / speed
  904. self._last_calc_ms = calc_ms
  905. if dist_mm > 0:
  906. status = f"(+) {calc_ms:.2f} ms"
  907. hint = self.texts.get("latency_hint_late", "Late firing: Increase latency (+)")
  908. color = "#e67e22"
  909. else:
  910. status = f"(-) {abs(calc_ms):.2f} ms"
  911. hint = self.texts.get("latency_hint_early", "Early firing: Decrease latency (-)")
  912. color = "#3498db"
  913. self.calc_result_label.setText(f"{self.texts.get('latency_results', 'Result:')} {status}")
  914. self.calc_result_label.setStyleSheet(f"font-weight: bold; color: {color}; border: none;")
  915. self.calc_hint_label.setText(f"ℹ️ {hint}")
  916. elif dist_mm == 0:
  917. self._last_calc_ms = 0.0
  918. self.calc_result_label.setText(f"{self.texts.get('latency_results', 'Result:')} 0.00 ms")
  919. self.calc_result_label.setStyleSheet("font-weight: bold; color: gray; border: none;")
  920. self.calc_hint_label.setText(self.texts.get("latency_perfect", "Perfectly aligned"))
  921. else:
  922. self.calc_result_label.setText(f"{self.texts.get('latency_results', 'Result:')} -- ms")
  923. self.calc_result_label.setStyleSheet("font-weight: bold; color: gray; border: none;")
  924. self.calc_hint_label.setText("")
  925. except (ValueError, AttributeError):
  926. if hasattr(self, 'calc_result_label'):
  927. self.calc_result_label.setText("Result: Error")
  928. self.calc_result_label.setStyleSheet("font-weight: bold; color: #e74c3c; border: none;")
  929. def apply_calculated_latency(self):
  930. """Injecte la valeur calculée dans le champ latency et sauvegarde."""
  931. val_ms = getattr(self, "_last_calc_ms", None)
  932. if val_ms is not None:
  933. self.latency_entry.setText(f"{val_ms:.2f}")
  934. self.update_mm_display()
  935. # On utilise la même API que settings_view_qt pour être sûr
  936. # d'écrire dans la même couche (set_section + save)
  937. try:
  938. current = self.controller.get_section("machine_settings") or {}
  939. current["laser_latency"] = round(val_ms, 3)
  940. self.controller.set_section("machine_settings", current)
  941. self.controller.save()
  942. except Exception as e:
  943. print(f"[calibration] save laser_latency failed: {e}")
  944. def validate_and_generate_latency(self):
  945. """Valide les champs, génère le G-Code de test de latence et propose l'enregistrement."""
  946. power_raw = self.power_entry.text().strip() if hasattr(self, 'power_entry') else ""
  947. if not power_raw:
  948. QMessageBox.warning(self, "Missing field", "⚠️ Please enter a power value.")
  949. return
  950. try:
  951. cfg = self.controller.config_manager if hasattr(self.controller, 'config_manager') else None
  952. cmd_mode = cfg.get_item("machine_settings", "cmd_mode", "") if cfg else ""
  953. use_s_mode = "S (" in cmd_mode
  954. settings = {
  955. "power": float(self.power_entry.text().strip()),
  956. "max_value": float(cfg.get_item("machine_settings", "ctrl_max", 255)) if cfg else 255,
  957. "feedrate": float(self.speed_entry.text().strip()),
  958. "latency": float(self.latency_entry.text().strip() or 0),
  959. "e_num": int(cfg.get_item("machine_settings", "m67_e_num", 0)) if cfg else 0,
  960. "use_s_mode": use_s_mode,
  961. "header": cfg.get_item("gcode_options", "header", "") if cfg else "",
  962. "footer": cfg.get_item("gcode_options", "footer", "M30") if cfg else "M30",
  963. }
  964. gcode_content = self.calibrate_engine.generate_latency_calibration(settings)
  965. lat_val = settings["latency"]
  966. file_path, _ = get_save_file(
  967. self,
  968. self.texts.get("save_dialog_title", "Save Calibration G-Code"),
  969. f"latency_test_{lat_val}ms.nc",
  970. "G-Code (*.nc *.gcode);;All files (*)"
  971. )
  972. if file_path:
  973. with open(file_path, "w", encoding="utf-8") as f:
  974. f.write(gcode_content)
  975. # Feedback visuel temporaire sur le bouton
  976. if self.action_btn:
  977. orig_text = self.action_btn.text()
  978. orig_style = self.action_btn.styleSheet()
  979. self.action_btn.setText("✅ G-Code Saved!")
  980. self.action_btn.setStyleSheet(
  981. "background-color: #27AE60; font-weight: bold; border-radius: 10px;")
  982. QTimer.singleShot(2000, lambda: (
  983. self.action_btn.setText(orig_text),
  984. self.action_btn.setStyleSheet(orig_style)
  985. ))
  986. except ValueError:
  987. QMessageBox.warning(self, "Invalid input", "⚠️ Please enter valid numbers in all fields.")
  988. except Exception as e:
  989. QMessageBox.critical(self, "Error", f"Generation failed:\n{e}")
  990. def validate_and_generate_linestep(self):
  991. """Valide les champs pour le LineStep et génère le G-Code multi-blocs."""
  992. if not self.power_entry.text().strip() or not self.min_step_entry.text().strip():
  993. QMessageBox.warning(self, "Missing field", "⚠️ Please enter Power and Minimum Step values.")
  994. return
  995. try:
  996. cfg = self.controller.config_manager if hasattr(self.controller, 'config_manager') else None
  997. cmd_mode = cfg.get_item("machine_settings", "cmd_mode", "") if cfg else ""
  998. use_s_mode = "S (" in cmd_mode
  999. # 2. Préparation des settings complets
  1000. settings = {
  1001. "power": float(self.power_entry.text().strip()),
  1002. "max_value": float(cfg.get_item("machine_settings", "ctrl_max", 1000)) if cfg else 1000,
  1003. "feedrate": float(self.speed_entry.text().strip()),
  1004. "min_step": float(self.min_step_entry.text().strip().replace(',', '.')),
  1005. "multiplier": float(self.multiplier_entry.text().strip().replace(',', '.')),
  1006. "latency": float(self.latency_entry.text().strip().replace(',', '.') or 0),
  1007. "scan_mode": self.scan_mode_combo.currentText(),
  1008. "e_num": int(cfg.get_item("machine_settings", "m67_e_num", 0)) if cfg else 0,
  1009. "use_s_mode": use_s_mode,
  1010. "firing_mode": cfg.get_item("machine_settings", "firing_mode", "M3/M5") if cfg else "M3/M5",
  1011. }
  1012. # 3. Appel du moteur (Version GRID/Multi-blocs)
  1013. gcode_content = self.calibrate_engine.generate_linestep_calibration(settings)
  1014. # 4. Dialogue de sauvegarde
  1015. mode_name = settings["scan_mode"].split()[0].lower()
  1016. file_path, _ = get_save_file(
  1017. self, "Save LineStep Test", f"linestep_grid_{mode_name}.nc", "G-Code (*.nc *.gcode)"
  1018. )
  1019. if file_path:
  1020. with open(file_path, "w", encoding="utf-8") as f:
  1021. f.write(gcode_content)
  1022. # Feedback visuel
  1023. if self.action_btn:
  1024. orig_text = self.action_btn.text()
  1025. orig_style = self.action_btn.styleSheet()
  1026. self.action_btn.setText("Grid Test Saved!")
  1027. self.action_btn.setStyleSheet("background-color: #27AE60; font-weight: bold; border-radius: 10px; color: white;")
  1028. QTimer.singleShot(2000, lambda: (self.action_btn.setText(orig_text), self.action_btn.setStyleSheet(orig_style)))
  1029. except ValueError:
  1030. QMessageBox.warning(self, "Invalid input", "⚠️ Please enter valid numbers.")
  1031. except Exception as e:
  1032. QMessageBox.critical(self, "Error", f"Generation failed:\n{e}")
  1033. def create_switch(self, layout, label_text, key, row):
  1034. """Ajoute une ligne avec un label et Switch animé"""
  1035. container = QWidget()
  1036. h_layout = QHBoxLayout(container)
  1037. h_layout.setContentsMargins(0, 5, 0, 5)
  1038. label = QLabel(label_text)
  1039. label.setStyleSheet("font-size: 14px; color: #DCE4EE;")
  1040. switch_btn = Switch()
  1041. # Si tu as besoin de régler l'état initial depuis la config
  1042. initial_state = self.config_manager.get_item("calibration", key, False)
  1043. switch_btn.setChecked(initial_state)
  1044. # Connexion au signal (ton switch émet 'toggled' comme une QCheckBox)
  1045. switch_btn.toggled.connect(lambda checked: self.on_switch_changed(key, checked))
  1046. h_layout.addWidget(label)
  1047. h_layout.addStretch() # Pousse le switch vers la droite
  1048. h_layout.addWidget(switch_btn)
  1049. # On l'ajoute à la grille de la zone dynamique
  1050. layout.addWidget(container, row, 0, 1, 2) # Prend 2 colonnes
  1051. # On stocke la référence pour pouvoir lire la valeur plus tard
  1052. self.controls[key] = {"switch": switch_btn}
  1053. def _apply_language(self, lang: str, translations: dict):
  1054. """Appelée par update_ui_language de main_window avec lang et translations déjà à jour."""
  1055. from core.translations import TRANSLATIONS as _TR
  1056. repo = translations if translations else _TR.get(lang, _TR["English"])
  1057. self.texts = repo.get("calibration", {})
  1058. self.common_texts = repo.get("common", {})
  1059. # Déléguer la mise à jour des widgets à retranslate_ui
  1060. # (sans rappeler load_texts qui relirait la config)
  1061. self._retranslate_widgets()
  1062. def retranslate_ui(self):
  1063. """Appelée lors d'un changement de langue — recharge les textes puis met à jour les widgets."""
  1064. self.load_texts()
  1065. self._retranslate_widgets()
  1066. def _retranslate_widgets(self):
  1067. """Met à jour tous les widgets avec self.texts déjà chargé."""
  1068. if hasattr(self, 'sidebar_title_label'):
  1069. self.sidebar_title_label.setText(self.texts.get("sidebar_title", "Tests"))
  1070. if hasattr(self, 'action_btn') and self.action_btn:
  1071. self.action_btn.setText(self.texts.get("btn_prepare", "Prepare"))
  1072. # 3. Mise à jour des cartes de test dans la sidebar
  1073. for card in self.test_cards:
  1074. # Récupération de l'ID stocké via setProperty
  1075. t_id = card.property("test_id")
  1076. # Recherche des labels enfants par leur ObjectName
  1077. title_label = card.findChild(QLabel, "card_title")
  1078. desc_label = card.findChild(QLabel, "card_desc")
  1079. if title_label:
  1080. title_label.setText(self.texts.get(f"{t_id}_title", "Sans titre"))
  1081. if desc_label:
  1082. desc_label.setText(self.texts.get(f"{t_id}_short", ""))
  1083. # 4. Rafraîchissement de la zone de détails (partie droite)
  1084. if self.current_test_id:
  1085. # Traduction du titre et de la description longue
  1086. self.detail_title.setText(self.texts.get(f"{self.current_test_id}_title", ""))
  1087. self.detail_desc.setText(self.texts.get(f"{self.current_test_id}_long", ""))
  1088. # Relancer on_test_selected pour reconstruire params + bouton traduit
  1089. test_info = next((t for t in self.tests_data if t["id"] == self.current_test_id), {})
  1090. if test_info:
  1091. self.on_test_selected(test_info)
  1092. else:
  1093. # Retour à l'état par défaut si aucun test n'est sélectionné
  1094. self.detail_title.setText(self.texts.get("default_title", "Select a test"))
  1095. self.detail_desc.setText(self.texts.get("default_desc", ""))
  1096. def reset_view(self):
  1097. """Remet la vue à zéro"""
  1098. self.current_test_id = None
  1099. # Décocher les boutons
  1100. self.button_group.setExclusive(False)
  1101. for card in self.test_cards:
  1102. card.setChecked(False)
  1103. self.button_group.setExclusive(True)
  1104. # Remettre l'en-tête par défaut
  1105. self.detail_title.setText(self.texts.get("default_title", "Select a test"))
  1106. self.detail_desc.setText(self.texts.get("default_desc", ""))
  1107. self.preview_image_label.clear()
  1108. # Vider les paramètres (détruit aussi le bouton action)
  1109. self.clear_dynamic_layout()
  1110. def showEvent(self, event):
  1111. """
  1112. Déclenché automatiquement par Qt chaque fois que
  1113. la vue de calibration devient visible.
  1114. """
  1115. super().showEvent(event)
  1116. self.reset_view()
  1117. def get_txt(self, key, default=""):
  1118. """Cherche d'abord dans calibration, puis dans common"""
  1119. return self.texts.get(key, self.common_texts.get(key, default))
  1120. # # Utilisation ultra simple :
  1121. # self.btn_prepare.setText(self.get_txt("btn_prepare"))
  1122. # self.label_f.setText(self.get_txt("feedrate"))