| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877 |
- # -*- coding: utf-8 -*-
- """
- A.L.I.G. - CheckerViewQt
- Lecteur / visualiseur de G-Code existant.
- Ouvre un fichier .nc/.gcode, le parse et lance la simulation de trajectoire.
- Aucune génération d'image — uniquement lecture + rendu.
- """
- import os
- import time
- import numpy as np
- from PyQt6.QtWidgets import (
- QWidget, QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QProgressBar, QSizePolicy, QMessageBox, QPlainTextEdit,
- QDoubleSpinBox,
- )
- from PyQt6.QtCore import (
- Qt, QTimer, QThread, pyqtSignal, QRect, QRectF, QPointF, QLineF, QSize
- )
- from PyQt6.QtGui import (
- QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFont,
- QLinearGradient, QPainterPath, QPolygonF, QTransform, QIcon
- )
- from engine.gcode_parser import GCodeParser
- from core.utils import truncate_path
- from core.translations import TRANSLATIONS
- from utils.paths import SVG_ICONS
- from gui.utils_qt import get_svg_pixmap
- from gui.utils_qt import get_open_file
- from gui.switch import Switch
- # ══════════════════════════════════════════════════════════════════════════════
- # WORKER : génération G-Code hors thread UI
- # ══════════════════════════════════════════════════════════════════════════════
- # ══════════════════════════════════════════════════════════════════════════════
- # RENDERER — logique de rasterisation batch (100 % NumPy + une passe QPainter)
- # ══════════════════════════════════════════════════════════════════════════════
- class _Renderer:
- """
- Stratégie haute perf :
- 1. _compute_segments() vectorise TOUS les segments éligibles en une fois
- via NumPy → tableau de QPolygonF groupés par couleur.
- 2. _rasterize() ouvre UN QPainter sur le QImage et dessine tous les
- polygones en une seule boucle Python sans calcul dans la boucle.
- 3. Résultat copié dans display_data uint8.
- """
- def __init__(self, rect_w, rect_h, scale, total_px_h,
- min_x, min_y, laser_width_px, ctrl_max,
- pwr_min=0.0, pwr_max=None, l_step_mm=None, draw_step_mm=None):
- self.rect_w = rect_w
- self.rect_h = rect_h
- self.scale = scale
- self.total_px_h = total_px_h
- self.min_x = min_x
- self.min_y = min_y
- self.laser_width_px = max(1, int(round(float(laser_width_px))))
- self.ctrl_max = float(ctrl_max)
- self.pwr_min = float(pwr_min)
- self.pwr_max = float(pwr_max) if pwr_max is not None else self.ctrl_max
- # l_step_mm : espacement réel inter-lignes → snap Y (pas de gaps)
- self.l_step_mm = float(l_step_mm) if l_step_mm else None
- self.l_step_px = float(l_step_mm) * scale if l_step_mm else None
- # draw_step_mm : épaisseur du trait laser (valeur utilisateur)
- # Si None, utilise l_step_mm (comportement identique à avant)
- _draw = draw_step_mm if draw_step_mm else l_step_mm
- self.draw_step_px = float(_draw) * scale if _draw else None
- self.display_data = np.full((rect_h, rect_w), 255, dtype=np.uint8)
- self._qi_ref = None
- def reset(self):
- self.display_data.fill(255)
- # ─── Calcul des segments à rasteriser ───────────────────────────────────
- def _compute_segments(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
- """
- Retourne {gray: [(x1,y1,x2,y2)...]} en coordonnées pixels flottantes.
- Les lignes raster horizontales sont stabilisées par index de ligne.
- """
- if pts_arr is None or len(pts_arr) < 2:
- return None
- if start >= end or start >= len(pts_arr) - 1:
- return None
- safe_end = min(end, len(pts_arr) - 1)
- p1 = pts_arr[start:safe_end].copy()
- p2 = pts_arr[start+1:safe_end+1].copy()
- if len(p1) == 0:
- return None
- # ── filtre puissance
- laser_threshold = max(self.pwr_min, self.ctrl_max * 0.001)
- mask = p2[:,2] > laser_threshold
- if not mask.any():
- return None
- p1 = p1[mask]
- p2 = p2[mask]
- x1 = p1[:,0].copy()
- y1_mm = p1[:,1].copy()
- x2 = p2[:,0].copy()
- y2_mm = p2[:,1].copy()
- # ── correction latence
- if use_lat and lat_mm != 0:
- if scan_axis == 'X':
- d = x2 - x1
- x1[d>1e-6] += lat_mm
- x2[d>1e-6] += lat_mm
- x1[d<-1e-6] -= lat_mm
- x2[d<-1e-6] -= lat_mm
- else:
- d = y2_mm - y1_mm
- y1_mm[d>1e-6] += lat_mm
- y2_mm[d>1e-6] += lat_mm
- y1_mm[d<-1e-6] -= lat_mm
- y2_mm[d<-1e-6] -= lat_mm
- # ── conversion mm → pixels
- sc = self.scale
- mnx = self.min_x
- mny = self.min_y
- th = self.total_px_h
- fx1 = (x1 - mnx) * sc
- fy1 = th - (y1_mm - mny) * sc
- fx2 = (x2 - mnx) * sc
- fy2 = th - (y2_mm - mny) * sc
- # ── rejet hors buffer
- lw = self.laser_width_px
- bw = float(self.rect_w)
- bh = float(self.rect_h)
- ok = (
- (np.maximum(fx1,fx2) >= -lw) &
- (np.minimum(fx1,fx2) < bw+lw) &
- (np.maximum(fy1,fy2) >= -lw) &
- (np.minimum(fy1,fy2) < bh+lw)
- )
- if not ok.any():
- return None
- fx1=fx1[ok]; fy1=fy1[ok]
- fx2=fx2[ok]; fy2=fy2[ok]
- pwr=p2[:,2][ok]
- y1_mm=y1_mm[ok]; y2_mm=y2_mm[ok]
- # ── filtre longueur : ne rejeter que les segments strictement nuls
- dx = fx2 - fx1
- dy = fy2 - fy1
- vis = (dx*dx + dy*dy) > 0.0
- if not vis.any():
- return None
- fx1=fx1[vis]; fy1=fy1[vis]
- fx2=fx2[vis]; fy2=fy2[vis]
- pwr=pwr[vis]
- y1_mm=y1_mm[vis]; y2_mm=y2_mm[vis]
- # ── couleur
- pwr_range = max(self.pwr_max - self.pwr_min,1.0)
- t = np.clip((pwr - self.pwr_min)/pwr_range,0.0,1.0)
- gray = (200.0*(1.0-t)).astype(np.uint8)
- # ─────────────────────────────
- # SNAP RASTER HORIZONTAL STABLE
- # ─────────────────────────────
- is_horiz = np.abs(fx2-fx1) >= np.abs(fy2-fy1)
- if self.l_step_mm and self.l_step_px:
- step_mm = self.l_step_mm
- step_px = self.l_step_px
- yc_mm = (y1_mm + y2_mm) * 0.5
- # index stable (pas de round)
- row_idx = np.floor((yc_mm - mny)/step_mm + 0.5).astype(np.int32)
- row_idx = np.maximum(row_idx,0)
- # centre exact de ligne
- fy_center = th - (row_idx + 0.5) * step_px
- fy1 = np.where(is_horiz, fy_center, fy1)
- fy2 = np.where(is_horiz, fy_center, fy2)
- else:
- fy_center = np.floor((fy1+fy2)*0.5)+0.5
- fy1 = np.where(is_horiz,fy_center,fy1)
- fy2 = np.where(is_horiz,fy_center,fy2)
- # ── snap X pour segments verticaux
- fx_center = np.floor((fx1+fx2)*0.5)+0.5
- fx1 = np.where(~is_horiz,fx_center,fx1)
- fx2 = np.where(~is_horiz,fx_center,fx2)
- # ── regroupement par gris
- result={}
- for i in range(len(gray)):
- c=int(gray[i])
- if c not in result:
- result[c]=[]
- result[c].append((fx1[i],fy1[i],fx2[i],fy2[i]))
- return result
- # ─── Rasterisation via fillRect (pixel-perfect) ──────────────────────────
- def _rasterize(self, segs_by_color):
- """Dessine les segments sous forme de rectangles pixel-parfaits."""
- if not segs_by_color:
- return
- h, w = self.display_data.shape
- # draw_step_px : épaisseur du trait (valeur utilisateur)
- # l_step_px : espacement inter-lignes (snap Y) — peut être différent
- step = self.draw_step_px if self.draw_step_px else (self.l_step_px if self.l_step_px else float(self.laser_width_px))
- half = step / 2.0
- qi = QImage(w, h, QImage.Format.Format_Grayscale8)
- qi.fill(QColor(255, 255, 255))
- qp = QPainter(qi)
- qp.setRenderHint(QPainter.RenderHint.Antialiasing, False)
- qp.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
- qp.setPen(Qt.PenStyle.NoPen)
- for c, segs in segs_by_color.items():
- qp.setBrush(QBrush(QColor(c, c, c)))
- for (x1, y1, x2, y2) in segs:
- # segment horizontal
- if abs(y2 - y1) < abs(x2 - x1):
- left = int(np.floor(min(x1, x2)))
- right = int(np.ceil(max(x1, x2)))
- top = int(np.floor(y1 - half))
- qp.fillRect(
- left,
- top,
- max(1, right - left),
- int(round(step)),
- QColor(c, c, c)
- )
- # segment vertical
- else:
- top = int(np.floor(min(y1, y2)))
- bottom = int(np.ceil(max(y1, y2)))
- left = int(np.floor(x1 - half))
- qp.fillRect(
- left,
- top,
- int(round(step)),
- max(1, bottom - top),
- QColor(c, c, c)
- )
- qp.end()
- bpl = qi.bytesPerLine()
- ptr = qi.bits()
- ptr.setsize(bpl * h)
- new_arr = np.frombuffer(ptr, dtype=np.uint8).reshape(h, bpl)[:, :w]
- np.minimum(self.display_data, new_arr, out=self.display_data)
- self._qi_ref = qi
- # ─── API publique ────────────────────────────────────────────────────────
- def redraw_range(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
- """Repart d'un fond blanc et dessine [start, end)."""
- self.display_data.fill(255)
- polys = self._compute_segments(pts_arr, start, end, use_lat, lat_mm, scan_axis)
- self._rasterize(polys)
- def draw_incremental(self, pts_arr, start, end, use_lat, lat_mm, scan_axis):
- """Ajoute les segments [start, end) sur l'état existant (animation)."""
- polys = self._compute_segments(pts_arr, start, end, use_lat, lat_mm, scan_axis)
- self._rasterize(polys)
- # ══════════════════════════════════════════════════════════════════════════════
- # CANVAS DE SIMULATION — rendu + zoom/pan
- # ══════════════════════════════════════════════════════════════════════════════
- class _SimCanvas(QWidget):
- """
- Affiche display_data (QPixmap mis à jour si dirty) + couche vectorielle
- (grille, laser). Zoom molette, pan clic-gauche.
- """
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
- self.setStyleSheet('background:#050505;')
- self._bg_color = '#050505'
- self.setSizePolicy(QSizePolicy.Policy.Expanding,
- QSizePolicy.Policy.Expanding)
- self.setMouseTracking(True)
- self._pixmap = None
- self._dirty = False
- self._img_buf = None
- self._x0 = self._y0 = 0.0
- self._pw = self._ph = 0.0
- self._sc = 1.0
- self._mnx = self._mxx = 0.0
- self._mny = self._mxy = 0.0
- self._lx = self._ly = 0.0
- self._zoom = 1.0
- self._pan = QPointF(0, 0)
- self._p0 = None
- self._p0_pan = None
- self._mouse_mm = None
- self._overlay_h = 150
- self._placeholder_text = 'Open a G-Code file to start'
- # ─── API ─────────────────────────────
- def setup(self, img_buf, x0, y0, pw, ph, sc, mnx, mxx, mny, mxy, l_step=0.1, overlay_h=150):
- self._img_buf = img_buf
- self._x0, self._y0 = x0, y0
- self._pw, self._ph = pw, ph
- self._sc = sc
- self._mnx, self._mxx = mnx, mxx
- self._mny, self._mxy = mny, mxy
- self._l_step = l_step
- self._overlay_h = overlay_h
- self._rebuild()
- self._dirty = False
- # Fit-to-view automatique après setup
- self.reset_view()
- def notify_dirty(self):
- self._dirty = True
- self.update()
- def set_theme(self, bg: str):
- self._bg_color = bg
- self.setStyleSheet(f'background:{bg};')
- self.update()
- def set_placeholder(self, text: str):
- self._placeholder_text = text
- if self._img_buf is None:
- self.update()
- def set_laser(self, sx, sy):
- self._lx, self._ly = sx, sy
- self.update()
- def reset_view(self):
- """Fit-to-view : zoom et pan pour que l'image remplisse la zone utile."""
- if self._pw <= 0 or self._ph <= 0:
- self._zoom = 1.0
- self._pan = QPointF(0, 0)
- self.update()
- return
- cw, ch = self.width(), self.height()
- if cw <= 1 or ch <= 1:
- return
- # Zone utile : toute la largeur, hauteur sans overlay (stockée dans _overlay_h)
- overlay_h = getattr(self, '_overlay_h', 150)
- usable_w = cw
- usable_h = max(ch - overlay_h, int(ch * 0.5))
- margin = 12 # px de marge autour de l'image
- # Zoom pour que l'image tienne dans la zone utile avec marge
- zoom_x = (usable_w - 2 * margin) / self._pw
- zoom_y = (usable_h - 2 * margin) / self._ph
- self._zoom = min(zoom_x, zoom_y)
- # Pan pour centrer l'image dans la zone utile
- img_screen_w = self._pw * self._zoom
- img_screen_h = self._ph * self._zoom
- pan_x = (usable_w - img_screen_w) / 2.0 - self._x0 * self._zoom
- pan_y = margin - self._y0 * self._zoom # calé en haut avec marge
- self._pan = QPointF(pan_x, pan_y)
- self.update()
- # ─── Rendu ───────────────────────────
- def _rebuild(self):
- if self._img_buf is None:
- self._pixmap = None; return
- h, w = self._img_buf.shape
- qi = QImage(self._img_buf.tobytes(), w, h, w,
- QImage.Format.Format_Grayscale8)
- self._pixmap = QPixmap.fromImage(qi)
- def paintEvent(self, _):
- if self._dirty:
- self._rebuild()
- self._dirty = False
- qp = QPainter(self)
- qp.setRenderHint(QPainter.RenderHint.Antialiasing, False)
- w, h = self.width(), self.height()
- qp.fillRect(0, 0, w, h, QColor(self._bg_color))
- if self._img_buf is None:
- qp.setPen(QColor('#888888'))
- qp.setFont(QFont('Arial', 13))
- qp.drawText(QRect(0, 0, w, h),
- Qt.AlignmentFlag.AlignCenter, self._placeholder_text)
- qp.end(); return
- # Zoom / pan
- t = QTransform()
- t.translate(self._pan.x(), self._pan.y())
- t.scale(self._zoom, self._zoom)
- qp.setTransform(t)
- # Fond blanc
- qp.fillRect(QRectF(self._x0, self._y0, self._pw, self._ph),
- QColor('white'))
- # Image simulation
- if self._pixmap:
- qp.drawPixmap(int(self._x0), int(self._y0), self._pixmap)
- # Grille
- pen_g = QPen(QColor(180, 180, 180, 140), 0.5, Qt.PenStyle.DashLine)
- pen_g.setDashPattern([4, 6])
- qp.setFont(QFont('Arial', 7))
- step = 10
- sx0 = int(np.ceil(self._mnx / step) * step)
- for mx in range(sx0, int(self._mxx) + 1, step):
- sx = self._x0 + (mx - self._mnx) * self._sc
- qp.setPen(pen_g)
- qp.drawLine(QLineF(sx, self._y0, sx, self._y0 + self._ph))
- qp.setPen(QColor('#777'))
- qp.drawText(QRectF(sx-15, self._y0+self._ph+1, 30, 13),
- Qt.AlignmentFlag.AlignCenter, str(mx))
- sy0 = int(np.ceil(self._mny / step) * step)
- for my in range(sy0, int(self._mxy) + 1, step):
- sy = self._y0 + self._ph - (my - self._mny) * self._sc
- qp.setPen(pen_g)
- qp.drawLine(QLineF(self._x0, sy, self._x0 + self._pw, sy))
- qp.setPen(QColor('#777'))
- qp.drawText(QRectF(self._x0-33, sy-7, 30, 13),
- Qt.AlignmentFlag.AlignRight |
- Qt.AlignmentFlag.AlignVCenter, str(my))
- # Laser
- lx, ly = self._lx, self._ly
- qp.setRenderHint(QPainter.RenderHint.Antialiasing, True)
- qp.setPen(QPen(QColor('#3385ff'), 1))
- qp.setBrush(QBrush(QColor(26, 117, 255, 100)))
- qp.drawEllipse(QPointF(lx, ly), 9, 9)
- qp.setPen(QPen(QColor('white'), 1))
- qp.setBrush(QBrush(QColor('#00ffff')))
- qp.drawEllipse(QPointF(lx, ly), 4, 4)
- # Coordonnées souris
- if self._mouse_mm:
- qp.resetTransform()
- qp.setPen(QColor('#666'))
- qp.setFont(QFont('Consolas', 9))
- mx_mm, my_mm = self._mouse_mm
- qp.drawText(QRect(6, h-18, 220, 15),
- Qt.AlignmentFlag.AlignLeft,
- f'X={mx_mm:.2f} Y={my_mm:.2f} mm')
- qp.end()
- # ─── Zoom / Pan ──────────────────────
- def wheelEvent(self, e):
- if self._img_buf is None:
- return
- factor = 1.15 if e.angleDelta().y() > 0 else (1.0 / 1.15)
- new_zoom = max(0.05, min(200.0, self._zoom * factor))
- pos = e.position()
- cx, cy = pos.x(), pos.y()
- wx = (cx - self._pan.x()) / self._zoom
- wy = (cy - self._pan.y()) / self._zoom
- self._zoom = new_zoom
- self._pan = QPointF(cx - wx * self._zoom, cy - wy * self._zoom)
- self.update()
- def mousePressEvent(self, e):
- if e.button() in (Qt.MouseButton.LeftButton,
- Qt.MouseButton.MiddleButton):
- self._p0 = e.pos()
- self._p0_pan = QPointF(self._pan)
- self.setCursor(Qt.CursorShape.ClosedHandCursor)
- def mouseMoveEvent(self, e):
- if self._p0 is not None:
- d = e.pos() - self._p0
- self._pan = self._p0_pan + QPointF(d.x(), d.y())
- self.update()
- pos = e.position()
- cx, cy = pos.x(), pos.y()
- if self._sc > 0 and self._ph > 0:
- ix = (cx - self._pan.x()) / self._zoom - self._x0
- iy = (cy - self._pan.y()) / self._zoom - self._y0
- self._mouse_mm = (ix/self._sc + self._mnx,
- self._mny + (self._ph - iy)/self._sc)
- self.update()
- def mouseReleaseEvent(self, e):
- self._p0 = None
- self.setCursor(Qt.CursorShape.ArrowCursor)
- # ══════════════════════════════════════════════════════════════════════════════
- # VUE PRINCIPALE
- # ══════════════════════════════════════════════════════════════════════════════
- # ══════════════════════════════════════════════════════════════════════════════
- # WORKER : parsing G-Code hors thread UI
- # ══════════════════════════════════════════════════════════════════════════════
- class _ParseWorker(QThread):
- done = pyqtSignal(dict)
- error = pyqtSignal(str)
- def __init__(self, gcode: str):
- super().__init__()
- self.gcode = gcode
- def run(self):
- try:
- parser = GCodeParser({})
- pts, dur, lim = parser.parse(self.gcode)
- if lim is not None and not all(abs(v) < 1e-9 for v in lim):
- bx0, bx1, by0, by1 = lim
- else:
- if pts is not None and len(pts):
- bx0, bx1 = float(pts[:,0].min()), float(pts[:,0].max())
- by0, by1 = float(pts[:,1].min()), float(pts[:,1].max())
- else:
- bx0 = bx1 = by0 = by1 = 0.0
- # Timestamps cumulés
- if pts is not None and len(pts) > 1:
- deltas = np.diff(pts[:, :2], axis=0)
- distances = np.hypot(deltas[:, 0], deltas[:, 1])
- rates = pts[1:, 4] / 60.0
- # Feedrate moyen (col 4 avant écrasement) — pour la latence
- feedrate_mmmin = float(np.median(pts[pts[:, 4] > 0, 4])) if (pts[:, 4] > 0).any() else 3000.0
- times = np.divide(distances, rates,
- out=np.zeros_like(distances),
- where=rates > 0)
- pts[0, 4] = 0.0
- pts[1:, 4] = np.cumsum(times)
- total_dur = float(pts[-1, 4])
- else:
- total_dur = 0.0
- feedrate_mmmin = 3000.0
- self.done.emit({
- 'gcode': self.gcode,
- 'pts': pts,
- 'total_dur': total_dur,
- 'bounds': (bx0, bx1, by0, by1),
- 'feedrate_mmmin': feedrate_mmmin,
- })
- except Exception as e:
- import traceback; traceback.print_exc()
- self.error.emit(str(e))
- class CheckerViewQt(QWidget):
- def __init__(self, parent, controller, return_view='dashboard'):
- super().__init__(parent)
- self.controller = controller
- self.return_view = return_view
- lang = controller.config_manager.get_item('machine_settings', 'language')
- if not lang or lang not in TRANSLATIONS:
- lang = 'English'
- self.t = TRANSLATIONS[lang].get('simulation', {})
- # ── état ──────────────────────────────────────────────────
- self.final_gcode = ''
- self.framing_gcode = ''
- self.full_metadata = {}
- self.points_list = None
- self.framing_end = 0
- self.total_sec = 0.0
- self.latence_mm = 0.0
- self.latence_enabled = False
- self._loaded_path = ''
- # ── animation ─────────────────────────────────────────────
- self.sim_running = False
- self.current_idx = 0
- self.current_sim_time = 0.0
- self.last_frame_time = 0.0
- self.sim_speed = 1.0
- self._last_drawn_idx = -1
- self._anim_timer = QTimer(self)
- self._anim_timer.setInterval(16)
- self._anim_timer.timeout.connect(self._tick)
- # ── renderer ──────────────────────────────────────────────
- self._renderer: _Renderer | None = None
- self._px_w = self._px_h = 0.0
- self._x0 = self._y0 = 0.0
- self._scale = 1.0
- self._mnx = self._mxx = 0.0
- self._mny = self._mxy = 0.0
- self.setStyleSheet('background:#2b2b2b; color:white;')
- self._build_ui()
- # ══════════════════════════════════════════════════════════════
- # CONSTRUCTION UI
- # ══════════════════════════════════════════════════════════════
- def _build_ui(self):
- root = QHBoxLayout(self)
- root.setContentsMargins(2, 2, 2, 2)
- root.setSpacing(2)
- root.addWidget(self._make_left_panel())
- root.addWidget(self._make_right_panel(), stretch=1)
- # ─── Panneau gauche ──────────────────────────────────────────
- def _make_left_panel(self):
- self.left = QFrame()
- self.left.setObjectName('checkerLeft')
- self.left.setFixedWidth(336)
- self.left.setStyleSheet(
- 'QFrame#checkerLeft{background:#1e1e1e; border-right:1px solid #333;}')
- lo = QVBoxLayout(self.left)
- lo.setContentsMargins(8, 8, 8, 8)
- lo.setSpacing(6)
- lo.addWidget(self._make_file_widget())
- self.gl_lbl = QLabel(self.t.get('live_gcode', 'Live G-Code'))
- self.gl_lbl.setStyleSheet('color:white;font-weight:bold;font-size:11px;border:none;')
- lo.addWidget(self.gl_lbl)
- self.gcode_view = QPlainTextEdit()
- self.gcode_view.setReadOnly(True)
- self.gcode_view.setStyleSheet(
- 'QPlainTextEdit{background:#1a1a1a;color:#00ff00;'
- 'font-family:Consolas;'
- 'border:1px solid #333;border-radius:3px;}')
- self._update_gcode_font()
- self.gcode_view.mousePressEvent = self._on_gcode_click
- self.gcode_view.keyPressEvent = self._on_gcode_key
- lo.addWidget(self.gcode_view, stretch=1)
- lo.addWidget(self._make_action_buttons())
- return self.left
- def _make_file_widget(self):
- """Bouton d'ouverture de fichier + infos du fichier chargé."""
- f = QFrame()
- self._file_frame = f
- f.setStyleSheet('QFrame{background:#252525;border-radius:6px;'
- 'border:1px solid #333;}')
- lo = QVBoxLayout(f)
- lo.setContentsMargins(8, 8, 8, 8)
- lo.setSpacing(6)
- self.btn_open = QPushButton(self.t.get('open_file', 'Open G-Code file…'))
- self.btn_open.setFixedHeight(38)
- self.btn_open.setStyleSheet(self._gbtn('#1f538d', '#2a6dbd'))
- self.btn_open.clicked.connect(self._on_open_file)
- lo.addWidget(self.btn_open)
- self.lbl_file = QLabel(self.t.get('no_file', 'No file loaded'))
- self.lbl_file.setStyleSheet('color:#888;font-size:10px;border:none;')
- self.lbl_file.setWordWrap(True)
- lo.addWidget(self.lbl_file)
- row = QHBoxLayout()
- self.lbl_size = QLabel('')
- self.lbl_size.setStyleSheet('color:#2ecc71;font-size:10px;'
- 'font-family:Consolas;border:none;')
- self.lbl_dur = QLabel('')
- self.lbl_dur.setStyleSheet('color:#f39c12;font-size:10px;'
- 'font-family:Consolas;border:none;')
- self.lbl_dur.setAlignment(Qt.AlignmentFlag.AlignRight)
- row.addWidget(self.lbl_size)
- row.addWidget(self.lbl_dur)
- lo.addLayout(row)
- # ── Champ line_step éditable (non sauvegardé) ──────────────
- step_row = QHBoxLayout()
- self.lbl_lstep = QLabel(self.t.get('line_step_lbl', 'Line step (mm):'))
- self.lbl_lstep.setStyleSheet('color:#aaa;font-size:10px;border:none;')
- step_row.addWidget(self.lbl_lstep)
- self.spin_lstep = QDoubleSpinBox()
- self.spin_lstep.setRange(0.001, 50.0)
- self.spin_lstep.setDecimals(4)
- self.spin_lstep.setSingleStep(0.01)
- self.spin_lstep.setFixedHeight(24)
- self.spin_lstep.setStyleSheet(
- 'QDoubleSpinBox{background:#1a1a1a;color:white;border:1px solid #555;'
- 'border-radius:3px;font-size:10px;padding:1px 4px;}'
- 'QDoubleSpinBox::up-button,QDoubleSpinBox::down-button{width:14px;}'
- )
- # Pré-remplir depuis config (hor_linestep par défaut)
- try:
- default_step = float(
- self.controller.config_manager.get_item('machine_settings', 'hor_linestep', 0.1) or 0.1
- )
- except Exception:
- default_step = 0.1
- self.spin_lstep.setValue(default_step)
- self.spin_lstep.valueChanged.connect(self._on_lstep_changed)
- step_row.addWidget(self.spin_lstep)
- lo.addLayout(step_row)
- return f
- def _make_action_buttons(self):
- f = QFrame()
- f.setStyleSheet('QFrame{border:none;background:transparent;}')
- lo = QVBoxLayout(f)
- lo.setContentsMargins(0, 0, 0, 0)
- lo.setSpacing(6)
- # ── Switch compensation délai laser ───────────────────────
- lat_row = QHBoxLayout()
- self.lbl_lat = QLabel(self.t.get('simulate_latency', 'Simulate latency'))
- self.lbl_lat.setStyleSheet('color:#aaa;font-size:10px;border:none;')
- lat_row.addWidget(self.lbl_lat)
- lat_row.addStretch()
- self.sw_latency = Switch()
- self.sw_latency.setChecked(False)
- self.sw_latency.toggled.connect(self._on_lat_toggle)
- lat_row.addWidget(self.sw_latency)
- lo.addLayout(lat_row)
- self.btn_cancel = QPushButton(self.t.get('cancel', 'Close'))
- self.btn_cancel.setFixedHeight(30)
- self.btn_cancel.setStyleSheet(self._gbtn('#333', '#444'))
- self.btn_cancel.clicked.connect(self.on_cancel)
- lo.addWidget(self.btn_cancel)
- return f
- # ─── Panneau droit ───────────────────────────────────────────
- def _make_right_panel(self):
- self._right_widget = QWidget()
- self._right_widget.setStyleSheet('background:#111;')
- lo = QVBoxLayout(self._right_widget)
- lo.setContentsMargins(0, 0, 0, 0)
- lo.setSpacing(0)
- # Canvas occupe tout l'espace
- self.canvas = _SimCanvas()
- self.canvas.set_placeholder(self.t.get('open_gcode_hint', 'Open a G-Code file to start'))
- lo.addWidget(self.canvas, stretch=1)
- # Les contrôles de lecture et la barre de progression sont créés
- # mais restent hors du layout — ils seront positionnés en overlay
- self._playback_frame = self._make_playback_bar()
- self._playback_frame.setParent(self._right_widget)
- self._playback_frame.setStyleSheet(
- 'QFrame{background:transparent;border:none;}')
- self._progress_frame = self._make_progress_bar()
- self._progress_frame.setParent(self._right_widget)
- self._progress_frame.setStyleSheet(
- 'QFrame{background:transparent;border:none;}')
- return self._right_widget
- def _make_playback_bar(self):
- f = QFrame()
- lo = QVBoxLayout(f)
- lo.setContentsMargins(8, 6, 8, 6)
- lo.setSpacing(5)
- # Transport
- tr = QHBoxLayout()
- tr.setAlignment(Qt.AlignmentFlag.AlignCenter)
- btn_rew = QPushButton()
- rewind_pixmap = get_svg_pixmap(SVG_ICONS["REWIND"], QSize(24, 24), "#ffffff")
- btn_rew.setIcon(QIcon(rewind_pixmap))
- btn_rew.setFixedSize(60, 40)
- btn_rew.setStyleSheet(self._gbtn('#444', '#555'))
- btn_rew.clicked.connect(self.rewind_sim)
- self.btn_play = QPushButton() # '▶'
- self.play_pixmap = get_svg_pixmap(SVG_ICONS["PLAY"], QSize(24, 24), "#ffffff")
- self.pause_pixmap = get_svg_pixmap(SVG_ICONS["PAUSE"], QSize(24, 24), "#ffffff")
- self.rerun_pixmap = get_svg_pixmap(SVG_ICONS["RERUN"], QSize(24, 24), "#ffffff")
- self.btn_play.setIcon(QIcon(self.play_pixmap))
- self.btn_play.setFixedSize(100, 40)
- # self.btn_play.setFont(QFont('Arial', 16))
- self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
- self.btn_play.clicked.connect(self.toggle_pause)
- btn_end = QPushButton() # '⏭'
- skiptoend_pixmap = get_svg_pixmap(SVG_ICONS["SKIPTOEND"], QSize(24, 24), "#ffffff")
- btn_end.setIcon(QIcon(skiptoend_pixmap))
- btn_end.setFixedSize(60, 40)
- # btn_end.setFont(QFont('Arial', 16))
- btn_end.setStyleSheet(self._gbtn('#444', '#555'))
- btn_end.clicked.connect(self.skip_to_end)
- btn_fit = QPushButton() # '⊞'
- fit_pixmap = get_svg_pixmap(SVG_ICONS["FIT"], QSize(50, 50), "#ffffff")
- btn_fit.setIcon(QIcon(fit_pixmap))
- btn_fit.setFixedSize(40, 40)
- # btn_fit.setFont(QFont('Arial', 20))
- btn_fit.setToolTip(self.t.get('reset_zoom', 'Reset zoom / pan'))
- btn_fit.setStyleSheet(self._gbtn('#333', '#444'))
- btn_fit.clicked.connect(self.canvas.reset_view)
- for b in [btn_rew, self.btn_play, btn_end, btn_fit]:
- tr.addWidget(b)
- lo.addLayout(tr)
- # Sélecteur vitesse
- sf = QFrame()
- sf.setStyleSheet('QFrame{background:#222;border:1px solid #444;'
- 'border-radius:5px;}')
- sr = QHBoxLayout(sf)
- sr.setContentsMargins(5, 2, 5, 2)
- sr.setSpacing(2)
- spd_lbl = QLabel(self.t.get('speed', 'Speed:'))
- spd_lbl.setStyleSheet('color:white;font-weight:bold;font-size:9px;border:none;')
- sr.addWidget(spd_lbl)
- self._spd_btns: dict[str, QPushButton] = {}
- spd_style = ('QPushButton{background:#3a3a3a;color:#aaa;'
- 'border:1px solid #555;border-radius:3px;'
- 'padding:0px 4px;font-size:8px;min-width:18px;max-width:30px;}'
- 'QPushButton:checked{background:#1f538d;color:white;'
- 'border-color:#2a6dbd;}'
- 'QPushButton:hover:!checked{background:#444;color:white;}')
- for v in ['0.5', '1', '5', '10', '50']:
- b = QPushButton(v)
- b.setCheckable(True)
- b.setFixedHeight(14)
- b.setStyleSheet(spd_style)
- b.clicked.connect(lambda _, val=v: self._set_speed(val))
- sr.addWidget(b)
- self._spd_btns[v] = b
- self._spd_btns['1'].setChecked(True)
- lo.addWidget(sf, alignment=Qt.AlignmentFlag.AlignHCenter)
- return f
- def _make_progress_bar(self):
- f = QFrame()
- lo = QVBoxLayout(f)
- lo.setContentsMargins(0, 0, 0, 0) # On laisse le resizeEvent gérer les bords
- lo.setSpacing(0)
- container = QWidget()
- container.setFixedHeight(30) # Un peu plus haut pour faciliter le clic
- container.setStyleSheet('background:transparent;')
- self.prog_bar = QProgressBar(container)
- # Supprimez le setGeometry fixe ici, ou mettez une valeur bidon
- self.prog_bar.setRange(0, 10000)
- self.prog_bar.setValue(0)
- self.prog_bar.setTextVisible(False)
- self.prog_bar.setStyleSheet(
- 'QProgressBar{background:#333;border-radius:7px;border:none;}'
- 'QProgressBar::chunk{background:#27ae60;border-radius:7px;}')
-
- # IMPORTANT : On lie le clic
- self.prog_bar.mousePressEvent = self._on_prog_click
- self.lbl_time = QLabel('00:00:00 / 00:00:00', container)
- self.lbl_time.setStyleSheet(
- 'color:white;font-size:9px;font-weight:bold;'
- 'background:transparent;border:none;')
- self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.lbl_time.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
- lo.addWidget(container)
- self.lbl_prog = QLabel('')
- self.lbl_prog.hide()
- lo.addWidget(self.lbl_prog)
- self._prog_container = container
- return f
- # ══════════════════════════════════════════════════════════════
- # LOADING OVERLAY
- # ══════════════════════════════════════════════════════════════
- def _show_loading(self):
- self._ov = QWidget(self)
- self._ov.setStyleSheet('background:rgba(20,20,20,220);')
- self._ov.resize(self.size())
- self._ov.show()
- self._ov.raise_()
- lo = QVBoxLayout(self._ov)
- lo.setAlignment(Qt.AlignmentFlag.AlignCenter)
- box = QFrame()
- box.setFixedWidth(340)
- box.setStyleSheet('QFrame{background:#3a3a3a;border-radius:12px;'
- 'border:1px solid #555;}')
- bl = QVBoxLayout(box)
- bl.setContentsMargins(30, 20, 30, 25)
- bl.setSpacing(12)
- lbl = QLabel(self.t.get('parsing', 'Parsing G-Code…'))
- lbl.setStyleSheet('color:white;font-size:14px;font-weight:bold;border:none;')
- lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
- bl.addWidget(lbl)
- pb = QProgressBar()
- pb.setFixedHeight(10)
- pb.setRange(0, 0)
- pb.setStyleSheet('QProgressBar{background:#555;border-radius:5px;border:none;}'
- 'QProgressBar::chunk{background:#27ae60;border-radius:5px;}')
- bl.addWidget(pb)
- lo.addWidget(box)
- def _hide_loading(self):
- if hasattr(self, '_ov'):
- self._ov.hide()
- self._ov.deleteLater()
- del self._ov
- def _update_gcode_font(self):
- """Adapte la taille de la police du G-Code à la largeur du panneau gauche."""
- if not hasattr(self, 'gcode_view'):
- return
- panel_w = self.left.width() if hasattr(self, 'left') else 336
- # Viser ~55 caractères lisibles — Consolas ratio ≈ 0.55
- usable = max(1, panel_w - 24)
- pt = max(8, min(16, int(usable / (55 * 0.55))))
- fnt = QFont('Consolas', pt)
- self.gcode_view.setFont(fnt)
- def resizeEvent(self, e):
- super().resizeEvent(e)
- self._update_gcode_font()
- if hasattr(self, '_ov'):
- self._ov.resize(self.size())
- # Repositionnement des blocs (Playback et Progress)
- if hasattr(self, '_playback_frame') and hasattr(self, '_progress_frame'):
- rw = self._right_widget
- rw_w = rw.width()
- rw_h = rw.height()
- margin_side = 40
- margin_bottom = 2 # px depuis le bas
- pb_hint = self._playback_frame.sizeHint()
- pr_hint = self._progress_frame.sizeHint()
- pb_h = max(pb_hint.height(), 70)
- pr_h = max(pr_hint.height(), 26)
- total_h = pb_h + pr_h + 2
- bottom_y = rw_h - total_h - margin_bottom
- # 1. On donne d'abord sa position au cadre transparent global
- prog_frame_w = rw_w - 2 * margin_side
- self._progress_frame.setGeometry(margin_side, bottom_y,
- prog_frame_w, pr_h)
-
- self._playback_frame.setGeometry(margin_side, bottom_y + pr_h + 2,
- rw_w - 2 * margin_side, pb_h)
- # 2. On dimensionne la barre ET le texte immédiatement
- # (Plus aucun décalage temporel)
- if hasattr(self, 'prog_bar'):
- bar_w = int(prog_frame_w * 0.8)
- bar_x = (prog_frame_w - bar_w) // 2
- self.prog_bar.setGeometry(bar_x, 3, bar_w, 14)
- # Le label prend toute la largeur, Qt le centrera parfaitement au-dessus de la barre
- self.lbl_time.setGeometry(0, 3, prog_frame_w, 14)
- self._playback_frame.raise_()
- self._progress_frame.raise_()
- # Mémoriser la hauteur occupée par les overlays pour _init_canvas
- self._overlay_h = rw_h - bottom_y + 8
- # ══════════════════════════════════════════════════════════════
- # GÉNÉRATION (QThread)
- # ══════════════════════════════════════════════════════════════
- # ══════════════════════════════════════════════════════════════
- # CHARGEMENT FICHIER G-CODE
- # ══════════════════════════════════════════════════════════════
- def _on_open_file(self):
- """Ouvre un QFileDialog et charge le fichier sélectionné."""
- stds = 'G-Code (*.nc *.gcode *.gc *.tap *.txt);;All files (*.*)'
- path, _ = get_open_file(
- self, self.t.get('open_file', 'Open G-Code file'), '', stds)
- if path:
- self._load_file(path)
- def _load_file(self, path):
- """Parse le fichier G-Code et lance la simulation."""
- self._stop_all()
- self._loaded_path = path
- self.lbl_file.setText(truncate_path(path, 38))
- self.lbl_size.setText('')
- self.lbl_dur.setText('')
- self._show_loading()
- # Lecture
- try:
- with open(path, 'r', encoding='utf-8', errors='replace') as f:
- gcode = f.read()
- except Exception as e:
- self._hide_loading()
- QMessageBox.critical(self,
- self.t.get('error_title', 'Error'),
- f"{self.t.get('error_read_file', 'Cannot read file:')}\n{e}")
- return
- # Parsing dans un thread pour ne pas bloquer l'UI
- self._parse_worker = _ParseWorker(gcode)
- self._parse_worker.done.connect(self._on_parse_done)
- self._parse_worker.error.connect(self._on_parse_error)
- self._parse_worker.start()
- def _on_parse_error(self, msg):
- self._hide_loading()
- QMessageBox.critical(self, self.t.get('parse_error_title', 'Parse Error'), msg)
- def _on_parse_done(self, d):
- self._hide_loading()
- self.final_gcode = d['gcode']
- self.points_list = d['pts']
- self.total_sec = d.get('total_dur', 0.0)
- self.framing_end = 0
- self._last_drawn_idx = -1
- # Calculer latence_mm depuis le feedrate réel et la config
- try:
- lat_ms = float(
- self.controller.config_manager.get_item('machine_settings', 'laser_latency', 0.0) or 0.0
- )
- feedrate_mmmin = d.get('feedrate_mmmin', 3000.0)
- # lat_ms * feedrate (mm/min) / 60000 = mm (signe inversé)
- self.latence_mm = -abs(lat_ms) * feedrate_mmmin / 60000.0
- except Exception:
- self.latence_mm = 0.0
- # Bounds = tous les points parsés (G0 + G1 Q=0 + G1 Q>0)
- # On veut voir l'intégralité des déplacements, overscan inclus
- pts = self.points_list
- if pts is not None and len(pts):
- self._mnx = float(pts[:, 0].min())
- self._mxx = float(pts[:, 0].max())
- self._mny = float(pts[:, 1].min())
- self._mxy = float(pts[:, 1].max())
- else:
- self._mnx = self._mxx = self._mny = self._mxy = 0.0
- # Infos affichées
- nb_lines = self.final_gcode.count('\n')
- self.lbl_size.setText(f'{nb_lines} lines')
- self.lbl_dur.setText(self._fmt(self.total_sec))
- if self.final_gcode:
- self.gcode_view.setPlainText(self.final_gcode)
- self._update_gcode_font()
- self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
- self._right_widget.updateGeometry()
- # Détecter l'axe dominant du raster pour pré-remplir le line_step
- if pts is not None and len(pts) > 1:
- dx_total = float(np.sum(np.abs(np.diff(pts[:, 0]))))
- dy_total = float(np.sum(np.abs(np.diff(pts[:, 1]))))
- is_horizontal = dx_total >= dy_total
- try:
- key = 'hor_linestep' if is_horizontal else 'ver_linestep'
- step_val = float(
- self.controller.config_manager.get_item('machine_settings', key, 0.1) or 0.1
- )
- self.spin_lstep.blockSignals(True)
- self.spin_lstep.setValue(step_val)
- self.spin_lstep.blockSignals(False)
- except Exception:
- pass
- self._init_canvas()
- # ══════════════════════════════════════════════════════════════
- # INITIALISATION CANVAS
- # ══════════════════════════════════════════════════════════════
- def _init_canvas(self):
- cw, ch = self.canvas.width(), self.canvas.height()
- # Attendre que le widget soit réellement dimensionné
- if cw <= 1 or ch <= 1:
- QTimer.singleShot(60, self._init_canvas)
- return
- if self.points_list is None or len(self.points_list) == 0:
- return
- # 1. ctrl_max : lu depuis la config
- self.ctrl_max = float(
- self.controller.config_manager.get_item('machine_settings', 'ctrl_max', 255) or 255
- )
- pts = self.points_list
- # 2. l_step (épaisseur trait laser) : champ éditable UI — non sauvegardé
- try:
- l_step = float(self.spin_lstep.value())
- if l_step <= 0:
- l_step = 0.1
- except Exception:
- l_step = 0.1
- # 3. scan_step (espacement réel entre lignes) : détecté depuis le G-Code
- # Sert au snap d'échelle et au snap Y — indépendant de l_step utilisateur
- scan_step = self._detect_scan_step(pts)
- if scan_step is None or scan_step <= 0:
- scan_step = l_step # fallback : utiliser l_step si pas de raster détecté
- # ─────────────────────────────
- # Dimensions réelles pièce (mm)
- # ─────────────────────────────
- tw = max(0.1, self._mxx - self._mnx)
- th = max(0.1, self._mxy - self._mny)
- # Espace utile
- overlay_h = getattr(self, '_overlay_h', 150)
- ch_usable = max(ch - overlay_h, int(ch * 0.5))
- # Scale pour tenir dans la vue
- sc = min((cw * 0.90) / tw, (ch_usable * 0.90) / th)
- # SNAP ÉCHELLE sur scan_step (pas inter-lignes réel)
- # → les lignes tombent exactement sur des pixels entiers → aucun gap
- snap_px = max(1, int(np.round(scan_step * sc)))
- sc = snap_px / scan_step # échelle recalculée
- # lw_px pour les marges du buffer (basé sur l_step affiché)
- lw_px = max(1, int(np.round(l_step * sc)))
- # Dimensions projetées écran
- pw = tw * sc
- ph = th * sc
- # Dimensions buffer image — marge d'une ligne pour la première et dernière
- rw = max(1, int(round(pw)) + lw_px * 2)
- rh = max(1, int(round(ph)) + lw_px * 2)
- # Position : centré horizontalement, marge en haut
- x0 = (cw - pw) / 2.0
- y0 = max(8.0, (ch_usable - ph) * 0.10)
- # ─────────────────────────────
- # Initialisation renderer
- # ─────────────────────────────
- # pwr_min_s = 0 toujours : si on utilisait le min des valeurs actives
- # (ex: 40), laser_threshold = max(40, ctrl_max*0.001) = 40, et la
- # condition pwr > threshold serait False pour Q=40 → rien dessiné.
- # pwr_max_s = valeur max trouvée dans le fichier (ou ctrl_max par défaut).
- pwr_col = pts[:, 2]
- pwr_active = pwr_col[pwr_col > 0]
- if len(pwr_active):
- pwr_min_s = 0.0
- pwr_max_s = float(pwr_active.max())
- else:
- pwr_min_s = 0.0
- pwr_max_s = float(self.ctrl_max)
- self._renderer = _Renderer(
- rw,
- rh,
- sc,
- rh,
- self._mnx,
- self._mny,
- int(lw_px),
- self.ctrl_max,
- pwr_min=pwr_min_s,
- pwr_max=pwr_max_s,
- l_step_mm=scan_step, # snap Y basé sur espacement réel
- draw_step_mm=l_step # épaisseur du trait (valeur utilisateur)
- )
- # Stockage géométrie
- self._px_w, self._px_h = pw, ph
- self._x0, self._y0 = x0, y0
- self._scale = sc
- # Index sécurisé
- # self._last_drawn_idx = max(0, self.framing_end - 1)
- self._last_drawn_idx = -1
- # ─────────────────────────────
- # Position initiale laser
- # ─────────────────────────────
- p0 = self.points_list[0]
- lx = x0 + (p0[0] - self._mnx) * sc
- ly = y0 + ph - (p0[1] - self._mny) * sc
- # ─────────────────────────────
- # Setup canvas (AVEC l_step en dernier argument)
- # ─────────────────────────────
- self.canvas.setup(
- self._renderer.display_data,
- x0, y0, pw, ph, sc,
- self._mnx, self._mxx,
- self._mny, self._mxy,
- l_step if l_step > 0 else 1.0,
- overlay_h
- )
- self.canvas.set_laser(lx, ly)
- # Forcer le repositionnement des overlays
- self.resizeEvent(None)
- # Afficher l'image complète immédiatement au chargement
- QTimer.singleShot(0, lambda: (
- self._redraw_to(len(self.points_list) - 1),
- self._update_ui(len(self.points_list) - 1)
- ))
- # ══════════════════════════════════════════════════════════════
- # ANIMATION — hot path
- # ══════════════════════════════════════════════════════════════
- def _tick(self):
- """
- Boucle animation (16 ms).
- Rasterisation incrémentale sécurisée.
- """
- if not self.sim_running:
- return
- if self.points_list is None or len(self.points_list) < 2:
- return
- pts = self.points_list
- total_pts = len(pts)
- last_idx = total_pts - 1
- # ───────────────────────────────
- # Temps simulation
- # ───────────────────────────────
- now = time.perf_counter()
- if self.last_frame_time == 0:
- self.last_frame_time = now
- dt = (now - self.last_frame_time) * self.sim_speed
- self.last_frame_time = now
- self.current_sim_time += dt
- # Clamp temps
- if self.current_sim_time >= self.total_sec:
- self.current_sim_time = self.total_sec
- # ───────────────────────────────
- # Avancement index via searchsorted
- # ───────────────────────────────
- idx = np.searchsorted(
- pts[:, 4],
- self.current_sim_time,
- side='right'
- ) - 1
- idx = max(0, min(int(idx), last_idx))
- self.current_idx = idx
- # ───────────────────────────────
- # Rasterisation incrémentale
- # ───────────────────────────────
- if self._renderer is not None:
- # start_idx = max(0, self.framing_end - 1)
- start_idx = 0
- if self.current_idx > self._last_drawn_idx:
- seg_start = max(self._last_drawn_idx, start_idx)
- if seg_start < self.current_idx:
- self._renderer.draw_incremental(
- pts,
- seg_start,
- self.current_idx,
- self.latence_enabled,
- self.latence_mm,
- self.full_metadata.get('scan_axis', 'X')
- )
- self._last_drawn_idx = self.current_idx
- self.canvas.notify_dirty()
- # ───────────────────────────────
- # Fin animation
- # ───────────────────────────────
- if self.current_idx >= last_idx:
- self._finish_anim()
- return
- # ───────────────────────────────
- # Interpolation laser fluide
- # ───────────────────────────────
- pc = pts[self.current_idx]
- pn = pts[self.current_idx + 1]
- td = pn[4] - pc[4]
- if td > 0:
- r = (self.current_sim_time - pc[4]) / td
- r = max(0.0, min(1.0, r))
- else:
- r = 0.0
- lx_mm = pc[0] + (pn[0] - pc[0]) * r
- ly_mm = pc[1] + (pn[1] - pc[1]) * r
- self.canvas.set_laser(*self._mm_to_screen(lx_mm, ly_mm))
- # ───────────────────────────────
- # UI sync
- # ───────────────────────────────
- self._update_ui(self.current_idx)
- # ══════════════════════════════════════════════════════════════
- # REDRAW COMPLET (scrub / seek / latence toggle)
- # ══════════════════════════════════════════════════════════════
- def _redraw_to(self, target_idx):
- """
- Redessine complètement la simulation jusqu'à target_idx.
- Utilisé pour :
- - scrub barre de progression
- - skip_to_end
- - toggle latence
- """
- if self._renderer is None:
- return
- if self.points_list is None or len(self.points_list) == 0:
- return
- total_pts = len(self.points_list)
- target_idx = max(0, min(int(target_idx), total_pts - 1))
- # start_idx = max(0, self.framing_end - 1)
- start_idx = 0
- if target_idx <= start_idx:
- self._renderer.reset()
- self._last_drawn_idx = start_idx
- else:
- # Fond blanc
- self._renderer.display_data.fill(255)
- scan_axis = self.full_metadata.get('scan_axis', 'X')
- # Dessiner le framing en premier (il sera le plus foncé si puissance forte)
- if self.framing_end > 0:
- polys = self._renderer._compute_segments(
- self.points_list, 0, self.framing_end, False, 0.0, scan_axis)
- self._renderer._rasterize(polys)
- # Dessiner le raster — np.minimum protège les pixels du framing
- if target_idx > self.framing_end:
- polys = self._renderer._compute_segments(
- self.points_list, self.framing_end, target_idx,
- self.latence_enabled, self.latence_mm, scan_axis)
- self._renderer._rasterize(polys)
- self._last_drawn_idx = target_idx
- self.canvas.notify_dirty()
- mx = float(self.points_list[target_idx][0])
- my = float(self.points_list[target_idx][1])
- self.canvas.set_laser(*self._mm_to_screen(mx, my))
- # ══════════════════════════════════════════════════════════════
- # CONTRÔLES PLAYBACK
- # ══════════════════════════════════════════════════════════════
- def _detect_scan_step(self, pts):
- """Détecte l'espacement réel entre lignes de scan depuis les points parsés."""
- if pts is None or len(pts) < 2:
- return None
- try:
- # Chercher les transitions laser actif → inactif (fin de ligne)
- pwr = pts[:, 2]
- starts = np.where((pwr[:-1] == 0) & (pwr[1:] > 0))[0] + 1
- if len(starts) < 2:
- # Fallback : diffs Y uniques pour raster horizontal
- y_unique = np.unique(np.round(pts[:, 1], 6))
- if len(y_unique) > 1:
- diffs = np.diff(y_unique)
- diffs = diffs[diffs > 1e-6]
- if len(diffs):
- return float(np.median(diffs))
- return None
- # Distance entre débuts de passes consécutives
- n = min(len(starts) - 1, 30)
- dists = []
- for i in range(n):
- p1 = pts[starts[i], :2]
- p2 = pts[starts[i + 1], :2]
- d = float(np.hypot(p2[0] - p1[0], p2[1] - p1[1]))
- if 1e-4 < d < 10.0:
- dists.append(d)
- if dists:
- return float(np.median(dists))
- except Exception:
- pass
- return None
- def _on_lstep_changed(self, value):
- """Recalcule le canvas quand l'utilisateur change le line_step."""
- if self.points_list is not None and len(self.points_list) > 0:
- self._stop_play()
- self._init_canvas()
- def toggle_pause(self):
- if self.points_list is None or self.points_list.size == 0:
- return
- if self.current_idx >= self.points_list.shape[0] - 1: # fin atteinte → replay
- self.rewind_sim(); self._start_play(); return
- if self.sim_running:
- self._stop_play()
- else:
- self._start_play()
- def _start_play(self):
- # Effacer l'image si déjà dessinée, pour repartir de zéro
- if self._last_drawn_idx > 0:
- if self._renderer:
- self._renderer.reset()
- self.canvas.notify_dirty()
- self._last_drawn_idx = -1
- self.current_idx = 0
- self.current_sim_time = 0.0
- self.last_frame_time = 0.0
- self.prog_bar.setValue(0)
- self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
- if self.points_list is not None and self.points_list.size > 0:
- lx, ly = self._mm_to_screen(self.points_list[0][0], self.points_list[0][1])
- self.canvas.set_laser(lx, ly)
- self.sim_running = True
- self.last_frame_time = time.perf_counter()
- self.btn_play.setIcon(QIcon(self.pause_pixmap))
- self.btn_play.setStyleSheet(self._gbtn('#e67e22', '#ca6f1e'))
- self._anim_timer.start()
- def _stop_play(self):
- self.sim_running = False
- self._anim_timer.stop()
- self.btn_play.setIcon(QIcon(self.play_pixmap))
- self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
- def _finish_anim(self):
- self.sim_running = False
- self._anim_timer.stop()
- self.btn_play.setIcon(QIcon(self.rerun_pixmap ))
- # self.btn_play.setText('🔄')
- self.btn_play.setStyleSheet(self._gbtn('#2980b9', '#1a6090'))
- self._update_ui(len(self.points_list) - 1)
- def rewind_sim(self):
- self._stop_play()
- self.current_idx = 0
- self.current_sim_time = 0.0
- self.last_frame_time = 0.0
- self._last_drawn_idx = -1
- if self._renderer:
- self._renderer.reset()
- self.canvas.notify_dirty()
- self.prog_bar.setValue(0)
- self.lbl_time.setText(f'00:00:00 / {self._fmt(self.total_sec)}')
- self.btn_play.setIcon(QIcon(self.play_pixmap))
- self.btn_play.setStyleSheet(self._gbtn('#27ae60', '#1e8449'))
- self._highlight_gcode(0)
- if self.points_list is not None and self.points_list.size > 0:
- lx, ly = self._mm_to_screen(
- self.points_list[0][0], self.points_list[0][1])
- self.canvas.set_laser(lx, ly)
- def skip_to_end(self):
- if self.points_list is None: return
- self._stop_play()
- self.current_idx = len(self.points_list) - 1
- self.current_sim_time = self.total_sec
- self._redraw_to(self.current_idx)
- self._update_ui(self.current_idx)
- self._finish_anim()
- def _set_speed(self, val):
- try:
- self.sim_speed = float(val)
- self.last_frame_time = time.perf_counter()
- except ValueError: pass
- for v, b in self._spd_btns.items():
- b.setChecked(v == val)
- def _on_lat_toggle(self, checked):
- self.latence_enabled = checked
- if self.points_list is not None and len(self.points_list) > 0:
- target = len(self.points_list) - 1 if self._last_drawn_idx >= len(self.points_list) - 1 else self.current_idx
- self._redraw_to(target)
- def _on_prog_click(self, e):
- # Sécurités de base
- if self.points_list is None or self.total_sec <= 0:
- return
- total_pts = len(self.points_list)
- if total_pts == 0:
- return
- # 1. RÉCUPÉRATION DE LA GÉOMÉTRIE RÉELLE
- # On récupère la largeur actuelle de la barre (celle affichée à l'écran)
- bar_w = self.prog_bar.width()
- if bar_w <= 1:
- return
- # 2. CALCUL DU RATIO (Pixel-Perfect)
- # mapFromGlobal convertit la position absolue de la souris sur l'écran
- # vers le référentiel interne de la prog_bar (0 = bord gauche exact).
- # Cela élimine tout décalage du aux marges ou au centrage (le bar_x).
- local_pos = self.prog_bar.mapFromGlobal(e.globalPosition().toPoint())
- pos_x = local_pos.x()
-
- # On sature le ratio entre 0.0 (début) et 1.0 (fin)
- ratio = max(0.0, min(1.0, pos_x / bar_w))
- # 3. LOGIQUE DE SAUT DANS LA SIMULATION
- # On mémorise si la lecture était en cours pour la reprendre après
- was_running = self.sim_running
- if was_running:
- self._stop_play()
- # Calcul du nouveau temps cible basé sur le ratio cliqué
- self.current_sim_time = ratio * self.total_sec
- # Recherche de l'index correspondant au temps dans les données NumPy
- # (on suppose que self.points_list[:, 4] contient les timestamps cumulés)
- idx = np.searchsorted(
- self.points_list[:, 4],
- self.current_sim_time,
- side='right'
- ) - 1
- # Sécurisation de l'index
- idx = max(0, min(int(idx), total_pts - 1))
- self.current_idx = idx
- # 4. MISE À JOUR VISUELLE IMMÉDIATE
- # Redessine le canvas jusqu'à ce point
- self._redraw_to(self.current_idx)
- # Met à jour les labels, le curseur laser et la valeur de la barre
- self._update_ui(self.current_idx)
- # 5. REPRISE DE LA LECTURE
- if was_running:
- # On réinitialise le timer de frame pour éviter un bond temporel
- self.last_frame_time = time.perf_counter()
- self._start_play()
- # ══════════════════════════════════════════════════════════════
- # UI SYNC
- # ══════════════════════════════════════════════════════════════
- # ══════════════════════════════════════════════════════════════
- # NAVIGATION G-CODE (click + clavier)
- # ══════════════════════════════════════════════════════════════
- def _seek_to_gcode_line(self, line_num):
- """Seek la simulation au premier point dont la ligne G-Code == line_num."""
- if self.points_list is None or len(self.points_list) == 0:
- return
- pts = self.points_list
- # pts[:,3] contient le numéro de ligne G-Code
- matches = np.where(pts[:, 3].astype(int) >= line_num)[0]
- if len(matches) == 0:
- idx = len(pts) - 1
- else:
- idx = int(matches[0])
- was_running = self.sim_running
- self._stop_play()
- self.current_idx = idx
- self.current_sim_time = float(pts[idx][4])
- self._redraw_to(idx)
- self._update_ui(idx)
- if was_running:
- self.last_frame_time = time.perf_counter()
- self._start_play()
- def _on_gcode_click(self, e):
- """Click sur le G-Code : positionne le curseur ET seek la simulation."""
- # Laisser le comportement normal de QPlainTextEdit (déplace le curseur)
- QPlainTextEdit.mousePressEvent(self.gcode_view, e)
- line_num = self.gcode_view.textCursor().blockNumber() + 1
- self._seek_to_gcode_line(line_num)
- def _on_gcode_key(self, e):
- """Flèches clavier sur le G-Code : navigation ±1 ligne ou ±20 lignes."""
- from PyQt6.QtCore import Qt as _Qt
- key = e.key()
- if key in (_Qt.Key.Key_Left, _Qt.Key.Key_Right,
- _Qt.Key.Key_Up, _Qt.Key.Key_Down):
- cur_line = self.gcode_view.textCursor().blockNumber() + 1
- if key == _Qt.Key.Key_Left:
- new_line = max(1, cur_line - 1)
- elif key == _Qt.Key.Key_Right:
- doc_lines = self.gcode_view.document().blockCount()
- new_line = min(doc_lines, cur_line + 1)
- elif key == _Qt.Key.Key_Up:
- new_line = max(1, cur_line - 20)
- else: # Down
- doc_lines = self.gcode_view.document().blockCount()
- new_line = min(doc_lines, cur_line + 20)
- # Déplacer le curseur du gcode_view
- block = self.gcode_view.document().findBlockByLineNumber(new_line - 1)
- if block.isValid():
- cur = self.gcode_view.textCursor()
- cur.setPosition(block.position())
- cur.select(cur.SelectionType.LineUnderCursor)
- self.gcode_view.setTextCursor(cur)
- self.gcode_view.ensureCursorVisible()
- self._seek_to_gcode_line(new_line)
- else:
- # Comportement normal pour toutes les autres touches
- QPlainTextEdit.keyPressEvent(self.gcode_view, e)
- def _update_ui(self, idx):
- if self.points_list is None or idx >= len(self.points_list): return
-
- ts = float(self.points_list[idx][4])
-
- # On base le rendu visuel sur l'écoulement du TEMPS, pas sur l'index des points
- pct = ts / max(0.001, self.total_sec)
- pct = max(0.0, min(1.0, pct))
-
- self.prog_bar.setValue(int(pct * 10000))
- self.lbl_time.setText(
- f'{self._fmt(ts)} / {self._fmt(self.total_sec)}')
- self._highlight_gcode(idx)
- def _highlight_gcode(self, idx):
- if self.points_list is None or idx >= len(self.points_list): return
- try:
- line_num = int(self.points_list[idx][3])
- block = self.gcode_view.document().findBlockByLineNumber(line_num-1)
- if block.isValid():
- cur = self.gcode_view.textCursor()
- cur.setPosition(block.position())
- cur.select(cur.SelectionType.LineUnderCursor)
- self.gcode_view.setTextCursor(cur)
- self.gcode_view.ensureCursorVisible()
- except Exception: pass
- # ══════════════════════════════════════════════════════════════
- # EXPORT
- # ══════════════════════════════════════════════════════════════
- # ══════════════════════════════════════════════════════════════
- # LANGUE
- # ══════════════════════════════════════════════════════════════
- def _apply_language(self, lang: str, translations: dict):
- """Appelée par update_ui_language — recharge self.t et met à jour les widgets."""
- from core.translations import TRANSLATIONS as _TR
- repo = translations if translations else _TR.get(lang, _TR['English'])
- self.t = repo.get('simulation', {})
- if hasattr(self, 'btn_open'):
- self.btn_open.setText(self.t.get('open_file', 'Open G-Code file…'))
- if hasattr(self, 'lbl_file') and not self._loaded_path:
- self.lbl_file.setText(self.t.get('no_file', 'No file loaded'))
- if hasattr(self, 'btn_cancel'):
- self.btn_cancel.setText(self.t.get('cancel', 'Close'))
- if hasattr(self, 'lbl_lstep'):
- self.lbl_lstep.setText(self.t.get('line_step_lbl', 'Line step (mm):'))
- if hasattr(self, 'lbl_lat'):
- self.lbl_lat.setText(self.t.get('simulate_latency', 'Simulate latency'))
- if hasattr(self, 'canvas') and self.canvas._img_buf is None:
- self.canvas.set_placeholder(self.t.get('open_gcode_hint', 'Open a G-Code file to start'))
- # ══════════════════════════════════════════════════════════════
- # THÈME
- # ══════════════════════════════════════════════════════════════
- def apply_theme(self, colors: dict):
- text = colors['text']
- text_sec = colors['text_secondary']
- bg_main = colors['bg_main']
- bg_card = colors['bg_card']
- bg_right = colors['bg_deep']
- bg_entry = colors['bg_entry']
- bg_spd = colors['bg_speed']
- border = colors['border']
- border_spd = colors['border_strong']
- gcode_col = colors['text_code']
- btn_dark_bg = colors['btn_dark']
- btn_dark_hov = colors['btn_dark_hover']
- # Scopé pour ne pas cascader sur _SimCanvas
- self.setStyleSheet(
- f'CheckerViewQt {{ background:{bg_main}; color:{text}; }}'
- )
- if hasattr(self, 'left'):
- self.left.setStyleSheet(
- f'QFrame#checkerLeft{{background:{bg_main};border-right:1px solid {border};}}'
- )
- if hasattr(self, '_file_frame'):
- self._file_frame.setStyleSheet(
- f'QFrame{{background:{bg_card};border-radius:6px;border:1px solid {border};}}'
- )
- if hasattr(self, 'gl_lbl'):
- self.gl_lbl.setStyleSheet(f'color:{text};font-weight:bold;font-size:11px;border:none;')
- if hasattr(self, 'lbl_file'):
- self.lbl_file.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
- if hasattr(self, 'lbl_lstep'):
- self.lbl_lstep.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
- if hasattr(self, 'spin_lstep'):
- self.spin_lstep.setStyleSheet(
- f'QDoubleSpinBox{{background:{bg_entry};color:{text};border:1px solid {border_spd};'
- f'border-radius:3px;font-size:10px;padding:1px 4px;}}'
- f'QDoubleSpinBox::up-button,QDoubleSpinBox::down-button{{width:14px;}}'
- )
- if hasattr(self, 'lbl_lat'):
- self.lbl_lat.setStyleSheet(f'color:{text_sec};font-size:10px;border:none;')
- if hasattr(self, 'lbl_time'):
- self.lbl_time.setStyleSheet(
- f'color:{text};font-size:9px;font-weight:bold;background:transparent;border:none;'
- )
- if hasattr(self, 'gcode_view'):
- self.gcode_view.setStyleSheet(
- f'QPlainTextEdit{{background:{bg_entry};color:{gcode_col};'
- f'font-family:Consolas;border:1px solid {border};border-radius:3px;}}'
- )
- self.gcode_view.verticalScrollBar().setStyleSheet(f"""
- QScrollBar:vertical {{
- border: none;
- background: {colors['scrollbar_bg']};
- width: 10px;
- margin: 0px;
- }}
- QScrollBar::handle:vertical {{
- background: {colors['scrollbar_handle']};
- min-height: 20px;
- border-radius: 5px;
- }}
- QScrollBar::handle:vertical:hover {{
- background: #1F6AA5;
- }}
- QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
- height: 0px;
- }}
- QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
- background: none;
- }}
- """)
- if hasattr(self, 'btn_cancel'):
- self.btn_cancel.setStyleSheet(self._gbtn(colors['btn_cancel'], colors['btn_cancel_hover']))
- if hasattr(self, '_spd_frame') and self._spd_frame:
- self._spd_frame.setStyleSheet(
- f'QFrame{{background:{bg_spd};border:1px solid {border_spd};border-radius:5px;}}'
- )
- for lbl in self._spd_frame.findChildren(QLabel):
- lbl.setStyleSheet(f'color:{text};font-weight:bold;font-size:9px;border:none;')
- if hasattr(self, '_spd_btns'):
- spd_style = (
- f'QPushButton{{background:{bg_card};color:{text_sec};'
- f'border:1px solid {border_spd};border-radius:3px;'
- f'padding:0px 4px;font-size:8px;min-width:18px;max-width:30px;}}'
- f'QPushButton:checked{{background:{colors["btn_speed_checked"]};color:white;'
- f'border-color:{colors["btn_speed_checked_hov"]};}}'
- f'QPushButton:hover:!checked{{background:{btn_dark_bg};color:{text};}}'
- )
- for b in self._spd_btns.values():
- b.setStyleSheet(spd_style)
- if hasattr(self, 'prog_bar'):
- self.prog_bar.setStyleSheet(
- f'QProgressBar{{background:{colors["progress_bg"]};border-radius:7px;border:none;}}'
- f'QProgressBar::chunk{{background:#27ae60;border-radius:7px;}}'
- )
- if hasattr(self, '_right_widget'):
- self._right_widget.setStyleSheet(f'background:{bg_right};')
- if hasattr(self, 'canvas'):
- self.canvas.set_theme(bg_right)
- # ══════════════════════════════════════════════════════════════
- # EXPORT
- # ══════════════════════════════════════════════════════════════
- def on_cancel(self):
- self._stop_all()
- if self.return_view == 'raster': self.controller.show_raster_mode()
- elif self.return_view == 'infill': self.controller.show_infill_mode()
- else: self.controller.show_dashboard()
- def _stop_all(self):
- self.sim_running = False
- self._anim_timer.stop()
- if hasattr(self, '_worker') and self._worker.isRunning():
- self._worker.quit(); self._worker.wait(500)
- def closeEvent(self, e):
- self._stop_all(); super().closeEvent(e)
- # ══════════════════════════════════════════════════════════════
- # UTILITAIRES
- # ══════════════════════════════════════════════════════════════
- def _mm_to_screen(self, mx, my):
- sx = self._x0 + (mx - self._mnx) * self._scale
- sy = self._y0 + self._px_h - (my - self._mny) * self._scale
- return sx, sy
- @staticmethod
- def _fmt(s):
- s = float(s)
- return f'{int(s//3600):02d}:{int((s%3600)//60):02d}:{int(s%60):02d}'
- @staticmethod
- def _gbtn(bg, hov):
- return (f'QPushButton{{background:{bg};color:white;border-radius:6px;'
- f'border:none;}}'
- f'QPushButton:hover{{background:{hov};}}')
|