#!/usr/bin/env python3

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
import sys

"""
Paul Magyar 6th October 2026

grid drill and line engraving G-Code Generator
----------------------------------------
The program calculates a hole and or grid pattern and can output
LinuxCNC-style G-code for drilling/line engraving grid.
"""

DEFAULTS = {
    "width": 100.0, "height": 100.0, "sx": 10.0, "sy": 10.0,
    "clear_z": 5.0, "cut_z": -0.5, "cut_feed": 300.0,
    "plunge_feed": 100.0, "g81_depth": -0.5, "g81_r": 5.0,
    "g81_feed": 100.0, "grid_depth": -0.5, "grid_feed": 300.0,
}

root = tk.Tk()
root.title("LinuxCNC Grid Tool - G81")
root.geometry("1000x860")

vars_ = {k: tk.StringVar(value=str(v)) for k, v in DEFAULTS.items()}
mode = tk.StringVar(value="Drill Holes")
gcode_text = preview = info_label = None

def positions(total, spacing):
    if spacing <= 0:
        raise ValueError("Spacing must be greater than zero.")
    n = round(total / spacing)
    if abs(n * spacing - total) > 1e-7:
        raise ValueError("Grid width/height must be an exact multiple of spacing.")
    return [-total / 2 + i * spacing for i in range(n + 1)]

def get_values():
    vals = {k: float(v.get()) for k, v in vars_.items()}
    if vals["width"] <= 0 or vals["height"] <= 0:
        raise ValueError("Grid width and height must be positive.")
    if vals["sx"] <= 0 or vals["sy"] <= 0:
        raise ValueError("Spacing must be positive.")
    if vals["clear_z"] <= vals["grid_depth"]:
        raise ValueError("Clear Z must be above Grid line depth Z.")
    if vals["g81_r"] <= vals["g81_depth"]:
        raise ValueError("G81 R plane must be above G81 depth Z.")
    return vals

def build_gcode():
    v = get_values()
    xs, ys = positions(v["width"], v["sx"]), positions(v["height"], v["sy"])
    m = mode.get()
    lines = [
        "(LinuxCNC PARAMETRIC GRID)", "(Generated by LinuxCNC Grid Tool)",
        f"(Mode: {m})", "G21", "G90", "G17", "G54", "",
        f"#<grid_width> = {v['width']:.3f}",
        f"#<grid_height> = {v['height']:.3f}",
        f"#<spacing_x> = {v['sx']:.3f}",
        f"#<spacing_y> = {v['sy']:.3f}",
        f"#<clear_z> = {v['clear_z']:.3f}",
        f"#<cut_z> = {v['cut_z']:.3f}",
        f"#<cut_feed> = {v['cut_feed']:.3f}",
        f"#<plunge_feed> = {v['plunge_feed']:.3f}",
        f"#<g81_depth> = {v['g81_depth']:.3f}",
        f"#<g81_r> = {v['g81_r']:.3f}",
        f"#<g81_feed> = {v['g81_feed']:.3f}",
        f"#<grid_depth> = {v['grid_depth']:.3f}",
        f"#<grid_feed> = {v['grid_feed']:.3f}", "", "G0 Z#<clear_z>",
    ]
    if m in ("Mill Grid", "Both"):
        lines += ["", "(--- HORIZONTAL GRID LINES ---)"]
        for y in ys:
            lines += [
                f"G0 X{xs[0]:.3f} Y{y:.3f}",
                "G1 Z#<grid_depth> F#<plunge_feed>",
                f"G1 X{xs[-1]:.3f} F#<grid_feed>", "G0 Z#<clear_z>",
            ]
        lines += ["", "(--- VERTICAL GRID LINES ---)"]
        for x in xs:
            lines += [
                f"G0 X{x:.3f} Y{ys[0]:.3f}",
                "G1 Z#<grid_depth> F#<plunge_feed>",
                f"G1 Y{ys[-1]:.3f} F#<grid_feed>", "G0 Z#<clear_z>",
            ]
    if m in ("Drill Holes", "Both"):
        if m == "Both":
            lines += [
                "", "(--- TOOL CHANGE: MILL TO DRILL ---)", "G80",
                "G0 Z#<clear_z>", "G0 X0 Y0", "M5",
                "M0 (PAUSE - CHANGE TO DRILL TOOL, THEN PRESS CYCLE START)",
            ]
        lines += [
            "", "(--- DRILL HOLES USING G81 ---)", "G0 Z#<clear_z>", "G98",
            "G81 Z#<g81_depth> R#<g81_r> F#<g81_feed>",
        ]
        for y in ys:
            for x in xs:
                lines.append(f"X{x:.3f} Y{y:.3f}")
        lines += ["G80", "G0 Z#<clear_z>"]
    lines += ["G0 X0 Y0", "M5", "M30"]
    return "\n".join(lines) + "\n"

def generate_gcode():
    try:
        data = build_gcode()
        gcode_text.delete("1.0", "end")
        gcode_text.insert("1.0", data)
        gcode_text.see("1.0")
        update_preview()
        return True
    except Exception as e:
        messagebox.showerror("Input error", str(e))
        return False

def save_gcode():
    if not gcode_text.get("1.0", "end-1c").strip() and not generate_gcode():
        return
    data = gcode_text.get("1.0", "end-1c")
    filename = filedialog.asksaveasfilename(
        title="Save LinuxCNC G-code", defaultextension=".ngc",
        filetypes=[("LinuxCNC G-code", "*.ngc"), ("G-code", "*.gcode"), ("All files", "*.*")]
    )
    if filename:
        try:
            Path(filename).write_text(data, encoding="utf-8")
            messagebox.showinfo("Saved", f"Saved:\n{filename}")
        except Exception as e:
            messagebox.showerror("Save error", str(e))

def copy_gcode():
    data = gcode_text.get("1.0", "end-1c")
    if data.strip():
        root.clipboard_clear()
        root.clipboard_append(data)
        root.update()
        messagebox.showinfo("Copied", "G-code copied to the clipboard.")

def write_to_axis():
    try:
        data = build_gcode()
    except Exception as e:
        messagebox.showerror("Input error", str(e))
        return
    # Exact mechanism used by the supplied working LinuxCNC script.
    for line in data.splitlines():
        print(line, flush=True)
    root.destroy()
    sys.exit(0)

def reset_defaults():
    for k, val in DEFAULTS.items():
        vars_[k].set(str(val))
    mode.set("Drill Holes")
    gcode_text.delete("1.0", "end")
    update_preview()

def update_preview(*args):
    preview.delete("all")
    try:
        v = get_values()
        xs, ys = positions(v["width"], v["sx"]), positions(v["height"], v["sy"])
    except Exception:
        info_label.config(text="Enter valid grid dimensions and spacing.")
        return
    w, h, margin = max(1, preview.winfo_width()), max(1, preview.winfo_height()), 45
    scale = min((w-2*margin)/v["width"], (h-2*margin)/v["height"])
    ox, oy = w/2, h/2
    px, py = lambda x: ox+x*scale, lambda y: oy-y*scale
    if mode.get() in ("Mill Grid", "Both"):
        for y in ys: preview.create_line(px(xs[0]), py(y), px(xs[-1]), py(y))
        for x in xs: preview.create_line(px(x), py(ys[0]), px(x), py(ys[-1]))
    if mode.get() in ("Drill Holes", "Both"):
        for y in ys:
            for x in xs:
                r=4; preview.create_oval(px(x)-r, py(y)-r, px(x)+r, py(y)+r)
    preview.create_line(px(0)-8, py(0), px(0)+8, py(0))
    preview.create_line(px(0), py(0)-8, px(0), py(0)+8)
    info_label.config(text=f"X positions: {len(xs)}    Y positions: {len(ys)}    Total holes: {len(xs)*len(ys)}")

top = ttk.Frame(root, padding=10); top.pack(fill="x")
fields = [
    ("Grid width X (mm)", "width"), ("Grid height Y (mm)", "height"),
    ("Spacing X (mm)", "sx"), ("Spacing Y (mm)", "sy"),
    ("Clear Z (mm)", "clear_z"), ("Grid line depth Z (mm)", "grid_depth"),
    ("Grid line feed (mm/min)", "grid_feed"), ("Grid plunge feed (mm/min)", "plunge_feed"),
    ("G81 depth Z (mm)", "g81_depth"), ("G81 R plane (mm)", "g81_r"),
    ("G81 feed (mm/min)", "g81_feed"),
]
for i,(label,key) in enumerate(fields):
    r,c=divmod(i,4)
    ttk.Label(top,text=label).grid(row=r*2,column=c,padx=5,pady=(2,0),sticky="w")
    ttk.Entry(top,textvariable=vars_[key],width=15).grid(row=r*2+1,column=c,padx=5,pady=(0,6),sticky="ew")

mode_frame=ttk.LabelFrame(root,text="Operation",padding=8); mode_frame.pack(fill="x",padx=10,pady=5)
for name in ("Drill Holes","Mill Grid","Both"):
    ttk.Radiobutton(mode_frame,text=name,value=name,variable=mode,command=update_preview).pack(side="left",padx=15)

preview=tk.Canvas(root,height=260,relief="sunken",borderwidth=1); preview.pack(fill="both",expand=True,padx=10,pady=8)
info_label=ttk.Label(root,text=""); info_label.pack(pady=2)
button_frame=ttk.Frame(root); button_frame.pack(pady=8)
ttk.Button(button_frame,text="Generate G-code",command=generate_gcode,width=20).pack(side="left",padx=5)
ttk.Button(button_frame,text="Save to .NGC",command=save_gcode,width=20).pack(side="left",padx=5)
ttk.Button(button_frame,text="Copy G-code",command=copy_gcode,width=16).pack(side="left",padx=5)
ttk.Button(button_frame,text="Reset",command=reset_defaults,width=12).pack(side="left",padx=5)
ttk.Button(root,text="Write to AXIS and Quit",command=write_to_axis,width=28).pack(pady=(0,8))
gcode_text=tk.Text(root,height=12,width=100,wrap="none"); gcode_text.pack(fill="both",expand=True,padx=10,pady=(0,10))
for var in vars_.values(): var.trace_add("write",update_preview)
root.after(100,update_preview)
root.mainloop()
