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

#############################################
##                                         ##
##   Gremlin Tool Holder Builder 1.13      ##
##          www.tormachtips.com            ##
##                                         ##
#############################################

# 1.13 - Standardized all holder file/folder names on gremlin_tool_holder* naming. - 9/17/2026
# 1.12 - Removed the unused donation reminder from this standalone builder; update checking remains enabled. - 9/17/2026
# 1.11 - Restored standalone Python 2 helper definitions and retained the custom Help / Warning dialog. - 9/17/2026
# 1.10 - Added custom visualization warning/help presentation and final standalone holder-builder layout. - 9/17/2026

"""
Gremlin Tool Holder Builder
Standalone Python 2.7 / Tkinter editor for PathPilot Gremlin holder definitions.
No third-party packages required.
Compatible with the multi-holder gremlin_tool_holder_config.json schema used by
the TormachTips Gremlin Tool Holder plugin.
Usage:
    python2 gremlin_tool_holder_builder.py
    python2 gremlin_tool_holder_builder.py /path/to/gremlin_tool_holder_config.json
"""

import copy
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.13"
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"

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",
            "height_inches": 0.700,
            "bottom_diameter_inches": 1.500,
            "top_diameter_inches": 1.500,
            "facets": 6,},
        {
            "name": "gutter / relief",
            "height_inches": 0.200,
            "bottom_diameter_inches": 1.100,
            "top_diameter_inches": 1.100,
            "facets": 32,},
        {
            "name": "main body",
            "height_inches": 0.700,
            "bottom_diameter_inches": 1.250,
            "top_diameter_inches": 1.250,
            "facets": 32,},
        {
            "name": "ATC retention ring",
            "tool_length_reference": True,
            "height_inches": 0.200,
            "bottom_diameter_inches": 2.000,
            "top_diameter_inches": 2.000,
            "facets": 32,},
        {
            "name": "upper TTS 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"

def fnum(value, default=0.0):
    try:
        return float(value)
    except (TypeError, ValueError):
        return default

def inum(value, default=32):
    try:
        return max(3, int(float(value)))
    except (TypeError, ValueError):
        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.section_vars = []
        self._redraw_job = None
        self._build_ui()
        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)

    # ---------- 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)
        holderbar = ttk.Frame(self, 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(self, 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)
        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="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")
        # Mouse wheel scrolling for right panel.
        right_canvas.bind_all("<MouseWheel>", lambda e: right_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"))
        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)
        render_box = ttk.LabelFrame(self.right, text="Rendering", padding=8)
        render_box.pack(fill="x", pady=(0, 8))
        self.edge_enabled_var = tk.BooleanVar(value=True)
        self.edge_width_var = tk.StringVar(value="1.5")
        ttk.Checkbutton(
            render_box,
            text="Black holder edges",
            variable=self.edge_enabled_var,
            command=self.on_render_style_changed,
        ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 4))
        self._grid_entry(render_box, 1, "Edge thickness (px)", self.edge_width_var)
        self.edge_width_var.trace("w", self.on_render_style_changed)
        section_box = ttk.LabelFrame(self.right, text="Selected primitive", padding=8)
        section_box.pack(fill="x", pady=(0, 8))
        self.section_box = section_box
        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_below = tk.BooleanVar()
        self._grid_entry(section_box, 0, "Name", self.sv_name)
        self._grid_entry(section_box, 1, "Height (in)", self.sv_height)
        self._grid_entry(section_box, 2, "Bottom OD (in)", self.sv_bottom)
        self._grid_entry(section_box, 3, "Top OD (in)", self.sv_top)
        self._grid_entry(section_box, 4, "Facets", self.sv_facets)
        ttk.Checkbutton(
            section_box,
            text="Tool-length reference at TOP of this primitive",
            variable=self.sv_below,
            command=self.on_section_changed,
        ).grid(row=5, column=0, columnspan=2, sticky="w", pady=(5, 2))
        self.hex_info = tk.StringVar(value="")
        ttk.Label(section_box, textvariable=self.hex_info).grid(row=6, 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 = (
            "Reference plane is the TOP of the marked primitive.\n"
            "All primitives below it, including that primitive, form fixed holder length.\n"
            "Gremlin stickout = PathPilot tool length - fixed holder length.")
        ttk.Label(summary, text=hint, justify="left").pack(anchor="w", pady=(8, 0))
        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.destroy)
        m.add_cascade(label="File", menu=fm)
        m.add_command(label="Help / Warning", command=self.show_visual_warning)
        self.config(menu=m)

    @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 _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 normalize_holder_references(self, data):
        holder_types = data.get("holder_types")
        if not isinstance(holder_types, dict):
            return False
        changed = False
        if "edge_enabled" not in data:
            data["edge_enabled"] = True
            changed = True
        if "edge_width" not in data:
            data["edge_width"] = 1.5
            changed = True
        for holder_id, holder in holder_types.items():
            if not isinstance(holder, dict):
                continue
            expected_image = "gremlin_tool_holders/%s.svg" % sanitize_holder_id(holder_id).lower()
            if holder.get("image") != expected_image:
                holder["image"] = expected_image
                changed = True
            sections = holder.get("sections")
            if not isinstance(sections, list) or not sections:
                continue
            explicit = []
            for index, section in enumerate(sections):
                if isinstance(section, dict) and bool(section.get("tool_length_reference", False)):
                    explicit.append(index)
            reference_index = explicit[0] if len(explicit) == 1 else None
            if reference_index is None:
                for index, section in enumerate(sections):
                    if not isinstance(section, dict):
                        continue
                    name = str(section.get("name", "")).lower()
                    if "atc" in name and ("ring" in name or "flange" in name):
                        reference_index = index
                        break
            for index, section in enumerate(sections):
                if not isinstance(section, dict):
                    continue
                if "below_tool_length_reference" in section:
                    del section["below_tool_length_reference"]
                    changed = True
                if reference_index is not None and index == reference_index:
                    if not bool(section.get("tool_length_reference", False)):
                        section["tool_length_reference"] = True
                        changed = True
                else:
                    if "tool_length_reference" in section:
                        del section["tool_length_reference"]
                        changed = True
        return changed

    def show_visual_warning(self):
        dialog = tk.Toplevel(self)
        dialog.title("Holder Visualization - Help / Warning")
        dialog.transient(self)
        dialog.resizable(False, False)
        outer = ttk.Frame(dialog, padding=14)
        outer.pack(fill="both", expand=True)
        title = ttk.Label(outer, text="Holder Visualization", font=("TkDefaultFont", 12, "bold"))
        title.pack(anchor="w")
        subtitle = ttk.Label(
            outer,
            text="Visual aid only - it does not alter machine-control offsets.",
            font=("TkDefaultFont", 10, "bold"))
        subtitle.pack(anchor="w", pady=(2, 12))

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

        add_section(
            "What this changes",
            "Holder assignments, Gremlin geometry, and generated SVG thumbnails are visualization only. "
            "They do not change PathPilot or LinuxCNC tool length, tool diameter, work offsets, or any other machine-control offset.")
        add_section(
            "Accuracy",
            "The models are generated from user-entered dimensions. They may be incomplete, inaccurate, "
            "or different from the physical holder and cutter actually installed in the spindle.")
        add_section(
            "Machining and safety",
            "Do not use this visualization as a collision-checking, clearance, setup, or safety authority. "
            "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=(16, 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_reqwidth()) // 2)
            y = self.winfo_rooty() + max(20, (self.winfo_height() - dialog.winfo_reqheight()) // 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 = {
            "enabled": True,
            "edge_enabled": True,
            "edge_width": 1.5,
            "stickout_mode": "tool_table_length",
            "default_holder_type": "NEW_TTS_HOLDER",
            "default_stickout_inches": 1.5,
            "minimum_stickout_inches": 0.05,
            "tool_stickout_inches": {},
            "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.status_var.set("Created new holder catalog")

    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
        if not isinstance(data, dict) or not isinstance(data.get("holder_types"), dict):
            messagebox.showerror(APP_TITLE, "This file does not contain the multi-holder 'holder_types' schema.")
            return
        normalized = self.normalize_holder_references(data)
        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()
        if normalized:
            self.status_var.set("Loaded %s - reference markers normalized; Save to write changes" % self.config_path)
        else:
            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 = fnum(self.data.get("edge_width", 1.5), 1.5)
        edge_width = max(0.5, min(10.0, edge_width))
        stroke = u"#000000" if edge_enabled else u"none"
        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" fill="#c8c8c8">' % (stroke, edge_width),]
        for sec in 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:
                # Engineering-style hex cue while keeping configured overall OD.
                chamfer = min((y - y2) * 0.22, min(bd, td) * scale * 0.10)
                points = [
                    (xb_l + chamfer, y), (xb_r - chamfer, y),
                    (xb_r, y - chamfer), (xt_r, y2 + chamfer),
                    (xt_r - chamfer, y2), (xt_l + chamfer, y2),
                    (xt_l, y2 + chamfer), (xb_l, y - chamfer),]
            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)
            parts.append(u'<polygon points="%s"/>' % point_text)
            y = y2
        parts.append(u'</g>')
        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
        try:
            edge_width = float(self.data.get("edge_width", 1.5))
        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
            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 bool(sec.get("tool_length_reference", False)):
                    reference_count += 1
            if reference_count != 1:
                messagebox.showerror(APP_TITLE, "%s must have exactly one tool-length reference primitive." % hid)
                return False
        return True

    def save(self):
        self.commit_section_from_ui()
        self.commit_meta_from_ui()
        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.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)
        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
        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
        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
        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
        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.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)

    # ---------- 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", 1.5)))
        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:
            return
        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 not self.data:
            return
        self.data["edge_enabled"] = bool(self.edge_enabled_var.get())
        try:
            width = float(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()
        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"))
            shape = "hex" if facets == 6 else ("%d-facet" % facets if facets < 24 else "round")
            self.primitive_list.insert(
                "end",
                "%d. %s   H %.3f   OD %.3f->%.3f   %s" %
                (display_i + 1, name, h, d1, d2, shape))
        if select_index is not None and self.primitive_list.size():
            display_index = self._storage_to_display_index(select_index)
            display_index = max(0, min(display_index, self.primitive_list.size() - 1))
            self.primitive_list.selection_set(display_index)
            self.primitive_list.activate(display_index)

    def selected_section_index(self):
        sel = self.primitive_list.curselection()
        if not sel:
            return None
        return self._display_to_storage_index(int(sel[0]))

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

    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_below.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", "")))
            self.sv_bottom.set(str(sec.get("bottom_diameter_inches", "")))
            self.sv_top.set(str(sec.get("top_diameter_inches", "")))
            self.sv_facets.set(str(sec.get("facets", 32)))
            self.sv_below.set(bool(sec.get("tool_length_reference", False)))
        finally:
            self.loading_ui = False
        self.update_hex_info()
        self.schedule_redraw()

    def on_section_changed(self, *args):
        if self.loading_ui:
            return
        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 = float(self.sv_height.get())
            if h > 0:
                sec["height_inches"] = h
        except ValueError:
            pass
        try:
            v = float(self.sv_bottom.get())
            if v > 0:
                sec["bottom_diameter_inches"] = v
        except ValueError:
            pass
        try:
            v = float(self.sv_top.get())
            if v > 0:
                sec["top_diameter_inches"] = v
        except ValueError:
            pass
        try:
            sec["facets"] = max(3, int(float(self.sv_facets.get())))
        except ValueError:
            pass
        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(select_index=idx)

    def add_section(self):
        holder = self.get_current_holder()
        if not holder:
            return
        idx = self.selected_section_index()
        insert_at = len(holder["sections"]) if idx is None else idx + 1
        sec = {
            "name": "new section",
            "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 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
        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
        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)
        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.schedule_redraw()

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

    def update_hex_info(self):
        try:
            facets = int(float(self.sv_facets.get()))
            d1 = float(self.sv_bottom.get())
            d2 = float(self.sv_top.get())
        except ValueError:
            self.hex_info.set("")
            return
        if facets == 6:
            flat1 = d1 * math.cos(math.pi / 6.0)
            flat2 = d2 * math.cos(math.pi / 6.0)
            self.hex_info.set("Hex: OD fields are across corners.  Across flats: %.3f -> %.3f in" % (flat1, flat2))
        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
        self.summary_var.set(
            "Total modeled height: %.4f in\n"
            "Fixed length below reference: %s\n"
            "Maximum OD: %.4f in" %
            (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()
        reference_index = None
        reference_y = 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 = "#d8e8f5" if reference_index is None or idx <= reference_index else "#e9e1f5"
            edge_enabled = bool(self.data.get("edge_enabled", True))
            edge_width = max(0.5, min(10.0, fnum(self.data.get("edge_width", 1.5), 1.5)))
            if idx == selected:
                outline = "#c35a00"
                width = max(edge_width + 2.0, 3.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
            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))

    # ---------- 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()