Short description
🧩 ExtendStartEndMultiple extends the start and end of multiple selected LINE ARC LWPOLYLINE and POLYLINE objects using independent extension values.
🎛️ The DCL interface provides separate Start and End values together with Apply Undo OK and Cancel.
↩️ Apply can be tested repeatedly and Undo reverses the most recent Apply while Cancel restores the geometry changed during the current run.

Command:
Main command: ExtendStartEndMultiple
Description:
📐 The routine is based on Lee Mac DoubleExtend logic and supports straight and curved polyline geometry.
🖱️ Source entities are selected before the DCL loop starts.
➡️ Extend Start and Extend End are independent values so each end can be lengthened by a different amount.
🧬 Lightweight polyline vertex and bulge calculations preserve supported curved end geometry.
✅ OK performs one apply when no Apply or Undo interaction has yet occurred and otherwise accepts the current state.
↩️ Cancel reverses all Apply operations created during the current command session.
🧱 The complete command is wrapped in an outer AutoCAD UNDO group.
Helper function: (if any)
LM:dex:extendpoly calculates extended lightweight polyline endpoint geometry.
LM:dex:run applies the extension logic to the current selection.
DEX:dialog returns OK Apply Undo or Cancel state to the main loop.
Functionalities:
✅ Multiple object processing – extends a selection of supported curve entities.
✅ Independent start extension – uses a separate value for the beginning of each curve.
✅ Independent end extension – uses a separate value for the end of each curve.
✅ LINE support – extends straight lines.
✅ ARC support – extends supported arc geometry.
✅ LWPOLYLINE support – extends lightweight polyline endpoints.
✅ Legacy POLYLINE support – processes supported old polyline geometry.
✅ Apply workflow – allows iterative changes without leaving the tool.
✅ Undo last Apply – reverses the latest Apply operation.
✅ Cancel rollback – restores Apply changes from the current session.
✅ Undo grouping – groups the command modifications for AutoCAD undo.
Result:
🎯 The result is the original selected curve geometry lengthened independently at its start and end.
🔄 The command supports iterative adjustment before the user accepts the final state.
Images, animations etc.

XAML code:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:src="clr-namespace:Autodesk.Windows;assembly=AdWindows" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <src:RibbonToolTip x:Key="ExtendStartEndMultiple"> <src:RibbonToolTip.ExpandedContent> <StackPanel> <TextBlock Background="AntiqueWhite" TextAlignment="Left"> <Bold>Function Syntax: ExtendStartEndMultiple</Bold> <LineBreak /> <Bold>Version: 1.0 Date: 20.06.2026 6:38:59PM</Bold> <LineBreak /> <Hyperlink>AI+ https://www.lee-mac.com/doubleextend.html</Hyperlink> <LineBreak /> <Bold> <Run Foreground="DodgerBlue">Description</Run> </Bold> <LineBreak /> <Run Foreground="White">🧩 ExtendStartEndMultiple extends the start and end of multiple selected LINE ARC LWPOLYLINE and POLYLINE objects using independent extension values.</Run> <LineBreak /> <Run Foreground="White">🎛️ The DCL interface provides separate Start and End values together with Apply Undo OK and Cancel.</Run> <LineBreak /> <Run Foreground="White">↩️ Apply can be tested repeatedly and Undo reverses the most recent Apply while Cancel restores the geometry changed during the current run.</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 /> <LineBreak /> <Bold> <Run Foreground="DodgerBlue">SEO Optimized Overview</Run> </Bold> <LineBreak /> <Run Foreground="White">📐 The routine is based on Lee Mac DoubleExtend logic and supports straight and curved polyline geometry.</Run> <LineBreak /> <Run Foreground="White">🖱️ Source entities are selected before the DCL loop starts.</Run> <LineBreak /> <Run Foreground="White">➡️ Extend Start and Extend End are independent values so each end can be lengthened by a different amount.</Run> <LineBreak /> <Run Foreground="White">🧬 Lightweight polyline vertex and bulge calculations preserve supported curved end geometry.</Run> <LineBreak /> <Run Foreground="White">✅ OK performs one apply when no Apply or Undo interaction has yet occurred and otherwise accepts the current state.</Run> <LineBreak /> <Run Foreground="White">↩️ Cancel reverses all Apply operations created during the current command session.</Run> <LineBreak /> <Run Foreground="White">🧱 The complete command is wrapped in an outer AutoCAD UNDO group.</Run> <LineBreak /> <LineBreak /> <Bold> <Run Foreground="LimeGreen">Primary Workflow</Run> </Bold> <LineBreak /> <Run Foreground="White">• Run ExtendStartEndMultiple.</Run> <LineBreak /> <Run Foreground="White">• Select LINE ARC LWPOLYLINE or POLYLINE objects.</Run> <LineBreak /> <Run Foreground="White">• Enter independent Extend Start and Extend End values.</Run> <LineBreak /> <Run Foreground="White">• Use Apply to modify the selected geometry while keeping the dialog workflow active.</Run> <LineBreak /> <Run Foreground="White">• Use Undo to reverse the latest Apply.</Run> <LineBreak /> <Run Foreground="White">• Use OK to keep the current result or Cancel to restore the changes made during this run.</Run> <LineBreak /> <LineBreak /> <Bold> <Run Foreground="LimeGreen">Functionalities</Run> </Bold> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Multiple object processing</Run> </Bold> <Run Foreground="White"> - extends a selection of supported curve entities.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Independent start extension</Run> </Bold> <Run Foreground="White"> - uses a separate value for the beginning of each curve.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Independent end extension</Run> </Bold> <Run Foreground="White"> - uses a separate value for the end of each curve.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">LINE support</Run> </Bold> <Run Foreground="White"> - extends straight lines.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">ARC support</Run> </Bold> <Run Foreground="White"> - extends supported arc geometry.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">LWPOLYLINE support</Run> </Bold> <Run Foreground="White"> - extends lightweight polyline endpoints.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Legacy POLYLINE support</Run> </Bold> <Run Foreground="White"> - processes supported old polyline geometry.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Apply workflow</Run> </Bold> <Run Foreground="White"> - allows iterative changes without leaving the tool.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Undo last Apply</Run> </Bold> <Run Foreground="White"> - reverses the latest Apply operation.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Cancel rollback</Run> </Bold> <Run Foreground="White"> - restores Apply changes from the current session.</Run> <LineBreak /> <Run Foreground="White">✅ </Run> <Bold> <Run Foreground="LimeGreen">Undo grouping</Run> </Bold> <Run Foreground="White"> - groups the command modifications for AutoCAD undo.</Run> <LineBreak /> <LineBreak /> <Bold> <Run Foreground="DodgerBlue">Important Commands and Aspects</Run> </Bold> <LineBreak /> <Run Foreground="White">🧩 LM:dex:extendpoly calculates extended lightweight polyline endpoint geometry.</Run> <LineBreak /> <Run Foreground="White">🧩 LM:dex:run applies the extension logic to the current selection.</Run> <LineBreak /> <Run Foreground="White">🧩 DEX:dialog returns OK Apply Undo or Cancel state to the main loop.</Run> <LineBreak /> <LineBreak /> <Bold> <Run Foreground="DodgerBlue">Result</Run> </Bold> <LineBreak /> <Run Foreground="White">🎯 The result is the original selected curve geometry lengthened independently at its start and end.</Run> <LineBreak /> <Run Foreground="White">🔄 The command supports iterative adjustment before the user accepts the final state.</Run> <LineBreak /> <LineBreak /> <Bold> <Run Foreground="DarkRed">Important Notes</Run> </Bold> <LineBreak /> <Run Foreground="White">⚠️ </Run> <Bold> <Run Foreground="DarkRed">The file is based on original DoubleExtend logic by Lee Mac.</Run> </Bold> <LineBreak /> <Run Foreground="White">⚠️ </Run> <Bold> <Run Foreground="DarkRed">Cancel is designed to reverse Apply operations from the current command run.</Run> </Bold> <LineBreak /> <Run Foreground="White">⚠️ </Run> <Bold> <Run Foreground="DarkRed">The current selection filter accepts LINE ARC LWPOLYLINE and POLYLINE.</Run> </Bold> <LineBreak /> <LineBreak /> <Bold> <Run Foreground="Yellow">SEO Keywords</Run> </Bold> <LineBreak /> <Run Foreground="Yellow">🏷️ AutoCAD, 🏷️ Civil 3D, 🏷️ AutoLISP, 🏷️ ExtendStartEndMultiple, 🏷️ DoubleExtend, 🏷️ Lengthen, 🏷️ Polyline extension, 🏷️ Start extension, 🏷️ End extension, 🏷️ Apply, 🏷️ Undo, 🏷️ DCL</Run> <LineBreak /> </TextBlock> <Grid> <Image Source="Image_000.jpg" Stretch="Uniform" /> </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\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 into plain PointF chains that PreviewPanel can
/// draw with zero further AutoCAD API calls. This is the only piece that
/// touches the database; PreviewPanel itself is pure WinForms and can be
/// reused as-is in any other project.
/// </summary>
public static class PreviewGeometryCollector
{
private const string Src = "PreviewGeometryCollector";
private const int ArcSamples = 24;
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 pts = new List<PointF>();
int n = pl.NumberOfVertices;
for (int i = 0; i < n; i++)
{
// Straight segment -> just the vertex; bulged segment -> sample the arc.
Point2d p2d = pl.GetPoint2dAt(i);
pts.Add(new PointF((float)p2d.X, (float)p2d.Y));
if (i < n - 1 && Math.Abs(pl.GetBulgeAt(i)) > 1e-8)
{
// Insert a couple of intermediate points along the bulge arc
// using the segment's own parameter range (GetPointAtParameter).
double p1 = pl.GetParameterAtPoint(pl.GetPoint3dAt(i));
double p2 = pl.GetParameterAtPoint(pl.GetPoint3dAt(i + 1));
for (int s = 1; s < 6; s++)
{
double t = p1 + (p2 - p1) * s / 6.0;
Point3d wp = pl.GetPointAtParameter(t);
pts.Add(ToPF(wp));
}
}
}
return pts;
}
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);
/// <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
/// in the form without any 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>();
int samples = ArcSamples;
for (int i = 0; i <= samples; i++)
{
double t = a1 + (a2 - a1) * i / samples;
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);
var pts = new List<PointF>();
for (int i = 0; i < extended.Count; i++)
{
pts.Add(new PointF((float)extended[i].Pt.X, (float)extended[i].Pt.Y));
if (i < extended.Count - 1 && Math.Abs(extended[i].Bulge) > 1e-8)
{
// Approximate the bulge arc with a few extra points for the preview
var p1 = extended[i].Pt;
var p2 = extended[i + 1].Pt;
var cen = DexGeometry.BulgeCentre(p1, p2, extended[i].Bulge);
double r = p1.GetDistanceTo(cen);
double startAng = cen.GetVectorTo(p1).Angle;
double sweep = 4.0 * Math.Atan(extended[i].Bulge);
for (int s = 1; s < 6; s++)
{
double t = startAng + sweep * s / 6.0;
pts.Add(new PointF((float)(cen.X + r * Math.Cos(t)), (float)(cen.Y + r * Math.Sin(t))));
}
}
}
result.Add(pts);
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);
var pts = extended.ConvertAll(v => new PointF((float)v.Pt.X, (float)v.Pt.Y));
result.Add(pts);
break;
}
}
}
return result;
}
}
}
// ===== 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\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: 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\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: 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;
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 Font(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 = FlowDirection.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 = FlowDirection.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; };
_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;
}
};
_trkBothSlot.Scroll += (s, e) =>
{
if (_suspendEvents) return;
_bothSlot = _trkBothSlot.Value / 10.0;
_suspendEvents = true;
_txtBothSlot.Text = _bothSlot.ToString("0.0");
_suspendEvents = false;
};
// ---- 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> preJigSnapshot;
Point3d basePt;
using (var tr = doc.Database.TransactionManager.StartTransaction())
{
preJigSnapshot = SelectionSnapshot.Capture(tr, _sel);
basePt = SelectionUtils.Midpoint(tr, _sel);
tr.Commit();
}
var jig = new BothEndsSlotJig(_sel, basePt, preJigSnapshot);
bool confirmed;
using (doc.LockDocument())
{
confirmed = jig.Run(ed);
}
if (confirmed)
{
using (doc.LockDocument())
using (var tr = doc.Database.TransactionManager.StartTransaction())
{
_preApplySnapshot = SelectionSnapshot.Capture(tr, _sel);
EntityExtender.RunAll(tr, _sel, jig.ConfirmedSlot, jig.ConfirmedSlot);
tr.Commit();
}
doc.Editor.Regen();
_bothSlot = jig.ConfirmedSlot;
_suspendEvents = true;
_txtBothSlot.Text = _bothSlot.ToString("0.0");
_trkBothSlot.Value = (int)Math.Min(_trkBothSlot.Maximum, _bothSlot * 10);
_suspendEvents = false;
}
else
{
SelectionSnapshot.RestoreNow(preJigSnapshot);
}
SelectionUtils.Highlight(_sel);
RefreshPreview();
}
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.SetGeometry(Enumerable.Empty<List<PointF>>());
return;
}
ReadNumericFields();
// Prefer the pure in-memory extended preview (fast, no DB writes);
// fall back to sampling live DB geometry if we have no baseline yet.
List<List<PointF>> geometry;
if (_baselineSnapshot != null)
{
geometry = PreviewGeometryCollector.CollectExtendedPreview(_baselineSnapshot, _extStart, _extEnd);
}
else
{
var doc = Application.DocumentManager.MdiActiveDocument;
using (var tr = doc.Database.TransactionManager.StartTransaction())
{
geometry = PreviewGeometryCollector.Collect(tr, _sel);
tr.Commit();
}
}
_preview.SetGeometry(geometry);
}
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;
namespace DoubleExtendCS.UI
{
/// <summary>
/// Self-contained 2D preview panel. Entirely decoupled from AutoCAD - once
/// the caller has collected world-space polylines (a List of PointF chains,
/// one per entity), this control only ever does WinForms drawing. Copy this
/// class + PreviewGeometryCollector as a pair into any other project.
///
/// Transform: scale = Min(panelW / worldW, panelH / worldH), then flip Y
/// (CAD is Y-up, GDI+ is Y-down) and centre the drawing in the panel.
/// </summary>
public class PreviewPanel : Panel
{
private List<List<PointF>> _worldPolylines = new List<List<PointF>>();
private int _highlightIndex = -1;
public Color CurveColor { get; set; } = Color.DeepSkyBlue;
public Color HighlightColor { get; set; } = Color.OrangeRed;
public Color BackgroundGridColor { get; set; } = Color.FromArgb(235, 235, 235);
public PreviewPanel()
{
DoubleBuffered = true;
BackColor = Color.White;
BorderStyle = BorderStyle.FixedSingle;
}
/// <summary>Replace the geometry to preview. Each inner list is one
/// entity's sampled point chain, already in world (drawing) units.</summary>
public void SetGeometry(IEnumerable<List<PointF>> worldPolylines)
{
_worldPolylines = worldPolylines?.Select(p => p.ToList()).ToList() ?? new List<List<PointF>>();
_highlightIndex = -1;
Invalidate();
}
public void SetHighlightIndex(int index)
{
_highlightIndex = index;
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
if (_worldPolylines.Count == 0 || Width < 4 || Height < 4)
{
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);
}
return;
}
var bounds = ComputeWorldBounds();
float worldW = Math.Max(bounds.Width, 1e-6f);
float worldH = Math.Max(bounds.Height, 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);
// Centre the drawing
float drawnW = worldW * scale;
float drawnH = worldH * scale;
float offsetX = margin + (availW - drawnW) / 2f;
float offsetY = margin + (availH - drawnH) / 2f;
PointF ToPanel(PointF wp)
{
float px = offsetX + (wp.X - bounds.Left) * scale;
// Flip Y: world Y-up -> panel Y-down
float py = offsetY + (bounds.Bottom - wp.Y) * scale;
return new PointF(px, py);
}
for (int i = 0; i < _worldPolylines.Count; i++)
{
var chain = _worldPolylines[i];
if (chain.Count < 2) continue;
var panelPts = chain.Select(ToPanel).ToArray();
Color c = (i == _highlightIndex) ? HighlightColor : CurveColor;
using (var pen = new Pen(c, (i == _highlightIndex) ? 2.4f : 1.6f))
{
g.DrawLines(pen, panelPts);
}
}
}
private RectangleF ComputeWorldBounds()
{
float minX = float.MaxValue, minY = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue;
foreach (var chain in _worldPolylines)
{
foreach (var p in chain)
{
if (p.X < minX) minX = p.X;
if (p.X > maxX) maxX = p.X;
if (p.Y < minY) minY = p.Y;
if (p.Y > maxY) maxY = p.Y;
}
}
if (minX > maxX) return new RectangleF(0, 0, 1, 1);
// Small padding so geometry never touches the panel edge exactly.
float padX = Math.Max((maxX - minX) * 0.05f, 0.01f);
float padY = Math.Max((maxY - minY) * 0.05f, 0.01f);
return RectangleF.FromLTRB(minX - padX, minY - padY, maxX + padX, maxY + padY);
}
}
}
// ===== 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 | dynamic slot jig ::" +
$"\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\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
| UserInputControls.Accept3dCoordinatesAlways
};
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;
}
public 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);
}
}
}
/// <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;
}
}
}
