#!/usr/bin/env python3

import sys
import math

from qtpy.QtCore import Signal, QPoint, QSize, Qt, QTimer, QSettings
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (QMenu, QAction, QPushButton, QColorDialog, QWidgetAction,
                             QDialog, QVBoxLayout, QHBoxLayout, QLabel)
from qtpy.QtWidgets import (QApplication, QHBoxLayout, QOpenGLWidget, QSlider,
                             QWidget)

import OpenGL.GL as GL
from OpenGL import GLU
from qtvcp.lib.qt_vismach.primitives import *

try:
    import hal
except ImportError:
    hal = None

class Window(QWidget):

    def __init__(self):
        super(Window, self).__init__()

        self.glWidget = GLWidget()

        self.xSlider = self.createSlider()
        self.ySlider = self.createSlider()
        self.zSlider = self.createSlider()
        self.zoomSlider = self.createSlider()
        self.zoomSlider.setRange(1000, 5600)
        self.zoomSlider.setPageStep(10)
        self.zoomSlider.setTickInterval(1)

        self.xSlider.valueChanged.connect(self.glWidget.setXRotation)
        self.glWidget.xRotationChanged.connect(self.xSlider.setValue)
        self.ySlider.valueChanged.connect(self.glWidget.setYRotation)
        self.glWidget.yRotationChanged.connect(self.ySlider.setValue)
        self.zSlider.valueChanged.connect(self.glWidget.setZRotation)
        self.glWidget.zRotationChanged.connect(self.zSlider.setValue)
        self.zoomSlider.valueChanged.connect(self.glWidget.setZoom)
        self.glWidget.zoomChanged.connect(self.zoomSlider.setValue)

        mainLayout = QHBoxLayout()
        mainLayout.addWidget(self.glWidget)
        mainLayout.addWidget(self.xSlider)
        mainLayout.addWidget(self.ySlider)
        mainLayout.addWidget(self.zSlider)
        mainLayout.addWidget(self.zoomSlider)
        self.setLayout(mainLayout)

        self.xSlider.setValue(112 * 16)
        self.ySlider.setValue(180 * 16)
        self.zSlider.setValue(0 * 16)
        self.zoomSlider.setValue(10)

        self.setWindowTitle("Qt Vismach")

    def createSlider(self):
        slider = QSlider(Qt.Vertical)

        slider.setRange(0, 360 * 16)
        slider.setSingleStep(16)
        slider.setPageStep(15 * 16)
        slider.setTickInterval(15 * 16)
        slider.setTickPosition(QSlider.TicksRight)

        return slider

    def setZoomRange(self, small, big):
        self.zoomSlider.setRange(small, big)

class GLWidget(QOpenGLWidget):
    xRotationChanged = Signal(int)
    yRotationChanged = Signal(int)
    zRotationChanged = Signal(int)
    zoomChanged = Signal(int)

    def __init__(self, parent=None):
        super(GLWidget, self).__init__(parent)

        # The settings button is a child of the actual Vismach GLWidget.
        # This also works when qt_vismach is embedded and started by QtVCP.
        self.settingsButton = QPushButton("⚙ Settings", self)
        self.settingsButton.setFixedHeight(28)
        self.settingsButton.setMinimumWidth(115)
        self.settingsButton.setStyleSheet(
            "QPushButton { background: rgba(45,45,45,220); color: white; "
            "border: 1px solid #888; border-radius: 4px; padding: 2px 8px; }"
            "QPushButton:hover { background: rgba(75,75,75,235); }"
        )
        self.settingsButton.clicked.connect(self.showSettingsMenu)
        self.settingsButton.raise_()

        # User preferences survive stopping/starting QtVCP.
        self._settings = QSettings("QtVCP", "QtVismach")

        self.model = None

        self.xRot = 1700
        self.yRot = 2850
        self.zRot = 0

        self.viewX = 0
        self.viewY = 0

        self.lastPos = QPoint()

        self.r_back = self.g_back = self.b_back = 0

        self.colors = {
        'DEFAULT': (1.0, 1.0, 0.0),
        'TRAVERSE': (0.30, 0.50, 0.50),
        'FEED': (1.00, 1.00, 1.00),
        'ARC': (1.00, 1.00, 1.00),
        'TOOLCHANGE': (1.00, 1.00, 1.00),
        'PROBE': (1.00, 1.00, 1.00),
        'ROTARYINDEX': (1.00, 1.00, 1.00),
        }

        self.plotdata = []
        self.plotColor = [1.0, 0.5, 0.5]
        self._user_plot_color_override = None
        self.plotWidth = 2
        self.plot_visible = True
        self.hud_visible = True
        self.lighting_enabled = True
        self.light_azimuth = 25
        self.light_elevation = 55
        self.light_intensity = 70
        self.light_ambient = 20
        self.menu_position = "top-right"
        self.material_color = None
        self.tool_color = None
        self._scene_colors_scanned = False
        self._material_color_nodes = []
        self._tool_color_nodes = []

        # Load saved values only after all defaults above exist.
        # Previously this was called too early, so AttributeError caused
        # the whole load routine to abort and settings silently reverted.
        self._load_user_settings()

        self.plotlen = 500000

        # Path segments: older fragments are stored as OpenGL Display Lists,
        # while only the current segment is drawn point by point.
        self.plot_lists = []
        self.plot_active = []
        self.plot_segment_size = 4000
        self.plot_min_distance = 0.4
        self._plot_min_distance_sq = self.plot_min_distance * self.plot_min_distance
        self._plot_max_segments = max(1, self.plotlen // self.plot_segment_size)

        self._widget_width = 0
        self._widget_height = 0

        # Where we are centering.
        self.xcenter = 0.0
        self.ycenter = 0.0
        self.zcenter = 0.0

        # Where the eye is
        self.distance = 10.0

        # Field of view in y direction
        self.fovy = 30.0

        # Position of clipping planes.
        self.near = 0.1
        self.far = 1000.0

        # View settings
        self.perspective = 0
        self.lat = 0
        self.minlat = -90
        self.maxlat = 90

        # does not show HUD by default
        self.hud = Hud()
        # self.hud.show("stuff")
        # While the machine is idle, traverse()/paintGL() is not continuously forced.
        # The timer still checks the position every 50 ms, but scene updates
        # are performed only while moving. One repaint is enough for
        # HUD/status updates while idle.
        self._last_hal_position = None
        self._hal_position_pins = []
        self._hal_position_pins_checked = False

        # HAL pins used by the Vismach scene are discovered once, after the
        # model is attached.  While the machine is moving we do not perform
        # this extra polling; normal Vismach updates already repaint the scene.
        self._vismach_hal_watch = []
        self._vismach_hal_last = None
        self._vismach_hal_discovered = False

        self._timer_poll_ms = 50
        self._idle_elapsed_ms = 0
        self._first_frame_pending = True
        self._mesh_materials_cache = None

        self.timer = QTimer()
        self.timer.timeout.connect(self._update_timer_tick)
        self.timer.start(self._timer_poll_ms)

    def _try_hal_pin(self, name):
        """Return a valid HAL pin name, or None if it is not available."""
        if hal is None or not isinstance(name, str) or not name:
            return None
        try:
            value = hal.get_value(name)
            # Reading is enough to verify that this is a real HAL name.
            float(value)
            return name
        except Exception:
            return None

    def _discover_vismach_hal_pins(self):
        """Build the HAL dependency list once from the already-built model.

        We deliberately inspect the actual Vismach object tree instead of
        parsing moja_maszyna_handler.py.  This also makes the mechanism safe
        for another machine configuration where some pins simply do not exist.
        """
        if self._vismach_hal_discovered or self.model is None or hal is None:
            return

        found = set()
        seen = set()

        def inspect_value(value):
            # Vismach primitives sometimes keep HAL expressions in tuples or
            # lists (for example in their coordinate arguments).
            if isinstance(value, str):
                pin = self._try_hal_pin(value)
                if pin is not None:
                    found.add(pin)
            elif isinstance(value, (tuple, list)):
                for item in value:
                    if isinstance(item, (str, tuple, list)):
                        inspect_value(item)

        def walk(obj):
            if obj is None:
                return
            oid = id(obj)
            if oid in seen:
                return
            seen.add(oid)

            d = getattr(obj, "__dict__", None)
            if d:
                # Explicit Vismach HAL variable used by HalTranslate,
                # HalRotate, HideCollection, HALColorRGB, HALColorFlip, etc.
                var = d.get("var")
                comp = d.get("comp")
                if comp is None and isinstance(var, str):
                    pin = self._try_hal_pin(var)
                    if pin is not None:
                        found.add(pin)

                # Also inspect tuple/list geometry parameters. This catches
                # HAL-adjustable primitives without requiring a hard-coded
                # class list.
                for key, value in d.items():
                    if key in ("parts", "v", "vn", "f", "d", "list"):
                        continue
                    if isinstance(value, (str, tuple, list)):
                        inspect_value(value)

            parts = getattr(obj, "parts", None)
            if parts:
                for child in parts:
                    walk(child)

        walk(self.model)

        # Preserve deterministic order for diagnostics and comparisons.
        self._vismach_hal_watch = sorted(found)
        self._vismach_hal_last = self._read_vismach_hal_watch()
        self._vismach_hal_discovered = True

        print("[Vismach HAL monitor] discovered %d pins:" % len(self._vismach_hal_watch))
        for name in self._vismach_hal_watch:
            print("   ", name)

    def _read_vismach_hal_watch(self):
        """Read the discovered pins. Missing pins are ignored safely."""
        if not self._vismach_hal_watch or hal is None:
            return ()
        values = []
        for name in self._vismach_hal_watch:
            try:
                values.append(float(hal.get_value(name)))
            except Exception:
                # A pin may disappear after startup; keep the monitor alive.
                values.append(None)
        return tuple(values)

    def _vismach_hal_changed_while_idle(self):
        """Check Vismach HAL dependencies only while the machine is idle."""
        if not self._vismach_hal_discovered:
            self._discover_vismach_hal_pins()

        if not self._vismach_hal_watch:
            return False

        current = self._read_vismach_hal_watch()
        previous = self._vismach_hal_last
        self._vismach_hal_last = current

        return previous is not None and current != previous

    def _get_hal_position(self):
        """Read the axis position without traversing the model."""
        if hal is None:
            return None

        if not self._hal_position_pins_checked:
            candidates = (
                "halui.axis.x.pos-relative",
                "halui.axis.y.pos-relative",
                "halui.axis.z.pos-relative",
                "halui.axis.a.pos-relative",
                "halui.axis.b.pos-relative",
                "halui.axis.c.pos-relative",
            )
            self._hal_position_pins = []
            for name in candidates:
                try:
                    hal.get_value(name)
                    self._hal_position_pins.append(name)
                except Exception:
                    pass
            self._hal_position_pins_checked = True

        if not self._hal_position_pins:
            return None

        values = []
        for name in self._hal_position_pins:
            try:
                values.append(float(hal.get_value(name)))
            except Exception:
                values.append(0.0)
        return tuple(values)

    def _mesh_needs_refresh(self):
        """Check whether MeshMaterial3D still has pending GUI work."""
        if self.model is None:
            return False

        if self._mesh_materials_cache is None:
            found = []
            seen = set()

            def walk(obj):
                if obj is None:
                    return
                oid = id(obj)
                if oid in seen:
                    return
                seen.add(oid)

                if isinstance(obj, MeshMaterial3D):
                    found.append(obj)
                    return

                parts = getattr(obj, "parts", None)
                if parts:
                    for child in parts:
                        walk(child)

            walk(self.model)
            self._mesh_materials_cache = found

        for mat in self._mesh_materials_cache:
            try:
                if getattr(mat, "_cut_worker_inflight", False):
                    return True
                if getattr(mat, "_cut_worker_pending", None):
                    return True
            except Exception:
                return True

        return False

    def _update_timer_tick(self):
        """50 ms scheduler: normal Vismach while moving, cheap HAL watch while idle."""
        if self._first_frame_pending:
            self.update()
            self._idle_elapsed_ms = 0
            return

        pos = self._get_hal_position()

        # If the standard position feedback is unavailable, keep the safe
        # fallback rather than trying to guess whether the machine moves.
        if pos is None:
            self.update()
            self._idle_elapsed_ms = 0
            return

        moving = (
            self._last_hal_position is not None
            and pos != self._last_hal_position
        )
        self._last_hal_position = pos

        if moving:
            # Do not run the additional Vismach HAL dependency polling while
            # the normal machine-motion repaint loop is active.
            self.update()
            self._idle_elapsed_ms = 0
            return

        # Machine is idle: check only the HAL values that are actually used by
        # this Vismach model.  A change causes exactly one repaint.
        if self._vismach_hal_changed_while_idle():
            self.update()
            self._idle_elapsed_ms = 0
            return

        if self._mesh_needs_refresh():
            self.update()
            self._idle_elapsed_ms = 0
            return

        self._idle_elapsed_ms += self._timer_poll_ms

    def freeze_plot_segment(self):
        """Zamroź bieżący fragment ścieżki w OpenGL Display List."""
        if len(self.plot_active) < 2:
            return

        list_id = GL.glGenLists(1)
        GL.glNewList(list_id, GL.GL_COMPILE)
        GL.glBegin(GL.GL_LINE_STRIP)

        for p, c in self.plot_active:
            GL.glColor3f(*c)
            GL.glVertex3f(*p)

        GL.glEnd()
        GL.glEndList()
        self.plot_lists.append(list_id)

        while len(self.plot_lists) > self._plot_max_segments:
            old = self.plot_lists.pop(0)
            GL.glDeleteLists(old, 1)
            del self.plotdata[:self.plot_segment_size]

        self.plot_active = [self.plot_active[-1]]

    def _load_user_settings(self):
        try:
            c = self._settings.value("plot/color", None)
            if c:
                vals = [float(x) for x in str(c).split(",")]
                if len(vals) == 3:
                    self.plotColor = vals
                    self._user_plot_color_override = list(vals)
                else:
                    self.plotColor = [1.0, 0.5, 0.5]
            self.plotWidth = int(self._settings.value("plot/width", 2))
            self.plot_visible = str(self._settings.value("plot/visible", True)).lower() not in ("false", "0")
            self.hud_visible = str(self._settings.value("hud/visible", True)).lower() not in ("false", "0")
            self.light_azimuth = int(self._settings.value("light/azimuth", 25))
            self.light_elevation = int(self._settings.value("light/elevation", 55))
            self.light_intensity = int(self._settings.value("light/intensity", 70))
            self.light_ambient = int(self._settings.value("light/ambient", 20))
            self.menu_position = str(self._settings.value("ui/menu_position", "top-right"))
            mc = self._settings.value("object/material_color", None)
            tc = self._settings.value("object/tool_color", None)
            if mc:
                vals = [float(x) for x in str(mc).split(",")]
                if len(vals) >= 3:
                    self.material_color = vals[:3]
            if tc:
                vals = [float(x) for x in str(tc).split(",")]
                if len(vals) >= 3:
                    self.tool_color = vals[:3]
        except Exception:
            pass

    def _save_user_settings(self):
        self._settings.setValue("plot/color", ",".join(str(float(x)) for x in self.plotColor))
        self._settings.setValue("plot/width", self.plotWidth)
        self._settings.setValue("plot/visible", self.plot_visible)
        self._settings.setValue("hud/visible", self.hud_visible)
        self._settings.setValue("light/azimuth", self.light_azimuth)
        self._settings.setValue("light/elevation", self.light_elevation)
        self._settings.setValue("light/intensity", self.light_intensity)
        self._settings.setValue("light/ambient", self.light_ambient)
        self._settings.setValue("ui/menu_position", self.menu_position)
        if self.material_color is not None:
            self._settings.setValue("object/material_color", ",".join(str(float(x)) for x in self.material_color))
        if self.tool_color is not None:
            self._settings.setValue("object/tool_color", ",".join(str(float(x)) for x in self.tool_color))
        self._settings.sync()

    def _position_settings_button(self):
        margin = 8
        w = self.settingsButton.sizeHint().width()
        h = self.settingsButton.height()
        pos = getattr(self, "menu_position", "top-right")
        if pos == "top-left":
            x, y = margin, margin
        elif pos == "middle-left":
            x, y = margin, max(margin, (self.height() - h) // 2)
        elif pos == "bottom-left":
            x, y = margin, max(margin, self.height() - h - margin)
        elif pos == "bottom-right":
            x, y = max(margin, self.width() - w - margin), max(margin, self.height() - h - margin)
        else:
            x, y = max(margin, self.width() - w - margin), margin
        self.settingsButton.setGeometry(x, y, w, h)
        self.settingsButton.raise_()

    def resizeEvent(self, event):
        self._position_settings_button()
        super(GLWidget, self).resizeEvent(event)

    def showSettingsMenu(self):
        menu = QMenu(self)

        pathMenu = menu.addMenu("Toolpath")
        actVisible = QAction("Show toolpath", menu)
        actVisible.setCheckable(True)
        actVisible.setChecked(getattr(self, "plot_visible", True))
        actVisible.triggered.connect(self.setPlotVisible)
        pathMenu.addAction(actVisible)

        actColor = QAction("Toolpath color...", menu)
        actColor.triggered.connect(self.setPlotColor)
        pathMenu.addAction(actColor)

        widthMenu = pathMenu.addMenu("Line width")
        group = []
        for width in range(1, 6):
            act = QAction(str(width), menu)
            act.setCheckable(True)
            act.setChecked(getattr(self, "plotWidth", 2) == width)
            act.triggered.connect(lambda checked=False, w=width: self.setPlotWidth(w))
            widthMenu.addAction(act)
            group.append(act)

        actClear = QAction("Clear toolpath", menu)
        actClear.triggered.connect(self.clearPlot)
        pathMenu.addAction(actClear)

        posMenu = menu.addMenu("Button position")
        positions = [("Top-left", "top-left"), ("Middle-left", "middle-left"),
                     ("Bottom-left", "bottom-left"), ("Top-right", "top-right"),
                     ("Bottom-right", "bottom-right")]
        for label, key in positions:
            act = QAction(label, menu)
            act.setCheckable(True)
            act.setChecked(getattr(self, "menu_position", "top-right") == key)
            act.triggered.connect(lambda checked=False, k=key: self.setMenuPosition(k))
            posMenu.addAction(act)

        colorMenu = menu.addMenu("Colors")
        actMaterialColor = QAction("Material color...", menu)
        actMaterialColor.triggered.connect(self.setMaterialColor)
        colorMenu.addAction(actMaterialColor)
        actToolColor = QAction("Tool color...", menu)
        actToolColor.triggered.connect(self.setToolColor)
        colorMenu.addAction(actToolColor)

        menu.addSeparator()

        actHud = QAction("Show HUD", menu)
        actHud.setCheckable(True)
        actHud.setChecked(getattr(self, "hud_visible", True))
        actHud.triggered.connect(self.setHudVisible)
        menu.addAction(actHud)

        material3dMenu = menu.addMenu("3D Material")
        actRebuildMaterial = QAction("Rebuild 3D material", menu)
        actRebuildMaterial.triggered.connect(self.rebuildMaterial3D)
        material3dMenu.addAction(actRebuildMaterial)

        lightMenu = menu.addMenu("Lighting")
        actLightSliders = QAction("Lighting controls...", menu)
        actLightSliders.triggered.connect(self.showLightingDialog)
        lightMenu.addAction(actLightSliders)

        menu.exec(self.settingsButton.mapToGlobal(self.settingsButton.rect().bottomLeft()))

    def setPlotVisible(self, visible):
        self.plot_visible = bool(visible)
        self._save_user_settings()
        self.update()

    def setPlotColor(self):
        current = QColor.fromRgbF(*getattr(self, "plotColor", [1.0, 0.5, 0.5]))
        color = QColorDialog.getColor(current, self, "Toolpath color")
        if color.isValid():
            self.plotColor = [color.redF(), color.greenF(), color.blueF()]
            self._user_plot_color_override = list(self.plotColor)
            self._save_user_settings()
            self.update()

    def _scene_subtree_has_tool(self, obj, seen=None):
        """Check whether the given subtree contains tool geometry."""
        if obj is None:
            return False
        if seen is None:
            seen = set()
        oid = id(obj)
        if oid in seen:
            return False
        seen.add(oid)

        # In moja_maszyna_handler.py the real tool contains a Capture() object
        # stored as ``tooltip``. GLWidget receives exactly the same object
        # as self.tool2view, so it is a more reliable identifier than the
        # geometry class name (here these are Sphere/CylinderZ).
        if getattr(self, "tool2view", None) is obj:
            return True

        name = type(obj).__name__.lower()
        if name.startswith("haltool"):
            return True

        parts = getattr(obj, "parts", None)
        if parts:
            for child in parts:
                if self._scene_subtree_has_tool(child, seen):
                    return True
        return False

    def _scan_scene_color_nodes(self):
        """Find Color() nodes responsible for the material and tool.

        The Vismach tree structure is not modified. We only locate existing
        Color wrappers and change their ``color`` attribute.
        """
        self._material_color_nodes = []
        self._tool_color_nodes = []
        self._tool_objects = []
        if self.model is None:
            return

        ColorClass = globals().get("Color")
        HALColorClass = globals().get("HALColorRGB")
        MeshClass = globals().get("MeshMaterial3D")
        color_classes = tuple(c for c in (ColorClass, HALColorClass) if c is not None)
        if not color_classes:
            return

        seen = set()

        def walk(obj, active_color=None):
            if obj is None:
                return
            oid = id(obj)
            if oid in seen:
                return
            seen.add(oid)

            if isinstance(obj, color_classes):
                active_color = obj

            if MeshClass is not None and isinstance(obj, MeshClass):
                if active_color is not None and active_color not in self._material_color_nodes:
                    self._material_color_nodes.append(active_color)

            if active_color is not None and self._scene_subtree_has_tool(obj, set()):
                if active_color not in self._tool_color_nodes:
                    self._tool_color_nodes.append(active_color)

            parts = getattr(obj, "parts", None)
            if parts:
                for child in parts:
                    walk(child, active_color)

        walk(self.model)

    def _apply_scene_colors(self):
        if self.model is None:
            return
        if not self._scene_colors_scanned:
            self._scan_scene_color_nodes()
            self._scene_colors_scanned = True

        # If the user has not selected a color yet, preserve the existing
        # handler color instead of changing the appearance on first startup.
        if self.material_color is None and self._material_color_nodes:
            c = getattr(self._material_color_nodes[0], "color", None)
            if c is not None and len(c) >= 3:
                self.material_color = [float(c[0]), float(c[1]), float(c[2])]

        if self.tool_color is None and self._tool_color_nodes:
            c = getattr(self._tool_color_nodes[0], "color", None)
            if c is not None and len(c) >= 3:
                self.tool_color = [float(c[0]), float(c[1]), float(c[2])]

        if self.material_color is not None:
            for node in self._material_color_nodes:
                old = getattr(node, "color", (1, 1, 1, 1))
                alpha = float(old[3]) if len(old) >= 4 else 1.0
                node.color = [*self.material_color, alpha]

        if self.tool_color is not None:
            for node in self._tool_color_nodes:
                old = getattr(node, "color", (1, 1, 1, 1))
                alpha = float(old[3]) if len(old) >= 4 else 1.0
                node.color = [*self.tool_color, alpha]

    def _choose_object_color(self, current, title):
        if current is None:
            current = [0.7, 0.7, 0.7]
        q = QColor.fromRgbF(float(current[0]), float(current[1]), float(current[2]))
        color = QColorDialog.getColor(q, self, title)
        if color.isValid():
            return [color.redF(), color.greenF(), color.blueF()]
        return None

    def setMaterialColor(self):
        self._apply_scene_colors()
        color = self._choose_object_color(self.material_color, "Material color")
        if color is not None:
            self.material_color = color
            self._apply_scene_colors()
            self._save_user_settings()
            self.update()

    def setToolColor(self):
        self._apply_scene_colors()
        color = self._choose_object_color(self.tool_color, "Tool color")
        if color is not None:
            self.tool_color = color
            self._apply_scene_colors()
            self._save_user_settings()
            self.update()

    def setPlotWidth(self, width):
        self.plotWidth = int(width)
        self._save_user_settings()
        self.update()

    def clearPlot(self):
        self.plotclear()
        self.update()

    def setHudVisible(self, visible):
        self.hud_visible = bool(visible)
        self._save_user_settings()
        self.update()

    def setMenuPosition(self, position):
        self.menu_position = position
        self._save_user_settings()
        self._position_settings_button()
        self.update()

    def showLightingDialog(self):
        dlg = QDialog(self)
        dlg.setWindowTitle("Lighting")
        dlg.setModal(False)
        layout = QVBoxLayout(dlg)

        controls = []
        for title, attr, lo, hi in (("Horizontal direction", "light_azimuth", 0, 360),
                                    ("Light elevation", "light_elevation", -90, 90),
                                    ("Light intensity", "light_intensity", 0, 100),
                                    ("Ambient light", "light_ambient", 0, 100)):
            row = QHBoxLayout()
            label = QLabel(f"{title}: {getattr(self, attr)}")
            slider = QSlider(Qt.Horizontal)
            slider.setRange(lo, hi)
            slider.setValue(int(getattr(self, attr)))
            slider.valueChanged.connect(lambda v, a=attr, l=label, t=title: self._setLightParam(a, v, l, t))
            row.addWidget(label)
            row.addWidget(slider)
            layout.addLayout(row)
            controls.append(slider)
        dlg.resize(420, 170)
        dlg.show()
        self._lighting_dialog = dlg

    def _setLightParam(self, attr, value, label, title):
        setattr(self, attr, int(value))
        label.setText(f"{title}: {int(value)}")
        self._applyLighting()
        self._save_user_settings()
        self.update()

    def _applyLighting(self):
        # Convert azimuth/elevation to a directional OpenGL light.
        az = math.radians(float(self.light_azimuth))
        el = math.radians(float(self.light_elevation))
        ca = math.cos(el)
        x = ca * math.cos(az)
        y = ca * math.sin(az)
        z = math.sin(el)
        power = self.light_intensity / 100.0
        ambient = self.light_ambient / 100.0
        GL.glLightfv(GL.GL_LIGHT0, GL.GL_POSITION, (x, y, z, 0.0))
        GL.glLightfv(GL.GL_LIGHT0, GL.GL_AMBIENT, (ambient, ambient, ambient, 0.0))
        GL.glLightfv(GL.GL_LIGHT0, GL.GL_DIFFUSE, (power, power, power, 0.0))

    def choosePlotColor(self, data):
        # Standard Vismach callers can still request DEFAULT/TRAVERSE/etc.,
        # but when the user selected a path color in Settings, that color
        # has priority and survives QtVCP/Vismach restarts.
        if self._user_plot_color_override is not None:
            self.plotColor = list(self._user_plot_color_override)
            return

        if isinstance(data, str):
            if data.upper() not in self.colors:
                return
            self.plotColor = self.colors[data.upper()]
            return
        elif isinstance(data, int):
            for n, i in enumerate(self.colors):
                if n == data:
                    self.plotColor = self.colors[i]
                    return

    def setColorsAttribute(self,attr, color):
        # by string
        if isinstance(attr.upper(), str):
            if not attr.upper() in self.colors:
                return
            self.colors[attr.upper()] = color
            return
        # by integer 0-6
        elif isinstance(attr, int):
            for n,i in enumerate(self.colors):
                if n == attr:
                    self.colors[i] = color
                    return

    def getOpenglInfo(self):
        info = """
            Vendor: {0}
            Renderer: {1}
            OpenGL Version: {2}
            Shader Version: {3}
        """.format(
            GL.glGetString(GL.GL_VENDOR),
            GL.glGetString(GL.GL_RENDERER),
            GL.glGetString(GL.GL_VERSION),
            GL.glGetString(GL.GL_SHADING_LANGUAGE_VERSION)
        )

        return info

    def minimumSizeHint(self):
        return QSize(50, 50)

    def sizeHint(self):
        return QSize(400, 400)

    def winfo_width(self):
        return self.geometry().width()

    def winfo_height(self):
        return self.geometry().height()

    def setXRotation(self, angle):
        angle = self.normalizeAngle(angle)
        if angle != self.xRot:
            self.xRot = angle
            self.xRotationChanged.emit(angle)
            self.update()

    def setYRotation(self, angle):
        angle = self.normalizeAngle(angle)
        if angle != self.yRot:
            self.yRot = angle
            self.yRotationChanged.emit(angle)
            self.update()

    def setZRotation(self, angle):
        angle = self.normalizeAngle(angle)
        if angle != self.zRot:
            self.zRot = angle
            self.zRotationChanged.emit(angle)
            self.update()

    def setZoom(self, depth):
        self.distance = depth
        self.update()

    def zoomin(self):
        self.distance = self.distance / 1.2 - 20
        if self.distance < self.near:
            self.distance = self.near
        self.update()

    def zoomout(self):
        self.distance = self.distance * 1.2 + 20
        if self.distance > self.far:
            self.distance = self.far
        self.update()

    def set_latitudelimits(self, minlat, maxlat):
        """Set the new "latitude" limits for rotations."""

        if maxlat > 180:
            return
        if minlat < -180:
            return
        if maxlat <= minlat:
            return
        self.maxlat = maxlat
        self.minlat = minlat

    def initializeGL(self):
        # basic_lighting
        GL.glLightfv(GL.GL_LIGHT0, GL.GL_POSITION, (1, -2, 10, 0))
        GL.glLightfv(GL.GL_LIGHT0, GL.GL_AMBIENT, (.2, .2, .2, 0))
        GL.glLightfv(GL.GL_LIGHT0, GL.GL_DIFFUSE, (.6, .6, .4, 0))
        GL.glLightfv(GL.GL_LIGHT0 + 1, GL.GL_POSITION, (-1, -2, 10, 30))
        GL.glLightfv(GL.GL_LIGHT0 + 1, GL.GL_AMBIENT, (.0, .0, .0, 0))
        GL.glLightfv(GL.GL_LIGHT0 + 1, GL.GL_DIFFUSE, (.0, .0, .4, 0))
        GL.glMaterialfv(GL.GL_FRONT_AND_BACK, GL.GL_AMBIENT_AND_DIFFUSE, (1, 1, 1, 0))
        GL.glDisable(GL.GL_CULL_FACE)
        GL.glEnable(GL.GL_LIGHTING)
        GL.glEnable(GL.GL_LIGHT0)
        GL.glEnable(GL.GL_LIGHT0 + 1)
        self._applyLighting()
        GL.glDepthFunc(GL.GL_LESS)
        GL.glEnable(GL.GL_DEPTH_TEST)
        GL.glMatrixMode(GL.GL_MODELVIEW)
        GL.glLoadIdentity()

    def paintGL(self):
        GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT)
        GL.glLoadIdentity()
        GL.glTranslated(0.0, 0.0, -10.0)
        GL.glRotated(self.xRot / 16.0, 1.0, 0.0, 0.0)
        GL.glRotated(self.yRot / 16.0, 0.0, 1.0, 0.0)
        GL.glRotated(self.zRot / 16.0, 0.0, 0.0, 1.0)

        # draw Objects
        GL.glPushMatrix()  # Protect our matrix

        # set view size
        w = self._widget_width
        h = self._widget_height
        GL.glViewport(0, 0, w, h)

        # Clear the background and depth buffer.
        GL.glClearColor(self.r_back, self.g_back, self.b_back, 0.)
        GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT)

        GL.glMatrixMode(GL.GL_PROJECTION)
        GL.glLoadIdentity()
        GLU.gluPerspective(self.fovy, float(w) / float(h), self.near, self.far)

        if 1:
            # Now translate the scene origin away from the world origin
            GL.glMatrixMode(GL.GL_MODELVIEW)
            mat = GL.glGetDoublev(GL.GL_MODELVIEW_MATRIX)
            GL.glLoadIdentity()
            GL.glTranslatef(-(self.xcenter + self.viewX), -(self.ycenter + self.viewY),
                             -(self.zcenter + self.distance))
            GL.glMultMatrixd(mat)
        else:
            GLU.gluLookAt(self.xcenter, self.ycenter, self.zcenter + self.distance,
                          self.xcenter, self.ycenter, self.zcenter,
                          0., 1., 0.)
            GL.glMatrixMode(GL.GL_MODELVIEW)

        # Call objects redraw method.
        self.drawObjects()
        self._first_frame_pending = False
        GL.glFlush()  # Tidy up
        GL.glPopMatrix()  # Restore the matrix

    def drawObjects(self, *args):
        if self._widget_width == 1: return
        if self.model is None: return

        # Lighting is always enabled. The sliders only change its parameters;
        # there is no need for an enable/disable option in the menu.
        GL.glEnable(GL.GL_LIGHTING)
        self._applyLighting()

        self._apply_scene_colors()
        self.model.traverse()
        # current coords: world
        # the matrices tool2view, work2view, and world2view
        # transform from tool/work/world coords to viewport coords
        # if we want to draw in tool coords, we need to do
        # "tool -> view -> world" (since the current frame is world)
        # and if we want to draw in work coords, we need
        # "work -> view -> world".  For both, we need to invert
        # the world2view matrix to do the second step
        view2world = invert(self.world2view.t)
        # likewise, for backplot, we want to transform the tooltip
        # position from tool coords (where it is [0,0,0]) to work
        # coords, so we need tool -> view -> work
        # so lets also invert the work2view matrix
        view2work = invert(self.work2view.t)

        # since backplot lines only need vertices, not orientation,
        # and the tooltip is at the origin, getting the tool coords
        # is easy
        tx, ty, tz = self.tool2view.t[3][:3]
        # now we have to transform them to the work frame
        wx = tx * view2work[0][0] + ty * view2work[1][0] + tz * view2work[2][0] + view2work[3][0]
        wy = tx * view2work[0][1] + ty * view2work[1][1] + tz * view2work[2][1] + view2work[3][1]
        wz = tx * view2work[0][2] + ty * view2work[1][2] + tz * view2work[2][2] + view2work[3][2]
        point = [wx, wy, wz]

        # Add a point only after the minimum distance has been exceeded.
        # This prevents very long paths from accumulating thousands of
        # unnecessary nearly identical points.
        add_point = False

        if not self.plotdata:
            add_point = True
        else:
            last = self.plotdata[-1][0]
            dx = point[0] - last[0]
            dy = point[1] - last[1]
            dz = point[2] - last[2]

            if (dx * dx + dy * dy + dz * dz) >= self._plot_min_distance_sq:
                add_point = True

        if add_point:
            self.plotdata.append([point, self.plotColor])
            self.plot_active.append((point, self.plotColor))

            if len(self.plot_active) >= self.plot_segment_size:
                self.freeze_plot_segment()

        # now lets draw something in the tool coordinate system
        # GL.glPushMatrix()
        # matrixes take effect in reverse order, so the next
        # two lines do "tool -> view -> world"
        # GL.glMultMatrixd(view2world)
        # GL.glMultMatrixd(self.tool2view.t)

        # do drawing here
        # cylinder normally goes to +Z, we want it down
        # GL.glTranslatef(0,0,-60)
        # GLU.gluCylinder(self.q1, 20, 20, 60, 32, 16)

        # back to world coords
        # GL.glPopMatrix()

        # we can also draw in the work coord system
        GL.glPushMatrix()
        # "work -> view -> world"
        GL.glMultMatrixd(view2world)
        GL.glMultMatrixd(self.work2view.t)
        # now we can draw in work coords, and whatever we draw
        # will move with the work, (if the work is attached to
        # a table or indexer or something that moves with
        # respect to the world

        # just a test object, sitting on the table
        # GLU.gluCylinder(self.q2, 40, 20, 60, 32, 16)

        # draw head up display
        if self.hud_visible and (hasattr(self.hud, "draw")):
            self.hud.draw()

        # draw backplot
        GL.glDisable(GL.GL_LIGHTING)

        if not self.plot_visible:
            GL.glEnable(GL.GL_LIGHTING) if self.lighting_enabled else GL.glDisable(GL.GL_LIGHTING)
            GL.glPopMatrix()
            return

        GL.glLineWidth(float(self.plotWidth))

        # Older segments are already compiled by OpenGL.
        for list_id in self.plot_lists:
            GL.glCallList(list_id)

        # Draw only the current small segment point by point.
        GL.glBegin(GL.GL_LINE_STRIP)
        last_color = None
        for p, c in self.plot_active:
            if c != last_color:
                GL.glColor3f(*c)
                last_color = c
            GL.glVertex3f(*p)
        GL.glEnd()

        if self.lighting_enabled:
            GL.glEnable(GL.GL_LIGHTING)
        else:
            GL.glDisable(GL.GL_LIGHTING)
        GL.glColor3f(1, 1, 1)
        GL.glLineWidth(1)
        GL.glDisable(GL.GL_BLEND)
        GL.glDepthFunc(GL.GL_LESS)

        # back to world again
        GL.glPopMatrix()

    def plotclear(self):
        del self.plotdata[:]
        self.plot_active = []

        for list_id in self.plot_lists:
            GL.glDeleteLists(list_id, 1)
        self.plot_lists.clear()

    def resizeGL(self, width, height):
        self._widget_width = width
        self._widget_height = height

        side = min(width, height)
        if side < 0:
            return

        GL.glViewport((width - side) // 2, (height - side) // 2, side,
                      side)

        GL.glMatrixMode(GL.GL_PROJECTION)
        GL.glLoadIdentity()
        GL.glOrtho(-0.5, +0.5, +0.5, -0.5, 4.0, 15.0)
        GL.glMatrixMode(GL.GL_MODELVIEW)

    def mousePressEvent(self, event):
        self.lastPos = event.pos()

    def mouseMoveEvent(self, event):
        dx = event.x() - self.lastPos.x()
        dy = event.y() - self.lastPos.y()

        if event.buttons() & Qt.LeftButton:
            scale = max(0.2, (self.distance ** 0.7) * 0.01)
            self.viewX -= dx * scale
            self.viewY += dy * scale
            # With continuous repainting disabled, the view must be explicitly
            # updated while dragging with the mouse.
            self.update()
        elif event.buttons() & Qt.RightButton:
            self.setXRotation(self.xRot + 8 * dy)
            self.setZRotation(self.zRot + 8 * dx)
        else:
            self.setXRotation(self.xRot + 8 * dy)
            self.setYRotation(self.yRot - 8 * dx)
        self.lastPos = event.pos()

    def rebuildMaterial3D(self):
        """Manually rebuild all MeshMaterial3D objects."""
        self.makeCurrent()
        try:
            self._reset_mesh_material()
        finally:
            self.doneCurrent()

    def _reset_mesh_material(self):
        """Rebuild all MeshMaterial3D objects."""
        if self.model is None:
            return False

        found = 0
        seen = set()

        def walk(obj):
            nonlocal found
            if obj is None:
                return

            oid = id(obj)
            if oid in seen:
                return
            seen.add(oid)

            if isinstance(obj, MeshMaterial3D):
                try:
                    obj.reset_material()
                    found += 1
                except Exception as e:
                    print("[MESH RESET] reset_material error:", repr(e))
                return

            parts = getattr(obj, "parts", None)
            if parts:
                for child in parts:
                    walk(child)

        walk(self.model)

        if found:
            self._mesh_materials_cache = None
            self.update()
            print(f"[MESH RESET] manual material reset, count={found}")
            return True

        print("[MESH RESET] MeshMaterial3D not found")
        return False

    def mouseDoubleClickEvent(self, event):
        if event.button() & Qt.RightButton:
            if event.modifiers() & Qt.ShiftModifier:
                self.makeCurrent()
                try:
                    self._reset_mesh_material()
                finally:
                    self.doneCurrent()
                event.accept()
                return

            self.plotclear()
            self.update()
            event.accept()

    def wheelEvent(self, event):
        # Use the mouse wheel to zoom in/out
        a = event.angleDelta().y() / 200
        if a < 0:
            self.zoomout()
        else:
            self.zoomin()
        event.accept()

    def normalizeAngle(self, angle):
        while angle < 0:
            angle += 360 * 16
        while angle > 360 * 16:
            angle -= 360 * 16
        return angle


def main(model, tool, work, size=10, hud=None, rotation_vectors=None, lat=0, lon=0):
    app = QApplication(sys.argv)

    window = Window()
    t = window.glWidget
    t.set_latitudelimits(-180, 180)

    if not hud is None:
        t.hud = hud
        t.hud.app = t #HUD needs to know where to draw

    world = Capture()

    t.model = Collection([model, world])
    t._mesh_materials_cache = None
    t._first_frame_pending = True
    t._last_hal_position = None
    t.distance = size * 3
    t.near = size * 0.01
    t.far = size * 10.0
    t.tool2view = tool
    t.world2view = world

    t.work2view = work

    window.setZoomRange(int(t.near),int(t.far))
    window.show()
    sys.exit(app.exec())


if __name__ == '__main__':
    print('This is a library file - it needs to be imported')

