| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505 |
- # -*- coding: utf-8 -*-
- """
- A.L.I.G. - RasterViewQt
- Corrections v2 :
- • Colorbar fixe (QWidget Qt natif) — non affectée par pan/zoom
- • Textes OVERSCAN trackés + supprimés à chaque redraw
- • Switch force_dim correctement connecté (lecture directe dans process_logic)
- • Bouton Reset View : plus de ligne noire (layout propre)
- • Auto-fit image à l'ouverture (reset_pan_zoom centré sur le contenu)
- • Histogramme : contours barres, abscisses par pas ronds, ylabel "Pixel count"
- """
- import os
- import sys
- import time
- import math
- import tempfile
- import numpy as np
- from PyQt6.QtWidgets import (
- QWidget, QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QComboBox, QLineEdit, QSlider, QScrollArea, QSplitter,
- QTextEdit, QSizePolicy, QMessageBox, QTabWidget,
- )
- from PyQt6.QtCore import Qt, QTimer, QSize, QPointF, QRectF
- from PyQt6.QtGui import (
- QPainter, QColor, QPen, QBrush, QFont, QLinearGradient,
- QTransform, QPixmap, QImage, QIcon,
- )
- from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
- from matplotlib.figure import Figure
- from matplotlib.patches import Rectangle
- from gui.switch import Switch
- from gui.utils_qt import (
- get_combo_stylesheet,
- show_loading_overlay,
- hide_loading_overlay,
- PanZoomMixin,
- translate_ui_widgets,
- get_open_file,
- get_save_file,
- get_existing_directory,
- )
- from core.translations import TRANSLATIONS
- from core.themes import get_theme
- from engine.gcode_engine import GCodeEngine
- from core.config_manager import save_json_file, load_json_file
- from core.utils import get_app_paths
- from gui.utils_qt import get_svg_pixmap
- try:
- from utils.paths import SVG_ICONS
- _ARROW_PATH = SVG_ICONS.get("ARROW_DOWN", "")
- except Exception:
- _ARROW_PATH = ""
- # ═══════════════════════════════════════════════════════════════════
- # HISTOGRAMME WIDGET
- # ═══════════════════════════════════════════════════════════════════
- class _HistogramWidget(QWidget):
- """
- Histogramme de distribution de puissance — rendu QPainter natif.
- """
- def __init__(self, parent=None):
- super().__init__(parent)
- self._matrix = None
- self._v_min = 0
- self._v_max = 255
- self._label_power = "Power"
- self._label_count = "Pixel count"
- self._label_title = "Power Distribution"
- self._bg_color = "#202020"
- self._fg_color = "#888888"
- self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- def set_theme(self, bg: str, fg: str):
- self._bg_color = bg
- self._fg_color = fg
- self.update()
- def update_data(self, matrix, v_min, v_max,
- label_title="Power Distribution",
- label_power="Power",
- label_count="Pixel count"):
- self._matrix = matrix
- self._v_min = float(v_min)
- self._v_max = float(v_max)
- self._label_title = label_title
- self._label_power = label_power
- self._label_count = label_count
- self.update()
- @staticmethod
- def _nice_step(value_range, max_ticks=7):
- """Retourne un pas « rond » pour ~max_ticks graduations."""
- if value_range <= 0:
- return 1
- raw = value_range / max_ticks
- mag = 10 ** math.floor(math.log10(raw)) if raw > 0 else 1
- for m in (1, 2, 5, 10):
- step = m * mag
- if value_range / step <= max_ticks:
- return step
- return mag * 10
- def paintEvent(self, _):
- qp = QPainter(self)
- qp.setRenderHint(QPainter.RenderHint.Antialiasing)
- W, H = self.width(), self.height()
- qp.fillRect(0, 0, W, H, QColor(self._bg_color))
- if self._matrix is None:
- qp.setPen(QColor(self._fg_color))
- qp.setFont(QFont("Arial", 10))
- qp.drawText(0, 0, W, H, Qt.AlignmentFlag.AlignCenter, "—")
- qp.end()
- return
- flat = self._matrix.ravel()[::8]
- total = max(flat.size, 1) # Le dénominateur pour le pourcentage
- v_min = self._v_min
- v_max = self._v_max
- data_off = flat[flat == 0]
- data_active = flat[flat > 0]
- BINS = 64
- if len(data_active) > 0:
- lo_range = max(v_min, 1e-9)
- hi_range = max(v_max, lo_range + 1e-8)
- counts, edges = np.histogram(
- data_active,
- bins=BINS,
- range=(lo_range, hi_range),
- density=False
- )
- # Conversion des comptes en pourcentages
- counts_pct = (counts / total) * 100.0
- else:
- counts_pct = np.zeros(BINS, dtype=float)
- edges = np.linspace(v_min, v_max, BINS + 1)
- # Pourcentage de pixels "éteints" (Laser OFF)
- count_off_pct = (data_off.size / total) * 100.0
-
- # Le y_max est maintenant le pourcentage le plus élevé trouvé (max 100)
- y_max = max(counts_pct.max() if counts_pct.size else 0, count_off_pct, 1.0)
- # ── Marges ─────────────────────────────────────────────────
- lm = 56 # axe Y + ylabel vertical
- rm = 10
- tm = 26 # titre
- bm = 28 # axe X + labels + xlabel
- plot_w = W - lm - rm
- zero_w = max(int(plot_w * 0.06), 8)
- act_w = plot_w - zero_w
- plot_h = H - tm - bm
- base_y = H - bm
- if plot_w <= 0 or plot_h <= 0:
- qp.end()
- return
- # ── Titre ──────────────────────────────────────────────────
- qp.setPen(QColor("white"))
- qp.setFont(QFont("Arial", 9, QFont.Weight.Bold))
- qp.drawText(0, 2, W, tm - 2, Qt.AlignmentFlag.AlignCenter, self._label_title)
- # ── Axes ───────────────────────────────────────────────────
- qp.setPen(QPen(QColor("#555"), 1))
- qp.drawLine(lm, base_y, W - rm, base_y)
- qp.drawLine(lm, tm, lm, base_y)
- # ── Mappings ───────────────────────────────────────────────
- def px_x(val):
- if v_max == v_min:
- return lm + zero_w
- return lm + zero_w + ((val - v_min) / (v_max - v_min)) * act_w
- def px_y(cnt):
- return base_y - min(cnt / y_max, 1.0) * plot_h
- # ── Barre OFF ──────────────────────────────────────────────
- if count_off_pct > 0:
- bh = int(max(base_y - px_y(count_off_pct), 1))
- bx0 = lm + 2
- bx1 = lm + zero_w - 2
- bw = max(bx1 - bx0, 1)
- by = base_y - bh
- qp.fillRect(bx0, by, bw, bh, QColor("#c0724a"))
- qp.setPen(QPen(QColor("#e8956b"), 1))
- qp.drawRect(bx0, by, bw - 1, bh - 1)
- qp.setPen(QColor("#e8956b"))
- qp.setFont(QFont("Arial", 7, QFont.Weight.Bold))
- qp.drawText(bx0, base_y + 4, bw, 14, Qt.AlignmentFlag.AlignCenter, "OFF")
- # ── Barres actives ─────────────────────────────────────────
- fill_col = QColor("#3a80b8")
- outline_col = QColor("#6ab0e0")
- for i, cnt in enumerate(counts_pct):
- if cnt <= 0:
- continue
- x0 = int(px_x(edges[i]))
- x1 = int(px_x(edges[i + 1]))
- bh = int(max(base_y - px_y(cnt), 1))
- bw = max(x1 - x0, 1)
- by = base_y - bh
- qp.fillRect(x0, by, bw, bh, fill_col)
- if bw >= 3:
- qp.setPen(QPen(outline_col, 1))
- qp.drawRect(x0, by, bw - 1, bh - 1)
- # ── Repères MIN / MAX ──────────────────────────────────────
- for val, col, txt in [(v_min, "#ffcc00", "MIN"), (v_max, "#ff4444", "MAX")]:
- px = int(px_x(val))
- qp.setPen(QPen(QColor(col), 1, Qt.PenStyle.DashLine))
- qp.drawLine(px, tm, px, base_y)
- qp.setPen(QColor(col))
- qp.setFont(QFont("Arial", 7, QFont.Weight.Bold))
- qp.drawText(px - 18, base_y + 18, 36, 13, Qt.AlignmentFlag.AlignCenter, txt)
- # ── Graduations Y ──────────────────────────────────────────
- step_y = self._nice_step(y_max, max_ticks=5)
- qp.setFont(QFont("Arial", 7))
- tick = 0
- while tick <= y_max:
- py = int(px_y(tick))
- qp.setPen(QPen(QColor("#2a2a2a"), 1, Qt.PenStyle.DotLine))
- qp.drawLine(lm + 1, py, W - rm, py)
- qp.setPen(QColor("#888"))
- lbl_str = f"{int(tick // 1000)}k" if tick >= 1000 else str(int(tick))
- qp.drawText(0, py - 7, lm - 4, 14,
- Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, lbl_str)
- tick += step_y
- # ── Graduations X par pas ronds ────────────────────────────
- v_range = v_max - v_min
- step_x = self._nice_step(v_range, max_ticks=6)
- x_start = math.ceil(v_min / step_x) * step_x if step_x else v_min
- qp.setFont(QFont("Arial", 7))
- cur = x_start
- while cur <= v_max + 1e-9:
- px = int(px_x(cur))
- if lm <= px <= W - rm:
- qp.setPen(QPen(QColor("#555"), 1))
- qp.drawLine(px, base_y, px, base_y + 4)
- qp.setPen(QColor("#888"))
- lbl = f"{int(cur)}" if cur == int(cur) else f"{cur:.1f}"
- qp.drawText(px - 15, base_y + 5, 30, 13,
- Qt.AlignmentFlag.AlignCenter, lbl)
- cur += step_x
- # ── Label X ────────────────────────────────────────────────
- qp.setPen(QColor("#777"))
- qp.setFont(QFont("Arial", 8))
- qp.drawText(lm, H - 12, int(act_w), 12,
- Qt.AlignmentFlag.AlignCenter, self._label_power)
- # ── Label Y vertical ───────────────────────────────────────
- qp.save()
- qp.translate(10, tm + plot_h // 2)
- qp.rotate(-90)
- qp.setPen(QColor("#777"))
- qp.setFont(QFont("Arial", 8))
- qp.drawText(-40, -7, 80, 14, Qt.AlignmentFlag.AlignCenter, self._label_count)
- qp.restore()
- qp.end()
- # ═══════════════════════════════════════════════════════════════════
- # COLORBAR WIDGET Qt (fixe, hors pan/zoom)
- # ═══════════════════════════════════════════════════════════════════
- class _ColorbarWidget(QWidget):
- """
- Colorbar affichée en Qt pur, côte à côte avec le canvas.
- Totalement indépendante du pan/zoom matplotlib.
- Dégradé gray_r : noir (haut/fort) → blanc (bas/faible).
- """
- def __init__(self, parent=None):
- super().__init__(parent)
- self._v_min = 0.0
- self._v_max = 255.0
- self._label = "Laser Power"
- self._bg_color = "#1e1e1e"
- self._fg_color = "#888888"
- self.setFixedWidth(52)
- self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
- def set_theme(self, bg: str, fg: str):
- self._bg_color = bg
- self._fg_color = fg
- self.update()
- def set_range(self, v_min, v_max, label="Laser Power"):
- self._v_min = float(v_min)
- self._v_max = float(v_max)
- self._label = label
- self.update()
- def paintEvent(self, _):
- qp = QPainter(self)
- W, H = self.width(), self.height()
- qp.fillRect(0, 0, W, H, QColor(self._bg_color))
- tm, bm = 12, 12
- bar_x = 14
- bar_w = 14
- bar_h = H - tm - bm
- if bar_h <= 0:
- qp.end()
- return
- # Dégradé : haut = noir (valeur haute = laser fort) / bas = blanc
- grad = QLinearGradient(bar_x, tm, bar_x, tm + bar_h)
- grad.setColorAt(0.0, QColor("#000000"))
- grad.setColorAt(1.0, QColor("#ffffff"))
- qp.fillRect(bar_x, tm, bar_w, bar_h, QBrush(grad))
- qp.setPen(QPen(QColor(self._fg_color), 1))
- qp.drawRect(bar_x, tm, bar_w - 1, bar_h - 1)
- # Ticks
- qp.setFont(QFont("Arial", 7))
- ticks = [(0.0, self._v_max), (0.5, (self._v_max + self._v_min) / 2), (1.0, self._v_min)]
- for ratio, val in ticks:
- py = int(tm + ratio * bar_h)
- qp.setPen(QPen(QColor(self._fg_color), 1))
- qp.drawLine(bar_x - 3, py, bar_x, py)
- s = f"{int(val)}" if val == int(val) else f"{val:.1f}"
- qp.drawText(0, py - 7, bar_x - 4, 14,
- Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, s)
- # Label vertical
- qp.save()
- qp.translate(W - 10, tm + bar_h // 2) # décalé 6px à gauche
- qp.rotate(-90)
- qp.setPen(QColor(self._fg_color))
- qp.setFont(QFont("Arial", 8))
- qp.drawText(-50, -6, 100, 13, Qt.AlignmentFlag.AlignCenter, self._label)
- qp.restore()
- qp.end()
- # ═══════════════════════════════════════════════════════════════════
- # TABBAR CENTRÉ
- # ═══════════════════════════════════════════════════════════════════
- # ═══════════════════════════════════════════════════════════════════
- # CANVAS IMAGE avec Pan / Zoom
- # ═══════════════════════════════════════════════════════════════════
- class _RasterCanvas(PanZoomMixin, QWidget):
- """
- Affiche le rendu matplotlib avec pan (clic-gauche) + zoom (molette).
- v2 : auto-fit à l'ouverture d'une image.
- """
- def __init__(self, fig, parent=None):
- super().__init__(parent)
- self.init_pan_zoom(zoom_min=0.10, zoom_max=30.0, zoom_step=1.15)
- self.setMouseTracking(True)
- self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- self._bg_color = "#1e1e1e"
- self.setStyleSheet(f"background:{self._bg_color};")
- self._fig = fig
- self._pixmap = None
- self._auto_fit = False
- self._fit_next = False
- self._needs_fit = False
- self._mpl_canvas = FigureCanvas(fig)
- self._mpl_canvas.setParent(self)
- self._mpl_canvas.setVisible(False)
- # Overlay QPainter natif (toujours net au zoom)
- self._overlay = None # None ou dict
- self._after_draw_cb = None # callable(pw, ph) appelé après draw()
- def set_overlay(self, ov):
- """
- ov = {
- 'transform': callable (mx, my) -> (px, py) # mm → pixels pixmap
- 'overscan_rects': [(x, y, w, h), ...], # mm
- 'border_rect': (x, y, w, h), # mm
- 'origin': (ox, oy), # mm
- 'grid_xs': [float, ...], # abscisses grille (mm)
- 'grid_ys': [float, ...], # ordonnées grille (mm)
- 'xlim': (x0, x1), # mm, pour borner les lignes
- 'ylim': (y0, y1),
- 'direction': 'horizontal'|'vertical',
- }
- """
- self._overlay = ov
- self.update()
- def request_auto_fit(self):
- self._auto_fit = True
- def redraw(self, fit=False):
- """Demande à matplotlib de redessiner et met à jour le pixmap.
- Si fit=True, ajuste automatiquement le zoom pour remplir la vue.
- """
- from PyQt6.QtCore import QTimer
- self._fig.tight_layout(pad=0.5)
- self._mpl_canvas.draw()
- # Capture du buffer matplotlib vers QPixmap
- buf = self._mpl_canvas.buffer_rgba()
- w, h = self._mpl_canvas.get_width_height()
- qi = QImage(bytes(buf), w, h, QImage.Format.Format_RGBA8888)
- self._pixmap = QPixmap.fromImage(qi)
- # Calcul du mapping mm → pixels pixmap via transData (valide après draw)
- # Appelé par set_overlay_from_ax() si fourni
- if self._after_draw_cb is not None:
- try:
- self._after_draw_cb(w, h)
- except Exception:
- pass
- do_fit = fit or self._fit_next
- self._fit_next = False
- if do_fit:
- self._needs_fit = True
- QTimer.singleShot(0, self.fit_to_view)
- else:
- self.update()
- def fit_to_view(self):
- """Calcule zoom + pan pour que le pixmap remplisse le widget (centré, sans déformation)."""
- if self._pixmap is None:
- self.reset_pan_zoom()
- return
- cw, ch = self.width(), self.height()
- pw, ph = self._pixmap.width(), self._pixmap.height()
- margin = 0.96
- zoom = min(cw / pw, ch / ph) * margin
- pan_x = (cw - pw * zoom) / 2.0
- pan_y = (ch - ph * zoom) / 2.0
- self._pz_zoom = zoom
- self._pz_pan = QPointF(pan_x, pan_y)
- self.update()
- self._on_pan_zoom_changed()
- def reset_view(self):
- self.fit_to_view()
- def paintEvent(self, _):
- qp = QPainter(self)
- qp.fillRect(0, 0, self.width(), self.height(), QColor(self._bg_color))
- if self._pixmap is None:
- ph_col = "#444444" if self._bg_color[1:3].lower() < "88" else "#aaaaaa"
- qp.setPen(QColor(ph_col))
- qp.setFont(QFont("Arial", 11))
- qp.drawText(0, 0, self.width(), self.height(),
- Qt.AlignmentFlag.AlignCenter, "Select an image…")
- qp.end()
- return
- # ── Pixmap matplotlib (image raster + ticks + labels) ───────
- self.apply_pan_zoom_transform(qp)
- qp.drawPixmap(0, 0, self._pixmap)
- # ── Overlay QPainter natif (toujours net au zoom) ────────────
- ov = self._overlay
- if ov is None:
- qp.end()
- return
- m2p = ov['transform'] # (mm_x, mm_y) → (px_x, px_y) dans pixmap
- x0, x1 = ov['xlim']
- y0, y1 = ov['ylim']
- horiz = ov.get('direction', 'horizontal') == 'horizontal'
- def cosmetic_pen(hex_col, alpha=1.0, widthF=0.0,
- style=Qt.PenStyle.SolidLine):
- c = QColor(hex_col); c.setAlphaF(alpha)
- p = QPen(c); p.setWidthF(widthF); p.setStyle(style)
- return p
- # ── 1. Grille ────────────────────────────────────────────────
- qp.setPen(cosmetic_pen("#00ffff", alpha=0.45))
- for gx in ov.get('grid_xs', []):
- ax1, ay1 = m2p(gx, y0)
- ax2, ay2 = m2p(gx, y1)
- qp.drawLine(QPointF(ax1, ay1), QPointF(ax2, ay2))
- for gy in ov.get('grid_ys', []):
- ax1, ay1 = m2p(x0, gy)
- ax2, ay2 = m2p(x1, gy)
- qp.drawLine(QPointF(ax1, ay1), QPointF(ax2, ay2))
- # ── 1b. Labels des ticks (Y gauche+droite, X haut+bas) ──────
- ax_left = ov.get('ax_left', 0.0)
- ax_right = ov.get('ax_right', float(self._pixmap.width()))
- ax_top = ov.get('ax_top', 0.0)
- ax_bot = ov.get('ax_bot', float(self._pixmap.height()))
- lbl_font = QFont("Arial", 8)
- qp.setFont(lbl_font)
- lbl_col = QColor("#888888")
- qp.setPen(QPen(lbl_col))
- lbl_w = 36; lbl_h = 14
- for (val, txt) in ov.get('yticks', []):
- _, py = m2p(x0, val)
- # gauche
- qp.drawText(QRectF(ax_left - lbl_w - 4, py - lbl_h / 2, lbl_w, lbl_h),
- Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, txt)
- # droite
- qp.drawText(QRectF(ax_right + 4, py - lbl_h / 2, lbl_w, lbl_h),
- Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
- for (val, txt) in ov.get('xticks', []):
- px, _ = m2p(val, y0)
- # bas
- qp.drawText(QRectF(px - lbl_w / 2, ax_bot + 3, lbl_w, lbl_h),
- Qt.AlignmentFlag.AlignCenter, txt)
- # haut
- qp.drawText(QRectF(px - lbl_w / 2, ax_top - lbl_h - 3, lbl_w, lbl_h),
- Qt.AlignmentFlag.AlignCenter, txt)
- # ── 2. Hachures + texte OVERSCAN ────────────────────────────
- for (rx, ry, rw, rh) in ov.get('overscan_rects', []):
- # y inversé : coin haut = y+h, coin bas = y
- tlx, tly = m2p(rx, ry + rh) # top-left pixel
- brx, bry = m2p(rx + rw, ry) # bot-right pixel
- rpx = min(tlx, brx); rpy = min(tly, bry)
- rpw = max(1.0, abs(brx - tlx))
- rph = max(1.0, abs(bry - tly))
- fill = QColor("#3498db"); fill.setAlphaF(0.10)
- qp.fillRect(QRectF(rpx, rpy, rpw, rph), fill)
- # hachures manuelles 8 px réels
- # horizontal = lignes de gravure horizontales → overscan gauche/droite (zones hautes/étroites)
- # → hachures "---" = lignes HORIZONTALES dans la zone
- # vertical = lignes de gravure verticales → overscan haut/bas (zones larges/plates)
- # → hachures "|||" = lignes VERTICALES dans la zone
- qp.setPen(cosmetic_pen("#3498db", alpha=0.35))
- step = 8.0
- if horiz:
- # hachures horizontales (---) dans zones gauche/droite
- y = rpy
- while y <= rpy + rph:
- qp.drawLine(QPointF(rpx, y), QPointF(rpx + rpw, y))
- y += step
- else:
- # hachures verticales (|||) dans zones haut/bas
- x = rpx
- while x <= rpx + rpw:
- qp.drawLine(QPointF(x, rpy), QPointF(x, rpy + rph))
- x += step
- # texte "OVERSCAN"
- # horizontal → zones étroites et hautes → texte tourné 90°
- # vertical → zones larges et plates → texte droit
- qp.save()
- tc = QColor("#3498db"); tc.setAlphaF(0.9)
- qp.setPen(QPen(tc))
- qp.setFont(QFont("Arial", 7, QFont.Weight.Bold))
- cx = rpx + rpw / 2; cy = rpy + rph / 2
- qp.translate(cx, cy)
- if horiz:
- qp.rotate(-90)
- qp.drawText(QRectF(-40, -8, 80, 16),
- Qt.AlignmentFlag.AlignCenter, "OVERSCAN")
- qp.restore()
- # ── 3. Rectangle bordure pointillé ──────────────────────────
- br = ov.get('border_rect')
- if br:
- bx, by, bw, bh = br
- tlx, tly = m2p(bx, by + bh)
- brx2, bry2 = m2p(bx + bw, by)
- pen = cosmetic_pen("#3498db", alpha=0.9, widthF=1.5,
- style=Qt.PenStyle.DashLine)
- qp.setPen(pen)
- qp.setBrush(Qt.BrushStyle.NoBrush)
- rx_ = min(tlx, brx2); ry_ = min(tly, bry2)
- qp.drawRect(QRectF(rx_, ry_, abs(brx2 - tlx), abs(bry2 - tly)))
- # ── 4. Point d'origine (disque rouge) ────────────────────────
- orig = ov.get('origin')
- if orig:
- opx, opy = m2p(orig[0], orig[1])
- r = 5.0
- qp.setPen(QPen(QColor("#cc0000"), 1.5))
- qp.setBrush(QBrush(QColor("#ff3333")))
- qp.drawEllipse(QRectF(opx - r, opy - r, r * 2, r * 2))
- qp.end()
- def resizeEvent(self, e):
- super().resizeEvent(e)
- if self._pixmap is not None:
- if self._needs_fit :
- self.fit_to_view()
- else:
- self.update()
- # ═══════════════════════════════════════════════════════════════════
- # VUE PRINCIPALE
- # ═══════════════════════════════════════════════════════════════════
- class RasterViewQt(QWidget):
- def __init__(self, parent, controller):
- super().__init__(parent)
- self.controller = controller
- lang = controller.config_manager.get_item("machine_settings", "language")
- if not lang or lang not in TRANSLATIONS:
- lang = "English"
- self._lang = lang
- self.t = TRANSLATIONS[lang]["common"]
- self.t_stats = TRANSLATIONS[lang]["stats"]
- self.t_orig = TRANSLATIONS[lang]["origin_options"]
- self.engine = GCodeEngine()
- self._loading = True
- self.input_image_path = ""
- self.output_dir = ""
- _, self.application_path = get_app_paths()
- self.output_dir = self.application_path
- self.version = getattr(controller, "version", "1.0")
- self.controls = {}
- self.translation_map = {}
- # Couleurs thème — dark par défaut, mis à jour par apply_theme
- self._theme_colors = get_theme('Dark')
- self._debounce_timer = QTimer(self)
- self._debounce_timer.setSingleShot(True)
- self._debounce_timer.timeout.connect(self._do_update_preview)
- self._fig = Figure(figsize=(5, 5), facecolor="#111111")
- self._ax = None
- self._img_plot = None
- self._cbar = None
- self._origin_marker = None
- self._overscan_patches = []
- self._overscan_texts = []
- self._rect_overscan = None
- self._placeholder_text = None
- self._last_matrix = None
- self._last_geom = None
- self.estimated_file_size = "N/A"
- self._source_img_cache = None
- self._source_img_path = ""
- self._build_ui()
- self._loading = False
- self.load_settings()
- # Le render est différé jusqu'au premier affichage (showEvent)
- # pour ne pas bloquer l'UI pendant le préchargement en arrière-plan
- self._render_pending = True
- # ── Construction de l'UI ─────────────────────────────────────────
- def _build_ui(self):
- root = QHBoxLayout(self)
- root.setContentsMargins(4, 4, 4, 4)
- root.setSpacing(4)
- sidebar = self._build_sidebar()
- right = self._build_right_panel()
- splitter = QSplitter(Qt.Orientation.Horizontal)
- splitter.addWidget(sidebar)
- splitter.addWidget(right)
- splitter.setStretchFactor(0, 0)
- splitter.setStretchFactor(1, 1)
- splitter.setSizes([390, 900])
- root.addWidget(splitter)
- def _build_sidebar(self):
- sidebar = QFrame()
- self._sidebar = sidebar
- sidebar.setObjectName("rasterSidebar")
- sidebar.setFixedWidth(390)
- sidebar.setStyleSheet(
- "QFrame#rasterSidebar{background:#1e1e1e;border-right:1px solid #333;}"
- "QFrame#rasterSidebar > QLabel{border:none;background:transparent;color:#ddd;}"
- )
- lo = QVBoxLayout(sidebar)
- lo.setContentsMargins(8, 8, 8, 8)
- lo.setSpacing(6)
- lo.addWidget(self._make_file_buttons())
- lo.addWidget(self._make_profile_buttons())
- self._tabs = QTabWidget()
- self._tabs.tabBar().setExpanding(False)
- self._tabs.tabBar().setDrawBase(False)
- self._tabs.setStyleSheet("""
- QTabWidget::pane { border: 1px solid #333; background: #252525; }
- QTabBar::tab {
- background: #2b2b2b; color: #aaa; padding: 5px 18px;
- border: 1px solid #333; border-bottom: none; border-radius: 4px 4px 0 0;
- }
- QTabBar::tab:selected { background: #1F6AA5; color: white; }
- """)
- # Centrage : décaler la tabBar via son parent layout
- from PyQt6.QtCore import QTimer as _QT
- def _center_tabs():
- tb = self._tabs.tabBar()
- total = sum(tb.tabRect(i).width() for i in range(tb.count()))
- offset = max(0, (tb.parent().width() - total) // 2)
- tb.setGeometry(offset, tb.y(), tb.parent().width() - offset, tb.height())
- self._tabs.tabBar().installEventFilter(self)
- self._center_tabs_fn = _center_tabs
- _QT.singleShot(0, _center_tabs)
- self._tab_geom = self._make_scrollable_tab()
- self._tab_img = self._make_scrollable_tab()
- self._tab_laser = self._make_scrollable_tab()
- self._tab_gcode = self._make_scrollable_tab()
- self._tabs.addTab(self._tab_geom, self.t.get("geometry", "Geometry"))
- self._tabs.addTab(self._tab_img, self.t.get("image", "Image"))
- self._tabs.addTab(self._tab_laser, self.t.get("laser", "Laser"))
- self._tabs.addTab(self._tab_gcode, self.t.get("gcode", "G-Code"))
- self._setup_tab_geom()
- self._setup_tab_img()
- self._setup_tab_laser()
- self._setup_tab_gcode()
- lo.addWidget(self._tabs, stretch=1)
- self.btn_gen = QPushButton(self.t.get("simulate_gcode", "Simulate G-Code"))
- self.btn_gen.setFixedHeight(50)
- self.btn_gen.setStyleSheet(
- "QPushButton{background:#1f538d;color:white;border-radius:8px;"
- "font-size:13px;font-weight:bold;border:none;}"
- "QPushButton:hover{background:#2a6dbd;}"
- )
- self.btn_gen.clicked.connect(self.generate_gcode)
- self.translation_map[self.btn_gen] = "simulate_gcode"
- lo.addWidget(self.btn_gen)
- return sidebar
- def _make_scrollable_tab(self):
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setFrameShape(QFrame.Shape.NoFrame)
- scroll.setStyleSheet("background:transparent;")
- container = QWidget()
- container.setStyleSheet("background:transparent;")
- lo = QVBoxLayout(container)
- lo.setAlignment(Qt.AlignmentFlag.AlignTop)
- lo.setContentsMargins(6, 6, 6, 6)
- lo.setSpacing(4)
- scroll.setWidget(container)
- scroll._inner = lo
- return scroll
- def _make_file_buttons(self):
- f = QFrame()
- self._file_buttons_frame = f
- f.setStyleSheet("QFrame{background:#333;border-radius:6px;}")
- lo = QVBoxLayout(f)
- lo.setContentsMargins(6, 6, 6, 6)
- lo.setSpacing(4)
- self.btn_input = QPushButton(self.t.get("select_image", "Select Image"))
- self.btn_input.setFixedHeight(32)
- self.btn_input.setStyleSheet(self._btn_style())
- self.btn_input.clicked.connect(self.select_input)
- self.translation_map[self.btn_input] = "select_image"
- self.btn_output = QPushButton(self.t.get("select_output", "Select Output"))
- self.btn_output.setFixedHeight(32)
- self.btn_output.setStyleSheet(self._btn_style())
- self.btn_output.clicked.connect(self.select_output)
- self.translation_map[self.btn_output] = "select_output"
- lo.addWidget(self.btn_input)
- lo.addWidget(self.btn_output)
- return f
- def _make_profile_buttons(self):
- f = QFrame()
- f.setStyleSheet("QFrame{background:transparent;border:none;}")
- lo = QHBoxLayout(f)
- lo.setContentsMargins(0, 0, 0, 0)
- lo.setSpacing(6)
- self.btn_load_prof = QPushButton(self.t.get("import_profile", "Import Profile"))
- self.btn_load_prof.setFixedHeight(28)
- self.btn_load_prof.setStyleSheet(self._btn_style(bg="#444"))
- self.btn_load_prof.clicked.connect(self.load_profile_from)
- self.translation_map[self.btn_load_prof] = "import_profile"
- self.btn_save_prof = QPushButton(self.t.get("export_profile", "Export Profile"))
- self.btn_save_prof.setFixedHeight(28)
- self.btn_save_prof.setStyleSheet(self._btn_style(bg="#444"))
- self.btn_save_prof.clicked.connect(self.export_profile)
- self.translation_map[self.btn_save_prof] = "export_profile"
- lo.addWidget(self.btn_load_prof, 1)
- lo.addWidget(self.btn_save_prof, 1)
- return f
- # ── Onglets ───────────────────────────────────────────────────────
- def _setup_tab_geom(self):
- lo = self._tab_geom._inner
- lbl_mode = QLabel(self.t.get("raster_mode", "raster_mode"))
- lbl_mode.setStyleSheet("color:#ddd;font-size:12px;")
- lo.addWidget(lbl_mode)
- self.translation_map[lbl_mode] = "raster_mode"
- self._raster_map = {
- "horizontal": "horizontal",
- "vertical": "vertical",
- }
- self._raster_map_inv = {v: k for k, v in self._raster_map.items()}
- self._raster_mode = "horizontal"
- mode_row = QHBoxLayout()
- self._btn_raster = {}
- for key, lbl in self._raster_map.items():
- b = QPushButton(lbl)
- b.setCheckable(True)
- b.setFixedHeight(28)
- b.setStyleSheet(self._seg_btn_style())
- b.clicked.connect(lambda _, k=key: self._on_raster_change(k))
- mode_row.addWidget(b)
- self._btn_raster[key] = b
- self._btn_raster["horizontal"].setChecked(True)
- lo.addLayout(mode_row)
- lo.addSpacing(6)
- self.width_label_widget, _ = self._add_slider_input(
- lo, "target_width", 5, 400, 30.0, "width",
- unclamped_entry=True)
- # Warning mémoire — visible si matrice trop grande
- self._mem_warn_label = QLabel()
- self._mem_warn_label.setWordWrap(True)
- self._mem_warn_label.setStyleSheet(
- "color: #FF9500; font-size: 11px; background: transparent; border: none;")
- self._mem_warn_label.setVisible(False)
- lo.addWidget(self._mem_warn_label)
- sw_row = QHBoxLayout()
- self.lbl_force_width = QLabel(self.t.get("force_width", "force_width"))
- self.lbl_force_width.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[self.lbl_force_width] = "force_width"
- self.sw_force_width = Switch()
- self.sw_force_width.toggled.connect(self._on_switch_with_delay)
- sw_row.addWidget(self.lbl_force_width)
- sw_row.addStretch()
- sw_row.addWidget(self.sw_force_width)
- lo.addLayout(sw_row)
- # line_step — label + valeur sur la même ligne (non éditable, piloté par Settings)
- ls_row = QHBoxLayout()
- lbl_ls = QLabel(self.t.get("line_step", "Line Step"))
- lbl_ls.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl_ls] = "line_step"
- self._line_step_value_lbl = QLabel("0.1307")
- self._line_step_value_lbl.setStyleSheet(
- "color:#aaa;font-size:12px;font-family:Consolas;"
- "background:#2a2a2a;border:1px solid #333;"
- "border-radius:4px;padding:2px 8px;"
- )
- self._line_step_value_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
- ls_row.addWidget(lbl_ls)
- ls_row.addStretch()
- ls_row.addWidget(self._line_step_value_lbl)
- lo.addLayout(ls_row)
- # Enregistrer dans controls pour que _get_val("line_step") fonctionne
- self.controls["line_step"] = {"label": self._line_step_value_lbl, "precision": 4, "is_int": False}
- self._add_slider_input(lo, "dpi_resolution", 10, 1200, 254, "dpi", is_int=True)
- lo.addSpacing(6)
- ORIGIN_KEYS = ["Lower-Left","Upper-Left","Lower-Right","Upper-Right","Center","Custom"]
- origin_opts = [(k, self.t_orig.get(k, k)) for k in ORIGIN_KEYS]
- self._add_combo(lo, "origin_point", origin_opts, "origin_mode",
- callback=self._on_origin_change)
- self.custom_offset_frame = QFrame()
- self.custom_offset_frame.setVisible(False)
- cf_lo = QVBoxLayout(self.custom_offset_frame)
- cf_lo.setContentsMargins(0, 0, 0, 0)
- self._add_simple_input(cf_lo, "custom_offset_x", 0.0, "custom_x")
- self._add_simple_input(cf_lo, "custom_offset_y", 0.0, "custom_y")
- lo.addWidget(self.custom_offset_frame)
- def _setup_tab_img(self):
- lo = self._tab_img._inner
- self._add_slider_input(lo, "contrast", -1.0, 1.0, 0.0, "contrast")
- self._add_slider_input(lo, "gamma", 0.1, 6.0, 1.0, "gamma")
- self._add_slider_input(lo, "thermal", 0.1, 3.0, 1.5, "thermal")
- sw_row = QHBoxLayout()
- lbl_inv = QLabel(self.t.get("invert_color", "invert_color"))
- lbl_inv.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl_inv] = "invert_color"
- self.sw_invert = Switch()
- self.sw_invert.toggled.connect(self._on_switch_with_delay)
- sw_row.addWidget(lbl_inv)
- sw_row.addStretch()
- sw_row.addWidget(self.sw_invert)
- lo.addLayout(sw_row)
- def _setup_tab_laser(self):
- lo = self._tab_laser._inner
- self._add_slider_input(lo, "feedrate", 500, 20000, 3000, "feedrate", is_int=True)
- self._add_slider_input(lo, "overscan", 0, 50, 10.0, "premove")
- pow_row = QHBoxLayout()
- left_p = QVBoxLayout()
- self._add_simple_input(left_p, "max_power", 40.0, "max_p")
- _, min_p_entry = self._add_simple_input(left_p, "min_power", 10.0, "min_p")
- # Re-vérifier le warning framing dès que min_power change
- min_p_entry.editingFinished.connect(
- lambda: self._check_framing_power_warning()
- if hasattr(self, '_framing_power_warning') else None
- )
- pow_row.addLayout(left_p)
- lo.addLayout(pow_row)
- self._add_slider_input(lo, "laser_latency", -20, 20, 0, "laser_latency")
- self._add_slider_input(lo, "gray_steps", 2, 256, 256, "gray_steps", is_int=True)
- def _setup_tab_gcode(self):
- lo = self._tab_gcode._inner
- sec_frame = QFrame()
- self._gcode_sec_frame = sec_frame
- sec_frame.setStyleSheet(
- "QFrame{background:#2b2b2b;border:1px solid #555;border-radius:8px;}"
- "QLabel{border:none;background:transparent;}"
- )
- sec_lo = QVBoxLayout(sec_frame)
- sec_lo.setContentsMargins(10, 8, 10, 8)
- sec_lo.setSpacing(4)
- hdr_row = QHBoxLayout()
- lbl_glob = QLabel(self.t.get("global_machine_params", "Global Machine Params"))
- lbl_glob.setStyleSheet("color:#FF9500;font-weight:bold;font-size:11px;")
- self.translation_map[lbl_glob] = "global_machine_params"
- self.lock_btn = QPushButton("🔒")
- self.lock_btn.setFixedSize(30, 26)
- self.lock_btn.setStyleSheet(
- "QPushButton{background:#444;color:white;border-radius:4px;border:none;font-size:13px;}"
- "QPushButton:hover{background:#666;}"
- )
- self.is_locked = True
- self.lock_btn.clicked.connect(self.toggle_machine_lock)
- hdr_row.addWidget(lbl_glob)
- hdr_row.addStretch()
- hdr_row.addWidget(self.lock_btn)
- sec_lo.addLayout(hdr_row)
- self._machine_container = QWidget()
- self._machine_container.setStyleSheet("background:transparent;")
- mc_lo = QVBoxLayout(self._machine_container)
- mc_lo.setContentsMargins(0, 0, 0, 0)
- mc_lo.setSpacing(4)
- self._add_combo(mc_lo, "cmd_mode",
- ["M67 (Analog)", "S (Spindle)"], "cmd_mode")
- row_em = QHBoxLayout()
- self._add_simple_input(row_em, "m67_output", 0, "m67_e_num", precision=0, box_length=40)
- self._add_simple_input(row_em, "ctrl_max_value", 100, "ctrl_max", precision=0)
- mc_lo.addLayout(row_em)
- self._add_combo(mc_lo, "firing_mode",
- ["M3/M5", "M4/M5"], "firing_mode")
- sec_lo.addWidget(self._machine_container)
- lo.addWidget(sec_frame)
- self.apply_lock_state()
- lo.addSpacing(8)
- lbl_h = QLabel(self.t.get("gcode_header", "gcode_header"))
- lbl_h.setStyleSheet("font-weight:bold;font-size:11px;color:#ddd;")
- self.translation_map[lbl_h] = "gcode_header"
- lo.addWidget(lbl_h)
- self.txt_global_header_preview = QTextEdit()
- self.txt_global_header_preview.setFixedHeight(28)
- self.txt_global_header_preview.setReadOnly(True)
- self.txt_global_header_preview.setStyleSheet(
- "QTextEdit{background:#222;color:#666;border:none;font-family:Consolas;font-size:9px;}")
- self.txt_global_header_preview.setPlainText("(Machine Settings Header…)")
- lo.addWidget(self.txt_global_header_preview)
- self.txt_header = QTextEdit()
- self.txt_header.setFixedHeight(50)
- self.txt_header.setStyleSheet(self._textedit_style())
- lo.addWidget(self.txt_header)
- lbl_f = QLabel(self.t.get("gcode_footer", "gcode_footer"))
- lbl_f.setStyleSheet("font-weight:bold;font-size:11px;color:#ddd;")
- self.translation_map[lbl_f] = "gcode_footer"
- lo.addWidget(lbl_f)
- self.txt_global_footer_preview = QTextEdit()
- self.txt_global_footer_preview.setFixedHeight(28)
- self.txt_global_footer_preview.setReadOnly(True)
- self.txt_global_footer_preview.setStyleSheet(
- "QTextEdit{background:#222;color:#666;border:none;font-family:Consolas;font-size:9px;}")
- self.txt_global_footer_preview.setPlainText("(Machine Settings Footer…)")
- lo.addWidget(self.txt_global_footer_preview)
- self.txt_footer = QTextEdit()
- self.txt_footer.setFixedHeight(50)
- self.txt_footer.setStyleSheet(self._textedit_style())
- lo.addWidget(self.txt_footer)
- lo.addSpacing(8)
- lbl_fr = QLabel(self.t.get("point_fram_options", "Pointing / Framing Options"))
- lbl_fr.setStyleSheet("font-weight:bold;font-size:11px;color:#ddd;")
- self.translation_map[lbl_fr] = "point_fram_options"
- lo.addWidget(lbl_fr)
- pause_row = QHBoxLayout()
- self._lbl_pause = QLabel(self.t.get("pause_command", "pause_command"))
- lbl_pause = self._lbl_pause
- lbl_pause.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl_pause] = "pause_command"
- self.pause_cmd_entry = QLineEdit("M0")
- self.pause_cmd_entry.setFixedWidth(60)
- self.pause_cmd_entry.setStyleSheet(self._entry_style())
- pause_row.addWidget(lbl_pause)
- pause_row.addWidget(self.pause_cmd_entry)
- pause_row.addStretch()
- lo.addLayout(pause_row)
- self.origin_pointer_var = False
- sw_ptr_row = QHBoxLayout()
- lbl_ptr = QLabel(self.t.get("origin_pointing", "origin_pointing"))
- lbl_ptr.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl_ptr] = "origin_pointing"
- self.sw_pointer = Switch()
- self.sw_pointer.toggled.connect(self._on_framing_toggle)
- sw_ptr_row.addWidget(lbl_ptr)
- sw_ptr_row.addStretch()
- sw_ptr_row.addWidget(self.sw_pointer)
- lo.addLayout(sw_ptr_row)
- self.frame_var = False
- sw_frm_row = QHBoxLayout()
- lbl_frm = QLabel(self.t.get("framing_option", "framing_option"))
- lbl_frm.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl_frm] = "framing_option"
- self.sw_frame = Switch()
- self.sw_frame.toggled.connect(self._on_framing_toggle)
- sw_frm_row.addWidget(lbl_frm)
- sw_frm_row.addStretch()
- sw_frm_row.addWidget(self.sw_frame)
- lo.addLayout(sw_frm_row)
- pwr_row = QHBoxLayout()
- self._lbl_framing_power = QLabel(self.t.get("framing_power", "framing_power"))
- lbl_fpwr = self._lbl_framing_power
- lbl_fpwr.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl_fpwr] = "framing_power"
- self.frame_power_entry = QLineEdit("0")
- self.frame_power_entry.setFixedWidth(60)
- self.frame_power_entry.setStyleSheet(self._entry_style())
- self.frame_power_entry.editingFinished.connect(self._check_framing_power_warning)
- pwr_row.addWidget(lbl_fpwr)
- pwr_row.addWidget(self.frame_power_entry)
- pwr_row.addStretch()
- lo.addLayout(pwr_row)
- # ── Warning puissance framing/pointing > min_power ────────────
- self._framing_power_warning = QLabel()
- self._framing_power_warning.setWordWrap(True)
- self._framing_power_warning.setStyleSheet(
- "QLabel{"
- "background:#5c2a00;color:#ffaa44;"
- "border:1px solid #ff7700;"
- "border-radius:5px;padding:4px 8px;"
- "font-size:11px;font-weight:bold;"
- "}"
- )
- self._framing_power_warning.setVisible(False)
- lo.addWidget(self._framing_power_warning)
- self._add_combo(lo, "framing_ratio",
- ["5%","10%","20%","30%","50%","80%","100%"],
- "frame_feed_ratio_menu")
- if "frame_feed_ratio_menu" in self.controls:
- self.controls["frame_feed_ratio_menu"]["combo"].setCurrentText("20%")
- self._update_framing_state()
- # ── Panneau droit ─────────────────────────────────────────────────
- def _build_right_panel(self):
- w = QWidget()
- self._right_panel = w
- w.setStyleSheet("background:#111;")
- lo = QVBoxLayout(w)
- lo.setContentsMargins(4, 4, 4, 4)
- lo.setSpacing(2)
- # Canvas + colorbar fixe côte à côte
- canvas_row = QHBoxLayout()
- canvas_row.setSpacing(2)
- canvas_row.setContentsMargins(0, 0, 0, 0)
- self._canvas = _RasterCanvas(self._fig)
- self._setup_matplotlib()
- canvas_row.addWidget(self._canvas, stretch=1)
- self._cbar_widget = _ColorbarWidget()
- self._cbar_widget.setVisible(False) # masquée jusqu'à chargement d'une image
- canvas_row.addWidget(self._cbar_widget)
- lo.addLayout(canvas_row, stretch=5)
- # Bouton Reset View — wrapper transparent pour éviter ligne noire
- # label_reset_view = "reset_view"
- fit_pixmap = get_svg_pixmap(SVG_ICONS["FIT"], QSize(24, 24), "#ffffff")
- # self.btn_reset_view = QPushButton(label_reset_view, self._canvas)
- self.btn_reset_view = QPushButton(self._canvas)
- self.btn_reset_view.setIcon(QIcon(fit_pixmap))
- self.btn_reset_view.setFixedSize(30, 30)
- self.btn_reset_view.setStyleSheet(
- "QPushButton{background:#2c2c2c;color:#aaa;border:none;"
- "border-radius:4px;font-size:10px;}"
- "QPushButton:hover{background:#3a3a3a;color:white;}"
- )
- self.btn_reset_view.clicked.connect(self._canvas.reset_view)
- self.btn_reset_view.raise_()
- # Stats + Histogramme
- stats_frame = QFrame()
- self._stats_frame = stats_frame
- stats_frame.setStyleSheet(
- "QFrame{background:#202020;border:1px solid #333;border-radius:6px;}"
- )
- stats_frame.setFixedHeight(160)
- sf_lo = QHBoxLayout(stats_frame)
- sf_lo.setContentsMargins(8, 6, 8, 6)
- sf_lo.setSpacing(8)
- stats_text_w = QFrame()
- stats_text_w.setStyleSheet("QFrame{border:none;background:transparent;}")
- stats_text_lo = QVBoxLayout(stats_text_w)
- stats_text_lo.setContentsMargins(8, 6, 8, 6)
- stats_text_lo.setAlignment(Qt.AlignmentFlag.AlignVCenter)
- self.stats_labels = []
- for _ in range(6):
- lbl = QLabel("")
- lbl.setFont(QFont("Consolas", 10))
- lbl.setStyleSheet("color:#aaaaaa;font-family:Consolas;font-size:12px;"
- "background:transparent;border:none;")
- stats_text_lo.addWidget(lbl)
- self.stats_labels.append(lbl)
- sf_lo.addWidget(stats_text_w)
- self._hist_widget = _HistogramWidget()
- sf_lo.addWidget(self._hist_widget, 2)
- lo.addWidget(stats_frame, stretch=2)
- return w
- def _setup_matplotlib(self):
- """Un seul axe, sans colorbar matplotlib — gérée en Qt."""
- self._ax = self._fig.add_subplot(111)
- self._fig.subplots_adjust(left=0.10, right=0.90, top=0.90, bottom=0.10)
- self._ax.set_facecolor("#111111")
- self._ax.tick_params(axis="both", colors="#888888", labelsize=9)
- self._ax.set_axisbelow(False)
- self._ax.grid(False) # masquée jusqu'à chargement d'une image
- for spine in self._ax.spines.values():
- spine.set_edgecolor("#333333")
- # Masquer ticks et labels tant qu'aucune image n'est chargée
- self._ax.tick_params(axis="both", length=0)
- self._ax.set_xticklabels([])
- self._ax.set_yticklabels([])
- _ph_text = self.t.get("choose_image", "PLEASE SELECT AN IMAGE\nTO BEGIN")
- self._placeholder_text = self._ax.text(
- 0.5, 0.5, _ph_text,
- color="#444444", fontsize=12, fontweight="bold",
- ha="center", va="center", transform=self._ax.transAxes
- )
- # ── Helpers construction ──────────────────────────────────────────
- def _add_slider_input(self, layout, label_key, vmin, vmax, default, key,
- is_int=False, precision=2, unclamped_entry=False):
- lbl = QLabel(self.t.get(label_key, label_key))
- lbl.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl] = label_key
- layout.addWidget(lbl)
- row = QHBoxLayout()
- slider = QSlider(Qt.Orientation.Horizontal)
- slider.setRange(int(vmin * 100) if not is_int else int(vmin),
- int(vmax * 100) if not is_int else int(vmax))
- slider.setValue(int(default * 100) if not is_int else int(default))
- entry = QLineEdit()
- entry.setFixedWidth(70)
- entry.setAlignment(Qt.AlignmentFlag.AlignCenter)
- entry.setStyleSheet(self._entry_style())
- fmt = "{:d}" if is_int else f"{{:.{precision}f}}"
- entry.setText(fmt.format(int(default) if is_int else default))
- def on_slider(v):
- real = v if is_int else v / 100.0
- entry.setText(fmt.format(int(real) if is_int else real))
- def on_entry():
- try:
- v = float(entry.text().replace(",", "."))
- # Si unclamped_entry, on borne le slider à vmax mais pas la valeur
- slider_v = min(int(v) if is_int else int(v * 100),
- int(vmax) if is_int else int(vmax * 100))
- slider.setValue(slider_v)
- self._schedule_preview()
- except ValueError:
- pass
- slider.valueChanged.connect(on_slider)
- slider.sliderReleased.connect(self._schedule_preview)
- entry.editingFinished.connect(on_entry)
- row.addWidget(slider)
- row.addWidget(entry)
- layout.addLayout(row)
- self.controls[key] = {
- "slider": slider, "entry": entry,
- "is_int": is_int, "precision": precision,
- "_vmin": vmin, "_vmax": vmax,
- "unclamped": unclamped_entry,
- }
- return lbl, entry
- def _add_simple_input(self, layout, label_key, default, key, precision=2, box_length=80):
- row = QHBoxLayout()
- lbl = QLabel(self.t.get(label_key, label_key))
- lbl.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl] = label_key
- entry = QLineEdit()
- entry.setFixedWidth(box_length)
- entry.setStyleSheet(self._entry_style())
- fmt = "{:d}" if precision == 0 else f"{{:.{precision}f}}"
- entry.setText(fmt.format(int(default) if precision == 0 else default))
- entry.editingFinished.connect(self._schedule_preview)
- row.addWidget(lbl)
- row.addStretch()
- row.addWidget(entry)
- layout.addLayout(row)
- self.controls[key] = {"slider": entry, "entry": entry,
- "is_int": (precision == 0), "precision": precision}
- return lbl, entry
- def _add_combo(self, layout, label_key, options, key, callback=None):
- lbl = QLabel(self.t.get(label_key, label_key))
- lbl.setStyleSheet("color:#ddd;font-size:12px;")
- self.translation_map[lbl] = label_key
- layout.addWidget(lbl)
- combo = QComboBox()
- combo.setFixedHeight(30)
- combo.setStyleSheet(self._combo_style())
- is_tuple = options and isinstance(options[0], tuple)
- if is_tuple:
- for k, v in options:
- combo.addItem(v, userData=k)
- else:
- for o in options:
- combo.addItem(o)
- def on_change():
- if callback:
- ud = combo.currentData()
- callback(ud if ud is not None else combo.currentText())
- self._schedule_preview()
- combo.currentIndexChanged.connect(on_change)
- layout.addWidget(combo)
- self.controls[key] = {"combo": combo, "_is_tuple": is_tuple, "_label": lbl}
- return combo
- # ── Styles ────────────────────────────────────────────────────────
- @staticmethod
- @staticmethod
- def _arrow_svg_path(color: str) -> str:
- src = SVG_ICONS.get("ARROW_DOWN", "")
- if not src or not os.path.isfile(src):
- return src.replace("\\", "/")
- try:
- with open(src, "r", encoding="utf-8") as f:
- svg = f.read()
- import re
- svg = re.sub(r'fill\s*=\s*"[^"]*"', f'fill="{color}"', svg)
- svg = re.sub(r'fill\s*:\s*[^;}"]+', f'fill:{color}', svg)
- svg = re.sub(r'stroke\s*=\s*"[^"]*"', f'stroke="{color}"',svg)
- svg = re.sub(r'stroke\s*:\s*[^;}"]+', f'stroke:{color}', svg)
- tmp = tempfile.NamedTemporaryFile(
- suffix=f'_arrow_{color.strip("#")}.svg',
- delete=False, mode='w', encoding='utf-8')
- tmp.write(svg); tmp.close()
- return tmp.name.replace("\\", "/")
- except Exception:
- return src.replace("\\", "/")
- @staticmethod
- def _btn_style(bg="#1F6AA5", hover="#2a6dbd"):
- return (f"QPushButton{{background:{bg};color:white;border-radius:6px;"
- f"border:none;font-weight:bold;}}"
- f"QPushButton:hover{{background:{hover};}}")
- def _seg_btn_style(self):
- c = self._theme_colors
- return (
- f"QPushButton{{background:{c['seg_bg']};color:{c['seg_text']};"
- f"border:1px solid {c['seg_border']};border-radius:4px;"
- f"padding:2px 8px;min-width:60px;font-size:11px;}}"
- f"QPushButton:checked{{background:#1F6AA5;color:white;border-color:#2a6dbd;}}"
- f"QPushButton:hover:!checked{{background:{c['seg_hover']};color:{c['seg_active_text']};}}"
- )
- def _entry_style(self):
- c = self._theme_colors
- return (f"QLineEdit{{background:{c['bg_entry']};border:1px solid {c['border_strong']};"
- f"border-radius:4px;color:{c['text']};padding:2px;}}")
- def _textedit_style(self):
- c = self._theme_colors
- return (f"QTextEdit{{background:{c['bg_entry_alt']};border:1px solid {c['border_strong']};"
- f"border-radius:6px;color:#8fbdf0;"
- f"font-family:Consolas;font-size:10px;padding:4px;}}")
- def _combo_style(self):
- c = self._theme_colors
- arrow_path = self._arrow_svg_path(c['arrow_color'])
- return (
- f"QComboBox{{background:{c['bg_entry']};border:1px solid {c['border_strong']};"
- f"border-radius:5px;padding:3px 30px 3px 10px;color:{c['text']};}}"
- f"QComboBox::drop-down{{subcontrol-origin:padding;"
- f"subcontrol-position:top right;width:25px;border:none;background:transparent;}}"
- f"QComboBox::down-arrow{{image:url({arrow_path});width:12px;height:8px;}}"
- f"QComboBox QAbstractItemView{{background:{c['bg_entry']};color:{c['text']};"
- f"selection-background-color:{c['combo_selection']};border:1px solid {c['border_strong']};}}"
- )
- # ── Handlers ──────────────────────────────────────────────────────
- def _on_raster_change(self, key):
- self._raster_mode = key
- for k, b in self._btn_raster.items():
- b.setChecked(k == key)
- if key == "vertical":
- self.width_label_widget.setText(self.t.get("target_height", "target_height"))
- self.lbl_force_width.setText(self.t.get("force_height", "force_height"))
- else:
- self.width_label_widget.setText(self.t.get("target_width", "target_width"))
- self.lbl_force_width.setText(self.t.get("force_width", "force_width"))
- self._sync_line_step_from_mode(key)
- self._schedule_preview()
- def _sync_line_step_from_mode(self, mode):
- """Met à jour le label line_step depuis hor_linestep ou ver_linestep."""
- setting_key = "hor_linestep" if mode == "horizontal" else "ver_linestep"
- raw = self.controller.config_manager.get_item("machine_settings", setting_key)
- try:
- val = float(raw) if raw is not None else 0.1307
- except (ValueError, TypeError):
- val = 0.1307
- if hasattr(self, "_line_step_value_lbl"):
- self._line_step_value_lbl.setText(f"{val:.4f}")
- def _on_origin_change(self, value):
- self.custom_offset_frame.setVisible(value == "Custom")
- def _on_framing_toggle(self, _=None):
- self._update_framing_state()
- def _update_framing_state(self):
- is_pointing = self.sw_pointer.isChecked()
- is_framing = self.sw_frame.isChecked()
- any_active = is_pointing or is_framing
- c = self._theme_colors
- # ── Activation fonctionnelle ──────────────────────────────────
- self.pause_cmd_entry.setEnabled(any_active)
- self.frame_power_entry.setEnabled(any_active)
- if "frame_feed_ratio_menu" in self.controls:
- self.controls["frame_feed_ratio_menu"]["combo"].setEnabled(is_framing)
- # ── Styles entrées ────────────────────────────────────────────
- entry_on = self._entry_style()
- entry_off = (
- f"QLineEdit{{background:{c.get('bg_entry','#2b2b2b')};color:#555555;"
- f"border:1px solid #3a3a3a;border-radius:4px;padding:2px;}}"
- )
- self.pause_cmd_entry.setStyleSheet(entry_on if any_active else entry_off)
- self.frame_power_entry.setStyleSheet(entry_on if any_active else entry_off)
- # ── Styles labels associés (widget par widget pour ne pas être écrasé) ──
- lbl_color_on = c.get('text', '#dddddd')
- lbl_color_off = '#555555'
- lbl_style_on = f"color:{lbl_color_on};font-size:12px;border:none;background:transparent;"
- lbl_style_off = "color:#555555;font-size:12px;border:none;background:transparent;"
- for lbl in [
- getattr(self, '_lbl_pause', None),
- getattr(self, '_lbl_framing_power', None),
- ]:
- if lbl is not None:
- lbl.setStyleSheet(lbl_style_on if any_active else lbl_style_off)
- # Label du combo framing_ratio (actif seulement si is_framing)
- if "frame_feed_ratio_menu" in self.controls:
- combo = self.controls["frame_feed_ratio_menu"]["combo"]
- lbl_ratio = self.controls["frame_feed_ratio_menu"].get("_label")
- if is_framing:
- combo.setStyleSheet(self._combo_style())
- if lbl_ratio:
- lbl_ratio.setStyleSheet(lbl_style_on)
- else:
- combo.setStyleSheet(
- f"QComboBox{{background:{c.get('bg_entry','#2b2b2b')};color:#555555;"
- f"border:1px solid #3a3a3a;border-radius:5px;"
- f"padding:3px 30px 3px 10px;}}"
- f"QComboBox::drop-down{{border:none;background:transparent;}}"
- f"QComboBox QAbstractItemView{{background:{c.get('bg_entry','#2b2b2b')};"
- f"color:{c.get('text','#ddd')};}}"
- )
- if lbl_ratio:
- lbl_ratio.setStyleSheet(lbl_style_off)
- # ── Warning puissance ─────────────────────────────────────────
- if hasattr(self, '_framing_power_warning'):
- self._check_framing_power_warning()
- def _check_framing_power_warning(self):
- """Affiche un warning si la puissance pointing/framing >= min_power (risque de gravure)."""
- if not hasattr(self, '_framing_power_warning'):
- return
- is_pointing = self.sw_pointer.isChecked()
- is_framing = self.sw_frame.isChecked()
- if not (is_pointing or is_framing):
- self._framing_power_warning.setVisible(False)
- return
- try:
- frame_pwr = float(self.frame_power_entry.text().replace(",", "."))
- except ValueError:
- self._framing_power_warning.setVisible(False)
- return
- min_pwr = self._get_val("min_p", default=0.0)
- if frame_pwr > 0 and frame_pwr >= min_pwr:
- warn_text = (
- f"⚠ Puissance pointing/framing ({frame_pwr:.1f}) "
- f"≥ puissance min ({min_pwr:.1f}) — "
- f"risque de gravure du matériau !"
- )
- self._framing_power_warning.setText(warn_text)
- self._framing_power_warning.setVisible(True)
- else:
- self._framing_power_warning.setVisible(False)
- def _on_switch_with_delay(self, _=None):
- QTimer.singleShot(120, self._schedule_preview)
- def _schedule_preview(self):
- if not self._loading:
- self._debounce_timer.start(80)
- # ── Loading Overlay ───────────────────────────────────────────────
- def _show_loading(self, msg=None):
- if hasattr(self, "_overlay"):
- return
- if msg is None:
- msg = self.t.get("loading", "Loading…")
- self._overlay = show_loading_overlay(self, msg)
- def _hide_loading(self):
- if hasattr(self, "_overlay"):
- hide_loading_overlay(self._overlay)
- del self._overlay
- def showEvent(self, e):
- super().showEvent(e)
- if getattr(self, '_render_pending', False):
- self._render_pending = False
- # Overlay immédiat visible dès que la vue apparaît
- if self.input_image_path and os.path.exists(self.input_image_path):
- self._show_loading(self.t.get("loading", "Loading…"))
- # Render au prochain tour d'événements — la vue est déjà affichée
- QTimer.singleShot(0, self._initial_render)
- def eventFilter(self, obj, event):
- from PyQt6.QtCore import QEvent
- if (hasattr(self, "_center_tabs_fn")
- and hasattr(self, "_tabs")
- and obj is self._tabs.tabBar()
- and event.type() == QEvent.Type.Resize):
- self._center_tabs_fn()
- return super().eventFilter(obj, event)
- def resizeEvent(self, e):
- super().resizeEvent(e)
- if hasattr(self, "_overlay"):
- self._overlay.resize(self.size())
- # ── Rendu / Preview ───────────────────────────────────────────────
- def _initial_render(self):
- # Afficher l'overlay si pas déjà visible (peut avoir été créé dans __init__)
- if self.input_image_path and os.path.exists(self.input_image_path):
- if not hasattr(self, '_overlay'):
- self._show_loading(self.t.get("loading", "Loading…"))
- try:
- self._do_update_preview(fit=True)
- finally:
- self._hide_loading()
- def _do_update_preview(self, fit=False):
- if self._loading:
- return
- if not self.input_image_path or not os.path.isfile(self.input_image_path):
- self._ax.grid(False)
- self._ax.tick_params(axis="both", length=0)
- self._ax.set_xticklabels([])
- self._ax.set_yticklabels([])
- for spine in self._ax.spines.values():
- spine.set_visible(False)
- self._cbar_widget.setVisible(False)
- self._canvas._after_draw_cb = None
- self._canvas.set_overlay(None)
- self._canvas.redraw(fit=fit)
- return
- res = self.process_logic()
- if not res or res[0] is None:
- if self._img_plot:
- self._img_plot.set_visible(False)
- self._canvas._after_draw_cb = None
- self._canvas.set_overlay(None)
- self._canvas.redraw(fit=fit)
- return
- matrix, geom = res
- real_w = geom["real_w"]
- real_h = geom["real_h"]
- rf = geom["rect_full"]
- offX, offY = self.calculate_offsets(real_w, real_h)
- v_min = self._get_val("min_p") or 0
- v_max = self._get_val("max_p") or 255
- # Nettoyage overscan patches + textes
- for p in self._overscan_patches:
- try: p.remove()
- except Exception: pass
- self._overscan_patches = []
- for t in self._overscan_texts:
- try: t.remove()
- except Exception: pass
- self._overscan_texts = []
- if self._rect_overscan is not None:
- try: self._rect_overscan.remove()
- except Exception: pass
- self._rect_overscan = None
- self._update_image_artist(matrix, offX, offY, real_w, real_h, v_min, v_max)
- # Colorbar Qt fixe
- self._cbar_widget.setVisible(True)
- self._cbar_widget.set_range(v_min, v_max,
- self.t.get("laser_power_level", "Laser Power"))
- # Overscan — calcul des rectangles (coordonnées mm exactes)
- direction = self._raster_mode
- ax = self._ax
- if direction == "horizontal":
- global_y = offY; global_h = real_h
- ow_l = abs(rf[0]); ow_r = rf[2] - real_w
- overscan_rects = []
- if ow_l > 0.1: overscan_rects.append((offX + rf[0], offY, ow_l, real_h))
- if ow_r > 0.1: overscan_rects.append((offX + real_w, offY, ow_r, real_h))
- else:
- oh_b = abs(rf[1]); oh_t = rf[3] - real_h
- global_y = offY + rf[1]; global_h = rf[3] - rf[1]
- overscan_rects = []
- if oh_b > 0.1: overscan_rects.append((offX, offY + rf[1], real_w, oh_b))
- if oh_t > 0.1: overscan_rects.append((offX, offY + real_h, real_w, oh_t))
- border_rect = (offX + rf[0], global_y, rf[2] - rf[0], global_h)
- decal = 0.5
- # Restaurer les spines et ticks maintenant qu'une image est affichée
- for spine in ax.spines.values():
- spine.set_visible(True)
- ax.set_xlim(offX + rf[0] - decal, offX + rf[2] + decal)
- ax.set_ylim(global_y - decal, global_y + global_h + decal)
- ax.tick_params(top=True, right=True, which="both")
- # Labels masqués dans matplotlib → redessinés en QPainter natif (nets au zoom)
- ax.xaxis.set_tick_params(labelbottom=False, labeltop=False)
- ax.yaxis.set_tick_params(labelleft=False, labelright=False)
- ax.tick_params(axis="both", colors="#888888", length=4, width=1)
- ax.set_axisbelow(False)
- ax.grid(False) # grille redessinée en QPainter natif (nette au zoom)
- if self._origin_marker:
- try: self._origin_marker[0].remove()
- except Exception: pass
- self._origin_marker = None # redessiné en QPainter natif
- # Callback appelé par redraw() après tight_layout+draw() — transData valide ici
- def _build_overlay(pw, ph):
- td = ax.transData
- tax = ax.transAxes
- def m2p(mx, my):
- d = td.transform((mx, my))
- return float(d[0]), float(ph - d[1])
- xlim = tuple(ax.get_xlim())
- ylim = tuple(ax.get_ylim())
- # Ticks réels de matplotlib (filtrés dans les limites)
- yticks = [v for v in ax.get_yticks() if ylim[0] - 1e-9 <= v <= ylim[1] + 1e-9]
- xticks = [v for v in ax.get_xticks() if xlim[0] - 1e-9 <= v <= xlim[1] + 1e-9]
- # Bords de l'axe en pixels pixmap
- ax_x0d, ax_y0d = tax.transform((0, 0))
- ax_x1d, ax_y1d = tax.transform((1, 1))
- ax_left = float(ax_x0d)
- ax_right = float(ax_x1d)
- ax_top = float(ph - ax_y1d)
- ax_bot = float(ph - ax_y0d)
- def fmt_val(v):
- if v == int(v):
- return str(int(v))
- return f"{v:.1f}"
- self._canvas.set_overlay({
- 'transform': m2p,
- 'xlim': xlim,
- 'ylim': ylim,
- 'overscan_rects': overscan_rects,
- 'border_rect': border_rect,
- 'origin': (0.0, 0.0),
- 'grid_xs': list(xticks),
- 'grid_ys': list(yticks),
- 'yticks': [(v, fmt_val(v)) for v in yticks],
- 'xticks': [(v, fmt_val(v)) for v in xticks],
- 'ax_left': ax_left,
- 'ax_right': ax_right,
- 'ax_top': ax_top,
- 'ax_bot': ax_bot,
- 'direction': direction,
- })
- self._canvas._after_draw_cb = _build_overlay
- est_min = geom.get("est_min", 0.0)
- ts = int(est_min * 60)
- hh, mm, ss = ts // 3600, (ts % 3600) // 60, ts % 60
- self._update_stats(geom["w_px"], geom["h_px"], real_w, real_h,
- geom["scan_step"], geom["l_step"], hh, mm, ss)
- self._hist_widget.update_data(
- matrix, v_min, v_max,
- label_title=self.t_stats.get("power_distribution", "Power Distribution"),
- label_power=self.t_stats.get("power_value", "Power"),
- label_count=self.t_stats.get("pixel_count", "Pixel count"),
- )
- self._canvas.redraw(fit=fit)
- def _update_image_artist(self, matrix, offX, offY, real_w, real_h, v_min, v_max):
- if self._placeholder_text is not None:
- try: self._placeholder_text.remove()
- except Exception: pass
- self._placeholder_text = None
- img_extent = [offX, offX + real_w, offY, offY + real_h]
- if self._img_plot is None:
- self._img_plot = self._ax.imshow(
- matrix, cmap="gray_r", origin="upper",
- extent=img_extent, aspect="equal",
- vmin=v_min, vmax=v_max,
- interpolation="nearest", zorder=1
- )
- else:
- self._img_plot.set_data(matrix)
- self._img_plot.set_extent(img_extent)
- self._img_plot.set_clim(v_min, v_max)
- self._img_plot.set_visible(True)
- def _update_stats(self, w_px, h_px, real_w, real_h,
- scan_step, line_step, hh, mm, ss):
- ts = self.t_stats
- lines = [
- f"{ts.get('real_dims','REAL DIMS'):<18}: {real_w:.2f} x {real_h:.2f} mm",
- f"{ts.get('est_time','EST. TIME'):<18}: {hh:02d}:{mm:02d}:{ss:02d}",
- f"{ts.get('file_size','FILE SIZE'):<18}: {self.estimated_file_size}",
- f"{ts.get('matrix_size','MATRIX'):<18}: {w_px} x {h_px} px",
- f"{ts.get('scan_step','SCAN STEP'):<18}: {scan_step:.4f} mm",
- f"{ts.get('line_step','LINE STEP'):<18}: {line_step:.4f} mm",
- ]
- for lbl, txt in zip(self.stats_labels, lines):
- lbl.setText(txt)
- # ── Logique moteur ────────────────────────────────────────────────
- def process_logic(self):
- if not self.input_image_path or not os.path.isfile(self.input_image_path):
- return None, None
- try:
- ui_dim = self._get_val("width")
- raster_mode = self._raster_mode
- settings = {
- "line_step": self._get_val("line_step"),
- "gamma": self._get_val("gamma"),
- "contrast": self._get_val("contrast"),
- "thermal": self._get_val("thermal"),
- "min_p": self._get_val("min_p"),
- "max_p": self._get_val("max_p"),
- "dpi": self._get_val("dpi"),
- "gray_steps": self._get_val("gray_steps"),
- "premove": self._get_val("premove"),
- "feedrate": self._get_val("feedrate"),
- "speed": self._get_val("feedrate"),
- "invert": self.sw_invert.isChecked(),
- "ui_dimension": ui_dim,
- "raster_mode": raster_mode,
- "force_dim": self.sw_force_width.isChecked(),
- }
- if settings["force_dim"]:
- if raster_mode == "horizontal":
- settings["width"] = ui_dim
- else:
- settings["height"] = ui_dim
- current_cache = None
- if self._source_img_path == self.input_image_path:
- current_cache = self._source_img_cache
- results = self.engine.process_image_logic(
- self.input_image_path, settings,
- source_img_cache=current_cache
- )
- matrix, img_obj, geom, mem_warn = results
- # Afficher ou masquer le warning mémoire
- if hasattr(self, "_mem_warn_label"):
- self._update_mem_warning(mem_warn, geom)
- if raster_mode == "vertical":
- geom["machine_step_x"] = geom.get("y_step", 0.1)
- geom["machine_step_y"] = geom.get("x_step", 0.1)
- else:
- geom["machine_step_x"] = geom.get("x_step", 0.1)
- geom["machine_step_y"] = geom.get("y_step", 0.1)
- self._source_img_cache = img_obj
- self._source_img_path = self.input_image_path
- self._last_matrix = matrix
- self._last_geom = geom
- self.estimated_file_size = geom.get("file_size_str", "N/A")
- return matrix, geom
- except Exception as e:
- import traceback; traceback.print_exc()
- return None, None
- def calculate_offsets(self, real_w, real_h):
- origin_ctrl = self.controls.get("origin_mode")
- origin_key = "Lower-Left"
- if origin_ctrl:
- combo = origin_ctrl["combo"]
- ud = combo.currentData()
- origin_key = ud if ud else combo.currentText()
- cx = self._get_val("custom_x") or 0.0
- cy = self._get_val("custom_y") or 0.0
- return self.engine.calculate_offsets(origin_key, real_w, real_h, cx, cy)
- def _update_mem_warning(self, mem_warn, geom):
- """Met à jour le label de warning mémoire."""
- if not hasattr(self, "_mem_warn_label"):
- return
- w_px = geom.get("w_px", 0)
- h_px = geom.get("h_px", 0)
- total = w_px * h_px
- rescaled = total < (self._get_val("width") / (25.4 / max(1, self._get_val("dpi"))))
- if total > 10_000_000:
- dpi_now = int(self._get_val("dpi"))
- dpi_sug = max(10, int(dpi_now * 0.7))
- tpl = self.t.get("mem_warn_hard",
- "⚠ Matrix too large ({total:.1f}M px) — image was rescaled."
- "\nTry reducing DPI (e.g. {dpi_sug} instead of {dpi_now}).")
- msg = tpl.format(total=total/1_000_000, dpi_sug=dpi_sug, dpi_now=dpi_now)
- self._mem_warn_label.setStyleSheet(
- "color: #e74c3c; font-size: 11px; background: transparent; border: none;")
- self._mem_warn_label.setText(msg)
- self._mem_warn_label.setVisible(True)
- elif mem_warn:
- dpi_now = int(self._get_val("dpi"))
- dpi_sug = max(10, int(dpi_now * 0.7))
- tpl = self.t.get("mem_warn_soft",
- "⚠ Large matrix ({total:.1f}M px) — may be slow."
- "\nConsider reducing DPI (e.g. {dpi_sug} instead of {dpi_now}).")
- msg = tpl.format(total=total/1_000_000, dpi_sug=dpi_sug, dpi_now=dpi_now)
- self._mem_warn_label.setStyleSheet(
- "color: #FF9500; font-size: 11px; background: transparent; border: none;")
- self._mem_warn_label.setText(msg)
- self._mem_warn_label.setVisible(True)
- else:
- self._mem_warn_label.setVisible(False)
- def _get_val(self, key, default=0.0):
- ctrl = self.controls.get(key)
- if not ctrl: return default
- try:
- # Cas label informatif (ex: line_step)
- if "label" in ctrl and "entry" not in ctrl:
- return float(ctrl["label"].text().replace(",", "."))
- return float(ctrl["entry"].text().replace(",", "."))
- except (ValueError, AttributeError):
- return default
- # ── Génération G-Code ─────────────────────────────────────────────
- # ── Helper : QMessageBox isolé du stylesheet parent ──────────────────────
- def _msgbox(self, icon: QMessageBox.Icon, title: str, text: str) -> None:
- """
- QMessageBox sans héritage de stylesheet parent.
- Évite le fond transparent/corrompu sous Linux causé par les styles
- globaux appliqués sur les widgets parents.
- """
- colors = getattr(self, '_theme_colors', {})
- bg = colors.get('bg_card_alt', '#2b2b2b')
- bg_btn = colors.get('btn_neutral', '#3a3a3a')
- bg_hov = colors.get('btn_neutral_hover', '#4a4a4a')
- fg = colors.get('text', '#ffffff')
- border = colors.get('border', '#555555')
- mb = QMessageBox()
- mb.setIcon(icon)
- mb.setWindowTitle(title)
- mb.setText(text)
- mb.setStyleSheet(f"""
- QMessageBox {{
- background-color: {bg};
- color: {fg};
- }}
- QMessageBox QLabel {{
- color: {fg};
- background: transparent;
- border: none;
- font-size: 13px;
- padding: 4px;
- }}
- QPushButton {{
- background-color: {bg_btn};
- color: {fg};
- border: 1px solid {border};
- border-radius: 5px;
- padding: 5px 18px;
- font-size: 12px;
- min-width: 70px;
- }}
- QPushButton:hover {{
- background-color: {bg_hov};
- }}
- """)
- mb.exec()
- def generate_gcode(self):
- self.save_settings()
- res = self.process_logic()
- if not res or res[0] is None:
- self._msgbox(QMessageBox.Icon.Warning, "No image",
- "Please select a valid image first.")
- return
- matrix, geom = res
- real_w = geom["real_w"]
- real_h = geom["real_h"]
- offX, offY = self.calculate_offsets(real_w, real_h)
- raster_mode = self._raster_mode
- cmd_mode_val = self.controls["cmd_mode"]["combo"].currentText() \
- if "cmd_mode" in self.controls else "M67 (Analog)"
- firing_val = self.controls["firing_mode"]["combo"].currentText() \
- if "firing_mode" in self.controls else "M3/M5"
- origin_val = (self.controls["origin_mode"]["combo"].currentData()
- or self.controls["origin_mode"]["combo"].currentText()) \
- if "origin_mode" in self.controls else "Lower-Left"
- ratio_raw = "20%"
- if "frame_feed_ratio_menu" in self.controls:
- ratio_raw = self.controls["frame_feed_ratio_menu"]["combo"].currentText()
- global_h = self.controller.config_manager.get_item("machine_settings", "custom_header", "").strip()
- global_f = self.controller.config_manager.get_item("machine_settings", "custom_footer", "").strip()
- raster_h = self.txt_header.toPlainText().strip()
- raster_f = self.txt_footer.toPlainText().strip()
- full_header = f"{global_h}\n{raster_h}".strip() if global_h and raster_h else (global_h or raster_h)
- full_footer = f"{raster_f}\n{global_f}".strip() if global_f and raster_f else (global_f or raster_f)
- file_ext = self.controller.config_manager.get_item("machine_settings", "gcode_extension", ".nc")
- file_name = (os.path.splitext(os.path.basename(self.input_image_path))[0]
- if self.input_image_path else "export")
- payload = {
- "matrix": matrix,
- "dims": (geom["h_px"], geom["w_px"], geom["y_step"], geom["x_step"]),
- "estimated_size": self.estimated_file_size,
- "offsets": (offX, offY),
- "params": {
- "e_num": int(self._get_val("m67_e_num")),
- "use_s_mode": "S (Spindle)" in cmd_mode_val,
- "ctrl_max": self._get_val("ctrl_max"),
- "min_power": self._get_val("min_p"),
- "max_power": self._get_val("max_p"),
- "premove": self._get_val("premove"),
- "feedrate": self._get_val("feedrate"),
- "laser_latency": self._get_val("laser_latency"),
- "gray_scales": int(self._get_val("gray_steps")),
- "gray_steps": int(self._get_val("gray_steps")),
- "raster_mode": raster_mode,
- },
- "framing": {
- "is_pointing": self.sw_pointer.isChecked(),
- "is_framing": self.sw_frame.isChecked(),
- "f_pwr": self.frame_power_entry.text(),
- "f_ratio": ratio_raw.replace("%", ""),
- "f_pause": self.pause_cmd_entry.text().strip() or None,
- "use_s_mode": "S (Spindle)" in cmd_mode_val,
- "e_num": int(self._get_val("m67_e_num")),
- "base_feedrate": self._get_val("feedrate"),
- },
- "text_blocks": {"header": full_header, "footer": full_footer},
- "metadata": {
- "version": self.version,
- "mode": cmd_mode_val.split(" ")[0],
- "firing_cmd": firing_val.split("/")[0],
- "file_extension": file_ext,
- "file_name": file_name,
- "output_dir": self.output_dir,
- "origin_mode": origin_val,
- "real_w": real_w,
- "real_h": real_h,
- "est_sec": int(geom.get("est_min", 0) * 60),
- "raster_direction": raster_mode,
- }
- }
- self.controller.show_simulation(self.engine, payload, return_view="raster")
- # ── Sélection fichiers ────────────────────────────────────────────
- def select_input(self):
- path, _ = get_open_file(
- self, "Select Image", "",
- "Image files (*.jpg *.jpeg *.png *.bmp)"
- )
- if path:
- self.input_image_path = path
- self.btn_input.setText(os.path.basename(path).upper())
- self.btn_input.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
- self._canvas.request_auto_fit()
- self._canvas._fit_next = True
- self._schedule_preview()
- def select_output(self):
- directory = get_existing_directory(self, "Select Output Folder")
- if directory:
- self.output_dir = directory
- folder = os.path.basename(directory) or directory
- self.btn_output.setText(f"OUT: {folder.upper()}/")
- self.btn_output.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
- # ── Profils ───────────────────────────────────────────────────────
- def export_profile(self):
- path, _ = get_save_file(
- self, "Export Profile",
- os.path.join(self.application_path, "alig_profile.json"),
- "JSON (*.json)"
- )
- if path:
- data = self._collect_settings()
- machine_keys = ["cmd_mode", "firing_mode", "m67_e_num", "ctrl_max", "laser_latency"]
- export = {
- "machine_settings": {k: data.pop(k) for k in machine_keys if k in data},
- "raster_settings": data
- }
- success, err = save_json_file(path, export)
- if success:
- self._msgbox(QMessageBox.Icon.Information, "Export",
- "Profile saved successfully.")
- else:
- self._msgbox(QMessageBox.Icon.Critical, "Error",
- f"Export failed:\n{err}")
- def load_profile_from(self):
- path, _ = get_open_file(
- self, "Load Profile", self.application_path, "JSON (*.json)"
- )
- if path:
- data, err = load_json_file(path)
- if data:
- if "machine_settings" in data:
- self._apply_settings(data.get("machine_settings", {}), is_machine=True)
- if "raster_settings" in data:
- self._apply_settings(data.get("raster_settings", {}))
- else:
- self._apply_settings(data)
- self._schedule_preview()
- self._msgbox(QMessageBox.Icon.Information, "Profile",
- f"Loaded:\n{os.path.basename(path)}")
- else:
- self._msgbox(QMessageBox.Icon.Critical, "Error",
- f"Could not load:\n{err}")
- # ── Save / Load settings ──────────────────────────────────────────
- def save_settings(self):
- all_data = self._collect_settings()
- machine_keys = ["cmd_mode", "firing_mode", "m67_e_num", "ctrl_max", "laser_latency"]
- machine_updates = {k: all_data.pop(k) for k in machine_keys if k in all_data}
- current_machine = self.controller.config_manager.get_section("machine_settings") or {}
- current_machine.update(machine_updates)
- self.controller.config_manager.set_section("machine_settings", current_machine)
- self.controller.config_manager.set_section("raster_settings", all_data)
- self.controller.config_manager.save()
- def load_settings(self):
- machine_data = self.controller.config_manager.get_section("machine_settings") or {}
- raster_data = self.controller.config_manager.get_section("raster_settings") or {}
- self._loading = True
- self._apply_settings(machine_data, is_machine=True)
- self._apply_settings(raster_data)
- self._loading = False
- # Synchronise line_step depuis hor/ver_linestep selon le mode actif
- self._sync_line_step_from_mode(self._raster_mode)
- # Mettre à jour le placeholder si visible
- if self._placeholder_text is not None:
- self._placeholder_text.set_text(
- self.t.get("choose_image", "PLEASE SELECT AN IMAGE\nTO BEGIN"))
- if not self.input_image_path:
- self._canvas.redraw()
- # Mettre à jour le label de la colorbar selon la langue
- if hasattr(self, "_cbar_widget") and self._cbar_widget.isVisible():
- self._cbar_widget._label = self.t.get("laser_power_level", "Laser Power")
- self._cbar_widget.update()
- self.refresh_global_previews()
- def _collect_settings(self):
- data = {}
- for k, ctrl in self.controls.items():
- if "combo" in ctrl:
- ud = ctrl["combo"].currentData()
- data[k] = ud if ud else ctrl["combo"].currentText()
- elif "entry" in ctrl:
- try:
- data[k] = float(ctrl["entry"].text().replace(",", "."))
- except ValueError:
- data[k] = ctrl["entry"].text()
- data["input_path"] = self.input_image_path
- data["output_dir"] = self.output_dir
- data["custom_header"] = self.txt_header.toPlainText().strip()
- data["custom_footer"] = self.txt_footer.toPlainText().strip()
- data["include_frame"] = self.sw_frame.isChecked()
- data["include_pointer"] = self.sw_pointer.isChecked()
- data["force_width"] = self.sw_force_width.isChecked()
- data["invert_relief"] = self.sw_invert.isChecked()
- data["frame_power"] = self.frame_power_entry.text()
- data["custom_pause_cmd"]= self.pause_cmd_entry.text()
- data["raster_mode"] = self._raster_mode
- return data
- def _apply_settings(self, data, is_machine=False):
- if not data: return
- for k, v in data.items():
- if k not in self.controls: continue
- ctrl = self.controls[k]
- if "combo" in ctrl:
- combo = ctrl["combo"]
- for i in range(combo.count()):
- if combo.itemData(i) == v or combo.itemText(i) == str(v):
- combo.setCurrentIndex(i)
- break
- elif "entry" in ctrl:
- try:
- is_int = ctrl.get("is_int", False)
- prec = ctrl.get("precision", 2)
- fmt = "{:d}" if is_int else f"{{:.{prec}f}}"
- val = float(v)
- ctrl["entry"].setText(fmt.format(int(val) if is_int else val))
- sl = ctrl.get("slider")
- if sl and sl is not ctrl["entry"]:
- sl.setValue(int(val) if is_int else int(val * 100))
- except (ValueError, TypeError):
- pass
- sw_map = {
- "invert_relief": self.sw_invert,
- "include_frame": self.sw_frame,
- "include_pointer": self.sw_pointer,
- "force_width": self.sw_force_width,
- }
- for key, sw in sw_map.items():
- if key in data:
- sw.setChecked(bool(data[key]))
- if "raster_mode" in data:
- self._on_raster_change(data["raster_mode"])
- if not is_machine:
- raw_path = data.get("input_path", "")
- if raw_path and os.path.isfile(raw_path):
- self.input_image_path = raw_path
- self.btn_input.setText(os.path.basename(raw_path).upper())
- self.btn_input.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
- out = data.get("output_dir", "")
- if out:
- self.output_dir = out
- folder = os.path.basename(out) or out
- self.btn_output.setText(f"OUT: {folder.upper()}/")
- self.btn_output.setStyleSheet(self._btn_style(bg="#2d5a27", hover="#367a31"))
- if "custom_header" in data:
- self.txt_header.setPlainText(data["custom_header"])
- if "custom_footer" in data:
- self.txt_footer.setPlainText(data["custom_footer"])
- if "frame_power" in data:
- self.frame_power_entry.setText(str(data["frame_power"]))
- if "custom_pause_cmd" in data:
- self.pause_cmd_entry.setText(str(data["custom_pause_cmd"]))
- if data.get("origin_mode") == "Custom":
- self.custom_offset_frame.setVisible(True)
- self._update_framing_state()
- # ── Lock machine params ───────────────────────────────────────────
- def toggle_machine_lock(self):
- self.is_locked = not self.is_locked
- self.apply_lock_state()
- def apply_lock_state(self):
- self.lock_btn.setText("\U0001f512" if self.is_locked else "\U0001f513")
- self._machine_container.setEnabled(not self.is_locked)
- c = self._theme_colors
- style = c['locked_btn'] if self.is_locked else "#D32F2F"
- self.lock_btn.setStyleSheet(
- f"QPushButton{{background:{style};color:{c['locked_text']};"
- f"border-radius:4px;border:none;font-size:13px;}}"
- f"QPushButton:hover{{background:{c['locked_btn_hover']};}}"
- )
- # Grisage widget par widget (priorité sur les styles inline appliqués par apply_theme)
- from PyQt6.QtWidgets import QLabel as _QL, QLineEdit as _QLE, QComboBox as _QCB
- if self.is_locked:
- for lbl in self._machine_container.findChildren(_QL):
- lbl.setStyleSheet(
- "color:#555555;font-size:12px;border:none;background:transparent;"
- )
- for entry in self._machine_container.findChildren(_QLE):
- entry.setStyleSheet(
- f"QLineEdit{{color:#555555;border:1px solid #3a3a3a;"
- f"background:{c.get('bg_entry','#2b2b2b')};border-radius:4px;padding:2px;}}"
- )
- for combo in self._machine_container.findChildren(_QCB):
- combo.setStyleSheet(
- f"QComboBox{{color:#555555;border:1px solid #3a3a3a;"
- f"background:{c.get('bg_entry','#2b2b2b')};border-radius:5px;"
- f"padding:3px 30px 3px 10px;}}"
- f"QComboBox::drop-down{{border:none;background:transparent;}}"
- f"QComboBox QAbstractItemView{{background:{c.get('bg_entry','#2b2b2b')};"
- f"color:#555555;}}"
- )
- else:
- # Restaurer les styles normaux du thème
- for lbl in self._machine_container.findChildren(_QL):
- lbl.setStyleSheet(
- f"color:{c['text']};font-size:12px;border:none;background:transparent;"
- )
- for entry in self._machine_container.findChildren(_QLE):
- entry.setStyleSheet(self._entry_style())
- for combo in self._machine_container.findChildren(_QCB):
- combo.setStyleSheet(self._combo_style())
- # ── Global previews ───────────────────────────────────────────────
- def refresh_global_previews(self):
- gh = self.controller.config_manager.get_item("machine_settings", "custom_header", "")
- gf = self.controller.config_manager.get_item("machine_settings", "custom_footer", "")
- self.txt_global_header_preview.setPlainText(
- gh if gh else self.t.get("no_global_header", "(Machine Settings Header…)"))
- self.txt_global_footer_preview.setPlainText(
- gf if gf else self.t.get("no_global_footer", "(Machine Settings Footer…)"))
- # ── Traduction dynamique ──────────────────────────────────────────
- def apply_theme(self, colors):
- """Met à jour toutes les couleurs de la vue selon le thème reçu."""
- self._theme_colors = colors
- bg_main = colors['bg_main']
- bg_card = colors['bg_card_alt']
- border = colors['border']
- text = colors['text']
- text_sec = colors['text_secondary']
- bg_tabs = colors['bg_tabs']
- bg_tab = colors['bg_tab']
- bg_right = colors['bg_deep']
- bg_stats = colors['bg_stats']
- bg_prev = colors['bg_preview']
- col_prev = colors['preview_text']
- btn_prof = colors['profile_btn']
- btn_prof_text = colors['profile_btn_text']
- sec_brd = colors['border_light']
- stats_col = colors['stats_text']
- canvas_fc = bg_right
- ax_col = colors['stats_text']
- spine_col = colors['border']
- ph_col = colors['seg_text']
- entry_col = colors['text']
- # ── Sidebar ───────────────────────────────────────────────────
- if hasattr(self, "_sidebar"):
- self._sidebar.setStyleSheet(
- f"QFrame#rasterSidebar{{background:{bg_main};border-right:1px solid {border};}}"
- f"QFrame#rasterSidebar > QLabel{{border:none;background:transparent;color:{text};}}"
- )
- # ── Cadre boutons fichiers ────────────────────────────────────
- if hasattr(self, "_file_buttons_frame"):
- self._file_buttons_frame.setStyleSheet(
- f"QFrame{{background:{btn_prof};border-radius:6px;}}"
- )
- # ── Boutons Import/Export profil ──────────────────────────────
- prof_style = (
- f"QPushButton{{background:{btn_prof};color:{btn_prof_text};"
- f"border-radius:6px;border:none;font-weight:bold;}}"
- f"QPushButton:hover{{background:{bg_card};}}"
- )
- if hasattr(self, "btn_load_prof"):
- self.btn_load_prof.setStyleSheet(prof_style)
- if hasattr(self, "btn_save_prof"):
- self.btn_save_prof.setStyleSheet(prof_style)
- # ── Onglets ───────────────────────────────────────────────────
- tab_col = colors['seg_text']
- if hasattr(self, "_tabs"):
- self._tabs.setStyleSheet(f"""
- QTabWidget::pane {{ border: 1px solid {border}; background: {bg_tabs}; }}
- QTabBar::tab {{
- background: {bg_tab}; color: {tab_col}; padding: 5px 12px;
- border: 1px solid {border}; border-bottom: none;
- border-radius: 4px 4px 0 0;
- }}
- QTabBar::tab:selected {{ background: #1F6AA5; color: white; }}
- """)
- for tab in [self._tab_geom, self._tab_img, self._tab_laser, self._tab_gcode]:
- tab.setStyleSheet(f"background:{bg_tabs};")
- if tab.widget():
- tab.widget().setStyleSheet(f"background:{bg_tabs};")
- tab.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;
- }}
- """)
- # ── Labels dans les onglets ───────────────────────────────────
- for tab in [self._tab_geom, self._tab_img, self._tab_laser, self._tab_gcode]:
- for lbl in tab.findChildren(QLabel):
- if not lbl.styleSheet().__contains__("font-weight:bold") and \
- not lbl.styleSheet().__contains__("FF9500"):
- lbl.setStyleSheet(f"color:{text};font-size:12px;border:none;background:transparent;")
- # ── Boutons raster mode ───────────────────────────────────────
- for btn in self._btn_raster.values():
- btn.setStyleSheet(self._seg_btn_style())
- # ── QLineEdit des contrôles ───────────────────────────────────
- entry_style = self._entry_style()
- for ctrl in self.controls.values():
- if "entry" in ctrl and isinstance(ctrl["entry"], QLineEdit):
- ctrl["entry"].setStyleSheet(entry_style)
- # ── QComboBox des contrôles ───────────────────────────────────
- combo_style = self._combo_style()
- for ctrl in self.controls.values():
- if "combo" in ctrl:
- ctrl["combo"].setStyleSheet(combo_style)
- # ── Label line_step informatif ────────────────────────────────
- if hasattr(self, "_line_step_value_lbl"):
- self._line_step_value_lbl.setStyleSheet(
- f"color:{text_sec};font-size:12px;font-family:Consolas;"
- f"background:{bg_stats};border:1px solid {border};"
- "border-radius:4px;padding:2px 8px;"
- )
- # ── Champs texte libres (header/footer) ───────────────────────
- for te in [self.txt_header, self.txt_footer]:
- te.setStyleSheet(self._textedit_style())
- # ── Previews header/footer globaux ────────────────────────────
- prev_style = (
- f"QTextEdit{{background:{bg_prev};color:{col_prev};"
- f"border:none;font-family:Consolas;font-size:9px;}}"
- )
- for te in [self.txt_global_header_preview, self.txt_global_footer_preview]:
- te.setStyleSheet(prev_style)
- # ── Entrées simples ───────────────────────────────────────────
- for entry in [self.pause_cmd_entry, self.frame_power_entry]:
- entry.setStyleSheet(self._entry_style())
- # ── Section machine params (gcode tab) ───────────────────────
- if hasattr(self, "_gcode_sec_frame"):
- self._gcode_sec_frame.setStyleSheet(
- f"QFrame{{background:{bg_card};border:1px solid {sec_brd};border-radius:8px;}}"
- f"QLabel{{border:none;background:transparent;}}"
- )
- # ── État framing/pointing (après labels pour ne pas être écrasé) ──
- if hasattr(self, "sw_pointer"):
- self._update_framing_state()
- # ── Bouton cadenas ────────────────────────────────────────────
- if hasattr(self, "lock_btn"):
- self.apply_lock_state()
- # ── Panneau droit ─────────────────────────────────────────────
- if hasattr(self, "_right_panel"):
- self._right_panel.setStyleSheet(f"background:{bg_right};")
- # ── Canvas ────────────────────────────────────────────────────
- if hasattr(self, "_canvas"):
- self._canvas._bg_color = bg_right
- self._canvas.setStyleSheet(f"background:{bg_right};")
- self._canvas.update()
- if hasattr(self, "_stats_frame"):
- self._stats_frame.setStyleSheet(
- f"QFrame{{background:{bg_stats};border:1px solid {border};border-radius:6px;}}"
- )
- for lbl in self.stats_labels:
- lbl.setStyleSheet(
- f"color:{stats_col};font-family:Consolas;font-size:12px;"
- "background:transparent;border:none;"
- )
- if hasattr(self, "_cbar_widget"):
- self._cbar_widget.set_theme(bg_right, text_sec)
- if hasattr(self, "_hist_widget"):
- self._hist_widget.set_theme(bg_stats, text_sec)
- # ── Bouton reset view ─────────────────────────────────────────
- if hasattr(self, "btn_reset_view"):
- self.btn_reset_view.setStyleSheet(
- f"QPushButton{{background:{colors['btn_neutral']};color:{text_sec};border:none;"
- f"border-radius:4px;font-size:10px;}}"
- f"QPushButton:hover{{background:{colors['btn_neutral_hover']};color:{entry_col};}}"
- )
- from gui.utils_qt import get_svg_pixmap
- from PyQt6.QtCore import QSize
- fit_pix = get_svg_pixmap(SVG_ICONS["FIT"], QSize(24, 24), text)
- if not fit_pix.isNull():
- self.btn_reset_view.setIcon(QIcon(fit_pix))
- # ── Matplotlib ────────────────────────────────────────────────
- if hasattr(self, "_fig") and self._ax is not None:
- self._fig.set_facecolor(canvas_fc)
- self._ax.set_facecolor(canvas_fc)
- self._ax.tick_params(axis="both", colors=ax_col)
- for spine in self._ax.spines.values():
- spine.set_edgecolor(spine_col)
- if self._placeholder_text:
- self._placeholder_text.set_color(ph_col)
- self._canvas.redraw()
- def update_texts(self):
- lang = self.controller.config_manager.get_item("machine_settings", "language", "English")
- if not lang or lang not in TRANSLATIONS:
- lang = "English"
- self._lang = lang
- self.t = TRANSLATIONS[lang]["common"]
- self.t_stats = TRANSLATIONS[lang]["stats"]
- self.t_orig = TRANSLATIONS[lang]["origin_options"]
- translate_ui_widgets(self.translation_map, self.t)
- # Onglets
- self._tabs.setTabText(0, self.t.get("geometry", "Geometry"))
- self._tabs.setTabText(1, self.t.get("image", "Image"))
- self._tabs.setTabText(2, self.t.get("laser", "Laser"))
- self._tabs.setTabText(3, self.t.get("gcode", "G-Code"))
- # Recalculer le centrage après changement des textes
- if hasattr(self, "_center_tabs_fn"):
- from PyQt6.QtCore import QTimer
- QTimer.singleShot(0, self._center_tabs_fn)
- # Boutons mode raster
- for key, btn in self._btn_raster.items():
- btn.setText(self.t.get(key, key.capitalize()))
- # Combo origin_mode — les items sont "(key, texte)" fixés à la création
- origin_ctrl = self.controls.get("origin_mode")
- if origin_ctrl:
- combo = origin_ctrl["combo"]
- current_data = combo.currentData()
- ORIGIN_KEYS = ["Lower-Left","Upper-Left","Lower-Right","Upper-Right","Center","Custom"]
- combo.blockSignals(True)
- for i, key in enumerate(ORIGIN_KEYS):
- if i < combo.count():
- combo.setItemText(i, self.t_orig.get(key, key))
- combo.blockSignals(False)
- # Restaurer la sélection par userData
- if current_data:
- for i in range(combo.count()):
- if combo.itemData(i) == current_data:
- combo.setCurrentIndex(i)
- break
- # Mettre à jour le placeholder si visible
- if self._placeholder_text is not None:
- self._placeholder_text.set_text(
- self.t.get("choose_image", "PLEASE SELECT AN IMAGE\nTO BEGIN"))
- if not self.input_image_path:
- self._canvas.redraw()
- # Mettre à jour le label de la colorbar selon la langue
- if hasattr(self, "_cbar_widget") and self._cbar_widget.isVisible():
- self._cbar_widget._label = self.t.get("laser_power_level", "Laser Power")
- self._cbar_widget.update()
- self.refresh_global_previews()
|