Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions Sources/ModelingEvolution.Drawing.Tests/Epic128PrimitivesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using FluentAssertions;
using ModelingEvolution.Drawing.Units;
using Xunit;

namespace ModelingEvolution.Drawing.Tests;

/// <summary>
/// Epic 128 §1.2 additive primitives, rebased onto the epic-129 quaternion <see cref="Frame3{T}"/> model:
/// <see cref="Frame3{T}.FromAxes"/> (basis-from-directions for tool placement), <see cref="Arc{T}"/>, and the
/// mm/s→mm/min <see cref="Speed{T}"/> bridge. These are the shared geometry the ModelingEvolution.ToolPath
/// module builds on. (The old Rotation3.FromAxes / Pose3.FromFrame were dropped: the basis→rotation now lives
/// on <see cref="Quaternion{T}.FromAxes"/>, which this builds on.)
/// </summary>
public class Epic128PrimitivesTests
{
private const double Eps = 1e-9;

private static void BeClose(Vector3<double> actual, Vector3<double> expected)
{
actual.X.Should().BeApproximately(expected.X, Eps);
actual.Y.Should().BeApproximately(expected.Y, Eps);
actual.Z.Should().BeApproximately(expected.Z, Eps);
}

[Fact]
public void FromAxes_aligned_hints_give_an_axis_aligned_frame()
{
// forward = +X, approach = +Z ⇒ local X=+X, Z=+Z, Y = Z×X = +Y (identity orientation).
var f = Frame3<double>.FromAxes(new Point3<double>(1, 2, 3), Vector3<double>.EX, Vector3<double>.EZ);

f.Position.X.Should().BeApproximately(1, Eps);
f.Position.Y.Should().BeApproximately(2, Eps);
f.Position.Z.Should().BeApproximately(3, Eps);
BeClose(f.TransformVector(Vector3<double>.EX), Vector3<double>.EX);
BeClose(f.TransformVector(Vector3<double>.EY), Vector3<double>.EY);
BeClose(f.TransformVector(Vector3<double>.EZ), Vector3<double>.EZ);
}

[Fact]
public void FromAxes_reorthogonalizes_forward_against_approach()
{
// forward is not orthogonal to approach — its approach-component must be projected out, so local X
// comes back as the clean +X while local Z stays +Z.
var f = Frame3<double>.FromAxes(Point3<double>.Zero, new Vector3<double>(1, 0, 0.5), Vector3<double>.EZ);

BeClose(f.TransformVector(Vector3<double>.EZ), Vector3<double>.EZ); // local Z == approach
BeClose(f.TransformVector(Vector3<double>.EX), Vector3<double>.EX); // local X == forward, de-Z'd
}

[Fact]
public void FromAxes_90deg_about_Z_maps_the_basis()
{
// forward +Y, approach +Z ⇒ local X=+Y, Z=+Z, Y = Z×X = -X (a +90° turn about Z).
var f = Frame3<double>.FromAxes(Point3<double>.Zero, Vector3<double>.EY, Vector3<double>.EZ);

BeClose(f.TransformVector(Vector3<double>.EX), Vector3<double>.EY);
BeClose(f.TransformVector(Vector3<double>.EY), new Vector3<double>(-1, 0, 0));
BeClose(f.TransformVector(Vector3<double>.EZ), Vector3<double>.EZ);
}

[Fact]
public void FromAxes_throws_when_forward_parallel_to_approach()
{
var act = () => Frame3<double>.FromAxes(Point3<double>.Zero, Vector3<double>.EZ, Vector3<double>.EZ);
act.Should().Throw<System.ArgumentException>();
}

[Fact]
public void FromAxes_throws_when_approach_is_degenerate()
{
var act = () => Frame3<double>.FromAxes(Point3<double>.Zero, Vector3<double>.EX, Vector3<double>.Zero);
act.Should().Throw<System.ArgumentException>();
}

[Fact]
public void Arc_points_and_length_for_quarter_circle()
{
var arc = new Arc<double>(new Point<double>(0, 0), 1.0, 0.0, 90.0); // CCW quarter from +X
arc.StartPoint.X.Should().BeApproximately(1, Eps);
arc.StartPoint.Y.Should().BeApproximately(0, Eps);
arc.EndPoint.X.Should().BeApproximately(0, Eps);
arc.EndPoint.Y.Should().BeApproximately(1, Eps);
arc.Mid.X.Should().BeApproximately(System.Math.Sqrt(0.5), 1e-9);
arc.Mid.Y.Should().BeApproximately(System.Math.Sqrt(0.5), 1e-9);
arc.Length.Should().BeApproximately(System.Math.PI / 2, 1e-9);
}

[Fact]
public void Arc_tangent_follows_sweep_sign()
{
var ccw = new Arc<double>(new Point<double>(0, 0), 1.0, 0.0, 90.0);
var cw = new Arc<double>(new Point<double>(0, 0), 1.0, 0.0, -90.0);
var tCcw = ccw.TangentAt(0.0);
var tCw = cw.TangentAt(0.0);
tCcw.X.Should().BeApproximately(0, Eps);
tCcw.Y.Should().BeApproximately(1, Eps); // CCW at +X travels +Y
tCw.X.Should().BeApproximately(0, Eps);
tCw.Y.Should().BeApproximately(-1, Eps); // CW reverses
}

[Fact]
public void Speed_bridge_converts_mm_per_second_to_mm_per_minute()
{
var mmPerS = new Speed<double, MillimetrePerSecond<double>>(10); // 10 mm/s
var legacy = mmPerS.ToLegacyMmPerMin(); // expect 600 mm/min
((double)legacy).Should().BeApproximately(600, 1e-9);
}
}
72 changes: 72 additions & 0 deletions Sources/ModelingEvolution.Drawing/Arc.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Numerics;
using ProtoBuf;

namespace ModelingEvolution.Drawing;

/// <summary>
/// A circular arc in 2D space: a <see cref="Center"/> and <see cref="Radius"/>, swept from
/// <see cref="Start"/> by a signed <see cref="Sweep"/> (CCW positive, CW negative). A full circle is
/// an arc with <c>|Sweep| = 360°</c>. Unlike <see cref="Circle{T}"/> it carries start/sweep, so it maps
/// directly to a CAD <c>ARC</c> entity and to a 3-point circular robot move (start / <see cref="Mid"/> / end).
/// </summary>
/// <typeparam name="T">The numeric type used for coordinates and angles.</typeparam>
[ProtoContract]
public readonly record struct Arc<T>
where T : INumber<T>, ITrigonometricFunctions<T>, IRootFunctions<T>, IFloatingPoint<T>, ISignedNumber<T>,
IFloatingPointIeee754<T>, IMinMaxValue<T>
{
private static readonly T Two = T.CreateTruncating(2);
private static readonly T DegToRad = T.Pi / T.CreateTruncating(180);

/// <summary>The arc's centre.</summary>
[ProtoMember(1)] public Point<T> Center { get; init; }

/// <summary>The arc's radius.</summary>
[ProtoMember(2)] public T Radius { get; init; }

/// <summary>The start angle, measured CCW from +X.</summary>
[ProtoMember(3)] public Degree<T> Start { get; init; }

/// <summary>The signed swept angle (CCW positive). A full circle is <c>±360°</c>.</summary>
[ProtoMember(4)] public Degree<T> Sweep { get; init; }

public Arc(Point<T> center, T radius, Degree<T> start, Degree<T> sweep)
{
Center = center;
Radius = radius;
Start = start;
Sweep = sweep;
}

/// <summary>The end angle (<see cref="Start"/> + <see cref="Sweep"/>).</summary>
public Degree<T> End => (Degree<T>)((T)Start + (T)Sweep);

/// <summary>The mid angle (<see cref="Start"/> + <see cref="Sweep"/>/2).</summary>
public Degree<T> MidAngle => (Degree<T>)((T)Start + (T)Sweep / Two);

/// <summary>The point on the arc at the given angle.</summary>
public Point<T> PointAt(Degree<T> angle)
=> new(Center.X + Radius * Degree.Cos(angle), Center.Y + Radius * Degree.Sin(angle));

/// <summary>The start point of the arc.</summary>
public Point<T> StartPoint => PointAt(Start);

/// <summary>The end point of the arc.</summary>
public Point<T> EndPoint => PointAt(End);

/// <summary>The midpoint of the arc (at <see cref="MidAngle"/>) — the natural via-point for a circular move.</summary>
public Point<T> Mid => PointAt(MidAngle);

/// <summary>The arc length.</summary>
public T Length => Radius * T.Abs((T)Sweep) * DegToRad;

/// <summary>
/// The unit travel tangent at the given angle, oriented in the sweep direction (CW arcs point the
/// opposite way to CCW arcs).
/// </summary>
public Vector<T> TangentAt(Degree<T> angle)
{
var sign = (T)Sweep >= T.Zero ? T.One : -T.One;
return new Vector<T>(-Degree.Sin(angle) * sign, Degree.Cos(angle) * sign).Normalize();
}
}
33 changes: 33 additions & 0 deletions Sources/ModelingEvolution.Drawing/Frame3.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@ public static Frame3<T> FromPose3(Pose3<T> p, T lengthScale)
public Pose3<T> ToPose3(T lengthScale) =>
new(Position * lengthScale, Rotation3<T>.FromQuaternion(Rotation));

/// <summary>
/// Creates a frame from an origin and two direction hints: the forward (local +X) and approach (local +Z)
/// axes. Local Z is the normalized <paramref name="approach"/>, local X is <paramref name="forward"/>
/// re-orthogonalized against Z, and local Y = Z × X. The hints need not be unit or exactly orthogonal — they
/// are cleaned up here — but must not be (near-)parallel or degenerate. This is the basis-from-directions
/// factory for tool placement (a torch approach axis + travel tangent), complementing the point-based
/// <see cref="FromSurface(Point3{T}, Point3{T}, Point3{T})"/>.
/// </summary>
/// <param name="origin">The frame origin.</param>
/// <param name="forward">Desired local +X (e.g. a travel tangent); its approach-component is projected out.</param>
/// <param name="approach">Desired local +Z (e.g. a tool approach axis).</param>
/// <exception cref="ArgumentException">Thrown when the axes are (near-)parallel or degenerate.</exception>
public static Frame3<T> FromAxes(Point3<T> origin, Vector3<T> forward, Vector3<T> approach)
{
var epsilon = T.CreateTruncating(1e-9);

var zLen = approach.Length;
if (zLen < epsilon)
throw new ArgumentException("Approach axis is degenerate (near-zero length).", nameof(approach));
var z = approach.Normalize();

// X = forward with its approach-component removed, then normalized.
var dot = Vector3<T>.Dot(forward, z);
var xRaw = forward - z * dot;
var xLen = xRaw.Length;
if (xLen < epsilon)
throw new ArgumentException("Forward axis is parallel to the approach axis; cannot build a frame.", nameof(forward));
var x = xRaw.Normalize();

var y = Vector3<T>.Cross(z, x);
return new Frame3<T>(origin, Quaternion<T>.FromAxes(x, y, z));
}

#region FromSurface

/// <summary>
Expand Down
21 changes: 21 additions & 0 deletions Sources/ModelingEvolution.Drawing/Units/SpeedInterop.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Numerics;

namespace ModelingEvolution.Drawing.Units;

/// <summary>
/// Bridges the unit-aware <see cref="Speed{T,TUnit}"/> to the legacy unit-less
/// <see cref="ModelingEvolution.Drawing.Speed{T}"/>, whose canonical unit is millimetres per minute (mm/min).
/// Use this at the boundary with APIs that take the legacy type (e.g. <c>Trajectory3Builder.MoveTo</c>) so a
/// mm/s value is not silently read as mm/min — a 60× error.
/// </summary>
public static class SpeedInterop
{
/// <summary>
/// Converts any unit-aware speed to the legacy <see cref="ModelingEvolution.Drawing.Speed{T}"/> (mm/min).
/// </summary>
public static Speed<T> ToLegacyMmPerMin<T, TUnit>(this Speed<T, TUnit> speed)
where T : INumber<T>, ITrigonometricFunctions<T>, IRootFunctions<T>, IFloatingPoint<T>,
ISignedNumber<T>, IFloatingPointIeee754<T>, IMinMaxValue<T>
where TUnit : ISpeedUnit<T>
=> Speed<T>.From(speed.To<MillimetrePerMinute<T>>().Value);
}
15 changes: 15 additions & 0 deletions Sources/ModelingEvolution.Drawing/Units/SpeedUnits.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,18 @@ public interface ISpeedUnit<T> : IUnit<T> where T : INumber<T>;
/// <inheritdoc/>
public static string Symbol => "m/min";
}

/// <summary>
/// Millimetres per minute. 1 mm/min = 1e-3 m / 60 s. This is the unit of the legacy unit-less
/// <see cref="ModelingEvolution.Drawing.Speed{T}"/>, so it is the bridge target for interop
/// (see <see cref="SpeedInterop.ToLegacyMmPerMin{T, TUnit}"/>).
/// </summary>
public readonly struct MillimetrePerMinute<T> : ISpeedUnit<T> where T : INumber<T>
{
/// <inheritdoc/>
public static T ToSi(T value) => value / T.CreateChecked(60000.0);
/// <inheritdoc/>
public static T FromSi(T si) => si * T.CreateChecked(60000.0);
/// <inheritdoc/>
public static string Symbol => "mm/min";
}
Loading