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.


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;
}
}
}
