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