ParallelPipes.NET

Short description

AutoCAD / Civil 3D .NET plug-in for placing multiple parallel Profile_000 pipe bundles between selected boundary curves with live model-space preview, profile management, radius presets, offset-layer control, grouping, logging, and undo support.

Image 000 87

Command:

NETLOAD ParallelPipesNet.dll, then run VTPCS at the AutoCAD command line.

Description:

ParallelPipes.NET is a managed C# / .NET Framework 4.8 port of the original ParallelPipes.lsp workflow for AutoCAD and Civil 3D 2024. It replaces the old DCL and grread interface with a modeless WinForms tool window and a TransientManager-based live preview, while keeping the core engineering logic for parallel pipe placement, tangent fillet construction, offset generation, layer handling, and placement history.

The VTPCS command opens a tabbed interface where the user selects boundary curves, chooses a pipe profile, edits pipe count and spacing, controls fillet radii and extensions, selects output layers, previews the solved bundle, and commits the resulting pipe geometry to the DWG.

Helper function: (if any)​

Main project modules:
– Commands.cs: AutoCAD ExtensionApplication, NETLOAD banner, VTPCS command, modeless form lifecycle, boundary selection, placement, final placement, idle preview refresh, and undo callback wiring.
– Cad/MultiPipeDrawer.cs: solves the complete pipe bundle and commits the created offset entities, optional centerlines, placement history, and AutoCAD groups.
– Cad/SegmentBuilder.cs: finds bracketing intersections between the selected boundary curves and the working placement direction.
– Core/FilletEngine.cs: builds tangent-fillet route geometry using the selected radius, direction, extension, and tangent information.
– Cad/CadHelpers.cs: creates layers, builds lightweight polylines, loads linetypes, creates offsets with GetOffsetCurves, assigns layers, creates groups, and highlights placements in the viewport.
– Cad/PipeBundlePreviewSession.cs: manages live cursor preview through Editor.PointMonitor and AutoCAD TransientManager.
– Core/AppSettings.cs and Core/PipeProfile.cs: store JSON settings, default Profile_003 / Profile_003 pipe profiles, radius presets, and persistent profile/radius data under %APPDATA%\ParallelPipesNet.
– UI/MainForm.cs, ProfileManagerForm.cs, RadiusManagerForm.cs, PipeBundlePreviewControl.cs, and C3DTheme.cs: implement the dark-themed tabbed WinForms user interface, preview panel, profile manager, radius manager, placement list, and log tab.

Functionalities:

– Provides one AutoCAD command: VTPCS.
– Opens a modeless dark-themed WinForms dialog with tabs for Pipe Profile, Geometry, Fillet Radius, Extensions, Offset Layer, Options, Placed Pipes, and Log.
– Lets the user select boundary curves including LINE, ARC, CIRCLE, ELLIPSE, LWPOLYLINE, POLYLINE, SPLINE, RAY, and XLINE entities.
– Creates multiple parallel pipes by shifting the picked point laterally according to pipe count and pipe spacing.
– Finds below/above boundary intersections and builds a route between them using the chosen working direction.
– Supports configurable gap, polyline width, search length factor, current-UCS or world-Y vertical direction, closest-pair modes, and optional fixed angle.
– Supports tangent fillets at both ends with common or separate below/above radii.
– Supports extension lengths below and above the fillets, with equal or separate values.
– Includes factory default pipe profiles for Profile_003 and Profile_003 Profile_000 bundles and a profile CRUD manager.
– Includes radius presets such as 750V, 400V, 230V, OSE, RL, PRL, SE_fST, 30kV, and D_25.
– Creates pipe edge geometry from AutoCAD GetOffsetCurves, using the same solved geometry for preview and final placement.
– Can keep a centerline polyline on _HILFSLINIEN or use it only as construction geometry for offset generation.
– Creates or updates known output layers such as Profile_000, Profile_001, and _HILFSLINIEN with configured color, linetype, and lineweight.
– Supports optional AutoCAD group creation per placement and a custom Undo Last placement stack.
– Shows placed pipe sets in a table and highlights selected placement rows in the AutoCAD viewport.
– Provides a timestamped log tab and a Save Log button for troubleshooting and later tuning.
– Uses a real TransientManager preview in model space, plus a synchronized preview panel that reflects the current viewport orientation.

Result:

The result is a compiled AutoCAD / Civil 3D plug-in project that can be built into ParallelPipesNet.dll and loaded with NETLOAD. Running VTPCS gives the user a visual tool for generating parallel Profile_000 / conduit pipe bundles between selected drawing boundaries. Final placement writes real DWG entities: offset pipe edges, optional centerlines, optional groups, and placement history for fast undo.

Images, animations etc.

Image 000 87
Image 001 46
Image 002 34
Image 003 24
Image 004 10
Image 005 3
Image 006 1
Pixel

XAML code:

<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:src="clr-namespace:Autodesk.Windows;assembly=AdWindows">

<src:RibbonToolTip x:Key="VTPCS">
<src:RibbonToolTip.ExpandedContent>
<StackPanel>
<TextBlock Background="AntiqueWhite" TextAlignment="Left">
<Bold>Function Syntax: VTPCS</Bold>
<LineBreak/>
<Bold>Version: 1.0 Date: 29.06.2026 08:24:05</Bold>
<LineBreak/>
<Bold>Version: 1.1 Date: 01.07.2026 7:49:49PM</Bold>
<LineBreak/>
<Bold><Span Foreground="DarkRed">Description</Span></Bold><LineBreak/>
This AutoLISP program creates dynamic parallel pipe bundles between selected boundary curves in AutoCAD or Civil 3D.<LineBreak/>
The program requires the user to select boundary curve objects first, then configure the pipe bundle in a DCL dialog, then move the cursor in modelspace to preview the pipes before placing them.<LineBreak/>
The main purpose is to generate multiple filleted pipe routes with centerlines and left and right pipe edge offsets, using editable pipe count, centerline distance, radii, extensions, layers, and output options.<LineBreak/>
<Bold><Span Foreground="DarkRed">Main Commands</Span></Bold><LineBreak/>
 <Bold><Span Foreground="Red">PARAPIPES</Span></Bold> starts the main ParallelPipes command and opens the configuration dialog.<LineBreak/>
 <Bold><Span Foreground="Red">VTP</Span></Bold> is a backward-compatible command name that runs the same workflow as PARAPIPES.<LineBreak/>
 <Bold><Span Foreground="Red">MANAGEPIPES</Span></Bold> opens the pipe profile manager, where pipe profiles can be added, edited, deleted, reordered, and saved.<LineBreak/>
 <Bold><Span Foreground="Red">MANAGERADII</Span></Bold> opens the radius preset manager, where fillet radius presets can be added, edited, deleted, reordered, and saved.<LineBreak/>
<Bold><Span Foreground="DarkRed">Basic Workflow</Span></Bold><LineBreak/>
 The user runs <Bold><Span Foreground="Red">PARAPIPES</Span></Bold> or <Bold><Span Foreground="Red">VTP</Span></Bold>.<LineBreak/>
 The DCL dialog opens with pipe profile, geometry, radius, extension, layer, preview, and placement settings.<LineBreak/>
 The user selects boundary curves such as lines, arcs, circles, ellipses, polylines, splines, rays, or xlines.<LineBreak/>
 The user adjusts the number of pipes, centerline spacing, pipe edge offsets, fillet radius, extension lengths, and output layer options.<LineBreak/>
 The user presses <Bold><Span Foreground="DarkRed">Place</Span></Bold>, <Bold><Span Foreground="DarkRed">Place+Close</Span></Bold>, or <Bold><Span Foreground="DarkRed">OK</Span></Bold> to enter modelspace placement mode.<LineBreak/>
 While the cursor moves, the program displays a dynamic virtual preview of the complete pipe bundle.<LineBreak/>
 When the user clicks, the preview is replaced by final real AutoCAD entities.<LineBreak/>
 If repeat mode is enabled, the user can keep placing pipe bundles until pressing Enter or Escape.<LineBreak/>
<Bold><Span Foreground="DarkRed">Boundary Curve Selection</Span></Bold><LineBreak/>
 The program uses selected boundary curves as the limits for the pipe route.<LineBreak/>
 For each cursor position, it searches for intersections above and below the pick point.<LineBreak/>
 The route can be based on the closest intersection below and above the pick point, or on the two closest intersections to the pick point.<LineBreak/>
 The vertical direction normally follows the current UCS Y direction, so custom and named UCS systems are supported.<LineBreak/>
 A fixed angle option can override the UCS direction and force placement along a user-defined angle in degrees.<LineBreak/>
<Bold><Span Foreground="DarkRed">Dynamic Modelspace Preview</Span></Bold><LineBreak/>
 <Bold><Span Foreground="Red">Dynamic preview</Span></Bold> shows temporary pipe geometry while the cursor moves.<LineBreak/>
 The preview is virtual because temporary preview entities are deleted and rebuilt at the next cursor position.<LineBreak/>
 The preview includes the full pipe bundle, not only the central construction line.<LineBreak/>
 The preview uses the current pipe count, current centerline distance, current offsets, current radius settings, and current extension settings.<LineBreak/>
 Left-click places the final real geometry at the current preview position.<LineBreak/>
 Enter or Escape exits the placement loop and returns to the dialog when appropriate.<LineBreak/>
<Bold><Span Foreground="DarkRed">Pipe Profile Controls</Span></Bold><LineBreak/>
 The pipe profile dropdown selects a saved pipe profile.<LineBreak/>
 Each profile can define a pipe name, pipe count, left offset, right offset, centerline spacing, and output layer behavior.<LineBreak/>
 The user can override the profile values directly in the main dialog.<LineBreak/>
 <Bold><Span Foreground="DarkRed">Pipe count</Span></Bold> controls how many parallel pipes are generated.<LineBreak/>
 <Bold><Span Foreground="DarkRed">CL spacing</Span></Bold> controls the distance between adjacent pipe centerlines.<LineBreak/>
 <Bold><Span Foreground="DarkRed">Offset left</Span></Bold> and <Bold><Span Foreground="DarkRed">Offset right</Span></Bold> control the pipe edge offsets from each pipe centerline.<LineBreak/>
<Bold><Span Foreground="DarkRed">DCL Schematic Preview</Span></Bold><LineBreak/>
 The DCL dialog contains an integrated schematic preview window.<LineBreak/>
 The preview draws a more realistic filleted pipe shape with lower horizontal runs, rounded bends, vertical runs, upper rounded bends, and upper horizontal runs.<LineBreak/>
 The schematic preview updates immediately when the user changes pipe count, CL spacing, or pipe offsets.<LineBreak/>
 This preview is symbolic and is not a true embedded AutoCAD viewport.<LineBreak/>
 The real placement preview still happens in modelspace after the user starts placement.<LineBreak/>
<Bold><Span Foreground="DarkRed">Slider and Min/Max Controls</Span></Bold><LineBreak/>
 The dialog includes a pipe count slider for fast adjustment of the number of pipes.<LineBreak/>
 The dialog includes a CL spacing slider for fast adjustment of the centerline distance between pipes.<LineBreak/>
 The <Bold><Span Foreground="Red">Min/Max</Span></Bold> button opens one combined range dialog for both pipe count and CL spacing.<LineBreak/>
 The user can define minimum pipe count, maximum pipe count, minimum CL spacing, and maximum CL spacing.<LineBreak/>
 After changing Min/Max values, the main dialog is rebuilt so the sliders use the new ranges.<LineBreak/>
<Bold><Span Foreground="DarkRed">Fillet Radius Controls</Span></Bold><LineBreak/>
 The program can create filleted pipe routes at both ends of the vertical segment.<LineBreak/>
 The user can turn fillets on or off.<LineBreak/>
 Equal above and below radius mode can force both ends to use the same radius.<LineBreak/>
 Separate below radius and above radius values are supported when equal radius mode is disabled.<LineBreak/>
 Radius presets are available through the preset radius dropdown.<LineBreak/>
 The user can reverse the below direction or above direction when the fillet orientation must be corrected.<LineBreak/>
<Bold><Span Foreground="DarkRed">Extension Controls</Span></Bold><LineBreak/>
 The program can extend the route below the lower fillet and above the upper fillet.<LineBreak/>
 Extensions can be enabled or disabled separately for below and above.<LineBreak/>
 Equal extension mode can force both sides to use the same extension length.<LineBreak/>
 Separate below extension and above extension values are supported when equal extension mode is disabled.<LineBreak/>
<Bold><Span Foreground="DarkRed">Layer and Output Controls</Span></Bold><LineBreak/>
 The program can place pipe edge offsets on a selected output layer.<LineBreak/>
 The default offset layer is typically <Bold><Span Foreground="DarkRed">VA_Schutzrohr</Span></Bold>.<LineBreak/>
 The layer filter helps narrow the layer dropdown list.<LineBreak/>
 The user can pick an existing drawing object to copy its layer into the output layer setting.<LineBreak/>
 Pipe centerlines can be placed on <Bold><Span Foreground="DarkRed">_HILFSLINIEN</Span></Bold> when the centerline option is enabled.<LineBreak/>
 The code can automatically create required layers if they do not already exist.<LineBreak/>
<Bold><Span Foreground="DarkRed">Parallel Placement Options</Span></Bold><LineBreak/>
 The program can create an AutoCAD group for each placed pipe bundle.<LineBreak/>
 The program can also place a mirrored copy of the bundle when mirror mode is enabled.<LineBreak/>
 The program can create or suppress centerline polylines.<LineBreak/>
 The program can move original helper geometry to <Bold><Span Foreground="DarkRed">_HILFSLINIEN</Span></Bold>.<LineBreak/>
 The program can delete or keep original temporary construction geometry depending on the selected option.<LineBreak/>
<Bold><Span Foreground="DarkRed">Dialog Window Behavior</Span></Bold><LineBreak/>
 The DCL dialog can be moved by dragging its title bar.<LineBreak/>
 The dialog remembers its last screen position during the AutoCAD session using the Lee Mac style global point pattern.<LineBreak/>
 The dialog can be reopened centered with the <Bold><Span Foreground="DarkRed">Center dialog</Span></Bold> button.<LineBreak/>
 The dialog size can be changed through Compact, Normal, Wide, and Max width presets.<LineBreak/>
 Native AutoCAD DCL does not support true edge-drag window resizing, so the program rebuilds the dialog with different preset widths.<LineBreak/>
<Bold><Span Foreground="DarkRed">Placement Buttons</Span></Bold><LineBreak/>
 <Bold><Span Foreground="Red">Place</Span></Bold> starts the dynamic placement loop and returns to the dialog after the loop is cancelled or completed.<LineBreak/>
 <Bold><Span Foreground="Red">Place+Close</Span></Bold> starts placement and then exits the command after placement mode ends.<LineBreak/>
 <Bold><Span Foreground="Red">Copy last</Span></Bold> copies the last placed pipe bundle to a new picked position.<LineBreak/>
 <Bold><Span Foreground="Red">Undo last</Span></Bold> deletes the last placed pipe bundle from the internal undo stack.<LineBreak/>
 <Bold><Span Foreground="Red">OK</Span></Bold> accepts the dialog settings and continues to placement.<LineBreak/>
 <Bold><Span Foreground="Red">Cancel</Span></Bold> closes the dialog without continuing placement.<LineBreak/>
<Bold><Span Foreground="DarkRed">Entity Creation</Span></Bold><LineBreak/>
 For each pipe, the program creates an individual centerline route.<LineBreak/>
 For each pipe centerline, the program creates left and right offset edge polylines.<LineBreak/>
 A single pipe can therefore generate three main entities: one centerline and two edge lines.<LineBreak/>
 Multiple pipes generate repeated parallel route entities based on the selected pipe count and CL spacing.<LineBreak/>
 The route is computed independently for each pipe so the chamfered or filleted path follows the selected boundary geometry.<LineBreak/>
<Bold><Span Foreground="DarkRed">Configuration Persistence</Span></Bold><LineBreak/>
 The program saves many dialog settings to a configuration file so the next run can reuse them.<LineBreak/>
 Saved values include geometry options, radius values, extension values, profile selection, pipe count, spacing, offsets, layer choices, and output options.<LineBreak/>
 Dialog screen position is handled separately with a session global variable rather than registry storage.<LineBreak/>
<Bold><Span Foreground="DarkRed">Summary</Span></Bold><LineBreak/>
This LSP is a complete DCL-driven AutoCAD tool for configuring, previewing, and placing dynamic multi-pipe bundles between selected boundary curves.<LineBreak/>
Its most important features are <Bold><Span Foreground="Red">dynamic virtual modelspace preview</Span></Bold>, <Bold><Span Foreground="Red">filleted multi-pipe generation</Span></Bold>, <Bold><Span Foreground="Red">DCL schematic preview</Span></Bold>, <Bold><Span Foreground="Red">pipe count and CL spacing sliders</Span></Bold>, <Bold><Span Foreground="Red">editable Min/Max ranges</Span></Bold>, and <Bold><Span Foreground="Red">pipe profile management</Span></Bold>.<LineBreak/>
<Hyperlink>N/A</Hyperlink>
<LineBreak/>
</TextBlock>
            
<Grid>
<Image Source="Image_000.png" Stretch="Uniform"/>
</Grid>

<Grid>
<Image Source="Image_001.png" Stretch="Uniform"/>
</Grid>

<Grid>
<Image Source="Image_002.png" Stretch="Uniform"/>
</Grid>

<Grid>
<Image Source="Image_003.png" Stretch="Uniform"/>
</Grid>

<Grid>
<Image Source="Image_004.png" Stretch="Uniform"/>
</Grid>

<Grid>
<Image Source="Image_005.png" Stretch="Uniform"/>
</Grid>

<Grid>
<Image Source="Image_006.png" Stretch="Uniform"/>
</Grid>

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

C# code:

// ===== FILE: BuildStamp.generated.cs =====
// <auto-generated/> regenerated on every build - do not edit
namespace ParallelPipesNet
{
internal static class BuildStamp
{
public const string Version = "1.0.001_02.07.2026_18.36.29";
public const string ShortVersion = "1.0.001";
public const string BuiltOn = "02.07.2026_18.36.29";
}
}


// ===== FILE: UI\RadiusManagerForm.cs =====
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Forms;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.UI
{
    /// <summary>Replaces c:ManageRadii with a directly-editable DataGridView, same pattern as ProfileManagerForm.</summary>
    public class RadiusManagerForm : Form
    {
        private readonly List<RadiusPreset> _target;
        private readonly BindingList<RadiusPreset> _working;
        private readonly DataGridView _grid;

        public RadiusManagerForm(List<RadiusPreset> presets)
        {
            _target = presets;
            _working = new BindingList<RadiusPreset>();
            foreach (var p in presets) _working.Add(new RadiusPreset(p.Name, p.Radius));

            Text = "Manage Radius Presets";
            FormBorderStyle = FormBorderStyle.SizableToolWindow;
            StartPosition = FormStartPosition.CenterParent;
            Size = new System.Drawing.Size(420, 420);

            var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, ColumnCount = 1 };
            layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
            layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            Controls.Add(layout);

            _grid = new DataGridView
            {
                Dock = DockStyle.Fill,
                DataSource = _working,
                AutoGenerateColumns = true,
                AllowUserToAddRows = false,
                RowHeadersVisible = false,
            };
            layout.Controls.Add(_grid, 0, 0);

            var moveRow = new FlowLayoutPanel { AutoSize = true, Padding = new Padding(4) };
            var top = new Button { Text = "Top", AutoSize = true };
            var up = new Button { Text = "Up", AutoSize = true };
            var down = new Button { Text = "Down", AutoSize = true };
            var bottom = new Button { Text = "Bottom", AutoSize = true };
            var add = new Button { Text = "Add", AutoSize = true };
            var del = new Button { Text = "Delete", AutoSize = true };
            top.Click += (s, e) => Move(0);
            up.Click += (s, e) => Move(SelectedIndex() - 1);
            down.Click += (s, e) => Move(SelectedIndex() + 1);
            bottom.Click += (s, e) => Move(_working.Count - 1);
            add.Click += (s, e) => { _working.Add(new RadiusPreset("New", 0.5)); _grid.CurrentCell = _grid.Rows[_working.Count - 1].Cells[0]; };
            del.Click += (s, e) => { int i = SelectedIndex(); if (i >= 0) _working.RemoveAt(i); };
            moveRow.Controls.AddRange(new Control[] { top, up, down, bottom, add, del });
            layout.Controls.Add(moveRow, 0, 1);

            var okRow = new FlowLayoutPanel { AutoSize = true, Padding = new Padding(4), FlowDirection = FlowDirection.RightToLeft };
            var cancelBtn = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, AutoSize = true };
            var okBtn = new Button { Text = "OK", AutoSize = true };
            okBtn.Click += (s, e) => CommitAndClose();
            okRow.Controls.Add(cancelBtn);
            okRow.Controls.Add(okBtn);
            layout.Controls.Add(okRow, 0, 2);

            AcceptButton = okBtn;
            CancelButton = cancelBtn;
        }

        private int SelectedIndex() => _grid.CurrentRow?.Index ?? -1;

        private void Move(int newIndex)
        {
            int i = SelectedIndex();
            if (i < 0 || newIndex < 0 || newIndex >= _working.Count || i == newIndex) return;
            var item = _working[i];
            _working.RemoveAt(i);
            _working.Insert(newIndex, item);
            _grid.CurrentCell = _grid.Rows[newIndex].Cells[0];
        }

        private void CommitAndClose()
        {
            _target.Clear();
            foreach (var p in _working) _target.Add(p);
            DialogResult = DialogResult.OK;
            Close();
        }
    }
}


// ===== FILE: UI\ProfileManagerForm.cs =====
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Forms;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.UI
{
    /// <summary>
    /// Replaces c:ManagePipes (the DCL list_box + Top/Up/Down/Bottom manager) with a
    /// directly-editable DataGridView bound to a BindingList&lt;PipeProfile&gt;.
    /// OK writes the reordered/edited list back into the caller's List&lt;PipeProfile&gt;
    /// and the caller persists it via ProfileStore.SaveProfiles (JSON, not a self-rewriting .lsp file).
    /// </summary>
    public class ProfileManagerForm : Form
    {
        private readonly List<PipeProfile> _target;
        private readonly BindingList<PipeProfile> _working;
        private readonly DataGridView _grid;

        public ProfileManagerForm(List<PipeProfile> profiles)
        {
            _target = profiles;
            _working = new BindingList<PipeProfile>();
            foreach (var p in profiles) _working.Add(p.Clone());

            Text = "Manage Pipe Profiles";
            FormBorderStyle = FormBorderStyle.SizableToolWindow;
            StartPosition = FormStartPosition.CenterParent;
            Size = new System.Drawing.Size(640, 460);

            var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, ColumnCount = 1 };
            layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
            layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            Controls.Add(layout);

            _grid = new DataGridView
            {
                Dock = DockStyle.Fill,
                DataSource = _working,
                AutoGenerateColumns = true,
                AllowUserToAddRows = false,
                RowHeadersVisible = false,
            };
            layout.Controls.Add(_grid, 0, 0);

            var moveRow = new FlowLayoutPanel { AutoSize = true, Padding = new Padding(4) };
            var top = new Button { Text = "Top", AutoSize = true };
            var up = new Button { Text = "Up", AutoSize = true };
            var down = new Button { Text = "Down", AutoSize = true };
            var bottom = new Button { Text = "Bottom", AutoSize = true };
            var add = new Button { Text = "Add", AutoSize = true };
            var del = new Button { Text = "Delete", AutoSize = true };
            top.Click += (s, e) => Move(0);
            up.Click += (s, e) => Move(SelectedIndex() - 1);
            down.Click += (s, e) => Move(SelectedIndex() + 1);
            bottom.Click += (s, e) => Move(_working.Count - 1);
            add.Click += (s, e) => { _working.Add(new PipeProfile()); _grid.CurrentCell = _grid.Rows[_working.Count - 1].Cells[0]; };
            del.Click += (s, e) => { int i = SelectedIndex(); if (i >= 0) _working.RemoveAt(i); };
            moveRow.Controls.AddRange(new Control[] { top, up, down, bottom, add, del });
            layout.Controls.Add(moveRow, 0, 1);

            var okRow = new FlowLayoutPanel { AutoSize = true, Padding = new Padding(4), FlowDirection = FlowDirection.RightToLeft };
            var cancelBtn = new Button { Text = "Cancel", DialogResult = DialogResult.Cancel, AutoSize = true };
            var okBtn = new Button { Text = "OK", AutoSize = true };
            okBtn.Click += (s, e) => { CommitAndClose(); };
            okRow.Controls.Add(cancelBtn);
            okRow.Controls.Add(okBtn);
            layout.Controls.Add(okRow, 0, 2);

            AcceptButton = okBtn;
            CancelButton = cancelBtn;
        }

        private int SelectedIndex() => _grid.CurrentRow?.Index ?? -1;

        private void Move(int newIndex)
        {
            int i = SelectedIndex();
            if (i < 0 || newIndex < 0 || newIndex >= _working.Count || i == newIndex) return;
            var item = _working[i];
            _working.RemoveAt(i);
            _working.Insert(newIndex, item);
            _grid.CurrentCell = _grid.Rows[newIndex].Cells[0];
        }

        private void CommitAndClose()
        {
            _target.Clear();
            foreach (var p in _working) _target.Add(p);
            DialogResult = DialogResult.OK;
            Close();
        }
    }
}


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

namespace ParallelPipesNet.UI
{
    /// <summary>
    /// Preview of the pipe bundle shown inside the dialog. Draws REAL solved
    /// geometry (Core.PreviewGeometry, captured by Cad.PipeBundlePreviewSession on
    /// every model-space cursor move: actual boundary-driven orientation, actual
    /// fillet curvature, actual relative proportions) fit-scaled into the panel -
    /// not a hardcoded mockup shape. Falls back to a generic schematic (the
    /// original DCL-image-tile-style "pipe count / spacing / offsets" sketch,
    /// ported to GDI+) only when there's no live geometry yet, e.g. before a
    /// Place drag has started.
    /// </summary>
    public class PipeBundlePreviewControl : Panel
    {
        // Schematic-mode inputs (used only as a fallback, see class remarks).
        public int PipeCount { get; set; } = 1;
        public double PipeDistance { get; set; } = 0.25;
        public double OffsetLeft { get; set; } = 0.055;
        public double OffsetRight { get; set; } = 0.055;
        public double FilletRadius { get; set; } = 0.75;

        // Live-mode input: the real tessellated geometry from the last solve.
        private PreviewGeometry _liveGeometry;
        public PreviewGeometry LiveGeometry
        {
            get => _liveGeometry;
            set
            {
                _liveGeometry = value;
                // Refresh() = Invalidate() + an immediate synchronous WM_PAINT dispatch.
                // Invalidate() alone only queues a repaint, and during AutoCAD's own
                // nested PointMonitor message loop a flood of mouse-move messages can
                // starve that queued paint, leaving the panel showing a stale frame
                // that visibly lags behind the current model-space state.
                Refresh();
            }
        }

        // Matches the ACI 4 (cyan) / ACI 1 (red) colors used for the actual
        // model-space transient preview in Cad.PipeBundlePreviewSession, so the
        // panel and the viewport agree visually.
        private static readonly Color LiveCenterlineColor = Color.FromArgb(0, 255, 255);
        private static readonly Color LiveEdgeColor = Color.FromArgb(255, 60, 60);

        // Mouse-wheel zoom: 1.0 = fit-to-panel (the default). Scroll up to zoom in,
        // down to zoom out; right-click resets back to fit-to-panel.
        private float _zoom = 1f;
        private const float MinZoom = 0.2f;
        private const float MaxZoom = 12f;

        public PipeBundlePreviewControl()
        {
            DoubleBuffered = true;
            BackColor = Color.Black;
            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>
        /// Overridden (not just subscribed via the MouseWheel event) so we can
        /// deliberately NOT call base.OnMouseWheel(e). Control's default handling
        /// otherwise still bubbles the wheel message up through the parent chain -
        /// previewPanel -> TabPage -> the dialog's TabControl - which has its own
        /// MouseWheel handler for switching tabs (see MainForm.StepTab). Without
        /// this override, scrolling to zoom the preview also switched tabs on the
        /// same scroll, since nothing stopped the message from propagating further.
        /// </summary>
        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();
            // Deliberately no base.OnMouseWheel(e) call - see remarks above.
        }

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

            int w = ClientSize.Width;
            int h = ClientSize.Height;
            if (w < 20 || h < 20) return;

            if (_liveGeometry != null && !_liveGeometry.IsEmpty)
                DrawLiveGeometry(g, w, h);
            else
                DrawSchematic(g, w, h);
        }

        /// <summary>
        /// Fit-scales the real solved geometry projected to the current AutoCAD
        /// viewport plane into the panel with margin, preserving true proportions
        /// and screen orientation, and draws it with Graphics.DrawLines - exactly
        /// the "2D preview panel" pattern: scale = Min(panelW/worldW, panelH/worldH).
        /// </summary>
        private void DrawLiveGeometry(Graphics g, int w, int h)
        {
            const int margin = 12;

            var all = _liveGeometry.Centerlines.Concat(_liveGeometry.Edges).Concat(_liveGeometry.Boundaries)
                .SelectMany(poly => poly)
                .ToList();
            if (all.Count == 0) { DrawSchematic(g, w, h); return; }

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

            double avail = w - 2.0 * margin;
            double availH = h - 2.0 * margin;
            double scale = Math.Min(avail / worldW, availH / worldH) * _zoom;

            double drawW = worldW * scale, drawH = worldH * scale;
            double padX = margin + (avail - drawW) / 2.0;
            double padY = margin + (availH - drawH) / 2.0;

            PointF ToPanel(Vec3 v) => new PointF(
                (float)(padX + (v.X - minX) * scale),
                (float)(h - padY - (v.Y - minY) * scale)); // flip Y: world-up = panel-up

            using (var boundaryPen = new Pen(Color.FromArgb(200, 200, 200), 1f) { DashStyle = DashStyle.Dash })
            {
                foreach (var poly in _liveGeometry.Boundaries)
                    DrawPolyPoints(g, boundaryPen, poly, ToPanel);
            }
            using (var edgePen = new Pen(LiveEdgeColor, 1.4f))
            {
                foreach (var poly in _liveGeometry.Edges)
                    DrawPolyPoints(g, edgePen, poly, ToPanel);
            }
            using (var clPen = new Pen(LiveCenterlineColor, 1.6f))
            {
                foreach (var poly in _liveGeometry.Centerlines)
                    DrawPolyPoints(g, clPen, poly, ToPanel);
            }

            using (var hintBrush = new SolidBrush(Color.FromArgb(120, 200, 200, 200)))
            using (var hintFont = new Font("Segoe UI", 7f))
            {
                var text = _zoom > 1.01f || _zoom < 0.99f
                    ? $"scroll: zoom ({_zoom:0.0}x)  |  right-click: reset"
                    : "scroll to zoom";
                var size = g.MeasureString(text, hintFont);
                g.DrawString(text, hintFont, hintBrush, w - size.Width - 6, h - size.Height - 4);
            }
        }

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

        // ─────────────────────────────────────────────────────────────────
        // Fallback: generic schematic (no live solve available yet), ported
        // from the DCL image-tile drawing code (VTP:DrawPipeDclPreview /
        // VTP:DclPipePath / VTP:DclArc).
        // ─────────────────────────────────────────────────────────────────
        private void DrawSchematic(Graphics g, int w, int h)
        {
            int margin = 10;
            int yt = 10;
            int yb = h - 20;
            if (yb - yt < 34) { yt = 6; yb = h - 14; }

            int maxR = Math.Max(6, (int)Math.Min((yb - yt) / 2.4, w / 9.0));
            int r = Math.Max(6, Math.Min(maxR, (int)(6 + 8 * FilletRadius)));

            double cx = w / 2.0;
            int n = Math.Max(1, PipeCount);
            double maxOff = 0.5 * Math.Max(0, n - 1) * PipeDistance + Math.Max(OffsetLeft, OffsetRight);
            double usable = Math.Max(4.0, w / 2.0 - margin - r - 8.0);
            double scale = maxOff > 1e-9 ? usable / maxOff : 1.0;

            using (var refPen = new Pen(Color.FromArgb(60, 60, 60), 1) { DashStyle = DashStyle.Dash })
            {
                float refY = (yt + yb) / 2f;
                g.DrawLine(refPen, margin, refY, w - margin, refY);
            }

            using (var edgePen = new Pen(Color.FromArgb(0, 200, 200), 1.4f))
            using (var clPen = new Pen(Color.FromArgb(255, 210, 60), 1.6f))
            {
                for (int k = 1; k <= n; k++)
                {
                    double dx = (k - (n + 1) / 2.0) * PipeDistance;
                    double x = cx + dx * scale;
                    double xL = x - OffsetLeft * scale;
                    double xR = x + OffsetRight * scale;
                    DrawPipePath(g, edgePen, (float)xL, margin, w - margin, yt, yb, r);
                    DrawPipePath(g, edgePen, (float)xR, margin, w - margin, yt, yb, r);
                }
                for (int k = 1; k <= n; k++)
                {
                    double dx = (k - (n + 1) / 2.0) * PipeDistance;
                    double x = cx + dx * scale;
                    DrawPipePath(g, clPen, (float)x, margin, w - margin, yt, yb, r);
                }
            }

            using (var rulerPen = new Pen(Color.FromArgb(90, 90, 90), 1))
            {
                float refY = h - 8;
                g.DrawLine(rulerPen, margin, refY, w - margin, refY);
                g.DrawLine(rulerPen, margin, refY - 4, margin, refY + 4);
                g.DrawLine(rulerPen, w - margin, refY - 4, w - margin, refY + 4);
                g.DrawLine(rulerPen, (float)cx, refY - 2, (float)cx, refY + 2);
            }
        }

        /// <summary>Ports VTP:DclPipePath - bottom run, lower fillet, vertical run, upper fillet, top run.</summary>
        private static void DrawPipePath(Graphics g, Pen pen, float x, int x0, int x1, int yt, int yb, int r)
        {
            g.DrawLine(pen, x0, yb, x - r, yb);
            DrawArc(g, pen, x - r, yb - r, r, 90, 90);   // lower-left quarter, sweeping up into the vertical
            g.DrawLine(pen, x, yb - r, x, yt + r);
            DrawArc(g, pen, x + r, yt + r, r, 180, 90);  // upper fillet
            g.DrawLine(pen, x + r, yt, x1, yt);
        }

        private static void DrawArc(Graphics g, Pen pen, float cx, float cy, int r, float startDeg, float sweepDeg)
        {
            if (r <= 0) return;
            var rect = new RectangleF(cx - r, cy - r, 2 * r, 2 * r);
            g.DrawArc(pen, rect, startDeg, sweepDeg);
        }
    }
}


// ===== FILE: UI\MainForm.cs =====
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using ParallelPipesNet.Cad;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.UI
{
    public class PlaceRequestArgs : EventArgs
    {
        public PipeProfile Profile;

        /// <summary>
        /// false = start an interactive Place prompt; true = commit the currently
        /// previewed point (the idle/model-space transient preview) directly.
        /// </summary>
        public bool UseCurrentPreviewPoint;

        /// <summary>Close the modeless dialog after a successful commit.</summary>
        public bool CloseDialogAfterPlace;
    }

    /// <summary>
    /// Main tool window. Replaces VTP:WriteDcl / VTP:RunDialog. Behavior notes
    /// requested for this conversion:
    ///  - FormBorderStyle.SizableToolWindow: small title bar, no taskbar entry,
    ///    stays above the AutoCAD main window because it is shown via
    ///    Application.ShowModelessDialog (owned by the AutoCAD main window).
    ///  - ProcessCmdKey swallows Escape so it can't accidentally close the form.
    ///  - Can stay open across Model Space / Paper Space switches (no tab-change
    ///    handling needed - modeless AutoCAD dialogs already behave this way).
    /// </summary>
    public class MainForm : Form
    {
        public event EventHandler SelectBoundaryRequested;
        public event EventHandler<PlaceRequestArgs> PlaceRequested;
        public event EventHandler UndoRequested;

        /// <summary>
        /// Raised whenever a pipe/geometry setting changes (see RefreshPreview) so
        /// Commands.cs can recompute a real (non-drag) solve and push it into the
        /// preview panel via UpdateLivePreview - otherwise the panel stays frozen
        /// on whatever was last shown during a drag, ignoring later settings edits.
        /// </summary>
        public event EventHandler LivePreviewRefreshRequested;

        private readonly AppSettings _settings;
        private readonly List<PipeProfile> _profiles;
        private readonly List<RadiusPreset> _radiusPresets;
        private readonly MultiPipeDrawer _drawer;

        private Label _boundaryLabel;
        private Label _spanLabel;
        private Label _versionLabel;
        private TabControl _tabs;
        private TableLayoutPanel _mainContentLayout;
        private Control _previewHost;
        private ComboBox _profileCombo;
        private PipeBundlePreviewControl _preview;
        private NumericUpDown _pipeCountEdit;
        private TrackBar _pipeCountSlider;
        private NumericUpDown _pipeDistEdit;
        private TrackBar _pipeDistSlider;
        private NumericUpDown _offLEdit, _offREdit;

        private CheckBox _useFillet, _equalRadius, _reverseBelow, _reverseAbove;
        private ComboBox _radiusPresetCombo;
        private NumericUpDown _radCommon, _radBelow, _radAbove;

        private CheckBox _useExtBelow, _useExtAbove, _equalExt;
        private NumericUpDown _extCommon, _extBelow, _extAbove;

        private NumericUpDown _gapEdit, _widthEdit, _searchLenEdit;
        private ComboBox _dirCombo, _modeCombo;
        private CheckBox _useAngle;
        private NumericUpDown _fixAngleEdit;

        private CheckBox _useOffLayer;
        private TextBox _offLayerFilter;
        private ComboBox _offLayerCombo;

        private CheckBox _createCenterline, _origHilf, _delOrig, _useGroups, _useMirror, _repeat, _dynamicPreview;

        private DataGridView _placementsGrid;
        private ListBox _logList;

        public MainForm(AppSettings settings, List<PipeProfile> profiles, List<RadiusPreset> radiusPresets, MultiPipeDrawer drawer)
        {
            _settings = settings;
            _profiles = profiles;
            _radiusPresets = radiusPresets;
            _drawer = drawer;

            BuildUi();
            LoadFromSettings();
            C3DTheme.Apply(this, C3DPalette.Dark);
            AppLog.EntryAdded += OnLogEntryAdded;
            FormClosed += (s, e) => AppLog.EntryAdded -= OnLogEntryAdded;
        }

        // ─────────────────────────────────────────────────────────────────
        // Form chrome / behaviour
        // ─────────────────────────────────────────────────────────────────
        protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
        {
            const int WM_KEYDOWN = 0x0100;
            if (msg.Msg == WM_KEYDOWN && keyData == Keys.Escape)
                return true; // swallow Escape - do not let it close/cancel the form
            return base.ProcessCmdKey(ref msg, keyData);
        }

        // ─────────────────────────────────────────────────────────────────
        // UI construction
        // ─────────────────────────────────────────────────────────────────
        private void BuildUi()
        {
            Text = "ParallelPipes";
            FormBorderStyle = FormBorderStyle.SizableToolWindow;
            ShowInTaskbar = false;
            StartPosition = FormStartPosition.Manual;
            Location = new Point(80, 80);
            MinimumSize = new Size(760, 640);
            Size = new Size(860, 780);

            var root = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 5 };
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            Controls.Add(root);

            _versionLabel = new Label
            {
                Text = $"v{BuildStamp.Version}",
                Dock = DockStyle.Fill,
                TextAlign = ContentAlignment.MiddleLeft,
                Padding = new Padding(10, 0, 0, 8),
                Tag = C3DTheme.RoleHeaderPath,
            };
            var headerTitle = new Label
            {
                Text = "ParallelPipes",
                Dock = DockStyle.Fill,
                TextAlign = ContentAlignment.MiddleLeft,
                Padding = new Padding(10, 8, 0, 1),
                Tag = C3DTheme.RoleHeaderTitle,
            };
            var header = new TableLayoutPanel { Dock = DockStyle.Top, ColumnCount = 1, RowCount = 2, Height = 48, Tag = C3DTheme.RoleHeader };
            header.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            header.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            header.Controls.Add(headerTitle, 0, 0);
            header.Controls.Add(_versionLabel, 0, 1);
            root.Controls.Add(header, 0, 0);

            root.Controls.Add(BuildBoundaryPanel(), 0, 1);

            _tabs = new TabControl { Dock = DockStyle.Fill };
            _tabs.TabPages.Add(BuildProfileTab());
            _tabs.TabPages.Add(BuildGeometryTab());
            _tabs.TabPages.Add(BuildFilletTab());
            _tabs.TabPages.Add(BuildExtensionsTab());
            _tabs.TabPages.Add(BuildLayerTab());
            _tabs.TabPages.Add(BuildOptionsTab());
            _tabs.TabPages.Add(BuildPlacementsTab());
            _tabs.TabPages.Add(BuildLogTab());

            // Keep wheel-based tab switching away from the preview and tab content:
            // it now only works while the pointer is directly over the tab header
            // rectangles. This prevents mouse-wheel preview zoom from switching tabs.
            _tabs.MouseWheel += (s, e) =>
            {
                Point p = _tabs.PointToClient(MousePosition);
                for (int i = 0; i < _tabs.TabCount; i++)
                {
                    if (_tabs.GetTabRect(i).Contains(p))
                    {
                        StepTab(e.Delta > 0 ? -1 : 1);
                        break;
                    }
                }
            };
            _tabs.SelectedIndexChanged += (s, e) => UpdatePreviewHostVisibility();

            root.Controls.Add(BuildMainContent(), 0, 2);

            root.Controls.Add(BuildButtonRow(), 0, 3);
        }

        private void StepTab(int direction)
        {
            int count = _tabs.TabPages.Count;
            if (count == 0) return;
            _tabs.SelectedIndex = ((_tabs.SelectedIndex + direction) % count + count) % count;
        }

        private Control BuildMainContent()
        {
            _mainContentLayout = new TableLayoutPanel
            {
                Dock = DockStyle.Fill,
                ColumnCount = 2,
                RowCount = 1,
                Padding = new Padding(0),
            };
            _mainContentLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 58));
            _mainContentLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 42));
            _mainContentLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));

            _preview = new PipeBundlePreviewControl { Dock = DockStyle.Fill, MinimumSize = new Size(220, 180) };
            var previewBox = new GroupBox
            {
                Text = "Preview",
                Dock = DockStyle.Fill,
                Padding = new Padding(6),
                MinimumSize = new Size(240, 220),
            };
            previewBox.Controls.Add(_preview);
            _previewHost = previewBox;

            _mainContentLayout.Controls.Add(_tabs, 0, 0);
            _mainContentLayout.Controls.Add(_previewHost, 1, 0);
            return _mainContentLayout;
        }

        private void UpdatePreviewHostVisibility()
        {
            if (_mainContentLayout == null || _previewHost == null || _tabs == null) return;

            bool showPreview = _tabs.SelectedTab == null || !_tabs.SelectedTab.Text.Equals("Log", StringComparison.OrdinalIgnoreCase);
            _previewHost.Visible = showPreview;

            if (showPreview)
            {
                _mainContentLayout.ColumnStyles[0].SizeType = SizeType.Percent;
                _mainContentLayout.ColumnStyles[0].Width = 58;
                _mainContentLayout.ColumnStyles[1].SizeType = SizeType.Percent;
                _mainContentLayout.ColumnStyles[1].Width = 42;
            }
            else
            {
                _mainContentLayout.ColumnStyles[0].SizeType = SizeType.Percent;
                _mainContentLayout.ColumnStyles[0].Width = 100;
                _mainContentLayout.ColumnStyles[1].SizeType = SizeType.Absolute;
                _mainContentLayout.ColumnStyles[1].Width = 0;
            }
        }

        private Control BuildBoundaryPanel()
        {
            var group = new GroupBox { Text = "Boundary curves", Dock = DockStyle.Top, Height = 56 };
            var pick = new Button { Text = "Select boundary curves...", AutoSize = true, Location = new Point(10, 20) };
            pick.Click += (s, e) => SelectBoundaryRequested?.Invoke(this, EventArgs.Empty);
            _boundaryLabel = new Label { Text = "(none selected)", AutoSize = true, Location = new Point(200, 25) };
            group.Controls.Add(pick);
            group.Controls.Add(_boundaryLabel);
            return group;
        }

        private TabPage BuildProfileTab()
        {
            var page = new TabPage("Pipe Profile");
            var left = new FlowLayoutPanel
            {
                FlowDirection = FlowDirection.TopDown,
                Dock = DockStyle.Fill,
                AutoScroll = true,
                Padding = new Padding(8),
                WrapContents = false,
            };

            var profileRow = new FlowLayoutPanel { AutoSize = true };
            profileRow.Controls.Add(new Label { Text = "Profile:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) });
            _profileCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
            _profileCombo.Items.AddRange(_profiles.Cast<object>().ToArray());
            _profileCombo.SelectedIndexChanged += (s, e) => ApplyProfileToTiles();
            profileRow.Controls.Add(_profileCombo);
            var mgBtn = new Button { Text = "Manage Pipes...", AutoSize = true };
            mgBtn.Click += (s, e) => OpenProfileManager();
            profileRow.Controls.Add(mgBtn);
            left.Controls.Add(profileRow);

            left.Controls.Add(LabeledNumeric("Number of pipes:", out _pipeCountEdit, 1, 30, 1, 0));
            _pipeCountSlider = new TrackBar { Minimum = 1, Maximum = 30, Value = 1, Width = 260, TickFrequency = 1 };
            left.Controls.Add(_pipeCountSlider);

            left.Controls.Add(LabeledNumeric("CL spacing (m):", out _pipeDistEdit, 0.01m, 10m, 0.25m, 2));
            _pipeDistSlider = new TrackBar { Minimum = 1, Maximum = 1000, Value = 25, Width = 260, TickFrequency = 10 };
            left.Controls.Add(_pipeDistSlider);

            left.Controls.Add(LabeledNumeric("Offset left (m):", out _offLEdit, 0m, 5m, 0.055m, 4));
            left.Controls.Add(LabeledNumeric("Offset right (m):", out _offREdit, 0m, 5m, 0.055m, 4));

            _spanLabel = new Label { Text = "Span: (move cursor to preview)", AutoSize = true, Padding = new Padding(0, 8, 0, 0) };
            left.Controls.Add(_spanLabel);

            WireBundleSync();

            page.Controls.Add(left);
            return page;
        }

        private void WireBundleSync()
        {
            _pipeCountEdit.ValueChanged += (s, e) => { _pipeCountSlider.Value = MathUtil.ClampInt((int)_pipeCountEdit.Value, _pipeCountSlider.Minimum, _pipeCountSlider.Maximum); RefreshPreview(); };
            _pipeCountSlider.ValueChanged += (s, e) => { _pipeCountEdit.Value = _pipeCountSlider.Value; };
            _pipeDistEdit.ValueChanged += (s, e) => { _pipeDistSlider.Value = MathUtil.ClampInt((int)(_pipeDistEdit.Value * 100), _pipeDistSlider.Minimum, _pipeDistSlider.Maximum); RefreshPreview(); };
            _pipeDistSlider.ValueChanged += (s, e) => { _pipeDistEdit.Value = _pipeDistSlider.Value / 100m; };
            _offLEdit.ValueChanged += (s, e) => RefreshPreview();
            _offREdit.ValueChanged += (s, e) => RefreshPreview();
        }

        private TabPage BuildGeometryTab()
        {
            var page = new TabPage("Geometry");
            var flow = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(8) };

            flow.Controls.Add(LabeledNumeric("Gap from intersections (m):", out _gapEdit, 0m, 100m, 0m, 4));
            flow.Controls.Add(LabeledNumeric("Polyline width (m):", out _widthEdit, 0m, 5m, 0m, 4));
            flow.Controls.Add(LabeledNumeric("Search length factor:", out _searchLenEdit, 0.1m, 100m, 2.5m, 2));
            _gapEdit.ValueChanged += (s, e) => RefreshPreview();
            _widthEdit.ValueChanged += (s, e) => RefreshPreview();
            _searchLenEdit.ValueChanged += (s, e) => RefreshPreview();

            var dirRow = new FlowLayoutPanel { AutoSize = true };
            dirRow.Controls.Add(new Label { Text = "Vertical direction:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) });
            _dirCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 260 };
            _dirCombo.Items.AddRange(new object[] { "Current UCS Y - honors custom/named UCS", "World/WCS Y" });
            _dirCombo.SelectedIndexChanged += (s, e) => RefreshPreview();
            dirRow.Controls.Add(_dirCombo);
            flow.Controls.Add(dirRow);

            var modeRow = new FlowLayoutPanel { AutoSize = true };
            modeRow.Controls.Add(new Label { Text = "Intersection pair:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) });
            _modeCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 260 };
            _modeCombo.Items.AddRange(new object[] { "Closest intersection below and above pick point", "Two closest intersections to pick point" });
            _modeCombo.SelectedIndexChanged += (s, e) => RefreshPreview();
            modeRow.Controls.Add(_modeCombo);
            flow.Controls.Add(modeRow);

            var angleRow = new FlowLayoutPanel { AutoSize = true };
            _useAngle = new CheckBox { Text = "Fix angle:", AutoSize = true };
            _useAngle.CheckedChanged += (s, e) => RefreshPreview();
            angleRow.Controls.Add(_useAngle);
            _fixAngleEdit = new NumericUpDown { Minimum = -360, Maximum = 360, Value = 90, DecimalPlaces = 1, Width = 70 };
            _fixAngleEdit.ValueChanged += (s, e) => RefreshPreview();
            angleRow.Controls.Add(_fixAngleEdit);
            angleRow.Controls.Add(new Label { Text = "degrees", AutoSize = true, Padding = new Padding(4, 6, 0, 0) });
            flow.Controls.Add(angleRow);

            page.Controls.Add(flow);
            return page;
        }

        private TabPage BuildFilletTab()
        {
            var page = new TabPage("Fillet Radius");
            var flow = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(8) };

            _useFillet = new CheckBox { Text = "Create fillets at both ends", AutoSize = true, Checked = true };
            _useFillet.CheckedChanged += (s, e) => RefreshPreview();
            flow.Controls.Add(_useFillet);
            _equalRadius = new CheckBox { Text = "Equal above/below radii", AutoSize = true, Checked = true };
            flow.Controls.Add(_equalRadius);

            var presetRow = new FlowLayoutPanel { AutoSize = true };
            presetRow.Controls.Add(new Label { Text = "Preset radius:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) });
            _radiusPresetCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 };
            _radiusPresetCombo.Items.AddRange(_radiusPresets.Cast<object>().ToArray());
            _radiusPresetCombo.SelectedIndexChanged += (s, e) => ApplyRadiusPreset();
            presetRow.Controls.Add(_radiusPresetCombo);
            var mgBtn = new Button { Text = "Manage Radii...", AutoSize = true };
            mgBtn.Click += (s, e) => OpenRadiusManager();
            presetRow.Controls.Add(mgBtn);
            flow.Controls.Add(presetRow);

            flow.Controls.Add(LabeledNumeric("Equal radius (m):", out _radCommon, 0m, 50m, 0.75m, 3));
            flow.Controls.Add(LabeledNumeric("Below radius (m):", out _radBelow, 0m, 50m, 0.75m, 3));
            flow.Controls.Add(LabeledNumeric("Above radius (m):", out _radAbove, 0m, 50m, 0.75m, 3));
            _radBelow.ValueChanged += (s, e) => RefreshPreview();
            _radAbove.ValueChanged += (s, e) => RefreshPreview();

            _reverseBelow = new CheckBox { Text = "Reverse below direction", AutoSize = true };
            _reverseBelow.CheckedChanged += (s, e) => RefreshPreview();
            flow.Controls.Add(_reverseBelow);
            _reverseAbove = new CheckBox { Text = "Reverse above direction", AutoSize = true };
            _reverseAbove.CheckedChanged += (s, e) => RefreshPreview();
            flow.Controls.Add(_reverseAbove);

            _equalRadius.CheckedChanged += (s, e) =>
            {
                _radBelow.Enabled = !_equalRadius.Checked;
                _radAbove.Enabled = !_equalRadius.Checked;
                if (_equalRadius.Checked) { _radBelow.Value = _radCommon.Value; _radAbove.Value = _radCommon.Value; }
                RefreshPreview();
            };
            _radCommon.ValueChanged += (s, e) => { if (_equalRadius.Checked) { _radBelow.Value = _radCommon.Value; _radAbove.Value = _radCommon.Value; } RefreshPreview(); };

            page.Controls.Add(flow);
            return page;
        }

        private TabPage BuildExtensionsTab()
        {
            var page = new TabPage("Extensions");
            var flow = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(8) };

            _useExtBelow = new CheckBox { Text = "Extend below fillet", AutoSize = true, Checked = true };
            _useExtBelow.CheckedChanged += (s, e) => RefreshPreview();
            flow.Controls.Add(_useExtBelow);
            _useExtAbove = new CheckBox { Text = "Extend above fillet", AutoSize = true, Checked = true };
            _useExtAbove.CheckedChanged += (s, e) => RefreshPreview();
            flow.Controls.Add(_useExtAbove);
            _equalExt = new CheckBox { Text = "Equal above/below extensions", AutoSize = true, Checked = true };
            flow.Controls.Add(_equalExt);

            flow.Controls.Add(LabeledNumeric("Equal extension (m):", out _extCommon, 0m, 50m, 0.5m, 3));
            flow.Controls.Add(LabeledNumeric("Below extension (m):", out _extBelow, 0m, 50m, 0.5m, 3));
            flow.Controls.Add(LabeledNumeric("Above extension (m):", out _extAbove, 0m, 50m, 0.5m, 3));
            _extBelow.ValueChanged += (s, e) => RefreshPreview();
            _extAbove.ValueChanged += (s, e) => RefreshPreview();

            _equalExt.CheckedChanged += (s, e) =>
            {
                _extBelow.Enabled = !_equalExt.Checked;
                _extAbove.Enabled = !_equalExt.Checked;
                if (_equalExt.Checked) { _extBelow.Value = _extCommon.Value; _extAbove.Value = _extCommon.Value; }
                RefreshPreview();
            };
            _extCommon.ValueChanged += (s, e) => { if (_equalExt.Checked) { _extBelow.Value = _extCommon.Value; _extAbove.Value = _extCommon.Value; } RefreshPreview(); };

            page.Controls.Add(flow);
            return page;
        }

        private TabPage BuildLayerTab()
        {
            var page = new TabPage("Offset Layer");
            var flow = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(8) };

            _useOffLayer = new CheckBox { Text = "Place offsets on layer", AutoSize = true, Checked = true };
            flow.Controls.Add(_useOffLayer);

            var filterRow = new FlowLayoutPanel { AutoSize = true };
            filterRow.Controls.Add(new Label { Text = "Filter:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) });
            _offLayerFilter = new TextBox { Width = 160, Text = "VA_Schutzrohr" };
            filterRow.Controls.Add(_offLayerFilter);
            var pickBtn = new Button { Text = "Pick from drawing", AutoSize = true };
            pickBtn.Click += (s, e) => AppLog.Info("UI", "Pick-layer-from-drawing: hook this to an entsel prompt in Commands.cs if desired.");
            filterRow.Controls.Add(pickBtn);
            flow.Controls.Add(filterRow);

            var layerRow = new FlowLayoutPanel { AutoSize = true };
            layerRow.Controls.Add(new Label { Text = "Layer:", AutoSize = true, Padding = new Padding(0, 6, 4, 0) });
            _offLayerCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
            layerRow.Controls.Add(_offLayerCombo);
            flow.Controls.Add(layerRow);

            page.Controls.Add(flow);
            return page;
        }

        private TabPage BuildOptionsTab()
        {
            var page = new TabPage("Options");
            var flow = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Dock = DockStyle.Fill, AutoScroll = true, Padding = new Padding(8) };

            _createCenterline = new CheckBox { Text = "Create centerline polyline", AutoSize = true, Checked = true };
            flow.Controls.Add(_createCenterline);
            _origHilf = new CheckBox { Text = "Original object on _HILFSLINIEN", AutoSize = true, Checked = true };
            flow.Controls.Add(_origHilf);
            _delOrig = new CheckBox { Text = "Delete original object", AutoSize = true, Checked = true };
            flow.Controls.Add(_delOrig);
            _useGroups = new CheckBox { Text = "Create AutoCAD group per placement", AutoSize = true };
            flow.Controls.Add(_useGroups);
            _useMirror = new CheckBox { Text = "Also place mirrored copy", AutoSize = true };
            flow.Controls.Add(_useMirror);
            _repeat = new CheckBox { Text = "Keep picking points", AutoSize = true, Checked = true };
            flow.Controls.Add(_repeat);
            _dynamicPreview = new CheckBox { Text = "Dynamic preview", AutoSize = true, Checked = true };
            flow.Controls.Add(_dynamicPreview);

            page.Controls.Add(flow);
            return page;
        }

        private TabPage BuildPlacementsTab()
        {
            var page = new TabPage("Placed Pipes");
            var flow = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
            flow.RowStyles.Add(new RowStyle(SizeType.AutoSize));
            flow.RowStyles.Add(new RowStyle(SizeType.Percent, 100));

            var hint = new Label
            {
                Text = "Select a row to highlight that placement in the AutoCAD viewport.",
                AutoSize = true,
                Padding = new Padding(6)
            };
            flow.Controls.Add(hint, 0, 0);

            _placementsGrid = new DataGridView
            {
                Dock = DockStyle.Fill,
                AllowUserToAddRows = false,
                AllowUserToDeleteRows = false,
                ReadOnly = true,
                SelectionMode = DataGridViewSelectionMode.FullRowSelect,
                MultiSelect = false,
                AutoGenerateColumns = false,
            };
            EnsurePlacementGridColumns();
            // Viewport-highlight pattern: DataGridView.SelectionChanged -> Editor.SetImpliedSelection + UpdateScreen.
            // Must run on the AutoCAD/UI thread, which it does naturally here since this
            // handler fires from a WinForms event inside the modeless dialog.
            _placementsGrid.SelectionChanged += (s, e) =>
            {
                if (_placementsGrid.CurrentRow == null) return;
                int idx = _placementsGrid.CurrentRow.Index;
                var all = _drawer.History.AllPlacements;
                if (idx < 0 || idx >= all.Count) return;
                CadHelpers.HighlightInViewport(all[idx]);
            };
            flow.Controls.Add(_placementsGrid, 0, 1);

            page.Controls.Add(flow);
            return page;
        }

        private TabPage BuildLogTab()
        {
            var page = new TabPage("Log");
            var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
            layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
            layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));

            _logList = new ListBox { Dock = DockStyle.Fill, Font = new Font("Consolas", 8.5f), HorizontalScrollbar = true };
            foreach (var entry in AppLog.Entries) _logList.Items.Add(entry.ToString());
            layout.Controls.Add(_logList, 0, 0);

            var btnRow = new FlowLayoutPanel { AutoSize = true, Padding = new Padding(4) };
            var saveBtn = new Button { Text = "Save log to file", AutoSize = true, Tag = C3DTheme.RolePrimary };
            saveBtn.Click += (s, e) =>
            {
                string path = AppLog.Save();
                MessageBox.Show(this, $"Log saved to:\n{path}", "ParallelPipes", MessageBoxButtons.OK, MessageBoxIcon.Information);
            };
            var clearBtn = new Button { Text = "Clear", AutoSize = true, Tag = C3DTheme.RoleDanger };
            clearBtn.Click += (s, e) => { AppLog.Clear(); _logList.Items.Clear(); };
            btnRow.Controls.Add(saveBtn);
            btnRow.Controls.Add(clearBtn);
            layout.Controls.Add(btnRow, 0, 1);

            page.Controls.Add(layout);
            return page;
        }

        private Control BuildButtonRow()
        {
            var row = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, Padding = new Padding(6), FlowDirection = FlowDirection.LeftToRight, Tag = C3DTheme.RoleHeader };

            var placeBtn = new Button { Text = "Place", AutoSize = true, Tag = C3DTheme.RolePrimary };
            placeBtn.Click += (s, e) =>
            {
                CommitToSettings();
                PlaceRequested?.Invoke(this, new PlaceRequestArgs
                {
                    Profile = SelectedProfile(),
                    UseCurrentPreviewPoint = false,
                    CloseDialogAfterPlace = false
                });
            };

            var finalPlaceBtn = new Button { Text = "Final place", AutoSize = true, Tag = C3DTheme.RoleRefresh };
            finalPlaceBtn.Click += (s, e) =>
            {
                CommitToSettings();
                PlaceRequested?.Invoke(this, new PlaceRequestArgs
                {
                    Profile = SelectedProfile(),
                    UseCurrentPreviewPoint = true,
                    CloseDialogAfterPlace = true
                });
            };

            var undoBtn = new Button { Text = "Undo last", AutoSize = true, Tag = C3DTheme.RoleDanger };
            undoBtn.Click += (s, e) => UndoRequested?.Invoke(this, EventArgs.Empty);

            var closeBtn = new Button { Text = "Close", AutoSize = true, Tag = C3DTheme.RoleNeutral };
            closeBtn.Click += (s, e) => { CommitToSettings(); Close(); };

            row.Controls.Add(placeBtn);
            row.Controls.Add(finalPlaceBtn);
            row.Controls.Add(undoBtn);
            row.Controls.Add(closeBtn);
            return row;
        }

        private static FlowLayoutPanel LabeledNumeric(string label, out NumericUpDown edit, decimal min, decimal max, decimal value, int decimals)
        {
            var row = new FlowLayoutPanel { AutoSize = true };
            row.Controls.Add(new Label { Text = label, AutoSize = true, Padding = new Padding(0, 6, 4, 0), Width = 150 });
            var e = new NumericUpDown { Minimum = min, Maximum = max, Value = value, DecimalPlaces = decimals, Increment = (decimal)Math.Pow(10, -decimals), Width = 90 };
            row.Controls.Add(e);
            edit = e;
            return row;
        }

        // ─────────────────────────────────────────────────────────────────
        // Settings <-> UI plumbing
        // ─────────────────────────────────────────────────────────────────
        private void LoadFromSettings()
        {
            _gapEdit.Value = (decimal)_settings.Gap;
            _widthEdit.Value = (decimal)_settings.PolylineWidth;
            _searchLenEdit.Value = (decimal)_settings.SearchLengthFactor;
            _dirCombo.SelectedIndex = (int)_settings.Direction;
            _modeCombo.SelectedIndex = (int)_settings.PairMode;
            _useAngle.Checked = _settings.UseAngleConstraint;
            _fixAngleEdit.Value = (decimal)_settings.FixedAngleDegrees;

            _useFillet.Checked = _settings.UseFillet;
            _equalRadius.Checked = _settings.EqualRadius;
            _radCommon.Value = (decimal)_settings.RadiusCommon;
            _radBelow.Value = (decimal)_settings.RadiusBelow;
            _radAbove.Value = (decimal)_settings.RadiusAbove;
            _reverseBelow.Checked = _settings.ReverseBelow;
            _reverseAbove.Checked = _settings.ReverseAbove;
            if (_radiusPresetCombo.Items.Count > 0)
                _radiusPresetCombo.SelectedIndex = MathUtil.ClampInt(_settings.RadiusPresetIndex, 0, _radiusPresetCombo.Items.Count - 1);

            _useExtBelow.Checked = _settings.UseExtensionBelow;
            _useExtAbove.Checked = _settings.UseExtensionAbove;
            _equalExt.Checked = _settings.EqualExtension;
            _extCommon.Value = (decimal)_settings.ExtensionCommon;
            _extBelow.Value = (decimal)_settings.ExtensionBelow;
            _extAbove.Value = (decimal)_settings.ExtensionAbove;

            if (_profileCombo.Items.Count > 0)
                _profileCombo.SelectedIndex = MathUtil.ClampInt(_settings.ProfileIndex, 0, _profileCombo.Items.Count - 1);
            _pipeCountEdit.Value = MathUtil.ClampInt(_settings.PipeCount, 1, 30);
            _pipeDistEdit.Value = (decimal)_settings.PipeDistance;
            _offLEdit.Value = (decimal)_settings.PipeOffsetLeft;
            _offREdit.Value = (decimal)_settings.PipeOffsetRight;

            _useOffLayer.Checked = _settings.UseOffsetLayer;
            _offLayerFilter.Text = _settings.OffsetLayerFilter;
            RefreshLayerList();

            _createCenterline.Checked = _settings.CreateCenterline;
            _origHilf.Checked = _settings.OriginalOnHilfslinien;
            _delOrig.Checked = _settings.DeleteOriginal;
            _useGroups.Checked = _settings.CreateGroupPerPlacement;
            _useMirror.Checked = _settings.AlsoPlaceMirroredCopy;
            _repeat.Checked = _settings.KeepPickingPoints;
            _dynamicPreview.Checked = _settings.DynamicPreview;

            RefreshPreview();
        }

        private void CommitToSettings()
        {
            SyncControlsToSettings();
            _settings.Save();
        }

        /// <summary>
        /// Pushes every control's current value into the shared AppSettings instance,
        /// WITHOUT writing to disk. This is the piece that was missing from the live
        /// preview: MultiPipeDrawer solves directly from _settings, so unless this
        /// runs, a changed "Number of pipes"/spacing/offset/etc. in the dialog is
        /// invisible to the solver until Place/Close (which called the disk-writing
        /// CommitToSettings) - the preview panel and model space kept showing
        /// whatever was solved with the OLD settings values. Called from
        /// RefreshPreview() on every relevant control change, and once more (via
        /// CommitToSettings) when actually placing/closing.
        /// </summary>
        private void SyncControlsToSettings()
        {
            _settings.Gap = (double)_gapEdit.Value;
            _settings.PolylineWidth = (double)_widthEdit.Value;
            _settings.SearchLengthFactor = (double)_searchLenEdit.Value;
            _settings.Direction = (VerticalDirectionMode)_dirCombo.SelectedIndex;
            _settings.PairMode = (IntersectionPairMode)_modeCombo.SelectedIndex;
            _settings.UseAngleConstraint = _useAngle.Checked;
            _settings.FixedAngleDegrees = (double)_fixAngleEdit.Value;

            _settings.UseFillet = _useFillet.Checked;
            _settings.EqualRadius = _equalRadius.Checked;
            _settings.RadiusCommon = (double)_radCommon.Value;
            _settings.RadiusBelow = (double)_radBelow.Value;
            _settings.RadiusAbove = (double)_radAbove.Value;
            _settings.ReverseBelow = _reverseBelow.Checked;
            _settings.ReverseAbove = _reverseAbove.Checked;
            _settings.RadiusPresetIndex = _radiusPresetCombo.SelectedIndex;

            _settings.UseExtensionBelow = _useExtBelow.Checked;
            _settings.UseExtensionAbove = _useExtAbove.Checked;
            _settings.EqualExtension = _equalExt.Checked;
            _settings.ExtensionCommon = (double)_extCommon.Value;
            _settings.ExtensionBelow = (double)_extBelow.Value;
            _settings.ExtensionAbove = (double)_extAbove.Value;

            _settings.ProfileIndex = _profileCombo.SelectedIndex;
            _settings.PipeCount = (int)_pipeCountEdit.Value;
            _settings.PipeDistance = (double)_pipeDistEdit.Value;
            _settings.PipeOffsetLeft = (double)_offLEdit.Value;
            _settings.PipeOffsetRight = (double)_offREdit.Value;

            _settings.UseOffsetLayer = _useOffLayer.Checked;
            _settings.OffsetLayerFilter = _offLayerFilter.Text;
            if (_offLayerCombo.SelectedItem is string layer) _settings.OffsetLayer = layer;

            _settings.CreateCenterline = _createCenterline.Checked;
            _settings.OriginalOnHilfslinien = _origHilf.Checked;
            _settings.DeleteOriginal = _delOrig.Checked;
            _settings.CreateGroupPerPlacement = _useGroups.Checked;
            _settings.AlsoPlaceMirroredCopy = _useMirror.Checked;
            _settings.KeepPickingPoints = _repeat.Checked;
            _settings.DynamicPreview = _dynamicPreview.Checked;
        }

        private PipeProfile SelectedProfile() =>
            _profileCombo.SelectedItem as PipeProfile ?? (_profiles.Count > 0 ? _profiles[0] : new PipeProfile());

        private void ApplyProfileToTiles()
        {
            if (!(_profileCombo.SelectedItem is PipeProfile p)) return;
            _pipeCountEdit.Value = MathUtil.ClampInt(p.PipeCount, 1, 30);
            _offLEdit.Value = (decimal)p.OffsetLeft;
            _offREdit.Value = (decimal)p.OffsetRight;
            if (!string.IsNullOrEmpty(p.Layer)) _offLayerFilter.Text = p.Layer;
            RefreshLayerList();
            RefreshPreview();
        }

        private void ApplyRadiusPreset()
        {
            if (!(_radiusPresetCombo.SelectedItem is RadiusPreset preset) || preset.Radius == null) return;
            decimal r = (decimal)preset.Radius.Value;
            _radCommon.Value = r;
            _radBelow.Value = r;
            _radAbove.Value = r;
            RefreshPreview();
        }

        private void RefreshLayerList()
        {
            _offLayerCombo.Items.Clear();
            var current = _profileCombo.SelectedItem as PipeProfile;
            var candidates = _profiles.Select(p => p.Layer).Distinct().OrderBy(s => s).ToArray();
            _offLayerCombo.Items.AddRange(candidates.Cast<object>().ToArray());
            if (current != null && _offLayerCombo.Items.Contains(current.Layer))
                _offLayerCombo.SelectedItem = current.Layer;
            else if (_offLayerCombo.Items.Count > 0)
                _offLayerCombo.SelectedIndex = 0;
        }

        private void RefreshPreview()
        {
            if (_preview == null) return;
            _preview.PipeCount = (int)_pipeCountEdit.Value;
            _preview.PipeDistance = (double)_pipeDistEdit.Value;
            _preview.OffsetLeft = (double)_offLEdit.Value;
            _preview.OffsetRight = (double)_offREdit.Value;
            _preview.FilletRadius = (double)_radCommon.Value;

            // If a live solve from a previous drag is frozen on screen, a settings
            // tweak (pipe count, spacing, offsets, radius, ...) should refresh it
            // immediately rather than sitting stale until the next Place drag. The
            // solver reads directly from _settings, so the values must be synced
            // in first (SyncControlsToSettings) or it would recompute with stale
            // numbers and appear not to update at all.
            SyncControlsToSettings();
            LivePreviewRefreshRequested?.Invoke(this, EventArgs.Empty);

            _preview.Invalidate();
        }

        private void OnLogEntryAdded(LogEntry entry)
        {
            if (_logList == null || _logList.IsDisposed) return;
            if (InvokeRequired) { BeginInvoke(new Action(() => AppendLogLine(entry))); return; }
            AppendLogLine(entry);
        }

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

        private void OpenProfileManager()
        {
            using (var mgr = new ProfileManagerForm(_profiles))
            {
                if (mgr.ShowDialog(this) == DialogResult.OK)
                {
                    ProfileStore.SaveProfiles(_profiles);
                    int sel = _profileCombo.SelectedIndex;
                    _profileCombo.Items.Clear();
                    _profileCombo.Items.AddRange(_profiles.Cast<object>().ToArray());
                    _profileCombo.SelectedIndex = MathUtil.ClampInt(sel, 0, Math.Max(0, _profiles.Count - 1));
                    AppLog.Info("ProfileManager", "Profiles saved and reloaded.");
                }
            }
        }

        private void OpenRadiusManager()
        {
            using (var mgr = new RadiusManagerForm(_radiusPresets))
            {
                if (mgr.ShowDialog(this) == DialogResult.OK)
                {
                    ProfileStore.SaveRadiusPresets(_radiusPresets);
                    int sel = _radiusPresetCombo.SelectedIndex;
                    _radiusPresetCombo.Items.Clear();
                    _radiusPresetCombo.Items.AddRange(_radiusPresets.Cast<object>().ToArray());
                    _radiusPresetCombo.SelectedIndex = MathUtil.ClampInt(sel, 0, Math.Max(0, _radiusPresets.Count - 1));
                    AppLog.Info("RadiusManager", "Radius presets saved and reloaded.");
                }
            }
        }

        // ─────────────────────────────────────────────────────────────────
        // Public hooks called from Commands.cs
        // ─────────────────────────────────────────────────────────────────
        public void RefreshBoundaryLabel(int count)
        {
            _boundaryLabel.Text = count > 0 ? $"{count} object(s) selected" : "(none selected)";
            if (count > 0) LivePreviewRefreshRequested?.Invoke(this, EventArgs.Empty);
        }

        public void UpdateSpanReadout(List<PipeSolution> solutions)
        {
            if (_spanLabel == null) return;
            var ok = solutions?.FirstOrDefault(s => s.Success);
            if (ok?.CenterlineRoute != null && ok.CenterlineRoute.Vertices.Count >= 2)
            {
                var v0 = ok.CenterlineRoute.Vertices[0].Point;
                var v1 = ok.CenterlineRoute.Vertices[ok.CenterlineRoute.Vertices.Count - 1].Point;
                double span = v0.DistanceTo(v1);
                if (InvokeRequired) BeginInvoke(new Action(() => _spanLabel.Text = $"Span: {span:0.0000} m"));
                else _spanLabel.Text = $"Span: {span:0.0000} m";
            }
        }

        /// <summary>
        /// Pushes the real, tessellated geometry from the current model-space drag
        /// into the schematic preview panel, so it shows the actual computed pipes
        /// (true orientation/curvature/proportions) instead of the generic mockup.
        /// Called on every Editor.PointMonitor tick from Commands.OnPlaceRequested.
        /// </summary>
        public void UpdateLivePreview(PreviewGeometry geometry)
        {
            if (_preview == null) return;
            if (InvokeRequired) BeginInvoke(new Action(() => _preview.LiveGeometry = geometry));
            else _preview.LiveGeometry = geometry;
        }

        private bool EnsurePlacementGridColumns()
        {
            if (_placementsGrid == null || _placementsGrid.IsDisposed) return false;

            bool hasIndex = _placementsGrid.Columns.Contains("Index");
            bool hasCount = _placementsGrid.Columns.Contains("Count");
            if (hasIndex && hasCount) return true;

            // AutoCAD modeless dialogs can receive refresh callbacks while the tab
            // control is still being themed/recreated.  A DataGridView without
            // columns throws InvalidOperationException on Rows.Add(), so make the
            // column setup idempotent and call it before every history refresh.
            _placementsGrid.Columns.Clear();
            _placementsGrid.AutoGenerateColumns = false;
            _placementsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Index", HeaderText = "#", Width = 40 });
            _placementsGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Count", HeaderText = "Entities", Width = 70 });
            return _placementsGrid.Columns.Count >= 2;
        }

        public void RefreshPlacementsList(PlacementHistory history)
        {
            if (_placementsGrid == null || _placementsGrid.IsDisposed) return;
            void Do()
            {
                try
                {
                    if (_placementsGrid == null || _placementsGrid.IsDisposed) return;
                    if (!EnsurePlacementGridColumns()) return;

                    _placementsGrid.SuspendLayout();
                    try
                    {
                        _placementsGrid.Rows.Clear();
                        var all = history?.AllPlacements;
                        if (all == null) return;

                        for (int i = 0; i < all.Count; i++)
                            _placementsGrid.Rows.Add(i + 1, all[i].Count);
                    }
                    finally
                    {
                        _placementsGrid.ResumeLayout();
                    }
                }
                catch (System.Exception ex)
                {
                    AppLog.Warn("UI", "Placement history grid refresh failed: " + ex.Message);
                }
            }
            if (InvokeRequired)
            {
                try { BeginInvoke((Action)Do); }
                catch (InvalidOperationException) { /* form is closing; ignore late refresh */ }
            }
            else Do();
        }
    }
}


// ===== FILE: UI\C3DTheme.cs =====
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;

namespace ParallelPipesNet.UI
{
    /// <summary>
    /// Palette + fonts, ported field-for-field from C3D_UI_Style_Template.py's
    /// C3D_DARK / C3D_LIGHT dicts and FONT dict.
    /// </summary>
    public class C3DPalette
    {
        public Color Bg, Panel, Border, HeaderBg, Bg2, Bg3, Fg, Muted, Accent, TextW,
                     SelBg, SelFg, CodeBg, CodeFg, Entry, Blue, Green, Red, Orange, Purple, Grey, Reload;

        public static C3DPalette Dark { get; } = new C3DPalette
        {
            Bg = FromHex("#2b2d3e"),
            Panel = FromHex("#23253a"),
            Border = FromHex("#3e4060"),
            HeaderBg = FromHex("#1e2030"),
            Bg2 = FromHex("#23253a"),
            Bg3 = FromHex("#1e2030"),
            Fg = FromHex("#dde1f0"),
            Muted = FromHex("#7a7f9a"),
            Accent = FromHex("#2979d4"),
            TextW = FromHex("#ffffff"),
            SelBg = FromHex("#3a5fa0"),
            SelFg = FromHex("#ffffff"),
            CodeBg = FromHex("#1e2030"),
            CodeFg = FromHex("#dde1f0"),
            Entry = FromHex("#1e2030"),
            Blue = FromHex("#2979d4"),
            Green = FromHex("#2a9e52"),
            Red = FromHex("#c0392b"),
            Orange = FromHex("#e06c2a"),
            Purple = FromHex("#7c4dbd"),
            Grey = FromHex("#3e4060"),
            Reload = FromHex("#4a5070"),
        };

        public static C3DPalette Light { get; } = new C3DPalette
        {
            Bg = FromHex("#ffffff"),
            Panel = FromHex("#f6f8fa"),
            Border = FromHex("#d0d7de"),
            HeaderBg = FromHex("#f6f8fa"),
            Bg2 = FromHex("#f6f8fa"),
            Bg3 = FromHex("#eaeef2"),
            Fg = FromHex("#1f2328"),
            Muted = FromHex("#57606a"),
            Accent = FromHex("#0969da"),
            TextW = FromHex("#ffffff"),
            SelBg = FromHex("#0969da"),
            SelFg = FromHex("#ffffff"),
            CodeBg = FromHex("#f6f8fa"),
            CodeFg = FromHex("#1f2328"),
            Entry = FromHex("#ffffff"),
            Blue = FromHex("#0969da"),
            Green = FromHex("#2a9e52"),
            Red = FromHex("#c0392b"),
            Orange = FromHex("#e06c2a"),
            Purple = FromHex("#7c4dbd"),
            Grey = FromHex("#d0d7de"),
            Reload = FromHex("#afb8c1"),
        };

        private static Color FromHex(string hex) => ColorTranslator.FromHtml(hex);
    }

    public static class C3DFonts
    {
        public static readonly Font Normal = new Font("Segoe UI", 9f);
        public static readonly Font Small = new Font("Segoe UI", 8f);
        public static readonly Font Title = new Font("Segoe UI", 10f, FontStyle.Bold);
        public static readonly Font Button = new Font("Segoe UI", 9f);
        public static readonly Font ButtonBold = new Font("Segoe UI", 9f, FontStyle.Bold);
        public static readonly Font Code = new Font("Consolas", 9.5f);
        public static readonly Font CodeBig = new Font("Consolas", 10.5f);
    }

    /// <summary>
    /// Recursive, role-based theming for WinForms controls, ported from
    /// C3D_UI_Style_Template.py's role()/apply_theme()/style_button().
    /// A control's "role" (equivalent to the Python side's `_theme_role`
    /// attribute set via role(widget, name)) is read from Control.Tag as a string,
    /// e.g. button.Tag = "primary" or label.Tag = "header_title".
    /// </summary>
    public static class C3DTheme
    {
        public const string RoleHeader = "header";
        public const string RoleHeaderTitle = "header_title";
        public const string RoleHeaderPath = "header_path";
        public const string RoleMuted = "muted";
        public const string RolePrimary = "primary";
        public const string RoleRefresh = "refresh";
        public const string RoleDanger = "danger";
        public const string RoleWarn = "warn";
        public const string RoleNeutral = "neutral";

        public static Color Lighten(Color c, int amount = 22)
        {
            int r = Math.Min(255, c.R + amount);
            int g = Math.Min(255, c.G + amount);
            int b = Math.Min(255, c.B + amount);
            return Color.FromArgb(c.A, r, g, b);
        }

        /// <summary>Attaches a role string to a control (ported from role()). Returns the same control for chaining.</summary>
        public static T Role<T>(T control, string roleName) where T : Control
        {
            control.Tag = roleName;
            return control;
        }

        public static string GetRole(Control c) => c.Tag as string ?? string.Empty;

        /// <summary>Recursively applies the palette to a control tree (ported from apply_theme()).</summary>
        public static void Apply(Control root, C3DPalette C)
        {
            string roleName = GetRole(root);

            try
            {
                switch (root)
                {
                    case TabControl tc:
                        tc.BackColor = C.Panel;
                        StyleTabControl(tc, C);
                        break;

                    case Button btn:
                        StyleButton(btn, roleName, C);
                        break;

                    case CheckBox cb:
                        cb.BackColor = C.Panel;
                        cb.ForeColor = C.Fg;
                        cb.Font = C3DFonts.Normal;
                        cb.FlatStyle = FlatStyle.Flat;
                        break;

                    case ComboBox combo:
                        combo.BackColor = C.Entry;
                        combo.ForeColor = C.Fg;
                        combo.FlatStyle = FlatStyle.Flat;
                        combo.Font = C3DFonts.Normal;
                        break;

                    case NumericUpDown num:
                        num.BackColor = C.Entry;
                        num.ForeColor = C.Fg;
                        num.Font = C3DFonts.Normal;
                        num.BorderStyle = BorderStyle.FixedSingle;
                        break;

                    case TextBox tb:
                        tb.BackColor = C.Entry;
                        tb.ForeColor = C.Fg;
                        tb.BorderStyle = BorderStyle.FixedSingle;
                        tb.Font = C3DFonts.Normal;
                        break;

                    case ListBox lb:
                        lb.BackColor = C.CodeBg;
                        lb.ForeColor = C.CodeFg;
                        lb.BorderStyle = BorderStyle.FixedSingle;
                        lb.Font = C3DFonts.Code;
                        break;

                    case DataGridView grid:
                        StyleGrid(grid, C);
                        break;

                    case GroupBox gb:
                        gb.BackColor = C.Panel;
                        gb.ForeColor = C.Accent;
                        gb.Font = C3DFonts.ButtonBold;
                        break;

                    case Label lbl:
                        StyleLabel(lbl, roleName, C);
                        break;

                    case PipeBundlePreviewControl:
                        // Keeps its own black background + cyan/gold/teal drawing colors -
                        // it derives from Panel, so it must be excluded before the
                        // generic Panel case below or it would get re-painted C.Panel gray.
                        break;

                    case Panel pnl:
                        pnl.BackColor = roleName == RoleHeader ? C.HeaderBg : C.Panel;
                        break;

                    case Form frm:
                        frm.BackColor = C.Bg;
                        break;

                    default:
                        if (root is TableLayoutPanel || root is FlowLayoutPanel)
                            root.BackColor = C.Panel;
                        break;
                }
            }
            catch
            {
                // Best-effort styling, matches the Python side's tk.TclError guard - a
                // control that doesn't support one of these properties shouldn't abort
                // theming the rest of the tree.
            }

            foreach (Control child in root.Controls)
                Apply(child, C);
        }

        private static void StyleLabel(Label lbl, string roleName, C3DPalette C)
        {
            switch (roleName)
            {
                case RoleHeaderTitle:
                    lbl.BackColor = C.HeaderBg;
                    lbl.ForeColor = C.TextW;
                    lbl.Font = C3DFonts.Title;
                    break;
                case RoleHeaderPath:
                    lbl.BackColor = C.HeaderBg;
                    lbl.ForeColor = C.Muted;
                    lbl.Font = C3DFonts.Small;
                    break;
                case RoleMuted:
                case "hint":
                    lbl.BackColor = C.Panel;
                    lbl.ForeColor = C.Muted;
                    lbl.Font = C3DFonts.Small;
                    break;
                default:
                    lbl.BackColor = C.Panel;
                    lbl.ForeColor = C.Fg;
                    lbl.Font = C3DFonts.Normal;
                    break;
            }
        }

        /// <summary>Ported from style_button(): one of neutral / primary / refresh / danger / warn.</summary>
        public static Button StyleButton(Button button, string roleName, C3DPalette C)
        {
            roleName = string.IsNullOrEmpty(roleName) ? RoleNeutral : roleName.ToLowerInvariant();

            button.FlatStyle = FlatStyle.Flat;
            button.FlatAppearance.BorderSize = 0;
            button.Font = C3DFonts.Button;
            button.Cursor = Cursors.Hand;
            button.Padding = new Padding(6, 2, 6, 2);
            button.UseVisualStyleBackColor = false;

            switch (roleName)
            {
                case RolePrimary:
                case "ok":
                case "success":
                    button.BackColor = C.Green;
                    button.ForeColor = C.TextW;
                    button.Font = C3DFonts.ButtonBold;
                    button.FlatAppearance.MouseOverBackColor = Lighten(C.Green);
                    button.FlatAppearance.MouseDownBackColor = Lighten(C.Green, 10);
                    break;
                case RoleRefresh:
                case "info":
                case "blue":
                    button.BackColor = C.Blue;
                    button.ForeColor = C.TextW;
                    button.FlatAppearance.MouseOverBackColor = Lighten(C.Blue);
                    button.FlatAppearance.MouseDownBackColor = Lighten(C.Blue, 10);
                    break;
                case RoleDanger:
                case "delete":
                case "revert":
                    button.BackColor = C.Panel;
                    button.ForeColor = C.Red;
                    button.FlatAppearance.MouseOverBackColor = C.Border;
                    button.FlatAppearance.MouseDownBackColor = Lighten(C.Border, 10);
                    break;
                case RoleWarn:
                case "warning":
                    button.BackColor = C.Orange;
                    button.ForeColor = C.TextW;
                    button.FlatAppearance.MouseOverBackColor = Lighten(C.Orange);
                    button.FlatAppearance.MouseDownBackColor = Lighten(C.Orange, 10);
                    break;
                default:
                    button.BackColor = C.Panel;
                    button.ForeColor = C.Fg;
                    button.FlatAppearance.MouseOverBackColor = C.Border;
                    button.FlatAppearance.MouseDownBackColor = Lighten(C.Border, 10);
                    break;
            }
            return button;
        }

        /// <summary>Convenience matching Python's make_button(): create + role + style in one call.</summary>
        public static Button MakeButton(string text, string roleName = RoleNeutral, EventHandler onClick = null, C3DPalette C = null)
        {
            C = C ?? C3DPalette.Dark;
            var btn = new Button { Text = text, AutoSize = true, Tag = roleName };
            if (onClick != null) btn.Click += onClick;
            StyleButton(btn, roleName, C);
            return btn;
        }

        private static void StyleGrid(DataGridView grid, C3DPalette C)
        {
            grid.BackgroundColor = C.CodeBg;
            grid.ForeColor = C.Fg;
            grid.GridColor = C.Border;
            grid.BorderStyle = BorderStyle.FixedSingle;
            grid.EnableHeadersVisualStyles = false;
            grid.ColumnHeadersDefaultCellStyle.BackColor = C.Bg2;
            grid.ColumnHeadersDefaultCellStyle.ForeColor = C.Fg;
            grid.ColumnHeadersDefaultCellStyle.Font = C3DFonts.ButtonBold;
            grid.DefaultCellStyle.BackColor = C.CodeBg;
            grid.DefaultCellStyle.ForeColor = C.CodeFg;
            grid.DefaultCellStyle.SelectionBackColor = C.SelBg;
            grid.DefaultCellStyle.SelectionForeColor = C.SelFg;
            grid.DefaultCellStyle.Font = C3DFonts.Normal;
            grid.RowHeadersDefaultCellStyle.BackColor = C.Bg2;
            grid.AlternatingRowsDefaultCellStyle.BackColor = C.Panel;
        }

        /// <summary>
        /// Owner-draws the TabControl's tab strip to match the ttk Notebook.Tab style
        /// in the Python template (unselected: bg2/fg, selected: panel bg + accent fg).
        /// Standard WinForms TabControl ignores BackColor for the native tab strip,
        /// so this is the only reliable way to theme it.
        /// </summary>
        public static void StyleTabControl(TabControl tc, C3DPalette C)
        {
            tc.DrawMode = TabDrawMode.OwnerDrawFixed;
            tc.Padding = new Point(14, 5);
            tc.DrawItem += (s, e) =>
            {
                var page = tc.TabPages[e.Index];
                bool selected = e.Index == tc.SelectedIndex;
                var tabRect = tc.GetTabRect(e.Index);

                using (var bg = new SolidBrush(selected ? C.Panel : C.Bg2))
                    e.Graphics.FillRectangle(bg, tabRect);

                var textColor = selected ? C.Accent : C.TextW;
                TextRenderer.DrawText(e.Graphics, page.Text, C3DFonts.Normal, tabRect, textColor,
                    TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
            };
            tc.SelectedIndexChanged += (s, e) => tc.Invalidate();
        }
    }
}


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

[assembly: AssemblyTitle("ParallelPipes.NET")]
[assembly: AssemblyDescription("Parallel chamfered pipe placement tool for AutoCAD / Civil3D (managed .NET port of ParallelPipes.lsp)")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("ParallelPipesNet")]
[assembly: AssemblyCopyright("")]
[assembly: ComVisible(false)]
[assembly: Guid("6f2b9a2e-6b7b-4a63-9a2a-6a8f6b9c9e01")]

// Fixed 4-part assembly version (must stay stable for binding); the human-readable,
// per-build timestamped version lives in BuildStamp.Version (see the csproj target
// PP_WriteBuildStamp) and is what gets printed on load / shown in the dialog title.
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion(ParallelPipesNet.BuildStamp.Version)]


// ===== FILE: Core\Vec3.cs =====
using System;

namespace ParallelPipesNet.Core
{
    /// <summary>
    /// Minimal 3D vector struct + math, ported 1:1 from the AutoLISP helpers
    /// VTP:v+ / VTP:v- / VTP:v* / VTP:dot / VTP:unit / VTP:cross / VTP:P3.
    /// AutoCAD's Point3d/Vector3d could be used instead, but this struct keeps
    /// Core/ free of AutoCAD assembly references so it is independently
    /// unit-testable without ObjectARX loaded.
    /// </summary>
    public readonly struct Vec3
    {
        public readonly double X, Y, Z;

        public Vec3(double x, double y, double z)
        {
            X = x; Y = y; Z = z;
        }

        public static readonly Vec3 Zero = new Vec3(0, 0, 0);
        public static readonly Vec3 XAxis = new Vec3(1, 0, 0);
        public static readonly Vec3 YAxis = new Vec3(0, 1, 0);
        public static readonly Vec3 ZAxis = new Vec3(0, 0, 1);

        public static Vec3 operator +(Vec3 a, Vec3 b) => new Vec3(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
        public static Vec3 operator -(Vec3 a, Vec3 b) => new Vec3(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
        public static Vec3 operator -(Vec3 a) => new Vec3(-a.X, -a.Y, -a.Z);
        public static Vec3 operator *(double s, Vec3 v) => new Vec3(s * v.X, s * v.Y, s * v.Z);
        public static Vec3 operator *(Vec3 v, double s) => s * v;

        public double Dot(Vec3 b) => X * b.X + Y * b.Y + Z * b.Z;

        public Vec3 Cross(Vec3 b) => new Vec3(
            Y * b.Z - Z * b.Y,
            Z * b.X - X * b.Z,
            X * b.Y - Y * b.X);

        public double Length => Math.Sqrt(X * X + Y * Y + Z * Z);

        public double DistanceTo(Vec3 b) => (this - b).Length;

        public Vec3 Unit()
        {
            double d = Length;
            return d > 1e-12 ? new Vec3(X / d, Y / d, Z / d) : this;
        }

        /// <summary>Projects this vector onto the plane with the given normal (VTP:ProjectToPlane).</summary>
        public Vec3 ProjectToPlane(Vec3 normal) => this - (Dot(normal) * normal);

        /// <summary>Left-hand perpendicular within the plane (VTP:PerpLeft): cross(normal, v).</summary>
        public Vec3 PerpLeft(Vec3 normal) => normal.Cross(this).Unit();

        public bool IsValid => !double.IsNaN(X) && !double.IsNaN(Y) && !double.IsNaN(Z)
                             && !double.IsInfinity(X) && !double.IsInfinity(Y) && !double.IsInfinity(Z);

        public override string ToString() => $"({X:0.####}, {Y:0.####}, {Z:0.####})";
    }

    public static class MathUtil
    {
        public static double Clamp(double x, double lo, double hi) => Math.Max(lo, Math.Min(hi, x));

        public static int ClampInt(int n, int lo, int hi) => n < lo ? lo : (n > hi ? hi : n);

        /// <summary>
        /// Intersection of two infinite lines living in the same plane (VTP:LineIntersectPlane).
        /// Returns null if the lines are parallel (degenerate corner).
        /// </summary>
        public static Vec3? LineIntersectPlane(Vec3 p1, Vec3 d1, Vec3 p2, Vec3 d2, Vec3 normal)
        {
            double den = d1.Cross(d2).Dot(normal);
            if (Math.Abs(den) <= 1e-10) return null;
            double num = (p2 - p1).Cross(d2).Dot(normal);
            return p1 + (num / den) * d1;
        }
    }
}


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

namespace ParallelPipesNet.Core
{
    /// <summary>
    /// Tessellated, world-space (WCS) polylines captured from the last live bundle
    /// solve, deliberately AutoCAD-free (plain Vec3 point lists) so the WinForms
    /// preview panel (UI/PipeBundlePreviewControl.cs) can draw the REAL computed
    /// geometry - actual orientation, actual proportions, actual curve shape -
    /// fit-scaled into the panel, instead of the old generic schematic mockup.
    /// Populated by Cad.PipeBundlePreviewSession on every PointMonitor tick.
    /// </summary>
    public class PreviewGeometry
    {
        public List<List<Vec3>> Centerlines { get; } = new List<List<Vec3>>();
        public List<List<Vec3>> Edges { get; } = new List<List<Vec3>>();

        /// <summary>The selected boundary curves themselves, tessellated, so the panel
        /// shows the same reference context (boundary + pipes) visible in the viewport.</summary>
        public List<List<Vec3>> Boundaries { get; } = new List<List<Vec3>>();

        public bool IsEmpty => Centerlines.Count == 0 && Edges.Count == 0 && Boundaries.Count == 0;
    }
}


// ===== FILE: Core\FilletEngine.cs =====
using System;
using System.Collections.Generic;

namespace ParallelPipesNet.Core
{
    /// <summary>One vertex of the resulting polyline route: a point plus the bulge of the segment leaving it.</summary>
    public struct RouteVertex
    {
        public Vec3 Point;
        public double Bulge;
        public RouteVertex(Vec3 p, double b) { Point = p; Bulge = b; }
    }

    /// <summary>Result of a single-pipe geometry solve: vertices + normal/width/layer for entity creation.</summary>
    public class RouteResult
    {
        public List<RouteVertex> Vertices { get; } = new List<RouteVertex>();
        public Vec3 Normal { get; set; } = Vec3.ZAxis;
        public double Width { get; set; }
        public string Layer { get; set; }
    }

    /// <summary>Result of a tangent-fillet corner solve (VTP:CornerFillet).</summary>
    public class CornerFilletResult
    {
        public Vec3 Q1;         // point on the "start" ray, where the arc begins
        public Vec3 Q2;         // point on the "end" ray, where the arc ends
        public Vec3 Center;
        public double Bulge;
        public double TangentDistance;
    }

    /// <summary>
    /// Pure geometry engine: tangent fillets between a vertical run and boundary
    /// curve tangents, plus extension segments. Ported from VTP:CornerFillet /
    /// VTP:FilletBulge / VTP:RouteData / VTP:OrientTangent. No AutoCAD types here
    /// so it can be unit tested without ObjectARX loaded.
    /// </summary>
    public static class FilletEngine
    {
        private const double Tol = 1e-7;

        /// <summary>VTP:FilletBulge - bulge factor for an arc from startpt to endpt around center.</summary>
        public static double FilletBulge(Vec3 startPt, Vec3 endPt, Vec3 center, Vec3 normal)
        {
            Vec3 v1 = (startPt - center).Unit();
            Vec3 v2 = (endPt - center).Unit();
            double signed = v1.Cross(v2).Dot(normal);
            double dotv = MathUtil.Clamp(v1.Dot(v2), -1.0, 1.0);
            double ang = Math.Atan2(Math.Abs(signed), dotv);
            double mag = Math.Tan(ang / 4.0);
            return signed >= 0.0 ? mag : -mag;
        }

        /// <summary>
        /// VTP:CornerFillet - builds one tangent fillet between two rays leaving `vertex`:
        /// uStart and uEnd, with radius `rad`. Returns null for a degenerate/parallel corner.
        /// </summary>
        public static CornerFilletResult CornerFillet(Vec3 vertex, Vec3 uStart, Vec3 uEnd, double rad, Vec3 normal)
        {
            rad = Math.Max(0.0, rad);
            uStart = uStart.ProjectToPlane(normal).Unit();
            uEnd = uEnd.ProjectToPlane(normal).Unit();

            double dotv = MathUtil.Clamp(uStart.Dot(uEnd), -1.0, 1.0);
            if (rad <= 1e-8 || dotv > 0.999999 || dotv < -0.999999)
                return null;

            double den = Math.Sqrt((1.0 - dotv) / (1.0 + dotv)); // tan(theta/2)
            if (den <= 1e-8) return null;

            double dist = rad / den;
            Vec3 q1 = vertex + dist * uStart;
            Vec3 q2 = vertex + dist * uEnd;

            var c = MathUtil.LineIntersectPlane(q1, uStart.PerpLeft(normal), q2, uEnd.PerpLeft(normal), normal);
            if (c == null) return null;

            double bulge = FilletBulge(q1, q2, c.Value, normal);
            return new CornerFilletResult { Q1 = q1, Q2 = q2, Center = c.Value, Bulge = bulge, TangentDistance = dist };
        }

        /// <summary>
        /// VTP:OrientTangent - orients a (possibly null) curve tangent so it points
        /// away from the vertical run's positive side, honoring the reverse toggle.
        /// </summary>
        public static Vec3 OrientTangent(Vec3? tangent, bool reverse, Vec3 sideDir, Vec3 normal)
        {
            Vec3 tan = tangent ?? sideDir;
            Vec3 chk = tan.ProjectToPlane(normal);
            tan = chk.Length > 1e-9 ? chk.Unit() : sideDir;

            if (tan.Dot(sideDir) < 0.0) tan = -tan;
            return reverse ? -tan : tan;
        }

        /// <summary>
        /// VTP:RouteData - builds the full vertex/bulge route between two boundary
        /// intersection points, including tangent fillets and extensions.
        /// Returns null if geometry cannot be built (radius too large, parallel curves, etc).
        /// </summary>
        public static RouteResult BuildRoute(RouteParams p)
        {
            Vec3 pBelow = p.PointBelow;
            Vec3 pAbove = p.PointAbove;
            Vec3 dir = p.Direction.ProjectToPlane(p.Normal).Unit();
            Vec3 normal = p.Normal.Unit();

            double rb = Math.Max(0.0, p.RadiusBelow);
            double ra = Math.Max(0.0, p.RadiusAbove);
            double avail = (pAbove - pBelow).Dot(dir);

            var result = new RouteResult { Normal = normal, Width = p.Width, Layer = p.Layer };

            bool noFillet = !p.UseFillet || (rb <= Tol && ra <= Tol);
            if (noFillet)
            {
                double extB = p.UseExtensionBelow ? p.ExtensionBelow : 0.0;
                double extA = p.UseExtensionAbove ? p.ExtensionAbove : 0.0;

                var pts = new List<Vec3>();
                if (extB > Tol) pts.Add(pBelow - extB * dir);
                pts.Add(pBelow);
                pts.Add(pAbove);
                if (extA > Tol) pts.Add(pAbove + extA * dir);

                foreach (var pt in pts) result.Vertices.Add(new RouteVertex(pt, 0.0));
                return result;
            }

            if (avail <= Tol) return null; // curves too close together / crossed

            // VTP:OrientTangent's reference is VTP:SideDir - a UCS-X-axis-based vector,
            // deliberately NOT the same as `dir` (the vertical run direction). Using
            // `dir` here was the earlier bug: it let the two end-fillets orient
            // inconsistently and curve in the same rotational sense instead of
            // opposite senses, producing a self-crossing hook instead of a clean S.
            Vec3 sideDir = (p.SideDir.Length > 1e-9 ? p.SideDir : dir).ProjectToPlane(normal).Unit();
            Vec3 tanB = OrientTangent(p.TangentBelow, p.ReverseBelow, sideDir, normal);
            Vec3 tanA = OrientTangent(p.TangentAbove, p.ReverseAbove, sideDir, normal);

            Vec3 uVertUp = dir;
            Vec3 uVertDn = -dir;

            // Proportional scale-down when radii exceed available spacing.
            if (rb + ra > avail * 0.80 && rb + ra > Tol)
            {
                double scale = (avail * 0.80) / (rb + ra);
                rb *= scale;
                ra *= scale;
            }

            CornerFilletResult fB = rb > Tol ? CornerFillet(pBelow, tanB, uVertUp, rb, normal) : null;
            CornerFilletResult fA = ra > Tol ? CornerFillet(pAbove, uVertDn, tanA, ra, normal) : null;

            Vec3 q1B = fB?.Q1 ?? pBelow;
            Vec3 q2B = fB?.Q2 ?? pBelow;
            double bulgeB = fB?.Bulge ?? 0.0;
            Vec3 q1A = fA?.Q1 ?? pAbove;
            Vec3 q2A = fA?.Q2 ?? pAbove;
            double bulgeA = fA?.Bulge ?? 0.0;

            // Sanity check: the two vertical tangent points must not overlap/cross.
            if ((q1A - q2B).Dot(dir) <= Tol)
            {
                p.OnWarning?.Invoke("Fillet radius is too large for the available distance between selected curves.");
                return null;
            }

            var verts = new List<RouteVertex>
            {
                new RouteVertex(q1B, bulgeB)
            };
            AddUnique(verts, q2B, 0.0);
            AddUnique(verts, q1A, bulgeA);
            AddUnique(verts, q2A, 0.0);

            // Extensions: straight segments continuing in the arc's own exit direction,
            // i.e. unit(q1B - pBelow) at the bottom and unit(q2A - pAbove) at the top.
            // This is robust regardless of how tanB/tanA ended up oriented.
            double extBelow = p.UseExtensionBelow ? p.ExtensionBelow : 0.0;
            double extAbove = p.UseExtensionAbove ? p.ExtensionAbove : 0.0;

            if (extBelow > Tol)
            {
                Vec3 extDir = (q1B - pBelow).Unit();
                Vec3 pExtB = q1B + extBelow * extDir;
                verts.Insert(0, new RouteVertex(pExtB, 0.0));
            }
            if (extAbove > Tol)
            {
                Vec3 extDir = (q2A - pAbove).Unit();
                Vec3 pExtA = q2A + extAbove * extDir;
                AddUnique(verts, pExtA, 0.0);
            }

            if (verts.Count < 2) return null;
            result.Vertices.AddRange(verts);
            return result;
        }

        private static void AddUnique(List<RouteVertex> verts, Vec3 pt, double bulge)
        {
            if (verts.Count > 0 && verts[verts.Count - 1].Point.DistanceTo(pt) <= Tol) return;
            verts.Add(new RouteVertex(pt, bulge));
        }
    }

    /// <summary>Input bundle for FilletEngine.BuildRoute (mirrors VTP:RouteData's argument list).</summary>
    public class RouteParams
    {
        public Vec3 PointBelow;
        public Vec3 PointAbove;
        public Vec3 Direction;
        public Vec3 Normal;
        public Vec3 SideDir;   // VTP:SideDir - separate X-axis-based orientation reference, see Cad.UcsHelper.GetSideDir
        public double Width;
        public string Layer;

        public bool UseFillet = true;
        public double RadiusBelow = 0.75;
        public double RadiusAbove = 0.75;
        public bool ReverseBelow;
        public bool ReverseAbove;
        public Vec3? TangentBelow;   // real boundary curve tangent at the lower intersection, if known
        public Vec3? TangentAbove;   // real boundary curve tangent at the upper intersection, if known

        public bool UseExtensionBelow = true;
        public bool UseExtensionAbove = true;
        public double ExtensionBelow = 0.5;
        public double ExtensionAbove = 0.5;

        public Action<string> OnWarning;
    }
}


// ===== FILE: Core\PipeProfile.cs =====
namespace ParallelPipesNet.Core
{
    /// <summary>
    /// One pipe profile entry. Ported from *VTP_Profiles*:
    ///   (list "Name" offL offR "LayerName" pipeCount diameter)
    /// </summary>
    public class PipeProfile
    {
        public string Name { get; set; } = "New profile";
        public double OffsetLeft { get; set; } = 0.055;
        public double OffsetRight { get; set; } = 0.055;
        public string Layer { get; set; } = "VA_Schutzrohr";
        public int PipeCount { get; set; } = 1;
        public double Diameter { get; set; } = 0.11;

        public PipeProfile() { }

        public PipeProfile(string name, double offL, double offR, string layer, int pipeCount, double diameter)
        {
            Name = name;
            OffsetLeft = offL;
            OffsetRight = offR;
            Layer = layer;
            PipeCount = pipeCount;
            Diameter = diameter;
        }

        public PipeProfile Clone() => new PipeProfile(Name, OffsetLeft, OffsetRight, Layer, PipeCount, Diameter);

        public override string ToString() => Name;

        /// <summary>Default factory-shipped profile table (the original 18 DN110/DN160 entries).</summary>
        public static System.Collections.Generic.List<PipeProfile> DefaultProfiles()
        {
            var list = new System.Collections.Generic.List<PipeProfile>();
            for (int i = 1; i <= 9; i++)
                list.Add(new PipeProfile($"{i}_Schutzrohr.DN 110", 0.0550, 0.0550, "VA_Schutzrohr", i, 0.11));
            for (int i = 1; i <= 9; i++)
                list.Add(new PipeProfile($"{i}_Schutzrohr.DN 160", 0.0800, 0.0800, "VA_Schutzrohr_160", i, 0.16));
            return list;
        }
    }

    /// <summary>
    /// One fillet-radius preset. Ported from *VTP_RadPresets*: (list "Name" radius-in-metres).
    /// Radius == null means "(custom)" - selecting it leaves the radius fields untouched.
    /// </summary>
    public class RadiusPreset
    {
        public string Name { get; set; } = "New preset";
        public double? Radius { get; set; }

        public RadiusPreset() { }

        public RadiusPreset(string name, double? radius)
        {
            Name = name;
            Radius = radius;
        }

        public override string ToString() => Radius.HasValue ? $"{Name} ({Radius:0.000} m)" : Name;

        public static System.Collections.Generic.List<RadiusPreset> DefaultPresets() =>
            new System.Collections.Generic.List<RadiusPreset>
            {
                new RadiusPreset("(custom)", null),
                new RadiusPreset("750V", 0.750),
                new RadiusPreset("400V", 0.600),
                new RadiusPreset("230V", 0.450),
                new RadiusPreset("OSE", 0.450),
                new RadiusPreset("RL", 0.750),
                new RadiusPreset("PRL", 0.750),
                new RadiusPreset("SE_fST", 0.375),
                new RadiusPreset("30kV", 1.500),
                new RadiusPreset("D_25", 0.375),
            };
    }
}


// ===== FILE: Core\BulgeUtil.cs =====
using System;
using System.Collections.Generic;

namespace ParallelPipesNet.Core
{
    /// <summary>
    /// Converts a (point, bulge) polyline vertex pair into a tessellated point list,
    /// used by the model-space dynamic-preview Jig to draw smooth arcs without
    /// creating any database entities (used by CadHelpers.TessellateCurveToWorldPoints
    /// for Polyline geometry, and available for any other non-DB preview drawing).
    /// </summary>
    public static class BulgeUtil
    {
        /// <summary>Tessellates a single polyline segment from p1 to p2 with the given bulge into `segments` chords.</summary>
        public static List<Vec3> TessellateSegment(Vec3 p1, Vec3 p2, double bulge, Vec3 normal, int segments = 16)
        {
            var pts = new List<Vec3> { p1 };
            if (Math.Abs(bulge) < 1e-9)
            {
                pts.Add(p2);
                return pts;
            }

            // Standard DXF bulge -> arc conversion:
            // bulge = tan(theta/4), theta = signed included angle.
            // The previous preview tessellation rebuilt the arc with Math.Atan2(X/Y)
            // in raw WCS, which is only valid for WCS/XY polylines.  Custom UCS routes
            // are stored as LWPOLYLINE entities in their own OCS plane.  AutoCAD drew
            // those entities correctly in model space, but the dialog panel re-sampled
            // the bulges in WCS/XY and could therefore put the radius on the opposite
            // side of the filleted leg.  This version rotates the radius vector around
            // the polyline normal, matching the entity plane used by AutoCAD.
            double theta = 4.0 * Math.Atan(bulge);
            double absTheta = Math.Abs(theta);
            double chord = p1.DistanceTo(p2);
            if (chord < 1e-9 || absTheta < 1e-9)
            {
                pts.Add(p2);
                return pts;
            }

            Vec3 n = normal.Length > 1e-12 ? normal.Unit() : Vec3.ZAxis;
            Vec3 chordDir = (p2 - p1).Unit();
            Vec3 perp = n.Cross(chordDir).Unit(); // left-hand perpendicular in the polyline plane
            if (perp.Length < 1e-9)
            {
                pts.Add(p2);
                return pts;
            }

            double radius = chord / (2.0 * Math.Sin(absTheta / 2.0));
            double halfChord = chord / 2.0;
            double centerOffset = Math.Sqrt(Math.Max(0.0, radius * radius - halfChord * halfChord));
            double sign = theta >= 0.0 ? 1.0 : -1.0;
            Vec3 center = 0.5 * (p1 + p2) + sign * centerOffset * perp;

            Vec3 startVec = p1 - center;
            int steps = Math.Max(1, segments);
            for (int i = 1; i <= steps; i++)
            {
                double t = (double)i / steps;
                Vec3 rv = RotateAroundAxis(startVec, n, theta * t);
                pts.Add(center + rv);
            }

            // Snap the last point exactly onto the entity vertex to avoid small
            // accumulated floating-point drift in the panel bounds/fit calculation.
            pts[pts.Count - 1] = p2;
            return pts;
        }

        private static Vec3 RotateAroundAxis(Vec3 v, Vec3 axis, double angle)
        {
            Vec3 a = axis.Length > 1e-12 ? axis.Unit() : Vec3.ZAxis;
            double c = Math.Cos(angle);
            double s = Math.Sin(angle);
            return c * v + s * a.Cross(v) + ((1.0 - c) * a.Dot(v)) * a;
        }

        /// <summary>Tessellates a whole polyline route (list of RouteVertex) into a flat point list for line-strip drawing.</summary>
        public static List<Vec3> TessellateRoute(IList<RouteVertex> vertices, Vec3 normal, int segmentsPerArc = 16)
        {
            var all = new List<Vec3>();
            for (int i = 0; i < vertices.Count - 1; i++)
            {
                var seg = TessellateSegment(vertices[i].Point, vertices[i + 1].Point, vertices[i].Bulge, normal, segmentsPerArc);
                if (i > 0) seg.RemoveAt(0); // avoid duplicate joint point
                all.AddRange(seg);
            }
            if (all.Count == 0 && vertices.Count > 0) all.Add(vertices[0].Point);
            return all;
        }
    }
}


// ===== FILE: Core\AppSettings.cs =====
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;

namespace ParallelPipesNet.Core
{
    /// <summary>
    /// All persistent dialog/tool settings. Ported field-for-field from VTP:Defaults.
    /// Persisted as JSON under %APPDATA%\ParallelPipesNet\ instead of the LISP
    /// association-list CFG file (VTP:CfgFile / VTP:SaveCfg / VTP:LoadCfg).
    /// One JSON file per AutoCAD-session-independent settings; the dialog's
    /// screen position is intentionally NOT persisted here (matches the LISP
    /// "Lee Mac session-global pattern" - kept in-memory only, see MainForm).
    /// </summary>
    public class AppSettings
    {
        // Geometry
        public double Gap { get; set; } = 0.0;
        public double PolylineWidth { get; set; } = 0.0;
        public double SearchLengthFactor { get; set; } = 2.5;
        public VerticalDirectionMode Direction { get; set; } = VerticalDirectionMode.CurrentUcsY;
        public IntersectionPairMode PairMode { get; set; } = IntersectionPairMode.ClosestBelowAndAbove;
        public OutputLayerMode OutputLayerMode { get; set; } = OutputLayerMode.CurrentLayer;
        public bool KeepPickingPoints { get; set; } = true;
        public bool DynamicPreview { get; set; } = true;
        public bool UseAngleConstraint { get; set; }
        public double FixedAngleDegrees { get; set; } = 90.0;

        // Fillet radius
        public bool UseFillet { get; set; } = true;
        public bool EqualRadius { get; set; } = true;
        public double RadiusCommon { get; set; } = 0.75;
        public double RadiusBelow { get; set; } = 0.75;
        public double RadiusAbove { get; set; } = 0.75;
        public bool ReverseBelow { get; set; }
        public bool ReverseAbove { get; set; }
        public int RadiusPresetIndex { get; set; } = 0;

        // Extensions
        public bool UseExtensionBelow { get; set; } = true;
        public bool UseExtensionAbove { get; set; } = true;
        public bool EqualExtension { get; set; } = true;
        public double ExtensionCommon { get; set; } = 0.5;
        public double ExtensionBelow { get; set; } = 0.5;
        public double ExtensionAbove { get; set; } = 0.5;

        // Pipe profile / bundle
        public int ProfileIndex { get; set; } = 0;
        public int PipeCount { get; set; } = 1;
        public double PipeOffsetLeft { get; set; } = 0.055;
        public double PipeOffsetRight { get; set; } = 0.055;
        public double PipeDistance { get; set; } = 0.25;
        public int PipeCountMin { get; set; } = 1;
        public int PipeCountMax { get; set; } = 9;
        public double PipeDistanceMin { get; set; } = 0.05;
        public double PipeDistanceMax { get; set; } = 1.00;

        // Offset layer
        public bool UseOffsetLayer { get; set; } = true;
        public string OffsetLayerFilter { get; set; } = "VA_Schutzrohr";
        public string OffsetLayer { get; set; } = "VA_Schutzrohr";

        // Parallel polyline / placement options
        public bool CreateCenterline { get; set; } = true;
        public bool OriginalOnHilfslinien { get; set; } = true;
        public bool DeleteOriginal { get; set; } = true;
        public bool CreateGroupPerPlacement { get; set; }
        public bool AlsoPlaceMirroredCopy { get; set; }

        [JsonIgnore]
        public string ConfigVersion => "1.0-net";

        public static string SettingsFolder =>
            Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ParallelPipesNet");

        public static string SettingsFile => Path.Combine(SettingsFolder, "settings.json");
        public static string ProfilesFile => Path.Combine(SettingsFolder, "profiles.json");
        public static string RadiiFile => Path.Combine(SettingsFolder, "radius_presets.json");
        public static string LogFolder => Path.Combine(SettingsFolder, "logs");

        public static AppSettings Load()
        {
            try
            {
                if (File.Exists(SettingsFile))
                {
                    var json = File.ReadAllText(SettingsFile);
                    var s = JsonConvert.DeserializeObject<AppSettings>(json);
                    if (s != null) return s;
                }
            }
            catch
            {
                // Corrupt/old settings file: fall back to defaults rather than crash on load.
            }
            return new AppSettings();
        }

        public void Save()
        {
            Directory.CreateDirectory(SettingsFolder);
            var json = JsonConvert.SerializeObject(this, Formatting.Indented);
            File.WriteAllText(SettingsFile, json);
        }

        /// <summary>VTP:FilletRadii equivalent: returns (radiusBelow, radiusAbove) honoring UseFillet/EqualRadius.</summary>
        public (double below, double above) EffectiveFilletRadii()
        {
            if (!UseFillet) return (0.0, 0.0);
            return EqualRadius ? (RadiusCommon, RadiusCommon) : (RadiusBelow, RadiusAbove);
        }

        /// <summary>VTP:ExtensionLengths equivalent.</summary>
        public (double below, double above) EffectiveExtensionLengths()
        {
            double eb = EqualExtension ? ExtensionCommon : ExtensionBelow;
            double ea = EqualExtension ? ExtensionCommon : ExtensionAbove;
            return (UseExtensionBelow ? eb : 0.0, UseExtensionAbove ? ea : 0.0);
        }

        /// <summary>VTP:ClampInt-style clamp applied to pipe count using the persisted min/max.</summary>
        public int ClampPipeCount(int n) => MathUtil.ClampInt(n, PipeCountMin, PipeCountMax);

        public double ClampPipeDistance(double d) => Math.Max(PipeDistanceMin, Math.Min(PipeDistanceMax, d));
    }

    public enum VerticalDirectionMode { CurrentUcsY, WorldY }
    public enum IntersectionPairMode { ClosestBelowAndAbove, TwoClosestOverall }
    public enum OutputLayerMode { CurrentLayer, FirstSelectedEntityLayer }

    /// <summary>
    /// Loads/saves pipe profiles and radius presets as JSON, replacing the LISP
    /// self-rewrite trick (PP:ProfilesRewriteSelf / PP:RadiiRewriteSelf, which
    /// edited the .lsp source between ;;&lt;&lt;PARAPIPES-...-BEGIN/END&gt;&gt; sentinels).
    /// JSON storage is strictly safer: it can never corrupt the plugin's own code.
    /// </summary>
    public static class ProfileStore
    {
        public static List<PipeProfile> LoadProfiles()
        {
            try
            {
                if (File.Exists(AppSettings.ProfilesFile))
                {
                    var json = File.ReadAllText(AppSettings.ProfilesFile);
                    var list = JsonConvert.DeserializeObject<List<PipeProfile>>(json);
                    if (list != null && list.Count > 0) return list;
                }
            }
            catch { /* fall back to defaults */ }

            var defaults = PipeProfile.DefaultProfiles();
            SaveProfiles(defaults);
            return defaults;
        }

        public static void SaveProfiles(List<PipeProfile> profiles)
        {
            Directory.CreateDirectory(AppSettings.SettingsFolder);
            File.WriteAllText(AppSettings.ProfilesFile, JsonConvert.SerializeObject(profiles, Formatting.Indented));
        }

        public static List<RadiusPreset> LoadRadiusPresets()
        {
            try
            {
                if (File.Exists(AppSettings.RadiiFile))
                {
                    var json = File.ReadAllText(AppSettings.RadiiFile);
                    var list = JsonConvert.DeserializeObject<List<RadiusPreset>>(json);
                    if (list != null && list.Count > 0) return list;
                }
            }
            catch { /* fall back to defaults */ }

            var defaults = RadiusPreset.DefaultPresets();
            SaveRadiusPresets(defaults);
            return defaults;
        }

        public static void SaveRadiusPresets(List<RadiusPreset> presets)
        {
            Directory.CreateDirectory(AppSettings.SettingsFolder);
            File.WriteAllText(AppSettings.RadiiFile, JsonConvert.SerializeObject(presets, Formatting.Indented));
        }
    }
}


// ===== FILE: Core\AppLog.cs =====
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace ParallelPipesNet.Core
{
    public enum LogLevel { Trace, Info, Warn, Error }

    public struct LogEntry
    {
        public DateTime Time;
        public LogLevel Level;
        public string Source;   // e.g. "PvSplitApi", "MultiPipeDraw"
        public string Message;

        /// <summary>Formats as: 05:29:43  [INFO]   [PvSplitApi]  SplitDataLines.Add(Double rawStation, Double datum)</summary>
        public override string ToString() =>
            $"{Time:HH:mm:ss}  [{Level.ToString().ToUpperInvariant(),-5}]  [{Source}]  {Message}";
    }

    /// <summary>
    /// Central, in-memory + file-backed log used by the log panel and by any
    /// diagnostic tracing while developing/tuning the geometry engine.
    /// Call AppLog.Save() (or use the "Save log" button in the UI) to persist
    /// the current session so it can be attached to bug reports or replayed
    /// for future tuning of the placement/fillet logic.
    /// </summary>
    public static class AppLog
    {
        private static readonly List<LogEntry> _entries = new List<LogEntry>();
        private static readonly object _lock = new object();

        public static event Action<LogEntry> EntryAdded;

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

        public static void Trace(string source, string message) => Write(LogLevel.Trace, source, message);
        public static void Info(string source, string message) => Write(LogLevel.Info, source, message);
        public static void Warn(string source, string message) => Write(LogLevel.Warn, source, message);
        public static void Error(string source, string message) => Write(LogLevel.Error, source, message);

        public static void Write(LogLevel level, string source, string message)
        {
            var entry = new LogEntry { Time = DateTime.Now, Level = level, Source = source, Message = message };
            lock (_lock) _entries.Add(entry);
            EntryAdded?.Invoke(entry);
        }

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

        /// <summary>
        /// Saves the current in-memory log to %APPDATA%\ParallelPipesNet\logs\ with a
        /// timestamped filename, and returns the path written. This is the "store this
        /// log so the function can be improved" mechanism requested for the tool.
        /// </summary>
        public static string Save(string suggestedFileName = null)
        {
            Directory.CreateDirectory(AppSettings.LogFolder);
            string name = suggestedFileName ?? $"ParallelPipes_{DateTime.Now:yyyy-MM-dd_HH.mm.ss}.log";
            string path = Path.Combine(AppSettings.LogFolder, name);

            var sb = new StringBuilder();
            sb.AppendLine($"# ParallelPipes.NET log - {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
            sb.AppendLine($"# Build: {BuildStamp.Version}");
            sb.AppendLine();
            foreach (var e in Entries)
                sb.AppendLine(e.ToString());

            File.WriteAllText(path, sb.ToString());
            return path;
        }
    }
}


// ===== FILE: Commands.cs =====
using System;
using System.Collections.Generic;
using System.Linq;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Runtime;
using ParallelPipesNet.Cad;
using ParallelPipesNet.Core;
using ParallelPipesNet.UI;

[assembly: ExtensionApplication(typeof(ParallelPipesNet.PluginEntry))]
[assembly: CommandClass(typeof(ParallelPipesNet.Commands))]

namespace ParallelPipesNet
{
    /// <summary>
    /// Runs once when NETLOAD/AutoCAD loads the assembly. Prints and logs the
    /// build version banner, e.g. "1.0.001_01.07.2026_14.32.09" - the requested
    /// "display after loading the dll, the version each time a new version is
    /// loaded" feature.
    /// </summary>
    public class PluginEntry : IExtensionApplication
    {
        public void Initialize()
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            string msg = $"\nParallelPipes.NET v{BuildStamp.Version} loaded. " +
                         "Type VTPCS to draw dynamic multipipe Schutzrohr bundles.";
            doc?.Editor.WriteMessage(msg);
            AppLog.Info("PluginEntry", $"ParallelPipesNet loaded - build {BuildStamp.Version}");
        }

        public void Terminate()
        {
            AppLog.Info("PluginEntry", "ParallelPipesNet unloading.");
        }
    }

    public class Commands
    {
        // Session-scoped state, mirrors the LISP *VTP_...* globals for the duration of one AutoCAD session.
        private static AppSettings _settings;
        private static List<PipeProfile> _profiles;
        private static List<RadiusPreset> _radiusPresets;
        private static MultiPipeDrawer _drawer;
        private static List<ObjectId> _boundaryIds = new List<ObjectId>();
        private static MainForm _form;

        /// <summary>
        /// Kept alive between settings changes (see OnLivePreviewRefreshRequested) so
        /// the ACTUAL model-space viewport - not just the WinForms panel - stays live
        /// when the user tweaks a dialog setting without an active Place drag.
        /// Disposed (erasing its transients) whenever an interactive drag starts, so
        /// the two preview mechanisms never draw overlapping transients at once.
        /// </summary>
        private static PipeBundlePreviewSession _idleSession;
        private static Vec3? _lastPreviewPointWcs;
        private static string _lastCommittedPreviewSignature;

        private static readonly SelectionFilter BoundaryFilter = new SelectionFilter(new[]
        {
            new TypedValue((int)DxfCode.Start, "LINE,ARC,CIRCLE,ELLIPSE,LWPOLYLINE,POLYLINE,SPLINE,RAY,XLINE")
        });

        [CommandMethod("VTPCS")]
        public void ParaPipes() => RunTool();

        private void RunTool()
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            if (doc == null) return;

            EnsureSessionState();

            if (_form == null || _form.IsDisposed)
            {
                _form = new MainForm(_settings, _profiles, _radiusPresets, _drawer);
                _form.SelectBoundaryRequested += OnSelectBoundaryRequested;
                _form.PlaceRequested += OnPlaceRequested;
                _form.UndoRequested += OnUndoRequested;
                _form.LivePreviewRefreshRequested += OnLivePreviewRefreshRequested;
                _form.FormClosed += (s, e2) => { _idleSession?.Dispose(); _idleSession = null; };
                Application.ShowModelessDialog(_form);
            }
            else
            {
                _form.Activate();
            }

            _form.RefreshBoundaryLabel(_boundaryIds.Count);
        }

        private void EnsureSessionState()
        {
            if (_settings == null) _settings = AppSettings.Load();
            if (_profiles == null) _profiles = ProfileStore.LoadProfiles();
            if (_radiusPresets == null) _radiusPresets = ProfileStore.LoadRadiusPresets();
            if (_drawer == null) _drawer = new MultiPipeDrawer(_settings);
        }

        private void OnSelectBoundaryRequested(object sender, EventArgs e)
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            var ed = doc.Editor;

            _form.Hide(); // let the user see/pick in the drawing without the dialog in the way
            var res = ed.GetSelection(BoundaryFilter);
            _form.Show();

            if (res.Status != PromptStatus.OK)
            {
                ed.WriteMessage("\nNo objects selected.");
                AppLog.Warn("BoundarySelect", "Selection cancelled or empty.");
                return;
            }

            _boundaryIds = res.Value.GetObjectIds().ToList();
            _lastPreviewPointWcs = null;
            _lastCommittedPreviewSignature = null;
            AppLog.Info("BoundarySelect", $"{_boundaryIds.Count} boundary object(s) selected.");
            _form.RefreshBoundaryLabel(_boundaryIds.Count);
        }

        private void OnPlaceRequested(object sender, PlaceRequestArgs e)
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            if (doc == null) return;
            var ed = doc.Editor;

            if (_boundaryIds.Count == 0)
            {
                ed.WriteMessage("\nSelect boundary curves first.");
                AppLog.Warn("Place", "No boundary curves selected.");
                return;
            }

            var profile = e.Profile ?? (_profiles.Count > 0 ? _profiles[MathUtil.ClampInt(_settings.ProfileIndex, 0, _profiles.Count - 1)] : new PipeProfile());

            // Final place commits the currently displayed preview point directly.
            // This is the key difference from the normal Place button, which starts
            // another interactive point prompt.  It fixes the old confusing
            // Place+Close behavior where the user expected the visible preview to be
            // written as real DWG entities but the command simply entered another
            // pick loop.
            if (e.UseCurrentPreviewPoint)
            {
                Vec3? finalPoint = _idleSession?.LastPickPointWcs ?? _lastPreviewPointWcs;

                if (finalPoint.HasValue)
                {
                    string signature = BuildPlacementSignature(finalPoint.Value, profile);
                    if (string.Equals(signature, _lastCommittedPreviewSignature, StringComparison.Ordinal))
                    {
                        ed.WriteMessage("\nCurrent preview has already been placed. Move the preview point or change settings before using Final place again.");
                        AppLog.Warn("FinalPlace", "Duplicate final-place request ignored.");
                        return;
                    }

                    _idleSession?.Dispose();
                    _idleSession = null;

                    bool committed;
                    using (doc.LockDocument())
                    {
                        committed = CommitAtPoint(doc, ed, profile, finalPoint.Value, "FinalPlace");
                    }

                    if (committed)
                    {
                        if (e.CloseDialogAfterPlace && _form != null && !_form.IsDisposed)
                            _form.Close();
                        else
                            OnLivePreviewRefreshRequested(sender, EventArgs.Empty);
                    }
                    return;
                }

                ed.WriteMessage("\nNo live preview point is available yet. Pick a point to final-place the bundle.");
            }

            // Hand off from the idle (settings-driven) preview to the interactive
            // drag preview - both use TransientManager, so only one may be active
            // at a time or their transients would overlap in the viewport.
            _idleSession?.Dispose();
            _idleSession = null;

            bool keepPicking = !e.CloseDialogAfterPlace && _settings.KeepPickingPoints;
            bool placing = true;

            while (placing)
            {
                bool committed = false;

                using (doc.LockDocument())
                {
                    Vec3? pickedPoint = null;

                    using (var session = new PipeBundlePreviewSession(_drawer, ed, _boundaryIds, profile))
                    {
                        var pointResult = session.Run(
                            "Move cursor between selected curves. Click to place, Esc to cancel: ",
                            (sols, geom) =>
                            {
                                if (session.LastPickPointWcs.HasValue)
                                    _lastPreviewPointWcs = session.LastPickPointWcs.Value;
                                _form.UpdateSpanReadout(sols);
                                _form.UpdateLivePreview(geom);
                            },
                            _settings.DynamicPreview);

                        if (session.LastPickPointWcs.HasValue)
                            _lastPreviewPointWcs = session.LastPickPointWcs.Value;

                        if (pointResult.Status != PromptStatus.OK)
                        {
                            placing = false; // Esc / cancel -> back to the dialog, matches the LISP version
                            break;
                        }

                        // Editor.GetPoint() already returns WCS coordinates.  The live
                        // preview solves from PointMonitorEventArgs.Context.ComputedPoint,
                        // also WCS, so both preview and commit use the same point space.
                        pickedPoint = UcsHelper.ToVec3(pointResult.Value);
                        _lastPreviewPointWcs = pickedPoint.Value;
                    }

                    // Dispose the transient preview before writing final DB entities.
                    // This avoids the confusing moment where the temporary preview and
                    // the committed polylines are both visible after a click.
                    if (pickedPoint.HasValue)
                        committed = CommitAtPoint(doc, ed, profile, pickedPoint.Value, e.CloseDialogAfterPlace ? "FinalPlace" : "Place");
                }

                if (e.CloseDialogAfterPlace && committed)
                {
                    if (_form != null && !_form.IsDisposed) _form.Close();
                    break;
                }

                if (!keepPicking) placing = false;
            }
        }

        private bool CommitAtPoint(Document doc, Editor ed, PipeProfile profile, Vec3 pickPointWcs, string logSource)
        {
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                var solutions = _drawer.SolveBundle(tr, ed, _boundaryIds, pickPointWcs, profile);
                var created = _drawer.CommitBundle(tr, doc.Database, solutions, profile, logSource);

                if (created.Count == 0)
                {
                    ed.WriteMessage("\nNo entity created. Move to a valid position or Esc for the dialog.");
                    AppLog.Warn(logSource, "Commit produced no entities.");
                    return false;
                }

                tr.Commit();

                _lastPreviewPointWcs = pickPointWcs;
                _lastCommittedPreviewSignature = BuildPlacementSignature(pickPointWcs, profile);

                try
                {
                    _form.UpdateSpanReadout(solutions);
                    _form.RefreshPlacementsList(_drawer.History);
                }
                catch (System.Exception ex)
                {
                    // Placement has already committed to the drawing.  A modeless UI
                    // refresh must never turn a successful DB write into a JIT crash.
                    AppLog.Warn(logSource, "Placed entities, but UI history refresh failed: " + ex.Message);
                }

                ed.WriteMessage($"\nPlaced {created.Count} pipe entity/entities.");
                return true;
            }
        }

        private void OnUndoRequested(object sender, EventArgs e)
        {
            var doc = Application.DocumentManager.MdiActiveDocument;
            using (doc.LockDocument())
            using (var tr = doc.Database.TransactionManager.StartTransaction())
            {
                _drawer.UndoLast(tr);
                tr.Commit();
            }
            _form.RefreshPlacementsList(_drawer.History);
        }

        private static string BuildPlacementSignature(Vec3 point, PipeProfile profile)
        {
            // Used only to prevent accidental double-click / Place-then-Final-place
            // duplicates at the exact same solved point with the exact same settings.
            // Rounded coordinates tolerate tiny AutoCAD cursor precision jitter while
            // still allowing deliberate nearby placements.
            return string.Join("|", new object[]
            {
                Math.Round(point.X, 6),
                Math.Round(point.Y, 6),
                Math.Round(point.Z, 6),
                profile?.Name ?? string.Empty,
                profile?.Layer ?? string.Empty,
                Math.Round(profile?.OffsetLeft ?? 0.0, 6),
                Math.Round(profile?.OffsetRight ?? 0.0, 6),
                profile?.PipeCount ?? 0,
                _settings.PipeCount,
                Math.Round(_settings.PipeDistance, 6),
                Math.Round(_settings.PipeOffsetLeft, 6),
                Math.Round(_settings.PipeOffsetRight, 6),
                Math.Round(_settings.Gap, 6),
                Math.Round(_settings.PolylineWidth, 6),
                _settings.Direction,
                _settings.PairMode,
                _settings.UseFillet,
                _settings.EqualRadius,
                Math.Round(_settings.RadiusCommon, 6),
                Math.Round(_settings.RadiusBelow, 6),
                Math.Round(_settings.RadiusAbove, 6),
                _settings.ReverseBelow,
                _settings.ReverseAbove,
                _settings.UseExtensionBelow,
                _settings.UseExtensionAbove,
                _settings.EqualExtension,
                Math.Round(_settings.ExtensionCommon, 6),
                Math.Round(_settings.ExtensionBelow, 6),
                Math.Round(_settings.ExtensionAbove, 6),
                _settings.UseOffsetLayer,
                _settings.OffsetLayer ?? string.Empty,
                _settings.CreateCenterline
            });
        }

        /// <summary>
        /// Fires whenever a pipe/geometry setting changes, or boundary curves are
        /// (re)selected, while no drag is in progress. Without this, the preview
        /// panel stays frozen on whatever the last drag session showed and ignores
        /// later settings edits entirely. Reuses the last live cursor/click point so
        /// radius, extension, spacing and count changes redraw the same model-space
        /// position the user was looking at; falls back to the boundary extents center
        /// only before the first live point is known.
        /// </summary>
        private void OnLivePreviewRefreshRequested(object sender, EventArgs e)
        {
            if (_boundaryIds.Count == 0 || !_settings.DynamicPreview)
            {
                _idleSession?.Dispose();
                _idleSession = null;
                return;
            }

            var doc = Application.DocumentManager.MdiActiveDocument;
            if (doc == null) return;
            var ed = doc.Editor;

            EnsureSessionState();
            var profile = _profiles.Count > 0 ? _profiles[MathUtil.ClampInt(_settings.ProfileIndex, 0, _profiles.Count - 1)] : new PipeProfile();

            try
            {
                // Recreated on every call: settings changes aren't a rapid-fire mouse
                // drag (unlike the interactive Run() loop), so the small overhead of a
                // fresh session is negligible, and it trivially picks up a changed
                // boundary selection or profile without needing separate invalidation.
                _idleSession?.Dispose();
                _idleSession = new PipeBundlePreviewSession(_drawer, ed, _boundaryIds, profile);

                Vec3? previewPoint = _lastPreviewPointWcs;
                if (previewPoint == null)
                {
                    using (doc.LockDocument())
                    using (var tr = doc.Database.TransactionManager.StartTransaction())
                    {
                        previewPoint = UcsHelper.GetBoundaryCenter(tr, _boundaryIds);
                        tr.Commit();
                    }
                }
                if (previewPoint == null) return;

                using (doc.LockDocument())
                {
                    _idleSession.UpdateOnce(previewPoint.Value); // rebuilds real transients in the viewport (opens its own transaction)
                }

                _form.UpdateSpanReadout(_idleSession.LastSolutions);
                _form.UpdateLivePreview(_idleSession.LastGeometry);
            }
            catch
            {
                // Best-effort visual refresh only - a transient failure here (e.g.
                // mid-edit invalid numeric field) must never surface as an error to
                // the user; the panel/viewport simply keep their last good frame.
            }
        }
    }
}


// ===== FILE: Cad\UcsHelper.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.Cad
{
    /// <summary>UCS-aware helpers, ported from VTP:SideDir / VTP:ActiveSpace / VTP:SSDiagonal.</summary>
    public static class UcsHelper
    {
        /// <summary>
        /// Working "vertical" direction and plane normal, honoring the settings'
        /// Direction mode and the optional fixed-angle constraint (VTP:SegmentAtPoint's
        /// direction block).
        /// </summary>
        public static (Vec3 dir, Vec3 normal) GetWorkingDirection(Editor ed, AppSettings settings)
        {
            if (settings.UseAngleConstraint)
            {
                double fa = settings.FixedAngleDegrees * Math.PI / 180.0;
                var dir = new Vec3(-Math.Sin(fa), Math.Cos(fa), 0.0).Unit();
                return (dir, Vec3.ZAxis);
            }

            if (settings.Direction == VerticalDirectionMode.WorldY)
                return (Vec3.YAxis, Vec3.ZAxis);

            // Current UCS Y, transformed into WCS.
            Matrix3d ucsToWcs = ed.CurrentUserCoordinateSystem;
            Vector3d yAxisWcs = Vector3d.YAxis.TransformBy(ucsToWcs).GetNormal();
            Vector3d zAxisWcs = Vector3d.ZAxis.TransformBy(ucsToWcs).GetNormal();
            return (new Vec3(yAxisWcs.X, yAxisWcs.Y, yAxisWcs.Z), new Vec3(zAxisWcs.X, zAxisWcs.Y, zAxisWcs.Z));
        }

        /// <summary>The UCS X axis in WCS, used for lateral pipe-to-pipe shifting (matches the LISP's fixed (1 0 0) UCS shift).</summary>
        public static Vec3 GetUcsXAxisWcs(Editor ed)
        {
            Matrix3d ucsToWcs = ed.CurrentUserCoordinateSystem;
            Vector3d xAxisWcs = Vector3d.XAxis.TransformBy(ucsToWcs).GetNormal();
            return new Vec3(xAxisWcs.X, xAxisWcs.Y, xAxisWcs.Z);
        }

        /// <summary>
        /// VTP:SideDir - the "side" reference used ONLY to orient fillet tangents
        /// consistently (FilletEngine.OrientTangent), so the two end-fillets curve in
        /// opposite rotational senses and form a clean S rather than a self-crossing
        /// hook. This is deliberately a DIFFERENT vector from GetWorkingDirection's
        /// `dir` (the vertical run direction) even though both can be UCS-Y-adjacent
        /// concepts - the original LISP keeps them as two separate functions
        /// (VTP:SideDir vs. the direction block in VTP:SegmentAtPoint), and always
        /// uses the X axis here, never Y, and never the fixed-angle override:
        ///   (defun VTP:SideDir ()
        ///     (if (= (VTP:Get 'dir "0") "1") '(1.0 0.0 0.0)
        ///       (VTP:unit (trans '(1.0 0.0 0.0) 1 0 T))))
        /// World/WCS Y mode -> raw WCS X axis (no UCS transform).
        /// Current UCS Y mode (default) -> UCS X axis transformed into WCS.
        /// Note this does NOT change when the fixed-angle constraint is active,
        /// matching the LISP original exactly.
        /// </summary>
        public static Vec3 GetSideDir(Editor ed, AppSettings settings)
        {
            if (settings.Direction == VerticalDirectionMode.WorldY)
                return Vec3.XAxis;
            return GetUcsXAxisWcs(ed);
        }

        public static Point3d ToPoint3d(Vec3 v) => new Point3d(v.X, v.Y, v.Z);
        public static Vec3 ToVec3(Point3d p) => new Vec3(p.X, p.Y, p.Z);

        /// <summary>
        /// Projects a list of WCS points into the CURRENT UCS's local XY plane
        /// (matches the rest of this tool's UCS-relative conventions - see
        /// GetWorkingDirection/GetSideDir, both UCS-based). Used so the WinForms
        /// preview panel renders geometry oriented "as if looking straight down the
        /// current UCS", matching what a plan-view drafting session actually shows
        /// on screen - without this, a rotated UCS/view (a tilted UCS icon, as
        /// opposed to a straight top-down plan view) makes the panel's orientation
        /// visually disagree with the AutoCAD viewport even though the underlying
        /// geometry is identical.
        /// </summary>
        public static List<Vec3> WcsListToUcsPlane(List<Vec3> wcsPoints, Matrix3d wcsToUcs)
        {
            var result = new List<Vec3>(wcsPoints.Count);
            foreach (var v in wcsPoints)
            {
                var p = ToPoint3d(v).TransformBy(wcsToUcs);
                result.Add(new Vec3(p.X, p.Y, p.Z));
            }
            return result;
        }


        /// <summary>
        /// Projects WCS points into the current UCS's local XY coordinates. This is
        /// the basis used by the bundle solver itself (CurrentUcsY / UCS-X spacing),
        /// so the WinForms preview shows fillets and extensions in the same custom
        /// UCS frame that creates the model-space transient/final geometry.
        /// </summary>
        public static List<Vec3> WcsListToCurrentUcsPlane(Editor ed, List<Vec3> wcsPoints)
        {
            if (wcsPoints == null) return new List<Vec3>();
            try
            {
                return WcsListToUcsPlane(wcsPoints, ed.CurrentUserCoordinateSystem.Inverse());
            }
            catch
            {
                return new List<Vec3>(wcsPoints);
            }
        }

        /// <summary>
        /// Projects WCS points into the current AutoCAD viewport's display coordinate
        /// system (DCS). This makes the WinForms preview panel match what the user
        /// is actually seeing in model space, including view direction and view twist,
        /// instead of drawing a separate UCS-only interpretation of the same geometry.
        /// </summary>
        public static List<Vec3> WcsListToCurrentViewPlane(Editor ed, List<Vec3> wcsPoints)
        {
            if (wcsPoints == null) return new List<Vec3>();

            try
            {
                using (var view = ed.GetCurrentView())
                {
                    // Standard WCS -> DCS transform pattern used by AutoCAD .NET view
                    // calculations: align to the view direction, translate to the
                    // target, apply the negative view twist, then invert to obtain
                    // display coordinates. Z is intentionally discarded by the panel.
                    Matrix3d wcsToDcs = Matrix3d.PlaneToWorld(view.ViewDirection);
                    wcsToDcs = Matrix3d.Displacement(view.Target - Point3d.Origin) * wcsToDcs;
                    wcsToDcs = Matrix3d.Rotation(-view.ViewTwist, view.ViewDirection, view.Target) * wcsToDcs;
                    wcsToDcs = wcsToDcs.Inverse();

                    var result = new List<Vec3>(wcsPoints.Count);
                    foreach (var v in wcsPoints)
                    {
                        var p = ToPoint3d(v).TransformBy(wcsToDcs);
                        result.Add(new Vec3(p.X, p.Y, 0.0));
                    }
                    return result;
                }
            }
            catch
            {
                // If AutoCAD cannot provide a current view (rare, but possible during
                // startup/headless test contexts), fall back to the older UCS-plane
                // projection rather than dropping the preview completely.
                return WcsListToUcsPlane(wcsPoints, ed.CurrentUserCoordinateSystem.Inverse());
            }
        }

        /// <summary>VTP:SSDiagonal - bounding-box diagonal of the boundary selection, used to size the intersection search line.</summary>
        public static double BoundarySelectionDiagonal(Transaction tr, IEnumerable<ObjectId> ids, double viewSize)
        {
            var total = CombinedExtents(tr, ids);
            if (total.HasValue)
                return Math.Max(total.Value.MinPoint.DistanceTo(total.Value.MaxPoint), 1.0);

            return Math.Max(2.0 * viewSize, 1.0);
        }

        /// <summary>Combined bounding-box center of the given entities, in WCS - used as a stand-in cursor position for the static (no-active-drag) preview.</summary>
        public static Vec3? GetBoundaryCenter(Transaction tr, IEnumerable<ObjectId> ids)
        {
            var total = CombinedExtents(tr, ids);
            if (!total.HasValue) return null;
            var mid = total.Value.MinPoint + (total.Value.MaxPoint - total.Value.MinPoint) * 0.5;
            return ToVec3(mid);
        }

        private static Extents3d? CombinedExtents(Transaction tr, IEnumerable<ObjectId> ids)
        {
            Extents3d? total = null;
            foreach (var id in ids)
            {
                try
                {
                    var ent = (Entity)tr.GetObject(id, OpenMode.ForRead);
                    var ext = ent.GeometricExtents;
                    total = total == null ? ext : Combine(total.Value, ext);
                }
                catch { /* entity may not report extents; skip */ }
            }
            return total;
        }

        private static Extents3d Combine(Extents3d a, Extents3d b)
        {
            var min = new Point3d(Math.Min(a.MinPoint.X, b.MinPoint.X), Math.Min(a.MinPoint.Y, b.MinPoint.Y), Math.Min(a.MinPoint.Z, b.MinPoint.Z));
            var max = new Point3d(Math.Max(a.MaxPoint.X, b.MaxPoint.X), Math.Max(a.MaxPoint.Y, b.MaxPoint.Y), Math.Max(a.MaxPoint.Z, b.MaxPoint.Z));
            return new Extents3d(min, max);
        }
    }
}


// ===== FILE: Cad\SegmentBuilder.cs =====
using System;
using System.Collections.Generic;
using System.Linq;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.Geometry;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.Cad
{
    /// <summary>One boundary/curve intersection candidate along the scan direction.</summary>
    public class IntersectionRecord
    {
        public double Param;       // signed distance from the pick point along `dir`
        public Vec3 Point;
        public ObjectId SourceId;
    }

    /// <summary>
    /// Finds the pair of boundary-curve intersections that bracket a pick point and
    /// builds the fillet route between them. Ported from VTP:SegmentAtPoint /
    /// VTP:AddIntersectionRecords / VTP:ChoosePairRec / VTP:CurveTangentAtPoint.
    /// The original used a real temporary construction LINE + vla-IntersectWith;
    /// here we use Curve.IntersectWith directly, which is faster and avoids
    /// creating/deleting a transient database entity.
    /// </summary>
    public static class SegmentBuilder
    {
        private const double Tol = 1e-7;

        /// <summary>VTP:CurveTangentAtPoint - real boundary tangent at the closest point to `p`, projected into the working plane.</summary>
        public static Vec3? CurveTangentAtPoint(Transaction tr, ObjectId curveId, Vec3 p, Vec3 normal)
        {
            try
            {
                var curve = (Curve)tr.GetObject(curveId, OpenMode.ForRead);
                var pt3 = new Point3d(p.X, p.Y, p.Z);
                var cp = curve.GetClosestPointTo(pt3, false);
                double param = curve.GetParameterAtPoint(cp);
                var deriv = curve.GetFirstDerivative(param);
                var tan = new Vec3(deriv.X, deriv.Y, deriv.Z).ProjectToPlane(normal);
                return tan.Length > 1e-9 ? tan.Unit() : (Vec3?)null;
            }
            catch
            {
                return null; // defensive: mirrors the vl-catch-all-apply guards in the LISP version
            }
        }

        /// <summary>
        /// Scans `boundaryIds` for intersections with the infinite line through `pickPoint`
        /// in direction `dir`, and returns the two records that bracket the pick point
        /// (VTP:ChoosePairRec). Returns (null, null) if fewer than two usable intersections exist.
        /// </summary>
        public static (IntersectionRecord below, IntersectionRecord above) FindBracketingPair(
            Transaction tr, IEnumerable<ObjectId> boundaryIds, Vec3 pickPoint, Vec3 dir, double searchLength,
            IntersectionPairMode mode)
        {
            var a = pickPoint - searchLength * dir;
            var b = pickPoint + searchLength * dir;
            var scanA = new Point3d(a.X, a.Y, a.Z);
            var scanB = new Point3d(b.X, b.Y, b.Z);

            var records = new List<IntersectionRecord>();
            using (var scanLine = new Line(scanA, scanB))
            {
                foreach (var id in boundaryIds)
                {
                    Curve curve;
                    try { curve = (Curve)tr.GetObject(id, OpenMode.ForRead); }
                    catch { continue; }

                    Point3dCollection pts;
                    try
                    {
                        pts = new Point3dCollection();
                        curve.IntersectWith(scanLine, Intersect.OnBothOperands, pts, IntPtr.Zero, IntPtr.Zero);
                    }
                    catch { continue; }

                    foreach (Point3d p in pts)
                    {
                        var v = new Vec3(p.X, p.Y, p.Z);
                        if (records.Any(r => r.Point.DistanceTo(v) <= Tol)) continue; // dedupe
                        double prm = (v - pickPoint).Dot(dir);
                        records.Add(new IntersectionRecord { Param = prm, Point = v, SourceId = id });
                    }
                }
            }

            return ChoosePair(records, mode);
        }

        /// <summary>VTP:ChoosePairRec.</summary>
        private static (IntersectionRecord below, IntersectionRecord above) ChoosePair(
            List<IntersectionRecord> recs, IntersectionPairMode mode)
        {
            var good = recs.Where(r => Math.Abs(r.Param) > Tol).ToList();
            if (good.Count < 2) return (null, null);

            if (mode == IntersectionPairMode.ClosestBelowAndAbove)
            {
                IntersectionRecord below = null, above = null;
                foreach (var r in good)
                {
                    if (r.Param < -Tol)
                    {
                        if (below == null || r.Param > below.Param) below = r;
                    }
                    else if (r.Param > Tol)
                    {
                        if (above == null || r.Param < above.Param) above = r;
                    }
                }
                if (below != null && above != null) return (below, above);
                return TwoNearest(good);
            }

            return TwoNearest(good);
        }

        private static (IntersectionRecord, IntersectionRecord) TwoNearest(List<IntersectionRecord> recs)
        {
            var sorted = recs.OrderBy(r => Math.Abs(r.Param)).ToList();
            if (sorted.Count < 2) return (null, null);
            var a = sorted[0];
            var b = sorted[1];
            return a.Param < b.Param ? (a, b) : (b, a);
        }
    }
}


// ===== FILE: Cad\PlacementHistory.cs =====
using System.Collections.Generic;
using System.Linq;
using Autodesk.AutoCAD.DatabaseServices;

namespace ParallelPipesNet.Cad
{
    /// <summary>
    /// Cumulative undo stack of placements, ported from *PP_UndoStack* / *VTP_LastEnts*.
    /// Each placement (one bundle of CL + edge entities, or one mirrored copy) is a
    /// separate entry; "Undo last" pops one entry and erases its entities.
    /// This is deliberately separate from AutoCAD's own command-level undo
    /// (Transaction/UsingDatabaseTransaction), which still wraps each placement too -
    /// so both PARAPIPES' own "Undo last" button and the standard AutoCAD U command work.
    /// </summary>
    public class PlacementHistory
    {
        private readonly List<List<ObjectId>> _stack = new List<List<ObjectId>>();

        public IReadOnlyList<ObjectId> LastPlacement => _stack.Count > 0 ? _stack[_stack.Count - 1] : null;
        public int Count => _stack.Count;

        /// <summary>All placements in stack order (oldest first), for the "Placed Pipes" grid + viewport highlight.</summary>
        public IReadOnlyList<IReadOnlyList<ObjectId>> AllPlacements => _stack;

        public void Push(IEnumerable<ObjectId> ids)
        {
            var list = ids.Where(i => !i.IsNull).ToList();
            if (list.Count > 0) _stack.Add(list);
        }

        /// <summary>Pops the most recent placement and returns its entity ids for the caller to erase.</summary>
        public List<ObjectId> PopLast()
        {
            if (_stack.Count == 0) return null;
            var last = _stack[_stack.Count - 1];
            _stack.RemoveAt(_stack.Count - 1);
            return last;
        }

        public void Clear() => _stack.Clear();
    }
}


// ===== FILE: Cad\PipeBundlePreviewSession.cs =====
using System;
using System.Collections.Generic;
using Autodesk.AutoCAD.Colors;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.GraphicsInterface;
using Autodesk.AutoCAD.Geometry;
using ParallelPipesNet.Core;
using Polyline = Autodesk.AutoCAD.DatabaseServices.Polyline;

namespace ParallelPipesNet.Cad
{
    /// <summary>
    /// Live cursor-following preview built from REAL (but database-detached) entities,
    /// registered with AutoCAD's TransientManager and refreshed on every cursor move
    /// via Editor.PointMonitor. This replaces the earlier WorldDraw-based DrawJig.
    ///
    /// WHY: a DrawJig's WorldDraw callback re-implements rendering by hand (issuing
    /// raw line/arc primitives) - that's an extra translation step between "the
    /// geometry we computed" and "what appears on screen". TransientManager instead
    /// hands AutoCAD the ACTUAL Polyline entities (built the exact same way, and
    /// using the exact same GetOffsetCurves() call, as MultiPipeDrawer.CommitBundle
    /// uses at final placement) and lets AutoCAD's own rendering pipeline draw them.
    /// The on-screen preview is therefore not merely "close to" the committed
    /// result - during the drag it IS the committed result, just not yet written to
    /// the database. This mirrors the later LISP revisions' "real temporary
    /// entmakex entities, erased and recreated every frame" approach, which
    /// TransientManager implements natively in the managed API.
    /// </summary>
    public class PipeBundlePreviewSession : IDisposable
    {
        private readonly MultiPipeDrawer _drawer;
        private readonly Editor _ed;
        private readonly IReadOnlyList<ObjectId> _boundaryIds;
        private readonly PipeProfile _profile;
        private readonly IntegerCollection _viewports = new IntegerCollection();

        private readonly List<Entity> _transientEntities = new List<Entity>();
        private Action<List<PipeSolution>, PreviewGeometry> _onUpdate;
        private bool _disposed;

        public List<PipeSolution> LastSolutions { get; private set; } = new List<PipeSolution>();

        /// <summary>Last WCS cursor/stand-in point used to solve the preview.</summary>
        public Vec3? LastPickPointWcs { get; private set; }

        /// <summary>
        /// Real, tessellated world-space geometry from the most recent solve - the
        /// same data the transient graphics are built from - exposed so the WinForms
        /// schematic preview panel (UI/PipeBundlePreviewControl.cs) can draw the
        /// actual computed pipes (true orientation/proportions/curvature) instead of
        /// a generic mockup.
        /// </summary>
        public PreviewGeometry LastGeometry { get; private set; } = new PreviewGeometry();

        public PipeBundlePreviewSession(MultiPipeDrawer drawer, Editor ed, IReadOnlyList<ObjectId> boundaryIds, PipeProfile profile)
        {
            _drawer = drawer;
            _ed = ed;
            _boundaryIds = boundaryIds;
            _profile = profile;
        }

        /// <summary>
        /// Runs one interactive point prompt with a live real-entity preview attached.
        /// Always cleans up its transients before returning, even on Esc/cancel.
        /// </summary>
        /// <summary>
        /// Non-interactive refresh: solves and rebuilds the same real transient
        /// graphics as an active drag, at a given WCS point, without requiring an
        /// Editor.GetPoint() prompt to be running. Used to keep the ACTUAL
        /// model-space viewport live when the user tweaks a dialog setting with no
        /// drag in progress - the caller (Commands.cs) keeps one instance of this
        /// session alive between such calls and Dispose()s it once an interactive
        /// Place drag starts (to avoid two overlapping sets of transients) or the
        /// boundary selection/profile changes.
        /// </summary>
        public void UpdateOnce(Vec3 pickPointWcs)
        {
            LastPickPointWcs = pickPointWcs;
            using (var tr = _ed.Document.Database.TransactionManager.StartTransaction())
            {
                LastSolutions = _drawer.SolveBundle(tr, _ed, _boundaryIds, pickPointWcs, _profile);
                RebuildTransients(tr);
                tr.Commit();
            }
        }

        public PromptPointResult Run(string message, Action<List<PipeSolution>, PreviewGeometry> onUpdate = null, bool enablePreview = true)
        {
            _onUpdate = onUpdate;
            var opts = new PromptPointOptions("\n" + message) { AllowNone = false };

            if (!enablePreview)
                return _ed.GetPoint(opts);

            _ed.PointMonitor += OnPointMonitor;
            try
            {
                return _ed.GetPoint(opts);
            }
            finally
            {
                _ed.PointMonitor -= OnPointMonitor;
                ClearTransients();
            }
        }

        private void OnPointMonitor(object sender, PointMonitorEventArgs e)
        {
            try
            {
                var pt = e.Context.ComputedPoint;
                using (var tr = _ed.Document.Database.TransactionManager.StartTransaction())
                {
                    LastPickPointWcs = UcsHelper.ToVec3(pt);
                    LastSolutions = _drawer.SolveBundle(tr, _ed, _boundaryIds, LastPickPointWcs.Value, _profile);
                    RebuildTransients(tr);
                    tr.Commit(); // read-only
                }

                _onUpdate?.Invoke(LastSolutions, LastGeometry);
            }
            catch
            {
                // A PointMonitor handler must never throw back into AutoCAD's input
                // pipeline - if a frame's geometry solve fails (e.g. transient
                // degenerate cursor position), just skip that frame's preview update.
            }
        }

        private void RebuildTransients(Transaction tr)
        {
            ClearTransients();
            var geometry = new PreviewGeometry();

            double offL = _drawer.Settings.PipeOffsetLeft > 0 ? _drawer.Settings.PipeOffsetLeft : (_profile?.OffsetLeft ?? 0.055);
            double offR = _drawer.Settings.PipeOffsetRight > 0 ? _drawer.Settings.PipeOffsetRight : (_profile?.OffsetRight ?? 0.055);

            var tm = TransientManager.CurrentTransientManager;

            foreach (var sol in LastSolutions)
            {
                if (!sol.Success || sol.CenterlineRoute == null) continue;

                var pl = CadHelpers.BuildDetachedPolyline(sol.CenterlineRoute);
                if (pl == null) continue;
                pl.ColorIndex = 4; // cyan centerline, matches original preview colors
                RegisterTransient(tm, pl);
                geometry.Centerlines.Add(ProjectForPanel(CadHelpers.TessellateCurveToWorldPoints(pl)));

                AddOffsetTransients(tm, pl, offL, geometry);
                AddOffsetTransients(tm, pl, -offR, geometry);
            }

            AddBoundaryReference(tr, _boundaryIds, geometry, _ed);

            LastGeometry = geometry;
            _ed.UpdateScreen();
        }

        private void AddOffsetTransients(TransientManager tm, Polyline source, double distance, PreviewGeometry geometry)
        {
            if (Math.Abs(distance) < 1e-9) return;

            DBObjectCollection offsets;
            try { offsets = source.GetOffsetCurves(distance); }
            catch { return; } // degenerate geometry mid-drag - just skip this frame's edge

            foreach (DBObject obj in offsets)
            {
                if (obj is Entity ent)
                {
                    ent.ColorIndex = 1; // red edges, matches original preview colors
                    RegisterTransient(tm, ent);
                    if (ent is Curve curve)
                        geometry.Edges.Add(ProjectForPanel(CadHelpers.TessellateCurveToWorldPoints(curve)));
                }
                else
                {
                    obj.Dispose();
                }
            }
        }

        private List<Vec3> ProjectForPanel(List<Vec3> wcsPoints)
        {
            // The pipe solver is UCS-based (Current UCS Y for the vertical run and
            // Current UCS X for bundle spacing / side orientation).  The dialog preview
            // must therefore be projected into the ACTIVE UCS plane, not only into the
            // current viewport/DCS.  Using DCS made custom UCS drawings look correct in
            // model space but "strange" in the panel: fillet exits and extension legs
            // were being viewed through the screen axes instead of the axes that built
            // the geometry.
            return UcsHelper.WcsListToCurrentUcsPlane(_ed, wcsPoints);
        }

        private void RegisterTransient(TransientManager tm, Entity ent)
        {
            tm.AddTransient(ent, TransientDrawingMode.DirectShortTerm, 128, _viewports);
            _transientEntities.Add(ent);
        }

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

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

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

        // ─────────────────────────────────────────────────────────────────
        // Shared helper.
        // ─────────────────────────────────────────────────────────────────

        /// <summary>Tessellates the selected boundary curves themselves into the geometry, so the panel/viewport show the same reference context.</summary>
        private static void AddBoundaryReference(Transaction tr, IReadOnlyList<ObjectId> boundaryIds, PreviewGeometry geometry, Editor ed)
        {
            foreach (var id in boundaryIds)
            {
                try
                {
                    if (tr.GetObject(id, OpenMode.ForRead) is Curve curve)
                        geometry.Boundaries.Add(UcsHelper.WcsListToCurrentUcsPlane(ed, CadHelpers.TessellateCurveToWorldPoints(curve)));
                }
                catch { /* boundary entity erased/unreadable - skip it for this frame */ }
            }
        }
    }
}


// ===== FILE: Cad\MultiPipeDrawer.cs =====
using System;
using System.Collections.Generic;
using System.Linq;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.DatabaseServices;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.Cad
{
    /// <summary>One pipe's solved geometry within a bundle - used by both the preview jig and the final draw.</summary>
    public class PipeSolution
    {
        public int Index;                 // 1-based pipe number within the bundle
        public double LateralOffset;      // signed distance from the bundle centerline
        public RouteResult CenterlineRoute;
        public bool Success => CenterlineRoute != null;
        public string FailureReason;
    }

    /// <summary>
    /// Orchestrates an N-pipe bundle: computes each pipe's independent chamfered
    /// centerline (VTP:SegmentAtPoint + FilletEngine.BuildRoute) shifted along the
    /// UCS X axis, exactly matching VTP:MultiPipeDraw / VTP:MultiPreview. Route
    /// solving is DB-read-only and side-effect free, so the same method backs both
    /// the live preview jig and the final commit.
    /// </summary>
    public class MultiPipeDrawer
    {
        public AppSettings Settings { get; }
        public PlacementHistory History { get; } = new PlacementHistory();

        public MultiPipeDrawer(AppSettings settings)
        {
            Settings = settings;
        }

        /// <summary>Solves the geometry for every pipe in the bundle at a given cursor point. Read-only; no DB writes.</summary>
        public List<PipeSolution> SolveBundle(
            Transaction tr, Editor ed, IReadOnlyList<ObjectId> boundaryIds, Vec3 pickPointWcs, PipeProfile profile)
        {
            var results = new List<PipeSolution>();
            int n = Settings.ClampPipeCount(Settings.PipeCount);
            double pipeDist = Settings.PipeDistance;

            var (dir, normal) = UcsHelper.GetWorkingDirection(ed, Settings);
            Vec3 lateralAxis = UcsHelper.GetUcsXAxisWcs(ed);
            Vec3 sideDir = UcsHelper.GetSideDir(ed, Settings);
            double viewSize = 1.0;
            try { viewSize = System.Convert.ToDouble(Autodesk.AutoCAD.ApplicationServices.Application.GetSystemVariable("VIEWSIZE")); }
            catch { /* headless/no active view - fall back to 1.0 */ }
            double diag = UcsHelper.BoundarySelectionDiagonal(tr, boundaryIds, viewSize);
            double searchLen = Math.Max(diag * Settings.SearchLengthFactor, 1.0);

            for (int k = 1; k <= n; k++)
            {
                double dist = (k - (n + 1) / 2.0) * pipeDist;
                Vec3 shifted = pickPointWcs + dist * lateralAxis;

                var sol = new PipeSolution { Index = k, LateralOffset = dist };
                var (below, above) = SegmentBuilder.FindBracketingPair(tr, boundaryIds, shifted, dir, searchLen, Settings.PairMode);

                if (below == null || above == null)
                {
                    sol.FailureReason = $"Pipe {k}: no bracketing intersection pair found.";
                    results.Add(sol);
                    continue;
                }

                double gap = Settings.Gap;
                if (above.Param - below.Param <= 2.0 * gap)
                {
                    sol.FailureReason = $"Pipe {k}: gap is >= available segment length.";
                    results.Add(sol);
                    continue;
                }

                Vec3 p1 = shifted + (below.Param + gap) * dir;
                Vec3 p2 = shifted + (above.Param - gap) * dir;

                var tanBelow = SegmentBuilder.CurveTangentAtPoint(tr, below.SourceId, below.Point, normal);
                var tanAbove = SegmentBuilder.CurveTangentAtPoint(tr, above.SourceId, above.Point, normal);

                var (rb, ra) = Settings.EffectiveFilletRadii();
                var (extB, extA) = Settings.EffectiveExtensionLengths();

                string routeWarning = null;
                var route = FilletEngine.BuildRoute(new RouteParams
                {
                    PointBelow = p1,
                    PointAbove = p2,
                    Direction = dir,
                    Normal = normal,
                    SideDir = sideDir,
                    Width = Settings.PolylineWidth,
                    Layer = ResolveOutputLayer(tr, boundaryIds),
                    UseFillet = Settings.UseFillet,
                    RadiusBelow = rb,
                    RadiusAbove = ra,
                    ReverseBelow = Settings.ReverseBelow,
                    ReverseAbove = Settings.ReverseAbove,
                    TangentBelow = tanBelow,
                    TangentAbove = tanAbove,
                    UseExtensionBelow = Settings.UseExtensionBelow,
                    UseExtensionAbove = Settings.UseExtensionAbove,
                    ExtensionBelow = extB,
                    ExtensionAbove = extA,
                    OnWarning = w => routeWarning = w,
                });

                sol.CenterlineRoute = route;
                sol.FailureReason = route == null ? (routeWarning ?? $"Pipe {k}: could not build route.") : null;
                results.Add(sol);
            }

            return results;
        }

        private string ResolveOutputLayer(Transaction tr, IReadOnlyList<ObjectId> boundaryIds)
        {
            if (Settings.OutputLayerMode == OutputLayerMode.FirstSelectedEntityLayer && boundaryIds.Count > 0)
            {
                try
                {
                    var ent = (Entity)tr.GetObject(boundaryIds[0], OpenMode.ForRead);
                    return ent.Layer;
                }
                catch { /* fall through to current layer */ }
            }

            var db = Autodesk.AutoCAD.ApplicationServices.Application.DocumentManager.MdiActiveDocument?.Database;
            if (db == null) return "0";
            try
            {
                var ltr = (LayerTableRecord)tr.GetObject(db.Clayer, OpenMode.ForRead);
                return ltr.Name;
            }
            catch
            {
                return "0";
            }
        }

        /// <summary>
        /// Commits a solved bundle to the database: creates each pipe's chamfered
        /// centerline, its two edge offsets, assigns layers, and optionally creates
        /// a named group. Ported from VTP:MultiPipeDraw.
        /// Returns the full list of entities created (already pushed to History).
        /// </summary>
        public List<ObjectId> CommitBundle(
            Transaction tr, Database db, List<PipeSolution> solutions, PipeProfile profile, string logSource = "MultiPipeDraw")
        {
            double offL = Settings.PipeOffsetLeft > 0 ? Settings.PipeOffsetLeft : profile.OffsetLeft;
            double offR = Settings.PipeOffsetRight > 0 ? Settings.PipeOffsetRight : profile.OffsetRight;

            string offLayer = Settings.UseOffsetLayer
                ? (!string.IsNullOrEmpty(profile.Layer) ? profile.Layer : Settings.OffsetLayer)
                : null;
            const string hilfLayer = "_HILFSLINIEN";

            if (!string.IsNullOrEmpty(offLayer)) CadHelpers.EnsurePipeLayer(tr, db, offLayer);
            CadHelpers.EnsurePipeLayer(tr, db, hilfLayer);

            var created = new List<ObjectId>();

            foreach (var sol in solutions)
            {
                if (!sol.Success)
                {
                    AppLog.Warn(logSource, sol.FailureReason);
                    continue;
                }

                var clId = CadHelpers.CreatePolylineFromRoute(tr, db, sol.CenterlineRoute);
                if (clId.IsNull)
                {
                    AppLog.Warn(logSource, $"Pipe {sol.Index}: could not create centerline polyline.");
                    continue;
                }

                var edgeLIds = CadHelpers.OffsetCurves(tr, db, clId, offL);
                if (edgeLIds.Count > 0)
                {
                    foreach (var edgeL in edgeLIds)
                    {
                        if (!string.IsNullOrEmpty(offLayer)) CadHelpers.AssignLayer(tr, edgeL, offLayer);
                        created.Add(edgeL);
                    }
                }
                else AppLog.Warn(logSource, $"Pipe {sol.Index}: left edge offset failed.");

                var edgeRIds = CadHelpers.OffsetCurves(tr, db, clId, -offR);
                if (edgeRIds.Count > 0)
                {
                    foreach (var edgeR in edgeRIds)
                    {
                        if (!string.IsNullOrEmpty(offLayer)) CadHelpers.AssignLayer(tr, edgeR, offLayer);
                        created.Add(edgeR);
                    }
                }
                else AppLog.Warn(logSource, $"Pipe {sol.Index}: right edge offset failed.");

                if (Settings.CreateCenterline)
                {
                    CadHelpers.AssignLayer(tr, clId, hilfLayer);
                    created.Add(clId);
                }
                else
                {
                    // Centerline is only a construction source for GetOffsetCurves();
                    // erase it when the user does not want centerlines kept.
                    try
                    {
                        var clEnt = (Entity)tr.GetObject(clId, OpenMode.ForWrite);
                        clEnt.Erase();
                    }
                    catch { /* best-effort cleanup */ }
                }

                AppLog.Info(logSource, $"Pipe {sol.Index}/{solutions.Count}: CL@{sol.LateralOffset:0.####}m edgesΒ±{offL:0.####}m");
            }

            if (created.Count == 0) return created;

            History.Push(created);

            if (Settings.CreateGroupPerPlacement)
            {
                string groupName = "PP_" + DateTime.Now.Ticks;
                CadHelpers.CreateGroup(tr, db, created, groupName);
                AppLog.Info(logSource, $"Group '{groupName}' created.");
            }

            AppLog.Info(logSource, $"{created.Count} entities created.");
            return created;
        }

        /// <summary>Undo last placement (own stack, independent of AutoCAD's U command). Erases the popped entities.</summary>
        public void UndoLast(Transaction tr)
        {
            var last = History.PopLast();
            if (last == null)
            {
                AppLog.Info("MultiPipeDraw", "Nothing to undo.");
                return;
            }
            foreach (var id in last)
            {
                try
                {
                    var ent = (Entity)tr.GetObject(id, OpenMode.ForWrite);
                    ent.Erase();
                }
                catch { /* already erased/invalid - ignore, matches vl-catch-all-apply guard */ }
            }
            AppLog.Info("MultiPipeDraw", $"Undo: placement removed. {History.Count} placement(s) remaining.");
        }
    }
}


// ===== FILE: Cad\CadHelpers.cs =====
using System;
using System.Collections.Generic;
using System.Linq;
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.AutoCAD.Colors;
using ParallelPipesNet.Core;

namespace ParallelPipesNet.Cad
{
    /// <summary>
    /// AutoCAD-side helpers: layer creation (VTP:EnsureLayer/EnsurePipeLayer),
    /// LWPOLYLINE construction from a Core.RouteResult (VTP:MakeLWPolylineRoute),
    /// offset (VTP:VlaOffsetEname), copy, mirror, group creation (PP:CreateGroup),
    /// and viewport highlight (SetImpliedSelection + UpdateScreen).
    /// </summary>
    public static class CadHelpers
    {
        public static Document ActiveDoc => Application.DocumentManager.MdiActiveDocument;

        /// <summary>Layer creation spec: color / linetype / lineweight, mirrors *VTP_LayerSpecs*.</summary>
        public static readonly Dictionary<string, (short color, string linetype, LineWeight weight)> LayerSpecs =
            new Dictionary<string, (short, string, LineWeight)>(StringComparer.OrdinalIgnoreCase)
            {
                ["VA_Schutzrohr"] = ((short)11, "STRICHLINIE", LineWeight.LineWeight025),
                ["VA_Schutzrohr_160"] = ((short)11, "STRICHLINIE", LineWeight.LineWeight025),
                ["_HILFSLINIEN"] = ((short)6, "Continuous", LineWeight.ByLayer),
            };

        /// <summary>VTP:EnsurePipeLayer - creates a known layer with the right style, or a generic one otherwise.</summary>
        public static void EnsurePipeLayer(Transaction tr, Database db, string layerName)
        {
            if (string.IsNullOrEmpty(layerName)) return;
            if (LayerSpecs.TryGetValue(layerName, out var spec))
                EnsureLayer(tr, db, layerName, spec.color, spec.linetype, spec.weight);
            else
                EnsureLayer(tr, db, layerName, 7, "Continuous", LineWeight.ByLayer);
        }

        /// <summary>VTP:EnsureLayer - creates the layer if missing and (re)applies color/linetype/lineweight.</summary>
        public static void EnsureLayer(Transaction tr, Database db, string layerName, short colorIndex, string linetype, LineWeight weight)
        {
            if (string.IsNullOrEmpty(layerName)) return;

            var lt = (LayerTable)tr.GetObject(db.LayerTableId, OpenMode.ForRead);
            LayerTableRecord ltr;

            if (lt.Has(layerName))
            {
                ltr = (LayerTableRecord)tr.GetObject(lt[layerName], OpenMode.ForWrite);
            }
            else
            {
                lt.UpgradeOpen();
                ltr = new LayerTableRecord { Name = layerName };
                lt.Add(ltr);
                tr.AddNewlyCreatedDBObject(ltr, true);
            }

            try { ltr.Color = Color.FromColorIndex(ColorMethod.ByAci, colorIndex); } catch { /* best-effort, mirrors vl-catch-all-apply */ }

            EnsureLinetypeLoaded(tr, db, linetype);
            try
            {
                var ltt = (LinetypeTable)tr.GetObject(db.LinetypeTableId, OpenMode.ForRead);
                if (ltt.Has(linetype)) ltr.LinetypeObjectId = ltt[linetype];
            }
            catch { /* best-effort */ }

            try { ltr.LineWeight = weight; } catch { /* best-effort */ }
        }

        private static void EnsureLinetypeLoaded(Transaction tr, Database db, string linetype)
        {
            if (string.IsNullOrEmpty(linetype) || linetype.Equals("Continuous", StringComparison.OrdinalIgnoreCase))
                return;

            var ltt = (LinetypeTable)tr.GetObject(db.LinetypeTableId, OpenMode.ForRead);
            if (ltt.Has(linetype)) return;

            try
            {
                db.LoadLineTypeFile(linetype, "acad.lin");
            }
            catch
            {
                try { db.LoadLineTypeFile(linetype, "acadiso.lin"); } catch { /* linetype stays default if neither .lin has it */ }
            }
        }

        /// <summary>
        /// VTP:MakeLWPolylineRoute - builds a Polyline entity from a Core.RouteResult.
        /// Points are supplied in WCS; converted to the entity's own OCS (normal) plane.
        /// The entity is NOT added to any database/BlockTableRecord - see
        /// AppendToCurrentSpace for that. This split exists so the exact same geometry
        /// (including GetOffsetCurves()) can be used for the live preview jig without
        /// ever touching the database, and for the final committed entities.
        /// Callers that don't append the returned Polyline to a database MUST Dispose it.
        /// </summary>
        public static Polyline BuildDetachedPolyline(RouteResult route)
        {
            if (route == null || route.Vertices.Count < 2) return null;

            var normal = new Vector3d(route.Normal.X, route.Normal.Y, route.Normal.Z).GetNormal();
            var ocs = Matrix3d.WorldToPlane(normal);

            var pl = new Polyline(route.Vertices.Count) { Normal = normal };
            double elevation = 0;

            for (int i = 0; i < route.Vertices.Count; i++)
            {
                var v = route.Vertices[i];
                var wcsPt = new Point3d(v.Point.X, v.Point.Y, v.Point.Z);
                var ocsPt = wcsPt.TransformBy(ocs);
                if (i == 0) elevation = ocsPt.Z;
                pl.AddVertexAt(i, new Point2d(ocsPt.X, ocsPt.Y), v.Bulge, 0, 0);
            }
            pl.Elevation = elevation;
            pl.ConstantWidth = route.Width;
            return pl;
        }

        /// <summary>Appends a previously-built detached entity to the current space and returns its new ObjectId.</summary>
        public static ObjectId AppendToCurrentSpace(Transaction tr, Database db, Entity ent, string layer)
        {
            var btr = (BlockTableRecord)tr.GetObject(db.CurrentSpaceId, OpenMode.ForWrite);
            var id = btr.AppendEntity(ent);
            tr.AddNewlyCreatedDBObject(ent, true);
            if (!string.IsNullOrEmpty(layer)) ent.Layer = layer;
            return id;
        }

        /// <summary>VTP:MakeLWPolylineRoute + database append, in one call (used by the final commit).</summary>
        public static ObjectId CreatePolylineFromRoute(Transaction tr, Database db, RouteResult route)
        {
            var pl = BuildDetachedPolyline(route);
            if (pl == null) return ObjectId.Null;

            // Do NOT wrap this in using/Dispose after AppendEntity(). Once a DBObject is
            // appended and registered with the transaction, AutoCAD owns it. Disposing
            // the wrapper immediately can leave the just-created centerline unusable
            // for the very next GetOffsetCurves() call in the final placement path.
            return AppendToCurrentSpace(tr, db, pl, route.Layer);
        }

        /// <summary>
        /// Reads WCS vertices out of ANY Curve (Polyline, Line, Arc, ...) as a tessellated
        /// point list, tracing the true AutoCAD geometry rather than approximating it.
        /// Used by the preview jig to draw the exact result of GetOffsetCurves().
        /// </summary>
        public static List<Vec3> TessellateCurveToWorldPoints(Curve curve, int segmentsPerArc = 16)
        {
            var pts = new List<Vec3>();
            if (curve == null) return pts;

            if (curve is Polyline pl)
            {
                var normal = pl.Normal.IsZeroLength() ? Vector3d.ZAxis : pl.Normal;
                int n = pl.NumberOfVertices;
                var routeVerts = new List<RouteVertex>();
                for (int i = 0; i < n; i++)
                {
                    var wcsPt = pl.GetPoint3dAt(i);
                    double bulge = i < n - 1 || pl.Closed ? pl.GetBulgeAt(i) : 0.0;
                    routeVerts.Add(new RouteVertex(new Vec3(wcsPt.X, wcsPt.Y, wcsPt.Z), bulge));
                }
                if (pl.Closed && n > 0) routeVerts.Add(routeVerts[0]);
                return BulgeUtil.TessellateRoute(routeVerts, new Vec3(normal.X, normal.Y, normal.Z), segmentsPerArc);
            }

            // Generic fallback for Line/Arc/other Curve types: sample by parameter.
            try
            {
                double sp = curve.StartParam, ep = curve.EndParam;
                int steps = curve is Line ? 1 : segmentsPerArc;
                for (int i = 0; i <= steps; i++)
                {
                    double t = sp + (ep - sp) * i / steps;
                    var p = curve.GetPointAtParameter(t);
                    pts.Add(new Vec3(p.X, p.Y, p.Z));
                }
            }
            catch { /* best-effort preview only */ }
            return pts;
        }

        /// <summary>VTP:VlaOffsetEname - offsets a Curve by a signed distance and returns all created entity ids.</summary>
        public static List<ObjectId> OffsetCurves(Transaction tr, Database db, ObjectId sourceId, double distance)
        {
            var created = new List<ObjectId>();

            if (sourceId.IsNull) return created;

            if (Math.Abs(distance) < 1e-9)
            {
                var copyId = CopyEntity(tr, db, sourceId);
                if (!copyId.IsNull) created.Add(copyId);
                return created;
            }

            var curve = tr.GetObject(sourceId, OpenMode.ForRead) as Curve;
            if (curve == null) return created;

            DBObjectCollection offsets;
            try
            {
                offsets = curve.GetOffsetCurves(distance);
            }
            catch
            {
                return created;
            }

            var btr = (BlockTableRecord)tr.GetObject(db.CurrentSpaceId, OpenMode.ForWrite);
            foreach (DBObject obj in offsets)
            {
                if (obj is Entity ent)
                {
                    var id = btr.AppendEntity(ent);
                    tr.AddNewlyCreatedDBObject(ent, true);
                    created.Add(id);
                }
                else
                {
                    obj.Dispose();
                }
            }

            return created;
        }

        /// <summary>Backward-compatible single-id offset wrapper. Prefer OffsetCurves for final placement.</summary>
        public static ObjectId OffsetCurve(Transaction tr, Database db, ObjectId sourceId, double distance)
        {
            var ids = OffsetCurves(tr, db, sourceId, distance);
            return ids.Count > 0 ? ids[0] : ObjectId.Null;
        }

        /// <summary>VTP:CopyEname - in-place copy of an entity.</summary>
        public static ObjectId CopyEntity(Transaction tr, Database db, ObjectId sourceId)
        {
            var ent = (Entity)tr.GetObject(sourceId, OpenMode.ForRead);
            var clone = (Entity)ent.Clone();
            var btr = (BlockTableRecord)tr.GetObject(db.CurrentSpaceId, OpenMode.ForWrite);
            var id = btr.AppendEntity(clone);
            tr.AddNewlyCreatedDBObject(clone, true);
            return id;
        }

        /// <summary>Assigns an entity to a layer (VTP:AssignLayer).</summary>
        public static void AssignLayer(Transaction tr, ObjectId id, string layer)
        {
            if (id.IsNull || string.IsNullOrEmpty(layer)) return;
            var ent = (Entity)tr.GetObject(id, OpenMode.ForWrite);
            ent.Layer = layer;
        }

        /// <summary>Mirrors an entity across the line through mirrorPt1/mirrorPt2, returns the new entity id (vla-Mirror).</summary>
        public static ObjectId MirrorEntity(Transaction tr, Database db, ObjectId sourceId, Point3d mirrorPt1, Point3d mirrorPt2)
        {
            var ent = (Entity)tr.GetObject(sourceId, OpenMode.ForRead);
            var clone = (Entity)ent.Clone();
            var line3d = new Line3d(mirrorPt1, mirrorPt2);
            clone.TransformBy(Matrix3d.Mirroring(line3d));

            var btr = (BlockTableRecord)tr.GetObject(db.CurrentSpaceId, OpenMode.ForWrite);
            var id = btr.AppendEntity(clone);
            tr.AddNewlyCreatedDBObject(clone, true);
            return id;
        }

        /// <summary>Moves an entity by a WCS delta (used by "Copy last" placement).</summary>
        public static void MoveEntity(Transaction tr, ObjectId id, Vector3d delta)
        {
            var ent = (Entity)tr.GetObject(id, OpenMode.ForWrite);
            ent.TransformBy(Matrix3d.Displacement(delta));
        }

        /// <summary>PP:CreateGroup - creates a named AutoCAD group containing the given entities.</summary>
        public static void CreateGroup(Transaction tr, Database db, IEnumerable<ObjectId> ids, string groupName)
        {
            var idList = ids.Where(i => !i.IsNull).ToList();
            if (idList.Count == 0) return;

            var groupDict = (DBDictionary)tr.GetObject(db.GroupDictionaryId, OpenMode.ForWrite);
            var group = new Group(groupName, true);
            groupDict.SetAt(groupName, group);
            tr.AddNewlyCreatedDBObject(group, true);

            var idCollection = new ObjectIdCollection(idList.ToArray());
            group.InsertAt(0, idCollection);
        }

        /// <summary>
        /// Viewport highlight: highlights the given entities in the AutoCAD viewport and
        /// refreshes the screen, exactly like the LISP-side pattern
        ///   (SetImpliedSelection ...) + (UpdateScreen).
        /// Must be called on the AutoCAD/UI thread (safe from a WinForms event handler in
        /// a modeless dialog shown via Application.ShowModelessDialog - see MainForm).
        /// </summary>
        public static void HighlightInViewport(IEnumerable<ObjectId> ids)
        {
            var doc = ActiveDoc;
            if (doc == null) return;
            var idArray = ids.Where(i => !i.IsNull).ToArray();
            doc.Editor.SetImpliedSelection(idArray);
            doc.Editor.UpdateScreen();
        }

        public static void ClearViewportHighlight()
        {
            var doc = ActiveDoc;
            if (doc == null) return;
            doc.Editor.SetImpliedSelection(Array.Empty<ObjectId>());
            doc.Editor.UpdateScreen();
        }

        /// <summary>Sorted list of all non-xref-dependent layer names (VTP:GetAllLayers).</summary>
        public static List<string> GetAllLayers(Transaction tr, Database db)
        {
            var names = new List<string>();
            var lt = (LayerTable)tr.GetObject(db.LayerTableId, OpenMode.ForRead);
            foreach (ObjectId id in lt)
            {
                var ltr = (LayerTableRecord)tr.GetObject(id, OpenMode.ForRead);
                if (!ltr.IsDependent) names.Add(ltr.Name);
            }
            names.Sort(StringComparer.OrdinalIgnoreCase);
            return names;
        }
    }
}

Additional info:

Share this page:

Page Tag: 🏷️ Pipe Bundle

  • ParallelPipes.NET

    β€”

    by

    Short descriptionAutoCAD / Civil 3D .NET plug-in for placing multiple parallel Profile_000 pipe bundles between selected boundary curves with live model-space preview, profile management, radius presets, offset-layer control, grouping, logging, and undo support. Command:NETLOAD ParallelPipesNet.dll, then run VTPCS at the AutoCAD command line. Description:ParallelPipes.NET is a managed C# / .NET Framework 4.8 port of the…