"""
Conversational Profile Editor - profile-editor
Author: Oscar Wilbers (2026)

Minimal conversational lathe profiler for LinuxCNC/PathPilot (PyNGCGUI-friendly).

 - Enter Z,X profile points (diameter values if using G7) in the order to be cut.
 - Add per-segment chamfer/fillet (A/C) corner words into the profile block.
 - Choose G71 (longitudinal) or G72 (facing) roughing, with G70 finish.
 - Preview stock + profile.
 - Generate a .ngc with the cycle call and pattern block.

IMPORTANT: AXIS Integration
  Profiler generates a real .ngc file and loads it using axis-remote.
  This ensures AXIS runs the generated G-code, NOT the Python script.
  
  DO NOT reintroduce stdout-based filter behavior. If profiler.py output
  goes to stdout and AXIS consumes it as a filter, AXIS will re-run the
  Python script instead of the G-code, causing infinite loops and wrong behavior.
  
  The axis-remote method is correct: generate .ngc file → load via axis-remote.

Standard library only (Tk). Reuse ProfileGenerator in your own PyNGCGUI panel if desired.
"""
from __future__ import annotations

import math
import sys
import os
import json
import random
import time
import subprocess
import re
from pathlib import Path
from dataclasses import dataclass
from typing import List, Sequence, Tuple, Any, Optional
from abc import ABC, abstractmethod

# ============================================================================
# Inlined: check_invariants_linuxcnc.py (LinuxCNC validation)
# ============================================================================
FORBIDDEN_PATTERNS = {
    r"\bM98\b": "M98 is forbidden (Fanuc-style subprogram calls)",
    r"\bM99\b": "M99 is forbidden (LinuxCNC subs must not use return)",
    r"\bT\d{4}\b": "T0101-style tool selection is forbidden",
    r"(?im)^\s*T\d+\s*$": "Bare tool line is forbidden (use 'Tn M6')",
    r"\bP100\b": "Hardcoded P100 is forbidden (drifting Fanuc logic)",
    r"\bQ100\b": "Hardcoded Q100 is forbidden (drifting Fanuc logic)",
}

G71_G72_PATTERN = r"\bG7[12]\b.*\bQ\d+\b"

REQUIRED_PATTERNS = {
    G71_G72_PATTERN: "G71/G72 must use Q<sub_id>",
    r"\bo\d+\s+sub\b": "At least one o#### sub block is required",
    r"\bo\d+\s+call\b": "Main program must call a subprogram",
    r"(?im)^\s*T\d+\s+M6\b": "At least one tool change 'Tn M6' required",
    r"(?im)^\s*T\d+\s+M6\b.*\bG43\b": "G43 must appear after tool change 'Tn M6'",
    r"(?ims)M5.*^\s*T\d+\s+M6\b": "Spindle stop (M5) must appear before the first 'Tn M6'",
    r"\bM30\b": "Program must end with M30",
}

SINGLETON_PATTERNS = {
    r"\bM30\b": "Only one M30 is allowed",
}

def check_gcode(text: str, require_g71_g72: bool = True) -> list[str]:
    """Validate LinuxCNC G-code invariants.

    require_g71_g72: set False when the whole program intentionally bypasses
    G71/G72 (see ProfileParams.direct_finish_only / wrapper_finish_direct_subprogram_gcode).
    """
    errors = []

    # Forbidden checks
    for pattern, msg in FORBIDDEN_PATTERNS.items():
        if re.search(pattern, text, re.IGNORECASE):
            errors.append(msg)

    # Required checks
    for pattern, msg in REQUIRED_PATTERNS.items():
        if pattern == G71_G72_PATTERN and not require_g71_g72:
            continue
        if not re.search(pattern, text, re.IGNORECASE):
            errors.append(f"Missing required pattern: {msg}")

    # Singleton checks
    for pattern, msg in SINGLETON_PATTERNS.items():
        count = len(re.findall(pattern, text, re.IGNORECASE))
        if count != 1:
            errors.append(f"{msg} (found {count})")

    return errors

# ============================================================================
# Inlined: ops_manager.py (Operation management and G-code generation)
# ============================================================================

class OperationBase(ABC):
    """Base class for an operation (Profile, Threading, etc.)."""

    def __init__(self, name: str, tool: int, sub_id: int | None = None, tool_offset: int | None = None) -> None:
        """
        Args:
            name: Human-readable name (e.g., "Profile OD")
            tool: Tool code/number for this operation (e.g. "1", "2", "3")
            sub_id: Optional explicit base subprogram number; if None, one is generated
            tool_offset: Ignored (kept for backwards compatibility)
        """
        self.name = name
        
        # Preserve user's tool input exactly as entered
        if isinstance(tool, str):
            self.tool_code = tool.strip()
        else:
            self.tool_code = str(tool)
        
        # Guard: Reject 4-digit Fanuc-style tool codes (e.g., 0101, 0305)
        if self.tool_code.isdigit() and len(self.tool_code) == 4:
            raise ValueError(
                f"Tool code '{self.tool_code}' appears to be Fanuc-style (TTOO format). "
                f"LinuxCNC uses simple tool numbers. Use '{int(self.tool_code[:2])}' instead."
            )
        
        # Parse for backwards compatibility (in case some code needs numeric values)
        tool_str = self.tool_code
        if tool_str.isdigit():
            if len(tool_str) >= 4:
                self.tool = int(tool_str[:2])
                self.tool_offset = int(tool_str[2:4])
            else:
                self.tool = int(tool_str)
                self.tool_offset = int(tool_str)
        else:
            self.tool = 0
            self.tool_offset = 0
        
        base_id = sub_id or random.randint(1000, 8999)
        self.contour_id = base_id
        self.op_id = base_id + 1

    @abstractmethod
    def subprograms(self) -> List[str]:
        """Return all subprogram blocks needed for this operation."""
        pass

    @abstractmethod
    def main_lines(self) -> List[str]:
        """Return main program lines for this operation (tool change + call)."""
        pass


class ProfileOperation(OperationBase):
    """A lathe profile operation (roughing + finishing via G71/G72)."""

    def __init__(
        self,
        name: str,
        points: List,  # List[Point]
        params,  # ProfileParams
        tool: int | None = None,
        sub_id: int | None = None,
        tool_offset: int | None = None,
    ) -> None:
        """
        Args:
            name: Operation name (e.g., "Profile OD")
            points: List of Point objects defining the profile
            params: ProfileParams instance with cycle, feeds, etc.
            tool: Tool number (overrides params.tool if provided)
            sub_id: Optional explicit subprogram number; if None, one is generated
            tool_offset: Tool offset (defaults to tool number if not specified)
        """
        self.points = points
        self.params = params
        self.generator = ProfileGenerator(params)

        # Use provided tool or fall back to params
        actual_tool = tool if tool is not None else params.tool

        super().__init__(name, actual_tool, sub_id=sub_id, tool_offset=tool_offset)
        
        # Store finish tool separately, defaulting to rough tool if not provided
        finish_val = getattr(params, "tool_finish", None)
        if isinstance(finish_val, str):
            finish_str = finish_val.strip()
            self.tool_finish_code = finish_str if finish_str else str(actual_tool).strip()
        elif finish_val is None:
            self.tool_finish_code = str(actual_tool).strip()
        else:
            self.tool_finish_code = str(finish_val)
        
        # Check if we need separate rough/finish wrappers
        self.separate_tools = (self.tool_code != self.tool_finish_code)
        
        # Assign wrapper IDs
        if self.separate_tools:
            self.rough_wrapper_id = self.op_id  # base + 1
            self.finish_wrapper_id = self.op_id + 1  # base + 2
        else:
            self.rough_wrapper_id = self.op_id  # base + 1 (combined wrapper)
            self.finish_wrapper_id = None

    def subprograms(self) -> List[str]:
        """Return contour + wrapper subprograms for this profile operation."""
        contour_block = self.generator.contour_subprogram_gcode(self.points, subprogram_number=self.contour_id)

        if self.params.direct_finish_only:
            # Bypass G71/G70 entirely: G0 approach + direct contour call @ feed_finish + retract.
            # See wrapper_finish_direct_subprogram_gcode for rationale.
            direct_block = self.generator.wrapper_finish_direct_subprogram_gcode(
                self.points, contour_sub_id=self.contour_id, wrapper_sub_id=self.op_id
            )
            return [contour_block, direct_block]
        elif self.separate_tools:
            # Generate separate rough and finish wrappers
            rough_block = self.generator.wrapper_rough_subprogram_gcode(
                self.points, contour_sub_id=self.contour_id, wrapper_sub_id=self.rough_wrapper_id
            )
            finish_block = self.generator.wrapper_finish_subprogram_gcode(
                self.points, contour_sub_id=self.contour_id, wrapper_sub_id=self.finish_wrapper_id
            )
            return [contour_block, rough_block, finish_block]
        else:
            # Generate combined wrapper (G71+G70)
            wrapper_block = self.generator.wrapper_subprogram_gcode(
                self.points, contour_sub_id=self.contour_id, wrapper_sub_id=self.op_id
            )
            return [contour_block, wrapper_block]

    def main_lines(self) -> List[str]:
        """Return main program lines for this operation."""
        if self.params.direct_finish_only:
            return [f"T{self.tool_code}", f"o{self.op_id} call"]
        elif self.separate_tools:
            return [
                f"T{self.tool_code}",
                f"o{self.rough_wrapper_id} call",
                f"T{self.tool_finish_code}",
                f"o{self.finish_wrapper_id} call",
            ]
        else:
            return [f"T{self.tool_code}", f"o{self.op_id} call"]



class OperationManager:
    """Manages a list of operations and generates a combined main program + subprograms."""

    def __init__(self, metric: bool = True, spindle_rpm: int = 1000, spindle_dir: str = "M3") -> None:
        """
        Args:
            metric: True for G21 (mm), False for G20 (inches)
            spindle_rpm: Default spindle speed
            spindle_dir: Default spindle direction (M3/M4)
        """
        self.metric = metric
        self.spindle_rpm = spindle_rpm
        self.spindle_dir = spindle_dir
        self.operations: List[OperationBase] = []

    def add_operation(self, op: OperationBase) -> None:
        """Add an operation to the manager."""
        self.operations.append(op)

    def clear_operations(self) -> None:
        """Remove all operations."""
        self.operations.clear()

    def generate(self) -> str:
        """Generate the complete G-code program."""
        if not self.operations:
            raise ValueError("No operations to generate.")

        lines: List[str] = []

        # Track used O numbers to avoid collisions
        used_ids: set[int] = set()
        for op in self.operations:
            used_ids.add(op.contour_id)
            while op.op_id in used_ids:
                op.op_id += 1
            used_ids.add(op.op_id)
            # rough_wrapper_id is aliased to op_id at init; keep them in sync after any bump
            if isinstance(op, ProfileOperation):
                op.rough_wrapper_id = op.op_id

            # For ProfileOperation with separate tools, also track finish_wrapper_id
            if isinstance(op, ProfileOperation) and op.finish_wrapper_id is not None:
                while op.finish_wrapper_id in used_ids:
                    op.finish_wrapper_id += 1
                used_ids.add(op.finish_wrapper_id)

        # ===== SUBPROGRAMS (FIRST): contour then wrapper =====
        for i, op in enumerate(self.operations):
            if i > 0:
                lines.append("")
            for block in op.subprograms():
                lines.append(block.rstrip())
                lines.append("")
            if lines and lines[-1] == "":
                lines.pop()

        # ===== MAIN PROGRAM HEADER =====
        lines.append("")
        lines.append("(Main program)")
        lines.append("G21" if self.metric else "G20")
        lines.append("G18")
        
        if self.operations:
            first_op = self.operations[0]
            if isinstance(first_op, ProfileOperation):
                if first_op.params.diameter_mode:
                    lines.append("G7")
                if first_op.params.feed_per_rev:
                    lines.append("G95")

        # ===== OPERATION EXECUTION =====
        # Before the FIRST subprogram call, explicitly drive the tool to a
        # KNOWN-SAFE position -- regardless of where it physically was when
        # this program started (left over from a previous job, manual jog,
        # machine home, etc.).
        #
        # Same "L-shaped" safety argument as _explicit_g71_g70_transition:
        #   1. G0 X{retract_x} -- retract_x = max(stock_diam, profile_extent_max_x)
        #      + safe_clear_x is, by definition, >= stock_diam, which is the
        #      ORIGINAL (maximum possible) material radius at ANY Z. Machining
        #      only REMOVES material, so the CURRENT material radius R(Z) at
        #      every Z satisfies R(Z) <= stock_diam <= retract_x. A tool
        #      physically sitting in/around the stock must already be at
        #      X_tool >= R(Z_tool) (can't be inside solid material); moving
        #      X-only to retract_x (>= stock_diam >= R(Z_tool)) therefore never
        #      crosses into material, for ANY starting (X_tool, Z_tool).
        #   2. G0 Z{start_z} -- at X=retract_x (>= stock_diam >= R(Z) for every
        #      Z), the tool is radially clear of all possible material at every
        #      Z, so a Z-only move to start_z is safe from any starting Z.
        #
        # After this, the wrapper's own first moves (G0 X{start_x}, G0 Z{start_z})
        # take over from this known position.
        first_op = self.operations[0] if self.operations else None
        initial_safe_lines: List[str] = []
        if isinstance(first_op, ProfileOperation):
            retract_x = first_op.generator._calculate_retract_x(first_op.points)
            start_z = (first_op.params.start_z if abs(first_op.params.start_z) > 1e-9
                       else first_op.points[0].z + first_op.params.safe_clear_z)
            initial_safe_lines = [f"G0 X{retract_x:.3f}", f"G0 Z{start_z:.3f}"]

        inserted_initial_safe_move = False
        for op in self.operations:
            main_lines = op.main_lines()
            i = 0
            while i < len(main_lines):
                line = main_lines[i].strip()
                if line.startswith("T"):
                    lines.append("M5")
                    lines.append(f"{line} M6 G43")
                    lines.append("G97 S%d %s" % (self.spindle_rpm, self.spindle_dir))
                    i += 1
                    continue
                if not inserted_initial_safe_move and initial_safe_lines:
                    lines.extend(initial_safe_lines)
                    inserted_initial_safe_move = True
                lines.append(main_lines[i])
                i += 1

        # ===== MAIN PROGRAM FOOTER =====
        # Safe retract first (spindle still running), then stop
        last_op = self.operations[-1] if self.operations else None
        if last_op is not None and isinstance(last_op, ProfileOperation):
            p = last_op.params
            pts = last_op.points
            # Use _calculate_retract_x (NOT _calculate_start_x) so that a user-set
            # start_x (e.g. bore diameter for approach entry) never becomes the retract
            # target while the tool is deep in the workpiece — that would cause a crash.
            retract_x = last_op.generator._calculate_retract_x(pts)
            retract_z = (p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z)
            lines.append(f"G0 X{retract_x:.3f}")
            lines.append(f"G0 Z{retract_z:.3f}")
        lines.append("M9")
        lines.append("M5")
        lines.append("M30")
        
        gcode = "\n".join(lines) + "\n"

        # If EVERY operation intentionally bypasses G71/G72 (direct_finish_only),
        # the program legitimately contains no G71/G72 -- don't flag that.
        require_g71_g72 = not (
            self.operations
            and all(
                isinstance(op, ProfileOperation) and op.params.direct_finish_only
                for op in self.operations
            )
        )
        errors = check_gcode(gcode, require_g71_g72=require_g71_g72)
        if errors:
            raise ValueError("LinuxCNC invariants failed:\n- " + "\n- ".join(errors))

        return gcode

# Lazy import tkinter (only needed for GUI, not for code generation)
tk = None
ttk = None
filedialog = None
messagebox = None
scrolledtext = None
simpledialog = None


@dataclass
class Point:
    z: float
    x: float
    r: float = 0.0  # arc radius to this point (0 = line)
    corner_type: str | None = None  # "A" fillet, "C" chamfer (per LinuxCNC G71/G72 spec)
    corner_value: float | None = None
    
    def __post_init__(self):
        """Round all coordinates to 3 decimals to match display and avoid floating point errors."""
        self.z = round(self.z, 3)
        self.x = round(self.x, 3)
        self.r = round(self.r, 3)
        if self.corner_value is not None:
            self.corner_value = round(self.corner_value, 3)


@dataclass
class ProfileParams:
    cycle: str = "G71"  # or G72
    od: bool = True
    diameter_mode: bool = True  # G7 vs G8
    metric: bool = True  # G21 (mm) vs G20 (inches)
    feed_per_rev: bool = False  # G95 (per rev) vs G94 (per minute)
    stock_diam: float = 30.0
    stock_len: float = 20.0  # stock length in Z (mm)
    safe_clear_x: float = 1.0
    safe_clear_z: float = 1.0
    doc: float = 0.3  # depth of cut per pass (I)
    retract: float = 1.0  # retract amount between passes (R)
    stock_allow: float = 0.2  # stock to leave for G70 finish (D)
    start_x: float = 0.0  # starting X for cycle entry (0 => auto)
    start_z: float = 0.0  # starting Z for cycle entry (0 => auto)
    feed_rough: float = 100
    feed_finish: float = 50
    spindle_rpm: int = 700
    spindle_dir: str = "M3"
    tool: str = "1"  # rough tool
    tool_finish: Optional[str] = None  # finish tool (None => same as rough)
    coolant: bool = False
    fill_profile: bool = False  # fill area below profile curve
    profile_fill_color: str = "#87CEEB"  # default fill color (sky blue)
    fill_profile_half: bool = False  # 50% transparent fill (stipple)
    background_image_color: str = "#a6a6a6"  # background image tint
    direct_finish_only: bool = False  # bypass G71/G70 entirely: G0 approach + direct contour call at feed_finish + retract


class ProfileGenerator:
    """Pure generator: takes points + params, returns G-code string."""
    
    # N-labels for G71/G72 contour block references
    N_CONTOUR_START = 100
    N_CONTOUR_END = 200

    def __init__(self, params: ProfileParams) -> None:
        self.params = params

    def generate(self, points: Sequence[Point], subprogram_number: int = 100, mode: str = "full") -> str:
        """Generate G-code for a profile.
        
        Args:
            points: Sequence of Point objects defining the profile
            subprogram_number: O-number for the subprogram (default 100)
            mode: "subprogram" (O SUB with approach + cycles + contour) or "full" (legacy, not used)
        
        Returns:
            G-code string
        
        Subprogram mode structure:
          O#### SUB
              G0 X... Z...           ← Rapid approach (no label)
              G71/G72 P100 Q200 ...  ← Roughing cycle BEFORE contour
              G70 P100 Q200 ...      ← Finishing pass
              G1 F...
              N100 G1 X... Z...      ← First geometry line (labeled)
              G1/G2/G3 ...           ← Middle geometry
              N200 G1/G2/G3 ...      ← Last geometry line (labeled)
          O#### ENDSUB
        """
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")
        
        # Validate and clean points to avoid "parallel lines" errors
        pts = self._validate_points(pts)
        if len(pts) < 2:
            raise ValueError("After validation, fewer than 2 points remain. Check for duplicates or invalid arcs.")
        
        # Filter out infeasible corners to prevent "profile not monotonic" errors
        pts = self._filter_infeasible_corners(pts)
        
        # Validate monotonicity (required by G71/G72)
        self._validate_monotonicity(pts, self.params.cycle)
        self._validate_start_x_axis_safety(pts, self.params)
        
        # Subprogram mode previously emitted everything; kept for backwards compatibility (unused)
        if mode == "subprogram":
            lines: List[str] = []
            lines.append(f"O{subprogram_number} SUB")
            lines.append("  (deprecated legacy subprogram mode)")
            lines.append("  M99")
            lines.append(f"O{subprogram_number} ENDSUB")
            return "\n".join(lines) + "\n"
        
        # Full program mode
        lines: List[str] = []
        p = self.params

        lines.append("(Generated by conversational/profiler.py)")
        lines.append("(Points: Z,X in order)")
        lines.append(f"(Cycle: {p.cycle} {'OD' if p.od else 'ID'})")
        lines.append("G21" if p.metric else "G20")
        lines.append("G18")
        lines.append("G7" if p.diameter_mode else "G8")
        lines.append(f"T{p.tool}")
        lines.append("G95" if p.feed_per_rev else "G94")
        lines.append(f"G97 S{p.spindle_rpm} {p.spindle_dir}")
        if p.coolant:
            lines.append("M8")

        start_x = self._calculate_start_x(pts)
        start_z = pts[0].z + p.safe_clear_z
        lines.append(f"G0 X{start_x:.3f}")
        lines.append(f"G0 Z{start_z:.3f}")

        lines.append(f"O{subprogram_number} SUB")
        lines += ["    " + l for l in self._emit_profile_block(pts)]
        lines.append(f"O{subprogram_number} ENDSUB")

        if p.cycle.upper() == "G72":
            lines += self._emit_g72(pts, contour_sub_id=subprogram_number)
        else:
            lines += self._emit_g71(pts, contour_sub_id=subprogram_number)

        # Safe retract first (spindle still running), then stop
        lines.append(f"G0 X{start_x:.3f}")
        lines.append(f"G0 Z{start_z:.3f}")
        if p.coolant:
            lines.append("M9")
        lines.append("M5")
        lines.append("M30")
        return "\n".join(lines) + "\n"

    def _emit_contour_block(self, pts: Sequence[Point]) -> List[str]:
        """Emit contour geometry moves ONLY (no feeds, no N-labels)."""
        block: List[str] = []
        last_had_corner = False

        for pt in pts:
            if abs(pt.r) > 1e-6:
                gcode = "G2" if pt.r > 0 else "G3"
                block.append(f"{gcode} X{pt.x:.3f} Z{pt.z:.3f} R{abs(pt.r):.3f}")
                last_had_corner = False
                continue

            line = f"G1 X{pt.x:.3f} Z{pt.z:.3f}"
            if not last_had_corner:
                corner = self._corner_word(pt)
                if corner:
                    line += f" {corner}"
                    last_had_corner = True
                    block.append(line)
                    continue
            last_had_corner = False
            block.append(line)

        return block

    def _emit_profile_block(self, pts: Sequence[Point]) -> List[str]:
        """Deprecated legacy helper (kept for compatibility)."""
        block: List[str] = []
        block.extend(self._emit_contour_block(pts))
        return block

    def _corner_word(self, pt: Point) -> str:
        """Return formatted A/C word for chamfer/fillet if present."""
        if not pt.corner_type or pt.corner_value is None:
            return ""
        word = pt.corner_type.upper()
        if word not in ("A", "C"):
            return ""
        return f"{word}{pt.corner_value:.3f}"

    def _g71_di_values(self) -> Tuple[float, float]:
        """Return (D, I) values to EMIT in G71/G72, converted for G7/G8.

        Like the G2/G3 R word, G71/G72's D and I parameters are ALWAYS
        real-radius-based -- LinuxCNC does NOT rescale them for G7 (diameter
        mode). Confirmed on hardware: with G7 active, setting I=0.15 actually
        removed 0.30mm of DIAMETER per pass (i.e. 0.15mm of RADIUS, exactly
        the unscaled value).

        params.doc / params.stock_allow are entered by the user as the DESIRED
        result in the units shown by "X programming" (diameter for G7, radius
        for G8). So:
          - G7 (diameter_mode=True):  emit doc/2, stock_allow/2
          - G8 (diameter_mode=False): emit doc, stock_allow  (no conversion --
            G8's X already IS radius, matching G71/G72's native units)

        _validate_start_x_axis_safety's "start_x - p.doc" check is unaffected:
        it compares two DIAMETER-domain quantities (start_x and the user's
        intended diameter-step p.doc), independent of how that step is encoded
        in the emitted G-code.
        """
        p = self.params
        if p.diameter_mode:
            return p.stock_allow / 2.0, p.doc / 2.0
        return p.stock_allow, p.doc

    def _explicit_g71_g70_transition(self, pts: Sequence[Point]) -> List[str]:
        """Explicit, predictable retract+reposition between G71/G72 and G70.

        Per LinuxCNC docs, G70 "retraces the path defined for cutting by
        G71/G72" -- but WHERE the tool physically IS when G70 starts depends
        on G71's own internal end-of-cycle retract/reposition. That internal
        step is exactly what's been unreliable (see
        _validate_start_x_axis_safety / check_start_x_advisory): for several
        Start X values the tool either crashed into the part or "returned
        without retracting" instead of backing off cleanly.

        Rather than depend on that, explicitly drive the tool to a KNOWN-SAFE
        position before G70 runs -- the SAME position the wrapper approached
        FROM before G71 (start_x, start_z), reached via the same kind of
        "L-shaped" moves that made the initial approach safe:

          1. G0 X{retract_x} -- pure X move at WHATEVER Z G71 left the tool.
             retract_x = max(stock_diam, profile_extent_max_x) + safe_clear_x
             (see _calculate_retract_x) is the GLOBAL max over the WHOLE
             profile (including arc bulges), so this is >= profile(Z)+D for
             EVERY Z -- always a move AWAY from material, regardless of
             which Z G71 stopped at.
          2. G0 Z{start_z} -- pure Z move. X is now at retract_x (outside
             everything), so the tool is in free air radially; any Z move is
             safe.
          3. G0 X{start_x} -- pure X move back to the cycle's entry X. Z is
             now start_z (outside the stock face when start_z>0, as is
             typical), so this is safe regardless of X.

        G70 then begins from (start_x, start_z) -- the exact position it
        would have approached FROM anyway -- instead of an unpredictable
        "wherever G71's internal bookkeeping left off".

        These are plain G0 moves; they don't affect G70's "remembered" Q-cycle
        geometry (still keyed by Q<contour_sub_id> and the D/I from the
        preceding G71/G72 line).
        """
        p = self.params
        retract_x = self._calculate_retract_x(pts)
        start_x = self._calculate_start_x(pts)
        start_z = p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z
        return [
            f"G0 X{retract_x:.3f}",
            f"G0 Z{start_z:.3f}",
            f"G0 X{start_x:.3f}",
        ]

    def _emit_g71(self, pts: Sequence[Point], contour_sub_id: int) -> List[str]:
        """Emit LinuxCNC G71/G70 using Q<contour_sub_id> (no Fanuc P/Q labels)."""
        p = self.params
        d_emit, i_emit = self._g71_di_values()
        lines: List[str] = []
        lines.append(
            f"G71 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{p.retract:.3f} F{p.feed_rough}"
        )
        lines += self._explicit_g71_g70_transition(pts)
        lines.append(f"G70 Q{contour_sub_id} F{p.feed_finish}")
        return lines

    def _emit_g72(self, pts: Sequence[Point], contour_sub_id: int) -> List[str]:
        """Emit LinuxCNC G72/G70 using Q<contour_sub_id> (no Fanuc P/Q labels)."""
        p = self.params
        d_emit, i_emit = self._g71_di_values()
        lines: List[str] = []
        lines.append(
            f"G72 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{p.retract:.3f} F{p.feed_rough}"
        )
        lines += self._explicit_g71_g70_transition(pts)
        lines.append(f"G70 Q{contour_sub_id} F{p.feed_finish}")
        return lines

    def contour_subprogram_gcode(self, points: Sequence[Point], subprogram_number: int) -> str:
        """Generate ONLY the contour subprogram block (geometry only)."""
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")
        
        # Validate and clean points
        pts = self._validate_points(pts)
        if len(pts) < 2:
            raise ValueError("After validation, fewer than 2 points remain.")
        pts = self._filter_infeasible_corners(pts)
        self._validate_monotonicity(pts, self.params.cycle)
        self._validate_start_x_axis_safety(pts, self.params)
        
        lines: List[str] = []
        lines.append(f"o{subprogram_number} sub")
        
        # Emit only the contour geometry (no feeds, no cycles)
        contour_lines = self._emit_contour_block(pts)
        lines += ["    " + l for l in contour_lines]
        
        lines.append(f"o{subprogram_number} endsub")
        return "\n".join(lines) + "\n"

    def main_program_lines(self, points: Sequence[Point], subprogram_number: int) -> List[str]:
        """Deprecated legacy helper (kept for compatibility)."""
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")

        lines: List[str] = []
        p = self.params

        start_x = self._calculate_start_x(pts)
        start_z = p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z
        lines.append(f"G0 X{start_x:.3f}")
        lines.append(f"G0 Z{start_z:.3f}")

        if p.cycle.upper() == "G72":
            cycle_lines = self._emit_g72(pts, contour_sub_id=subprogram_number)
        else:
            cycle_lines = self._emit_g71(pts, contour_sub_id=subprogram_number)

        lines.extend(cycle_lines)

        return lines

    def wrapper_subprogram_gcode(self, points: Sequence[Point], contour_sub_id: int, wrapper_sub_id: int) -> str:
        """Generate the wrapper subprogram: approach + G7x cycles referencing contour subprogram.
        
        This method generates a combined wrapper with both roughing and finishing cycles.
        For separate rough/finish tools, use wrapper_rough_subprogram_gcode and wrapper_finish_subprogram_gcode instead.
        """
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")

        lines: List[str] = []
        p = self.params

        lines.append(f"o{wrapper_sub_id} sub")

        start_x = self._calculate_start_x(pts)
        start_z = p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z
        lines.append(f"    G0 X{start_x:.3f}")
        lines.append(f"    G0 Z{start_z:.3f}")

        if p.cycle.upper() == "G72":
            cycle_lines = self._emit_g72(pts, contour_sub_id=contour_sub_id)
        else:
            cycle_lines = self._emit_g71(pts, contour_sub_id=contour_sub_id)
        lines += ["    " + l for l in cycle_lines]

        lines.append(f"o{wrapper_sub_id} endsub")
        return "\n".join(lines) + "\n"

    def wrapper_rough_subprogram_gcode(self, points: Sequence[Point], contour_sub_id: int, wrapper_sub_id: int) -> str:
        """Generate roughing wrapper subprogram: approach + G71/G72 only (no G70)."""
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")

        lines: List[str] = []
        p = self.params

        lines.append(f"o{wrapper_sub_id} sub")

        start_x = self._calculate_start_x(pts)
        start_z = p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z
        lines.append(f"    G0 X{start_x:.3f}")
        lines.append(f"    G0 Z{start_z:.3f}")

        # Emit only roughing cycle (no G70)
        d_emit, i_emit = self._g71_di_values()
        if p.cycle.upper() == "G72":
            rough_line = f"G72 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{p.retract:.3f} F{p.feed_rough}"
        else:
            rough_line = f"G71 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{p.retract:.3f} F{p.feed_rough}"
        lines.append(f"    {rough_line}")

        lines.append(f"o{wrapper_sub_id} endsub")
        return "\n".join(lines) + "\n"

    def wrapper_finish_subprogram_gcode(self, points: Sequence[Point], contour_sub_id: int, wrapper_sub_id: int) -> str:
        """Generate finishing wrapper subprogram: approach + G70 only."""
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")

        lines: List[str] = []
        p = self.params

        lines.append(f"o{wrapper_sub_id} sub")

        start_x = self._calculate_start_x(pts)
        start_z = p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z
        lines.append(f"    G0 X{start_x:.3f}")
        lines.append(f"    G0 Z{start_z:.3f}")

        # Emit only finishing pass
        lines.append(f"    G70 Q{contour_sub_id} F{p.feed_finish}")

        lines.append(f"o{wrapper_sub_id} endsub")
        return "\n".join(lines) + "\n"

    def wrapper_finish_direct_subprogram_gcode(self, points: Sequence[Point], contour_sub_id: int, wrapper_sub_id: int) -> str:
        """Generate a finish-only wrapper that does NOT use G71/G70 at all.

        Background: G70 retraces "the path defined for cutting by G71/G72" --
        per LinuxCNC semantics this means G70 depends on state established by
        G71/G72 having run in the SAME program execution. Re-running just
        "wrapper_finish_subprogram_gcode" (G70 alone) -- or even a 1-pass
        G71+G70 (see _validate_start_x_axis_safety) -- has repeatedly produced
        the "не виїзджає, б'є в заготовку" crash on real hardware for profiles
        whose first point sits at X=0 (centerline).

        This method sidesteps G71/G70 completely:
          1. G0 to (profile[0].x, profile[0].z + safe_clear_z) -- positions the
             tool directly ABOVE/OUTSIDE the contour's own starting point. When
             profile[0].z == 0 (typical), this is at Z > 0, i.e. off the stock
             face -- guaranteed clear of material.
          2. Set F{feed_finish}.
          3. `o{contour_sub_id} call` -- runs the EXACT same G1/G2/G3 moves
             used inside G70, as a plain subroutine call. Its first move
             (G1 to profile[0]) becomes a simple plunge from directly above
             into the start point -- e.g. straight down the centerline when
             profile[0].x == 0 -- not a diagonal G70 rapid that can clip the
             part near a pointed tip.
          4. Retract using the same _calculate_retract_x as the normal cycle.

        Net effect: re-cuts the exact finish contour once, at feed_finish,
        with fully explicit/predictable positioning -- i.e. "redo just the
        last (finishing) pass" without any G71/G70 involved.
        """
        pts = list(points)
        if len(pts) < 2:
            raise ValueError("Provide at least two points for the profile.")

        lines: List[str] = []
        p = self.params

        lines.append(f"o{wrapper_sub_id} sub")

        approach_x = pts[0].x
        approach_z = pts[0].z + p.safe_clear_z
        lines.append(f"    G0 X{approach_x:.3f}")
        lines.append(f"    G0 Z{approach_z:.3f}")
        lines.append(f"    F{p.feed_finish}")
        lines.append(f"    o{contour_sub_id} call")

        retract_x = self._calculate_retract_x(pts)
        retract_z = p.start_z if abs(p.start_z) > 1e-9 else pts[0].z + p.safe_clear_z
        lines.append(f"    G0 X{retract_x:.3f}")
        lines.append(f"    G0 Z{retract_z:.3f}")

        lines.append(f"o{wrapper_sub_id} endsub")
        return "\n".join(lines) + "\n"

    def _arc_extent_x(self, z0: float, x0: float, z1: float, x1: float, r: float, diameter_mode: bool) -> Tuple[float, float]:
        """Return (min_x, max_x) for ONE segment, accounting for arc bulge beyond its endpoints.

        `r` follows the contour-emission convention used by _emit_contour_block:
        r > 0 => G2 (CW), r < 0 => G3 (CCW), r == 0 => straight line.

        Critical unit subtlety: the G2/G3 R word is ALWAYS a real geometric
        radius -- LinuxCNC does NOT rescale it for G7 (diameter mode). Only the
        X AXIS MOTION itself is diameter-scaled. So when diameter_mode is True,
        the programmed X values (which ARE diameters) must be converted to
        radius (÷2) before doing circle-center/sweep math with `r`, and the
        resulting extent converted back (×2) to diameter before returning --
        otherwise an arc that geometrically bulges to radius 5.63 (=> diameter
        11.26) would be reported as bulging only to X=5.0 (an endpoint), which
        is exactly the discrepancy that produced an unsafe retract.
        """
        if abs(r) < 1e-9:
            return (min(x0, x1), max(x0, x1))

        scale = 2.0 if diameter_mode else 1.0
        xr0, xr1 = x0 / scale, x1 / scale
        radius = abs(r)
        vz, vxr = z1 - z0, xr1 - xr0
        chord = math.hypot(vz, vxr)
        if chord < 1e-9 or radius < chord / 2.0 - 1e-9:
            # Degenerate/invalid arc geometry -- fall back to endpoints only
            return (min(x0, x1), max(x0, x1))

        mid_z, mid_xr = (z0 + z1) / 2.0, (xr0 + xr1) / 2.0
        perp_z, perp_xr = -vxr / chord, vz / chord  # unit perpendicular (same convention as _sample_arc)
        h = math.sqrt(max(radius * radius - (chord * chord) / 4.0, 0.0))
        cw = r > 0
        sign = -1.0 if cw else 1.0
        cxr, cz = mid_xr + sign * perp_xr * h, mid_z + sign * perp_z * h

        a0 = math.atan2(xr0 - cxr, z0 - cz)
        a1 = math.atan2(xr1 - cxr, z1 - cz)
        if cw and a1 > a0:
            a1 -= 2 * math.pi
        if not cw and a1 < a0:
            a1 += 2 * math.pi
        lo, hi = (a0, a1) if a0 <= a1 else (a1, a0)

        def in_sweep(theta: float) -> bool:
            for k in (-1, 0, 1):
                if lo - 1e-9 <= theta + k * 2 * math.pi <= hi + 1e-9:
                    return True
            return False

        candidates_xr = [xr0, xr1]
        if in_sweep(math.pi / 2):       # rightmost point of the circle (+Xr)
            candidates_xr.append(cxr + radius)
        if in_sweep(-math.pi / 2):      # leftmost point of the circle (-Xr)
            candidates_xr.append(cxr - radius)

        return (min(candidates_xr) * scale, max(candidates_xr) * scale)

    def _profile_extent_x(self, pts: Sequence[Point]) -> Tuple[float, float]:
        """Return (min_x, max_x) over the WHOLE profile, including arc bulges.

        A plain max(pt.x for pt in pts) only looks at segment ENDPOINTS. An arc
        with a large enough R bulges OUTWARD past both of its endpoints (e.g.
        a near-semicircle), so the true profile extent can be significantly
        larger than any single endpoint's X -- this caused an unsafe retract
        (the tool stopped short of the actual machined diameter).
        """
        pts = list(pts)
        if not pts:
            return (0.0, 0.0)
        diameter_mode = self.params.diameter_mode
        min_x = max_x = pts[0].x
        for i in range(1, len(pts)):
            prev, cur = pts[i - 1], pts[i]
            seg_min, seg_max = self._arc_extent_x(prev.z, prev.x, cur.z, cur.x, cur.r, diameter_mode)
            min_x = min(min_x, seg_min)
            max_x = max(max_x, seg_max)
        return (min_x, max_x)

    def _calculate_start_x(self, pts: Sequence[Point]) -> float:
        """Calculate the G71/G72 entry X position (the G0 X before G71/G70).

        In diameter mode (G7), all values are in diameter units.

        For OD (params.od == True):
            start_x = max(profile_extent_max_x, stock_diam)
                      -- NO safe_clear_x here. G71's first roughing pass needs
                      to START AT the stock surface (or the profile's true
                      outermost extent, including arc bulges, if that's
                      larger) so the FIRST layer is exactly `doc` deep into
                      real material -- not an extra `safe_clear_x` of air-cut.
                      safe_clear_x is for the FINAL retract only, see
                      _calculate_retract_x.

        For ID (params.od == False):
            start_x = max(0.0, profile_extent_min_x - safe_clear_x)

        profile_extent_*_x comes from _profile_extent_x, which accounts for
        arcs that bulge beyond their endpoint X values (see _arc_extent_x).

        User can override via params.start_x if non-zero.
        """
        p = self.params

        # If user explicitly set start_x, use it
        if abs(p.start_x) > 1e-9:
            return p.start_x

        profile_min_x, profile_max_x = self._profile_extent_x(pts)

        if p.od:
            # OD: start AT the stock surface (or profile extent if larger).
            # No clearance added -- the first pass should bite into real
            # material immediately, not air-cut a safe_clear_x gap first.
            return max(p.stock_diam, profile_max_x)
        else:
            # ID: start inside the bore (to the left), never negative
            return max(0.0, profile_min_x - p.safe_clear_x)

    def _calculate_retract_x(self, pts: Sequence[Point]) -> float:
        """Calculate safe retract X after cycle completes.

        Unlike _calculate_start_x, this method NEVER uses params.start_x override,
        because the retract must always move the tool away from the workpiece
        regardless of where the approach entry was.

        After G70 finishes the tool is at the end of the profile (deepest Z).
        Moving to start_x (e.g. bore diameter) while at deep Z = crash.
        We must always retract outward (OD) or inward (ID) to clear the part first.

        Uses _profile_extent_x (arc-bulge-aware) so an arc that geometrically
        extends past its endpoints (e.g. a near-semicircle bulging to diameter
        11.26 even though its endpoints are 0 and 5) is correctly cleared --
        otherwise the retract could stop SHORT of the actual machined surface.
        """
        p = self.params
        profile_min_x, profile_max_x = self._profile_extent_x(pts)
        if p.od:
            return max(p.stock_diam, profile_max_x) + p.safe_clear_x
        else:
            return max(0.0, profile_min_x - p.safe_clear_x)

    def _validate_points(self, pts: List[Point]) -> List[Point]:
        """Remove duplicate/very close points and validate arc radii to avoid parallel line errors."""
        if len(pts) < 2:
            return pts
        
        MIN_SEGMENT_LENGTH = 0.01  # Minimum segment length in mm
        
        cleaned: List[Point] = [pts[0]]
        for i in range(1, len(pts)):
            pt = pts[i]
            prev = cleaned[-1]
            
            # Check segment length
            dz = pt.z - prev.z
            dx = pt.x - prev.x
            chord = math.hypot(dz, dx)
            
            # Skip points that are too close to previous
            if chord < MIN_SEGMENT_LENGTH:
                continue
            
            # Validate arc radius if present
            if abs(pt.r) > 1e-6:
                min_radius = chord / 2.0
                if abs(pt.r) < min_radius - 1e-6:
                    # Arc radius too small for chord - make it a line instead
                    pt = Point(pt.z, pt.x, 0.0, pt.corner_type, pt.corner_value)
            
            cleaned.append(pt)
        
        # Ensure first point has R=0
        if cleaned:
            cleaned[0] = Point(cleaned[0].z, cleaned[0].x, 0.0, cleaned[0].corner_type, cleaned[0].corner_value)
        
        return cleaned

    def _filter_infeasible_corners(self, pts: List[Point]) -> List[Point]:
        """Remove corner metadata from points with infeasible corners.
        Returns a new list with corner words dropped from infeasible points."""
        if len(pts) < 3:
            return pts
        
        MARGIN = 0.99
        MIN_SEG_LEN = 0.1
        
        filtered = []
        for i, pt in enumerate(pts):
            # First and last points can't have corners
            if i == 0 or i == len(pts) - 1:
                filtered.append(pt)
                continue
            
            # If no corner, keep as-is
            if not pt.corner_type or pt.corner_value is None or pt.corner_value <= 0:
                filtered.append(pt)
                continue
            
            prev_pt = pts[i - 1]
            next_pt = pts[i + 1]
            
            # Compute segment lengths
            v_in = (pt.z - prev_pt.z, pt.x - prev_pt.x)
            v_out = (next_pt.z - pt.z, next_pt.x - pt.x)
            len_in = math.hypot(v_in[0], v_in[1])
            len_out = math.hypot(v_out[0], v_out[1])
            
            # If segments too short, drop corner
            if len_in < MIN_SEG_LEN or len_out < MIN_SEG_LEN:
                filtered.append(Point(pt.z, pt.x, pt.r, None, None))
                continue
            
            # Compute angle
            u_in = (v_in[0] / len_in, v_in[1] / len_in)
            u_out = (v_out[0] / len_out, v_out[1] / len_out)
            dot = max(min(u_in[0] * u_out[0] + u_in[1] * u_out[1], 1.0), -1.0)
            theta = math.acos(dot)
            
            # If nearly straight, drop corner
            if theta < 0.01 or abs(math.pi - theta) < 0.01:
                filtered.append(Point(pt.z, pt.x, pt.r, None, None))
                continue
            
            corner_type = pt.corner_type.upper()
            keep_corner = False
            
            if corner_type == "A":
                # Fillet radius must fit
                r = pt.corner_value
                t = r * math.tan(theta / 2.0)
                if t < len_in * MARGIN and t < len_out * MARGIN:
                    keep_corner = True
            elif corner_type == "C":
                # Chamfer distance must fit
                d = pt.corner_value
                if d < len_in * MARGIN and d < len_out * MARGIN:
                    keep_corner = True
            
            if keep_corner:
                filtered.append(pt)
            else:
                # Drop corner but keep point
                filtered.append(Point(pt.z, pt.x, pt.r, None, None))
        
        return filtered

    def _validate_corner_feasibility(self, pts: List[Point]) -> None:
        """Check that all corners (A=fillet, C=chamfer) are feasible given segment lengths and angles.
        Raises ValueError if any corner is too large for its adjacent segments."""
        if len(pts) < 3:
            return  # No interior points
        
        MARGIN = 0.99  # Allow up to 99% of available segment length
        
        for i in range(1, len(pts) - 1):
            pt = pts[i]
            if not pt.corner_type or pt.corner_value is None or pt.corner_value <= 0:
                continue  # No corner on this point
            
            prev_pt = pts[i - 1]
            next_pt = pts[i + 1]
            
            # Compute vectors and lengths
            v_in = (pt.z - prev_pt.z, pt.x - prev_pt.x)  # incoming direction
            v_out = (next_pt.z - pt.z, next_pt.x - pt.x)  # outgoing direction
            len_in = math.hypot(v_in[0], v_in[1])
            len_out = math.hypot(v_out[0], v_out[1])
            
            if len_in < 1e-6 or len_out < 1e-6:
                raise ValueError(f"Point {i}: adjacent segments too short for corner.")
            
            # Normalize vectors
            u_in = (v_in[0] / len_in, v_in[1] / len_in)
            u_out = (v_out[0] / len_out, v_out[1] / len_out)
            
            # Compute corner angle
            dot = max(min(u_in[0] * u_out[0] + u_in[1] * u_out[1], 1.0), -1.0)
            theta = math.acos(dot)
            
            # Check if corner is nearly straight
            if theta < 1e-4 or abs(math.pi - theta) < 1e-4:
                raise ValueError(f"Point {i}: corner is nearly straight (angle {math.degrees(theta):.1f}°).")
            
            corner_type = pt.corner_type.upper()
            if corner_type == "A":
                # Fillet: compute offset distance t = r * tan(theta/2)
                r = pt.corner_value
                t = r * math.tan(theta / 2.0)
                max_t_in = len_in * MARGIN
                max_t_out = len_out * MARGIN
                if t >= max_t_in or t >= max_t_out:
                    raise ValueError(
                        f"Point {i}: fillet radius {r:.4f} is too large for adjacent segments "
                        f"(needs {t:.4f} on each side, have {len_in:.4f} and {len_out:.4f})."
                    )
            elif corner_type == "C":
                # Chamfer: check chamfer distance against segment lengths
                chamfer_len = pt.corner_value
                max_len_in = len_in * MARGIN
                max_len_out = len_out * MARGIN
                if chamfer_len >= max_len_in or chamfer_len >= max_len_out:
                    raise ValueError(
                        f"Point {i}: chamfer distance {chamfer_len:.4f} is too large for adjacent segments "
                        f"(have {len_in:.4f} and {len_out:.4f})."
                    )

    def _validate_monotonicity(self, pts: List[Point], cycle: str) -> None:
        """Verify profile is monotonic as required by G71/G72.
        G71 (OD turn): Z must be monotonically decreasing (from pos to neg or staying same).
        G72 (Face): X must be monotonically decreasing (from pos to neg or staying same).
        Raises ValueError if profile violates monotonicity."""
        if len(pts) < 2:
            return
        
        cycle_upper = cycle.upper()
        # Use 0.0005 tolerance (half of 0.001, the 3rd decimal place)
        tol = 0.0005
        
        if cycle_upper == "G71":
            # Z must be monotonic (decreasing or flat)
            for i in range(1, len(pts)):
                if pts[i].z > pts[i - 1].z + tol:
                    raise ValueError(
                        f"G71 profile not monotonic: Z increases from point {i-1} ({pts[i-1].z:.3f}) "
                        f"to point {i} ({pts[i].z:.3f}). Z must be non-increasing."
                    )
        elif cycle_upper == "G72":
            # X must be monotonic (decreasing or flat)
            for i in range(1, len(pts)):
                if pts[i].x > pts[i - 1].x + tol:
                    raise ValueError(
                        f"G72 profile not monotonic: X increases from point {i-1} ({pts[i-1].x:.3f}) "
                        f"to point {i} ({pts[i].x:.3f}). X must be non-increasing."
                    )

    def _validate_start_x_axis_safety(self, pts: List[Point], p: ProfileParams) -> None:
        """Guard against a known LinuxCNC G71 'fragile' crash (see issues #707/#1146).

        For G71 (turning, OD), material is removed in X-direction layers of depth
        I (p.doc), starting from the cycle's "current position" — which, per the
        LinuxCNC docs ("X - the starting X position, defaults to the initial
        position"), is wherever the G0 X{start_x} move (emitted just before G71)
        left the tool.

        If (start_x - doc) < 0, G71's very first roughing layer would sit at a
        NEGATIVE diameter — i.e. on the far side of the spindle centerline. This
        has no physical meaning in G7 (diameter) mode. In practice it makes G71
        compute a garbage retract/reposition for the transition into the next
        layer: instead of lifting clear in X, it plunges straight back into the
        stock — the "не виїзджає, вдарилось в заготовку" crash.

        Empirically (tested on real hardware), BOTH of the following crash:
          - start_x=0.10, doc=0.15 -> diff = -0.05 (negative)
          - start_x=0.15, doc=0.15 -> diff =  0.00 (exactly zero)
        So the safe condition must be a STRICT inequality (diff > 0 by a real
        margin), not "diff >= 0". At diff<=0 the first layer lands AT or PAST
        the centerline (zero/negative diameter), corrupting G71's pass-to-pass
        retract regardless of sign.

        Scope: only G71 + OD. G72 (facing) steps in Z, which has no centerline,
        so the analogous crossing is not physically meaningless the same way.
        Raises ValueError (same severity as the monotonicity check) so this is
        caught before any G-code reaches the controller.
        """
        if not pts or p.cycle.upper() != "G71" or not p.od or p.direct_finish_only:
            return
        tol = 0.0005
        start_x = self._calculate_start_x(pts)
        if start_x - p.doc <= tol:
            raise ValueError(
                f"Start X ({start_x:.3f}) - Глиб різан/I ({p.doc:.3f}) = "
                f"{start_x - p.doc:.3f} <= 0: перший прохід G71 потрапляє на "
                f"вісь обертання або за неї (X<=0). Перевірено на залізі — "
                f"ламає retract між проходами G71 ('не виїзджає, б'є в "
                f"заготовку' — LinuxCNC #707/#1146), навіть коли різниця рівно "
                f"0. Збільшіть 'Start X' СУВОРО більше {p.doc:.3f} (наприклад "
                f"{p.doc + 0.05:.3f}), або поставте Start X = 0 (авто) — це "
                f"безпечний шлях для профілю, що торкається осі (X=0)."
            )

        # NOTE: a second, "between zone" condition was previously a hard
        # ValueError here. It's now a non-blocking advisory -- see
        # check_start_x_advisory() below -- because it's based on pattern-
        # matching across a handful of hardware tests, not a confirmed
        # mechanism, and blocking it prevented the user from testing
        # intermediate Start X values that might actually be fine.

    def check_start_x_advisory(self, pts: List[Point], p: ProfileParams) -> Optional[str]:
        """Return a non-fatal warning string if Start X is in an UNVERIFIED zone
        for G71+OD, or None if it's in a zone that has tested OK.

        Across hardware tests, exactly two configurations were CONFIRMED OK:

          Branch A ("full roughing from outside"):
              start_x >= profile_extent_max_x + D
            G71 has a valid outer reference covering the profile's ENTIRE
            geometric extent (including any arc bulge -- see
            _profile_extent_x), so it can step inward in I-layers across
            the whole path. This is what Start X = 0 (auto) always produces.

          Branch B ("enter at the profile's own starting point"):
              start_x == profile_min_x   (and profile_min_x > 0)
            G71's "current position" exactly matches where the contour
            subprogram itself begins -- e.g. test4: start_x=19 ==
            profile_min_x=19, profile range [19,40]. Worked correctly.

        Everything else has been a MIX of confirmed crashes (start_x=0.10,
        0.15, 5.125 on a profile with profile_min_x=0) and at least one
        "returns without retracting" report (start_x=1.0, same profile) --
        but these are too few data points to derive a precise boundary, so
        this is now ADVISORY ONLY: it doesn't block generation, it just
        flags "this combination hasn't been confirmed safe yet, test
        carefully" so the result can be reported back and the model refined.
        """
        if not pts or p.cycle.upper() != "G71" or not p.od or p.direct_finish_only:
            return None
        tol = 0.0005
        start_x = self._calculate_start_x(pts)

        profile_min_x, profile_max_x = self._profile_extent_x(pts)
        required_outer = profile_max_x + p.stock_allow
        branch_a = start_x >= required_outer - tol
        branch_b = profile_min_x > tol and abs(start_x - profile_min_x) <= tol
        if branch_a or branch_b:
            return None

        if profile_min_x > tol:
            zone_desc = (
                f"не дорівнює точці початку профілю ({profile_min_x:.3f}) і "
                f"менший за розмах профілю+D={required_outer:.3f}"
            )
        else:
            zone_desc = f"менший за розмах профілю+D={required_outer:.3f}"
        return (
            f"Start X={start_x:.3f} в неперевіреній зоні ({zone_desc}). "
            f"На кількох перевірених прикладах подібні значення давали або "
            f"краш, або 'повертається без відводу'. Перевіряй обережно "
            f"(можливо з низькою подачею/без оберту), або скористайся "
            f"Start X=0 (авто) чи 'Тільки фініш'."
        )


def _ensure_tk_imported():
    """Lazily import tkinter only when GUI is used."""
    global tk, ttk, filedialog, messagebox, scrolledtext, simpledialog
    if tk is None:
        import tkinter as _tk
        from tkinter import ttk as _ttk
        from tkinter import filedialog as _filedialog
        from tkinter import messagebox as _messagebox
        from tkinter import scrolledtext as _scrolledtext
        from tkinter import simpledialog as _simpledialog
        tk = _tk
        ttk = _ttk
        filedialog = _filedialog
        messagebox = _messagebox
        scrolledtext = _scrolledtext
        simpledialog = _simpledialog


# ============================================================================
# UI translations (Ukrainian / English)
#
# Only STATIC labels, buttons, headers, checkboxes, tab names, and menu
# entries are translated here. Dropdown/OptionMenu VALUES (e.g.
# "G7 (діаметр)"/"G8 (радіус)", "M3 (CW)"/"M4 (CCW)", "OD"/"ID", corner type
# "A (fillet)"/"C (chamfer)"/"none") are intentionally NOT translated: their
# exact text is parsed by _read_params (substring checks for "G7"/"G8",
# "M3"/"M4", "OD"/"ID", etc.) and saved verbatim in session JSON
# (corner_type). Translating them would risk breaking parsing/session
# load-save, for cosmetic benefit only.
#
# Status-bar messages, validation errors, and advisory text (generated by
# ProfileGenerator) are also NOT translated -- those are produced in
# Ukrainian by the generator/validator methods and are out of scope for this
# UI-shell language switch.
# ============================================================================
TRANSLATIONS: dict[str, dict[str, str]] = {
    # --- Section headers ---
    "MACHINING": {"uk": "ОБРОБКА", "en": "MACHINING"},
    "STOCK & CLEAR": {"uk": "ЗАГОТОВКА ТА ВІДВІД", "en": "STOCK & CLEAR"},
    "CYCLE": {"uk": "ЦИКЛ", "en": "CYCLE"},
    "PREVIEW": {"uk": "ПЕРЕГЛЯД", "en": "PREVIEW"},
    "OUTPUT": {"uk": "РЕЗУЛЬТАТ", "en": "OUTPUT"},
    "Actions": {"uk": "Дії", "en": "Actions"},

    # --- MACHINING fields ---
    "Cycle": {"uk": "Цикл", "en": "Cycle"},
    "Spindle Dir": {"uk": "Напрямок шпинделя", "en": "Spindle Dir"},
    "Spindle (RPM)": {"uk": "Шпиндель (об/хв)", "en": "Spindle (RPM)"},
    "Tool # rough": {"uk": "Інстр. чорновий №", "en": "Tool # rough"},
    "Tool # finish": {"uk": "Інстр. фінішний №", "en": "Tool # finish"},
    "Coolant?": {"uk": "ЗОР?", "en": "Coolant?"},
    "OD/ID": {"uk": "Зовн./Внутр.", "en": "OD/ID"},
    "Units": {"uk": "Одиниці", "en": "Units"},
    "Feed Mode": {"uk": "Режим подачі", "en": "Feed Mode"},

    # --- STOCK & CLEAR fields ---
    "Діам загот": {"uk": "Діам загот", "en": "Stock diameter"},
    "Довж загот (Z)": {"uk": "Довж загот (Z)", "en": "Stock length (Z)"},
    "Clear X": {"uk": "Відвід X", "en": "Clear X"},
    "Clear Z": {"uk": "Відвід Z", "en": "Clear Z"},
    "X programming": {"uk": "Програмування X", "en": "X programming"},

    # --- CYCLE fields ---
    "Глиб різан (I)": {"uk": "Глиб різан (I)", "en": "Depth of cut (I)"},
    "Відвід (R)": {"uk": "Відвід (R)", "en": "Retract (R)"},
    "Припуск (D)": {"uk": "Припуск (D)", "en": "Finish allowance (D)"},
    "Подача чорнова": {"uk": "Подача чорнова", "en": "Roughing feed"},
    "Подача фінішна": {"uk": "Подача фінішна", "en": "Finishing feed"},
    "Start X (optional)": {"uk": "Start X (необов'язково)", "en": "Start X (optional)"},
    "Тільки фініш (без G71/G70)": {"uk": "Тільки фініш (без G71/G70)", "en": "Finish only (no G71/G70)"},
    "Start Z (optional)": {"uk": "Start Z (необов'язково)", "en": "Start Z (optional)"},

    # --- Tabs ---
    "Segments": {"uk": "Сегменти", "en": "Segments"},
    "Import": {"uk": "Імпорт", "en": "Import"},
    "DXF": {"uk": "DXF", "en": "DXF"},
    "3MF": {"uk": "3MF", "en": "3MF"},

    # --- Segment editor ---
    "Z": {"uk": "Z", "en": "Z"},
    "X": {"uk": "X", "en": "X"},
    "R (arc radius)": {"uk": "R (радіус дуги)", "en": "R (arc radius)"},
    "Corner type": {"uk": "Тип кута", "en": "Corner type"},
    "Corner value": {"uk": "Значення кута", "en": "Corner value"},
    "Add": {"uk": "Додати", "en": "Add"},
    "Update": {"uk": "Оновити", "en": "Update"},
    "Remove": {"uk": "Видалити", "en": "Remove"},
    "Undo": {"uk": "Скасувати", "en": "Undo"},
    "Delete All": {"uk": "Видалити все", "en": "Delete All"},
    "Segment List": {"uk": "Список сегментів", "en": "Segment List"},

    # --- DXF options ---
    "DXF Options": {"uk": "Опції DXF", "en": "DXF Options"},
    "Import DXF": {"uk": "Імпорт DXF", "en": "Import DXF"},
    "Export DXF": {"uk": "Експорт DXF", "en": "Export DXF"},
    "Swap X/Z": {"uk": "Поміняти X/Z", "en": "Swap X/Z"},
    "Flip Z": {"uk": "Дзеркало Z", "en": "Flip Z"},
    "Flip X": {"uk": "Дзеркало X", "en": "Flip X"},
    "Shift min X to 0": {"uk": "Зсунути min X у 0", "en": "Shift min X to 0"},
    "Use LINE/ARC": {"uk": "Використати LINE/ARC", "en": "Use LINE/ARC"},
    "DXF X is radius (double if G7)": {"uk": "DXF X — це радіус (×2 якщо G7)", "en": "DXF X is radius (double if G7)"},
    "Close gaps (auto-connect)": {"uk": "З'єднати розриви (авто)", "en": "Close gaps (auto-connect)"},
    "Center X on spindle": {"uk": "Центрувати X по осі", "en": "Center X on spindle"},
    "Z offset": {"uk": "Зсув Z", "en": "Z offset"},
    "X offset": {"uk": "Зсув X", "en": "X offset"},
    "Max X → заготовка": {"uk": "Max X → заготовка", "en": "Max X → stock"},
    "Min X → вісь": {"uk": "Min X → вісь", "en": "Min X → axis"},
    "Spline tol (mm)": {"uk": "Точність сплайна (мм)", "en": "Spline tol (mm)"},
    "Fit arcs": {"uk": "Розпізнавати дуги", "en": "Fit arcs"},
    "Arc tol (mm)": {"uk": "Точність дуги (мм)", "en": "Arc tol (mm)"},
    "Apply imported outline": {"uk": "Застосувати імпортований контур", "en": "Apply imported outline"},

    # --- 3MF tab ---
    "Import 3MF": {"uk": "Імпорт 3MF", "en": "Import 3MF"},
    "Projection": {"uk": "Проєкція", "en": "Projection"},
    "Plane": {"uk": "Площина", "en": "Plane"},
    "Convex hull": {"uk": "Опукла оболонка", "en": "Convex hull"},
    "Scale": {"uk": "Масштаб", "en": "Scale"},
    "Simplify": {"uk": "Спростити", "en": "Simplify"},
    "Orientation": {"uk": "Орієнтація", "en": "Orientation"},
    "Use as background image": {"uk": "Використати як фон", "en": "Use as background image"},

    # --- Preview controls ---
    "+ Zoom": {"uk": "+ Збільшити", "en": "+ Zoom"},
    "- Zoom": {"uk": "- Зменшити", "en": "- Zoom"},
    "Reset": {"uk": "Скинути", "en": "Reset"},
    "Max canvas": {"uk": "Макс. полотно", "en": "Max canvas"},
    "Restore UI": {"uk": "Відновити вигляд", "en": "Restore UI"},
    "Mode:": {"uk": "Режим:", "en": "Mode:"},
    "Snap": {"uk": "Прив'язка", "en": "Snap"},
    "Step (mm):": {"uk": "Крок (мм):", "en": "Step (mm):"},
    "Mirror preview": {"uk": "Дзеркальне прев'ю", "en": "Mirror preview"},
    "Fill profile": {"uk": "Заливка профілю", "en": "Fill profile"},
    "50%": {"uk": "50%", "en": "50%"},
    "Show Background": {"uk": "Показати фон", "en": "Show Background"},
    "Hide Background": {"uk": "Сховати фон", "en": "Hide Background"},
    "Del BG": {"uk": "Видал. фон", "en": "Del BG"},

    # --- Actions ---
    "Preview": {"uk": "Перегляд", "en": "Preview"},
    "Generate G-code": {"uk": "Згенерувати G-код", "en": "Generate G-code"},
    "Save G-code": {"uk": "Зберегти G-код", "en": "Save G-code"},
    "Send to LinuxCNC and quit": {"uk": "Надіслати в LinuxCNC і вийти", "en": "Send to LinuxCNC and quit"},

    # --- File menu ---
    "File": {"uk": "Файл", "en": "File"},
    "Open Session...": {"uk": "Відкрити сесію...", "en": "Open Session..."},
    "Save Session": {"uk": "Зберегти сесію", "en": "Save Session"},
    "Save Session As...": {"uk": "Зберегти сесію як...", "en": "Save Session As..."},
    "New Session": {"uk": "Нова сесія", "en": "New Session"},
}


class ProfileApp:
    # Defensive class attribute to avoid AttributeError if init is bypassed
    selected_point_idx: int | None = None
    def __init__(self, root) -> None:  # tk.Tk (type hint deferred to avoid import)
        _ensure_tk_imported()
        self.root = root
        self.root.title("Conversational Profile (G71/G72)")
        self.params = ProfileParams()
        self.points: List[Point] = []
        self.dxf_raw_verts: List[Tuple[float, float, float]] | None = None
        self.dxf_raw_verts_unscaled: List[Tuple[float, float, float]] | None = None
        self.dxf_closed: bool = False
        self.dxf_preview_points: List[Point] | None = None
        self.background_points: List[Point] | None = None  # For background template
        self.zoom_factor: float = 1.0
        self.pan_z_offset: float = 0.0
        self.pan_x_offset: float = 0.0
        self._pan_start: dict | None = None
        self._3mf_last_path: str | None = None  # Store 3MF file path for re-projection on settings change
        self.delete_undo_stack: List[Tuple[int, Point]] = []
        self.preview_job = None
        self.snap_enabled = tk.BooleanVar(value=False)
        self.snap_step = tk.StringVar(value="0.1")
        self._snap_warned_invalid_step = False
        self.preview_mirror_x_var = tk.BooleanVar(value=False)
        self._drag_kind: str | None = None
        self._drag_index: int | None = None
        self._drag_start_model: Tuple[float, float] | None = None
        self._drag_orig_points: List[Tuple[int, float, float]] = []

        # Suppress programmatic listbox scrolling
        self._suppress_listbox_scroll: bool = False
        
        # Draw mode state
        self.draw_mode: str = "LINES"

        # Last used radius (for fillet prompt)
        self.last_radius_value: float = 1.0

        # Last used chamfer (for chamfer prompt)
        self.last_chamfer_value: float = 1.0

        # Suppress auto-commit during programmatic UI updates
        self._suppress_commit: bool = False
        
        # Tooltip items (created lazily on first motion)
        self.tooltip_text_id: int | None = None
        self.tooltip_bg_id: int | None = None

        # Rubber-band preview line (created lazily)
        self.rubber_line_id: int | None = None
        
        # Selection state
        self.selected_point_idx: int | None = None
        
        self.drag_idx: int | None = None
        
        # State persistence
        self._autosave_path = self._default_state_path()  # Canonical autosave location
        self._current_session_path: str | None = None   # User's explicit session file
        self._autosave_job_id: str | None = None        # Periodic autosave job
        
        self._canvas_max = False
        self._pack_restore = {}

        # --- i18n (Ukrainian / English UI shell, see TRANSLATIONS) ---
        self.lang_var = tk.StringVar(value="en")
        self._i18n_registry: List[Tuple[Any, str]] = []  # (widget, translation_key)
        self._i18n_tabs: List[Tuple[Any, Any, str]] = []  # (notebook, tab, key)
        self._i18n_menu: List[Tuple[Any, int, str]] = []  # (menu, index, key)

        self._build_ui()
        # Allow Escape to restore UI when maximized
        self.root.bind("<Escape>", lambda e: self._canvas_max and self._exit_max_canvas())
        self._load_sample_points()
        
        # Hook window close to autosave
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)
        
        # Prompt to restore previous session from autosave if it exists
        if os.path.exists(self._autosave_path):
            if messagebox.askyesno("Restore Session", "Restore previous session?"):
                try:
                    self._load_state(self._autosave_path)
                    # Don't set _current_session_path here; autosave is separate from user sessions
                except Exception as e:
                    self.show_status(f"Failed to load session: {e}", color="red")
        
        # Start periodic autosave (every 30 seconds)
        self._autosave_tick()
        
        # Ensure preview renders after layout on startup
        self.root.after(200, self.preview)

    def _default_state_path(self) -> str:
        """Get platform-specific default state file path, creating parent dirs if needed."""
        if sys.platform == "darwin":
            base = os.path.expanduser("~/Library/Application Support/Conversational Profile")
        else:
            base = os.path.expanduser("~/.config/conversational-profile")
        os.makedirs(base, exist_ok=True)
        return os.path.join(base, "state.json")

    def _serialize_state(self) -> dict:
        """Return complete application state as a dict."""
        return {
            "points": [
                {
                    "z": p.z,
                    "x": p.x,
                    "r": p.r,
                    "corner_type": p.corner_type,
                    "corner_value": p.corner_value,
                }
                for p in self.points
            ],
            "background_points": [
                {
                    "z": p.z,
                    "x": p.x,
                    "r": p.r,
                    "corner_type": p.corner_type,
                    "corner_value": p.corner_value,
                }
                for p in (self.background_points or [])
            ],
            "entries": {key: ent.get() for key, ent in self.entries.items()},
            "segment_editor": {
                "z": self.in_z.get(),
                "x": self.in_x.get(),
                "r": self.in_r.get(),
                "corner_type": self.corner_type.get(),
                "corner_value": self.in_corner_val.get(),
            },
            "view": {
                "zoom_factor": self.zoom_factor,
                "pan_z_offset": self.pan_z_offset,
                "pan_x_offset": self.pan_x_offset,
            },
            "mode": self.draw_mode,
            "snap": {
                "enabled": self.snap_enabled.get(),
                "step": self.snap_step.get(),
            },
            "last_radius_value": self.last_radius_value,
            "last_chamfer_value": self.last_chamfer_value,
            "display": {
                "profile_fill_color": self.params.profile_fill_color,
                "background_image_color": self.params.background_image_color,
            },
        }

    def _apply_state(self, data: dict) -> None:
        """Load state from dict, with safe defaults for missing keys."""
        try:
            # Load points
            if "points" in data and isinstance(data["points"], list):
                self.points = [
                    Point(
                        float(p.get("z", 0.0)),
                        float(p.get("x", 0.0)),
                        float(p.get("r", 0.0)),
                        p.get("corner_type"),
                        float(p["corner_value"]) if p.get("corner_value") is not None else None,
                    )
                    for p in data["points"]
                ]
            
            # Load background points (with backward compatibility for old state files)
            if "background_points" in data and isinstance(data["background_points"], list) and data["background_points"]:
                self.background_points = [
                    Point(
                        float(p.get("z", 0.0)),
                        float(p.get("x", 0.0)),
                        float(p.get("r", 0.0)),
                        p.get("corner_type"),
                        float(p["corner_value"]) if p.get("corner_value") is not None else None,
                    )
                    for p in data["background_points"]
                ]
            else:
                self.background_points = None

            # Clear any DXF preview points on load (we want list-backed points)
            self.dxf_preview_points = None
            
            # Load entries
            if "entries" in data and isinstance(data["entries"], dict):
                for key, val in data["entries"].items():
                    if key in self.entries:
                        sval = str(val)
                        # Backward compat: old configs stored diameter_mode as "true"/"false"
                        if key == "diameter_mode" and "G7" not in sval.upper() and "G8" not in sval.upper():
                            sval = "G7 (діаметр)" if sval.strip().lower() in ("true", "1", "yes") else "G8 (радіус)"
                        if isinstance(self.entries[key], tk.StringVar):
                            # For StringVar (like OD/ID), just set the value
                            self.entries[key].set(sval)
                        else:
                            # For Entry widgets, delete and insert
                            self.entries[key].delete(0, tk.END)
                            self.entries[key].insert(0, sval)
            
            # Load segment editor fields
            if "segment_editor" in data:
                se = data["segment_editor"]
                self.in_z.delete(0, tk.END)
                self.in_z.insert(0, se.get("z", ""))
                self.in_x.delete(0, tk.END)
                self.in_x.insert(0, se.get("x", ""))
                self.in_r.delete(0, tk.END)
                self.in_r.insert(0, se.get("r", ""))
                self.in_corner_val.delete(0, tk.END)
                self.in_corner_val.insert(0, se.get("corner_value", ""))
                self.corner_type.set(se.get("corner_type", "none"))
            
            # Load view settings
            if "view" in data:
                v = data["view"]
                self.zoom_factor = float(v.get("zoom_factor", 1.0))
                self.pan_z_offset = float(v.get("pan_z_offset", 0.0))
                self.pan_x_offset = float(v.get("pan_x_offset", 0.0))
            
            # Load mode
            if "mode" in data:
                mode = data["mode"]
                if mode in ("LINES", "RADIUS", "CHAMFER", "SELECT"):
                    self.draw_mode = mode
                    if hasattr(self, "mode_combo"):
                        self.mode_combo.set(mode)
            
            # Load snap settings
            if "snap" in data:
                s = data["snap"]
                self.snap_enabled.set(bool(s.get("enabled", False)))
                self.snap_step.set(str(s.get("step", "0.1")))
            
            # Load last used values
            if "last_radius_value" in data:
                self.last_radius_value = float(data["last_radius_value"])
            if "last_chamfer_value" in data:
                self.last_chamfer_value = float(data["last_chamfer_value"])
            
            # Load display colors (but always show background on startup)
            if "display" in data:
                d = data["display"]
                if "profile_fill_color" in d:
                    self.params.profile_fill_color = d["profile_fill_color"]
                    if hasattr(self, "color_swatch"):
                        self.color_swatch.config(bg=self.params.profile_fill_color, activebackground=self.params.profile_fill_color)
                if "background_image_color" in d:
                    self.params.background_image_color = d["background_image_color"]
                    if hasattr(self, "bg_color_swatch"):
                        self.bg_color_swatch.config(bg=self.params.background_image_color, activebackground=self.params.background_image_color)
            
            # Force background visibility to on (even if saved as off)
            if hasattr(self, "show_background_var"):
                self.show_background_var.set(True)
                if hasattr(self, "bg_btn"):
                    self.bg_btn.config(text="Hide Background")
        
        except Exception as e:
            self.show_status(f"Error applying state: {e}", color="red")

    def _autosave_tick(self) -> None:
        """Periodic autosave every 30 seconds."""
        self._save_state(self._autosave_path, quiet=True)
        self._autosave_job_id = self.root.after(30000, self._autosave_tick)
    
    def _force_save(self, reason: str) -> None:
        """Force immediate autosave before critical operations."""
        try:
            self._save_state(self._autosave_path, quiet=True)
        except Exception as e:
            print(f"Force save failed ({reason}): {e}", file=sys.stderr)
    
    def _save_state(self, path: str | None = None, quiet: bool = False) -> None:
        """Save current state to JSON file with atomic writes (tmp + os.replace)."""
        if path is None:
            path = self._current_session_path or self._autosave_path
        try:
            state = self._serialize_state()
            os.makedirs(os.path.dirname(path), exist_ok=True)
            
            # Atomic write: tmp file + fsync + replace
            tmp_path = path + ".tmp"
            with open(tmp_path, "w") as f:
                json.dump(state, f, indent=2)
                f.flush()
                os.fsync(f.fileno())
            os.replace(tmp_path, path)
            
            # Update _current_session_path only if this is an explicit user save
            if path != self._autosave_path:
                self._current_session_path = path
        except Exception as e:
            if not quiet:
                print(f"Failed to save state: {e}", file=sys.stderr)

    def _load_state(self, path: str) -> None:
        """Load state from JSON file."""
        with open(path, "r") as f:
            data = json.load(f)
        self._apply_state(data)
        # Update params from loaded entries
        try:
            self.params = self._read_params()
        except Exception:
            pass  # If params can't be read yet, preview() will handle it
        # Populate list from self.points and select first if available
        if hasattr(self, "seg_list"):
            sel_idx = 0 if self.points else None
            self._refresh_points_list(select_idx=sel_idx)
            if self.points:
                self._set_input_fields_for_index(0)
            else:
                self._clear_segment_inputs()
        self.preview()

    def _on_close(self) -> None:
        """Save state and close the window."""
        # Cancel periodic autosave
        if self._autosave_job_id:
            self.root.after_cancel(self._autosave_job_id)
        # Force final save
        self._force_save("on_close")
        self.root.destroy()

    def _menu_open_session(self) -> None:
        """Open a session from a user-selected file."""
        path = filedialog.askopenfilename(
            title="Open Session",
            filetypes=[("JSON files", "*.json"), ("All files", "*")],
            initialdir=os.path.dirname(self._current_session_path or self._default_state_path()),
        )
        if path:
            try:
                self._load_state(path)
                self._current_session_path = path
                self.show_status(f"Loaded session: {path}", color="#66CC99")
            except Exception as e:
                messagebox.showerror("Load Error", f"Failed to load session:\n{e}")

    def _menu_save_session_as(self) -> None:
        """Save current state to a user-selected file."""
        path = filedialog.asksaveasfilename(
            title="Save Session As",
            filetypes=[("JSON files", "*.json"), ("All files", "*")],
            initialdir=os.path.dirname(self._current_session_path or self._default_state_path()),
            defaultextension=".json",
        )
        if path:
            try:
                self._save_state(path)
                self.show_status(f"Saved session: {path}", color="#66CC99")
            except Exception as e:
                messagebox.showerror("Save Error", f"Failed to save session:\n{e}")

    def _menu_new_session(self) -> None:
        """Clear all points and reset UI to start fresh."""
        if not messagebox.askyesno("New Session", "Clear all points and reset? (unsaved changes will be lost)"):
            return
        self.points = []
        self.selected_point_idx = None
        self._refresh_points_list()
        self._clear_segment_inputs()
        self.zoom_factor = 1.0
        self.pan_z_offset = 0.0
        self.pan_x_offset = 0.0
        self.draw_mode = "LINES"
        if hasattr(self, "mode_combo"):
            self.mode_combo.set("LINES")
        self._update_mode_hint()
        self._current_session_path = None
        self.preview()
        self.show_status("New session started", color="#66CC99")

    def _build_ui(self) -> None:
        # Create menu bar
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)

        file_menu = tk.Menu(menubar, tearoff=False)
        menubar.add_cascade(label=self._tr("File"), menu=file_menu)
        self._i18n_menu.append((menubar, 0, "File"))
        file_menu.add_command(label=self._tr("Open Session..."), command=self._menu_open_session)
        self._i18n_menu.append((file_menu, 0, "Open Session..."))
        file_menu.add_command(label=self._tr("Save Session"), command=lambda: self._save_state())
        self._i18n_menu.append((file_menu, 1, "Save Session"))
        file_menu.add_command(label=self._tr("Save Session As..."), command=self._menu_save_session_as)
        self._i18n_menu.append((file_menu, 2, "Save Session As..."))
        file_menu.add_separator()
        file_menu.add_command(label=self._tr("New Session"), command=self._menu_new_session)
        self._i18n_menu.append((file_menu, 4, "New Session"))

        # ===== LANGUAGE SELECTOR (very first thing in the window) =====
        lang_bar = tk.Frame(self.root)
        lang_bar.pack(side="top", fill="x", padx=8, pady=(6, 0))
        tk.Label(lang_bar, text="Language / Мова:").pack(side="left", padx=(0, 6))
        tk.Radiobutton(
            lang_bar, text="English", variable=self.lang_var, value="en",
            command=self._apply_language,
        ).pack(side="left")
        tk.Radiobutton(
            lang_bar, text="Українська", variable=self.lang_var, value="uk",
            command=self._apply_language,
        ).pack(side="left")

        frm = tk.Frame(self.root)
        frm.pack(fill="both", expand=True)

        left = tk.Frame(frm)
        left.pack(side="left", fill="both", padx=8, pady=8)
        right = tk.Frame(frm)
        right.pack(side="right", fill="both", expand=True, padx=8, pady=8)
        self.left_panel = left
        self.right_panel = right

        # ===== LEFT PANEL: PARAMETERS + TABBED INTERFACE =====

        # Parameters in 2-column layout
        params_frm = tk.Frame(left)
        params_frm.pack(fill="x", padx=0, pady=(0, 8))

        # Left column: Machining & Stock
        left_params = tk.Frame(params_frm)
        left_params.pack(side="left", fill="both", expand=True, padx=(0, 4))

        self.entries = {}

        self._i18n_text(tk.Label(left_params, font=("TkDefaultFont", 9, "bold")), "MACHINING").pack(anchor="w", pady=(0, 4))
        mach_fields = [
            ("cycle", "Cycle", self.params.cycle),
        ]
        for key, label, default in mach_fields:
            frm = tk.Frame(left_params)
            frm.pack(fill="x", pady=1)
            self._i18n_text(tk.Label(frm, width=14, anchor="w"), label).pack(side="left")
            ent = tk.Entry(frm, width=10)
            ent.insert(0, default)
            ent.pack(side="left", padx=(2, 0))
            self.entries[key] = ent
            ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())
            ent.bind("<FocusOut>", lambda _e: self._schedule_preview())

        # Spindle direction (M3/M4)
        spindle_dir_frm = tk.Frame(left_params)
        spindle_dir_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(spindle_dir_frm, width=14, anchor="w"), "Spindle Dir").pack(side="left")
        spindle_dir_default = "M3 (CW)" if self.params.spindle_dir.strip().upper() != "M4" else "M4 (CCW)"
        self.spindle_dir_var = tk.StringVar(value=spindle_dir_default)
        spindle_dir_menu = tk.OptionMenu(
            spindle_dir_frm,
            self.spindle_dir_var,
            "M3 (CW)",
            "M4 (CCW)",
            command=lambda _v: self._schedule_preview(),
        )
        spindle_dir_menu.config(width=8)
        spindle_dir_menu.pack(side="left", padx=(2, 0))
        self.entries["spindle_dir"] = self.spindle_dir_var

        # Spindle speed (RPM)
        spindle_rpm_frm = tk.Frame(left_params)
        spindle_rpm_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(spindle_rpm_frm, width=14, anchor="w"), "Spindle (RPM)").pack(side="left")
        spindle_rpm_ent = tk.Entry(spindle_rpm_frm, width=10)
        spindle_rpm_ent.insert(0, str(self.params.spindle_rpm))
        spindle_rpm_ent.pack(side="left", padx=(2, 0))
        self.entries["spindle_rpm"] = spindle_rpm_ent
        spindle_rpm_ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())
        spindle_rpm_ent.bind("<FocusOut>", lambda _e: self._schedule_preview())

        tool_fields = [
            ("tool", "Tool # rough", self.params.tool),
            # Default finish tool to current rough tool for UI convenience
            ("tool_finish", "Tool # finish", self.params.tool),
            ("coolant", "Coolant?", "false"),
        ]
        for key, label, default in tool_fields:
            frm = tk.Frame(left_params)
            frm.pack(fill="x", pady=1)
            self._i18n_text(tk.Label(frm, width=14, anchor="w"), label).pack(side="left")
            ent = tk.Entry(frm, width=10)
            ent.insert(0, default)
            ent.pack(side="left", padx=(2, 0))
            self.entries[key] = ent
            ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())
            ent.bind("<FocusOut>", lambda _e: self._schedule_preview())
        
        # OD/ID as OptionMenu
        od_frm = tk.Frame(left_params)
        od_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(od_frm, width=14, anchor="w"), "OD/ID").pack(side="left")
        self.od_var = tk.StringVar(value="OD" if self.params.od else "ID")
        od_menu = tk.OptionMenu(od_frm, self.od_var, "OD", "ID", command=lambda _v: self._schedule_preview())
        od_menu.config(width=8)
        od_menu.pack(side="left", padx=(2, 0))
        self.entries["od"] = self.od_var
        
        # Units: G21 (mm) vs G20 (inches)
        units_frm = tk.Frame(left_params)
        units_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(units_frm, width=14, anchor="w"), "Units").pack(side="left")
        self.units_var = tk.StringVar(value="G21 (mm)" if self.params.metric else "G20 (in)")
        units_menu = tk.OptionMenu(units_frm, self.units_var, "G21 (mm)", "G20 (in)", command=lambda _v: self._schedule_preview())
        units_menu.config(width=8)
        units_menu.pack(side="left", padx=(2, 0))
        self.entries["metric"] = self.units_var
        
        # Feed Mode: G95 (per rev) vs G94 (per min)
        feed_frm = tk.Frame(left_params)
        feed_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(feed_frm, width=14, anchor="w"), "Feed Mode").pack(side="left")
        self.feed_mode_var = tk.StringVar(value="G95 (/rev)" if self.params.feed_per_rev else "G94 (/min)")
        feed_menu = tk.OptionMenu(feed_frm, self.feed_mode_var, "G95 (/rev)", "G94 (/min)", command=lambda _v: self._schedule_preview())
        feed_menu.config(width=8)
        feed_menu.pack(side="left", padx=(2, 0))
        self.entries["feed_mode"] = self.feed_mode_var

        # Display controls
        self._i18n_text(tk.Label(left_params, font=("TkDefaultFont", 9, "bold")), "STOCK & CLEAR").pack(anchor="w", pady=(8, 4))
        stock_fields = [
            ("stock_diam", "Діам загот", str(self.params.stock_diam)),
            ("stock_len", "Довж загот (Z)", str(self.params.stock_len)),
            ("safe_clear_x", "Clear X", str(self.params.safe_clear_x)),
            ("safe_clear_z", "Clear Z", str(self.params.safe_clear_z)),
        ]
        for key, label, default in stock_fields:
            frm = tk.Frame(left_params)
            frm.pack(fill="x", pady=1)
            self._i18n_text(tk.Label(frm, width=14, anchor="w"), label).pack(side="left")
            ent = tk.Entry(frm, width=10)
            ent.insert(0, default)
            ent.pack(side="left", padx=(2, 0))
            self.entries[key] = ent
            ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())
            ent.bind("<FocusOut>", lambda _e: self._schedule_preview())

        # Diameter/radius programming mode as dropdown (was a "true"/"false" text entry)
        diam_frm = tk.Frame(left_params)
        diam_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(diam_frm, width=14, anchor="w"), "X programming").pack(side="left")
        self.diameter_mode_var = tk.StringVar(
            value="G7 (діаметр)" if self.params.diameter_mode else "G8 (радіус)")
        diam_menu = tk.OptionMenu(diam_frm, self.diameter_mode_var,
                                  "G7 (діаметр)", "G8 (радіус)",
                                  command=lambda _v: self._schedule_preview())
        diam_menu.config(width=10)
        diam_menu.pack(side="left", padx=(2, 0))
        self.entries["diameter_mode"] = self.diameter_mode_var

        # Right column: Cycle Parameters
        right_params = tk.Frame(params_frm)
        right_params.pack(side="left", fill="both", expand=True, padx=(4, 0))

        self._i18n_text(tk.Label(right_params, font=("TkDefaultFont", 9, "bold")), "CYCLE").pack(anchor="w", pady=(0, 4))
        cycle_fields = [
            ("doc", "Глиб різан (I)", str(self.params.doc)),
            ("retract", "Відвід (R)", str(self.params.retract)),
            ("stock_allow", "Припуск (D)", str(self.params.stock_allow)),
            ("feed_rough", "Подача чорнова", str(self.params.feed_rough)),
            ("feed_finish", "Подача фінішна", str(self.params.feed_finish)),
        ]
        for key, label, default in cycle_fields:
            frm = tk.Frame(right_params)
            frm.pack(fill="x", pady=1)
            self._i18n_text(tk.Label(frm, width=14, anchor="w"), label).pack(side="left")
            ent = tk.Entry(frm, width=10)
            ent.insert(0, default)
            ent.pack(side="left", padx=(2, 0))
            self.entries[key] = ent
            ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())

        # Start X field
        start_x_frm = tk.Frame(right_params)
        start_x_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(start_x_frm, width=14, anchor="w"), "Start X (optional)").pack(side="left")
        ent = tk.Entry(start_x_frm, width=10)
        ent.insert(0, str(self.params.start_x))
        ent.pack(side="left", padx=(2, 0))
        self.entries["start_x"] = ent
        ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())

        # "Тільки фініш (без G71/G70)" checkbox — bypasses G71/G70 entirely:
        # G0 approach + direct contour call @ feed_finish + retract. See
        # wrapper_finish_direct_subprogram_gcode for rationale.
        self.direct_finish_var = tk.BooleanVar(value=self.params.direct_finish_only)
        direct_finish_frm = tk.Frame(right_params)
        direct_finish_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Checkbutton(
            direct_finish_frm,
            variable=self.direct_finish_var,
            command=self._schedule_preview,
        ), "Тільки фініш (без G71/G70)").pack(side="left")

        # Start Z field
        start_z_frm = tk.Frame(right_params)
        start_z_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(start_z_frm, width=14, anchor="w"), "Start Z (optional)").pack(side="left")
        ent = tk.Entry(start_z_frm, width=10)
        ent.insert(0, str(self.params.start_z))
        ent.pack(side="left", padx=(2, 0))
        self.entries["start_z"] = ent
        ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())

        # --- TABBED INTERFACE ---
        from tkinter import ttk
        self.notebook = ttk.Notebook(left)
        self.notebook.pack(fill="both", expand=True, pady=(8, 0))

        # TAB 1: Segment Editor
        tab_segment = tk.Frame(self.notebook)
        self.notebook.add(tab_segment, text="")
        self._i18n_tab(self.notebook, tab_segment, "Segments")

        self._i18n_text(tk.Label(tab_segment), "Z").grid(row=0, column=0, sticky="w", pady=2, padx=4)
        self.in_z = tk.Entry(tab_segment, width=12)
        self.in_z.grid(row=0, column=1, sticky="ew", pady=2, padx=4)

        self._i18n_text(tk.Label(tab_segment), "X").grid(row=1, column=0, sticky="w", pady=2, padx=4)
        self.in_x = tk.Entry(tab_segment, width=12)
        self.in_x.grid(row=1, column=1, sticky="ew", pady=2, padx=4)

        self._i18n_text(tk.Label(tab_segment), "R (arc radius)").grid(row=2, column=0, sticky="w", pady=2, padx=4)
        self.in_r = tk.Entry(tab_segment, width=12)
        self.in_r.grid(row=2, column=1, sticky="ew", pady=2, padx=4)
        self.r_bg_default = self.in_r.cget("bg")
        self.r_fg_default = self.in_r.cget("fg")
        self.in_r.bind("<KeyRelease>", lambda _e: self._validate_radius_field())

        for widget in (self.in_z, self.in_x, self.in_r):
            widget.bind("<FocusIn>", lambda _e: self._schedule_preview())
            widget.bind("<FocusOut>", lambda _e: (self._schedule_preview(), self._commit_segment_edit()))
            widget.bind("<Return>", lambda _e: self._commit_segment_edit())
            widget.bind("<KP_Enter>", lambda _e: self._commit_segment_edit())

        self._i18n_text(tk.Label(tab_segment), "Corner type").grid(row=3, column=0, sticky="w", pady=2, padx=4)
        self.corner_type = tk.StringVar(value="none")
        corner_menu = tk.OptionMenu(tab_segment, self.corner_type, "none", "A (fillet)", "C (chamfer)",
                                    command=lambda _v: self._schedule_preview())
        corner_menu.grid(row=3, column=1, sticky="ew", pady=2, padx=4)

        self._i18n_text(tk.Label(tab_segment), "Corner value").grid(row=4, column=0, sticky="w", pady=2, padx=4)
        self.in_corner_val = tk.Entry(tab_segment, width=12)
        self.in_corner_val.grid(row=4, column=1, sticky="ew", pady=2, padx=4)
        self.in_corner_val.bind("<FocusIn>", lambda _e: self._schedule_preview())
        self.in_corner_val.bind("<FocusOut>", lambda _e: (self._schedule_preview(), self._commit_segment_edit()))
        self.in_corner_val.bind("<Return>", lambda _e: self._commit_segment_edit())

        tab_segment.columnconfigure(1, weight=1)

        seg_btn_frm = tk.Frame(tab_segment)
        seg_btn_frm.grid(row=5, column=0, columnspan=2, pady=(8, 4), sticky="ew")
        self._i18n_text(tk.Button(seg_btn_frm, command=self.add_segment), "Add").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_frm, command=self.update_selected), "Update").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_frm, command=self.remove_selected), "Remove").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_frm, command=self.undo_delete), "Undo").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_frm, command=self.delete_all), "Delete All").pack(side="left", padx=2)

        self._i18n_text(tk.Label(tab_segment), "Segment List").grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 2), padx=4)
        self.seg_list = tk.Listbox(tab_segment, width=32, height=10, exportselection=False)
        self.seg_list.grid(row=7, column=0, columnspan=2, sticky="nsew", padx=4, pady=(0, 4))
        self.seg_list.bind("<<ListboxSelect>>", self._on_select_segment)
        self.seg_list.bind("<ButtonPress-1>", self._on_drag_start)
        self.seg_list.bind("<B1-Motion>", self._on_drag_motion)
        self.seg_list.bind("<ButtonRelease-1>", self._on_drag_end)
        self.seg_list.bind("<Delete>", self._on_delete_key)
        self.seg_list.bind("<BackSpace>", self._on_delete_key)
        tab_segment.rowconfigure(7, weight=1)

        # TAB 2: Import (DXF + 3MF)
        tab_import = tk.Frame(self.notebook)
        self.notebook.add(tab_import, text="")
        self._i18n_tab(self.notebook, tab_import, "Import")

        import_notebook = ttk.Notebook(tab_import)
        import_notebook.pack(fill="both", expand=True, padx=4, pady=4)

        # DXF sub-tab
        tab_dxf = tk.Frame(import_notebook)
        import_notebook.add(tab_dxf, text="")
        self._i18n_tab(import_notebook, tab_dxf, "DXF")

        dxf_btn_frm = tk.Frame(tab_dxf)
        dxf_btn_frm.pack(pady=8)
        self._i18n_text(tk.Button(dxf_btn_frm, command=self.import_dxf), "Import DXF").pack(side="left", padx=4)
        self._i18n_text(tk.Button(dxf_btn_frm, command=self.export_dxf), "Export DXF").pack(side="left", padx=4)

        self.dxf_swap_axes = tk.BooleanVar(value=False)
        self.dxf_flip_z = tk.BooleanVar(value=False)
        self.dxf_flip_x = tk.BooleanVar(value=False)
        self.dxf_shift_min_x = tk.BooleanVar(value=False)
        self.dxf_allow_line_arc = tk.BooleanVar(value=True)
        self.dxf_x_is_radius = tk.BooleanVar(value=False)
        self.dxf_close_gaps = tk.BooleanVar(value=True)
        self.dxf_center_x = tk.BooleanVar(value=False)
        self.dxf_z_offset = tk.DoubleVar(value=0.0)
        self.dxf_x_offset = tk.DoubleVar(value=0.0)  # X shift applied after all other transforms
        self.dxf_spline_tol = tk.DoubleVar(value=0.05)  # max deviation for SPLINE tessellation (mm)
        self.dxf_fit_arcs = tk.BooleanVar(value=True)   # fit circular arcs to imported polyline
        self.dxf_arc_tol = tk.DoubleVar(value=0.05)     # max deviation for arc fitting (mm)
        self.import_source = "dxf"  # Track which import type is active: "dxf" or "3mf"

        dxf_opts = self._i18n_text(tk.LabelFrame(tab_dxf, padx=8, pady=8), "DXF Options")
        dxf_opts.pack(fill="both", expand=True, padx=8, pady=8)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_swap_axes,
                       command=self._refresh_dxf_preview), "Swap X/Z").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_flip_z,
                       command=self._refresh_dxf_preview), "Flip Z").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_flip_x,
                       command=self._refresh_dxf_preview), "Flip X").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_shift_min_x,
                       command=self._refresh_dxf_preview), "Shift min X to 0").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_allow_line_arc,
                   command=self._refresh_dxf_preview), "Use LINE/ARC").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_x_is_radius,
                   command=self._refresh_dxf_preview), "DXF X is radius (double if G7)").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_close_gaps,
                   command=self._refresh_dxf_preview), "Close gaps (auto-connect)").pack(anchor="w", pady=2)
        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_center_x,
                   command=self._refresh_dxf_preview), "Center X on spindle").pack(anchor="w", pady=2)

        dxf_z_frm = tk.Frame(dxf_opts)
        dxf_z_frm.pack(anchor="w", pady=2)
        self._i18n_text(tk.Label(dxf_z_frm), "Z offset").pack(side="left", padx=4)
        self.dxf_z_offset_entry = tk.Entry(dxf_z_frm, textvariable=self.dxf_z_offset, width=10)
        self.dxf_z_offset_entry.pack(side="left", padx=2)
        self.dxf_z_offset_entry.bind("<FocusOut>", self._validate_and_refresh_dxf)
        self.dxf_z_offset_entry.bind("<Return>", self._validate_and_refresh_dxf)
        self.dxf_z_offset_entry.bind("<KP_Enter>", self._validate_and_refresh_dxf)

        dxf_x_frm = tk.Frame(dxf_opts)
        dxf_x_frm.pack(anchor="w", pady=2)
        self._i18n_text(tk.Label(dxf_x_frm), "X offset").pack(side="left", padx=4)
        self.dxf_x_offset_entry = tk.Entry(dxf_x_frm, textvariable=self.dxf_x_offset, width=10)
        self.dxf_x_offset_entry.pack(side="left", padx=2)
        self.dxf_x_offset_entry.bind("<FocusOut>", self._validate_and_refresh_dxf)
        self.dxf_x_offset_entry.bind("<Return>", self._validate_and_refresh_dxf)
        self.dxf_x_offset_entry.bind("<KP_Enter>", self._validate_and_refresh_dxf)
        dxf_align_frm = tk.Frame(dxf_opts)
        dxf_align_frm.pack(anchor="w", pady=2)
        self._i18n_text(tk.Button(dxf_align_frm,
                  command=self._dxf_align_max_x_to_stock), "Max X → заготовка").pack(side="left", padx=16)
        self._i18n_text(tk.Button(dxf_align_frm,
                  command=self._dxf_align_min_x_to_axis), "Min X → вісь").pack(side="left", padx=4)

        dxf_spline_frm = tk.Frame(dxf_opts)
        dxf_spline_frm.pack(anchor="w", pady=2)
        self._i18n_text(tk.Label(dxf_spline_frm), "Spline tol (mm)").pack(side="left", padx=4)
        self.dxf_spline_tol_entry = tk.Entry(dxf_spline_frm, textvariable=self.dxf_spline_tol, width=10)
        self.dxf_spline_tol_entry.pack(side="left", padx=2)
        self.dxf_spline_tol_entry.bind("<FocusOut>", self._validate_and_refresh_dxf)
        self.dxf_spline_tol_entry.bind("<Return>", self._validate_and_refresh_dxf)
        self.dxf_spline_tol_entry.bind("<KP_Enter>", self._validate_and_refresh_dxf)

        self._i18n_text(tk.Checkbutton(dxf_opts, variable=self.dxf_fit_arcs,
                       command=self._refresh_dxf_preview), "Fit arcs").pack(anchor="w", pady=2)

        dxf_arc_frm = tk.Frame(dxf_opts)
        dxf_arc_frm.pack(anchor="w", pady=2)
        self._i18n_text(tk.Label(dxf_arc_frm), "Arc tol (mm)").pack(side="left", padx=4)
        self.dxf_arc_tol_entry = tk.Entry(dxf_arc_frm, textvariable=self.dxf_arc_tol, width=10)
        self.dxf_arc_tol_entry.pack(side="left", padx=2)
        self.dxf_arc_tol_entry.bind("<FocusOut>", self._validate_and_refresh_dxf)
        self.dxf_arc_tol_entry.bind("<Return>", self._validate_and_refresh_dxf)
        self.dxf_arc_tol_entry.bind("<KP_Enter>", self._validate_and_refresh_dxf)

        self.apply_dxf_btn = self._i18n_text(tk.Button(dxf_opts, command=self.apply_dxf), "Apply imported outline")
        self.apply_dxf_btn.pack(pady=(8, 0))
        self.apply_dxf_btn.pack_forget()  # Hidden by default

        # 3MF sub-tab
        tab_3mf = tk.Frame(import_notebook)
        import_notebook.add(tab_3mf, text="")
        self._i18n_tab(import_notebook, tab_3mf, "3MF")

        self.threemf_plane = tk.StringVar(value="ZX")
        self.threemf_convex_hull = tk.BooleanVar(value=False)
        self.threemf_simplify_tol = tk.DoubleVar(value=0.05)
        self.threemf_scale = tk.DoubleVar(value=1.0)
        self.threemf_swap_axes = tk.BooleanVar(value=False)
        self.threemf_flip_z = tk.BooleanVar(value=False)
        self.threemf_flip_x = tk.BooleanVar(value=False)
        self.threemf_shift_min_x = tk.BooleanVar(value=False)
        self.threemf_center_x = tk.BooleanVar(value=True)
        self.threemf_z_offset = tk.DoubleVar(value=0.0)
        self.threemf_as_background = tk.BooleanVar(value=False)

        threemf_btn_frm = tk.Frame(tab_3mf)
        threemf_btn_frm.pack(pady=8)
        self._i18n_text(tk.Button(threemf_btn_frm, command=self.import_3mf), "Import 3MF").pack(side="left", padx=4)

        # 2-column layout for options (no expand, so button fits below)
        threemf_content = tk.Frame(tab_3mf)
        threemf_content.pack(fill="both", padx=8, pady=8)

        # Left column
        left_col = tk.Frame(threemf_content)
        left_col.pack(side="left", fill="both", expand=True, padx=(0, 4))

        threemf_proj = self._i18n_text(tk.LabelFrame(left_col, padx=8, pady=8), "Projection")
        threemf_proj.pack(fill="x", pady=(0, 8))
        self._i18n_text(tk.Label(threemf_proj), "Plane").pack(anchor="w")
        planes = tk.Frame(threemf_proj)
        planes.pack(anchor="w", pady=(0, 6))
        for txt, val in (("XY", "XY"), ("XZ", "XZ"), ("ZX", "ZX")):
            tk.Radiobutton(planes, text=txt, variable=self.threemf_plane, value=val, command=self._refresh_3mf_preview).pack(side="left", padx=4)
        self._i18n_text(tk.Checkbutton(threemf_proj, variable=self.threemf_convex_hull, command=self._refresh_3mf_preview), "Convex hull").pack(anchor="w", pady=2)

        scale_frm = tk.Frame(threemf_proj)
        scale_frm.pack(anchor="w", pady=(4, 0))
        self._i18n_text(tk.Label(scale_frm), "Scale").pack(side="left")
        self.threemf_scale_entry = tk.Entry(scale_frm, textvariable=self.threemf_scale, width=8)
        self.threemf_scale_entry.pack(side="left", padx=6)
        self.threemf_scale_entry.bind("<FocusOut>", lambda _e: self._apply_3mf_scale_and_refresh())
        self.threemf_scale_entry.bind("<Return>", lambda _e: self._apply_3mf_scale_and_refresh())
        self.threemf_scale_entry.bind("<KP_Enter>", lambda _e: self._apply_3mf_scale_and_refresh())

        simp_frm = tk.Frame(threemf_proj)
        simp_frm.pack(anchor="w", pady=(4, 0))
        self._i18n_text(tk.Label(simp_frm), "Simplify").pack(side="left")
        self.threemf_simplify_entry = tk.Entry(simp_frm, textvariable=self.threemf_simplify_tol, width=8)
        self.threemf_simplify_entry.pack(side="left", padx=6)
        self.threemf_simplify_entry.bind("<FocusOut>", lambda _e: self._refresh_3mf_preview())
        self.threemf_simplify_entry.bind("<Return>", lambda _e: self._refresh_3mf_preview())
        self.threemf_simplify_entry.bind("<KP_Enter>", lambda _e: self._refresh_3mf_preview())

        # Right column
        right_col = tk.Frame(threemf_content)
        right_col.pack(side="left", fill="both", expand=True, padx=(4, 0))

        threemf_orient = self._i18n_text(tk.LabelFrame(right_col, padx=8, pady=8), "Orientation")
        threemf_orient.pack(fill="x", pady=(0, 8))
        self._i18n_text(tk.Checkbutton(threemf_orient, variable=self.threemf_swap_axes,
                       command=self._refresh_dxf_preview), "Swap X/Z").pack(anchor="w", pady=1)
        self._i18n_text(tk.Checkbutton(threemf_orient, variable=self.threemf_flip_z,
                       command=self._refresh_dxf_preview), "Flip Z").pack(anchor="w", pady=1)
        self._i18n_text(tk.Checkbutton(threemf_orient, variable=self.threemf_flip_x,
                       command=self._refresh_dxf_preview), "Flip X").pack(anchor="w", pady=1)
        self._i18n_text(tk.Checkbutton(threemf_orient, variable=self.threemf_shift_min_x,
                       command=self._refresh_dxf_preview), "Shift min X to 0").pack(anchor="w", pady=1)
        self._i18n_text(tk.Checkbutton(threemf_orient, variable=self.threemf_center_x,
                       command=self._refresh_dxf_preview), "Center X on spindle").pack(anchor="w", pady=1)
        self._i18n_text(tk.Checkbutton(threemf_orient, variable=self.threemf_as_background,
                       command=self._refresh_dxf_preview), "Use as background image").pack(anchor="w", pady=1)

        threemf_z_frm = tk.Frame(threemf_orient)
        threemf_z_frm.pack(anchor="w", pady=(4, 0))
        self._i18n_text(tk.Label(threemf_z_frm), "Z offset").pack(side="left")
        self.threemf_z_offset_entry = tk.Entry(threemf_z_frm, textvariable=self.threemf_z_offset, width=8)
        self.threemf_z_offset_entry.pack(side="left", padx=6)
        self.threemf_z_offset_entry.bind("<FocusOut>", self._validate_and_refresh_dxf)
        self.threemf_z_offset_entry.bind("<Return>", self._validate_and_refresh_dxf)
        self.threemf_z_offset_entry.bind("<KP_Enter>", self._validate_and_refresh_dxf)

        # Bottom button spanning both columns
        button_frm = tk.Frame(tab_3mf)
        button_frm.pack(pady=8)
        self.apply_3mf_btn = self._i18n_text(tk.Button(button_frm, command=self.apply_dxf), "Apply imported outline")
        self.apply_3mf_btn.pack()
        self.apply_3mf_btn.pack_forget()  # Hidden by default

        # ===== RIGHT PANEL: PREVIEW & G-CODE =====
        self.preview_label = self._i18n_text(tk.Label(right, font=("TkDefaultFont", 10, "bold")), "PREVIEW")
        self.preview_label.pack(anchor="w", pady=(0, 4))

        zoom_bar = tk.Frame(right)
        zoom_bar.pack(anchor="w", pady=(0, 4))
        self._i18n_text(tk.Button(zoom_bar, command=self.zoom_in, width=8), "+ Zoom").pack(side="left", padx=2)
        self._i18n_text(tk.Button(zoom_bar, command=self.zoom_out, width=8), "- Zoom").pack(side="left", padx=2)
        self._i18n_text(tk.Button(zoom_bar, command=self.reset_view, width=8), "Reset").pack(side="left", padx=2)
        self.max_canvas_btn = self._i18n_text(tk.Button(zoom_bar, command=self._toggle_max_canvas, width=12), "Max canvas")
        self.max_canvas_btn.pack(side="left", padx=2)
        self.zoom_bar = zoom_bar

        # Draw mode selector and snap controls on one line
        controls_bar = tk.Frame(right)
        controls_bar.pack(anchor="w", pady=(0, 4))
        self.controls_bar = controls_bar
        
        self._i18n_text(tk.Label(controls_bar), "Mode:").pack(side="left", padx=(0, 4))
        self.mode_var = tk.StringVar(value=self.draw_mode)
        self.mode_combo = ttk.Combobox(controls_bar, textvariable=self.mode_var,
                                       values=("LINES", "RADIUS", "CHAMFER", "SELECT", "SPLIT"),
                                       state="readonly", width=10)
        self.mode_combo.bind("<<ComboboxSelected>>", lambda e: self._set_draw_mode(self.mode_var.get()))
        self.mode_combo.pack(side="left", padx=4)
        
        # Snap controls
        self.chk_snap = self._i18n_text(tk.Checkbutton(controls_bar, variable=self.snap_enabled, command=self._validate_snap_step), "Snap")
        self.chk_snap.pack(side="left", padx=(12, 4))
        self._i18n_text(tk.Label(controls_bar), "Step (mm):").pack(side="left", padx=(0, 4))
        self.snap_step_entry = tk.Entry(controls_bar, textvariable=self.snap_step, width=6)
        self.snap_step_entry.pack(side="left")
        self.snap_step_entry.bind("<FocusOut>", self._validate_snap_step)

        # Mirror preview option
        self.chk_mirror = self._i18n_text(tk.Checkbutton(
            controls_bar,
            variable=self.preview_mirror_x_var,
            command=self.preview,
        ), "Mirror preview")
        self.chk_mirror.pack(side="left", padx=(12, 4))

        # Display controls for preview
        self.fill_profile_var = tk.BooleanVar(value=self.params.fill_profile)
        fill_chk = self._i18n_text(tk.Checkbutton(
            controls_bar,
            variable=self.fill_profile_var,
            command=lambda: self._schedule_preview(),
        ), "Fill profile")
        fill_chk.pack(side="left", padx=(12, 4))

        self.color_swatch = tk.Label(controls_bar, width=3, bg=self.params.profile_fill_color, relief="sunken", bd=2)
        self.color_swatch.pack(side="left", padx=(0, 8))
        self.color_swatch.bind("<Button-1>", lambda e: self._choose_fill_color())

        self.fill_profile_half_var = tk.BooleanVar(value=self.params.fill_profile_half)
        fill_half_chk = self._i18n_text(tk.Checkbutton(
            controls_bar,
            variable=self.fill_profile_half_var,
            command=lambda: self._schedule_preview(),
        ), "50%")
        fill_half_chk.pack(side="left", padx=(0, 8))

        self.show_background_var = tk.BooleanVar(value=True)
        bg_btn = tk.Button(controls_bar, width=8, command=self._toggle_background_visibility)
        bg_btn.pack(side="left", padx=(0, 4))
        self.bg_btn = bg_btn
        self.bg_btn.config(text=self._tr(self._bg_btn_key()))

        self.bg_color_swatch = tk.Label(controls_bar, width=3, bg=self.params.background_image_color, relief="sunken", bd=2)
        self.bg_color_swatch.pack(side="left", padx=(0, 4))
        self.bg_color_swatch.bind("<Button-1>", lambda e: self._choose_background_color())
        
        delete_bg_btn = self._i18n_text(tk.Button(controls_bar, width=6, command=self._delete_background), "Del BG")
        delete_bg_btn.pack(side="left", padx=(0, 0))

        self.canvas = tk.Canvas(right, width=600, height=350, bg="white", relief="sunken", bd=1)
        self.canvas.pack(fill="both", expand=True, pady=(0, 4))
        self.canvas.bind("<ButtonPress-1>", self._on_canvas_pan_start)
        self.canvas.bind("<B1-Motion>", self._on_canvas_pan_move)
        self.canvas.bind("<ButtonRelease-1>", self._on_canvas_pan_end)
        # Explicit modifier bindings for segment split in SELECT mode
        self.canvas.bind("<Control-ButtonRelease-1>", self._on_canvas_pan_end_ctrl_split)
        self.canvas.bind("<Command-ButtonRelease-1>", self._on_canvas_pan_end_ctrl_split)
        self.canvas.bind("<MouseWheel>", self._on_canvas_zoom)
        self.canvas.bind("<Button-4>", self._on_canvas_zoom)
        self.canvas.bind("<Button-5>", self._on_canvas_zoom)
        self.canvas.bind("<Double-Button-1>", lambda _e: self.reset_view())
        self.canvas.bind("<Motion>", self._on_canvas_motion)
        self.canvas.bind("<Leave>", self._on_canvas_leave)
        self.canvas.bind("<FocusIn>", lambda _e: self._update_mode_hint())
        # Context menu for mode selection: Button-3 (Linux/Windows), Button-2 and Control-Button-1 (macOS)
        self.canvas.bind("<Button-3>", self._show_canvas_context_menu)
        self.canvas.bind("<Button-2>", self._show_canvas_context_menu)
        self.canvas.bind("<Control-Button-1>", self._show_canvas_context_menu)
        
        # Status message area below canvas (explicit colors for dark mode visibility)
        self.status_label = tk.Label(right, text="", fg="#FFD966", bg="#333333", 
                                      anchor="w", relief="flat", font=("TkDefaultFont", 12))
        self.status_label.pack(fill="x", pady=(0, 8))
        self.status_clear_timer = None

        # Output section below preview
        self.output_label = self._i18n_text(tk.Label(right, font=("TkDefaultFont", 10, "bold")), "OUTPUT")
        self.output_label.pack(anchor="w", pady=(8, 4))
        
        self.output_container = tk.Frame(right)
        self.output_container.pack(fill="both", expand=True)
        
        # G-code text area (2/3 of horizontal space)
        gcode_frame = tk.Frame(self.output_container)
        gcode_frame.pack(side="left", fill="both", expand=True, padx=(0, 4))
        self.output_text = scrolledtext.ScrolledText(gcode_frame, width=60, height=12, relief="sunken", bd=1)
        self.output_text.pack(fill="both", expand=True)

        # Actions group (1/3 of horizontal space)
        actions_frame = self._i18n_text(tk.LabelFrame(self.output_container, font=("TkDefaultFont", 9, "bold"), padx=10, pady=10), "Actions")
        actions_frame.pack(side="left", fill="y", padx=(4, 0))
        self._i18n_text(tk.Button(actions_frame, command=self.preview, width=20), "Preview").pack(pady=4)
        self._i18n_text(tk.Button(actions_frame, command=self.generate, width=20), "Generate G-code").pack(pady=4)
        self._i18n_text(tk.Button(actions_frame, command=self.save_gcode, width=20), "Save G-code").pack(pady=4)
        self._i18n_text(tk.Button(actions_frame, command=self.write_stdout, width=20), "Send to LinuxCNC and quit").pack(pady=4)
        
        # Sync initial mode button state
        self._set_draw_mode(self.draw_mode)

    # --- i18n helpers (see TRANSLATIONS for scope/limitations) ---

    def _tr(self, key: str) -> str:
        """Translate a UI string per the current language selector.

        Falls back to `key` itself if no translation is registered (e.g. for
        dropdown values, which are intentionally not in TRANSLATIONS).
        """
        entry = TRANSLATIONS.get(key)
        if entry is None:
            return key
        return entry.get(self.lang_var.get(), key)

    def _i18n_text(self, widget, key: str):
        """Set `widget`'s text to the translation of `key` (current language)
        and register it so `_apply_language` updates it on language switch.

        Works for any widget supporting `.config(text=...)`: Label, Button,
        Checkbutton, LabelFrame.
        """
        widget.config(text=self._tr(key))
        self._i18n_registry.append((widget, key))
        return widget

    def _i18n_tab(self, notebook, tab, key: str) -> None:
        """Register a Notebook tab's text for language updates."""
        notebook.tab(tab, text=self._tr(key))
        self._i18n_tabs.append((notebook, tab, key))

    def _i18n_menu_entry(self, menu, index: int, key: str) -> None:
        """Register a Menu entry's label for language updates."""
        menu.entryconfig(index, label=self._tr(key))
        self._i18n_menu.append((menu, index, key))

    def _bg_btn_key(self) -> str:
        """Translation key for the background-toggle button, based on state."""
        return "Hide Background" if self.show_background_var.get() else "Show Background"

    def _max_canvas_btn_key(self) -> str:
        """Translation key for the max-canvas toggle button, based on state."""
        return "Restore UI" if getattr(self, "_canvas_max", False) else "Max canvas"

    def _apply_language(self) -> None:
        """Re-apply all registered translations after the language selector changes."""
        for widget, key in self._i18n_registry:
            try:
                widget.config(text=self._tr(key))
            except tk.TclError:
                pass
        for notebook, tab, key in self._i18n_tabs:
            try:
                notebook.tab(tab, text=self._tr(key))
            except tk.TclError:
                pass
        for menu, index, key in self._i18n_menu:
            try:
                menu.entryconfig(index, label=self._tr(key))
            except tk.TclError:
                pass
        if hasattr(self, "bg_btn"):
            self.bg_btn.config(text=self._tr(self._bg_btn_key()))
        if hasattr(self, "max_canvas_btn"):
            self.max_canvas_btn.config(text=self._tr(self._max_canvas_btn_key()))

    def show_status(self, message: str, color: str = "red") -> None:
        """Display a status message that auto-clears after 5 seconds.
        Color defaults to red for errors; pass a lighter color for info.
        Also logs all messages to console for debugging.
        """
        import sys
        
        # Log to console based on severity
        # Check message content to distinguish success from warnings (both use yellow #FFD966)
        if color in ("green", "#00ff00") or any(keyword in message.lower() for keyword in 
                                                  ["generated and saved", "loaded in axis", "removed", "applied", "imported"]):
            print(f"[SUCCESS] {message}")
        elif color in ("red", "#ff0000"):
            print(f"[ERROR] {message}", file=sys.stderr)
        elif "must be" in message.lower() or "error" in message.lower() or "failed" in message.lower():
            print(f"[WARNING] {message}", file=sys.stderr)
        else:
            print(f"[INFO] {message}")
        
        self.status_label.config(text=message, fg=color)
        # Cancel any existing timer
        if self.status_clear_timer:
            self.root.after_cancel(self.status_clear_timer)
        # Set new timer to clear after 5 seconds
        self.status_clear_timer = self.root.after(5000, lambda: self.status_label.config(text=""))

    def _load_sample_points(self) -> None:
        # Riegler Euro Quick Coupling Nipple NW 7.2, G1/4" AG
        # Total length: 32mm, SW17 hex
        self.points = [
            Point(0.0, 7.2),           # Face off - NW 7.2mm inner diameter
            Point(-3.0, 7.2),          # Inner bore section
            Point(-4.0, 10.0, 0.5),    # Taper to sealing diameter
            Point(-8.0, 10.0),         # Sealing section (10mm OD)
            Point(-9.0, 11.5, 0.5),    # Step to groove
            Point(-11.0, 11.5),        # O-ring groove outer
            Point(-11.5, 10.5),        # Groove depth
            Point(-12.5, 10.5),        # Groove width
            Point(-13.0, 11.5),        # Back to outer
            Point(-15.0, 11.5),        # Body section
            Point(-16.0, 13.0, 0.5),   # Step to hex section
            Point(-26.0, 13.0),        # Hex section (SW17 = 13mm radius approx)
            Point(-27.0, 13.35, 0.3),  # G1/4 thread major diameter start
            Point(-32.0, 13.35),       # G1/4" external thread (13.157mm major)
        ]
        self._refresh_points_list()
        self.preview()

    def add_segment(self) -> None:
        try:
            insert_idx = len(self.points)
            sel = self.seg_list.curselection()
            if sel:
                insert_idx = sel[0] + 1
                # When adding after a segment, keep Z/X defaults but clear carried radius
                self.in_r.delete(0, tk.END)
                self.in_corner_val.delete(0, tk.END)
                self.corner_type.set("none")
            point = self._read_segment_inputs(insert_idx)
            self.points.insert(insert_idx, point)
            self._refresh_points_list(select_idx=insert_idx)
            self._set_input_fields(point)
            self.preview()
        except Exception as exc:
            self.show_status(f"Segment error: {exc}")

    def update_selected(self) -> None:
        selection = self.seg_list.curselection()
        if not selection:
            return
        idx = selection[0]
        try:
            # Read Z/X values
            cur = self.points[idx]
            z_val = float(self.in_z.get()) if self.in_z.get().strip() else cur.z
            x_val = float(self.in_x.get()) if self.in_x.get().strip() else cur.x
            
            # Handle corner: explicit clearing when "none" or blank value
            ct_ui = self.corner_type.get().strip()
            cv_txt = self.in_corner_val.get().strip()
            
            # Normalize corner type to "A", "C", or None
            ct_norm = None
            if ct_ui.upper().startswith("A"):
                ct_norm = "A"
            elif ct_ui.upper().startswith("C"):
                ct_norm = "C"
            
            # Determine corner state from UI
            if (ct_norm is None) or (cv_txt == ""):
                # User wants no corner: clear it
                corner_type = None
                corner_value = None
            else:
                # User specified A or C with a value
                try:
                    cv = float(cv_txt)
                except ValueError:
                    raise ValueError("Corner value must be a number.")
                if cv <= 0:
                    # Invalid corner value: treat as "no corner"
                    corner_type = None
                    corner_value = None
                else:
                    corner_type = ct_norm
                    corner_value = cv
            
            # Cannot have corner on first point
            if idx == 0:
                corner_type = None
                corner_value = None
            
            # Update point with new values, preserving incoming arc radius
            self.points[idx] = Point(z_val, x_val, cur.r, corner_type, corner_value)
            
            # Handle R as segment radius to next point
            r_text = self.in_r.get().strip()
            if idx + 1 < len(self.points):
                if r_text:
                    try:
                        r_val = float(r_text)
                    except ValueError:
                        raise ValueError("Radius must be a number.")
                    # Validate against chord from current to next
                    p0 = self.points[idx]
                    p1 = self.points[idx + 1]
                    chord = math.hypot(p1.z - p0.z, p1.x - p0.x)
                    if chord < 1e-9:
                        raise ValueError("Invalid radius for segment to next point (zero-length segment).")
                    min_radius = chord / 2.0
                    if abs(r_val) < min_radius:
                        # Auto-correct to minimum radius
                        r_val = round(min_radius, 3)
                        self.in_r.delete(0, tk.END)
                        self.in_r.insert(0, str(r_val))
                        self.show_status(f"Radius too small, auto-corrected to minimum: {r_val:.3f}", color="#FFD966")
                    self.points[idx + 1].r = r_val
                else:
                    # Empty R clears segment radius
                    self.points[idx + 1].r = 0.0
            self._refresh_points_list(select_idx=idx)
            self.preview()
        except Exception as exc:
            self.show_status(f"Update error: {exc}")

    def remove_selected(self) -> None:
        selection = self.seg_list.curselection()
        if not selection:
            return
        idx = selection[0]
        if 0 <= idx < len(self.points):
            removed = self.points.pop(idx)
            self.delete_undo_stack.append((idx, removed))
            self._refresh_points_list()
            self.preview()

    def _refresh_points_list(self, select_idx: int | None = None) -> None:
        """Refresh the list. Display segment radius mapped to next point for clarity."""
        self.seg_list.delete(0, tk.END)
        for i, pt in enumerate(self.points):
            # Show R for the segment from this point to the next point
            r_next = self.points[i + 1].r if i + 1 < len(self.points) else 0.0
            label = f"Z={pt.z:.3f}, X={pt.x:.3f}, R→{r_next:.3f}"
            if pt.corner_type and pt.corner_value is not None:
                label += f" {pt.corner_type.upper()}{pt.corner_value:.2f}"
            self.seg_list.insert(tk.END, label)
        if select_idx is not None and 0 <= select_idx < len(self.points):
            self.seg_list.selection_set(select_idx)
            self.seg_list.activate(select_idx)
            if not self._suppress_listbox_scroll:
                self.seg_list.see(select_idx)

    def _clear_segment_inputs(self) -> None:
        self.in_z.delete(0, tk.END)
        self.in_x.delete(0, tk.END)
        self.in_r.delete(0, tk.END)
        self.in_corner_val.delete(0, tk.END)
        self.corner_type.set("none")

    def _set_input_fields(self, pt: Point) -> None:
        """Legacy setter kept for compatibility if called directly."""
        self._set_input_fields_for_index(self.points.index(pt))

    def _set_input_fields_for_index(self, idx: int) -> None:
        """Set inputs for the selected point index.
        Z/X and corner apply to the point at idx; R applies to the segment to idx+1.
        """
        self.in_z.delete(0, tk.END)
        self.in_x.delete(0, tk.END)
        self.in_r.delete(0, tk.END)
        self.in_corner_val.delete(0, tk.END)
        pt = self.points[idx]
        self.in_z.insert(0, f"{pt.z:.3f}")
        self.in_x.insert(0, f"{pt.x:.3f}")
        # Radius applies to next segment
        if idx + 1 < len(self.points):
            r_next = self.points[idx + 1].r
            if abs(r_next) > 1e-9:
                self.in_r.insert(0, f"{r_next:.2f}")
            else:
                self.in_r.insert(0, "")
        else:
            self.in_r.insert(0, "")
        if pt.corner_type and pt.corner_value is not None:
            self.corner_type.set(f"{pt.corner_type.upper()} (fillet)" if pt.corner_type.upper() == "A" else "C (chamfer)")
            self.in_corner_val.insert(0, f"{pt.corner_value:.2f}")
        else:
            self.corner_type.set("none")

    def _read_segment_inputs(self, idx: int) -> Point:
        if idx == 0 and (not self.in_z.get().strip() or not self.in_x.get().strip()):
            raise ValueError("First point needs Z and X.")
        prev_z, prev_x = (0.0, 0.0)
        if idx > 0 and idx - 1 < len(self.points):
            prev_z, prev_x = self.points[idx - 1].z, self.points[idx - 1].x
        elif self.points:
            prev_z, prev_x = self.points[-1].z, self.points[-1].x
        z_val = float(self.in_z.get()) if self.in_z.get().strip() else prev_z
        x_val = float(self.in_x.get()) if self.in_x.get().strip() else prev_x
        r_val = float(self.in_r.get()) if self.in_r.get().strip() else 0.0
        is_valid, corrected = self._validate_radius_field(idx, z_val, x_val, r_val)
        if not is_valid:
            raise ValueError("Invalid radius (must be >= half the chord to previous point).")
        if corrected is not None:
            r_val = corrected
            self.in_r.delete(0, tk.END)
            self.in_r.insert(0, str(r_val))
            self.show_status(f"Radius too small, auto-corrected to minimum: {r_val:.3f}", color="#FFD966")
        corner_sel = self.corner_type.get().strip().upper()
        corner_word = None
        if corner_sel.startswith("A"):
            corner_word = "A"
        elif corner_sel.startswith("C"):
            corner_word = "C"
        corner_val = None
        if corner_word:
            text = self.in_corner_val.get().strip()
            if not text:
                raise ValueError("Corner value required when A/C selected.")
            corner_val = float(text)
            if corner_val <= 0:
                raise ValueError("Corner value must be positive.")
            if idx == 0:
                # Corner applies to the move into this point; first point cannot have one
                corner_word = None
                corner_val = None
        elif self.in_corner_val.get().strip():
            # Value provided but no type selected
            raise ValueError("Select A (fillet) or C (chamfer) when entering a corner value.")
        return Point(z_val, x_val, r_val, corner_word, corner_val)

    def _read_segment_inputs_for_update(self, idx: int) -> Point:
        """Read inputs for updating an existing point; blanks keep current values."""
        if idx < 0 or idx >= len(self.points):
            raise ValueError("Invalid index.")
        cur = self.points[idx]
        z_val = float(self.in_z.get()) if self.in_z.get().strip() else cur.z
        x_val = float(self.in_x.get()) if self.in_x.get().strip() else cur.x
        r_val = float(self.in_r.get()) if self.in_r.get().strip() else 0.0
        is_valid, corrected = self._validate_radius_field(idx, z_val, x_val, r_val)
        if not is_valid:
            raise ValueError("Invalid radius (must be >= half the chord to previous point).")
        if corrected is not None:
            r_val = corrected
            self.in_r.delete(0, tk.END)
            self.in_r.insert(0, str(r_val))
            self.show_status(f"Radius too small, auto-corrected to minimum: {r_val:.3f}", color="#FFD966")

        corner_sel = self.corner_type.get().strip().upper()
        corner_word = cur.corner_type
        corner_val = cur.corner_value
        if corner_sel.startswith("A"):
            corner_word = "A"
        elif corner_sel.startswith("C"):
            corner_word = "C"

        text = self.in_corner_val.get().strip()
        if corner_word:
            if text:
                corner_val = float(text)
                if corner_val <= 0:
                    raise ValueError("Corner value must be positive.")
            elif corner_val is None:
                raise ValueError("Corner value required when A/C selected.")
            if idx == 0:
                corner_word = None
                corner_val = None
        else:
            if text:
                raise ValueError("Select A (fillet) or C (chamfer) when entering a corner value.")

        return Point(z_val, x_val, r_val, corner_word, corner_val)

    def _on_select_segment(self, _event=None) -> None:
        self._suppress_listbox_scroll = True
        try:
            selection = self.seg_list.curselection()
            if not selection:
                self.preview()
                return
            idx = selection[0]
            if 0 <= idx < len(self.points):
                self._set_input_fields_for_index(idx)
            # Update highlight immediately when selection changes
            self.preview()
        finally:
            self._suppress_listbox_scroll = False

    def _on_drag_start(self, event) -> None:
        idx = self.seg_list.nearest(event.y)
        if 0 <= idx < len(self.points):
            self.drag_idx = idx
            self.seg_list.selection_clear(0, tk.END)
            self.seg_list.selection_set(idx)

    def _on_drag_motion(self, event) -> None:
        if self.drag_idx is None:
            return
        target = self.seg_list.nearest(event.y)
        if target == self.drag_idx or not (0 <= target < len(self.points)):
            return
        # Reorder points
        item = self.points.pop(self.drag_idx)
        self.points.insert(target, item)
        self.drag_idx = target
        self._refresh_points_list(select_idx=target)
        self.preview()

    def _on_drag_end(self, _event=None) -> None:
        self.drag_idx = None
        self._commit_segment_edit()

    def _commit_segment_edit(self) -> None:
        """Apply edits to the selected segment without requiring button press."""
        if getattr(self, "_suppress_commit", False):
            return
        sel = self.seg_list.curselection()
        if not sel:
            return
        try:
            self.update_selected()
        except Exception:
            # update_selected already surfaces errors; keep focus flow intact
            pass

    def _end_suppressed_commit_after_idle(self) -> None:
        """Re-enable commit suppression after Tk has processed pending events."""
        self.root.after_idle(lambda: setattr(self, "_suppress_commit", False))

    def undo_delete(self) -> None:
        """Restore the most recently deleted segment."""
        if not self.delete_undo_stack:
            messagebox.showinfo("Undo", "Nothing to undo.")
            return
        idx, point = self.delete_undo_stack.pop()
        if idx < 0:
            idx = 0
        if idx > len(self.points):
            idx = len(self.points)
        self.points.insert(idx, point)
        self._refresh_points_list(select_idx=idx)
        self._set_input_fields(point)
        self.preview()

    def _on_delete_key(self, event=None) -> None:
        """Handle Delete/Backspace key press on segment list."""
        selection = self.seg_list.curselection()
        if not selection:
            return
        idx = selection[0]
        if 0 <= idx < len(self.points):
            removed = self.points.pop(idx)
            self.delete_undo_stack.append((idx, removed))
            self._refresh_points_list()
            
            # Select next segment, or previous if at end
            if len(self.points) > 0:
                new_idx = idx if idx < len(self.points) else len(self.points) - 1
                self.seg_list.selection_set(new_idx)
                self.seg_list.activate(new_idx)
                self._set_input_fields(self.points[new_idx])
            else:
                self._clear_segment_inputs()
            
            self.preview()
        return "break"  # Prevent default behavior

    def delete_all(self) -> None:
        """Delete all segments and start clean."""
        self.points.clear()
        self.delete_undo_stack.clear()
        self.selected_point_idx = None
        self._refresh_points_list()
        self._clear_segment_inputs()
        self.preview()

    def export_dxf(self) -> None:
        """Export current segments to a simple DXF LWPOLYLINE."""
        if len(self.points) < 2:
            self.show_status("Need at least two points to export.")
            return
        try:
            import ezdxf  # type: ignore
        except Exception:
            self.show_status("Please install ezdxf (e.g. pip install ezdxf).")
            return
        path = filedialog.asksaveasfilename(
            title="Save DXF",
            defaultextension=".dxf",
            filetypes=[("DXF files", "*.dxf"), ("All files", "*.*")],
        )
        if not path:
            return

        def radius_to_bulge(p0: Tuple[float, float], p1: Tuple[float, float], r: float) -> float:
            """Convert generator-style radius to DXF bulge (tan(theta/4)); CCW positive."""
            if abs(r) < 1e-9:
                return 0.0
            dx = p1[0] - p0[0]
            dy = p1[1] - p0[1]
            chord = math.hypot(dx, dy)
            if chord < 1e-9 or abs(r) < chord / 2.0:
                return 0.0
            theta = 2 * math.asin(chord / (2 * abs(r)))  # positive angle
            bulge = math.tan(theta / 4.0)
            # Our convention: +R => CW, DXF bulge: + => CCW
            return -bulge if r > 0 else bulge

        try:
            pts = list(self.points)
            dxf_pts = []
            for i in range(len(pts) - 1):
                p0 = pts[i]
                p1 = pts[i + 1]
                bulge = radius_to_bulge((p0.z, p0.x), (p1.z, p1.x), p1.r)
                dxf_pts.append((p0.z, p0.x, bulge))
            # Last vertex, bulge zero
            dxf_pts.append((pts[-1].z, pts[-1].x, 0.0))

            doc = ezdxf.new("R2010")
            msp = doc.modelspace()
            msp.add_lwpolyline(dxf_pts, format="xyb", dxfattribs={"layer": "PROFILE"})
            doc.saveas(path)
            messagebox.showinfo("Export DXF", f"Saved {path}")
        except Exception as exc:
            self.show_status(f"Export DXF error: {exc}")

    def zoom_in(self) -> None:
        self.zoom_factor = min(self.zoom_factor * 1.2, 10.0)
        self.preview()

    def zoom_out(self) -> None:
        self.zoom_factor = max(self.zoom_factor / 1.2, 0.1)
        self.preview()

    def reset_view(self) -> None:
        """Reset zoom and pan to defaults."""
        self.zoom_factor = 1.0
        self.pan_z_offset = 0.0
        self.pan_x_offset = 0.0
        self.preview()

    def _toggle_max_canvas(self) -> None:
        if getattr(self, "_canvas_max", False):
            self._exit_max_canvas()
        else:
            self._enter_max_canvas()

    def _hide_pack(self, widget) -> None:
        if widget is None:
            return
        try:
            mgr = widget.winfo_manager()
        except Exception:
            mgr = ""
        if mgr != "pack":
            return
        if widget in getattr(self, "_pack_restore", {}):
            return
        info = widget.pack_info()
        self._pack_restore[widget] = info
        widget.pack_forget()

    def _restore_pack(self, widget) -> None:
        info = getattr(self, "_pack_restore", {}).pop(widget, None)
        if not info:
            return
        info = {k: v for k, v in info.items() if k != "in"}
        widget.pack(**info)

    def _enter_max_canvas(self) -> None:
        if getattr(self, "_canvas_max", False):
            return
        if not hasattr(self, "_pack_restore"):
            self._pack_restore = {}
        # Store original window geometry before maximizing
        self._window_original_geometry = self.root.geometry()
        # Maximize the window to screen size
        screen_width = self.root.winfo_screenwidth()
        screen_height = self.root.winfo_screenheight()
        self.root.geometry(f"{screen_width}x{screen_height}+0+0")
        # Hide only left panel and output section; keep controls visible
        targets = [
            getattr(self, "left_panel", None),
            getattr(self, "output_label", None),
            getattr(self, "output_container", None),
        ]
        for w in targets:
            self._hide_pack(w)
        # Canvas gets re-packed with expand to fill available space
        if self.canvas.winfo_manager() == "pack":
            if self.canvas not in self._pack_restore:
                self._pack_restore[self.canvas] = self.canvas.pack_info()
            self.canvas.pack_forget()
        self.canvas.pack(in_=self.right_panel, fill="both", expand=True, pady=(0, 4))
        self._canvas_max = True
        if hasattr(self, "max_canvas_btn"):
            self.max_canvas_btn.config(text=self._tr("Restore UI"))
        self.root.update_idletasks()
        self.preview()

    def _exit_max_canvas(self) -> None:
        if not getattr(self, "_canvas_max", False):
            return
        # Restore widgets in original layout order
        for w in [
            getattr(self, "left_panel", None),
            getattr(self, "preview_label", None),
            getattr(self, "zoom_bar", None),
            getattr(self, "controls_bar", None),
            self.canvas,
            getattr(self, "status_label", None),
            getattr(self, "output_label", None),
            getattr(self, "output_container", None),
        ]:
            self._restore_pack(w)
        # Restore window to original geometry
        if hasattr(self, "_window_original_geometry"):
            self.root.geometry(self._window_original_geometry)
        self._canvas_max = False
        if hasattr(self, "max_canvas_btn"):
            self.max_canvas_btn.config(text=self._tr("Max canvas"))
        self.root.update_idletasks()
        self.preview()

    def _set_draw_mode(self, mode: str) -> None:
        """Set the drawing mode and update UI."""
        self.draw_mode = mode
        # Sync mode selector if it exists
        if hasattr(self, 'mode_var'):
            self.mode_var.set(mode)
        # Clear point selection when switching to LINES
        if mode == "LINES":
            self.selected_point_idx = None
        # Update cursor for modes
        if mode == "SELECT":
            cursor = "arrow"
        elif mode == "SPLIT":
            cursor = "crosshair"
        else:
            cursor = "hand2" if mode in ("RADIUS", "CHAMFER") else "arrow"
        self.canvas.config(cursor=cursor)
        # Update status hint
        self._update_mode_hint()

    def _update_mode_hint(self) -> None:
        """Display a mode-dependent hint in the status area."""
        hints = {
            "LINES": "LINES mode: Click to add points. Select a segment in the list to insert after it. Hold Shift to constrain to horizontal/vertical/45°.",
            "RADIUS": "RADIUS mode: Click near an interior point to add a fillet.",
            "CHAMFER": "CHAMFER mode: Click near an interior point to add a chamfer.",
            "SELECT": "SELECT mode: Drag to move; Shift constrains.",
            "SPLIT": "SPLIT: click near a straight segment to insert a point (split), geometry unchanged.",
        }
        hint = hints.get(self.draw_mode, "")
        self.status_label.config(text=hint, fg="#FFD966")

    def _on_canvas_pan_start(self, event) -> None:
        """Begin panning with mouse drag."""
        # SELECT mode: check for point or segment drag
        if self.draw_mode == "SELECT" and hasattr(self, "_view_cache") and self._view_cache:
            pt_hit = self.hit_test_point(event.x, event.y, tol_px=6.0)
            seg_hit = None if pt_hit is not None else self.hit_test_segment(event.x, event.y, tol_px=8.0)
            if pt_hit is not None or seg_hit is not None:
                try:
                    mz, mx = self._canvas_to_model_lathe(event.x, event.y)
                except Exception:
                    mz, mx = None, None
                self._drag_kind = "POINT" if pt_hit is not None else "SEGMENT"
                self._drag_index = pt_hit if pt_hit is not None else seg_hit
                self._drag_start_model = (mz, mx)
                self._drag_orig_points = []
                if self._drag_kind == "POINT" and self._drag_index is not None and self._drag_index < len(self.points):
                    p = self.points[self._drag_index]
                    self._drag_orig_points.append((self._drag_index, p.z, p.x))
                    self.selected_point_idx = self._drag_index
                    self._select_list_index(self._drag_index)
                elif self._drag_kind == "SEGMENT" and self._drag_index is not None and self._drag_index + 1 < len(self.points):
                    p0 = self.points[self._drag_index]
                    p1 = self.points[self._drag_index + 1]
                    self._drag_orig_points.append((self._drag_index, p0.z, p0.x))
                    self._drag_orig_points.append((self._drag_index + 1, p1.z, p1.x))
                    self.selected_point_idx = None
                    self._select_list_index(self._drag_index)
                self._pan_start = {"x": event.x, "y": event.y}
                self._pan_moved = False
                return

        self._drag_kind = None
        self._drag_index = None
        self._drag_start_model = None
        self._drag_orig_points = []
        self._pan_start = {
            "x": event.x,
            "y": event.y,
            "pan_z": self.pan_z_offset,
            "pan_x": self.pan_x_offset,
        }
        # Track whether user is intending a click (no movement)
        self._pan_moved = False

    def _on_canvas_pan_move(self, event) -> None:
        if not self._pan_start or not hasattr(self, "_view_cache"):
            return
        # Handle SELECT drag of point/segment
        if self.draw_mode == "SELECT" and self._drag_kind and self._drag_start_model:
            self._pan_moved = True
            try:
                cur_z, cur_x = self._canvas_to_model_lathe(event.x, event.y)
            except Exception:
                return
            dz = cur_z - self._drag_start_model[0]
            dx = cur_x - self._drag_start_model[1]
            # Shift constraint
            if hasattr(event, "state") and bool(event.state & 0x0001):
                if abs(dz) >= abs(dx):
                    dx = 0.0
                else:
                    dz = 0.0
            if self._drag_kind == "POINT" and self._drag_index is not None and self._drag_index < len(self.points):
                orig = next(((i, oz, ox) for (i, oz, ox) in self._drag_orig_points if i == self._drag_index), None)
                if orig:
                    _, oz, ox = orig
                    self.points[self._drag_index] = Point(oz + dz, ox + dx, self.points[self._drag_index].r, self.points[self._drag_index].corner_type, self.points[self._drag_index].corner_value)
            elif self._drag_kind == "SEGMENT" and self._drag_index is not None and self._drag_index + 1 < len(self.points):
                for idx, oz, ox in self._drag_orig_points:
                    self.points[idx] = Point(oz + dz, ox + dx, self.points[idx].r, self.points[idx].corner_type, self.points[idx].corner_value)
            self.preview()
            # Keep tooltip visible with live coordinates while dragging
            self._on_canvas_motion(event)
            return

        self._pan_moved = True
        view = getattr(self, "_view_cache", None)
        if not view:
            return
        scale = view["scale"]
        dz = (event.x - self._pan_start["x"]) / scale
        dx = (event.y - self._pan_start["y"]) / scale
        # Drag right -> move view right (reduce min_z); drag down -> move view down (increase min_x)
        self.pan_z_offset = self._pan_start["pan_z"] - dz
        self.pan_x_offset = self._pan_start["pan_x"] + dx
        self.preview()

    def _on_canvas_pan_end_ctrl_split(self, event) -> None:
        # Wrapper to force split behavior on Ctrl/Command release
        self._on_canvas_pan_end(event, force_split=True)

    def _on_canvas_pan_end(self, event=None, force_split: bool = False) -> None:
        # Handle end of SELECT dragging
        if self._drag_kind is not None:
            start_drag_kind = self._drag_kind
            drag_idx = self._drag_index
            self._drag_kind = None
            self._drag_index = None
            self._drag_start_model = None
            self._pan_start = None

            def revert_drag():
                for idx, oz, ox in self._drag_orig_points:
                    if 0 <= idx < len(self.points):
                        p = self.points[idx]
                        self.points[idx] = Point(oz, ox, p.r, p.corner_type, p.corner_value)

            valid = True
            refresh_idx: int | None = None
            # Determine which axis must be monotonic for the current cycle.
            # Read LIVE from the UI entry — self.params.cycle is only set at
            # __init__ (default "G71") and on session load; it is NOT kept in
            # sync with the Cycle dropdown during normal editing.
            try:
                cycle_upper = self.entries["cycle"].get().strip().upper() or "G71"
            except Exception:
                cycle_upper = "G71"
            is_g72 = cycle_upper == "G72"

            if start_drag_kind == "POINT" and drag_idx is not None and 0 <= drag_idx < len(self.points):
                # Monotonic check around the moved point
                if is_g72:
                    # G72 (facing): X must be non-increasing
                    if drag_idx > 0:
                        if self.points[drag_idx - 1].x < self.points[drag_idx].x:
                            valid = False
                    if drag_idx + 1 < len(self.points):
                        if self.points[drag_idx].x < self.points[drag_idx + 1].x:
                            valid = False
                else:
                    # G71 (turning): Z must be non-increasing
                    if drag_idx > 0:
                        if self.points[drag_idx - 1].z < self.points[drag_idx].z:
                            valid = False
                    if drag_idx + 1 < len(self.points):
                        if self.points[drag_idx].z < self.points[drag_idx + 1].z:
                            valid = False
                if valid:
                    self.selected_point_idx = drag_idx
                    refresh_idx = drag_idx
                else:
                    revert_drag()
                    axis = "X" if is_g72 else "Z"
                    self.show_status(f"{axis} must be monotonic (non-increasing) for {cycle_upper}", color="#FFD966")
            elif start_drag_kind == "SEGMENT" and drag_idx is not None and drag_idx + 1 < len(self.points):
                # Check neighbors around the segment endpoints
                if is_g72:
                    def pair_ok(i: int) -> bool:
                        return self.points[i].x >= self.points[i + 1].x
                else:
                    def pair_ok(i: int) -> bool:
                        return self.points[i].z >= self.points[i + 1].z

                for j in (drag_idx - 1, drag_idx, drag_idx + 1):
                    if 0 <= j < len(self.points) - 1:
                        if not pair_ok(j):
                            valid = False
                            break
                if not valid:
                    revert_drag()
                    axis = "X" if is_g72 else "Z"
                    self.show_status(f"{axis} must be monotonic (non-increasing) for {cycle_upper}", color="#FFD966")
                else:
                    self.selected_point_idx = None
                    refresh_idx = drag_idx

            if valid and refresh_idx is not None:
                self._refresh_points_list(select_idx=refresh_idx)
                self._select_list_index(refresh_idx)
            self._drag_orig_points = []
            self.preview()
            return

        # If no movement occurred, treat as a click in the canvas
        if self._pan_start and not getattr(self, "_pan_moved", False):
            # Clear _pan_start early to avoid stuck state
            start = self._pan_start
            self._pan_start = None
            try:
                z, x = self._canvas_to_model(start["x"], start["y"])
                # Snap to background/DXF only (NOT to self.points for placement)
                cand_pts: List[Point] = []
                if self.background_points:
                    cand_pts.extend(self.background_points)
                if self.dxf_preview_points:
                    cand_pts.extend(self.dxf_preview_points)
                # Only snap if nearest candidate is within tolerance
                if cand_pts and hasattr(self, "_view_cache") and self._view_cache:
                    snap_px = 10.0
                    snap_mm = snap_px / self._view_cache["scale"]
                    sz, sx = self._nearest_point(z, x, cand_pts)
                    if math.hypot(sz - z, sx - x) <= snap_mm:
                        z, x = sz, sx
                # Round to 0.01 mm
                nz = round(z, 2)
                nx = round(x, 2)

                # SPLIT mode: split nearest straight segment by inserting a point
                if self.draw_mode == "SPLIT":
                    seg_proj = self.hit_test_segment_with_projection(start["x"], start["y"], tol_px=8.0)
                    if seg_proj is not None:
                        seg_idx, z_proj, x_proj = seg_proj
                        # Check if segment is arc (r != 0)
                        if self.points[seg_idx + 1].r != 0.0:
                            self.show_status("Split only supported on straight segments (arc split not implemented).", color="#FFD966")
                            return
                        # Validate monotonic Z
                        nz_split = round(z_proj, 2)
                        nx_split = round(x_proj, 2)
                        prev_z = self.points[seg_idx].z
                        next_z = self.points[seg_idx + 1].z
                        if not (prev_z >= nz_split >= next_z):
                            self.show_status("Z must be monotonic (non-increasing) for G71/G72", color="#FFD966")
                            return
                        # Insert new point
                        self._suppress_commit = True
                        try:
                            insert_idx = seg_idx + 1
                            self.points.insert(insert_idx, Point(float(nz_split), float(nx_split), 0.0))
                            self._refresh_points_list(select_idx=insert_idx)
                            self.seg_list.selection_set(insert_idx)
                            self.seg_list.activate(insert_idx)
                            self._set_input_fields_for_index(insert_idx)
                            self.selected_point_idx = insert_idx
                        finally:
                            self._suppress_commit = False
                        self.preview()
                        self.canvas.focus_set()
                        self._end_suppressed_commit_after_idle()
                        return
                    return

                # SELECT mode: point/segment selection (no split here)
                if self.draw_mode == "SELECT":
                    # Normal click: select point or segment
                    pt_hit = self.hit_test_point(start["x"], start["y"], tol_px=6.0)
                    if pt_hit is not None:
                        self.selected_point_idx = pt_hit
                        self._select_list_index(pt_hit)
                        self.preview()
                        return
                    piece_idx = self.hit_test_drawn_segment(start["x"], start["y"], tol_px=8.0)
                    if piece_idx is not None:
                        owner = None
                        try:
                            owner = self._seg_owner_idx[piece_idx]
                        except Exception:
                            owner = None
                        if owner is not None and 0 <= owner < len(self.points):
                            self.selected_point_idx = None
                            # Refresh list and select owner; update inputs
                            self._refresh_points_list(select_idx=owner)
                            self._set_input_fields_for_index(owner)
                            self.preview()
                            return
                    return
                # LINES mode: normal click adds a point (unless near existing point)
                if self.draw_mode == "LINES":
                    # Skip if clicking near an existing point (reduced tolerance)
                    hit_idx = self.hit_test_point(start["x"], start["y"], tol_px=6.0)
                    if hit_idx is not None:
                        return

                    sel = self.seg_list.curselection()
                    insert_idx = sel[0] + 1 if sel else len(self.points)

                    # Apply Shift-based angle constraint relative to reference point
                    ref_pt: Point | None = None
                    if insert_idx > 0:
                        ref_pt = self.points[insert_idx - 1]
                    elif self.points:
                        ref_pt = self.points[0]
                    if ref_pt is not None and event is not None and hasattr(event, "state") and bool(event.state & 0x0001):
                        nz_c, nx_c = self._constrain_point_045(ref_pt.z, ref_pt.x, nz, nx)
                        nz, nx = round(nz_c, 2), round(nx_c, 2)

                    # Apply snap-to-grid after constraint
                    nz, nx = self._snap_point(nz, nx)

                    prev_z = self.points[insert_idx - 1].z if insert_idx > 0 else None
                    next_z = self.points[insert_idx].z if insert_idx < len(self.points) else None

                    if prev_z is not None and next_z is not None:
                        if not (prev_z >= nz >= next_z):
                            self.show_status("Z must be monotonic (non-increasing) for G71/G72", color="#FFD966")
                            return
                    elif prev_z is not None:
                        if nz > prev_z:
                            self.show_status("Z must be monotonic (non-increasing) for G71/G72", color="#FFD966")
                            return
                    elif next_z is not None:
                        if nz < next_z:
                            self.show_status("Z must be monotonic (non-increasing) for G71/G72", color="#FFD966")
                            return

                    # Suppress commits during programmatic UI updates
                    self._suppress_commit = True
                    self.points.insert(insert_idx, Point(float(nz), float(nx), 0.0))
                    self._refresh_points_list(select_idx=insert_idx)
                    self.seg_list.selection_set(insert_idx)
                    self.seg_list.activate(insert_idx)
                    self._set_input_fields_for_index(insert_idx)
                    self.preview()
                    self.canvas.focus_set()
                    self._end_suppressed_commit_after_idle()
                    return

                # In RADIUS/CHAMFER modes, select nearest point if within tolerance
                if self.draw_mode in ("RADIUS", "CHAMFER"):
                    hit_idx = self.hit_test_point(start["x"], start["y"])
                    if hit_idx is not None:
                        self.selected_point_idx = hit_idx
                        if self.draw_mode == "RADIUS":
                            # Two-phase: show selection highlight, then prompt
                            self.preview()
                            self.root.update_idletasks()
                            self._apply_radius_at_selected()
                        else:
                            # CHAMFER mode: same two-phase flow
                            self.preview()
                            self.root.update_idletasks()
                            self._apply_chamfer_at_selected()
                        return
            except Exception:
                # Ignore conversion errors silently
                pass
        else:
            # Normal pan end or no click
            self._pan_start = None

    def _nearest_point(self, z: float, x: float, pts: List[Point]) -> Tuple[float, float]:
        """Return nearest point (Z,X) from a list of Points."""
        best = None
        best_d = float("inf")
        for p in pts:
            d = math.hypot(p.z - z, p.x - x)
            if d < best_d:
                best_d = d
                best = (p.z, p.x)
        return best if best is not None else (z, x)

    def hit_test_segment(self, canvas_x: float, canvas_y: float, tol_px: float = 10.0) -> int | None:
        """Return index of nearest segment (i -> i+1) within pixel tolerance, else None."""
        if not hasattr(self, "_view_cache") or not self._view_cache:
            return None
        best_idx = None
        best_d2 = tol_px * tol_px
        for i in range(len(self.points) - 1):
            p0, p1 = self.points[i], self.points[i + 1]
            try:
                x0, y0 = self._model_to_canvas(p0.z, p0.x)
                x1, y1 = self._model_to_canvas(p1.z, p1.x)
            except (RuntimeError, AttributeError):
                continue
            dx = x1 - x0
            dy = y1 - y0
            seg_len2 = dx * dx + dy * dy
            if seg_len2 == 0:
                continue
            t = ((canvas_x - x0) * dx + (canvas_y - y0) * dy) / seg_len2
            t = max(0.0, min(1.0, t))
            proj_x = x0 + t * dx
            proj_y = y0 + t * dy
            d2 = (canvas_x - proj_x) ** 2 + (canvas_y - proj_y) ** 2
            if d2 <= best_d2:
                best_d2 = d2
                best_idx = i
        return best_idx

    def hit_test_segment_with_projection(self, canvas_x: float, canvas_y: float, tol_px: float = 8.0) -> tuple[int, float, float] | None:
        """
        Return (seg_start_idx, z_proj, x_proj) for nearest segment within tol,
        where segment is points[i] -> points[i+1], and (z_proj, x_proj) is the
        projection of the cursor onto that segment in MODEL space.
        Return None if no segment close enough.
        """
        if not hasattr(self, "_view_cache") or not self._view_cache:
            return None
        try:
            z, x = self._canvas_to_model(canvas_x, canvas_y)
        except (RuntimeError, AttributeError):
            return None
        
        best_idx = None
        best_d2 = tol_px * tol_px
        best_proj_z = None
        best_proj_x = None
        
        for i in range(len(self.points) - 1):
            p0, p1 = self.points[i], self.points[i + 1]
            # Project cursor onto segment in model space
            z0, x0 = p0.z, p0.x
            z1, x1 = p1.z, p1.x
            dz = z1 - z0
            dx = x1 - x0
            seg_len2 = dz * dz + dx * dx
            if seg_len2 < 1e-9:
                continue
            t = ((z - z0) * dz + (x - x0) * dx) / seg_len2
            t = max(0.0, min(1.0, t))
            z_proj = z0 + t * dz
            x_proj = x0 + t * dx
            
            # Measure distance in canvas pixels
            try:
                px_proj, py_proj = self._model_to_canvas(z_proj, x_proj)
            except (RuntimeError, AttributeError):
                continue
            d2 = (canvas_x - px_proj) ** 2 + (canvas_y - py_proj) ** 2
            if d2 <= best_d2:
                best_d2 = d2
                best_idx = i
                best_proj_z = z_proj
                best_proj_x = x_proj
        
        if best_idx is not None:
            return (best_idx, best_proj_z, best_proj_x)
        return None

    def _select_list_index(self, idx: int) -> None:
        if idx is None:
            return
        try:
            self.seg_list.selection_clear(0, tk.END)
            self.seg_list.selection_set(idx)
            self.seg_list.activate(idx)
            if not self._suppress_listbox_scroll:
                self.seg_list.see(idx)
            self._set_input_fields_for_index(idx)
        except Exception:
            pass

    def _on_canvas_zoom(self, event):
        """Mouse wheel zoom when cursor is over canvas, anchored to cursor position."""
        direction = 0
        if hasattr(event, "delta") and event.delta:
            direction = 1 if event.delta > 0 else -1
        elif getattr(event, "num", None) in (4, 5):
            direction = 1 if event.num == 4 else -1
        if direction == 0:
            return "break"

        # If view not ready, just zoom without anchor
        if not hasattr(self, "_view_cache") or not self._view_cache:
            if direction > 0:
                self.zoom_factor = min(self.zoom_factor * 1.2, 10.0)
            elif direction < 0:
                self.zoom_factor = max(self.zoom_factor / 1.2, 0.1)
            self.preview()
            return "break"

        # Capture model position under cursor before zoom
        try:
            z0, x0 = self._canvas_to_model(event.x, event.y)
        except (RuntimeError, AttributeError):
            # Can't get model coords; zoom without anchor
            if direction > 0:
                self.zoom_factor = min(self.zoom_factor * 1.2, 10.0)
            elif direction < 0:
                self.zoom_factor = max(self.zoom_factor / 1.2, 0.1)
            self.preview()
            return "break"

        # Update zoom factor
        if direction > 0:
            self.zoom_factor = min(self.zoom_factor * 1.2, 10.0)
        else:
            self.zoom_factor = max(self.zoom_factor / 1.2, 0.1)

        # Preview once to update _view_cache with new scale
        self.preview()

        # Compute where cursor now maps after zoom
        try:
            z1, x1 = self._canvas_to_model(event.x, event.y)
        except (RuntimeError, AttributeError):
            return "break"

        # Correct pan offsets by the drift in model units
        dz = z0 - z1
        dx = x0 - x1

        params = self._read_params()
        xf = self._preview_x_factor(params)  # 0.5 in diameter mode, else 1.0

        self.pan_z_offset += dz
        # Pan offset is stored in preview-space units; convert model drift using xf only
        # Using the mirror sign here overcorrects when mirrored, so omit xs
        self.pan_x_offset += dx * xf

        # Preview again with corrected pan
        self.preview()

        return "break"

    def _canvas_to_model_lathe(self, canvas_x: float, canvas_y: float) -> tuple[float, float]:
        """Like _canvas_to_model but always returns non-negative X.

        In the symmetric preview the lower half is a display-only mirror.
        Any user interaction below the axis (negative X_preview) is reflected
        back to positive X so adding/dragging points always stays in lathe space.
        """
        z, x = self._canvas_to_model(canvas_x, canvas_y)
        return z, abs(x)

    def _on_canvas_motion(self, event) -> None:
        """Update tooltip showing Z, X coordinates at cursor position."""
        # Only show tooltip if view is initialized
        if not hasattr(self, "_view_cache") or not self._view_cache:
            return

        try:
            z, x = self._canvas_to_model(event.x, event.y)
        except (RuntimeError, AttributeError):
            return

        # The lower half of the symmetric preview is a mirror (display only).
        # Reflect negative X back to positive so the tooltip always shows the
        # actual lathe X (radius/diameter from the rotation axis).
        x = abs(x)

        # Create tooltip items lazily
        if self.tooltip_text_id is None:
            self.tooltip_text_id = self.canvas.create_text(
                0, 0, text="", fill="#333", font=("TkDefaultFont", 12), anchor="nw"
            )
            self.tooltip_bg_id = self.canvas.create_rectangle(
                0, 0, 0, 0, fill="#FFFFCC", outline="#999", width=1
            )
            # Ensure text is on top of background on creation
            self.canvas.tag_raise(self.tooltip_bg_id)
            self.canvas.tag_raise(self.tooltip_text_id)

        # Update text content
        text = f"Z={z:.2f}  X={x:.2f}"
        self.canvas.itemconfig(self.tooltip_bg_id, state="normal")
        self.canvas.itemconfig(self.tooltip_text_id, text=text, state="normal")
        
        # Position text with offset from cursor
        text_x = event.x + 12
        text_y = event.y + 12
        self.canvas.coords(self.tooltip_text_id, text_x, text_y)
        
        # Update background rectangle to fit text with padding
        bbox = self.canvas.bbox(self.tooltip_text_id)
        if bbox:
            padding = 3
            self.canvas.coords(
                self.tooltip_bg_id,
                bbox[0] - padding,
                bbox[1] - padding,
                bbox[2] + padding,
                bbox[3] + padding
            )
            # Keep background behind text and both above other items
            self.canvas.tag_raise(self.tooltip_bg_id)
            self.canvas.tag_raise(self.tooltip_text_id)

        # Rubber-band line in LINES mode
        if self.draw_mode == "LINES" and len(self.points) >= 1:
            last_pt = self.points[-1]
            try:
                # Constrain to 0/45/90 if Shift held
                if event and hasattr(event, "state") and bool(event.state & 0x0001):
                    z_con, x_con = self._constrain_point_045(last_pt.z, last_pt.x, z, x)
                else:
                    z_con, x_con = z, x
                z_con, x_con = self._snap_point(z_con, x_con)
                cz_last, cy_last = self._model_to_canvas(last_pt.z, last_pt.x)
                cz_cur, cy_cur = self._model_to_canvas(z_con, x_con)
            except (RuntimeError, AttributeError):
                return
            if self.rubber_line_id is None:
                self.rubber_line_id = self.canvas.create_line(
                    cz_last, cy_last, cz_cur, cy_cur, fill="#2e8b57", width=1
                )
            else:
                self.canvas.coords(self.rubber_line_id, cz_last, cy_last, cz_cur, cy_cur)
                self.canvas.itemconfig(self.rubber_line_id, state="normal")
            self.canvas.tag_raise(self.rubber_line_id)
        else:
            if self.rubber_line_id is not None:
                self.canvas.itemconfig(self.rubber_line_id, state="hidden")

    def _on_canvas_leave(self, event) -> None:
        """Hide tooltip when mouse leaves canvas."""
        if self.tooltip_text_id is not None:
            self.canvas.itemconfig(self.tooltip_text_id, state="hidden")
        if self.tooltip_bg_id is not None:
            self.canvas.itemconfig(self.tooltip_bg_id, state="hidden")
        if self.rubber_line_id is not None:
            self.canvas.itemconfig(self.rubber_line_id, state="hidden")

    def _show_canvas_context_menu(self, event) -> None:
        """Show context menu for mode selection on right-click or middle-click.
        Supports Button-3 (right), Button-2 (middle on macOS), and Control+Button-1 (macOS trackpad).
        """
        # Force window and canvas focus so menu works without prior click
        try:
            self.root.lift()
            self.root.focus_force()
        except Exception:
            pass
        self.canvas.focus_set()
        
        context = tk.Menu(self.canvas, tearoff=False)
        for mode in ("LINES", "RADIUS", "CHAMFER", "SELECT", "SPLIT"):
            context.add_command(
                label=mode,
                command=lambda m=mode: self._set_draw_mode(m)
            )
        try:
            context.tk_popup(event.x_root, event.y_root)
        finally:
            context.grab_release()

    def import_dxf(self) -> None:
        """Load a DXF polyline and preview orientation before applying."""
        try:
            import ezdxf  # type: ignore
        except Exception:
            self.show_status("Please install ezdxf (e.g. pip install ezdxf) to import DXF.")
            return
        self.import_source = "dxf"  # Set source
        path = filedialog.askopenfilename(
            title="Select DXF profile",
            filetypes=[("DXF files", "*.dxf"), ("All files", "*.*")],
        )
        if not path:
            return
        try:
            doc = ezdxf.readfile(path)
            msp = doc.modelspace()
            poly = None
            for ent in msp.query("LWPOLYLINE"):
                poly = ent
                break
            if poly is None:
                for ent in msp.query("POLYLINE"):
                    poly = ent
                    break
            if poly is None and self.dxf_allow_line_arc.get():
                # Try SPLINE tessellation before LINE/ARC chain
                spline_ent = None
                for ent in msp.query("SPLINE"):
                    spline_ent = ent
                    break
                if spline_ent is not None:
                    tol = max(1e-4, float(self.dxf_spline_tol.get()))
                    verts = self._tessellate_spline(spline_ent, tol)
                    if len(verts) < 2:
                        raise ValueError("SPLINE tessellation produced fewer than 2 points.")
                    closed = bool(spline_ent.closed)
                else:
                    verts, closed = self._extract_line_arc_chain(msp, close_gaps=self.dxf_close_gaps.get())
            elif poly is None:
                raise ValueError("No LWPOLYLINE or POLYLINE found in DXF.")
            else:
                verts = []
                actual_type = poly.dxftype()
                if actual_type == "LWPOLYLINE":
                    verts = list(poly.get_points("xyb"))
                    closed = bool(poly.closed)
                else:
                    # Some ezdxf builds classify SPLINE entities as POLYLINE.
                    # Detect spline capability by duck-typing rather than dxftype string.
                    has_spline_api = (
                        actual_type == "SPLINE"
                        or hasattr(poly, "control_points")
                        or callable(getattr(poly, "flattening", None))
                    )
                    if has_spline_api:
                        tol = max(1e-4, float(self.dxf_spline_tol.get()))
                        verts = self._tessellate_spline(poly, tol)
                        closed = bool(getattr(poly, "closed", False))
                    else:
                        # POLYLINE: .vertices is a list or method depending on ezdxf version;
                        # VERTEX position is in dxf.location (Vec3), not dxf.x / dxf.y.
                        vertices_iter = (
                            poly.vertices() if callable(poly.vertices) else poly.vertices
                        )
                        verts = []
                        for v in vertices_iter:
                            bulge = 0.0
                            try:
                                bulge = float(v.dxf.bulge)
                            except Exception:
                                pass
                            try:
                                loc = v.dxf.location
                                verts.append((float(loc[0]), float(loc[1]), bulge))
                            except Exception:
                                verts.append((float(v.dxf.x), float(v.dxf.y), bulge))
                        closed = bool(poly.is_closed)

            if len(verts) < 2:
                raise ValueError("Polyline must have at least two vertices.")

            # Persist raw verts for orientation preview
            self.dxf_raw_verts = [(float(x), float(y), float(b)) for x, y, b in verts]
            self.dxf_raw_verts_unscaled = None
            self.dxf_closed = bool(closed)
            self._refresh_dxf_preview()
            messagebox.showinfo(
                "DXF loaded",
                "DXF loaded for preview. Adjust Swap/Flip options, then click 'Apply imported outline'.",
            )
        except Exception as exc:
            import traceback
            traceback.print_exc()
            self.show_status(f"DXF import error: {exc}")

    def import_3mf(self) -> None:
        """Load a 3MF, project to 2D, and preview before applying."""
        try:
            import trimesh  # type: ignore
        except Exception:
            self.show_status("Please install trimesh (e.g. pip install trimesh) to import 3MF/mesh files.")
            return
        self.import_source = "3mf"  # Set source

        path = filedialog.askopenfilename(
            title="Select 3MF (or mesh)",
            filetypes=[("3MF files", "*.3mf"), ("Mesh files", "*.stl *.obj *.ply"), ("All files", "*.*")],
        )
        if not path:
            return

        try:
            mesh = trimesh.load(path, force="mesh")
            if mesh.is_empty:
                raise ValueError("Mesh is empty or unsupported.")

            plane = self.threemf_plane.get()
            pts_2d, faces = self._project_mesh_to_2d(mesh, plane)
            outline = self._outline_from_projected(pts_2d, faces, self.threemf_convex_hull.get())
            if len(outline) < 3:
                raise ValueError("Could not compute outline from mesh.")

            # Simplify outline to avoid excessive segments
            outline = self._simplify_polyline(outline, tol=self.threemf_simplify_tol.get())
            if len(outline) < 3:
                raise ValueError("Outline simplified away; adjust projection or scale.")

            # Ensure X is non-negative (lathe radius) by shifting if needed
            min_x_outline = min(p[1] for p in outline)
            if min_x_outline < 0:
                shift = -min_x_outline
                outline = [(z, x + shift) for z, x in outline]

            # Close loop explicitly for our downstream format
            if outline[0] != outline[-1]:
                outline.append(outline[0])

            # Clamp negative X to zero (lathe radius cannot be negative)
            outline = [(z, max(x, 0.0)) for z, x in outline]

            # Store file path for re-projection when settings change
            self._3mf_last_path = path
            
            # Store unscaled verts; scaling applied in _apply_3mf_scale_and_refresh
            self.dxf_raw_verts_unscaled = [(float(u), float(v), 0.0) for u, v in outline]
            self.dxf_closed = True
            # 3MF geometry is typically diameter; avoid unintended doubling
            if self.dxf_x_is_radius.get():
                self.dxf_x_is_radius.set(False)
            self._apply_3mf_scale_and_refresh()
            self._refresh_dxf_preview()
        except Exception as exc:
            self.show_status(f"3MF import error: {exc}")

    def _project_mesh_to_2d(self, mesh, plane: str) -> Tuple[List[Tuple[float, float]], Any]:
        """Project mesh vertices to 2D based on selected plane."""
        verts = mesh.vertices
        if plane == "XY":
            pts = [(float(v[0]), float(v[1])) for v in verts]
        elif plane == "XZ":
            pts = [(float(v[0]), float(v[2])) for v in verts]
        else:  # ZX
            pts = [(float(v[2]), float(v[0])) for v in verts]
        return pts, mesh.faces

    def _outline_from_projected(
        self, pts_2d: List[Tuple[float, float]], faces, use_convex_hull: bool
    ) -> List[Tuple[float, float]]:
        """Return outline from projected mesh. Uses convex hull unless shapely is available and hull is disabled."""

        def convex_hull(points: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
            pts = sorted(set(points))
            if len(pts) <= 1:
                return pts

            def cross(o, a, b):
                return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])

            lower: List[Tuple[float, float]] = []
            for p in pts:
                while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
                    lower.pop()
                lower.append(p)
            upper: List[Tuple[float, float]] = []
            for p in reversed(pts):
                while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
                    upper.pop()
                upper.append(p)
            return lower[:-1] + upper[:-1]

        if use_convex_hull:
            return convex_hull(pts_2d)

        try:
            from shapely.geometry import Polygon  # type: ignore
            from shapely.ops import unary_union  # type: ignore
        except Exception as exc:
            raise ValueError(
                "Shapely is required for non-convex outline extraction. Install shapely or enable Convex Hull."
            ) from exc

        polys = []
        for face in faces:
            try:
                tri = [pts_2d[int(idx)] for idx in face]
            except Exception:
                continue
            if len({(p[0], p[1]) for p in tri}) < 3:
                continue
            poly = Polygon(tri)
            if poly.is_valid and not poly.is_empty and poly.area > 1e-9:
                polys.append(poly)
        if not polys:
            return convex_hull(pts_2d)

        merged = unary_union(polys)
        if merged.is_empty:
            return convex_hull(pts_2d)

        try:
            coords = list(merged.exterior.coords)
        except Exception:
            return convex_hull(pts_2d)
        return [(float(x), float(y)) for x, y in coords]

    def _simplify_polyline(self, pts: List[Tuple[float, float]], tol: float = 0.05) -> List[Tuple[float, float]]:
        """Simplify polyline using Ramer-Douglas-Peucker algorithm."""
        if len(pts) < 3:
            return pts

        def perpendicular_distance(point: Tuple[float, float], line_start: Tuple[float, float], line_end: Tuple[float, float]) -> float:
            """Compute perpendicular distance from point to line segment."""
            x0, y0 = point
            x1, y1 = line_start
            x2, y2 = line_end
            num = abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1)
            denom = math.hypot(y2 - y1, x2 - x1)
            if denom < 1e-12:
                return math.hypot(x0 - x1, y0 - y1)
            return num / denom

        def rdp(points: List[Tuple[float, float]], epsilon: float) -> List[Tuple[float, float]]:
            """Ramer-Douglas-Peucker reduction."""
            if len(points) < 3:
                return points
            dmax = 0.0
            index = 0
            for i in range(1, len(points) - 1):
                d = perpendicular_distance(points[i], points[0], points[-1])
                if d > dmax:
                    dmax = d
                    index = i
            if dmax > epsilon:
                rec1 = rdp(points[:index+1], epsilon)
                rec2 = rdp(points[index:], epsilon)
                result = rec1[:-1] + rec2
            else:
                result = [points[0], points[-1]]
            return result

        simplified = rdp(pts, tol)
        # Remove tiny segments
        out: List[Tuple[float, float]] = []
        for p in simplified:
            if not out:
                out.append(p)
                continue
            dx = p[0] - out[-1][0]
            dy = p[1] - out[-1][1]
            if (dx * dx + dy * dy) ** 0.5 < tol * 0.1:  # 10% of tolerance
                continue
            out.append(p)
        return out

    def _fit_arcs(self, points: List[Point], tol: float = 0.1, angle_tol: float = 5.0) -> List[Point]:
        """Fit arcs to sequences of points using least-squares circle fitting.
        
        Detects groups of points that lie approximately on a circle arc and 
        replaces them with single points with arc radius.
        """
        if len(points) < 3:
            return points

        def fit_circle_ls(pts: List[Tuple[float, float]]) -> Tuple[float, float, float] | None:
            """Least-squares circle fit. Returns (cx, cy, radius) or None."""
            if len(pts) < 3:
                return None
            n = len(pts)
            sum_x = sum(p[0] for p in pts)
            sum_y = sum(p[1] for p in pts)
            sum_x2 = sum(p[0]**2 for p in pts)
            sum_y2 = sum(p[1]**2 for p in pts)
            sum_xy = sum(p[0] * p[1] for p in pts)
            sum_x3 = sum(p[0]**3 for p in pts)
            sum_y3 = sum(p[1]**3 for p in pts)
            sum_x2y = sum(p[0]**2 * p[1] for p in pts)
            sum_xy2 = sum(p[0] * p[1]**2 for p in pts)
            
            A = n * sum_x2 - sum_x**2
            B = n * sum_xy - sum_x * sum_y
            C = n * sum_y2 - sum_y**2
            D = 0.5 * (n * sum_x3 + n * sum_xy2 - sum_x * sum_x2 - sum_x * sum_y2)
            E = 0.5 * (n * sum_x2y + n * sum_y3 - sum_y * sum_x2 - sum_y * sum_y2)
            
            denom = A * C - B * B
            if abs(denom) < 1e-9:
                return None
            
            cx = (D * C - B * E) / denom
            cy = (A * E - B * D) / denom
            
            r_sum = sum(math.hypot(p[0] - cx, p[1] - cy) for p in pts)
            r = r_sum / n
            
            return (cx, cy, r)

        def validate_arc(pts: List[Tuple[float, float]], cx: float, cy: float, r: float, tol: float) -> bool:
            """Check if all points fit the arc within tolerance."""
            for p in pts:
                dist = math.hypot(p[0] - cx, p[1] - cy)
                if abs(dist - r) > tol:
                    return False
            # Check chord-radius constraint for G-code
            chord = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1])
            if r < chord / 2.0 - 1e-6:  # Must be valid arc
                return False
            return True

        result: List[Point] = [points[0]]  # Always keep first point
        i = 1
        
        while i < len(points):
            best_k = 1
            best_r = 0.0
            best_fit = None
            
            # Try increasing arc lengths
            for k in range(max(3, i + 2), min(len(points) + 1, i + 30)):  # Look ahead up to 30 points
                candidate_pts = [(result[-1].z, result[-1].x)]
                for j in range(i, k):
                    if j < len(points):
                        candidate_pts.append((points[j].z, points[j].x))
                
                if len(candidate_pts) < 3:
                    continue
                
                circle = fit_circle_ls(candidate_pts)
                if circle is None:
                    continue
                
                cx, cy, r = circle
                
                if r < 0.1:  # Skip tiny arcs
                    continue
                
                if not validate_arc(candidate_pts, cx, cy, r, tol):
                    continue
                
                # Valid arc found - check if CCW or CW
                p0 = candidate_pts[0]
                p1 = candidate_pts[len(candidate_pts)//2]
                p2 = candidate_pts[-1]
                is_ccw = self._is_ccw(p0, p1, p2)
                
                best_k = k
                best_r = -r if is_ccw else r
                best_fit = circle
            
            if best_k > 1 and best_fit is not None:
                # Use arc
                result.append(Point(points[best_k - 1].z, points[best_k - 1].x, best_r))
                i = best_k
            else:
                # No arc fit; add as line
                if i < len(points):
                    result.append(Point(points[i].z, points[i].x, 0.0))
                i += 1
        
        return result

    def _is_ccw(self, p0: Tuple[float, float], p1: Tuple[float, float], p2: Tuple[float, float]) -> bool:
        """Check if three points are in counter-clockwise order."""
        return (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p1[1] - p0[1]) * (p2[0] - p0[0]) > 0

    def _outline_to_lathe_profile(self, outline: List[Tuple[float, float]], samples: int = 200) -> List[Tuple[float, float]]:
        """Convert a closed 2D outline to a 1-sided lathe profile by taking the max radius per Z.

        Samples along Z between min/max Z and keeps the maximum X (radius) at each sample.
        """
        if len(outline) < 2:
            return outline
        zs = [p[0] for p in outline]
        min_z, max_z = min(zs), max(zs)
        if max_z - min_z < 1e-6:
            return outline

        # Ensure closed for edge iteration
        pts = outline if outline[0] == outline[-1] else outline + [outline[0]]
        sample_zs = [min_z + (max_z - min_z) * i / max(samples - 1, 1) for i in range(samples)]
        result: List[Tuple[float, float]] = []

        for zq in sample_zs:
            max_x = None
            for i in range(len(pts) - 1):
                z1, x1 = pts[i]
                z2, x2 = pts[i + 1]
                if (zq < min(z1, z2)) or (zq > max(z1, z2)):
                    continue
                if abs(z2 - z1) < 1e-9:
                    xq = max(x1, x2)
                else:
                    t = (zq - z1) / (z2 - z1)
                    xq = x1 + t * (x2 - x1)
                max_x = xq if max_x is None else max(max_x, xq)
            if max_x is not None:
                result.append((zq, max_x))

        # Deduplicate and simplify
        dedup: List[Tuple[float, float]] = []
        for z, x in result:
            if dedup and abs(dedup[-1][0] - z) < 1e-6 and abs(dedup[-1][1] - x) < 1e-6:
                continue
            dedup.append((z, x))
        dedup = self._simplify_polyline(dedup, tol=0.005)
        return dedup

    def _apply_3mf_scale_and_refresh(self) -> None:
        """Rescale stored 3MF outline and refresh preview."""
        if not self.dxf_raw_verts_unscaled:
            return
        scale = self.threemf_scale.get()
        if abs(scale) < 1e-9:
            scale = 1.0
        self.dxf_raw_verts = [(x * scale, y * scale, b) for x, y, b in self.dxf_raw_verts_unscaled]
        self._refresh_dxf_preview()

    def _compute_dxf_points_from_raw(self) -> List[Point]:
        """Convert stored DXF verts to (Z, X, R) with current orientation settings."""
        if not self.dxf_raw_verts:
            return []

        def bulge_to_radius(p0, p1, bulge_val: float) -> float:
            if abs(bulge_val) < 1e-9:
                return 0.0
            dx = p1[0] - p0[0]
            dy = p1[1] - p0[1]
            chord = math.hypot(dx, dy)
            if chord < 1e-9:
                return 0.0
            angle = 4 * math.atan(bulge_val)  # total included angle
            if abs(angle) < 1e-9:
                return 0.0
            radius = chord / (2 * math.sin(angle / 2))
            # ezdxf bulge > 0 => CCW; our generator uses negative R for CCW
            return -radius if bulge_val > 0 else radius

        def to_zx(pt):
            """Map DXF/3MF XY to lathe Z/X with optional swap/mirror."""
            x_raw, y_raw = float(pt[0]), float(pt[1])
            # Choose controls based on import source
            if self.import_source == "3mf":
                swap = self.threemf_swap_axes.get()
                flip_z = self.threemf_flip_z.get()
                flip_x = self.threemf_flip_x.get()
                shift_min_x = self.threemf_shift_min_x.get()
            else:
                swap = self.dxf_swap_axes.get()
                flip_z = self.dxf_flip_z.get()
                flip_x = self.dxf_flip_x.get()
                shift_min_x = self.dxf_shift_min_x.get()

            if swap:
                z_val, x_val = y_raw, x_raw
            else:
                z_val, x_val = x_raw, y_raw
            if flip_z:
                z_val = -z_val
            if flip_x:
                x_val = -x_val
            return z_val, x_val

        pts: List[Tuple[float, float, float]] = []
        verts = self.dxf_raw_verts
        closed = self.dxf_closed
        num = len(verts)
        for idx, (vx, vy, vbulge) in enumerate(verts):
            if idx == 0:
                pts.append((vx, vy, 0.0))
            nxt_idx = (idx + 1) % num if closed else idx + 1
            if nxt_idx >= num:
                break
            nxt = verts[nxt_idx]
            r = bulge_to_radius((vx, vy), (nxt[0], nxt[1]), vbulge)
            # Set radius on the NEXT point to match our point format
            if nxt_idx < len(pts):
                z_prev, x_prev, _ = pts[nxt_idx]
                pts[nxt_idx] = (z_prev, x_prev, r)
            else:
                pts.append((nxt[0], nxt[1], r))
            # For closed polylines, ensure the first point carries the radius from the last segment
            if closed and nxt_idx == 0 and pts:
                z0, x0, _ = pts[0]
                pts[0] = (z0, x0, r)

        # First point cannot have a radius in our editor/generator
        if pts:
            z0, x0, _ = pts[0]
            pts[0] = (z0, x0, 0.0)

        pts_mapped: List[Point] = []
        for z_raw, x_raw, r_val in pts:
            z, x = to_zx((z_raw, x_raw))
            pts_mapped.append(Point(z, x, r_val))

        # Apply X-offset (DXF only) in RAW DRAWING UNITS — i.e. before the
        # radius→diameter doubling below. Lathe half-profiles are drawn as
        # radius in CAD, so the user thinks and enters offsets in radius mm.
        if pts_mapped and self.import_source == "dxf":
            try:
                x_offset_raw = self.dxf_x_offset.get() if hasattr(self.dxf_x_offset, 'get') else 0.0
                x_offset = float(x_offset_raw) if str(x_offset_raw).strip() else 0.0
            except Exception:
                x_offset = 0.0
            if abs(x_offset) > 1e-9:
                pts_mapped = [Point(pt.z, pt.x + x_offset, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]

        # If in diameter mode and DXF X is radius, double X values (only for DXF imports)
        if pts_mapped and self.import_source == "dxf" and getattr(self, "params", None) and getattr(self, "dxf_x_is_radius", None):
            if self.params.diameter_mode and self.dxf_x_is_radius.get():
                pts_mapped = [Point(pt.z, pt.x * 2.0, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]

        # For 3MF imports on a lathe, derive half-profile where X >= 0 when centered
        if pts_mapped and self.import_source == "3mf":
            closed_poly = bool(self.dxf_closed)

            # Center full outline on spindle axis (X) using bbox mid, regardless of UI toggle
            min_x = min(pt.x for pt in pts_mapped)
            max_x = max(pt.x for pt in pts_mapped)
            cx = 0.5 * (min_x + max_x)
            if abs(cx) > 1e-12:
                pts_centered = [Point(pt.z, pt.x - cx, 0.0, pt.corner_type, pt.corner_value) for pt in pts_mapped]
            else:
                pts_centered = [Point(pt.z, pt.x, 0.0, pt.corner_type, pt.corner_value) for pt in pts_mapped]

            def extract_positive_x_half(points: List[Point], closed: bool) -> List[Point]:
                n = len(points)
                if n < 2:
                    return list(points)

                def edge_iter():
                    last = n - 1
                    end = last if not closed else last
                    for i in range(0, end + 1):
                        j = (i + 1) % n if closed else (i + 1)
                        if j >= n:
                            break
                        yield i, j

                # Find intersections with X=0
                inters = []  # tuples: (i, t, z, x)
                eps = 1e-12
                for i, j in edge_iter():
                    xi, zi = points[i].x, points[i].z
                    xj, zj = points[j].x, points[j].z
                    # treat very small as zero
                    xi0 = 0.0 if abs(xi) < eps else xi
                    xj0 = 0.0 if abs(xj) < eps else xj
                    if xi0 == 0.0 and xj0 == 0.0:
                        continue  # edge lies on X=0; ignore for intersection finding
                    if (xi0 < 0 and xj0 > 0) or (xi0 > 0 and xj0 < 0):
                        t = -xi / (xj - xi)
                        z_int = zi + t * (zj - zi)
                        inters.append((i, t, z_int, 0.0))

                if len(inters) < 2:
                    # Fallback: keep contiguous run of x>=0 points
                    kept = [Point(p.z, max(0.0, p.x), 0.0, p.corner_type, p.corner_value) for p in points if p.x >= -1e-9]
                    if kept:
                        kept[0] = Point(kept[0].z, kept[0].x, 0.0, kept[0].corner_type, kept[0].corner_value)
                    return kept

                # Order intersections by path position (i then t)
                inters.sort(key=lambda it: (it[0], it[1]))
                # Use first two for candidate split; build both directions
                (i0, t0, z0, _), (i1, t1, z1, _) = inters[0], inters[1]

                def build_path_forward(start, start_t, end, end_t) -> List[Point]:
                    out: List[Point] = []
                    # start intersection
                    out.append(Point(z0, 0.0, 0.0))
                    # iterate vertices strictly between (i0, i1)
                    k = (start + 1) % n
                    while True:
                        if (not closed and k > end) or (closed and start < end and k > end):
                            break
                        if closed and start > end and k == (end + 1) % n:
                            break
                        if k == (end + 1) % n and closed:
                            break
                        if k == end + 1 and not closed:
                            break
                        if k == end:
                            break
                        p = points[k]
                        if p.x >= -1e-9:
                            out.append(Point(p.z, max(0.0, p.x), 0.0))
                        if not closed and k == end:
                            break
                        k = (k + 1) % n if closed else k + 1
                    # end intersection
                    out.append(Point(z1, 0.0, 0.0))
                    return out

                # Build two candidate paths: forward i0->i1 and wrap i1->i0
                # Forward path
                path_fwd = build_path_forward(i0, t0, i1, t1)

                # Wrapped path (swap roles of intersections)
                z0b, z1b = z1, z0
                def build_path_wrap(start, start_t, end, end_t) -> List[Point]:
                    out: List[Point] = []
                    out.append(Point(z0b, 0.0, 0.0))
                    k = (start + 1) % n
                    while True:
                        if k == end:
                            break
                        p = points[k]
                        if p.x >= -1e-9:
                            out.append(Point(p.z, max(0.0, p.x), 0.0))
                        k = (k + 1) % n
                    out.append(Point(z1b, 0.0, 0.0))
                    return out

                path_wrap = build_path_wrap(i1, t1, i0, t0)

                def avg_x(path: List[Point]) -> float:
                    if not path:
                        return -1e9
                    vals = [p.x for p in path]
                    return sum(vals) / len(vals)

                best = path_fwd if avg_x(path_fwd) >= avg_x(path_wrap) else path_wrap

                # Deduplicate nearly-equal points and ensure first point radius 0
                cleaned: List[Point] = []
                for p in best:
                    if cleaned:
                        dz = abs(p.z - cleaned[-1].z)
                        dx = abs(p.x - cleaned[-1].x)
                        if dz + dx < 1e-9:
                            continue
                    cleaned.append(Point(p.z, p.x, 0.0))
                if cleaned:
                    cleaned[0] = Point(cleaned[0].z, cleaned[0].x, 0.0)
                return cleaned

            pts_half = extract_positive_x_half(pts_centered, closed_poly)

            # Apply Z-offset only (X is already centered and half-clipped)
            try:
                z_offset_raw = self.threemf_z_offset.get() if hasattr(self.threemf_z_offset, 'get') else 0.0
                z_offset = float(z_offset_raw) if str(z_offset_raw).strip() else 0.0
            except Exception:
                z_offset = 0.0
            if pts_half and abs(z_offset) > 1e-9:
                pts_half = [Point(pt.z + z_offset, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_half]
            
            # Normalize Z so rightmost is at Z=0 and path progresses left (negative Z)
            if pts_half and len(pts_half) > 1:
                # Reverse if path goes left-to-right
                pts_half.reverse()
                max_z = max(pt.z for pt in pts_half)
                pts_half = [Point(pt.z - max_z, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_half]
                # Ensure first point has R=0
                pts_half[0] = Point(pts_half[0].z, pts_half[0].x, 0.0, pts_half[0].corner_type, pts_half[0].corner_value)
            
            return pts_half

        # Shift minimum X to zero if requested
        shift_min_x = self.threemf_shift_min_x.get() if self.import_source == "3mf" else self.dxf_shift_min_x.get()
        if pts_mapped and shift_min_x:
            min_x = min(pt.x for pt in pts_mapped)
            pts_mapped = [Point(pt.z, pt.x - min_x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]

        # Center X on spindle if requested
        center_x = self.threemf_center_x.get() if self.import_source == "3mf" else self.dxf_center_x.get()
        if pts_mapped and center_x:
            avg_x = sum(pt.x for pt in pts_mapped) / len(pts_mapped)
            pts_mapped = [Point(pt.z, pt.x - avg_x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]

        # Normalize Z: ensure path starts at right (Z=0) and progresses left (negative Z)
        # For 3MF imports, always reverse and flip Z to get correct direction
        if pts_mapped and len(pts_mapped) > 1:
            if self.import_source == "3mf":
                # Reverse path and flip Z so rightmost is at Z=0, leftmost is negative
                pts_mapped.reverse()
                max_z = max(pt.z for pt in pts_mapped)
                # Flip: rightmost (max_z) becomes 0, others become negative
                pts_mapped = [Point(pt.z - max_z, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]
            else:
                # For DXF: just normalize to rightmost at Z=0
                first_z = pts_mapped[0].z
                last_z = pts_mapped[-1].z
                if first_z < last_z:
                    pts_mapped.reverse()
                offset = pts_mapped[0].z
                pts_mapped = [Point(pt.z - offset, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]
            
            # Ensure first point has R=0
            pts_mapped[0] = Point(pts_mapped[0].z, pts_mapped[0].x, 0.0, pts_mapped[0].corner_type, pts_mapped[0].corner_value)

        # Apply Z-offset
        try:
            z_offset_var = self.threemf_z_offset if self.import_source == "3mf" else self.dxf_z_offset
            z_offset_raw = z_offset_var.get() if hasattr(z_offset_var, 'get') else 0.0
            # Var may be DoubleVar (float) or StringVar (str) — handle both
            z_offset = float(z_offset_raw) if str(z_offset_raw).strip() else 0.0
        except Exception:
            z_offset = 0.0
        if pts_mapped and abs(z_offset) > 1e-9:
            pts_mapped = [Point(pt.z + z_offset, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]

        # Normalize Z: ensure path starts at right (Z=0) and progresses left (negative Z)
        # For 3MF imports, always reverse and flip Z to get correct direction
        if pts_mapped and len(pts_mapped) > 1:
            if self.import_source == "3mf":
                # Reverse path and flip Z so rightmost is at Z=0, leftmost is negative
                pts_mapped.reverse()
                max_z = max(pt.z for pt in pts_mapped)
                # Flip: rightmost (max_z) becomes 0, others become negative
                pts_mapped = [Point(pt.z - max_z, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]
            else:
                # For DXF: just normalize to rightmost at Z=0
                first_z = pts_mapped[0].z
                last_z = pts_mapped[-1].z
                if first_z < last_z:
                    pts_mapped.reverse()
                offset = pts_mapped[0].z
                pts_mapped = [Point(pt.z - offset, pt.x, pt.r, pt.corner_type, pt.corner_value) for pt in pts_mapped]
            
            # Ensure first point has R=0
            pts_mapped[0] = Point(pts_mapped[0].z, pts_mapped[0].x, 0.0, pts_mapped[0].corner_type, pts_mapped[0].corner_value)

        # Enforce half-profile for lathe: clip to X >= 0 without shifting
        def _clip_to_positive_x(points: List[Point], closed_poly: bool) -> List[Point]:
            if not points or len(points) < 2:
                return list(points)
            eps = 1e-12
            subpaths: List[List[Point]] = []
            cur: List[Point] = []

            def add_point(lst: List[Point], z: float, x: float):
                if lst and abs(lst[-1].z - z) + abs(lst[-1].x - x) < 1e-9:
                    return
                lst.append(Point(z, max(0.0, x), 0.0))

            n = len(points)
            total_edges = n if closed_poly else n - 1
            for i in range(total_edges):
                j = (i + 1) % n
                pi, pj = points[i], points[j]
                xi = 0.0 if abs(pi.x) < eps else pi.x
                xj = 0.0 if abs(pj.x) < eps else pj.x
                zi, zj = pi.z, pj.z

                if xi >= 0.0 and xj >= 0.0:
                    if not cur:
                        add_point(cur, zi, xi)
                    add_point(cur, zj, xj)
                elif xi >= 0.0 and xj < 0.0:
                    # Exiting positive region: add intersection then close subpath
                    t = xi / (xi - xj) if abs(xi - xj) > eps else 0.0
                    z_int = zi + t * (zj - zi)
                    if not cur:
                        add_point(cur, zi, xi)
                    add_point(cur, z_int, 0.0)
                    if cur:
                        cur[0] = Point(cur[0].z, cur[0].x, 0.0)
                    if cur:
                        subpaths.append(cur)
                    cur = []
                elif xi < 0.0 and xj >= 0.0:
                    # Entering positive region: start new subpath at intersection
                    t = xi / (xi - xj) if abs(xi - xj) > eps else 0.0
                    z_int = zi + t * (zj - zi)
                    cur = [] if cur else cur
                    add_point(cur, z_int, 0.0)
                    add_point(cur, zj, xj)
                else:
                    # entirely negative: nothing
                    continue

            if not subpaths:
                # If no subpaths but some points are >=0, keep those points
                kept = [Point(p.z, max(0.0, p.x), 0.0) for p in points if p.x >= -1e-9]
                if kept:
                    kept[0] = Point(kept[0].z, kept[0].x, 0.0)
                return kept

            def path_length(lst: List[Point]) -> float:
                s = 0.0
                for k in range(1, len(lst)):
                    dz = lst[k].z - lst[k - 1].z
                    dx = lst[k].x - lst[k - 1].x
                    s += math.hypot(dz, dx)
                return s

            best = max(subpaths, key=path_length)
            if best:
                best[0] = Point(best[0].z, best[0].x, 0.0)
            return best

        if any(pt.x < -1e-9 for pt in pts_mapped):
            clipped = _clip_to_positive_x(pts_mapped, closed)
            if clipped:
                pts_mapped = clipped
            # else: every point was negative; keep pts_mapped as-is so
            # preview() can display them and show an orientation hint

        # For 3MF imports, fit arcs to smooth out segmented curves
        if pts_mapped and self.import_source == "3mf":
            pts_mapped = self._fit_arcs(pts_mapped, tol=self.threemf_simplify_tol.get())

        # For DXF imports, optionally fit arcs (replaces short segments with G2/G3 arcs)
        if pts_mapped and self.import_source == "dxf" and self.dxf_fit_arcs.get():
            arc_tol = max(1e-4, float(self.dxf_arc_tol.get()))
            pts_mapped = self._fit_arcs(pts_mapped, tol=arc_tol)

        return pts_mapped


    def _tessellate_spline(self, spline_ent, tol: float) -> List[Tuple[float, float, float]]:
        """Tessellate a DXF SPLINE entity to a list of (x, y, 0.0) tuples.

        Tries multiple ezdxf APIs in order so the result is the same regardless
        of whether the installed package is 0.17, 1.1, or 1.4+:

        1. flattening(tol)  — positional arg (works in 0.16+)
        2. flattening()     — no-arg call (some builds expose it as a property)
        3. BSpline.approximate via ezdxf.math (always available)
        """
        def _to_xy(pts) -> List[Tuple[float, float, float]]:
            result = []
            for p in pts:
                try:
                    result.append((float(p.x), float(p.y), 0.0))
                except AttributeError:
                    result.append((float(p[0]), float(p[1]), 0.0))
            return result

        # --- attempt 1: flattening(distance) as a callable method ---
        fn = getattr(spline_ent, "flattening", None)
        if callable(fn):
            for kwargs in [{"distance": tol}, {}]:
                try:
                    raw = fn(**kwargs) if kwargs else fn()
                    pts = _to_xy(raw)
                    if len(pts) >= 2:
                        return pts
                except Exception:
                    pass

        # --- attempt 2: ezdxf.math.BSpline (lower-level, stable across versions) ---
        try:
            from ezdxf.math import BSpline  # type: ignore
            cp = list(spline_ent.control_points)
            degree = int(spline_ent.dxf.degree)
            order = degree + 1
            try:
                knots = list(spline_ent.knots)
                bsp = BSpline(cp, order=order, knots=knots)
            except Exception:
                bsp = BSpline(cp, order=order)
            n = max(64, len(cp) * 6)
            pts = _to_xy(bsp.approximate(n))
            if len(pts) >= 2:
                return pts
        except Exception:
            pass

        raise ValueError(
            "Cannot tessellate SPLINE: no compatible ezdxf API found. "
            "Try: sudo apt install python3-ezdxf  (or pip install ezdxf)"
        )

    def _extract_line_arc_chain(self, msp, tol: float = 2.0, close_gaps: bool = True) -> Tuple[List[Tuple[float, float, float]], bool]:
        """Trace a simple chain of LINE/ARC entities into (x, y, bulge_like_radius_sign) tuples."""
        segments = []
        for ln in msp.query("LINE"):
            start = (float(ln.dxf.start[0]), float(ln.dxf.start[1]))
            end = (float(ln.dxf.end[0]), float(ln.dxf.end[1]))
            segments.append({"start": start, "end": end, "radius": 0.0})
        for arc in msp.query("ARC"):
            start = (float(arc.start_point[0]), float(arc.start_point[1]))
            end = (float(arc.end_point[0]), float(arc.end_point[1]))
            rmag = float(arc.dxf.radius)
            # DXF arcs are CCW by default with positive extrusion; map CCW to negative radius per our convention
            r_signed = -rmag
            segments.append({"start": start, "end": end, "radius": r_signed})

        if not segments:
            raise ValueError("No LWPOLYLINE/POLYLINE/LINE/ARC entities found.")

        def dist(p0, p1):
            return math.hypot(p0[0] - p1[0], p0[1] - p1[1])

        path: List[Tuple[float, float, float]] = []
        seg = segments.pop(0)
        path.append((seg["start"][0], seg["start"][1], 0.0))
        path.append((seg["end"][0], seg["end"][1], seg["radius"]))
        current = seg["end"]
        closed = False

        while segments:
            found = False
            best_dist = float('inf')
            best_idx = -1
            best_next = None
            best_r = 0.0
            
            # Find closest segment endpoint to current point
            for idx, seg in enumerate(segments):
                s, e, r = seg["start"], seg["end"], seg["radius"]
                d_to_s = dist(current, s)
                d_to_e = dist(current, e)
                
                if d_to_s < best_dist:
                    best_dist = d_to_s
                    best_idx = idx
                    best_next = e
                    best_r = r
                if d_to_e < best_dist:
                    best_dist = d_to_e
                    best_idx = idx
                    best_next = s
                    best_r = -r  # reverse direction flips sign
            
            if best_dist <= tol:
                path.append((best_next[0], best_next[1], best_r))
                current = best_next
                segments.pop(best_idx)
                found = True
                if dist(current, (path[0][0], path[0][1])) <= tol and not segments:
                    closed = True
            elif close_gaps and best_idx >= 0:
                # Auto-close gap with line segment
                path.append((best_next[0], best_next[1], 0.0))  # Insert line (radius=0)
                current = best_next
                segments.pop(best_idx)
                found = True
            
            if not found:
                # Provide helpful error message
                remaining_pts = [f"({s['start'][0]:.2f},{s['start'][1]:.2f})-({s['end'][0]:.2f},{s['end'][1]:.2f})" 
                                 for s in segments[:3]]
                msg = f"Could not stitch LINE/ARC: gap of {best_dist:.4f} at ({current[0]:.2f},{current[1]:.2f}). "
                msg += f"Tolerance={tol}. Try enabling 'Close gaps'. Remaining segments: {', '.join(remaining_pts)}..."
                raise ValueError(msg)

        # If closed, drop the duplicate endpoint to avoid zero-length segment
        if closed and len(path) > 1 and dist((path[-1][0], path[-1][1]), (path[0][0], path[0][1])) <= tol:
            path.pop()

        # Clear radius on first point for our format
        if path:
            z0, x0, _ = path[0]
            path[0] = (z0, x0, 0.0)
        return path, closed

    # end canvas helpers

    def _dxf_align_max_x_to_stock(self) -> None:
        """Auto-compute X offset so that the profile's max X meets the stock surface.

        Tessellates bulge arcs in dxf_raw_verts so the true geometric maximum X
        (which may occur at an arc midpoint, not an endpoint) is used.
        Works in RAW DRAWING UNITS (radius), before any diameter doubling.
        """
        if not self.dxf_raw_verts:
            self.show_status("Немає імпортованого DXF профілю", color="#FFD966")
            return
        try:
            params = self._read_params()
            stock = float(params.stock_diam)
        except Exception:
            self.show_status("Невірний діаметр заготовки", color="#FFD966")
            return

        swap = self.dxf_swap_axes.get()
        flip_x = self.dxf_flip_x.get()

        def raw_to_lathe_x(vx: float, vy: float) -> float:
            x_val = float(vx) if swap else float(vy)
            return -x_val if flip_x else x_val

        def tessellate_bulge(x0, y0, x1, y1, bulge, n=12):
            """Return tessellated (x, y) points along the arc, including endpoints."""
            import math
            if abs(bulge) < 1e-9:
                return [(x0, y0), (x1, y1)]
            dx, dy = x1 - x0, y1 - y0
            chord = math.hypot(dx, dy)
            if chord < 1e-9:
                return [(x0, y0)]
            alpha = 4 * math.atan(abs(bulge))
            r = chord / (2 * math.sin(alpha / 2))
            # Midpoint of chord + perpendicular offset to centre
            mx, my = (x0 + x1) / 2, (y0 + y1) / 2
            nx, ny = -dy / chord, dx / chord          # unit normal
            dist = r * math.cos(alpha / 2)
            sign = 1 if bulge > 0 else -1              # CCW vs CW
            cx = mx + sign * dist * nx
            cy_c = my + sign * dist * ny
            a0 = math.atan2(y0 - cy_c, x0 - cx)
            a1 = math.atan2(y1 - cy_c, x1 - cx)
            if bulge > 0 and a1 < a0:
                a1 += 2 * math.pi
            elif bulge < 0 and a1 > a0:
                a1 -= 2 * math.pi
            pts = []
            for i in range(n + 1):
                t = i / n
                a = a0 + t * (a1 - a0)
                pts.append((cx + r * math.cos(a), cy_c + r * math.sin(a)))
            return pts

        # Collect all tessellated points across all segments
        all_x: list[float] = []
        verts = self.dxf_raw_verts
        for idx, (vx, vy, bulge) in enumerate(verts):
            all_x.append(raw_to_lathe_x(vx, vy))
            nxt = idx + 1
            if nxt < len(verts):
                nx2, ny2, _ = verts[nxt]
                if abs(bulge) > 1e-9:
                    tess = tessellate_bulge(float(vx), float(vy),
                                            float(nx2), float(ny2), bulge)
                    for px, py in tess:
                        all_x.append(raw_to_lathe_x(px, py))

        if not all_x:
            self.show_status("Профіль порожній", color="#FFD966")
            return

        max_raw_x = max(all_x)

        doubling = bool(params.diameter_mode and self.dxf_x_is_radius.get())
        target = stock / 2.0 if doubling else stock

        new_offset = target - max_raw_x
        self.dxf_x_offset.set(round(new_offset, 4))
        self._refresh_dxf_preview()
        unit = "R" if doubling else "X"
        self.show_status(f"X offset = {new_offset:.3f} (max {unit} → {target:.3f})", color="#A9DC76")

    def _dxf_align_min_x_to_axis(self) -> None:
        """Shift profile so its minimum X = 0 (inner edge lands on rotation axis).

        Tessellates bulge arcs so the true geometric minimum is used, not just
        the polyline endpoints. Works in raw drawing units before any doubling.
        """
        if not self.dxf_raw_verts:
            self.show_status("Немає імпортованого DXF профілю", color="#FFD966")
            return

        import math

        swap = self.dxf_swap_axes.get()
        flip_x = self.dxf_flip_x.get()

        def raw_to_lathe_x(vx: float, vy: float) -> float:
            x_val = float(vx) if swap else float(vy)
            return -x_val if flip_x else x_val

        def tessellate_bulge(x0, y0, x1, y1, bulge, n=12):
            if abs(bulge) < 1e-9:
                return [(x0, y0), (x1, y1)]
            dx, dy = x1 - x0, y1 - y0
            chord = math.hypot(dx, dy)
            if chord < 1e-9:
                return [(x0, y0)]
            alpha = 4 * math.atan(abs(bulge))
            r = chord / (2 * math.sin(alpha / 2))
            mx, my = (x0 + x1) / 2, (y0 + y1) / 2
            nx, ny = -dy / chord, dx / chord
            dist = r * math.cos(alpha / 2)
            sign = 1 if bulge > 0 else -1
            cx = mx + sign * dist * nx
            cy_c = my + sign * dist * ny
            a0 = math.atan2(y0 - cy_c, x0 - cx)
            a1 = math.atan2(y1 - cy_c, x1 - cx)
            if bulge > 0 and a1 < a0:
                a1 += 2 * math.pi
            elif bulge < 0 and a1 > a0:
                a1 -= 2 * math.pi
            return [(cx + r * math.cos(a0 + i / n * (a1 - a0)),
                     cy_c + r * math.sin(a0 + i / n * (a1 - a0))) for i in range(n + 1)]

        all_x: list[float] = []
        verts = self.dxf_raw_verts
        for idx, (vx, vy, bulge) in enumerate(verts):
            all_x.append(raw_to_lathe_x(vx, vy))
            nxt = idx + 1
            if nxt < len(verts):
                nx2, ny2, _ = verts[nxt]
                if abs(bulge) > 1e-9:
                    for px, py in tessellate_bulge(float(vx), float(vy),
                                                   float(nx2), float(ny2), bulge):
                        all_x.append(raw_to_lathe_x(px, py))
        if not all_x:
            self.show_status("Профіль порожній", color="#FFD966")
            return

        min_raw_x = min(all_x)
        new_offset = -min_raw_x  # shift so that min → 0
        self.dxf_x_offset.set(round(new_offset, 4))
        self._refresh_dxf_preview()
        self.show_status(f"X offset = {new_offset:.3f} (min X → вісь 0)", color="#A9DC76")

    def _validate_and_refresh_dxf(self, event=None) -> None:
        """Validate Z/X offset entries and refresh preview."""
        def _check(var) -> None:
            # Var may be DoubleVar (returns float, raises TclError on bad text)
            # or StringVar (returns str). Reset to 0.0 only on genuinely bad input.
            try:
                raw = var.get()
                if str(raw).strip():
                    float(raw)
            except Exception:
                var.set(0.0)

        if self.import_source == "3mf":
            _check(self.threemf_z_offset)
        else:
            _check(self.dxf_z_offset)
            _check(self.dxf_x_offset)
        self._refresh_dxf_preview()

    def _refresh_dxf_preview(self) -> None:
        """Recompute DXF preview with current orientation options."""
        if not self.dxf_raw_verts:
            return
        self.dxf_preview_points = self._compute_dxf_points_from_raw()
        self.preview()

    def _refresh_3mf_preview(self) -> None:
        """Recompute 3MF preview with current projection settings (Plane, Convex hull, Simplify).
        Re-projects the mesh using the current plane, convex hull, and simplify settings."""
        # Only refresh if we have a 3MF import in progress (not DXF)
        if self.import_source != "3mf" or not self.dxf_raw_verts_unscaled:
            return
        
        try:
            import trimesh  # type: ignore
        except ImportError:
            self.show_status("Trimesh required for 3MF refresh", color="orange")
            return
        
        # We need the original file path to reload and re-project
        # Since we don't store it, we'll need to re-process from dxf_raw_verts_unscaled
        # But dxf_raw_verts_unscaled is already the final outline; we need the MESH to re-project
        # 
        # For now, trigger a re-import with the last selected path if available
        # OR, we store the 3mf_last_path during import_3mf
        if not hasattr(self, '_3mf_last_path') or not self._3mf_last_path:
            return
        
        try:
            mesh = trimesh.load(self._3mf_last_path, force="mesh")
            if mesh.is_empty:
                raise ValueError("Mesh is empty.")
            
            # Re-project with current plane setting
            plane = self.threemf_plane.get()
            pts_2d, faces = self._project_mesh_to_2d(mesh, plane)
            
            # Re-outline with current convex hull setting
            outline = self._outline_from_projected(pts_2d, faces, self.threemf_convex_hull.get())
            if len(outline) < 3:
                raise ValueError("Could not compute outline from mesh.")
            
            # Re-simplify with current tolerance
            outline = self._simplify_polyline(outline, tol=self.threemf_simplify_tol.get())
            if len(outline) < 3:
                raise ValueError("Outline simplified away; adjust projection or scale.")
            
            # Ensure X is non-negative
            min_x_outline = min(p[1] for p in outline)
            if min_x_outline < 0:
                shift = -min_x_outline
                outline = [(z, x + shift) for z, x in outline]
            
            # Close loop
            if outline[0] != outline[-1]:
                outline.append(outline[0])
            
            # Clamp negative X
            outline = [(z, max(x, 0.0)) for z, x in outline]
            
            # Store unscaled verts and re-apply scaling
            self.dxf_raw_verts_unscaled = [(float(u), float(v), 0.0) for u, v in outline]
            self._apply_3mf_scale_and_refresh()
            self._refresh_dxf_preview()
        except Exception as exc:
            self.show_status(f"3MF refresh error: {exc}", color="red")

    def _show_apply_buttons(self) -> None:
        """Show the Apply imported outline buttons."""
        if hasattr(self, 'apply_dxf_btn'):
            self.apply_dxf_btn.pack(pady=(8, 0))
        if hasattr(self, 'apply_3mf_btn'):
            self.apply_3mf_btn.pack()

    def _hide_apply_buttons(self) -> None:
        """Hide the Apply imported outline buttons."""
        if hasattr(self, 'apply_dxf_btn'):
            self.apply_dxf_btn.pack_forget()
        if hasattr(self, 'apply_3mf_btn'):
            self.apply_3mf_btn.pack_forget()

    def apply_dxf(self) -> None:
        """Commit the current DXF preview points into the editor or as background."""
        if not self.dxf_preview_points:
            messagebox.showinfo("DXF", "No DXF preview loaded. Click Import DXF first.")
            return
        # Check if user wants background image (only for 3MF imports)
        if self.import_source == "3mf" and self.threemf_as_background.get():
            self.background_points = list(self.dxf_preview_points)
            self.dxf_preview_points = None
            self.dxf_raw_verts = None
            self.dxf_closed = False
            self.preview()
            return
        # Normal mode: apply to segments
        self.points = list(self.dxf_preview_points)
        self.dxf_preview_points = None
        self.dxf_raw_verts = None
        self.dxf_closed = False
        self._hide_apply_buttons()

        # Auto-fit stock dimensions to the imported profile
        if self.points:
            profile_max_x = max(pt.x for pt in self.points)
            profile_min_z = min(pt.z for pt in self.points)
            profile_len = abs(profile_min_z)

            # Round up to next 5 mm and add 10% margin
            def ceil5(v: float) -> float:
                import math
                return math.ceil(max(v * 1.1, v + 1.0) / 5.0) * 5.0

            new_diam = ceil5(profile_max_x)
            new_len = ceil5(profile_len)

            if "stock_diam" in self.entries:
                self.entries["stock_diam"].delete(0, "end")
                self.entries["stock_diam"].insert(0, f"{new_diam:.1f}")
            if "stock_len" in self.entries:
                self.entries["stock_len"].delete(0, "end")
                self.entries["stock_len"].insert(0, f"{new_len:.1f}")
        self._refresh_points_list()
        self._set_input_fields(self.points[0])
        self.preview()
        # Switch to Segments tab to show imported points
        self.notebook.select(0)

    def _validate_radius_field(
        self, idx: int | None = None, z_val: float | None = None, x_val: float | None = None, r_val: float | None = None
    ) -> tuple[bool, float | None]:
        """Validate radius field and return (is_valid, corrected_value).
        If radius is too small, returns (True, min_radius) to auto-correct.
        """
        text = self.in_r.get().strip()
        if r_val is None:
            if not text:
                self.in_r.configure(bg=self.r_bg_default, fg=self.r_fg_default)
                return (True, None)
            try:
                r_val = float(text)
            except ValueError:
                self.in_r.configure(bg="misty rose", fg="black")
                return (False, None)
        if idx is None:
            sel = self.seg_list.curselection()
            idx = sel[0] if sel else len(self.points)
        # First point cannot have a radius
        if idx == 0 and abs(r_val) > 1e-9:
            self.in_r.configure(bg="misty rose", fg="black")
            return (False, None)
        prev = None
        if idx and idx - 1 < len(self.points):
            prev = self.points[idx - 1]
        elif self.points:
            prev = self.points[-1]
        if prev is None:
            # No previous point yet; allow empty/zero radius only
            if abs(r_val) < 1e-9:
                self.in_r.configure(bg=self.r_bg_default, fg=self.r_fg_default)
                return (True, None)
            self.in_r.configure(bg="misty rose", fg="black")
            return (False, None)
        prev_z, prev_x = prev.z, prev.x
        if z_val is None:
            z_val = float(self.in_z.get()) if self.in_z.get().strip() else prev_z
        if x_val is None:
            x_val = float(self.in_x.get()) if self.in_x.get().strip() else prev_x
        chord = math.hypot(z_val - prev_z, x_val - prev_x)
        if abs(r_val) < 1e-9:
            self.in_r.configure(bg=self.r_bg_default, fg=self.r_fg_default)
            return (True, None)
        if chord < 1e-9:
            self.in_r.configure(bg="misty rose", fg="black")
            return (False, None)
        min_radius = chord / 2.0
        if abs(r_val) < min_radius:
            # Auto-correct to minimum radius
            corrected = round(min_radius, 3)
            self.in_r.configure(bg=self.r_bg_default, fg=self.r_fg_default)
            return (True, corrected)
        self.in_r.configure(bg=self.r_bg_default, fg=self.r_fg_default)
        return (True, None)

    def _parse_points(self) -> List[Point]:
        if len(self.points) < 2:
            raise ValueError("Need at least two points.")
        return list(self.points)

    def _read_params(self) -> ProfileParams:
        def to_bool(val: str) -> bool:
            return val.strip().lower() in ("1", "true", "yes", "y")

        p = ProfileParams()
        p.cycle = self.entries["cycle"].get().strip().upper() or "G71"
        od_value = self.entries["od"].get() if isinstance(self.entries["od"], tk.StringVar) else self.entries["od"].get()
        p.od = od_value.strip().upper() == "OD"
        metric_value = self.entries["metric"].get() if isinstance(self.entries["metric"], tk.StringVar) else self.entries["metric"].get()
        p.metric = "G21" in metric_value.upper()
        feed_value = self.entries["feed_mode"].get() if isinstance(self.entries["feed_mode"], tk.StringVar) else self.entries["feed_mode"].get()
        p.feed_per_rev = "G95" in feed_value.upper()
        p.stock_diam = float(self.entries["stock_diam"].get())
        p.stock_len = float(self.entries["stock_len"].get())
        p.safe_clear_x = float(self.entries["safe_clear_x"].get())
        p.safe_clear_z = float(self.entries["safe_clear_z"].get())
        p.start_x = float(self.entries["start_x"].get())
        p.start_z = float(self.entries["start_z"].get())
        p.doc = float(self.entries["doc"].get())
        p.retract = float(self.entries["retract"].get())
        p.stock_allow = float(self.entries["stock_allow"].get())
        p.feed_rough = float(self.entries["feed_rough"].get())
        p.feed_finish = float(self.entries["feed_finish"].get())
        p.spindle_rpm = int(float(self.entries["spindle_rpm"].get()))
        spindle_dir_value = self.entries["spindle_dir"].get() if isinstance(self.entries["spindle_dir"], tk.StringVar) else self.entries["spindle_dir"].get()
        p.spindle_dir = "M4" if "M4" in spindle_dir_value.upper() else "M3"
        p.tool = self.entries["tool"].get().strip() or "1"
        p.tool_finish = self.entries.get("tool_finish", self.entries["tool"]).get().strip() or p.tool
        p.coolant = to_bool(self.entries["coolant"].get())
        diam_value = self.entries["diameter_mode"].get() if isinstance(self.entries["diameter_mode"], tk.StringVar) else self.entries["diameter_mode"].get()
        p.diameter_mode = "G7" in str(diam_value).upper()
        p.fill_profile = self.fill_profile_var.get() if hasattr(self, "fill_profile_var") else False
        p.profile_fill_color = self.params.profile_fill_color
        p.fill_profile_half = self.fill_profile_half_var.get() if hasattr(self, "fill_profile_half_var") else False
        p.background_image_color = self.params.background_image_color
        p.direct_finish_only = self.direct_finish_var.get() if hasattr(self, "direct_finish_var") else False
        return p

    def preview(self) -> None:
        try:
            params = self._read_params()
            if self.dxf_preview_points is not None:
                pts = self.dxf_preview_points
                if len(pts) < 2:
                    self._show_apply_buttons()
                    self.show_status(
                        "Профіль порожній після орієнтації — увімкніть 'Swap X/Z' та/або 'Flip X'",
                        color="orange",
                    )
                    return
                if all(pt.x < -1e-9 for pt in pts):
                    self._show_apply_buttons()
                    self.show_status(
                        "Всі X від'ємні — увімкніть 'Flip X' (або 'Swap X/Z' + 'Flip X') для правильної орієнтації",
                        color="orange",
                    )
                    # Still draw so user can see the shape (mirrored)
                self._show_apply_buttons()
            elif len(self.points) < 2:
                self._hide_apply_buttons()
                self._draw_preview_empty(params)
                return
            else:
                pts = self._parse_points()
                self._hide_apply_buttons()

            sel = self.seg_list.curselection()
            # Use selected point index as owner for highlighting
            if sel and len(pts) >= 2:
                selected_owner = min(sel[0], len(pts) - 2)
            else:
                selected_owner = None
            self._draw_preview(pts, params, selected_owner)
        except Exception as exc:
            self.show_status(f"Preview error: {exc}")

    def generate(self) -> None:
        self._force_save("before_generate")
        try:
            pts = self._parse_points()
            params = self._read_params()
            # Create an operation manager with a single profile operation
            manager = OperationManager(metric=params.metric, spindle_rpm=params.spindle_rpm, spindle_dir=params.spindle_dir)
            profile_op = ProfileOperation(
                name="Profile",
                points=pts,
                params=params,
                tool=params.tool,
            )
            manager.add_operation(profile_op)
            gcode = manager.generate()

            # Validated points may differ from raw `pts` (corner filtering,
            # duplicate removal) -- use what the generator actually emitted
            # for the advisory check, mirroring contour_subprogram_gcode's
            # validation pipeline.
            checked_pts = profile_op.generator._validate_points(pts)
            checked_pts = profile_op.generator._filter_infeasible_corners(checked_pts)
            advisory = profile_op.generator.check_start_x_advisory(checked_pts, params)

            out_path = "generated_profile.ngc"
            with open(out_path, "w", encoding="ascii") as fh:
                fh.write(gcode)
            out_path_abs = os.path.abspath(out_path)
            self.output_text.delete("1.0", tk.END)
            self.output_text.insert(tk.END, gcode)
            status_msg = f"G-code generated and saved to {out_path_abs}"
            if advisory:
                status_msg += f"\n⚠ {advisory}"
            self.show_status(status_msg, color="#FFD966")
        except Exception as exc:
            self.show_status(f"Generate error: {exc}")
        self.preview_job = None

    def write_stdout(self) -> None:
        """
        Generate G-code, save to a .ngc file, and load it into the running AXIS instance.

        This avoids AXIS re-running the .py as a FILTER program.
        """
        self._force_save("before_write_stdout")
        try:
            pts = self._parse_points()
            params = self._read_params()

            manager = OperationManager(metric=params.metric, spindle_rpm=params.spindle_rpm, spindle_dir=params.spindle_dir)
            profile_op = ProfileOperation(
                name="Profile",
                points=pts,
                params=params,
                tool=params.tool,
            )
            manager.add_operation(profile_op)
            gcode = manager.generate()

            # Where to save:
            # Prefer LinuxCNC standard nc_files; fallback to current directory.
            nc_dir = Path.home() / "linuxcnc" / "nc_files"
            if not nc_dir.exists():
                nc_dir = Path.cwd()

            # Unique filename (avoid overwriting while testing)
            ts = time.strftime("%Y%m%d-%H%M%S")
            out_path = nc_dir / f"generated_profile_{ts}.ngc"

            out_path.write_text(gcode, encoding="ascii")

            # Ask AXIS to load the generated file immediately
            # axis-remote is shipped with LinuxCNC and controls the running AXIS GUI.
            subprocess.run(["axis-remote", str(out_path)], check=False)

            self.show_status(f"Loaded in AXIS: {out_path}", color="#FFD966")

            # Optional: close profiler after sending
            self.root.quit()

        except Exception as exc:
            self.show_status(f"Write error: {exc}")

    def save_gcode(self) -> None:
        """Save the current G-code output to a user-selected file."""
        self._force_save("before_save_gcode")
        gcode_text = self.output_text.get("1.0", tk.END).strip()
        if not gcode_text:
            messagebox.showwarning("Save G-code", "No G-code to save. Click 'Generate G-code' first.")
            return
        path = filedialog.asksaveasfilename(
            title="Save G-code",
            defaultextension=".ngc",
            filetypes=[("G-code files", "*.ngc"), ("Text files", "*.txt"), ("All files", "*.*")],
        )
        if not path:
            return
        try:
            with open(path, "w") as f:
                f.write(gcode_text)
            messagebox.showinfo("G-code saved", f"G-code saved to {path}")
        except Exception as exc:
            self.show_status(f"Save error: {exc}")

    def _schedule_preview(self) -> None:
        if self.preview_job:
            self.root.after_cancel(self.preview_job)
        self.preview_job = self.root.after(400, self.preview)

    def _choose_fill_color(self) -> None:
        try:
            from tkinter import colorchooser
            color = colorchooser.askcolor(
                color=self.params.profile_fill_color,
                title="Choose Profile Fill Color",
            )
            if color[1]:
                self.params.profile_fill_color = color[1]
                self.color_swatch.config(bg=color[1], activebackground=color[1])
                self._schedule_preview()
        except Exception as exc:
            self.show_status(f"Color picker error: {exc}")

    def _choose_background_color(self) -> None:
        try:
            from tkinter import colorchooser
            color = colorchooser.askcolor(
                color=self.params.background_image_color,
                title="Choose Background Image Color",
            )
            if color[1]:
                self.params.background_image_color = color[1]
                self.bg_color_swatch.config(bg=color[1], activebackground=color[1])
                self._schedule_preview()
        except Exception as exc:
            self.show_status(f"Color picker error: {exc}")

    def _toggle_background_visibility(self) -> None:
        self.show_background_var.set(not self.show_background_var.get())
        self.bg_btn.config(text=self._tr(self._bg_btn_key()))
        self._schedule_preview()
    
    def _delete_background(self) -> None:
        """Clear background image template."""
        if self.background_points:
            if messagebox.askyesno("Delete Background", "Remove the background image template?"):
                self.background_points = None
                self.show_status("Background image removed", color="green")
                self._schedule_preview()
        else:
            messagebox.showinfo("Delete Background", "No background image loaded.")

    def _constrain_point_045(self, z0: float, x0: float, z1: float, x1: float) -> tuple[float, float]:
        dz = z1 - z0
        dx = x1 - x0
        if abs(dz) < 1e-12 and abs(dx) < 1e-12:
            return z1, x1
        adz, adx = abs(dz), abs(dx)
        err_h = adx  # horizontal => dx -> 0
        err_v = adz  # vertical => dz -> 0
        err_d = abs(adx - adz)  # diagonal => |dx| == |dz|
        if err_h <= err_v and err_h <= err_d:
            return z1, x0
        if err_v <= err_d:
            return z0, x1
        sdz = 1.0 if dz >= 0 else -1.0
        sdx = 1.0 if dx >= 0 else -1.0
        m = min(adz, adx)
        return z0 + sdz * m, x0 + sdx * m

    def _snap_value(self, v: float, step: float) -> float:
        if step <= 0:
            return v
        return round(v / step) * step

    def _snap_point(self, z: float, x: float) -> tuple[float, float]:
        if not self.snap_enabled.get():
            return z, x
        try:
            step = float(self.snap_step.get())
        except Exception:
            step = 0.0
        if step <= 0:
            if not self._snap_warned_invalid_step:
                self.show_status("Snap step must be > 0", color="#FFD966")
                self._snap_warned_invalid_step = True
            return z, x
        # Valid step: clear warning latch
        self._snap_warned_invalid_step = False
        return self._snap_value(z, step), self._snap_value(x, step)

    def _validate_snap_step(self, event=None) -> None:
        try:
            step = float(self.snap_step.get())
        except Exception:
            step = 0.0
        if self.snap_enabled.get() and step <= 0:
            if not self._snap_warned_invalid_step:
                self.show_status("Snap step must be > 0", color="#FFD966")
                self._snap_warned_invalid_step = True
        else:
            self._snap_warned_invalid_step = False

    def _preview_x_factor(self, params: ProfileParams) -> float:
        """Return X scale factor for preview: 0.5 in diameter mode (half-profile), 1.0 otherwise."""
        return 0.5 if params.diameter_mode else 1.0

    def _preview_x_sign(self) -> float:
        """Return X sign for preview mirroring (LinuxCNC-style)."""
        return -1.0 if self.preview_mirror_x_var.get() else 1.0

    def _draw_preview_empty(self, params: ProfileParams) -> None:
        """Render an empty preview with stock outline and a valid view cache."""
        xf = self._preview_x_factor(params)  # Apply X scaling for preview
        xs = self._preview_x_sign()  # Apply X sign for mirror

        self.canvas.delete("all")
        # Reset tooltip and rubber-band handles removed by delete("all")
        self.tooltip_text_id = None
        self.tooltip_bg_id = None
        self.rubber_line_id = None

        width = int(self.canvas.winfo_width()) or 600
        height = int(self.canvas.winfo_height()) or 400

        # Fixed stock envelope (apply preview X scaling and sign)
        stock_x_preview = params.stock_diam * xf * xs
        z_stock_max = 0.0
        z_stock_min = -params.stock_len

        # Symmetric bounds: max positive half → mirror both sides
        raw_max_x = max(0.0, abs(stock_x_preview))
        min_z = z_stock_min + self.pan_z_offset
        max_z = z_stock_max + self.pan_z_offset

        # Include background points in bounds if present (apply preview X scaling and sign)
        if self.background_points:
            bg_xs = [pt.x * xf * xs for pt in self.background_points]
            bg_zs = [pt.z for pt in self.background_points]
            raw_max_x = max(raw_max_x, max(abs(v) for v in bg_xs))
            raw_min_z = min(bg_zs)
            raw_max_z = max(bg_zs)
            min_z = min(min_z, raw_min_z + self.pan_z_offset)
            max_z = max(max_z, raw_max_z + self.pan_z_offset)

        raw_min_x = -raw_max_x  # Always symmetric
        min_x = raw_min_x + self.pan_x_offset
        max_x = raw_max_x + self.pan_x_offset
        
        pad = 5.0
        min_z -= pad
        max_z += pad
        min_x -= pad
        max_x += pad

        scale_z = width / (max_z - min_z) if max_z != min_z else 1.0
        scale_x = height / (max_x - min_x) if max_x != min_x else 1.0
        scale = 0.9 * min(scale_z, scale_x) * self.zoom_factor

        def to_canvas(z: float, x: float) -> Tuple[float, float]:
            cz = (z - min_z) * scale + width * 0.05
            cy = height - ((x - min_x) * scale + height * 0.05)
            return cz, cy

        # Draw stock rectangle (lowest layer, in preview space)
        if params.od:
            # Upper half: axis → +stock_radius
            z0, y0 = to_canvas(z_stock_min, 0.0)
            z1, y1 = to_canvas(z_stock_max, stock_x_preview)
            self.canvas.create_rectangle(z0, y0, z1, y1, outline="#bbb", fill="#f5f5f5", tags="stock")
            # Lower half (mirror): axis → -stock_radius
            z0m, y0m = to_canvas(z_stock_min, 0.0)
            z1m, y1m = to_canvas(z_stock_max, -stock_x_preview)
            self.canvas.create_rectangle(z0m, y0m, z1m, y1m, outline="#bbb", fill="#f5f5f5", tags="stock")
        else:
            z0, y0 = to_canvas(z_stock_min, raw_min_x)
            z1, y1 = to_canvas(z_stock_max, raw_max_x)
            self.canvas.create_rectangle(z0, y0, z1, y1, outline="#bbb", fill="#f5f5f5", tags="stock")
        self.canvas.tag_lower("stock")

        # Rotation axis (centerline) at X=0: dash-dot, slightly longer than stock
        ext = 0.05 * max(abs(z_stock_max - z_stock_min), 1.0)
        cz_a, cy_a = to_canvas(z_stock_min - ext, 0.0)
        cz_b, cy_b = to_canvas(z_stock_max + ext, 0.0)
        self.canvas.create_line(cz_a, cy_a, cz_b, cy_b,
                                fill="#d08770", dash=(12, 4, 2, 4), width=1, tags="axis")
        
        # Draw background template if present (in preview space)
        if self.background_points and self.show_background_var.get():
            bg_coords: List[float] = []
            for pt in self.background_points:
                cz, cy = to_canvas(pt.z, pt.x * xf * xs)
                bg_coords.extend([cz, cy])
            if bg_coords:
                # Draw as filled polygon with configurable background tint
                self.canvas.create_polygon(
                    *bg_coords,
                    fill=params.background_image_color,
                    outline="#888",
                    width=1,
                    tags="background",
                )
                self.canvas.tag_lower("background")
                self.canvas.tag_lower("stock")  # Ensure stock stays below background

        self._view_cache = {
            "min_z": min_z,
            "min_x": min_x,
            "scale": scale,
        }

    def _draw_preview(self, pts: Sequence[Point], params: ProfileParams, selected_owner: int | None = None) -> None:
        xf = self._preview_x_factor(params)  # Apply X scaling for preview
        
        self.canvas.delete("all")
        # Reset tooltip item handles since delete("all") removes them
        self.tooltip_text_id = None
        self.tooltip_bg_id = None
        # Reset rubber-band line handle
        self.rubber_line_id = None
        
        # Get preview transform
        xs = self._preview_x_sign()  # Apply X sign for mirror
        
        # Fixed stock envelope in model coordinates
        z_stock_max = 0.0
        z_stock_min = -params.stock_len
        stock_x_model = params.stock_diam  # Keep original model value for dimensions
        stock_x_preview = params.stock_diam * xf * xs  # Apply preview X scaling and sign for display

        # Compute bounds from profile points (apply preview X scaling and sign for display)
        xs_model = [pt.x for pt in pts]  # Keep original model values for dimensions
        xs_preview = [pt.x * xf * xs for pt in pts]  # Preview-scaled and signed for display
        zs = [pt.z for pt in pts]
        raw_max_x = max(xs_preview + [0.0, stock_x_preview])
        raw_min_z = min(zs)
        raw_max_z = max(zs)

        # Track original model dimensions for dimension text
        model_min_x = min(xs_model + [0])
        model_max_x = max(xs_model + [stock_x_model])

        # Include background points in bounds if present (apply preview X scaling and sign)
        if self.background_points and self.show_background_var.get():
            bg_xs_model = [pt.x for pt in self.background_points]  # Model values
            bg_xs_preview = [pt.x * xf * xs for pt in self.background_points]  # Preview-scaled and signed
            bg_zs = [pt.z for pt in self.background_points]
            raw_max_x = max(raw_max_x, max(bg_xs_preview))
            raw_min_z = min(raw_min_z, min(bg_zs))
            raw_max_z = max(raw_max_z, max(bg_zs))
            model_min_x = min(model_min_x, min(bg_xs_model))
            model_max_x = max(model_max_x, max(bg_xs_model))

        # Always include fixed stock envelope in bounds
        raw_min_z = min(raw_min_z, z_stock_min)
        raw_max_z = max(raw_max_z, z_stock_max)

        # SYMMETRIC bounds: always show full cross-section (profile + mirror below axis)
        raw_min_x = -raw_max_x

        min_x = raw_min_x + self.pan_x_offset
        max_x = raw_max_x + self.pan_x_offset
        min_z = raw_min_z + self.pan_z_offset
        max_z = raw_max_z + self.pan_z_offset
        pad = 5.0
        min_x -= pad
        max_x += pad
        min_z -= pad
        max_z += pad

        width = int(self.canvas.winfo_width()) or 600
        height = int(self.canvas.winfo_height()) or 400

        def to_canvas(z: float, x: float) -> Tuple[float, float]:
            if max_z == min_z:
                scale_z = 1.0
            else:
                scale_z = width / (max_z - min_z)
            if max_x == min_x:
                scale_x = 1.0
            else:
                scale_x = height / (max_x - min_x)
            scale = 0.9 * min(scale_z, scale_x) * self.zoom_factor
            cz = (z - min_z) * scale + width * 0.05
            cy = height - ((x - min_x) * scale + height * 0.05)
            return cz, cy

        # Stock outline - draw BOTH halves for full symmetric cross-section view
        if params.od:
            # Upper half (OD turning): axis (0) → +stock_radius
            z0, y0 = to_canvas(z_stock_min, 0.0)
            z1, y1 = to_canvas(z_stock_max, stock_x_preview)
            self.canvas.create_rectangle(z0, y0, z1, y1, outline="#bbb", fill="#f5f5f5", tags="stock")
            # Lower half (mirror): axis (0) → -stock_radius
            z0m, y0m = to_canvas(z_stock_min, 0.0)
            z1m, y1m = to_canvas(z_stock_max, -stock_x_preview)
            self.canvas.create_rectangle(z0m, y0m, z1m, y1m, outline="#bbb", fill="#f5f5f5", tags="stock")
        else:
            z0, y0 = to_canvas(z_stock_min, raw_min_x)
            z1, y1 = to_canvas(z_stock_max, raw_max_x)
            self.canvas.create_rectangle(z0, y0, z1, y1, outline="#bbb", fill="#f5f5f5", tags="stock")
        self.canvas.tag_lower("stock")

        # Rotation axis (centerline) at X=0: dash-dot, slightly longer than stock
        ext = 0.05 * max(abs(z_stock_max - z_stock_min), 1.0)
        cz_a, cy_a = to_canvas(z_stock_min - ext, 0.0)
        cz_b, cy_b = to_canvas(z_stock_max + ext, 0.0)
        self.canvas.create_line(cz_a, cy_a, cz_b, cy_b,
                                fill="#d08770", dash=(12, 4, 2, 4), width=1, tags="axis")
        
        # Draw background template if present (in preview space)
        if self.background_points and self.show_background_var.get():
            bg_coords: List[float] = []
            for pt in self.background_points:
                cz, cy = to_canvas(pt.z, pt.x * xf * xs)
                bg_coords.extend([cz, cy])
            if bg_coords:
                # Draw as filled polygon with configurable background tint
                self.canvas.create_polygon(
                    *bg_coords,
                    fill=params.background_image_color,
                    outline="#888",
                    width=1,
                )

        # Build polyline with arc sampling for preview and segment highlighting
        # Note: sampled points use preview-scaled and signed X values for drawing
        sampled: List[Tuple[float, float]] = []
        segments: List[List[Tuple[float, float]]] = []
        seg_owner_idx: List[int] = []
        prev_point = (pts[0].z, pts[0].x * xf * xs)  # Apply preview X scaling and sign
        sampled.append(prev_point)
        i = 1
        while i < len(pts):
            cur = pts[i]
            nxt = pts[i + 1] if i + 1 < len(pts) else None
            # Arc move uses R format directly (apply preview X scaling and sign)
            if abs(cur.r) > 1e-6:
                seg = self._sample_arc(prev_point, (cur.z, cur.x * xf * xs), cur.r, segments=20)
                sampled += seg[1:]
                segments.append(seg)
                seg_owner_idx.append(i - 1)
                prev_point = (cur.z, cur.x * xf * xs)
                i += 1
                continue

            # Corner word (A/C) for chamfer/fillet when a next point exists (apply preview X scaling and sign)
            if (
                cur.corner_type
                and cur.corner_value is not None
                and nxt is not None
                and abs(cur.r) < 1e-6
            ):
                # Create preview-scaled and signed point objects for corner calculation
                cur_preview = Point(cur.z, cur.x * xf * xs, 0.0, cur.corner_type, cur.corner_value)
                nxt_preview = Point(nxt.z, nxt.x * xf * xs, nxt.r, nxt.corner_type, nxt.corner_value)
                p1, p2, corner_seg = self._preview_corner(prev_point, cur_preview, nxt_preview)
                if corner_seg:
                    if p1 != prev_point:
                        seg_line = [prev_point, p1]
                        segments.append(seg_line)
                        seg_owner_idx.append(i - 1)
                        sampled.append(p1)
                    segments.append(corner_seg)
                    seg_owner_idx.append(i - 1)
                    sampled += corner_seg[1:]
                    prev_point = p2
                    i += 1
                    continue
                # fall through to straight if corner could not be built

            # Straight line (apply preview X scaling and sign)
            seg = [prev_point, (cur.z, cur.x * xf * xs)]
            segments.append(seg)
            seg_owner_idx.append(i - 1)
            sampled.append((cur.z, cur.x * xf * xs))
            prev_point = (cur.z, cur.x * xf * xs)
            i += 1

        # Draw full profile first
        coords: List[float] = []
        for z, x in sampled:
            cz, cy = to_canvas(z, x)
            coords.extend([cz, cy])
        if coords and params.fill_profile:
            fill_coords = list(coords)
            last_z, _last_x = sampled[-1]
            first_z, _first_x = sampled[0]
            cz_bottom, cy_bottom = to_canvas(last_z, 0.0)
            cz_start, cy_start = to_canvas(first_z, 0.0)
            fill_coords.extend([cz_bottom, cy_bottom, cz_start, cy_start])
            # Use stipple pattern for transparency effect
            stipple_pattern = "gray12" if params.fill_profile_half else ""
            self.canvas.create_polygon(
                *fill_coords,
                fill=params.profile_fill_color,
                outline="",
                stipple=stipple_pattern,
                tags="profile_fill",
            )
        if coords:
            self.canvas.create_line(*coords, fill="#0066cc", width=2)

        # Mirror profile below the axis (lower half of cross-section, display only)
        if params.od and len(sampled) >= 2:
            mirror_coords: List[float] = []
            for z, x in sampled:
                cz, cy = to_canvas(z, -x)  # negate X to mirror below axis
                mirror_coords.extend([cz, cy])
            if mirror_coords:
                self.canvas.create_line(*mirror_coords, fill="#7bb3d9", width=1)

        # Store segments and owner mapping for hit-testing and selection mapping
        self._segments_for_hit_test = segments
        self._seg_owner_idx = seg_owner_idx

        # Highlight all pieces owned by selected point index
        if selected_owner is not None:
            for idx, piece in enumerate(segments):
                if idx < len(seg_owner_idx) and seg_owner_idx[idx] == selected_owner:
                    seg_coords: List[float] = []
                    for z, x in piece:
                        cz, cy = to_canvas(z, x)
                        seg_coords.extend([cz, cy])
                    if seg_coords:
                        self.canvas.create_line(*seg_coords, fill="#d22", width=3)

        # Start point marker
        if coords:
            self.canvas.create_oval(
                coords[0] - 3, coords[1] - 3, coords[0] + 3, coords[1] + 3, fill="#d22"
            )
        # Highlight selected point (if any) — defensive guard against stale selection
        if (self.selected_point_idx is not None 
            and 0 <= self.selected_point_idx < len(pts)):
            try:
                sel_pt = pts[self.selected_point_idx]
                sel_cz, sel_cy = self._model_to_canvas(sel_pt.z, sel_pt.x)
                r = 5
                self.canvas.create_oval(
                    sel_cz - r,
                    sel_cy - r,
                    sel_cz + r,
                    sel_cy + r,
                    outline="#ff9900",
                    width=2,
                    fill="",
                )
            except Exception:
                pass
        # Bounding box dimensions in work units (mm with G21) - use original model dimensions
        span_z = raw_max_z - raw_min_z
        span_x = model_max_x - model_min_x  # Use model dimensions, not preview-scaled
        self.canvas.create_text(
            10,
            10,
            anchor="nw",
            text=f"Box Z span: {span_z:.3f}  X span: {span_x:.3f}",
            fill="#444",
            font=("TkDefaultFont", 11),
        )
        # Track view for panning
        self._view_cache = {
            "min_z": min_z,
            "min_x": min_x,
            "scale": 0.9 * min(
                width / (max_z - min_z) if max_z != min_z else 1.0,
                height / (max_x - min_x) if max_x != min_x else 1.0,
            )
            * self.zoom_factor,
        }

    def _canvas_to_model(self, cz: float, cy: float) -> Tuple[float, float]:
        """Convert canvas pixel coordinates to model Z,X using the current view cache.
        Accounts for preview X scaling (xf) and mirror sign (xs) when converting back."""
        if not hasattr(self, "_view_cache"):
            raise RuntimeError("View not initialized")
        view = getattr(self, "_view_cache", None)
        if not view:
            raise RuntimeError("View not initialized")
        params = self._read_params()
        xf = self._preview_x_factor(params)
        xs = self._preview_x_sign()
        width = int(self.canvas.winfo_width()) or 600
        height = int(self.canvas.winfo_height()) or 400
        scale = view["scale"]
        min_z = view["min_z"]
        min_x = view["min_x"]
        z = ((cz - width * 0.05) / scale) + min_z
        x_preview = (((height - cy) - height * 0.05) / scale) + min_x
        x = x_preview / (xf * xs)  # Undo both xf scaling and xs mirror sign
        return z, x

    def _model_to_canvas(self, z: float, x: float) -> Tuple[float, float]:
        """Convert model Z,X to canvas pixel coordinates using the current view cache.
        Applies both preview X scaling (xf) and mirror sign (xs)."""
        if not hasattr(self, "_view_cache"):
            raise RuntimeError("View not initialized")
        view = getattr(self, "_view_cache", None)
        if not view:
            raise RuntimeError("View not initialized")
        params = self._read_params()
        xf = self._preview_x_factor(params)
        xs = self._preview_x_sign()
        width = int(self.canvas.winfo_width()) or 600
        height = int(self.canvas.winfo_height()) or 400
        scale = view["scale"]
        min_z = view["min_z"]
        min_x = view["min_x"]
        x_preview = x * xf * xs  # Apply both xf scaling and xs mirror sign
        cz = (z - min_z) * scale + width * 0.05
        cy = height - ((x_preview - min_x) * scale + height * 0.05)
        return cz, cy

    def _preview_to_canvas(self, z: float, x_preview: float) -> Tuple[float, float]:
        """Convert preview-space (z, x_preview) directly to canvas coordinates.

        Unlike _model_to_canvas, this method takes x that is already scaled by
        xf * xs (as stored in _segments_for_hit_test). Using _model_to_canvas on
        those values would apply xf a second time, offsetting hit detection.
        """
        if not hasattr(self, "_view_cache") or not self._view_cache:
            raise RuntimeError("View not initialized")
        view = self._view_cache
        scale = view["scale"]
        min_z = view["min_z"]
        min_x = view["min_x"]
        width = int(self.canvas.winfo_width()) or 600
        height = int(self.canvas.winfo_height()) or 400
        cz = (z - min_z) * scale + width * 0.05
        cy = height - ((x_preview - min_x) * scale + height * 0.05)
        return cz, cy

    def hit_test_point(self, canvas_x: float, canvas_y: float, tol_px: float = 10.0) -> int | None:
        """Return index of nearest point within pixel tolerance, else None.
        Applies preview X factor via _model_to_canvas (do not double-apply)."""
        if not hasattr(self, "_view_cache") or not self._view_cache:
            return None
        tol2 = tol_px * tol_px
        best_idx = None
        best_d2 = tol2
        for idx, pt in enumerate(self.points):
            try:
                # _model_to_canvas already applies preview X scaling
                cx, cy = self._model_to_canvas(pt.z, pt.x)
            except (RuntimeError, AttributeError):
                return None
            dx = cx - canvas_x
            dy = cy - canvas_y
            d2 = dx * dx + dy * dy
            if d2 <= best_d2:
                best_d2 = d2
                best_idx = idx
        return best_idx

    def hit_test_drawn_segment(self, canvas_x: float, canvas_y: float, tol_px: float = 10.0) -> int | None:
        """Return index of nearest drawn piece (from preview) within pixel tolerance, else None.
        Uses self._segments_for_hit_test built during preview to respect corners/arcs.
        """
        if not hasattr(self, "_view_cache") or not self._view_cache:
            return None
        pieces = getattr(self, "_segments_for_hit_test", None)
        if not pieces:
            return None
        tol2 = tol_px * tol_px
        best_k = None
        best_d2 = tol2
        for k, piece in enumerate(pieces):
            if not piece or len(piece) < 2:
                continue
            # Check each subsegment within the piece
            for j in range(len(piece) - 1):
                (z0, x0) = piece[j]
                (z1, x1) = piece[j + 1]
                try:
                    # piece coords are already preview-scaled (xf*xs applied during draw);
                    # use _preview_to_canvas to avoid applying xf a second time.
                    x0c, y0c = self._preview_to_canvas(z0, x0)
                    x1c, y1c = self._preview_to_canvas(z1, x1)
                except (RuntimeError, AttributeError):
                    continue
                dx = x1c - x0c
                dy = y1c - y0c
                seg_len2 = dx * dx + dy * dy
                if seg_len2 <= 1e-12:
                    continue
                t = ((canvas_x - x0c) * dx + (canvas_y - y0c) * dy) / seg_len2
                t = max(0.0, min(1.0, t))
                proj_x = x0c + t * dx
                proj_y = y0c + t * dy
                d2 = (canvas_x - proj_x) ** 2 + (canvas_y - proj_y) ** 2
                if d2 <= best_d2:
                    best_d2 = d2
                    best_k = k
        return best_k

    def _apply_radius_at_selected(self) -> None:
        """Prompt for radius and store fillet corner metadata on the selected point."""
        idx = self.selected_point_idx
        if idx is None or idx <= 0 or idx >= len(self.points) - 1:
            return

        # Prompt user for radius
        r_val = simpledialog.askfloat(
            "Fillet radius",
            "Enter fillet radius (mm):",
            initialvalue=self.last_radius_value,
            minvalue=0.0,
        )
        if r_val is None:
            return  # cancelled; keep selection visible
        if r_val <= 0:
            self.show_status("Radius must be > 0.", color="red")
            return

        self.last_radius_value = r_val

        # Store as corner metadata; clear arc radius
        pt = self.points[idx]
        self.points[idx] = Point(pt.z, pt.x, 0.0, "A", r_val)

        self.selected_point_idx = None
        self._refresh_points_list()
        self.preview()

    def _apply_chamfer_at_selected(self) -> None:
        """Prompt for chamfer length and store chamfer corner metadata on the selected point."""
        idx = self.selected_point_idx
        if idx is None or idx <= 0 or idx >= len(self.points) - 1:
            return

        # Prefill dialog with existing corner value if present
        default_val = self.last_chamfer_value
        if self.points[idx].corner_value is not None and self.points[idx].corner_value > 0:
            default_val = self.points[idx].corner_value

        # Prompt user for chamfer length
        c_val = simpledialog.askfloat(
            "Chamfer length",
            "Enter chamfer length (mm):",
            initialvalue=default_val,
            minvalue=0.0,
        )
        if c_val is None:
            return  # cancelled; keep selection visible
        if c_val <= 0:
            self.show_status("Chamfer length must be > 0.", color="red")
            return

        self.last_chamfer_value = c_val

        # Store as corner metadata; clear arc radius
        pt = self.points[idx]
        self.points[idx] = Point(pt.z, pt.x, 0.0, "C", c_val)

        self.selected_point_idx = None
        self._refresh_points_list()
        self.preview()

    def _preview_corner(
        self, prev: Tuple[float, float], corner_pt: Point, nxt: Point
    ) -> Tuple[Tuple[float, float], Tuple[float, float], List[Tuple[float, float]] | None]:
        """Return trimmed entry/exit points and an optional chamfer/fillet segment for preview."""
        c = (corner_pt.z, corner_pt.x)
        v_in = (c[0] - prev[0], c[1] - prev[1])  # direction into corner (travel)
        v_out = (nxt.z - c[0], nxt.x - c[1])     # direction leaving corner
        len_in = math.hypot(*v_in)
        len_out = math.hypot(*v_out)
        if len_in < 1e-6 or len_out < 1e-6:
            return prev, c, None
        u_in = (v_in[0] / len_in, v_in[1] / len_in)
        u_out = (v_out[0] / len_out, v_out[1] / len_out)

        # Guard near-straight line (no meaningful corner)
        dot = max(min(u_in[0] * u_out[0] + u_in[1] * u_out[1], 1.0), -1.0)
        theta = math.acos(dot)
        if theta < 1e-4 or abs(math.pi - theta) < 1e-4:
            return prev, c, None

        if corner_pt.corner_type.upper() == "C":
            offset = corner_pt.corner_value or 0.0
            offset = min(offset, len_in * 0.49, len_out * 0.49)  # keep inside available legs
            p1 = (c[0] - u_in[0] * offset, c[1] - u_in[1] * offset)
            p2 = (c[0] + u_out[0] * offset, c[1] + u_out[1] * offset)
            chamfer_seg = [p1, p2]
            return p1, p2, chamfer_seg

        # Fillet
        r = corner_pt.corner_value or 0.0
        t = r * math.tan(theta / 2.0)
        if t >= len_in or t >= len_out:
            return prev, c, None
        p1 = (c[0] - u_in[0] * t, c[1] - u_in[1] * t)
        p2 = (c[0] + u_out[0] * t, c[1] + u_out[1] * t)
        cross = u_in[0] * u_out[1] - u_in[1] * u_out[0]
        r_signed = -r if cross > 0 else r  # left turn => CCW => negative radius for sampler
        arc_seg = self._sample_arc(p1, p2, r_signed, segments=12)
        return p1, p2, arc_seg

    def _sample_arc(
        self,
        start: Tuple[float, float],
        end: Tuple[float, float],
        radius: float,
        segments: int = 16,
    ) -> List[Tuple[float, float]]:
        """Return points along an arc from start to end using R format (G2/G3 style)."""
        (z0, x0), (z1, x1) = start, end
        r = abs(radius)
        vz, vx = z1 - z0, x1 - x0
        chord = math.hypot(vz, vx)
        if chord < 1e-9 or r < chord / 2:
            # Invalid arc; fall back to straight line
            return [start, end]
        mid_z, mid_x = (z0 + z1) / 2.0, (x0 + x1) / 2.0
        perp_z, perp_x = -vx / chord, vz / chord  # unit perpendicular
        h = math.sqrt(max(r * r - (chord * chord) / 4.0, 0.0))
        cw = radius > 0  # follow generator convention: +R => G2 (CW), -R => G3 (CCW)
        sign = -1.0 if cw else 1.0
        cx, cz = mid_x + sign * perp_x * h, mid_z + sign * perp_z * h  # note (x,z) vs (z,x)

        # Angles in ZX plane
        a0 = math.atan2(x0 - cx, z0 - cz)
        a1 = math.atan2(x1 - cx, z1 - cz)
        if cw and a1 > a0:
            a1 -= 2 * math.pi
        if not cw and a1 < a0:
            a1 += 2 * math.pi
        pts: List[Tuple[float, float]] = []
        for i in range(segments + 1):
            t = i / segments
            a = a0 + (a1 - a0) * t
            z = cz + r * math.cos(a)
            x = cx + r * math.sin(a)
            pts.append((z, x))
        return pts

    # end helpers

    def _start_x(self, pts: Sequence[Point], params: ProfileParams) -> float:
        """Preview helper: mirror generator start X calculation."""
        profile_max_x = max(pt.x for pt in pts)
        profile_min_x = min(pt.x for pt in pts)
        if params.od:
            base = max(params.stock_diam, profile_max_x)
            return base + 2 * params.safe_clear_x if params.diameter_mode else base + params.safe_clear_x
        base = min(profile_min_x, params.stock_diam)
        return max(base - 2 * params.safe_clear_x, 0.1) if params.diameter_mode else max(
            base - params.safe_clear_x, 0.1
        )


def main() -> None:
    _ensure_tk_imported()
    root = tk.Tk()
    # Open maximized on all platforms
    try:
        root.state("zoomed")           # Windows / some Linux WMs
    except Exception:
        try:
            root.attributes("-zoomed", True)   # most Linux WMs (e.g. Openbox, xfwm)
        except Exception:
            pass
    app = ProfileApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
