Lengthen

Short description

🧩 Lengthen is an enhanced Double Extend style AutoLISP tool for extending multiple selected LINE ARC LWPOLYLINE and POLYLINE entities at both ends.
🎛️ The DCL first workflow includes object picking highlighting separate Start and End extension values a Both Ends Slot option dynamic drag preview Apply Undo Debug OK and Cancel.
🧪 The file also provides DEX and DEXDEBUG aliases for the main tool and its diagnostic report.

Command:

Main command: ExtendStartEndMultiple
Alias: DEX
Debug command: DEXDEBUG

Description:

🎛️ The dialog opens before object selection and includes Pick Entities Window Crossing.
➡️ Extend Start and Extend End control independent endpoint extension values.
↔️ Enable Both Ends Slot activates one shared slot length that can be used on both ends.
👁️ Dynamic Drag Preview lets the user interactively adjust the both ends slot while selected objects remain highlighted.
✨ Selected entities are highlighted while the tool is active and restored when appropriate.
✅ Apply performs the current operation and keeps the tool available for further adjustment.
↩️ Undo restores the snapshot taken before the latest Apply style operation.
🧪 Debug writes a DEX_debug.txt diagnostic report into the AutoCAD TEMP folder.
💾 The dialog stores its last screen relative position in a session global.
🛡️ The header identifies the file as Civil 3D compatibility updated.

Helper function: (if any)​

DEX:snapshot and DEX:restore preserve source entity state.
DEX:dynamic-both implements the interactive slot drag preview.
LM:dex:run applies the underlying endpoint extension geometry.
DEX:debug writes the diagnostic text report.

Functionalities:

✅ DCL first workflow – opens the interface before source selection.
✅ Pick Entities button – supports window crossing pick fence and All selection methods.
✅ Selection highlighting – keeps selected geometry visually highlighted.
✅ Separate endpoint values – controls start and end extensions independently.
✅ Both Ends Slot – applies one shared extension value to both ends.
✅ Dynamic drag preview – updates the shared slot through cursor movement.
✅ Apply – updates the selected geometry without closing the dialog.
✅ Undo – restores the latest stored geometry snapshot.
✅ Debug report – writes settings selection and entity information to DEX_debug.txt.
✅ Dialog position memory – stores the DCL screen position during the AutoCAD session.
✅ C3D compatibility – includes protected Visual LISP COM initialization and command-s cleanup.

Result:

🎯 The result is selected curve geometry extended at the start end or both ends according to the current DCL settings.
👁️ The interactive preview and rollback workflow is designed for rapid trial and adjustment.

Images, animations etc.

Animation 000 15
Animation 001 4
Pixel

XAML code:

<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:src="clr-namespace:Autodesk.Windows;assembly=AdWindows">
    
<src:RibbonToolTip x:Key="Lengthen">
<src:RibbonToolTip.ExpandedContent>
<StackPanel>
<TextBlock Background="AntiqueWhite" TextAlignment="Left">
<Bold>Function Syntax: ExtendStartEndMultiple</Bold>
<LineBreak/>
<Bold>Version: 1 Date: 01.08.2026</Bold>
<LineBreak/>
<Hyperlink>AI+https://www.lee-mac.com/doubleextend.html</Hyperlink>
<LineBreak/>

<Bold><Run Foreground="DodgerBlue">Description</Run></Bold><LineBreak/>
<Run Foreground="White">🧩 Lengthen is an enhanced Double Extend style AutoLISP tool for extending multiple selected LINE ARC LWPOLYLINE and POLYLINE entities at both ends.</Run><LineBreak/>
<Run Foreground="White">🎛️ The DCL first workflow includes object picking highlighting separate Start and End extension values a Both Ends Slot option dynamic drag preview Apply Undo Debug OK and Cancel.</Run><LineBreak/>
<Run Foreground="White">🧪 The file also provides DEX and DEXDEBUG aliases for the main tool and its diagnostic report.</Run><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="DodgerBlue">Function Syntax</Run></Bold><LineBreak/>
<Run Foreground="White">Main command: </Run><Bold><Run Foreground="Orange">ExtendStartEndMultiple</Run></Bold><LineBreak/>
<Run Foreground="White">Alias: </Run><Bold><Run Foreground="Orange">DEX</Run></Bold><LineBreak/>
<Run Foreground="White">Debug command: </Run><Bold><Run Foreground="Orange">DEXDEBUG</Run></Bold><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="DodgerBlue">SEO Optimized Overview</Run></Bold><LineBreak/>
<Run Foreground="White">🎛️ The dialog opens before object selection and includes Pick Entities Window Crossing.</Run><LineBreak/>
<Run Foreground="White">➡️ Extend Start and Extend End control independent endpoint extension values.</Run><LineBreak/>
<Run Foreground="White">↔️ Enable Both Ends Slot activates one shared slot length that can be used on both ends.</Run><LineBreak/>
<Run Foreground="White">👁️ Dynamic Drag Preview lets the user interactively adjust the both ends slot while selected objects remain highlighted.</Run><LineBreak/>
<Run Foreground="White">✨ Selected entities are highlighted while the tool is active and restored when appropriate.</Run><LineBreak/>
<Run Foreground="White">✅ Apply performs the current operation and keeps the tool available for further adjustment.</Run><LineBreak/>
<Run Foreground="White">↩️ Undo restores the snapshot taken before the latest Apply style operation.</Run><LineBreak/>
<Run Foreground="White">🧪 Debug writes a DEX_debug.txt diagnostic report into the AutoCAD TEMP folder.</Run><LineBreak/>
<Run Foreground="White">💾 The dialog stores its last screen relative position in a session global.</Run><LineBreak/>
<Run Foreground="White">🛡️ The header identifies the file as Civil 3D compatibility updated.</Run><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="LimeGreen">Primary Workflow</Run></Bold><LineBreak/>
<Run Foreground="White">• Run DEX or ExtendStartEndMultiple.</Run><LineBreak/>
<Run Foreground="White">• Use Pick Entities to select LINE ARC LWPOLYLINE or POLYLINE objects.</Run><LineBreak/>
<Run Foreground="White">• Enter Extend Start and Extend End values.</Run><LineBreak/>
<Run Foreground="White">• Optionally enable the Both Ends Slot and set its slot length.</Run><LineBreak/>
<Run Foreground="White">• Use Dynamic Drag Preview to adjust the shared slot interactively.</Run><LineBreak/>
<Run Foreground="White">• Use Apply and Undo while tuning the result.</Run><LineBreak/>
<Run Foreground="White">• Use Debug when a diagnostic report is needed.</Run><LineBreak/>
<Run Foreground="White">• Use OK to commit the final geometry or Cancel to restore the starting snapshot.</Run><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="LimeGreen">Functionalities</Run></Bold><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">DCL first workflow</Run></Bold><Run Foreground="White"> - opens the interface before source selection.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Pick Entities button</Run></Bold><Run Foreground="White"> - supports window crossing pick fence and All selection methods.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Selection highlighting</Run></Bold><Run Foreground="White"> - keeps selected geometry visually highlighted.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Separate endpoint values</Run></Bold><Run Foreground="White"> - controls start and end extensions independently.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Both Ends Slot</Run></Bold><Run Foreground="White"> - applies one shared extension value to both ends.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Dynamic drag preview</Run></Bold><Run Foreground="White"> - updates the shared slot through cursor movement.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Apply</Run></Bold><Run Foreground="White"> - updates the selected geometry without closing the dialog.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Undo</Run></Bold><Run Foreground="White"> - restores the latest stored geometry snapshot.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Debug report</Run></Bold><Run Foreground="White"> - writes settings selection and entity information to DEX_debug.txt.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">Dialog position memory</Run></Bold><Run Foreground="White"> - stores the DCL screen position during the AutoCAD session.</Run><LineBreak/>
<Run Foreground="White">✅ </Run><Bold><Run Foreground="LimeGreen">C3D compatibility</Run></Bold><Run Foreground="White"> - includes protected Visual LISP COM initialization and command-s cleanup.</Run><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="DodgerBlue">Important Commands and Aspects</Run></Bold><LineBreak/>
<Run Foreground="White">🧩 DEX:snapshot and DEX:restore preserve source entity state.</Run><LineBreak/>
<Run Foreground="White">🧩 DEX:dynamic-both implements the interactive slot drag preview.</Run><LineBreak/>
<Run Foreground="White">🧩 LM:dex:run applies the underlying endpoint extension geometry.</Run><LineBreak/>
<Run Foreground="White">🧩 DEX:debug writes the diagnostic text report.</Run><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="DodgerBlue">Result</Run></Bold><LineBreak/>
<Run Foreground="White">🎯 The result is selected curve geometry extended at the start end or both ends according to the current DCL settings.</Run><LineBreak/>
<Run Foreground="White">👁️ The interactive preview and rollback workflow is designed for rapid trial and adjustment.</Run><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="DarkRed">Important Notes</Run></Bold><LineBreak/>
<Run Foreground="White">⚠️ </Run><Bold><Run Foreground="DarkRed">The DCL title is Double Extend even though the file is named Lengthen.</Run></Bold><LineBreak/>
<Run Foreground="White">⚠️ </Run><Bold><Run Foreground="DarkRed">The main callable commands are ExtendStartEndMultiple DEX and DEXDEBUG.</Run></Bold><LineBreak/>
<Run Foreground="White">⚠️ </Run><Bold><Run Foreground="DarkRed">Debug output is written to the AutoCAD temporary directory.</Run></Bold><LineBreak/>
<Run Foreground="White">⚠️ </Run><Bold><Run Foreground="DarkRed">Cancel restores the initial snapshot when one has been created.</Run></Bold><LineBreak/>
<LineBreak/>
<Bold><Run Foreground="Yellow">SEO Keywords</Run></Bold><LineBreak/>
<Run Foreground="Yellow">🏷️ AutoCAD, 🏷️ Civil 3D, 🏷️ AutoLISP, 🏷️ Lengthen, 🏷️ DEX, 🏷️ Double Extend, 🏷️ ExtendStartEndMultiple, 🏷️ Dynamic preview, 🏷️ Apply, 🏷️ Undo, 🏷️ Debug, 🏷️ Polyline extension, 🏷️ DCL</Run><LineBreak/>

</TextBlock>
            
<Grid>
<Image Source="PNG" Stretch="Uniform"/>
</Grid>

<Grid>
<MediaElement
 Source="GIF"
 Stretch="Uniform"
 Visibility="Visible"/>
 </Grid>

</StackPanel>
</src:RibbonToolTip.ExpandedContent>
</src:RibbonToolTip>
</ResourceDictionary>



C# code:

// ===== FILE: Logging\DexLogger.cs =====
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace DoubleExtendCS.Logging
{
    public enum LogLevel
    {
        INFO,
        WARN,
        ERROR,
        DEBUG
    }

    public class LogEntry
    {
        public DateTime Timestamp { get; }
        public LogLevel Level { get; }
        public string Source { get; }
        public string Message { get; }

        public LogEntry(DateTime timestamp, LogLevel level, string source, string message)
        {
            Timestamp = timestamp;
            Level = level;
            Source = source;
            Message = message;
        }

        /// <summary>
        /// Formats exactly like the requested style:
        /// 05:29:43  [INFO]   [PvSplitApi]  SplitDataLines.Add(Double rawStation, Double datum)
        /// </summary>
        public override string ToString()
        {
            return $"{Timestamp:HH:mm:ss}  [{Level,-5}]  [{Source}]  {Message}";
        }
    }

    /// <summary>
    /// Central, static, thread-safe-ish logger for the whole plugin.
    /// - Keeps an in-memory buffer (capped) that the modeless form binds to.
    /// - Raises EntryAdded so any open DexForm can append live, on the UI thread.
    /// - Can persist the current buffer to disk ("store this log") so behaviour
    ///   / bug patterns can be reviewed later and used to improve the tool.
    /// </summary>
    public static class DexLogger
    {
        private const int MaxBufferedEntries = 5000;
        private static readonly object _lock = new object();
        private static readonly List<LogEntry> _entries = new List<LogEntry>();

        /// <summary>Raised on every new log entry. Subscribers (the form) must
        /// marshal to the UI thread themselves (DexForm does this via Invoke).</summary>
        public static event Action<LogEntry> EntryAdded;

        public static IReadOnlyList<LogEntry> Entries
        {
            get { lock (_lock) { return _entries.ToList(); } }
        }

        public static void Log(LogLevel level, string source, string message)
        {
            var entry = new LogEntry(DateTime.Now, level, source, message);
            lock (_lock)
            {
                _entries.Add(entry);
                if (_entries.Count > MaxBufferedEntries)
                    _entries.RemoveAt(0);
            }
            EntryAdded?.Invoke(entry);
        }

        public static void Info(string source, string message) => Log(LogLevel.INFO, source, message);
        public static void Warn(string source, string message) => Log(LogLevel.WARN, source, message);
        public static void Error(string source, string message) => Log(LogLevel.ERROR, source, message);
        public static void Debug(string source, string message) => Log(LogLevel.DEBUG, source, message);

        public static void Clear()
        {
            lock (_lock) { _entries.Clear(); }
        }

        /// <summary>
        /// Persists the current in-memory log buffer to disk so it can be
        /// reviewed later, attached to a bug report, or fed back in to improve
        /// the tool/prompting. Returns the full path written, or null on failure.
        /// </summary>
        public static string SaveToFile(string folder = null)
        {
            try
            {
                folder ??= Path.Combine(Path.GetTempPath(), "DoubleExtendCS", "logs");
                Directory.CreateDirectory(folder);

                string fileName = $"DEX_log_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt";
                string fullPath = Path.Combine(folder, fileName);

                var sb = new StringBuilder();
                sb.AppendLine("============================================================");
                sb.AppendLine($"DoubleExtendCS log export - {DateTime.Now:dd/MM/yyyy HH:mm:ss}");
                sb.AppendLine($"Build: {VersionInfo.GetLoadBanner()}");
                sb.AppendLine("============================================================");
                foreach (var e in Entries)
                    sb.AppendLine(e.ToString());

                File.WriteAllText(fullPath, sb.ToString(), Encoding.UTF8);
                return fullPath;
            }
            catch (Exception ex)
            {
                // Do not throw from the logger itself - swallow and report INFO-level.
                System.Diagnostics.Debug.WriteLine("DexLogger.SaveToFile failed: " + ex);
                return null;
            }
        }
    }
}


// ===== FILE: Core\PreviewGeometry.cs =====
using System.Collections.Generic;
using System.Drawing;
using System.Linq;

namespace DoubleExtendCS.Core
{
    /// <summary>
    /// Plain, WinForms-safe preview geometry. The naming mirrors the dynamic
    /// preview model used in PPN_preview_bulge_arc_fix: the viewport transient
    /// preview and the dialog panel both consume the same tessellated point data.
    ///
    /// For Double Extend:
    /// - Boundaries = the original picked geometry, drawn as the reference shape.
    /// - Centerlines = the computed extended result, drawn as the active preview.
    /// - Edges = drag guides, such as the live both-ends slot vector.
    /// </summary>
    public class PreviewGeometry
    {
        public List<List<PointF>> Centerlines { get; } = new List<List<PointF>>();
        public List<List<PointF>> Edges { get; } = new List<List<PointF>>();
        public List<List<PointF>> Boundaries { get; } = new List<List<PointF>>();

        public bool IsEmpty =>
            !Centerlines.Any(p => p != null && p.Count >= 2) &&
            !Edges.Any(p => p != null && p.Count >= 2) &&
            !Boundaries.Any(p => p != null && p.Count >= 2);
    }
}


// ===== FILE: Core\SelectionUtils.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using DoubleExtendCS.Logging;

namespace DoubleExtendCS.Core
{
    public static class SelectionUtils
    {
        private const string Src = "SelectionUtils";

        /// <summary>
        /// Viewport highlight: the "one line" pattern requested -
        /// SetImpliedSelection + UpdateScreen - wired from DataGridView /
        /// selection-changed style events. MUST run on the AutoCAD main
        /// thread; this is guaranteed automatically when called from a
        /// WinForms event handler inside a modeless dialog opened via
        /// Application.ShowModelessDialog (see UI/DexForm.cs).
        /// </summary>
        public static void Highlight(ObjectIdCollection ids)
        {
            if (ids == null || ids.Count == 0) return;
            var doc = Application.DocumentManager.MdiActiveDocument;
            if (doc == null) return;

            var idArray = new ObjectId[ids.Count];
            ids.CopyTo(idArray, 0);

            doc.Editor.SetImpliedSelection(idArray);
            doc.Editor.UpdateScreen();
        }

        public static void Unhighlight()
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            if (doc == null) return;

            // Passing an empty array clears PICKFIRST / implied selection highlight.
            doc.Editor.SetImpliedSelection(Array.Empty<ObjectId>());
            doc.Editor.UpdateScreen();
        }

        /// <summary>Bounding-box midpoint of a selection, used as the fixed
        /// drag origin for the Both-Ends slot jig. Port of DEX:sel-midpoint,
        /// but simplified: AutoCAD entities expose GeometricExtents directly,
        /// so there is no need to hand-walk vertices per entity type like the
        /// LISP had to.</summary>
        public static Point3d Midpoint(Transaction tr, ObjectIdCollection ids)
        {
            bool any = false;
            double minX = 0, maxX = 0, minY = 0, maxY = 0;

            foreach (ObjectId id in ids)
            {
                if (id.IsErased) continue;
                Entity ent;
                try { ent = (Entity)tr.GetObject(id, OpenMode.ForRead); }
                catch { continue; }

                Extents3d ext;
                try { ext = ent.GeometricExtents; }
                catch (Autodesk.AutoCAD.Runtime.Exception)
                {
                    DexLogger.Warn(Src, $"Midpoint: could not read extents for {id}, skipped.");
                    continue;
                }

                if (!any)
                {
                    minX = ext.MinPoint.X; maxX = ext.MaxPoint.X;
                    minY = ext.MinPoint.Y; maxY = ext.MaxPoint.Y;
                    any = true;
                }
                else
                {
                    minX = Math.Min(minX, ext.MinPoint.X);
                    maxX = Math.Max(maxX, ext.MaxPoint.X);
                    minY = Math.Min(minY, ext.MinPoint.Y);
                    maxY = Math.Max(maxY, ext.MaxPoint.Y);
                }
            }

            return any
                ? new Point3d((minX + maxX) / 2.0, (minY + maxY) / 2.0, 0.0)
                : Point3d.Origin;
        }
    }
}


// ===== FILE: Core\PreviewGeometryCollector.cs =====
using System;
using System.Collections.Generic;
using System.Drawing;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using DoubleExtendCS.Logging;

namespace DoubleExtendCS.Core
{
    /// <summary>
    /// Samples AutoCAD entities / cached snapshots into plain PointF chains that
    /// both the WinForms preview panel and the model-space transient preview can
    /// reuse. The UI preview is therefore generated from the same solved geometry
    /// as the dynamic AutoCAD preview, not from a hard-coded sketch.
    /// </summary>
    public static class PreviewGeometryCollector
    {
        private const string Src = "PreviewGeometryCollector";
        private const int ArcSamples = 32;
        private const int BulgeSamples = 8;

        public static List<List<PointF>> Collect(Transaction tr, ObjectIdCollection ids)
        {
            var result = new List<List<PointF>>();
            foreach (ObjectId id in ids)
            {
                if (id.IsErased) continue;
                try
                {
                    var chain = CollectOne(tr, id);
                    if (chain != null && chain.Count >= 2)
                        result.Add(chain);
                }
                catch (Exception ex)
                {
                    DexLogger.Warn(Src, $"Could not sample {id} for preview: {ex.Message}");
                }
            }
            return result;
        }

        private static List<PointF> CollectOne(Transaction tr, ObjectId id)
        {
            var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;

            switch (ent)
            {
                case Line line:
                    return new List<PointF>
                    {
                        ToPF(line.StartPoint),
                        ToPF(line.EndPoint)
                    };

                case Arc arc:
                    return SampleCurve(arc, ArcSamples);

                case Polyline pl:
                    {
                        var verts = new List<PolyVertex>();
                        int n = pl.NumberOfVertices;
                        for (int i = 0; i < n; i++)
                            verts.Add(new PolyVertex(pl.GetPoint2dAt(i), pl.GetBulgeAt(i)));
                        return TessellateVertices(verts);
                    }

                case Polyline2d pl2d:
                    return SampleCurve(pl2d, ArcSamples * 2);

                default:
                    return null;
            }
        }

        /// <summary>Generic sampler for any Curve using GetPointAtParameter across
        /// its own StartParam..EndParam - works for Arc, Polyline2d, Spline, etc.</summary>
        private static List<PointF> SampleCurve(Curve curve, int samples)
        {
            var pts = new List<PointF>();
            double sp = curve.StartParam;
            double ep = curve.EndParam;
            if (double.IsNaN(sp) || double.IsNaN(ep) || samples < 2)
                return pts;

            for (int i = 0; i <= samples; i++)
            {
                double t = sp + (ep - sp) * i / samples;
                try
                {
                    Point3d p = curve.GetPointAtParameter(t);
                    pts.Add(ToPF(p));
                }
                catch
                {
                    // Skip unsampleable parameter (e.g. degenerate segment).
                }
            }
            return pts;
        }

        private static PointF ToPF(Point3d p) => new PointF((float)p.X, (float)p.Y);
        private static PointF ToPF(Point2d p) => new PointF((float)p.X, (float)p.Y);

        private static List<PointF> TessellateVertices(IList<PolyVertex> verts)
        {
            var pts = new List<PointF>();
            if (verts == null || verts.Count == 0) return pts;

            for (int i = 0; i < verts.Count; i++)
            {
                pts.Add(ToPF(verts[i].Pt));

                if (i >= verts.Count - 1) continue;
                double bulge = verts[i].Bulge;
                if (Math.Abs(bulge) <= 1e-8) continue;

                Point2d p1 = verts[i].Pt;
                Point2d p2 = verts[i + 1].Pt;
                Point2d center;
                try { center = DexGeometry.BulgeCentre(p1, p2, bulge); }
                catch { continue; }

                double radius = p1.GetDistanceTo(center);
                if (radius < 1e-8) continue;

                double startAng = center.GetVectorTo(p1).Angle;
                double sweep = 4.0 * Math.Atan(bulge);

                for (int s = 1; s < BulgeSamples; s++)
                {
                    double t = startAng + sweep * s / BulgeSamples;
                    pts.Add(new PointF(
                        (float)(center.X + radius * Math.Cos(t)),
                        (float)(center.Y + radius * Math.Sin(t))));
                }
            }

            return pts;
        }

        /// <summary>
        /// Builds a "what it would look like" preview purely from a cached
        /// EntitySnapshot list + the current extS/extE values - no database
        /// access at all, so this can be called on every keystroke/slider tick
        /// or every PointMonitor frame without transaction overhead.
        /// </summary>
        public static List<List<PointF>> CollectExtendedPreview(IEnumerable<EntitySnapshot> snapshot, double extS, double extE)
        {
            var result = new List<List<PointF>>();
            if (snapshot == null) return result;

            foreach (var snap in snapshot)
            {
                switch (snap)
                {
                    case LineSnapshot ls:
                        {
                            Vector3d dir = ls.End - ls.Start;
                            double len = dir.Length;
                            if (len < 1e-8) break;
                            Vector3d u = dir / len;
                            Point3d newStart = ls.Start - u * extS;
                            Point3d newEnd = ls.End + u * extE;
                            result.Add(new List<PointF> { ToPF(newStart), ToPF(newEnd) });
                            break;
                        }
                    case ArcSnapshot arcSnap:
                        {
                            if (arcSnap.Radius < 1e-8) break;
                            double a1 = arcSnap.StartAngle - extS / arcSnap.Radius;
                            double a2 = arcSnap.EndAngle + extE / arcSnap.Radius;
                            var pts = new List<PointF>();
                            for (int i = 0; i <= ArcSamples; i++)
                            {
                                double t = a1 + (a2 - a1) * i / ArcSamples;
                                double x = arcSnap.Center.X + arcSnap.Radius * Math.Cos(t);
                                double y = arcSnap.Center.Y + arcSnap.Radius * Math.Sin(t);
                                pts.Add(new PointF((float)x, (float)y));
                            }
                            result.Add(pts);
                            break;
                        }
                    case LwPolylineSnapshot lwSnap:
                        {
                            var verts = new List<PolyVertex>(lwSnap.Points.Count);
                            for (int i = 0; i < lwSnap.Points.Count; i++)
                                verts.Add(new PolyVertex(lwSnap.Points[i], lwSnap.Bulges[i]));

                            var extended = DexGeometry.ExtendPolyline(verts, extS, extE);
                            result.Add(TessellateVertices(extended));
                            break;
                        }
                    case Polyline2dSnapshot p2dSnap:
                        {
                            var verts = new List<PolyVertex>(p2dSnap.Positions.Count);
                            for (int i = 0; i < p2dSnap.Positions.Count; i++)
                                verts.Add(new PolyVertex(new Point2d(p2dSnap.Positions[i].X, p2dSnap.Positions[i].Y), p2dSnap.Bulges[i]));

                            var extended = DexGeometry.ExtendPolyline(verts, extS, extE);
                            result.Add(TessellateVertices(extended));
                            break;
                        }
                }
            }
            return result;
        }

        public static PreviewGeometry CollectPreviewGeometry(IEnumerable<EntitySnapshot> snapshot, double extS, double extE, bool includeOriginal = true)
        {
            var geometry = new PreviewGeometry();
            if (includeOriginal)
                geometry.Boundaries.AddRange(CollectExtendedPreview(snapshot, 0.0, 0.0));
            geometry.Centerlines.AddRange(CollectExtendedPreview(snapshot, extS, extE));
            return geometry;
        }

        public static PreviewGeometry CollectSlotPreviewGeometry(IEnumerable<EntitySnapshot> snapshot, double slot, Point3d basePt, Vector3d dragAxis)
        {
            var geometry = CollectPreviewGeometry(snapshot, slot, slot, includeOriginal: true);
            Vector3d axis = dragAxis.Length > 1e-8 ? dragAxis.GetNormal() : Vector3d.XAxis;
            geometry.Edges.Add(new List<PointF>
            {
                ToPF(basePt),
                ToPF(basePt + axis * Math.Max(0.0, slot))
            });
            return geometry;
        }
    }
}


// ===== FILE: Core\SelectionSnapshot.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using DoubleExtendCS.Logging;

namespace DoubleExtendCS.Core
{
    /// <summary>Geometry captured for a single entity so it can be restored later
    /// without relying on AutoCAD's command-level undo stack (which is awkward
    /// to drive from a modeless dialog / jig loop). Direct analogue of the
    /// LISP's DEX:snapshot / DEX:restore (which used raw entmod entity lists).</summary>
    public abstract class EntitySnapshot
    {
        public ObjectId Id;
        public abstract void Restore(Transaction tr);
    }

    public class LineSnapshot : EntitySnapshot
    {
        public Point3d Start, End;
        public override void Restore(Transaction tr)
        {
            if (Id.IsErased) return;
            var line = (Line)tr.GetObject(Id, OpenMode.ForWrite);
            line.StartPoint = Start;
            line.EndPoint = End;
        }
    }

    public class ArcSnapshot : EntitySnapshot
    {
        public double StartAngle, EndAngle;
        // Cached so consumers (preview panel, jig) can recompute geometry
        // purely in-memory without round-tripping through the database.
        public Point3d Center;
        public double Radius;
        public Vector3d Normal;

        public override void Restore(Transaction tr)
        {
            if (Id.IsErased) return;
            var arc = (Arc)tr.GetObject(Id, OpenMode.ForWrite);
            arc.StartAngle = StartAngle;
            arc.EndAngle = EndAngle;
        }
    }

    public class LwPolylineSnapshot : EntitySnapshot
    {
        public List<Point2d> Points = new List<Point2d>();
        public List<double> Bulges = new List<double>();
        public override void Restore(Transaction tr)
        {
            if (Id.IsErased) return;
            var pl = (Polyline)tr.GetObject(Id, OpenMode.ForWrite);
            for (int i = 0; i < Points.Count && i < pl.NumberOfVertices; i++)
            {
                pl.SetPointAt(i, Points[i]);
                pl.SetBulgeAt(i, Bulges[i]);
            }
        }
    }

    public class Polyline2dSnapshot : EntitySnapshot
    {
        public List<ObjectId> VertexIds = new List<ObjectId>();
        public List<Point3d> Positions = new List<Point3d>();
        public List<double> Bulges = new List<double>();
        public override void Restore(Transaction tr)
        {
            for (int i = 0; i < VertexIds.Count; i++)
            {
                if (VertexIds[i].IsErased) continue;
                var v2d = (Vertex2d)tr.GetObject(VertexIds[i], OpenMode.ForWrite);
                v2d.Position = Positions[i];
                v2d.Bulge = Bulges[i];
            }
        }
    }

    /// <summary>Captures / restores geometry for a whole ObjectIdCollection.</summary>
    public static class SelectionSnapshot
    {
        private const string Src = "SelectionSnapshot";

        public static List<EntitySnapshot> Capture(Transaction tr, ObjectIdCollection ids)
        {
            var result = new List<EntitySnapshot>();
            foreach (ObjectId id in ids)
            {
                if (id.IsErased) continue;
                var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
                switch (ent)
                {
                    case Line line:
                        result.Add(new LineSnapshot { Id = id, Start = line.StartPoint, End = line.EndPoint });
                        break;
                    case Arc arc:
                        result.Add(new ArcSnapshot
                        {
                            Id = id,
                            StartAngle = arc.StartAngle,
                            EndAngle = arc.EndAngle,
                            Center = arc.Center,
                            Radius = arc.Radius,
                            Normal = arc.Normal
                        });
                        break;
                    case Polyline pl:
                        {
                            var snap = new LwPolylineSnapshot { Id = id };
                            for (int i = 0; i < pl.NumberOfVertices; i++)
                            {
                                snap.Points.Add(pl.GetPoint2dAt(i));
                                snap.Bulges.Add(pl.GetBulgeAt(i));
                            }
                            result.Add(snap);
                            break;
                        }
                    case Polyline2d pl2d:
                        {
                            var snap = new Polyline2dSnapshot { Id = id };
                            foreach (ObjectId vid in pl2d)
                            {
                                var v2d = (Vertex2d)tr.GetObject(vid, OpenMode.ForRead);
                                snap.VertexIds.Add(vid);
                                snap.Positions.Add(v2d.Position);
                                snap.Bulges.Add(v2d.Bulge);
                            }
                            result.Add(snap);
                            break;
                        }
                    default:
                        DexLogger.Warn(Src, $"Capture: unsupported type '{ent?.GetType().Name}' for {id} - not snapshotted.");
                        break;
                }
            }
            return result;
        }

        public static void Restore(Transaction tr, IEnumerable<EntitySnapshot> snapshot)
        {
            if (snapshot == null) return;
            foreach (var s in snapshot)
            {
                try { s.Restore(tr); }
                catch (Exception ex) { DexLogger.Error(Src, $"Restore failed for {s.Id}: {ex.Message}"); }
            }
        }

        /// <summary>Convenience: capture inside its own transaction against the
        /// current document's database.</summary>
        public static List<EntitySnapshot> CaptureNow(ObjectIdCollection ids)
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                var snap = Capture(tr, ids);
                tr.Commit();
                return snap;
            }
        }

        /// <summary>Convenience: restore + regen inside its own transaction.
        /// Locks the document first - required because this is typically
        /// invoked from a WinForms event handler on a modeless dialog, not
        /// from an active AutoCAD command context.</summary>
        public static void RestoreNow(List<EntitySnapshot> snapshot)
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            using (doc.LockDocument())
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                Restore(tr, snapshot);
                tr.Commit();
            }
            doc.Editor.Regen();
        }
    }
}


// ===== FILE: Core\VersionInfo.cs =====
using System;
using System.IO;
using System.Reflection;

namespace DoubleExtendCS
{
    /// <summary>
    /// Builds the "vX.Y.ZZZ_dd.mm.yyyy_hh.mm.ss" banner that gets printed to
    /// the AutoCAD command line every time the DLL is loaded (NETLOAD or
    /// auto-load), so a user in a live session can immediately tell which
    /// build they are running - equivalent to the LISP file's load banner,
    /// but with an actual version + load-timestamp instead of static text.
    /// </summary>
    public static class VersionInfo
    {
        /// <summary>
        /// e.g. "1.0.001_01.07.2026_05.29.43"
        /// The version part comes from AssemblyFileVersion (bump it per release,
        /// see Properties/AssemblyInfo.cs). The timestamp is the moment this
        /// call runs (i.e. the moment the DLL is loaded into the current session).
        /// </summary>
        public static string GetLoadBanner()
        {
            var asm = Assembly.GetExecutingAssembly();
            var fileVer = asm.GetName().Version; // falls back to AssemblyVersion if FileVersion attribute missing at runtime read
            string verText;
            try
            {
                var fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(asm.Location);
                verText = $"{fvi.FileMajorPart}.{fvi.FileMinorPart}.{fvi.FileBuildPart:D3}";
            }
            catch
            {
                verText = $"{fileVer.Major}.{fileVer.Minor}.{fileVer.Build:D3}";
            }

            string stamp = DateTime.Now.ToString("dd.MM.yyyy_HH.mm.ss");
            return $"{verText}_{stamp}";
        }

        /// <summary>On-disk last-write time of the DLL, useful to confirm you're
        /// really running the build you just copied (vs. a stale locked copy).</summary>
        public static DateTime? GetAssemblyBuildTime()
        {
            try
            {
                var path = Assembly.GetExecutingAssembly().Location;
                return File.GetLastWriteTime(path);
            }
            catch
            {
                return null;
            }
        }
    }
}


// ===== FILE: Core\EntityExtender.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using DoubleExtendCS.Logging;

namespace DoubleExtendCS.Core
{
    /// <summary>
    /// Applies the "extend start / extend end" operation to a selection of
    /// entities. Supports Line, Arc, Polyline (LWPOLYLINE) and Polyline2d
    /// (old-style POLYLINE) - the same entity set the original LISP handled.
    /// Direct port of LM:dex:run / LM:dex:extendpoly.
    /// </summary>
    public static class EntityExtender
    {
        private const string Src = "EntityExtender";

        public static void RunAll(Transaction tr, ObjectIdCollection ids, double extS, double extE)
        {
            foreach (ObjectId id in ids)
            {
                try
                {
                    RunOne(tr, id, extS, extE);
                }
                catch (Exception ex)
                {
                    DexLogger.Error(Src, $"RunOne failed for {id}: {ex.Message}");
                }
            }
        }

        public static void RunOne(Transaction tr, ObjectId id, double extS, double extE)
        {
            if (id.IsNull || id.IsErased) return;

            var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
            switch (ent)
            {
                case Line line:
                    ExtendLine(tr, line, extS, extE);
                    break;
                case Arc arc:
                    ExtendArc(tr, arc, extS, extE);
                    break;
                case Polyline pl: // LWPOLYLINE
                    ExtendLwPolyline(tr, pl, extS, extE);
                    break;
                case Polyline2d pl2d: // old-style POLYLINE
                    ExtendPolyline2d(tr, pl2d, extS, extE);
                    break;
                default:
                    DexLogger.Warn(Src, $"Unsupported entity type '{ent?.GetType().Name}' - skipped ({id}).");
                    break;
            }
        }

        private static void ExtendLine(Transaction tr, Line line, double extS, double extE)
        {
            Point3d p1 = line.StartPoint;
            Point3d p2 = line.EndPoint;
            double dis = p1.DistanceTo(p2);
            if (dis < 1e-8)
            {
                DexLogger.Warn(Src, $"Line {line.ObjectId} has zero length - skipped.");
                return;
            }

            Vector3d u = (p2 - p1) / dis; // unit vector start->end
            var w = (Line)tr.GetObject(line.ObjectId, OpenMode.ForWrite);
            w.StartPoint = p1 - u * extS;
            w.EndPoint = p2 + u * extE;

            DexLogger.Info(Src, $"Line {line.ObjectId} extended (start={extS:0.###}, end={extE:0.###}).");
        }

        private static void ExtendArc(Transaction tr, Arc arc, double extS, double extE)
        {
            double rad = arc.Radius;
            double an1 = arc.StartAngle;
            double an2 = arc.EndAngle;
            double sweep = (an2 - an1 + Math.PI + Math.PI) % (Math.PI + Math.PI);
            double dis = rad * sweep;

            if (dis + extS + extE >= 2.0 * rad * Math.PI)
            {
                DexLogger.Warn(Src, $"Arc {arc.ObjectId} extension would exceed full circle - skipped.");
                return;
            }

            var w = (Arc)tr.GetObject(arc.ObjectId, OpenMode.ForWrite);
            w.StartAngle = an1 - extS / rad;
            w.EndAngle = an2 + extE / rad;

            DexLogger.Info(Src, $"Arc {arc.ObjectId} extended (start={extS:0.###}, end={extE:0.###}).");
        }

        private static void ExtendLwPolyline(Transaction tr, Polyline pl, double extS, double extE)
        {
            int n = pl.NumberOfVertices;
            if (n < 2)
            {
                DexLogger.Warn(Src, $"Polyline {pl.ObjectId} has < 2 vertices - skipped.");
                return;
            }
            if (pl.Closed)
            {
                DexLogger.Warn(Src, $"Polyline {pl.ObjectId} is closed - start/end extension does not apply, skipped.");
                return;
            }

            var verts = new List<PolyVertex>(n);
            for (int i = 0; i < n; i++)
                verts.Add(new PolyVertex(pl.GetPoint2dAt(i), pl.GetBulgeAt(i)));

            var extended = DexGeometry.ExtendPolyline(verts, extS, extE);

            var w = (Polyline)tr.GetObject(pl.ObjectId, OpenMode.ForWrite);
            for (int i = 0; i < n; i++)
            {
                w.SetPointAt(i, extended[i].Pt);
                w.SetBulgeAt(i, extended[i].Bulge);
            }

            DexLogger.Info(Src, $"LWPolyline {pl.ObjectId} extended (start={extS:0.###}, end={extE:0.###}).");
        }

        private static void ExtendPolyline2d(Transaction tr, Polyline2d pl2d, double extS, double extE)
        {
            if (pl2d.Closed)
            {
                DexLogger.Warn(Src, $"Polyline2d {pl2d.ObjectId} is closed - skipped.");
                return;
            }

            var vertexIds = new List<ObjectId>();
            foreach (ObjectId vid in pl2d)
                vertexIds.Add(vid);

            if (vertexIds.Count < 2)
            {
                DexLogger.Warn(Src, $"Polyline2d {pl2d.ObjectId} has < 2 vertices - skipped.");
                return;
            }

            var verts = new List<PolyVertex>(vertexIds.Count);
            foreach (var vid in vertexIds)
            {
                var v2d = (Vertex2d)tr.GetObject(vid, OpenMode.ForRead);
                verts.Add(new PolyVertex(new Point2d(v2d.Position.X, v2d.Position.Y), v2d.Bulge));
            }

            var extended = DexGeometry.ExtendPolyline(verts, extS, extE);

            for (int i = 0; i < vertexIds.Count; i++)
            {
                var v2d = (Vertex2d)tr.GetObject(vertexIds[i], OpenMode.ForWrite);
                v2d.Position = new Point3d(extended[i].Pt.X, extended[i].Pt.Y, v2d.Position.Z);
                v2d.Bulge = extended[i].Bulge;
            }

            DexLogger.Info(Src, $"Polyline2d {pl2d.ObjectId} extended (start={extS:0.###}, end={extE:0.###}).");
        }
    }
}


// ===== FILE: Core\DexGeometry.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.Geometry;

namespace DoubleExtendCS.Core
{
    /// <summary>One 2D polyline vertex + its outgoing bulge (LWPOLYLINE style,
    /// bulge stored on the vertex it leaves from - matches DXF group 42 / AutoLISP's
    /// LM:lwvertices convention used by the original LISP).</summary>
    public struct PolyVertex
    {
        public Point2d Pt;
        public double Bulge;

        public PolyVertex(Point2d pt, double bulge)
        {
            Pt = pt;
            Bulge = bulge;
        }
    }

    /// <summary>
    /// Pure, side-effect-free geometry helpers, ported 1:1 from the original
    /// LISP (vxs, LM:bulgecentre, LM:dex:extendpoly). Kept independent from
    /// any AutoCAD DBObject/Transaction code so they're trivially unit-testable.
    /// </summary>
    public static class DexGeometry
    {
        private const double Tolerance = 1e-8;

        /// <summary>Centre of the arc defined by a bulge between p1 and p2
        /// (Lee Mac's LM:bulgecentre, ported verbatim).</summary>
        public static Point2d BulgeCentre(Point2d p1, Point2d p2, double bulge)
        {
            double ang = p1.GetVectorTo(p2).Angle;
            double dir = ang + (Math.PI / 2.0 - 2.0 * Math.Atan(bulge));
            double dist = (p1.GetDistanceTo(p2) * (1.0 + bulge * bulge)) / (4.0 * bulge);
            return PolarPoint(p1, dir, dist);
        }

        public static Point2d PolarPoint(Point2d basePt, double angle, double distance)
        {
            return new Point2d(basePt.X + distance * Math.Cos(angle),
                                basePt.Y + distance * Math.Sin(angle));
        }

        private static bool NearZero(double v) => Math.Abs(v) < Tolerance;

        /// <summary>
        /// Extends the START and END of an ordered vertex chain (LWPOLYLINE or
        /// old-style POLYLINE, expressed generically here) by extS / extE.
        /// Straight segments extend linearly; bulged (arc) segments extend
        /// along the arc. Direct port of LM:dex:extendpoly.
        /// </summary>
        public static List<PolyVertex> ExtendPolyline(List<PolyVertex> verts, double extS, double extE)
        {
            if (verts == null || verts.Count < 2)
                return verts;

            var lst = new List<PolyVertex>(verts);

            // ---------- Start ----------
            {
                Point2d pt1 = lst[0].Pt;
                Point2d pt2 = lst[1].Pt;
                double bul = lst[0].Bulge;
                double dis = pt1.GetDistanceTo(pt2);

                if (NearZero(bul))
                {
                    if (!NearZero(dis))
                    {
                        Vector2d u = (pt2 - pt1) / dis; // unit vector pt1->pt2
                        Point2d newPt = pt1 - u * extS;
                        lst[0] = new PolyVertex(newPt, lst[0].Bulge);
                    }
                }
                else
                {
                    Point2d cen = BulgeCentre(pt1, pt2, bul);
                    double rad = (dis * (1.0 + bul * bul)) / (4.0 * Math.Abs(bul));
                    double len = Math.Abs(4.0 * Math.Atan(bul) * rad);

                    if (len + extS < rad * 2.0 * Math.PI)
                    {
                        double baseAng = cen.GetVectorTo(pt1).Angle;
                        double sign = bul < 0 ? -1.0 : 1.0;
                        double newAng = baseAng + sign * (extS / rad);
                        Point2d pt3 = PolarPoint(cen, newAng, rad);

                        // Matches the LISP:  ang = (if (minusp b) - +) (atan b) (extS/rad/4)
                        double atanB = Math.Atan(bul);
                        double angDelta = extS / rad / 4.0;
                        double newHalfAng = bul < 0 ? (atanB - angDelta) : (atanB + angDelta);
                        double newBulge = Math.Tan(newHalfAng);

                        lst[0] = new PolyVertex(pt3, newBulge);
                    }
                }
            }

            // ---------- End ----------
            {
                int last = lst.Count - 1;
                int prev = last - 1;
                Point2d pt1 = lst[prev].Pt; // "pt2" in LISP after reverse == second-to-last original
                Point2d pt2 = lst[last].Pt;
                double bul = lst[prev].Bulge; // bulge leaving the second-to-last vertex
                double dis = pt1.GetDistanceTo(pt2);

                if (NearZero(bul))
                {
                    if (!NearZero(dis))
                    {
                        Vector2d u = (pt1 - pt2) / dis; // unit vector pt2->pt1 (i.e. direction "outward" from pt1 reversed)
                        Point2d newPt = pt1 + u * extE;
                        lst[last] = new PolyVertex(newPt, lst[last].Bulge);
                    }
                }
                else
                {
                    Point2d cen = BulgeCentre(pt2, pt1, bul);
                    double rad = (dis * (1.0 + bul * bul)) / (4.0 * Math.Abs(bul));
                    double len = Math.Abs(4.0 * Math.Atan(bul) * rad);

                    if (len + extE < rad * 2.0 * Math.PI)
                    {
                        double baseAng = cen.GetVectorTo(pt1).Angle;
                        double sign = bul < 0 ? -1.0 : 1.0;
                        double newAng = baseAng - sign * (extE / rad);
                        Point2d pt3 = PolarPoint(cen, newAng, rad);

                        // Matches the LISP:  ang = (if (minusp b) - +) (atan b) (extE/rad/4)
                        double atanB = Math.Atan(bul);
                        double angDelta = extE / rad / 4.0;
                        double newHalfAng = bul < 0 ? (atanB - angDelta) : (atanB + angDelta);
                        double newBulge = Math.Tan(newHalfAng);

                        lst[last] = new PolyVertex(pt3, lst[last].Bulge);
                        lst[prev] = new PolyVertex(lst[prev].Pt, newBulge);
                    }
                }
            }

            return lst;
        }
    }
}


// ===== FILE: UI\DexForm.cs =====
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using DoubleExtendCS.Core;
using DoubleExtendCS.Jigs;
using DoubleExtendCS.Logging;
using Application = Autodesk.AutoCAD.ApplicationServices.Application;
using GdiFont = System.Drawing.Font;
using WinFlowDirection = System.Windows.Forms.FlowDirection;

namespace DoubleExtendCS.UI
{
    /// <summary>
    /// Modeless replacement for the LISP DCL dialog. Opened via
    /// Application.ShowModelessDialog so it stays open while the user
    /// switches between Model Space / Paper Space / other commands.
    /// All Editor calls made from event handlers here run on the AutoCAD
    /// main thread automatically, because WinForms events on a modeless
    /// dialog hosted by ShowModelessDialog are pumped on that same thread.
    /// </summary>
    public class DexForm : Form
    {
        private const string Src = "DexForm";

        // ---- selection / undo state ----
        private ObjectIdCollection _sel = new ObjectIdCollection();
        private List<EntitySnapshot> _baselineSnapshot;   // state when entities were picked / dialog opened
        private List<EntitySnapshot> _preApplySnapshot;    // state just before the last Apply (for Undo)

        private double _extStart = 1.0;
        private double _extEnd = 1.5;
        private bool _bothEnable;
        private double _bothSlot;

        // ---- controls ----
        private Label _lblVersion;
        private Button _btnPick;
        private Label _lblSelCount;
        private DataGridView _grid;
        private TextBox _txtExtStart;
        private TextBox _txtExtEnd;
        private CheckBox _chkBothEnable;
        private TextBox _txtBothSlot;
        private TrackBar _trkBothSlot;
        private Button _btnDynSlot;
        private Button _btnApply;
        private Button _btnUndo;
        private Button _btnDebug;
        private Button _btnOk;
        private Button _btnCancel;
        private PreviewPanel _preview;
        private ListBox _logList;
        private Button _btnSaveLog;

        private bool _suspendEvents;

        public DexForm()
        {
            BuildUi();
            WireEvents();
            RefreshGrid();
            RefreshPreview();
            _lblVersion.Text = "DoubleExtendCS " + VersionInfo.GetLoadBanner();

            DexLogger.EntryAdded += OnLogEntryAdded;
            FormClosed += (s, e) => DexLogger.EntryAdded -= OnLogEntryAdded;

            // seed the visible log with whatever is already buffered (e.g. the
            // load banner printed by Commands.Initialize before this form existed)
            foreach (var entry in DexLogger.Entries)
                AppendLogLine(entry.ToString());
        }

        // ==============================================================
        // UI construction (code-only, no designer file, kept compact)
        // ==============================================================
        private void BuildUi()
        {
            Text = "Double Extend";
            FormBorderStyle = FormBorderStyle.SizableToolWindow; // small title bar, stays above AutoCAD
            ShowInTaskbar = false;
            StartPosition = FormStartPosition.CenterScreen;
            MinimumSize = new Size(560, 640);
            Size = new Size(620, 760);
            KeyPreview = true;

            var root = new TableLayoutPanel
            {
                Dock = DockStyle.Fill,
                ColumnCount = 1,
                Padding = new Padding(8)
            };
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // version
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // pick row
            root.RowStyles.Add(new RowStyle(SizeType.Absolute, 130)); // grid
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // extension group
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // both-ends group
            root.RowStyles.Add(new RowStyle(SizeType.Percent, 45)); // preview
            root.RowStyles.Add(new RowStyle(SizeType.Percent, 30)); // log
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // action buttons

            _lblVersion = new Label { AutoSize = true, ForeColor = Color.DimGray, Text = "DoubleExtendCS" };
            root.Controls.Add(_lblVersion);

            // ---- Pick row ----
            var pickRow = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
            _btnPick = new Button { Text = "Pick Entities (Window / Crossing)", AutoSize = true };
            _lblSelCount = new Label { Text = "No selection", AutoSize = true, Padding = new Padding(10, 6, 0, 0) };
            pickRow.Controls.Add(_btnPick);
            pickRow.Controls.Add(_lblSelCount);
            root.Controls.Add(pickRow);

            // ---- Grid ----
            _grid = new DataGridView
            {
                Dock = DockStyle.Fill,
                ReadOnly = true,
                AllowUserToAddRows = false,
                AllowUserToDeleteRows = false,
                AllowUserToResizeRows = false,
                SelectionMode = DataGridViewSelectionMode.FullRowSelect,
                MultiSelect = true,
                AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
                RowHeadersVisible = false
            };
            _grid.Columns.Add("idx", "#");
            _grid.Columns.Add("type", "Type");
            _grid.Columns.Add("handle", "Handle");
            _grid.Columns.Add("layer", "Layer");
            root.Controls.Add(_grid);

            // ---- Extension group ----
            var extGroup = new GroupBox { Text = "Extension", AutoSize = true, Dock = DockStyle.Fill, Padding = new Padding(6) };
            var extLayout = new TableLayoutPanel { ColumnCount = 4, AutoSize = true };
            extLayout.Controls.Add(new Label { Text = "Extend Start", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
            _txtExtStart = new TextBox { Width = 80, Text = "1.0" };
            extLayout.Controls.Add(_txtExtStart, 1, 0);
            extLayout.Controls.Add(new Label { Text = "Extend End", AutoSize = true, Anchor = AnchorStyles.Left }, 2, 0);
            _txtExtEnd = new TextBox { Width = 80, Text = "1.5" };
            extLayout.Controls.Add(_txtExtEnd, 3, 0);
            extGroup.Controls.Add(extLayout);
            root.Controls.Add(extGroup);

            // ---- Both-ends group ----
            var bothGroup = new GroupBox { Text = "Both Ends - Horizontal Slot", AutoSize = true, Dock = DockStyle.Fill, Padding = new Padding(6) };
            var bothLayout = new TableLayoutPanel { ColumnCount = 1, AutoSize = true };
            _chkBothEnable = new CheckBox { Text = "Enable Both-Ends Slot", AutoSize = true };
            bothLayout.Controls.Add(_chkBothEnable);

            var slotRow = new FlowLayoutPanel { AutoSize = true };
            slotRow.Controls.Add(new Label { Text = "Slot Length (0 = All Slot)", AutoSize = true, Padding = new Padding(0, 6, 6, 0) });
            _txtBothSlot = new TextBox { Width = 80, Text = "0.0" };
            slotRow.Controls.Add(_txtBothSlot);
            bothLayout.Controls.Add(slotRow);

            _trkBothSlot = new TrackBar { Minimum = 0, Maximum = 1000, TickFrequency = 100, Width = 480 };
            bothLayout.Controls.Add(_trkBothSlot);

            _btnDynSlot = new Button { Text = "Dynamic Drag Preview", AutoSize = true };
            bothLayout.Controls.Add(_btnDynSlot);

            bothGroup.Controls.Add(bothLayout);
            root.Controls.Add(bothGroup);

            // ---- Preview ----
            var previewGroup = new GroupBox { Text = "Preview", Dock = DockStyle.Fill };
            _preview = new PreviewPanel { Dock = DockStyle.Fill, Margin = new Padding(4) };
            previewGroup.Controls.Add(_preview);
            root.Controls.Add(previewGroup);

            // ---- Log ----
            var logGroup = new GroupBox { Text = "Log", Dock = DockStyle.Fill };
            var logLayout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2 };
            logLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
            logLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            _logList = new ListBox { Dock = DockStyle.Fill, Font = new GdiFont(FontFamily.GenericMonospace, 8f), HorizontalScrollbar = true };
            logLayout.Controls.Add(_logList, 0, 0);
            _btnSaveLog = new Button { Text = "Save Log...", AutoSize = true, Anchor = AnchorStyles.Right };
            var saveRow = new FlowLayoutPanel { AutoSize = true, FlowDirection = WinFlowDirection.RightToLeft, Dock = DockStyle.Fill };
            saveRow.Controls.Add(_btnSaveLog);
            logLayout.Controls.Add(saveRow, 0, 1);
            logGroup.Controls.Add(logLayout);
            root.Controls.Add(logGroup);

            // ---- Action buttons ----
            var actionRow = new FlowLayoutPanel { AutoSize = true, FlowDirection = WinFlowDirection.LeftToRight, Dock = DockStyle.Fill };
            _btnApply = new Button { Text = "Apply", AutoSize = true };
            _btnUndo = new Button { Text = "Undo", AutoSize = true };
            _btnDebug = new Button { Text = "Debug", AutoSize = true };
            _btnOk = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK };
            _btnCancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
            actionRow.Controls.AddRange(new Control[] { _btnApply, _btnUndo, _btnDebug, _btnOk, _btnCancel });
            root.Controls.Add(actionRow);

            Controls.Add(root);
            AcceptButton = null; // Enter must not silently trigger OK while editing text fields
        }

        private void WireEvents()
        {
            _btnPick.Click += (s, e) => PickEntities();
            _btnApply.Click += (s, e) => ApplyExtension();
            _btnUndo.Click += (s, e) => UndoLastApply();
            _btnDebug.Click += (s, e) => ShowDebugReport();
            _btnDynSlot.Click += (s, e) => RunDynamicSlot();
            _btnSaveLog.Click += (s, e) => SaveLog();

            _txtExtStart.TextChanged += (s, e) => { ReadNumericFields(); RefreshPreview(); };
            _txtExtEnd.TextChanged += (s, e) => { ReadNumericFields(); RefreshPreview(); };
            _chkBothEnable.CheckedChanged += (s, e) => { _bothEnable = _chkBothEnable.Checked; RefreshPreview(); };

            _txtBothSlot.TextChanged += (s, e) =>
            {
                if (_suspendEvents) return;
                if (double.TryParse(_txtBothSlot.Text, out double v))
                {
                    _bothSlot = Math.Max(0, v);
                    _suspendEvents = true;
                    _trkBothSlot.Value = (int)Math.Min(_trkBothSlot.Maximum, _bothSlot * 10);
                    _suspendEvents = false;
                    RefreshPreview();
                }
            };
            _trkBothSlot.Scroll += (s, e) =>
            {
                if (_suspendEvents) return;
                _bothSlot = _trkBothSlot.Value / 10.0;
                _suspendEvents = true;
                _txtBothSlot.Text = _bothSlot.ToString("0.0");
                _suspendEvents = false;
                RefreshPreview();
            };

            // ---- viewport highlight wiring (the "one line" pattern) ----
            _grid.SelectionChanged += Grid_SelectionChanged;

            _btnOk.Click += (s, e) => { ApplyExtension(); Close(); };
            _btnCancel.Click += (s, e) => { RestoreBaseline(); Close(); };
        }

        // Esc in the AutoCAD viewport must not close this floating dialog.
        protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
        {
            const int WM_KEYDOWN = 0x100;
            if (msg.Msg == WM_KEYDOWN && keyData == Keys.Escape)
                return true; // swallow
            return base.ProcessCmdKey(ref msg, keyData);
        }

        private void Grid_SelectionChanged(object sender, EventArgs e)
        {
            if (_grid.SelectedRows.Count == 0) return;

            var idx = new List<int>();
            foreach (DataGridViewRow row in _grid.SelectedRows)
                idx.Add(row.Index);

            var ids = new ObjectIdCollection();
            var flatIds = _sel.Cast<ObjectId>().ToList();
            foreach (var i in idx)
                if (i >= 0 && i < flatIds.Count)
                    ids.Add(flatIds[i]);

            SelectionUtils.Highlight(ids);
            _preview.SetHighlightIndex(idx.Count == 1 ? idx[0] : -1);
        }

        // ==============================================================
        // Commands
        // ==============================================================

        private void PickEntities()
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            var ed = doc.Editor;

            SelectionUtils.Unhighlight();

            var filter = new SelectionFilter(new[]
            {
                new TypedValue((int)DxfCode.Start, "LINE,ARC,LWPOLYLINE,POLYLINE2D")
            });

            ed.WriteMessage("\nSelect objects (Window / Crossing / Pick / Fence / ALL):");
            PromptSelectionResult res = ed.GetSelection(filter);

            if (res.Status != PromptStatus.OK || res.Value.Count == 0)
            {
                DexLogger.Info(Src, "Pick Entities: nothing selected.");
                _sel = new ObjectIdCollection();
                _lblSelCount.Text = "No selection";
                RefreshGrid();
                RefreshPreview();
                return;
            }

            _sel = new ObjectIdCollection(res.Value.GetObjectIds());
            _lblSelCount.Text = $"{_sel.Count} object(s) selected";
            DexLogger.Info(Src, $"Pick Entities: {_sel.Count} object(s) selected.");

            _baselineSnapshot = SelectionSnapshot.CaptureNow(_sel);
            _preApplySnapshot = null;

            SelectionUtils.Highlight(_sel);
            RefreshGrid();
            RefreshPreview();
        }

        private void ApplyExtension()
        {
            if (_sel.Count == 0)
            {
                DexLogger.Warn(Src, "Apply: no entities selected - use Pick Entities first.");
                return;
            }

            ReadNumericFields();

            var doc = Application.DocumentManager.MdiActiveDocument;
            using (doc.LockDocument())
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                _preApplySnapshot = SelectionSnapshot.Capture(tr, _sel);

                EntityExtender.RunAll(tr, _sel, _extStart, _extEnd);

                if (_bothEnable && _bothSlot > 0.0)
                    EntityExtender.RunAll(tr, _sel, _bothSlot, _bothSlot);

                tr.Commit();
            }
            doc.Editor.Regen();
            SelectionUtils.Highlight(_sel);
            RefreshPreview();
            DexLogger.Info(Src, $"Apply: extStart={_extStart:0.###}, extEnd={_extEnd:0.###}, bothEnable={_bothEnable}, bothSlot={_bothSlot:0.###}.");
        }

        private void UndoLastApply()
        {
            if (_preApplySnapshot == null)
            {
                DexLogger.Info(Src, "Undo: nothing to undo.");
                return;
            }

            SelectionSnapshot.RestoreNow(_preApplySnapshot);
            SelectionUtils.Highlight(_sel);
            RefreshPreview();
            DexLogger.Info(Src, "Undo: last Apply reverted.");
            _preApplySnapshot = null;
        }

        private void RestoreBaseline()
        {
            if (_baselineSnapshot != null)
            {
                SelectionSnapshot.RestoreNow(_baselineSnapshot);
                DexLogger.Info(Src, "Cancel: restored geometry to state at Pick Entities.");
            }
            SelectionUtils.Unhighlight();
        }

        private void RunDynamicSlot()
        {
            if (_sel.Count == 0)
            {
                DexLogger.Warn(Src, "Dynamic Drag Preview: no entities selected - use Pick Entities first.");
                return;
            }

            var doc = Application.DocumentManager.MdiActiveDocument;
            var ed = doc.Editor;

            List<EntitySnapshot> prePreviewSnapshot;
            Point3d basePt;
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                prePreviewSnapshot = SelectionSnapshot.Capture(tr, _sel);
                basePt = SelectionUtils.Midpoint(tr, _sel);
                tr.Commit();
            }

            var session = new BothEndsSlotPreviewSession(basePt, GetCurrentUcsXAxis(ed), prePreviewSnapshot);
            PromptPointResult pointResult;
            using (doc.LockDocument())
            using (session)
            {
                pointResult = session.Run(ed, (slot, geometry) =>
                {
                    SetBothSlotValue(slot);
                    _preview.SetPreviewGeometry(geometry);
                });
            }

            bool confirmed = pointResult.Status == PromptStatus.OK;
            if (confirmed)
            {
                using (doc.LockDocument())
                using (var tr = doc.Database.TransactionManager.StartTransaction())
                {
                    _preApplySnapshot = SelectionSnapshot.Capture(tr, _sel);
                    EntityExtender.RunAll(tr, _sel, session.ConfirmedSlot, session.ConfirmedSlot);
                    tr.Commit();
                }
                doc.Editor.Regen();
                SetBothSlotValue(session.ConfirmedSlot);
                _preview.SetPreviewGeometry(session.LastGeometry);
                DexLogger.Info(Src, $"Dynamic Drag Preview: confirmed both-ends slot={session.ConfirmedSlot:0.###}.");
            }
            else
            {
                DexLogger.Info(Src, "Dynamic Drag Preview: cancelled. No database geometry was changed.");
                RefreshPreview();
            }

            SelectionUtils.Highlight(_sel);
        }

        private void ShowDebugReport()
        {
            var sb = new System.Text.StringBuilder();
            sb.AppendLine("============================================================");
            sb.AppendLine($"DEX DEBUG REPORT  {DateTime.Now:dd/MM/yyyy HH:mm:ss}");
            sb.AppendLine("============================================================");
            sb.AppendLine($"extStart   = {_extStart:0.000000}");
            sb.AppendLine($"extEnd     = {_extEnd:0.000000}");
            sb.AppendLine($"bothSlot   = {_bothSlot:0.000000}");
            sb.AppendLine($"bothEnable = {_bothEnable}");
            sb.AppendLine($"Selection  : {_sel.Count} object(s)");

            var doc = Application.DocumentManager.MdiActiveDocument;
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                int i = 0;
                foreach (ObjectId id in _sel)
                {
                    if (id.IsErased) continue;
                    var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
                    sb.AppendLine($"  [{i}] {ent?.GetType().Name}  handle={ent?.Handle}  layer={ent?.Layer}");
                    i++;
                }
                tr.Commit();
            }
            sb.AppendLine("============================================================");

            DexLogger.Info(Src, "Debug report generated (see log for full detail).");
            foreach (var line in sb.ToString().Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries))
                DexLogger.Debug(Src, line.TrimEnd('\r'));

            var path = DexLogger.SaveToFile();
            if (path != null)
                DexLogger.Info(Src, $"Debug log written to: {path}");
        }

        private void SaveLog()
        {
            using (var dlg = new SaveFileDialog
            {
                Filter = "Text log (*.txt)|*.txt",
                FileName = $"DEX_log_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt"
            })
            {
                if (dlg.ShowDialog(this) == DialogResult.OK)
                {
                    var folder = Path.GetDirectoryName(dlg.FileName);
                    var path = DexLogger.SaveToFile(folder);
                    if (path != null && !string.Equals(path, dlg.FileName, StringComparison.OrdinalIgnoreCase))
                    {
                        File.Copy(path, dlg.FileName, true);
                    }
                    DexLogger.Info(Src, $"Log saved to {dlg.FileName}");
                }
            }
        }

        // ==============================================================
        // Helpers
        // ==============================================================

        private void ReadNumericFields()
        {
            if (double.TryParse(_txtExtStart.Text, out double s)) _extStart = s;
            if (double.TryParse(_txtExtEnd.Text, out double eVal)) _extEnd = eVal;
        }

        private void RefreshGrid()
        {
            _grid.Rows.Clear();
            if (_sel.Count == 0) return;

            var doc = Application.DocumentManager.MdiActiveDocument;
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                int i = 0;
                foreach (ObjectId id in _sel)
                {
                    if (id.IsErased) continue;
                    var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
                    _grid.Rows.Add(i, ent?.GetType().Name, ent?.Handle.ToString(), ent?.Layer);
                    i++;
                }
                tr.Commit();
            }
        }

        private void RefreshPreview()
        {
            if (_sel.Count == 0)
            {
                _preview.SetPreviewGeometry(new PreviewGeometry());
                return;
            }

            ReadNumericFields();
            double previewStart = _extStart + (_bothEnable ? _bothSlot : 0.0);
            double previewEnd = _extEnd + (_bothEnable ? _bothSlot : 0.0);

            // Prefer the pure in-memory extended preview (fast, no DB writes);
            // fall back to sampling live DB geometry if we have no baseline yet.
            if (_baselineSnapshot != null)
            {
                _preview.SetPreviewGeometry(PreviewGeometryCollector.CollectPreviewGeometry(_baselineSnapshot, previewStart, previewEnd));
            }
            else
            {
                var doc = Application.DocumentManager.MdiActiveDocument;
                using (var tr = doc.Database.TransactionManager.StartTransaction())
                {
                    var geometry = new PreviewGeometry();
                    geometry.Centerlines.AddRange(PreviewGeometryCollector.Collect(tr, _sel));
                    _preview.SetPreviewGeometry(geometry);
                    tr.Commit();
                }
            }
        }

        private void SetBothSlotValue(double value)
        {
            _bothSlot = Math.Max(0.0, value);
            _suspendEvents = true;
            _txtBothSlot.Text = _bothSlot.ToString("0.0");
            _trkBothSlot.Value = (int)Math.Min(_trkBothSlot.Maximum, _bothSlot * 10.0);
            _suspendEvents = false;
        }

        private static Vector3d GetCurrentUcsXAxis(Editor ed)
        {
            try
            {
                Matrix3d ucs = ed.CurrentUserCoordinateSystem;
                Point3d origin = Point3d.Origin.TransformBy(ucs);
                Point3d xPoint = new Point3d(1.0, 0.0, 0.0).TransformBy(ucs);
                Vector3d axis = origin.GetVectorTo(xPoint);
                if (axis.Length > 1e-8)
                    return axis.GetNormal();
            }
            catch
            {
                // Fall back to WCS X if CurrentUserCoordinateSystem is unavailable.
            }
            return Vector3d.XAxis;
        }

        private void OnLogEntryAdded(LogEntry entry)
        {
            if (IsDisposed) return;
            if (InvokeRequired)
            {
                try { BeginInvoke(new Action(() => AppendLogLine(entry.ToString()))); }
                catch (ObjectDisposedException) { /* form closing race - ignore */ }
                return;
            }
            AppendLogLine(entry.ToString());
        }

        private void AppendLogLine(string line)
        {
            _logList.Items.Add(line);
            if (_logList.Items.Count > 2000)
                _logList.Items.RemoveAt(0);
            _logList.TopIndex = _logList.Items.Count - 1;
        }
    }
}


// ===== FILE: UI\PreviewPanel.cs =====
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using DoubleExtendCS.Core;

namespace DoubleExtendCS.UI
{
    /// <summary>
    /// Dynamic 2D preview panel. It draws real tessellated preview geometry from
    /// Core.PreviewGeometry, fit-scaled into the panel using the same pattern as
    /// PPN_preview_bulge_arc_fix: scale = Min(panelW/worldW, panelH/worldH),
    /// with a mouse-wheel zoom multiplier and right-click reset.
    /// </summary>
    public class PreviewPanel : Panel
    {
        private PreviewGeometry _geometry = new PreviewGeometry();
        private int _highlightIndex = -1;

        private float _zoom = 1f;
        private const float MinZoom = 0.2f;
        private const float MaxZoom = 12f;

        public Color CurveColor { get; set; } = Color.DeepSkyBlue;
        public Color HighlightColor { get; set; } = Color.OrangeRed;
        public Color OriginalColor { get; set; } = Color.FromArgb(170, 170, 170);
        public Color GuideColor { get; set; } = Color.FromArgb(240, 150, 40);
        public Color HintColor { get; set; } = Color.FromArgb(130, 120, 120, 120);

        public PreviewPanel()
        {
            DoubleBuffered = true;
            BackColor = Color.White;
            BorderStyle = BorderStyle.FixedSingle;
            TabStop = true;
            SetStyle(ControlStyles.ResizeRedraw | ControlStyles.Selectable, true);

            MouseEnter += (s, e) => Focus();
            MouseDown += (s, e) =>
            {
                Focus();
                if (e.Button == MouseButtons.Right)
                {
                    _zoom = 1f;
                    Refresh();
                }
            };
        }

        /// <summary>Backward-compatible entry point used by older form code.</summary>
        public void SetGeometry(IEnumerable<List<PointF>> worldPolylines)
        {
            var geometry = new PreviewGeometry();
            if (worldPolylines != null)
                geometry.Centerlines.AddRange(worldPolylines.Select(p => p?.ToList()).Where(p => p != null && p.Count >= 2));
            SetPreviewGeometry(geometry);
        }

        /// <summary>Replace the complete preview geometry and paint immediately.</summary>
        public void SetPreviewGeometry(PreviewGeometry geometry)
        {
            _geometry = geometry ?? new PreviewGeometry();
            _highlightIndex = -1;
            Refresh();
        }

        public void SetHighlightIndex(int index)
        {
            _highlightIndex = index;
            Refresh();
        }

        protected override void OnMouseWheel(MouseEventArgs e)
        {
            float factor = e.Delta > 0 ? 1.15f : 1f / 1.15f;
            _zoom = Math.Max(MinZoom, Math.Min(MaxZoom, _zoom * factor));
            Refresh();
            // Do not call base.OnMouseWheel(e): consume the message so parent
            // scroll containers/dialogs do not react while the preview is zooming.
        }

        protected override void OnPaint(PaintEventArgs e)
        {
            base.OnPaint(e);
            var g = e.Graphics;
            g.SmoothingMode = SmoothingMode.AntiAlias;

            if (_geometry == null || _geometry.IsEmpty || Width < 20 || Height < 20)
            {
                DrawEmptyMessage(g);
                return;
            }

            var all = _geometry.Centerlines
                .Concat(_geometry.Edges)
                .Concat(_geometry.Boundaries)
                .Where(poly => poly != null)
                .SelectMany(poly => poly)
                .ToList();

            if (all.Count == 0)
            {
                DrawEmptyMessage(g);
                return;
            }

            float minX = all.Min(p => p.X), maxX = all.Max(p => p.X);
            float minY = all.Min(p => p.Y), maxY = all.Max(p => p.Y);
            float worldW = Math.Max(maxX - minX, 1e-6f);
            float worldH = Math.Max(maxY - minY, 1e-6f);

            const int margin = 12;
            float availW = Math.Max(Width - 2 * margin, 1);
            float availH = Math.Max(Height - 2 * margin, 1);
            float scale = Math.Min(availW / worldW, availH / worldH) * _zoom;

            float drawW = worldW * scale;
            float drawH = worldH * scale;
            float padX = margin + (availW - drawW) / 2f;
            float padY = margin + (availH - drawH) / 2f;

            PointF ToPanel(PointF wp)
            {
                float px = padX + (wp.X - minX) * scale;
                float py = Height - padY - (wp.Y - minY) * scale; // flip Y: world-up -> panel-up
                return new PointF(px, py);
            }

            using (var originalPen = new Pen(OriginalColor, 1f) { DashStyle = DashStyle.Dash })
            {
                foreach (var poly in _geometry.Boundaries)
                    DrawPolyPoints(g, originalPen, poly, ToPanel);
            }

            using (var guidePen = new Pen(GuideColor, 1.4f) { DashStyle = DashStyle.Dot })
            {
                foreach (var poly in _geometry.Edges)
                    DrawPolyPoints(g, guidePen, poly, ToPanel);
            }

            for (int i = 0; i < _geometry.Centerlines.Count; i++)
            {
                Color c = i == _highlightIndex ? HighlightColor : CurveColor;
                float width = i == _highlightIndex ? 2.4f : 1.6f;
                using (var pen = new Pen(c, width))
                    DrawPolyPoints(g, pen, _geometry.Centerlines[i], ToPanel);
            }

            DrawZoomHint(g);
        }

        private static void DrawPolyPoints(Graphics g, Pen pen, List<PointF> worldPts, Func<PointF, PointF> toPanel)
        {
            if (worldPts == null || worldPts.Count < 2) return;
            var pts = worldPts.Select(toPanel).ToArray();
            g.DrawLines(pen, pts);
        }

        private void DrawEmptyMessage(Graphics g)
        {
            using (var f = new Font(Font.FontFamily, 9f))
            using (var b = new SolidBrush(Color.Gray))
            {
                var msg = "No selection to preview";
                var size = g.MeasureString(msg, f);
                g.DrawString(msg, f, b, (Width - size.Width) / 2f, (Height - size.Height) / 2f);
            }
        }

        private void DrawZoomHint(Graphics g)
        {
            using (var hintBrush = new SolidBrush(HintColor))
            using (var hintFont = new Font("Segoe UI", 7f))
            {
                string text = Math.Abs(_zoom - 1f) > 0.01f
                    ? $"scroll: zoom ({_zoom:0.0}x)  |  right-click: reset"
                    : "scroll to zoom";
                var size = g.MeasureString(text, hintFont);
                g.DrawString(text, hintFont, hintBrush, Width - size.Width - 6, Height - size.Height - 4);
            }
        }
    }
}


// ===== FILE: Commands.cs =====
using System;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.Runtime;
using DoubleExtendCS.Core;
using DoubleExtendCS.Logging;
using DoubleExtendCS.UI;
using Application = Autodesk.AutoCAD.ApplicationServices.Application;
using Exception = System.Exception;

namespace DoubleExtendCS
{
    /// <summary>
    /// Entry point. IExtensionApplication.Initialize runs once per DLL load
    /// (NETLOAD or auto-load via a .bundle / acad.rx path) - this is where we
    /// print the version+timestamp banner requested ("Display after loading
    /// the dll each time a new version is loaded").
    /// </summary>
    public class Commands : IExtensionApplication
    {
        private const string Src = "Commands";
        private static DexForm _form; // single modeless instance, reused across DEX invocations

        public void Initialize()
        {
            string banner = VersionInfo.GetLoadBanner();
            DexLogger.Info(Src, $"DoubleExtendCS loaded - build {banner}");

            var doc = Application.DocumentManager.MdiActiveDocument;
            doc?.Editor.WriteMessage(
                "\n:: DoubleExtendCS | modeless dialog | live preview | viewport highlight | transient dynamic slot ::" +
                $"\n:: Build {banner} ::" +
                "\n:: Commands: DEX  /  EXTENDSTARTENDMULTIPLE  /  DEXDEBUG ::\n");
        }

        public void Terminate()
        {
            DexLogger.Info(Src, "DoubleExtendCS unloading.");
            _form?.Close();
            _form = null;
        }

        [CommandMethod("EXTENDSTARTENDMULTIPLE", CommandFlags.Modal | CommandFlags.Redraw)]
        public void ExtendStartEndMultiple()
        {
            ShowForm();
        }

        [CommandMethod("DEX")]
        public void Dex()
        {
            ShowForm();
        }

        [CommandMethod("DEXDEBUG")]
        public void DexDebug()
        {
            var path = DexLogger.SaveToFile();
            var doc = Application.DocumentManager.MdiActiveDocument;
            doc.Editor.WriteMessage(path != null
                ? $"\nDEX debug log written to: {path}\n"
                : "\nDEX debug log could not be written.\n");
        }

        private void ShowForm()
        {
            try
            {
                if (_form == null || _form.IsDisposed)
                {
                    _form = new DexForm();
                    Application.ShowModelessDialog(_form);
                }
                else
                {
                    _form.Activate();
                }
            }
            catch (Exception ex)
            {
                DexLogger.Error(Src, $"Failed to open DexForm: {ex}");
                Application.DocumentManager.MdiActiveDocument?.Editor.WriteMessage($"\nDEX error: {ex.Message}\n");
            }
        }
    }
}


// ===== FILE: Properties\AssemblyInfo.cs =====
using System.Reflection;
using System.Runtime.InteropServices;
using Autodesk.AutoCAD.Runtime;
using DoubleExtendCS;

// ==============================================================
// Bump AssemblyVersion / AssemblyFileVersion here for every release.
// VersionInfo.cs reads AssemblyFileVersion + the DLL's own on-disk
// timestamp to build the banner printed to the command line each
// time the DLL is (re)loaded into AutoCAD / Civil 3D.
// ==============================================================
[assembly: AssemblyTitle("DoubleExtendCS")]
[assembly: AssemblyDescription("Double Extend (Lengthen Start/End) - Civil 3D managed .NET plugin")]
[assembly: AssemblyCompany("DB E.C.O. Group")]
[assembly: AssemblyProduct("DoubleExtendCS")]
[assembly: AssemblyCopyright("Copyright (c) 2026")]
[assembly: ComVisible(false)]

[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.1.0")]

// ObjectARX / AutoCAD .NET plugin entry points
[assembly: ExtensionApplication(typeof(Commands))]
[assembly: CommandClass(typeof(Commands))]


// ===== FILE: Jigs\BothEndsSlotPreviewSession.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.GraphicsInterface;
using Autodesk.AutoCAD.Geometry;
using DoubleExtendCS.Core;
using DoubleExtendCS.Logging;
using Polyline = Autodesk.AutoCAD.DatabaseServices.Polyline;

namespace DoubleExtendCS.Jigs
{
    /// <summary>
    /// Dynamic both-ends slot preview based on the PPN_preview_bulge_arc_fix
    /// pattern: Editor.GetPoint + PointMonitor + TransientManager. Instead of a
    /// DrawJig re-drawing primitives by hand, this creates real detached AutoCAD
    /// entities from the same cached/snapshot geometry used by the final commit,
    /// registers them as short-term transients, and updates the WinForms preview
    /// panel from the same tessellated PreviewGeometry frame.
    /// </summary>
    public class BothEndsSlotPreviewSession : IDisposable
    {
        private readonly Point3d _basePt;
        private readonly Vector3d _dragAxis;
        private readonly List<EntitySnapshot> _originalSnapshot;
        private readonly IntegerCollection _viewports = new IntegerCollection();
        private readonly List<Entity> _transientEntities = new List<Entity>();

        private Editor _ed;
        private Action<double, PreviewGeometry> _onUpdate;
        private bool _disposed;
        private double _slotVal;

        public double ConfirmedSlot { get; private set; }
        public PreviewGeometry LastGeometry { get; private set; } = new PreviewGeometry();

        public BothEndsSlotPreviewSession(Point3d basePt, Vector3d dragAxis, List<EntitySnapshot> originalSnapshot)
        {
            _basePt = basePt;
            _dragAxis = dragAxis.Length > 1e-8 ? dragAxis.GetNormal() : Vector3d.XAxis;
            _originalSnapshot = originalSnapshot ?? new List<EntitySnapshot>();
        }

        public PromptPointResult Run(Editor ed, Action<double, PreviewGeometry> onUpdate = null)
        {
            _ed = ed;
            _onUpdate = onUpdate;

            var opts = new PromptPointOptions("\nDrag along current UCS X for Both-Ends slot amount. Click to accept, Esc to cancel: ")
            {
                AllowNone = false,
                BasePoint = _basePt,
                UseBasePoint = true
            };

            UpdateFrame(0.0);
            _ed.PointMonitor += OnPointMonitor;
            try
            {
                var result = _ed.GetPoint(opts);
                if (result.Status == PromptStatus.OK)
                {
                    ConfirmedSlot = SlotFromPoint(result.Value);
                    UpdateFrame(ConfirmedSlot);
                }
                return result;
            }
            finally
            {
                _ed.PointMonitor -= OnPointMonitor;
                ClearTransients();
            }
        }

        private void OnPointMonitor(object sender, PointMonitorEventArgs e)
        {
            try
            {
                double next = SlotFromPoint(e.Context.ComputedPoint);
                if (Math.Abs(next - _slotVal) < 1e-6) return;
                UpdateFrame(next);
            }
            catch
            {
                // PointMonitor must never throw into AutoCAD's input pipeline.
            }
        }

        private double SlotFromPoint(Point3d point)
        {
            Vector3d delta = point - _basePt;
            return Math.Max(0.0, delta.DotProduct(_dragAxis));
        }

        private void UpdateFrame(double slot)
        {
            _slotVal = Math.Max(0.0, slot);
            LastGeometry = PreviewGeometryCollector.CollectSlotPreviewGeometry(_originalSnapshot, _slotVal, _basePt, _dragAxis);
            RebuildTransients();
            _onUpdate?.Invoke(_slotVal, LastGeometry);
        }

        private void RebuildTransients()
        {
            ClearTransients();

            var tm = TransientManager.CurrentTransientManager;
            foreach (var entity in BuildPreviewEntities(_slotVal))
            {
                try
                {
                    tm.AddTransient(entity, TransientDrawingMode.DirectShortTerm, 128, _viewports);
                    _transientEntities.Add(entity);
                }
                catch
                {
                    try { entity.Dispose(); } catch { /* best-effort cleanup */ }
                }
            }

            _ed?.UpdateScreen();
        }

        private IEnumerable<Entity> BuildPreviewEntities(double slot)
        {
            // Orange/yellow guide vector from the selection midpoint.
            var guide = new Line(_basePt, _basePt + _dragAxis * Math.Max(0.0, slot)) { ColorIndex = 30 };
            yield return guide;

            foreach (var snap in _originalSnapshot)
            {
                Entity ent = null;
                try
                {
                    ent = BuildExtendedEntity(snap, slot, slot);
                }
                catch (Exception ex)
                {
                    DexLogger.Warn("BothEndsSlotPreviewSession", $"Preview entity build skipped: {ex.Message}");
                }

                if (ent == null) continue;
                ent.ColorIndex = 4; // cyan, matches the dialog preview active result
                yield return ent;
            }
        }

        private static Entity BuildExtendedEntity(EntitySnapshot snap, double extS, double extE)
        {
            switch (snap)
            {
                case LineSnapshot ls:
                    {
                        Vector3d dir = ls.End - ls.Start;
                        double len = dir.Length;
                        if (len < 1e-8) return null;
                        Vector3d u = dir / len;
                        return new Line(ls.Start - u * extS, ls.End + u * extE);
                    }

                case ArcSnapshot arcSnap:
                    {
                        if (arcSnap.Radius < 1e-8) return null;
                        double a1 = arcSnap.StartAngle - extS / arcSnap.Radius;
                        double a2 = arcSnap.EndAngle + extE / arcSnap.Radius;
                        return new Arc(arcSnap.Center, arcSnap.Normal, arcSnap.Radius, a1, a2);
                    }

                case LwPolylineSnapshot lwSnap:
                    {
                        var verts = new List<PolyVertex>(lwSnap.Points.Count);
                        for (int i = 0; i < lwSnap.Points.Count; i++)
                            verts.Add(new PolyVertex(lwSnap.Points[i], lwSnap.Bulges[i]));
                        return BuildPolyline(DexGeometry.ExtendPolyline(verts, extS, extE));
                    }

                case Polyline2dSnapshot p2dSnap:
                    {
                        var verts = new List<PolyVertex>(p2dSnap.Positions.Count);
                        for (int i = 0; i < p2dSnap.Positions.Count; i++)
                            verts.Add(new PolyVertex(new Point2d(p2dSnap.Positions[i].X, p2dSnap.Positions[i].Y), p2dSnap.Bulges[i]));
                        return BuildPolyline(DexGeometry.ExtendPolyline(verts, extS, extE));
                    }

                default:
                    return null;
            }
        }

        private static Polyline BuildPolyline(IList<PolyVertex> verts)
        {
            if (verts == null || verts.Count < 2) return null;
            var pl = new Polyline(verts.Count);
            for (int i = 0; i < verts.Count; i++)
                pl.AddVertexAt(i, verts[i].Pt, verts[i].Bulge, 0.0, 0.0);
            return pl;
        }

        private void ClearTransients()
        {
            if (_transientEntities.Count == 0) return;

            var tm = TransientManager.CurrentTransientManager;
            foreach (var entity in _transientEntities)
            {
                try { tm.EraseTransient(entity, _viewports); } catch { /* already gone */ }
                try { entity.Dispose(); } catch { /* best-effort */ }
            }
            _transientEntities.Clear();
            _ed?.UpdateScreen();
        }

        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            ClearTransients();
        }
    }
}


// ===== FILE: Jigs\BothEndsSlotJig.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.AutoCAD.GraphicsInterface;
using DoubleExtendCS.Core;
using DoubleExtendCS.Logging;

namespace DoubleExtendCS.Jigs
{
    /// <summary>
    /// Improvement over the original LISP: instead of grread/grdraw drawing a
    /// flat screen-space slider bar (DEX:draw-slider) and re-running entmod on
    /// every mouse-move sample (DEX:dynamic-both), this Jig lets AutoCAD do
    /// what it's built for - WorldDraw is called by the graphics system itself
    /// on every regen, and Sampler only needs to report the current slot
    /// value. The actual entity geometry is only committed once, when the
    /// user confirms, which is both faster and safer (no repeated entmod/undo
    /// churn while dragging).
    /// </summary>
    public class BothEndsSlotJig : DrawJig
    {
        private readonly ObjectIdCollection _ids;
        private readonly Point3d _basePt;
        private readonly List<EntitySnapshot> _originalSnapshot;

        private double _slotVal;
        private Point3d _lastMouse;

        public double ConfirmedSlot { get; private set; }

        public BothEndsSlotJig(ObjectIdCollection ids, Point3d basePt, List<EntitySnapshot> originalSnapshot)
        {
            _ids = ids;
            _basePt = basePt;
            _originalSnapshot = originalSnapshot;
            _slotVal = 0.0;
        }

        protected override SamplerStatus Sampler(JigPrompts prompts)
        {
            var opts = new JigPromptPointOptions("\nDrag RIGHT for Both-Ends slot amount: ")
            {
                UserInputControls = UserInputControls.NullResponseAccepted
                                    | UserInputControls.NoNegativeResponseAccepted
                                    | UserInputControls.GovernedByOrthoMode
            };

            PromptPointResult res = prompts.AcquirePoint(opts);
            if (res.Status != PromptStatus.OK)
                return SamplerStatus.Cancel;

            if (res.Value.IsEqualTo(_lastMouse))
                return SamplerStatus.NoChange;

            _lastMouse = res.Value;
            _slotVal = Math.Max(0.0, res.Value.X - _basePt.X);
            return SamplerStatus.OK;
        }

        protected override bool WorldDraw(WorldDraw draw)
        {
            draw.Geometry.PushModelTransform(Matrix3d.Identity);
            try
            {
                // Preview: show the base point, drag direction, and a live
                // numeric readout so the user gets exact geometric feedback
                // rather than an abstract screen-space bar.
                draw.SubEntityTraits.Color = 4; // cyan
                draw.Geometry.WorldLine(_basePt, new Point3d(_basePt.X + _slotVal, _basePt.Y, _basePt.Z));

                draw.SubEntityTraits.Color = 2; // yellow marker at base
                draw.Geometry.Circle(_basePt, ComputeMarkerRadius(draw), Vector3d.ZAxis);

                // Live-extend a lightweight *preview only* copy of each entity's
                // end geometry so the operator sees the true result while
                // dragging - the real DB entities are left untouched until Commit().
                DrawExtendedPreview(draw);
            }
            finally
            {
                draw.Geometry.PopModelTransform();
            }
            return true;
        }

        private double ComputeMarkerRadius(WorldDraw draw) => 0.15; // simple fixed-size marker; swap for a viewport-scaled size if desired

        private void DrawExtendedPreview(WorldDraw draw)
        {
            // Recomputed entirely from the cached snapshot (no database access
            // inside WorldDraw - it can be called many times per second while
            // dragging, so keeping this in-memory-only matters for smoothness).
            // NOTE: Polyline/Polyline2d previews are intentionally left out of
            // the jig's WorldDraw for performance; the modeless form's
            // PreviewPanel (PictureBox-based) already renders every supported
            // entity type cheaply from cached points - see UI/PreviewPanel.cs.
            foreach (var snap in _originalSnapshot)
            {
                if (snap is LineSnapshot ls)
                {
                    Vector3d dir = ls.End - ls.Start;
                    double len = dir.Length;
                    if (len < 1e-8) continue;
                    Vector3d u = dir / len;
                    Point3d newStart = ls.Start - u * _slotVal;
                    Point3d newEnd = ls.End + u * _slotVal;
                    draw.SubEntityTraits.Color = 3; // green preview
                    draw.Geometry.WorldLine(newStart, newEnd);
                }
                else if (snap is ArcSnapshot arcSnap)
                {
                    if (arcSnap.Radius < 1e-8) continue;
                    double a1 = arcSnap.StartAngle - _slotVal / arcSnap.Radius;
                    double a2 = arcSnap.EndAngle + _slotVal / arcSnap.Radius;
                    Vector3d startVector = new Vector3d(Math.Cos(a1), Math.Sin(a1), 0.0);
                    draw.SubEntityTraits.Color = 3;
                    draw.Geometry.CircularArc(arcSnap.Center, arcSnap.Radius, arcSnap.Normal, startVector, a2 - a1, ArcType.ArcSimple);
                }
            }
        }

        /// <summary>Runs the jig; returns true if the user confirmed (left
        /// click / Enter), false on cancel (Esc / right-click). On confirm,
        /// ConfirmedSlot holds the final value to persist via EntityExtender.</summary>
        public bool Run(Editor ed)
        {
            PromptResult pr = ed.Drag(this);
            if (pr.Status == PromptStatus.OK)
            {
                ConfirmedSlot = _slotVal;
                DexLogger.Info("BothEndsSlotJig", $"Confirmed slot = {_slotVal:0.###}");
                return true;
            }

            DexLogger.Info("BothEndsSlotJig", "Cancelled by user.");
            return false;
        }
    }
}

Additional info:

Share this page:

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x