# coding: utf-8
# python 2 only

# 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 Builder 1.36      ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 1.36 - Adds event-driven autosave. Valid edits are debounced and atomically written to the active holder JSON
#        (and SVG thumbnails) about 0.9 seconds after editing stops; Undo/Redo are autosaved too. A new catalog
#        auto-adopts the normal default config path, and closing the Builder flushes any pending autosave. No
#        recurring polling timer is used. Partially typed/invalid fields never overwrite the last valid value. - 9/25/2026
# 1.35 - Add Primitive now inserts the new holder section immediately BELOW the currently selected section
#        in the top-to-bottom Builder view, matching the normal top-down holder-building workflow. - 9/25/2026
# 1.34 - Numeric entry fields accept safe arithmetic expressions (1/4, 0.5+1/8, (3+5)/2). Primitive lists
#        support Ctrl/Shift multi-selection and Calculated Dimensions shows the summed height of the selected
#        holder parts. Adds 50-level Undo/Redo for live holder edits and structural changes, with Ctrl+Z/Ctrl+Y.
#        - 9/25/2026
# 1.33 - Expands Help / Warning into a current Builder workflow/reference covering holders, primitives,
#        tool-length reference, T# overlay, rendering, stickout, cutter models, flutes, overrides, and saving. - 9/21/2026
# 1.32 - Adds JSON-backed editable flute width multipliers: 4=1.00, 3=1.6667, 2=2.3333, 1=3.00. - 9/21/2026
# 1.31 - Clarifies flute width as the 4-flute baseline; Gremlin widens 3/2/1-flute visuals automatically. - 9/20/2026
# 1.30 - Adds configurable Drill Bit model using the chamfer-style conical renderer. - 9/20/2026
# 1.29 - Adds startup suite dependency warning when TLM or Gremlin Holder Plugin is missing. - 9/20/2026
# 1.28 - Removes internal settings-row separators and makes wheel/PageUp/PageDown scrolling reliable inside settings tabs. - 9/20/2026
# 1.27 - Removes Builder min/max validation caps from tool neck/shank diameters and reamer shank ratios. - 9/20/2026

import copy
import ast
import json
import math
import os
import sys
import Tkinter as tk
import tkFileDialog as filedialog
import tkMessageBox as messagebox
import tkSimpleDialog as simpledialog
import ttk
import io

CURRENT_VER             = "1.36"
SCRIPT_NAME             = "Gremlin Tool Holder Builder"
DESCRIPTION             = "Standalone editor for PathPilot Gremlin tool-holder definitions and SVG thumbnails."
APP_TITLE               = SCRIPT_NAME
DEFAULT_CONFIG_PATH     = "/home/operator/gcode/python/gremlin_tool_holder_config.json"
SUITE_DIR               = os.path.dirname(os.path.abspath(__file__))
TOOL_LIFE_MANAGER_PATH  = os.path.join(SUITE_DIR, "tool_life_manager_plugin.py")
GREMLIN_PLUGIN_PATH      = os.path.join(SUITE_DIR, "gremlin_tool_holder_plugin.py")
MATERIAL_CHOICES        = ("body", "nut", "relief", "flange", "shank")
MATERIAL_FILL           = {"body": "#808080","nut": "#757575","relief": "#707070","flange": "#858585","shank": "#8c8c8c",}
UNDO_LIMIT              = 50
HISTORY_COALESCE_MS      = 650
AUTOSAVE_DELAY_MS        = 900
CURRENT_CONFIG_DEFAULTS = {
    "enabled": True,
    "edge_enabled": True,
    "edge_width": 3.0,
    "chamfer_enabled": False,
    "vector_font_line_width": 2.0,
    "stickout_mode": "tool_table_length",
    "default_stickout_inches": 1.5,
    "minimum_stickout_inches": 0.05,
    "tool_stickout_inches": {},
    "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.70,},
        "reamer_spiral": {
            "cutting_length_inches": 0.75,
            "shank_diameter_ratio": 0.70,
            "helix_angle_degrees": 25.0,},
        "chamfer_90": {"tip_diameter_inches": 0.0},
        "chamfer_45": {"tip_diameter_inches": 0.0},
        "drill_bit": {"included_angle_degrees": 118.0, "tip_diameter_inches": 0.0},
        "key_cutter": {
            "head_thickness_inches": 0.125,
            "neck_diameter_inches": 0.25,
            "neck_length_inches": 0.5,
            "shank_diameter_inches": 0.375,},
        "key_cutter_rounded": {
            "head_thickness_inches": 0.125,
            "edge_radius_inches": 0.03125,
            "edge_segments": 6,
            "neck_diameter_inches": 0.25,
            "neck_length_inches": 0.5,
            "shank_diameter_inches": 0.375,},
        "dovetail": {
            "included_angle_degrees": 60.0,
            "neck_diameter_inches": 0.25,
            "neck_length_inches": 0.25,
            "shank_diameter_inches": 0.375,},
        "thread_mill": {
            "included_angle_degrees": 60.0,
            "neck_diameter_inches": 0.25,
            "neck_length_inches": 0.50,
            "shank_diameter_inches": 0.375,},
        "thread_mill_0250": {
            "included_angle_degrees": 60.0,
            "neck_diameter_inches": 0.1875,
            "neck_length_inches": 0.50,
            "shank_diameter_inches": 0.250,},
        "thread_mill_01875": {
            "included_angle_degrees": 60.0,
            "neck_diameter_inches": 0.125,
            "neck_length_inches": 0.50,
            "shank_diameter_inches": 0.1875,},},
    "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,},}

def infer_section_material(section):
    try:
        material = str(section.get("material", "") or "").strip().lower()
    except Exception:
        material = ""
    return material if material in MATERIAL_CHOICES else "body"

def check_for_updates():
    """Run the standard TormachTips update check without making startup depend on it."""
    try:
        import update_checker
        update_checker.tormachtips(__file__, CURRENT_VER)
    except Exception:
        pass

DEFAULT_NEW_HOLDER = {
    "display_name": "New TTS Holder",
    "family": "TTS",
    "manufacturer": "Generic",
    "image": "gremlin_tool_holders/new_tts_holder.svg",
    "sections": [
        {
            "name": "collet / nose",
            "material": "nut",
            "height_inches": 0.700,
            "bottom_diameter_inches": 1.500,
            "top_diameter_inches": 1.500,
            "facets": 6,},
        {
            "name": "gutter / relief",
            "material": "relief",
            "height_inches": 0.200,
            "bottom_diameter_inches": 1.100,
            "top_diameter_inches": 1.100,
            "facets": 32,},
        {
            "name": "main body",
            "material": "body",
            "vector_font_overlay": True,
            "height_inches": 0.700,
            "bottom_diameter_inches": 1.250,
            "top_diameter_inches": 1.250,
            "facets": 32,},
        {
            "name": "ATC retention ring",
            "material": "flange",
            "tool_length_reference": True,
            "height_inches": 0.200,
            "bottom_diameter_inches": 2.000,
            "top_diameter_inches": 2.000,
            "facets": 32,},
        {
            "name": "upper TTS shank",
            "material": "shank",
            "height_inches": 1.200,
            "bottom_diameter_inches": 0.750,
            "top_diameter_inches": 0.750,
            "facets": 32,},],}

def sanitize_holder_id(text):
    text = (text or u"").strip().upper()
    out = []
    prev_us = False
    for ch in text:
        if ch.isalnum():
            out.append(ch)
            prev_us = False
        else:
            if not prev_us:
                out.append("_")
                prev_us = True
    return "".join(out).strip("_") or "NEW_HOLDER"

try:
    INTEGER_TYPES = (int, long)
except NameError:
    INTEGER_TYPES = (int,)

def numeric_value(value):
    """Safely evaluate a number or a simple arithmetic expression."""
    if isinstance(value, INTEGER_TYPES + (float,)):
        result = float(value)
    else:
        raw = str(value).strip()
        if not raw:
            raise ValueError("Numeric value is blank.")
        if len(raw) > 120:
            raise ValueError("Numeric expression is too long.")
        try:
            tree = ast.parse(raw, mode="eval")
        except Exception:
            raise ValueError("Invalid numeric expression: %s" % raw)

        def evaluate(node, depth=0):
            if depth > 32:
                raise ValueError("Numeric expression is too deeply nested.")
            if isinstance(node, ast.Expression):
                return evaluate(node.body, depth + 1)
            if isinstance(node, ast.Num):
                return float(node.n)
            if hasattr(ast, "Constant") and isinstance(node, ast.Constant):
                if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
                    return float(node.value)
                raise ValueError("Only numbers are allowed in numeric expressions.")
            if isinstance(node, ast.UnaryOp):
                operand = evaluate(node.operand, depth + 1)
                if isinstance(node.op, ast.UAdd):
                    return operand
                if isinstance(node.op, ast.USub):
                    return -operand
                raise ValueError("Unsupported unary operator.")
            if isinstance(node, ast.BinOp):
                left = evaluate(node.left, depth + 1)
                right = evaluate(node.right, depth + 1)
                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 right == 0.0:
                        raise ValueError("Division by zero.")
                    return left / right
                if isinstance(node.op, ast.FloorDiv):
                    if right == 0.0:
                        raise ValueError("Division by zero.")
                    return left // right
                if isinstance(node.op, ast.Mod):
                    if right == 0.0:
                        raise ValueError("Modulo by zero.")
                    return left % right
                if isinstance(node.op, ast.Pow):
                    if abs(right) > 100.0:
                        raise ValueError("Exponent is too large.")
                    return left ** right
                raise ValueError("Unsupported arithmetic operator.")
            raise ValueError("Only numeric arithmetic is allowed.")

        try:
            result = float(evaluate(tree))
        except ValueError:
            raise
        except Exception as exc:
            raise ValueError(
                "Invalid numeric expression: %s (%s)"
                % (raw, str(exc)))

    if math.isnan(result) or math.isinf(result):
        raise ValueError("Numeric result must be finite.")
    return result

def fnum(value, default=0.0):
    try:
        return numeric_value(value)
    except Exception:
        return default

def inum(value, default=32):
    try:
        return max(3, int(numeric_value(value)))
    except Exception:
        return default

class HolderBuilder(tk.Tk):
    def __init__(self, initial_path=None):
        tk.Tk.__init__(self)
        self.title(APP_TITLE)
        try:
            self.wm_attributes("-zoomed", 1)
        except Exception:
            self.geometry("1024x700")
        self.minsize(760, 560)
        self.config_path = None
        self.data = None
        self.current_holder_id = None
        self.loading_ui = False
        self._redraw_job = None

        # Numeric-entry registry used by the safe arithmetic resolver.
        self._numeric_vars = []

        # In-memory editor history. Nothing is written to disk by Undo/Redo;
        # Save remains explicit.
        self._undo_stack = []
        self._redo_stack = []
        self._history_restoring = False
        self._history_coalesce_open = False
        self._history_coalesce_job = None

        # Event-driven autosave: one debounced callback after edits, never a
        # recurring poll. Manual Save and shutdown temporarily suspend it.
        self._autosave_job = None
        self._autosave_suspended = False
        self._autosave_setting_traces = []

        self.edge_enabled_var = tk.BooleanVar(value=bool(CURRENT_CONFIG_DEFAULTS["edge_enabled"]))
        self.edge_width_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["edge_width"]))
        self._build_ui()
        self._install_numeric_expression_entries()
        self._install_autosave_triggers()
        self.protocol("WM_DELETE_WINDOW", self._on_close)
        self.after(100, self._check_suite_dependencies_once)
        self.edge_enabled_var.trace("w", self.on_render_style_changed)
        self.edge_width_var.trace("w", self.on_render_style_changed)
        self.after(350, self._set_initial_split)
        if initial_path and os.path.isfile(initial_path):
            self.load_file(initial_path)
        elif os.path.isfile(DEFAULT_CONFIG_PATH):
            self.load_file(DEFAULT_CONFIG_PATH)
        else:
            self.after(50, self.open_or_create_startup)

    def _check_suite_dependencies_once(self):
        missing = []
        for label, path in (
                ("Tool Life Manager", TOOL_LIFE_MANAGER_PATH),
                ("Gremlin Tool Holder Plugin", GREMLIN_PLUGIN_PATH)):
            if not os.path.isfile(path):
                missing.append("%s\n%s" % (label, path))
        if not missing:
            return
        messagebox.showwarning(
            "Gremlin/TLM Suite - Missing Component",
            "This Builder 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 Builder will continue running, but related holder/model features "
            "may not work correctly until the missing file(s) are installed."
            % "\n\n".join(missing),
            parent=self)

    # ---------- UI ----------

    def _build_ui(self):
        self._build_menu()
        top = ttk.Frame(self, padding=(8, 8, 8, 4))
        top.pack(fill="x")
        ttk.Label(top, text="Config:").grid(row=0, column=0, sticky="w")
        self.path_var = tk.StringVar(value="No file loaded")
        ttk.Label(top, textvariable=self.path_var, width=58, anchor="w").grid(row=0, column=1, sticky="ew", padx=(6, 8))
        ttk.Button(top, text="Save", command=self.save, width=9).grid(row=0, column=2, padx=2)
        ttk.Button(top, text="Save As...", command=self.save_as, width=10).grid(row=0, column=3, padx=2)
        ttk.Button(top, text="Open...", command=self.open_file, width=9).grid(row=0, column=4, padx=2)
        top.columnconfigure(1, weight=1)
        self.settings_scroll_regions = []
        notebook = ttk.Notebook(self)
        notebook.pack(fill="both", expand=True, padx=8, pady=(0, 4))
        holder_tab = ttk.Frame(notebook)
        notebook.add(holder_tab, text="Holders")
        holderbar = ttk.Frame(holder_tab, padding=(8, 2, 8, 2))
        holderbar.pack(fill="x")
        ttk.Label(holderbar, text="Holder:").grid(row=0, column=0, sticky="w")
        self.holder_combo = ttk.Combobox(holderbar, state="readonly", width=34)
        self.holder_combo.grid(row=0, column=1, sticky="ew", padx=(6, 5))
        self.holder_combo.bind("<<ComboboxSelected>>", self.on_holder_selected)
        ttk.Button(holderbar, text="New", command=self.new_holder, width=8).grid(row=0, column=2, padx=2)
        ttk.Button(holderbar, text="Duplicate", command=self.duplicate_holder, width=9).grid(row=0, column=3, padx=2)
        ttk.Button(holderbar, text="Rename ID", command=self.rename_holder_id, width=9).grid(row=0, column=4, padx=2)
        ttk.Button(holderbar, text="Delete", command=self.delete_holder, width=8).grid(row=0, column=5, padx=2)
        ttk.Button(holderbar, text="Set Default", command=self.set_default_holder, width=10).grid(row=0, column=6, padx=(8, 2))
        holderbar.columnconfigure(1, weight=1)
        self.default_label = tk.StringVar(value="")
        ttk.Label(holderbar, textvariable=self.default_label, anchor="w").grid(
            row=1, column=1, columnspan=6, sticky="w", padx=(6, 0), pady=(2, 0))
        paned = ttk.Panedwindow(holder_tab, orient="horizontal")
        paned.pack(fill="both", expand=True, padx=8, pady=(0, 8))
        self.main_paned = paned
        # LEFT: live visualization and primitive stack
        left = ttk.Frame(paned, padding=6)
        paned.add(left)
        ttk.Label(left, text="Live holder representation").pack(anchor="w")
        self.canvas = tk.Canvas(left, background="white", highlightthickness=1, highlightbackground="#888")
        self.canvas.pack(fill="both", expand=True, pady=(4, 6))
        self.canvas.bind("<Configure>", lambda e: self.schedule_redraw())
        self.canvas.bind("<Button-1>", self.on_canvas_click)
        self.canvas_section_boxes = []
        primitive_box = ttk.LabelFrame(left, text="Primitive stack (top -> bottom)", padding=5)
        primitive_box.pack(fill="x")
        self.primitive_list = tk.Listbox(primitive_box, height=7, exportselection=False, selectmode=tk.EXTENDED)
        self.primitive_list.pack(side="left", fill="both", expand=True)
        self.primitive_list.bind("<<ListboxSelect>>", self.on_primitive_selected)
        pbuttons = ttk.Frame(primitive_box)
        pbuttons.pack(side="right", fill="y", padx=(6, 0))
        ttk.Button(pbuttons, text="Add", width=8, command=self.add_section).pack(pady=2)
        ttk.Button(pbuttons, text="Duplicate", width=8, command=self.duplicate_section).pack(pady=2)
        ttk.Button(pbuttons, text="Delete", width=8, command=self.delete_section).pack(pady=2)
        ttk.Button(pbuttons, text="Up", width=8, command=lambda: self.move_section(1)).pack(pady=2)
        ttk.Button(pbuttons, text="Down", width=8, command=lambda: self.move_section(-1)).pack(pady=2)
        # RIGHT: metadata and section inputs
        right_outer = ttk.Frame(paned)
        paned.add(right_outer)
        right_canvas = tk.Canvas(right_outer, highlightthickness=0)
        right_scroll = ttk.Scrollbar(right_outer, orient="vertical", command=right_canvas.yview)
        self.right = ttk.Frame(right_canvas, padding=8)
        self.right.bind("<Configure>", lambda e: right_canvas.configure(scrollregion=right_canvas.bbox("all")))
        self._right_window = right_canvas.create_window((0, 0), window=self.right, anchor="nw")
        right_canvas.bind(
            "<Configure>",
            lambda e: right_canvas.itemconfigure(
                self._right_window,
                width=e.width,
                height=max(e.height, self.right.winfo_reqheight())))
        right_canvas.configure(yscrollcommand=right_scroll.set)
        right_canvas.pack(side="left", fill="both", expand=True)
        right_scroll.pack(side="right", fill="y")
        self.right_canvas = right_canvas
        # PathPilot/Mint may report the wheel as either MouseWheel or X11
        # Button-4/Button-5. Bind all three and only consume them when the
        # pointer is over the vertically scrollable right pane. PageUp/PageDown
        # scroll the same pane when focus or the pointer is inside it.
        self.bind_all("<MouseWheel>", self._on_global_vertical_scroll)
        self.bind_all("<Button-4>", self._on_global_vertical_scroll)
        self.bind_all("<Button-5>", self._on_global_vertical_scroll)
        self.bind_all("<Prior>", self._on_global_page_scroll)
        self.bind_all("<Next>", self._on_global_page_scroll)
        meta = ttk.LabelFrame(self.right, text="Holder definition", padding=8)
        meta.pack(fill="x", pady=(0, 8))
        self.meta_vars = {
            "display_name": tk.StringVar(),
            "family": tk.StringVar(),
            "manufacturer": tk.StringVar(),
            "image": tk.StringVar(),}
        self._grid_entry(meta, 0, "Display name", self.meta_vars["display_name"])
        self._grid_entry(meta, 1, "Family", self.meta_vars["family"])
        self._grid_entry(meta, 2, "Manufacturer", self.meta_vars["manufacturer"])
        image_entry = self._grid_entry(meta, 3, "SVG thumbnail", self.meta_vars["image"])
        try:
            image_entry.configure(state="readonly")
        except Exception:
            pass
        for var in self.meta_vars.values():
            var.trace("w", self.on_meta_changed)
        section_box = ttk.LabelFrame(self.right, text="Selected primitive", padding=8)
        section_box.pack(fill="x", pady=(0, 8))
        self.sv_name = tk.StringVar()
        self.sv_height = tk.StringVar()
        self.sv_bottom = tk.StringVar()
        self.sv_top = tk.StringVar()
        self.sv_facets = tk.StringVar()
        self.sv_material = tk.StringVar(value="body")
        self.sv_below = tk.BooleanVar()
        self.sv_vector_overlay = tk.BooleanVar()
        for numeric_var in (
                self.sv_top,
                self.sv_bottom,
                self.sv_height,
                self.sv_facets):
            self._register_numeric_var(numeric_var)
        self._grid_entry(section_box, 0, "Name", self.sv_name)
        self._grid_entry(section_box, 1, "Top OD (in)", self.sv_top)
        self._grid_entry(section_box, 2, "Bottom OD (in)", self.sv_bottom)
        self._grid_entry(section_box, 3, "Height (in)", self.sv_height)
        self._grid_entry(section_box, 4, "Facets", self.sv_facets)
        ttk.Label(section_box, text="Material").grid(row=5, column=0, sticky="w", padx=(0, 8), pady=3)
        self.material_combo = ttk.Combobox(
            section_box,
            textvariable=self.sv_material,
            values=MATERIAL_CHOICES,
            state="readonly")
        self.material_combo.grid(row=5, column=1, sticky="ew", pady=3)
        self.material_combo.bind("<<ComboboxSelected>>", self.on_section_changed)
        ttk.Checkbutton(
            section_box,
            text="Offset length starts at top HERE (spindle-face mating surface)",
            variable=self.sv_below,
            command=self.on_section_changed,
        ).grid(row=6, column=0, columnspan=2, sticky="w", pady=(5, 2))
        ttk.Checkbutton(
            section_box,
            text="Vector font overlay goes here (T#)",
            variable=self.sv_vector_overlay,
            command=self.on_section_changed,
        ).grid(row=7, column=0, columnspan=2, sticky="w", pady=(2, 2))
        self.hex_info = tk.StringVar(value="")
        ttk.Label(section_box, textvariable=self.hex_info).grid(row=8, column=0, columnspan=2, sticky="w", pady=(3, 0))
        for var in (self.sv_name, self.sv_height, self.sv_bottom, self.sv_top, self.sv_facets):
            var.trace("w", self.on_section_changed)
        summary = ttk.LabelFrame(self.right, text="Calculated dimensions", padding=8)
        summary.pack(fill="x")
        self.summary_var = tk.StringVar()
        ttk.Label(summary, textvariable=self.summary_var, justify="left").pack(anchor="w")
        hint = (
            "Spindle face is the TOP of the marked primitive.\n"
            "PathPilot overall tool-offset length starts at this mating surface.\n"
            "All primitives below it, including that primitive, form fixed holder length.\n"
            "Gremlin stickout = PathPilot tool length - fixed holder length.\n"
            "Primitive names are descriptive only; material/spindle-face/T# placement are explicit.")
        ttk.Label(summary, text=hint, justify="left").pack(anchor="w", pady=(8, 0))
        self._build_settings_tabs(notebook)
        self._install_settings_scroll_bindings()
        status = ttk.Frame(self, padding=(8, 2, 8, 6))
        status.pack(fill="x")
        self.status_var = tk.StringVar(value="Ready")
        ttk.Label(status, textvariable=self.status_var).pack(side="left")

    def _build_menu(self):
        m = tk.Menu(self)
        fm = tk.Menu(m, tearoff=False)
        fm.add_command(label="Open...", command=self.open_file)
        fm.add_command(label="Save", command=self.save)
        fm.add_command(label="Save As...", command=self.save_as)
        fm.add_separator()
        fm.add_command(label="Exit", command=self._on_close)
        m.add_cascade(label="File", menu=fm)

        em = tk.Menu(m, tearoff=False)
        em.add_command(
            label="Undo",
            accelerator="Ctrl+Z",
            command=self.undo)
        em.add_command(
            label="Redo",
            accelerator="Ctrl+Y",
            command=self.redo)
        m.add_cascade(label="Edit", menu=em)
        self.edit_menu = em

        self.bind_all("<Control-z>", self._on_undo_shortcut)
        self.bind_all("<Control-Z>", self._on_undo_shortcut)
        self.bind_all("<Control-y>", self._on_redo_shortcut)
        self.bind_all("<Control-Y>", self._on_redo_shortcut)

        m.add_command(label="Help / Warning", command=self.show_visual_warning)
        self.config(menu=m)
        self._update_history_menu_state()

    # ---------- autosave ----------

    def _install_autosave_triggers(self):
        """Attach event-driven autosave triggers to settings not already live."""
        vars_to_watch = [
            self.config_enabled_var,
            self.edge_enabled_var,
            self.edge_width_var,
            self.chamfer_enabled_var,
            self.vector_font_width_var,
            self.stickout_mode_var,
            self.default_stickout_var,
            self.minimum_stickout_var,
            self.flute_enabled_var,
            self.flute_default_count_var,
            self.flute_helix_angle_var,
            self.flute_width_percent_var,
            self.flute_width_scale_4_var,
            self.flute_width_scale_3_var,
            self.flute_width_scale_2_var,
            self.flute_width_scale_1_var,
            self.flute_surface_offset_var,
            self.flute_segments_var,
            self.flute_max_turns_var,
        ]
        vars_to_watch.extend(self.model_vars.values())

        for var in vars_to_watch:
            try:
                trace_id = var.trace(
                    "w",
                    self._on_autosave_setting_changed)
                self._autosave_setting_traces.append(
                    (var, trace_id))
            except Exception:
                pass

        for widget in (
                self.tool_stickout_text,
                self.tool_model_overrides_text):
            try:
                widget.bind(
                    "<<Modified>>",
                    self._on_autosave_text_modified,
                    add="+")
                widget.edit_modified(False)
            except Exception:
                pass

    def _on_autosave_setting_changed(self, *args):
        if (
                self.loading_ui or
                self._history_restoring or
                self._autosave_suspended):
            return
        self._schedule_autosave()

    def _on_autosave_text_modified(self, event):
        widget = getattr(event, "widget", None)
        if widget is None:
            return
        try:
            modified = bool(widget.edit_modified())
            widget.edit_modified(False)
        except Exception:
            modified = True

        if not modified:
            return
        if (
                self.loading_ui or
                self._history_restoring or
                self._autosave_suspended):
            return
        self._schedule_autosave()

    def _cancel_autosave(self):
        if self._autosave_job is not None:
            try:
                self.after_cancel(self._autosave_job)
            except Exception:
                pass
        self._autosave_job = None

    def _schedule_autosave(self):
        if (
                self._autosave_suspended or
                self.loading_ui or
                self._history_restoring or
                self.data is None):
            return

        self._cancel_autosave()
        self._autosave_job = self.after(
            AUTOSAVE_DELAY_MS,
            self._autosave_now)

    def _autosave_target_path(self):
        if self.config_path:
            return os.path.abspath(self.config_path)

        # A freshly-created catalog has no user-selected pathname yet. The
        # Builder's normal operating file is the standard PathPilot config, so
        # autosave adopts that path rather than leaving a new editing session
        # vulnerable until Save/Save As is clicked.
        return os.path.abspath(DEFAULT_CONFIG_PATH)

    def _atomic_write_current_config(self, target_path):
        target_path = os.path.abspath(target_path)
        folder = os.path.dirname(target_path)
        if folder and not os.path.isdir(folder):
            os.makedirs(folder)

        # SVG thumbnails are part of the holder edit and are kept in sync with
        # the JSON during the same debounced save pass.
        old_path = self.config_path
        try:
            if self.config_path is None:
                self.config_path = target_path
            self.generate_all_svgs()
        finally:
            if old_path is None:
                self.config_path = old_path

        tmp = target_path + ".tmp"
        payload = json.dumps(
            self.data,
            indent=4,
            ensure_ascii=False)
        if isinstance(payload, unicode):
            payload = payload.encode("utf-8")

        try:
            with open(tmp, "wb") as handle:
                handle.write(payload)
                handle.write("\n")
                handle.flush()
                try:
                    os.fsync(handle.fileno())
                except Exception:
                    pass
            os.rename(tmp, target_path)
        except Exception:
            try:
                if os.path.exists(tmp):
                    os.remove(tmp)
            except Exception:
                pass
            raise

    def _autosave_now(self):
        self._autosave_job = None

        if (
                self._autosave_suspended or
                self.loading_ui or
                self._history_restoring or
                self.data is None):
            return False

        target_path = self._autosave_target_path()

        # Keep every already-valid edit, while refusing to let a partially
        # typed settings expression/JSON blob corrupt the saved configuration.
        self._autosave_suspended = True
        try:
            self.commit_section_from_ui()
            self.commit_meta_from_ui()

            data_before_settings = copy.deepcopy(self.data)
            settings_valid = self.commit_settings_from_ui(
                show_errors=False,
                record_history=False)
            if not settings_valid:
                self.data = data_before_settings

            issues = self.current_schema_issues(self.data)
            if issues:
                self.status_var.set(
                    "Auto-save paused: %s" % issues[0])
                return False

            self._atomic_write_current_config(target_path)

            if self.config_path is None:
                self.config_path = target_path
                self.path_var.set(self.config_path)

            if settings_valid:
                self.status_var.set(
                    "Auto-saved %s" % self.config_path)
            else:
                self.status_var.set(
                    "Auto-saved valid changes; finish the invalid setting to save it.")
            return False

        except Exception as exc:
            self.status_var.set(
                "Auto-save failed: %s" % str(exc))
            return False
        finally:
            self._autosave_suspended = False

    def _on_close(self):
        # Flush the debounce window so an edit made immediately before closing
        # is not lost.
        self._cancel_autosave()
        try:
            self._autosave_now()
        except Exception:
            pass
        self.destroy()

    # ---------- numeric arithmetic entries ----------

    def _register_numeric_var(self, var):
        if var is None:
            return
        for existing in self._numeric_vars:
            if existing is var:
                return
        self._numeric_vars.append(var)

    def _install_numeric_expression_entries(self):
        """Resolve arithmetic in registered numeric Entry fields."""
        try:
            self.bind_class(
                "TEntry",
                "<Return>",
                self._on_numeric_entry_commit,
                add="+")
            self.bind_class(
                "TEntry",
                "<KP_Enter>",
                self._on_numeric_entry_commit,
                add="+")
            self.bind_class(
                "TEntry",
                "<FocusOut>",
                self._on_numeric_entry_commit,
                add="+")
        except Exception:
            pass

    def _numeric_var_for_entry(self, widget):
        try:
            variable_name = str(widget.cget("textvariable") or "")
        except Exception:
            variable_name = ""
        if not variable_name:
            return None
        for var in self._numeric_vars:
            try:
                if variable_name == str(var):
                    return var
            except Exception:
                pass
        return None

    @staticmethod
    def _format_numeric_result(value):
        # Compact enough for dimensions while avoiding gratuitous trailing zeroes.
        return "%.10g" % float(value)

    def _on_numeric_entry_commit(self, event):
        widget = getattr(event, "widget", None)
        if widget is None:
            return
        var = self._numeric_var_for_entry(widget)
        if var is None:
            return

        try:
            raw = str(var.get()).strip()
            value = numeric_value(raw)
            resolved = self._format_numeric_result(value)
            if raw != resolved:
                var.set(resolved)
                self.status_var.set(
                    "Resolved %s = %s" % (raw, resolved))
        except Exception as exc:
            self.status_var.set(
                "Invalid numeric expression: %s" % str(exc))

        if str(getattr(event, "keysym", "")) in ("Return", "KP_Enter"):
            try:
                widget.selection_range(0, tk.END)
                widget.icursor(tk.END)
            except Exception:
                pass
            return "break"

    # ---------- undo / redo ----------

    def _history_snapshot(self):
        selected = []
        active = None
        try:
            selected = self.selected_section_indices()
            active = self.selected_section_index()
        except Exception:
            selected = []
            active = None
        return {
            "data": copy.deepcopy(self.data),
            "holder_id": self.current_holder_id,
            "selected": list(selected),
            "active": active,
        }

    @staticmethod
    def _history_equivalent(a, b):
        if a is None or b is None:
            return False
        return (
            a.get("data") == b.get("data") and
            a.get("holder_id") == b.get("holder_id"))

    def _history_close_coalesce(self):
        self._history_coalesce_open = False
        self._history_coalesce_job = None
        return None

    def _history_schedule_close(self):
        if self._history_coalesce_job is not None:
            try:
                self.after_cancel(self._history_coalesce_job)
            except Exception:
                pass
        self._history_coalesce_job = self.after(
            HISTORY_COALESCE_MS,
            self._history_close_coalesce)

    def _history_finish_coalesce(self):
        if self._history_coalesce_job is not None:
            try:
                self.after_cancel(self._history_coalesce_job)
            except Exception:
                pass
        self._history_coalesce_job = None
        self._history_coalesce_open = False

    def _history_checkpoint(self, label, coalesce=False):
        if self._history_restoring or self.data is None:
            return

        self._schedule_autosave()

        if coalesce and self._history_coalesce_open:
            self._history_schedule_close()
            return

        if not coalesce:
            self._history_finish_coalesce()

        snapshot = self._history_snapshot()
        snapshot["label"] = str(label or "Edit")

        if (
                not self._undo_stack or
                not self._history_equivalent(
                    self._undo_stack[-1],
                    snapshot)):
            self._undo_stack.append(snapshot)
            if len(self._undo_stack) > UNDO_LIMIT:
                del self._undo_stack[0]

        self._redo_stack = []

        if coalesce:
            self._history_coalesce_open = True
            self._history_schedule_close()

        self._update_history_menu_state()

    def _update_history_menu_state(self):
        menu = getattr(self, "edit_menu", None)
        if menu is None:
            return
        try:
            menu.entryconfig(
                "Undo",
                state=("normal" if self._undo_stack else "disabled"))
            menu.entryconfig(
                "Redo",
                state=("normal" if self._redo_stack else "disabled"))
        except Exception:
            pass

    def _reset_history(self):
        self._history_finish_coalesce()
        self._undo_stack = []
        self._redo_stack = []
        self._update_history_menu_state()

    def _restore_history_snapshot(self, snapshot):
        if snapshot is None:
            return
        self._history_restoring = True
        try:
            self.data = copy.deepcopy(snapshot.get("data"))
            self.current_holder_id = snapshot.get("holder_id")

            holders = (
                self.data.get("holder_types", {})
                if isinstance(self.data, dict)
                else {})
            if self.current_holder_id not in holders:
                ids = list(holders.keys())
                self.current_holder_id = ids[0] if ids else None

            self.refresh_holder_combo()
            self.load_current_holder()
            self.load_settings_ui()

            self._apply_section_selection(
                snapshot.get("selected", []),
                snapshot.get("active"))
            self.load_selected_section()
            self.update_summary()
            self.schedule_redraw()
            self.update_default_label()
        finally:
            self._history_restoring = False

        self._schedule_autosave()

    def undo(self):
        if not self._undo_stack or self.data is None:
            return
        self._history_finish_coalesce()
        current = self._history_snapshot()

        while self._undo_stack:
            target = self._undo_stack.pop()
            if self._history_equivalent(target, current):
                continue
            current["label"] = target.get("label", "Edit")
            self._redo_stack.append(current)
            if len(self._redo_stack) > UNDO_LIMIT:
                del self._redo_stack[0]
            self._restore_history_snapshot(target)
            self.status_var.set(
                "Undo: %s" % target.get("label", "Edit"))
            self._update_history_menu_state()
            return

        self._update_history_menu_state()

    def redo(self):
        if not self._redo_stack or self.data is None:
            return
        self._history_finish_coalesce()
        current = self._history_snapshot()

        while self._redo_stack:
            target = self._redo_stack.pop()
            if self._history_equivalent(target, current):
                continue
            self._undo_stack.append(current)
            if len(self._undo_stack) > UNDO_LIMIT:
                del self._undo_stack[0]
            self._restore_history_snapshot(target)
            self.status_var.set(
                "Redo: %s" % target.get("label", "Edit"))
            self._update_history_menu_state()
            return

        self._update_history_menu_state()

    def _on_undo_shortcut(self, event=None):
        self.undo()
        return "break"

    def _on_redo_shortcut(self, event=None):
        self.redo()
        return "break"

    @staticmethod
    def _grid_entry(parent, row, label, var):
        ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=3)
        ent = ttk.Entry(parent, textvariable=var)
        ent.grid(row=row, column=1, sticky="ew", pady=3)
        parent.columnconfigure(1, weight=1)
        return ent

    def _make_scrollable_settings_tab(self, notebook, title):
        tab = ttk.Frame(notebook)
        notebook.add(tab, text=title)
        canvas = tk.Canvas(tab, highlightthickness=0)
        scrollbar = ttk.Scrollbar(tab, orient="vertical", command=canvas.yview)
        content = ttk.Frame(canvas, padding=(10, 8, 10, 8))
        window_id = canvas.create_window((0, 0), window=content, anchor="nw")
        content.bind("<Configure>", lambda event, c=canvas: c.configure(scrollregion=c.bbox("all")))
        canvas.bind("<Configure>", lambda event, c=canvas, wid=window_id: c.itemconfigure(wid, width=event.width))
        canvas.configure(yscrollcommand=scrollbar.set)
        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")
        self.settings_scroll_regions.append((canvas, content))
        return content

    def _settings_entry(self, parent, row, label, var, description):
        ttk.Label(parent, text=label, width=27).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=4)
        entry = ttk.Entry(parent, textvariable=var, width=16)
        entry.grid(row=row, column=1, sticky="ew", padx=(0, 12), pady=4)
        self._register_numeric_var(var)
        ttk.Label(
            parent,
            text=u"\u2022 " + unicode(description),
            justify="left",
            wraplength=535).grid(
                row=row,
                column=2,
                sticky="w",
                pady=4)
        parent.columnconfigure(2, weight=1)
        return row + 1

    def _settings_check(self, parent, row, text, var, description):
        ttk.Checkbutton(
            parent,
            text=text,
            variable=var).grid(
                row=row,
                column=0,
                columnspan=2,
                sticky="w",
                padx=(0, 12),
                pady=4)
        ttk.Label(
            parent,
            text=u"\u2022 " + unicode(description),
            justify="left",
            wraplength=535).grid(
                row=row,
                column=2,
                sticky="w",
                pady=4)
        parent.columnconfigure(2, weight=1)
        return row + 1

    def _settings_combo(self, parent, row, label, var, values, description):
        ttk.Label(parent, text=label, width=27).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=4)
        combo = ttk.Combobox(parent, textvariable=var, values=values, state="readonly", width=16)
        combo.grid(row=row, column=1, sticky="ew", padx=(0, 12), pady=4)
        ttk.Label(
            parent,
            text=u"\u2022 " + unicode(description),
            justify="left",
            wraplength=535).grid(
                row=row,
                column=2,
                sticky="w",
                pady=4)
        parent.columnconfigure(2, weight=1)
        return row + 1

    def _build_settings_tabs(self, notebook):
        # ----- General / Rendering / Stickout -----
        general = self._make_scrollable_settings_tab(notebook, "General")
        intro = ttk.Label(
            general,
            text=(
                "Basic display settings. Everything on this tab changes only what Gremlin looks like; "
                "none of it changes machine offsets or cutting behavior."),
            justify="left",
            wraplength=850)
        intro.pack(anchor="w", fill="x", pady=(0, 8))
        general_box = ttk.LabelFrame(general, text="Holder display and cutter stickout", padding=8)
        general_box.pack(fill="x")
        self.config_enabled_var = tk.BooleanVar(value=True)
        self.chamfer_enabled_var = tk.BooleanVar(value=False)
        self.vector_font_width_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["vector_font_line_width"]))
        self.stickout_mode_var = tk.StringVar(value="tool_table_length")
        self.default_stickout_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["default_stickout_inches"]))
        self.minimum_stickout_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["minimum_stickout_inches"]))
        row = 0
        row = self._settings_check(
            general_box, row,
            "Show holder visualization",
            self.config_enabled_var,
            "Turns the custom holder model on or off.")
        row = self._settings_check(
            general_box, row,
            "Black holder edges",
            self.edge_enabled_var,
            "Adds black outlines so the holder sections are easier to see.")
        row = self._settings_entry(
            general_box, row,
            "Holder edge width (px)",
            self.edge_width_var,
            "Thickness of the black holder outlines. Around 1 to 3 is usually plenty. Range: 0.5 to 10.")
        row = self._settings_check(
            general_box, row,
            "Chamfer holder edges",
            self.chamfer_enabled_var,
            "Adds small beveled-looking breaks where holder sections meet. Visual only; dimensions do not change.")
        row = self._settings_entry(
            general_box, row,
            "T# vector line width (px)",
            self.vector_font_width_var,
            "Thickness of the T# lettering drawn on the holder. Range: 0.5 to 10.")
        row = self._settings_combo(
            general_box, row,
            "Stickout mode",
            self.stickout_mode_var,
            ("tool_table_length", "default"),
            "Normally leave this on tool_table_length: Gremlin uses PathPilot tool length minus holder length. "
            "Default ignores that calculation and always uses Default stickout.")
        row = self._settings_entry(
            general_box, row,
            "Default stickout (in)",
            self.default_stickout_var,
            "Displayed cutter length used when Gremlin cannot calculate stickout, or when Stickout mode is Default.")
        row = self._settings_entry(
            general_box, row,
            "Minimum stickout (in)",
            self.minimum_stickout_var,
            "Visual sanity check. If calculated stickout is smaller than this, Gremlin uses Default stickout instead.")
        ttk.Label(
            general,
            text="Holder shapes and the default holder are edited on the Holders tab.",
            justify="left",
            wraplength=850).pack(anchor="w", fill="x", pady=(8, 0))
        # ----- Flute visuals -----
        flutes = self._make_scrollable_settings_tab(notebook, "Flutes")
        flute_box = ttk.LabelFrame(flutes, text="Flute appearance", padding=8)
        flute_box.pack(fill="x")
        self.flute_enabled_var = tk.BooleanVar(value=True)
        self.flute_default_count_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["default_count"]))
        self.flute_helix_angle_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["helix_angle_degrees"]))
        self.flute_width_percent_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["mark_width_percent"]))
        self.flute_width_scale_4_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["width_scale_by_flute_count"]["4"]))
        self.flute_width_scale_3_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["width_scale_by_flute_count"]["3"]))
        self.flute_width_scale_2_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["width_scale_by_flute_count"]["2"]))
        self.flute_width_scale_1_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["width_scale_by_flute_count"]["1"]))
        self.flute_surface_offset_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["surface_offset_inches"]))
        self.flute_segments_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["segments"]))
        self.flute_max_turns_var = tk.StringVar(value=str(CURRENT_CONFIG_DEFAULTS["flute_visuals"]["max_turns"]))
        row = 0
        row = self._settings_check(
            flute_box, row,
            "Enable flute marks",
            self.flute_enabled_var,
            "Turns the dark flute stripes on or off.")
        row = self._settings_entry(
            flute_box, row,
            "Default flute count",
            self.flute_default_count_var,
            "Used only when TLM has not set a flute count for that tool. Pick 1 to 6; a TLM value takes priority.")
        row = self._settings_entry(
            flute_box, row,
            "Helix angle (deg)",
            self.flute_helix_angle_var,
            "How much the flutes twist. Lower looks straighter; higher looks more spiral. "
            "End mills, ball mills, rounded-edge mills, and chamfer mills use this angle. Straight reamers/key-seat/dovetail styles override it; spiral reamers use their own angle. Range: -75 to 75.")
        row = self._settings_entry(
            flute_box, row,
            "Flute width (% of cutter OD)",
            self.flute_width_percent_var,
            "Base stripe width as a percent of cutter diameter. The flute-count multipliers below scale this base width. Range: 1 to 25%.")
        row = self._settings_entry(
            flute_box, row,
            "4-flute width multiplier",
            self.flute_width_scale_4_var,
            "Multiplier applied to the base flute width for 4-flute tools. Default: 1.00x.")
        row = self._settings_entry(
            flute_box, row,
            "3-flute width multiplier",
            self.flute_width_scale_3_var,
            "Multiplier applied to the base flute width for 3-flute tools. Default: 1.6667x.")
        row = self._settings_entry(
            flute_box, row,
            "2-flute width multiplier",
            self.flute_width_scale_2_var,
            "Multiplier applied to the base flute width for 2-flute tools. Default: 2.3333x.")
        row = self._settings_entry(
            flute_box, row,
            "1-flute width multiplier",
            self.flute_width_scale_1_var,
            "Multiplier applied to the base flute width for 1-flute tools. Default: 3.00x.")
        row = self._settings_entry(
            flute_box, row,
            "Surface lift (in)",
            self.flute_surface_offset_var,
            "Tiny gap that keeps the black flute from flickering into the gray cutter. Usually leave this at 0.002 in.")
        row = self._settings_entry(
            flute_box, row,
            "Curve smoothness (segments)",
            self.flute_segments_var,
            "64 is a good normal setting. Higher looks smoother when zoomed in and uses a little more cached geometry. "
            "Range: 48 to 128.")
        row = self._settings_entry(
            flute_box, row,
            "Maximum wraps",
            self.flute_max_turns_var,
            "Limits how many revolutions one flute can make. Example: 1.5 means no more than one-and-a-half wraps, "
            "even if Helix angle would otherwise make more. Range: 0.25 to 3.")
        # ----- Tool models -----
        models = self._make_scrollable_settings_tab(notebook, "Tool Models")
        ttk.Label(
            models,
            text=(
                "Fixed cutter-family settings used by the Tool Model selector in TLM. PathPilot always supplies the maximum cutting OD; "
                "these values only control radii, axial proportions, reduced necks/shanks, and the dovetail/spiral style."),
            justify="left",
            wraplength=850).pack(anchor="w", fill="x", pady=(0, 8))
        d = CURRENT_CONFIG_DEFAULTS["tool_model_dimensions"]
        self.model_vars = {
            "ball_mill.hemisphere_segments": tk.StringVar(value=str(d["ball_mill"]["hemisphere_segments"])),
            "bull_nose_end_mill.corner_radius_inches": tk.StringVar(value=str(d["bull_nose_end_mill"]["corner_radius_inches"])),
            "bull_nose_end_mill.corner_segments": tk.StringVar(value=str(d["bull_nose_end_mill"]["corner_segments"])),
            "reamer_straight.cutting_length_inches": tk.StringVar(value=str(d["reamer_straight"]["cutting_length_inches"])),
            "reamer_straight.shank_diameter_ratio": tk.StringVar(value=str(d["reamer_straight"]["shank_diameter_ratio"])),
            "reamer_spiral.cutting_length_inches": tk.StringVar(value=str(d["reamer_spiral"]["cutting_length_inches"])),
            "reamer_spiral.shank_diameter_ratio": tk.StringVar(value=str(d["reamer_spiral"]["shank_diameter_ratio"])),
            "reamer_spiral.helix_angle_degrees": tk.StringVar(value=str(d["reamer_spiral"]["helix_angle_degrees"])),
            "chamfer_90.tip_diameter_inches": tk.StringVar(value=str(d["chamfer_90"]["tip_diameter_inches"])),
            "chamfer_45.tip_diameter_inches": tk.StringVar(value=str(d["chamfer_45"]["tip_diameter_inches"])),
            "drill_bit.included_angle_degrees": tk.StringVar(value=str(d["drill_bit"]["included_angle_degrees"])),
            "drill_bit.tip_diameter_inches": tk.StringVar(value=str(d["drill_bit"]["tip_diameter_inches"])),
            "key_cutter.head_thickness_inches": tk.StringVar(value=str(d["key_cutter"]["head_thickness_inches"])),
            "key_cutter.neck_diameter_inches": tk.StringVar(value=str(d["key_cutter"]["neck_diameter_inches"])),
            "key_cutter.neck_length_inches": tk.StringVar(value=str(d["key_cutter"]["neck_length_inches"])),
            "key_cutter.shank_diameter_inches": tk.StringVar(value=str(d["key_cutter"]["shank_diameter_inches"])),
            "key_cutter_rounded.head_thickness_inches": tk.StringVar(value=str(d["key_cutter_rounded"]["head_thickness_inches"])),
            "key_cutter_rounded.edge_radius_inches": tk.StringVar(value=str(d["key_cutter_rounded"]["edge_radius_inches"])),
            "key_cutter_rounded.edge_segments": tk.StringVar(value=str(d["key_cutter_rounded"]["edge_segments"])),
            "key_cutter_rounded.neck_diameter_inches": tk.StringVar(value=str(d["key_cutter_rounded"]["neck_diameter_inches"])),
            "key_cutter_rounded.neck_length_inches": tk.StringVar(value=str(d["key_cutter_rounded"]["neck_length_inches"])),
            "key_cutter_rounded.shank_diameter_inches": tk.StringVar(value=str(d["key_cutter_rounded"]["shank_diameter_inches"])),
            "dovetail.included_angle_degrees": tk.StringVar(value=str(d["dovetail"]["included_angle_degrees"])),
            "dovetail.neck_diameter_inches": tk.StringVar(value=str(d["dovetail"]["neck_diameter_inches"])),
            "dovetail.neck_length_inches": tk.StringVar(value=str(d["dovetail"]["neck_length_inches"])),
            "dovetail.shank_diameter_inches": tk.StringVar(value=str(d["dovetail"]["shank_diameter_inches"])),
            "thread_mill.included_angle_degrees": tk.StringVar(value=str(d["thread_mill"]["included_angle_degrees"])),
            "thread_mill.neck_diameter_inches": tk.StringVar(value=str(d["thread_mill"]["neck_diameter_inches"])),
            "thread_mill.neck_length_inches": tk.StringVar(value=str(d["thread_mill"]["neck_length_inches"])),
            "thread_mill.shank_diameter_inches": tk.StringVar(value=str(d["thread_mill"]["shank_diameter_inches"])),
            "thread_mill_0250.included_angle_degrees": tk.StringVar(value=str(d["thread_mill_0250"]["included_angle_degrees"])),
            "thread_mill_0250.neck_diameter_inches": tk.StringVar(value=str(d["thread_mill_0250"]["neck_diameter_inches"])),
            "thread_mill_0250.neck_length_inches": tk.StringVar(value=str(d["thread_mill_0250"]["neck_length_inches"])),
            "thread_mill_0250.shank_diameter_inches": tk.StringVar(value=str(d["thread_mill_0250"]["shank_diameter_inches"])),
            "thread_mill_01875.included_angle_degrees": tk.StringVar(value=str(d["thread_mill_01875"]["included_angle_degrees"])),
            "thread_mill_01875.neck_diameter_inches": tk.StringVar(value=str(d["thread_mill_01875"]["neck_diameter_inches"])),
            "thread_mill_01875.neck_length_inches": tk.StringVar(value=str(d["thread_mill_01875"]["neck_length_inches"])),
            "thread_mill_01875.shank_diameter_inches": tk.StringVar(value=str(d["thread_mill_01875"]["shank_diameter_inches"])),}
        ball_box = ttk.LabelFrame(models, text="Ball / Rounded End Mills", padding=8)
        ball_box.pack(fill="x", pady=(0, 6))
        row = 0
        row = self._settings_entry(ball_box, row, "Ball-end smoothness", self.model_vars["ball_mill.hemisphere_segments"], "Ball mill hemisphere segments. Range: 4 to 32.")
        row = self._settings_entry(ball_box, row, "Rounded-edge corner radius (in)", self.model_vars["bull_nose_end_mill.corner_radius_inches"], "Corner radius for the Rounded Edge End Mill. The major OD still comes from PathPilot.")
        row = self._settings_entry(ball_box, row, "Rounded-edge smoothness", self.model_vars["bull_nose_end_mill.corner_segments"], "Quarter-round segments. Range: 4 to 24.")
        reamer_box = ttk.LabelFrame(models, text="Reamers", padding=8)
        reamer_box.pack(fill="x", pady=(0, 6))
        row = 0
        row = self._settings_entry(reamer_box, row, "Straight cutting length (in)", self.model_vars["reamer_straight.cutting_length_inches"], "Full-OD cutting length for Straight Reamer.")
        row = self._settings_entry(reamer_box, row, "Straight shank size ratio", self.model_vars["reamer_straight.shank_diameter_ratio"], "Reduced shank as a fraction of cutter OD; no imposed minimum or maximum.")
        row = self._settings_entry(reamer_box, row, "Spiral cutting length (in)", self.model_vars["reamer_spiral.cutting_length_inches"], "Full-OD cutting length for Spiral Reamer.")
        row = self._settings_entry(reamer_box, row, "Spiral shank size ratio", self.model_vars["reamer_spiral.shank_diameter_ratio"], "Reduced shank as a fraction of cutter OD; no imposed minimum or maximum.")
        row = self._settings_entry(reamer_box, row, "Spiral helix angle (deg)", self.model_vars["reamer_spiral.helix_angle_degrees"], "Visual spiral angle. Range: -75 to 75 degrees.")
        chamfer_box = ttk.LabelFrame(models, text="Chamfer Mills", padding=8)
        chamfer_box.pack(fill="x", pady=(0, 6))
        row = 0
        row = self._settings_entry(chamfer_box, row, "90 deg tip flat diameter (in)", self.model_vars["chamfer_90.tip_diameter_inches"], "Tip flat for the fixed 90-degree chamfer model. Major OD comes from PathPilot.")
        row = self._settings_entry(chamfer_box, row, "45 deg tip flat diameter (in)", self.model_vars["chamfer_45.tip_diameter_inches"], "Tip flat for the fixed 45-degree chamfer model. Major OD comes from PathPilot.")
        drill_box = ttk.LabelFrame(models, text="Drill Bit", padding=8)
        drill_box.pack(fill="x", pady=(0, 6))
        row = 0
        row = self._settings_entry(drill_box, row, "Point angle (deg)", self.model_vars["drill_bit.included_angle_degrees"], "Included drill-point angle. 118 degrees is the default.")
        row = self._settings_entry(drill_box, row, "Tip flat diameter (in)", self.model_vars["drill_bit.tip_diameter_inches"], "Optional flat at the drill point. Major OD comes from PathPilot.")
        key_box = ttk.LabelFrame(models, text="Key Seat Cutter", padding=8)
        key_box.pack(fill="x", pady=(0, 6))
        row = 0
        row = self._settings_entry(key_box, row, "Head thickness (in)", self.model_vars["key_cutter.head_thickness_inches"], "Wide cutting-head thickness. Head OD comes from PathPilot.")
        row = self._settings_entry(key_box, row, "Neck diameter (in)", self.model_vars["key_cutter.neck_diameter_inches"], "Reduced neck diameter above the cutting head.")
        row = self._settings_entry(key_box, row, "Neck length (in)", self.model_vars["key_cutter.neck_length_inches"], "Reduced neck length.")
        row = self._settings_entry(key_box, row, "Shank diameter (in)", self.model_vars["key_cutter.shank_diameter_inches"], "Upper shank diameter.")
        rounded_key_box = ttk.LabelFrame(models, text="Rounded Key Seat Cutter", padding=8)
        rounded_key_box.pack(fill="x", pady=(0, 6))
        row = 0
        row = self._settings_entry(rounded_key_box, row, "Head thickness (in)", self.model_vars["key_cutter_rounded.head_thickness_inches"], "Wide cutting-head thickness. Head OD comes from PathPilot.")
        row = self._settings_entry(rounded_key_box, row, "Outer edge radius (in)", self.model_vars["key_cutter_rounded.edge_radius_inches"], "Rounds the outer top and bottom cutting corners; this is not a full Woodruff profile.")
        row = self._settings_entry(rounded_key_box, row, "Edge smoothness", self.model_vars["key_cutter_rounded.edge_segments"], "Quarter-round segments. Range: 3 to 20.")
        row = self._settings_entry(rounded_key_box, row, "Neck diameter (in)", self.model_vars["key_cutter_rounded.neck_diameter_inches"], "Reduced neck diameter above the cutting head.")
        row = self._settings_entry(rounded_key_box, row, "Neck length (in)", self.model_vars["key_cutter_rounded.neck_length_inches"], "Reduced neck length.")
        row = self._settings_entry(rounded_key_box, row, "Shank diameter (in)", self.model_vars["key_cutter_rounded.shank_diameter_inches"], "Upper shank diameter.")
        dovetail_box = ttk.LabelFrame(models, text="Dovetail Cutter", padding=8)
        dovetail_box.pack(fill="x")
        row = 0
        row = self._settings_entry(dovetail_box, row, "Included angle (deg)", self.model_vars["dovetail.included_angle_degrees"], "Included angle of the flared cutting head. Range: 10 to 170.")
        row = self._settings_entry(dovetail_box, row, "Neck diameter (in)", self.model_vars["dovetail.neck_diameter_inches"], "Reduced diameter above the dovetail head.")
        row = self._settings_entry(dovetail_box, row, "Neck length (in)", self.model_vars["dovetail.neck_length_inches"], "Reduced neck length.")
        row = self._settings_entry(dovetail_box, row, "Shank diameter (in)", self.model_vars["dovetail.shank_diameter_inches"], "Upper shank diameter.")
        thread_box = ttk.LabelFrame(models, text="Thread Mill", padding=8)
        thread_box.pack(fill="x", pady=(6, 0))
        row = 0
        row = self._settings_entry(thread_box, row, "Thread included angle (deg)", self.model_vars["thread_mill.included_angle_degrees"], "Included V angle for the single-form tooth. 60 degrees is standard for Unified/ISO threads. Range: 10 to 170.")
        row = self._settings_entry(thread_box, row, "Neck / root diameter (in)", self.model_vars["thread_mill.neck_diameter_inches"], "Root diameter at both sides of the V tooth and reduced neck diameter above it. Maximum cutting OD always comes from PathPilot.")
        row = self._settings_entry(thread_box, row, "Neck length (in)", self.model_vars["thread_mill.neck_length_inches"], "Reduced neck length above the thread tooth.")
        row = self._settings_entry(thread_box, row, "Shank diameter (in)", self.model_vars["thread_mill.shank_diameter_inches"], "Upper shank diameter.")
        thread_small_box = ttk.LabelFrame(models, text="Thread Mill - 1/4 Shank", padding=8)
        thread_small_box.pack(fill="x", pady=(6, 0))
        row = 0
        row = self._settings_entry(thread_small_box, row, "Thread included angle (deg)", self.model_vars["thread_mill_0250.included_angle_degrees"], "Included V angle for the single-form tooth. Cutter OD always comes from PathPilot.")
        row = self._settings_entry(thread_small_box, row, "Neck / root diameter (in)", self.model_vars["thread_mill_0250.neck_diameter_inches"], "Smaller preset defaults to a 3/16-inch neck.")
        row = self._settings_entry(thread_small_box, row, "Neck length (in)", self.model_vars["thread_mill_0250.neck_length_inches"], "Reduced neck length above the thread tooth.")
        row = self._settings_entry(thread_small_box, row, "Shank diameter (in)", self.model_vars["thread_mill_0250.shank_diameter_inches"], "Smaller preset defaults to a 1/4-inch shank.")
        thread_micro_box = ttk.LabelFrame(models, text="Thread Mill - 3/16 Shank", padding=8)
        thread_micro_box.pack(fill="x", pady=(6, 0))
        row = 0
        row = self._settings_entry(thread_micro_box, row, "Thread included angle (deg)", self.model_vars["thread_mill_01875.included_angle_degrees"], "Included V angle for the single-form tooth. Cutter OD always comes from PathPilot.")
        row = self._settings_entry(thread_micro_box, row, "Neck / root diameter (in)", self.model_vars["thread_mill_01875.neck_diameter_inches"], "Smallest preset defaults to a 1/8-inch neck.")
        row = self._settings_entry(thread_micro_box, row, "Neck length (in)", self.model_vars["thread_mill_01875.neck_length_inches"], "Reduced neck length above the thread tooth.")
        row = self._settings_entry(thread_micro_box, row, "Shank diameter (in)", self.model_vars["thread_mill_01875.shank_diameter_inches"], "Smallest preset defaults to a 3/16-inch shank.")
        # ----- Per-tool overrides -----
        overrides = self._make_scrollable_settings_tab(notebook, "Overrides")
        ttk.Label(
            overrides,
            text=(
                "Advanced exceptions for individual tools. Most users can leave both boxes as {}. "
                "Use these only when one specific T-number needs to look different from the normal settings."),
            justify="left",
            wraplength=850).pack(anchor="w", fill="x", pady=(0, 8))
        stickout_box = ttk.LabelFrame(overrides, text="Per-tool stickout", padding=8)
        stickout_box.pack(fill="x", pady=(0, 6))
        ttk.Label(
            stickout_box,
            text=(
                "Force a specific displayed cutter stickout for selected tools. "
                "Example: {\"6\": 1.25, \"T12\": 2.0} makes T6 show 1.25 in and T12 show 2.0 in."),
            justify="left",
            wraplength=820).pack(anchor="w", fill="x", pady=(0, 5))
        self.tool_stickout_text = tk.Text(stickout_box, height=6, wrap="none", font=("TkFixedFont", 10))
        self.tool_stickout_text.pack(fill="both", expand=True)
        model_override_box = ttk.LabelFrame(overrides, text="Per-tool model dimensions", padding=8)
        model_override_box.pack(fill="x")
        ttk.Label(
            model_override_box,
            text=(
                "Change model dimensions for only one T-number without changing the Tool Models tab for everyone. "
                "Use the same JSON setting names. Example: {\"6\": {\"hemisphere_segments\": 18}} makes only T6 use a smoother ball end."),
            justify="left",
            wraplength=820).pack(anchor="w", fill="x", pady=(0, 5))
        self.tool_model_overrides_text = tk.Text(model_override_box, height=7, wrap="none", font=("TkFixedFont", 10))
        self.tool_model_overrides_text.pack(fill="both", expand=True)

    def load_settings_ui(self):
        if not self.data:
            return
        old_loading = self.loading_ui
        self.loading_ui = True
        try:
            self.config_enabled_var.set(bool(self.data.get("enabled", True)))
            self.edge_enabled_var.set(bool(self.data.get("edge_enabled", True)))
            self.edge_width_var.set(str(self.data.get("edge_width", CURRENT_CONFIG_DEFAULTS["edge_width"])))
            self.chamfer_enabled_var.set(bool(self.data.get("chamfer_enabled", False)))
            self.vector_font_width_var.set(str(self.data.get("vector_font_line_width", CURRENT_CONFIG_DEFAULTS["vector_font_line_width"])))
            self.stickout_mode_var.set(str(self.data.get("stickout_mode", "tool_table_length")))
            self.default_stickout_var.set(str(self.data.get("default_stickout_inches", CURRENT_CONFIG_DEFAULTS["default_stickout_inches"])))
            self.minimum_stickout_var.set(str(self.data.get("minimum_stickout_inches", CURRENT_CONFIG_DEFAULTS["minimum_stickout_inches"])))
            flute = self.data.get("flute_visuals", {})
            if not isinstance(flute, dict):
                flute = {}
            self.flute_enabled_var.set(bool(flute.get("enabled", True)))
            self.flute_default_count_var.set(str(flute.get("default_count", CURRENT_CONFIG_DEFAULTS["flute_visuals"]["default_count"])))
            self.flute_helix_angle_var.set(str(flute.get("helix_angle_degrees", CURRENT_CONFIG_DEFAULTS["flute_visuals"]["helix_angle_degrees"])))
            self.flute_width_percent_var.set(str(flute.get("mark_width_percent", CURRENT_CONFIG_DEFAULTS["flute_visuals"]["mark_width_percent"])))
            width_scales = flute.get("width_scale_by_flute_count", {})
            if not isinstance(width_scales, dict):
                width_scales = {}
            default_scales = CURRENT_CONFIG_DEFAULTS["flute_visuals"]["width_scale_by_flute_count"]
            self.flute_width_scale_4_var.set(str(width_scales.get("4", default_scales["4"])))
            self.flute_width_scale_3_var.set(str(width_scales.get("3", default_scales["3"])))
            self.flute_width_scale_2_var.set(str(width_scales.get("2", default_scales["2"])))
            self.flute_width_scale_1_var.set(str(width_scales.get("1", default_scales["1"])))
            self.flute_surface_offset_var.set(str(flute.get("surface_offset_inches", CURRENT_CONFIG_DEFAULTS["flute_visuals"]["surface_offset_inches"])))
            self.flute_segments_var.set(str(flute.get("segments", CURRENT_CONFIG_DEFAULTS["flute_visuals"]["segments"])))
            self.flute_max_turns_var.set(str(flute.get("max_turns", CURRENT_CONFIG_DEFAULTS["flute_visuals"]["max_turns"])))
            dimensions = self.data.get("tool_model_dimensions", {})
            if not isinstance(dimensions, dict):
                dimensions = {}
            defaults = {}
            for dotted_key in self.model_vars.keys():
                model_name, setting_name = dotted_key.split(".", 1)
                defaults[dotted_key] = CURRENT_CONFIG_DEFAULTS["tool_model_dimensions"][model_name][setting_name]
            for dotted_key, var in self.model_vars.items():
                model_name, setting_name = dotted_key.split(".", 1)
                model_data = dimensions.get(model_name, {})
                if not isinstance(model_data, dict):
                    model_data = {}
                if not model_data and model_name in ("reamer_straight", "reamer_spiral"):
                    legacy = dimensions.get("reamer", {})
                    if isinstance(legacy, dict):
                        model_data = legacy
                elif not model_data and model_name in ("chamfer_90", "chamfer_45"):
                    legacy = dimensions.get("chamfer_drill", {})
                    if isinstance(legacy, dict):
                        model_data = legacy
                var.set(str(model_data.get(setting_name, defaults[dotted_key])))
            self.tool_stickout_text.delete("1.0", "end")
            self.tool_stickout_text.insert(
                "1.0",
                json.dumps(
                    self.data.get("tool_stickout_inches", {}),
                    indent=4,
                    sort_keys=True))
            self.tool_model_overrides_text.delete("1.0", "end")
            self.tool_model_overrides_text.insert(
                "1.0",
                json.dumps(
                    self.data.get("tool_model_overrides", {}),
                    indent=4,
                    sort_keys=True))
        finally:
            for widget in (
                    self.tool_stickout_text,
                    self.tool_model_overrides_text):
                try:
                    widget.edit_modified(False)
                except Exception:
                    pass
            self.loading_ui = old_loading

    def _setting_float(self, var, label, minimum=None, maximum=None):
        try:
            value = numeric_value(var.get())
        except Exception:
            raise ValueError("%s must be numeric." % label)
        if minimum is not None and value < minimum:
            raise ValueError("%s must be at least %s." % (label, minimum))
        if maximum is not None and value > maximum:
            raise ValueError("%s must be no more than %s." % (label, maximum))
        return value

    def _setting_int(self, var, label, minimum=None, maximum=None):
        try:
            value = int(numeric_value(var.get()))
        except Exception:
            raise ValueError("%s must be a whole number." % label)
        if minimum is not None and value < minimum:
            raise ValueError("%s must be at least %s." % (label, minimum))
        if maximum is not None and value > maximum:
            raise ValueError("%s must be no more than %s." % (label, maximum))
        return value

    def _text_json_object(self, widget, label):
        raw = widget.get("1.0", "end").strip()
        if not raw:
            return {}
        try:
            value = json.loads(raw)
        except Exception as exc:
            raise ValueError("%s is not valid JSON: %s" % (label, exc))
        if not isinstance(value, dict):
            raise ValueError("%s must be a JSON object/dictionary." % label)
        return value

    def commit_settings_from_ui(self, show_errors=True, record_history=True):
        if self.loading_ui or not self.data:
            return True
        if record_history and not self._history_restoring:
            self._history_checkpoint("Apply settings")
        try:
            self.data["enabled"] = bool(self.config_enabled_var.get())
            self.data["edge_enabled"] = bool(self.edge_enabled_var.get())
            self.data["edge_width"] = self._setting_float(self.edge_width_var, "Holder edge width", 0.5, 10.0)
            self.data["chamfer_enabled"] = bool(self.chamfer_enabled_var.get())
            self.data["vector_font_line_width"] = self._setting_float(
                self.vector_font_width_var, "T# vector line width", 0.5, 10.0)
            mode = str(self.stickout_mode_var.get() or "").strip()
            if mode not in ("tool_table_length", "default"):
                raise ValueError("Stickout mode must be 'tool_table_length' or 'default'.")
            self.data["stickout_mode"] = mode
            self.data["default_stickout_inches"] = self._setting_float(
                self.default_stickout_var, "Default stickout", 0.0001, None)
            self.data["minimum_stickout_inches"] = self._setting_float(
                self.minimum_stickout_var, "Minimum stickout", 0.0001, None)
            flute = self.data.get("flute_visuals", {})
            if not isinstance(flute, dict):
                flute = {}
            flute["enabled"] = bool(self.flute_enabled_var.get())
            flute["default_count"] = self._setting_int(self.flute_default_count_var, "Default flute count", 1, 6)
            flute["helix_angle_degrees"] = self._setting_float(self.flute_helix_angle_var, "Helix angle", -75.0, 75.0)
            flute["mark_width_percent"] = self._setting_float(
                self.flute_width_percent_var, "Flute mark width percent", 1.0, 25.0)
            flute["width_scale_by_flute_count"] = {
                "4": self._setting_float(self.flute_width_scale_4_var, "4-flute width multiplier", 0.10, 5.00),
                "3": self._setting_float(self.flute_width_scale_3_var, "3-flute width multiplier", 0.10, 5.00),
                "2": self._setting_float(self.flute_width_scale_2_var, "2-flute width multiplier", 0.10, 5.00),
                "1": self._setting_float(self.flute_width_scale_1_var, "1-flute width multiplier", 0.10, 5.00),
            }
            flute["surface_offset_inches"] = self._setting_float(
                self.flute_surface_offset_var, "Flute surface offset", 0.0005, 0.010)
            flute["segments"] = self._setting_int(self.flute_segments_var, "Flute length segments", 48, 128)
            flute["max_turns"] = self._setting_float(self.flute_max_turns_var, "Maximum flute turns", 0.25, 3.0)
            self.data["flute_visuals"] = flute
            dimensions = self.data.get("tool_model_dimensions", {})
            if not isinstance(dimensions, dict):
                dimensions = {}

            def model_dict(name):
                value = dimensions.get(name, {})
                return value if isinstance(value, dict) else {}

            ball = model_dict("ball_mill")
            ball["hemisphere_segments"] = self._setting_int(self.model_vars["ball_mill.hemisphere_segments"], "Ball Mill hemisphere segments", 4, 32)
            dimensions["ball_mill"] = ball
            bull = model_dict("bull_nose_end_mill")
            bull["corner_radius_inches"] = self._setting_float(self.model_vars["bull_nose_end_mill.corner_radius_inches"], "Rounded Edge End Mill corner radius", 0.0, None)
            bull["corner_segments"] = self._setting_int(self.model_vars["bull_nose_end_mill.corner_segments"], "Rounded Edge End Mill corner segments", 4, 24)
            dimensions["bull_nose_end_mill"] = bull
            rs = model_dict("reamer_straight")
            rs["cutting_length_inches"] = self._setting_float(self.model_vars["reamer_straight.cutting_length_inches"], "Straight Reamer cutting length", 0.01, None)
            rs["shank_diameter_ratio"] = self._setting_float(self.model_vars["reamer_straight.shank_diameter_ratio"], "Straight Reamer shank diameter ratio")
            dimensions["reamer_straight"] = rs
            rp = model_dict("reamer_spiral")
            rp["cutting_length_inches"] = self._setting_float(self.model_vars["reamer_spiral.cutting_length_inches"], "Spiral Reamer cutting length", 0.01, None)
            rp["shank_diameter_ratio"] = self._setting_float(self.model_vars["reamer_spiral.shank_diameter_ratio"], "Spiral Reamer shank diameter ratio")
            rp["helix_angle_degrees"] = self._setting_float(self.model_vars["reamer_spiral.helix_angle_degrees"], "Spiral Reamer helix angle", -75.0, 75.0)
            dimensions["reamer_spiral"] = rp
            c90 = model_dict("chamfer_90")
            c90["tip_diameter_inches"] = self._setting_float(self.model_vars["chamfer_90.tip_diameter_inches"], "90 degree chamfer tip diameter", 0.0, None)
            dimensions["chamfer_90"] = c90
            c45 = model_dict("chamfer_45")
            c45["tip_diameter_inches"] = self._setting_float(self.model_vars["chamfer_45.tip_diameter_inches"], "45 degree chamfer tip diameter", 0.0, None)
            dimensions["chamfer_45"] = c45
            drill = model_dict("drill_bit")
            drill["included_angle_degrees"] = self._setting_float(self.model_vars["drill_bit.included_angle_degrees"], "Drill Bit point angle", 10.0, 170.0)
            drill["tip_diameter_inches"] = self._setting_float(self.model_vars["drill_bit.tip_diameter_inches"], "Drill Bit tip flat diameter", 0.0, None)
            dimensions["drill_bit"] = drill
            key_cutter = model_dict("key_cutter")
            key_cutter["head_thickness_inches"] = self._setting_float(self.model_vars["key_cutter.head_thickness_inches"], "Key Seat Cutter head thickness", 0.0001, None)
            key_cutter["neck_diameter_inches"] = self._setting_float(self.model_vars["key_cutter.neck_diameter_inches"], "Key Seat Cutter neck diameter")
            key_cutter["neck_length_inches"] = self._setting_float(self.model_vars["key_cutter.neck_length_inches"], "Key Seat Cutter neck length", 0.0, None)
            key_cutter["shank_diameter_inches"] = self._setting_float(self.model_vars["key_cutter.shank_diameter_inches"], "Key Seat Cutter shank diameter")
            dimensions["key_cutter"] = key_cutter
            rounded = model_dict("key_cutter_rounded")
            rounded["head_thickness_inches"] = self._setting_float(self.model_vars["key_cutter_rounded.head_thickness_inches"], "Rounded Key Seat head thickness", 0.0001, None)
            rounded["edge_radius_inches"] = self._setting_float(self.model_vars["key_cutter_rounded.edge_radius_inches"], "Rounded Key Seat edge radius", 0.0, None)
            rounded["edge_segments"] = self._setting_int(self.model_vars["key_cutter_rounded.edge_segments"], "Rounded Key Seat edge segments", 3, 20)
            rounded["neck_diameter_inches"] = self._setting_float(self.model_vars["key_cutter_rounded.neck_diameter_inches"], "Rounded Key Seat neck diameter")
            rounded["neck_length_inches"] = self._setting_float(self.model_vars["key_cutter_rounded.neck_length_inches"], "Rounded Key Seat neck length", 0.0, None)
            rounded["shank_diameter_inches"] = self._setting_float(self.model_vars["key_cutter_rounded.shank_diameter_inches"], "Rounded Key Seat shank diameter")
            dimensions["key_cutter_rounded"] = rounded
            dovetail = model_dict("dovetail")
            dovetail["included_angle_degrees"] = self._setting_float(self.model_vars["dovetail.included_angle_degrees"], "Dovetail included angle", 10.0, 170.0)
            dovetail["neck_diameter_inches"] = self._setting_float(self.model_vars["dovetail.neck_diameter_inches"], "Dovetail neck diameter")
            dovetail["neck_length_inches"] = self._setting_float(self.model_vars["dovetail.neck_length_inches"], "Dovetail neck length", 0.0, None)
            dovetail["shank_diameter_inches"] = self._setting_float(self.model_vars["dovetail.shank_diameter_inches"], "Dovetail shank diameter")
            dimensions["dovetail"] = dovetail
            thread_mill = model_dict("thread_mill")
            thread_mill["included_angle_degrees"] = self._setting_float(self.model_vars["thread_mill.included_angle_degrees"], "Thread Mill included angle", 10.0, 170.0)
            thread_mill["neck_diameter_inches"] = self._setting_float(self.model_vars["thread_mill.neck_diameter_inches"], "Thread Mill neck diameter")
            thread_mill["neck_length_inches"] = self._setting_float(self.model_vars["thread_mill.neck_length_inches"], "Thread Mill neck length", 0.0, None)
            thread_mill["shank_diameter_inches"] = self._setting_float(self.model_vars["thread_mill.shank_diameter_inches"], "Thread Mill shank diameter")
            dimensions["thread_mill"] = thread_mill
            thread_mill_0250 = model_dict("thread_mill_0250")
            thread_mill_0250["included_angle_degrees"] = self._setting_float(self.model_vars["thread_mill_0250.included_angle_degrees"], "Thread Mill 1/4 shank included angle", 10.0, 170.0)
            thread_mill_0250["neck_diameter_inches"] = self._setting_float(self.model_vars["thread_mill_0250.neck_diameter_inches"], "Thread Mill 1/4 shank neck diameter")
            thread_mill_0250["neck_length_inches"] = self._setting_float(self.model_vars["thread_mill_0250.neck_length_inches"], "Thread Mill 1/4 shank neck length", 0.0, None)
            thread_mill_0250["shank_diameter_inches"] = self._setting_float(self.model_vars["thread_mill_0250.shank_diameter_inches"], "Thread Mill 1/4 shank diameter")
            dimensions["thread_mill_0250"] = thread_mill_0250
            thread_mill_01875 = model_dict("thread_mill_01875")
            thread_mill_01875["included_angle_degrees"] = self._setting_float(self.model_vars["thread_mill_01875.included_angle_degrees"], "Thread Mill 3/16 shank included angle", 10.0, 170.0)
            thread_mill_01875["neck_diameter_inches"] = self._setting_float(self.model_vars["thread_mill_01875.neck_diameter_inches"], "Thread Mill 3/16 shank neck diameter")
            thread_mill_01875["neck_length_inches"] = self._setting_float(self.model_vars["thread_mill_01875.neck_length_inches"], "Thread Mill 3/16 shank neck length", 0.0, None)
            thread_mill_01875["shank_diameter_inches"] = self._setting_float(self.model_vars["thread_mill_01875.shank_diameter_inches"], "Thread Mill 3/16 shank diameter")
            dimensions["thread_mill_01875"] = thread_mill_01875
            self.data["tool_model_dimensions"] = dimensions
            stickout_overrides = self._text_json_object(self.tool_stickout_text, "tool_stickout_inches")
            for tool_key, value in stickout_overrides.items():
                try:
                    stickout_value = numeric_value(value)
                except Exception:
                    raise ValueError("tool_stickout_inches value for %s must be numeric." % tool_key)
                if stickout_value <= 0.0:
                    raise ValueError("tool_stickout_inches value for %s must be greater than zero." % tool_key)
            self.data["tool_stickout_inches"] = stickout_overrides
            model_overrides = self._text_json_object(self.tool_model_overrides_text, "tool_model_overrides")
            for tool_key, value in model_overrides.items():
                if not isinstance(value, dict):
                    raise ValueError("tool_model_overrides value for %s must be a JSON object." % tool_key)
            self.data["tool_model_overrides"] = model_overrides
            return True
        except ValueError as exc:
            if show_errors:
                messagebox.showerror(APP_TITLE, str(exc))
            return False

    def _set_initial_split(self):
        """Start the main panes at approximately 50/50."""
        try:
            self.update_idletasks()
            width = self.main_paned.winfo_width()
            if width > 200:
                self.main_paned.sashpos(0, int(width * 0.50))
        except Exception:
            # Older ttk implementations may not expose sashpos reliably.
            try:
                width = self.main_paned.winfo_width()
                self.main_paned.sash_place(0, int(width * 0.50), 1)
            except Exception:
                pass

    def _pointer_over_widget(self, widget):
        try:
            px = self.winfo_pointerx()
            py = self.winfo_pointery()
            x1 = widget.winfo_rootx()
            y1 = widget.winfo_rooty()
            x2 = x1 + widget.winfo_width()
            y2 = y1 + widget.winfo_height()
            return x1 <= px < x2 and y1 <= py < y2
        except Exception:
            return False

    def _widget_is_descendant(self, widget, ancestor):
        current = widget
        while current is not None:
            if current == ancestor:
                return True
            try:
                current = current.master
            except Exception:
                return False
        return False

    def _vertical_scroll_target(self):
        try:
            if self._pointer_over_widget(self.right_canvas):
                return self.right_canvas
            for canvas, content in self.settings_scroll_regions:
                if self._pointer_over_widget(canvas) or self._pointer_over_widget(content):
                    return canvas
            focus = self.focus_get()
            if focus is not None:
                if self._widget_is_descendant(focus, self.right):
                    return self.right_canvas
                for canvas, content in self.settings_scroll_regions:
                    if self._widget_is_descendant(focus, content):
                        return canvas
        except Exception:
            pass
        return None

    def _install_settings_scroll_bindings(self):
        """Give settings-tab scrolling priority over Entry/Combobox class bindings."""
        tag = "BeagleSettingsScroll"
        try:
            self.bind_class(tag, "<MouseWheel>", self._on_global_vertical_scroll)
            self.bind_class(tag, "<Button-4>", self._on_global_vertical_scroll)
            self.bind_class(tag, "<Button-5>", self._on_global_vertical_scroll)
            self.bind_class(tag, "<Prior>", self._on_global_page_scroll)
            self.bind_class(tag, "<Next>", self._on_global_page_scroll)
        except Exception:
            return

        def add_tag(widget):
            try:
                tags = tuple(widget.bindtags())
                if tag not in tags:
                    widget.bindtags((tag,) + tags)
            except Exception:
                pass
            try:
                for child in widget.winfo_children():
                    add_tag(child)
            except Exception:
                pass

        for canvas, content in self.settings_scroll_regions:
            add_tag(canvas)
            add_tag(content)

    def _on_global_vertical_scroll(self, event):
        target = self._vertical_scroll_target()
        if target is None:
            return None
        try:
            if getattr(event, "num", None) == 4:
                units = -3
            elif getattr(event, "num", None) == 5:
                units = 3
            else:
                delta = int(getattr(event, "delta", 0) or 0)
                if delta == 0:
                    return None
                units = -3 if delta > 0 else 3
            target.yview_scroll(units, "units")
            return "break"
        except Exception:
            return None

    def _on_global_page_scroll(self, event):
        target = self._vertical_scroll_target()
        if target is None:
            return None
        try:
            direction = -1 if getattr(event, "keysym", "") == "Prior" else 1
            target.yview_scroll(direction, "pages")
            return "break"
        except Exception:
            return None

    @staticmethod
    def _hex_across_flats_from_storage(value):
        # Gremlin's legacy hex primitive stores the circumscribed diameter
        # (corner-to-corner). The builder presents the standard machinist
        # dimension instead: across flats.
        return float(value) * math.cos(math.pi / 6.0)

    @staticmethod
    def _hex_storage_from_across_flats(value):
        cosine = math.cos(math.pi / 6.0)
        return float(value) / cosine if cosine > 0.0 else float(value)

    def current_schema_issues(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 holder_types.")
        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 not holder_types:
            issues.append("holder_types must be a non-empty JSON object.")
            return issues
        default_holder = data.get("default_holder_type")
        if default_holder not in holder_types:
            issues.append("default_holder_type must name one of the holder_types entries.")
        if not isinstance(data.get("tool_stickout_inches"), dict):
            issues.append("tool_stickout_inches must be a JSON object.")
        if not isinstance(data.get("tool_model_dimensions"), dict):
            issues.append("tool_model_dimensions must be a JSON object.")
        if not isinstance(data.get("tool_model_overrides"), dict):
            issues.append("tool_model_overrides must be a JSON object.")
        if not isinstance(data.get("flute_visuals"), dict):
            issues.append("flute_visuals must be a JSON object.")
        valid_materials = set(MATERIAL_CHOICES)
        for holder_id, holder in holder_types.items():
            prefix = str(holder_id)
            if not isinstance(holder, dict):
                issues.append("%s: holder definition must be a JSON object." % prefix)
                continue
            if "body_label_enabled" in holder or "body_label_text" in holder:
                issues.append("%s: old holder-level body-label settings are not supported." % prefix)
            image = holder.get("image")
            if not isinstance(image, basestring) or not image.strip():
                issues.append("%s: image path is required." % prefix)
            sections = holder.get("sections")
            if not isinstance(sections, list) or not sections:
                issues.append("%s: sections must be a non-empty list." % prefix)
                continue
            reference_count = 0
            overlay_count = 0
            for index, section in enumerate(sections):
                section_prefix = "%s primitive %d" % (prefix, index + 1)
                if not isinstance(section, dict):
                    issues.append("%s must be a JSON object." % section_prefix)
                    continue
                if "below_tool_length_reference" in section:
                    issues.append("%s uses obsolete below_tool_length_reference." % section_prefix)
                material = str(section.get("material", "") or "").strip().lower()
                if material not in valid_materials:
                    issues.append("%s requires an explicit material role." % section_prefix)
                if bool(section.get("tool_length_reference", False)):
                    reference_count += 1
                if bool(section.get("vector_font_overlay", False)):
                    overlay_count += 1
                for field in ("height_inches", "bottom_diameter_inches", "top_diameter_inches", "facets"):
                    if field not in section:
                        issues.append("%s is missing %s." % (section_prefix, field))
            if reference_count != 1:
                issues.append("%s must have exactly one tool_length_reference primitive." % prefix)
            if overlay_count > 1:
                issues.append("%s may have at most one vector_font_overlay primitive." % prefix)
        return issues

    def show_visual_warning(self):
        dialog = tk.Toplevel(self)
        dialog.title("Gremlin Tool Holder Builder - Help / Warning")
        dialog.transient(self)
        dialog.geometry("760x680")
        dialog.minsize(640, 520)

        outer = ttk.Frame(dialog, padding=10)
        outer.pack(fill="both", expand=True)

        title = ttk.Label(
            outer,
            text="Gremlin Tool Holder Builder",
            font=("TkDefaultFont", 12, "bold"))
        title.pack(anchor="w")
        subtitle = ttk.Label(
            outer,
            text="Holder/cutter visualization editor - visual aid only.",
            font=("TkDefaultFont", 10, "bold"))
        subtitle.pack(anchor="w", pady=(2, 8))

        body_frame = ttk.Frame(outer)
        body_frame.pack(fill="both", expand=True)

        canvas = tk.Canvas(body_frame, highlightthickness=0)
        scrollbar = ttk.Scrollbar(body_frame, orient="vertical", command=canvas.yview)
        content = ttk.Frame(canvas, padding=(4, 2, 10, 8))
        content_window = canvas.create_window((0, 0), window=content, anchor="nw")

        content.bind(
            "<Configure>",
            lambda event: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.bind(
            "<Configure>",
            lambda event: canvas.itemconfigure(content_window, width=event.width))
        canvas.configure(yscrollcommand=scrollbar.set)
        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        def _scroll(event):
            try:
                if getattr(event, "num", 0) == 4:
                    canvas.yview_scroll(-3, "units")
                elif getattr(event, "num", 0) == 5:
                    canvas.yview_scroll(3, "units")
                else:
                    delta = int(getattr(event, "delta", 0) or 0)
                    if delta:
                        canvas.yview_scroll(-3 if delta > 0 else 3, "units")
            except Exception:
                pass
            return "break"

        canvas.bind("<MouseWheel>", _scroll)
        canvas.bind("<Button-4>", _scroll)
        canvas.bind("<Button-5>", _scroll)
        content.bind("<MouseWheel>", _scroll)
        content.bind("<Button-4>", _scroll)
        content.bind("<Button-5>", _scroll)

        def add_section(heading, body):
            ttk.Label(
                content,
                text=heading,
                font=("TkDefaultFont", 10, "bold")
            ).pack(anchor="w", pady=(9, 2))
            ttk.Label(
                content,
                text=body,
                justify="left",
                wraplength=690
            ).pack(anchor="w", fill="x")

        add_section(
            "Recommended workflow",
            "1. Open the normal gremlin_tool_holder_config.json or create a new configuration. "
            "2. On Holders, select an existing holder, Duplicate it, or create New. "
            "3. Build the holder from the ordered primitive stack and enter the real dimensions. "
            "4. Mark exactly one primitive as the tool-length reference surface. "
            "5. Optionally mark one primitive for the T# vector-font overlay. "
            "6. Set the holder Display name / Family / Manufacturer and choose Set Default if appropriate. "
            "7. Review General, Flutes, Tool Models, and Overrides. "
            "8. Save. Gremlin reads this JSON configuration and TLM supplies each tool's Holder, Cutter, and Flutes assignment.")

        add_section(
            "Holder IDs and catalog controls",
            "New creates a fresh holder definition. Duplicate is the normal starting point when a new holder is similar "
            "to an existing one. Rename ID changes the JSON holder key; Display name is the friendly name shown to the user. "
            "Delete removes the selected holder from the catalog. Set Default selects the holder Gremlin uses when a T-number "
            "does not have an explicit holder assignment.")

        add_section(
            "Primitive stack",
            "A holder is an ordered top-to-bottom stack of independent rotational primitives. Add inserts a new primitive "
            "directly below the current selection so holders can be built naturally from the top down. Duplicate, Delete, Up, "
            "and Down control that stack. Each primitive has Top OD, Bottom OD, Height, Facets, and a material role. "
            "Equal top/bottom diameters make a cylinder; different diameters make a tapered section. More facets look rounder; "
            "low facet counts can intentionally make hexagonal or other polygonal shapes.")

        add_section(
            "Material roles",
            "Material is a visual role used by Gremlin for holder shading: body, nut, relief, flange, or shank. "
            "Primitive names are descriptive only; they do not determine rendering behavior.")

        add_section(
            "Tool-length reference",
            "Every holder must have exactly one primitive marked 'Offset length starts at top HERE'. The TOP of that primitive "
            "represents the spindle-face mating surface used as the holder's tool-length reference. All holder geometry below "
            "that point contributes to fixed holder length. In tool_table_length mode, Gremlin displays cutter stickout as "
            "PathPilot tool length minus this fixed holder length. This setting changes only the visualization; it does not "
            "write or alter PathPilot/LinuxCNC tool offsets.")

        add_section(
            "T# vector-font overlay",
            "At most one primitive may be marked 'Vector font overlay goes here (T#)'. The Builder previews the placeholder T#. "
            "Gremlin replaces it with the active tool number, such as T6 or T125. The General tab controls the vector-line width.")

        add_section(
            "Live preview and SVG thumbnail",
            "The left preview shows the current holder definition while you edit it. The SVG thumbnail path is generated/managed "
            "from the holder definition and is used as a visual catalog aid. The preview and SVG are not measurement or collision tools.")

        add_section(
            "General tab",
            "Show holder visualization enables/disables the custom Gremlin holder. Black holder edges and Holder edge width control "
            "outline appearance; Chamfer holder edges adds visual breaks between sections. Stickout mode should normally remain "
            "tool_table_length. Default mode always uses Default stickout. Minimum stickout is a visual sanity threshold: if a "
            "calculated stickout is smaller than that value, Gremlin falls back to Default stickout.")

        add_section(
            "Flutes tab",
            "Enable flute marks controls the dark visual flute stripes. Default flute count is used only when TLM has not assigned a "
            "count to the tool. Helix angle, base flute-width percentage, surface lift, curve smoothness, and maximum wraps control "
            "appearance. The 4/3/2/1-flute width multipliers scale the base width for each flute count; current defaults are "
            "4=1.00x, 3=1.6667x, 2=2.3333x, and 1=3.00x. A TLM flute assignment takes priority over the Builder's default count.")

        add_section(
            "Tool Models tab",
            "These settings tune the cutter families selected by TLM: Ball Mill, Rounded Edge End Mill, Straight/Spiral Reamers, "
            "90/45-degree Chamfer Mills, Drill Bit, Key Seat Cutter, Rounded Key Seat Cutter, Dovetail Cutter, and the 3/8, 1/4, "
            "and 3/16-shank Thread Mill presets. PathPilot supplies the tool's main cutting diameter. Builder settings control "
            "secondary geometry such as corner radius, point angle, tip flat, cutting length, neck/shank diameter, helix angle, "
            "edge smoothness, and thread/dovetail included angle.")

        add_section(
            "Overrides tab",
            "Overrides are advanced per-T-number exceptions. Per-tool stickout can force a specific displayed stickout, for example "
            '{"6": 1.25, "T12": 2.0}. Per-tool model dimensions can override normal Tool Models values for only selected tools. '
            "Most users should leave both override boxes as {}.")

        add_section(
            "Saving and live use",
            "Save writes the current JSON configuration. Save As writes another configuration file; Gremlin normally uses "
            "/home/operator/gcode/python/gremlin_tool_holder_config.json. Gremlin periodically refreshes the configuration, so "
            "saved visual-setting changes can appear without rebuilding PathPilot. Holder/Cutter/Flutes assignments themselves "
            "come from TLM.")

        add_section(
            "Suite relationship",
            "Builder defines holder geometry and global cutter/flute rendering settings. TLM assigns Holder, Cutter model, and Flutes "
            "to each T-number. Gremlin consumes both and draws the result in PathPilot's 3D viewer. The three core components are "
            "designed to work together.")

        add_section(
            "What this does NOT change",
            "Holder assignments, cutter models, flute marks, generated SVG thumbnails, and Gremlin geometry are visualization only. "
            "They do not change PathPilot or LinuxCNC tool length, tool diameter, work offsets, machine coordinates, feeds, speeds, "
            "or any other machine-control value.")

        add_section(
            "Accuracy and safety",
            "The models are generated from user-entered dimensions and may differ from the physical holder/cutter actually installed. "
            "Do not use this visualization as collision detection, clearance verification, setup authority, or a safety system. "
            "Verify the physical holder, cutter, stickout, offsets, workholding, machine travel, and clearances before machining.")

        add_section(
            "Responsibility",
            "Use is at your own risk. The author is not responsible for crashes, collisions, damaged tools, damaged work, machine "
            "damage, injury, or other losses caused by relying on the visualization.")

        button_row = ttk.Frame(outer)
        button_row.pack(fill="x", pady=(10, 0))
        ok_button = ttk.Button(button_row, text="OK", width=10, command=dialog.destroy)
        ok_button.pack(side="right")
        ok_button.focus_set()

        dialog.bind("<Escape>", lambda event: dialog.destroy())
        dialog.bind("<Return>", lambda event: dialog.destroy())
        dialog.update_idletasks()
        try:
            x = self.winfo_rootx() + max(20, (self.winfo_width() - dialog.winfo_width()) // 2)
            y = self.winfo_rooty() + max(20, (self.winfo_height() - dialog.winfo_height()) // 2)
            dialog.geometry("+%d+%d" % (x, y))
        except Exception:
            pass
        try:
            dialog.grab_set()
        except Exception:
            pass

    # ---------- file handling ----------

    def open_or_create_startup(self):
        if messagebox.askyesno(
            APP_TITLE,
            "Default config was not found at:\n%s\n\n"
            "Open another gremlin_tool_holder_config.json?" % DEFAULT_CONFIG_PATH
        ):
            self.open_file()
        else:
            self.create_new_config()

    def create_new_config(self):
        self.data = copy.deepcopy(CURRENT_CONFIG_DEFAULTS)
        self.data["default_holder_type"] = "NEW_TTS_HOLDER"
        self.data["holder_types"] = {"NEW_TTS_HOLDER": copy.deepcopy(DEFAULT_NEW_HOLDER)}
        self.config_path = None
        self.path_var.set("New unsaved configuration")
        self.current_holder_id = "NEW_TTS_HOLDER"
        self.refresh_holder_combo()
        self.load_current_holder()
        self.load_settings_ui()
        self._reset_history()
        self.status_var.set("Created new holder catalog - auto-save enabled")
        self._schedule_autosave()

    def open_file(self):
        initial_dir = os.path.dirname(DEFAULT_CONFIG_PATH)
        path = filedialog.askopenfilename(
            title="Open gremlin_tool_holder_config.json",
            initialdir=initial_dir if os.path.isdir(initial_dir) else None,
            filetypes=[("JSON files", "*.json"), ("All files", "*.*")])
        if path:
            self.load_file(path)

    def load_file(self, path):
        try:
            with io.open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
        except Exception as exc:
            messagebox.showerror(APP_TITLE, "Could not open JSON:\n\n%s" % exc)
            return
        issues = self.current_schema_issues(data)
        if issues:
            preview = "\n".join("- %s" % issue for issue in issues[:12])
            if len(issues) > 12:
                preview += "\n- ... and %d more" % (len(issues) - 12)
            messagebox.showerror(APP_TITLE, "This file is not the current Gremlin holder schema.\n\n%s" % preview)
            return
        # tool_thumbnail_examples was briefly written into the Gremlin config,
        # but it is TLM presentation data rather than Gremlin model/config data.
        # Drop it from the in-memory document so the next Save removes stale
        # copies without otherwise migrating or rewriting the current schema.
        if "tool_thumbnail_examples" in data:
            try:
                del data["tool_thumbnail_examples"]
            except Exception:
                pass
        self.data = data
        self.config_path = os.path.abspath(path)
        self.path_var.set(self.config_path)
        holders = list(self.data["holder_types"].keys())
        preferred = self.data.get("default_holder_type")
        self.current_holder_id = preferred if preferred in holders else (holders[0] if holders else None)
        self.refresh_holder_combo()
        self.load_current_holder()
        self.load_settings_ui()
        self._reset_history()
        self.status_var.set("Loaded %s" % self.config_path)

    def _svg_escape(self, value):
        text = unicode(value)
        return (text.replace(u"&", u"&amp;").replace(u"<", u"&lt;").replace(u">", u"&gt;").replace(u'"', u"&quot;"))

    def _holder_svg_text(self, holder_id, holder):
        sections = holder.get("sections", [])
        if not sections:
            return u""
        total_h = sum(max(0.001, fnum(sec.get("height_inches"), 0.001)) for sec in sections)
        max_d = max(
            max(fnum(sec.get("bottom_diameter_inches"), 0.001),
                fnum(sec.get("top_diameter_inches"), 0.001))
            for sec in sections)
        width = 180.0
        height = 240.0
        margin = 14.0
        scale = min((height - 2.0 * margin) / total_h, (width - 2.0 * margin) / max_d)
        cx = width / 2.0
        y = height - margin
        edge_enabled = bool(self.data.get("edge_enabled", True))
        edge_width = max(0.5, min(10.0, fnum(self.data.get("edge_width", CURRENT_CONFIG_DEFAULTS["edge_width"]), CURRENT_CONFIG_DEFAULTS["edge_width"])))
        stroke = u"#000000" if edge_enabled else u"none"
        label_text = u"T#"
        label_index = self.vector_overlay_section_index(holder)
        parts = [
            u'<?xml version="1.0" encoding="UTF-8"?>',
            u'<svg xmlns="http://www.w3.org/2000/svg" width="180" height="240" viewBox="0 0 180 240">',
            u'<rect x="0" y="0" width="180" height="240" fill="#ffffff"/>',
            u'<g stroke="%s" stroke-width="%.3f" stroke-linejoin="round">' %
            (stroke, edge_width),]
        label_info = None
        for sec_index, sec in enumerate(sections):
            h_in = max(0.001, fnum(sec.get("height_inches"), 0.001))
            bd = max(0.001, fnum(sec.get("bottom_diameter_inches"), 0.001))
            td = max(0.001, fnum(sec.get("top_diameter_inches"), 0.001))
            facets = inum(sec.get("facets"), 32)
            y2 = y - h_in * scale
            xb_l = cx - bd * scale / 2.0
            xb_r = cx + bd * scale / 2.0
            xt_l = cx - td * scale / 2.0
            xt_r = cx + td * scale / 2.0
            if facets == 6:
                facet_cut = min((y - y2) * 0.22, min(bd, td) * scale * 0.10)
                points = [
                    (xb_l + facet_cut, y), (xb_r - facet_cut, y),
                    (xb_r, y - facet_cut), (xt_r, y2 + facet_cut),
                    (xt_r - facet_cut, y2), (xt_l + facet_cut, y2),
                    (xt_l, y2 + facet_cut), (xb_l, y - facet_cut),]
            else:
                points = [(xb_l, y), (xb_r, y), (xt_r, y2), (xt_l, y2)]
            point_text = u" ".join(u"%.3f,%.3f" % (x, py) for x, py in points)
            fill = MATERIAL_FILL.get(infer_section_material(sec), MATERIAL_FILL["body"])
            parts.append(u'<polygon points="%s" fill="%s"/>' % (point_text, fill))
            if sec_index == label_index:
                label_info = (cx, (y + y2) / 2.0, min(bd, td) * scale, y - y2)
            y = y2
        parts.append(u'</g>')
        if label_info is not None:
            tx, ty, section_width, section_height = label_info
            font_size = max(7.0, min(16.0, section_width * 0.18, section_height * 0.42))
            parts.append(
                u'<text x="%.2f" y="%.2f" text-anchor="middle" '
                u'dominant-baseline="middle" font-family="Arial,Helvetica,sans-serif" '
                u'font-size="%.2f" font-weight="bold" fill="#222222">%s</text>' %
                (tx, ty, font_size, self._svg_escape(label_text)))
        parts.append(u'</svg>')
        return u"\n".join(parts) + u"\n"

    def generate_all_svgs(self):
        if not self.data or not isinstance(self.data.get("holder_types"), dict):
            return
        base_config = self.config_path or DEFAULT_CONFIG_PATH
        base_dir = os.path.dirname(os.path.abspath(base_config))
        for holder_id, holder in self.data["holder_types"].items():
            if not isinstance(holder, dict):
                continue
            rel_path = "gremlin_tool_holders/%s.svg" % sanitize_holder_id(holder_id).lower()
            holder["image"] = rel_path
            full_path = os.path.join(base_dir, rel_path)
            folder = os.path.dirname(full_path)
            if not os.path.isdir(folder):
                os.makedirs(folder)
            svg = self._holder_svg_text(holder_id, holder)
            if not svg:
                continue
            payload = svg.encode("utf-8") if isinstance(svg, unicode) else svg
            with open(full_path, "wb") as handle:
                handle.write(payload)

    def validate_before_save(self):
        if not self.data:
            messagebox.showerror(APP_TITLE, "No configuration is loaded.")
            return False
        if not self.data.get("holder_types"):
            messagebox.showerror(APP_TITLE, "At least one holder definition is required.")
            return False
        schema_issues = self.current_schema_issues(self.data)
        if schema_issues:
            messagebox.showerror(APP_TITLE, schema_issues[0])
            return False
        try:
            edge_width = float(self.data.get("edge_width", CURRENT_CONFIG_DEFAULTS["edge_width"]))
        except Exception:
            messagebox.showerror(APP_TITLE, "Edge thickness must be numeric.")
            return False
        if edge_width < 0.5 or edge_width > 10.0:
            messagebox.showerror(APP_TITLE, "Edge thickness must be between 0.5 and 10.0 pixels.")
            return False
        for hid, holder in self.data["holder_types"].items():
            sections = holder.get("sections", [])
            if not sections:
                messagebox.showerror(APP_TITLE, "%s has no primitives." % hid)
                return False
            reference_count = 0
            overlay_count = 0
            for i, sec in enumerate(sections, 1):
                if fnum(sec.get("height_inches"), -1) <= 0:
                    messagebox.showerror(APP_TITLE, "%s primitive %d has invalid height." % (hid, i))
                    return False
                if (fnum(sec.get("bottom_diameter_inches"), -1) <= 0 or
                        fnum(sec.get("top_diameter_inches"), -1) <= 0):
                    messagebox.showerror(APP_TITLE, "%s primitive %d has invalid diameter." % (hid, i))
                    return False
                if inum(sec.get("facets"), 0) < 3:
                    messagebox.showerror(APP_TITLE, "%s primitive %d must have at least 3 facets." % (hid, i))
                    return False
                material = str(sec.get("material", "") or "").strip().lower()
                if material not in MATERIAL_CHOICES:
                    messagebox.showerror(APP_TITLE, "%s primitive %d has an invalid material role." % (hid, i))
                    return False
                if bool(sec.get("tool_length_reference", False)):
                    reference_count += 1
                if bool(sec.get("vector_font_overlay", False)):
                    overlay_count += 1
            if reference_count != 1:
                messagebox.showerror(APP_TITLE, "%s must have exactly one tool-length reference primitive." % hid)
                return False
            if overlay_count > 1:
                messagebox.showerror(APP_TITLE, "%s may have only one vector-font overlay primitive." % hid)
                return False
        return True

    def save(self):
        self._cancel_autosave()
        self._autosave_suspended = True
        try:
            return self._save_manual_impl()
        finally:
            self._autosave_suspended = False

    def _save_manual_impl(self):
        self.commit_section_from_ui()
        self.commit_meta_from_ui()
        if not self.commit_settings_from_ui(show_errors=True):
            return
        if not self.validate_before_save():
            return
        if not self.config_path:
            return self.save_as()
        try:
            self.generate_all_svgs()
            tmp = self.config_path + ".tmp"
            payload = json.dumps(self.data, indent=4, ensure_ascii=False)
            if isinstance(payload, unicode):
                payload = payload.encode("utf-8")
            with open(tmp, "wb") as f:
                f.write(payload)
                f.write("\n")
                f.flush()
                try:
                    os.fsync(f.fileno())
                except Exception:
                    pass
            # Same-directory rename is atomic on the PathPilot Linux filesystem.
            os.rename(tmp, self.config_path)
            self.status_var.set("Saved %s" % self.config_path)
        except Exception as exc:
            try:
                if os.path.exists(tmp):
                    os.remove(tmp)
            except Exception:
                pass
            messagebox.showerror(APP_TITLE, "Could not save JSON:\n\n%s" % exc)

    def save_as(self):
        self.commit_section_from_ui()
        self.commit_meta_from_ui()
        if not self.commit_settings_from_ui(show_errors=True):
            return
        if not self.validate_before_save():
            return
        initial_dir = os.path.dirname(DEFAULT_CONFIG_PATH)
        path = filedialog.asksaveasfilename(
            title="Save gremlin_tool_holder_config.json",
            defaultextension=".json",
            initialdir=initial_dir if os.path.isdir(initial_dir) else None,
            initialfile="gremlin_tool_holder_config.json",
            filetypes=[("JSON files", "*.json"), ("All files", "*.*")])
        if not path:
            return
        self.config_path = os.path.abspath(path)
        self.path_var.set(self.config_path)
        if self._autosave_suspended:
            self._save_manual_impl()
        else:
            self.save()

    # ---------- holder catalog ----------

    def holder_display(self, hid):
        holder = self.data["holder_types"].get(hid, {})
        return "%s  -  %s" % (hid, holder.get("display_name", hid))

    def refresh_holder_combo(self):
        if not self.data:
            self.holder_combo["values"] = []
            return
        ids = list(self.data["holder_types"].keys())
        values = [self.holder_display(hid) for hid in ids]
        self.holder_combo["values"] = values
        if self.current_holder_id in ids:
            self.holder_combo.current(ids.index(self.current_holder_id))
        self.update_default_label()

    def update_default_label(self):
        if not self.data:
            self.default_label.set("")
            return
        self.default_label.set("Default: %s" % self.data.get("default_holder_type", "(none)"))

    def on_holder_selected(self, event=None):
        if not self.data:
            return
        self.commit_section_from_ui()
        self.commit_meta_from_ui()
        idx = self.holder_combo.current()
        ids = list(self.data["holder_types"].keys())
        if 0 <= idx < len(ids):
            self.current_holder_id = ids[idx]
            self.load_current_holder()

    def new_holder(self):
        if not self.data:
            self.create_new_config()
            return
        name = simpledialog.askstring(APP_TITLE, "New holder display name:", initialvalue="New TTS Holder")
        if not name:
            return
        suggested = sanitize_holder_id(name)
        hid = simpledialog.askstring(APP_TITLE, "Holder ID:", initialvalue=suggested)
        if not hid:
            return
        hid = sanitize_holder_id(hid)
        if hid in self.data["holder_types"]:
            messagebox.showerror(APP_TITLE, "Holder ID already exists.")
            return
        self._history_checkpoint("Add holder")
        holder = copy.deepcopy(DEFAULT_NEW_HOLDER)
        holder["display_name"] = name.strip()
        holder["image"] = "gremlin_tool_holders/%s.svg" % hid.lower()
        self.data["holder_types"][hid] = holder
        self.current_holder_id = hid
        self.refresh_holder_combo()
        self.load_current_holder()
        self.status_var.set("Added %s" % hid)

    def duplicate_holder(self):
        holder = self.get_current_holder()
        if not holder:
            return
        suggested = self.current_holder_id + "_COPY"
        hid = simpledialog.askstring(APP_TITLE, "New holder ID:", initialvalue=suggested)
        if not hid:
            return
        hid = sanitize_holder_id(hid)
        if hid in self.data["holder_types"]:
            messagebox.showerror(APP_TITLE, "Holder ID already exists.")
            return
        self._history_checkpoint("Duplicate holder")
        new_holder = copy.deepcopy(holder)
        new_holder["display_name"] = new_holder.get("display_name", self.current_holder_id) + " Copy"
        new_holder["image"] = "gremlin_tool_holders/%s.svg" % hid.lower()
        self.data["holder_types"][hid] = new_holder
        self.current_holder_id = hid
        self.refresh_holder_combo()
        self.load_current_holder()
        self.status_var.set("Duplicated holder as %s" % hid)

    def rename_holder_id(self):
        if not self.current_holder_id:
            return
        old = self.current_holder_id
        new = simpledialog.askstring(APP_TITLE, "New holder ID:", initialvalue=old)
        if not new:
            return
        new = sanitize_holder_id(new)
        if new == old:
            return
        if new in self.data["holder_types"]:
            messagebox.showerror(APP_TITLE, "Holder ID already exists.")
            return
        self._history_checkpoint("Rename holder")
        rebuilt = {}
        for hid, holder in self.data["holder_types"].items():
            rebuilt[new if hid == old else hid] = holder
        self.data["holder_types"] = rebuilt
        if isinstance(self.data["holder_types"].get(new), dict):
            self.data["holder_types"][new]["image"] = "gremlin_tool_holders/%s.svg" % new.lower()
        if self.data.get("default_holder_type") == old:
            self.data["default_holder_type"] = new
        self.current_holder_id = new
        self.refresh_holder_combo()
        self.load_current_holder()

    def delete_holder(self):
        if not self.current_holder_id or not self.data:
            return
        if len(self.data["holder_types"]) <= 1:
            messagebox.showerror(APP_TITLE, "The catalog must contain at least one holder.")
            return
        hid = self.current_holder_id
        if not messagebox.askyesno(APP_TITLE, "Delete holder '%s'?" % hid):
            return
        self._history_checkpoint("Delete holder")
        del self.data["holder_types"][hid]
        ids = list(self.data["holder_types"].keys())
        self.current_holder_id = ids[0]
        if self.data.get("default_holder_type") == hid:
            self.data["default_holder_type"] = self.current_holder_id
        self.refresh_holder_combo()
        self.load_current_holder()

    def set_default_holder(self):
        if not self.current_holder_id or not self.data:
            return
        self._history_checkpoint("Set default holder")
        self.data["default_holder_type"] = self.current_holder_id
        self.update_default_label()
        self.status_var.set("Default holder set to %s" % self.current_holder_id)

    def get_current_holder(self):
        if not self.data or not self.current_holder_id:
            return None
        return self.data["holder_types"].get(self.current_holder_id)

    def vector_overlay_section_index(self, holder=None):
        holder = holder or self.get_current_holder()
        if not holder:
            return None
        sections = holder.get("sections", [])
        for index, section in enumerate(sections):
            if isinstance(section, dict) and bool(section.get("vector_font_overlay", False)):
                return index
        return None

    # ---------- metadata ----------

    def load_current_holder(self):
        holder = self.get_current_holder()
        if not holder:
            return
        self.loading_ui = True
        try:
            for key, var in self.meta_vars.items():
                var.set(str(holder.get(key, "")))
            self.edge_enabled_var.set(bool(self.data.get("edge_enabled", True)))
            self.edge_width_var.set(str(self.data.get("edge_width", CURRENT_CONFIG_DEFAULTS["edge_width"])))
        finally:
            self.loading_ui = False
        self.refresh_primitive_list()
        if holder.get("sections"):
            self.primitive_list.selection_clear(0, "end")
            self.primitive_list.selection_set(0)
            self.primitive_list.activate(0)
            self.load_selected_section()
        else:
            self.clear_section_ui()
        self.schedule_redraw()
        self.update_summary()

    def on_meta_changed(self, *args):
        if self.loading_ui or self._history_restoring:
            return
        self._history_checkpoint("Edit holder metadata", coalesce=True)
        self.commit_meta_from_ui()
        self.refresh_holder_combo()
        self.schedule_redraw()

    def commit_meta_from_ui(self):
        holder = self.get_current_holder()
        if not holder or self.loading_ui:
            return
        for key, var in self.meta_vars.items():
            holder[key] = var.get().strip()

    def on_render_style_changed(self, *args):
        if self.loading_ui or self._history_restoring or not self.data:
            return
        self._history_checkpoint("Edit holder rendering", coalesce=True)
        self.data["edge_enabled"] = bool(self.edge_enabled_var.get())
        try:
            width = numeric_value(self.edge_width_var.get())
            if width < 0.5:
                width = 0.5
            elif width > 10.0:
                width = 10.0
            self.data["edge_width"] = width
        except (TypeError, ValueError):
            pass
        self.schedule_redraw()

    # ---------- primitive sections ----------

    def _storage_to_display_index(self, storage_index):
        holder = self.get_current_holder()
        if not holder:
            return storage_index
        count = len(holder.get("sections", []))
        return (count - 1) - storage_index

    def _display_to_storage_index(self, display_index):
        holder = self.get_current_holder()
        if not holder:
            return display_index
        count = len(holder.get("sections", []))
        return (count - 1) - display_index

    def refresh_primitive_list(self, select_index=None):
        holder = self.get_current_holder()

        preserved_selection = []
        preserved_active = None
        if select_index is None:
            try:
                preserved_selection = self.selected_section_indices()
                preserved_active = self.selected_section_index()
            except Exception:
                preserved_selection = []
                preserved_active = None

        self.primitive_list.delete(0, "end")
        if not holder:
            return
        sections = holder.get("sections", [])
        # Internal JSON order remains bottom -> top for Gremlin compatibility,
        # but the editor displays top -> bottom to match the visual model.
        for display_i, storage_i in enumerate(range(len(sections) - 1, -1, -1)):
            sec = sections[storage_i]
            name = sec.get("name", "section")
            h = fnum(sec.get("height_inches"))
            d1 = fnum(sec.get("bottom_diameter_inches"))
            d2 = fnum(sec.get("top_diameter_inches"))
            facets = inum(sec.get("facets"))
            if facets == 6:
                display_d1 = self._hex_across_flats_from_storage(d1)
                display_d2 = self._hex_across_flats_from_storage(d2)
                diameter_label = "AF"
            else:
                display_d1 = d1
                display_d2 = d2
                diameter_label = "OD"
            shape = "hex" if facets == 6 else ("%d-facet" % facets if facets < 24 else "round")
            material = infer_section_material(sec)
            flags = []
            if bool(sec.get("tool_length_reference", False)):
                flags.append("REF")
            if bool(sec.get("vector_font_overlay", False)):
                flags.append("T#")
            flag_text = (" [" + ",".join(flags) + "]") if flags else ""
            self.primitive_list.insert(
                "end",
                "%d. %s%s   H %.3f   %s %.3f->%.3f   %s   %s" %
                (display_i + 1, name, flag_text, h, diameter_label, display_d1, display_d2, shape, material))
        if select_index is not None and self.primitive_list.size():
            self._apply_section_selection(
                [select_index],
                select_index)
        elif preserved_selection:
            self._apply_section_selection(
                preserved_selection,
                preserved_active)

    def selected_section_indices(self):
        result = []
        try:
            selection = self.primitive_list.curselection()
        except Exception:
            selection = ()
        for display_index in selection:
            try:
                storage_index = self._display_to_storage_index(
                    int(display_index))
            except Exception:
                continue
            if storage_index not in result:
                result.append(storage_index)
        return result

    def selected_section_index(self):
        selected = self.selected_section_indices()
        if not selected:
            return None

        try:
            active_display = int(self.primitive_list.index(tk.ACTIVE))
            active_storage = self._display_to_storage_index(
                active_display)
            if active_storage in selected:
                return active_storage
        except Exception:
            pass

        return selected[0]

    def _apply_section_selection(self, storage_indices, active_storage=None):
        try:
            self.primitive_list.selection_clear(0, "end")
        except Exception:
            return

        valid = []
        holder = self.get_current_holder()
        count = len(holder.get("sections", [])) if holder else 0

        for storage_index in storage_indices or []:
            try:
                storage_index = int(storage_index)
            except Exception:
                continue
            if storage_index < 0 or storage_index >= count:
                continue
            display_index = self._storage_to_display_index(
                storage_index)
            self.primitive_list.selection_set(display_index)
            valid.append(storage_index)

        if active_storage not in valid:
            active_storage = valid[0] if valid else None

        if active_storage is not None:
            display_index = self._storage_to_display_index(
                active_storage)
            self.primitive_list.activate(display_index)
            try:
                self.primitive_list.see(display_index)
            except Exception:
                pass

    def on_primitive_selected(self, event=None):
        self.load_selected_section()
        self.update_summary()
        self.schedule_redraw()

    def clear_section_ui(self):
        self.loading_ui = True
        try:
            self.sv_name.set("")
            self.sv_height.set("")
            self.sv_bottom.set("")
            self.sv_top.set("")
            self.sv_facets.set("")
            self.sv_material.set("body")
            self.sv_below.set(False)
            self.sv_vector_overlay.set(False)
            self.hex_info.set("")
        finally:
            self.loading_ui = False

    def load_selected_section(self):
        holder = self.get_current_holder()
        idx = self.selected_section_index()
        if not holder or idx is None or idx >= len(holder.get("sections", [])):
            self.clear_section_ui()
            return
        sec = holder["sections"][idx]
        self.loading_ui = True
        try:
            self.sv_name.set(str(sec.get("name", "")))
            self.sv_height.set(str(sec.get("height_inches", "")))
            facets = inum(sec.get("facets", 32), 32)
            bottom_value = sec.get("bottom_diameter_inches", "")
            top_value = sec.get("top_diameter_inches", "")
            if facets == 6:
                try:
                    bottom_value = "%.6g" % self._hex_across_flats_from_storage(bottom_value)
                    top_value = "%.6g" % self._hex_across_flats_from_storage(top_value)
                except Exception:
                    pass
            self.sv_bottom.set(str(bottom_value))
            self.sv_top.set(str(top_value))
            self.sv_facets.set(str(facets))
            self.sv_material.set(infer_section_material(sec))
            self.sv_below.set(bool(sec.get("tool_length_reference", False)))
            self.sv_vector_overlay.set(bool(sec.get("vector_font_overlay", False)))
        finally:
            self.loading_ui = False
        self.update_hex_info()
        self.schedule_redraw()

    def on_section_changed(self, *args):
        if self.loading_ui or self._history_restoring:
            return
        self._history_checkpoint("Edit holder primitive", coalesce=True)
        self.commit_section_from_ui()
        self.update_hex_info()
        self.update_summary()
        self.schedule_redraw()

    def commit_section_from_ui(self):
        if self.loading_ui:
            return
        holder = self.get_current_holder()
        idx = self.selected_section_index()
        if not holder or idx is None:
            return
        sections = holder.get("sections", [])
        if idx >= len(sections):
            return
        sec = sections[idx]
        sec["name"] = self.sv_name.get().strip() or "section"
        # Keep partially typed/invalid values out of the model until valid.
        try:
            h = numeric_value(self.sv_height.get())
            if h > 0:
                sec["height_inches"] = h
        except ValueError:
            pass
        try:
            entered_facets = max(3, int(numeric_value(self.sv_facets.get())))
            sec["facets"] = entered_facets
        except ValueError:
            entered_facets = inum(sec.get("facets"), 32)
        try:
            v = numeric_value(self.sv_bottom.get())
            if v > 0:
                sec["bottom_diameter_inches"] = (self._hex_storage_from_across_flats(v) if entered_facets == 6 else v)
        except ValueError:
            pass
        try:
            v = numeric_value(self.sv_top.get())
            if v > 0:
                sec["top_diameter_inches"] = (self._hex_storage_from_across_flats(v) if entered_facets == 6 else v)
        except ValueError:
            pass
        material = str(self.sv_material.get() or "body").strip().lower()
        sec["material"] = material if material in MATERIAL_CHOICES else "body"
        wants_overlay = bool(self.sv_vector_overlay.get())
        if wants_overlay:
            for other in sections:
                if isinstance(other, dict) and "vector_font_overlay" in other:
                    del other["vector_font_overlay"]
            sec["vector_font_overlay"] = True
        elif "vector_font_overlay" in sec:
            del sec["vector_font_overlay"]
        wants_reference = bool(self.sv_below.get())
        was_reference = bool(sec.get("tool_length_reference", False))
        if wants_reference:
            for other in sections:
                if isinstance(other, dict) and "tool_length_reference" in other:
                    del other["tool_length_reference"]
            sec["tool_length_reference"] = True
        elif was_reference:
            # A holder must always retain one reference. Move it by checking
            # another primitive rather than unchecking the current one.
            self.loading_ui = True
            try:
                self.sv_below.set(True)
            finally:
                self.loading_ui = False
        # Refresh text without disturbing current selection.
        self.refresh_primitive_list()

    def add_section(self):
        holder = self.get_current_holder()
        if not holder:
            return
        idx = self.selected_section_index()

        # Internal JSON order is bottom -> top while the Builder displays
        # top -> bottom. To add BELOW the selected primitive in the Builder,
        # insert BEFORE its storage index. If nothing is selected, retain the
        # historical behavior and add at the top of the holder.
        insert_at = len(holder["sections"]) if idx is None else idx

        self._history_checkpoint("Add primitive")
        sec = {
            "name": "new section",
            "material": "body",
            "height_inches": 0.250,
            "bottom_diameter_inches": 1.000,
            "top_diameter_inches": 1.000,
            "facets": 32,}
        holder["sections"].insert(insert_at, sec)
        self.refresh_primitive_list(select_index=insert_at)
        self.load_selected_section()
        self.update_summary()
        self.schedule_redraw()

    def duplicate_section(self):
        holder = self.get_current_holder()
        idx = self.selected_section_index()
        if not holder or idx is None:
            return
        sections = holder.get("sections", [])
        if idx >= len(sections):
            return
        self._history_checkpoint("Duplicate primitive")
        sec = copy.deepcopy(sections[idx])
        sec["name"] = (str(sec.get("name", "section")) + " copy").strip()
        # Unique semantic markers never duplicate automatically.
        if "tool_length_reference" in sec:
            del sec["tool_length_reference"]
        if "vector_font_overlay" in sec:
            del sec["vector_font_overlay"]
        insert_at = idx + 1
        sections.insert(insert_at, sec)
        self.refresh_primitive_list(select_index=insert_at)
        self.load_selected_section()
        self.update_summary()
        self.schedule_redraw()

    def delete_section(self):
        holder = self.get_current_holder()
        idx = self.selected_section_index()
        if not holder or idx is None:
            return
        if len(holder["sections"]) <= 1:
            messagebox.showerror(APP_TITLE, "A holder must contain at least one primitive.")
            return
        if bool(holder["sections"][idx].get("tool_length_reference", False)):
            messagebox.showerror(
                APP_TITLE,
                "This primitive defines the spindle-face mating reference.\n\n"
                "Mark another primitive as the spindle-face reference before deleting this one.")
            return
        self._history_checkpoint("Delete primitive")
        del holder["sections"][idx]
        idx = min(idx, len(holder["sections"]) - 1)
        self.refresh_primitive_list(select_index=idx)
        self.load_selected_section()
        self.update_summary()
        self.schedule_redraw()

    def move_section(self, delta):
        holder = self.get_current_holder()
        idx = self.selected_section_index()
        if not holder or idx is None:
            return
        new_idx = idx + delta
        if new_idx < 0 or new_idx >= len(holder["sections"]):
            return
        self._history_checkpoint("Move primitive")
        holder["sections"][idx], holder["sections"][new_idx] = (
            holder["sections"][new_idx],
            holder["sections"][idx],)
        self.refresh_primitive_list(select_index=new_idx)
        self.load_selected_section()
        self.update_summary()
        self.schedule_redraw()

    def on_canvas_click(self, event):
        """Select the primitive clicked in the live preview."""
        if not self.canvas_section_boxes:
            return
        x = event.x
        y = event.y
        # Prefer exact polygon bounding-box hits.  Boxes are stored using
        # internal/storage section indices.
        hit_index = None
        for storage_index, x1, y1, x2, y2 in self.canvas_section_boxes:
            if x1 <= x <= x2 and y1 <= y <= y2:
                hit_index = storage_index
                break
        if hit_index is None:
            return
        display_index = self._storage_to_display_index(hit_index)

        # Tk/X11 modifier bits: Shift=0x0001, Control=0x0004.
        shift_down = bool(int(getattr(event, "state", 0) or 0) & 0x0001)
        ctrl_down = bool(int(getattr(event, "state", 0) or 0) & 0x0004)

        if shift_down:
            try:
                active_display = int(
                    self.primitive_list.index(tk.ACTIVE))
            except Exception:
                active_display = display_index
            lo = min(active_display, display_index)
            hi = max(active_display, display_index)
            self.primitive_list.selection_clear(0, "end")
            self.primitive_list.selection_set(lo, hi)
        elif ctrl_down:
            if self.primitive_list.selection_includes(display_index):
                self.primitive_list.selection_clear(display_index)
            else:
                self.primitive_list.selection_set(display_index)
        else:
            self.primitive_list.selection_clear(0, "end")
            self.primitive_list.selection_set(display_index)

        self.primitive_list.activate(display_index)
        try:
            self.primitive_list.see(display_index)
        except Exception:
            pass
        self.load_selected_section()
        self.update_summary()
        self.schedule_redraw()

    # ---------- calculations ----------

    def update_hex_info(self):
        try:
            facets = int(numeric_value(self.sv_facets.get()))
            d1 = numeric_value(self.sv_bottom.get())
            d2 = numeric_value(self.sv_top.get())
        except ValueError:
            self.hex_info.set("")
            return
        if facets == 6:
            corner1 = self._hex_storage_from_across_flats(d1)
            corner2 = self._hex_storage_from_across_flats(d2)
            self.hex_info.set(
                "Hex: OD fields are entered across flats.  Across corners: %.3f -> %.3f in" %
                (corner1, corner2))
        else:
            self.hex_info.set("")

    def update_summary(self):
        holder = self.get_current_holder()
        if not holder:
            self.summary_var.set("")
            return
        secs = holder.get("sections", [])
        total = sum(fnum(s.get("height_inches")) for s in secs)
        reference_index = None
        for index, section in enumerate(secs):
            if bool(section.get("tool_length_reference", False)):
                reference_index = index
                break
        if reference_index is None:
            below = None
        else:
            below = sum(fnum(s.get("height_inches")) for s in secs[:reference_index + 1])
        max_od = max(
            [max(fnum(s.get("bottom_diameter_inches")), fnum(s.get("top_diameter_inches"))) for s in secs]
            or [0.0])
        if below is None:
            fixed_text = "NOT SET"
        else:
            fixed_text = "%.4f in" % below
        selected_indices = self.selected_section_indices()
        selected_height = sum(
            fnum(secs[index].get("height_inches"))
            for index in selected_indices
            if 0 <= index < len(secs))
        selected_count = len(selected_indices)

        self.summary_var.set(
            "Selected height (%d primitive%s): %.4f in\n"
            "Total modeled height: %.4f in\n"
            "Fixed length below reference: %s\n"
            "Maximum OD: %.4f in" %
            (
                selected_count,
                "" if selected_count == 1 else "s",
                selected_height,
                total,
                fixed_text,
                max_od))

    # ---------- drawing ----------

    def schedule_redraw(self):
        if self._redraw_job:
            try:
                self.after_cancel(self._redraw_job)
            except Exception:
                pass
        self._redraw_job = self.after(25, self.redraw)

    def redraw(self):
        self._redraw_job = None
        c = self.canvas
        c.delete("all")
        self.canvas_section_boxes = []
        holder = self.get_current_holder()
        if not holder:
            return
        sections = holder.get("sections", [])
        if not sections:
            return
        w = max(200, c.winfo_width())
        h = max(200, c.winfo_height())
        total_h = sum(max(0.001, fnum(sec.get("height_inches"), 0.001)) for sec in sections)
        max_d = max(
            max(fnum(sec.get("bottom_diameter_inches"), 0.001),
                fnum(sec.get("top_diameter_inches"), 0.001))
            for sec in sections)
        # The preview is geometry only now, so use the whole pane and center
        # the holder. Leave a modest border for the selection outline.
        margin_x = 30
        margin_y = 28
        drawable_w = max(80, w - (2 * margin_x))
        drawable_h = max(80, h - (2 * margin_y))
        px_per_in = min(drawable_h / total_h, drawable_w / max_d)
        center_x = w / 2.0
        bottom_y = h - margin_y
        selected = self.selected_section_index()
        selected_all = set(self.selected_section_indices())
        reference_index = None
        reference_y = None
        label_text = "T#"
        label_index = self.vector_overlay_section_index(holder)
        label_draw_info = None
        for index, section in enumerate(sections):
            if bool(section.get("tool_length_reference", False)):
                reference_index = index
                break
        y = bottom_y
        for idx, sec in enumerate(sections):
            height_in = max(0.001, fnum(sec.get("height_inches"), 0.001))
            bd = max(0.001, fnum(sec.get("bottom_diameter_inches"), 0.001))
            td = max(0.001, fnum(sec.get("top_diameter_inches"), 0.001))
            facets = inum(sec.get("facets"), 32)
            y2 = y - height_in * px_per_in
            xb_l = center_x - (bd * px_per_in / 2.0)
            xb_r = center_x + (bd * px_per_in / 2.0)
            xt_l = center_x - (td * px_per_in / 2.0)
            xt_r = center_x + (td * px_per_in / 2.0)
            fill = MATERIAL_FILL.get(infer_section_material(sec), MATERIAL_FILL["body"])
            edge_enabled = bool(self.data.get("edge_enabled", True))
            edge_width = max(0.5, min(10.0, fnum(self.data.get("edge_width", CURRENT_CONFIG_DEFAULTS["edge_width"]), CURRENT_CONFIG_DEFAULTS["edge_width"])))
            if idx == selected:
                outline = "#c35a00"
                width = max(edge_width + 2.0, 3.0)
            elif idx in selected_all:
                outline = "#3266b0"
                width = max(edge_width + 1.0, 2.0)
            elif edge_enabled:
                outline = "#000000"
                width = edge_width
            else:
                outline = ""
                width = 1
            c.create_polygon(xb_l, y, xb_r, y, xt_r, y2, xt_l, y2, fill=fill, outline=outline, width=width)
            # Keep a subtle faceted cue for hex sections without adding text.
            if facets == 6:
                inset_b = (bd * px_per_in) * 0.10
                inset_t = (td * px_per_in) * 0.10
                cue_color = "#000000" if edge_enabled else "#7c7c7c"
                cue_width = edge_width if edge_enabled else 1
                c.create_line(xb_l + inset_b, y, xt_l + inset_t, y2, fill=cue_color, width=cue_width)
                c.create_line(xb_r - inset_b, y, xt_r - inset_t, y2, fill=cue_color, width=cue_width)
            # Slightly padded hit box makes thin rings easy to click.
            self.canvas_section_boxes.append((
                idx,
                min(xb_l, xt_l) - 5,
                min(y, y2) - 4,
                max(xb_r, xt_r) + 5,
                max(y, y2) + 4
            ))
            if idx == reference_index:
                reference_y = y2
            if idx == label_index:
                label_draw_info = ((center_x), ((y + y2) / 2.0), min(bd, td) * px_per_in, (y - y2))
            y = y2
        # Spindle-face / gauge plane. This line is drawn at exactly the same
        # Y coordinate as the TOP edge of the marked ATC/reference primitive.
        # Because it is derived from the live geometry every redraw, it stays
        # coincident while any holder dimensions are edited.
        if reference_y is not None:
            c.create_line(8, reference_y, w - 8, reference_y, fill="#b00020", width=1, dash=(5, 4))
            c.create_text(12, reference_y - 5,
                          text="Dotted line represents spindle face",
                          fill="#b00020", anchor="sw", font=("Arial", 8))
        if label_draw_info is not None:
            lx, ly, section_px_w, section_px_h = label_draw_info
            font_size = int(max(8, min(16, min(section_px_w * 0.22, section_px_h * 0.45))))
            c.create_text(lx, ly, text=label_text, fill="#222222", font=("Arial", font_size, "bold"))

    # ---------- cleanup ----------

    def destroy(self):
        tk.Tk.destroy(self)

def main():
    check_for_updates()
    initial = sys.argv[1] if len(sys.argv) > 1 else None
    app = HolderBuilder(initial)
    app.mainloop()

if __name__ == "__main__":
    main()