"""
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"\bM30\b": "Program must end with M30",
}

def _check_m5_before_later_tool_changes(text: str) -> list[str]:
    """A tool change ('Tn M6') must be preceded by M5 UNLESS it's the very
    first tool change in the program -- the spindle can't be running before
    it has ever been started with M3/M4, so an M5 there would be a no-op,
    not a safety requirement. Every SUBSEQUENT tool change, however, DOES
    need an explicit M5 first (the spindle from the previous tool is still
    running at that point).
    """
    errors: List[str] = []
    tool_change_re = re.compile(r"(?im)^\s*(T\d+\s+M6)\b")
    m5_re = re.compile(r"(?im)^\s*M5\b")
    matches = list(tool_change_re.finditer(text))
    for idx, m in enumerate(matches):
        if idx == 0:
            continue  # first tool change: spindle not running yet, M5 not required
        segment = text[matches[idx - 1].end():m.start()]
        if not m5_re.search(segment):
            errors.append(
                f"Spindle stop (M5) must appear before tool change #{idx + 1} "
                f"('{m.group(1)}') -- the spindle from the previous tool may "
                f"still be running."
            )
    return errors

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})")

    # Stateful check: M5 required before every tool change except the first
    errors.extend(_check_m5_before_later_tool_changes(text))

    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.
        #
        # NOTE: this specific move stays G0 (rapid), unlike the other
        # "idle X move" changes elsewhere -- it's the one move whose safety
        # is mathematically guaranteed regardless of stock precision (see
        # the proof above), starting from a genuinely arbitrary prior tool
        # position that could be far away. Making it G1 gave no real safety
        # benefit and just turned an otherwise rapid (dashed, easy to ignore
        # in AXIS's backplot) traverse into a prominent solid line.
        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
        spindle_ever_started = 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"):
                    # Only stop the spindle if it was actually already running --
                    # before the very first tool change in the program, M3/M4
                    # hasn't run yet, so an M5 here is a no-op that just adds
                    # noise to the header.
                    if spindle_ever_started:
                        lines.append("M5")
                    lines.append(f"{line} M6 G43")
                    lines.append("G97 S%d %s" % (self.spindle_rpm, self.spindle_dir))
                    spindle_ever_started = True
                    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"G1 X{retract_x:.3f} F{p.feed_rough}")
            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  # NOTE: Clear Z=0 was initially suspected of causing a LinuxCNC
    # hang (zero-length lead-in landing exactly on the first cutting point), but hardware
    # testing showed the REAL cause was a profile point sitting exactly on the centerline
    # (X=0.000) -- now caught explicitly by _validate_start_x_axis_safety's ValueError.
    # Once that's avoided, Clear Z=0 works fine (confirmed on hardware) -- so no artificial
    # floor is enforced here; the user's exact input is respected, same as safe_clear_x.
    doc: float = 0.3  # depth of cut per pass (I)
    retract: float = 1.0  # retract amount between passes (R)
    stock_allow: float = 0.2  # TOTAL stock left for G70 finish (D) -- computed as
    # finish_depth_per_pass * finish_passes in _read_params, not entered directly.
    finish_depth_per_pass: float = 0.1  # what the user actually types: how much
    # each individual finish pass removes; stock_allow (total) = this * finish_passes
    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 = True  # 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
    finish_passes: int = 1  # number of G70 finishing passes (P word); forced to 1 when use_tool_comp is True
    spring_passes: int = 0  # count of ADDITIONAL spring passes (0=No) run as a separate stage after the normal finishing passes: each repeats the identical final cut (D=E=0), for tool deflection (e.g. boring bars)
    use_tool_comp: bool = False  # wrap the finishing pass in G41/G42 ... G40 (TNRC)
    comp_direction: str = "G42"  # "G41" or "G42", used only when use_tool_comp is True


def _arc_end_tangent(prev_z: float, prev_xr: float, z1: float, xr1: float, r: float) -> Tuple[float, float, float, float, float, bool]:
    """For the arc leg prev->(z1,xr1) with signed radius r (r>0 => G2/CW,
    r<0 => G3/CCW -- same convention as _emit_contour_block/_sample_arc),
    return: (cz, cxr, radius, theta_start, theta_end, cw)

    Module-level (not a method) so both ProfileGenerator (actual G-code
    emission, works in real-radius units) and ProfileApp's preview drawing
    (works in preview-scaled/mirrored units) call the exact same geometry --
    they're different classes, and duplicating this math risked exactly the
    "preview doesn't match what's actually cut" class of bug fixed earlier.
    """
    radius = abs(r)
    vz, vxr = z1 - prev_z, xr1 - prev_xr
    chord = math.hypot(vz, vxr)
    if chord < 1e-9:
        # Degenerate (coincident) endpoints -- no meaningful tangent; fall
        # back to a zero-length/zero-curvature result rather than dividing
        # by zero.
        return prev_z, prev_xr, radius, 0.0, 0.0, r > 0
    mid_z, mid_xr = (prev_z + z1) / 2.0, (prev_xr + xr1) / 2.0
    perp_z, perp_xr = -vxr / chord, vz / chord
    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
    theta_start = math.atan2(prev_xr - cxr, prev_z - cz)
    theta_end = math.atan2(xr1 - cxr, z1 - cz)
    return cz, cxr, radius, theta_start, theta_end, cw


def _arc_bulge_point(z0: float, x0: float, z1: float, x1: float, r: float, diameter_mode: bool) -> Optional[Tuple[float, float]]:
    """Return (bulge_z, bulge_x) -- the real coordinate of the arc's peak/trough
    (whichever extreme the sweep actually reaches, in X) if the arc bulges
    past its own chord; otherwise None (no bulge, or invalid/degenerate arc).

    Same real-radius-vs-diameter unit handling as _arc_extent_x: r is always
    a true geometric radius (never diameter-scaled), X values are converted
    to radius-equivalent for the circle math and back for the result.
    """
    if abs(r) < 1e-9:
        return None
    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:
        return None

    mid_z, mid_xr = (z0 + z1) / 2.0, (xr0 + xr1) / 2.0
    perp_z, perp_xr = -vxr / chord, vz / chord
    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 = []
    if in_sweep(math.pi / 2):
        candidates.append((cxr + radius, math.pi / 2))
    if in_sweep(-math.pi / 2):
        candidates.append((cxr - radius, -math.pi / 2))
    if not candidates:
        return None

    lo_xr, hi_xr = min(xr0, xr1), max(xr0, xr1)
    best = None
    best_dist = 0.0
    for xr_c, theta in candidates:
        d = max(lo_xr - xr_c, xr_c - hi_xr, 0.0)  # how far past the chord's own X range
        if d > best_dist:
            best_dist = d
            best = (xr_c, theta)
    if best is None:
        return None
    xr_c, theta = best
    bulge_z = cz + radius * math.cos(theta)
    bulge_x = xr_c * scale
    return bulge_z, bulge_x


def _arc_radius_for_bulge_x(
    z0: float, x0: float, z1: float, x1: float, target_x: float, cw: bool, diameter_mode: bool,
    max_r_factor: float = 1000.0,
) -> Optional[float]:
    """Inverse of _arc_bulge_point: solve for the signed radius r (same
    convention: positive=G2/CW, negative=G3/CCW) whose arc's peak/trough X
    equals target_x, keeping the given rotation direction `cw` fixed.

    Works by bisection: as R grows from its minimum (chord/2, a semicircle),
    the sagitta (bulge depth) shrinks monotonically. For chords whose
    endpoints have DIFFERENT X (asymmetric case), the bulge vanishes
    entirely past some finite R (the sweep stops reaching the circle's true
    extreme, so _arc_bulge_point returns None from there on) -- unlike the
    symmetric same-X case, which can keep a small bulge indefinitely. The
    valid range is found adaptively first, then bisected.
    """
    scale = 2.0 if diameter_mode else 1.0
    xr0, xr1 = x0 / scale, x1 / scale
    chord = math.hypot(z1 - z0, xr1 - xr0)
    if chord < 1e-9:
        return None

    def bulge_at(radius_mag: float) -> Optional[float]:
        r_signed = radius_mag if cw else -radius_mag
        result = _arc_bulge_point(z0, x0, z1, x1, r_signed, diameter_mode)
        return result[1] if result is not None else None

    r_min = chord / 2.0 * 1.0000001
    f_min = bulge_at(r_min)
    if f_min is None:
        return None

    # Find where the bulge disappears (if it does), expanding geometrically.
    r_cap = chord * max_r_factor
    r_bad = None
    r_probe = chord * 2.0
    while r_probe <= r_cap:
        if bulge_at(r_probe) is None:
            r_bad = r_probe
            break
        r_probe *= 2.0

    if r_bad is None:
        r_hi = r_cap
    else:
        r_ok = r_probe / 2.0  # last value confirmed to still have a bulge
        lo_b, hi_b = r_ok, r_bad
        for _ in range(60):
            mid_b = (lo_b + hi_b) / 2.0
            if bulge_at(mid_b) is None:
                hi_b = mid_b
            else:
                lo_b = mid_b
        r_hi = lo_b  # just inside the valid range

    f_hi = bulge_at(r_hi)
    if f_hi is None:
        return None

    f_min_c = f_min - target_x
    f_hi_c = f_hi - target_x
    if f_min_c == 0:
        return r_min if cw else -r_min
    if (f_min_c > 0) == (f_hi_c > 0):
        return None  # target diameter not reachable in the valid bulge range

    lo, hi, flo = r_min, r_hi, f_min_c
    for _ in range(80):
        mid = (lo + hi) / 2.0
        fm = bulge_at(mid)
        if fm is None:
            hi = mid
            continue
        fm -= target_x
        if (fm > 0) == (flo > 0):
            lo, flo = mid, fm
        else:
            hi = mid
    r_solution = (lo + hi) / 2.0
    return r_solution if cw else -r_solution


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 as explicit G1/G2/G3 commands.

        Chamfers (corner_type="C") and fillets (corner_type="A") are computed
        as EXPLICIT geometry (extra G1 moves / G2/G3 arcs) instead of the
        LinuxCNC A/C extension words on G1, which require [RS274NGC] remap
        and generate "Bad character 'a' used" on machines that don't have it.
        Explicit geometry is 100% portable to any G-code interpreter.

        A = fillet with radius = corner_value
        C = chamfer with equal-leg distance = corner_value
        """
        block: List[str] = []
        pts = list(pts)
        n = len(pts)

        for i, pt in enumerate(pts):
            ct = (pt.corner_type or "").upper()
            cv = pt.corner_value

            # ---- arc (G2/G3) that ALSO has a fillet/chamfer at its end ----
            if abs(pt.r) > 1e-6 and ct in ("A", "C") and cv and cv > 0 and 0 < i < n - 1:
                prev = pts[i - 1]
                nxt = pts[i + 1]
                xf = 0.5 if self.params.diameter_mode else 1.0
                pt_xr = pt.x * xf
                prev_xr = prev.x * xf
                nxt_xr = nxt.x * xf

                cz, cxr, radius, theta_start, theta_end, cw = _arc_end_tangent(
                    prev.z, prev_xr, pt.z, pt_xr, pt.r
                )
                # Exit tangent direction at the arc's (untrimmed) endpoint --
                # this replaces the straight prev->pt chord direction used
                # for ordinary line-line corners.
                if cw:
                    dz_in, dx_in = math.sin(theta_end), -math.cos(theta_end)
                else:
                    dz_in, dx_in = -math.sin(theta_end), math.cos(theta_end)

                dz_out = nxt.z - pt.z; dx_out = nxt_xr - pt_xr
                l_out = math.hypot(dz_out, dx_out)
                if l_out < 1e-9:
                    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}")
                    continue
                dz_out /= l_out; dx_out /= l_out

                # Leg length back from the corner: reused for both chamfer
                # (cv IS the leg length) and fillet (t derived from the
                # incoming/outgoing angle, same formula as the line-line case).
                if ct == "C":
                    leg = cv
                else:
                    dot = max(-1.0, min(1.0, dz_in * dz_out + dx_in * dx_out))
                    turning = math.pi - math.acos(dot)
                    if abs(turning) < 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}")
                        continue
                    leg = cv / math.tan(turning / 2)

                # Trim the arc back by arc-length `leg` from its endpoint
                # (exact -- stays exactly on the arc's own circle -- unlike a
                # straight-line offset, which would leave the circle).
                sweep = abs(theta_end - theta_start)
                max_sweep = 2 * math.pi - 1e-6
                sweep = min(sweep, max_sweep)
                max_leg = radius * sweep * 0.9  # keep 10% of the arc, matches the
                                                 # 0.99->0.9 style safety margin used elsewhere
                if leg > max_leg:
                    leg = max_leg
                dtheta = leg / radius
                theta_trim = theta_end + dtheta if cw else theta_end - dtheta
                z_trim = cz + radius * math.cos(theta_trim)
                xr_trim = cxr + radius * math.sin(theta_trim)
                x_trim = xr_trim / xf

                gcode = "G2" if pt.r > 0 else "G3"
                block.append(f"{gcode} X{x_trim:.3f} Z{z_trim:.3f} R{radius:.3f}")

                if ct == "C":
                    ce_z = pt.z + leg * dz_out; ce_xr = pt_xr + leg * dx_out
                    ce_x = ce_xr / xf
                    block.append(f"G1 X{ce_x:.3f} Z{ce_z:.3f}")
                else:
                    fe_z = pt.z + leg * dz_out; fe_xr = pt_xr + leg * dx_out
                    fe_x = fe_xr / xf
                    cross = dz_in * dx_out - dx_in * dz_out
                    arc_g = "G3" if cross > 0 else "G2"
                    block.append(f"{arc_g} X{fe_x:.3f} Z{fe_z:.3f} R{cv:.3f}")
                continue

            # ---- arc (G2/G3), no corner at its end ----
            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}")
                continue

            # ---- corner with explicit geometry ----
            if ct in ("A", "C") and cv and cv > 0 and 0 < i < n - 1:
                prev = pts[i - 1]
                nxt  = pts[i + 1]

                # Work in physically-real units (radius-equivalent X), same as
                # the preview does, so the chamfer/fillet legs are true mm
                # distances. On G7 (diameter_mode) the programmed X value is
                # 2x the real radial travel, so computing offsets directly on
                # raw X made the X-leg of a chamfer only HALF the physical
                # length of the Z-leg -- e.g. a "C1" corner between a facing
                # cut and a turning cut looked like a symmetric 45 deg
                # chamfer in raw X units, but LinuxCNC only moved 0.5mm of
                # actual radius for that 1.0 leg, cutting a different angle.
                xf = 0.5 if self.params.diameter_mode else 1.0
                pt_xr = pt.x * xf
                prev_xr = prev.x * xf
                nxt_xr = nxt.x * xf

                dz_in = pt.z - prev.z; dx_in = pt_xr - prev_xr
                l_in  = math.hypot(dz_in, dx_in)
                if l_in < 1e-9:
                    block.append(f"G1 X{pt.x:.3f} Z{pt.z:.3f}")
                    continue
                dz_in /= l_in; dx_in /= l_in

                dz_out = nxt.z - pt.z; dx_out = nxt_xr - pt_xr
                l_out  = math.hypot(dz_out, dx_out)
                if l_out < 1e-9:
                    block.append(f"G1 X{pt.x:.3f} Z{pt.z:.3f}")
                    continue
                dz_out /= l_out; dx_out /= l_out

                if ct == "C":
                    cs_z = pt.z - cv * dz_in;  cs_xr = pt_xr - cv * dx_in
                    ce_z = pt.z + cv * dz_out; ce_xr = pt_xr + cv * dx_out
                    cs_x = cs_xr / xf
                    ce_x = ce_xr / xf
                    block.append(f"G1 X{cs_x:.3f} Z{cs_z:.3f}")
                    block.append(f"G1 X{ce_x:.3f} Z{ce_z:.3f}")
                else:
                    dot = max(-1.0, min(1.0, dz_in*dz_out + dx_in*dx_out))
                    turning = math.pi - math.acos(dot)
                    if abs(turning) < 1e-6:
                        block.append(f"G1 X{pt.x:.3f} Z{pt.z:.3f}")
                        continue
                    t = cv / math.tan(turning / 2)
                    fs_z = pt.z - t * dz_in;  fs_xr = pt_xr - t * dx_in
                    fe_z = pt.z + t * dz_out; fe_xr = pt_xr + t * dx_out
                    fs_x = fs_xr / xf
                    fe_x = fe_xr / xf
                    cross = dz_in * dx_out - dx_in * dz_out
                    arc_g = "G3" if cross > 0 else "G2"
                    block.append(f"G1 X{fs_x:.3f} Z{fs_z:.3f}")
                    block.append(f"{arc_g} X{fe_x:.3f} Z{fe_z:.3f} R{cv:.3f}")
                continue

            # ---- plain G1 ----
            block.append(f"G1 X{pt.x:.3f} Z{pt.z:.3f}")

        return block

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

    def _corner_word(self, pt: Point) -> str:
        """Kept for any external callers; explicit geometry is used instead."""
        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 _g72_di_values(self) -> Tuple[float, float]:
        """Return (D, I) values to EMIT in G72, UNCONVERTED.

        G71 steps/layers in X (radial), so its I/D are X-quantities and need
        the G7/G8 diameter<->radius conversion (see _g71_di_values). G72 is
        the mirror-image cycle: it steps/layers in Z (axial) while
        traversing X to face each layer -- so G72's I/D are Z-quantities.
        Z never has a "diameter" (only X does), so no conversion applies
        regardless of G7/G8 -- params.doc/stock_allow are used as-is.
        """
        p = self.params
        return p.stock_allow, p.doc

    def _g72_retract_value(self) -> float:
        """Return the retract R value to EMIT in G72, UNCONVERTED (Z-based, see _g72_di_values)."""
        return self.params.retract

    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 "G7/G8" (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 _g71_retract_value(self) -> float:
        """Return the retract R value to EMIT in G71/G72, converted for G7/G8.

        Same convention as _g71_di_values (R is the third real-radius-based
        G71/G72 word, alongside D and I) -- params.retract is entered as the
        desired DIAMETER-domain retract distance in G7, so it's halved before
        emission, exactly like doc/stock_allow already are. Previously only
        D and I got this conversion; R was emitted unconverted, so a G7 user
        entering "1" for Retract actually got 0.5mm of real retract instead
        of the intended 1mm (visible as "1mm requested, 2mm real observed"
        when compared the other way around).
        """
        p = self.params
        return p.retract / 2.0 if p.diameter_mode else p.retract

    def _g70_lines(self, contour_sub_id: int, pts: Sequence[Point], finish_contour_ids: Optional[List[int]] = None) -> List[str]:
        """Build the G70 finishing line(s): the normal finishing pass(es),
        then -- as a separate stage -- any spring passes.

        Normal finishing (TNRC off, or finish_passes==1): D uses the same
        real-radius-based value as G71/G72's D (see _g71_di_values) -- G70's
        D/E follow the same "always real radius, never diameter-scaled"
        convention. E is always 0 (last pass lands exactly on the
        programmed profile). D/E/P are only emitted when finish_passes > 1,
        keeping single-pass output identical to before this feature
        existed. LinuxCNC's own distance(pass) = (pass-1)*(D-E)/P + E
        formula then divides the finish allowance progressively across the
        P passes.

        Normal finishing (TNRC on, finish_passes > 1): G70 with P>1 combined
        with active cutter compensation is a confirmed crash combo
        ("concave corner cannot be reached without gouging"), and emitting
        separate single-pass E-based G70 calls on the SAME contour did NOT
        dodge it either -- confirmed on hardware, the compensation engine
        fails at the same arc/line concave corner regardless of how the
        offset is expressed. Instead, `finish_contour_ids` (built by the
        caller via _shift_points_x + contour_subprogram_gcode -- one
        proportionally X-shifted contour per intermediate pass, plus the
        original contour_sub_id as the final, zero-offset pass) is used:
        each is a genuinely SEPARATE contour subprogram, called with a
        plain G70 Q<id> (no D/E/P at all -- each is just a normal single
        finish pass on its own geometry), roughest/most-offset first. TNRC
        stays active across the whole sequence.

        Spring passes (a separate stage, count independent of finish_passes):
        emitted as an ADDITIONAL G70 call with D=E=0, so distance(pass) = 0
        for every one of them -- each aims exactly at the programmed
        profile with nothing left to remove, letting a boring bar (or any
        tool prone to deflection under load) spring back and true up the
        diameter over repeated light contact. Safe with TNRC even at P>1,
        since EVERY pass targets the identical final point (no varying
        intermediate offset for the compensation to trip over).
        """
        p = self.params
        # Build each SEPARATELY-invoked G70 command as its own list entry --
        # any pair of consecutive entries here needs an explicit reposition
        # between them (see docstring above and _explicit_g71_g70_transition):
        # G70's own X/Z default to "wherever the tool currently is" for every
        # call after the first, which is the END of the previous pass, not a
        # safe position -- without an explicit move, LinuxCNC rapids straight
        # (diagonally) from there to the next pass's profile start.
        calls: List[str] = []
        if p.finish_passes and p.finish_passes > 1:
            if p.use_tool_comp and finish_contour_ids:
                for cid in finish_contour_ids:
                    calls.append(f"G70 Q{cid} F{p.feed_finish}")
            elif p.use_tool_comp:
                # Fallback if the caller didn't build shifted contours (e.g.
                # an older call site) -- same-contour E-based passes. Known
                # NOT to dodge the concave-corner failure on hardware, but
                # keeps output well-formed rather than silently misbehaving.
                if p.cycle.upper() == "G72":
                    d_total, _ = self._g72_di_values()
                else:
                    d_total, _ = self._g71_di_values()
                n = p.finish_passes
                for i in range(n):
                    remaining = d_total * (n - 1 - i) / n
                    calls.append(f"G70 Q{contour_sub_id} F{p.feed_finish} E{remaining:.3f}")
            else:
                if p.cycle.upper() == "G72":
                    d_total, _ = self._g72_di_values()
                else:
                    d_total, _ = self._g71_di_values()
                calls.append(
                    f"G70 Q{contour_sub_id} F{p.feed_finish} D{d_total:.3f} E0.000 P{p.finish_passes}"
                )
        else:
            calls.append(f"G70 Q{contour_sub_id} F{p.feed_finish}")
        if p.spring_passes and p.spring_passes > 0:
            calls.append(f"G70 Q{contour_sub_id} F{p.feed_finish} D0.000 E0.000 P{p.spring_passes}")

        lines: List[str] = []
        reposition = self._explicit_g71_g70_transition(pts)
        for idx, call in enumerate(calls):
            if idx > 0 and reposition:
                lines += reposition
            lines.append(call)
        return lines

    def _tnrc_wrap(self, lines: List[str]) -> List[str]:
        """Wrap finishing-pass lines in G41/G42 ... G40 tool nose radius compensation.

        No-op when use_tool_comp is False, so existing output is unaffected
        unless the user explicitly opts in.
        """
        p = self.params
        if not p.use_tool_comp:
            return list(lines)
        comp = p.comp_direction if p.comp_direction in ("G41", "G42") else "G42"
        return [comp, *lines, "G40"]

    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 G1-at-roughing-feed moves for X (Z stays G0/rapid); 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"G1 X{retract_x:.3f} F{p.feed_rough}",
            f"G0 Z{start_z:.3f}",
            f"G1 X{start_x:.3f} F{p.feed_rough}",
        ]

    def _emit_g71(self, pts: Sequence[Point], contour_sub_id: int, finish_contour_ids: Optional[List[int]] = None) -> 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()
        r_emit = self._g71_retract_value()
        lines: List[str] = []
        lines.append(
            f"G71 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{r_emit:.3f} F{p.feed_rough}"
        )
        lines += self._explicit_g71_g70_transition(pts)
        lines += self._tnrc_wrap(self._g70_lines(contour_sub_id, pts, finish_contour_ids))
        return lines

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

    def _shift_points_x(self, points: Sequence[Point], offset_real: float) -> List[Point]:
        """Return a copy of points with X shifted to leave `offset_real` extra
        material (a real, radius-based distance, same convention as G70's
        D/E -- see _g71_di_values), for an intermediate finish pass.

        OD: more material left => LARGER X, so shift outward (+).
        ID: more material left => SMALLER X (bore not yet opened up that far), so shift inward (-).

        Z, R (arc radius), and corner_type/corner_value are left untouched --
        a uniform translation preserves all relative geometry (arc radius,
        corner angles, fillet/chamfer validity) exactly, it just moves the
        whole contour by a constant amount in X.
        """
        p = self.params
        scale = 2.0 if p.diameter_mode else 1.0
        dx = offset_real * scale
        if not p.od:
            dx = -dx
        return [Point(pt.z, pt.x + dx, pt.r, pt.corner_type, pt.corner_value) for pt in points]

    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 build_shifted_finish_contours(
        self, points: Sequence[Point], final_contour_id: int
    ) -> Tuple[List[int], List[str]]:
        """Build the extra offset-contour subprograms for TNRC multi-pass
        finishing (see _g70_lines). Returns (ordered_ids, subprogram_blocks):
          - ordered_ids: [id_of_roughest_pass, ..., final_contour_id] --
            final_contour_id (the ORIGINAL, unshifted contour, offset=0) is
            always last, i.e. the actual finish-to-size pass.
          - subprogram_blocks: G-code text for the NEW (shifted) contours
            only -- final_contour_id's own block is built separately by the
            caller (it's the normal, already-existing contour).
        Only called when use_tool_comp and finish_passes > 1.
        """
        p = self.params
        n = max(1, p.finish_passes)
        if p.cycle.upper() == "G72":
            d_total, _ = self._g72_di_values()
        else:
            d_total, _ = self._g71_di_values()

        used_ids = {final_contour_id}
        ordered_ids: List[int] = []
        blocks: List[str] = []
        for i in range(n - 1):
            remaining = d_total * (n - 1 - i) / n
            new_id = random.randint(1000, 8999)
            while new_id in used_ids:
                new_id = random.randint(1000, 8999)
            used_ids.add(new_id)
            shifted_pts = self._shift_points_x(points, remaining)
            blocks.append(self.contour_subprogram_gcode(shifted_pts, new_id))
            ordered_ids.append(new_id)
        ordered_ids.append(final_contour_id)
        return ordered_ids, blocks

    def wrapper_subprogram_gcode(
        self, points: Sequence[Point], contour_sub_id: int, wrapper_sub_id: int,
        finish_contour_ids: Optional[List[int]] = None,
    ) -> 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.

        finish_contour_ids: see _g70_lines / build_shifted_finish_contours --
        pass this when use_tool_comp + finish_passes>1 to use separate
        offset contours per finish pass instead of the same-contour E-based
        fallback.
        """
        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"    G1 X{start_x:.3f} F{p.feed_rough}")
        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, finish_contour_ids=finish_contour_ids)
        else:
            cycle_lines = self._emit_g71(pts, contour_sub_id=contour_sub_id, finish_contour_ids=finish_contour_ids)
        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"    G1 X{start_x:.3f} F{p.feed_rough}")
        lines.append(f"    G0 Z{start_z:.3f}")

        # Emit only roughing cycle (no G70)
        if p.cycle.upper() == "G72":
            d_emit, i_emit = self._g72_di_values()
            r_emit = self._g72_retract_value()
            rough_line = f"G72 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{r_emit:.3f} F{p.feed_rough}"
        else:
            d_emit, i_emit = self._g71_di_values()
            r_emit = self._g71_retract_value()
            rough_line = f"G71 Q{contour_sub_id} D{d_emit:.3f} I{i_emit:.3f} R{r_emit:.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,
        finish_contour_ids: Optional[List[int]] = None,
    ) -> 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"    G1 X{start_x:.3f} F{p.feed_rough}")
        lines.append(f"    G0 Z{start_z:.3f}")

        # Emit only finishing pass
        for l in self._tnrc_wrap(self._g70_lines(contour_sub_id, pts, finish_contour_ids)):
            lines.append(f"    {l}")

        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,
        finish_contour_ids: Optional[List[int]] = None,
    ) -> str:
        """Generate a finish-only wrapper: explicit safe approach + G70 (no G71).

        Skips G71 (no roughing pass in this program -- stock is assumed
        already close to final size), but DOES use G70, so the finish
        allowance (stock_allow) is genuinely divided across finish_passes
        via G70's native D/E/P -- each pass removes progressively less
        material, ending exactly on the profile -- instead of repeating an
        identical cut.

        The position before G70 is established explicitly via
        _calculate_start_x/_calculate_retract_x -- the SAME safe, known
        entry point the ordinary G71->G70 pipeline uses (see
        _explicit_g71_g70_transition) -- rather than relying on whatever
        internal state G71 would have left behind, since there's no G71
        here at all.

        Known caveat (see check_direct_finish_centerline_advisory): earlier
        testing on real hardware repeatedly produced a "не виїзджає, б'є в
        заготовку" crash specifically for profiles whose first point sits at
        X=0 (centerline), when G70 ran without a preceding G71 in the same
        program. That failure mode is specific to X=0-starting profiles --
        it has NOT been observed for profiles that stay off the centerline.
        Generation still succeeds for X=0 profiles, but a status advisory is
        surfaced so it can be tested cautiously.
        """
        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"    G1 X{start_x:.3f} F{p.feed_rough}")
        lines.append(f"    G0 Z{start_z:.3f}")

        for l in self._tnrc_wrap(self._g70_lines(contour_sub_id, pts)):
            lines.append(f"    {l}")

        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"    G1 X{retract_x:.3f} F{p.feed_rough}")
        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) + safe_clear_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),
            # plus Clear X -- the user's own margin control, matching ID's
            # "profile_min_x - safe_clear_x" convention below. Previously
            # this added stock_allow (D) instead, to force landing in the
            # check_start_x_advisory "confirmed safe" zone -- but that
            # silently overrode an explicit Clear X=0 with an unrequested
            # 0.5mm+ margin. The advisory is informational-only (doesn't
            # block generation), so it's better to respect what the user
            # actually typed and let the advisory flag it if it lands in
            # unverified territory, than to override their input.
            return max(p.stock_diam, profile_max_x) + p.safe_clear_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.

        Checks two conditions:
        1. Each corner's tangent length must fit within its adjacent segment
           lengths (standard individual feasibility check).
        2. Two consecutive corners on the SAME segment must not overlap:
           t_out(i) + t_in(i+1) <= segment_length.
           When they do overlap, the OUTGOING tangent of the earlier corner
           is removed (so the entering fillet is preserved, which is usually
           more important geometrically).
        """
        if len(pts) < 3:
            return pts

        MARGIN = 0.99
        MIN_SEG_LEN = 0.1

        def _tangent(pt: Point, u_in, u_out, len_in: float, len_out: float):
            """Compute the tangent length for a corner, or None if infeasible."""
            if not pt.corner_type or pt.corner_value is None or pt.corner_value <= 0:
                return None
            if len_in < MIN_SEG_LEN or len_out < MIN_SEG_LEN:
                return None
            dot = max(-1.0, min(1.0,
                u_in[0] * u_out[0] + u_in[1] * u_out[1]))
            theta = math.acos(dot)
            if theta < 0.01 or abs(math.pi - theta) < 0.01:
                return None
            ct = pt.corner_type.upper()
            if ct == "A":
                t = pt.corner_value * math.tan(theta / 2.0)
            elif ct == "C":
                t = pt.corner_value
            else:
                return None
            if t < len_in * MARGIN and t < len_out * MARGIN:
                return t
            return None  # doesn't fit individually

        # First pass: compute tangents and unit vectors for all corners
        n = len(pts)
        tangents: list[float | None] = [None] * n

        for i in range(1, n - 1):
            pt = pts[i]
            if not pt.corner_type or pt.corner_value is None or pt.corner_value <= 0:
                continue
            prev_pt = pts[i - 1]
            next_pt = pts[i + 1]
            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 len_in < 1e-9 or len_out < 1e-9:
                continue
            u_in = (v_in[0] / len_in, v_in[1] / len_in)
            u_out = (v_out[0] / len_out, v_out[1] / len_out)
            tangents[i] = _tangent(pt, u_in, u_out, len_in, len_out)

        # Second pass: check adjacent-corner overlap on the shared segment.
        # If pts[i] and pts[i+1] both have corners, the outgoing tangent of
        # pts[i] and the incoming tangent of pts[i+1] share the segment
        # pts[i]→pts[i+1].  When they overlap (t_out + t_in > seg_len), we
        # REDUCE BOTH radii/distances proportionally so each gets exactly
        # seg_len*MARGIN/2 of tangent length.  This preserves rounded corners
        # on BOTH sides of a narrow peak/valley, just with smaller radii,
        # rather than dropping one corner entirely.
        #
        # The reduced corner_value is stored back in the pts list (as a new
        # Point) so that _emit_contour_block picks up the smaller radius.
        pts = list(pts)  # make mutable copy for in-place modifications
        for i in range(1, n - 2):
            if tangents[i] is None or tangents[i + 1] is None:
                continue
            seg_len_ij = math.hypot(
                pts[i + 1].z - pts[i].z,
                pts[i + 1].x - pts[i].x,
            )
            if tangents[i] + tangents[i + 1] <= seg_len_ij * MARGIN:
                continue  # no overlap
            # Reduce both to fit.  Each corner gets exactly half the
            # available tangent length.
            t_each = seg_len_ij * MARGIN / 2.0

            # Update pts[i]: reduce corner_value so its outgoing tangent = t_each
            # t = corner_value * tan(theta/2)  for fillet (A)
            # t = corner_value                 for chamfer (C)
            def _reduced_value(pt: Point, t_target: float, prev_pt, next_pt) -> float | None:
                v_in  = (pt.z - prev_pt.z, pt.x - prev_pt.x)
                v_out = (next_pt.z - pt.z, next_pt.x - pt.x)
                l_in  = math.hypot(*v_in); l_out = math.hypot(*v_out)
                if l_in < 1e-9 or l_out < 1e-9:
                    return None
                u_in  = (v_in[0]/l_in,  v_in[1]/l_in)
                u_out = (v_out[0]/l_out, v_out[1]/l_out)
                dot_v = max(-1.0, min(1.0, u_in[0]*u_out[0] + u_in[1]*u_out[1]))
                theta = math.acos(dot_v)
                if theta < 0.01 or abs(math.pi - theta) < 0.01:
                    return None
                ct = (pt.corner_type or "").upper()
                if ct == "A":
                    tan_half = math.tan(theta / 2.0)
                    if tan_half < 1e-9:
                        return None
                    return t_target / tan_half
                elif ct == "C":
                    return t_target
                return None

            rv_i = _reduced_value(pts[i], t_each, pts[i - 1], pts[i + 1])
            rv_j = _reduced_value(pts[i + 1], t_each, pts[i], pts[i + 2] if i + 2 < n else pts[i + 1])

            if rv_i is not None and rv_i > 0:
                pt = pts[i]
                pts[i] = Point(pt.z, pt.x, pt.r, pt.corner_type, round(rv_i, 4))
                tangents[i] = t_each
            else:
                tangents[i] = None  # can't fit, drop

            if rv_j is not None and rv_j > 0:
                pt = pts[i + 1]
                pts[i + 1] = Point(pt.z, pt.x, pt.r, pt.corner_type, round(rv_j, 4))
                tangents[i + 1] = t_each
            else:
                tangents[i + 1] = None

        # Build filtered list using the computed tangent table
        filtered = []
        for i, pt in enumerate(pts):
            if i == 0 or i == n - 1:
                filtered.append(pt)
            elif tangents[i] is not None:
                filtered.append(pt)          # keep corner as-is
            else:
                filtered.append(Point(pt.z, pt.x, pt.r, None, None))  # drop corner

        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)."
            )

        # Separate guard: even with Start X safely clear of doc (checked
        # above), the CONTOUR ITSELF containing a point AT the centerline
        # (X=0, a sharp point/tip) is its own confirmed hang scenario --
        # G71's finishing/last roughing layer has to cut exactly to that
        # point, which reproduces the same "не виїзджає" failure at the tip
        # regardless of how Start X and doc relate. Confirmed on hardware:
        # this hangs LinuxCNC before any motion even starts.
        for pt in pts:
            if abs(pt.x) <= tol:
                raise ValueError(
                    f"Точка профілю Z={pt.z:.3f} лежить рівно на осі обертання "
                    f"(X=0.000) — гострий кінчик на осі. Перевірено на залізі: "
                    f"G71 зависає ще до виконання (не пов'язано з Start X чи "
                    f"Clear Z). Замініть X=0.000 на невелике додатне значення "
                    f"(наприклад X=0.100-0.200) — це дасть плаский мікро-торець "
                    f"замість ідеально гострого кінчика, і LinuxCNC оброблятиме "
                    f"профіль нормально."
                )

        # 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 check_direct_finish_centerline_advisory(self, pts: List[Point], p: ProfileParams) -> Optional[str]:
        """Return a non-fatal warning when direct_finish_only (G70 without a
        preceding G71 in the same program) is used on a profile whose first
        point sits at/near X=0 (centerline).

        Earlier hardware testing repeatedly produced a "не виїзджає, б'є в
        заготовку" crash for exactly this combination -- see
        wrapper_finish_direct_subprogram_gcode's docstring. The failure mode
        has NOT been observed for profiles that stay off the centerline, so
        this only fires for the specific X=0 case rather than blocking the
        (now normal) G70-based multi-pass finish in general.
        """
        if not pts or not p.direct_finish_only:
            return None
        tol = 0.0005
        if abs(pts[0].x) <= tol:
            return (
                f"Профіль починається на осі (X={pts[0].x:.3f}) при увімкненому "
                f"'Тільки фініш'. На залізі такий випадок (G70 без попереднього "
                f"G71) неодноразово давав аварійний в'їзд в заготовку. "
                f"Перевірте перший прохід обережно (одиночний блок/знижена подача)."
            )
        return None


class _ToolTip:
    """Minimal hover tooltip (Tkinter has none built in).

    text_getter is called fresh on every hover, so passing a lambda that
    reads through the app's current-language translator (self._tr) makes
    the tooltip automatically follow language switches with no extra
    registry/bookkeeping needed.
    """

    def __init__(self, widget, text_getter):
        self.widget = widget
        self.text_getter = text_getter
        self.tipwindow = None
        widget.bind("<Enter>", self.show, add="+")
        widget.bind("<Leave>", self.hide, add="+")
        widget.bind("<ButtonPress>", self.hide, add="+")

    def show(self, _event=None) -> None:
        text = self.text_getter()
        if self.tipwindow or not text:
            return
        x = self.widget.winfo_rootx() + 4
        y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
        tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        try:
            tw.wm_attributes("-topmost", True)
        except tk.TclError:
            pass
        tw.wm_geometry(f"+{x}+{y}")
        tk.Label(
            tw, text=text, justify="left", background="#ffffe0",
            relief="solid", borderwidth=1, font=("TkDefaultFont", 8),
            padx=4, pady=2,
        ).pack()
        self.tipwindow = tw

    def hide(self, _event=None) -> None:
        if self.tipwindow is not None:
            self.tipwindow.destroy()
            self.tipwindow = None


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 #": {"uk": "Інструмент №", "en": "Tool #"},
    "Coolant?": {"uk": "ЗОР?", "en": "Coolant?"},
    "Yes": {"uk": "Так", "en": "Yes"},
    "No": {"uk": "Ні", "en": "No"},
    "OD/ID": {"uk": "Зовн./Внутр.", "en": "OD/ID"},
    "Units": {"uk": "Одиниці", "en": "Units"},
    "Feed Mode": {"uk": "Режим подачі", "en": "Feed Mode"},

    # --- STOCK & CLEAR fields ---
    "Діам загот": {"uk": "Діам загот", "en": "Stock diam"},
    "Довж загот (Z)": {"uk": "Довж загот (Z)", "en": "Stock len (Z)"},
    "Clear X": {"uk": "Відвід X", "en": "Clear X"},
    "Clear Z": {"uk": "Відвід Z", "en": "Clear Z"},

    # --- CYCLE fields ---
    "Глиб різан (I)": {"uk": "Глиб різан (I)", "en": "Cut depth (I)"},
    "Відвід (R)": {"uk": "Відвід (R)", "en": "Retract (R)"},
    "Фініш/прохід (D)": {"uk": "Фініш/прохід", "en": "Finish/pass"},
    "Remaining (D)": {"uk": "Remaining (D)", "en": "Remaining (D)"},
    "Подача чорнова": {"uk": "Подача чорнова", "en": "Roughing feed"},
    "Подача фінішна": {"uk": "Подача фінішна", "en": "Finishing feed"},
    "Start X": {"uk": "Start X", "en": "Start X"},
    "Start X tooltip": {
        "uk": "Необов'язково. Якщо 0 — розраховується автоматично.",
        "en": "Optional. If 0, calculated automatically.",
    },
    "Тільки фініш (без G71)": {"uk": "Тільки фініш", "en": "Finish only"},
    "Finish only tooltip": {
        "uk": "Пропустити чорновий G71 — прямий фінішний прохід (G70), заготовка вже близька до розміру.",
        "en": "Skip G71 roughing — direct finish pass (G70), stock assumed already near-final size.",
    },
    "К-сть чист. проходів": {"uk": "Чист. проходи", "en": "Finish passes"},
    "К-сть пружинних": {"uk": "Пружинні проходи", "en": "Spring passes"},
    "Spring passes tooltip": {
        "uk": "Пружинні проходи: всі чистові проходи ріжуть точно по профілю (без ділення припуску) — повторюють той самий фінішний прохід, щоб різець \"відпружинив\" (актуально для розточних/тонких різців, що відгинаються). Без цієї галочки припуск ділиться прогресивно на всі проходи.",
        "en": "Spring passes: every finish pass cuts exactly to the profile (no progressive stock division) -- repeats the identical final cut so the tool can spring back (useful for boring bars/slender tools that deflect). Without this, stock allowance is divided progressively across passes.",
    },
    "Компенсація різця": {"uk": "Компенсація різця", "en": "Tool comp"},
    "Start Z": {"uk": "Start Z", "en": "Start Z"},
    "Start Z tooltip": {
        "uk": "Необов'язково. Якщо 0 — розраховується автоматично.",
        "en": "Optional. If 0, calculated automatically.",
    },

    # --- 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)"},
    "Delta Z tooltip": {
        "uk": "Ввести Z як відстань від попередньої точки (напр. зі штангенциркуля), а не абсолютну координату. Діє тільки для Add, глибше по Z (в мінус).",
        "en": "Enter Z as a distance from the previous point (e.g. from calipers) instead of an absolute coordinate. Applies to Add only, moves deeper in Z (negative direction).",
    },
    "Arc no bulge": {"uk": "дуга не виступає за хорду", "en": "arc stays within chord"},
    "Arc diam tooltip": {
        "uk": "Ввести бажаний реальний діаметр(радіус) у вершині/впадині дуги, а не сам радіус дуги — програма порахує потрібний R сама. Перераховується автоматично, якщо зміняться початкова чи кінцева точки.",
        "en": "Enter the desired real diameter (or radius) at the arc's peak/trough instead of the arc radius itself -- the program solves for R. Recomputes automatically if the start/end points change.",
    },
    "Diam not reachable": {"uk": "недосяжний діаметр для цієї хорди", "en": "diameter not reachable for this chord"},
    "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"},
    "All Z offset": {"uk": "Зсув Z всіх точок", "en": "All Z offset"},
    "All X offset": {"uk": "Зсув X всіх точок", "en": "All X offset"},
    "Segment List": {"uk": "Список сегментів", "en": "Segment List"},
    "Import segments": {"uk": "Імпорт", "en": "Import"},
    "Exp coord": {"uk": "Експ координ", "en": "Exp coord"},
    "Exp Proj": {"uk": "Експ проект", "en": "Exp Proj"},

    # --- 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"},
    "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"},
    "Half": {"uk": "Половина", "en": "Half"},
    "Fill profile": {"uk": "Заливка", "en": "Fill"},
    "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": {"uk": "Надіслати в LinuxCNC", "en": "Send to LinuxCNC"},
    "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._loaded_file_path: Optional[str] = None  # last imported/exported coord or project file
        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 = {}

        # G-code generation gating: True until "Generate G-code" is clicked;
        # any input change (via preview()) flips it back to True, disabling
        # Save/Send buttons and greying the OUTPUT text until regenerated.
        self._gcode_dirty = True

        # Live-computed (z, x) of the arc's real bulge point, shown next to
        # the R field and highlighted gray on canvas; recomputed by
        # _sync_r_and_arc_diam()/_set_r_display() whenever R, the target
        # diameter, or the arc's Z/X endpoints change.
        self._arc_bulge_preview: Optional[Tuple[float, float]] = None

        # --- 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)
        
        # Automatically restore previous session from autosave if it exists (no prompt)
        if os.path.exists(self._autosave_path):
            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):
                entries_data = dict(data["entries"])
                # Backward compat: old sessions stored TNRC as two separate
                # keys (use_tool_comp bool + comp_direction G41/G42) instead
                # of the single "tool_comp" No/G42/G41 dropdown
                if "tool_comp" not in entries_data and "use_tool_comp" in entries_data:
                    was_on = str(entries_data.get("use_tool_comp", "")).strip().lower() in ("1", "true", "yes", "y")
                    direction = str(entries_data.get("comp_direction", "G42"))
                    entries_data["tool_comp"] = ("G41" if "G41" in direction.upper() else "G42") if was_on else "No"
                # Backward compat: old sessions stored the TOTAL allowance
                # directly as "stock_allow"; the new "finish_depth_per_pass"
                # is per-pass, so divide by the pass count to approximate
                # the same total D as before.
                if "finish_depth_per_pass" not in entries_data and "stock_allow" in entries_data:
                    try:
                        old_total = float(entries_data.get("stock_allow", 0.1))
                        old_count = max(1, int(float(entries_data.get("finish_passes", 1))))
                        entries_data["finish_depth_per_pass"] = str(old_total / old_count)
                    except (ValueError, TypeError):
                        pass
                for key, val in entries_data.items():
                    if key in self.entries:
                        sval = str(val)
                        # Backward compat: old configs stored diameter_mode either as
                        # "true"/"false", or as older dropdown display strings
                        # (pre language-neutral rename, then pre-shortening)
                        if key == "diameter_mode":
                            if "G7" not in sval.upper() and "G8" not in sval.upper():
                                sval = "G7 Diam" if sval.strip().lower() in ("true", "1", "yes") else "G8 Rad"
                            elif "G7" in sval.upper():
                                sval = "G7 Diam"
                            elif "G8" in sval.upper():
                                sval = "G8 Rad"
                        if isinstance(self.entries[key], tk.BooleanVar):
                            # For BooleanVar (like use_tool_comp), parse truthy strings
                            self.entries[key].set(str(val).strip().lower() in ("1", "true", "yes", "y"))
                        elif 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)
            
            # Re-apply TNRC dropdown side effects (finish_passes lock) after restore
            if hasattr(self, "_on_tool_comp_toggle"):
                self._on_tool_comp_toggle()
            self._rebuild_coolant_menu()

            # 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", "FILLET", "CHAMFER", "SELECT", "SPLIT"):
                    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 moved into the PREVIEW panel header (see below) --
        # was previously its own full-width row at the top of the window,
        # which wasted vertical space just to show two radio buttons.

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

        # Cycle: G71 (OD/turning, layers along Z) vs G72 (facing, layers along X)
        cycle_frm = tk.Frame(left_params)
        cycle_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(cycle_frm, width=16, anchor="w"), "Cycle").pack(side="left")
        self.cycle_var = tk.StringVar(value=self.params.cycle.strip().upper() or "G71")
        cycle_menu = tk.OptionMenu(cycle_frm, self.cycle_var, "G71", "G72",
                                    command=lambda _v: self._schedule_preview())
        cycle_menu.config(width=8)
        cycle_menu.pack(side="left", padx=(2, 0))
        self.entries["cycle"] = self.cycle_var

        # 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=16, 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=16, 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 #", self.params.tool),
        ]
        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=16, 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())

        # Coolant: Yes/No dropdown, displayed text follows the current language
        coolant_frm = tk.Frame(left_params)
        coolant_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(coolant_frm, width=16, anchor="w"), "Coolant?").pack(side="left")
        self.coolant_var = tk.BooleanVar(value=bool(self.params.coolant))
        self.coolant_display_var = tk.StringVar()
        self.coolant_menu = tk.OptionMenu(coolant_frm, self.coolant_display_var, "")
        self.coolant_menu.config(width=8)
        self.coolant_menu.pack(side="left", padx=(2, 0))
        self.entries["coolant"] = self.coolant_var
        self._rebuild_coolant_menu()
        
        # 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=16, 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=16, 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=16, 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

        # Diameter/radius programming mode as dropdown (language-neutral values --
        # "diam"/"rad" are technical abbreviations, not translated per-language,
        # matching how "G21 (mm)"/"G95 (/rev)" etc. are already handled)
        diam_frm = tk.Frame(left_params)
        diam_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(diam_frm, width=16, anchor="w"), "G7/G8").pack(side="left")
        self.diameter_mode_var = tk.StringVar(
            value="G7 Diam" if self.params.diameter_mode else "G8 Rad")
        diam_menu = tk.OptionMenu(diam_frm, self.diameter_mode_var,
                                  "G7 Diam", "G8 Rad",
                                  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

        # Display controls
        self._i18n_text(tk.Label(left_params, font=("TkDefaultFont", 9, "bold")), "STOCK & CLEAR").pack(anchor="w", pady=(8, 4))

        # Stock (diam/length) and Clear (X/Z) as two side-by-side columns
        stock_clear_row = tk.Frame(left_params)
        stock_clear_row.pack(fill="x", pady=1)

        stock_col = tk.Frame(stock_clear_row)
        stock_col.pack(side="left", anchor="n")
        clear_col = tk.Frame(stock_clear_row)
        clear_col.pack(side="left", anchor="n", padx=(8, 0))

        stock_diam_frm = tk.Frame(stock_col)
        stock_diam_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(stock_diam_frm, anchor="w"), "Діам загот").pack(side="left")
        stock_diam_ent = tk.Entry(stock_diam_frm, width=7)
        stock_diam_ent.insert(0, str(self.params.stock_diam))
        stock_diam_ent.pack(side="left", padx=(2, 0))
        self.entries["stock_diam"] = stock_diam_ent
        stock_diam_ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())
        stock_diam_ent.bind("<FocusOut>", lambda _e: self._schedule_preview())

        stock_len_frm = tk.Frame(stock_col)
        stock_len_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(stock_len_frm, anchor="w"), "Довж загот (Z)").pack(side="left")
        stock_len_ent = tk.Entry(stock_len_frm, width=7)
        stock_len_ent.insert(0, str(self.params.stock_len))
        stock_len_ent.pack(side="left", padx=(2, 0))
        self.entries["stock_len"] = stock_len_ent
        stock_len_ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())
        stock_len_ent.bind("<FocusOut>", lambda _e: self._schedule_preview())

        clear_fields = [
            ("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 clear_fields:
            frm = tk.Frame(clear_col)
            frm.pack(fill="x", pady=1)
            self._i18n_text(tk.Label(frm, anchor="w"), label).pack(side="left")
            ent = tk.Entry(frm, width=7)
            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())

        # 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 = [
            ("feed_rough", "Подача чорнова", str(self.params.feed_rough)),
            ("feed_finish", "Подача фінішна", str(self.params.feed_finish)),
            ("retract", "Відвід (R)", str(self.params.retract)),
            ("doc", "Глиб різан (I)", str(self.params.doc)),
            ("finish_depth_per_pass", "Фініш/прохід (D)", str(self.params.finish_depth_per_pass)),
        ]
        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=16, 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
            if key == "finish_depth_per_pass":
                ent.bind("<KeyRelease>", lambda _e: self._update_finish_pass_info())
            else:
                ent.bind("<KeyRelease>", lambda _e: self._schedule_preview())

        # Finish passes count (P word for G70) — dropdown 1-5
        finish_passes_frm = tk.Frame(right_params)
        finish_passes_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(finish_passes_frm, width=16, anchor="w"), "К-сть чист. проходів").pack(side="left")
        self.finish_passes_var = tk.StringVar(value=str(self.params.finish_passes or 1))
        self.finish_passes_menu = tk.OptionMenu(
            finish_passes_frm, self.finish_passes_var, "1", "2", "3", "4", "5",
            command=lambda _v: self._update_finish_pass_info(),
        )
        self.finish_passes_menu.config(width=8)
        self.finish_passes_menu.pack(side="left", padx=(2, 0))
        self.entries["finish_passes"] = self.finish_passes_var

        # Remaining distance (D): the actual computed total that goes into
        # G70/G71/G72 -- shown in the same style as every other row, not as
        # a small gray inline note.
        remaining_d_frm = tk.Frame(right_params)
        remaining_d_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(remaining_d_frm, width=16, anchor="w"), "Remaining (D)").pack(side="left")
        self.finish_pass_info_label = tk.Label(remaining_d_frm, width=14, anchor="w",
                                               bg="#e8e4d0", fg="#000000")
        self.finish_pass_info_label.pack(side="left", padx=(2, 0))
        self._update_finish_pass_info()

        # Spring passes — a SEPARATE stage after the finish passes: each one
        # repeats the identical final cut (D=E=0), not tied to finish_passes'
        # own count. "No" = 0 = disabled.
        spring_passes_frm = tk.Frame(right_params)
        spring_passes_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(spring_passes_frm, width=16, anchor="w"), "К-сть пружинних").pack(side="left")
        self.spring_passes_var = tk.StringVar(value=str(self.params.spring_passes) if self.params.spring_passes else "No")
        spring_passes_menu = tk.OptionMenu(
            spring_passes_frm, self.spring_passes_var, "No", "1", "2", "3", "4", "5",
            command=lambda _v: self._schedule_preview(),
        )
        spring_passes_menu.config(width=8)
        spring_passes_menu.pack(side="left", padx=(2, 0))
        self._tooltip(spring_passes_menu, "Spring passes tooltip")
        self.entries["spring_passes"] = self.spring_passes_var

        # "Тільки фініш (без G71)" — skips G71 roughing (stock assumed
        # already near-final size); DOES use G70, so finish_passes genuinely
        # divides stock_allow across passes. See
        # wrapper_finish_direct_subprogram_gcode for rationale/caveats.
        direct_finish_frm = tk.Frame(right_params)
        direct_finish_frm.pack(fill="x", pady=1)
        self._tooltip(
            self._i18n_text(tk.Label(direct_finish_frm, width=16, anchor="w"), "Тільки фініш (без G71)"),
            "Finish only tooltip",
        ).pack(side="left")
        self.direct_finish_var = tk.StringVar(value="Yes" if self.params.direct_finish_only else "No")
        direct_finish_menu = tk.OptionMenu(
            direct_finish_frm, self.direct_finish_var, "No", "Yes",
            command=lambda _v: self._schedule_preview(),
        )
        direct_finish_menu.config(width=8)
        direct_finish_menu.pack(side="left", padx=(2, 0))

        # Tool nose radius compensation (TNRC): single No/G42/G41 dropdown
        comp_frm = tk.Frame(right_params)
        comp_frm.pack(fill="x", pady=1)
        self._i18n_text(tk.Label(comp_frm, width=16, anchor="w"), "Компенсація різця").pack(side="left")
        self.tool_comp_var = tk.StringVar(
            value=self.params.comp_direction if self.params.use_tool_comp else "No")
        tool_comp_menu = tk.OptionMenu(
            comp_frm, self.tool_comp_var, "No", "G42", "G41",
            command=lambda _v: self._on_tool_comp_toggle(),
        )
        tool_comp_menu.config(width=8)
        tool_comp_menu.pack(side="left", padx=(2, 0))
        self.entries["tool_comp"] = self.tool_comp_var
        self._on_tool_comp_toggle()  # apply initial enabled/disabled state + 1-pass lock

        # Start X field
        start_x_frm = tk.Frame(right_params)
        start_x_frm.pack(fill="x", pady=1)
        self._tooltip(
            self._i18n_text(tk.Label(start_x_frm, width=16, anchor="w"), "Start X"),
            "Start X tooltip",
        ).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())

        # Start Z field
        start_z_frm = tk.Frame(right_params)
        start_z_frm.pack(fill="x", pady=1)
        self._tooltip(
            self._i18n_text(tk.Label(start_z_frm, width=16, anchor="w"), "Start Z"),
            "Start Z tooltip",
        ).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")

        z_row_frm = tk.Frame(tab_segment)
        z_row_frm.grid(row=0, column=0, columnspan=4, sticky="w", pady=2, padx=4)
        self._i18n_text(tk.Label(z_row_frm), "Z").pack(side="left")
        self.in_z = tk.Entry(z_row_frm, width=10)
        self.in_z.pack(side="left", padx=(4, 10))
        self.delta_z_var = tk.BooleanVar(value=False)
        self._tooltip(
            tk.Checkbutton(z_row_frm, text="\u0394Z", variable=self.delta_z_var, command=self._on_delta_z_toggle),
            "Delta Z tooltip",
        ).pack(side="left")
        self.in_delta_z = tk.Entry(z_row_frm, width=10)
        self.in_delta_z.pack(side="left", padx=(4, 0))
        self.in_z.bind("<KeyRelease>", lambda _e: (self._sync_z_and_delta(), self._sync_r_and_arc_diam()))
        self.in_z.bind("<FocusOut>", lambda _e: (self._sync_z_and_delta(), self._sync_r_and_arc_diam()))
        self.in_delta_z.bind("<KeyRelease>", lambda _e: (self._sync_z_and_delta(), self._sync_r_and_arc_diam()))
        self.in_delta_z.bind("<FocusOut>", lambda _e: (self._sync_z_and_delta(), self._sync_r_and_arc_diam()))
        self._on_delta_z_toggle()  # apply initial enabled/disabled state

        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.in_x.bind("<KeyRelease>", lambda _e: self._sync_r_and_arc_diam())
        self.in_x.bind("<FocusOut>", lambda _e: self._sync_r_and_arc_diam())

        self._i18n_text(tk.Label(tab_segment), "R (arc radius)").grid(row=2, column=0, sticky="w", pady=2, padx=4)
        r_row_frm = tk.Frame(tab_segment)
        r_row_frm.grid(row=2, column=1, columnspan=3, sticky="w", pady=2, padx=4)
        self.in_r = tk.Entry(r_row_frm, width=8)
        self.in_r.pack(side="left")
        self.arc_diam_var = tk.BooleanVar(value=False)
        self._tooltip(
            tk.Checkbutton(r_row_frm, text="\u2300X", variable=self.arc_diam_var, command=self._on_arc_diam_toggle),
            "Arc diam tooltip",
        ).pack(side="left", padx=(8, 2))
        self.in_arc_diam = tk.Entry(r_row_frm, width=10)
        self.in_arc_diam.pack(side="left")
        self.arc_diam_error_label = tk.Label(r_row_frm, text="", fg="red", anchor="w")
        self.arc_diam_error_label.pack(side="left", padx=(6, 0))
        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(), self._sync_r_and_arc_diam()))
        self.in_r.bind("<FocusOut>", lambda _e: self._sync_r_and_arc_diam())
        self.in_arc_diam.bind("<KeyRelease>", lambda _e: self._sync_r_and_arc_diam())
        self.in_arc_diam.bind("<FocusOut>", lambda _e: self._sync_r_and_arc_diam())
        self._on_arc_diam_toggle()  # apply initial enabled/disabled state

        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())
            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)
        corner_row_frm = tk.Frame(tab_segment)
        corner_row_frm.grid(row=3, column=1, columnspan=3, sticky="w", pady=2, padx=4)
        self.corner_type = tk.StringVar(value="none")
        corner_menu = tk.OptionMenu(corner_row_frm, self.corner_type, "none", "A (fillet)", "C (chamfer)",
                                    command=lambda _v: self._schedule_preview())
        corner_menu.pack(side="left")
        self._i18n_text(tk.Label(corner_row_frm), "Corner value").pack(side="left", padx=(10, 4))
        self.in_corner_val = tk.Entry(corner_row_frm, width=10)
        self.in_corner_val.pack(side="left")
        self.in_corner_val.bind("<FocusIn>", lambda _e: self._schedule_preview())
        self.in_corner_val.bind("<FocusOut>", lambda _e: self._schedule_preview())
        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=4, column=0, columnspan=2, pady=(8, 4), sticky="ew")
        seg_btn_row1 = tk.Frame(seg_btn_frm)
        seg_btn_row1.pack(fill="x")
        self._tooltip(tk.Button(seg_btn_row1, text="+", width=3, command=self.add_segment), "Add").pack(side="left", padx=2)
        self._tooltip(tk.Button(seg_btn_row1, text="\U0001F504", width=3, command=self.update_selected), "Update").pack(side="left", padx=2)
        self._tooltip(tk.Button(seg_btn_row1, text="\u2212", width=3, command=self.remove_selected), "Remove").pack(side="left", padx=2)
        self._tooltip(tk.Button(seg_btn_row1, text="\u21B6", width=3, command=self.undo_delete), "Undo").pack(side="left", padx=2)
        self._tooltip(tk.Button(seg_btn_row1, text="\U0001F5D1", width=3, command=self.delete_all), "Delete All").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_row1, command=self.import_auto), "Import segments").pack(side="left", padx=(10, 2))
        self._i18n_text(tk.Button(seg_btn_row1, command=self.export_txt), "Exp coord").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_row1, command=self.export_proj), "Exp Proj").pack(side="left", padx=2)

        seg_btn_row2 = tk.Frame(seg_btn_frm)
        seg_btn_row2.pack(fill="x", pady=(4, 0))
        self._i18n_text(tk.Button(seg_btn_row2, command=self.apply_all_z_offset), "All Z offset").pack(side="left", padx=2)
        self._i18n_text(tk.Button(seg_btn_row2, command=self.apply_all_x_offset), "All X offset").pack(side="left", padx=2)

        self._i18n_text(tk.Label(tab_segment), "Segment List").grid(row=5, 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=6, 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(6, weight=1)

        # TAB 2: Import (DXF + 3MF)
        tab_import = tk.Frame(self.notebook)
        self.notebook.add(tab_import, text="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="DXF")

        dxf_btn_frm = tk.Frame(tab_dxf)
        dxf_btn_frm.pack(pady=8)
        tk.Button(dxf_btn_frm, text="Import DXF", command=self.import_dxf).pack(side="left", padx=4)
        tk.Button(dxf_btn_frm, text="Export DXF", command=self.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=True)
        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.import_source = "dxf"  # Track which import type is active: "dxf" or "3mf"

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

        dxf_z_frm = tk.Frame(dxf_opts)
        dxf_z_frm.pack(anchor="w", pady=2)
        tk.Label(dxf_z_frm, text="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)

        self.apply_dxf_btn = tk.Button(dxf_opts, text="Apply imported outline", command=self.apply_dxf)
        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="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)
        tk.Button(threemf_btn_frm, text="Import 3MF", command=self.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 = tk.LabelFrame(left_col, text="Projection", padx=8, pady=8)
        threemf_proj.pack(fill="x", pady=(0, 8))
        tk.Label(threemf_proj, text="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)
        tk.Checkbutton(threemf_proj, text="Convex hull", variable=self.threemf_convex_hull, command=self._refresh_3mf_preview).pack(anchor="w", pady=2)

        scale_frm = tk.Frame(threemf_proj)
        scale_frm.pack(anchor="w", pady=(4, 0))
        tk.Label(scale_frm, text="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))
        tk.Label(simp_frm, text="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 = tk.LabelFrame(right_col, text="Orientation", padx=8, pady=8)
        threemf_orient.pack(fill="x", pady=(0, 8))
        tk.Checkbutton(threemf_orient, text="Swap X/Z", variable=self.threemf_swap_axes,
                       command=self._refresh_dxf_preview).pack(anchor="w", pady=1)
        tk.Checkbutton(threemf_orient, text="Flip Z", variable=self.threemf_flip_z,
                       command=self._refresh_dxf_preview).pack(anchor="w", pady=1)
        tk.Checkbutton(threemf_orient, text="Flip X", variable=self.threemf_flip_x,
                       command=self._refresh_dxf_preview).pack(anchor="w", pady=1)
        tk.Checkbutton(threemf_orient, text="Shift min X to 0", variable=self.threemf_shift_min_x,
                       command=self._refresh_dxf_preview).pack(anchor="w", pady=1)
        tk.Checkbutton(threemf_orient, text="Center X on spindle", variable=self.threemf_center_x,
                       command=self._refresh_dxf_preview).pack(anchor="w", pady=1)
        tk.Checkbutton(threemf_orient, text="Use as background image", variable=self.threemf_as_background,
                       command=self._refresh_dxf_preview).pack(anchor="w", pady=1)

        threemf_z_frm = tk.Frame(threemf_orient)
        threemf_z_frm.pack(anchor="w", pady=(4, 0))
        tk.Label(threemf_z_frm, text="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 = tk.Button(button_frm, text="Apply imported outline", command=self.apply_dxf)
        self.apply_3mf_btn.pack()
        self.apply_3mf_btn.pack_forget()  # Hidden by default

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

        # Language selector: compact dropdown, top-right of the preview panel
        lang_frm = tk.Frame(preview_header)
        lang_frm.pack(side="right")
        self._lang_display_to_code = {"English": "en", "Українська": "uk"}
        self._lang_code_to_display = {"en": "English", "uk": "Українська"}
        self.lang_display_var = tk.StringVar(value=self._lang_code_to_display[self.lang_var.get()])

        def _on_lang_select(display_value: str) -> None:
            self.lang_var.set(self._lang_display_to_code.get(display_value, "en"))
            self._apply_language()

        lang_menu = tk.OptionMenu(lang_frm, self.lang_display_var, "English", "Українська", command=_on_lang_select)
        lang_menu.config(width=10)
        lang_menu.pack(side="left")

        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 the SAME row as zoom buttons
        controls_bar = tk.Frame(zoom_bar)
        controls_bar.pack(side="left", padx=(12, 0))
        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", "FILLET", "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 -- hidden for now (not needed), kept wired in case
        # re-enabled later; just not packed into the visible layout.
        self.chk_snap = self._i18n_text(tk.Checkbutton(controls_bar, variable=self.snap_enabled, command=self._validate_snap_step), "Snap")
        self._i18n_text(tk.Label(controls_bar), "Step (mm):")
        self.snap_step_entry = tk.Entry(controls_bar, textvariable=self.snap_step, width=6)
        self.snap_step_entry.bind("<FocusOut>", self._validate_snap_step)

        # Half profile: show only one half of the cross-section instead of
        # both (mirrored top+bottom, the default). When active, "Mirror
        # preview" switches meaning: instead of mirroring everything (which
        # does nothing useful when both halves are already shown), it picks
        # WHICH half (top/bottom) is the one drawn.
        self.half_var = tk.BooleanVar(value=False)
        self.chk_half = self._i18n_text(tk.Checkbutton(
            controls_bar,
            variable=self.half_var,
            command=self._on_half_toggle,
        ), "Half")
        self.chk_half.pack(side="left", padx=(12, 4))

        # 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=(4, 4))
        self._on_half_toggle()  # apply initial enabled/disabled state

        # 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())
        # Right-click/middle-click context menu removed -- mode is already
        # chosen via the "Mode:" dropdown above the canvas; a surprise
        # right-click menu on the preview was unwanted.
        
        # 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.generate, width=20), "Generate G-code").pack(pady=4)
        self.save_gcode_btn = self._i18n_text(tk.Button(actions_frame, command=self.save_gcode, width=20), "Save G-code")
        self.save_gcode_btn.pack(pady=4)
        self.send_linuxcnc_btn = self._i18n_text(tk.Button(actions_frame, command=self.send_to_linuxcnc, width=20), "Send to LinuxCNC")
        self.send_linuxcnc_btn.pack(pady=4)
        self.send_quit_btn = self._i18n_text(tk.Button(actions_frame, command=self.write_stdout, width=20), "Send to LinuxCNC and quit")
        self.send_quit_btn.pack(pady=4)
        self._mark_gcode_dirty()  # start disabled -- nothing generated yet

        # 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 _tooltip(self, widget, key: str):
        """Attach a hover tooltip that shows the translation of `key`.

        Unlike _i18n_text, this does NOT touch the widget's own text (used
        for icon-only buttons whose permanent label is a symbol, not a
        translated word).
        """
        _ToolTip(widget, lambda: self._tr(key))
        return widget

    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()))
        self._rebuild_coolant_menu()

    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:
        z_was_disabled = str(self.in_z.cget("state")) == "disabled"
        if z_was_disabled:
            self.in_z.config(state="normal")
        self.in_z.delete(0, tk.END)
        if z_was_disabled:
            self.in_z.config(state="disabled")
        d_was_disabled = str(self.in_delta_z.cget("state")) == "disabled"
        if d_was_disabled:
            self.in_delta_z.config(state="normal")
        self.in_delta_z.delete(0, tk.END)
        if d_was_disabled:
            self.in_delta_z.config(state="disabled")
        self.in_x.delete(0, tk.END)
        r_was_disabled = str(self.in_r.cget("state")) == "disabled"
        if r_was_disabled:
            self.in_r.config(state="normal")
        self.in_r.delete(0, tk.END)
        if r_was_disabled:
            self.in_r.config(state="disabled")
        if hasattr(self, "in_arc_diam"):
            ad_was_disabled = str(self.in_arc_diam.cget("state")) == "disabled"
            if ad_was_disabled:
                self.in_arc_diam.config(state="normal")
            self.in_arc_diam.delete(0, tk.END)
            if ad_was_disabled:
                self.in_arc_diam.config(state="disabled")
        self.in_corner_val.delete(0, tk.END)
        self.corner_type.set("none")

    def _set_z_display(self, z_val: float) -> None:
        """Write z_val (an existing point's real Z) into in_z, and recompute
        in_delta_z to match -- updates BOTH fields regardless of which is
        currently the disabled one (plain .insert() on a disabled Entry is
        silently ignored by Tkinter, which was leaving stale text behind).
        """
        z_was_disabled = str(self.in_z.cget("state")) == "disabled"
        if z_was_disabled:
            self.in_z.config(state="normal")
        self.in_z.delete(0, tk.END)
        self.in_z.insert(0, f"{z_val:.3f}")
        if z_was_disabled:
            self.in_z.config(state="disabled")

        prev_z = self._get_delta_z_prev()
        if prev_z is None:
            return
        delta = prev_z - z_val
        d_was_disabled = str(self.in_delta_z.cget("state")) == "disabled"
        if d_was_disabled:
            self.in_delta_z.config(state="normal")
        self.in_delta_z.delete(0, tk.END)
        self.in_delta_z.insert(0, f"{delta:.3f}")
        if d_was_disabled:
            self.in_delta_z.config(state="disabled")

    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_x.delete(0, tk.END)
        self.in_corner_val.delete(0, tk.END)
        pt = self.points[idx]
        self._set_z_display(pt.z)
        self.in_x.insert(0, f"{pt.x:.3f}")
        # Radius applies to next segment
        r_next = self.points[idx + 1].r if idx + 1 < len(self.points) else 0.0
        self._set_r_display(r_next if abs(r_next) > 1e-9 else None)
        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
        if self.delta_z_var.get() and self.in_delta_z.get().strip():
            # Compute directly from the raw ΔZ text using THIS insert's own
            # prev_z -- self.in_z's displayed value was live-synced against
            # _get_delta_z_prev()'s reference point (points[selected-1], for
            # Update's benefit), which can differ from this Add's actual
            # prev (points[idx-1]) when a point is selected in the list.
            z_val = prev_z - float(self.in_delta_z.get())
        else:
            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", "FILLET", "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 (same as FILLET mode).",
            "FILLET":  "FILLET mode: Click near an interior point to add a fillet (rounded corner).",
            "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: clicking a point/segment selects it (no drag-to-move --
        # moving a point is done deliberately via the Z/X fields + Update).
        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:
                self.selected_point_idx = pt_hit
                self._select_list_index(pt_hit)
            elif seg_hit is not None:
                self.selected_point_idx = None
                self._select_list_index(seg_hit)
            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,
            }
            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/FILLET/CHAMFER modes, select nearest point if within tolerance
                if self.draw_mode in ("RADIUS", "FILLET", "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 in ("RADIUS", "FILLET"):
                            # 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 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():
                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 = []
                if poly.dxftype() == "LWPOLYLINE":
                    verts = list(poly.get_points("xyb"))
                    closed = bool(poly.closed)
                else:
                    verts = [(v.dxf.x, v.dxf.y, getattr(v.dxf, "bulge", 0.0)) for v in poly.vertices()]
                    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:
            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))

        # 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_str = self.threemf_z_offset.get() if hasattr(self.threemf_z_offset, 'get') else "0"
                z_offset = float(z_offset_str) if z_offset_str.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_str = z_offset_var.get() if hasattr(z_offset_var, 'get') else "0"
            z_offset = float(z_offset_str) if z_offset_str.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):
            pts_mapped = _clip_to_positive_x(pts_mapped, closed)

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

        return pts_mapped



    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 _validate_and_refresh_dxf(self, event=None) -> None:
        """Validate Z offset entry and refresh preview."""
        if self.import_source == "3mf":
            try:
                float(self.threemf_z_offset.get()) if self.threemf_z_offset.get().strip() else None
            except Exception:
                self.threemf_z_offset.set(0.0)
        else:
            try:
                float(self.dxf_z_offset.get()) if self.dxf_z_offset.get().strip() else None
            except Exception:
                self.dxf_z_offset.set(0.0)
        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()
        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.finish_depth_per_pass = float(self.entries["finish_depth_per_pass"].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 = p.tool  # single-tool workflow: finish always uses the same tool
        p.coolant = self.coolant_var.get() if hasattr(self, "coolant_var") else 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() == "Yes") if hasattr(self, "direct_finish_var") else False
        tool_comp_value = self.tool_comp_var.get() if hasattr(self, "tool_comp_var") else "No"
        p.use_tool_comp = tool_comp_value != "No"
        p.comp_direction = "G41" if "G41" in str(tool_comp_value).upper() else "G42"
        try:
            p.finish_passes = max(1, int(float(self.entries["finish_passes"].get())))
        except (ValueError, KeyError):
            p.finish_passes = 1
        if hasattr(self, "spring_passes_var"):
            spring_val = self.spring_passes_var.get()
            try:
                p.spring_passes = int(spring_val) if spring_val != "No" else 0
            except ValueError:
                p.spring_passes = 0
        else:
            p.spring_passes = 0
        # TOTAL allowance (D) = per-pass depth * pass count, computed AFTER
        # finish_passes is fully finalized above, so e.g. 0.25mm/pass *
        # 2 passes = 0.5mm total, matching what the user actually typed.
        p.stock_allow = p.finish_depth_per_pass * max(1, p.finish_passes)
        return p

    def _mark_gcode_dirty(self) -> None:
        """Mark the OUTPUT text as stale relative to current inputs.

        Disables Save/Send buttons and greys the OUTPUT text until the user
        clicks 'Generate G-code' again. Idempotent -- safe to call on every
        keystroke.
        """
        self._gcode_dirty = True
        for btn_name in ("save_gcode_btn", "send_linuxcnc_btn", "send_quit_btn"):
            btn = getattr(self, btn_name, None)
            if btn is not None:
                btn.config(state="disabled")
        if hasattr(self, "output_text"):
            self.output_text.config(fg="#999999")

    def _mark_gcode_clean(self) -> None:
        """Re-enable Save/Send buttons and restore normal OUTPUT text color
        after a successful 'Generate G-code'."""
        self._gcode_dirty = False
        for btn_name in ("save_gcode_btn", "send_linuxcnc_btn", "send_quit_btn"):
            btn = getattr(self, btn_name, None)
            if btn is not None:
                btn.config(state="normal")
        if hasattr(self, "output_text"):
            self.output_text.config(fg="black")

    def preview(self) -> None:
        self._mark_gcode_dirty()
        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")
        # Cancel any pending debounced preview (e.g. a FocusOut fired by
        # clicking this very button) so it can't fire ~400ms later and
        # spuriously re-mark the just-generated output as dirty.
        if self.preview_job:
            self.root.after_cancel(self.preview_job)
            self.preview_job = None
        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)
            direct_finish_advisory = profile_op.generator.check_direct_finish_centerline_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)
            self._mark_gcode_clean()
            status_msg = f"G-code generated and saved to {out_path_abs}"
            if advisory:
                status_msg += f"\n⚠ {advisory}"
            if direct_finish_advisory:
                status_msg += f"\n⚠ {direct_finish_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:
        """
        Take the current OUTPUT text (as generated by 'Generate G-code', with
        any manual edits/comments the user added) and load it into the
        running AXIS instance.

        Deliberately does NOT regenerate from params -- Save G-code, Send to
        LinuxCNC, and this all use the exact text visible in OUTPUT, so a
        comment added after generating isn't silently dropped.
        """
        self._force_save("before_write_stdout")
        gcode = self.output_text.get("1.0", tk.END).strip()
        if not gcode:
            messagebox.showwarning("Send to LinuxCNC", "No G-code to send. Click 'Generate G-code' first.")
            return
        try:
            # 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 send_to_linuxcnc(self) -> None:
        """Send the current OUTPUT text to AXIS -- WITHOUT closing the window.

        Same as write_stdout but keeps the profiler open so the user can
        continue editing and re-send without restarting the program. Uses
        the OUTPUT text verbatim (see write_stdout docstring).
        """
        self._force_save("before_send")
        gcode = self.output_text.get("1.0", tk.END).strip()
        if not gcode:
            messagebox.showwarning("Send to LinuxCNC", "No G-code to send. Click 'Generate G-code' first.")
            return
        try:
            nc_dir = Path.home() / "linuxcnc" / "nc_files"
            if not nc_dir.exists():
                nc_dir = Path.cwd()

            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")

            subprocess.run(["axis-remote", str(out_path)], check=False)

            self.show_status(f"Sent to AXIS: {out_path}", color="#A9DC76")

        except Exception as exc:
            self.show_status(f"Send 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 _rebuild_coolant_menu(self) -> None:
        """(Re)build the Coolant dropdown's menu entries in the current
        language, and sync the displayed text to the underlying BooleanVar.

        Needed because tk.OptionMenu bakes its menu item labels in at
        creation time -- a plain widget.config(text=...) (used for buttons/
        labels on language switch) doesn't touch them, so the menu itself
        has to be rebuilt whenever the language changes.
        """
        if not hasattr(self, "coolant_menu"):
            return
        yes_label = self._tr("Yes")
        no_label = self._tr("No")

        def _set(val: bool) -> None:
            self.coolant_var.set(val)
            self.coolant_display_var.set(yes_label if val else no_label)
            self._schedule_preview()

        menu = self.coolant_menu["menu"]
        menu.delete(0, "end")
        menu.add_command(label=yes_label, command=lambda: _set(True))
        menu.add_command(label=no_label, command=lambda: _set(False))
        self.coolant_display_var.set(yes_label if self.coolant_var.get() else no_label)

    def _get_delta_z_prev(self) -> Optional[float]:
        """Z of the point ΔZ should be measured from, given current selection.

        Mirrors update_selected's own "prev = points[idx-1]" convention when a
        point is selected (since the user specifically wants ΔZ usable via
        Update), and falls back to the last point when nothing is selected
        (the plain "append via Add" case).
        """
        if not hasattr(self, "seg_list"):
            return self.points[-1].z if self.points else None
        sel = self.seg_list.curselection()
        idx = sel[0] if sel else None
        if idx is not None:
            return self.points[idx - 1].z if idx > 0 else None
        return self.points[-1].z if self.points else None

    def _sync_z_and_delta(self) -> None:
        """Keep Z and ΔZ mutually consistent -- whichever is currently
        editable (per the checkbox) drives the other's (disabled) displayed
        value, so BOTH always show a real number, not just the active one.
        """
        prev_z = self._get_delta_z_prev()
        if prev_z is None:
            return
        if self.delta_z_var.get():
            # ΔZ is the editable source -> compute absolute Z from it
            delta_text = self.in_delta_z.get().strip()
            if not delta_text:
                return
            try:
                delta = float(delta_text)
            except ValueError:
                return
            abs_z = prev_z - delta  # caliper reading is always positive; profile moves deeper (-Z)
            self.in_z.config(state="normal")
            self.in_z.delete(0, tk.END)
            self.in_z.insert(0, f"{abs_z:.3f}")
            self.in_z.config(state="disabled")
        else:
            # Z is the editable source -> compute ΔZ from it
            z_text = self.in_z.get().strip()
            if not z_text:
                return
            try:
                z_val = float(z_text)
            except ValueError:
                return
            delta = prev_z - z_val
            self.in_delta_z.config(state="normal")
            self.in_delta_z.delete(0, tk.END)
            self.in_delta_z.insert(0, f"{delta:.3f}")
            self.in_delta_z.config(state="disabled")
        self._schedule_preview()

    def _on_delta_z_toggle(self) -> None:
        """Exactly one of Z / ΔZ is editable at a time; the other is greyed
        out but still shows its live-computed value (see _sync_z_and_delta)."""
        if self.delta_z_var.get():
            self.in_z.config(state="disabled")
            self.in_delta_z.config(state="normal")
        else:
            self.in_z.config(state="normal")
            self.in_delta_z.config(state="disabled")
        self._sync_z_and_delta()
        self._sync_r_and_arc_diam()

    def _on_half_toggle(self) -> None:
        """Mirror preview only means something when Half is active (picks
        which half to show); disable it otherwise so it can't be toggled
        for no visible effect. Also reset it to unchecked when leaving Half
        mode -- otherwise a leftover "mirrored" xs sign from before could
        keep affecting the full (both-halves) rendering, leaving the
        profile drawn upside-down/at the bottom even though both halves
        are meant to be shown."""
        if self.half_var.get():
            self.chk_mirror.config(state="normal")
        else:
            self.preview_mirror_x_var.set(False)
            self.chk_mirror.config(state="disabled")
        if hasattr(self, "canvas"):
            self.preview()

    def _on_tool_comp_toggle(self) -> None:
        """TNRC (G41/G42) no longer forces a single finishing pass.

        G70 with P>1 combined with active cutter compensation was the
        combination that produced "concave corner cannot be reached without
        gouging" errors during testing -- but _g70_lines() now avoids that
        specific mechanism when TNRC is on, emitting separate single-pass
        G70 calls (via E, not P) instead. Multi-pass finishing under
        compensation is safe again.
        """
        self._update_finish_pass_info()

    def _get_arc_endpoints(self) -> Optional[Tuple[float, float, float, float]]:
        """Resolve (prev_z, prev_x, targ_z, targ_x) for the arc implied by
        the current Z/X fields and selection state: editing an existing
        point's outgoing arc to the next point, or appending a new one
        after the last existing point. Shared by the R<->diameter sync and
        (previously) the read-only arc info display.
        """
        try:
            z_val = float(self.in_z.get().strip())
            x_val = float(self.in_x.get().strip())
        except ValueError:
            return None
        if not hasattr(self, "seg_list"):
            return None
        sel = self.seg_list.curselection()
        idx = sel[0] if sel else None
        if idx is not None and idx + 1 < len(self.points):
            targ = self.points[idx + 1]
            return z_val, x_val, targ.z, targ.x
        elif self.points:
            last = self.points[-1]
            return last.z, last.x, z_val, x_val
        return None

    def _diameter_mode_active(self) -> bool:
        return "G7" in str(self.diameter_mode_var.get()).upper() if hasattr(self, "diameter_mode_var") else True

    def _set_r_display(self, r_val: Optional[float]) -> None:
        """Load an existing point's stored R (ground truth when a point is
        freshly selected from the list) into in_r, and refresh the computed
        peak/trough diameter in in_arc_diam to match -- regardless of which
        field the checkbox currently marks as the editable one (that only
        governs what happens when you type, not what's shown on load).
        """
        r_was_disabled = str(self.in_r.cget("state")) == "disabled"
        if r_was_disabled:
            self.in_r.config(state="normal")
        self.in_r.delete(0, tk.END)
        if r_val is not None and abs(r_val) > 1e-9:
            self.in_r.insert(0, f"{r_val:.3f}")
        if r_was_disabled:
            self.in_r.config(state="disabled")

        if not hasattr(self, "in_arc_diam"):
            return
        d_was_disabled = str(self.in_arc_diam.cget("state")) == "disabled"
        if d_was_disabled:
            self.in_arc_diam.config(state="normal")
        self.in_arc_diam.delete(0, tk.END)
        self._arc_bulge_preview = None
        endpoints = self._get_arc_endpoints()
        if endpoints is not None and r_val is not None and abs(r_val) > 1e-9:
            prev_z, prev_x, targ_z, targ_x = endpoints
            result = _arc_bulge_point(prev_z, prev_x, targ_z, targ_x, r_val, self._diameter_mode_active())
            if result is not None:
                self.in_arc_diam.insert(0, f"{result[1]:.3f}")
                self._arc_bulge_preview = result
        if d_was_disabled:
            self.in_arc_diam.config(state="disabled")
        if hasattr(self, "arc_diam_error_label"):
            self.arc_diam_error_label.config(text="")

    def _set_finish_pass_info_text(self, text: str) -> None:
        """Write text into the Remaining-distance display (a Label, not an
        Entry -- no text selection possible, no input-field border)."""
        self.finish_pass_info_label.config(text=text)

    def _update_finish_pass_info(self) -> None:
        """Live info field next to Finish passes: shows the TOTAL allowance
        (D) that results from the current per-pass depth * pass count,
        e.g. Finish/pass=0.2 with count=1 shows "1 * 0.2 = 0.2"."""
        if not hasattr(self, "finish_pass_info_label"):
            return
        try:
            per_pass_text = self.entries["finish_depth_per_pass"].get().strip()
            count_text = self.finish_passes_var.get().strip()
            if not per_pass_text or not count_text:
                self._set_finish_pass_info_text("")
                return
            per_pass = float(per_pass_text)
            count = int(count_text)
            total = per_pass * count
            self._set_finish_pass_info_text(f"{count}*{per_pass:g}={total:g}")
        except (ValueError, KeyError):
            self._set_finish_pass_info_text("")
        self._schedule_preview()

    def _sync_r_and_arc_diam(self) -> None:
        """Keep R and the peak/trough diameter mutually consistent.

        Checkbox off: R is the editable source; in_arc_diam is disabled and
        just shows the diameter that R currently produces (or a "no bulge"
        note). Checkbox on: the target diameter is the editable source;
        in_r is disabled and shows the SOLVED radius, recomputed here every
        time this is called -- including from _set_z_display/_set_r_display
        when Z/X endpoints change, e.g. correcting the start or end point --
        so the requested peak diameter is always honoured against the
        current geometry, not just the geometry it was originally solved for.
        """
        if not hasattr(self, "in_arc_diam"):
            return
        self._arc_bulge_preview = None
        self.arc_diam_error_label.config(text="")
        endpoints = self._get_arc_endpoints()
        diameter_mode = self._diameter_mode_active()

        if self.arc_diam_var.get():
            # Target diameter/radius is the editable source -> solve for R
            if endpoints is None:
                return
            prev_z, prev_x, targ_z, targ_x = endpoints
            diam_text = self.in_arc_diam.get().strip()
            if not diam_text:
                return
            try:
                target_x = float(diam_text)
            except ValueError:
                return
            # Preserve the existing arc's rotation direction; default to CCW
            # (negative R / G3) -- this app's data has consistently used
            # negative R for these bump/dip features -- when R is blank.
            try:
                existing_r = float(self.in_r.get().strip())
            except ValueError:
                existing_r = 0.0
            cw = existing_r > 0 if abs(existing_r) > 1e-9 else False
            solved_r = _arc_radius_for_bulge_x(prev_z, prev_x, targ_z, targ_x, target_x, cw, diameter_mode)
            if solved_r is None:
                self.arc_diam_error_label.config(text=self._tr("Diam not reachable"))
                return
            self.in_r.config(state="normal")
            self.in_r.delete(0, tk.END)
            self.in_r.insert(0, f"{solved_r:.3f}")
            self.in_r.config(state="disabled")
            result = _arc_bulge_point(prev_z, prev_x, targ_z, targ_x, solved_r, diameter_mode)
            if result is not None:
                self._arc_bulge_preview = result
        else:
            # R is the editable source -> compute the resulting diameter from it
            self.in_arc_diam.config(state="normal")
            self.in_arc_diam.delete(0, tk.END)
            r_text = self.in_r.get().strip()
            if endpoints is not None and r_text:
                prev_z, prev_x, targ_z, targ_x = endpoints
                try:
                    r_val = float(r_text)
                    result = _arc_bulge_point(prev_z, prev_x, targ_z, targ_x, r_val, diameter_mode)
                    if result is not None:
                        self.in_arc_diam.insert(0, f"{result[1]:.3f}")
                        self._arc_bulge_preview = result
                    else:
                        self.in_arc_diam.insert(0, self._tr("Arc no bulge"))
                except ValueError:
                    pass
            self.in_arc_diam.config(state="disabled")
        self._schedule_preview()

    def _on_arc_diam_toggle(self) -> None:
        """Exactly one of R / peak diameter is editable at a time; the
        other is greyed out but still shows its live-computed value."""
        if self.arc_diam_var.get():
            self.in_r.config(state="disabled")
            self.in_arc_diam.config(state="normal")
        else:
            self.in_r.config(state="normal")
            self.in_arc_diam.config(state="disabled")
        self._sync_r_and_arc_diam()

    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:
            # OD: stock spans axis → +stock_radius (material OUTSIDE removed up to this)
            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:
            # ID (boring): same OD stock envelope is still the physical
            # blank/bore outer surface -- draw it symmetric about the axis
            # exactly like OD, so the centerline lands in the middle of the
            # canvas instead of at one edge.
            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")
            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")
        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

        # Point markers -- always show every entered point, even before there
        # are enough points to form a line (previously the very first point
        # was invisible until a second one existed to draw a segment).
        for pt in self.points:
            cz, cy = to_canvas(pt.z, pt.x * xf * xs)
            self.canvas.create_oval(
                cz - 3, cy - 3, cz + 3, cy + 3,
                fill="red", outline="", tags="point_marker",
            )

        # Live arc bulge point (from the Z/X/R fields currently being typed)
        if self._arc_bulge_preview is not None:
            bz, bx = self._arc_bulge_preview
            cz, cy = to_canvas(bz, bx * xf * xs)
            self.canvas.create_oval(
                cz - 4, cy - 4, cz + 4, cy + 4,
                fill="#888888", outline="", tags="arc_bulge_marker",
            )

        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:
            # ID (boring): the blank's outer surface is the SAME stock_diam
            # envelope as OD -- draw it symmetric about the axis exactly like
            # OD, so the centerline lands in the middle of the canvas instead
            # of at one edge (it previously used the canvas's overall
            # raw_min_x/raw_max_x bounds, which are NOT symmetric about the
            # stock's own axis when the profile extends further than the
            # stock on one side).
            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")
            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")
        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 that ALSO has a fillet/chamfer at its endpoint: trim the
            # arc back and blend into the next leg, mirroring exactly what
            # _emit_contour_block cuts (same _arc_end_tangent geometry, just
            # evaluated in preview-scaled/mirrored coordinates instead of
            # real-radius G-code coordinates).
            if (
                abs(cur.r) > 1e-6
                and cur.corner_type
                and cur.corner_value is not None
                and nxt is not None
            ):
                cur_x_prev = cur.x * xf * xs
                nxt_x_prev = nxt.x * xf * xs
                ct = cur.corner_type.upper()
                cv = cur.corner_value
                # xs flips X for the Mirror preview option; the arc's own
                # rotation sign (CW/CCW) must flip along with it, or the
                # computed center/tangent geometry becomes inconsistent with
                # the now-mirrored coordinates (breaks Half+Mirror together).
                r_prev = cur.r * xs
                cz_c, cxr_c, radius_c, theta_start, theta_end, cw = _arc_end_tangent(
                    prev_point[0], prev_point[1], cur.z, cur_x_prev, r_prev
                )
                if cw:
                    dz_in, dx_in = math.sin(theta_end), -math.cos(theta_end)
                else:
                    dz_in, dx_in = -math.sin(theta_end), math.cos(theta_end)
                dz_out = nxt.z - cur.z; dx_out = nxt_x_prev - cur_x_prev
                l_out = math.hypot(dz_out, dx_out)
                built = False
                if l_out >= 1e-9 and cv > 0:
                    dz_out /= l_out; dx_out /= l_out
                    if ct == "C":
                        leg = cv
                    else:
                        dot = max(-1.0, min(1.0, dz_in * dz_out + dx_in * dx_out))
                        turning = math.pi - math.acos(dot)
                        leg = cv / math.tan(turning / 2) if abs(turning) >= 1e-6 else 0.0
                    if leg > 0:
                        sweep = min(abs(theta_end - theta_start), 2 * math.pi - 1e-6)
                        max_leg = radius_c * sweep * 0.9
                        leg = min(leg, max_leg)
                        dtheta = leg / radius_c
                        theta_trim = theta_end + dtheta if cw else theta_end - dtheta
                        z_trim = cz_c + radius_c * math.cos(theta_trim)
                        x_trim = cxr_c + radius_c * math.sin(theta_trim)

                        arc_seg = self._sample_arc(prev_point, (z_trim, x_trim), r_prev, segments=20)
                        sampled += arc_seg[1:]
                        segments.append(arc_seg)
                        seg_owner_idx.append(i - 1)

                        if ct == "C":
                            ce = (cur.z + leg * dz_out, cur_x_prev + leg * dx_out)
                            line_seg = [(z_trim, x_trim), ce]
                            segments.append(line_seg)
                            seg_owner_idx.append(i - 1)
                            sampled.append(ce)
                            prev_point = ce
                        else:
                            fe = (cur.z + leg * dz_out, cur_x_prev + leg * dx_out)
                            cross = dz_in * dx_out - dx_in * dz_out
                            fillet_r = -cv if cross > 0 else cv  # sign convention matches _sample_arc
                            fillet_seg = self._sample_arc((z_trim, x_trim), fe, fillet_r, segments=12)
                            sampled += fillet_seg[1:]
                            segments.append(fillet_seg)
                            seg_owner_idx.append(i - 1)
                            prev_point = fe
                        i += 1
                        built = True
                if built:
                    continue
                # fall through to plain arc if the blend couldn't be built

            # Arc move uses R format directly (apply preview X scaling and sign,
            # AND flip the rotation sign by xs -- same reasoning as the
            # corner-blend case above, or Mirror produces a broken shape)
            if abs(cur.r) > 1e-6:
                seg = self._sample_arc(prev_point, (cur.z, cur.x * xf * xs), cur.r * xs, 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
        show_half = getattr(self, "half_var", None) is not None and self.half_var.get()
        # 'coords' below already reflects the Mirror preview checkbox (xs is
        # baked into `sampled`/`segments` from the per-point loop above), so
        # in Half mode we always draw it as-is and simply skip the
        # artificial "negate coords again" mirror_coords block entirely --
        # drawing mirror_coords instead would double-negate xs, undoing the
        # mirror for the line/fill even though independently-computed point
        # markers (which just do x*xf*xs) moved correctly.
        draw_top = True
        draw_bottom = not show_half

        coords: List[float] = []
        for z, x in sampled:
            cz, cy = to_canvas(z, x)
            coords.extend([cz, cy])
        if draw_top and coords and params.fill_profile:
            fill_coords = list(coords)
            last_z, _last_x = sampled[-1]
            first_z, _first_x = sampled[0]
            # OD: material is between the profile and the axis, so the fill
            # closes toward X=0. ID: material is OUTSIDE the bore, so the
            # fill should close toward the stock's outer edge instead --
            # closing toward the axis would incorrectly paint the empty
            # hole as solid material.
            far_x = 0.0 if params.od else stock_x_preview
            cz_bottom, cy_bottom = to_canvas(last_z, far_x)
            cz_start, cy_start = to_canvas(first_z, far_x)
            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",
            )
            # Any stock length the profile doesn't reach is still solid,
            # un-machined material (whether OD or ID) -- fill it too (full
            # X=0..stock_diam), or it renders as plain (unfilled-looking)
            # stock background right where it visually looks like it
            # should already be part of the finished part.
            for z_from, z_to in ((z_stock_min, last_z), (first_z, z_stock_max)):
                if z_from < z_to - 1e-6:
                    c0z, c0y = to_canvas(z_from, 0.0)
                    c1z, c1y = to_canvas(z_to, stock_x_preview)
                    self.canvas.create_rectangle(
                        c0z, c0y, c1z, c1y,
                        fill=params.profile_fill_color, outline="",
                        stipple=stipple_pattern, tags="profile_fill",
                    )
        if draw_top and coords:
            self.canvas.create_line(*coords, fill="#0066cc", width=2)

        # Mirror profile below the axis (lower half of cross-section, display only)
        # Applies to BOTH OD and ID -- the symmetric cross-section view makes
        # sense for a bore profile too (e.g. an ID groove or taper), not just
        # outside turning.
        if draw_bottom 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 and params.fill_profile:
                mirror_fill_coords = list(mirror_coords)
                last_z, _last_x = sampled[-1]
                first_z, _first_x = sampled[0]
                far_x = 0.0 if params.od else -stock_x_preview
                cz_bottom, cy_bottom = to_canvas(last_z, far_x)
                cz_start, cy_start = to_canvas(first_z, far_x)
                mirror_fill_coords.extend([cz_bottom, cy_bottom, cz_start, cy_start])
                stipple_pattern = "gray12" if params.fill_profile_half else ""
                self.canvas.create_polygon(
                    *mirror_fill_coords,
                    fill=params.profile_fill_color,
                    outline="",
                    stipple=stipple_pattern,
                    tags="profile_fill",
                )
                for z_from, z_to in ((z_stock_min, last_z), (first_z, z_stock_max)):
                    if z_from < z_to - 1e-6:
                        c0z, c0y = to_canvas(z_from, 0.0)
                        c1z, c1y = to_canvas(z_to, -stock_x_preview)
                        self.canvas.create_rectangle(
                            c0z, c0y, c1z, c1y,
                            fill=params.profile_fill_color, outline="",
                            stipple=stipple_pattern, tags="profile_fill",
                        )
            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 = to_canvas(sel_pt.z, sel_pt.x * xf * xs)
                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
        # Point markers -- always show every entered vertex as a red dot,
        # slightly thicker than the 2px profile line, drawn last so they sit
        # on top of the line/fill/mirror.
        for pt in pts:
            cz, cy = to_canvas(pt.z, pt.x * xf * xs)
            self.canvas.create_oval(
                cz - 3, cy - 3, cz + 3, cy + 3,
                fill="red", outline="", tags="point_marker",
            )

        # Extra green, thicker marker on the point currently selected in the
        # segment list -- on top of the red dot + the red line highlight
        # above, so the exact selected vertex is unambiguous.
        sel = self.seg_list.curselection()
        if sel and 0 <= sel[0] < len(pts):
            sel_pt = pts[sel[0]]
            cz, cy = to_canvas(sel_pt.z, sel_pt.x * xf * xs)
            self.canvas.create_oval(
                cz - 5, cy - 5, cz + 5, cy + 5,
                fill="#00b300", outline="", tags="point_marker_selected",
            )

        # Live arc bulge point (from the Z/X/R fields currently being typed)
        if self._arc_bulge_preview is not None:
            bz, bx = self._arc_bulge_preview
            cz, cy = to_canvas(bz, bx * xf * xs)
            self.canvas.create_oval(
                cz - 4, cy - 4, cz + 4, cy + 4,
                fill="#888888", outline="", tags="arc_bulge_marker",
            )

        # 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_all_z_offset(self) -> None:
        """Prompt for a +/- Z offset and shift EVERY point's Z by that amount."""
        if not self.points:
            self.show_status("No segments to offset.", color="#FFD966")
            return
        offset = simpledialog.askfloat(
            self._tr("All Z offset"),
            "Z += ?" if self.lang_var.get() == "en" else "Зсунути Z на:",
            initialvalue=0.0,
        )
        if offset is None or abs(offset) < 1e-12:
            return  # cancelled or no-op
        self.points = [
            Point(pt.z + offset, pt.x, pt.r, pt.corner_type, pt.corner_value)
            for pt in self.points
        ]
        self.delete_undo_stack.clear()
        self._refresh_points_list()
        if self.points:
            self._set_input_fields_for_index(min(self.selected_point_idx or 0, len(self.points) - 1))
        self.preview()
        self.show_status(f"Shifted all Z by {offset:+.3f}", color="#A9DC76")

    def apply_all_x_offset(self) -> None:
        """Prompt for a +/- X offset and shift EVERY point's X by that amount."""
        if not self.points:
            self.show_status("No segments to offset.", color="#FFD966")
            return
        offset = simpledialog.askfloat(
            self._tr("All X offset"),
            "X += ?" if self.lang_var.get() == "en" else "Зсунути X на:",
            initialvalue=0.0,
        )
        if offset is None or abs(offset) < 1e-12:
            return  # cancelled or no-op
        self.points = [
            Point(pt.z, pt.x + offset, pt.r, pt.corner_type, pt.corner_value)
            for pt in self.points
        ]
        self.delete_undo_stack.clear()
        self._refresh_points_list()
        if self.points:
            self._set_input_fields_for_index(min(self.selected_point_idx or 0, len(self.points) - 1))
        self.preview()
        self.show_status(f"Shifted all X by {offset:+.3f}", color="#A9DC76")

    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; PRESERVE any existing arc radius on this
        # point (this point may be an arc's endpoint -- _emit_contour_block
        # now knows how to blend a fillet into the end of an arc, but only if
        # pt.r survives here instead of being wiped to 0.0).
        pt = self.points[idx]
        self.points[idx] = Point(pt.z, pt.x, pt.r, "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; PRESERVE any existing arc radius on this
        # point (see matching comment in _apply_radius_at_selected).
        pt = self.points[idx]
        self.points[idx] = Point(pt.z, pt.x, pt.r, "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.99, len_out * 0.99)  # 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 _set_loaded_file(self, path: str) -> None:
        """Track the file just imported/exported and reflect it in the
        window title, e.g. 'Conversational Profile (G71/G72) - fara1.txt'."""
        self._loaded_file_path = path
        import os as _os
        name = _os.path.basename(path)
        self.root.title(f"Conversational Profile (G71/G72) - {name}")

    def import_auto(self) -> None:
        """Single Import entry point: peeks at the file header to decide whether
        it's a coordinates-only TXT (import_txt format) or a full project TXT
        (export_proj format), then dispatches to the matching importer.
        """
        path = filedialog.askopenfilename(
            title="Import",
            filetypes=[("Text files", "*.txt"), ("CSV files", "*.csv"), ("All files", "*.*")],
        )
        if not path:
            return
        try:
            with open(path, "r", encoding="utf-8") as f:
                for raw in f:
                    stripped = raw.strip()
                    if not stripped:
                        continue
                    if stripped == "[PARAMS]":
                        self._import_proj_from_path(path)
                    else:
                        # Coordinates file: first non-empty line is either the
                        # "Z, X, R, CornerType, CornerValue" header or a data row
                        # starting with a Z value -- either way it's not [PARAMS].
                        self._import_txt_from_path(path)
                    return
            self.show_status("Файл порожній", color="#FFD966")
        except Exception as e:
            self.show_status(f"Помилка визначення типу файлу: {e}", color="red")

    def export_txt(self) -> None:
        """Експорт списку сегментів у текстовий файл формату CSV."""
        if not self.points:
            self.show_status("Немає сегментів для експорту.", color="#FFD966")
            return
        path = filedialog.asksaveasfilename(
            title="Експорт сегментів (TXT)",
            defaultextension=".txt",
            filetypes=[("Text files", "*.txt"), ("CSV files", "*.csv"), ("All files", "*.*")],
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write("Z, X, R, CornerType, CornerValue\n")
                for pt in self.points:
                    ctype = pt.corner_type if pt.corner_type else ""
                    cval = f"{pt.corner_value:.3f}" if pt.corner_value is not None else ""
                    f.write(f"{pt.z:.3f}, {pt.x:.3f}, {pt.r:.3f}, {ctype}, {cval}\n")
            self._set_loaded_file(path)
            self.show_status(f"Сегменти успішно експортовано: {path}", color="#A9DC76")
        except Exception as e:
            self.show_status(f"Помилка експорту: {e}", color="red")

    def _import_txt_from_path(self, path: str) -> None:
        """Імпорт списку сегментів з текстового файлу формату CSV (шлях уже відомий)."""
        try:
            new_points = []
            with open(path, "r", encoding="utf-8") as f:
                lines = f.readlines()
            for line in lines:
                line = line.strip()
                if not line or line.lower().startswith("z"):
                    continue
                parts = [p.strip() for p in line.split(",")]
                if len(parts) >= 2:
                    z = float(parts[0])
                    x = float(parts[1])
                    r = float(parts[2]) if len(parts) > 2 and parts[2] else 0.0
                    ctype = parts[3] if len(parts) > 3 and parts[3] else None
                    if ctype and ctype.lower() in ('none', 'null', ''):
                        ctype = None
                    cval = None
                    if len(parts) > 4 and parts[4]:
                        try:
                            cval = float(parts[4])
                        except ValueError:
                            pass
                    new_points.append(Point(z, x, r, ctype, cval))
            if new_points:
                if self.points:
                    if not messagebox.askyesno("Імпорт", "Існуючі сегменти будуть замінені. Продовжити?"):
                        return
                self.points = new_points
                self.delete_undo_stack.clear()
                self.selected_point_idx = None
                self._refresh_points_list()
                if self.points:
                    self._set_input_fields_for_index(0)
                else:
                    self._clear_segment_inputs()
                self.preview()
                self._set_loaded_file(path)
                self.show_status(f"Імпортовано {len(self.points)} сегментів", color="#A9DC76")
            else:
                self.show_status("Файл не містить валідних сегментів", color="#FFD966")
        except Exception as e:
            self.show_status(f"Помилка імпорту TXT: {e}", color="red")

    def export_proj(self) -> None:
        """Export the FULL project (Machining/Stock&Clear/Cycle params + profile points) as TXT."""
        path = filedialog.asksaveasfilename(
            title="Експорт проекту (TXT)",
            defaultextension=".txt",
            filetypes=[("Text files", "*.txt"), ("All files", "*.*")],
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write("[PARAMS]\n")
                for key, widget in self.entries.items():
                    try:
                        val = widget.get()
                    except Exception:
                        continue
                    f.write(f"{key}={val}\n")
                # Checkboxes/settings not tracked via self.entries
                f.write(f"fill_profile={self.fill_profile_var.get() if hasattr(self, 'fill_profile_var') else False}\n")
                f.write(f"fill_profile_half={self.fill_profile_half_var.get() if hasattr(self, 'fill_profile_half_var') else False}\n")
                f.write(f"direct_finish_only={self.direct_finish_var.get() if hasattr(self, 'direct_finish_var') else False}\n")
                f.write(f"profile_fill_color={self.params.profile_fill_color}\n")
                f.write(f"background_image_color={self.params.background_image_color}\n")
                f.write("\n[POINTS]\n")
                f.write("Z, X, R, CornerType, CornerValue\n")
                for pt in self.points:
                    ctype = pt.corner_type if pt.corner_type else ""
                    cval = f"{pt.corner_value:.3f}" if pt.corner_value is not None else ""
                    f.write(f"{pt.z:.3f}, {pt.x:.3f}, {pt.r:.3f}, {ctype}, {cval}\n")
            self._set_loaded_file(path)
            self.show_status(f"Проект успішно експортовано: {path}", color="#A9DC76")
        except Exception as e:
            self.show_status(f"Помилка експорту проекту: {e}", color="red")

    def _import_proj_from_path(self, path: str) -> None:
        """Import a FULL project (params + points), given an already-known path."""
        try:
            with open(path, "r", encoding="utf-8") as f:
                lines = f.readlines()

            section = None
            param_lines: List[str] = []
            point_lines: List[str] = []
            for raw in lines:
                line = raw.rstrip("\n")
                stripped = line.strip()
                if stripped == "[PARAMS]":
                    section = "params"
                    continue
                if stripped == "[POINTS]":
                    section = "points"
                    continue
                if not stripped:
                    continue
                if section == "params":
                    param_lines.append(stripped)
                elif section == "points":
                    point_lines.append(stripped)

            if not param_lines and not point_lines:
                self.show_status("Файл не містить валідного проекту (немає [PARAMS]/[POINTS])", color="#FFD966")
                return

            if self.points:
                if not messagebox.askyesno(
                    "Імпорт проекту",
                    "Існуючі сегменти та параметри будуть замінені. Продовжити?",
                ):
                    return

            # --- Parse points (same CSV format as import_txt) ---
            new_points: List[Point] = []
            for line in point_lines:
                if line.lower().startswith("z"):
                    continue
                parts = [p.strip() for p in line.split(",")]
                if len(parts) >= 2:
                    z = float(parts[0])
                    x = float(parts[1])
                    r = float(parts[2]) if len(parts) > 2 and parts[2] else 0.0
                    ctype = parts[3] if len(parts) > 3 and parts[3] else None
                    if ctype and ctype.lower() in ("none", "null", ""):
                        ctype = None
                    cval = None
                    if len(parts) > 4 and parts[4]:
                        try:
                            cval = float(parts[4])
                        except ValueError:
                            pass
                    new_points.append(Point(z, x, r, ctype, cval))

            # --- Parse params as key=value pairs ---
            param_dict: dict = {}
            for line in param_lines:
                if "=" not in line:
                    continue
                key, _, val = line.partition("=")
                param_dict[key.strip()] = val.strip()

            def as_bool(s: str) -> bool:
                return s.strip().lower() in ("1", "true", "yes", "y")

            # Backward compat: old project files stored TNRC as two separate
            # keys instead of the single "tool_comp" No/G42/G41 dropdown
            if "tool_comp" not in param_dict and "use_tool_comp" in param_dict:
                was_on = as_bool(param_dict.get("use_tool_comp", ""))
                direction = param_dict.get("comp_direction", "G42")
                param_dict["tool_comp"] = ("G41" if "G41" in direction.upper() else "G42") if was_on else "No"

            # Backward compat: old project files stored the TOTAL allowance
            # directly as "stock_allow"; divide by pass count to approximate
            # the same total D under the new per-pass "finish_depth_per_pass".
            if "finish_depth_per_pass" not in param_dict and "stock_allow" in param_dict:
                try:
                    old_total = float(param_dict.get("stock_allow", 0.1))
                    old_count = max(1, int(float(param_dict.get("finish_passes", 1))))
                    param_dict["finish_depth_per_pass"] = str(old_total / old_count)
                except (ValueError, TypeError):
                    pass

            # Apply to self.entries (Entry / StringVar / BooleanVar widgets)
            for key, val in param_dict.items():
                if key in self.entries:
                    widget = self.entries[key]
                    if key == "diameter_mode":
                        # Normalize old dropdown display strings (pre
                        # language-neutral rename, then pre-shortening) to
                        # the current ones
                        val = "G7 Diam" if "G7" in val.upper() else "G8 Rad"
                    if isinstance(widget, tk.BooleanVar):
                        widget.set(as_bool(val))
                    elif isinstance(widget, tk.StringVar):
                        widget.set(val)
                    else:
                        widget.delete(0, tk.END)
                        widget.insert(0, val)

            # Apply extra checkboxes not tracked in self.entries
            if "fill_profile" in param_dict and hasattr(self, "fill_profile_var"):
                self.fill_profile_var.set(as_bool(param_dict["fill_profile"]))
            if "fill_profile_half" in param_dict and hasattr(self, "fill_profile_half_var"):
                self.fill_profile_half_var.set(as_bool(param_dict["fill_profile_half"]))
            if "direct_finish_only" in param_dict and hasattr(self, "direct_finish_var"):
                self.direct_finish_var.set("Yes" if as_bool(param_dict["direct_finish_only"]) else "No")
            if "profile_fill_color" in param_dict:
                self.params.profile_fill_color = param_dict["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 param_dict:
                self.params.background_image_color = param_dict["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
                    )

            # Re-apply TNRC checkbox side effects (finish_passes lock)
            if hasattr(self, "_on_tool_comp_toggle"):
                self._on_tool_comp_toggle()
            self._rebuild_coolant_menu()

            # Apply points
            if new_points:
                self.points = new_points
                self.delete_undo_stack.clear()
                self.selected_point_idx = None
                self._refresh_points_list()
                if self.points:
                    self._set_input_fields_for_index(0)
                else:
                    self._clear_segment_inputs()

            self.preview()
            self._set_loaded_file(path)
            self.show_status(
                f"Проект імпортовано: {len(new_points)} сегментів, {len(param_dict)} параметрів",
                color="#A9DC76",
            )
        except Exception as e:
            self.show_status(f"Помилка імпорту проекту: {e}", color="red")

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