# 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:         tormachtips@gmail.com
# Information page: https://tormachtips.com/plugins.htm

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

# 2.52 - Extends Re-register ER1-2-3-4 with shared Z-top registration from G58. G54-G57 still register each
#        ER40 collet/exclusion XY center, while G58 Z sets zmax on ER40-1..4 and all four matching exclusion
#        cylinders. Refuses an unset/zero G58 Z, previews the Z-top change, and commits XY+Z atomically. - 9/29/2026
# 2.51 - Fixture-focused right-click menus now depend on where the CURRENT right-click lands, not merely on
#        a previously highlighted fixture face. Right-clicking empty Gremlin space restores the normal full
#        PathPilot menu while leaving the existing fixture highlight intact; right-clicking a fixture still
#        opens the compact fixture/boundary-work menu. No polling or extra picking outside menu-open time. - 9/25/2026
# 2.50 - Fixes the Fixture Geometry submenu appearing as an empty/thin popup in GTK2. Its submenu children
#        are now explicitly shown when constructed, matching the proven selected-fixture submenu pattern.
#        No registration behavior, timers, or fixture geometry logic changed. - 9/25/2026
# 2.49 - Adds a modular Fixture Geometry submenu to the Gremlin right-click menu. The first registration
#        preset, Re-register ER1-2-3-4, reads the freshly-established G54/G55/G56/G57 XY origins directly
#        from PathPilot's g5x_offsets and atomically moves ER40-1..4 plus their matching Exclusion cylinders
#        to those G53 XY centers. The operation is idle-only, requires X/Y homed, previews all four moves,
#        participates in Fixture Undo/Redo, and does not modify any work offset or machine-control state.
#        The registration engine is preset-driven so future fixture/WCS registrations can reuse it. - 9/25/2026
# 2.48 - Adds fixture-focused Gremlin context menus. When a fixture face is selected, right-click shows only
#        fixture/boundary actions (selected-face submenu, Undo/Redo, Fixture Builder, Reload Geometry), hiding
#        camera/viewer/tool/overlay/help items until the fixture highlight is cleared. A right-click away from
#        the fixture reuses the existing selected face, so boundary work remains immediately available. - 9/25/2026
# 2.47 - Forces the requested context-menu mnemonic letters to remain visibly underlined in PathPilot GTK2.
#        Keeps the existing menu-only mnemonic activation, then applies a GtkLabel underline pattern to the
#        rendered menu label so PathPilot/theme mnemonic-visibility behavior cannot hide the indicator. - 9/25/2026
# 2.46 - Adds context-menu-only GTK mnemonics for the requested Gremlin actions: I=Iso View, R=Reset Zoom,
#        H=Show/Hide Tools On Deck, F=Open Fixture Builder, and U=Undo Fixture Edit. Mnemonics are applied
#        only to the popup menu labels; no global/window accelerators or key hooks are installed. - 9/25/2026
# 2.45 - Adds session-only Fixture Undo/Redo for direct Gremlin fixture edits. Each successful edit stores one
#        bounded scene snapshot; Undo/Redo restore atomically, validate the scene before writing, and reuse the
#        existing fixture reload/redraw path. External Fixture Builder/file changes invalidate history rather
#        than overwriting newer work. No new timer or background work is added. - 9/25/2026
# 2.44 - Adds combined center editing for centered fixtures and multi-face box centering. Cylinder/frustum/hex
#        side menus can edit or capture Center X/Y together in one operation. Ctrl- or Shift-click can add a
#        second face on the same box; selecting opposite X-/X+ or Y-/Y+ faces exposes Feature Center X/Y
#        editing and current-G53 centering while preserving the feature width. No new timer or hover work. - 9/25/2026
# 2.43 - Adds event-driven fixture interaction directly in PathPilot Gremlin. A stationary left-click can
#        color-pick an exact fixture face/surface and highlight it amber; right-click on fixture geometry adds
#        shape-aware value editing, current G53 insertion, visibility, and Builder access. Picking runs only
#        on clicks, reuses the existing fixture/config refresh path, and adds no hover work or recurring timer. - 9/25/2026
# 2.42 - Adds Gremlin right-click Traveled Path controls. Clear Traveled Path erases only the live
#        yellow/red motion already accumulated; later motion immediately draws again. A persistent
#        Traveled Path checkbox can suppress live travel history entirely until re-enabled. - 9/22/2026
# 2.41 - Cleans and groups the Gremlin right-click menu with separators: view/camera, viewer layout,
#        overlays, tools/holder, fixture, and help. Moves Center Here beside Reset Zoom, keeps the OEM
#        A-axis menu item unchanged, and converts Tool Holder / Holder Rotation to positive check items. - 9/22/2026
# 2.40 - Fixes the 2.39 installer SyntaxError caused by literal newlines being inserted into the
#        embedded gremlin_draw.py patch payload. Keeps the standard-ISO holder-label rotation change. - 9/22/2026
# 2.39 - Rotates the holder T-number body label toward the standard isometric camera so it faces more
#        directly forward in PathPilot's default ISO view. - 9/22/2026
# 2.38 - Adds Excluded Zone fixture visualization and Hex Prism support. collision_role=exclude objects
#        are rendered as translucent cyan geometry with bright wire edges on top of fixture solids so
#        pass-through/bore clearances remain visible in Gremlin. Older fixture JSON remains solid by default. - 9/22/2026
# 2.37 - Adds a second persistent Gremlin right-click toggle for Program Boundary Dimensions: the OEM
#        draw_program_extents() lines and numeric X/Y/Z extent labels around the loaded toolpath. It is
#        independent of Coordinate Overlays and does not change program extents or machine limits. - 9/22/2026
# 2.36 - Adds a persistent Gremlin right-click "Coordinate Overlays" toggle. It hides/shows the OEM
#        draw_coordinate_systems() layer (local XYZ axes plus G53/G5x/G92 offset/origin graphics) without
#        changing PathPilot coordinates, toolpath, DROs, or machine state. - 9/22/2026
# 2.35 - Replaces fixed two-jaw fixture data with a live generic G53 scene: unlimited grouped vises and
#        Box / Cylinder / Frustum objects, per-object opacity, live JSON reload on the existing config timer,
#        and Fixture Builder / manual reload entries in the Gremlin menu. No fixture timer exists when disabled. - 9/22/2026
# 2.34 - Fixes fixture default JSON definition so gremlin_fixture_config.json contains only fixture jaw data;
#        restores the holder DEFAULT_CONFIG_TEXT as a separate independent configuration. - 9/22/2026
# 2.33 - Adds optional static G53 Fixture Geometry rendering with two rectangular vise jaws from a separate
#        gremlin_fixture_config.json. When FIXTURE_GEOMETRY_ENABLED=0 no draw wrapper, JSON load, or timer is installed. - 9/22/2026
# 2.32 - Adds Gremlin right-click Help / About documentation for holder visibility, rotation, TLM assignments,
#        cutter/flute visualization, T99 probe rendering, Builder/config refresh, and visualization-only limits. - 9/21/2026
# 2.31 - Moves flute-count width multipliers into flute_visuals JSON; defaults 4=1.00, 3=1.6667, 2=2.3333, 1=3.00. - 9/21/2026
# 2.30 - Release installer patches only pristine OEM Gremlin sources; removes previous-payload upgrade/reconstruction paths. - 9/21/2026
# 2.29 - Scales visual flute width by flute count: 4=1.00x, 3=1.25x, 2=1.50x, 1=1.75x. - 9/20/2026
# 2.28 - Adds Drill Bit model by reusing the chamfer-style conical renderer with configurable point angle. - 9/20/2026
# 2.27 - Adds startup suite dependency warning when TLM or Holder Builder is missing. - 9/20/2026
# 2.26 - Fixes Thread Mill 1/4 and 3/16 model dispatch and supports in-place upgrade from V225. - 9/20/2026

import os
import re
import ast
import copy
import time
import shutil
import subprocess
import sys
import fcntl
import json
import math
import types
from collections import OrderedDict
import glib
import gtk
import constants
import singletons
import linuxcnc
try:
    import minigl
except Exception:
    minigl = None
from ui_hooks import plugin

CURRENT_VER                           = "2.52"
SCRIPT_NAME                           = "Gremlin Tool Holder"
OEM_FILES_MODIFIED                    = (    '/home/operator/tmc/lib/python/gremlin/gremlin_draw.py',)
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"
FIXTURE_GEOMETRY_ENABLED              = 1
FIXTURE_CONFIG_PATH                   = "/home/operator/gcode/python/gremlin_fixture_config.json"
FIXTURE_BUILDER_PATH                  = "/home/operator/gcode/python/gremlin_fixture_builder.py"
FIXTURE_FACE_RED                      = 0.46
FIXTURE_FACE_GREEN                    = 0.50
FIXTURE_FACE_BLUE                     = 0.56
FIXTURE_FACE_ALPHA                    = 1.00
FIXTURE_EDGE_RED                      = 0.08
FIXTURE_EDGE_GREEN                    = 0.08
FIXTURE_EDGE_BLUE                     = 0.08
FIXTURE_EDGE_WIDTH                    = 2.0
FIXTURE_EXCLUSION_FACE_RED             = 0.00
FIXTURE_EXCLUSION_FACE_GREEN           = 0.68
FIXTURE_EXCLUSION_FACE_BLUE            = 0.82
FIXTURE_EXCLUSION_FACE_ALPHA           = 0.20
FIXTURE_EXCLUSION_EDGE_RED             = 0.00
FIXTURE_EXCLUSION_EDGE_GREEN           = 0.92
FIXTURE_EXCLUSION_EDGE_BLUE            = 1.00
FIXTURE_EXCLUSION_EDGE_WIDTH           = 2.5
FIXTURE_SELECTED_RED                   = 0.965
FIXTURE_SELECTED_GREEN                 = 0.773
FIXTURE_SELECTED_BLUE                  = 0.259
FIXTURE_SELECTED_ALPHA                 = 0.78
FIXTURE_SELECTED_EDGE_WIDTH            = 2.0
FIXTURE_HISTORY_LIMIT                  = 25
FIXTURE_PICK_SEARCH_RADIUS             = 2
# When a fixture face is highlighted, keep the Gremlin right-click menu focused
# on fixture/boundary work instead of showing unrelated camera/tool/view options.
FIXTURE_SELECTED_CONTEXT_MENU_ONLY     = 1

# Fixture/WCS registration presets.  Each entry maps one PathPilot work offset
# to one or more fixture objects whose XY centers should coincide with that WCS
# origin in G53 machine space.  Add future presets here without changing the
# registration engine below.
FIXTURE_WCS_REGISTRATION_PRESETS = (
    {
        "key": "er40_1_4",
        "menu_label": "Re-register ER1-2-3-4",
        "history_label": "Re-register ER1-2-3-4 from G54-G58",
        "z_top": {
            "wcs_index": 5,
            "wcs_name": "G58",
            "objects": (
                "ER40-1",
                "ER40-1 Exclusion",
                "ER40-2",
                "ER40-2 Exclusion",
                "ER40-3",
                "ER40-3 Exclusion",
                "ER40-4",
                "ER40-4 Exclusion",
            ),
            "require_nonzero": True,
        },
        "entries": (
            {
                "wcs_index": 1,
                "wcs_name": "G54",
                "objects": ("ER40-1", "ER40-1 Exclusion"),
            },
            {
                "wcs_index": 2,
                "wcs_name": "G55",
                "objects": ("ER40-2", "ER40-2 Exclusion"),
            },
            {
                "wcs_index": 3,
                "wcs_name": "G56",
                "objects": ("ER40-3", "ER40-3 Exclusion"),
            },
            {
                "wcs_index": 4,
                "wcs_name": "G57",
                "objects": ("ER40-4", "ER40-4 Exclusion"),
            },
        ),
    },
)

HOLDER_BUILDER_PATH                   = "/home/operator/gcode/python/gremlin_tool_holder_builder.py"
SUITE_DIR                             = os.path.dirname(os.path.abspath(__file__))
TOOL_LIFE_MANAGER_PATH                = os.path.join(SUITE_DIR, "tool_life_manager_plugin.py")
HOLDER_VISIBILITY_REDIS_HASH          = "machine_prefs"
HOLDER_VISIBILITY_REDIS_KEY           = "gremlin_show_tool_holder"
COORD_OVERLAY_REDIS_HASH              = "machine_prefs"
COORD_OVERLAY_REDIS_KEY               = "gremlin_show_coordinate_overlays"
PROGRAM_EXTENTS_REDIS_HASH            = "machine_prefs"
PROGRAM_EXTENTS_REDIS_KEY             = "gremlin_show_program_boundary_dimensions"
TRAVELED_PATH_REDIS_HASH              = "machine_prefs"
TRAVELED_PATH_REDIS_KEY               = "gremlin_show_traveled_path"
MENU_HOOK_DELAY_MS                    = 3500
GREMLIN_MENU_CONTRIBUTOR_KEY          = "tool_holder"
HOLDER_ROTATION_ENABLED               = 1
HOLDER_ROTATION_FPS                   = 8.0
HOLDER_ROTATION_MIN_VISUAL_RPS        = 0.15
HOLDER_ROTATION_MAX_VISUAL_RPS        = 1.00
HOLDER_ROTATION_FALLBACK_MAX_RPM      = 7500.0
HOLDER_ROTATION_INTERVAL_MS           = int(round(1000.0 / HOLDER_ROTATION_FPS))
HOLDER_ROTATION_MAX_DT                = 0.25
HOLDER_ROTATION_ZERO_RPM_EPSILON      = 0.5
HOLDER_ROTATION_REDIS_HASH            = "machine_prefs"
HOLDER_ROTATION_REDIS_KEY             = "gremlin_holder_rotation_enabled"
CONFIG_REFRESH_ENABLED                = 1
CONFIG_REFRESH_INTERVAL_MS            = 1500
ROTATION_PATCH_MARKER                 = "BEAGLE_GREMLIN_TOOL_HOLDER_ROTATION_BASELINE"
PATCH_MARKER                          = "BEAGLE_GREMLIN_TOOL_HOLDER_CLEAN_BASELINE"
GREMLIN_PAYLOAD_PATCH_MARKER          = "BEAGLE_GREMLIN_PAYLOAD_V231"
TOOL_NUMBER_LABEL_PATCH_MARKER        = "BEAGLE_GREMLIN_TOOL_NUMBER_BODY_LABEL"
TOOL_NUMBER_FONT_PATCH_MARKER         = "BEAGLE_GREMLIN_TOOL_NUMBER_FONT_REFINED"
TOOL_NUMBER_6_WIDE_BOWL_PATCH_MARKER  = "BEAGLE_GREMLIN_TOOL_NUMBER_6_WIDE_BOWL"
MODULAR_SECTION_PATCH_MARKER          = "BEAGLE_GREMLIN_MODULAR_HOLDER_SECTIONS_V1"
TOOL_MODEL_PATCH_MARKER               = "BEAGLE_GREMLIN_SIMPLE_TOOL_MODELS_V1"
FLUTE_VISUAL_PATCH_MARKER             = "BEAGLE_GREMLIN_FLUTE_WIDTH_PERCENT_V4"
REAMER_MODEL_PATCH_MARKER             = "BEAGLE_GREMLIN_REAMER_MODEL_V1"
PROBE_HOLDER_PATCH_MARKER             = "BEAGLE_GREMLIN_T99_PROBE_HOLDER"
OEM_BALL_PROBE_TOOL                   = "    def _draw_ball_probe_tool(self, current_tool):\n        # Use default tool alpha (used for constant alpha blend)\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        # KLUDGE hard-code until we can embed this in tool properties\n        shaft_radius = .66 * r\n        # KLUDGE hard-code the probe tip length in inches\n        shaft_height = 1.25\n        facets = 32\n\n        self.color_config.gl_color('probe_shaft')\n        with gl_push_matrix(), gl_quadric() as q_shaft:\n            # Offset so shaft end is at tip center (for visual alignment)\n            glTranslatef(0, 0, r)\n            gluCylinder(q_shaft, shaft_radius, shaft_radius, shaft_height, facets, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q_shaft, 0, shaft_radius, facets, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, shaft_height)\n                gluDisk(q_shaft, 0, shaft_radius, facets, 1)\n        self.color_config.gl_color('probe_tip')\n        with gl_quadric() as q_ball:\n            glTranslatef(0, 0, r)\n            gluSphere(q_ball, r, 24, 24)\n\n"
PATCHED_BALL_PROBE_TOOL               = "    def _draw_ball_probe_tool(self, current_tool):\n        # BEAGLE_GREMLIN_T99_PROBE_HOLDER\n        # OEM Gremlin special-cases tool 99. Keep its dedicated ball/shaft, then\n        # use the same modular holder-stack renderer as ordinary tools.\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        shaft_radius = .66 * r\n        facets = 32\n\n        holder_config = self._load_tts_holder_config(current_tool)\n        runtime_holder_visible = bool(getattr(self, '_beagle_tts_holder_visible', True))\n        draw_holder = bool(holder_config['enabled'] and runtime_holder_visible)\n        stickout = holder_config['stickout_inches']\n\n        shaft_height = 1.25\n        if draw_holder:\n            try:\n                derived_shaft_height = float(stickout) - float(r)\n                if derived_shaft_height > 0.05:\n                    shaft_height = derived_shaft_height\n            except Exception:\n                pass\n\n        self.color_config.gl_color('probe_shaft')\n        with gl_push_matrix(), gl_quadric() as q_shaft:\n            glTranslatef(0, 0, r)\n            gluCylinder(q_shaft, shaft_radius, shaft_radius, shaft_height, facets, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q_shaft, 0, shaft_radius, facets, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, shaft_height)\n                gluDisk(q_shaft, 0, shaft_radius, facets, 1)\n\n        self.color_config.gl_color('probe_tip')\n        with gl_push_matrix(), gl_quadric() as q_ball:\n            glTranslatef(0, 0, r)\n            gluSphere(q_ball, r, 24, 24)\n\n        if not draw_holder:\n            return\n\n        with gl_push_matrix(), gl_quadric() as q_holder:\n            glTranslatef(0, 0, stickout)\n            self._draw_tts_holder_stack(\n                q_holder,\n                holder_config,\n                0.0,\n                0.0,\n                False)\n"
OEM_CYLINDER_TOOL                     = '    def _draw_cylinder_tool(self, current_tool):\n        """\n        Draws a basic cylindrical tool shape with current material / color settings\n        :param current_tool: Model data for the current tool\n        """\n        dia = current_tool.diameter\n        r = self.to_internal_linear_unit(dia) / 2.\n        self.color_config.gl_color(\'tool_body\')\n        with gl_quadric() as q:\n            gluCylinder(q, r, r, 8 * r, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, r, 32, 1)\n            glTranslatef(0, 0, 8 * r)\n            gluDisk(q, 0, r, 32, 1)\n\n'
PATCHED_CYLINDER_TOOL                 = "    def _load_tts_holder_config(self, current_tool):\n        # BEAGLE_GREMLIN_TOOL_HOLDER_CLEAN_BASELINE\n        # BEAGLE_GREMLIN_PAYLOAD_V231\n        # BEAGLE_GREMLIN_MODULAR_HOLDER_SECTIONS_V1\n        default_holder = {\n            'display_name': 'TTS ER32 GENERIC',\n            'sections': [\n                {'name': 'ER32 hex collet nut', 'material': 'nut', 'height_inches': 0.4, 'bottom_diameter_inches': 1.34, 'top_diameter_inches': 1.34, 'facets': 6},\n                {'name': 'gutter / relief', 'material': 'relief', 'height_inches': 0.44, 'bottom_diameter_inches': 1.34, 'top_diameter_inches': 1.34, 'facets': 32},\n                {'name': 'holder body', 'material': 'body', 'vector_font_overlay': True, 'height_inches': 0.885, 'bottom_diameter_inches': 1.1, 'top_diameter_inches': 1.1, 'facets': 32},\n                {'name': 'ATC retention ring', 'material': 'flange', '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', 'material': '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            'chamfer_enabled': False,\n            'edge_width': 3.00,\n            'vector_font_line_width': 2.00,\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            'tool_model_dimensions': {\n                'ball_mill': {'hemisphere_segments': 12},\n                'bull_nose_end_mill': {'corner_radius_inches': 0.0625, 'corner_segments': 8},\n                'reamer_straight': {'cutting_length_inches': 0.750, 'shank_diameter_ratio': 0.70},\n                'reamer_spiral': {'cutting_length_inches': 0.750, 'shank_diameter_ratio': 0.70, 'helix_angle_degrees': 25.0},\n                'chamfer_90': {'tip_diameter_inches': 0.0},\n                'chamfer_45': {'tip_diameter_inches': 0.0},\n                'drill_bit': {'included_angle_degrees': 118.0, 'tip_diameter_inches': 0.0},\n                'key_cutter': {'head_thickness_inches': 0.125, 'neck_diameter_inches': 0.250, 'neck_length_inches': 0.500, 'shank_diameter_inches': 0.375},\n                'key_cutter_rounded': {'head_thickness_inches': 0.125, 'edge_radius_inches': 0.03125, 'edge_segments': 6, 'neck_diameter_inches': 0.250, 'neck_length_inches': 0.500, 'shank_diameter_inches': 0.375},\n                'dovetail': {'included_angle_degrees': 60.0, 'neck_diameter_inches': 0.250, 'neck_length_inches': 0.250, 'shank_diameter_inches': 0.375},\n                'thread_mill': {'included_angle_degrees': 60.0, 'neck_diameter_inches': 0.250, 'neck_length_inches': 0.500, 'shank_diameter_inches': 0.375},\n                'thread_mill_0250': {'included_angle_degrees': 60.0, 'neck_diameter_inches': 0.1875, 'neck_length_inches': 0.500, 'shank_diameter_inches': 0.250},\n                'thread_mill_01875': {'included_angle_degrees': 60.0, 'neck_diameter_inches': 0.125, 'neck_length_inches': 0.500, 'shank_diameter_inches': 0.1875},\n            },\n            'tool_model_overrides': {},\n            'flute_visuals': {\n                'enabled': True,\n                'default_count': 4,\n                'helix_angle_degrees': 15.0,\n                'mark_width_percent': 20.0,\n                'width_scale_by_flute_count': {'1': 3.0, '2': 2.3333333333, '3': 1.6666666667, '4': 1.0},\n                'surface_offset_inches': 0.002,\n                'segments': 64,\n                'max_turns': 1.5,\n            },\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                config['chamfer_enabled'] = bool(loaded.get('chamfer_enabled', config['chamfer_enabled']))\n                try:\n                    config['edge_width'] = float(loaded.get('edge_width', config['edge_width']))\n                except Exception:\n                    config['edge_width'] = 3.00\n                # BEAGLE_GREMLIN_VECTOR_FONT_HOT_RELOAD_FIX\n                try:\n                    config['vector_font_line_width'] = float(\n                        loaded.get(\n                            'vector_font_line_width',\n                            config['vector_font_line_width']))\n                except Exception:\n                    config['vector_font_line_width'] = 2.00\n                config['default_holder_type'] = str(loaded.get('default_holder_type', 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                if isinstance(loaded.get('tool_model_dimensions'), dict):\n                    for model_name, model_values in loaded['tool_model_dimensions'].items():\n                        if isinstance(model_values, dict):\n                            base_values = dict(config['tool_model_dimensions'].get(str(model_name), {}))\n                            base_values.update(model_values)\n                            config['tool_model_dimensions'][str(model_name)] = base_values\n                    legacy_dimensions = loaded['tool_model_dimensions']\n                    legacy_reamer = legacy_dimensions.get('reamer')\n                    if isinstance(legacy_reamer, dict):\n                        for new_name in ('reamer_straight', 'reamer_spiral'):\n                            if new_name not in legacy_dimensions:\n                                migrated = dict(config['tool_model_dimensions'].get(new_name, {}))\n                                for key in ('cutting_length_inches', 'shank_diameter_ratio'):\n                                    if key in legacy_reamer:\n                                        migrated[key] = legacy_reamer[key]\n                                config['tool_model_dimensions'][new_name] = migrated\n                    legacy_chamfer = legacy_dimensions.get('chamfer_drill')\n                    if isinstance(legacy_chamfer, dict):\n                        for new_name in ('chamfer_90', 'chamfer_45'):\n                            if new_name not in legacy_dimensions:\n                                migrated = dict(config['tool_model_dimensions'].get(new_name, {}))\n                                if 'tip_diameter_inches' in legacy_chamfer:\n                                    migrated['tip_diameter_inches'] = legacy_chamfer['tip_diameter_inches']\n                                config['tool_model_dimensions'][new_name] = migrated\n                if isinstance(loaded.get('tool_model_overrides'), dict):\n                    config['tool_model_overrides'] = loaded['tool_model_overrides']\n                if isinstance(loaded.get('flute_visuals'), dict):\n                    flute_visuals = dict(config['flute_visuals'])\n                    flute_visuals.update(loaded['flute_visuals'])\n                    config['flute_visuals'] = flute_visuals\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        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        if config['vector_font_line_width'] < 0.50:\n            config['vector_font_line_width'] = 0.50\n        elif config['vector_font_line_width'] > 10.0:\n            config['vector_font_line_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\n        # BEAGLE_GREMLIN_SIMPLE_TOOL_MODELS_V1\n        # BEAGLE_GREMLIN_FLUTE_WIDTH_PERCENT_V4\n        # BEAGLE_GREMLIN_REAMER_MODEL_V1\n        # Tool model choice lives in the TLM SQLite DB. Dimensions remain in the\n        # holder JSON so they can be edited manually without changing this source.\n        tool_model = 'standard'\n        flute_count = 4\n        try:\n            flute_count = int(config.get('flute_visuals', {}).get('default_count', 4))\n        except Exception:\n            flute_count = 4\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 model_type, flute_count FROM tool_model_assignment WHERE tool = ?', (int(current_tool.id),))\n                assignment = cursor.fetchone()\n                if assignment:\n                    if assignment[0]:\n                        tool_model = str(assignment[0]).strip().lower()\n                    if assignment[1] is not None:\n                        flute_count = int(assignment[1])\n            finally:\n                db.close()\n        except Exception:\n            tool_model = 'standard'\n        # Fixed catalog aliases preserve assignments made by the previous five-model build.\n        if tool_model == 'chamfer_drill':\n            tool_model = 'chamfer_90'\n        elif tool_model == 'reamer':\n            tool_model = 'reamer_straight'\n        if tool_model not in (\n                'standard', 'ball_mill', 'bull_nose_end_mill',\n                'reamer_straight', 'reamer_spiral',\n                'chamfer_90', 'chamfer_45', 'drill_bit',\n                'key_cutter', 'key_cutter_rounded', 'dovetail',\n                'thread_mill', 'thread_mill_0250', 'thread_mill_01875'):\n            tool_model = 'standard'\n        if flute_count < 1:\n            flute_count = 1\n        elif flute_count > 6:\n            flute_count = 6\n        config['tool_model'] = tool_model\n        config['flute_count'] = flute_count\n\n        model_params = {}\n        try:\n            defaults = config.get('tool_model_dimensions', {}).get(tool_model, {})\n            if isinstance(defaults, dict):\n                model_params.update(defaults)\n            overrides = config.get('tool_model_overrides', {})\n            override = None\n            if isinstance(overrides, dict):\n                if tool_key in overrides:\n                    override = overrides.get(tool_key)\n                elif ('T%s' % tool_key) in overrides:\n                    override = overrides.get('T%s' % tool_key)\n            if isinstance(override, dict):\n                model_params.update(override)\n        except Exception:\n            model_params = {}\n        config['tool_model_params'] = model_params\n\n        # BEAGLE_GREMLIN_TOOL_NUMBER_BODY_LABEL\n        # The vector overlay is always the active PathPilot T-number. The selected\n        # primitive controls where it is drawn; holder names/text do not.\n        tool_number_label = ''\n        try:\n            tool_number = int(current_tool.id)\n            if tool_number > 0:\n                tool_number_label = 'T%d' % tool_number\n        except Exception:\n            tool_number_label = ''\n        config['tool_number_label'] = tool_number_label\n        return config\n\n    def _tts_holder_section_gray(self, section):\n        # Current modular schema: appearance is driven only by explicit material.\n        if not isinstance(section, dict):\n            return 0.50\n\n        try:\n            explicit_gray = section.get('material_gray', None)\n            if explicit_gray is not None:\n                explicit_gray = float(explicit_gray)\n                if explicit_gray < 0.20:\n                    explicit_gray = 0.20\n                elif explicit_gray > 0.85:\n                    explicit_gray = 0.85\n                return explicit_gray\n        except Exception:\n            pass\n\n        material = str(section.get('material', '') or '').strip().lower()\n        material_grays = {\n            'nut': 0.46,\n            'relief': 0.44,\n            'body': 0.50,\n            'flange': 0.52,\n            'shank': 0.55,\n        }\n        return material_grays.get(material, 0.50)\n\n    def _tts_holder_label_enabled(self, holder_config):\n        try:\n            for section in holder_config.get('sections', []):\n                if isinstance(section, dict) and bool(section.get('vector_font_overlay', False)):\n                    return True\n        except Exception:\n            pass\n        return False\n\n    def _tts_holder_label_text(self, raw):\n        try:\n            raw = str(raw or '')\n        except Exception:\n            raw = ''\n        cleaned = []\n        previous_space = False\n        for ch in raw.upper():\n            if ('A' <= ch <= 'Z') or ('0' <= ch <= '9') or ch in ('-', '_', ' ', '/', '.'):\n                if ch in ('_', ' '):\n                    if previous_space:\n                        continue\n                    cleaned.append(' ')\n                    previous_space = True\n                else:\n                    cleaned.append(ch)\n                    previous_space = False\n        return ''.join(cleaned).strip()[:18]\n\n    def _tts_holder_label_section_index(self, sections):\n        for index, section in enumerate(sections):\n            try:\n                if bool(section.get('vector_font_overlay', False)):\n                    return index\n            except Exception:\n                pass\n        return None\n\n    def _tts_holder_char_strokes(self, ch):\n        font = {\n            'A': [[(0.0,0.0),(0.0,0.7),(0.5,1.0),(1.0,0.7),(1.0,0.0)],[(0.2,0.48),(0.8,0.48)]],\n            'B': [[(0.0,0.0),(0.0,1.0),(0.62,1.0),(0.82,0.86),(0.82,0.64),(0.62,0.52),(0.0,0.52)],[(0.0,0.52),(0.66,0.52),(0.88,0.38),(0.88,0.14),(0.66,0.0),(0.0,0.0)]],\n            'C': [[(0.95,0.9),(0.72,1.0),(0.24,1.0),(0.0,0.75),(0.0,0.25),(0.24,0.0),(0.72,0.0),(0.95,0.1)]],\n            'D': [[(0.0,0.0),(0.0,1.0),(0.56,1.0),(0.92,0.74),(0.92,0.26),(0.56,0.0),(0.0,0.0)]],\n            'E': [[(0.95,1.0),(0.0,1.0),(0.0,0.0),(0.95,0.0)],[(0.0,0.5),(0.72,0.5)]],\n            'F': [[(0.0,0.0),(0.0,1.0),(0.95,1.0)],[(0.0,0.5),(0.72,0.5)]],\n            'G': [[(0.95,0.88),(0.72,1.0),(0.24,1.0),(0.0,0.75),(0.0,0.25),(0.24,0.0),(0.72,0.0),(0.95,0.2),(0.95,0.45),(0.58,0.45)]],\n            'H': [[(0.0,0.0),(0.0,1.0)],[(1.0,0.0),(1.0,1.0)],[(0.0,0.5),(1.0,0.5)]],\n            'I': [[(0.0,1.0),(1.0,1.0)],[(0.5,1.0),(0.5,0.0)],[(0.0,0.0),(1.0,0.0)]],\n            'J': [[(0.0,1.0),(1.0,1.0)],[(0.72,1.0),(0.72,0.2),(0.56,0.0),(0.18,0.0),(0.0,0.18)]],\n            'K': [[(0.0,0.0),(0.0,1.0)],[(1.0,1.0),(0.0,0.46),(1.0,0.0)]],\n            'L': [[(0.0,1.0),(0.0,0.0),(0.95,0.0)]],\n            'M': [[(0.0,0.0),(0.0,1.0),(0.5,0.54),(1.0,1.0),(1.0,0.0)]],\n            'N': [[(0.0,0.0),(0.0,1.0),(1.0,0.0),(1.0,1.0)]],\n            'O': [[(0.24,0.0),(0.76,0.0),(1.0,0.24),(1.0,0.76),(0.76,1.0),(0.24,1.0),(0.0,0.76),(0.0,0.24),(0.24,0.0)]],\n            'P': [[(0.0,0.0),(0.0,1.0),(0.68,1.0),(0.92,0.78),(0.92,0.58),(0.68,0.38),(0.0,0.38)]],\n            'Q': [[(0.24,0.0),(0.76,0.0),(1.0,0.24),(1.0,0.76),(0.76,1.0),(0.24,1.0),(0.0,0.76),(0.0,0.24),(0.24,0.0)],[(0.56,0.24),(1.0,-0.1)]],\n            'R': [[(0.0,0.0),(0.0,1.0),(0.66,1.0),(0.9,0.8),(0.9,0.6),(0.66,0.42),(0.0,0.42)],[(0.48,0.42),(1.0,0.0)]],\n            'S': [[(0.92,0.84),(0.72,1.0),(0.22,1.0),(0.0,0.8),(0.0,0.62),(0.2,0.5),(0.78,0.5),(1.0,0.36),(1.0,0.18),(0.78,0.0),(0.22,0.0),(0.02,0.14)]],\n            'T': [[(0.0,1.0),(1.0,1.0)],[(0.5,1.0),(0.5,0.0)]],\n            'U': [[(0.0,1.0),(0.0,0.24),(0.24,0.0),(0.76,0.0),(1.0,0.24),(1.0,1.0)]],\n            'V': [[(0.0,1.0),(0.5,0.0),(1.0,1.0)]],\n            'W': [[(0.0,1.0),(0.22,0.0),(0.5,0.56),(0.78,0.0),(1.0,1.0)]],\n            'X': [[(0.0,1.0),(1.0,0.0)],[(0.0,0.0),(1.0,1.0)]],\n            'Y': [[(0.0,1.0),(0.5,0.5),(1.0,1.0)],[(0.5,0.5),(0.5,0.0)]],\n            'Z': [[(0.0,1.0),(1.0,1.0),(0.0,0.0),(1.0,0.0)]],\n            '0': [[(0.24,0.0),(0.76,0.0),(1.0,0.24),(1.0,0.76),(0.76,1.0),(0.24,1.0),(0.0,0.76),(0.0,0.24),(0.24,0.0)],[(0.2,0.18),(0.8,0.82)]],\n            '1': [[(0.22,0.78),(0.5,1.0),(0.5,0.0)],[(0.22,0.0),(0.78,0.0)]],\n            '2': [[(0.08,0.78),(0.26,1.0),(0.78,1.0),(1.0,0.8),(1.0,0.62),(0.0,0.0),(1.0,0.0)]],\n            '3': [[(0.06,0.86),(0.26,1.0),(0.76,1.0),(0.98,0.8),(0.76,0.56),(0.42,0.5),(0.76,0.44),(0.98,0.2),(0.76,0.0),(0.26,0.0),(0.06,0.14)]],\n            '4': [[(0.82,0.0),(0.82,1.0)],[(0.0,0.34),(1.0,0.34)],[(0.0,0.34),(0.64,1.0)]],\n            '5': [[(1.0,1.0),(0.16,1.0),(0.08,0.56),(0.72,0.56),(0.98,0.34),(0.98,0.18),(0.74,0.0),(0.24,0.0),(0.04,0.16)]],\n            # BEAGLE_GREMLIN_TOOL_NUMBER_FONT_REFINED\n            # BEAGLE_GREMLIN_TOOL_NUMBER_6_WIDE_BOWL\n            '6': [[(0.86,0.90),(0.68,1.0),(0.32,1.0),(0.12,0.82),(0.12,0.30)],[(0.12,0.38),(0.28,0.54),(0.68,0.54),(0.86,0.40),(0.86,0.18),(0.68,0.0),(0.30,0.0),(0.12,0.18),(0.12,0.38)]],\n            '7': [[(0.0,1.0),(1.0,1.0),(0.26,0.0)]],\n            '8': [[(0.24,0.0),(0.76,0.0),(1.0,0.18),(1.0,0.38),(0.76,0.5),(0.24,0.5),(0.0,0.38),(0.0,0.18),(0.24,0.0)],[(0.24,0.5),(0.76,0.5),(1.0,0.64),(1.0,0.84),(0.76,1.0),(0.24,1.0),(0.0,0.84),(0.0,0.64),(0.24,0.5)]],\n            '9': [[(0.28,0.48),(0.70,0.48),(0.92,0.66),(0.92,0.82),(0.72,1.0),(0.28,1.0),(0.08,0.82),(0.08,0.66),(0.28,0.48)],[(0.92,0.78),(0.92,0.22),(0.72,0.0),(0.34,0.0),(0.14,0.14)]],\n            '-': [[(0.14,0.5),(0.86,0.5)]],\n            '/': [[(0.06,0.0),(0.94,1.0)]],\n            '.': [[(0.46,0.0),(0.54,0.0)]],\n            ' ': []\n        }\n        return font.get(ch, font.get(' '))\n\n    def _draw_tts_holder_body_label(self, section, height, label_text, holder_config):\n        label_text = self._tts_holder_label_text(label_text)\n        if not label_text:\n            return\n\n        try:\n            bottom_r = float(section.get('bottom_diameter_inches', 0.0)) * 0.5\n            top_r = float(section.get(\n                'top_diameter_inches',\n                section.get('bottom_diameter_inches', 0.0))) * 0.5\n        except Exception:\n            return\n\n        reference_r = min(bottom_r, top_r)\n        if reference_r <= 0.0 or height <= 0.0:\n            return\n\n        char_count = len(label_text)\n        if char_count <= 0:\n            return\n\n        # Fit the label to a real arc around the holder body instead of a flat\n        # tangent plane.  About 140 degrees makes the wrapping obvious while a\n        # short label such as T6 or T125 remains readable.\n        char_aspect = 0.68\n        char_gap = 0.16\n        max_arc_radians = math.radians(140.0)\n        available_arc_length = reference_r * max_arc_radians\n        available_height = max(0.10, height * 0.45)\n\n        denom = (char_count * char_aspect) + ((char_count - 1) * char_gap)\n        if denom <= 0:\n            return\n\n        char_height = min(available_height, available_arc_length / denom)\n        if char_height < 0.055:\n            return\n\n        char_width = char_height * char_aspect\n        gap_width = char_height * char_gap\n        total_arc_length = (\n            (char_count * char_width) +\n            ((char_count - 1) * gap_width))\n        start_arc = -0.5 * total_arc_length\n        base_z = (height * 0.5) - (0.5 * char_height)\n\n        # Bias the wrapped holder label toward the default PathPilot standard\n        # isometric camera so the tool number reads more head-on instead of\n        # sitting too far around the right side of the holder.\n        label_theta_offset = math.radians(-45.0)\n\n        # BEAGLE_GREMLIN_VECTOR_FONT_LINE_WIDTH_JSON\n        try:\n            line_width = float(holder_config.get('vector_font_line_width', 1.2))\n        except Exception:\n            line_width = 1.2\n        if line_width < 0.5:\n            line_width = 0.5\n        elif line_width > 10.0:\n            line_width = 10.0\n        radial_offset = 0.010\n        max_segment_angle = math.radians(4.0)\n\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.10, 0.10, 0.10)\n            glLineWidth(line_width)\n\n            cursor_arc = 0.0\n            for ch in label_text:\n                strokes = self._tts_holder_char_strokes(ch)\n                if strokes:\n                    for stroke in strokes:\n                        if len(stroke) < 2:\n                            continue\n\n                        # Subdivide stroke segments in angular space so\n                        # horizontal/diagonal lines hug the cylinder instead of\n                        # cutting straight chords through it. This geometry is\n                        # still compiled only once into Gremlin's cached tool\n                        # display list; rotation frames do no label construction.\n                        for point_index in range(len(stroke) - 1):\n                            px1, pz1 = stroke[point_index]\n                            px2, pz2 = stroke[point_index + 1]\n\n                            arc1 = start_arc + cursor_arc + (float(px1) * char_width)\n                            arc2 = start_arc + cursor_arc + (float(px2) * char_width)\n                            z1 = base_z + (float(pz1) * char_height)\n                            z2 = base_z + (float(pz2) * char_height)\n\n                            theta1 = arc1 / reference_r\n                            theta2 = arc2 / reference_r\n                            delta_theta = abs(theta2 - theta1)\n                            steps = max(\n                                1,\n                                int(delta_theta / max_segment_angle) + 1)\n\n                            with gl_begin(GL_LINE_STRIP):\n                                for step in range(steps + 1):\n                                    fraction = float(step) / float(steps)\n                                    arc = arc1 + ((arc2 - arc1) * fraction)\n                                    z = z1 + ((z2 - z1) * fraction)\n                                    theta = (arc / reference_r) + label_theta_offset\n                                    # Respect a tapered body as well as a\n                                    # cylinder by interpolating the local radius.\n                                    z_fraction = z / float(height)\n                                    if z_fraction < 0.0:\n                                        z_fraction = 0.0\n                                    elif z_fraction > 1.0:\n                                        z_fraction = 1.0\n\n                                    surface_r = (\n                                        bottom_r +\n                                        ((top_r - bottom_r) * z_fraction) +\n                                        radial_offset)\n\n                                    glVertex3f(\n                                        surface_r * math.cos(theta),\n                                        surface_r * math.sin(theta),\n                                        z)\n\n                cursor_arc += char_width + gap_width\n\n            glLineWidth(1.0)\n            glDepthFunc(GL_LESS)\n\n    def _draw_tts_holder_section(self, q, section, chamfer_enabled, previous_section, next_section):\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        except Exception:\n            return\n\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        section_gray = self._tts_holder_section_gray(section)\n        underside_gray = max(0.30, section_gray - 0.10)\n\n        try:\n            previous_top_diameter = float(previous_section.get(\n                'top_diameter_inches',\n                previous_section.get('bottom_diameter_inches', 0.0))) if isinstance(previous_section, dict) else 0.0\n            previous_facets = int(previous_section.get('facets', 32)) if isinstance(previous_section, dict) else 0\n        except Exception:\n            previous_top_diameter = 0.0\n            previous_facets = 0\n        try:\n            next_bottom_diameter = float(next_section.get(\n                'bottom_diameter_inches',\n                next_section.get('top_diameter_inches', 0.0))) if isinstance(next_section, dict) else 0.0\n            next_facets = int(next_section.get('facets', 32)) if isinstance(next_section, dict) else 0\n        except Exception:\n            next_bottom_diameter = 0.0\n            next_facets = 0\n\n        # Lightweight visual-only edge breaks. These do not change section\n        # height, holder length, or the explicit tool-length reference plane.\n        bottom_break_height = 0.0\n        top_break_height = 0.0\n        bottom_end_r = bottom_r\n        top_end_r = top_r\n        core_height = height\n\n        if chamfer_enabled:\n            max_axial_break = min(0.025, height * 0.12)\n            max_radial_break = 0.020\n            radius_epsilon = 0.005\n\n            previous_r = previous_top_diameter / 2. if previous_top_diameter > 0 else 0.0\n            next_r = next_bottom_diameter / 2. if next_bottom_diameter > 0 else 0.0\n\n            bottom_delta = previous_r - bottom_r\n            bottom_shape_change = (\n                previous_r > 0 and\n                int(previous_facets or 0) >= 3 and\n                int(previous_facets or 0) != int(facets))\n            if previous_r <= 0:\n                bottom_break_height = max_axial_break\n                bottom_end_r = max(0.001, bottom_r - max_radial_break)\n            elif abs(bottom_delta) >= radius_epsilon:\n                bottom_break_height = max_axial_break\n                radial_break = min(max_radial_break, abs(bottom_delta))\n                if bottom_delta > 0:\n                    bottom_end_r = bottom_r + radial_break\n                else:\n                    bottom_end_r = max(0.001, bottom_r - radial_break)\n            elif bottom_shape_change:\n                bottom_break_height = max_axial_break\n                bottom_end_r = max(0.001, bottom_r - max_radial_break)\n\n            top_delta = next_r - top_r\n            top_shape_change = (\n                next_r > 0 and\n                int(next_facets or 0) >= 3 and\n                int(next_facets or 0) != int(facets))\n            if next_r <= 0:\n                top_break_height = max_axial_break\n                top_end_r = max(0.001, top_r - max_radial_break)\n            elif abs(top_delta) >= radius_epsilon:\n                top_break_height = max_axial_break\n                radial_break = min(max_radial_break, abs(top_delta))\n                if top_delta > 0:\n                    top_end_r = top_r + radial_break\n                else:\n                    top_end_r = max(0.001, top_r - radial_break)\n            elif top_shape_change:\n                top_break_height = max_axial_break\n                top_end_r = max(0.001, top_r - max_radial_break)\n\n            total_break_height = bottom_break_height + top_break_height\n            max_total_break_height = height * 0.30\n            if total_break_height > max_total_break_height and total_break_height > 0:\n                scale = max_total_break_height / total_break_height\n                bottom_break_height *= scale\n                top_break_height *= scale\n\n            core_height = height - bottom_break_height - top_break_height\n            if core_height < 0.0:\n                core_height = 0.0\n\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            glColor3f(underside_gray, underside_gray, underside_gray)\n            gluDisk(q, 0, bottom_end_r, facets, 1)\n\n        glColor3f(section_gray, section_gray, section_gray)\n\n        if bottom_break_height > 0:\n            gluCylinder(q, bottom_end_r, bottom_r, bottom_break_height, facets, 1)\n            glTranslatef(0, 0, bottom_break_height)\n\n        if core_height > 0:\n            gluCylinder(q, bottom_r, top_r, core_height, facets, 1)\n            glTranslatef(0, 0, core_height)\n\n        if top_break_height > 0:\n            gluCylinder(q, top_r, top_end_r, top_break_height, facets, 1)\n            glTranslatef(0, 0, top_break_height)\n\n        gluDisk(q, 0, top_end_r, facets, 1)\n        glColor3f(section_gray, section_gray, section_gray)\n\n    def _draw_tts_holder_stack(self, q, holder_config, cutter_r, stickout, include_cutter_edges):\n        # One generic stack renderer handles ordinary cutters and special tools\n        # such as T99. Geometry is entirely data-driven by the ordered sections.\n        sections = holder_config.get('sections', [])\n        if not isinstance(sections, list) or not sections:\n            return\n\n        label_index = None\n        label_text = ''\n        if self._tts_holder_label_enabled(holder_config):\n            label_text = self._tts_holder_label_text(holder_config.get('tool_number_label', ''))\n            if label_text:\n                label_index = self._tts_holder_label_section_index(sections)\n\n        holder_stack_height = 0.0\n        for section in sections:\n            try:\n                section_height = float(section.get('height_inches', 0.0))\n                if section_height > 0:\n                    holder_stack_height += section_height\n            except Exception:\n                pass\n\n        for section_index, section in enumerate(sections):\n            try:\n                height = float(section.get('height_inches', 0.0))\n                previous_section = sections[section_index - 1] if section_index > 0 else None\n                next_section = sections[section_index + 1] if section_index + 1 < len(sections) else None\n                self._draw_tts_holder_section(\n                    q,\n                    section,\n                    holder_config['chamfer_enabled'],\n                    previous_section,\n                    next_section)\n\n                if label_index == section_index and label_text and height > 0:\n                    with gl_push_matrix():\n                        glTranslatef(0, 0, -height)\n                        self._draw_tts_holder_body_label(section, height, label_text, holder_config)\n            except Exception:\n                continue\n\n        if holder_config['edge_enabled']:\n            with gl_push_matrix():\n                glTranslatef(0, 0, -holder_stack_height)\n                self._draw_tts_holder_final_edges(\n                    q,\n                    sections,\n                    cutter_r if include_cutter_edges else 0.0,\n                    stickout if include_cutter_edges else 0.0,\n                    holder_config['edge_width'])\n\n    def _draw_tts_holder_final_edges(self, q, sections, cutter_r, stickout, edge_width):\n        # Final edge pass, intentionally drawn AFTER every holder solid/text.\n        #\n        # Use very small opaque black rim geometry rather than depending only on\n        # GL line primitives. With Gremlin's translucent tool rendering, nearly\n        # coplanar GL_LINE_LOOP edges can lose the depth test from some views.\n        # These microscopic annular rims / boundary bands physically sit just\n        # outside the solids, so the visible hard edges stay black reliably.\n        if edge_width <= 0:\n            return\n\n        rim_radial = max(\n            0.0025,\n            min(0.0060, 0.0020 + (float(edge_width) * 0.0015)))\n        rim_axial = max(\n            0.0015,\n            min(0.0040, 0.0010 + (float(edge_width) * 0.0010)))\n\n        def draw_boundary_rim(radius, facets, z_position):\n            if radius <= 0:\n                return\n            if facets < 3:\n                facets = 3\n\n            inner_r = max(0.0, radius - rim_radial)\n            outer_r = radius + rim_radial\n\n            # Face ring slightly above the boundary plane.\n            with gl_push_matrix():\n                glTranslatef(0, 0, z_position + rim_axial)\n                gluDisk(q, inner_r, outer_r, facets, 1)\n\n            # Matching face ring slightly below it. Culling is disabled for this\n            # pass, so one GLU orientation is sufficient for both sides.\n            with gl_push_matrix():\n                glTranslatef(0, 0, z_position - rim_axial)\n                gluDisk(q, inner_r, outer_r, facets, 1)\n\n            # Thin outside band connects the two rings. This is what keeps the\n            # boundary visible from ordinary side / iso views.\n            with gl_push_matrix():\n                glTranslatef(0, 0, z_position - rim_axial)\n                gluCylinder(\n                    q,\n                    outer_r,\n                    outer_r,\n                    2.0 * rim_axial,\n                    facets,\n                    1)\n\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.0, 0.0, 0.0)\n\n            # Cutter bottom and the cutter-to-collet / holder interface.\n            if cutter_r > 0 and stickout > 0:\n                draw_boundary_rim(cutter_r, 32, -stickout)\n                draw_boundary_rim(cutter_r, 32, 0.0)\n\n            z_base = 0.0\n            for section in sections:\n                try:\n                    bottom_r = float(\n                        section.get('bottom_diameter_inches', 0.0)) * 0.5\n                    top_r = float(\n                        section.get(\n                            'top_diameter_inches',\n                            section.get('bottom_diameter_inches', 0.0))) * 0.5\n                    height = float(section.get('height_inches', 0.0))\n                    facets = int(section.get('facets', 32))\n                except Exception:\n                    continue\n\n                if bottom_r <= 0 or top_r <= 0 or height <= 0:\n                    continue\n                if facets < 3:\n                    facets = 3\n\n                z_top = z_base + height\n\n                # Both ends of every primitive. At a hex-to-round interface this\n                # intentionally draws both the hex boundary and the round boundary.\n                draw_boundary_rim(bottom_r, facets, z_base)\n                draw_boundary_rim(top_r, facets, z_top)\n\n                # Keep the faceted vertical corners as thin lines; those already\n                # render well and adding real corner rods would be unnecessary\n                # geometry. The half-facet phase matches GLU's faceted solids.\n                if facets <= 12:\n                    phase = math.pi / float(facets)\n                    corner_r_bottom = bottom_r + rim_radial\n                    corner_r_top = top_r + rim_radial\n                    glLineWidth(edge_width)\n                    for index in range(facets):\n                        angle = (\n                            ((2.0 * math.pi * index) / facets) + phase)\n                        ca = math.cos(angle)\n                        sa = math.sin(angle)\n                        with gl_begin(GL_LINE_STRIP):\n                            glVertex3f(\n                                corner_r_bottom * ca,\n                                corner_r_bottom * sa,\n                                z_base)\n                            glVertex3f(\n                                corner_r_top * ca,\n                                corner_r_top * sa,\n                                z_top)\n                    glLineWidth(1.0)\n\n                z_base = z_top\n\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_ball_mill(self, q, radius, stickout, params):\n        segments = 12\n        try:\n            segments = int(params.get('hemisphere_segments', segments))\n        except Exception:\n            segments = 12\n        if segments < 4:\n            segments = 4\n        elif segments > 32:\n            segments = 32\n        ball_height = min(radius, stickout)\n        if ball_height > 0.0 and radius > 0.0:\n            for index in range(segments):\n                z0 = ball_height * (float(index) / float(segments))\n                z1 = ball_height * (float(index + 1) / float(segments))\n                r0 = math.sqrt(max(0.0, (radius * radius) - ((z0 - radius) * (z0 - radius))))\n                r1 = math.sqrt(max(0.0, (radius * radius) - ((z1 - radius) * (z1 - radius))))\n                with gl_push_matrix():\n                    glTranslatef(0, 0, z0)\n                    gluCylinder(q, r0, r1, z1 - z0, 32, 1)\n        if stickout > ball_height:\n            with gl_push_matrix():\n                glTranslatef(0, 0, ball_height)\n                gluCylinder(q, radius, radius, stickout - ball_height, 32, 1)\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, radius, 32, 1)\n        return radius\n\n    def _draw_beagle_bull_nose_end_mill(self, q, radius, stickout, params):\n        # Major cutter OD is always the PathPilot tool-table diameter.\n        try:\n            corner_radius = abs(float(params.get('corner_radius_inches', 0.0625)))\n        except Exception:\n            corner_radius = 0.0625\n        try:\n            segments = int(params.get('corner_segments', 8))\n        except Exception:\n            segments = 8\n        if segments < 4:\n            segments = 4\n        elif segments > 24:\n            segments = 24\n        max_corner = min(radius * 0.95, stickout * 0.45)\n        corner_radius = min(corner_radius, max_corner)\n        if corner_radius <= 0.0001:\n            gluCylinder(q, radius, radius, stickout, 32, 1)\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, radius, 32, 1)\n            with gl_push_matrix():\n                glTranslatef(0, 0, stickout)\n                gluDisk(q, 0, radius, 32, 1)\n            return radius\n        inner_radius = max(0.0, radius - corner_radius)\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            gluDisk(q, 0, inner_radius, 32, 1)\n        for index in range(segments):\n            z0 = corner_radius * (float(index) / float(segments))\n            z1 = corner_radius * (float(index + 1) / float(segments))\n            r0 = inner_radius + math.sqrt(max(0.0, (corner_radius * corner_radius) - ((z0 - corner_radius) * (z0 - corner_radius))))\n            r1 = inner_radius + math.sqrt(max(0.0, (corner_radius * corner_radius) - ((z1 - corner_radius) * (z1 - corner_radius))))\n            with gl_push_matrix():\n                glTranslatef(0, 0, z0)\n                gluCylinder(q, r0, r1, z1 - z0, 32, 1)\n        if stickout > corner_radius:\n            with gl_push_matrix():\n                glTranslatef(0, 0, corner_radius)\n                gluCylinder(q, radius, radius, stickout - corner_radius, 32, 1)\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, radius, 32, 1)\n        return radius\n\n    def _draw_beagle_chamfer_drill(self, q, radius, stickout, params, fixed_angle=None):\n        angle = 90.0 if fixed_angle is None else float(fixed_angle)\n        tip_diameter = 0.0\n        if fixed_angle is None:\n            try:\n                angle = float(params.get('included_angle_degrees', angle))\n            except Exception:\n                angle = 90.0\n        try:\n            tip_diameter = abs(float(params.get('tip_diameter_inches', tip_diameter)))\n        except Exception:\n            tip_diameter = 0.0\n        if angle < 5.0:\n            angle = 5.0\n        elif angle > 175.0:\n            angle = 175.0\n        tip_r = min(radius * 0.95, tip_diameter * 0.5)\n        tangent = math.tan(math.radians(angle * 0.5))\n        if tangent <= 0.0001:\n            tangent = 1.0\n        full_cone_height = (radius - tip_r) / tangent\n        if full_cone_height < 0.001:\n            full_cone_height = 0.001\n        cone_height = min(stickout, full_cone_height)\n        top_r = tip_r + ((radius - tip_r) * (cone_height / full_cone_height))\n        gluCylinder(q, tip_r, top_r, cone_height, 32, 1)\n        if tip_r > 0.0:\n            with gl_push_matrix():\n                glRotatef(180, 1, 0, 0)\n                gluDisk(q, 0, tip_r, 32, 1)\n        interface_r = top_r\n        if stickout > cone_height:\n            with gl_push_matrix():\n                glTranslatef(0, 0, cone_height)\n                gluCylinder(q, radius, radius, stickout - cone_height, 32, 1)\n            interface_r = radius\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, interface_r, 32, 1)\n        return interface_r\n\n    def _draw_beagle_key_cutter(self, q, head_radius, stickout, params):\n        try:\n            head_thickness = abs(float(params.get('head_thickness_inches', 0.125)))\n        except Exception:\n            head_thickness = 0.125\n        try:\n            neck_radius = abs(float(params.get('neck_diameter_inches', 0.250))) * 0.5\n        except Exception:\n            neck_radius = 0.125\n        try:\n            neck_length = abs(float(params.get('neck_length_inches', 0.500)))\n        except Exception:\n            neck_length = 0.500\n        try:\n            shank_radius = abs(float(params.get('shank_diameter_inches', 0.375))) * 0.5\n        except Exception:\n            shank_radius = 0.1875\n        head_thickness = max(0.001, head_thickness)\n\n        head_h = min(stickout, head_thickness)\n        gluCylinder(q, head_radius, head_radius, head_h, 32, 1)\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            gluDisk(q, 0, head_radius, 32, 1)\n        remaining = max(0.0, stickout - head_h)\n        neck_h = min(remaining, neck_length)\n        if neck_h > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h)\n                if neck_radius > head_radius:\n                    gluDisk(q, head_radius, neck_radius, 32, 1)\n                elif head_radius > neck_radius:\n                    gluDisk(q, neck_radius, head_radius, 32, 1)\n                gluCylinder(q, neck_radius, neck_radius, neck_h, 32, 1)\n        remaining -= neck_h\n        interface_r = head_radius\n        if remaining > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h + neck_h)\n                if shank_radius > neck_radius:\n                    gluDisk(q, neck_radius, shank_radius, 32, 1)\n                elif neck_radius > shank_radius:\n                    gluDisk(q, shank_radius, neck_radius, 32, 1)\n                gluCylinder(q, shank_radius, shank_radius, remaining, 32, 1)\n            interface_r = shank_radius\n        elif neck_h > 0.0:\n            interface_r = neck_radius\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, interface_r, 32, 1)\n        return interface_r\n\n    def _draw_beagle_rounded_key_cutter(self, q, head_radius, stickout, params):\n        try:\n            head_thickness = abs(float(params.get('head_thickness_inches', 0.125)))\n        except Exception:\n            head_thickness = 0.125\n        try:\n            edge_radius = abs(float(params.get('edge_radius_inches', 0.03125)))\n        except Exception:\n            edge_radius = 0.03125\n        try:\n            edge_segments = int(params.get('edge_segments', 6))\n        except Exception:\n            edge_segments = 6\n        try:\n            neck_radius = abs(float(params.get('neck_diameter_inches', 0.250))) * 0.5\n        except Exception:\n            neck_radius = 0.125\n        try:\n            neck_length = abs(float(params.get('neck_length_inches', 0.500)))\n        except Exception:\n            neck_length = 0.500\n        try:\n            shank_radius = abs(float(params.get('shank_diameter_inches', 0.375))) * 0.5\n        except Exception:\n            shank_radius = 0.1875\n        head_h = min(stickout, max(0.001, head_thickness))\n        if edge_segments < 3:\n            edge_segments = 3\n        elif edge_segments > 20:\n            edge_segments = 20\n        edge_radius = min(edge_radius, head_radius * 0.45, head_h * 0.45)\n        core_radius = max(0.001, head_radius - edge_radius)\n\n        if edge_radius <= 0.0001:\n            return self._draw_beagle_key_cutter(q, head_radius, stickout, params)\n\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            gluDisk(q, 0, core_radius, 32, 1)\n        for index in range(edge_segments):\n            z0 = edge_radius * (float(index) / float(edge_segments))\n            z1 = edge_radius * (float(index + 1) / float(edge_segments))\n            r0 = core_radius + math.sqrt(max(0.0, (edge_radius * edge_radius) - ((z0 - edge_radius) * (z0 - edge_radius))))\n            r1 = core_radius + math.sqrt(max(0.0, (edge_radius * edge_radius) - ((z1 - edge_radius) * (z1 - edge_radius))))\n            with gl_push_matrix():\n                glTranslatef(0, 0, z0)\n                gluCylinder(q, r0, r1, z1 - z0, 32, 1)\n        middle_h = max(0.0, head_h - (2.0 * edge_radius))\n        if middle_h > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, edge_radius)\n                gluCylinder(q, head_radius, head_radius, middle_h, 32, 1)\n        top_base = head_h - edge_radius\n        for index in range(edge_segments):\n            t0 = edge_radius * (float(index) / float(edge_segments))\n            t1 = edge_radius * (float(index + 1) / float(edge_segments))\n            r0 = core_radius + math.sqrt(max(0.0, (edge_radius * edge_radius) - (t0 * t0)))\n            r1 = core_radius + math.sqrt(max(0.0, (edge_radius * edge_radius) - (t1 * t1)))\n            with gl_push_matrix():\n                glTranslatef(0, 0, top_base + t0)\n                gluCylinder(q, r0, r1, t1 - t0, 32, 1)\n\n        remaining = max(0.0, stickout - head_h)\n        neck_h = min(remaining, neck_length)\n        if neck_h > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h)\n                if neck_radius > core_radius:\n                    gluDisk(q, core_radius, neck_radius, 32, 1)\n                elif core_radius > neck_radius:\n                    gluDisk(q, neck_radius, core_radius, 32, 1)\n                gluCylinder(q, neck_radius, neck_radius, neck_h, 32, 1)\n        remaining -= neck_h\n        interface_r = core_radius\n        if remaining > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h + neck_h)\n                if shank_radius > neck_radius:\n                    gluDisk(q, neck_radius, shank_radius, 32, 1)\n                elif neck_radius > shank_radius:\n                    gluDisk(q, shank_radius, neck_radius, 32, 1)\n                gluCylinder(q, shank_radius, shank_radius, remaining, 32, 1)\n            interface_r = shank_radius\n        elif neck_h > 0.0:\n            interface_r = neck_radius\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, interface_r, 32, 1)\n        return interface_r\n\n    def _draw_beagle_reamer(self, q, cutter_radius, stickout, params):\n        # Cutter OD is always the authoritative PathPilot tool-table diameter.\n        try:\n            cutting_length = abs(float(params.get('cutting_length_inches', 0.750)))\n        except Exception:\n            cutting_length = 0.750\n        try:\n            shank_ratio = abs(float(params.get('shank_diameter_ratio', 0.70)))\n        except Exception:\n            shank_ratio = 0.70\n        if cutting_length < 0.01:\n            cutting_length = 0.01\n        cutting_h = min(stickout, cutting_length)\n        shank_radius = cutter_radius * shank_ratio\n        gluCylinder(q, cutter_radius, cutter_radius, cutting_h, 32, 1)\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            gluDisk(q, 0, cutter_radius, 32, 1)\n        remaining = max(0.0, stickout - cutting_h)\n        interface_r = cutter_radius\n        if remaining > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, cutting_h)\n                gluDisk(q, shank_radius, cutter_radius, 32, 1)\n                gluCylinder(q, shank_radius, shank_radius, remaining, 32, 1)\n            interface_r = shank_radius\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, interface_r, 32, 1)\n        return interface_r\n\n    def _draw_beagle_thread_mill(self, q, head_radius, stickout, params):\n        # Single-form thread mill: symmetric V tooth. Maximum tooth radius is\n        # always head_radius from PathPilot current_tool.diameter.\n        try:\n            angle = float(params.get('included_angle_degrees', 60.0))\n        except Exception:\n            angle = 60.0\n        try:\n            root_radius = abs(float(params.get('neck_diameter_inches', 0.250))) * 0.5\n        except Exception:\n            root_radius = 0.125\n        try:\n            neck_length = abs(float(params.get('neck_length_inches', 0.500)))\n        except Exception:\n            neck_length = 0.500\n        try:\n            shank_radius = abs(float(params.get('shank_diameter_inches', 0.375))) * 0.5\n        except Exception:\n            shank_radius = 0.1875\n\n        if angle < 10.0:\n            angle = 10.0\n        elif angle > 170.0:\n            angle = 170.0\n\n        radial_depth = max(0.001, head_radius - root_radius)\n        full_head_h = max(\n            0.002,\n            2.0 * radial_depth * math.tan(math.radians(angle * 0.5)))\n        head_h = min(stickout, full_head_h)\n        half_h = head_h * 0.5\n\n        gluCylinder(q, root_radius, head_radius, half_h, 32, 1)\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            gluDisk(q, 0, root_radius, 32, 1)\n        if head_h > half_h:\n            with gl_push_matrix():\n                glTranslatef(0, 0, half_h)\n                gluCylinder(q, head_radius, root_radius, head_h - half_h, 32, 1)\n\n        remaining = max(0.0, stickout - head_h)\n        neck_h = min(remaining, neck_length)\n        if neck_h > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h)\n                gluCylinder(q, root_radius, root_radius, neck_h, 32, 1)\n        remaining -= neck_h\n\n        interface_r = root_radius\n        if remaining > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h + neck_h)\n                if shank_radius > root_radius:\n                    gluDisk(q, root_radius, shank_radius, 32, 1)\n                elif root_radius > shank_radius:\n                    gluDisk(q, shank_radius, root_radius, 32, 1)\n                gluCylinder(q, shank_radius, shank_radius, remaining, 32, 1)\n            interface_r = shank_radius\n\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, interface_r, 32, 1)\n        return interface_r\n\n    def _draw_beagle_dovetail(self, q, head_radius, stickout, params):\n        try:\n            angle = float(params.get('included_angle_degrees', 60.0))\n        except Exception:\n            angle = 60.0\n        try:\n            neck_radius = abs(float(params.get('neck_diameter_inches', 0.250))) * 0.5\n        except Exception:\n            neck_radius = 0.125\n        try:\n            neck_length = abs(float(params.get('neck_length_inches', 0.250)))\n        except Exception:\n            neck_length = 0.250\n        try:\n            shank_radius = abs(float(params.get('shank_diameter_inches', 0.375))) * 0.5\n        except Exception:\n            shank_radius = 0.1875\n        if angle < 10.0:\n            angle = 10.0\n        elif angle > 170.0:\n            angle = 170.0\n        tangent = math.tan(math.radians(angle * 0.5))\n        if tangent <= 0.0001:\n            tangent = 1.0\n        full_head_h = max(0.001, (head_radius - neck_radius) / tangent)\n        head_h = min(stickout, full_head_h)\n        top_radius = max(neck_radius, head_radius - (tangent * head_h))\n        gluCylinder(q, head_radius, top_radius, head_h, 32, 1)\n        with gl_push_matrix():\n            glRotatef(180, 1, 0, 0)\n            gluDisk(q, 0, head_radius, 32, 1)\n        remaining = max(0.0, stickout - head_h)\n        neck_h = min(remaining, neck_length)\n        if neck_h > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h)\n                if top_radius > neck_radius:\n                    gluDisk(q, neck_radius, top_radius, 32, 1)\n                gluCylinder(q, neck_radius, neck_radius, neck_h, 32, 1)\n        remaining -= neck_h\n        interface_r = top_radius\n        if remaining > 0.0:\n            with gl_push_matrix():\n                glTranslatef(0, 0, head_h + neck_h)\n                if shank_radius > neck_radius:\n                    gluDisk(q, neck_radius, shank_radius, 32, 1)\n                elif neck_radius > shank_radius:\n                    gluDisk(q, shank_radius, neck_radius, 32, 1)\n                gluCylinder(q, shank_radius, shank_radius, remaining, 32, 1)\n            interface_r = shank_radius\n        elif neck_h > 0.0:\n            interface_r = neck_radius\n        with gl_push_matrix():\n            glTranslatef(0, 0, stickout)\n            gluDisk(q, 0, interface_r, 32, 1)\n        return interface_r\n\n\n    def _beagle_flute_style(self, holder_config, cutter_radius):\n        settings = holder_config.get('flute_visuals', {})\n        try:\n            enabled = bool(settings.get('enabled', True))\n        except Exception:\n            enabled = True\n        try:\n            count = int(holder_config.get('flute_count', settings.get('default_count', 4)))\n        except Exception:\n            count = 4\n        if count < 1:\n            count = 1\n        elif count > 6:\n            count = 6\n\n        try:\n            helix_angle = float(settings.get('helix_angle_degrees', 30.0))\n        except Exception:\n            helix_angle = 30.0\n        if helix_angle < -75.0:\n            helix_angle = -75.0\n        elif helix_angle > 75.0:\n            helix_angle = 75.0\n\n        # Relative flute-width calculation.\n        # Fresh-baseline behavior only: flute mark width is always a percentage\n        # of the authoritative PathPilot cutter OD. No pixel-width or absolute\n        # inch-width compatibility fallbacks remain.\n        try:\n            width_percent = float(settings.get('mark_width_percent', 8.0))\n        except Exception:\n            width_percent = 8.0\n        if width_percent < 1.0:\n            width_percent = 1.0\n        elif width_percent > 25.0:\n            width_percent = 25.0\n\n        cutter_diameter = max(0.0002, float(cutter_radius) * 2.0)\n\n        # BEAGLE_GREMLIN_JSON_FLUTE_WIDTH_SCALE_V1\n        # Width multipliers are user-adjustable in flute_visuals JSON.\n        # JSON object keys are strings; 5/6+ flute cutters default to 1.0x.\n        width_scales = settings.get('width_scale_by_flute_count', {})\n        if not isinstance(width_scales, dict):\n            width_scales = {}\n        try:\n            width_scale = float(width_scales.get(str(count), 1.0))\n        except Exception:\n            width_scale = 1.0\n        if width_scale < 0.10:\n            width_scale = 0.10\n        elif width_scale > 5.00:\n            width_scale = 5.00\n        mark_width = cutter_diameter * ((width_percent * width_scale) / 100.0)\n\n        try:\n            offset = abs(float(settings.get('surface_offset_inches', 0.002)))\n        except Exception:\n            offset = 0.002\n        if offset < 0.0005:\n            offset = 0.0005\n        elif offset > 0.010:\n            offset = 0.010\n\n        try:\n            segments = int(settings.get('segments', 64))\n        except Exception:\n            segments = 64\n        if segments < 48:\n            segments = 48\n        elif segments > 128:\n            segments = 128\n\n        try:\n            max_turns = abs(float(settings.get('max_turns', 1.5)))\n        except Exception:\n            max_turns = 1.5\n        if max_turns < 0.25:\n            max_turns = 0.25\n        elif max_turns > 3.0:\n            max_turns = 3.0\n\n        return enabled, count, helix_angle, mark_width, offset, segments, max_turns\n\n    def _beagle_flute_total_theta(self, radius, axial_length, helix_angle, max_turns):\n        if radius <= 0.0001 or axial_length <= 0.0:\n            return 0.0\n        total_theta = axial_length * math.tan(math.radians(helix_angle)) / radius\n        limit = 2.0 * math.pi * max_turns\n        if total_theta > limit:\n            total_theta = limit\n        elif total_theta < -limit:\n            total_theta = -limit\n        return total_theta\n\n    def _beagle_flute_half_angle(self, surface_radius, mark_width):\n        if surface_radius <= 0.0001 or mark_width <= 0.0:\n            return 0.0\n        half_angle = (mark_width * 0.5) / float(surface_radius)\n        max_half_angle = math.radians(35.0)\n        if half_angle > max_half_angle:\n            half_angle = max_half_angle\n        return half_angle\n\n    def _beagle_draw_flute_surface(self, samples, mark_width, offset):\n        # Conformal surface fill: subdivide ACROSS ribbon width as well as\n        # along its length. This keeps the fill on the cylindrical/conical\n        # surface instead of spanning the cutter as one flat chord.\n        if not isinstance(samples, list) or len(samples) < 2:\n            return\n\n        min_radius = None\n        for sample in samples:\n            try:\n                sample_radius = max(0.0001, float(sample[0])) + offset\n            except Exception:\n                continue\n            if min_radius is None or sample_radius < min_radius:\n                min_radius = sample_radius\n        if min_radius is None or min_radius <= 0.0:\n            return\n\n        total_width_angle = min(\n            math.radians(70.0),\n            mark_width / float(min_radius))\n        width_segments = int(math.ceil(\n            total_width_angle / math.radians(4.0)))\n        if width_segments < 1:\n            width_segments = 1\n        elif width_segments > 20:\n            width_segments = 20\n\n        with gl_begin(GL_QUADS):\n            for sample_index in range(len(samples) - 1):\n                radius0, angle0, z0 = samples[sample_index]\n                radius1, angle1, z1 = samples[sample_index + 1]\n                rr0 = max(0.0001, float(radius0)) + offset\n                rr1 = max(0.0001, float(radius1)) + offset\n                half0 = self._beagle_flute_half_angle(rr0, mark_width)\n                half1 = self._beagle_flute_half_angle(rr1, mark_width)\n\n                for width_index in range(width_segments):\n                    u0 = -1.0 + (\n                        2.0 * float(width_index) /\n                        float(width_segments))\n                    u1 = -1.0 + (\n                        2.0 * float(width_index + 1) /\n                        float(width_segments))\n\n                    a00 = angle0 + (half0 * u0)\n                    a01 = angle0 + (half0 * u1)\n                    a10 = angle1 + (half1 * u0)\n                    a11 = angle1 + (half1 * u1)\n\n                    glVertex3f(rr0 * math.cos(a00), rr0 * math.sin(a00), z0)\n                    glVertex3f(rr1 * math.cos(a10), rr1 * math.sin(a10), z1)\n                    glVertex3f(rr1 * math.cos(a11), rr1 * math.sin(a11), z1)\n                    glVertex3f(rr0 * math.cos(a01), rr0 * math.sin(a01), z0)\n\n    def _draw_beagle_cylinder_flute_marks(self, radius, z_start, z_end, holder_config):\n        enabled, count, helix_angle, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, radius)\n        length = z_end - z_start\n        if not enabled or count < 1 or radius <= 0.0 or length <= 0.001:\n            return\n        total_theta = self._beagle_flute_total_theta(radius, length, helix_angle, max_turns)\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                samples = []\n                for point_index in range(segments + 1):\n                    t = float(point_index) / float(segments)\n                    samples.append((radius, phase + (total_theta * t), z_start + (length * t)))\n                self._beagle_draw_flute_surface(samples, mark_width, offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_ball_flute_marks(self, radius, stickout, holder_config):\n        enabled, count, helix_angle, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, radius)\n        if not enabled or count < 1 or radius <= 0.0 or stickout <= 0.001:\n            return\n        ball_height = min(radius, stickout)\n        z_start = min(stickout * 0.03, max(0.001, radius * 0.08))\n        length = stickout - z_start\n        if length <= 0.001:\n            return\n        total_theta = self._beagle_flute_total_theta(radius, length, helix_angle, max_turns)\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                samples = []\n                for point_index in range(segments + 1):\n                    t = float(point_index) / float(segments)\n                    z_pos = z_start + (length * t)\n                    if z_pos <= ball_height:\n                        surface_r = math.sqrt(max(0.0, (radius * radius) - ((z_pos - radius) * (z_pos - radius))))\n                    else:\n                        surface_r = radius\n                    samples.append((surface_r, phase + (total_theta * t), z_pos))\n                self._beagle_draw_flute_surface(samples, mark_width, offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_bull_nose_flute_marks(self, radius, stickout, params, holder_config):\n        enabled, count, helix_angle, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, radius)\n        if not enabled or count < 1 or radius <= 0.0 or stickout <= 0.001:\n            return\n        try:\n            corner_radius = abs(float(params.get('corner_radius_inches', 0.0625)))\n        except Exception:\n            corner_radius = 0.0625\n        corner_radius = min(corner_radius, radius * 0.95, stickout * 0.45)\n        inner_radius = max(0.0, radius - corner_radius)\n        z_start = min(stickout * 0.03, max(0.001, corner_radius * 0.10))\n        length = stickout - z_start\n        if length <= 0.001:\n            return\n        total_theta = self._beagle_flute_total_theta(radius, length, helix_angle, max_turns)\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                samples = []\n                for point_index in range(segments + 1):\n                    t = float(point_index) / float(segments)\n                    z_pos = z_start + (length * t)\n                    if corner_radius > 0.0001 and z_pos <= corner_radius:\n                        surface_r = inner_radius + math.sqrt(max(0.0, (corner_radius * corner_radius) - ((z_pos - corner_radius) * (z_pos - corner_radius))))\n                    else:\n                        surface_r = radius\n                    samples.append((surface_r, phase + (total_theta * t), z_pos))\n                self._beagle_draw_flute_surface(samples, mark_width, offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_chamfer_flute_marks(self, radius, stickout, params, holder_config, fixed_angle=None):\n        enabled, count, helix_angle, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, radius)\n        if not enabled or count < 1 or radius <= 0.0 or stickout <= 0.001:\n            return\n        angle_deg = 90.0 if fixed_angle is None else float(fixed_angle)\n        if fixed_angle is None:\n            try:\n                angle_deg = float(params.get('included_angle_degrees', 90.0))\n            except Exception:\n                angle_deg = 90.0\n        try:\n            tip_diameter = abs(float(params.get('tip_diameter_inches', 0.0)))\n        except Exception:\n            tip_diameter = 0.0\n        if angle_deg < 5.0:\n            angle_deg = 5.0\n        elif angle_deg > 175.0:\n            angle_deg = 175.0\n        tip_r = min(radius * 0.95, tip_diameter * 0.5)\n        tangent = math.tan(math.radians(angle_deg * 0.5))\n        if tangent <= 0.0001:\n            tangent = 1.0\n        full_cone_height = max(0.001, (radius - tip_r) / tangent)\n        cone_height = min(stickout, full_cone_height)\n        z_start = min(stickout * 0.03, max(0.001, cone_height * 0.05))\n        length = stickout - z_start\n        if length <= 0.001:\n            return\n        total_theta = self._beagle_flute_total_theta(radius, length, helix_angle, max_turns)\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                samples = []\n                for point_index in range(segments + 1):\n                    t = float(point_index) / float(segments)\n                    z_pos = z_start + (length * t)\n                    if z_pos <= cone_height:\n                        surface_r = tip_r + ((radius - tip_r) * (z_pos / full_cone_height))\n                        if surface_r > radius:\n                            surface_r = radius\n                    else:\n                        surface_r = radius\n                    samples.append((surface_r, phase + (total_theta * t), z_pos))\n                self._beagle_draw_flute_surface(samples, mark_width, offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_key_flute_marks(self, head_radius, stickout, params, holder_config):\n        enabled, count, helix_angle, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, head_radius)\n        if not enabled or count < 1 or head_radius <= 0.0 or stickout <= 0.001:\n            return\n        try:\n            head_height = min(stickout, max(0.001, abs(float(params.get('head_thickness_inches', 0.125)))))\n        except Exception:\n            head_height = min(stickout, 0.125)\n        if head_height <= 0.001:\n            return\n        z_start = head_height * 0.08\n        z_end = head_height * 0.92\n        rr = head_radius + offset\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                self._beagle_draw_flute_surface([(head_radius, phase, z_start), (head_radius, phase, z_end)], mark_width, offset)\n                inner_r = max(0.001, head_radius * 0.68)\n                outer_r = rr\n                mid_r = max(0.001, (inner_r + outer_r) * 0.5)\n                half_angle = self._beagle_flute_half_angle(mid_r, mark_width)\n                z_top = head_height + min(offset, 0.003)\n                with gl_begin(GL_QUAD_STRIP):\n                    glVertex3f(inner_r * math.cos(phase - half_angle), inner_r * math.sin(phase - half_angle), z_top)\n                    glVertex3f(inner_r * math.cos(phase + half_angle), inner_r * math.sin(phase + half_angle), z_top)\n                    glVertex3f(outer_r * math.cos(phase - half_angle), outer_r * math.sin(phase - half_angle), z_top)\n                    glVertex3f(outer_r * math.cos(phase + half_angle), outer_r * math.sin(phase + half_angle), z_top)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_rounded_key_flute_marks(self, head_radius, stickout, params, holder_config):\n        enabled, count, unused_helix, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, head_radius)\n        if not enabled or count < 1 or head_radius <= 0.0 or stickout <= 0.001:\n            return\n        try:\n            head_h = min(stickout, max(0.001, abs(float(params.get('head_thickness_inches', 0.125)))))\n        except Exception:\n            head_h = min(stickout, 0.125)\n        try:\n            edge_radius = abs(float(params.get('edge_radius_inches', 0.03125)))\n        except Exception:\n            edge_radius = 0.03125\n        edge_radius = min(edge_radius, head_radius * 0.45, head_h * 0.45)\n        core_radius = max(0.001, head_radius - edge_radius)\n        z_start = min(head_h * 0.45, edge_radius + (head_h * 0.05))\n        z_end = max(z_start + 0.001, head_h - edge_radius - (head_h * 0.05))\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                if z_end > z_start:\n                    self._beagle_draw_flute_surface([(head_radius, phase, z_start), (head_radius, phase, z_end)], mark_width, offset)\n                inner_r = max(0.001, core_radius * 0.68)\n                outer_r = core_radius + offset\n                mid_r = max(0.001, (inner_r + outer_r) * 0.5)\n                half_angle = self._beagle_flute_half_angle(mid_r, mark_width)\n                z_top = head_h + min(offset, 0.003)\n                with gl_begin(GL_QUAD_STRIP):\n                    glVertex3f(inner_r * math.cos(phase - half_angle), inner_r * math.sin(phase - half_angle), z_top)\n                    glVertex3f(inner_r * math.cos(phase + half_angle), inner_r * math.sin(phase + half_angle), z_top)\n                    glVertex3f(outer_r * math.cos(phase - half_angle), outer_r * math.sin(phase - half_angle), z_top)\n                    glVertex3f(outer_r * math.cos(phase + half_angle), outer_r * math.sin(phase + half_angle), z_top)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_reamer_flute_marks(self, cutter_radius, stickout, params, holder_config, spiral=False):\n        enabled, count, global_helix, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, cutter_radius)\n        if not enabled or count < 1 or cutter_radius <= 0.0 or stickout <= 0.001:\n            return\n        try:\n            cutting_length = abs(float(params.get('cutting_length_inches', 0.750)))\n        except Exception:\n            cutting_length = 0.750\n        cutting_h = min(stickout, max(0.01, cutting_length))\n        if cutting_h <= 0.001:\n            return\n        # Reamer flute marks run the full cutting section, edge to edge,\n        # matching the standard/end-mill flute treatment.\n        z_start = 0.0\n        z_end = cutting_h\n        length = z_end - z_start\n        helix_angle = 0.0\n        if spiral:\n            try:\n                helix_angle = float(params.get('helix_angle_degrees', 25.0))\n            except Exception:\n                helix_angle = 25.0\n            if helix_angle < -75.0:\n                helix_angle = -75.0\n            elif helix_angle > 75.0:\n                helix_angle = 75.0\n        total_theta = self._beagle_flute_total_theta(cutter_radius, length, helix_angle, max_turns) if spiral else 0.0\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                samples = []\n                for point_index in range(segments + 1):\n                    t = float(point_index) / float(segments)\n                    samples.append((cutter_radius, phase + (total_theta * t), z_start + (length * t)))\n                self._beagle_draw_flute_surface(samples, mark_width, offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_thread_mill_flute_marks(self, head_radius, stickout, params, holder_config):\n        enabled, count, unused_helix, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, head_radius)\n        if not enabled or count < 1 or head_radius <= 0.0 or stickout <= 0.001:\n            return\n        try:\n            angle = float(params.get('included_angle_degrees', 60.0))\n        except Exception:\n            angle = 60.0\n        try:\n            root_radius = abs(float(params.get('neck_diameter_inches', 0.250))) * 0.5\n        except Exception:\n            root_radius = 0.125\n        if angle < 10.0:\n            angle = 10.0\n        elif angle > 170.0:\n            angle = 170.0\n        radial_depth = max(0.001, head_radius - root_radius)\n        full_head_h = max(\n            0.002,\n            2.0 * radial_depth * math.tan(math.radians(angle * 0.5)))\n        head_h = min(stickout, full_head_h)\n        half_h = head_h * 0.5\n        z0 = min(head_h * 0.06, 0.010)\n        z1 = max(half_h + 0.001, head_h - min(head_h * 0.06, 0.010))\n\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                if half_h > z0:\n                    t0 = z0 / max(0.0001, half_h)\n                    r0 = root_radius + ((head_radius - root_radius) * t0)\n                    self._beagle_draw_flute_surface(\n                        [(r0, phase, z0), (head_radius, phase, half_h)],\n                        mark_width,\n                        offset)\n                if z1 > half_h:\n                    t1 = (z1 - half_h) / max(0.0001, head_h - half_h)\n                    r1 = head_radius + ((root_radius - head_radius) * t1)\n                    self._beagle_draw_flute_surface(\n                        [(head_radius, phase, half_h), (r1, phase, z1)],\n                        mark_width,\n                        offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_dovetail_flute_marks(self, head_radius, stickout, params, holder_config):\n        enabled, count, unused_helix, mark_width, offset, segments, max_turns = self._beagle_flute_style(holder_config, head_radius)\n        if not enabled or count < 1 or head_radius <= 0.0 or stickout <= 0.001:\n            return\n        try:\n            angle = float(params.get('included_angle_degrees', 60.0))\n        except Exception:\n            angle = 60.0\n        try:\n            neck_radius = abs(float(params.get('neck_diameter_inches', 0.250))) * 0.5\n        except Exception:\n            neck_radius = 0.125\n        if angle < 10.0:\n            angle = 10.0\n        elif angle > 170.0:\n            angle = 170.0\n        tangent = math.tan(math.radians(angle * 0.5))\n        if tangent <= 0.0001:\n            tangent = 1.0\n        head_h = min(stickout, max(0.001, (head_radius - neck_radius) / tangent))\n        top_radius = max(neck_radius, head_radius - (tangent * head_h))\n        z0 = min(head_h * 0.06, 0.015)\n        z1 = max(z0 + 0.001, head_h * 0.94)\n        r0 = max(top_radius, head_radius - (tangent * z0))\n        r1 = max(top_radius, head_radius - (tangent * z1))\n        with gl_disable(GL_LIGHTING), gl_disable(GL_BLEND), gl_disable(GL_CULL_FACE):\n            glDepthFunc(GL_LEQUAL)\n            glColor3f(0.08, 0.08, 0.08)\n            for flute_index in range(count):\n                phase = (2.0 * math.pi * float(flute_index)) / float(count)\n                self._beagle_draw_flute_surface([(r0, phase, z0), (r1, phase, z1)], mark_width, offset)\n            glDepthFunc(GL_LESS)\n\n    def _draw_beagle_tool_flute_marks(self, tool_model, radius, stickout, params, holder_config):\n        if tool_model == 'ball_mill':\n            self._draw_beagle_ball_flute_marks(radius, stickout, holder_config)\n        elif tool_model == 'bull_nose_end_mill':\n            self._draw_beagle_bull_nose_flute_marks(radius, stickout, params, holder_config)\n        elif tool_model == 'chamfer_90':\n            self._draw_beagle_chamfer_flute_marks(radius, stickout, params, holder_config, 90.0)\n        elif tool_model == 'chamfer_45':\n            self._draw_beagle_chamfer_flute_marks(radius, stickout, params, holder_config, 45.0)\n        elif tool_model == 'drill_bit':\n            try:\n                drill_angle = float(params.get('included_angle_degrees', 118.0))\n            except Exception:\n                drill_angle = 118.0\n            self._draw_beagle_chamfer_flute_marks(radius, stickout, params, holder_config, drill_angle)\n        elif tool_model == 'key_cutter':\n            self._draw_beagle_key_flute_marks(radius, stickout, params, holder_config)\n        elif tool_model == 'key_cutter_rounded':\n            self._draw_beagle_rounded_key_flute_marks(radius, stickout, params, holder_config)\n        elif tool_model == 'reamer_straight':\n            self._draw_beagle_reamer_flute_marks(radius, stickout, params, holder_config, False)\n        elif tool_model == 'reamer_spiral':\n            self._draw_beagle_reamer_flute_marks(radius, stickout, params, holder_config, True)\n        elif tool_model == 'dovetail':\n            self._draw_beagle_dovetail_flute_marks(radius, stickout, params, holder_config)\n        elif tool_model in ('thread_mill', 'thread_mill_0250', 'thread_mill_01875'):\n            self._draw_beagle_thread_mill_flute_marks(radius, stickout, params, holder_config)\n        else:\n            self._draw_beagle_cylinder_flute_marks(radius, 0.0, stickout, holder_config)\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        tool_model = holder_config.get('tool_model', 'standard')\n        params = holder_config.get('tool_model_params', {})\n        self.color_config.gl_color('tool_body')\n\n        with gl_quadric() as q:\n            interface_r = r\n            custom_model = False\n            if tool_model == 'ball_mill':\n                custom_model = True\n                interface_r = self._draw_beagle_ball_mill(q, r, stickout, params)\n            elif tool_model == 'bull_nose_end_mill':\n                custom_model = True\n                interface_r = self._draw_beagle_bull_nose_end_mill(q, r, stickout, params)\n            elif tool_model == 'chamfer_90':\n                custom_model = True\n                interface_r = self._draw_beagle_chamfer_drill(q, r, stickout, params, 90.0)\n            elif tool_model == 'chamfer_45':\n                custom_model = True\n                interface_r = self._draw_beagle_chamfer_drill(q, r, stickout, params, 45.0)\n            elif tool_model == 'drill_bit':\n                custom_model = True\n                try:\n                    drill_angle = float(params.get('included_angle_degrees', 118.0))\n                except Exception:\n                    drill_angle = 118.0\n                interface_r = self._draw_beagle_chamfer_drill(q, r, stickout, params, drill_angle)\n            elif tool_model == 'key_cutter':\n                custom_model = True\n                interface_r = self._draw_beagle_key_cutter(q, r, stickout, params)\n            elif tool_model == 'key_cutter_rounded':\n                custom_model = True\n                interface_r = self._draw_beagle_rounded_key_cutter(q, r, stickout, params)\n            elif tool_model in ('reamer_straight', 'reamer_spiral'):\n                custom_model = True\n                interface_r = self._draw_beagle_reamer(q, r, stickout, params)\n            elif tool_model == 'dovetail':\n                custom_model = True\n                interface_r = self._draw_beagle_dovetail(q, r, stickout, params)\n            elif tool_model in ('thread_mill', 'thread_mill_0250', 'thread_mill_01875'):\n                custom_model = True\n                interface_r = self._draw_beagle_thread_mill(q, r, stickout, params)\n            else:\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\n            # Lightweight flute visualization: cached with the tool display list.\n            # Surface marks only; no subtractive flute geometry.\n            self._draw_beagle_tool_flute_marks(\n                tool_model, r, stickout, params, holder_config)\n\n            runtime_holder_visible = bool(getattr(self, '_beagle_tts_holder_visible', True))\n            if holder_config['enabled'] and runtime_holder_visible:\n                with gl_push_matrix():\n                    glTranslatef(0, 0, stickout)\n                    self._draw_tts_holder_stack(\n                        q,\n                        holder_config,\n                        interface_r,\n                        stickout,\n                        not custom_model)\n"
OEM_DRAW_SINGLE_TOOL                  = "    def draw_single_tool(self, pos, rx, ry, rz):\n        glShadeModel(GL_SMOOTH)\n        with gl_push_matrix(), gl_enable(GL_LIGHTING), gl_enable(GL_COLOR_MATERIAL):\n            glTranslatef(*pos)\n            # FIXME not sure if the extrinsic / intrinsic order is right here\n            glRotatef(rz * 180./np.pi, 0, 0, 1)\n            glRotatef(ry * 180./np.pi, 0, 1, 0)\n            glRotatef(rx * 180./np.pi, 1, 0, 0)\n            glEnable(GL_BLEND)\n            glEnable(GL_CULL_FACE)\n            glBlendFunc(GL_ONE, GL_CONSTANT_ALPHA)\n            current_tool = self.get_current_tool()\n            if current_tool != self.cached_tool:\n                self.stale_dlist('tool')\n            tool_dlist = self.dlist('tool', gen=lambda n: self.make_tool(n, current_tool))\n            glCallList(tool_dlist)\n            self.cached_tool = current_tool\n\n"
PATCHED_DRAW_SINGLE_TOOL              = "    def draw_single_tool(self, pos, rx, ry, rz):\n        glShadeModel(GL_SMOOTH)\n        with gl_push_matrix(), gl_enable(GL_LIGHTING), gl_enable(GL_COLOR_MATERIAL):\n            glTranslatef(*pos)\n            # FIXME not sure if the extrinsic / intrinsic order is right here\n            glRotatef(rz * 180./np.pi, 0, 0, 1)\n            glRotatef(ry * 180./np.pi, 0, 1, 0)\n            glRotatef(rx * 180./np.pi, 1, 0, 0)\n            glEnable(GL_BLEND)\n            glEnable(GL_CULL_FACE)\n            glBlendFunc(GL_ONE, GL_CONSTANT_ALPHA)\n            current_tool = self.get_current_tool()\n            if current_tool != self.cached_tool:\n                self.stale_dlist('tool')\n            tool_dlist = self.dlist('tool', gen=lambda n: self.make_tool(n, current_tool))\n\n            # BEAGLE_GREMLIN_TOOL_HOLDER_ROTATION_BASELINE\n            # Rotate the already-cached tool display list at draw time. The\n            # cutter model is rotationally symmetric, so this visually reads\n            # as holder rotation without rebuilding holder geometry.\n            holder_rotation = float(getattr(\n                self,\n                '_beagle_tts_holder_rotation_deg',\n                0.0) or 0.0)\n            if abs(holder_rotation) > 0.0001:\n                with gl_push_matrix():\n                    glRotatef(holder_rotation, 0, 0, 1)\n                    glCallList(tool_dlist)\n            else:\n                glCallList(tool_dlist)\n            self.cached_tool = current_tool\n"
DEFAULT_FIXTURE_CONFIG_TEXT           = r'''
{
    "schema_version": 1,
    "units": "inches",
    "groups": [
        {
            "id": "vise_1",
            "name": "Vise 1",
            "type": "vise",
            "visible": true,
            "travel_axis": "Y"
        }
    ],
    "objects": [
        {
            "id": "vise_1_bed",
            "group": "vise_1",
            "name": "Vise 1 Bed",
            "role": "bed",
            "type": "box",
            "visible": true,
            "opacity": 1.0,
            "movable": false,
            "xmin": -4.0,
            "xmax": 4.0,
            "ymin": -2.0,
            "ymax": 2.0,
            "zmin": -1.0,
            "zmax": -0.75
        },
        {
            "id": "vise_1_fixed_jaw",
            "group": "vise_1",
            "name": "Vise 1 Fixed Jaw",
            "role": "fixed_jaw",
            "type": "box",
            "visible": true,
            "opacity": 1.0,
            "movable": false,
            "xmin": -4.0,
            "xmax": 4.0,
            "ymin": 1.5,
            "ymax": 2.0,
            "zmin": -0.75,
            "zmax": 0.0
        },
        {
            "id": "vise_1_movable_jaw",
            "group": "vise_1",
            "name": "Vise 1 Movable Jaw",
            "role": "movable_jaw",
            "type": "box",
            "visible": true,
            "opacity": 1.0,
            "movable": true,
            "move_axis": "Y",
            "xmin": -4.0,
            "xmax": 4.0,
            "ymin": -2.0,
            "ymax": -1.5,
            "zmin": -0.75,
            "zmax": 0.0
        }
    ]
}
'''

DEFAULT_CONFIG_TEXT                   = r'''
{
    "minimum_stickout_inches": 0.05,
    "default_holder_type": "TTS_ER32",
    "tool_model_dimensions": {
        "ball_mill": {
            "hemisphere_segments": 12
        },
        "bull_nose_end_mill": {
            "corner_radius_inches": 0.0625,
            "corner_segments": 8
        },
        "reamer_straight": {
            "cutting_length_inches": 0.75,
            "shank_diameter_ratio": 0.7
        },
        "reamer_spiral": {
            "cutting_length_inches": 0.75,
            "shank_diameter_ratio": 0.7,
            "helix_angle_degrees": 25.0
        },
        "chamfer_90": {
            "tip_diameter_inches": 0.0
        },
        "chamfer_45": {
            "tip_diameter_inches": 0.0
        },
        "key_cutter": {
            "neck_length_inches": 0.5,
            "neck_diameter_inches": 0.25,
            "head_thickness_inches": 0.125,
            "shank_diameter_inches": 0.375
        },
        "key_cutter_rounded": {
            "neck_length_inches": 0.5,
            "neck_diameter_inches": 0.25,
            "head_thickness_inches": 0.125,
            "edge_radius_inches": 0.03125,
            "edge_segments": 6,
            "shank_diameter_inches": 0.375
        },
        "dovetail": {
            "included_angle_degrees": 60.0,
            "neck_length_inches": 0.25,
            "neck_diameter_inches": 0.25,
            "shank_diameter_inches": 0.375
        },
        "thread_mill": {
            "included_angle_degrees": 60.0,
            "neck_length_inches": 0.5,
            "neck_diameter_inches": 0.25,
            "shank_diameter_inches": 0.375
        },
        "thread_mill_0250": {
            "included_angle_degrees": 60.0,
            "neck_length_inches": 0.5,
            "neck_diameter_inches": 0.1875,
            "shank_diameter_inches": 0.25
        },
        "thread_mill_01875": {
            "included_angle_degrees": 60.0,
            "neck_length_inches": 0.5,
            "neck_diameter_inches": 0.125,
            "shank_diameter_inches": 0.1875
        }
    },
    "enabled": true,
    "chamfer_enabled": false,
    "tool_stickout_inches": {},
    "holder_types": {
        "TTS_ER32": {
            "display_name": "TTS ER32",
            "image": "gremlin_tool_holders/tts_er32.svg",
            "sections": [
                {
                    "top_diameter_inches": 1.34,
                    "name": "ER32 hex collet nut",
                    "height_inches": 0.4,
                    "facets": 6,
                    "material": "nut",
                    "bottom_diameter_inches": 1.34
                },
                {
                    "top_diameter_inches": 1.34,
                    "name": "gutter / relief",
                    "height_inches": 0.44,
                    "facets": 32,
                    "material": "relief",
                    "bottom_diameter_inches": 1.34
                },
                {
                    "top_diameter_inches": 1.1,
                    "name": "holder body",
                    "height_inches": 0.885,
                    "facets": 32,
                    "material": "body",
                    "bottom_diameter_inches": 1.1,
                    "vector_font_overlay": true
                },
                {
                    "top_diameter_inches": 1.5,
                    "name": "ATC retention ring copy",
                    "height_inches": 0.343,
                    "tool_length_reference": true,
                    "material": "nut",
                    "bottom_diameter_inches": 1.5,
                    "facets": 32
                },
                {
                    "top_diameter_inches": 0.75,
                    "name": "upper TTS shank",
                    "height_inches": 1.375,
                    "facets": 32,
                    "material": "shank",
                    "bottom_diameter_inches": 0.75
                }
            ],
            "family": "",
            "manufacturer": ""
        },
        "TTS_ER40": {
            "sections": [
                {
                    "top_diameter_inches": 1.75,
                    "name": "ER40 hex collet nut",
                    "height_inches": 0.4,
                    "facets": 6,
                    "material": "nut",
                    "bottom_diameter_inches": 1.75
                },
                {
                    "top_diameter_inches": 1.75,
                    "name": "gutter / relief",
                    "height_inches": 0.44,
                    "facets": 32,
                    "material": "relief",
                    "bottom_diameter_inches": 1.75
                },
                {
                    "top_diameter_inches": 1.1,
                    "name": "holder body",
                    "height_inches": 0.4,
                    "facets": 32,
                    "material": "body",
                    "bottom_diameter_inches": 1.1
                },
                {
                    "top_diameter_inches": 1.75,
                    "name": "ATC retention ring",
                    "height_inches": 0.343,
                    "tool_length_reference": true,
                    "material": "flange",
                    "bottom_diameter_inches": 1.75,
                    "facets": 32
                },
                {
                    "top_diameter_inches": 0.75,
                    "name": "upper TTS shank",
                    "height_inches": 1.375,
                    "facets": 32,
                    "material": "shank",
                    "bottom_diameter_inches": 0.75
                }
            ],
            "image": "gremlin_tool_holders/tts_er40.svg",
            "display_name": "TTS ER40",
            "family": "",
            "manufacturer": ""
        },
        "DRILL_CHUCK": {
            "sections": [
                {
                    "top_diameter_inches": 1.5,
                    "name": "new section",
                    "height_inches": 0.5,
                    "facets": 32,
                    "material": "body",
                    "bottom_diameter_inches": 1.0
                },
                {
                    "top_diameter_inches": 1.5,
                    "name": "ATC retention ring",
                    "height_inches": 1.0,
                    "tool_length_reference": true,
                    "material": "flange",
                    "bottom_diameter_inches": 1.5,
                    "vector_font_overlay": true,
                    "facets": 32
                },
                {
                    "top_diameter_inches": 0.75,
                    "name": "upper TTS shank",
                    "height_inches": 1.2,
                    "facets": 32,
                    "material": "shank",
                    "bottom_diameter_inches": 0.75
                }
            ],
            "image": "gremlin_tool_holders/drill_chuck.svg",
            "display_name": "Drill Chuck",
            "family": "TTS",
            "manufacturer": "Generic"
        },
        "PROBE_HOLDER": {
            "display_name": "Probe Holder",
            "image": "gremlin_tool_holders/probe_holder.svg",
            "sections": [
                {
                    "top_diameter_inches": 1.5,
                    "name": "new section",
                    "height_inches": 0.25,
                    "facets": 32,
                    "material": "body",
                    "bottom_diameter_inches": 1.5
                },
                {
                    "top_diameter_inches": 1.5,
                    "name": "ATC retention ring",
                    "height_inches": 1.0,
                    "tool_length_reference": true,
                    "material": "flange",
                    "bottom_diameter_inches": 1.5,
                    "vector_font_overlay": true,
                    "facets": 32
                },
                {
                    "top_diameter_inches": 0.75,
                    "name": "upper TTS shank",
                    "height_inches": 1.2,
                    "facets": 32,
                    "material": "shank",
                    "bottom_diameter_inches": 0.75
                }
            ],
            "family": "TTS",
            "manufacturer": "Generic"
        }
    },
    "edge_enabled": true,
    "stickout_mode": "tool_table_length",
    "edge_width": 3.0,
    "default_stickout_inches": 1.5,
    "tool_model_overrides": {},
    "flute_visuals": {
        "enabled": true,
        "default_count": 4,
        "helix_angle_degrees": 15.0,
        "mark_width_percent": 20.0,
        "width_scale_by_flute_count": {
            "1": 3.0,
            "2": 2.3333333333,
            "3": 1.6666666667,
            "4": 1.0
        },
        "surface_offset_inches": 0.002,
        "segments": 64,
        "max_turns": 1.5
    },
    "vector_font_line_width": 2.0
}
'''

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._holder_rotation_angle = 0.0
        self._holder_rotation_last_time = time.time()
        self._holder_rotation_active = False
        self._holder_rotation_runtime_enabled = True
        self._holder_rotation_runtime_loaded = False
        self._config_signature = None
        self._last_config_validation_signature = None
        self._holder_builder_process = None
        self._fixture_builder_process = None
        self._fixture_objects = ()
        self._fixture_groups = {}
        self._fixture_config_signature = None
        self._fixture_draw_hook_installed = False
        self._fixture_select_hook_installed = False
        self._fixture_selection = None
        self._fixture_secondary_selection = None
        # Ephemeral: true only when the CURRENT context-menu right-click
        # actually lands on fixture geometry. A stale highlight alone must
        # never force the compact fixture-only menu.
        self._fixture_context_menu_on_fixture = False
        self._fixture_undo_stack = []
        self._fixture_redo_stack = []
        self._fixture_history_internal_write = False
        glib.timeout_add(1200, self._check_suite_dependencies_once)
        glib.timeout_add(PATCH_DELAY_MS, self.try_patch)
        glib.timeout_add(MENU_HOOK_DELAY_MS, self.install_holder_context_menu)
        if HOLDER_ROTATION_ENABLED:
            glib.timeout_add(HOLDER_ROTATION_INTERVAL_MS, self._holder_rotation_tick)
        if CONFIG_REFRESH_ENABLED:
            glib.timeout_add(CONFIG_REFRESH_INTERVAL_MS, self._config_refresh_tick)

    def _check_suite_dependencies_once(self):
        missing = []
        for label, path in (
                ("Tool Life Manager", TOOL_LIFE_MANAGER_PATH),
                ("Gremlin Tool Holder Builder", HOLDER_BUILDER_PATH)):
            if not os.path.isfile(path):
                missing.append("%s\n%s" % (label, path))
        if not missing:
            return False
        message = (
            "This component is part of the Gremlin/TLM tool-visualization suite "
            "and expects the other suite components to be installed.\n\n"
            "Missing:\n\n%s\n\n"
            "The plugin will continue loading, but related holder/model features "
            "may not work correctly until the missing file(s) are installed."
            % "\n\n".join(missing))
        try:
            dialog = gtk.MessageDialog(
                None,
                gtk.DIALOG_MODAL,
                gtk.MESSAGE_WARNING,
                gtk.BUTTONS_OK,
                "Gremlin/TLM Suite - Missing Component")
            dialog.format_secondary_text(message)
            dialog.run()
            dialog.destroy()
        except Exception:
            try:
                self.error_handler.write(
                    "[%s] %s" % (SCRIPT_NAME, message.replace("\n", " ")),
                    constants.ALARM_LEVEL_MEDIUM)
            except Exception:
                pass
        return False

    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 _redis_coordinate_overlays_visible(self):
        try:
            value = singletons.g_Machine.redis.hget(
                COORD_OVERLAY_REDIS_HASH,
                COORD_OVERLAY_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_coordinate_overlays_visible(self, visible):
        try:
            singletons.g_Machine.redis.hset(
                COORD_OVERLAY_REDIS_HASH,
                COORD_OVERLAY_REDIS_KEY,
                "True" if visible else "False")
        except Exception:
            pass

    def _install_coordinate_overlay_hook(self):
        """Runtime-only wrapper around OEM Gremlin draw_coordinate_systems().

        GremlinDraw.redraw_scene() calls draw_coordinate_systems() whenever the
        program or live plot is shown. That one OEM method owns the local XYZ axes
        and the G53/G5x/G92 origin/offset visualization layer, so suppressing that
        call cleanly hides the coordinate graphics without touching any machine or
        interpreter state.
        """
        if self.gremlin is None:
            return False
        visible = self._redis_coordinate_overlays_visible()
        self.gremlin._tormachtips_coordinate_overlays_visible = bool(visible)
        if bool(getattr(
                self.gremlin,
                "_tormachtips_coordinate_overlay_hook_installed",
                False)):
            self.gremlin.queue_draw()
            return True
        original = getattr(self.gremlin, "draw_coordinate_systems", None)
        if not callable(original):
            self.debug("Gremlin draw_coordinate_systems() was not available.")
            return False
        self.gremlin._tormachtips_coordinate_overlay_base = original

        def coordinate_overlay_draw_wrapper(gremlin_self, last_position):
            if not bool(getattr(
                    gremlin_self,
                    "_tormachtips_coordinate_overlays_visible",
                    True)):
                return None
            return gremlin_self._tormachtips_coordinate_overlay_base(last_position)

        try:
            self.gremlin.draw_coordinate_systems = types.MethodType(
                coordinate_overlay_draw_wrapper,
                self.gremlin,
                self.gremlin.__class__)
        except TypeError:
            self.gremlin.draw_coordinate_systems = types.MethodType(
                coordinate_overlay_draw_wrapper,
                self.gremlin)
        self.gremlin._tormachtips_coordinate_overlay_hook_installed = True
        self.gremlin.queue_draw()
        return True

    def toggle_coordinate_overlays(self, menu_item=None):
        try:
            if self.gremlin is None:
                return
            if menu_item is not None and hasattr(menu_item, "get_active"):
                visible = bool(menu_item.get_active())
            else:
                visible = not bool(getattr(
                    self.gremlin,
                    "_tormachtips_coordinate_overlays_visible",
                    True))
            self.gremlin._tormachtips_coordinate_overlays_visible = visible
            self._persist_coordinate_overlays_visible(visible)
            self.gremlin.queue_draw()
            self.debug(
                "Coordinate overlays %s." %
                ("shown" if visible else "hidden"))
        except Exception as e:
            self.debug("Coordinate overlay toggle failed: %s" % str(e))

    def _redis_program_extents_visible(self):
        try:
            value = singletons.g_Machine.redis.hget(
                PROGRAM_EXTENTS_REDIS_HASH,
                PROGRAM_EXTENTS_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_program_extents_visible(self, visible):
        try:
            singletons.g_Machine.redis.hset(
                PROGRAM_EXTENTS_REDIS_HASH,
                PROGRAM_EXTENTS_REDIS_KEY,
                "True" if visible else "False")
        except Exception:
            pass

    def _install_program_extents_hook(self):
        """Runtime-only wrapper around OEM Gremlin draw_program_extents().

        This is the red/limit-colored project boundary dimension layer: the X/Y/Z
        extent lines, end ticks, and numeric min/max/size labels surrounding the
        loaded program. Suppressing it changes only Gremlin drawing.
        """
        if self.gremlin is None:
            return False
        visible = self._redis_program_extents_visible()
        self.gremlin._tormachtips_program_extents_visible = bool(visible)
        if bool(getattr(
                self.gremlin,
                "_tormachtips_program_extents_hook_installed",
                False)):
            self.gremlin.queue_draw()
            return True
        original = getattr(self.gremlin, "draw_program_extents", None)
        if not callable(original):
            self.debug("Gremlin draw_program_extents() was not available.")
            return False
        self.gremlin._tormachtips_program_extents_base = original

        def program_extents_draw_wrapper(gremlin_self):
            if not bool(getattr(
                    gremlin_self,
                    "_tormachtips_program_extents_visible",
                    True)):
                return None
            return gremlin_self._tormachtips_program_extents_base()

        try:
            self.gremlin.draw_program_extents = types.MethodType(
                program_extents_draw_wrapper,
                self.gremlin,
                self.gremlin.__class__)
        except TypeError:
            self.gremlin.draw_program_extents = types.MethodType(
                program_extents_draw_wrapper,
                self.gremlin)
        self.gremlin._tormachtips_program_extents_hook_installed = True
        self.gremlin.queue_draw()
        return True

    def toggle_program_extents(self, menu_item=None):
        try:
            if self.gremlin is None:
                return
            if menu_item is not None and hasattr(menu_item, "get_active"):
                visible = bool(menu_item.get_active())
            else:
                visible = not bool(getattr(
                    self.gremlin,
                    "_tormachtips_program_extents_visible",
                    True))
            self.gremlin._tormachtips_program_extents_visible = visible
            self._persist_program_extents_visible(visible)
            self.gremlin.queue_draw()
            self.debug(
                "Program boundary dimensions %s." %
                ("shown" if visible else "hidden"))
        except Exception as e:
            self.debug("Program boundary dimension toggle failed: %s" % str(e))

    def _redis_traveled_path_visible(self):
        try:
            value = singletons.g_Machine.redis.hget(
                TRAVELED_PATH_REDIS_HASH,
                TRAVELED_PATH_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_traveled_path_visible(self, visible):
        try:
            singletons.g_Machine.redis.hset(
                TRAVELED_PATH_REDIS_HASH,
                TRAVELED_PATH_REDIS_KEY,
                "True" if visible else "False")
        except Exception:
            pass

    def _clear_traveled_path_history(self):
        if self.gremlin is None:
            return False
        try:
            clear_method = getattr(
                self.gremlin,
                "clear_live_plotter",
                None)
            if callable(clear_method):
                clear_method()
            else:
                logger = getattr(self.gremlin, "logger", None)
                if logger is None or not hasattr(logger, "clear"):
                    raise RuntimeError(
                        "Gremlin live plot logger was not available.")
                logger.clear()
            self.gremlin.queue_draw()
            return True
        except Exception as e:
            self.debug(
                "Clear traveled path failed: %s" % str(e))
            return False

    def _set_traveled_path_visible(self, visible, clear_when_enabling=False):
        """Control PathPilot's OEM live backplot without touching program preview.

        When hidden, keep the native position logger capped at one point so
        motion performed while the overlay is disabled cannot suddenly appear
        later when the overlay is turned back on.
        """
        if self.gremlin is None:
            return False

        visible = bool(visible)
        self.gremlin._tormachtips_traveled_path_visible = visible

        try:
            self.gremlin.show_live_plot = visible
        except Exception:
            pass

        try:
            set_max = getattr(
                self.gremlin,
                "set_max_live_plot_points",
                None)
            if callable(set_max):
                if visible:
                    set_max()
                else:
                    set_max(1)
        except Exception as e:
            self.debug(
                "Could not change traveled-path history limit: %s"
                % str(e))

        # Disabling always removes anything already drawn. Re-enabling also
        # starts clean so motion made while hidden never appears retroactively.
        if (not visible) or clear_when_enabling:
            self._clear_traveled_path_history()

        try:
            self.gremlin.queue_draw()
        except Exception:
            pass
        return True

    def clear_traveled_path(self, menu_item=None):
        if self._clear_traveled_path_history():
            self.debug(
                "Cleared traveled path history; new motion will draw normally.")

    def toggle_traveled_path(self, menu_item=None):
        try:
            if self.gremlin is None:
                return
            if menu_item is not None and hasattr(menu_item, "get_active"):
                visible = bool(menu_item.get_active())
            else:
                visible = not bool(getattr(
                    self.gremlin,
                    "_tormachtips_traveled_path_visible",
                    True))

            self._persist_traveled_path_visible(visible)
            self._set_traveled_path_visible(
                visible,
                clear_when_enabling=visible)

            self.debug(
                "Traveled path %s." %
                ("shown" if visible else "hidden"))
        except Exception as e:
            self.debug(
                "Traveled path toggle failed: %s" % str(e))

    def _redis_holder_rotation_enabled(self):
        if not HOLDER_ROTATION_ENABLED:
            return False
        try:
            value = singletons.g_Machine.redis.hget(HOLDER_ROTATION_REDIS_HASH, HOLDER_ROTATION_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_rotation_enabled(self, enabled):
        if not HOLDER_ROTATION_ENABLED:
            return
        try:
            singletons.g_Machine.redis.hset(
                HOLDER_ROTATION_REDIS_HASH,
                HOLDER_ROTATION_REDIS_KEY,
                "True" if enabled else "False")
        except Exception:
            pass

    def _holder_spindle_max_rpm(self):
        # PathPilot's spindle-max-speed HAL pin tracks the active machine and
        # current belt/range, so 5k/7.5k/10k-era machines scale automatically.
        try:
            ui = singletons.g_Machine
            if ui is not None and hasattr(ui, "hal"):
                value = abs(float(ui.hal["spindle-max-speed"]))
                if value > HOLDER_ROTATION_ZERO_RPM_EPSILON:
                    return value
        except Exception:
            pass
        try:
            ui = singletons.g_Machine
            if ui is not None and hasattr(ui, "redis"):
                value = ui.redis.hget("machine_prefs", "spindle_rpm_max")
                if value is not None:
                    value = abs(float(value))
                    if value > HOLDER_ROTATION_ZERO_RPM_EPSILON:
                        return value
        except Exception:
            pass
        return HOLDER_ROTATION_FALLBACK_MAX_RPM

    def _holder_visual_rps(self, rpm_output):
        try:
            rpm = abs(float(rpm_output))
        except Exception:
            rpm = 0.0
        if rpm <= HOLDER_ROTATION_ZERO_RPM_EPSILON:
            return 0.0
        max_rpm = self._holder_spindle_max_rpm()
        if max_rpm <= HOLDER_ROTATION_ZERO_RPM_EPSILON:
            max_rpm = HOLDER_ROTATION_FALLBACK_MAX_RPM
        fraction = rpm / float(max_rpm)
        if fraction < 0.0:
            fraction = 0.0
        elif fraction > 1.0:
            fraction = 1.0
        return (
            HOLDER_ROTATION_MIN_VISUAL_RPS +
            ((HOLDER_ROTATION_MAX_VISUAL_RPS - HOLDER_ROTATION_MIN_VISUAL_RPS) * fraction))

    def _holder_spindle_rpm_output(self):
        # Match the RPM value PathPilot itself uses for the spindle DRO.
        # Reading this HAL pin is effectively free and requires no extra
        # linuxcnc.stat().poll() call. Most importantly, spindle override at
        # 0% makes spindle-speed-out zero, which must stop the animation.
        try:
            ui = singletons.g_Machine
            if ui is None or not hasattr(ui, "hal"):
                return 0.0
            rpm_output = float(ui.hal["spindle-speed-out"])
            if abs(rpm_output) <= HOLDER_ROTATION_ZERO_RPM_EPSILON:
                return 0.0
            return rpm_output
        except Exception:
            return 0.0

    def _holder_spindle_direction(self, rpm_output):
        # Prefer the sign of spindle-speed-out when it carries direction.
        # If the HAL value is positive for both M3/M4 on a particular machine,
        # use PathPilot's already-maintained spindle_direction state only to
        # determine visual direction. RPM output still remains the on/off gate.
        try:
            rpm_output = float(rpm_output)
        except Exception:
            rpm_output = 0.0
        if rpm_output < -HOLDER_ROTATION_ZERO_RPM_EPSILON:
            return -1
        try:
            ui = singletons.g_Machine
            status = getattr(ui, "status", None) if ui is not None else None
            if status is not None:
                direction = int(getattr(status, "spindle_direction", 0) or 0)
                if direction > 0:
                    return 1
                if direction < 0:
                    return -1
        except Exception:
            pass
        # A nonzero positive RPM output still means visually rotating even if
        # the direction field is unavailable.
        if rpm_output > HOLDER_ROTATION_ZERO_RPM_EPSILON:
            return 1
        return 0

    def _holder_rotation_can_draw(self):
        if self.gremlin is None:
            return False
        try:
            if not bool(getattr(
                    self.gremlin,
                    "_beagle_tts_holder_visible",
                    True)):
                return False
        except Exception:
            pass
        try:
            if not self.gremlin.get_visible():
                return False
        except Exception:
            pass
        try:
            if hasattr(self.gremlin, "get_mapped"):
                if not self.gremlin.get_mapped():
                    return False
        except Exception:
            pass
        return True

    def _holder_rotation_stop(self, redraw):
        was_active = bool(self._holder_rotation_active)
        self._holder_rotation_active = False
        self._holder_rotation_angle = 0.0
        self._holder_rotation_last_time = time.time()
        try:
            if self.gremlin is not None:
                self.gremlin._beagle_tts_holder_rotation_deg = 0.0
                if redraw and was_active:
                    self.gremlin.queue_draw()
        except Exception:
            pass

    def _holder_rotation_tick(self):
        if not HOLDER_ROTATION_ENABLED:
            return False
        if not self._holder_rotation_runtime_enabled:
            if self._holder_rotation_active:
                self._holder_rotation_stop(True)
            return True
        now = time.time()
        rpm_output = self._holder_spindle_rpm_output()
        direction = self._holder_spindle_direction(rpm_output)
        can_draw = self._holder_rotation_can_draw()
        if rpm_output == 0.0 or direction == 0 or not can_draw:
            # spindle-speed-out is the authoritative animation gate. This means
            # setting spindle override to 0% pauses the holder immediately even
            # if M3/M4 remains logically commanded. While stopped/hidden, there
            # is no repeated redraw traffic.
            self._holder_rotation_stop(bool(rpm_output == 0.0 and can_draw))
            return True
        elapsed = now - self._holder_rotation_last_time
        self._holder_rotation_last_time = now
        if not self._holder_rotation_active:
            elapsed = 0.0
            self._holder_rotation_active = True
        if elapsed < 0.0:
            elapsed = 0.0
        elif elapsed > HOLDER_ROTATION_MAX_DT:
            elapsed = HOLDER_ROTATION_MAX_DT
        visual_rps = self._holder_visual_rps(rpm_output)
        # BEAGLE_GREMLIN_TOOL_HOLDER_FORWARD_DIRECTION_FLIPPED
        # OpenGL's positive Z rotation appears opposite PathPilot's spindle-forward
        # convention from the normal Gremlin camera, so invert the visual sign.
        self._holder_rotation_angle = (
            self._holder_rotation_angle +
            (-float(direction) * 360.0 * visual_rps * elapsed)
        ) % 360.0
        try:
            self.gremlin._beagle_tts_holder_rotation_deg = self._holder_rotation_angle
            # Normal redraw only. No stale_dlist(), config reload, SQLite query,
            # or holder-geometry rebuild is performed for animation frames.
            self.gremlin.queue_draw()
        except Exception:
            pass
        return True

    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
            if menu_item is not None and hasattr(menu_item, "get_active"):
                visible = bool(menu_item.get_active())
            else:
                visible = not bool(getattr(
                    self.gremlin,
                    "_beagle_tts_holder_visible",
                    True))
            self.gremlin._beagle_tts_holder_visible = visible
            self._persist_holder_visible(visible)
            if not visible:
                self._holder_rotation_stop(False)
            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 toggle_holder_rotation(self, menu_item=None):
        if not HOLDER_ROTATION_ENABLED:
            return
        try:
            if menu_item is not None and hasattr(menu_item, "get_active"):
                enabled = bool(menu_item.get_active())
            else:
                enabled = not bool(self._holder_rotation_runtime_enabled)
            self._holder_rotation_runtime_enabled = enabled
            self._holder_rotation_runtime_loaded = True
            self._persist_holder_rotation_enabled(self._holder_rotation_runtime_enabled)
            if not self._holder_rotation_runtime_enabled:
                self._holder_rotation_stop(True)
            else:
                self._holder_rotation_last_time = time.time()
            self.debug(
                "Tool holder rotation %s." %
                ("enabled" if self._holder_rotation_runtime_enabled else "disabled"))
        except Exception as e:
            self.debug("Tool holder rotation toggle failed: %s" % str(e))

    def _poll_holder_builder_process(self):
        process = self._holder_builder_process
        if process is None:
            return False
        try:
            if process.poll() is None:
                return True
        except Exception as e:
            self.debug("Tool Holder Builder process check failed: %s" % str(e))
        self._holder_builder_process = None
        self.debug("Tool Holder Builder closed.")
        return False

    def launch_holder_builder(self, menu_item=None):
        # BEAGLE_GREMLIN_HOLDER_BUILDER_CONTEXT_MENU
        if not os.path.isfile(HOLDER_BUILDER_PATH):
            try:
                self.error_handler.write(
                    "[%s] Holder Builder was not found: %s" %
                    (SCRIPT_NAME, HOLDER_BUILDER_PATH),
                    constants.ALARM_LEVEL_LOW)
            except Exception:
                pass
            return
        try:
            if (self._holder_builder_process is not None and
                    self._holder_builder_process.poll() is None):
                self.debug("Tool Holder Builder is already running.")
                return
        except Exception:
            self._holder_builder_process = None
        try:
            self._holder_builder_process = subprocess.Popen([sys.executable, HOLDER_BUILDER_PATH, CONFIG_PATH])
            glib.timeout_add(250, self._poll_holder_builder_process)
            self.debug("Tool Holder Builder launched.")
        except Exception as e:
            self._holder_builder_process = None
            try:
                self.error_handler.write(
                    "[%s] Could not launch Holder Builder: %s" %
                    (SCRIPT_NAME, str(e)),
                    constants.ALARM_LEVEL_LOW)
            except Exception:
                pass

    def _poll_fixture_builder_process(self):
        process=self._fixture_builder_process
        if process is None:return False
        try:
            if process.poll() is None:return True
        except Exception as e:self.debug("Fixture Builder process check failed: %s"%str(e))
        self._fixture_builder_process=None; self.debug("Fixture Builder closed."); return False

    def launch_fixture_builder(self, menu_item=None):
        if not FIXTURE_GEOMETRY_ENABLED:return
        if not os.path.isfile(FIXTURE_BUILDER_PATH):
            try:self.error_handler.write("[%s] Fixture Builder was not found: %s"%(SCRIPT_NAME,FIXTURE_BUILDER_PATH),constants.ALARM_LEVEL_LOW)
            except Exception:pass
            return
        try:
            if self._fixture_builder_process is not None and self._fixture_builder_process.poll() is None:return
        except Exception:self._fixture_builder_process=None
        try:
            self._fixture_builder_process=subprocess.Popen([sys.executable,FIXTURE_BUILDER_PATH,FIXTURE_CONFIG_PATH]); glib.timeout_add(250,self._poll_fixture_builder_process); self.debug("Fixture Builder launched.")
        except Exception as e:
            self._fixture_builder_process=None
            try:self.error_handler.write("[%s] Could not launch Fixture Builder: %s"%(SCRIPT_NAME,str(e)),constants.ALARM_LEVEL_LOW)
            except Exception:pass

    def show_gremlin_help(self, menu_item=None):
        dialog = gtk.Dialog(
            "Gremlin Tool Holder - Help",
            None,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (gtk.STOCK_CLOSE, gtk.RESPONSE_CLOSE))
        dialog.set_default_size(760, 640)

        outer = gtk.VBox(False, 8)
        outer.set_border_width(10)
        dialog.vbox.pack_start(outer, True, True, 0)

        title = gtk.Label()
        title.set_alignment(0.0, 0.5)
        title.set_markup("<b>Tool &amp; Holder Visualization</b>")
        outer.pack_start(title, False, False, 0)

        scroller = gtk.ScrolledWindow()
        scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        scroller.set_shadow_type(gtk.SHADOW_IN)

        text_view = gtk.TextView()
        text_view.set_editable(False)
        text_view.set_cursor_visible(False)
        text_view.set_wrap_mode(gtk.WRAP_WORD)
        text_view.set_left_margin(8)
        text_view.set_right_margin(8)

        help_text = (
            "OVERVIEW\n\n"
            "This plugin extends PathPilot's Gremlin 3D viewer with visual tool-holder models, cutter models, "
            "flute marks, T# labels, T99 probe-holder rendering, and optional slowed visual spindle rotation.\n\n"

            "SHOW / HIDE HOLDER\n\n"
            "The Gremlin right-click menu can show or hide the custom holder geometry at runtime. "
            "The visibility choice is saved in PathPilot machine preferences. Hiding the holder does not remove "
            "the cutter model and does not change any machine/tool offset.\n\n"

            "TRAVELED PATH\n\n"
            "The Gremlin right-click menu can clear the yellow/red live traveled-path history without changing the "
            "loaded G-code preview. Motion after Clear Traveled Path begins drawing a fresh history immediately. "
            "The persistent Traveled Path check box can hide live travel history entirely; while hidden, history is "
            "discarded so old hidden motion does not reappear when the display is turned back on.\n\n"

            "COORDINATE OVERLAYS\n\n"
            "The Gremlin right-click menu also has a persistent Coordinate Overlays check box. It controls the OEM "
            "3D coordinate visualization layer: local XYZ axes plus G53/G5x/G92 origin and offset graphics. It does not "
            "hide the toolpath or PathPilot DRO/status text and it does not change any coordinate value.\n\n"

            "PROGRAM BOUNDARY DIMENSIONS\n\n"
            "Program Boundary Dimensions is a separate persistent check box for the OEM program-extents layer: "
            "the boundary/dimension lines and numeric X/Y/Z min/max/size labels drawn around the loaded toolpath. "
            "It only changes Gremlin visibility; it does not alter the computed extents, soft limits, or G-code.\n\n"

            "HOLDER ROTATION\n\n"
            "Rotation can be enabled or disabled from the Gremlin right-click menu. When enabled, the holder/cutter "
            "visualization rotates while the spindle is running. The displayed rotation is intentionally slowed and "
            "clamped for readability; it is not intended to represent exact spindle RPM.\n\n"

            "OPEN TOOL HOLDER BUILDER\n\n"
            "Launches the Gremlin Tool Holder Builder, where holder geometry and global cutter/flute rendering settings "
            "are edited. Saving the normal configuration file updates what Gremlin uses.\n\n"

            "WHERE HOLDER / CUTTER / FLUTES COME FROM\n\n"
            "Per-tool Holder, Cutter model, and Flutes assignments come from Tool Life Manager. Gremlin reads those "
            "assignments together with the Builder's JSON configuration and draws the active tool accordingly. "
            "If a tool has no explicit holder assignment, the configured default holder is used.\n\n"

            "CUTTER MODELS\n\n"
            "The visualization supports End Mill, Ball Mill, Rounded Edge End Mill, Straight and Spiral Reamers, "
            "90 and 45 degree Chamfer Mills, Drill Bit, Key Seat Cutter, Rounded Key Seat Cutter, Dovetail Cutter, "
            "and Thread Mill presets for 3/8, 1/4, and 3/16 inch shanks. PathPilot supplies the primary tool diameter; "
            "secondary visual dimensions come from the Builder configuration.\n\n"

            "FLUTE VISUALIZATION\n\n"
            "Flute marks are visual overlays. TLM supplies the assigned flute count, while the Builder supplies helix angle, "
            "base width, surface offset, smoothness, wrap count, and width multipliers. The current defaults widen the visual "
            "marks as flute count decreases: 4=1.00x, 3=1.6667x, 2=2.3333x, and 1=3.00x. Tools with 5 or more flutes use 1.00x.\n\n"

            "T99 MILL PROBE\n\n"
            "PathPilot special-cases T99 as the mill probe. This plugin preserves the dedicated probe ball/shaft rendering "
            "and can add the same modular holder stack above it so the probe holder is represented consistently with ordinary tools.\n\n"

            "CONFIG REFRESH\n\n"
            "Gremlin periodically checks the saved holder configuration so many Builder changes can appear without restarting "
            "PathPilot. Per-tool Holder/Cutter/Flutes assignments are refreshed through the normal TLM/Gremlin integration.\n\n"

            "VISUALIZATION ONLY\n\n"
            "All custom holder/cutter/flute geometry is a visual aid. It does not change LinuxCNC or PathPilot tool length, "
            "tool diameter, work offsets, machine coordinates, feeds, speeds, or any other machine-control value.\n\n"

            "NOT COLLISION DETECTION\n\n"
            "The displayed geometry is not a collision engine, clearance checker, setup authority, or safety system. "
            "Models depend on user-entered dimensions and may differ from the physical holder/cutter actually installed. "
            "Always verify the physical setup, stickout, offsets, travel, workholding, and clearances before machining."
        )

        text_view.get_buffer().set_text(help_text)
        scroller.add(text_view)
        outer.pack_start(scroller, True, True, 0)

        dialog.show_all()
        dialog.run()
        dialog.destroy()

    def _build_holder_gremlin_menu_item(self, gremlin_self):
        items = []

        # Context-menu focus is based on this right-click only. Reset before
        # picking so an old selected/highlighted face cannot leak into a later
        # right-click on empty Gremlin space.
        self._fixture_context_menu_on_fixture = False

        if FIXTURE_GEOMETRY_ENABLED:
            try:
                fixture_item = self._build_fixture_selection_menu_item(gremlin_self)
                if fixture_item is not None:
                    items.append(fixture_item)
            except Exception as e:
                self.debug("Fixture context pick/menu failed: %s" % str(e))

        # Positive check items are easier to scan than labels whose verbs flip
        # between Show/Hide or Enable/Disable.
        holder_visible = bool(getattr(
            gremlin_self,
            "_beagle_tts_holder_visible",
            True))
        holder_item = gtk.CheckMenuItem("Tool Holder")
        holder_item.set_active(holder_visible)
        holder_item.connect("toggled", self.toggle_holder_visibility)
        items.append(holder_item)

        traveled_item = gtk.CheckMenuItem("Traveled Path")
        traveled_item.set_active(bool(getattr(
            gremlin_self,
            "_tormachtips_traveled_path_visible",
            True)))
        traveled_item.connect("toggled", self.toggle_traveled_path)
        items.append(traveled_item)

        clear_traveled_item = gtk.MenuItem("Clear Traveled Path")
        clear_traveled_item.connect("activate", self.clear_traveled_path)
        items.append(clear_traveled_item)

        coordinate_item = gtk.CheckMenuItem("Coordinate Overlays")
        coordinate_item.set_active(bool(getattr(
            gremlin_self,
            "_tormachtips_coordinate_overlays_visible",
            True)))
        coordinate_item.connect("toggled", self.toggle_coordinate_overlays)
        items.append(coordinate_item)

        extents_item = gtk.CheckMenuItem("Program Boundary Dimensions")
        extents_item.set_active(bool(getattr(
            gremlin_self,
            "_tormachtips_program_extents_visible",
            True)))
        extents_item.connect("toggled", self.toggle_program_extents)
        items.append(extents_item)

        rotation_item = gtk.CheckMenuItem("Holder Rotation")
        rotation_item.set_active(bool(
            HOLDER_ROTATION_ENABLED and
            self._holder_rotation_runtime_enabled))
        if HOLDER_ROTATION_ENABLED:
            rotation_item.connect("toggled", self.toggle_holder_rotation)
        else:
            rotation_item.set_sensitive(False)
        items.append(rotation_item)

        builder_item = gtk.MenuItem("Open Tool Holder Builder...")
        builder_item.connect("activate", self.launch_holder_builder)
        items.append(builder_item)

        if FIXTURE_GEOMETRY_ENABLED:
            undo_label = "Undo Fixture Edit"
            if self._fixture_undo_stack:
                undo_label += " - " + str(self._fixture_undo_stack[-1].get("label", "Fixture Edit"))
            undo_item = gtk.MenuItem(undo_label)
            undo_item.set_sensitive(bool(self._fixture_undo_stack) and self._fixture_edit_allowed())
            undo_item.connect("activate", self._fixture_undo)
            items.append(undo_item)

            redo_label = "Redo Fixture Edit"
            if self._fixture_redo_stack:
                redo_label += " - " + str(self._fixture_redo_stack[-1].get("label", "Fixture Edit"))
            redo_item = gtk.MenuItem(redo_label)
            redo_item.set_sensitive(bool(self._fixture_redo_stack) and self._fixture_edit_allowed())
            redo_item.connect("activate", self._fixture_redo)
            items.append(redo_item)

            fixture_builder_item = gtk.MenuItem("Open Fixture Builder...")
            fixture_builder_item.connect("activate", self.launch_fixture_builder)
            items.append(fixture_builder_item)

            fixture_geometry_item = gtk.MenuItem("Fixture Geometry")
            fixture_geometry_menu = gtk.Menu()

            fixture_reload_item = gtk.MenuItem("Reload Fixture Geometry")
            fixture_reload_item.connect(
                "activate",
                self.reload_fixture_geometry)
            fixture_geometry_menu.append(fixture_reload_item)
            fixture_reload_item.show()

            fixture_reregister_item = gtk.MenuItem(
                "Re-register ER1-2-3-4")
            fixture_reregister_item.set_sensitive(
                self._fixture_edit_allowed())
            fixture_reregister_item.connect(
                "activate",
                self._fixture_reregister_er40_1_4)
            fixture_geometry_menu.append(fixture_reregister_item)
            fixture_reregister_item.show()

            # GTK2 does not automatically map/show submenu children merely
            # because the parent GtkMenuItem is later shown by the shared
            # Gremlin menu dispatcher.  Explicitly show the submenu contents,
            # matching the selected-fixture context submenu implementation.
            try:
                fixture_geometry_menu.show_all()
            except Exception:
                pass

            fixture_geometry_item.set_submenu(
                fixture_geometry_menu)
            items.append(fixture_geometry_item)

        help_item = gtk.MenuItem("Help / About Tool & Holder Visualization")
        help_item.connect("activate", self.show_gremlin_help)
        items.append(help_item)
        return items

    def _gremlin_menu_item_label(self, item):
        try:
            label = item.get_label()
            if label is not None:
                return str(label)
        except Exception:
            pass
        try:
            child = item.get_child()
            if child is not None and hasattr(child, "get_text"):
                return str(child.get_text() or "")
        except Exception:
            pass
        return ""

    def _apply_gremlin_menu_mnemonics(self, menu):
        """Apply requested GTK mnemonics to this popup menu only.

        These are menu mnemonics, not global/window accelerators.  The keys are
        therefore meaningful only while this Gremlin context menu has keyboard
        focus.  Existing callbacks/widgets remain untouched.
        """
        if menu is None:
            return menu

        try:
            children = list(menu.get_children())
        except Exception:
            return menu

        for item in children:
            try:
                if isinstance(item, gtk.SeparatorMenuItem):
                    continue
            except Exception:
                pass

            raw_label = self._gremlin_menu_item_label(item)
            if not raw_label:
                continue

            # Be idempotent if another contributor asks us to organize the same
            # menu object more than once.  Only our known mnemonic underscores
            # are present in these labels, so remove them before matching.
            plain = raw_label.replace("_", "")
            mnemonic_label = None

            if plain == "Iso View":
                mnemonic_label = "_Iso View"
            elif plain == "Reset Zoom":
                mnemonic_label = "_Reset Zoom"
            elif plain == "Show Tools On Deck":
                mnemonic_label = "S_how Tools On Deck"
            elif plain == "Hide Tools On Deck":
                mnemonic_label = "_Hide Tools On Deck"
            elif plain == "Open Fixture Builder...":
                mnemonic_label = "Open _Fixture Builder..."
            elif plain.startswith("Undo Fixture Edit"):
                # Keep the live undo-description suffix intact.
                mnemonic_label = "_" + plain

            if mnemonic_label is None:
                continue

            try:
                if hasattr(item, "set_use_underline"):
                    item.set_use_underline(True)
                item.set_label(mnemonic_label)

                # PathPilot's GTK2 theme/settings can keep mnemonic indicators
                # visually hidden even though the mnemonic key itself works.
                # Preserve the real GTK mnemonic above, then explicitly apply
                # an underline pattern to the child GtkLabel/AccelLabel so the
                # requested letter is visibly underlined whenever this popup is
                # shown.  This is presentation-only and adds no global key hook.
                child = item.get_child() if hasattr(item, "get_child") else None
                if child is not None and hasattr(child, "set_pattern"):
                    visible_label = mnemonic_label.replace("_", "")
                    mnemonic_pos = mnemonic_label.find("_")
                    if 0 <= mnemonic_pos < len(visible_label):
                        pattern = (
                            (" " * mnemonic_pos) +
                            "_" +
                            (" " * (len(visible_label) - mnemonic_pos - 1)))
                        child.set_pattern(pattern)
            except Exception:
                # Older/custom menu items can expose only a child label.  Do
                # not replace the widget or its signal connections just to add
                # a mnemonic.
                pass

        return menu

    def _fixture_focused_context_menu_active(self):
        """True when a valid visible fixture face is currently selected.

        The selection itself is event-driven by the existing fixture click/pick
        path.  This check performs no OpenGL picking, file I/O, polling, or timer
        work; it only validates the already-held selection against the loaded
        fixture scene.
        """
        if not FIXTURE_SELECTED_CONTEXT_MENU_ONLY:
            return False
        if not FIXTURE_GEOMETRY_ENABLED:
            return False
        selection = self._fixture_selection
        if not isinstance(selection, dict):
            return False
        object_id = str(selection.get("object_id", "") or "")
        face_key = str(selection.get("face", "") or "")
        if not object_id or not face_key:
            return False
        obj = self._fixture_object_by_id(object_id)
        if not isinstance(obj, dict):
            return False
        if not bool(obj.get("visible", True)):
            return False
        try:
            if not self._fixture_group_visible(obj):
                return False
        except Exception:
            return False
        return True

    def _organize_gremlin_context_menu(self, menu):
        """Group the completed Gremlin menu without replacing any OEM actions.

        The OEM A-axis item is deliberately left as the OEM widget with its
        original dynamic Enable/Disable label and callback.  This method only
        moves existing menu widgets and inserts separators.
        """
        if menu is None:
            return menu

        try:
            children = list(menu.get_children())
        except Exception:
            return menu

        # Remove separators supplied by earlier contributor dispatchers.  Keep
        # every real item object intact so all OEM/plugin signal connections and
        # sensitivity/visibility state survive unchanged.
        real_items = []
        for item in children:
            try:
                is_separator = isinstance(item, gtk.SeparatorMenuItem)
            except Exception:
                is_separator = False
            if is_separator:
                try:
                    menu.remove(item)
                except Exception:
                    pass
            else:
                real_items.append(item)

        groups = {
            "view": [],
            "viewer": [],
            "overlays": [],
            "tools": [],
            "fixture": [],
            "other": [],
            "help": [],
        }

        for item in real_items:
            label = self._gremlin_menu_item_label(item)
            normalized = label.strip()

            if (
                    normalized in (
                        "Iso View",
                        "Top View",
                        "Front View",
                        "Side View",
                        "Reset Zoom",
                        "Center Here",
                        "Enable A Axis Display",
                        "Disable A Axis Display",
                        "No Grid") or
                    normalized.startswith("Grid ")):
                groups["view"].append(item)

            elif normalized in (
                    "Full-Screen 3D Viewer",
                    "Exit Full-Screen 3D Viewer",
                    "Windowed 3D Viewer",
                    "Show Both",
                    "Hide G-Code Scroller",
                    "Show G-Code Scroller",
                    "Hide 3D Viewer",
                    "Show 3D Viewer"):
                groups["viewer"].append(item)

            elif normalized in (
                    "Traveled Path",
                    "Clear Traveled Path",
                    "Coordinate Overlays",
                    "Program Boundary Dimensions"):
                groups["overlays"].append(item)

            elif normalized in (
                    "Show Tools On Deck",
                    "Hide Tools On Deck",
                    "Tool Holder",
                    "Show Tool Holder",
                    "Hide Tool Holder",
                    "Holder Rotation",
                    "Enable Holder Rotation",
                    "Disable Holder Rotation",
                    "Holder Rotation Disabled",
                    "Open Tool Holder Builder..."):
                groups["tools"].append(item)

            elif (
                    normalized.startswith("Fixture: ") or
                    normalized.startswith("Undo Fixture Edit") or
                    normalized.startswith("Redo Fixture Edit") or
                    normalized in (
                        "Open Fixture Builder...",
                        "Fixture Geometry",
                        "Reload Fixture Geometry",
                        "Re-register ER1-2-3-4")):
                groups["fixture"].append(item)

            elif normalized == "Help / About Tool & Holder Visualization":
                groups["help"].append(item)

            else:
                groups["other"].append(item)

        fixture_focused = bool(self._fixture_context_menu_on_fixture)
        if fixture_focused:
            # A fixture face hit by THIS right-click is treated as an explicit
            # boundary-work context. Remove unrelated OEM/tool/overlay/help
            # entries from this popup only. A previously highlighted face does
            # not force this mode when the operator right-clicks empty space.
            fixture_items = list(groups["fixture"])
            for item in real_items:
                if item in fixture_items:
                    continue
                try:
                    menu.remove(item)
                except Exception:
                    pass
            group_order = ("fixture",)
        else:
            group_order = (
                "view",
                "viewer",
                "overlays",
                "tools",
                "fixture",
                "other",
                "help",
            )

        ordered_items = []
        ordered_groups = []
        for group_name in group_order:
            group_items = groups[group_name]
            if group_items:
                ordered_groups.append(group_items)
                ordered_items.extend(group_items)

        # Move the existing widgets only; this preserves PathPilot's OEM A-axis
        # widget exactly as created by PathPilot.
        try:
            for index, item in enumerate(ordered_items):
                menu.reorder_child(item, index)
        except Exception as e:
            self.debug("Gremlin menu reorder failed: %s" % str(e))
            return menu

        # Add one clean separator between each logical non-empty section.
        insert_position = 0
        for group_index, group_items in enumerate(ordered_groups):
            if group_index > 0:
                try:
                    separator = gtk.SeparatorMenuItem()
                    menu.insert(separator, insert_position)
                    separator.show()
                    insert_position += 1
                except Exception:
                    pass
            insert_position += len(group_items)

        return self._apply_gremlin_menu_mnemonics(menu)

    def _register_shared_gremlin_menu_contributor(self, contributor_key, builder_callback, group=10, order=10):
        gremlin = self.gremlin
        if gremlin is None:
            return False
        registry = getattr(gremlin, "_tormachtips_gremlin_menu_contributors", None)
        if not isinstance(registry, dict):
            registry = {}
            gremlin._tormachtips_gremlin_menu_contributors = registry
        registry[contributor_key] = {"group": int(group), "order": int(order), "builder": builder_callback,}
        # Upgrade any older shared dispatcher in place.  The original OEM menu
        # callable was saved by the first dispatcher installer, so replacing the
        # dispatcher does not stack wrappers or depend on plugin load order.
        dispatcher_version = int(getattr(gremlin, "_tormachtips_gremlin_menu_dispatcher_version", 0) or 0)
        if dispatcher_version >= 4:
            return True
        base_menu = getattr(gremlin, "_tormachtips_gremlin_menu_base", None)
        if base_menu is None:
            base_menu = gremlin.make_and_show_context_menu
            gremlin._tormachtips_gremlin_menu_base = base_menu

        def shared_make_and_show_context_menu(gremlin_self):
            menu = gremlin_self._tormachtips_gremlin_menu_base()
            if menu is None:
                return menu
            menu_registry = getattr(gremlin_self, "_tormachtips_gremlin_menu_contributors", {})
            built_groups = {}
            for key in sorted(menu_registry.keys()):
                try:
                    spec = menu_registry[key]
                    if not isinstance(spec, dict):
                        continue
                    group_value = int(spec.get("group", 50))
                    order_value = int(spec.get("order", 50))
                    builder = spec.get("builder")
                    if not callable(builder):
                        continue
                    built = builder(gremlin_self)
                    if built is None:
                        continue
                    if not isinstance(built, (list, tuple)):
                        built = [built]
                    for item_index, item in enumerate(built):
                        if item is None:
                            continue
                        built_groups.setdefault(group_value, []).append((order_value, str(key), item_index, item))
                except Exception:
                    pass
            if not built_groups:
                return self._organize_gremlin_context_menu(menu)
            # First build all registered contributor groups. Final separator
            # placement/order is normalized after every contributor is present.
            # between logical TormachTips groups.
            for group_value in sorted(built_groups.keys()):
                group_items = built_groups[group_value]
                group_items.sort(key=lambda row: (row[0], row[1], row[2]))
                for _order, _key, _item_index, item in group_items:
                    menu.append(item)
                    item.show()
            return self._organize_gremlin_context_menu(menu)

        try:
            gremlin.make_and_show_context_menu = types.MethodType(
                shared_make_and_show_context_menu,
                gremlin,
                gremlin.__class__)
        except TypeError:
            gremlin.make_and_show_context_menu = types.MethodType(shared_make_and_show_context_menu, gremlin)
        gremlin._tormachtips_gremlin_menu_dispatcher_installed = True
        gremlin._tormachtips_gremlin_menu_dispatcher_version = 4
        return True

    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 FIXTURE_GEOMETRY_ENABLED:
                self._install_fixture_geometry_hook()
            self._install_coordinate_overlay_hook()
            self._install_program_extents_hook()

            traveled_visible = self._redis_traveled_path_visible()
            self._set_traveled_path_visible(
                traveled_visible,
                clear_when_enabling=False)

            self.gremlin._beagle_tts_holder_visible = self._redis_holder_visible()
            self.gremlin._beagle_tts_holder_rotation_deg = 0.0
            if not self._holder_rotation_runtime_loaded:
                self._holder_rotation_runtime_enabled = (
                    self._redis_holder_rotation_enabled()
                    if HOLDER_ROTATION_ENABLED else False)
                self._holder_rotation_runtime_loaded = True
            if self._register_shared_gremlin_menu_contributor(
                    GREMLIN_MENU_CONTRIBUTOR_KEY,
                    self._build_holder_gremlin_menu_item,
                    group=20,
                    order=20):
                self._invalidate_holder_tool_dlist()
                self.debug("Added organized Tool Holder / traveled-path / overlay / fixture controls to shared Gremlin right-click menu.")
        except Exception as e:
            self.debug("Gremlin holder context-menu hook failed: %s" % str(e))
        return False

    def _get_fixture_config_signature(self):
        try:
            return (float(os.path.getmtime(FIXTURE_CONFIG_PATH)), int(os.path.getsize(FIXTURE_CONFIG_PATH)))
        except Exception:
            return None

    def _fixture_unit_scale(self, raw_units):
        units = str(raw_units or "inches").strip().lower()
        if units in ("inch", "inches", "in"):
            return 1.0
        if units in ("millimeter", "millimeters", "mm"):
            return 1.0 / 25.4
        raise RuntimeError("Fixture config units must be 'inches' or 'mm'; found %s." % units)

    def _fixture_common(self, raw):
        if not isinstance(raw, dict):
            raise ValueError("fixture definition is not an object")
        result = {
            "id": str(raw.get("id", "") or ""),
            "name": str(raw.get("name", "Fixture Object") or "Fixture Object"),
            "group": str(raw.get("group", "") or ""),
            "role": str(raw.get("role", "") or ""),
            "type": str(raw.get("type", "") or "").strip().lower(),
            "collision_role": str(raw.get("collision_role", "solid") or "solid").strip().lower(),
            "exclude_target": str(raw.get("exclude_target", "global") or "global"),
            "visible": bool(raw.get("visible", True)),
            "movable": bool(raw.get("movable", False)),
            "move_axis": str(raw.get("move_axis", "") or "").upper(),
        }
        try:
            opacity = float(raw.get("opacity", FIXTURE_FACE_ALPHA))
        except Exception:
            opacity = FIXTURE_FACE_ALPHA
        result["opacity"] = max(0.0, min(1.0, opacity))
        color = raw.get("color")
        if isinstance(color, (list, tuple)) and len(color) >= 3:
            try:
                result["color"] = tuple(max(0.0, min(1.0, float(v))) for v in color[:3])
            except Exception:
                result["color"] = (FIXTURE_FACE_RED, FIXTURE_FACE_GREEN, FIXTURE_FACE_BLUE)
        else:
            result["color"] = (FIXTURE_FACE_RED, FIXTURE_FACE_GREEN, FIXTURE_FACE_BLUE)
        return result

    def _fixture_parse_box(self, raw, unit_scale):
        result = self._fixture_common(raw)
        result["type"] = "box"
        xmin, xmax = float(raw["xmin"]) * unit_scale, float(raw["xmax"]) * unit_scale
        ymin, ymax = float(raw["ymin"]) * unit_scale, float(raw["ymax"]) * unit_scale
        zmin, zmax = float(raw["zmin"]) * unit_scale, float(raw["zmax"]) * unit_scale
        if xmin > xmax: xmin, xmax = xmax, xmin
        if ymin > ymax: ymin, ymax = ymax, ymin
        if zmin > zmax: zmin, zmax = zmax, zmin
        if abs(xmax-xmin) < 0.000001 or abs(ymax-ymin) < 0.000001 or abs(zmax-zmin) < 0.000001:
            raise ValueError("%s has a zero-size box dimension" % result["name"])
        result.update({"xmin":xmin,"xmax":xmax,"ymin":ymin,"ymax":ymax,"zmin":zmin,"zmax":zmax})
        return result

    def _fixture_parse_round(self, raw, unit_scale, object_type):
        result = self._fixture_common(raw)
        result["type"] = object_type
        result["x"] = float(raw.get("x",0.0)) * unit_scale
        result["y"] = float(raw.get("y",0.0)) * unit_scale
        result["zmin"] = float(raw["zmin"]) * unit_scale
        result["zmax"] = float(raw["zmax"]) * unit_scale
        if result["zmin"] > result["zmax"]: result["zmin"], result["zmax"] = result["zmax"], result["zmin"]
        result["bottom_diameter"] = abs(float(raw["bottom_diameter"]) * unit_scale)
        result["top_diameter"] = result["bottom_diameter"] if object_type == "cylinder" else abs(float(raw.get("top_diameter", result["bottom_diameter"])) * unit_scale)
        if result["bottom_diameter"] <= 0.000001 or abs(result["zmax"]-result["zmin"]) < 0.000001:
            raise ValueError("%s has invalid round geometry" % result["name"])
        try: facets = int(raw.get("facets",32))
        except Exception: facets = 32
        result["facets"] = max(8,min(128,facets))
        return result

    def _fixture_parse_hex(self, raw, unit_scale):
        result = self._fixture_common(raw)
        result["type"] = "hex_prism"
        result["x"] = float(raw.get("x", 0.0)) * unit_scale
        result["y"] = float(raw.get("y", 0.0)) * unit_scale
        result["zmin"] = float(raw["zmin"]) * unit_scale
        result["zmax"] = float(raw["zmax"]) * unit_scale
        if result["zmin"] > result["zmax"]:
            result["zmin"], result["zmax"] = result["zmax"], result["zmin"]
        result["across_flats"] = abs(float(raw["across_flats"]) * unit_scale)
        result["rotation_degrees"] = float(raw.get("rotation_degrees", 0.0))
        if result["across_flats"] <= 0.000001 or abs(result["zmax"]-result["zmin"]) < 0.000001:
            raise ValueError("%s has invalid hex-prism geometry" % result["name"])
        return result

    def _fixture_parse_scene(self, loaded):
        if not isinstance(loaded, dict):
            raise RuntimeError("Fixture config root must be a JSON object.")
        unit_scale = self._fixture_unit_scale(loaded.get("units","inches"))
        raw_groups = loaded.get("groups", []) or []
        if not isinstance(raw_groups, list):
            raise RuntimeError("Fixture config 'groups' must be a list.")
        groups = {}
        for row in raw_groups:
            if not isinstance(row, dict): continue
            gid = str(row.get("id","") or "")
            if gid:
                groups[gid] = {"id":gid,"name":str(row.get("name",gid) or gid),"type":str(row.get("type","") or ""),"visible":bool(row.get("visible",True)),"travel_axis":str(row.get("travel_axis","") or "").upper()}
        raw_objects = loaded.get("objects")
        if raw_objects is None and isinstance(loaded.get("jaws"), list):
            raw_objects = []
            for i,jaw in enumerate(loaded.get("jaws", [])):
                if not isinstance(jaw, dict): continue
                converted = dict(jaw); converted.update({"id":"legacy_jaw_%d"%(i+1),"type":"box","visible":True,"opacity":1.0})
                raw_objects.append(converted)
        if not isinstance(raw_objects, list):
            raise RuntimeError("Fixture config 'objects' must be a list.")
        parsed=[]; seen=set()
        for i,raw in enumerate(raw_objects):
            if not isinstance(raw, dict): raise RuntimeError("Fixture object %d is not an object."%(i+1))
            typ=str(raw.get("type","") or "").strip().lower()
            try:
                obj=self._fixture_parse_box(raw,unit_scale) if typ=="box" else self._fixture_parse_round(raw,unit_scale,typ) if typ in ("cylinder","frustum") else self._fixture_parse_hex(raw,unit_scale) if typ=="hex_prism" else None
                if obj is None: raise ValueError("unsupported type '%s'"%typ)
            except Exception as e:
                raise RuntimeError("Fixture object %d is invalid: %s"%(i+1,str(e)))
            oid=obj.get("id") or "fixture_object_%d"%(i+1)
            if oid in seen: raise RuntimeError("Duplicate fixture object id: %s"%oid)
            seen.add(oid); obj["id"]=oid; parsed.append(obj)
        return groups, tuple(parsed)

    def _load_fixture_geometry(self, create_if_missing=False):
        if not FIXTURE_GEOMETRY_ENABLED:
            self._fixture_objects=(); self._fixture_groups={}; self._fixture_config_signature=None; return False
        config_dir=os.path.dirname(FIXTURE_CONFIG_PATH)
        if not os.path.isdir(config_dir): os.makedirs(config_dir)
        if not os.path.exists(FIXTURE_CONFIG_PATH):
            if not create_if_missing: return False
            self.write_file(FIXTURE_CONFIG_PATH, DEFAULT_FIXTURE_CONFIG_TEXT)
            self.debug("Created fixture config: %s" % FIXTURE_CONFIG_PATH)
        handle=open(FIXTURE_CONFIG_PATH,"r")
        try: loaded=json.load(handle)
        finally: handle.close()
        groups,objects=self._fixture_parse_scene(loaded)
        self._fixture_groups=groups; self._fixture_objects=objects; self._fixture_config_signature=self._get_fixture_config_signature()
        if self._fixture_selection is not None:
            selected_id = str(self._fixture_selection.get("object_id", "") or "")
            if self._fixture_object_by_id(selected_id) is None:
                self._fixture_selection = None
                self._fixture_secondary_selection = None
        if self._fixture_secondary_selection is not None:
            secondary_id = str(self._fixture_secondary_selection.get("object_id", "") or "")
            if self._fixture_object_by_id(secondary_id) is None:
                self._fixture_secondary_selection = None
        self.debug("Loaded %d G53 fixture object%s from %s."%(len(objects),"" if len(objects)==1 else "s",FIXTURE_CONFIG_PATH))
        return bool(objects)

    def _fixture_group_visible(self, obj):
        gid=str(obj.get("group","") or "")
        if not gid: return True
        group=self._fixture_groups.get(gid)
        return True if not isinstance(group,dict) else bool(group.get("visible",True))

    def _fixture_is_exclusion(self, obj):
        return str(obj.get("collision_role", "solid") or "solid").strip().lower() == "exclude"

    # ------------------------------------------------------------------
    # Lightweight fixture picking / editing
    # ------------------------------------------------------------------

    def _fixture_object_by_id(self, object_id):
        target = str(object_id or "")
        for obj in self._fixture_objects:
            if str(obj.get("id", "") or "") == target:
                return obj
        return None

    def _fixture_face_label(self, face_key):
        return {
            "xmin": "X- face",
            "xmax": "X+ face",
            "ymin": "Y- face",
            "ymax": "Y+ face",
            "zmin": "Z- / bed-facing face",
            "zmax": "Z+ / top-facing face",
            "side": "side surface",
        }.get(str(face_key or ""), str(face_key or "face"))

    def _fixture_face_axis(self, face_key):
        key = str(face_key or "")
        if key.startswith("x"):
            return "x"
        if key.startswith("y"):
            return "y"
        if key.startswith("z"):
            return "z"
        return None

    def _fixture_pick_allowed(self, gremlin_self=None):
        gremlin_self = gremlin_self or self.gremlin
        try:
            stat = getattr(gremlin_self, "stat", None)
            if stat is not None:
                stat.poll()
                if (stat.task_mode == linuxcnc.MODE_AUTO and
                        stat.interp_state not in (linuxcnc.INTERP_IDLE, linuxcnc.INTERP_PAUSED)):
                    return False
        except Exception:
            pass
        return True

    def _fixture_edit_allowed(self):
        try:
            ui = singletons.g_Machine
            status = getattr(ui, "status", None)
            if status is not None:
                status.poll()
                if status.interp_state != linuxcnc.INTERP_IDLE:
                    return False
            moving = getattr(ui, "moving", None)
            if callable(moving) and moving():
                return False
            command_in_progress = getattr(ui, "command_in_progress", None)
            if callable(command_in_progress) and command_in_progress():
                return False
        except Exception:
            return False
        return True

    def _fixture_pathpilot_scale(self):
        try:
            ui = singletons.g_Machine
            scale = float(ui.get_linear_scale())
            if abs(scale) > 0.000001:
                return scale
        except Exception:
            pass
        return 1.0

    def _fixture_display_units(self):
        return "mm" if abs(self._fixture_pathpilot_scale() - 25.4) < 0.01 else "in"

    def _fixture_display_linear(self, value_inches):
        return float(value_inches) * self._fixture_pathpilot_scale()

    def _fixture_current_g53_inches(self, axis):
        axis = str(axis or "").lower()
        index = {"x": 0, "y": 1, "z": 2}.get(axis)
        if index is None:
            raise ValueError("Unknown axis: %s" % axis)
        ui = singletons.g_Machine
        status = getattr(ui, "status", None)
        if status is None:
            raise RuntimeError("PathPilot status is unavailable.")
        try:
            status.poll()
        except Exception:
            pass
        position = getattr(status, "actual_position", None)
        if position is None:
            position = getattr(status, "position", None)
        if position is None:
            raise RuntimeError("PathPilot machine position is unavailable.")
        tool_offset = getattr(status, "tool_offset", None)
        offset = 0.0
        if tool_offset is not None:
            try:
                offset = float(tool_offset[index])
            except Exception:
                offset = 0.0
        return float(position[index]) - offset

    def _fixture_numeric_expr(self, text):
        expression = str(text or "").strip()
        if not expression:
            raise ValueError("A numeric value is required.")
        tree = ast.parse(expression, mode="eval")

        def evaluate(node):
            if isinstance(node, ast.Expression):
                return evaluate(node.body)
            if isinstance(node, ast.Num):
                return float(node.n)
            constant = getattr(ast, "Constant", None)
            if constant is not None and isinstance(node, constant):
                if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
                    return float(node.value)
                raise ValueError("Only numeric constants are allowed.")
            if isinstance(node, ast.UnaryOp):
                value = evaluate(node.operand)
                if isinstance(node.op, ast.UAdd):
                    return value
                if isinstance(node.op, ast.USub):
                    return -value
                raise ValueError("Unsupported unary operator.")
            if isinstance(node, ast.BinOp):
                left = evaluate(node.left)
                right = evaluate(node.right)
                if isinstance(node.op, ast.Add):
                    return left + right
                if isinstance(node.op, ast.Sub):
                    return left - right
                if isinstance(node.op, ast.Mult):
                    return left * right
                if isinstance(node.op, ast.Div):
                    if abs(right) < 1.0e-300:
                        raise ValueError("Division by zero.")
                    return left / right
                if isinstance(node.op, ast.Mod):
                    if abs(right) < 1.0e-300:
                        raise ValueError("Division by zero.")
                    return left % right
                if isinstance(node.op, ast.Pow):
                    if abs(right) > 12.0:
                        raise ValueError("Exponent is too large.")
                    return left ** right
                raise ValueError("Unsupported arithmetic operator.")
            raise ValueError("Only numeric arithmetic is allowed.")

        value = float(evaluate(tree))
        if math.isnan(value) or math.isinf(value):
            raise ValueError("Result is not finite.")
        return value

    def _fixture_value_dialog(self, title, label, current_value, unit_label=None):
        parent = None
        try:
            parent = singletons.g_Machine.window
        except Exception:
            pass
        dialog = gtk.Dialog(
            title,
            parent,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OK, gtk.RESPONSE_OK))
        dialog.set_default_size(380, 120)
        box = dialog.vbox
        row = gtk.HBox(False, 8)
        row.set_border_width(10)
        prompt = gtk.Label(label)
        prompt.set_alignment(0.0, 0.5)
        entry = gtk.Entry()
        entry.set_text("%.6f" % float(current_value))
        entry.set_activates_default(True)
        row.pack_start(prompt, False, False, 0)
        row.pack_start(entry, True, True, 0)
        if unit_label:
            units = gtk.Label(str(unit_label))
            row.pack_start(units, False, False, 0)
        box.pack_start(row, True, True, 0)
        hint = gtk.Label("Arithmetic is allowed (example: 1/4 or -3.5547-5.5897).")
        hint.set_alignment(0.0, 0.5)
        hint.set_padding(10, 0)
        box.pack_start(hint, False, False, 4)
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.show_all()
        entry.select_region(0, -1)
        entry.grab_focus()
        try:
            response = dialog.run()
            if response != gtk.RESPONSE_OK:
                return None
            return self._fixture_numeric_expr(entry.get_text())
        finally:
            dialog.destroy()

    def _fixture_xy_value_dialog(self, title, x_value, y_value, unit_label=None):
        parent = None
        try:
            parent = singletons.g_Machine.window
        except Exception:
            pass
        dialog = gtk.Dialog(
            title,
            parent,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OK, gtk.RESPONSE_OK))
        dialog.set_default_size(410, 155)
        box = dialog.vbox
        entries = {}
        for axis, value in (("X", x_value), ("Y", y_value)):
            row = gtk.HBox(False, 8)
            row.set_border_width(6)
            prompt = gtk.Label("Center %s" % axis)
            prompt.set_alignment(0.0, 0.5)
            entry = gtk.Entry()
            entry.set_text("%.6f" % float(value))
            entry.set_activates_default(True)
            row.pack_start(prompt, False, False, 0)
            row.pack_start(entry, True, True, 0)
            if unit_label:
                units = gtk.Label(str(unit_label))
                row.pack_start(units, False, False, 0)
            box.pack_start(row, False, False, 0)
            entries[axis] = entry
        hint = gtk.Label("Arithmetic is allowed in either field.")
        hint.set_alignment(0.0, 0.5)
        hint.set_padding(10, 0)
        box.pack_start(hint, False, False, 4)
        dialog.set_default_response(gtk.RESPONSE_OK)
        dialog.show_all()
        entries["X"].select_region(0, -1)
        entries["X"].grab_focus()
        try:
            response = dialog.run()
            if response != gtk.RESPONSE_OK:
                return None
            return (
                self._fixture_numeric_expr(entries["X"].get_text()),
                self._fixture_numeric_expr(entries["Y"].get_text()))
        finally:
            dialog.destroy()

    def _fixture_show_error(self, title, message):
        try:
            parent = singletons.g_Machine.window
        except Exception:
            parent = None
        try:
            dialog = gtk.MessageDialog(
                parent,
                gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
                gtk.MESSAGE_ERROR,
                gtk.BUTTONS_OK,
                title)
            dialog.format_secondary_text(str(message))
            dialog.run()
            dialog.destroy()
        except Exception:
            self.debug("%s: %s" % (title, str(message)))

    def _fixture_load_raw_ordered(self):
        handle = open(FIXTURE_CONFIG_PATH, "r")
        try:
            return json.load(handle, object_pairs_hook=OrderedDict)
        finally:
            handle.close()

    def _fixture_atomic_write_raw(self, data):
        directory = os.path.dirname(FIXTURE_CONFIG_PATH)
        if directory and not os.path.isdir(directory):
            os.makedirs(directory)
        temp_path = "%s.%d.tmp" % (FIXTURE_CONFIG_PATH, os.getpid())
        handle = open(temp_path, "w")
        try:
            json.dump(data, handle, indent=4)
            handle.write("\n")
            handle.flush()
            os.fsync(handle.fileno())
        finally:
            handle.close()
        try:
            mode = os.stat(FIXTURE_CONFIG_PATH).st_mode
            os.chmod(temp_path, mode)
        except Exception:
            pass
        os.rename(temp_path, FIXTURE_CONFIG_PATH)

    def _fixture_history_fingerprint(self, data):
        try:
            return json.dumps(
                data,
                sort_keys=True,
                separators=(",", ":"),
                ensure_ascii=True)
        except Exception:
            return None

    def _fixture_clear_history(self, reason=None):
        had_history = bool(self._fixture_undo_stack or self._fixture_redo_stack)
        self._fixture_undo_stack = []
        self._fixture_redo_stack = []
        if had_history and reason:
            self.debug("Fixture Undo/Redo history cleared: %s" % str(reason))
        return had_history

    def _fixture_history_label(self, data, object_id, detail):
        raw_obj = self._fixture_raw_object(data, object_id)
        name = "Fixture"
        if isinstance(raw_obj, dict):
            name = str(raw_obj.get("name", raw_obj.get("id", "Fixture")) or "Fixture")
        detail = str(detail or "Edit")
        return "%s - %s" % (name, detail)

    def _fixture_commit_edit(self, before_data, after_data, label):
        before_fp = self._fixture_history_fingerprint(before_data)
        after_fp = self._fixture_history_fingerprint(after_data)
        if before_fp is not None and before_fp == after_fp:
            return True

        self._fixture_validate_raw_geometry(after_data)
        entry = {
            "label": str(label or "Fixture Edit"),
            "before": copy.deepcopy(before_data),
            "after": copy.deepcopy(after_data),
            "before_fingerprint": before_fp,
            "after_fingerprint": after_fp,
        }

        self._fixture_history_internal_write = True
        try:
            self._fixture_atomic_write_raw(after_data)
            if not self.reload_fixture_geometry(quiet=True):
                raise RuntimeError("Fixture geometry could not be reloaded after the edit.")
        finally:
            self._fixture_history_internal_write = False

        self._fixture_undo_stack.append(entry)
        if len(self._fixture_undo_stack) > int(FIXTURE_HISTORY_LIMIT):
            del self._fixture_undo_stack[0:len(self._fixture_undo_stack)-int(FIXTURE_HISTORY_LIMIT)]
        self._fixture_redo_stack = []
        return True

    def _fixture_restore_history_entry(self, entry, use_before):
        if not self._fixture_edit_allowed():
            self._fixture_show_error(
                "Fixture Undo/Redo",
                "Fixture Undo/Redo is only available while PathPilot is idle.")
            return False
        try:
            current = self._fixture_load_raw_ordered()
            expected_fp = entry.get("after_fingerprint") if use_before else entry.get("before_fingerprint")
            current_fp = self._fixture_history_fingerprint(current)
            if expected_fp is not None and current_fp != expected_fp:
                self._fixture_clear_history("fixture file changed outside Gremlin")
                self._fixture_show_error(
                    "Fixture Undo/Redo",
                    "The fixture configuration changed outside this Gremlin editing session.\n\n"
                    "Undo/Redo history was cleared so newer Fixture Builder or file edits are not overwritten.")
                return False

            restored = copy.deepcopy(entry.get("before") if use_before else entry.get("after"))
            if not isinstance(restored, dict):
                raise ValueError("Fixture Undo/Redo snapshot is invalid.")
            self._fixture_validate_raw_geometry(restored)
            self._fixture_history_internal_write = True
            try:
                self._fixture_atomic_write_raw(restored)
                if not self.reload_fixture_geometry(quiet=True):
                    raise RuntimeError("Fixture geometry could not be reloaded after Undo/Redo.")
            finally:
                self._fixture_history_internal_write = False
            return True
        except Exception as e:
            self._fixture_show_error("Fixture Undo/Redo", str(e))
            return False

    def _fixture_undo(self, menu_item=None):
        if not self._fixture_undo_stack:
            return False
        entry = self._fixture_undo_stack[-1]
        if not self._fixture_restore_history_entry(entry, True):
            return False
        self._fixture_undo_stack.pop()
        self._fixture_redo_stack.append(entry)
        if len(self._fixture_redo_stack) > int(FIXTURE_HISTORY_LIMIT):
            del self._fixture_redo_stack[0:len(self._fixture_redo_stack)-int(FIXTURE_HISTORY_LIMIT)]
        self.debug("Undid fixture edit: %s" % str(entry.get("label", "Fixture Edit")))
        return True

    def _fixture_redo(self, menu_item=None):
        if not self._fixture_redo_stack:
            return False
        entry = self._fixture_redo_stack[-1]
        if not self._fixture_restore_history_entry(entry, False):
            return False
        self._fixture_redo_stack.pop()
        self._fixture_undo_stack.append(entry)
        if len(self._fixture_undo_stack) > int(FIXTURE_HISTORY_LIMIT):
            del self._fixture_undo_stack[0:len(self._fixture_undo_stack)-int(FIXTURE_HISTORY_LIMIT)]
        self.debug("Redid fixture edit: %s" % str(entry.get("label", "Fixture Edit")))
        return True

    def _fixture_registration_preset(self, preset_key):
        target = str(preset_key or "")
        for preset in FIXTURE_WCS_REGISTRATION_PRESETS:
            if str(preset.get("key", "") or "") == target:
                return preset
        return None

    def _fixture_raw_object_by_name(self, data, object_name):
        target = str(object_name or "")
        matches = []
        for obj in data.get("objects", []) or []:
            if not isinstance(obj, dict):
                continue
            if str(obj.get("name", "") or "") == target:
                matches.append(obj)
        if len(matches) != 1:
            raise RuntimeError(
                "Expected exactly one fixture object named '%s'; found %d."
                % (target, len(matches)))
        return matches[0]

    def _fixture_raw_center_xy(self, raw_obj):
        if not isinstance(raw_obj, dict):
            raise RuntimeError("Fixture object is invalid.")

        if "x" in raw_obj and "y" in raw_obj:
            return (float(raw_obj["x"]), float(raw_obj["y"]))

        if (
                "xmin" in raw_obj and
                "xmax" in raw_obj and
                "ymin" in raw_obj and
                "ymax" in raw_obj):
            return (
                (float(raw_obj["xmin"]) + float(raw_obj["xmax"])) * 0.5,
                (float(raw_obj["ymin"]) + float(raw_obj["ymax"])) * 0.5)

        raise RuntimeError(
            "Fixture '%s' does not expose an XY center that can be registered."
            % str(raw_obj.get("name", raw_obj.get("id", "Fixture")) or "Fixture"))

    def _fixture_set_raw_center_xy(self, raw_obj, target_x, target_y):
        old_x, old_y = self._fixture_raw_center_xy(raw_obj)
        dx = float(target_x) - float(old_x)
        dy = float(target_y) - float(old_y)

        if "x" in raw_obj and "y" in raw_obj:
            raw_obj["x"] = float(target_x)
            raw_obj["y"] = float(target_y)
            return (dx, dy)

        if (
                "xmin" in raw_obj and
                "xmax" in raw_obj and
                "ymin" in raw_obj and
                "ymax" in raw_obj):
            raw_obj["xmin"] = float(raw_obj["xmin"]) + dx
            raw_obj["xmax"] = float(raw_obj["xmax"]) + dx
            raw_obj["ymin"] = float(raw_obj["ymin"]) + dy
            raw_obj["ymax"] = float(raw_obj["ymax"]) + dy
            return (dx, dy)

        raise RuntimeError(
            "Fixture '%s' cannot be translated in XY."
            % str(raw_obj.get("name", raw_obj.get("id", "Fixture")) or "Fixture"))

    def _fixture_raw_z_top(self, raw_obj):
        if not isinstance(raw_obj, dict):
            raise RuntimeError("Fixture object is invalid.")
        if "zmin" not in raw_obj or "zmax" not in raw_obj:
            raise RuntimeError(
                "Fixture '%s' does not expose zmin/zmax."
                % str(raw_obj.get("name", raw_obj.get("id", "Fixture")) or "Fixture"))
        return max(float(raw_obj["zmin"]), float(raw_obj["zmax"]))

    def _fixture_set_raw_z_top(self, raw_obj, target_z):
        if not isinstance(raw_obj, dict):
            raise RuntimeError("Fixture object is invalid.")
        if "zmin" not in raw_obj or "zmax" not in raw_obj:
            raise RuntimeError(
                "Fixture '%s' does not expose zmin/zmax."
                % str(raw_obj.get("name", raw_obj.get("id", "Fixture")) or "Fixture"))

        zmin = float(raw_obj["zmin"])
        zmax = float(raw_obj["zmax"])
        old_top = max(zmin, zmax)
        old_bottom = min(zmin, zmax)
        target_z = float(target_z)

        if target_z <= old_bottom + 0.0000001:
            raise RuntimeError(
                "Requested Z top %.6f for '%s' is not above its existing bottom %.6f."
                % (
                    target_z,
                    str(raw_obj.get("name", raw_obj.get("id", "Fixture")) or "Fixture"),
                    old_bottom))

        # Fixture files use zmin <= zmax. Preserve the lower boundary and set
        # only the top-facing boundary requested by the registration preset.
        raw_obj["zmin"] = old_bottom
        raw_obj["zmax"] = target_z
        return target_z - old_top

    def _fixture_work_offset_xy_inches(self, wcs_index):
        ui = singletons.g_Machine
        status = getattr(ui, "status", None)
        if status is None:
            raise RuntimeError("PathPilot status is unavailable.")

        try:
            status.poll()
        except Exception:
            pass

        homed = getattr(status, "homed", None)
        if homed is not None:
            try:
                if not bool(homed[0]) or not bool(homed[1]):
                    raise RuntimeError(
                        "X and Y must both be referenced before fixture re-registration.")
            except IndexError:
                raise RuntimeError(
                    "PathPilot homing state does not contain X/Y axes.")

        offsets = getattr(status, "g5x_offsets", None)
        if offsets is None:
            raise RuntimeError("PathPilot work-offset data is unavailable.")

        try:
            row = offsets[int(wcs_index)]
        except Exception:
            try:
                row = offsets.get(int(wcs_index))
            except Exception:
                row = None

        if row is None:
            raise RuntimeError(
                "PathPilot work offset index %d is unavailable."
                % int(wcs_index))

        try:
            return (float(row[0]), float(row[1]))
        except Exception:
            raise RuntimeError(
                "PathPilot work offset index %d does not contain XY coordinates."
                % int(wcs_index))

    def _fixture_work_offset_z_inches(self, wcs_index):
        ui = singletons.g_Machine
        status = getattr(ui, "status", None)
        if status is None:
            raise RuntimeError("PathPilot status is unavailable.")

        try:
            status.poll()
        except Exception:
            pass

        homed = getattr(status, "homed", None)
        if homed is not None:
            try:
                if not bool(homed[2]):
                    raise RuntimeError(
                        "Z must be referenced before fixture Z-top re-registration.")
            except IndexError:
                raise RuntimeError(
                    "PathPilot homing state does not contain the Z axis.")

        offsets = getattr(status, "g5x_offsets", None)
        if offsets is None:
            raise RuntimeError("PathPilot work-offset data is unavailable.")

        try:
            row = offsets[int(wcs_index)]
        except Exception:
            try:
                row = offsets.get(int(wcs_index))
            except Exception:
                row = None

        if row is None:
            raise RuntimeError(
                "PathPilot work offset index %d is unavailable."
                % int(wcs_index))

        try:
            return float(row[2])
        except Exception:
            raise RuntimeError(
                "PathPilot work offset index %d does not contain a Z coordinate."
                % int(wcs_index))

    def _fixture_registration_preview_dialog(
            self,
            preset,
            preview_rows,
            z_top_preview=None):
        try:
            parent = singletons.g_Machine.window
        except Exception:
            parent = None

        units = self._fixture_display_units()
        scale = self._fixture_pathpilot_scale()

        lines = [
            "This will move fixture geometry only.",
            "PathPilot work offsets will NOT be changed.",
            "",
        ]

        for row in preview_rows:
            lines.append(
                "%s  ->  %s"
                % (
                    str(row["wcs_name"]),
                    " + ".join(row["object_names"])))
            lines.append(
                "    New G53 center:  X %.4f   Y %.4f %s"
                % (
                    float(row["target_x_inches"]) * scale,
                    float(row["target_y_inches"]) * scale,
                    units))
            lines.append(
                "    Primary move:     dX %+.4f   dY %+.4f %s"
                % (
                    float(row["primary_dx_inches"]) * scale,
                    float(row["primary_dy_inches"]) * scale,
                    units))

        if z_top_preview is not None:
            lines.extend([
                "",
                "%s  ->  top-facing Z of %s"
                % (
                    str(z_top_preview["wcs_name"]),
                    " + ".join(z_top_preview["object_names"])),
                "    New G53 Z top:    Z %.4f %s"
                % (
                    float(z_top_preview["target_z_inches"]) * scale,
                    units),
            ])

        lines.extend([
            "",
            "Apply these fixture registrations?",
        ])

        dialog = gtk.MessageDialog(
            parent,
            gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
            gtk.MESSAGE_QUESTION,
            gtk.BUTTONS_NONE,
            "Re-register Fixture Geometry")
        dialog.format_secondary_text("\n".join(lines))
        dialog.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
        apply_button = dialog.add_button("APPLY", gtk.RESPONSE_OK)
        try:
            apply_button.set_can_default(True)
            apply_button.grab_default()
            dialog.set_default_response(gtk.RESPONSE_OK)
        except Exception:
            pass

        try:
            response = dialog.run()
            return response == gtk.RESPONSE_OK
        finally:
            dialog.destroy()

    def _fixture_reregister_from_wcs(self, preset_key):
        if not self._fixture_edit_allowed():
            self._fixture_show_error(
                "Fixture Re-registration",
                "Fixture geometry can only be re-registered while PathPilot is idle.")
            return False

        try:
            preset = self._fixture_registration_preset(preset_key)
            if preset is None:
                raise RuntimeError(
                    "Unknown fixture registration preset: %s"
                    % str(preset_key))

            data = self._fixture_load_raw_ordered()
            before_data = copy.deepcopy(data)
            unit_scale = self._fixture_unit_scale(
                data.get("units", "inches"))

            preview_rows = []
            z_top_preview = None

            for entry in preset.get("entries", ()) or ():
                wcs_index = int(entry.get("wcs_index"))
                wcs_name = str(
                    entry.get(
                        "wcs_name",
                        "G5x index %d" % wcs_index) or "")
                object_names = tuple(entry.get("objects", ()) or ())
                if not object_names:
                    raise RuntimeError(
                        "%s has no fixture objects assigned."
                        % wcs_name)

                target_x_inches, target_y_inches = (
                    self._fixture_work_offset_xy_inches(wcs_index))

                target_x_raw = float(target_x_inches) / unit_scale
                target_y_raw = float(target_y_inches) / unit_scale

                primary_dx_inches = 0.0
                primary_dy_inches = 0.0

                for object_index, object_name in enumerate(object_names):
                    raw_obj = self._fixture_raw_object_by_name(
                        data,
                        object_name)
                    old_x_raw, old_y_raw = self._fixture_raw_center_xy(
                        raw_obj)

                    dx_raw = target_x_raw - old_x_raw
                    dy_raw = target_y_raw - old_y_raw

                    self._fixture_set_raw_center_xy(
                        raw_obj,
                        target_x_raw,
                        target_y_raw)

                    if object_index == 0:
                        primary_dx_inches = dx_raw * unit_scale
                        primary_dy_inches = dy_raw * unit_scale

                preview_rows.append({
                    "wcs_name": wcs_name,
                    "object_names": object_names,
                    "target_x_inches": target_x_inches,
                    "target_y_inches": target_y_inches,
                    "primary_dx_inches": primary_dx_inches,
                    "primary_dy_inches": primary_dy_inches,
                })

            z_top_spec = preset.get("z_top")
            if z_top_spec:
                z_wcs_index = int(z_top_spec.get("wcs_index"))
                z_wcs_name = str(
                    z_top_spec.get(
                        "wcs_name",
                        "G5x index %d" % z_wcs_index) or "")
                z_object_names = tuple(z_top_spec.get("objects", ()) or ())
                if not z_object_names:
                    raise RuntimeError(
                        "%s Z-top registration has no fixture objects assigned."
                        % z_wcs_name)

                target_z_inches = self._fixture_work_offset_z_inches(
                    z_wcs_index)
                if (
                        bool(z_top_spec.get("require_nonzero", False)) and
                        abs(float(target_z_inches)) < 0.0000001):
                    raise RuntimeError(
                        "%s Z offset is zero/unset. Probe an ER collet top "
                        "with T99 and set %s Z0 before re-registering."
                        % (z_wcs_name, z_wcs_name))

                target_z_raw = float(target_z_inches) / unit_scale
                z_deltas_inches = []

                for object_name in z_object_names:
                    raw_obj = self._fixture_raw_object_by_name(
                        data,
                        object_name)
                    dz_raw = self._fixture_set_raw_z_top(
                        raw_obj,
                        target_z_raw)
                    z_deltas_inches.append(dz_raw * unit_scale)

                z_top_preview = {
                    "wcs_name": z_wcs_name,
                    "object_names": z_object_names,
                    "target_z_inches": target_z_inches,
                    "delta_inches": tuple(z_deltas_inches),
                }

            if not self._fixture_registration_preview_dialog(
                    preset,
                    preview_rows,
                    z_top_preview):
                return False

            if not self._fixture_commit_edit(
                    before_data,
                    data,
                    preset.get(
                        "history_label",
                        "Re-register Fixture Geometry")):
                raise RuntimeError(
                    "Fixture geometry could not be committed.")

            summary = []
            for row in preview_rows:
                summary.append(
                    "%s -> X%.4f Y%.4f"
                    % (
                        row["wcs_name"],
                        row["target_x_inches"],
                        row["target_y_inches"]))
            if z_top_preview is not None:
                summary.append(
                    "%s -> Ztop %.4f"
                    % (
                        z_top_preview["wcs_name"],
                        z_top_preview["target_z_inches"]))

            self.debug(
                "Fixture re-registration applied: %s"
                % "; ".join(summary))
            return True

        except Exception as e:
            self._fixture_show_error(
                "Fixture Re-registration",
                str(e))
            return False

    def _fixture_reregister_er40_1_4(self, menu_item=None):
        return self._fixture_reregister_from_wcs(
            "er40_1_4")

    def _fixture_raw_object(self, data, object_id):
        target = str(object_id or "")
        for obj in data.get("objects", []) or []:
            if isinstance(obj, dict) and str(obj.get("id", "") or "") == target:
                return obj
        return None

    def _fixture_raw_group(self, data, group_id):
        target = str(group_id or "")
        for group in data.get("groups", []) or []:
            if isinstance(group, dict) and str(group.get("id", "") or "") == target:
                return group
        return None

    def _fixture_vise_role_objects(self, data, group_id):
        result = {}
        target = str(group_id or "")
        for obj in data.get("objects", []) or []:
            if not isinstance(obj, dict):
                continue
            if str(obj.get("group", "") or "") != target:
                continue
            role = str(obj.get("role", "") or "")
            if role in ("bed", "fixed_jaw", "movable_jaw"):
                result[role] = obj
        return result

    def _fixture_physical_field_to_raw_key(self, raw_obj, field_name):
        field_name = str(field_name or "")
        pairs = {
            "xmin": ("xmin", "xmax", True),
            "xmax": ("xmin", "xmax", False),
            "ymin": ("ymin", "ymax", True),
            "ymax": ("ymin", "ymax", False),
            "zmin": ("zmin", "zmax", True),
            "zmax": ("zmin", "zmax", False),
        }
        spec = pairs.get(field_name)
        if spec is None or spec[0] not in raw_obj or spec[1] not in raw_obj:
            return field_name
        low_key, high_key, want_low = spec
        low_value = float(raw_obj[low_key])
        high_value = float(raw_obj[high_key])
        if low_value <= high_value:
            return low_key if want_low else high_key
        return high_key if want_low else low_key

    def _fixture_validate_face_move(self, raw_obj, physical_field, raw_value):
        physical_field = str(physical_field or "")
        pairs = {
            "xmin": ("xmin", "xmax", True),
            "xmax": ("xmin", "xmax", False),
            "ymin": ("ymin", "ymax", True),
            "ymax": ("ymin", "ymax", False),
            "zmin": ("zmin", "zmax", True),
            "zmax": ("zmin", "zmax", False),
        }
        spec = pairs.get(physical_field)
        if spec is None or spec[0] not in raw_obj or spec[1] not in raw_obj:
            return
        first = float(raw_obj[spec[0]])
        second = float(raw_obj[spec[1]])
        physical_low = min(first, second)
        physical_high = max(first, second)
        if spec[2]:
            if float(raw_value) >= physical_high:
                raise ValueError("%s must remain below the opposite face (%.6f)." % (
                    self._fixture_face_label(physical_field), physical_high))
        else:
            if float(raw_value) <= physical_low:
                raise ValueError("%s must remain above the opposite face (%.6f)." % (
                    self._fixture_face_label(physical_field), physical_low))

    def _fixture_validate_raw_geometry(self, data):
        for obj in data.get("objects", []) or []:
            if not isinstance(obj, dict):
                continue
            typ = str(obj.get("type", "") or "").lower()
            name = str(obj.get("name", obj.get("id", "fixture")) or "fixture")
            if typ == "box":
                for first_key, second_key in (("xmin", "xmax"), ("ymin", "ymax"), ("zmin", "zmax")):
                    if abs(float(obj[first_key]) - float(obj[second_key])) < 0.0000001:
                        raise ValueError("%s has a zero-size %s/%s span." % (name, first_key, second_key))
            elif typ in ("cylinder", "frustum"):
                if abs(float(obj["zmin"]) - float(obj["zmax"])) < 0.0000001:
                    raise ValueError("%s has zero Z height." % name)
                if float(obj["bottom_diameter"]) <= 0.0:
                    raise ValueError("%s bottom diameter must be greater than zero." % name)
                if typ == "frustum" and float(obj.get("top_diameter", 0.0)) < 0.0:
                    raise ValueError("%s top diameter cannot be negative." % name)
            elif typ == "hex_prism":
                if abs(float(obj["zmin"]) - float(obj["zmax"])) < 0.0000001:
                    raise ValueError("%s has zero Z height." % name)
                if float(obj["across_flats"]) <= 0.0:
                    raise ValueError("%s across-flats value must be greater than zero." % name)

        # Preserve the semantic generated-vise relationships used by Fixture Builder.
        for group in data.get("groups", []) or []:
            if not isinstance(group, dict) or str(group.get("type", "") or "").lower() != "vise":
                continue
            gid = str(group.get("id", "") or "")
            roles = self._fixture_vise_role_objects(data, gid)
            if not all(key in roles for key in ("bed", "fixed_jaw", "movable_jaw")):
                continue
            body = roles["bed"]
            fixed = roles["fixed_jaw"]
            movable = roles["movable_jaw"]
            body_top = max(float(body["zmin"]), float(body["zmax"]))
            fixed_bottom = min(float(fixed["zmin"]), float(fixed["zmax"]))
            movable_bottom = min(float(movable["zmin"]), float(movable["zmax"]))
            fixed_top = max(float(fixed["zmin"]), float(fixed["zmax"]))
            movable_top = max(float(movable["zmin"]), float(movable["zmax"]))
            if abs(fixed_bottom - body_top) > 0.0000001 or abs(movable_bottom - body_top) > 0.0000001:
                raise ValueError("Generated vise jaw bottoms must match the body top Z.")
            if fixed_top <= body_top or movable_top <= body_top:
                raise ValueError("Generated vise jaw top must be above the body top.")

        # Run the same parser Gremlin itself uses before committing anything.
        self._fixture_parse_scene(data)

    def _fixture_apply_raw_value(self, data, raw_obj, field_name, raw_value):
        field_name = str(field_name or "")
        group_id = str(raw_obj.get("group", "") or "")
        role = str(raw_obj.get("role", "") or "")
        group = self._fixture_raw_group(data, group_id) if group_id else None
        is_vise = isinstance(group, dict) and str(group.get("type", "") or "").lower() == "vise"

        if field_name == "diameter" and str(raw_obj.get("type", "") or "").lower() == "cylinder":
            raw_obj["bottom_diameter"] = raw_value
            raw_obj["top_diameter"] = raw_value
            return

        if not is_vise or role not in ("bed", "fixed_jaw", "movable_jaw"):
            raw_obj[field_name] = raw_value
            return

        roles = self._fixture_vise_role_objects(data, group_id)
        body = roles.get("bed")
        fixed = roles.get("fixed_jaw")
        movable = roles.get("movable_jaw")
        axis = str(group.get("travel_axis", "Y") or "Y").upper()
        if axis not in ("X", "Y"):
            axis = "Y"

        if role == "bed" and field_name == "zmax" and body and fixed and movable:
            body["zmax"] = raw_value
            fixed["zmin"] = raw_value
            movable["zmin"] = raw_value
            return

        if role in ("fixed_jaw", "movable_jaw") and field_name == "zmin" and body and fixed and movable:
            body["zmax"] = raw_value
            fixed["zmin"] = raw_value
            movable["zmin"] = raw_value
            return

        if role in ("fixed_jaw", "movable_jaw") and field_name == "zmax" and fixed and movable:
            fixed["zmax"] = raw_value
            movable["zmax"] = raw_value
            return

        shared_cross = ((axis == "Y" and field_name in ("xmin", "xmax")) or
                        (axis == "X" and field_name in ("ymin", "ymax")))
        if role in ("fixed_jaw", "movable_jaw") and shared_cross and fixed and movable:
            fixed[field_name] = raw_value
            movable[field_name] = raw_value
            return

        raw_obj[field_name] = raw_value

    def _fixture_write_property(self, object_id, field_name, value_internal, is_angle=False):
        if not self._fixture_edit_allowed():
            self._fixture_show_error("Fixture Editing", "Fixture values can only be changed while PathPilot is idle.")
            return False
        try:
            data = self._fixture_load_raw_ordered()
            before_data = copy.deepcopy(data)
            raw_obj = self._fixture_raw_object(data, object_id)
            if raw_obj is None:
                raise ValueError("The selected fixture component no longer exists.")
            if is_angle:
                raw_value = float(value_internal)
                resolved_field = field_name
            else:
                unit_scale = self._fixture_unit_scale(data.get("units", "inches"))
                raw_value = float(value_internal) / unit_scale
                self._fixture_validate_face_move(raw_obj, field_name, raw_value)
                resolved_field = self._fixture_physical_field_to_raw_key(raw_obj, field_name)
            self._fixture_apply_raw_value(data, raw_obj, resolved_field, raw_value)
            detail = self._fixture_face_label(field_name) if str(field_name) in ("xmin", "xmax", "ymin", "ymax", "zmin", "zmax") else str(field_name).replace("_", " ").title()
            return self._fixture_commit_edit(
                before_data,
                data,
                self._fixture_history_label(before_data, object_id, detail))
        except Exception as e:
            self._fixture_show_error("Fixture Editing", str(e))
            return False

    def _fixture_edit_linear_property(self, object_id, field_name, label, current_inches):
        if not self._fixture_edit_allowed():
            self._fixture_show_error("Fixture Editing", "Fixture values can only be changed while PathPilot is idle.")
            return
        scale = self._fixture_pathpilot_scale()
        display_value = float(current_inches) * scale
        entered = self._fixture_value_dialog(
            "Edit Fixture Value",
            label,
            display_value,
            self._fixture_display_units())
        if entered is None:
            return
        self._fixture_write_property(object_id, field_name, float(entered) / scale, False)

    def _fixture_edit_angle_property(self, object_id, field_name, label, current_degrees):
        entered = self._fixture_value_dialog(
            "Edit Fixture Value",
            label,
            float(current_degrees),
            "deg")
        if entered is None:
            return
        self._fixture_write_property(object_id, field_name, float(entered), True)

    def _fixture_set_property_to_current_g53(self, object_id, field_name, axis):
        try:
            value_inches = self._fixture_current_g53_inches(axis)
        except Exception as e:
            self._fixture_show_error("Current G53", str(e))
            return
        self._fixture_write_property(object_id, field_name, value_inches, False)

    def _fixture_write_center_xy(self, object_id, x_inches, y_inches):
        if not self._fixture_edit_allowed():
            self._fixture_show_error("Fixture Editing", "Fixture values can only be changed while PathPilot is idle.")
            return False
        try:
            data = self._fixture_load_raw_ordered()
            before_data = copy.deepcopy(data)
            raw_obj = self._fixture_raw_object(data, object_id)
            if raw_obj is None:
                raise ValueError("The selected fixture component no longer exists.")
            if "x" not in raw_obj or "y" not in raw_obj:
                raise ValueError("This fixture does not have centered X/Y geometry.")
            unit_scale = self._fixture_unit_scale(data.get("units", "inches"))
            raw_obj["x"] = float(x_inches) / unit_scale
            raw_obj["y"] = float(y_inches) / unit_scale
            return self._fixture_commit_edit(
                before_data,
                data,
                self._fixture_history_label(before_data, object_id, "Center X/Y"))
        except Exception as e:
            self._fixture_show_error("Fixture Editing", str(e))
            return False

    def _fixture_edit_center_xy(self, object_id, current_x_inches, current_y_inches):
        if not self._fixture_edit_allowed():
            self._fixture_show_error("Fixture Editing", "Fixture values can only be changed while PathPilot is idle.")
            return
        scale = self._fixture_pathpilot_scale()
        entered = self._fixture_xy_value_dialog(
            "Edit Fixture Center",
            float(current_x_inches) * scale,
            float(current_y_inches) * scale,
            self._fixture_display_units())
        if entered is None:
            return
        self._fixture_write_center_xy(
            object_id,
            float(entered[0]) / scale,
            float(entered[1]) / scale)

    def _fixture_set_center_xy_to_current_g53(self, object_id):
        try:
            x_inches = self._fixture_current_g53_inches("x")
            y_inches = self._fixture_current_g53_inches("y")
        except Exception as e:
            self._fixture_show_error("Current G53", str(e))
            return
        self._fixture_write_center_xy(object_id, x_inches, y_inches)

    def _fixture_write_box_center(self, object_id, axis, center_inches):
        axis = str(axis or "").lower()
        if axis not in ("x", "y"):
            self._fixture_show_error("Fixture Editing", "Feature centering is available for X or Y face pairs.")
            return False
        if not self._fixture_edit_allowed():
            self._fixture_show_error("Fixture Editing", "Fixture values can only be changed while PathPilot is idle.")
            return False
        try:
            data = self._fixture_load_raw_ordered()
            before_data = copy.deepcopy(data)
            raw_obj = self._fixture_raw_object(data, object_id)
            if raw_obj is None:
                raise ValueError("The selected fixture component no longer exists.")
            if str(raw_obj.get("type", "") or "").lower() != "box":
                raise ValueError("Feature centering requires a box fixture.")
            low_key = axis + "min"
            high_key = axis + "max"
            if low_key not in raw_obj or high_key not in raw_obj:
                raise ValueError("The selected box does not contain both %s faces." % axis.upper())
            unit_scale = self._fixture_unit_scale(data.get("units", "inches"))
            target_raw = float(center_inches) / unit_scale
            current_raw = (float(raw_obj[low_key]) + float(raw_obj[high_key])) * 0.5
            delta = target_raw - current_raw

            targets = [raw_obj]
            group_id = str(raw_obj.get("group", "") or "")
            role = str(raw_obj.get("role", "") or "")
            group = self._fixture_raw_group(data, group_id) if group_id else None
            if (isinstance(group, dict) and str(group.get("type", "") or "").lower() == "vise" and
                    role in ("fixed_jaw", "movable_jaw")):
                travel_axis = str(group.get("travel_axis", "Y") or "Y").lower()
                # Builder semantics: the two jaws share their cross-axis span.
                if axis != travel_axis:
                    roles = self._fixture_vise_role_objects(data, group_id)
                    targets = [obj for obj in (roles.get("fixed_jaw"), roles.get("movable_jaw")) if obj is not None]

            for target in targets:
                target[low_key] = float(target[low_key]) + delta
                target[high_key] = float(target[high_key]) + delta

            return self._fixture_commit_edit(
                before_data,
                data,
                self._fixture_history_label(before_data, object_id, "Feature Center %s" % axis.upper()))
        except Exception as e:
            self._fixture_show_error("Fixture Editing", str(e))
            return False

    def _fixture_edit_box_center(self, object_id, axis, current_center_inches):
        scale = self._fixture_pathpilot_scale()
        entered = self._fixture_value_dialog(
            "Center Fixture Feature",
            "Feature Center %s" % str(axis).upper(),
            float(current_center_inches) * scale,
            self._fixture_display_units())
        if entered is None:
            return
        self._fixture_write_box_center(object_id, axis, float(entered) / scale)

    def _fixture_set_box_center_to_current_g53(self, object_id, axis):
        try:
            value_inches = self._fixture_current_g53_inches(axis)
        except Exception as e:
            self._fixture_show_error("Current G53", str(e))
            return
        self._fixture_write_box_center(object_id, axis, value_inches)

    def _fixture_set_visible(self, object_id, visible):
        if not self._fixture_edit_allowed():
            self._fixture_show_error("Fixture Editing", "Fixture visibility can only be changed while PathPilot is idle.")
            return False
        try:
            data = self._fixture_load_raw_ordered()
            before_data = copy.deepcopy(data)
            raw_obj = self._fixture_raw_object(data, object_id)
            if raw_obj is None:
                raise ValueError("The selected fixture component no longer exists.")
            raw_obj["visible"] = bool(visible)
            if not self._fixture_commit_edit(
                    before_data,
                    data,
                    self._fixture_history_label(before_data, object_id, "Show" if visible else "Hide")):
                return False
            if not visible and self._fixture_selection and str(self._fixture_selection.get("object_id", "")) == str(object_id):
                self._fixture_selection = None
                self._fixture_secondary_selection = None
            if self.gremlin is not None:
                self.gremlin.queue_draw()
            return True
        except Exception as e:
            self._fixture_show_error("Fixture Editing", str(e))
            return False

    def _fixture_on_visibility_toggled(self, menu_item, object_id):
        self._fixture_set_visible(object_id, bool(menu_item.get_active()))

    def _fixture_pick_set_color(self, code):
        code = int(code)
        r = code & 0xff
        g = (code >> 8) & 0xff
        b = (code >> 16) & 0xff
        minigl.glColor3f(float(r) / 255.0, float(g) / 255.0, float(b) / 255.0)

    def _fixture_pick_pixel_code(self, px_data):
        if px_data is None or len(px_data) < 3:
            return 0
        values = []
        for i in range(3):
            value = px_data[i]
            if not isinstance(value, int):
                value = ord(value)
            values.append(int(value) & 0xff)
        return values[0] | (values[1] << 8) | (values[2] << 16)

    def _fixture_pick_draw_box(self, obj, next_code, mapping):
        x0, x1 = float(obj["xmin"]), float(obj["xmax"])
        y0, y1 = float(obj["ymin"]), float(obj["ymax"])
        z0, z1 = float(obj["zmin"]), float(obj["zmax"])
        faces = (
            ("zmin", ((x0,y0,z0),(x1,y0,z0),(x1,y1,z0),(x0,y1,z0))),
            ("zmax", ((x0,y0,z1),(x0,y1,z1),(x1,y1,z1),(x1,y0,z1))),
            ("xmin", ((x0,y0,z0),(x0,y1,z0),(x0,y1,z1),(x0,y0,z1))),
            ("xmax", ((x1,y0,z0),(x1,y0,z1),(x1,y1,z1),(x1,y1,z0))),
            ("ymin", ((x0,y0,z0),(x0,y0,z1),(x1,y0,z1),(x1,y0,z0))),
            ("ymax", ((x0,y1,z0),(x1,y1,z0),(x1,y1,z1),(x0,y1,z1))),
        )
        for face_key, vertices in faces:
            code = next_code[0]
            next_code[0] += 1
            mapping[code] = (str(obj.get("id", "")), face_key)
            self._fixture_pick_set_color(code)
            minigl.glBegin(minigl.GL_QUADS)
            for vertex in vertices:
                minigl.glVertex3f(*vertex)
            minigl.glEnd()

    def _fixture_pick_draw_round(self, obj, next_code, mapping):
        x, y = float(obj["x"]), float(obj["y"])
        z0, z1 = float(obj["zmin"]), float(obj["zmax"])
        r0 = float(obj["bottom_diameter"]) * 0.5
        r1 = float(obj["top_diameter"]) * 0.5
        facets = max(8, min(128, int(obj.get("facets", 32))))
        code = next_code[0]
        next_code[0] += 1
        mapping[code] = (str(obj.get("id", "")), "side")
        self._fixture_pick_set_color(code)
        minigl.glBegin(minigl.GL_QUADS)
        for i in range(facets):
            a0 = 2.0 * math.pi * float(i) / float(facets)
            a1 = 2.0 * math.pi * float(i + 1) / float(facets)
            c0, s0 = math.cos(a0), math.sin(a0)
            c1, s1 = math.cos(a1), math.sin(a1)
            minigl.glVertex3f(x+r0*c0, y+r0*s0, z0)
            minigl.glVertex3f(x+r0*c1, y+r0*s1, z0)
            minigl.glVertex3f(x+r1*c1, y+r1*s1, z1)
            minigl.glVertex3f(x+r1*c0, y+r1*s0, z1)
        minigl.glEnd()
        for face_key, radius, z, direction in (("zmin", r0, z0, -1.0), ("zmax", r1, z1, 1.0)):
            if radius <= 0.000001:
                continue
            code = next_code[0]
            next_code[0] += 1
            mapping[code] = (str(obj.get("id", "")), face_key)
            self._fixture_pick_set_color(code)
            minigl.glBegin(minigl.GL_TRIANGLE_FAN)
            minigl.glVertex3f(x, y, z)
            for i in range(facets + 1):
                a = direction * 2.0 * math.pi * float(i) / float(facets)
                minigl.glVertex3f(x+radius*math.cos(a), y+radius*math.sin(a), z)
            minigl.glEnd()

    def _fixture_pick_draw_hex(self, obj, next_code, mapping):
        x = float(obj["x"])
        y = float(obj["y"])
        z0 = float(obj["zmin"])
        z1 = float(obj["zmax"])
        apothem = float(obj["across_flats"]) * 0.5
        radius = apothem / math.cos(math.pi / 6.0)
        rotation = math.radians(float(obj.get("rotation_degrees", 0.0)))
        bottom = []
        top = []
        for i in range(6):
            a = rotation + (math.pi / 6.0) + (i * math.pi / 3.0)
            bottom.append((x+radius*math.cos(a), y+radius*math.sin(a), z0))
            top.append((x+radius*math.cos(a), y+radius*math.sin(a), z1))
        code = next_code[0]
        next_code[0] += 1
        mapping[code] = (str(obj.get("id", "")), "side")
        self._fixture_pick_set_color(code)
        minigl.glBegin(minigl.GL_QUADS)
        for i in range(6):
            j = (i + 1) % 6
            for vertex in (bottom[i], bottom[j], top[j], top[i]):
                minigl.glVertex3f(*vertex)
        minigl.glEnd()
        for face_key, ring in (("zmin", list(reversed(bottom))), ("zmax", top)):
            code = next_code[0]
            next_code[0] += 1
            mapping[code] = (str(obj.get("id", "")), face_key)
            self._fixture_pick_set_color(code)
            minigl.glBegin(minigl.GL_POLYGON)
            for vertex in ring:
                minigl.glVertex3f(*vertex)
            minigl.glEnd()

    def _fixture_pick_draw_object(self, obj, next_code, mapping):
        typ = str(obj.get("type", "") or "")
        if typ == "box":
            self._fixture_pick_draw_box(obj, next_code, mapping)
        elif typ in ("cylinder", "frustum"):
            self._fixture_pick_draw_round(obj, next_code, mapping)
        elif typ == "hex_prism":
            self._fixture_pick_draw_hex(obj, next_code, mapping)

    def _fixture_pick_active_context(self, gremlin_self, x, y_inv):
        if not self._fixture_objects or not self._fixture_pick_allowed(gremlin_self):
            return None
        viewport = minigl.glGetIntegerv(minigl.GL_VIEWPORT)
        y = int(viewport[3] - y_inv)
        x = int(x)
        mapping = {}
        next_code = [1]
        minigl.glMatrixMode(minigl.GL_MODELVIEW)
        minigl.glClearColor(0.0, 0.0, 0.0, 0.0)
        minigl.glClear(minigl.GL_COLOR_BUFFER_BIT | minigl.GL_DEPTH_BUFFER_BIT)
        try:
            minigl.glForceColorPassthrough()
        except Exception:
            pass
        try:
            minigl.glDisable(minigl.GL_DITHER)
        except Exception:
            pass
        minigl.glDisable(minigl.GL_LIGHTING)
        minigl.glDisable(minigl.GL_BLEND)
        minigl.glDisable(minigl.GL_CULL_FACE)
        minigl.glEnable(minigl.GL_DEPTH_TEST)

        for obj in self._fixture_objects:
            if self._fixture_is_exclusion(obj):
                continue
            if not bool(obj.get("visible", True)) or not self._fixture_group_visible(obj):
                continue
            self._fixture_pick_draw_object(obj, next_code, mapping)

        # Match the visible exclusion overlay policy: exclusions are deliberately
        # drawn on top of fixture solids, so a click on a visible exclusion picks it.
        minigl.glDisable(minigl.GL_DEPTH_TEST)
        for obj in self._fixture_objects:
            if not self._fixture_is_exclusion(obj):
                continue
            if not bool(obj.get("visible", True)) or not self._fixture_group_visible(obj):
                continue
            self._fixture_pick_draw_object(obj, next_code, mapping)
        minigl.glEnable(minigl.GL_DEPTH_TEST)
        minigl.glFlush()

        radius = int(FIXTURE_PICK_SEARCH_RADIUS)
        offsets = []
        for dy in range(-radius, radius + 1):
            for dx in range(-radius, radius + 1):
                offsets.append((max(abs(dx), abs(dy)), abs(dx) + abs(dy), dx, dy))
        offsets.sort()
        result = None
        for unused_ring, unused_distance, dx, dy in offsets:
            px = x + dx
            py = y + dy
            if px < viewport[0] or py < viewport[1] or px >= viewport[0] + viewport[2] or py >= viewport[1] + viewport[3]:
                continue
            raw = minigl.glReadPixels(px, py, 1, 1, minigl.GL_RGB, minigl.GL_UNSIGNED_BYTE)
            code = self._fixture_pick_pixel_code(raw)
            if code in mapping:
                object_id, face_key = mapping[code]
                result = {"object_id": object_id, "face": face_key}
                break
        minigl.glEnable(minigl.GL_DEPTH_TEST)
        minigl.glEnable(minigl.GL_CULL_FACE)
        minigl.glDisable(minigl.GL_BLEND)
        try:
            minigl.glEnable(minigl.GL_DITHER)
        except Exception:
            pass
        return result

    def _fixture_pick_at(self, gremlin_self, x, y):
        activated = False
        try:
            activated = bool(gremlin_self.activate())
            if not activated:
                return None
            return self._fixture_pick_active_context(gremlin_self, x, y)
        except Exception as e:
            self.debug("Fixture pick failed: %s" % str(e))
            return None
        finally:
            if activated:
                try:
                    gremlin_self.deactivate()
                except Exception:
                    pass

    def _fixture_selected_faces(self, object_id=None):
        result = []
        target = None if object_id is None else str(object_id or "")
        for selection in (self._fixture_selection, self._fixture_secondary_selection):
            if not isinstance(selection, dict):
                continue
            if target is not None and str(selection.get("object_id", "") or "") != target:
                continue
            face = str(selection.get("face", "") or "")
            if face and face not in result:
                result.append(face)
        return result

    def _fixture_box_pair_axis(self, faces):
        face_set = set(str(face or "") for face in (faces or []))
        if face_set == set(("xmin", "xmax")):
            return "x"
        if face_set == set(("ymin", "ymax")):
            return "y"
        return None

    def _fixture_set_selection(self, selection, redraw=True, additive=False):
        if not isinstance(selection, dict):
            self._fixture_selection = None
            self._fixture_secondary_selection = None
        elif additive and isinstance(self._fixture_selection, dict):
            primary_id = str(self._fixture_selection.get("object_id", "") or "")
            new_id = str(selection.get("object_id", "") or "")
            obj = self._fixture_object_by_id(new_id)
            if primary_id == new_id and isinstance(obj, dict) and str(obj.get("type", "") or "") == "box":
                primary_face = str(self._fixture_selection.get("face", "") or "")
                new_face = str(selection.get("face", "") or "")
                if new_face == primary_face:
                    pass
                elif (isinstance(self._fixture_secondary_selection, dict) and
                      str(self._fixture_secondary_selection.get("object_id", "") or "") == new_id and
                      str(self._fixture_secondary_selection.get("face", "") or "") == new_face):
                    self._fixture_secondary_selection = None
                else:
                    self._fixture_secondary_selection = dict(selection)
            else:
                self._fixture_selection = dict(selection)
                self._fixture_secondary_selection = None
        else:
            self._fixture_selection = dict(selection)
            self._fixture_secondary_selection = None
        if redraw and self.gremlin is not None:
            try:
                self.gremlin.queue_draw()
            except Exception:
                pass

    def _fixture_pick_pointer(self, gremlin_self):
        try:
            window = getattr(gremlin_self, "window", None)
            if window is None and hasattr(gremlin_self, "get_window"):
                window = gremlin_self.get_window()
            if window is None:
                return None
            pointer = window.get_pointer()
            if not pointer or len(pointer) < 2:
                return None
            return self._fixture_pick_at(gremlin_self, pointer[0], pointer[1])
        except Exception:
            return None

    def _install_fixture_select_hook(self):
        if self.gremlin is None or self._fixture_select_hook_installed:
            return bool(self._fixture_select_hook_installed)
        gremlin = self.gremlin
        if bool(getattr(gremlin, "_tormachtips_fixture_select_hook_installed", False)):
            self._fixture_select_hook_installed = True
            return True
        base_select = gremlin.select
        gremlin._tormachtips_fixture_select_base = base_select
        outer = self

        def fixture_select(gremlin_self, x, y):
            if not outer._fixture_pick_allowed(gremlin_self):
                return base_select(x, y)
            activated = False
            try:
                activated = bool(gremlin_self.activate())
                if activated:
                    hit = outer._fixture_pick_active_context(gremlin_self, x, y)
                    if hit is not None:
                        additive = False
                        try:
                            window = gremlin_self.get_window()
                            pointer = window.get_pointer() if window is not None else None
                            mask = int(pointer[2]) if pointer is not None and len(pointer) >= 3 else 0
                            additive = bool(mask & (gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK))
                        except Exception:
                            additive = False
                        outer._fixture_set_selection(hit, redraw=True, additive=additive)
                        return None
                    outer._fixture_set_selection(None, redraw=True)
                    return base_select(x, y)
            except Exception as e:
                outer.debug("Fixture click selection failed: %s" % str(e))
            finally:
                if activated:
                    try:
                        gremlin_self.deactivate()
                    except Exception:
                        pass
            return base_select(x, y)

        try:
            gremlin.select = types.MethodType(fixture_select, gremlin, gremlin.__class__)
        except TypeError:
            gremlin.select = types.MethodType(fixture_select, gremlin)
        gremlin._tormachtips_fixture_select_hook_installed = True
        self._fixture_select_hook_installed = True
        self.debug("Installed event-driven fixture face picking on Gremlin select().")
        return True

    def _fixture_selection_current_text(self, obj, face_key):
        units = self._fixture_display_units()
        scale = self._fixture_pathpilot_scale()
        if face_key in ("xmin", "xmax", "ymin", "ymax", "zmin", "zmax") and face_key in obj:
            return "Current %s: %.4f %s" % (self._fixture_face_label(face_key), float(obj[face_key]) * scale, units)
        return self._fixture_face_label(face_key)

    def _fixture_menu_add_linear_edit(self, menu, obj, field_name, label, current_inches, g53_axis=None, sensitive=True):
        item = gtk.MenuItem(label + "...")
        item.set_sensitive(bool(sensitive))
        item.connect(
            "activate",
            lambda unused, oid=str(obj.get("id", "")), field=field_name, text=label, value=float(current_inches):
                self._fixture_edit_linear_property(oid, field, text, value))
        menu.append(item)
        item.show()
        if g53_axis:
            try:
                current_g53 = self._fixture_current_g53_inches(g53_axis)
                display = self._fixture_display_linear(current_g53)
                g53_item = gtk.MenuItem(
                    "Set to Current G53 %s   %.4f %s" % (
                        str(g53_axis).upper(), display, self._fixture_display_units()))
                g53_item.set_sensitive(bool(sensitive))
                g53_item.connect(
                    "activate",
                    lambda unused, oid=str(obj.get("id", "")), field=field_name, axis=g53_axis:
                        self._fixture_set_property_to_current_g53(oid, field, axis))
            except Exception:
                g53_item = gtk.MenuItem("Set to Current G53 %s" % str(g53_axis).upper())
                g53_item.set_sensitive(False)
            menu.append(g53_item)
            g53_item.show()

    def _fixture_menu_add_xy_center(self, menu, obj, sensitive=True):
        item = gtk.MenuItem("Center X/Y...")
        item.set_sensitive(bool(sensitive))
        item.connect(
            "activate",
            lambda unused, oid=str(obj.get("id", "")), x=float(obj["x"]), y=float(obj["y"]):
                self._fixture_edit_center_xy(oid, x, y))
        menu.append(item)
        item.show()
        try:
            gx = self._fixture_current_g53_inches("x")
            gy = self._fixture_current_g53_inches("y")
            g53_item = gtk.MenuItem(
                "Set Center X/Y to Current G53   X%.4f  Y%.4f %s" % (
                    self._fixture_display_linear(gx),
                    self._fixture_display_linear(gy),
                    self._fixture_display_units()))
            g53_item.set_sensitive(bool(sensitive))
            g53_item.connect(
                "activate",
                lambda unused, oid=str(obj.get("id", "")):
                    self._fixture_set_center_xy_to_current_g53(oid))
        except Exception:
            g53_item = gtk.MenuItem("Set Center X/Y to Current G53")
            g53_item.set_sensitive(False)
        menu.append(g53_item)
        g53_item.show()

    def _fixture_menu_add_box_center(self, menu, obj, axis, sensitive=True):
        low_key = str(axis).lower() + "min"
        high_key = str(axis).lower() + "max"
        center_inches = (float(obj[low_key]) + float(obj[high_key])) * 0.5
        item = gtk.MenuItem("Feature Center %s..." % str(axis).upper())
        item.set_sensitive(bool(sensitive))
        item.connect(
            "activate",
            lambda unused, oid=str(obj.get("id", "")), a=str(axis).lower(), value=center_inches:
                self._fixture_edit_box_center(oid, a, value))
        menu.append(item)
        item.show()
        try:
            current_g53 = self._fixture_current_g53_inches(axis)
            g53_item = gtk.MenuItem(
                "Set Feature Center %s to Current G53 %s   %.4f %s" % (
                    str(axis).upper(), str(axis).upper(),
                    self._fixture_display_linear(current_g53),
                    self._fixture_display_units()))
            g53_item.set_sensitive(bool(sensitive))
            g53_item.connect(
                "activate",
                lambda unused, oid=str(obj.get("id", "")), a=str(axis).lower():
                    self._fixture_set_box_center_to_current_g53(oid, a))
        except Exception:
            g53_item = gtk.MenuItem("Set Feature Center %s to Current G53 %s" % (str(axis).upper(), str(axis).upper()))
            g53_item.set_sensitive(False)
        menu.append(g53_item)
        g53_item.show()

    def _fixture_menu_add_angle_edit(self, menu, obj, field_name, label, current_degrees, sensitive=True):
        item = gtk.MenuItem(label + "...")
        item.set_sensitive(bool(sensitive))
        item.connect(
            "activate",
            lambda unused, oid=str(obj.get("id", "")), field=field_name, text=label, value=float(current_degrees):
                self._fixture_edit_angle_property(oid, field, text, value))
        menu.append(item)
        item.show()

    def _build_fixture_selection_menu_item(self, gremlin_self):
        # Right-click picking is performed only while the menu is being opened;
        # there is no hover work and no new timer.
        hit = self._fixture_pick_pointer(gremlin_self)
        if hit is None:
            return None

        # Only a real hit from THIS right-click enables the compact fixture
        # context. The existing highlight may remain visible, but it does not
        # determine what menu is shown.
        self._fixture_context_menu_on_fixture = True

        existing_faces = self._fixture_selected_faces(hit.get("object_id"))
        if str(hit.get("face", "") or "") not in existing_faces:
            self._fixture_set_selection(hit, redraw=True)
        obj = self._fixture_object_by_id(hit.get("object_id"))
        if obj is None:
            return None
        face_key = str(hit.get("face", "") or "")
        selected_faces = self._fixture_selected_faces(obj.get("id"))
        pair_axis = self._fixture_box_pair_axis(selected_faces) if str(obj.get("type", "") or "") == "box" else None
        if pair_axis:
            face_text = " + ".join(self._fixture_face_label(face) for face in selected_faces)
        else:
            face_text = self._fixture_face_label(face_key)
        title = "Fixture: %s - %s" % (
            str(obj.get("name", obj.get("id", "Fixture")) or "Fixture"),
            face_text)
        root_item = gtk.MenuItem(title)
        submenu = gtk.Menu()
        root_item.set_submenu(submenu)
        editable = self._fixture_edit_allowed()

        if pair_axis:
            low_key = pair_axis + "min"
            high_key = pair_axis + "max"
            center_value = (float(obj[low_key]) + float(obj[high_key])) * 0.5
            current_item = gtk.MenuItem(
                "Current Feature Center %s: %.4f %s" % (
                    pair_axis.upper(),
                    self._fixture_display_linear(center_value),
                    self._fixture_display_units()))
        else:
            current_item = gtk.MenuItem(self._fixture_selection_current_text(obj, face_key))
        current_item.set_sensitive(False)
        submenu.append(current_item)
        current_item.show()
        separator = gtk.SeparatorMenuItem()
        submenu.append(separator)
        separator.show()

        typ = str(obj.get("type", "") or "")
        if typ == "box" and pair_axis:
            self._fixture_menu_add_box_center(submenu, obj, pair_axis, editable)
        elif typ == "box" and face_key in ("xmin", "xmax", "ymin", "ymax", "zmin", "zmax"):
            axis = self._fixture_face_axis(face_key)
            self._fixture_menu_add_linear_edit(
                submenu, obj, face_key,
                "Set %s Value" % self._fixture_face_label(face_key),
                obj[face_key], axis, editable)
            if face_key in ("xmin", "xmax", "ymin", "ymax"):
                hint = gtk.MenuItem("Ctrl/Shift-click opposite face for Feature Center")
                hint.set_sensitive(False)
                submenu.append(hint)
                hint.show()
        elif typ in ("cylinder", "frustum"):
            if face_key in ("zmin", "zmax"):
                self._fixture_menu_add_linear_edit(
                    submenu, obj, face_key,
                    "Set %s Value" % self._fixture_face_label(face_key),
                    obj[face_key], "z", editable)
            else:
                self._fixture_menu_add_xy_center(submenu, obj, editable)
                self._fixture_menu_add_linear_edit(submenu, obj, "x", "Center X", obj["x"], "x", editable)
                self._fixture_menu_add_linear_edit(submenu, obj, "y", "Center Y", obj["y"], "y", editable)
                if typ == "cylinder":
                    self._fixture_menu_add_linear_edit(submenu, obj, "diameter", "Diameter", obj["bottom_diameter"], None, editable)
                else:
                    self._fixture_menu_add_linear_edit(submenu, obj, "bottom_diameter", "Bottom Diameter", obj["bottom_diameter"], None, editable)
                    self._fixture_menu_add_linear_edit(submenu, obj, "top_diameter", "Top Diameter", obj["top_diameter"], None, editable)
        elif typ == "hex_prism":
            if face_key in ("zmin", "zmax"):
                self._fixture_menu_add_linear_edit(
                    submenu, obj, face_key,
                    "Set %s Value" % self._fixture_face_label(face_key),
                    obj[face_key], "z", editable)
            else:
                self._fixture_menu_add_xy_center(submenu, obj, editable)
                self._fixture_menu_add_linear_edit(submenu, obj, "x", "Center X", obj["x"], "x", editable)
                self._fixture_menu_add_linear_edit(submenu, obj, "y", "Center Y", obj["y"], "y", editable)
                self._fixture_menu_add_linear_edit(submenu, obj, "across_flats", "Across Flats", obj["across_flats"], None, editable)
                self._fixture_menu_add_angle_edit(submenu, obj, "rotation_degrees", "Rotation", obj.get("rotation_degrees", 0.0), editable)

        separator = gtk.SeparatorMenuItem()
        submenu.append(separator)
        separator.show()
        visible_item = gtk.CheckMenuItem("Visible")
        visible_item.set_active(bool(obj.get("visible", True)))
        visible_item.set_sensitive(bool(editable))
        visible_item.connect("toggled", self._fixture_on_visibility_toggled, str(obj.get("id", "")))
        submenu.append(visible_item)
        visible_item.show()

        builder_item = gtk.MenuItem("Open Fixture Builder...")
        builder_item.connect("activate", self.launch_fixture_builder)
        submenu.append(builder_item)
        builder_item.show()

        clear_item = gtk.MenuItem("Clear Fixture Highlight")
        clear_item.connect("activate", lambda unused: self._fixture_set_selection(None, redraw=True))
        submenu.append(clear_item)
        clear_item.show()

        if not editable:
            note = gtk.MenuItem("Editing disabled while PathPilot is not idle")
            note.set_sensitive(False)
            submenu.append(note)
            note.show()
        return root_item

    def _draw_fixture_selection_overlay(self):
        selection = self._fixture_selection
        if not isinstance(selection, dict):
            return
        obj = self._fixture_object_by_id(selection.get("object_id"))
        if obj is None or not bool(obj.get("visible", True)) or not self._fixture_group_visible(obj):
            return
        face_key = str(selection.get("face", "") or "")
        try:
            minigl.glEnable(minigl.GL_BLEND)
            minigl.glBlendFunc(minigl.GL_SRC_ALPHA, minigl.GL_ONE_MINUS_SRC_ALPHA)
            minigl.glDisable(minigl.GL_LIGHTING)
            minigl.glDisable(minigl.GL_CULL_FACE)
            minigl.glEnable(minigl.GL_DEPTH_TEST)
            try:
                minigl.glDepthFunc(minigl.GL_LEQUAL)
            except Exception:
                pass
            minigl.glColor4f(
                FIXTURE_SELECTED_RED,
                FIXTURE_SELECTED_GREEN,
                FIXTURE_SELECTED_BLUE,
                FIXTURE_SELECTED_ALPHA)
            typ = str(obj.get("type", "") or "")
            outline_sets = []
            if typ == "box":
                x0, x1 = float(obj["xmin"]), float(obj["xmax"])
                y0, y1 = float(obj["ymin"]), float(obj["ymax"])
                z0, z1 = float(obj["zmin"]), float(obj["zmax"])
                faces = {
                    "zmin": ((x0,y0,z0),(x1,y0,z0),(x1,y1,z0),(x0,y1,z0)),
                    "zmax": ((x0,y0,z1),(x0,y1,z1),(x1,y1,z1),(x1,y0,z1)),
                    "xmin": ((x0,y0,z0),(x0,y1,z0),(x0,y1,z1),(x0,y0,z1)),
                    "xmax": ((x1,y0,z0),(x1,y0,z1),(x1,y1,z1),(x1,y1,z0)),
                    "ymin": ((x0,y0,z0),(x0,y0,z1),(x1,y0,z1),(x1,y0,z0)),
                    "ymax": ((x0,y1,z0),(x1,y1,z0),(x1,y1,z1),(x0,y1,z1)),
                }
                vertices = faces.get(face_key)
                if vertices:
                    minigl.glBegin(minigl.GL_QUADS)
                    for vertex in vertices:
                        minigl.glVertex3f(*vertex)
                    minigl.glEnd()
                    outline_sets.append(vertices)
            elif typ in ("cylinder", "frustum"):
                x, y = float(obj["x"]), float(obj["y"])
                z0, z1 = float(obj["zmin"]), float(obj["zmax"])
                r0 = float(obj["bottom_diameter"]) * 0.5
                r1 = float(obj["top_diameter"]) * 0.5
                facets = max(8, min(128, int(obj.get("facets", 32))))
                if face_key == "side":
                    minigl.glBegin(minigl.GL_QUADS)
                    for i in range(facets):
                        a0 = 2.0*math.pi*float(i)/float(facets)
                        a1 = 2.0*math.pi*float(i+1)/float(facets)
                        c0, s0 = math.cos(a0), math.sin(a0)
                        c1, s1 = math.cos(a1), math.sin(a1)
                        minigl.glVertex3f(x+r0*c0,y+r0*s0,z0)
                        minigl.glVertex3f(x+r0*c1,y+r0*s1,z0)
                        minigl.glVertex3f(x+r1*c1,y+r1*s1,z1)
                        minigl.glVertex3f(x+r1*c0,y+r1*s0,z1)
                    minigl.glEnd()
                elif face_key in ("zmin", "zmax"):
                    radius = r0 if face_key == "zmin" else r1
                    z = z0 if face_key == "zmin" else z1
                    direction = -1.0 if face_key == "zmin" else 1.0
                    minigl.glBegin(minigl.GL_TRIANGLE_FAN)
                    minigl.glVertex3f(x,y,z)
                    ring = []
                    for i in range(facets+1):
                        a = direction*2.0*math.pi*float(i)/float(facets)
                        point = (x+radius*math.cos(a),y+radius*math.sin(a),z)
                        minigl.glVertex3f(*point)
                        if i < facets:
                            ring.append(point)
                    minigl.glEnd()
                    outline_sets.append(tuple(ring))
            elif typ == "hex_prism":
                x, y = float(obj["x"]), float(obj["y"])
                z0, z1 = float(obj["zmin"]), float(obj["zmax"])
                apothem = float(obj["across_flats"])*0.5
                radius = apothem/math.cos(math.pi/6.0)
                rotation = math.radians(float(obj.get("rotation_degrees",0.0)))
                bottom = []
                top = []
                for i in range(6):
                    a = rotation+(math.pi/6.0)+(i*math.pi/3.0)
                    bottom.append((x+radius*math.cos(a),y+radius*math.sin(a),z0))
                    top.append((x+radius*math.cos(a),y+radius*math.sin(a),z1))
                if face_key == "side":
                    minigl.glBegin(minigl.GL_QUADS)
                    for i in range(6):
                        j=(i+1)%6
                        for vertex in (bottom[i],bottom[j],top[j],top[i]):
                            minigl.glVertex3f(*vertex)
                    minigl.glEnd()
                elif face_key in ("zmin", "zmax"):
                    ring = bottom if face_key == "zmin" else top
                    minigl.glBegin(minigl.GL_POLYGON)
                    for vertex in (list(reversed(ring)) if face_key == "zmin" else ring):
                        minigl.glVertex3f(*vertex)
                    minigl.glEnd()
                    outline_sets.append(tuple(ring))

            minigl.glColor4f(1.0, 1.0, 1.0, 1.0)
            minigl.glLineWidth(float(FIXTURE_SELECTED_EDGE_WIDTH))
            for vertices in outline_sets:
                minigl.glBegin(minigl.GL_LINE_LOOP)
                for vertex in vertices:
                    minigl.glVertex3f(*vertex)
                minigl.glEnd()
            if face_key == "side" and typ in ("cylinder", "frustum"):
                for radius, z in ((r0, z0), (r1, z1)):
                    if radius <= 0.000001:
                        continue
                    minigl.glBegin(minigl.GL_LINE_LOOP)
                    for i in range(facets):
                        a=2.0*math.pi*float(i)/float(facets)
                        minigl.glVertex3f(x+radius*math.cos(a),y+radius*math.sin(a),z)
                    minigl.glEnd()
            elif face_key == "side" and typ == "hex_prism":
                for ring in (bottom, top):
                    minigl.glBegin(minigl.GL_LINE_LOOP)
                    for vertex in ring:
                        minigl.glVertex3f(*vertex)
                    minigl.glEnd()
            minigl.glLineWidth(1.0)
            try:
                minigl.glDepthFunc(minigl.GL_LESS)
            except Exception:
                pass
        except Exception as e:
            self.debug("Fixture selection highlight failed: %s" % str(e))
        self._draw_fixture_secondary_box_overlay()

    def _draw_fixture_secondary_box_overlay(self):
        selection = self._fixture_secondary_selection
        if not isinstance(selection, dict):
            return
        obj = self._fixture_object_by_id(selection.get("object_id"))
        if obj is None or str(obj.get("type", "") or "") != "box":
            return
        if not bool(obj.get("visible", True)) or not self._fixture_group_visible(obj):
            return
        face_key = str(selection.get("face", "") or "")
        x0, x1 = float(obj["xmin"]), float(obj["xmax"])
        y0, y1 = float(obj["ymin"]), float(obj["ymax"])
        z0, z1 = float(obj["zmin"]), float(obj["zmax"])
        faces = {
            "zmin": ((x0,y0,z0),(x1,y0,z0),(x1,y1,z0),(x0,y1,z0)),
            "zmax": ((x0,y0,z1),(x0,y1,z1),(x1,y1,z1),(x1,y0,z1)),
            "xmin": ((x0,y0,z0),(x0,y1,z0),(x0,y1,z1),(x0,y0,z1)),
            "xmax": ((x1,y0,z0),(x1,y0,z1),(x1,y1,z1),(x1,y1,z0)),
            "ymin": ((x0,y0,z0),(x0,y0,z1),(x1,y0,z1),(x1,y0,z0)),
            "ymax": ((x0,y1,z0),(x1,y1,z0),(x1,y1,z1),(x0,y1,z1)),
        }
        vertices = faces.get(face_key)
        if not vertices:
            return
        try:
            minigl.glEnable(minigl.GL_BLEND)
            minigl.glBlendFunc(minigl.GL_SRC_ALPHA, minigl.GL_ONE_MINUS_SRC_ALPHA)
            minigl.glDisable(minigl.GL_LIGHTING)
            minigl.glDisable(minigl.GL_CULL_FACE)
            minigl.glEnable(minigl.GL_DEPTH_TEST)
            try:
                minigl.glDepthFunc(minigl.GL_LEQUAL)
            except Exception:
                pass
            minigl.glColor4f(
                FIXTURE_SELECTED_RED, FIXTURE_SELECTED_GREEN,
                FIXTURE_SELECTED_BLUE, FIXTURE_SELECTED_ALPHA)
            minigl.glBegin(minigl.GL_QUADS)
            for vertex in vertices:
                minigl.glVertex3f(*vertex)
            minigl.glEnd()
            minigl.glColor4f(1.0, 1.0, 1.0, 1.0)
            minigl.glLineWidth(float(FIXTURE_SELECTED_EDGE_WIDTH))
            minigl.glBegin(minigl.GL_LINE_LOOP)
            for vertex in vertices:
                minigl.glVertex3f(*vertex)
            minigl.glEnd()
            minigl.glLineWidth(1.0)
            try:
                minigl.glDepthFunc(minigl.GL_LESS)
            except Exception:
                pass
        except Exception as e:
            self.debug("Secondary fixture highlight failed: %s" % str(e))

    def _fixture_begin_color(self, obj):
        if self._fixture_is_exclusion(obj):
            alpha = min(float(obj.get("opacity", FIXTURE_EXCLUSION_FACE_ALPHA)), FIXTURE_EXCLUSION_FACE_ALPHA)
            minigl.glColor4f(
                FIXTURE_EXCLUSION_FACE_RED, FIXTURE_EXCLUSION_FACE_GREEN,
                FIXTURE_EXCLUSION_FACE_BLUE, max(0.03, alpha))
            return
        color=obj.get("color",(FIXTURE_FACE_RED,FIXTURE_FACE_GREEN,FIXTURE_FACE_BLUE))
        minigl.glColor4f(float(color[0]),float(color[1]),float(color[2]),float(obj.get("opacity",FIXTURE_FACE_ALPHA)))

    def _fixture_begin_edges(self, obj):
        if self._fixture_is_exclusion(obj):
            minigl.glColor3f(
                FIXTURE_EXCLUSION_EDGE_RED, FIXTURE_EXCLUSION_EDGE_GREEN,
                FIXTURE_EXCLUSION_EDGE_BLUE)
            minigl.glLineWidth(float(FIXTURE_EXCLUSION_EDGE_WIDTH))
        else:
            minigl.glColor3f(FIXTURE_EDGE_RED,FIXTURE_EDGE_GREEN,FIXTURE_EDGE_BLUE)
            minigl.glLineWidth(float(FIXTURE_EDGE_WIDTH))

    def _draw_fixture_box(self, obj):
        x0,x1=float(obj["xmin"]),float(obj["xmax"]); y0,y1=float(obj["ymin"]),float(obj["ymax"]); z0,z1=float(obj["zmin"]),float(obj["zmax"])
        self._fixture_begin_color(obj); minigl.glBegin(minigl.GL_QUADS)
        faces=(((x0,y0,z0),(x1,y0,z0),(x1,y1,z0),(x0,y1,z0)),((x0,y0,z1),(x0,y1,z1),(x1,y1,z1),(x1,y0,z1)),((x0,y0,z0),(x0,y1,z0),(x0,y1,z1),(x0,y0,z1)),((x1,y0,z0),(x1,y0,z1),(x1,y1,z1),(x1,y1,z0)),((x0,y0,z0),(x0,y0,z1),(x1,y0,z1),(x1,y0,z0)),((x0,y1,z0),(x1,y1,z0),(x1,y1,z1),(x0,y1,z1)))
        for face in faces:
            for v in face: minigl.glVertex3f(*v)
        minigl.glEnd(); self._fixture_begin_edges(obj); minigl.glBegin(minigl.GL_LINES)
        edges=((x0,y0,z0,x1,y0,z0),(x1,y0,z0,x1,y1,z0),(x1,y1,z0,x0,y1,z0),(x0,y1,z0,x0,y0,z0),(x0,y0,z1,x1,y0,z1),(x1,y0,z1,x1,y1,z1),(x1,y1,z1,x0,y1,z1),(x0,y1,z1,x0,y0,z1),(x0,y0,z0,x0,y0,z1),(x1,y0,z0,x1,y0,z1),(x1,y1,z0,x1,y1,z1),(x0,y1,z0,x0,y1,z1))
        for e in edges: minigl.glVertex3f(e[0],e[1],e[2]); minigl.glVertex3f(e[3],e[4],e[5])
        minigl.glEnd(); minigl.glLineWidth(1.0)

    def _draw_fixture_round(self, obj):
        x,y=float(obj["x"]),float(obj["y"]); z0,z1=float(obj["zmin"]),float(obj["zmax"]); r0=float(obj["bottom_diameter"])*0.5; r1=float(obj["top_diameter"])*0.5; facets=int(obj.get("facets",32)); facets=max(8,min(128,facets))
        self._fixture_begin_color(obj); minigl.glBegin(minigl.GL_QUADS)
        for i in range(facets):
            a0=2.0*math.pi*float(i)/float(facets); a1=2.0*math.pi*float(i+1)/float(facets); c0,s0=math.cos(a0),math.sin(a0); c1,s1=math.cos(a1),math.sin(a1)
            minigl.glVertex3f(x+r0*c0,y+r0*s0,z0); minigl.glVertex3f(x+r0*c1,y+r0*s1,z0); minigl.glVertex3f(x+r1*c1,y+r1*s1,z1); minigl.glVertex3f(x+r1*c0,y+r1*s0,z1)
        minigl.glEnd()
        if r0>0.000001:
            minigl.glBegin(minigl.GL_TRIANGLE_FAN); minigl.glVertex3f(x,y,z0)
            for i in range(facets+1):
                a=-2.0*math.pi*float(i)/float(facets); minigl.glVertex3f(x+r0*math.cos(a),y+r0*math.sin(a),z0)
            minigl.glEnd()
        if r1>0.000001:
            minigl.glBegin(minigl.GL_TRIANGLE_FAN); minigl.glVertex3f(x,y,z1)
            for i in range(facets+1):
                a=2.0*math.pi*float(i)/float(facets); minigl.glVertex3f(x+r1*math.cos(a),y+r1*math.sin(a),z1)
            minigl.glEnd()
        self._fixture_begin_edges(obj)
        for radius,z in ((r0,z0),(r1,z1)):
            if radius<=0.000001: continue
            minigl.glBegin(minigl.GL_LINE_LOOP)
            for i in range(facets):
                a=2.0*math.pi*float(i)/float(facets); minigl.glVertex3f(x+radius*math.cos(a),y+radius*math.sin(a),z)
            minigl.glEnd()
        minigl.glBegin(minigl.GL_LINES); stride=max(1,facets//8)
        for i in range(0,facets,stride):
            a=2.0*math.pi*float(i)/float(facets); c,s=math.cos(a),math.sin(a); minigl.glVertex3f(x+r0*c,y+r0*s,z0); minigl.glVertex3f(x+r1*c,y+r1*s,z1)
        minigl.glEnd(); minigl.glLineWidth(1.0)

    def _draw_fixture_hex(self, obj):
        x=float(obj["x"]); y=float(obj["y"]); z0=float(obj["zmin"]); z1=float(obj["zmax"])
        apothem=float(obj["across_flats"])*0.5
        radius=apothem/math.cos(math.pi/6.0)
        rotation=math.radians(float(obj.get("rotation_degrees",0.0)))
        bottom=[]; top=[]
        for i in range(6):
            a=rotation+(math.pi/6.0)+(i*math.pi/3.0)
            c,sn=math.cos(a),math.sin(a)
            bottom.append((x+radius*c,y+radius*sn,z0))
            top.append((x+radius*c,y+radius*sn,z1))
        self._fixture_begin_color(obj); minigl.glBegin(minigl.GL_QUADS)
        for i in range(6):
            j=(i+1)%6
            for v in (bottom[i],bottom[j],top[j],top[i]): minigl.glVertex3f(*v)
        minigl.glEnd()
        minigl.glBegin(minigl.GL_POLYGON)
        for v in reversed(bottom): minigl.glVertex3f(*v)
        minigl.glEnd()
        minigl.glBegin(minigl.GL_POLYGON)
        for v in top: minigl.glVertex3f(*v)
        minigl.glEnd()
        self._fixture_begin_edges(obj)
        minigl.glBegin(minigl.GL_LINES)
        for ring in (bottom,top):
            for i in range(6):
                j=(i+1)%6; a=ring[i]; b=ring[j]; minigl.glVertex3f(*a); minigl.glVertex3f(*b)
        for i in range(6):
            minigl.glVertex3f(*bottom[i]); minigl.glVertex3f(*top[i])
        minigl.glEnd(); minigl.glLineWidth(1.0)

    def _draw_fixture_geometry(self, gremlin_self):
        if not self._fixture_objects: return
        try:
            minigl.glDisable(minigl.GL_LIGHTING); minigl.glDisable(minigl.GL_CULL_FACE); minigl.glEnable(minigl.GL_BLEND); minigl.glBlendFunc(minigl.GL_SRC_ALPHA,minigl.GL_ONE_MINUS_SRC_ALPHA)

            # Draw physical fixture solids normally.
            for obj in self._fixture_objects:
                if self._fixture_is_exclusion(obj): continue
                if not bool(obj.get("visible",True)) or not self._fixture_group_visible(obj): continue
                if obj.get("type")=="box": self._draw_fixture_box(obj)
                elif obj.get("type") in ("cylinder","frustum"): self._draw_fixture_round(obj)
                elif obj.get("type")=="hex_prism": self._draw_fixture_hex(obj)

            # Excluded zones are configuration/clearance volumes rather than material.
            # Draw them on top so a bore hidden inside an opaque fixture is still obvious.
            minigl.glDisable(minigl.GL_DEPTH_TEST)
            for obj in self._fixture_objects:
                if not self._fixture_is_exclusion(obj): continue
                if not bool(obj.get("visible",True)) or not self._fixture_group_visible(obj): continue
                if obj.get("type")=="box": self._draw_fixture_box(obj)
                elif obj.get("type") in ("cylinder","frustum"): self._draw_fixture_round(obj)
                elif obj.get("type")=="hex_prism": self._draw_fixture_hex(obj)
            minigl.glEnable(minigl.GL_DEPTH_TEST)

            # One tiny overlay only when an operator has explicitly selected a fixture face.
            self._draw_fixture_selection_overlay()

            minigl.glDisable(minigl.GL_BLEND); minigl.glEnable(minigl.GL_CULL_FACE)
        except Exception as e:
            try: minigl.glEnable(minigl.GL_DEPTH_TEST)
            except Exception: pass
            self.debug("Fixture draw failed: %s" % str(e))

    def reload_fixture_geometry(self, menu_item=None, quiet=False):
        if not FIXTURE_GEOMETRY_ENABLED: return False
        try:
            incoming_signature = self._get_fixture_config_signature()
            if (
                    not self._fixture_history_internal_write and
                    self._fixture_config_signature is not None and
                    incoming_signature is not None and
                    incoming_signature != self._fixture_config_signature):
                self._fixture_clear_history("fixture configuration changed outside Gremlin")
            self._load_fixture_geometry(create_if_missing=True)
            if self.gremlin is not None: self.gremlin.queue_draw()
            if not quiet: self.debug("Fixture geometry reloaded live (%d objects)." % len(self._fixture_objects))
            return True
        except Exception as e:
            if not quiet:
                try:self.error_handler.write("[%s] Fixture geometry reload failed; keeping the previous scene: %s"%(SCRIPT_NAME,str(e)),constants.ALARM_LEVEL_LOW)
                except Exception:pass
            return False

    def _install_fixture_geometry_hook(self):
        if not FIXTURE_GEOMETRY_ENABLED or self.gremlin is None: return False
        if self._fixture_draw_hook_installed: return True
        try:
            if minigl is None: raise RuntimeError("PathPilot minigl module is unavailable.")
            self._load_fixture_geometry(create_if_missing=True); gremlin=self.gremlin
            if bool(getattr(gremlin,"_tormachtips_fixture_draw_hook_installed",False)):
                self._fixture_draw_hook_installed=True
                self._install_fixture_select_hook()
                return True
            base_redraw_scene=gremlin.redraw_scene; gremlin._tormachtips_fixture_redraw_scene_base=base_redraw_scene
            def fixture_redraw_scene(gremlin_self,last_position=None,last_tpos=None):
                result=base_redraw_scene(last_position,last_tpos); self._draw_fixture_geometry(gremlin_self); return result
            try: gremlin.redraw_scene=types.MethodType(fixture_redraw_scene,gremlin,gremlin.__class__)
            except TypeError: gremlin.redraw_scene=types.MethodType(fixture_redraw_scene,gremlin)
            gremlin._tormachtips_fixture_draw_hook_installed=True; self._fixture_draw_hook_installed=True
            self._install_fixture_select_hook()
            self.debug("Installed live G53 fixture scene hook (%d objects)."%len(self._fixture_objects))
            try:gremlin.queue_draw()
            except Exception:pass
            return True
        except Exception as e:
            self.error_handler.write("[%s] Fixture geometry disabled because the config could not be loaded: %s"%(SCRIPT_NAME,str(e)),constants.ALARM_LEVEL_LOW); 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 _get_config_signature(self):
        try:
            return (float(os.path.getmtime(CONFIG_PATH)), int(os.path.getsize(CONFIG_PATH)))
        except Exception:
            return None

    def _validate_holder_config_data(self, data):
        issues = []
        if not isinstance(data, dict):
            return ["Config root is not a JSON object."]
        if "holder_model" in data or "sections" in data:
            issues.append("Legacy single-holder keys are not supported; use current holder_types schema.")
        required_root = (
            "enabled", "edge_enabled", "edge_width", "chamfer_enabled",
            "vector_font_line_width", "stickout_mode", "default_holder_type",
            "default_stickout_inches", "minimum_stickout_inches",
            "tool_stickout_inches", "tool_model_dimensions",
            "tool_model_overrides", "flute_visuals", "holder_types")
        for key in required_root:
            if key not in data:
                issues.append("Missing current-schema setting: %s" % key)
        holder_types = data.get("holder_types")
        if not isinstance(holder_types, dict) or len(holder_types) == 0:
            return issues + ["holder_types is missing, malformed, or empty."]
        default_holder_type = str(data.get("default_holder_type", "") or "")
        if default_holder_type and default_holder_type not in holder_types:
            issues.append("Default holder '%s' is not present in holder_types." % default_holder_type)
        if "vector_font_line_width" in data:
            try:
                vector_width = float(data.get("vector_font_line_width"))
                if vector_width < 0.50 or vector_width > 10.0:
                    issues.append("vector_font_line_width must be 0.50 through 10.0.")
            except Exception:
                issues.append("vector_font_line_width is not numeric.")
        valid_materials = ("body", "nut", "relief", "flange", "shank")
        for holder_type in sorted(holder_types.keys()):
            holder = holder_types.get(holder_type)
            prefix = str(holder_type)
            if not isinstance(holder, dict):
                issues.append("%s: holder definition is not an object." % prefix)
                continue
            if "body_label_enabled" in holder or "body_label_text" in holder:
                issues.append("%s: legacy holder-level body-label keys are not supported." % prefix)
            sections = holder.get("sections")
            if not isinstance(sections, list) or len(sections) == 0:
                issues.append("%s: sections is missing or empty." % prefix)
                continue
            reference_count = 0
            overlay_count = 0
            for section_index, section in enumerate(sections):
                section_prefix = "%s section %d" % (prefix, section_index + 1)
                if not isinstance(section, dict):
                    issues.append("%s: malformed section object." % section_prefix)
                    continue
                if bool(section.get("tool_length_reference", False)):
                    reference_count += 1
                if bool(section.get("vector_font_overlay", False)):
                    overlay_count += 1
                if "below_tool_length_reference" in section:
                    issues.append("%s: legacy below_tool_length_reference is not supported." % section_prefix)
                if "material" not in section:
                    issues.append("%s: explicit material is required." % section_prefix)
                else:
                    material = str(section.get("material", "") or "").strip().lower()
                    if material not in valid_materials:
                        issues.append("%s: material must be one of %s." % (section_prefix, ", ".join(valid_materials)))
                if "material_gray" in section:
                    try:
                        gray = float(section.get("material_gray"))
                        if gray < 0.20 or gray > 0.85:
                            issues.append("%s: material_gray must be 0.20 through 0.85." % section_prefix)
                    except Exception:
                        issues.append("%s: material_gray is not numeric." % section_prefix)
                try:
                    height = float(section["height_inches"])
                    if height <= 0:
                        issues.append("%s: height must be > 0." % section_prefix)
                except Exception:
                    issues.append("%s: invalid/missing height_inches." % section_prefix)
                try:
                    bottom = float(section["bottom_diameter_inches"])
                    if bottom <= 0:
                        issues.append("%s: bottom diameter must be > 0." % section_prefix)
                except Exception:
                    issues.append("%s: invalid/missing bottom_diameter_inches." % section_prefix)
                try:
                    top = float(section.get("top_diameter_inches", section["bottom_diameter_inches"]))
                    if top <= 0:
                        issues.append("%s: top diameter must be > 0." % section_prefix)
                except Exception:
                    issues.append("%s: invalid top_diameter_inches." % section_prefix)
                try:
                    facets = int(section.get("facets", 32))
                    if facets < 3:
                        issues.append("%s: facets must be >= 3." % section_prefix)
                except Exception:
                    issues.append("%s: facets is not an integer." % section_prefix)
            if reference_count == 0:
                issues.append("%s: no tool_length_reference section." % prefix)
            elif reference_count > 1:
                issues.append(
                    "%s: %d tool_length_reference sections; exactly one is required." %
                    (prefix, reference_count))
            if overlay_count > 1:
                issues.append("%s: %d vector_font_overlay sections; at most one is allowed." % (prefix, overlay_count))
        return issues

    def _validate_holder_config_file(self, reason):
        try:
            handle = open(CONFIG_PATH, "r")
            try:
                data = json.load(handle)
            finally:
                handle.close()
            issues = self._validate_holder_config_data(data)
        except Exception as e:
            issues = ["Could not parse config JSON: %s" % str(e)]
        signature = tuple(issues)
        if signature == self._last_config_validation_signature:
            return len(issues) == 0
        self._last_config_validation_signature = signature
        if not issues:
            self.debug("Holder config validation OK (%s)." % reason)
            return True
        preview = "; ".join(issues[:5])
        if len(issues) > 5:
            preview += "; ... +%d more" % (len(issues) - 5)
        self.error_handler.write(
            "[%s] Holder config warning (%d issue%s, %s): %s" %
            (SCRIPT_NAME,
             len(issues),
             "" if len(issues) == 1 else "s",
             reason,
             preview),
            constants.ALARM_LEVEL_LOW)
        return False

    def _config_refresh_tick(self):
        if not CONFIG_REFRESH_ENABLED:
            return False
        try:
            signature=self._get_config_signature()
            if signature is not None:
                if self._config_signature is None:
                    self._config_signature=signature; self._validate_holder_config_file("startup")
                elif signature != self._config_signature:
                    self._config_signature=signature; self._validate_holder_config_file("config changed"); self._invalidate_holder_tool_dlist(); self.debug("Holder config changed; Gremlin tool geometry refreshed.")
            # Reuse this existing 1500 ms config timer; fixture support creates no second poller.
            if FIXTURE_GEOMETRY_ENABLED and self._fixture_draw_hook_installed:
                fixture_signature=self._get_fixture_config_signature()
                if fixture_signature is not None:
                    if self._fixture_config_signature is None:self._fixture_config_signature=fixture_signature
                    elif fixture_signature != self._fixture_config_signature:
                        previous=self._fixture_config_signature
                        if self.reload_fixture_geometry(quiet=True):self.debug("Fixture config changed; live G53 scene refreshed (%d objects)."%len(self._fixture_objects))
                        else:self._fixture_config_signature=previous
        except Exception as e:
            self.debug("Configuration refresh check failed: %s"%str(e))
        return True

    def ensure_config_file(self):
        # BEAGLE_GREMLIN_FRESH_BASELINE_CLEANUP_V1
        # Final distribution starts from the current schema. Do not rename,
        # migrate, or silently normalize older Gremlin holder configs.
        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:
            self.write_file(CONFIG_PATH, DEFAULT_CONFIG_TEXT)
            self.debug("Created holder config: %s" % CONFIG_PATH)
            return "created"
        try:
            handle = open(CONFIG_PATH, "r")
            try:
                loaded = json.load(handle)
            finally:
                handle.close()
        except Exception as e:
            raise RuntimeError("Existing holder config could not be parsed: %s" % str(e))
        issues = self._validate_holder_config_data(loaded)
        if issues:
            raise RuntimeError(
                "Existing holder config is not the current schema. "
                "Open/save it with the current Builder or remove it to create a fresh config. "
                "First issue: %s" % issues[0])
        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):
        # BEAGLE_GREMLIN_STOCK_OEM_ONLY_INSTALLER_V1
        #
        # Release installer supports only:
        #   1) current TormachTips payload already installed -> verified no-op
        #   2) pristine OEM PathPilot Gremlin source -> patch exactly once
        #
        # No previous-payload upgrade, reconstruction, or legacy source fallback.
        required_markers = (
            GREMLIN_PAYLOAD_PATCH_MARKER,
            PATCH_MARKER,
            ROTATION_PATCH_MARKER,
            TOOL_NUMBER_LABEL_PATCH_MARKER,
            TOOL_NUMBER_FONT_PATCH_MARKER,
            TOOL_NUMBER_6_WIDE_BOWL_PATCH_MARKER,
            MODULAR_SECTION_PATCH_MARKER,
            TOOL_MODEL_PATCH_MARKER,
            FLUTE_VISUAL_PATCH_MARKER,
            REAMER_MODEL_PATCH_MARKER,
            PROBE_HOLDER_PATCH_MARKER,)

        if GREMLIN_PAYLOAD_PATCH_MARKER in content:
            missing = [marker for marker in required_markers if marker not in content]
            if missing:
                raise RuntimeError(
                    "Current Gremlin Tool Holder payload is incomplete. "
                    "Restore the original PathPilot gremlin_draw.py before reinstalling. "
                    "Missing markers: %s" % ", ".join(missing))
            return content, False

        cylinder_count = content.count(OEM_CYLINDER_TOOL)
        probe_count = content.count(OEM_BALL_PROBE_TOOL)
        draw_count = content.count(OEM_DRAW_SINGLE_TOOL)

        if cylinder_count != 1 or probe_count != 1 or draw_count != 1:
            raise RuntimeError(
                "gremlin_draw.py is not a recognized pristine PathPilot OEM source. "
                "Expected stock cylinder/probe/draw blocks = 1/1/1; found %d/%d/%d. "
                "Restore the original PathPilot gremlin_draw.py, then install again." %
                (cylinder_count, probe_count, draw_count))

        patched = content.replace(
            OEM_CYLINDER_TOOL,
            PATCHED_CYLINDER_TOOL,
            1)
        patched = patched.replace(
            OEM_BALL_PROBE_TOOL,
            PATCHED_BALL_PROBE_TOOL,
            1)
        patched = patched.replace(
            OEM_DRAW_SINGLE_TOOL,
            PATCHED_DRAW_SINGLE_TOOL,
            1)

        for marker in required_markers:
            if patched.count(marker) != 1:
                raise RuntimeError(
                    "Gremlin OEM patch verification failed for marker: %s" %
                    marker)

        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()
            self._config_signature = self._get_config_signature()
            self._validate_holder_config_file("startup")
            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)
                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(GREMLIN_PAYLOAD_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source payload-revision marker verification failed.")
            if staged_content.count(PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source holder marker verification failed.")
            if staged_content.count(ROTATION_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source rotation marker verification failed.")
            if staged_content.count(TOOL_NUMBER_LABEL_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source tool-number label marker verification failed.")
            if staged_content.count(TOOL_NUMBER_FONT_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source refined vector-font marker verification failed.")
            if staged_content.count(TOOL_NUMBER_6_WIDE_BOWL_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source wide-bowl glyph-6 marker verification failed.")
            if staged_content.count(PROBE_HOLDER_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source T99 probe-holder marker verification failed.")
            if staged_content.count(MODULAR_SECTION_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source modular-section marker verification failed.")
            if staged_content.count(TOOL_MODEL_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source simple tool-model marker verification failed.")
            if staged_content.count(FLUTE_VISUAL_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source flute-visual marker verification failed.")
            if staged_content.count(REAMER_MODEL_PATCH_MARKER) != 1:
                raise RuntimeError("Staged Gremlin source reamer-model marker verification failed.")
            try:
                compile(staged_content, staged_path, "exec")
            except SyntaxError as e:
                raise RuntimeError("Staged Gremlin source Python compile check failed: %s" % str(e))
            self.make_chronological_backup(target_path)
            self.install_staged_file(target_path, staged_path)
            installed = self.read_file(target_path)
            if installed.count(GREMLIN_PAYLOAD_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source payload-revision marker verification failed after swap.")
            if installed.count(PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source holder marker verification failed after swap.")
            if installed.count(ROTATION_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source rotation marker verification failed after swap.")
            if installed.count(TOOL_NUMBER_LABEL_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source tool-number label marker verification failed after swap.")
            if installed.count(TOOL_NUMBER_FONT_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source refined vector-font marker verification failed after swap.")
            if installed.count(TOOL_NUMBER_6_WIDE_BOWL_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source wide-bowl glyph-6 marker verification failed after swap.")
            if installed.count(PROBE_HOLDER_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source T99 probe-holder marker verification failed after swap.")
            if installed.count(MODULAR_SECTION_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source modular-section marker verification failed after swap.")
            if installed.count(TOOL_MODEL_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source simple tool-model marker verification failed after swap.")
            if installed.count(FLUTE_VISUAL_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source flute-visual marker verification failed after swap.")
            if installed.count(REAMER_MODEL_PATCH_MARKER) != 1:
                raise RuntimeError("Installed Gremlin source reamer-model marker verification failed after swap.")
            self.error_handler.write("[%s] Installed modular multi-holder Gremlin model with simple cutter models and lightweight rotation hook 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