# coding: utf-8
# python 2 compatible

# Copyright (c) 2026 TormachTips.com. All rights reserved.
# Licensed under the TormachTips Personal Use License.
# Permission is granted only for private personal use and private personal modification.
# No sharing, publication, distribution, resale, sublicensing, screenshots, code excerpts,
# benchmarks, or videos are permitted without prior written permission.
# Requests:         tormach.1100m@gmail.com
# Information page: https://tormachtips.com/plugins.htm

#############################################
##                                         ##
##    Gremlin Tool Holder Plugin 1.07      ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 1.07 - Standardized live config/folder naming on gremlin_tool_holder*; upgrades V7 Gremlin patch to canonical config path. - 9/17/2026
# 1.06 - Gremlin right-click Show/Hide Tool Holder control with persisted visibility. - 9/17/2026
# 1.05 - black holder edge pass with adjustable global line thickness; prepares SVG-generated holder visuals. - 9/17/2026
# 1.04 - explicit per-holder tool-length reference section; fixes stickout calculation when upper shank was counted. - 9/17/2026
# 1.03 - per-tool holder assignments plus multi-holder/image-ready config. - 9/17/2026
# 1.02 - derive cutter stickout from the active tool table Z length and configured holder
#        dimensions below the top-of-ATC-flange reference plane. Per-tool stickout
#        overrides remain available; invalid/zero tool lengths fall back safely. - 9/17/2026
# 1.01 - replace the generic cylinder with a configurable TTS/ER32-style holder model.
#        Dimensions are loaded from /home/operator/gcode/python/gremlin_config.json.
#        Supports a hex ER32 nut, relief/gutter, body, ATC ring, upper shank, and
#        optional per-tool stickout overrides. Existing 1.00 installs upgrade in place. - 9/17/2026
# 1.00 - add a generic translucent 1.5 inch diameter x 3.0 inch tall holder
#        above normal mill tools in Gremlin so buried tools remain easy to locate. - 9/17/2026

import os
import re
import time
import shutil
import fcntl
import json
import types
import glib
import gtk
import constants
import singletons
from ui_hooks import plugin

CURRENT_VER                    = "1.07"
SCRIPT_NAME                    = "Gremlin Tool Holder"
ENABLED                        = 1
DEV_MACHINE                    = 1
DEV_MACHINE_FLAG               = "/home/operator/gcode/python/dev_machine.txt"
PATCH_DELAY_MS                 = 3000
PATCH_LOCK_PATH                = "/tmp/tt_pathpilot_file_patch.lock"
PATCH_LOCK_TIMEOUT_SECONDS     = 30.0
TMC_ROOT                       = "/home/operator/tmc"
GREMLIN_RELATIVE_PATH          = os.path.join("lib", "python", "gremlin", "gremlin_draw.py")
CONFIG_PATH                    = "/home/operator/gcode/python/gremlin_tool_holder_config.json"
LEGACY_CONFIG_PATH             = "/home/operator/gcode/python/gremlin_config.json"
HOLDER_VISIBILITY_REDIS_HASH   = "machine_prefs"
HOLDER_VISIBILITY_REDIS_KEY    = "gremlin_show_tool_holder"
MENU_HOOK_DELAY_MS             = 3500
PATCH_MARKER                   = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V8"
OLD_PATCH_MARKER_V7            = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V7"
OLD_PATCH_MARKER_V6            = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V6"
OLD_PATCH_MARKER_V5            = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V5"
OLD_PATCH_MARKER_V4            = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V4"
OLD_PATCH_MARKER_V3            = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V3"
OLD_PATCH_MARKER_V2            = "BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V2"
OLD_PATCH_MARKER               = "BEAGLE_GREMLIN_GENERIC_TOOL_HOLDER_V1"

DEFAULT_CONFIG_TEXT = r'''
{
    "minimum_stickout_inches": 0.05,
    "default_holder_type": "TTS_ER32_GENERIC",
    "enabled": true,
    "tool_stickout_inches": {},
    "holder_types": {
        "TTS_ER40": {
            "sections": [
                {
                    "top_diameter_inches": 1.75,
                    "name": "ER40 hex collet nut",
                    "height_inches": 0.4,
                    "facets": 6,
                    "bottom_diameter_inches": 1.75
                },
                {
                    "top_diameter_inches": 1.75,
                    "name": "gutter / relief",
                    "height_inches": 0.44,
                    "facets": 32,
                    "bottom_diameter_inches": 1.75
                },
                {
                    "top_diameter_inches": 1.1,
                    "name": "holder body",
                    "height_inches": 0.4,
                    "facets": 32,
                    "bottom_diameter_inches": 1.1
                },
                {
                    "top_diameter_inches": 1.75,
                    "name": "ATC retention ring",
                    "height_inches": 0.343,
                    "facets": 32,
                    "bottom_diameter_inches": 1.75,
                    "tool_length_reference": true
                },
                {
                    "top_diameter_inches": 0.75,
                    "name": "upper TTS shank",
                    "height_inches": 1.375,
                    "facets": 32,
                    "bottom_diameter_inches": 0.75
                }
            ],
            "image": "gremlin_tool_holders/tts_er40.svg",
            "display_name": "TTS ER40",
            "family": "",
            "manufacturer": ""
        },
        "TTS_ER32_GENERIC": {
            "display_name": "TTS ER32 GENERIC",
            "image": "gremlin_tool_holders/tts_er32_generic.svg",
            "sections": [
                {
                    "height_inches": 0.4,
                    "facets": 6,
                    "top_diameter_inches": 1.34,
                    "name": "ER32 hex collet nut",
                    "bottom_diameter_inches": 1.34
                },
                {
                    "height_inches": 0.44,
                    "facets": 32,
                    "top_diameter_inches": 1.34,
                    "name": "gutter / relief",
                    "bottom_diameter_inches": 1.34
                },
                {
                    "height_inches": 0.885,
                    "facets": 32,
                    "top_diameter_inches": 1.1,
                    "name": "holder body",
                    "bottom_diameter_inches": 1.1
                },
                {
                    "height_inches": 0.343,
                    "facets": 32,
                    "top_diameter_inches": 1.5,
                    "name": "ATC retention ring",
                    "bottom_diameter_inches": 1.5,
                    "tool_length_reference": true
                },
                {
                    "height_inches": 1.375,
                    "facets": 32,
                    "top_diameter_inches": 0.75,
                    "name": "upper TTS shank",
                    "bottom_diameter_inches": 0.75
                }
            ],
            "family": "",
            "manufacturer": ""
        }
    },
    "stickout_mode": "tool_table_length",
    "default_stickout_inches": 1.5,
    "edge_enabled": true,
    "edge_width": 1.5
}
'''

OEM_CYLINDER_TOOL = '''    def _draw_cylinder_tool(self, current_tool):
        """
        Draws a basic cylindrical tool shape with current material / color settings
        :param current_tool: Model data for the current tool
        """
        dia = current_tool.diameter
        r = self.to_internal_linear_unit(dia) / 2.
        self.color_config.gl_color('tool_body')
        with gl_quadric() as q:
            gluCylinder(q, r, r, 8 * r, 32, 1)
            with gl_push_matrix():
                glRotatef(180, 1, 0, 0)
                gluDisk(q, 0, r, 32, 1)
            glTranslatef(0, 0, 8 * r)
            gluDisk(q, 0, r, 32, 1)
'''

OLD_PATCHED_CYLINDER_TOOL = '''    def _draw_cylinder_tool(self, current_tool):
        """
        Draws a basic cylindrical tool shape with current material / color settings
        :param current_tool: Model data for the current tool
        """
        dia = current_tool.diameter
        r = self.to_internal_linear_unit(dia) / 2.
        tool_height = 8 * r
        holder_r = self.to_internal_linear_unit(1.50) / 2.
        holder_height = self.to_internal_linear_unit(3.00)
        self.color_config.gl_color('tool_body')
        with gl_quadric() as q:
            gluCylinder(q, r, r, tool_height, 32, 1)
            with gl_push_matrix():
                glRotatef(180, 1, 0, 0)
                gluDisk(q, 0, r, 32, 1)
            with gl_push_matrix():
                glTranslatef(0, 0, tool_height)
                gluDisk(q, 0, r, 32, 1)
                gluCylinder(q, holder_r, holder_r, holder_height, 32, 1)  # BEAGLE_GREMLIN_GENERIC_TOOL_HOLDER_V1
                glTranslatef(0, 0, holder_height)
                gluDisk(q, 0, holder_r, 32, 1)
'''

OLD_PATCHED_TTS_TOOL_HOLDER_V2 = '    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V2\n        config = {\n            \'enabled\': True,\n            \'default_stickout_inches\': 1.50,\n            \'tool_stickout_inches\': {},\n            \'sections\': [\n                {\'name\': \'ER32 hex collet nut\', \'height_inches\': 0.70, \'bottom_diameter_inches\': 1.50, \'top_diameter_inches\': 1.50, \'facets\': 6},\n                {\'name\': \'gutter / relief\', \'height_inches\': 0.20, \'bottom_diameter_inches\': 1.10, \'top_diameter_inches\': 1.10, \'facets\': 32},\n                {\'name\': \'holder body\', \'height_inches\': 0.70, \'bottom_diameter_inches\': 1.25, \'top_diameter_inches\': 1.25, \'facets\': 32},\n                {\'name\': \'ATC retention ring\', \'height_inches\': 0.20, \'bottom_diameter_inches\': 2.00, \'top_diameter_inches\': 2.00, \'facets\': 32},\n                {\'name\': \'upper TTS shank\', \'height_inches\': 1.20, \'bottom_diameter_inches\': 0.75, \'top_diameter_inches\': 0.75, \'facets\': 32},\n            ],\n        }\n        try:\n            handle = open(\'/home/operator/gcode/python/gremlin_config.json\', \'r\')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                if \'enabled\' in loaded:\n                    config[\'enabled\'] = bool(loaded[\'enabled\'])\n                if \'default_stickout_inches\' in loaded:\n                    config[\'default_stickout_inches\'] = float(loaded[\'default_stickout_inches\'])\n                if isinstance(loaded.get(\'tool_stickout_inches\'), dict):\n                    config[\'tool_stickout_inches\'] = loaded[\'tool_stickout_inches\']\n                if isinstance(loaded.get(\'sections\'), list) and len(loaded[\'sections\']) > 0:\n                    config[\'sections\'] = loaded[\'sections\']\n        except Exception:\n            pass\n\n        stickout = config[\'default_stickout_inches\']\n        tool_key = str(current_tool.id)\n        tool_key_t = \'T%s\' % tool_key\n        try:\n            if tool_key in config[\'tool_stickout_inches\']:\n                stickout = float(config[\'tool_stickout_inches\'][tool_key])\n            elif tool_key_t in config[\'tool_stickout_inches\']:\n                stickout = float(config[\'tool_stickout_inches\'][tool_key_t])\n        except Exception:\n            pass\n        if stickout <= 0:\n            stickout = 1.50\n        config[\'stickout_inches\'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets):\n        bottom_r = self.to_internal_linear_unit(bottom_diameter) / 2.\n        top_r = self.to_internal_linear_unit(top_diameter) / 2.\n        section_height = self.to_internal_linear_unit(height)\n        if section_height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, section_height, facets, 1)\n        glTranslatef(0, 0, section_height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n    def _draw_cylinder_tool(self, current_tool):\n        """\n        Draws the cutter plus a configurable TTS/ER32-style holder model.\n        """\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = self.to_internal_linear_unit(holder_config[\'stickout_inches\'])\n        self.color_config.gl_color(\'tool_body\')\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                if holder_config[\'enabled\']:\n                    for section in holder_config[\'sections\']:\n                        try:\n                            bottom_diameter = float(section[\'bottom_diameter_inches\'])\n                            top_diameter = float(section.get(\'top_diameter_inches\', bottom_diameter))\n                            height = float(section[\'height_inches\'])\n                            facets = int(section.get(\'facets\', 32))\n                            self._draw_tts_holder_section(q, bottom_diameter, top_diameter, height, facets)\n                        except Exception:\n                            continue\n'

OLD_PATCHED_TTS_TOOL_HOLDER_V3 = '    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V3\n        config = {\n            \'enabled\': True,\n            \'stickout_mode\': \'tool_table_length\',\n            \'default_stickout_inches\': 1.50,\n            \'minimum_stickout_inches\': 0.05,\n            \'tool_stickout_inches\': {},\n            \'sections\': [\n                {\'name\': \'ER32 hex collet nut\', \'height_inches\': 0.70, \'bottom_diameter_inches\': 1.50, \'top_diameter_inches\': 1.50, \'facets\': 6, \'below_tool_length_reference\': True},\n                {\'name\': \'gutter / relief\', \'height_inches\': 0.20, \'bottom_diameter_inches\': 1.10, \'top_diameter_inches\': 1.10, \'facets\': 32, \'below_tool_length_reference\': True},\n                {\'name\': \'holder body\', \'height_inches\': 0.70, \'bottom_diameter_inches\': 1.25, \'top_diameter_inches\': 1.25, \'facets\': 32, \'below_tool_length_reference\': True},\n                {\'name\': \'ATC retention ring\', \'height_inches\': 0.20, \'bottom_diameter_inches\': 2.00, \'top_diameter_inches\': 2.00, \'facets\': 32, \'below_tool_length_reference\': True},\n                {\'name\': \'upper TTS shank\', \'height_inches\': 1.20, \'bottom_diameter_inches\': 0.75, \'top_diameter_inches\': 0.75, \'facets\': 32, \'below_tool_length_reference\': False},\n            ],\n        }\n        try:\n            handle = open(\'/home/operator/gcode/python/gremlin_config.json\', \'r\')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                if \'enabled\' in loaded:\n                    config[\'enabled\'] = bool(loaded[\'enabled\'])\n                if \'stickout_mode\' in loaded:\n                    config[\'stickout_mode\'] = str(loaded[\'stickout_mode\'])\n                if \'default_stickout_inches\' in loaded:\n                    config[\'default_stickout_inches\'] = float(loaded[\'default_stickout_inches\'])\n                if \'minimum_stickout_inches\' in loaded:\n                    config[\'minimum_stickout_inches\'] = float(loaded[\'minimum_stickout_inches\'])\n                if isinstance(loaded.get(\'tool_stickout_inches\'), dict):\n                    config[\'tool_stickout_inches\'] = loaded[\'tool_stickout_inches\']\n                if isinstance(loaded.get(\'sections\'), list) and len(loaded[\'sections\']) > 0:\n                    config[\'sections\'] = loaded[\'sections\']\n        except Exception:\n            pass\n\n        holder_length = 0.0\n        has_reference_flags = False\n        for section in config[\'sections\']:\n            if isinstance(section, dict) and \'below_tool_length_reference\' in section:\n                has_reference_flags = True\n                break\n\n        if has_reference_flags:\n            for section in config[\'sections\']:\n                try:\n                    if bool(section.get(\'below_tool_length_reference\', False)):\n                        holder_length += float(section[\'height_inches\'])\n                except Exception:\n                    continue\n        else:\n            # Backward compatibility with 1.01 configs: sum from the cutter up\n            # through the ATC ring, whose top is the PathPilot length reference.\n            for section in config[\'sections\']:\n                try:\n                    holder_length += float(section[\'height_inches\'])\n                    section_name = str(section.get(\'name\', \'\')).lower()\n                    if \'atc\' in section_name and (\'ring\' in section_name or \'flange\' in section_name):\n                        break\n                except Exception:\n                    continue\n\n        default_stickout = config[\'default_stickout_inches\']\n        minimum_stickout = config[\'minimum_stickout_inches\']\n        if default_stickout <= 0:\n            default_stickout = 1.50\n        if minimum_stickout <= 0:\n            minimum_stickout = 0.05\n\n        tool_length_inches = 0.0\n        try:\n            # Tool Manager Length is the LinuxCNC tool-table Z offset.\n            tool_length_inches = self.to_internal_linear_unit(float(current_tool.zoffset))\n        except Exception:\n            tool_length_inches = 0.0\n\n        stickout = default_stickout\n        if config[\'stickout_mode\'] == \'tool_table_length\' and tool_length_inches > 0 and holder_length > 0:\n            derived_stickout = tool_length_inches - holder_length\n            if derived_stickout >= minimum_stickout:\n                stickout = derived_stickout\n\n        # Optional per-tool values remain an explicit override for oddball tools.\n        tool_key = str(current_tool.id)\n        tool_key_t = \'T%s\' % tool_key\n        try:\n            if tool_key in config[\'tool_stickout_inches\']:\n                stickout = float(config[\'tool_stickout_inches\'][tool_key])\n            elif tool_key_t in config[\'tool_stickout_inches\']:\n                stickout = float(config[\'tool_stickout_inches\'][tool_key_t])\n        except Exception:\n            pass\n\n        if stickout < minimum_stickout:\n            stickout = default_stickout\n\n        config[\'holder_length_below_reference_inches\'] = holder_length\n        config[\'tool_length_inches\'] = tool_length_inches\n        config[\'stickout_inches\'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets):\n        # Config geometry is stored in inches; Gremlin internal geometry is inches.\n        bottom_r = bottom_diameter / 2.\n        top_r = top_diameter / 2.\n        section_height = height\n        if section_height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, section_height, facets, 1)\n        glTranslatef(0, 0, section_height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n    def _draw_cylinder_tool(self, current_tool):\n        """\n        Draws the cutter plus a configurable TTS/ER32-style holder model.\n        Cutter stickout is normally derived from the active tool-table length.\n        """\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = holder_config[\'stickout_inches\']\n        self.color_config.gl_color(\'tool_body\')\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                if holder_config[\'enabled\']:\n                    for section in holder_config[\'sections\']:\n                        try:\n                            bottom_diameter = float(section[\'bottom_diameter_inches\'])\n                            top_diameter = float(section.get(\'top_diameter_inches\', bottom_diameter))\n                            height = float(section[\'height_inches\'])\n                            facets = int(section.get(\'facets\', 32))\n                            self._draw_tts_holder_section(q, bottom_diameter, top_diameter, height, facets)\n                        except Exception:\n                            continue\n'

OLD_PATCHED_TTS_TOOL_HOLDER_V4 = "    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V4\n        default_holder = {\n            'display_name': 'TTS ER32 - Generic',\n            'sections': [\n                {'name': 'ER32 hex collet nut', 'height_inches': 0.70, 'bottom_diameter_inches': 1.50, 'top_diameter_inches': 1.50, 'facets': 6, 'below_tool_length_reference': True},\n                {'name': 'gutter / relief', 'height_inches': 0.20, 'bottom_diameter_inches': 1.10, 'top_diameter_inches': 1.10, 'facets': 32, 'below_tool_length_reference': True},\n                {'name': 'holder body', 'height_inches': 0.70, 'bottom_diameter_inches': 1.25, 'top_diameter_inches': 1.25, 'facets': 32, 'below_tool_length_reference': True},\n                {'name': 'ATC retention ring', 'height_inches': 0.20, 'bottom_diameter_inches': 2.00, 'top_diameter_inches': 2.00, 'facets': 32, 'below_tool_length_reference': True},\n                {'name': 'upper TTS shank', 'height_inches': 1.20, 'bottom_diameter_inches': 0.75, 'top_diameter_inches': 0.75, 'facets': 32, 'below_tool_length_reference': False},\n            ],\n        }\n        config = {'enabled': True, 'default_holder_type': 'TTS_ER32_GENERIC', 'holder_type': 'TTS_ER32_GENERIC', 'stickout_mode': 'tool_table_length', 'default_stickout_inches': 1.50, 'minimum_stickout_inches': 0.05, 'tool_stickout_inches': {}, 'holder': default_holder, 'sections': default_holder['sections']}\n        try:\n            handle = open('/home/operator/gcode/python/gremlin_config.json', 'r')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                config['enabled'] = bool(loaded.get('enabled', config['enabled']))\n                config['default_holder_type'] = str(loaded.get('default_holder_type', loaded.get('holder_model', config['default_holder_type'])))\n                config['stickout_mode'] = str(loaded.get('stickout_mode', config['stickout_mode']))\n                config['default_stickout_inches'] = float(loaded.get('default_stickout_inches', config['default_stickout_inches']))\n                config['minimum_stickout_inches'] = float(loaded.get('minimum_stickout_inches', config['minimum_stickout_inches']))\n                if isinstance(loaded.get('tool_stickout_inches'), dict):\n                    config['tool_stickout_inches'] = loaded['tool_stickout_inches']\n                assigned_holder_type = ''\n                try:\n                    sqlite3 = __import__('sqlite3')\n                    db = sqlite3.connect('/home/operator/gcode/python/hobbs/hobbs_tool_life.db')\n                    try:\n                        cursor = db.cursor()\n                        cursor.execute('SELECT holder_type FROM tool_holder_assignment WHERE tool = ?', (int(current_tool.id),))\n                        assignment = cursor.fetchone()\n                        if assignment and assignment[0]:\n                            assigned_holder_type = str(assignment[0])\n                    finally:\n                        db.close()\n                except Exception:\n                    pass\n                holder_type = assigned_holder_type or config['default_holder_type']\n                holder_types = loaded.get('holder_types')\n                if isinstance(holder_types, dict):\n                    holder = holder_types.get(holder_type) or holder_types.get(config['default_holder_type'])\n                    if isinstance(holder, dict) and isinstance(holder.get('sections'), list) and holder['sections']:\n                        config['holder_type'] = holder_type if holder_types.get(holder_type) is holder else config['default_holder_type']\n                        config['holder'] = holder\n                        config['sections'] = holder['sections']\n                elif isinstance(loaded.get('sections'), list) and loaded['sections']:\n                    config['sections'] = loaded['sections']\n        except Exception:\n            pass\n        holder_length = 0.0\n        has_flags = any(isinstance(section, dict) and 'below_tool_length_reference' in section for section in config['sections'])\n        for section in config['sections']:\n            try:\n                if has_flags:\n                    if bool(section.get('below_tool_length_reference', False)):\n                        holder_length += float(section['height_inches'])\n                else:\n                    holder_length += float(section['height_inches'])\n                    name = str(section.get('name', '')).lower()\n                    if 'atc' in name and ('ring' in name or 'flange' in name):\n                        break\n            except Exception:\n                continue\n        default_stickout = config['default_stickout_inches'] if config['default_stickout_inches'] > 0 else 1.50\n        minimum_stickout = config['minimum_stickout_inches'] if config['minimum_stickout_inches'] > 0 else 0.05\n        try:\n            tool_length_inches = self.to_internal_linear_unit(float(current_tool.zoffset))\n        except Exception:\n            tool_length_inches = 0.0\n        stickout = default_stickout\n        if config['stickout_mode'] == 'tool_table_length' and tool_length_inches > 0 and holder_length > 0:\n            derived = tool_length_inches - holder_length\n            if derived >= minimum_stickout:\n                stickout = derived\n        tool_key = str(current_tool.id)\n        try:\n            if tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches'][tool_key])\n            elif 'T%s' % tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches']['T%s' % tool_key])\n        except Exception:\n            pass\n        if stickout < minimum_stickout:\n            stickout = default_stickout\n        config['holder_length_below_reference_inches'] = holder_length\n        config['tool_length_inches'] = tool_length_inches\n        config['stickout_inches'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets):\n        bottom_r = bottom_diameter / 2.\n        top_r = top_diameter / 2.\n        if height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, height, facets, 1)\n        glTranslatef(0, 0, height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n    def _draw_cylinder_tool(self, current_tool):\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = holder_config['stickout_inches']\n        self.color_config.gl_color('tool_body')\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                if holder_config['enabled']:\n                    for section in holder_config['sections']:\n                        try:\n                            bottom_diameter = float(section['bottom_diameter_inches'])\n                            top_diameter = float(section.get('top_diameter_inches', bottom_diameter))\n                            height = float(section['height_inches'])\n                            facets = int(section.get('facets', 32))\n                            self._draw_tts_holder_section(q, bottom_diameter, top_diameter, height, facets)\n                        except Exception:\n                            continue\n"

OLD_PATCHED_TTS_TOOL_HOLDER_V5 = "    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V5\n        default_holder = {\n            'display_name': 'TTS ER32 - Generic',\n            'sections': [\n                {'name': 'ER32 hex collet nut', 'height_inches': 0.70, 'bottom_diameter_inches': 1.50, 'top_diameter_inches': 1.50, 'facets': 6},\n                {'name': 'gutter / relief', 'height_inches': 0.20, 'bottom_diameter_inches': 1.10, 'top_diameter_inches': 1.10, 'facets': 32},\n                {'name': 'holder body', 'height_inches': 0.70, 'bottom_diameter_inches': 1.25, 'top_diameter_inches': 1.25, 'facets': 32},\n                {'name': 'ATC retention ring', 'height_inches': 0.20, 'bottom_diameter_inches': 2.00, 'top_diameter_inches': 2.00, 'facets': 32, 'tool_length_reference': True},\n                {'name': 'upper TTS shank', 'height_inches': 1.20, 'bottom_diameter_inches': 0.75, 'top_diameter_inches': 0.75, 'facets': 32},\n            ],\n        }\n        config = {'enabled': True, 'default_holder_type': 'TTS_ER32_GENERIC', 'holder_type': 'TTS_ER32_GENERIC', 'stickout_mode': 'tool_table_length', 'default_stickout_inches': 1.50, 'minimum_stickout_inches': 0.05, 'tool_stickout_inches': {}, 'holder': default_holder, 'sections': default_holder['sections']}\n        try:\n            handle = open('/home/operator/gcode/python/gremlin_config.json', 'r')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                config['enabled'] = bool(loaded.get('enabled', config['enabled']))\n                config['default_holder_type'] = str(loaded.get('default_holder_type', loaded.get('holder_model', config['default_holder_type'])))\n                config['stickout_mode'] = str(loaded.get('stickout_mode', config['stickout_mode']))\n                config['default_stickout_inches'] = float(loaded.get('default_stickout_inches', config['default_stickout_inches']))\n                config['minimum_stickout_inches'] = float(loaded.get('minimum_stickout_inches', config['minimum_stickout_inches']))\n                if isinstance(loaded.get('tool_stickout_inches'), dict):\n                    config['tool_stickout_inches'] = loaded['tool_stickout_inches']\n                assigned_holder_type = ''\n                try:\n                    sqlite3 = __import__('sqlite3')\n                    db = sqlite3.connect('/home/operator/gcode/python/hobbs/hobbs_tool_life.db')\n                    try:\n                        cursor = db.cursor()\n                        cursor.execute('SELECT holder_type FROM tool_holder_assignment WHERE tool = ?', (int(current_tool.id),))\n                        assignment = cursor.fetchone()\n                        if assignment and assignment[0]:\n                            assigned_holder_type = str(assignment[0])\n                    finally:\n                        db.close()\n                except Exception:\n                    pass\n                holder_type = assigned_holder_type or config['default_holder_type']\n                holder_types = loaded.get('holder_types')\n                if isinstance(holder_types, dict):\n                    holder = holder_types.get(holder_type) or holder_types.get(config['default_holder_type'])\n                    if isinstance(holder, dict) and isinstance(holder.get('sections'), list) and holder['sections']:\n                        config['holder_type'] = holder_type if holder_types.get(holder_type) is holder else config['default_holder_type']\n                        config['holder'] = holder\n                        config['sections'] = holder['sections']\n                elif isinstance(loaded.get('sections'), list) and loaded['sections']:\n                    config['sections'] = loaded['sections']\n        except Exception:\n            pass\n        holder_length = 0.0\n        reference_found = False\n        for section in config['sections']:\n            try:\n                holder_length += float(section['height_inches'])\n                if bool(section.get('tool_length_reference', False)):\n                    reference_found = True\n                    break\n            except Exception:\n                continue\n        if not reference_found:\n            holder_length = 0.0\n            for section in config['sections']:\n                try:\n                    holder_length += float(section['height_inches'])\n                    name = str(section.get('name', '')).lower()\n                    if 'atc' in name and ('ring' in name or 'flange' in name):\n                        reference_found = True\n                        break\n                except Exception:\n                    continue\n        if not reference_found:\n            holder_length = 0.0\n        default_stickout = config['default_stickout_inches'] if config['default_stickout_inches'] > 0 else 1.50\n        minimum_stickout = config['minimum_stickout_inches'] if config['minimum_stickout_inches'] > 0 else 0.05\n        try:\n            tool_length_inches = self.to_internal_linear_unit(float(current_tool.zoffset))\n        except Exception:\n            tool_length_inches = 0.0\n        stickout = default_stickout\n        if config['stickout_mode'] == 'tool_table_length' and tool_length_inches > 0 and holder_length > 0:\n            derived = tool_length_inches - holder_length\n            if derived >= minimum_stickout:\n                stickout = derived\n        tool_key = str(current_tool.id)\n        try:\n            if tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches'][tool_key])\n            elif 'T%s' % tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches']['T%s' % tool_key])\n        except Exception:\n            pass\n        if stickout < minimum_stickout:\n            stickout = default_stickout\n        config['holder_length_below_reference_inches'] = holder_length\n        config['tool_length_inches'] = tool_length_inches\n        config['stickout_inches'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets):\n        bottom_r = bottom_diameter / 2.\n        top_r = top_diameter / 2.\n        if height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, height, facets, 1)\n        glTranslatef(0, 0, height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n    def _draw_cylinder_tool(self, current_tool):\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = holder_config['stickout_inches']\n        self.color_config.gl_color('tool_body')\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                if holder_config['enabled']:\n                    for section in holder_config['sections']:\n                        try:\n                            bottom_diameter = float(section['bottom_diameter_inches'])\n                            top_diameter = float(section.get('top_diameter_inches', bottom_diameter))\n                            height = float(section['height_inches'])\n                            facets = int(section.get('facets', 32))\n                            self._draw_tts_holder_section(q, bottom_diameter, top_diameter, height, facets)\n                        except Exception:\n                            continue\n"

OLD_PATCHED_TTS_TOOL_HOLDER_V6 = "    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V6\n        default_holder = {\n            'display_name': 'TTS ER32 - Generic',\n            'sections': [\n                {'name': 'ER32 hex collet nut', 'height_inches': 0.70, 'bottom_diameter_inches': 1.50, 'top_diameter_inches': 1.50, 'facets': 6},\n                {'name': 'gutter / relief', 'height_inches': 0.20, 'bottom_diameter_inches': 1.10, 'top_diameter_inches': 1.10, 'facets': 32},\n                {'name': 'holder body', 'height_inches': 0.70, 'bottom_diameter_inches': 1.25, 'top_diameter_inches': 1.25, 'facets': 32},\n                {'name': 'ATC retention ring', 'height_inches': 0.20, 'bottom_diameter_inches': 2.00, 'top_diameter_inches': 2.00, 'facets': 32, 'tool_length_reference': True},\n                {'name': 'upper TTS shank', 'height_inches': 1.20, 'bottom_diameter_inches': 0.75, 'top_diameter_inches': 0.75, 'facets': 32},\n            ],\n        }\n        config = {\n            'enabled': True,\n            'edge_enabled': True,\n            'edge_width': 1.50,\n            'default_holder_type': 'TTS_ER32_GENERIC',\n            'holder_type': 'TTS_ER32_GENERIC',\n            'stickout_mode': 'tool_table_length',\n            'default_stickout_inches': 1.50,\n            'minimum_stickout_inches': 0.05,\n            'tool_stickout_inches': {},\n            'holder': default_holder,\n            'sections': default_holder['sections'],\n        }\n        try:\n            handle = open('/home/operator/gcode/python/gremlin_config.json', 'r')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                config['enabled'] = bool(loaded.get('enabled', config['enabled']))\n                config['edge_enabled'] = bool(loaded.get('edge_enabled', config['edge_enabled']))\n                try:\n                    config['edge_width'] = float(loaded.get('edge_width', config['edge_width']))\n                except Exception:\n                    config['edge_width'] = 1.50\n                config['default_holder_type'] = str(loaded.get('default_holder_type', loaded.get('holder_model', config['default_holder_type'])))\n                config['stickout_mode'] = str(loaded.get('stickout_mode', config['stickout_mode']))\n                config['default_stickout_inches'] = float(loaded.get('default_stickout_inches', config['default_stickout_inches']))\n                config['minimum_stickout_inches'] = float(loaded.get('minimum_stickout_inches', config['minimum_stickout_inches']))\n                if isinstance(loaded.get('tool_stickout_inches'), dict):\n                    config['tool_stickout_inches'] = loaded['tool_stickout_inches']\n\n                assigned_holder_type = ''\n                try:\n                    sqlite3 = __import__('sqlite3')\n                    db = sqlite3.connect('/home/operator/gcode/python/hobbs/hobbs_tool_life.db')\n                    try:\n                        cursor = db.cursor()\n                        cursor.execute('SELECT holder_type FROM tool_holder_assignment WHERE tool = ?', (int(current_tool.id),))\n                        assignment = cursor.fetchone()\n                        if assignment and assignment[0]:\n                            assigned_holder_type = str(assignment[0])\n                    finally:\n                        db.close()\n                except Exception:\n                    pass\n\n                holder_type = assigned_holder_type or config['default_holder_type']\n                holder_types = loaded.get('holder_types')\n                if isinstance(holder_types, dict):\n                    holder = holder_types.get(holder_type) or holder_types.get(config['default_holder_type'])\n                    if isinstance(holder, dict) and isinstance(holder.get('sections'), list) and holder['sections']:\n                        config['holder_type'] = holder_type if holder_types.get(holder_type) is holder else config['default_holder_type']\n                        config['holder'] = holder\n                        config['sections'] = holder['sections']\n                elif isinstance(loaded.get('sections'), list) and loaded['sections']:\n                    config['sections'] = loaded['sections']\n        except Exception:\n            pass\n\n        if config['edge_width'] < 0.50:\n            config['edge_width'] = 0.50\n        elif config['edge_width'] > 10.0:\n            config['edge_width'] = 10.0\n\n        holder_length = 0.0\n        reference_found = False\n        for section in config['sections']:\n            try:\n                holder_length += float(section['height_inches'])\n                if bool(section.get('tool_length_reference', False)):\n                    reference_found = True\n                    break\n            except Exception:\n                continue\n        if not reference_found:\n            holder_length = 0.0\n\n        default_stickout = config['default_stickout_inches'] if config['default_stickout_inches'] > 0 else 1.50\n        minimum_stickout = config['minimum_stickout_inches'] if config['minimum_stickout_inches'] > 0 else 0.05\n        try:\n            tool_length_inches = self.to_internal_linear_unit(float(current_tool.zoffset))\n        except Exception:\n            tool_length_inches = 0.0\n\n        stickout = default_stickout\n        if config['stickout_mode'] == 'tool_table_length' and tool_length_inches > 0 and holder_length > 0:\n            derived = tool_length_inches - holder_length\n            if derived >= minimum_stickout:\n                stickout = derived\n\n        tool_key = str(current_tool.id)\n        try:\n            if tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches'][tool_key])\n            elif 'T%s' % tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches']['T%s' % tool_key])\n        except Exception:\n            pass\n\n        if stickout < minimum_stickout:\n            stickout = default_stickout\n\n        config['holder_length_below_reference_inches'] = holder_length\n        config['tool_length_inches'] = tool_length_inches\n        config['stickout_inches'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets, edge_enabled, edge_width):\n        bottom_r = bottom_diameter / 2.\n        top_r = top_diameter / 2.\n        if height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, height, facets, 1)\n        glTranslatef(0, 0, height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n        if not edge_enabled:\n            return\n\n        # Draw a second opaque black edge pass. Round sections receive black\n        # top/bottom rims; faceted sections also receive their longitudinal\n        # corner edges. This keeps cylinders clean rather than wireframed.\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.0, 0.0, 0.0)\n            glLineWidth(edge_width)\n\n            with gl_begin(GL_LINE_LOOP):\n                for index in range(facets):\n                    angle = (2.0 * math.pi * index) / facets\n                    glVertex3f(top_r * math.cos(angle), top_r * math.sin(angle), 0.0)\n\n            with gl_begin(GL_LINE_LOOP):\n                for index in range(facets):\n                    angle = (2.0 * math.pi * index) / facets\n                    glVertex3f(bottom_r * math.cos(angle), bottom_r * math.sin(angle), -height)\n\n            if facets <= 12:\n                with gl_begin(GL_LINES):\n                    for index in range(facets):\n                        angle = (2.0 * math.pi * index) / facets\n                        ca = math.cos(angle)\n                        sa = math.sin(angle)\n                        glVertex3f(bottom_r * ca, bottom_r * sa, -height)\n                        glVertex3f(top_r * ca, top_r * sa, 0.0)\n\n            glLineWidth(1.0)\n            glDepthFunc(GL_LESS)\n\n        # The edge pass changes the current GL color to black. Restore the\n        # normal holder material before the next section is rendered.\n        self.color_config.gl_color('tool_body')\n\n    def _draw_cylinder_tool(self, current_tool):\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = holder_config['stickout_inches']\n        self.color_config.gl_color('tool_body')\n\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                if holder_config['enabled']:\n                    for section in holder_config['sections']:\n                        try:\n                            bottom_diameter = float(section['bottom_diameter_inches'])\n                            top_diameter = float(section.get('top_diameter_inches', bottom_diameter))\n                            height = float(section['height_inches'])\n                            facets = int(section.get('facets', 32))\n                            self._draw_tts_holder_section(\n                                q,\n                                bottom_diameter,\n                                top_diameter,\n                                height,\n                                facets,\n                                holder_config['edge_enabled'],\n                                holder_config['edge_width'])\n                        except Exception:\n                            continue\n"

OLD_PATCHED_TTS_TOOL_HOLDER_V7 = "    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V7\n        default_holder = {\n            'display_name': 'TTS ER32 - Generic',\n            'sections': [\n                {'name': 'ER32 hex collet nut', 'height_inches': 0.70, 'bottom_diameter_inches': 1.50, 'top_diameter_inches': 1.50, 'facets': 6},\n                {'name': 'gutter / relief', 'height_inches': 0.20, 'bottom_diameter_inches': 1.10, 'top_diameter_inches': 1.10, 'facets': 32},\n                {'name': 'holder body', 'height_inches': 0.70, 'bottom_diameter_inches': 1.25, 'top_diameter_inches': 1.25, 'facets': 32},\n                {'name': 'ATC retention ring', 'height_inches': 0.20, 'bottom_diameter_inches': 2.00, 'top_diameter_inches': 2.00, 'facets': 32, 'tool_length_reference': True},\n                {'name': 'upper TTS shank', 'height_inches': 1.20, 'bottom_diameter_inches': 0.75, 'top_diameter_inches': 0.75, 'facets': 32},\n            ],\n        }\n        config = {\n            'enabled': True,\n            'edge_enabled': True,\n            'edge_width': 1.50,\n            'default_holder_type': 'TTS_ER32_GENERIC',\n            'holder_type': 'TTS_ER32_GENERIC',\n            'stickout_mode': 'tool_table_length',\n            'default_stickout_inches': 1.50,\n            'minimum_stickout_inches': 0.05,\n            'tool_stickout_inches': {},\n            'holder': default_holder,\n            'sections': default_holder['sections'],\n        }\n        try:\n            handle = open('/home/operator/gcode/python/gremlin_config.json', 'r')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                config['enabled'] = bool(loaded.get('enabled', config['enabled']))\n                config['edge_enabled'] = bool(loaded.get('edge_enabled', config['edge_enabled']))\n                try:\n                    config['edge_width'] = float(loaded.get('edge_width', config['edge_width']))\n                except Exception:\n                    config['edge_width'] = 1.50\n                config['default_holder_type'] = str(loaded.get('default_holder_type', loaded.get('holder_model', config['default_holder_type'])))\n                config['stickout_mode'] = str(loaded.get('stickout_mode', config['stickout_mode']))\n                config['default_stickout_inches'] = float(loaded.get('default_stickout_inches', config['default_stickout_inches']))\n                config['minimum_stickout_inches'] = float(loaded.get('minimum_stickout_inches', config['minimum_stickout_inches']))\n                if isinstance(loaded.get('tool_stickout_inches'), dict):\n                    config['tool_stickout_inches'] = loaded['tool_stickout_inches']\n\n                assigned_holder_type = ''\n                try:\n                    sqlite3 = __import__('sqlite3')\n                    db = sqlite3.connect('/home/operator/gcode/python/hobbs/hobbs_tool_life.db')\n                    try:\n                        cursor = db.cursor()\n                        cursor.execute('SELECT holder_type FROM tool_holder_assignment WHERE tool = ?', (int(current_tool.id),))\n                        assignment = cursor.fetchone()\n                        if assignment and assignment[0]:\n                            assigned_holder_type = str(assignment[0])\n                    finally:\n                        db.close()\n                except Exception:\n                    pass\n\n                holder_type = assigned_holder_type or config['default_holder_type']\n                holder_types = loaded.get('holder_types')\n                if isinstance(holder_types, dict):\n                    holder = holder_types.get(holder_type) or holder_types.get(config['default_holder_type'])\n                    if isinstance(holder, dict) and isinstance(holder.get('sections'), list) and holder['sections']:\n                        config['holder_type'] = holder_type if holder_types.get(holder_type) is holder else config['default_holder_type']\n                        config['holder'] = holder\n                        config['sections'] = holder['sections']\n                elif isinstance(loaded.get('sections'), list) and loaded['sections']:\n                    config['sections'] = loaded['sections']\n        except Exception:\n            pass\n\n        if config['edge_width'] < 0.50:\n            config['edge_width'] = 0.50\n        elif config['edge_width'] > 10.0:\n            config['edge_width'] = 10.0\n\n        holder_length = 0.0\n        reference_found = False\n        for section in config['sections']:\n            try:\n                holder_length += float(section['height_inches'])\n                if bool(section.get('tool_length_reference', False)):\n                    reference_found = True\n                    break\n            except Exception:\n                continue\n        if not reference_found:\n            holder_length = 0.0\n\n        default_stickout = config['default_stickout_inches'] if config['default_stickout_inches'] > 0 else 1.50\n        minimum_stickout = config['minimum_stickout_inches'] if config['minimum_stickout_inches'] > 0 else 0.05\n        try:\n            tool_length_inches = self.to_internal_linear_unit(float(current_tool.zoffset))\n        except Exception:\n            tool_length_inches = 0.0\n\n        stickout = default_stickout\n        if config['stickout_mode'] == 'tool_table_length' and tool_length_inches > 0 and holder_length > 0:\n            derived = tool_length_inches - holder_length\n            if derived >= minimum_stickout:\n                stickout = derived\n\n        tool_key = str(current_tool.id)\n        try:\n            if tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches'][tool_key])\n            elif 'T%s' % tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches']['T%s' % tool_key])\n        except Exception:\n            pass\n\n        if stickout < minimum_stickout:\n            stickout = default_stickout\n\n        config['holder_length_below_reference_inches'] = holder_length\n        config['tool_length_inches'] = tool_length_inches\n        config['stickout_inches'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets, edge_enabled, edge_width):\n        bottom_r = bottom_diameter / 2.\n        top_r = top_diameter / 2.\n        if height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, height, facets, 1)\n        glTranslatef(0, 0, height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n        if not edge_enabled:\n            return\n\n        # Draw a second opaque black edge pass. Round sections receive black\n        # top/bottom rims; faceted sections also receive their longitudinal\n        # corner edges. This keeps cylinders clean rather than wireframed.\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.0, 0.0, 0.0)\n            glLineWidth(edge_width)\n\n            with gl_begin(GL_LINE_LOOP):\n                for index in range(facets):\n                    angle = (2.0 * math.pi * index) / facets\n                    glVertex3f(top_r * math.cos(angle), top_r * math.sin(angle), 0.0)\n\n            with gl_begin(GL_LINE_LOOP):\n                for index in range(facets):\n                    angle = (2.0 * math.pi * index) / facets\n                    glVertex3f(bottom_r * math.cos(angle), bottom_r * math.sin(angle), -height)\n\n            if facets <= 12:\n                with gl_begin(GL_LINES):\n                    for index in range(facets):\n                        angle = (2.0 * math.pi * index) / facets\n                        ca = math.cos(angle)\n                        sa = math.sin(angle)\n                        glVertex3f(bottom_r * ca, bottom_r * sa, -height)\n                        glVertex3f(top_r * ca, top_r * sa, 0.0)\n\n            glLineWidth(1.0)\n            glDepthFunc(GL_LESS)\n\n        # The edge pass changes the current GL color to black. Restore the\n        # normal holder material before the next section is rendered.\n        self.color_config.gl_color('tool_body')\n\n    def _draw_cylinder_tool(self, current_tool):\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = holder_config['stickout_inches']\n        self.color_config.gl_color('tool_body')\n\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                runtime_holder_visible = bool(getattr(self, '_beagle_tts_holder_visible', True))\n                if holder_config['enabled'] and runtime_holder_visible:\n                    for section in holder_config['sections']:\n                        try:\n                            bottom_diameter = float(section['bottom_diameter_inches'])\n                            top_diameter = float(section.get('top_diameter_inches', bottom_diameter))\n                            height = float(section['height_inches'])\n                            facets = int(section.get('facets', 32))\n                            self._draw_tts_holder_section(\n                                q,\n                                bottom_diameter,\n                                top_diameter,\n                                height,\n                                facets,\n                                holder_config['edge_enabled'],\n                                holder_config['edge_width'])\n                        except Exception:\n                            continue\n"
PATCHED_CYLINDER_TOOL = "    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TTS_TOOL_HOLDER_V8\n        default_holder = {\n            'display_name': 'TTS ER32 GENERIC',\n            'sections': [\n                {'name': 'ER32 hex collet nut', 'height_inches': 0.4, 'bottom_diameter_inches': 1.34, 'top_diameter_inches': 1.34, 'facets': 6},\n                {'name': 'gutter / relief', 'height_inches': 0.44, 'bottom_diameter_inches': 1.34, 'top_diameter_inches': 1.34, 'facets': 32},\n                {'name': 'holder body', 'height_inches': 0.885, 'bottom_diameter_inches': 1.1, 'top_diameter_inches': 1.1, 'facets': 32},\n                {'name': 'ATC retention ring', 'height_inches': 0.343, 'bottom_diameter_inches': 1.5, 'top_diameter_inches': 1.5, 'facets': 32, 'tool_length_reference': True},\n                {'name': 'upper TTS shank', 'height_inches': 1.375, 'bottom_diameter_inches': 0.75, 'top_diameter_inches': 0.75, 'facets': 32},\n            ],\n        }\n        config = {\n            'enabled': True,\n            'edge_enabled': True,\n            'edge_width': 1.50,\n            'default_holder_type': 'TTS_ER32_GENERIC',\n            'holder_type': 'TTS_ER32_GENERIC',\n            'stickout_mode': 'tool_table_length',\n            'default_stickout_inches': 1.50,\n            'minimum_stickout_inches': 0.05,\n            'tool_stickout_inches': {},\n            'holder': default_holder,\n            'sections': default_holder['sections'],\n        }\n        try:\n            handle = open('/home/operator/gcode/python/gremlin_tool_holder_config.json', 'r')\n            try:\n                loaded = json.load(handle)\n            finally:\n                handle.close()\n            if isinstance(loaded, dict):\n                config['enabled'] = bool(loaded.get('enabled', config['enabled']))\n                config['edge_enabled'] = bool(loaded.get('edge_enabled', config['edge_enabled']))\n                try:\n                    config['edge_width'] = float(loaded.get('edge_width', config['edge_width']))\n                except Exception:\n                    config['edge_width'] = 1.50\n                config['default_holder_type'] = str(loaded.get('default_holder_type', loaded.get('holder_model', config['default_holder_type'])))\n                config['stickout_mode'] = str(loaded.get('stickout_mode', config['stickout_mode']))\n                config['default_stickout_inches'] = float(loaded.get('default_stickout_inches', config['default_stickout_inches']))\n                config['minimum_stickout_inches'] = float(loaded.get('minimum_stickout_inches', config['minimum_stickout_inches']))\n                if isinstance(loaded.get('tool_stickout_inches'), dict):\n                    config['tool_stickout_inches'] = loaded['tool_stickout_inches']\n\n                assigned_holder_type = ''\n                try:\n                    sqlite3 = __import__('sqlite3')\n                    db = sqlite3.connect('/home/operator/gcode/python/hobbs/hobbs_tool_life.db')\n                    try:\n                        cursor = db.cursor()\n                        cursor.execute('SELECT holder_type FROM tool_holder_assignment WHERE tool = ?', (int(current_tool.id),))\n                        assignment = cursor.fetchone()\n                        if assignment and assignment[0]:\n                            assigned_holder_type = str(assignment[0])\n                    finally:\n                        db.close()\n                except Exception:\n                    pass\n\n                holder_type = assigned_holder_type or config['default_holder_type']\n                holder_types = loaded.get('holder_types')\n                if isinstance(holder_types, dict):\n                    holder = holder_types.get(holder_type) or holder_types.get(config['default_holder_type'])\n                    if isinstance(holder, dict) and isinstance(holder.get('sections'), list) and holder['sections']:\n                        config['holder_type'] = holder_type if holder_types.get(holder_type) is holder else config['default_holder_type']\n                        config['holder'] = holder\n                        config['sections'] = holder['sections']\n                elif isinstance(loaded.get('sections'), list) and loaded['sections']:\n                    config['sections'] = loaded['sections']\n        except Exception:\n            pass\n\n        if config['edge_width'] < 0.50:\n            config['edge_width'] = 0.50\n        elif config['edge_width'] > 10.0:\n            config['edge_width'] = 10.0\n\n        holder_length = 0.0\n        reference_found = False\n        for section in config['sections']:\n            try:\n                holder_length += float(section['height_inches'])\n                if bool(section.get('tool_length_reference', False)):\n                    reference_found = True\n                    break\n            except Exception:\n                continue\n        if not reference_found:\n            holder_length = 0.0\n\n        default_stickout = config['default_stickout_inches'] if config['default_stickout_inches'] > 0 else 1.50\n        minimum_stickout = config['minimum_stickout_inches'] if config['minimum_stickout_inches'] > 0 else 0.05\n        try:\n            tool_length_inches = self.to_internal_linear_unit(float(current_tool.zoffset))\n        except Exception:\n            tool_length_inches = 0.0\n\n        stickout = default_stickout\n        if config['stickout_mode'] == 'tool_table_length' and tool_length_inches > 0 and holder_length > 0:\n            derived = tool_length_inches - holder_length\n            if derived >= minimum_stickout:\n                stickout = derived\n\n        tool_key = str(current_tool.id)\n        try:\n            if tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches'][tool_key])\n            elif 'T%s' % tool_key in config['tool_stickout_inches']:\n                stickout = float(config['tool_stickout_inches']['T%s' % tool_key])\n        except Exception:\n            pass\n\n        if stickout < minimum_stickout:\n            stickout = default_stickout\n\n        config['holder_length_below_reference_inches'] = holder_length\n        config['tool_length_inches'] = tool_length_inches\n        config['stickout_inches'] = stickout\n        return config\n\n    def _draw_tts_holder_section(self, q, bottom_diameter, top_diameter, height, facets, edge_enabled, edge_width):\n        bottom_r = bottom_diameter / 2.\n        top_r = top_diameter / 2.\n        if height <= 0 or bottom_r <= 0 or top_r <= 0:\n            return\n        if facets < 3:\n            facets = 3\n\n        gluDisk(q, 0, bottom_r, facets, 1)\n        gluCylinder(q, bottom_r, top_r, height, facets, 1)\n        glTranslatef(0, 0, height)\n        gluDisk(q, 0, top_r, facets, 1)\n\n        if not edge_enabled:\n            return\n\n        # Draw a second opaque black edge pass. Round sections receive black\n        # top/bottom rims; faceted sections also receive their longitudinal\n        # corner edges. This keeps cylinders clean rather than wireframed.\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.0, 0.0, 0.0)\n            glLineWidth(edge_width)\n\n            with gl_begin(GL_LINE_LOOP):\n                for index in range(facets):\n                    angle = (2.0 * math.pi * index) / facets\n                    glVertex3f(top_r * math.cos(angle), top_r * math.sin(angle), 0.0)\n\n            with gl_begin(GL_LINE_LOOP):\n                for index in range(facets):\n                    angle = (2.0 * math.pi * index) / facets\n                    glVertex3f(bottom_r * math.cos(angle), bottom_r * math.sin(angle), -height)\n\n            if facets <= 12:\n                with gl_begin(GL_LINES):\n                    for index in range(facets):\n                        angle = (2.0 * math.pi * index) / facets\n                        ca = math.cos(angle)\n                        sa = math.sin(angle)\n                        glVertex3f(bottom_r * ca, bottom_r * sa, -height)\n                        glVertex3f(top_r * ca, top_r * sa, 0.0)\n\n            glLineWidth(1.0)\n            glDepthFunc(GL_LESS)\n\n        # The edge pass changes the current GL color to black. Restore the\n        # normal holder material before the next section is rendered.\n        self.color_config.gl_color('tool_body')\n\n    def _draw_cylinder_tool(self, current_tool):\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        holder_config = self._load_tts_holder_config(current_tool)\n        stickout = holder_config['stickout_inches']\n        self.color_config.gl_color('tool_body')\n\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, r, 32, 1)\n                runtime_holder_visible = bool(getattr(self, '_beagle_tts_holder_visible', True))\n                if holder_config['enabled'] and runtime_holder_visible:\n                    for section in holder_config['sections']:\n                        try:\n                            bottom_diameter = float(section['bottom_diameter_inches'])\n                            top_diameter = float(section.get('top_diameter_inches', bottom_diameter))\n                            height = float(section['height_inches'])\n                            facets = int(section.get('facets', 32))\n                            self._draw_tts_holder_section(\n                                q,\n                                bottom_diameter,\n                                top_diameter,\n                                height,\n                                facets,\n                                holder_config['edge_enabled'],\n                                holder_config['edge_width'])\n                        except Exception:\n                            continue\n"



class UserPlugin(plugin):
    def __init__(self):
        plugin.__init__(self, "%s %s" % (SCRIPT_NAME, CURRENT_VER))
        dev_machine_found = os.path.exists(DEV_MACHINE_FLAG)
        if dev_machine_found:
            plugin_enabled = DEV_MACHINE
        else:
            plugin_enabled = ENABLED
        if plugin_enabled == 0:
            self.error_handler.write("[%s] Plugin loaded, but disabled." % SCRIPT_NAME, constants.ALARM_LEVEL_QUIET)
            return
        self.gremlin = None
        self.original_gremlin_context_menu = None
        glib.timeout_add(PATCH_DELAY_MS, self.try_patch)
        glib.timeout_add(MENU_HOOK_DELAY_MS, self.install_holder_context_menu)

    def debug(self, message):
        try:
            self.error_handler.write("[%s] %s" % (SCRIPT_NAME, message), constants.ALARM_LEVEL_QUIET)
        except Exception:
            pass

    def _redis_holder_visible(self):
        try:
            value = singletons.g_Machine.redis.hget(
                HOLDER_VISIBILITY_REDIS_HASH,
                HOLDER_VISIBILITY_REDIS_KEY)
            if value is None:
                return True
            return str(value).strip().lower() not in ("0", "false", "off", "no")
        except Exception:
            return True

    def _persist_holder_visible(self, visible):
        try:
            singletons.g_Machine.redis.hset(
                HOLDER_VISIBILITY_REDIS_HASH,
                HOLDER_VISIBILITY_REDIS_KEY,
                "True" if visible else "False")
        except Exception:
            pass

    def _invalidate_holder_tool_dlist(self):
        try:
            if self.gremlin is None:
                return
            # Do not call stale_dlist() here because that issues glDeleteLists()
            # and the right-click menu callback is not inside Gremlin's GL context.
            # Changing cached_tool guarantees the next real Gremlin draw invalidates
            # the tool display list safely from inside draw_single_tool().
            self.gremlin.cached_tool = "__BEAGLE_HOLDER_VISIBILITY_CHANGED__"
            self.gremlin.queue_draw()
        except Exception as e:
            self.debug("Holder redraw request failed: %s" % str(e))

    def toggle_holder_visibility(self, menu_item=None):
        try:
            if self.gremlin is None:
                return
            visible = not bool(getattr(
                self.gremlin,
                "_beagle_tts_holder_visible",
                True))
            self.gremlin._beagle_tts_holder_visible = visible
            self._persist_holder_visible(visible)
            self._invalidate_holder_tool_dlist()
            self.debug("Tool holder display %s." % ("shown" if visible else "hidden"))
        except Exception as e:
            self.debug("Tool holder visibility toggle failed: %s" % str(e))

    def install_holder_context_menu(self):
        try:
            ui = singletons.g_Machine
            if ui is None or not hasattr(ui, "gremlin") or ui.gremlin is None:
                glib.timeout_add(1000, self.install_holder_context_menu)
                return False

            self.gremlin = ui.gremlin

            if getattr(self.gremlin, "_beagle_holder_context_menu_installed", False):
                return False

            self.gremlin._beagle_tts_holder_visible = self._redis_holder_visible()
            self.original_gremlin_context_menu = self.gremlin.make_and_show_context_menu
            plugin_self = self
            original_menu = self.original_gremlin_context_menu

            def patched_make_and_show_context_menu(gremlin_self):
                menu = original_menu()
                if menu is None:
                    return menu

                separator = gtk.SeparatorMenuItem()
                holder_visible = bool(getattr(
                    gremlin_self,
                    "_beagle_tts_holder_visible",
                    True))
                label = "Hide Tool Holder" if holder_visible else "Show Tool Holder"
                holder_item = gtk.MenuItem(label)
                holder_item.connect("activate", plugin_self.toggle_holder_visibility)

                menu.append(separator)
                menu.append(holder_item)
                separator.show()
                holder_item.show()
                return menu

            # PathPilot 2.9.1 is Python 2.  Keep Python 3-compatible binding too
            # so static/test environments can import the source.
            try:
                self.gremlin.make_and_show_context_menu = types.MethodType(
                    patched_make_and_show_context_menu,
                    self.gremlin,
                    self.gremlin.__class__)
            except TypeError:
                self.gremlin.make_and_show_context_menu = types.MethodType(
                    patched_make_and_show_context_menu,
                    self.gremlin)

            self.gremlin._beagle_holder_context_menu_installed = True
            self._invalidate_holder_tool_dlist()
            self.debug("Added Show/Hide Tool Holder to Gremlin right-click menu.")
        except Exception as e:
            self.debug("Gremlin holder context-menu hook failed: %s" % str(e))
        return False

    def safe_script_name(self):
        safe_name = SCRIPT_NAME.lower()
        safe_name = safe_name.replace(" ", "_")
        safe_name = re.sub(r"[^a-z0-9_]+", "_", safe_name)
        return safe_name

    def get_active_pathpilot_root(self):
        if os.path.exists(TMC_ROOT) == False:
            raise RuntimeError("Active PathPilot link/path was not found: %s" % TMC_ROOT)
        active_root = os.path.realpath(TMC_ROOT)
        if os.path.isdir(active_root) == False:
            raise RuntimeError("Active PathPilot root is not a directory: %s" % active_root)
        return active_root

    def get_active_pathpilot_version(self, active_root):
        version_name = os.path.basename(active_root.rstrip(os.sep))
        if version_name == "":
            return "unknown"
        return version_name

    def get_target_path(self, active_root):
        target_path = os.path.join(active_root, GREMLIN_RELATIVE_PATH)
        if os.path.isfile(target_path) == False:
            raise RuntimeError("Gremlin source file was not found: %s" % target_path)
        return target_path

    def read_file(self, path):
        handle = open(path, "r")
        try:
            return handle.read()
        finally:
            handle.close()

    def write_file(self, path, content):
        handle = open(path, "w")
        try:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        finally:
            handle.close()

    def normalize_holder_references(self, data):
        changed = False
        holder_types = data.get("holder_types")
        if isinstance(holder_types, dict) == False:
            return changed

        for holder in holder_types.values():
            if isinstance(holder, dict) == False:
                continue
            sections = holder.get("sections")
            if isinstance(sections, list) == False or len(sections) == 0:
                continue

            reference_index = None

            # Honor an explicit new-style reference marker if exactly one exists.
            explicit = []
            for index, section in enumerate(sections):
                if isinstance(section, dict) and bool(section.get("tool_length_reference", False)):
                    explicit.append(index)
            if len(explicit) == 1:
                reference_index = explicit[0]

            # For current test configs, the top of the ATC ring/flange is the
            # PathPilot tool-length reference plane.
            if reference_index is None:
                for index, section in enumerate(sections):
                    if isinstance(section, dict) == False:
                        continue
                    name = str(section.get("name", "")).lower()
                    if "atc" in name and ("ring" in name or "flange" in name):
                        reference_index = index
                        break

            if reference_index is None:
                continue

            for index, section in enumerate(sections):
                if isinstance(section, dict) == False:
                    continue
                if index == reference_index:
                    if bool(section.get("tool_length_reference", False)) == False:
                        section["tool_length_reference"] = True
                        changed = True
                else:
                    if "tool_length_reference" in section:
                        del section["tool_length_reference"]
                        changed = True
                if "below_tool_length_reference" in section:
                    del section["below_tool_length_reference"]
                    changed = True
        return changed

    def ensure_config_file(self):
        config_dir = os.path.dirname(CONFIG_PATH)
        if os.path.isdir(config_dir) == False:
            os.makedirs(config_dir)
        if os.path.exists(CONFIG_PATH) == False:
            if os.path.isfile(LEGACY_CONFIG_PATH):
                shutil.copy2(LEGACY_CONFIG_PATH, CONFIG_PATH)
                self.debug("Copied legacy holder config to canonical path: %s -> %s" % (LEGACY_CONFIG_PATH, CONFIG_PATH))
                legacy_name_migrated = True
            else:
                self.write_file(CONFIG_PATH, DEFAULT_CONFIG_TEXT)
                self.debug("Created holder config: %s" % CONFIG_PATH)
                return "created"
        else:
            legacy_name_migrated = False
        try:
            handle = open(CONFIG_PATH, "r")
            try:
                loaded = json.load(handle)
            finally:
                handle.close()
            if isinstance(loaded, dict) and not isinstance(loaded.get("holder_types"), dict) and isinstance(loaded.get("sections"), list):
                holder_type = str(loaded.get("holder_model", "TTS_ER32_GENERIC"))
                migrated = {
                    "enabled": bool(loaded.get("enabled", True)),
                    "edge_enabled": bool(loaded.get("edge_enabled", True)),
                    "edge_width": float(loaded.get("edge_width", 1.50)),
                    "default_holder_type": holder_type,
                    "stickout_mode": str(loaded.get("stickout_mode", "tool_table_length")),
                    "default_stickout_inches": float(loaded.get("default_stickout_inches", 1.50)),
                    "minimum_stickout_inches": float(loaded.get("minimum_stickout_inches", 0.05)),
                    "tool_stickout_inches": loaded.get("tool_stickout_inches", {}),
                    "holder_types": {holder_type: {"display_name": holder_type.replace("_", " "), "family": "", "manufacturer": "", "image": "gremlin_tool_holders/%s.svg" % holder_type.lower(), "sections": loaded["sections"]}}
                }
                self.normalize_holder_references(migrated)
                backup_path = "%s.%s.pre_multi_holder.bak" % (CONFIG_PATH, time.strftime("%Y%m%d_%H%M%S"))
                shutil.copy2(CONFIG_PATH, backup_path)
                self.write_file(CONFIG_PATH, json.dumps(migrated, indent=4, sort_keys=False) + "\n")
                self.debug("Migrated single-holder config; backup: %s" % backup_path)
                return "migrated"
            if isinstance(loaded, dict) and isinstance(loaded.get("holder_types"), dict):
                changed = self.normalize_holder_references(loaded)
                if "edge_enabled" not in loaded:
                    loaded["edge_enabled"] = True
                    changed = True
                if "edge_width" not in loaded:
                    loaded["edge_width"] = 1.50
                    changed = True
                for holder_type, holder in loaded["holder_types"].items():
                    if isinstance(holder, dict) == False:
                        continue
                    expected_image = "gremlin_tool_holders/%s.svg" % str(holder_type).lower()
                    if holder.get("image") != expected_image:
                        holder["image"] = expected_image
                        changed = True
                if changed:
                    backup_path = "%s.%s.pre_render_style.bak" % (CONFIG_PATH, time.strftime("%Y%m%d_%H%M%S"))
                    shutil.copy2(CONFIG_PATH, backup_path)
                    self.write_file(CONFIG_PATH, json.dumps(loaded, indent=4, sort_keys=False) + "\n")
                    self.debug("Normalized holder reference/render settings; backup: %s" % backup_path)
                    return "normalized"
        except Exception as e:
            self.debug("Holder config migration skipped: %s" % str(e))
        if legacy_name_migrated:
            return "renamed"
        return "unchanged"

    def make_chronological_backup(self, target_path):
        timestamp = time.strftime("%Y%m%d_%H%M%S")
        backup_path = "%s.%s.%s.bak" % (target_path, timestamp, self.safe_script_name())
        counter = 1
        final_path = backup_path
        while os.path.exists(final_path):
            final_path = "%s.%03d" % (backup_path, counter)
            counter += 1
        shutil.copy2(target_path, final_path)
        self.debug("Backup created: %s" % final_path)
        return final_path

    def staged_new_path(self, target_path):
        return "%s.%s.%d.new" % (target_path, self.safe_script_name(), os.getpid())

    def staged_old_path(self, target_path):
        return "%s.%s.%d.old" % (target_path, self.safe_script_name(), os.getpid())

    def cleanup_staged_files(self, target_path):
        for path in (self.staged_new_path(target_path), self.staged_old_path(target_path)):
            try:
                if os.path.exists(path):
                    os.remove(path)
            except Exception:
                pass

    def patch_content(self, content):
        if PATCH_MARKER in content:
            return content, False
        if OLD_PATCH_MARKER_V7 in content:
            count = content.count(OLD_PATCHED_TTS_TOOL_HOLDER_V7)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.06 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_TTS_TOOL_HOLDER_V7, PATCHED_CYLINDER_TOOL, 1)
        elif OLD_PATCH_MARKER_V6 in content:
            count = content.count(OLD_PATCHED_TTS_TOOL_HOLDER_V6)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.05 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_TTS_TOOL_HOLDER_V6, PATCHED_CYLINDER_TOOL, 1)
        elif OLD_PATCH_MARKER_V5 in content:
            count = content.count(OLD_PATCHED_TTS_TOOL_HOLDER_V5)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.04 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_TTS_TOOL_HOLDER_V5, PATCHED_CYLINDER_TOOL, 1)
        elif OLD_PATCH_MARKER_V4 in content:
            count = content.count(OLD_PATCHED_TTS_TOOL_HOLDER_V4)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.03 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_TTS_TOOL_HOLDER_V4, PATCHED_CYLINDER_TOOL, 1)
        elif OLD_PATCH_MARKER_V3 in content:
            count = content.count(OLD_PATCHED_TTS_TOOL_HOLDER_V3)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.02 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_TTS_TOOL_HOLDER_V3, PATCHED_CYLINDER_TOOL, 1)
        elif OLD_PATCH_MARKER_V2 in content:
            count = content.count(OLD_PATCHED_TTS_TOOL_HOLDER_V2)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.01 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_TTS_TOOL_HOLDER_V2, PATCHED_CYLINDER_TOOL, 1)
        elif OLD_PATCH_MARKER in content:
            count = content.count(OLD_PATCHED_CYLINDER_TOOL)
            if count != 1:
                raise RuntimeError("Existing Gremlin 1.00 tool-holder patch was found, but its expected block count is %d." % count)
            patched = content.replace(OLD_PATCHED_CYLINDER_TOOL, PATCHED_CYLINDER_TOOL, 1)
        else:
            count = content.count(OEM_CYLINDER_TOOL)
            if count != 1:
                raise RuntimeError("Expected Gremlin OEM _draw_cylinder_tool block exactly once, found %d." % count)
            patched = content.replace(OEM_CYLINDER_TOOL, PATCHED_CYLINDER_TOOL, 1)
        if patched.count(PATCH_MARKER) != 1:
            raise RuntimeError("Gremlin holder patch verification failed; expected 1 marker.")
        if OLD_PATCH_MARKER in patched or OLD_PATCH_MARKER_V2 in patched or OLD_PATCH_MARKER_V3 in patched or OLD_PATCH_MARKER_V4 in patched or OLD_PATCH_MARKER_V5 in patched or OLD_PATCH_MARKER_V6 in patched or OLD_PATCH_MARKER_V7 in patched:
            raise RuntimeError("Old Gremlin tool-holder marker remained after upgrade.")
        return patched, True

    def install_staged_file(self, target_path, staged_path):
        old_path = self.staged_old_path(target_path)
        if os.path.exists(old_path):
            os.remove(old_path)
        try:
            os.rename(target_path, old_path)
            os.rename(staged_path, target_path)
        except Exception:
            try:
                if os.path.exists(target_path):
                    os.remove(target_path)
            except Exception:
                pass
            if os.path.exists(old_path):
                os.rename(old_path, target_path)
            raise
        try:
            os.remove(old_path)
        except Exception:
            pass

    def acquire_patch_lock(self):
        lock_handle = open(PATCH_LOCK_PATH, "a+")
        deadline = time.time() + PATCH_LOCK_TIMEOUT_SECONDS
        while True:
            try:
                fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                self.debug("Patch lock acquired: %s" % PATCH_LOCK_PATH)
                return lock_handle
            except IOError:
                if time.time() >= deadline:
                    lock_handle.close()
                    raise RuntimeError("Timed out waiting for PathPilot patch lock.")
                time.sleep(0.10)

    def release_patch_lock(self, lock_handle):
        if lock_handle is None:
            return
        try:
            fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
        except Exception:
            pass
        try:
            lock_handle.close()
        except Exception:
            pass
        self.debug("Patch lock released: %s" % PATCH_LOCK_PATH)

    def show_install_dialog(self, success, message):
        message_type = gtk.MESSAGE_INFO if success else gtk.MESSAGE_ERROR
        dialog = gtk.MessageDialog(
            None,
            gtk.DIALOG_DESTROY_WITH_PARENT,
            message_type,
            gtk.BUTTONS_OK,
            message)
        dialog.set_title(SCRIPT_NAME)
        dialog.set_keep_above(True)
        dialog.set_modal(False)

        def on_response(widget, response_id):
            widget.destroy()

        dialog.connect("response", on_response)
        dialog.show_all()
        return False

    def try_patch(self):
        lock_handle = None
        target_path = None
        try:
            lock_handle = self.acquire_patch_lock()
            active_root = self.get_active_pathpilot_root()
            pathpilot_version = self.get_active_pathpilot_version(active_root)
            target_path = self.get_target_path(active_root)
            self.debug("Active PathPilot root: %s" % active_root)
            self.debug("Detected PathPilot version: %s" % pathpilot_version)
            self.debug("Gremlin source: %s" % target_path)
            config_state = self.ensure_config_file()
            original = self.read_file(target_path)
            patched, changed = self.patch_content(original)
            if changed == False:
                if config_state == "created":
                    self.error_handler.write("[%s] Gremlin patch already installed for PathPilot %s. Created default config: %s" % (SCRIPT_NAME, pathpilot_version, CONFIG_PATH), constants.ALARM_LEVEL_LOW)
                elif config_state == "migrated":
                    self.error_handler.write("[%s] Gremlin patch already installed for PathPilot %s. Migrated holder config: %s" % (SCRIPT_NAME, pathpilot_version, CONFIG_PATH), constants.ALARM_LEVEL_LOW)
                elif config_state == "renamed":
                    self.error_handler.write("[%s] Gremlin patch already installed for PathPilot %s. Moved legacy holder config to: %s" % (SCRIPT_NAME, pathpilot_version, CONFIG_PATH), constants.ALARM_LEVEL_LOW)
                else:
                    self.error_handler.write("[%s] Already installed for PathPilot %s. TTS holder config: %s" % (SCRIPT_NAME, pathpilot_version, CONFIG_PATH), constants.ALARM_LEVEL_QUIET)
                return False
            staged_path = self.staged_new_path(target_path)
            self.cleanup_staged_files(target_path)
            self.write_file(staged_path, patched)
            shutil.copystat(target_path, staged_path)
            os.utime(staged_path, None)
            staged_content = self.read_file(staged_path)
            if staged_content != patched:
                raise RuntimeError("Staged Gremlin source did not read back exactly.")
            if staged_content.count(PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source marker verification failed.")
            self.make_chronological_backup(target_path)
            self.install_staged_file(target_path, staged_path)
            installed = self.read_file(target_path)
            if installed.count(PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source marker verification failed after swap.")
            self.error_handler.write("[%s] Installed multi-holder Gremlin tool model for PathPilot %s. Config: %s. Restart PathPilot once more to load it." % (SCRIPT_NAME, pathpilot_version, CONFIG_PATH), constants.ALARM_LEVEL_LOW)
            success_message = (
                "Gremlin Tool Holder was installed successfully for PathPilot %s.\n\n"
                "Holder catalog is now configurable at:\n%s\n\n"
                "Restart PathPilot once more to load the updated Gremlin code."
            ) % (pathpilot_version, CONFIG_PATH)
            glib.idle_add(self.show_install_dialog, True, success_message)
        except Exception as e:
            if target_path is not None:
                self.cleanup_staged_files(target_path)
            self.error_handler.write("[%s] ERROR: %s" % (SCRIPT_NAME, str(e)), constants.ALARM_LEVEL_HIGH)
            failure_message = (
                "Gremlin Tool Holder installation failed.\n\n"
                "%s"
            ) % str(e)
            glib.idle_add(self.show_install_dialog, False, failure_message)
        finally:
            self.release_patch_lock(lock_handle)
        return False
