From bb21ffdf35f5865040d5cb5cefde98860c854739 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 29 Oct 2026 18:51:42 -0400 Subject: [PATCH] feat(machines): split machine leads into outside and inside cuts Machine Configuration held one lead-in and lead-out per thickness and applied it to every contour. Each thickness now has Outside Cuts and Inside Cuts tabs; inside cuts follow the outside leads until "Same as outside cuts" is cleared, and configurations without inside leads keep applying one pair to both. The lead type lists, per-type fields and preview from Cutting Settings are now a reusable LeadsEditor control that both dialogs use. The machine tabs hide what the configuration cannot hold: the arc sweep, the clean-hole kerf (taken from the thickness) and Line + Line. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/MachineCuttingDefaults.cs | 115 +++- OpenNest.Data/ThicknessConfig.cs | 11 + .../Data/MachineCuttingDefaultsTests.cs | 17 + .../Forms/MachineCutsFormsTests.cs | 102 +++ OpenNest/Controls/CuttingPanel.cs | 451 +------------- OpenNest/Controls/LeadsEditor.cs | 579 ++++++++++++++++++ OpenNest/Forms/MachineConfigForm.cs | 204 +++--- docs/machine-cutting-defaults.md | 13 +- 8 files changed, 957 insertions(+), 535 deletions(-) create mode 100644 OpenNest.WinForms.Tests/Forms/MachineCutsFormsTests.cs create mode 100644 OpenNest/Controls/LeadsEditor.cs diff --git a/OpenNest.Data/MachineCuttingDefaults.cs b/OpenNest.Data/MachineCuttingDefaults.cs index df7fb139..22ff48f6 100644 --- a/OpenNest.Data/MachineCuttingDefaults.cs +++ b/OpenNest.Data/MachineCuttingDefaults.cs @@ -3,12 +3,12 @@ using OpenNest.CNC.CuttingStrategy; namespace OpenNest.Data; /// -/// The lead-in, lead-out, kerf and cut-off a machine configuration has on file for a nest's -/// material and thickness, converted to the nest's units. A machine has one lead-in and one -/// lead-out per thickness, so they apply to external, internal and arc/circle contours alike; -/// a Clean Hole lead-in is for holes only and leaves the external lead-in as it was, and a -/// Line + Line lead-in, whose second segment the configuration does not hold, leaves every -/// lead-in as it was. +/// The lead-ins, lead-outs, kerf and cut-off a machine configuration has on file for a nest's +/// material and thickness, converted to the nest's units. Outside cuts take the thickness's +/// lead-in and lead-out; inside cuts (circles included) take its inside ones, or the outside +/// ones when it has none. A Clean Hole lead-in is for holes only, so on outside cuts it leaves +/// the external lead-in as it was, and a Line + Line lead-in, whose second segment the +/// configuration does not hold, leaves the lead-in it would set as it was. /// public static class MachineCuttingDefaults { @@ -55,17 +55,8 @@ public static class MachineCuttingDefaults return false; var scale = Scale(machine!.Units, nest.Units); - var kerf = settings.Kerf * scale; - // Each lead set gets its own instance, as the cutting panel builds them. - if (ToLeadIn(settings.LeadIn, scale, kerf) is { } leadIn) - { - if (leadIn is not CleanHoleLeadIn) - parameters.ExternalLeadIn = leadIn; - parameters.InternalLeadIn = ToLeadIn(settings.LeadIn, scale, kerf); - } - parameters.ExternalLeadOut = ToLeadOut(settings.LeadOut, scale); - parameters.InternalLeadOut = ToLeadOut(settings.LeadOut, scale); - parameters.Kerf = kerf; + ApplyLeads(parameters, settings, scale); + parameters.Kerf = settings.Kerf * scale; parameters.MachineName = machine.Name; parameters.MaterialName = nest.Material.Name; parameters.Grade = nest.Material.Grade; @@ -73,6 +64,31 @@ public static class MachineCuttingDefaults return true; } + /// + /// The leads give, in the machine's units, for previewing them: + /// a lead the machine leaves as it was (Line + Line, or Clean Hole on outside cuts) is none. + /// + public static CuttingParameters LeadsOf(ThicknessConfig settings) + { + ArgumentNullException.ThrowIfNull(settings); + var parameters = new CuttingParameters { ExternalLeadIn = new NoLeadIn(), InternalLeadIn = new NoLeadIn() }; + ApplyLeads(parameters, settings, 1); + parameters.Kerf = settings.Kerf; + return parameters; + } + + private static void ApplyLeads(CuttingParameters parameters, ThicknessConfig settings, double scale) + { + var kerf = settings.Kerf * scale; + // Each lead set gets its own instance, as the cutting panel builds them. + if (ToLeadIn(settings.LeadIn, scale, kerf) is { } leadIn and not CleanHoleLeadIn) + parameters.ExternalLeadIn = leadIn; + if (ToLeadIn(settings.InsideLeadIn ?? settings.LeadIn, scale, kerf) is { } insideLeadIn) + parameters.InternalLeadIn = insideLeadIn; + parameters.ExternalLeadOut = ToLeadOut(settings.LeadOut, scale); + parameters.InternalLeadOut = ToLeadOut(settings.InsideLeadOut ?? settings.LeadOut, scale); + } + /// /// Whether are stamped with the machine, material, grade and /// thickness would record for @@ -112,6 +128,71 @@ public static class MachineCuttingDefaults return true; } + /// + /// The lead-in describes, in the machine's units, with + /// for a clean hole; null for a type it cannot build (Line + Line). + /// + public static LeadIn? LeadInOf(LeadConfig lead, double kerf) => ToLeadIn(lead, 1, kerf); + + /// The lead-out describes, in the machine's units. + public static LeadOut LeadOutOf(LeadConfig lead) => ToLeadOut(lead, 1); + + /// + /// The machine configuration's form of ; what it cannot hold (an + /// arc's sweep, a clean hole's kerf, a second line) is left out. Unset fields keep + /// 's values. + /// + public static LeadConfig ToConfig(LeadIn leadIn, LeadConfig? previous = null) + { + var config = Copy(previous); + switch (leadIn) + { + case LineLeadIn line: + (config.Type, config.Length, config.Angle) = ("Line", line.Length, line.ApproachAngle); + break; + case ArcLeadIn arc: + (config.Type, config.Radius) = ("Arc", arc.Radius); + break; + case LineArcLeadIn lineArc: + (config.Type, config.Length, config.Radius, config.Angle) = + ("Line + Arc", lineArc.LineLength, lineArc.ArcRadius, lineArc.ApproachAngle); + break; + case CleanHoleLeadIn cleanHole: + (config.Type, config.Length, config.Radius) = ("Clean Hole", cleanHole.LineLength, cleanHole.ArcRadius); + break; + case LineLineLeadIn lineLine: + (config.Type, config.Length, config.Angle) = ("Line + Line", lineLine.Length1, lineLine.ApproachAngle1); + break; + default: + config.Type = "None"; + break; + } + return config; + } + + /// The machine configuration's form of . + public static LeadConfig ToConfig(LeadOut leadOut, LeadConfig? previous = null) + { + var config = Copy(previous); + switch (leadOut) + { + case LineLeadOut line: + (config.Type, config.Length, config.Angle) = ("Line", line.Length, line.ApproachAngle); + break; + case ArcLeadOut arc: + (config.Type, config.Radius) = ("Arc", arc.Radius); + break; + default: + config.Type = "None"; + break; + } + return config; + } + + private static LeadConfig Copy(LeadConfig? lead) => lead == null + ? new LeadConfig() + : new LeadConfig { Type = lead.Type, Length = lead.Length, Angle = lead.Angle, Radius = lead.Radius }; + // Lead types are the names Tools > Machine Configuration offers; null when one cannot be built. private static LeadIn? ToLeadIn(LeadConfig? lead, double scale, double kerf) => lead?.Type switch { diff --git a/OpenNest.Data/ThicknessConfig.cs b/OpenNest.Data/ThicknessConfig.cs index 6614615d..6d674e8b 100644 --- a/OpenNest.Data/ThicknessConfig.cs +++ b/OpenNest.Data/ThicknessConfig.cs @@ -11,8 +11,19 @@ public class ThicknessConfig public double Kerf { get; set; } public string AssistGas { get; set; } = ""; + + /// The lead-in of outside cuts (part perimeters), and of inside cuts while is null. public LeadConfig LeadIn { get; set; } = new(); + + /// The lead-out of outside cuts, and of inside cuts while is null. public LeadConfig LeadOut { get; set; } = new(); + + /// The lead-in of inside cuts (cutouts and holes); null uses . + public LeadConfig? InsideLeadIn { get; set; } + + /// The lead-out of inside cuts; null uses . + public LeadConfig? InsideLeadOut { get; set; } + public CutOffConfig CutOff { get; set; } = new(); public List PlateSizes { get; set; } = new(); } diff --git a/OpenNest.Tests/Data/MachineCuttingDefaultsTests.cs b/OpenNest.Tests/Data/MachineCuttingDefaultsTests.cs index a6d96fbe..5cb46e29 100644 --- a/OpenNest.Tests/Data/MachineCuttingDefaultsTests.cs +++ b/OpenNest.Tests/Data/MachineCuttingDefaultsTests.cs @@ -146,6 +146,23 @@ public class MachineCuttingDefaultsTests Assert.Equal(0.012, Assert.IsType(parameters.InternalLeadIn).Kerf); } + [Fact] + public void InsideLeads_GoToInternalContoursOnly() + { + var parameters = new CuttingParameters(); + var machine = Machine(LineArc); + var thickness = machine.Materials[0].Thicknesses[0]; + thickness.InsideLeadIn = new LeadConfig { Type = "Clean Hole", Length = 0.1, Radius = 0.05 }; + thickness.InsideLeadOut = new LeadConfig { Type = "Arc", Radius = 0.04 }; + + Assert.True(MachineCuttingDefaults.ApplyTo(parameters, machine, NestOf(0.25))); + + Assert.IsType(parameters.ExternalLeadIn); + Assert.IsType(parameters.ExternalLeadOut); + Assert.Equal(0.05, Assert.IsType(parameters.InternalLeadIn).ArcRadius); + Assert.Equal(0.04, Assert.IsType(parameters.InternalLeadOut).Radius); + } + [Fact] public void LineLineLeadIn_KeepsTheLeadInsButStillSetsLeadOuts() { diff --git a/OpenNest.WinForms.Tests/Forms/MachineCutsFormsTests.cs b/OpenNest.WinForms.Tests/Forms/MachineCutsFormsTests.cs new file mode 100644 index 00000000..cd3f0e9a --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/MachineCutsFormsTests.cs @@ -0,0 +1,102 @@ +using System.Windows.Forms; +using OpenNest.Data; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests.Forms; + +/// +/// The Outside Cuts and Inside Cuts tabs of a thickness in Machine Configuration: inside cuts +/// follow the outside leads until "Same as outside cuts" is cleared, which gives them a copy. +/// +public class MachineCutsFormsTests +{ + [Fact] + public void InsideCutsTab_GivesHolesTheirOwnLeads() + { + RunSta(() => + { + var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(directory); + try + { + var provider = new LocalJsonProvider(directory); + var thickness = new ThicknessConfig + { + Value = 0.25, + LeadIn = new LeadConfig { Type = "Arc", Radius = 0.125 }, + LeadOut = new LeadConfig { Type = "None" }, + }; + provider.SaveMachine(new MachineConfig + { + Name = "Laser", + Materials = { new MaterialConfig { Name = "Mild Steel", Thicknesses = { thickness } } }, + }); + + using var form = new MachineConfigForm(provider); + var node = Descendants(Find(form, "_tree").Nodes).Single(n => n.Tag is ThicknessConfig); + // A hidden tree raises no AfterSelect, so show the thickness as selecting it would. + typeof(MachineConfigForm) + .GetMethod("Tree_AfterSelect", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(form, new object[] { null, new TreeViewEventArgs(node) }); + var shown = (ThicknessConfig)node.Tag; + + var pages = All(form).Single().TabPages.Cast().ToList(); + Assert.DoesNotContain(pages, p => p.Text is "Lead In" or "Lead Out"); + var outside = pages.Single(p => p.Text == "Outside Cuts"); + Assert.DoesNotContain(All(outside), c => c.Items.Contains("Clean Hole")); + + var inside = pages.Single(p => p.Text == "Inside Cuts"); + var same = All(inside).Single(c => c.Text == "Same as outside cuts"); + Assert.True(same.Checked); + Assert.All(All(inside), c => Assert.False(c.Enabled)); + Assert.NotNull(All(inside).Single().Parameters); + + same.Checked = false; + Assert.Equal("Arc", shown.InsideLeadIn!.Type); + Assert.Equal(0.125, shown.InsideLeadIn.Radius); + Assert.NotSame(shown.LeadIn, shown.InsideLeadIn); + Assert.All(All(inside), c => Assert.True(c.Enabled)); + + All(inside).Single(c => c.Items.Contains("Clean Hole")).SelectedItem = "Clean Hole"; + Assert.Equal("Clean Hole", shown.InsideLeadIn.Type); + Assert.Equal("Arc", shown.LeadIn.Type); + // Only the chosen type's fields show: line length and arc radius. + var leadInGrid = All(inside).ToList(); + Assert.Equal(2, leadInGrid.Count); + leadInGrid[1].Value = 0.07m; + Assert.Equal(0.07, shown.InsideLeadIn.Radius, 6); + Assert.Equal(0.125, shown.LeadIn.Radius); + var preview = All(inside).Single().Parameters; + Assert.Equal(0.07, Assert.IsType(preview.InternalLeadIn).ArcRadius, 6); + + same.Checked = true; + Assert.Null(shown.InsideLeadIn); + Assert.Null(shown.InsideLeadOut); + } + finally + { + Directory.Delete(directory, recursive: true); + } + }); + } + + private static IEnumerable Descendants(TreeNodeCollection nodes) => + nodes.Cast().SelectMany(n => Descendants(n.Nodes).Prepend(n)); + + private static T Find(Control root, string name) where T : Control => + Assert.IsType(root.Controls.Find(name, true).Single()); + + private static IEnumerable All(Control root) where T : Control + { + foreach (Control child in root.Controls) + { + if (child is T match) + yield return match; + foreach (var nested in All(child)) + yield return nested; + } + } + + private static void RunSta(System.Action action) => + StaTestThread.Run(action, Timeout.InfiniteTimeSpan, "The STA test did not complete."); +} diff --git a/OpenNest/Controls/CuttingPanel.cs b/OpenNest/Controls/CuttingPanel.cs index 238d5958..75ce056f 100644 --- a/OpenNest/Controls/CuttingPanel.cs +++ b/OpenNest/Controls/CuttingPanel.cs @@ -8,24 +8,10 @@ namespace OpenNest.Controls { public class CuttingPanel : Panel { - private static readonly string[] LeadInTypes = - { - "None", - "Line", - "Arc", - "Line + Arc", - "Clean Hole", - "Line + Line", - }; - - private static readonly string[] LeadOutTypes = { "None", "Line", "Arc" }; - // Every field label shares this column so inputs line up across sections. private const int LabelWidth = 150; private const int FieldIndent = 16; private const int InputWidth = 100; - // The lead preview's least height, below the type with the most fields. - private const int PreviewHeight = 110; private static readonly Color HintColor = SystemColors.GrayText; @@ -33,18 +19,8 @@ namespace OpenNest.Controls private readonly ToolTip toolTip = new ToolTip { AutoPopDelay = 15000 }; private readonly TabControl tabControl; - private readonly ComboBox cboExternalLeadIn, - cboExternalLeadOut; - private readonly ComboBox cboInternalLeadIn, - cboInternalLeadOut; - - private readonly TableLayoutPanel pnlExternalLeadIn, - pnlExternalLeadOut; - private readonly TableLayoutPanel pnlInternalLeadIn, - pnlInternalLeadOut; - - private readonly LeadPreview externalPreview = new LeadPreview { Emphasis = ContourType.External }; - private readonly LeadPreview internalPreview = new LeadPreview { Emphasis = ContourType.Internal }; + private readonly LeadsEditor externalLeads = new LeadsEditor { Emphasis = ContourType.External }; + private readonly LeadsEditor internalLeads = new LeadsEditor { Emphasis = ContourType.Internal }; private readonly CheckBox chkTabsEnabled; private readonly TableLayoutPanel tabOptions; @@ -124,22 +100,13 @@ namespace OpenNest.Controls var tabInternal = new TabPage("Holes") { Padding = new Padding(6), UseVisualStyleBackColor = true }; toolTip.SetToolTip(tabControl, "Outside: each part's perimeter. Holes: inner contours and circles."); - var externalContent = SetupTab( - tabExternal, - externalPreview, - out cboExternalLeadIn, - out pnlExternalLeadIn, - out cboExternalLeadOut, - out pnlExternalLeadOut - ); - SetupTab( - tabInternal, - internalPreview, - out cboInternalLeadIn, - out pnlInternalLeadIn, - out cboInternalLeadOut, - out pnlInternalLeadOut - ); + foreach (var (tab, leads) in new[] { (tabExternal, externalLeads), (tabInternal, internalLeads) }) + { + leads.Dock = DockStyle.Fill; + leads.LeadInChanged += (s, e) => OnParametersChanged(); + leads.LeadOutChanged += (s, e) => OnParametersChanged(); + tab.Controls.Add(leads); + } tabControl.Controls.Add(tabExternal); tabControl.Controls.Add(tabInternal); @@ -282,9 +249,9 @@ namespace OpenNest.Controls Controls.Add(layout); - PopulateDropdowns(); - FitLeadTabs(externalContent); - WireChangeEvents(); + // Sized for the type with the most fields, so picking one never moves the sections below; + // the tab strip, page padding and borders take the rest. + tabControl.Height = externalLeads.GetTallestHeight() + Font.Height + 28; UpdatePreviews(); } @@ -303,10 +270,10 @@ namespace OpenNest.Controls { var parameters = loaded == null ? new CuttingParameters() : CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(loaded)); - parameters.ExternalLeadIn = BuildLeadIn(cboExternalLeadIn, pnlExternalLeadIn); - parameters.ExternalLeadOut = BuildLeadOut(cboExternalLeadOut, pnlExternalLeadOut); - parameters.InternalLeadIn = BuildLeadIn(cboInternalLeadIn, pnlInternalLeadIn); - parameters.InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut); + parameters.ExternalLeadIn = externalLeads.LeadIn; + parameters.ExternalLeadOut = externalLeads.LeadOut; + parameters.InternalLeadIn = internalLeads.LeadIn; + parameters.InternalLeadOut = internalLeads.LeadOut; parameters.TabsEnabled = chkTabsEnabled.Checked && rbTabAll.Checked; parameters.TabConfig = new NormalTab { Size = (double)nudTabWidth.Value }; parameters.PierceClearance = (double)nudPierceClearance.Value; @@ -325,10 +292,10 @@ namespace OpenNest.Controls suppressEvents = true; loaded = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(p)); - LoadLeadIn(cboExternalLeadIn, pnlExternalLeadIn, p.ExternalLeadIn); - LoadLeadOut(cboExternalLeadOut, pnlExternalLeadOut, p.ExternalLeadOut); - LoadLeadIn(cboInternalLeadIn, pnlInternalLeadIn, p.InternalLeadIn); - LoadLeadOut(cboInternalLeadOut, pnlInternalLeadOut, p.InternalLeadOut); + externalLeads.LeadIn = p.ExternalLeadIn; + externalLeads.LeadOut = p.ExternalLeadOut; + internalLeads.LeadIn = p.InternalLeadIn; + internalLeads.LeadOut = p.InternalLeadOut; chkTabsEnabled.Checked = p.TabsEnabled || p.SupportTabs; rbSupportTab.Checked = p.SupportTabs; @@ -353,8 +320,8 @@ namespace OpenNest.Controls private void UpdatePreviews() { var parameters = BuildParameters(); - externalPreview.Parameters = parameters; - internalPreview.Parameters = parameters; + externalLeads.PreviewParameters = parameters; + internalLeads.PreviewParameters = parameters; } private void UpdateSupportTabOptions() @@ -375,225 +342,6 @@ namespace OpenNest.Controls ParametersChanged?.Invoke(this, EventArgs.Empty); } - /// - /// Sizes the lead tabs for the lead-in and lead-out with the most fields, so picking a - /// type never clips its fields or moves the sections below. - /// - private void FitLeadTabs(Control content) - { - var tallestLeadIn = 0; - for (var i = 0; i < LeadInTypes.Length; i++) - { - BuildLeadInParamControls(pnlExternalLeadIn, i); - tallestLeadIn = System.Math.Max(tallestLeadIn, pnlExternalLeadIn.GetPreferredSize(Size.Empty).Height); - } - - var tallestLeadOut = 0; - for (var i = 0; i < LeadOutTypes.Length; i++) - { - BuildLeadOutParamControls(pnlExternalLeadOut, i); - tallestLeadOut = System.Math.Max(tallestLeadOut, pnlExternalLeadOut.GetPreferredSize(Size.Empty).Height); - } - - BuildLeadInParamControls(pnlExternalLeadIn, 0); - BuildLeadOutParamControls(pnlExternalLeadOut, 0); - - var contentHeight = content.GetPreferredSize(Size.Empty).Height + tallestLeadIn + tallestLeadOut - + PreviewHeight; - // Tab strip, page padding and borders. - tabControl.Height = contentHeight + Font.Height + 28; - } - - private TableLayoutPanel SetupTab( - TabPage tab, - LeadPreview preview, - out ComboBox leadInCombo, - out TableLayoutPanel leadInPanel, - out ComboBox leadOutCombo, - out TableLayoutPanel leadOutPanel - ) - { - var content = new TableLayoutPanel - { - Dock = DockStyle.Top, - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - ColumnCount = 1, - }; - - leadInCombo = CreateCombo(); - var leadInType = CreateGrid(0); - AddRow(leadInType, "Lead-in:", leadInCombo, 0, bold: true); - content.Controls.Add(leadInType); - - leadInPanel = CreateGrid(FieldIndent); - content.Controls.Add(leadInPanel); - - leadOutCombo = CreateCombo(); - var leadOutType = CreateGrid(0); - leadOutType.Margin = new Padding(0, 8, 0, 0); - AddRow(leadOutType, "Lead-out:", leadOutCombo, 0, bold: true); - content.Controls.Add(leadOutType); - - leadOutPanel = CreateGrid(FieldIndent); - content.Controls.Add(leadOutPanel); - - // The preview fills whatever the fields leave, so it grows for types with fewer. - preview.Dock = DockStyle.Fill; - preview.Margin = new Padding(0, 6, 0, 0); - tab.Controls.Add(preview); - tab.Controls.Add(content); - preview.BringToFront(); - return content; - } - - private void PopulateDropdowns() - { - foreach ( - var combo in new[] { cboExternalLeadIn, cboInternalLeadIn } - ) - { - combo.Items.AddRange(LeadInTypes); - combo.SelectedIndex = 0; - } - - foreach ( - var combo in new[] { cboExternalLeadOut, cboInternalLeadOut } - ) - { - combo.Items.AddRange(LeadOutTypes); - combo.SelectedIndex = 0; - } - } - - private void WireChangeEvents() - { - cboExternalLeadIn.SelectedIndexChanged += OnLeadInTypeChanged; - cboInternalLeadIn.SelectedIndexChanged += OnLeadInTypeChanged; - - cboExternalLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged; - cboInternalLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged; - } - - private void OnLeadInTypeChanged(object sender, EventArgs e) - { - var combo = (ComboBox)sender; - var panel = GetLeadInPanel(combo); - if (panel != null) - BuildLeadInParamControls(panel, combo.SelectedIndex); - OnParametersChanged(); - } - - private void OnLeadOutTypeChanged(object sender, EventArgs e) - { - var combo = (ComboBox)sender; - var panel = GetLeadOutPanel(combo); - if (panel != null) - BuildLeadOutParamControls(panel, combo.SelectedIndex); - OnParametersChanged(); - } - - private TableLayoutPanel GetLeadInPanel(ComboBox combo) - { - if (combo == cboExternalLeadIn) - return pnlExternalLeadIn; - if (combo == cboInternalLeadIn) - return pnlInternalLeadIn; - return null; - } - - private TableLayoutPanel GetLeadOutPanel(ComboBox combo) - { - if (combo == cboExternalLeadOut) - return pnlExternalLeadOut; - if (combo == cboInternalLeadOut) - return pnlInternalLeadOut; - return null; - } - - private void BuildLeadInParamControls(TableLayoutPanel panel, int typeIndex) - { - ClearRows(panel); - - switch (typeIndex) - { - case 1: - AddNumericField(panel, "Length:", 0.25, "Length"); - AddAngleField(panel, "Approach angle:", 90, "ApproachAngle"); - break; - case 2: - AddNumericField(panel, "Radius:", 0.25, "Radius"); - // Degrees of arc: 90 is a quarter circle, 180 a half circle. - AddAngleField(panel, "Sweep angle:", ArcLeadIn.DefaultSweepAngle, "SweepAngle", - increment: 15, minimum: 1, maximum: 180); - break; - case 3: - AddNumericField(panel, "Line length:", 0.25, "LineLength"); - AddNumericField(panel, "Arc radius:", 0.125, "ArcRadius"); - AddAngleField(panel, "Approach angle:", 135, "ApproachAngle"); - break; - case 4: - AddNumericField(panel, "Line length:", 0.25, "LineLength"); - AddNumericField(panel, "Arc radius:", 0.125, "ArcRadius"); - AddNumericField(panel, "Kerf:", 0.06, "Kerf"); - break; - case 5: - AddNumericField(panel, "First length:", 0.25, "Length1"); - AddAngleField(panel, "First angle:", 90, "Angle1"); - AddNumericField(panel, "Second length:", 0.25, "Length2"); - AddAngleField(panel, "Second angle:", 90, "Angle2"); - break; - } - } - - private void BuildLeadOutParamControls(TableLayoutPanel panel, int typeIndex) - { - ClearRows(panel); - - switch (typeIndex) - { - case 1: - AddNumericField(panel, "Length:", 0.25, "Length"); - AddAngleField(panel, "Approach angle:", 90, "ApproachAngle"); - break; - case 2: - AddNumericField(panel, "Radius:", 0.25, "Radius"); - break; - } - } - - private void AddAngleField( - TableLayoutPanel panel, - string label, - double defaultValue, - string tag, - double increment = 5, - double minimum = 0, - double maximum = 360 - ) => AddNumericField(panel, label, defaultValue, tag, increment, minimum, maximum, "°", 2); - - private void AddNumericField( - TableLayoutPanel panel, - string label, - double defaultValue, - string tag, - double increment = 0.0625, - double minimum = 0, - double maximum = 9999, - string suffix = null, - int decimalPlaces = 4 - ) - { - var nud = CreateNumeric(defaultValue, increment); - nud.Minimum = (decimal)minimum; - nud.Maximum = (decimal)maximum; - nud.DecimalPlaces = decimalPlaces; - nud.Value = System.Math.Clamp((decimal)defaultValue, nud.Minimum, nud.Maximum); - nud.Tag = tag; - nud.ValueChanged += (s, e) => OnParametersChanged(); - AddRow(panel, label, nud, FieldIndent, suffix); - } - private void AddSection(string title) { var header = new Label @@ -633,8 +381,7 @@ namespace OpenNest.Controls string label, Control input, int indent, - string hint = null, - bool bold = false + string hint = null ) { var row = NextRow(grid); @@ -645,8 +392,6 @@ namespace OpenNest.Controls Anchor = AnchorStyles.Left, Margin = new Padding(0, 0, 4, 0), }; - if (bold) - text.Font = new Font(text.Font, FontStyle.Bold); grid.Controls.Add(text, 0, row); input.Anchor = AnchorStyles.Left; @@ -672,16 +417,6 @@ namespace OpenNest.Controls return row; } - private static void ClearRows(TableLayoutPanel grid) - { - grid.SuspendLayout(); - while (grid.Controls.Count > 0) - grid.Controls[0].Dispose(); - grid.RowStyles.Clear(); - grid.RowCount = 0; - grid.ResumeLayout(); - } - private static FlowLayoutPanel CreateInline(params Control[] controls) { var flow = new FlowLayoutPanel @@ -709,13 +444,6 @@ namespace OpenNest.Controls Margin = margin ?? new Padding(2, 0, 0, 0), }; - private static ComboBox CreateCombo() => - new ComboBox - { - DropDownStyle = ComboBoxStyle.DropDownList, - Width = 150, - }; - private static NumericUpDown CreateNumeric(double defaultValue, double increment) { return new NumericUpDown @@ -729,138 +457,5 @@ namespace OpenNest.Controls TextAlign = HorizontalAlignment.Right, }; } - - private static void LoadLeadIn(ComboBox combo, Panel panel, LeadIn leadIn) - { - switch (leadIn) - { - case LineLeadIn line: - combo.SelectedIndex = 1; - SetParam(panel, "Length", line.Length); - SetParam(panel, "ApproachAngle", line.ApproachAngle); - break; - case ArcLeadIn arc: - combo.SelectedIndex = 2; - SetParam(panel, "Radius", arc.Radius); - SetParam(panel, "SweepAngle", arc.SweepAngle); - break; - case LineArcLeadIn lineArc: - combo.SelectedIndex = 3; - SetParam(panel, "LineLength", lineArc.LineLength); - SetParam(panel, "ArcRadius", lineArc.ArcRadius); - SetParam(panel, "ApproachAngle", lineArc.ApproachAngle); - break; - case CleanHoleLeadIn cleanHole: - combo.SelectedIndex = 4; - SetParam(panel, "LineLength", cleanHole.LineLength); - SetParam(panel, "ArcRadius", cleanHole.ArcRadius); - SetParam(panel, "Kerf", cleanHole.Kerf); - break; - case LineLineLeadIn lineLine: - combo.SelectedIndex = 5; - SetParam(panel, "Length1", lineLine.Length1); - SetParam(panel, "Angle1", lineLine.ApproachAngle1); - SetParam(panel, "Length2", lineLine.Length2); - SetParam(panel, "Angle2", lineLine.ApproachAngle2); - break; - default: - combo.SelectedIndex = 0; - break; - } - } - - private static void LoadLeadOut(ComboBox combo, Panel panel, LeadOut leadOut) - { - switch (leadOut) - { - case LineLeadOut line: - combo.SelectedIndex = 1; - SetParam(panel, "Length", line.Length); - SetParam(panel, "ApproachAngle", line.ApproachAngle); - break; - case ArcLeadOut arc: - combo.SelectedIndex = 2; - SetParam(panel, "Radius", arc.Radius); - break; - default: - combo.SelectedIndex = 0; - break; - } - } - - private static LeadIn BuildLeadIn(ComboBox combo, Panel panel) - { - return combo.SelectedIndex switch - { - 1 => new LineLeadIn - { - Length = GetParam(panel, "Length", 0.25), - ApproachAngle = GetParam(panel, "ApproachAngle", 90), - }, - 2 => new ArcLeadIn - { - Radius = GetParam(panel, "Radius", 0.25), - SweepAngle = GetParam(panel, "SweepAngle", ArcLeadIn.DefaultSweepAngle), - }, - 3 => new LineArcLeadIn - { - LineLength = GetParam(panel, "LineLength", 0.25), - ArcRadius = GetParam(panel, "ArcRadius", 0.125), - ApproachAngle = GetParam(panel, "ApproachAngle", 135), - }, - 4 => new CleanHoleLeadIn - { - LineLength = GetParam(panel, "LineLength", 0.25), - ArcRadius = GetParam(panel, "ArcRadius", 0.125), - Kerf = GetParam(panel, "Kerf", 0.06), - }, - 5 => new LineLineLeadIn - { - Length1 = GetParam(panel, "Length1", 0.25), - ApproachAngle1 = GetParam(panel, "Angle1", 90), - Length2 = GetParam(panel, "Length2", 0.25), - ApproachAngle2 = GetParam(panel, "Angle2", 90), - }, - _ => new NoLeadIn(), - }; - } - - private static LeadOut BuildLeadOut(ComboBox combo, Panel panel) - { - return combo.SelectedIndex switch - { - 1 => new LineLeadOut - { - Length = GetParam(panel, "Length", 0.25), - ApproachAngle = GetParam(panel, "ApproachAngle", 90), - }, - 2 => new ArcLeadOut { Radius = GetParam(panel, "Radius", 0.25) }, - _ => new NoLeadOut(), - }; - } - - private static void SetParam(Panel panel, string tag, double value) - { - foreach (Control c in panel.Controls) - { - if (c is NumericUpDown nud && (string)nud.Tag == tag) - { - nud.Value = double.IsFinite(value) - ? System.Math.Clamp((decimal)value, nud.Minimum, nud.Maximum) - : nud.Minimum; - return; - } - } - } - - private static double GetParam(Panel panel, string tag, double defaultValue) - { - foreach (Control c in panel.Controls) - { - if (c is NumericUpDown nud && (string)nud.Tag == tag) - return (double)nud.Value; - } - return defaultValue; - } } } diff --git a/OpenNest/Controls/LeadsEditor.cs b/OpenNest/Controls/LeadsEditor.cs new file mode 100644 index 00000000..326f6e2a --- /dev/null +++ b/OpenNest/Controls/LeadsEditor.cs @@ -0,0 +1,579 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using OpenNest.CNC.CuttingStrategy; + +namespace OpenNest.Controls +{ + /// + /// Edits one contour type's lead-in and lead-out: a type list for each, the chosen type's + /// fields below it, and a filling the rest. Cutting Settings and + /// Machine Configuration both use it; the options hide what a host cannot hold. + /// + public class LeadsEditor : UserControl + { + private const string None = "None"; + private const string Line = "Line"; + private const string Arc = "Arc"; + private const string LineArc = "Line + Arc"; + private const string CleanHole = "Clean Hole"; + private const string LineLine = "Line + Line"; + + private static readonly string[] AllLeadInTypes = { None, Line, Arc, LineArc, CleanHole, LineLine }; + private static readonly string[] LeadOutTypes = { None, Line, Arc }; + + // Every field label shares this column so inputs line up across hosts. + private const int LabelWidth = 150; + private const int FieldIndent = 16; + private const int InputWidth = 100; + // The preview's least height, below the type with the most fields. + private const int PreviewHeight = 110; + + private static readonly Color HintColor = SystemColors.GrayText; + + private readonly TableLayoutPanel content; + private readonly ComboBox leadInCombo; + private readonly ComboBox leadOutCombo; + private readonly TableLayoutPanel leadInFields; + private readonly TableLayoutPanel leadOutFields; + private readonly LeadPreview preview = new(); + + private bool allowCleanHole = true; + private bool allowLineLine = true; + private bool showArcSweep = true; + private bool showCleanHoleKerf = true; + private string lengthUnit; + private bool loading; + + /// Raised when the user changes the lead-in's type or fields. + public event EventHandler LeadInChanged; + + /// Raised when the user changes the lead-out's type or fields. + public event EventHandler LeadOutChanged; + + public LeadsEditor() + { + BackColor = Color.White; + + content = new TableLayoutPanel + { + Dock = DockStyle.Top, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + }; + + leadInCombo = CreateCombo(); + var leadInType = CreateGrid(0); + AddRow(leadInType, "Lead-in:", leadInCombo, 0, bold: true); + content.Controls.Add(leadInType); + leadInFields = CreateGrid(FieldIndent); + content.Controls.Add(leadInFields); + + leadOutCombo = CreateCombo(); + var leadOutType = CreateGrid(0); + leadOutType.Margin = new Padding(0, 8, 0, 0); + AddRow(leadOutType, "Lead-out:", leadOutCombo, 0, bold: true); + content.Controls.Add(leadOutType); + leadOutFields = CreateGrid(FieldIndent); + content.Controls.Add(leadOutFields); + + // The preview fills whatever the fields leave, so it grows for types with fewer. + preview.Dock = DockStyle.Fill; + preview.Margin = new Padding(0, 6, 0, 0); + Controls.Add(preview); + Controls.Add(content); + preview.BringToFront(); + + FillCombo(leadInCombo, LeadInTypes(), None); + FillCombo(leadOutCombo, LeadOutTypes, None); + leadInCombo.SelectedIndexChanged += (s, e) => + { + BuildLeadInFields(); + if (!loading) + LeadInChanged?.Invoke(this, EventArgs.Empty); + }; + leadOutCombo.SelectedIndexChanged += (s, e) => + { + BuildLeadOutFields(); + if (!loading) + LeadOutChanged?.Invoke(this, EventArgs.Empty); + }; + } + + /// The contour the preview draws strong; the other one's leads are faint. + [DefaultValue(ContourType.External)] + public ContourType Emphasis + { + get => preview.Emphasis; + set + { + preview.Emphasis = value; + preview.Invalidate(); + } + } + + /// Whether Clean Hole is offered; a lead already of that type still shows. + [DefaultValue(true)] + public bool AllowCleanHole + { + get => allowCleanHole; + set + { + allowCleanHole = value; + ReloadLeadInTypes(); + } + } + + /// Whether Line + Line is offered; a lead already of that type still shows. + [DefaultValue(true)] + public bool AllowLineLine + { + get => allowLineLine; + set + { + allowLineLine = value; + ReloadLeadInTypes(); + } + } + + /// Whether an arc lead-in shows its sweep angle; hidden, it is the default half circle. + [DefaultValue(true)] + public bool ShowArcSweep + { + get => showArcSweep; + set + { + showArcSweep = value; + RebuildFields(); + } + } + + /// Whether a clean-hole lead-in shows its kerf; hidden, the host supplies it. + [DefaultValue(true)] + public bool ShowCleanHoleKerf + { + get => showCleanHoleKerf; + set + { + showCleanHoleKerf = value; + RebuildFields(); + } + } + + /// A unit shown after each length field, or null for none. + [DefaultValue(null)] + public string LengthUnit + { + get => lengthUnit; + set + { + lengthUnit = value; + RebuildFields(); + } + } + + /// The whole plate's leads, which the preview draws. + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public CuttingParameters PreviewParameters + { + get => preview.Parameters; + set => preview.Parameters = value; + } + + /// Whether the type lists and fields take edits; the preview stays. + [DefaultValue(true)] + public bool FieldsEnabled + { + get => content.Enabled; + set => content.Enabled = value; + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public LeadIn LeadIn + { + get => (string)leadInCombo.SelectedItem switch + { + Line => new LineLeadIn + { + Length = GetParam(leadInFields, "Length", 0.25), + ApproachAngle = GetParam(leadInFields, "ApproachAngle", 90), + }, + Arc => new ArcLeadIn + { + Radius = GetParam(leadInFields, "Radius", 0.25), + SweepAngle = GetParam(leadInFields, "SweepAngle", ArcLeadIn.DefaultSweepAngle), + }, + LineArc => new LineArcLeadIn + { + LineLength = GetParam(leadInFields, "LineLength", 0.25), + ArcRadius = GetParam(leadInFields, "ArcRadius", 0.125), + ApproachAngle = GetParam(leadInFields, "ApproachAngle", 135), + }, + CleanHole => new CleanHoleLeadIn + { + LineLength = GetParam(leadInFields, "LineLength", 0.25), + ArcRadius = GetParam(leadInFields, "ArcRadius", 0.125), + Kerf = GetParam(leadInFields, "Kerf", 0.06), + }, + LineLine => new LineLineLeadIn + { + Length1 = GetParam(leadInFields, "Length1", 0.25), + ApproachAngle1 = GetParam(leadInFields, "Angle1", 90), + Length2 = GetParam(leadInFields, "Length2", 0.25), + ApproachAngle2 = GetParam(leadInFields, "Angle2", 90), + }, + _ => new NoLeadIn(), + }; + set + { + loading = true; + FillCombo(leadInCombo, LeadInTypes(), TypeOf(value)); + switch (value) + { + case LineLeadIn line: + SetParam(leadInFields, "Length", line.Length); + SetParam(leadInFields, "ApproachAngle", line.ApproachAngle); + break; + case ArcLeadIn arc: + SetParam(leadInFields, "Radius", arc.Radius); + SetParam(leadInFields, "SweepAngle", arc.SweepAngle); + break; + case LineArcLeadIn lineArc: + SetParam(leadInFields, "LineLength", lineArc.LineLength); + SetParam(leadInFields, "ArcRadius", lineArc.ArcRadius); + SetParam(leadInFields, "ApproachAngle", lineArc.ApproachAngle); + break; + case CleanHoleLeadIn cleanHole: + SetParam(leadInFields, "LineLength", cleanHole.LineLength); + SetParam(leadInFields, "ArcRadius", cleanHole.ArcRadius); + SetParam(leadInFields, "Kerf", cleanHole.Kerf); + break; + case LineLineLeadIn lineLine: + SetParam(leadInFields, "Length1", lineLine.Length1); + SetParam(leadInFields, "Angle1", lineLine.ApproachAngle1); + SetParam(leadInFields, "Length2", lineLine.Length2); + SetParam(leadInFields, "Angle2", lineLine.ApproachAngle2); + break; + } + loading = false; + } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public LeadOut LeadOut + { + get => (string)leadOutCombo.SelectedItem switch + { + Line => new LineLeadOut + { + Length = GetParam(leadOutFields, "Length", 0.25), + ApproachAngle = GetParam(leadOutFields, "ApproachAngle", 90), + }, + Arc => new ArcLeadOut { Radius = GetParam(leadOutFields, "Radius", 0.25) }, + _ => new NoLeadOut(), + }; + set + { + loading = true; + FillCombo(leadOutCombo, LeadOutTypes, value switch + { + LineLeadOut => Line, + ArcLeadOut => Arc, + _ => None, + }); + switch (value) + { + case LineLeadOut line: + SetParam(leadOutFields, "Length", line.Length); + SetParam(leadOutFields, "ApproachAngle", line.ApproachAngle); + break; + case ArcLeadOut arc: + SetParam(leadOutFields, "Radius", arc.Radius); + break; + } + loading = false; + } + } + + /// + /// The height that fits the lead-in and lead-out with the most fields above the preview's + /// least height, so a host can size for it and picking a type never clips its fields. + /// + public int GetTallestHeight() + { + var (savedIn, savedOut) = (LeadIn, LeadOut); + loading = true; + + var tallestLeadIn = 0; + foreach (var type in leadInCombo.Items.Cast().ToList()) + { + leadInCombo.SelectedItem = type; + tallestLeadIn = System.Math.Max(tallestLeadIn, leadInFields.GetPreferredSize(Size.Empty).Height); + } + var tallestLeadOut = 0; + foreach (var type in leadOutCombo.Items.Cast().ToList()) + { + leadOutCombo.SelectedItem = type; + tallestLeadOut = System.Math.Max(tallestLeadOut, leadOutFields.GetPreferredSize(Size.Empty).Height); + } + + leadInCombo.SelectedItem = None; + leadOutCombo.SelectedItem = None; + var rows = content.GetPreferredSize(Size.Empty).Height; + loading = false; + LeadIn = savedIn; + LeadOut = savedOut; + return rows + tallestLeadIn + tallestLeadOut + PreviewHeight + Padding.Vertical; + } + + private IEnumerable LeadInTypes() => + AllLeadInTypes.Where(t => (allowCleanHole || t != CleanHole) && (allowLineLine || t != LineLine)); + + // Keeps the values while the fields are laid out again. + private void RebuildFields() + { + var (leadIn, leadOut) = (LeadIn, LeadOut); + loading = true; + BuildLeadInFields(); + BuildLeadOutFields(); + loading = false; + LeadIn = leadIn; + LeadOut = leadOut; + } + + private void ReloadLeadInTypes() + { + var lead = LeadIn; + LeadIn = lead; + } + + private static string TypeOf(LeadIn leadIn) => leadIn switch + { + LineLeadIn => Line, + ArcLeadIn => Arc, + LineArcLeadIn => LineArc, + CleanHoleLeadIn => CleanHole, + LineLineLeadIn => LineLine, + _ => None, + }; + + // A type the list does not offer still shows when it is the lead's. + private static void FillCombo(ComboBox combo, IEnumerable types, string selected) + { + var items = types.ToList(); + if (!items.Contains(selected)) + items.Add(selected); + if (combo.Items.Cast().SequenceEqual(items)) + { + combo.SelectedItem = selected; + return; + } + combo.Items.Clear(); + combo.Items.AddRange(items.ToArray()); + combo.SelectedItem = selected; + } + + private void BuildLeadInFields() + { + ClearRows(leadInFields); + switch ((string)leadInCombo.SelectedItem) + { + case Line: + AddLength(leadInFields, "Length:", 0.25, "Length"); + AddAngle(leadInFields, "Approach angle:", 90, "ApproachAngle"); + break; + case Arc: + AddLength(leadInFields, "Radius:", 0.25, "Radius"); + // Degrees of arc: 90 is a quarter circle, 180 a half circle. + if (showArcSweep) + AddAngle(leadInFields, "Sweep angle:", ArcLeadIn.DefaultSweepAngle, "SweepAngle", + increment: 15, minimum: 1, maximum: 180); + break; + case LineArc: + AddLength(leadInFields, "Line length:", 0.25, "LineLength"); + AddLength(leadInFields, "Arc radius:", 0.125, "ArcRadius"); + AddAngle(leadInFields, "Approach angle:", 135, "ApproachAngle"); + break; + case CleanHole: + AddLength(leadInFields, "Line length:", 0.25, "LineLength"); + AddLength(leadInFields, "Arc radius:", 0.125, "ArcRadius"); + if (showCleanHoleKerf) + AddLength(leadInFields, "Kerf:", 0.06, "Kerf"); + break; + case LineLine: + AddLength(leadInFields, "First length:", 0.25, "Length1"); + AddAngle(leadInFields, "First angle:", 90, "Angle1"); + AddLength(leadInFields, "Second length:", 0.25, "Length2"); + AddAngle(leadInFields, "Second angle:", 90, "Angle2"); + break; + } + } + + private void BuildLeadOutFields() + { + ClearRows(leadOutFields); + switch ((string)leadOutCombo.SelectedItem) + { + case Line: + AddLength(leadOutFields, "Length:", 0.25, "Length"); + AddAngle(leadOutFields, "Approach angle:", 90, "ApproachAngle"); + break; + case Arc: + AddLength(leadOutFields, "Radius:", 0.25, "Radius"); + break; + } + } + + private void AddLength(TableLayoutPanel grid, string label, double defaultValue, string tag) => + AddNumeric(grid, label, defaultValue, tag, 0.0625, 0, 9999, lengthUnit, 4); + + private void AddAngle( + TableLayoutPanel grid, + string label, + double defaultValue, + string tag, + double increment = 5, + double minimum = 0, + double maximum = 360 + ) => AddNumeric(grid, label, defaultValue, tag, increment, minimum, maximum, "°", 2); + + private void AddNumeric( + TableLayoutPanel grid, + string label, + double defaultValue, + string tag, + double increment, + double minimum, + double maximum, + string suffix, + int decimalPlaces + ) + { + var nud = new NumericUpDown + { + Width = InputWidth, + DecimalPlaces = decimalPlaces, + Increment = (decimal)increment, + Minimum = (decimal)minimum, + Maximum = (decimal)maximum, + TextAlign = HorizontalAlignment.Right, + Tag = tag, + }; + nud.Value = System.Math.Clamp((decimal)defaultValue, nud.Minimum, nud.Maximum); + var leadInField = grid == leadInFields; + nud.ValueChanged += (s, e) => + { + if (loading) + return; + (leadInField ? LeadInChanged : LeadOutChanged)?.Invoke(this, EventArgs.Empty); + }; + AddRow(grid, label, nud, FieldIndent, suffix); + } + + private static void SetParam(Panel panel, string tag, double value) + { + foreach (Control c in panel.Controls) + { + if (c is NumericUpDown nud && (string)nud.Tag == tag) + { + nud.Value = double.IsFinite(value) + ? System.Math.Clamp((decimal)value, nud.Minimum, nud.Maximum) + : nud.Minimum; + return; + } + } + } + + private static double GetParam(Panel panel, string tag, double defaultValue) + { + foreach (Control c in panel.Controls) + { + if (c is NumericUpDown nud && (string)nud.Tag == tag) + return (double)nud.Value; + } + return defaultValue; + } + + private static TableLayoutPanel CreateGrid(int indent) + { + var grid = new TableLayoutPanel + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 3, + Margin = new Padding(indent, 0, 0, 0), + }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, LabelWidth - indent)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + return grid; + } + + private static void AddRow( + TableLayoutPanel grid, + string label, + Control input, + int indent, + string hint = null, + bool bold = false + ) + { + var row = grid.RowCount; + grid.RowCount = row + 1; + grid.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + var text = new Label + { + Text = label, + AutoSize = true, + Anchor = AnchorStyles.Left, + Margin = new Padding(0, 0, 4, 0), + }; + if (bold) + text.Font = new Font(text.Font, FontStyle.Bold); + grid.Controls.Add(text, 0, row); + + input.Anchor = AnchorStyles.Left; + input.Margin = new Padding(0, 2, 0, 2); + grid.Controls.Add(input, 1, row); + + if (hint != null) + grid.Controls.Add( + new Label + { + Text = hint, + AutoSize = true, + ForeColor = HintColor, + Anchor = AnchorStyles.Left, + Margin = new Padding(4, 0, 0, 0), + }, + 2, + row + ); + } + + private static void ClearRows(TableLayoutPanel grid) + { + grid.SuspendLayout(); + while (grid.Controls.Count > 0) + grid.Controls[0].Dispose(); + grid.RowStyles.Clear(); + grid.RowCount = 0; + grid.ResumeLayout(); + } + + private static ComboBox CreateCombo() => + new ComboBox + { + DropDownStyle = ComboBoxStyle.DropDownList, + Width = 150, + }; + } +} diff --git a/OpenNest/Forms/MachineConfigForm.cs b/OpenNest/Forms/MachineConfigForm.cs index 37bcd8bb..7e763037 100644 --- a/OpenNest/Forms/MachineConfigForm.cs +++ b/OpenNest/Forms/MachineConfigForm.cs @@ -14,18 +14,6 @@ namespace OpenNest.Forms { public partial class MachineConfigForm : Form { - private static readonly string[] LeadInTypes = - { - "None", - "Line", - "Arc", - "Line + Arc", - "Clean Hole", - "Line + Line", - }; - - private static readonly string[] LeadOutTypes = { "None", "Line", "Arc" }; - private static readonly string[] AssistGasOptions = { "O2", "N2", "Air" }; private static readonly string[] CutOffDirectionOptions = Enum.GetNames(); @@ -630,12 +618,8 @@ namespace OpenNest.Forms { var tabs = new TabControl { Dock = DockStyle.Fill }; tabs.TabPages.Add(CreateTab("General", BuildThicknessGeneralTab(thickness, node))); - tabs.TabPages.Add( - CreateTab("Lead In", BuildLeadTab(thickness.LeadIn, LeadInTypes)) - ); - tabs.TabPages.Add( - CreateTab("Lead Out", BuildLeadTab(thickness.LeadOut, LeadOutTypes)) - ); + tabs.TabPages.Add(CreateTab("Outside Cuts", BuildOutsideCutsTab(thickness))); + tabs.TabPages.Add(CreateTab("Inside Cuts", BuildInsideCutsTab(thickness))); tabs.TabPages.Add(CreateTab("Cut Off", BuildCutOffTab(thickness.CutOff))); tabs.TabPages.Add(CreateTab("Plate Sizes", BuildPlateSizesTab(thickness))); RememberTab(tabs, _thicknessTab, t => _thicknessTab = t); @@ -717,82 +701,130 @@ namespace OpenNest.Forms return panel; } - private Control BuildLeadTab(LeadConfig lead, string[] typeOptions) + private Control BuildOutsideCutsTab(ThicknessConfig thickness) { - var layout = CreateDetailLayout(); - var row = 0; - - var typeCombo = new ComboBox + var editor = CreateLeadsEditor(CNC.CuttingStrategy.ContourType.External); + // Clean Hole is for holes only, so outside cuts do not offer it. + editor.AllowCleanHole = false; + ShowLeads(editor, thickness, thickness.LeadIn, thickness.LeadOut); + editor.LeadInChanged += (s, e) => { - DropDownStyle = ComboBoxStyle.DropDownList, - Width = InputWidth, + thickness.LeadIn = MachineCuttingDefaults.ToConfig(editor.LeadIn, thickness.LeadIn); + LeadsEdited(editor, thickness); }; - typeCombo.Items.AddRange(typeOptions); - typeCombo.SelectedItem = typeOptions.Contains(lead.Type) ? lead.Type : typeOptions[0]; - typeCombo.SelectedIndexChanged += (s, e) => + editor.LeadOutChanged += (s, e) => { - lead.Type = typeCombo.SelectedItem?.ToString() ?? typeOptions[0]; - MarkDirty(); + thickness.LeadOut = MachineCuttingDefaults.ToConfig(editor.LeadOut, thickness.LeadOut); + LeadsEdited(editor, thickness); + }; + editor.VisibleChanged += (s, e) => + { + if (editor.Visible) + ShowLeads(editor, thickness, thickness.LeadIn, thickness.LeadOut); }; - AddField(layout, ref row, "Type:", typeCombo); - AddField( - layout, - ref row, - "Length:", - CreateLabeledNumeric( - lead.Length, - v => - { - lead.Length = v; - MarkDirty(); - }, - 4, - LengthUnitSuffix, - 50, - 0.001m - ) - ); - AddField( - layout, - ref row, - "Angle:", - CreateLabeledNumeric( - lead.Angle, - v => - { - lead.Angle = v; - MarkDirty(); - }, - 1, - _ => "°", - 360, - 0.1m - ) - ); - AddField( - layout, - ref row, - "Radius:", - CreateLabeledNumeric( - lead.Radius, - v => - { - lead.Radius = v; - MarkDirty(); - }, - 4, - LengthUnitSuffix, - 50, - 0.001m - ) - ); - - var panel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10) }; - panel.Controls.Add(layout); - return panel; + var page = new Panel { Dock = DockStyle.Fill, BackColor = Color.White }; + page.Controls.Add(editor); + page.Controls.Add(CreateTabNote("Part perimeters. Inside cuts use these too unless given their own.")); + return page; } + // Inside cuts (cutouts and holes) follow the outside cuts until they get leads of their + // own, which start as a copy of the outside ones. + private Control BuildInsideCutsTab(ThicknessConfig thickness) + { + var editor = CreateLeadsEditor(CNC.CuttingStrategy.ContourType.Internal); + var sameBox = new CheckBox + { + Text = "Same as outside cuts", + AutoSize = true, + Dock = DockStyle.Top, + Padding = new Padding(10, 0, 0, 0), + Checked = thickness.InsideLeadIn == null && thickness.InsideLeadOut == null, + }; + + void Show() + { + ShowLeads(editor, thickness, thickness.InsideLeadIn ?? thickness.LeadIn, + thickness.InsideLeadOut ?? thickness.LeadOut); + editor.FieldsEnabled = !sameBox.Checked; + } + + sameBox.CheckedChanged += (s, e) => + { + thickness.InsideLeadIn = sameBox.Checked ? null : Copy(thickness.LeadIn); + thickness.InsideLeadOut = sameBox.Checked ? null : Copy(thickness.LeadOut); + MarkDirty(); + Show(); + }; + editor.LeadInChanged += (s, e) => + { + thickness.InsideLeadIn = MachineCuttingDefaults.ToConfig(editor.LeadIn, thickness.InsideLeadIn); + LeadsEdited(editor, thickness); + }; + editor.LeadOutChanged += (s, e) => + { + thickness.InsideLeadOut = MachineCuttingDefaults.ToConfig(editor.LeadOut, thickness.InsideLeadOut); + LeadsEdited(editor, thickness); + }; + // Shown again on each visit, so following the outside cuts shows their current values. + editor.VisibleChanged += (s, e) => + { + if (editor.Visible) + Show(); + }; + Show(); + + var page = new Panel { Dock = DockStyle.Fill, BackColor = Color.White }; + page.Controls.Add(editor); + page.Controls.Add(sameBox); + page.Controls.Add(CreateTabNote("Cutouts and holes, full circles included.")); + return page; + } + + // The machine holds no arc sweep (leads are half circles) and takes a clean hole's kerf + // from the thickness; Line + Line is never applied from it, so it is not offered. + private Controls.LeadsEditor CreateLeadsEditor(CNC.CuttingStrategy.ContourType emphasis) + { + return new Controls.LeadsEditor + { + Dock = DockStyle.Fill, + Padding = new Padding(10, 0, 10, 10), + Emphasis = emphasis, + AllowLineLine = false, + ShowArcSweep = false, + ShowCleanHoleKerf = false, + LengthUnit = LengthUnitSuffix(null), + }; + } + + private static void ShowLeads(Controls.LeadsEditor editor, ThicknessConfig thickness, LeadConfig leadIn, LeadConfig leadOut) + { + editor.LeadIn = MachineCuttingDefaults.LeadInOf(leadIn, thickness.Kerf) ?? new CNC.CuttingStrategy.NoLeadIn(); + editor.LeadOut = MachineCuttingDefaults.LeadOutOf(leadOut); + editor.PreviewParameters = MachineCuttingDefaults.LeadsOf(thickness); + } + + private void LeadsEdited(Controls.LeadsEditor editor, ThicknessConfig thickness) + { + editor.PreviewParameters = MachineCuttingDefaults.LeadsOf(thickness); + MarkDirty(); + } + + private static LeadConfig Copy(LeadConfig lead) => + new() { Type = lead.Type, Length = lead.Length, Angle = lead.Angle, Radius = lead.Radius }; + + private static Label CreateTabNote(string text) => + new() + { + Dock = DockStyle.Top, + AutoSize = true, + MaximumSize = new Size(560, 0), + ForeColor = SystemColors.GrayText, + Padding = new Padding(10, 8, 0, 6), + Text = text, + }; + private Control BuildCutOffTab(CutOffConfig cutOff) { var layout = CreateDetailLayout(); diff --git a/docs/machine-cutting-defaults.md b/docs/machine-cutting-defaults.md index 14fce265..6183402d 100644 --- a/docs/machine-cutting-defaults.md +++ b/docs/machine-cutting-defaults.md @@ -27,8 +27,13 @@ soon as its settings dialog is accepted, not only on Apply. A plate that already parameters keeps them: changing the machine, material or thickness does not rewrite leads the plate was planned with. -The machine holds one lead-in and one lead-out per thickness; both go to external and internal -contours (internal covers full circles). Types map as follows: +Each thickness holds a lead-in and lead-out for outside cuts (the Outside Cuts tab, external +contours) and, optionally, its own pair for inside cuts (the Inside Cuts tab, internal contours, +full circles included). Inside cuts use the outside pair until "Same as outside cuts" is cleared, +which starts them from a copy of it; configurations saved before the split have no inside pair +and keep applying one pair to both. Both tabs use the shared `LeadsEditor` control that Cutting +Settings uses, without the fields the configuration cannot hold (an arc's sweep; a clean hole's kerf, +which comes from the thickness). Types map as follows: | Machine type | Lead-in | |---|---| @@ -36,8 +41,8 @@ contours (internal covers full circles). Types map as follows: | Line | line (length, angle) | | Arc | arc (radius; the default 180-degree half-circle sweep) | | Line + Arc | line-arc (length, radius, angle) | -| Clean Hole | clean hole (length, radius, the thickness's kerf), holes only: the external lead-in is kept | -| Line + Line | not mapped (the configuration has no second segment): every lead-in is kept | +| Clean Hole | clean hole (length, radius, the thickness's kerf), inside cuts only; an outside Clean Hole (older configurations) keeps the external lead-in | +| Line + Line | not offered; an older configuration's is not mapped (it has no second segment): the lead-in it would set is kept | Lead-outs map None, Line (length, angle) and Arc (radius); any other type is none.