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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-30 22:47:56 -04:00
1 parent 05d5c56ac0
commit ca693285bb
14 files changed
+689 -35

No files matched your search

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class DimensionBuilder
{
private const double DefaultOffsetFactor = 0.1;
private const double RadialOffsetFactor = 0.05;
/// <summary>
/// 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;
}
/// <summary>
/// A <paramref name="kind"/> dimension for each circle or arc in <paramref name="curves"/>
/// 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.
/// </summary>
public static List<DrawingDimension> MissingRadial(IEnumerable<Entity> curves, DimensionKind kind,
IEnumerable<Entity> part, IEnumerable<DrawingDimension> existing)
{
var added = new List<DrawingDimension>();
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;
}
/// <summary>Whether <paramref name="entity"/> is a circle or arc a radius or diameter can measure.</summary>
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<Entity> 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),
};
}
/// <summary><paramref name="angle"/> if it is on the arc's sweep, else the nearer end's angle.</summary>
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);
}
}
}
@@ -37,8 +37,61 @@ namespace OpenNest.Dimensions
public string ValueText => Value.ToString("0.000", CultureInfo.InvariantCulture);
/// <summary>The value as drawn: radius and diameter carry their drafting prefix.</summary>
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.
/// <summary>Radius and diameter: the measured curve's center.</summary>
public Vector Center { get; init; }
/// <summary>Radius and diameter: the measured curve's radius.</summary>
public double Radius { get; init; }
/// <summary>Radius and diameter: the leader's direction from the center, in radians.</summary>
public double Angle { get; init; }
/// <summary>Radius and diameter: where the leader meets the curve on the label's side.</summary>
public Vector ArrowPoint { get; init; }
/// <summary>Diameter: where the leader meets the curve opposite <see cref="ArrowPoint"/>.</summary>
public Vector FarArrowPoint { get; init; }
/// <summary>Radius and diameter on an arc: the measured arc; null for a circle.</summary>
public Arc Arc { get; init; }
/// <summary>
/// Radius and diameter: the leader angle and offset that put the label at
/// <paramref name="point"/>, with the label at least <paramref name="minimum"/> from both the
/// curve and the center. A radius on an arc stays on the arc's sweep.
/// </summary>
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));
}
/// <summary>
/// Where an extension line leaving <paramref name="foot"/> on the dimension line first
/// meets the profile offset by <paramref name="clearance"/>, walking toward the part by the
+23 -3
View File
@@ -1,3 +1,5 @@
using System;
namespace OpenNest.Dimensions
{
public enum DimensionKind
@@ -7,6 +9,12 @@ namespace OpenNest.Dimensions
/// <summary>The part's overall size along Y, drawn right or left of it.</summary>
OverallVertical,
/// <summary>A circle's or arc's radius, on a leader from its center.</summary>
Radius,
/// <summary>A circle's or arc's diameter, on a leader across it.</summary>
Diameter,
}
/// <summary>
@@ -18,11 +26,23 @@ namespace OpenNest.Dimensions
public DimensionKind Kind { get; set; }
/// <summary>
/// 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.
/// </summary>
public double Offset { get; set; }
public DrawingDimension Clone() => new() { Kind = Kind, Offset = Offset };
/// <summary>The circle or arc a radius or diameter measures; empty for overall dimensions.</summary>
public Guid EntityId { get; set; }
/// <summary>Radius and diameter: the leader's direction from the center, in radians.</summary>
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 };
}
}
@@ -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 };
+8
View File
@@ -495,6 +495,14 @@ namespace OpenNest.IO
{
public string Kind { get; init; }
public double Offset { get; init; }
/// <summary>Radius and diameter: the measured circle or arc. Omitted for overall dimensions.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Guid? EntityId { get; init; }
/// <summary>Radius and diameter: the leader angle in radians. Omitted for overall dimensions.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Angle { get; init; }
}
public record TabbedCutoutDto
+11 -2
View File
@@ -468,9 +468,18 @@ namespace OpenNest.IO
{
var dto = d.Dimensions[i];
if (dto != null && TryParseName<DimensionKind>(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
{
+2
View File
@@ -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,
}
@@ -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));
}
}
@@ -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<EntityView>(form, "entityView1");
var item = Field<FileListControl>(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<EntityView>(reopened, "entityView1");
Assert.Equal(new[] { 1.0, 2.0 }, reopenedView.DimensionLayouts.Select(l => l.Value));
var reopenedHole = Field<FileListControl>(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<FileListControl>(form, "fileList").SelectedItem!.Dimensions);
form.Close();
});
private static Drawing Part()
{
var entities = new List<Entity>
+84 -5
View File
@@ -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; }
/// <summary>The new distance from the part's extents, already kept clear of the part.</summary>
/// <summary>
/// The new distance from the part's extents, or from the curve for a radius or diameter,
/// already kept clear of the part.
/// </summary>
public double Offset { get; }
/// <summary>Radius and diameter: the new leader angle; null for overall dimensions.</summary>
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)
{
+14
View File
@@ -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);
}
+40 -3
View File
@@ -988,10 +988,14 @@ namespace OpenNest.Forms
private static List<Entity> 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<Entity>();
var layouts = item.Dimensions.Select(d => DimensionBuilder.Layout(entities, d)).ToList();
var entities = item.Dimensions.Any(d => !d.IsRadial) ? DimensionedEntities(item) : new List<Entity>();
var curves = item.Dimensions.Any(d => d.IsRadial) ? VisibleEntities(item) : new List<Entity>();
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<Entity> 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);
+42 -17
View File
@@ -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).
+12 -5
View File
@@ -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