From ca693285bb0f3be85866876959f8d28a81e485fc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 30 Oct 2026 22:47:56 -0400 Subject: [PATCH] feat(cad): add radius and diameter dimensions Holes and radii need sizes on the drawing as well as the overall extents. Select circles or arcs, right-click and choose Add Radius Dimension or Add Diameter Dimension. The value is measured live from the curve, found by its entity ID, so it follows edits; only the leader angle and label offset are saved. A radius on an arc stays on its sweep, and labels are kept clear of the curve and center. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Dimensions/DimensionBuilder.cs | 128 ++++++++++++++ OpenNest.Core/Dimensions/DimensionLayout.cs | 53 ++++++ OpenNest.Core/Dimensions/DrawingDimension.cs | 26 ++- .../DimensionPersistenceTests.cs | 50 ++++++ OpenNest.IO/NestFormat.cs | 8 + OpenNest.IO/NestReader.cs | 13 +- OpenNest.IO/NestWriter.cs | 2 + .../Dimensions/RadialDimensionTests.cs | 156 ++++++++++++++++++ .../Forms/CadConverterDimensionTests.cs | 66 ++++++++ OpenNest/Controls/EntityView.Dimensions.cs | 89 +++++++++- OpenNest/Controls/EntityView.Selection.cs | 14 ++ OpenNest/Forms/CadConverterForm.cs | 43 ++++- docs/dimensions.md | 59 +++++-- docs/nest-file-format.md | 17 +- 14 files changed, 689 insertions(+), 35 deletions(-) create mode 100644 OpenNest.Tests/Dimensions/RadialDimensionTests.cs diff --git a/OpenNest.Core/Dimensions/DimensionBuilder.cs b/OpenNest.Core/Dimensions/DimensionBuilder.cs index 71479a5b..a379b36f 100644 --- a/OpenNest.Core/Dimensions/DimensionBuilder.cs +++ b/OpenNest.Core/Dimensions/DimensionBuilder.cs @@ -1,17 +1,21 @@ +using System; using System.Collections.Generic; using System.Linq; using OpenNest.Converters; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Dimensions { /// /// Resolves dimensions against the entities being cut (visible, with roles, breakoffs and /// tabbed cutouts applied), so a dimension measures the part as it will come off the table. + /// Radius and diameter dimensions find their circle or arc by ID among the visible entities. /// public static class DimensionBuilder { private const double DefaultOffsetFactor = 0.1; + private const double RadialOffsetFactor = 0.05; /// /// The extents of the geometry that bounds the part, or null if there is none. Source marks @@ -35,6 +39,9 @@ namespace OpenNest.Dimensions if (dimension.Offset == 0 || !double.IsFinite(dimension.Offset)) return new DimensionLayout { Kind = kind, Error = "its offset is not usable" }; + if (dimension.IsRadial) + return RadialLayout(entities, dimension); + var measured = Measured(entities); if (measured.Count == 0) return new DimensionLayout { Kind = kind, Error = "there is no cut geometry to measure" }; @@ -102,5 +109,126 @@ namespace OpenNest.Dimensions added.Add(new DrawingDimension { Kind = DimensionKind.OverallVertical, Offset = offset }); return added; } + + /// + /// A dimension for each circle or arc in + /// that does not have one yet. On a circle the leader points up and right for a radius and + /// up and left for a diameter, so the two do not overlap; on an arc it points through the + /// middle. The label sits outside the curve by half its radius, or a twentieth of + /// the part's larger size when that is more. + /// + public static List MissingRadial(IEnumerable curves, DimensionKind kind, + IEnumerable part, IEnumerable existing) + { + var added = new List(); + if (!DrawingDimension.IsRadialKind(kind)) + return added; + + var box = Extents(part); + var partSize = box == null ? 0 : System.Math.Max(box.Length, box.Width); + var taken = existing.Where(d => d.Kind == kind).Select(d => d.EntityId).ToHashSet(); + foreach (var curve in curves) + { + if (curve.Id == Guid.Empty || !taken.Add(curve.Id) || !TryCurve(curve, out _, out var radius, out var arc)) + continue; + added.Add(new DrawingDimension + { + Kind = kind, + EntityId = curve.Id, + Angle = arc != null ? MidAngle(arc) + : kind == DimensionKind.Radius ? Angle.HalfPI / 2 : Angle.HalfPI * 1.5, + Offset = System.Math.Max(radius / 2, partSize * RadialOffsetFactor), + }); + } + return added; + } + + /// Whether is a circle or arc a radius or diameter can measure. + public static bool IsRadialTarget(Entity entity) => TryCurve(entity, out _, out _, out _); + + private static bool TryCurve(Entity entity, out Vector center, out double radius, out Arc arc) + { + arc = null; + switch (entity) + { + case Circle circle: + center = circle.Center; + radius = circle.Radius; + break; + case Arc a: + center = a.Center; + radius = a.Radius; + arc = a.IsFullCircle() ? null : a; + break; + default: + center = default; + radius = 0; + return false; + } + return radius > 0 && double.IsFinite(radius); + } + + private static DimensionLayout RadialLayout(IEnumerable entities, DrawingDimension dimension) + { + var kind = dimension.Kind; + if (!double.IsFinite(dimension.Angle)) + return new DimensionLayout { Kind = kind, Error = "its leader angle is not usable" }; + + var entity = dimension.EntityId == Guid.Empty + ? null + : entities.FirstOrDefault(e => e.Id == dimension.EntityId); + if (entity == null) + return new DimensionLayout { Kind = kind, Error = "its circle or arc is not cut" }; + if (!TryCurve(entity, out var center, out var radius, out var arc)) + return new DimensionLayout { Kind = kind, Error = "it no longer measures a circle or arc" }; + + var reach = radius + dimension.Offset; + if (reach <= 0) + return new DimensionLayout { Kind = kind, Error = "its offset is not usable" }; + + var angle = arc != null && kind == DimensionKind.Radius + ? ClampToSweep(arc, dimension.Angle) + : dimension.Angle; + var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); + var arrow = center + direction * radius; + var far = center - direction * radius; + return new DimensionLayout + { + Kind = kind, + Value = kind == DimensionKind.Diameter ? radius * 2 : radius, + Center = center, + Radius = radius, + Angle = angle, + Arc = arc, + ArrowPoint = arrow, + FarArrowPoint = far, + LineStart = kind == DimensionKind.Diameter ? far : center, + LineEnd = center + direction * reach, + Extents = new Box(center.X - radius, center.Y - radius, radius * 2, radius * 2), + }; + } + + /// if it is on the arc's sweep, else the nearer end's angle. + public static double ClampToSweep(Arc arc, double angle) + { + if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed)) + return angle; + var toStart = AngularDistance(angle, arc.StartAngle); + var toEnd = AngularDistance(angle, arc.EndAngle); + return toStart <= toEnd ? arc.StartAngle : arc.EndAngle; + } + + private static double AngularDistance(double a, double b) + { + var d = Angle.NormalizeRad(a - b); + return System.Math.Min(d, Angle.TwoPI - d); + } + + private static double MidAngle(Arc arc) + { + // SweepAngle runs counterclockwise from the start, or from the end on a reversed arc. + var from = arc.IsReversed ? arc.EndAngle : arc.StartAngle; + return Angle.NormalizeRad(from + arc.SweepAngle() / 2); + } } } diff --git a/OpenNest.Core/Dimensions/DimensionLayout.cs b/OpenNest.Core/Dimensions/DimensionLayout.cs index 3bb4c4b3..809c2b74 100644 --- a/OpenNest.Core/Dimensions/DimensionLayout.cs +++ b/OpenNest.Core/Dimensions/DimensionLayout.cs @@ -37,8 +37,61 @@ namespace OpenNest.Dimensions public string ValueText => Value.ToString("0.000", CultureInfo.InvariantCulture); + /// The value as drawn: radius and diameter carry their drafting prefix. + public string DisplayText => Kind switch + { + DimensionKind.Radius => "R" + ValueText, + DimensionKind.Diameter => "\u00D8" + ValueText, + _ => ValueText, + }; + public Vector MidPoint => new((LineStart.X + LineEnd.X) / 2, (LineStart.Y + LineEnd.Y) / 2); + public bool IsRadial => DrawingDimension.IsRadialKind(Kind); + + // Radius and diameter: the leader runs from LineStart (the center for a radius, the far + // side of the curve for a diameter) through ArrowPoint on the curve to LineEnd, the label. + + /// Radius and diameter: the measured curve's center. + public Vector Center { get; init; } + + /// Radius and diameter: the measured curve's radius. + public double Radius { get; init; } + + /// Radius and diameter: the leader's direction from the center, in radians. + public double Angle { get; init; } + + /// Radius and diameter: where the leader meets the curve on the label's side. + public Vector ArrowPoint { get; init; } + + /// Diameter: where the leader meets the curve opposite . + public Vector FarArrowPoint { get; init; } + + /// Radius and diameter on an arc: the measured arc; null for a circle. + public Arc Arc { get; init; } + + /// + /// Radius and diameter: the leader angle and offset that put the label at + /// , with the label at least from both the + /// curve and the center. A radius on an arc stays on the arc's sweep. + /// + public (double Offset, double Angle) RadialPlacementAt(Vector point, double minimum) + { + var dx = point.X - Center.X; + var dy = point.Y - Center.Y; + var distance = System.Math.Sqrt(dx * dx + dy * dy); + var angle = distance > 0 ? System.Math.Atan2(dy, dx) : Angle; + if (Arc != null && Kind == DimensionKind.Radius) + angle = DimensionBuilder.ClampToSweep(Arc, angle); + + var offset = distance - Radius; + if (offset > -minimum && offset < minimum) + offset = offset >= 0 ? minimum : -minimum; + if (offset < minimum - Radius) + offset = minimum - Radius <= -minimum ? minimum - Radius : minimum; + return (offset, Math.Angle.NormalizeRad(angle)); + } + /// /// Where an extension line leaving on the dimension line first /// meets the profile offset by , walking toward the part by the diff --git a/OpenNest.Core/Dimensions/DrawingDimension.cs b/OpenNest.Core/Dimensions/DrawingDimension.cs index 893bef09..ed171475 100644 --- a/OpenNest.Core/Dimensions/DrawingDimension.cs +++ b/OpenNest.Core/Dimensions/DrawingDimension.cs @@ -1,3 +1,5 @@ +using System; + namespace OpenNest.Dimensions { public enum DimensionKind @@ -7,6 +9,12 @@ namespace OpenNest.Dimensions /// The part's overall size along Y, drawn right or left of it. OverallVertical, + + /// A circle's or arc's radius, on a leader from its center. + Radius, + + /// A circle's or arc's diameter, on a leader across it. + Diameter, } /// @@ -18,11 +26,23 @@ namespace OpenNest.Dimensions public DimensionKind Kind { get; set; } /// - /// Distance from the part's extents to the dimension line; never zero. Positive is above a - /// horizontal dimension's part or right of a vertical one's, negative the opposite side. + /// Never zero. Overall dimensions: distance from the part's extents to the dimension line, + /// positive above a horizontal dimension's part or right of a vertical one's. Radius and + /// diameter: distance from the curve to the label along the leader, positive outside. /// public double Offset { get; set; } - public DrawingDimension Clone() => new() { Kind = Kind, Offset = Offset }; + /// The circle or arc a radius or diameter measures; empty for overall dimensions. + public Guid EntityId { get; set; } + + /// Radius and diameter: the leader's direction from the center, in radians. + public double Angle { get; set; } + + public bool IsRadial => IsRadialKind(Kind); + + public static bool IsRadialKind(DimensionKind kind) => + kind is DimensionKind.Radius or DimensionKind.Diameter; + + public DrawingDimension Clone() => new() { Kind = Kind, Offset = Offset, EntityId = EntityId, Angle = Angle }; } } diff --git a/OpenNest.IO.Tests/DimensionPersistenceTests.cs b/OpenNest.IO.Tests/DimensionPersistenceTests.cs index df99b8d2..2a08c327 100644 --- a/OpenNest.IO.Tests/DimensionPersistenceTests.cs +++ b/OpenNest.IO.Tests/DimensionPersistenceTests.cs @@ -53,6 +53,56 @@ public class DimensionPersistenceTests Assert.Contains(warnings, w => w.Contains("dimension 1 could not be read")); } + private static Drawing WithRadial() + { + var drawing = DrawingPackageFixture.Build(); + drawing.Dimensions.Add(new DrawingDimension { Kind = DimensionKind.OverallHorizontal, Offset = -1.25 }); + drawing.Dimensions.Add(new DrawingDimension + { + Kind = DimensionKind.Diameter, + EntityId = RadialId, + Angle = 0.75, + Offset = 0.5, + }); + return drawing; + } + + private static readonly Guid RadialId = Guid.Parse("6d6f0d55-2f5b-4e83-9d1e-5a0b8f1c2a11"); + + [Fact] + public void RadialDimensions_RoundTripTheirCurveAndAngle_AndOverallOnesOmitThem() + { + var archive = Write(WithRadial()); + var json = NestJson(archive)["drawings"]![0]!["dimensions"]!; + + Assert.False(json[0]!.AsObject().ContainsKey("entityId")); + Assert.False(json[0]!.AsObject().ContainsKey("angle")); + + var (restored, warnings) = RoundTrip(archive); + Assert.Empty(warnings); + var radial = Assert.Single(restored.Drawings).Dimensions[1]; + Assert.Equal(DimensionKind.Diameter, radial.Kind); + Assert.Equal(RadialId, radial.EntityId); + Assert.Equal(0.75, radial.Angle); + Assert.Equal(0.5, radial.Offset); + } + + [Theory] + [InlineData("entityId")] + [InlineData("angle")] + public void RadialDimensionWithoutItsCurveOrAngle_IsDroppedWithAWarning(string field) + { + var archive = Write(WithRadial()); + var json = NestJson(archive); + json["drawings"]![0]!["dimensions"]![1]!.AsObject().Remove(field); + + var (restored, warnings) = RoundTrip(ReplaceNestJson(archive, json)); + + var dimension = Assert.Single(Assert.Single(restored.Drawings).Dimensions); + Assert.Equal(DimensionKind.OverallHorizontal, dimension.Kind); + Assert.Contains(warnings, w => w.Contains("dimension 2 could not be read")); + } + private static byte[] Write(Drawing drawing) { var nest = new Nest(drawing.Name) { Units = Units.Inches }; diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index d3f8be11..cbf6df15 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -495,6 +495,14 @@ namespace OpenNest.IO { public string Kind { get; init; } public double Offset { get; init; } + + /// Radius and diameter: the measured circle or arc. Omitted for overall dimensions. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? EntityId { get; init; } + + /// Radius and diameter: the leader angle in radians. Omitted for overall dimensions. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Angle { get; init; } } public record TabbedCutoutDto diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 95fa7a6c..dcbcc9e2 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -468,9 +468,18 @@ namespace OpenNest.IO { var dto = d.Dimensions[i]; if (dto != null && TryParseName(dto.Kind, out var kind) - && dto.Offset != 0 && double.IsFinite(dto.Offset)) + && dto.Offset != 0 && double.IsFinite(dto.Offset) + && (!DrawingDimension.IsRadialKind(kind) + || (dto.EntityId is { } id && id != Guid.Empty + && dto.Angle is { } angle && double.IsFinite(angle)))) { - dimensions.Add(new DrawingDimension { Kind = kind, Offset = dto.Offset }); + dimensions.Add(new DrawingDimension + { + Kind = kind, + Offset = dto.Offset, + EntityId = DrawingDimension.IsRadialKind(kind) ? dto.EntityId!.Value : Guid.Empty, + Angle = DrawingDimension.IsRadialKind(kind) ? dto.Angle!.Value : 0, + }); } else { diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index b9b07b76..de13b9bf 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -269,6 +269,8 @@ namespace OpenNest.IO { Kind = m.Kind.ToString(), Offset = m.Offset, + EntityId = m.IsRadial ? m.EntityId : null, + Angle = m.IsRadial ? m.Angle : null, }).ToList() : null, } diff --git a/OpenNest.Tests/Dimensions/RadialDimensionTests.cs b/OpenNest.Tests/Dimensions/RadialDimensionTests.cs new file mode 100644 index 00000000..071633a2 --- /dev/null +++ b/OpenNest.Tests/Dimensions/RadialDimensionTests.cs @@ -0,0 +1,156 @@ +using OpenNest.Dimensions; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Dimensions; + +public class RadialDimensionTests +{ + private static readonly Layer Cut = new("0") { IsVisible = true }; + + private static Circle Hole(double x = 5, double y = 3, double radius = 1) => + new(x, y, radius) { Layer = Cut, Id = Guid.NewGuid() }; + + private static DimensionLayout Layout(Entity curve, DimensionKind kind, double angle, double offset = 0.5) => + DimensionBuilder.Layout([curve], new DrawingDimension + { + Kind = kind, + EntityId = curve.Id, + Angle = angle, + Offset = offset, + }); + + [Fact] + public void Radius_MeasuresTheCircle_WithTheLeaderFromTheCenter() + { + var hole = Hole(radius: 1.25); + + var layout = Layout(hole, DimensionKind.Radius, 0, 0.5); + + Assert.True(layout.IsValid, layout.Error); + Assert.Equal(1.25, layout.Value, 9); + Assert.Equal("R1.250", layout.DisplayText); + Assert.Equal(0, layout.LineStart.DistanceTo(new Vector(5, 3)), 12); + Assert.Equal(0, layout.ArrowPoint.DistanceTo(new Vector(6.25, 3)), 12); + Assert.Equal(0, layout.LineEnd.DistanceTo(new Vector(6.75, 3)), 12); + } + + [Fact] + public void Diameter_MeasuresTwiceTheRadius_WithTheLeaderAcrossTheCircle() + { + var hole = Hole(radius: 1.25); + + var layout = Layout(hole, DimensionKind.Diameter, Angle.HalfPI, 0.5); + + Assert.True(layout.IsValid, layout.Error); + Assert.Equal(2.5, layout.Value, 9); + Assert.Equal("\u00D82.500", layout.DisplayText); + Assert.Equal(0, layout.LineStart.DistanceTo(new Vector(5, 1.75)), 12); + Assert.Equal(0, layout.ArrowPoint.DistanceTo(new Vector(5, 4.25)), 12); + Assert.Equal(0, layout.LineEnd.DistanceTo(new Vector(5, 4.75)), 12); + } + + [Fact] + public void RadiusOnAnArc_StaysOnItsSweep() + { + // A quarter arc from 0 to 90 degrees; a leader asked for at 180 degrees snaps to the + // nearer end, 90 degrees. + var arc = new Arc(new Vector(0, 0), 2, 0, Angle.HalfPI) { Layer = Cut, Id = Guid.NewGuid() }; + + var layout = Layout(arc, DimensionKind.Radius, System.Math.PI * 0.9); + + Assert.True(layout.IsValid, layout.Error); + Assert.Equal(2, layout.Value, 9); + Assert.Equal(Angle.HalfPI, layout.Angle, 9); + Assert.Equal(0, layout.ArrowPoint.DistanceTo(new Vector(0, 2)), 9); + } + + [Fact] + public void AMissingCurve_IsNotShown() + { + var layout = DimensionBuilder.Layout([Hole()], new DrawingDimension + { + Kind = DimensionKind.Radius, + EntityId = Guid.NewGuid(), + Angle = 0, + Offset = 1, + }); + + Assert.False(layout.IsValid); + Assert.Contains("not cut", layout.Error); + } + + [Fact] + public void ALine_CannotBeMeasuredAsARadius() + { + var line = new Line(0, 0, 4, 0) { Layer = Cut, Id = Guid.NewGuid() }; + + Assert.False(DimensionBuilder.IsRadialTarget(line)); + Assert.Empty(DimensionBuilder.MissingRadial([line], DimensionKind.Radius, [line], [])); + Assert.False(Layout(line, DimensionKind.Radius, 0).IsValid); + } + + [Fact] + public void MissingRadial_AddsOncePerCurveAndKind_OutsideTheCurve() + { + var hole = Hole(radius: 1); + var arc = new Arc(new Vector(0, 0), 2, 0, System.Math.PI) { Layer = Cut, Id = Guid.NewGuid() }; + + var first = DimensionBuilder.MissingRadial([hole, arc], DimensionKind.Radius, [hole, arc], []); + + Assert.Equal(2, first.Count); + Assert.All(first, d => Assert.True(d.Offset > 0)); + Assert.Equal(Angle.HalfPI, first[1].Angle, 9); // The middle of the half arc. + Assert.Empty(DimensionBuilder.MissingRadial([hole, arc], DimensionKind.Radius, [hole, arc], first)); + Assert.Equal(2, DimensionBuilder.MissingRadial([hole, arc], DimensionKind.Diameter, [hole, arc], first).Count); + } + + [Fact] + public void MissingRadial_OnAReversedArc_PointsThroughItsMiddle() + { + // Clockwise from 90 down to 0 degrees covers the first quadrant; its middle is 45. + var arc = new Arc(new Vector(0, 0), 2, Angle.HalfPI, 0, reversed: true) { Layer = Cut, Id = Guid.NewGuid() }; + + var added = Assert.Single(DimensionBuilder.MissingRadial([arc], DimensionKind.Radius, [arc], [])); + + Assert.Equal(Angle.HalfPI / 2, added.Angle, 9); + } + + [Theory] + [InlineData(8, 3, 2, 0)] // Outside to the right: 2 past the curve. + [InlineData(5, 3.5, -0.5, 0)] // Inside, near the center: kept the minimum clear of it. + [InlineData(6.02, 3, 0.1, 0)] // Hugging the curve: pushed the minimum out. + public void Dragging_PutsTheLabelUnderTheCursor_KeptClearOfCurveAndCenter(double x, double y, + double expectedOffset, double expectedAngle) + { + var layout = Layout(Hole(radius: 1), DimensionKind.Radius, 0); + + var (offset, angle) = layout.RadialPlacementAt(new Vector(x, y), 0.1); + + Assert.Equal(expectedOffset, offset, 9); + if (y == 3) + Assert.Equal(expectedAngle, angle, 9); + else + Assert.Equal(Angle.HalfPI, angle, 9); + } + + [Fact] + public void Dragging_ASmallHolesLabelInside_KeepsItOutsideInstead() + { + // Radius 0.15 with a 0.1 minimum: no inside spot is clear of both curve and center. + var layout = Layout(Hole(radius: 0.15), DimensionKind.Diameter, 0); + + var (offset, _) = layout.RadialPlacementAt(new Vector(5.05, 3), 0.1); + + Assert.Equal(0.1, offset, 9); + } + + [Fact] + public void Clone_KeepsTheCurveAndAngle() + { + var id = Guid.NewGuid(); + var clone = new DrawingDimension { Kind = DimensionKind.Diameter, EntityId = id, Angle = 1.5, Offset = 2 }.Clone(); + + Assert.Equal((DimensionKind.Diameter, id, 1.5, 2.0), (clone.Kind, clone.EntityId, clone.Angle, clone.Offset)); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs index 0c29baeb..dfc154d2 100644 --- a/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CadConverterDimensionTests.cs @@ -114,6 +114,72 @@ public class CadConverterDimensionTests form.Close(); }); + [Fact] + public void RadiusAndDiameter_FromTheEntityMenu_DragAroundTheHole_AndSurviveReopening() => ArchUnitsTests.RunSta(() => + { + var part = Part(); + var hole = new Circle(5, 3, 1) { Layer = part.SourceEntities![0].Layer }; + part.SourceEntities.Add(hole); + + using var form = new DimensionTestForm(); + Show(form); + form.LoadDrawings([part]); + var view = Field(form, "entityView1"); + var item = Field(form, "fileList").SelectedItem!; + + Invoke(form, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.AddRadiusDimension, [hole])); + Invoke(form, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.AddDiameterDimension, [hole])); + // Each kind exists once per curve. + Invoke(form, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.AddRadiusDimension, [hole])); + + Assert.Equal(new[] { DimensionKind.Radius, DimensionKind.Diameter }, item.Dimensions.Select(d => d.Kind)); + Assert.Equal(new[] { 1.0, 2.0 }, view.DimensionLayouts.Select(l => l.Value)); + + // Drag the radius label to straight left of the hole, 2 past its edge. + var press = view.PointWorldToControl(view.DimensionLayouts[0].LineEnd); + var release = view.PointWorldToControl(new Vector(2, 3)); + Mouse(view, "OnMouseDown", press); + Mouse(view, "OnMouseMove", release); + Mouse(view, "OnMouseUp", release); + Assert.InRange(item.Dimensions[0].Offset, 1.9, 2.1); + Assert.InRange(view.DimensionLayouts[0].ArrowPoint.X, 3.99, 4.01); + + var saved = Assert.Single(form.GetDrawings()); + Assert.All(saved.Dimensions, d => Assert.Equal(hole.Id, d.EntityId)); + form.Close(); + + // Reopened, both still measure the hole; excluded, they are listed as not shown. + using var reopened = new DimensionTestForm(); + Show(reopened); + reopened.LoadDrawings([saved]); + var reopenedView = Field(reopened, "entityView1"); + Assert.Equal(new[] { 1.0, 2.0 }, reopenedView.DimensionLayouts.Select(l => l.Value)); + + var reopenedHole = Field(reopened, "fileList").SelectedItem!.Entities.Single(e => e.Id == hole.Id); + Invoke(reopened, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.Exclude, [reopenedHole])); + Assert.All(reopenedView.DimensionLayouts, l => Assert.False(l.IsValid)); + reopened.Close(); + }); + + [Fact] + public void DisplayOnlyCircle_ExplainsHowToDimensionIt() => ArchUnitsTests.RunSta(() => + { + var part = Part(); + var hole = new Circle(5, 3, 1) { Layer = part.SourceEntities![0].Layer }; + part.SourceEntities.Add(hole); + + using var form = new DimensionTestForm(); + Show(form); + form.LoadDrawings([part]); + Invoke(form, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.Display, [hole])); + + Invoke(form, "OnEntityActionRequested", null, new EntityActionEventArgs(EntityAction.AddRadiusDimension, [hole])); + + Assert.Contains("Cut or Etch", Assert.Single(form.Messages)); + Assert.Empty(Field(form, "fileList").SelectedItem!.Dimensions); + form.Close(); + }); + private static Drawing Part() { var entities = new List diff --git a/OpenNest/Controls/EntityView.Dimensions.cs b/OpenNest/Controls/EntityView.Dimensions.cs index 96171593..65b99e01 100644 --- a/OpenNest/Controls/EntityView.Dimensions.cs +++ b/OpenNest/Controls/EntityView.Dimensions.cs @@ -10,16 +10,23 @@ namespace OpenNest.Controls { public sealed class DimensionDragEventArgs : EventArgs { - public DimensionDragEventArgs(int dimension, double offset) + public DimensionDragEventArgs(int dimension, double offset, double? angle = null) { Dimension = dimension; Offset = offset; + Angle = angle; } public int Dimension { get; } - /// The new distance from the part's extents, already kept clear of the part. + /// + /// The new distance from the part's extents, or from the curve for a radius or diameter, + /// already kept clear of the part. + /// public double Offset { get; } + + /// Radius and diameter: the new leader angle; null for overall dimensions. + public double? Angle { get; } } public partial class EntityView @@ -30,6 +37,7 @@ namespace OpenNest.Controls private const int DimensionOvershootPixels = 5; private const int ArrowLengthPixels = 9; private const int ArrowHalfWidthPixels = 3; + private const int CenterMarkPixels = 4; private readonly Font dimensionFont = new Font("Segoe UI", 9f); private int? draggedDimension; @@ -46,7 +54,15 @@ namespace OpenNest.Controls var layout = DimensionLayouts.ElementAtOrDefault(index); if (layout is not { IsValid: true }) return; - var offset = layout.OffsetAt(PointControlToWorld(location), LengthGuiToWorld(DimensionMinimumPixels)); + var point = PointControlToWorld(location); + var minimum = LengthGuiToWorld(DimensionMinimumPixels); + if (layout.IsRadial) + { + var (radialOffset, angle) = layout.RadialPlacementAt(point, minimum); + DimensionDragged?.Invoke(this, new DimensionDragEventArgs(index, radialOffset, angle)); + return; + } + var offset = layout.OffsetAt(point, minimum); DimensionDragged?.Invoke(this, new DimensionDragEventArgs(index, offset)); } @@ -66,8 +82,12 @@ namespace OpenNest.Controls return null; } - private Cursor DimensionCursor(int index) => - DimensionLayouts[index].Kind == DimensionKind.OverallHorizontal ? Cursors.SizeNS : Cursors.SizeWE; + private Cursor DimensionCursor(int index) => DimensionLayouts[index].Kind switch + { + DimensionKind.OverallHorizontal => Cursors.SizeNS, + DimensionKind.OverallVertical => Cursors.SizeWE, + _ => Cursors.SizeAll, + }; // Dimensions sit outside the part, so fitting the part alone would leave them off screen. private Box WithDimensions(Box box) @@ -85,6 +105,11 @@ namespace OpenNest.Controls { points.Add(layout.LineStart); points.Add(layout.LineEnd); + if (layout.IsRadial) + { + points.Add(layout.ArrowPoint); + points.Add(layout.FarArrowPoint); + } } return points.GetBoundingBox(); } @@ -113,6 +138,12 @@ namespace OpenNest.Controls private void DrawDimension(Graphics g, DimensionLayout layout, Pen pen, Brush brush) { + if (layout.IsRadial) + { + DrawRadialDimension(g, layout, pen, brush); + return; + } + var from = PointWorldToGraph(layout.LineStart); var to = PointWorldToGraph(layout.LineEnd); var length = System.Math.Sqrt((to.X - from.X) * (to.X - from.X) + (to.Y - from.Y) * (to.Y - from.Y)); @@ -138,6 +169,54 @@ namespace OpenNest.Controls g.Restore(state); } + // A leader from the center (radius) or the far side of the curve (diameter) out to the + // label, with arrows on the curve pointing at it from the label's side. + private void DrawRadialDimension(Graphics g, DimensionLayout layout, Pen pen, Brush brush) + { + var start = PointWorldToGraph(layout.LineStart); + var label = PointWorldToGraph(layout.LineEnd); + var arrow = PointWorldToGraph(layout.ArrowPoint); + g.DrawLine(pen, start, label); + + var center = PointWorldToGraph(layout.Center); + if (layout.Kind == DimensionKind.Diameter) + { + // Both tips on the curve, tails across the circle, the label beyond one end. + DrawArrowAt(g, brush, arrow, center); + DrawArrowAt(g, brush, PointWorldToGraph(layout.FarArrowPoint), center); + } + else + { + // The tail runs along the leader toward the label, outside or inside the curve. + var outside = layout.LineEnd.DistanceTo(layout.Center) >= layout.Radius; + DrawArrowAt(g, brush, arrow, outside ? label : center); + g.DrawLine(pen, center.X - CenterMarkPixels, center.Y, center.X + CenterMarkPixels, center.Y); + g.DrawLine(pen, center.X, center.Y - CenterMarkPixels, center.X, center.Y + CenterMarkPixels); + } + + // The label sits past the leader's end, on the side away from the curve's center. + var text = layout.DisplayText; + var size = g.MeasureString(text, dimensionFont); + var dx = System.Math.Cos(layout.Angle); + var dy = -System.Math.Sin(layout.Angle); // Screen Y runs down. + var reach = (float)(System.Math.Abs(dx) * size.Width / 2 + System.Math.Abs(dy) * size.Height / 2) + 2; + var cx = label.X + (float)dx * reach; + var cy = label.Y + (float)dy * reach; + g.FillRectangle(labelBackBrush, cx - size.Width / 2f, cy - size.Height / 2f, size.Width, size.Height); + g.DrawString(text, dimensionFont, brush, cx - size.Width / 2f, cy - size.Height / 2f); + } + + // An arrow with its tip at tip and its tail toward toward. + private static void DrawArrowAt(Graphics g, Brush brush, PointF tip, PointF toward) + { + var dx = toward.X - tip.X; + var dy = toward.Y - tip.Y; + var length = (float)System.Math.Sqrt(dx * dx + dy * dy); + if (length < 1) + return; + DrawArrow(g, brush, tip, new PointF(dx / length, dy / length)); + } + // Runs a little past the dimension line, the way drafted extension lines do. private static void DrawExtensionLine(Graphics g, Pen pen, PointF from, PointF to) { diff --git a/OpenNest/Controls/EntityView.Selection.cs b/OpenNest/Controls/EntityView.Selection.cs index d7e8c711..985b5266 100644 --- a/OpenNest/Controls/EntityView.Selection.cs +++ b/OpenNest/Controls/EntityView.Selection.cs @@ -16,6 +16,8 @@ namespace OpenNest.Controls Display, Exclude, Include, + AddRadiusDimension, + AddDiameterDimension, } public sealed class EntityActionEventArgs : EventArgs @@ -199,6 +201,18 @@ namespace OpenNest.Controls menu.Items.Add(include); } + var curves = selection.Where(en => !IsExcluded(en) && Dimensions.DimensionBuilder.IsRadialTarget(en)).ToList(); + if (curves.Count > 0) + { + menu.Items.Add(new ToolStripSeparator()); + var radius = new ToolStripMenuItem("Add Radius Dimension"); + radius.Click += (s, e) => RaiseEntityAction(EntityAction.AddRadiusDimension, curves); + menu.Items.Add(radius); + var diameter = new ToolStripMenuItem("Add Diameter Dimension"); + diameter.Click += (s, e) => RaiseEntityAction(EntityAction.AddDiameterDimension, curves); + menu.Items.Add(diameter); + } + menu.Closed += (s, e) => BeginInvoke(new Action(menu.Dispose)); menu.Show(this, location); } diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index 6839409e..3dd9344f 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -988,10 +988,14 @@ namespace OpenNest.Forms private static List DimensionedEntities(FileListItem item) => TabbedCutoutBuilder.Apply(CutoutEntities(item), item.TabbedCutouts, out _); + // Radius and diameter find their curve by ID before tabbed cutouts split it into pieces. private void RefreshDimensions(FileListItem item) { - var entities = item.Dimensions.Count > 0 ? DimensionedEntities(item) : new List(); - var layouts = item.Dimensions.Select(d => DimensionBuilder.Layout(entities, d)).ToList(); + var entities = item.Dimensions.Any(d => !d.IsRadial) ? DimensionedEntities(item) : new List(); + var curves = item.Dimensions.Any(d => d.IsRadial) ? VisibleEntities(item) : new List(); + var layouts = item.Dimensions + .Select(d => DimensionBuilder.Layout(d.IsRadial ? curves : entities, d)) + .ToList(); entityView1.DimensionLayouts = layouts; filterPanel.LoadDimensions(layouts .Select(l => DimensionLabel(l)) @@ -1000,7 +1004,13 @@ namespace OpenNest.Forms private static string DimensionLabel(DimensionLayout layout) { - var name = layout.Kind == DimensionKind.OverallHorizontal ? "Horizontal" : "Vertical"; + var name = layout.Kind switch + { + DimensionKind.OverallHorizontal => "Horizontal", + DimensionKind.OverallVertical => "Vertical", + DimensionKind.Radius => "Radius", + _ => "Diameter", + }; return layout.IsValid ? $"{name}: {layout.ValueText}" : $"{name} (not shown): {layout.Error}"; } @@ -1022,6 +1032,25 @@ namespace OpenNest.Forms entityView1.ZoomToFit(); } + private void AddRadialDimensions(FileListItem item, IEnumerable curves, DimensionKind kind) + { + var visible = VisibleEntities(item).Select(en => en.Id).ToHashSet(); + var cut = curves.Where(c => visible.Contains(c.Id)).ToList(); + if (cut.Count == 0) + { + ShowDimensionMessage("Only a circle or arc that is cut or etched can be dimensioned. Right-click it and choose Cut or Etch first."); + return; + } + + var added = DimensionBuilder.MissingRadial(cut, kind, DimensionedEntities(item), item.Dimensions); + if (added.Count == 0) + return; + item.Dimensions.AddRange(added); + entityView1.SelectedDimensionIndex = item.Dimensions.Count - 1; + RefreshDimensions(item); + entityView1.Invalidate(); + } + private void OnDimensionRemoved(object sender, int index) { var item = CurrentItem; @@ -1047,6 +1076,8 @@ namespace OpenNest.Forms return; item.Dimensions[e.Dimension].Offset = e.Offset; + if (e.Angle is { } angle) + item.Dimensions[e.Dimension].Angle = angle; entityView1.SelectedDimensionIndex = e.Dimension; RefreshDimensions(item); entityView1.Invalidate(); @@ -1522,6 +1553,12 @@ namespace OpenNest.Forms switch (e.Action) { + case EntityAction.AddRadiusDimension: + case EntityAction.AddDiameterDimension: + AddRadialDimensions(item, e.Entities, + e.Action == EntityAction.AddRadiusDimension ? DimensionKind.Radius : DimensionKind.Diameter); + return; + case EntityAction.Exclude: foreach (var entity in e.Entities) item.ExcludedEntityIds.Add(entity.Id); diff --git a/docs/dimensions.md b/docs/dimensions.md index 39a01d78..6076439f 100644 --- a/docs/dimensions.md +++ b/docs/dimensions.md @@ -1,28 +1,53 @@ # Dimensions -A drawing can carry overall dimensions: the part's size along X and along Y, shown in the -CAD Converter. In the **Dimensions** panel, **Add Overall Dimensions** adds whichever of the -two is missing, below and to the right of the part. Drag a dimension line to move it, across -the part to its other side if wanted; **Remove** deletes the selected one. +A drawing can carry dimensions, shown in the CAD Converter: + +- Overall dimensions: the part's size along X and along Y. In the **Dimensions** panel, + **Add Overall Dimensions** adds whichever of the two is missing, below and to the right of + the part. Drag a dimension line to move it, across the part to its other side if wanted. +- Radius and diameter dimensions on circles and arcs. Select one or more circles or arcs, + right-click and choose **Add Radius Dimension** or **Add Diameter Dimension**. Each curve + gets at most one of each. Drag the leader to swing the label around the curve and move it + in or out; a radius on an arc stays on the arc's sweep. + +**Remove** in the Dimensions panel deletes the selected dimension. ## Contract Dimensions are annotations. They never affect the program, nesting, cut time or posts, and they do not appear on plates, in PDFs or in DXF export. -- `Drawing.Dimensions` stores only a `DrawingDimension`'s kind and signed offset from the - part's extents, so a value cannot go stale. Positive is above (horizontal) or right - (vertical); zero is not a valid offset. -- `DimensionBuilder` (`OpenNest.Core/Dimensions`) measures the entities as they will be cut: - visible, with roles, breakoffs and tabbed cutouts applied, as the Program tab builds them. - `FileListItem.Bounds` is not used because it includes hidden layers. -- Marks do not count: anything `ScribeLayerRepair.IsMarkLayer` matches (scribe, etch and - engrave, by layer name) and anything `SpecialLayers.IsMaterial` rejects (rapids, break - cuts). `IsMaterial` compares layer objects, so it alone would count source marks on their - own layers. Tabs and a breakoff's scrap count: both are material of the part as cut. -- A dimension with nothing to measure, or a part with no size along its axis, is listed as - not shown and keeps its placement until the geometry returns. -- Values show three decimals with no unit, in the drawing's units. +- `Drawing.Dimensions` stores only placement, so a value cannot go stale: + - Overall: the kind and the signed offset from the part's extents. Positive is above + (horizontal) or right (vertical). + - Radius and diameter: the kind, the measured curve's entity ID, the leader angle from the + curve's center (radians) and the signed offset from the curve to the label along the + leader, positive outside. + - Zero is never a valid offset. +- `DimensionBuilder` (`OpenNest.Core/Dimensions`) measures overall dimensions from the + entities as they will be cut: visible, with roles, breakoffs and tabbed cutouts applied, as + the Program tab builds them. `FileListItem.Bounds` is not used because it includes hidden + layers. +- Marks do not count toward overall dimensions: anything `ScribeLayerRepair.IsMarkLayer` + matches (scribe, etch and engrave, by layer name) and anything `SpecialLayers.IsMaterial` + rejects (rapids, break cuts). `IsMaterial` compares layer objects, so it alone would count + source marks on their own layers. Tabs and a breakoff's scrap count: both are material of + the part as cut. +- Radius and diameter find their curve by ID among the visible entities with roles applied, + before breakoffs and tabbed cutouts, which split a curve into new pieces. A cut or etched + curve can be dimensioned; an excluded, hidden or display-only one is not shown. Adding one + to a display-only curve explains that it must be set to Cut or Etch first. An arc that is + a full circle is measured as a circle. +- New radius and diameter labels sit outside the curve by half its radius, or a twentieth of + the part's larger size when that is more. On a circle the radius leader points up and right + and the diameter leader up and left, so the two do not overlap; on an arc both point + through the middle of its sweep. Dragging keeps the label a few pixels clear of both the + curve and the center; a curve too small for that keeps the label outside. +- A dimension with nothing to measure, a part with no size along its axis, or a curve that + is gone or no longer cut is listed as not shown and keeps its placement until the geometry + returns. +- Values show three decimals with no unit, in the drawing's units; radius labels are drawn + with an `R` prefix and diameter labels with `Ø`. - Split pieces start with no dimensions, like breakoffs and tabbed cutouts. File format: see [drawing dimensions](nest-file-format.md#drawing-dimensions). diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 5d89b57f..f81d9ab5 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -398,11 +398,18 @@ versions without tabbed cutout support read them as ordinary cut motions. ## Drawing dimensions A drawing may carry a `dimensions` array, omitted when empty. Each entry has `kind` -(`OverallHorizontal` or `OverallVertical`) and `offset`, the signed distance from the part's -extents to the dimension line (positive is above or right); see [dimensions](dimensions.md). -Values are not stored: they are measured when shown. An entry with an unknown kind or an -offset that is zero or not finite is dropped with a reader warning; the drawing and its -program are unaffected. +(`OverallHorizontal`, `OverallVertical`, `Radius` or `Diameter`) and `offset`. For the +overall kinds, `offset` is the signed distance from the part's extents to the dimension line +(positive is above or right). `Radius` and `Diameter` also have `entityId`, the source entity +ID of the measured circle or arc, and `angle`, the leader's direction from its center in +radians; their `offset` is the signed distance from the curve to the label along the leader +(positive is outside). Overall entries omit `entityId` and `angle`. See +[dimensions](dimensions.md). + +Values are not stored: they are measured when shown. An entry with an unknown kind, an +offset that is zero or not finite, or a radius or diameter without a non-empty `entityId` and +a finite `angle` is dropped with a reader warning; the drawing and its program are +unaffected. Versions without radius and diameter support drop those entries the same way. ## Drawing entity roles