Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7c98ef4a | ||
|
|
f44c1d465f | ||
|
|
bdb78a689d | ||
|
|
3f89f8f5dd | ||
|
|
3cab90747a | ||
|
|
fff3bef4e4 | ||
|
|
2b78fb3a75 | ||
|
|
a04460c57b | ||
|
|
27948ba8cf |
@@ -217,3 +217,7 @@ docs/superpowers/
|
||||
|
||||
# Local test config (contains user-specific paths to proprietary test assets)
|
||||
OpenNest.Tests/test-config.json
|
||||
|
||||
# Vendor programming manuals: keep reference copies outside source control.
|
||||
CINCINNATI LASER PROGRAMMING MANUAL.pdf
|
||||
TF5200_programming_manual_en.pdf
|
||||
|
||||
@@ -111,7 +111,8 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
|
||||
The UI application with MDI interface.
|
||||
|
||||
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Nest defaults**: new nests load plate defaults (units, size, quadrant, part/edge spacing) from `%APPDATA%\OpenNest\defaults.json` via `OpenNest.Data.NestDefaults` — a single JSON file edited via Tools > Nest Defaults or captured from the active plate via Tools > Save Current Plate as Defaults. Loading never throws: missing/corrupt/invalid fields fall back individually (units then come from the legacy `DefaultUnit` setting). This replaces the `.nstdot` nest-template file; a set `NestTemplatePath` setting is converted to `defaults.json` once at startup and cleared. Console/Training `--template <nest>` flags are unrelated explicit inputs and remain.
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), `NestDefaultsForm` (persisted new-nest defaults), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. Plugin sources live in the repository's `Posts/` folder (the solution's `PostProcessors` folder).
|
||||
@@ -137,6 +138,8 @@ Always keep `README.md` and `AGENTS.md` up to date when making changes that affe
|
||||
|
||||
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
|
||||
|
||||
Keep vendor programming manuals and full-text extracts outside source control unless redistribution permission has been established. Maintain project-written post behavior references instead: [Cincinnati CL](docs/cincinnati-post-output.md) and [Cincinnati CI Fiber](docs/cincinnati-ci-fiber-post-output.md). Cite the manual edition and relevant sections, distinguish controller rules from machine-specific macros, and document unconfirmed behavior without copying vendor text.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Signed clearance between two closed polygons, plus the unit direction that
|
||||
/// increases it by moving the first polygon.
|
||||
/// </summary>
|
||||
public struct ClearanceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// > 0: minimum boundary distance. 0: touching. < 0: penetration depth
|
||||
/// (the translation of <c>a</c> along <see cref="Direction"/> needed to end
|
||||
/// contact).
|
||||
/// </summary>
|
||||
public double Distance;
|
||||
|
||||
/// <summary>
|
||||
/// Unit direction for translating <c>a</c> away from <c>b</c>. For penetration
|
||||
/// this is the minimum-translation direction. Never zero-length; degenerate
|
||||
/// (coincident-centroid) penetration resolves to a deterministic axis.
|
||||
/// </summary>
|
||||
public Vector Direction;
|
||||
|
||||
public ClearanceResult(double distance, Vector direction)
|
||||
{
|
||||
Distance = distance;
|
||||
Direction = direction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Omnidirectional clearance between two closed, lines-only polygons.
|
||||
/// Complements <see cref="SpatialQuery.DirectionalDistance"/> (movement along a
|
||||
/// fixed ray) with the all-directions minimum distance and separating direction,
|
||||
/// and <see cref="Collision"/> (boolean overlap) with depth and direction.
|
||||
/// <para>
|
||||
/// Reference quality, not hot-loop quality: separation is a brute-force
|
||||
/// segment-pair minimum with a bounding-box reject, penetration is a
|
||||
/// separating-axis sweep over both polygons' edge normals. The overlap verdict
|
||||
/// defers to <see cref="Collision.HasOverlap(Polygon, Polygon, List{Polygon}, List{Polygon})"/>
|
||||
/// so callers that validate with Collision never see a disagreeing kernel.
|
||||
/// Rings with holes are handled by the caller: pass every ring pair (a part's
|
||||
/// material boundary is its outer ring plus its hole rings).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Clearance
|
||||
{
|
||||
public static ClearanceResult Between(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
if (Collision.HasOverlap(a, b))
|
||||
return Penetration(linesA, linesB);
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-negative minimum boundary (edge-to-edge) distance between two rings
|
||||
/// and the direction that translates <paramref name="a"/> away from
|
||||
/// <paramref name="b"/> at the closest contact. Never tests overlap: a ring
|
||||
/// contained in another (a part inside a cutout ring) still reports its true
|
||||
/// gap. For signed material clearance use <see cref="Between"/>.
|
||||
/// </summary>
|
||||
public static ClearanceResult BoundaryDistance(Polygon a, Polygon b)
|
||||
{
|
||||
var linesA = a.ToLines();
|
||||
var linesB = b.ToLines();
|
||||
|
||||
if (linesA.Count == 0 || linesB.Count == 0)
|
||||
return new ClearanceResult(0, new Vector(1, 0));
|
||||
|
||||
return Separation(linesA, linesB);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum boundary distance between two non-overlapping rings and the
|
||||
/// direction that translates <paramref name="linesA"/> away from
|
||||
/// <paramref name="linesB"/> at the closest contact.
|
||||
/// </summary>
|
||||
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var pa = Vector.Zero;
|
||||
var pb = Vector.Zero;
|
||||
|
||||
var boxes = new Box[linesB.Count];
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
boxes[i] = SegmentBox(linesB[i]);
|
||||
|
||||
foreach (var la in linesA)
|
||||
{
|
||||
var boxA = SegmentBox(la);
|
||||
|
||||
for (var i = 0; i < linesB.Count; i++)
|
||||
{
|
||||
if (!BoxesWithin(boxA, boxes[i], minDist))
|
||||
continue;
|
||||
|
||||
var d = SegmentDistance(la, linesB[i], out var qa, out var qb);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
pa = qa;
|
||||
pb = qb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dir = pa - pb;
|
||||
var len = Magnitude(dir);
|
||||
|
||||
if (len <= Tolerance.Epsilon)
|
||||
dir = CentroidAway(linesA, linesB);
|
||||
else
|
||||
dir = dir / len;
|
||||
|
||||
return new ClearanceResult(minDist, dir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Penetration depth and minimum-translation direction along the separating-
|
||||
/// axis candidates of both rings. Per candidate axis the true translation
|
||||
/// depth is used (exit distance to the far side), so containment reports the
|
||||
/// depth that actually ends contact, not the interval-intersection length.
|
||||
/// Depth is reported as a negative clearance.
|
||||
/// </summary>
|
||||
private static ClearanceResult Penetration(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var ca = Centroid(linesA);
|
||||
var cb = Centroid(linesB);
|
||||
|
||||
var bestDepth = double.MaxValue;
|
||||
var bestDir = new Vector(1, 0);
|
||||
|
||||
var bestAxis = -1;
|
||||
|
||||
for (var axis = 0; axis < 2; axis++)
|
||||
{
|
||||
var lines = axis == 0 ? linesA : linesB;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var edge = line.pt2 - line.pt1;
|
||||
var n = new Vector(edge.Y, -edge.X);
|
||||
var len = Magnitude(n);
|
||||
if (len <= Tolerance.Epsilon)
|
||||
continue;
|
||||
n = n / len;
|
||||
|
||||
var (minA, maxA) = Project(linesA, n);
|
||||
var (minB, maxB) = Project(linesB, n);
|
||||
|
||||
if (maxA <= minB || maxB <= minA)
|
||||
continue; // separating axis found
|
||||
|
||||
// Depth pushing a away from b along ±n.
|
||||
var forward = maxB - minA; // move a in +n until minA >= maxB
|
||||
var backward = maxA - minB; // move a in -n until maxA <= minB
|
||||
|
||||
double depth;
|
||||
Vector dir;
|
||||
if (forward <= backward)
|
||||
{
|
||||
depth = forward;
|
||||
dir = n;
|
||||
}
|
||||
else
|
||||
{
|
||||
depth = backward;
|
||||
dir = -n;
|
||||
}
|
||||
|
||||
if (depth < bestDepth - Tolerance.Epsilon || bestAxis < 0)
|
||||
{
|
||||
bestDepth = depth;
|
||||
bestDir = dir;
|
||||
bestAxis = axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestAxis < 0)
|
||||
{
|
||||
// No candidate axis (degenerate rings): deterministic fallback.
|
||||
var away = ca - cb;
|
||||
var len = Magnitude(away);
|
||||
bestDir = len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
bestDepth = 0;
|
||||
}
|
||||
|
||||
return new ClearanceResult(-bestDepth, bestDir);
|
||||
}
|
||||
|
||||
private static Vector CentroidAway(List<Line> linesA, List<Line> linesB)
|
||||
{
|
||||
var away = Centroid(linesA) - Centroid(linesB);
|
||||
var len = Magnitude(away);
|
||||
return len > Tolerance.Epsilon ? away / len : new Vector(1, 0);
|
||||
}
|
||||
|
||||
private static Vector Centroid(List<Line> lines)
|
||||
{
|
||||
var sum = Vector.Zero;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
sum += line.pt1;
|
||||
sum += line.pt2;
|
||||
}
|
||||
return sum / (2 * lines.Count);
|
||||
}
|
||||
|
||||
private static (double Min, double Max) Project(List<Line> lines, Vector n)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var d1 = line.pt1.DotProduct(n);
|
||||
var d2 = line.pt2.DotProduct(n);
|
||||
if (d1 < min)
|
||||
min = d1;
|
||||
if (d1 > max)
|
||||
max = d1;
|
||||
if (d2 < min)
|
||||
min = d2;
|
||||
if (d2 > max)
|
||||
max = d2;
|
||||
}
|
||||
|
||||
return (min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum distance between two segments with the closest points.
|
||||
/// Non-parallel segments use the classic clamped closest-point solve;
|
||||
/// (near-)parallel segments fall back to the four endpoint-to-segment
|
||||
/// distances, which is where the minimum always lies.
|
||||
/// </summary>
|
||||
private static double SegmentDistance(Line a, Line b, out Vector pa, out Vector pb)
|
||||
{
|
||||
var p = a.pt1;
|
||||
var r = a.pt2 - a.pt1;
|
||||
var q = b.pt1;
|
||||
var s = b.pt2 - b.pt1;
|
||||
|
||||
var rxr = r.DotProduct(r);
|
||||
var sxs = s.DotProduct(s);
|
||||
var rxs = r.DotProduct(s);
|
||||
|
||||
const double eps = 1e-12;
|
||||
|
||||
var denom = rxr * sxs - rxs * rxs;
|
||||
if (denom > eps && rxr > eps && sxs > eps)
|
||||
{
|
||||
// Minimize |(p + r t) - (q + s u)|^2; setting both partials to
|
||||
// zero and solving (Cramer) with d0 = p - q:
|
||||
// t = ((r.s)(d0.s) - (d0.r)(s.s)) / (rr.ss - (r.s)^2)
|
||||
// u = ((r.r)(d0.s) - (r.s)(d0.r)) / (rr.ss - (r.s)^2)
|
||||
var d0 = p - q;
|
||||
var d0r = d0.DotProduct(r);
|
||||
var d0s = d0.DotProduct(s);
|
||||
|
||||
var t = Clamp((rxs * d0s - d0r * sxs) / denom, 0, 1);
|
||||
var u = Clamp((rxs * t + d0s) / sxs, 0, 1); // nearest u on b for clamped t
|
||||
t = Clamp((rxs * u - d0r) / rxr, 0, 1); // re-solve t for clamped u
|
||||
|
||||
pa = p + r * t;
|
||||
pb = q + s * u;
|
||||
return pa.DistanceTo(pb);
|
||||
}
|
||||
|
||||
// Degenerate or parallel: the minimum is attained at an endpoint.
|
||||
var bestPa = p;
|
||||
var bestPb = q;
|
||||
var best = double.MaxValue;
|
||||
|
||||
void Consider(Vector pt, Line seg, bool ptOnA)
|
||||
{
|
||||
var d = seg.pt2 - seg.pt1;
|
||||
var len2 = d.DotProduct(d);
|
||||
var u = len2 <= eps ? 0 : Clamp((pt - seg.pt1).DotProduct(d) / len2, 0, 1);
|
||||
var on = seg.pt1 + d * u;
|
||||
var dist = pt.DistanceTo(on);
|
||||
if (dist < best)
|
||||
{
|
||||
best = dist;
|
||||
bestPa = ptOnA ? pt : on;
|
||||
bestPb = ptOnA ? on : pt;
|
||||
}
|
||||
}
|
||||
|
||||
Consider(p, b, true);
|
||||
Consider(a.pt2, b, true);
|
||||
Consider(q, a, false);
|
||||
Consider(b.pt2, a, false);
|
||||
|
||||
pa = bestPa;
|
||||
pb = bestPb;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static double Clamp(double v, double lo, double hi) =>
|
||||
v < lo ? lo : (v > hi ? hi : v);
|
||||
|
||||
private static double Magnitude(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||
|
||||
private static Box SegmentBox(Line line)
|
||||
{
|
||||
return new Box(
|
||||
System.Math.Min(line.pt1.X, line.pt2.X),
|
||||
System.Math.Min(line.pt1.Y, line.pt2.Y),
|
||||
System.Math.Abs(line.pt2.X - line.pt1.X),
|
||||
System.Math.Abs(line.pt2.Y - line.pt1.Y)
|
||||
);
|
||||
}
|
||||
|
||||
private static bool BoxesWithin(Box a, Box b, double distance)
|
||||
{
|
||||
return !(
|
||||
a.Right + distance < b.Left
|
||||
|| b.Right + distance < a.Left
|
||||
|| a.Top + distance < b.Bottom
|
||||
|| b.Top + distance < a.Bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
internal enum ContactSide
|
||||
{
|
||||
/// <summary>The boundary could not be decomposed into closed loops.</summary>
|
||||
Unresolved,
|
||||
|
||||
/// <summary>The point is not on the boundary: a tolerance near-miss, not a contact.</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>Several boundary runs meet here, or the corner is a cusp or spike.</summary>
|
||||
Ambiguous,
|
||||
|
||||
/// <summary>The material sector is known.</summary>
|
||||
Sector,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed boundary loops of one entity list, prepared so a directional slide can tell
|
||||
/// which side of each boundary point is material. Immutable after
|
||||
/// <see cref="Prepare"/>, so one instance may be shared by concurrent queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Loops are recovered from contiguous runs whose end points chain back to their start
|
||||
/// (the order produced by <see cref="ShapeBuilder"/> and the offset helpers). Nesting
|
||||
/// depth decides holes: material is inside even-depth loops and outside odd-depth ones.
|
||||
/// When the list cannot be decomposed that way, every contact query is unresolved.
|
||||
/// </remarks>
|
||||
public sealed class SlideContactGeometry
|
||||
{
|
||||
// Contact points are computed from unsnapped ray parameters, so a genuine contact is
|
||||
// on both boundaries to floating-point accuracy. This also bounds the overlap sliver a
|
||||
// tangential classification can admit, so keep it far below spacing tolerances.
|
||||
internal const double IncidenceTolerance = 1e-7;
|
||||
|
||||
private readonly List<Entity> entities;
|
||||
private readonly int[] loopOf;
|
||||
private readonly int[] previous;
|
||||
private readonly int[] following;
|
||||
private readonly bool[] materialLeft;
|
||||
|
||||
private SlideContactGeometry(
|
||||
List<Entity> entities,
|
||||
int[] loopOf,
|
||||
int[] previous,
|
||||
int[] following,
|
||||
bool[] materialLeft
|
||||
)
|
||||
{
|
||||
this.entities = entities;
|
||||
this.loopOf = loopOf;
|
||||
this.previous = previous;
|
||||
this.following = following;
|
||||
this.materialLeft = materialLeft;
|
||||
}
|
||||
|
||||
/// <summary>True when every entity belongs to a closed loop with a known material side.</summary>
|
||||
public bool IsResolved => materialLeft != null;
|
||||
|
||||
public static SlideContactGeometry Prepare(List<Entity> entities)
|
||||
{
|
||||
var count = entities.Count;
|
||||
var loopOf = new int[count];
|
||||
var previous = new int[count];
|
||||
var following = new int[count];
|
||||
var loops = new List<(int First, int Last)>();
|
||||
|
||||
var i = 0;
|
||||
while (i < count)
|
||||
{
|
||||
var first = i;
|
||||
if (entities[i] is Circle)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryEndpoints(entities[i], out var start, out _))
|
||||
return Unresolved(entities);
|
||||
|
||||
var closed = false;
|
||||
while (i < count && TryEndpoints(entities[i], out _, out var end))
|
||||
{
|
||||
// A lone closed arc is a loop; a lone line cannot be, even when it
|
||||
// has zero length and so ends where it starts.
|
||||
if (Near(end, start) && (i > first || entities[i] is Arc))
|
||||
{
|
||||
closed = true;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
i + 1 >= count
|
||||
|| !TryEndpoints(entities[i + 1], out var nextStart, out _)
|
||||
|| !Near(nextStart, end)
|
||||
)
|
||||
break;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!closed)
|
||||
return Unresolved(entities);
|
||||
}
|
||||
|
||||
var loop = loops.Count;
|
||||
loops.Add((first, i - 1));
|
||||
for (var k = first; k < i; k++)
|
||||
{
|
||||
loopOf[k] = loop;
|
||||
previous[k] = k == first ? i - 1 : k - 1;
|
||||
following[k] = k == i - 1 ? first : k + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var materialLeft = new bool[loops.Count];
|
||||
|
||||
for (var loop = 0; loop < loops.Count; loop++)
|
||||
{
|
||||
var area = SignedArea(entities, loops[loop].First, loops[loop].Last);
|
||||
if (System.Math.Abs(area) <= Tolerance.Epsilon)
|
||||
return Unresolved(entities);
|
||||
|
||||
var depth = 0;
|
||||
if (loops.Count > 1)
|
||||
{
|
||||
var sample = SamplePoint(entities[loops[loop].First]);
|
||||
for (var other = 0; other < loops.Count; other++)
|
||||
{
|
||||
if (other == loop)
|
||||
continue;
|
||||
if (Contains(entities, loops[other].First, loops[other].Last, sample))
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
materialLeft[loop] = (area > 0) == (depth % 2 == 0);
|
||||
}
|
||||
|
||||
return new SlideContactGeometry(entities, loopOf, previous, following, materialLeft);
|
||||
}
|
||||
|
||||
private static SlideContactGeometry Unresolved(List<Entity> entities) =>
|
||||
new SlideContactGeometry(entities, null, null, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Material directions at a boundary point: an angular sector starting at
|
||||
/// <paramref name="start"/> and sweeping CCW by <paramref name="width"/>.
|
||||
/// Concavity is recorded separately at each sector ray: only the supporting
|
||||
/// curve, not an unrelated curve at that corner, can block a tangential slide.
|
||||
/// Entities wholly inside the incidence tolerance are treated as part of the corner.
|
||||
/// </summary>
|
||||
internal ContactSide GetMaterialSector(
|
||||
Vector point,
|
||||
out double start,
|
||||
out double width,
|
||||
out bool startConcave,
|
||||
out bool endConcave
|
||||
)
|
||||
{
|
||||
start = width = 0;
|
||||
startConcave = endConcave = false;
|
||||
if (materialLeft == null)
|
||||
return ContactSide.Unresolved;
|
||||
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var distance = DistanceTo(entities[i], point);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (best < 0 || bestDistance > IncidenceTolerance)
|
||||
return ContactSide.Off;
|
||||
|
||||
// Walk to the entities that enter and leave the tolerance disc.
|
||||
var loopLength = LoopLength(best);
|
||||
var incoming = best;
|
||||
var steps = 0;
|
||||
var smoothLoop = loopLength == 1 && (entities[best] is Circle
|
||||
|| entities[best] is Arc fullArc && fullArc.IsFullCircle());
|
||||
while (!smoothLoop && StartsNear(incoming, point))
|
||||
{
|
||||
incoming = previous[incoming];
|
||||
if (++steps >= loopLength)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
var outgoing = best;
|
||||
steps = 0;
|
||||
while (!smoothLoop && EndsNear(outgoing, point))
|
||||
{
|
||||
outgoing = following[outgoing];
|
||||
if (++steps >= loopLength)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
// Anything else touching this point (another loop, a spike, a self-crossing)
|
||||
// makes the local material side ambiguous.
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (InRun(i, incoming, outgoing))
|
||||
continue;
|
||||
if (DistanceTo(entities[i], point) <= IncidenceTolerance)
|
||||
return ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
var interior = incoming == best && outgoing == best && !EndsNear(best, point);
|
||||
var inTangent = interior ? TangentAt(entities[best], point) : EndTangent(entities[incoming]);
|
||||
var outTangent = interior
|
||||
? inTangent
|
||||
: StartTangent(entities[outgoing]);
|
||||
|
||||
// A circle has no endpoints, so its point is always interior.
|
||||
if (smoothLoop)
|
||||
inTangent = outTangent = TangentAt(entities[best], point);
|
||||
|
||||
if (IsZero(inTangent) || IsZero(outTangent))
|
||||
return ContactSide.Ambiguous;
|
||||
|
||||
var outAngle = System.Math.Atan2(outTangent.Y, outTangent.X);
|
||||
var inAngle = System.Math.Atan2(-inTangent.Y, -inTangent.X);
|
||||
var left = materialLeft[loopOf[best]];
|
||||
|
||||
start = left ? outAngle : inAngle;
|
||||
width = Angle.NormalizeRad((left ? inAngle : outAngle) - start);
|
||||
|
||||
startConcave = IsConcave(entities[left ? outgoing : incoming], left);
|
||||
endConcave = IsConcave(entities[left ? incoming : outgoing], left);
|
||||
|
||||
return
|
||||
width > SlideContact.AngleTolerance
|
||||
&& width < Angle.TwoPI - 2 * SlideContact.SplitOverlap
|
||||
? ContactSide.Sector
|
||||
: ContactSide.Ambiguous;
|
||||
}
|
||||
|
||||
private int LoopLength(int index)
|
||||
{
|
||||
var length = 1;
|
||||
for (var i = following[index]; i != index; i = following[i])
|
||||
length++;
|
||||
return length;
|
||||
}
|
||||
|
||||
private bool StartsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out var start, out _)
|
||||
&& start.DistanceTo(point) <= IncidenceTolerance;
|
||||
|
||||
private bool EndsNear(int index, Vector point) =>
|
||||
TryEndpoints(entities[index], out _, out var end)
|
||||
&& end.DistanceTo(point) <= IncidenceTolerance;
|
||||
|
||||
private bool InRun(int index, int first, int last)
|
||||
{
|
||||
for (var i = first; ; i = following[i])
|
||||
{
|
||||
if (i == index)
|
||||
return true;
|
||||
if (i == last)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsZero(Vector v) => v.X == 0 && v.Y == 0;
|
||||
|
||||
private static bool IsConcave(Entity entity, bool materialLeft)
|
||||
{
|
||||
// A CCW curve has its center on its left; that center is on the free side
|
||||
// (a concave boundary) exactly when material is on the right.
|
||||
return entity switch
|
||||
{
|
||||
Arc arc => materialLeft == arc.IsReversed,
|
||||
Circle circle => materialLeft == (circle.Rotation == RotationType.CW),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector StartTangent(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.StartAngle, arc.IsReversed),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector EndTangent(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.EndAngle, arc.IsReversed),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector TangentAt(Entity entity, Vector point) =>
|
||||
entity switch
|
||||
{
|
||||
Line line => Direction(line.pt1, line.pt2),
|
||||
Arc arc => ArcTangent(arc.Center.AngleTo(point), arc.IsReversed),
|
||||
Circle circle => ArcTangent(
|
||||
circle.Center.AngleTo(point),
|
||||
circle.Rotation == RotationType.CW
|
||||
),
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
private static Vector ArcTangent(double angle, bool clockwise)
|
||||
{
|
||||
var sign = clockwise ? -1.0 : 1.0;
|
||||
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
|
||||
}
|
||||
|
||||
private static Vector Direction(Vector from, Vector to)
|
||||
{
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
return length > 0 ? new Vector(dx / length, dy / length) : new Vector();
|
||||
}
|
||||
|
||||
private static double DistanceTo(Entity entity, Vector point)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
return point.DistanceTo(line.ClosestPointTo(point));
|
||||
case Arc arc:
|
||||
{
|
||||
var angle = arc.Center.AngleTo(point);
|
||||
if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
return System.Math.Abs(arc.Center.DistanceTo(point) - arc.Radius);
|
||||
return System.Math.Min(
|
||||
point.DistanceTo(arc.StartPoint()),
|
||||
point.DistanceTo(arc.EndPoint())
|
||||
);
|
||||
}
|
||||
case Circle circle:
|
||||
return System.Math.Abs(circle.Center.DistanceTo(point) - circle.Radius);
|
||||
default:
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
start = line.pt1;
|
||||
end = line.pt2;
|
||||
return true;
|
||||
case Arc arc:
|
||||
start = arc.StartPoint();
|
||||
end = arc.EndPoint();
|
||||
return true;
|
||||
default:
|
||||
start = end = new Vector();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
|
||||
|
||||
private static double SignedArea(List<Entity> entities, int first, int last)
|
||||
{
|
||||
var area = 0.0;
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Circle circle:
|
||||
var sign = circle.Rotation == RotationType.CW ? -1 : 1;
|
||||
area += sign * System.Math.PI * circle.Radius * circle.Radius;
|
||||
break;
|
||||
case Line line:
|
||||
area += Cross(line.pt1, line.pt2) / 2;
|
||||
break;
|
||||
case Arc arc:
|
||||
var sweep = arc.IsReversed ? -arc.SweepAngle() : arc.SweepAngle();
|
||||
var r = arc.Radius;
|
||||
area += Cross(arc.StartPoint(), arc.EndPoint()) / 2;
|
||||
area += r * r / 2 * (sweep - System.Math.Sin(sweep));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - b.X * a.Y;
|
||||
|
||||
private static Vector SamplePoint(Entity entity) =>
|
||||
entity switch
|
||||
{
|
||||
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||
Arc arc => arc.StartPoint(),
|
||||
Line line => line.pt1,
|
||||
_ => new Vector(),
|
||||
};
|
||||
|
||||
// Exact horizontal-ray parity. Split arcs at Y extrema so every piece is
|
||||
// monotone; the same half-open endpoint rule as lines avoids seam double counts.
|
||||
// A coarse inscribed polygon can misclassify thin rings as solid material.
|
||||
private static bool Contains(List<Entity> entities, int first, int last, Vector point)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
return circle.Center.DistanceTo(point) < circle.Radius;
|
||||
if (entities[i] is Line line)
|
||||
{
|
||||
var a = line.pt1;
|
||||
var b = line.pt2;
|
||||
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||
inside = !inside;
|
||||
}
|
||||
else if (entities[i] is Arc arc)
|
||||
{
|
||||
var sweep = arc.SweepAngle();
|
||||
var sign = arc.IsReversed ? -1.0 : 1.0;
|
||||
var cuts = new List<double> { 0, sweep };
|
||||
foreach (var extreme in new[] { Angle.HalfPI, 3 * Angle.HalfPI })
|
||||
{
|
||||
var t = Angle.NormalizeRad(sign * (extreme - arc.StartAngle));
|
||||
if (t > 0 && t < sweep)
|
||||
cuts.Add(t);
|
||||
}
|
||||
cuts.Sort();
|
||||
for (var k = 1; k < cuts.Count; k++)
|
||||
{
|
||||
var a = arc.StartAngle + sign * cuts[k - 1];
|
||||
var b = arc.StartAngle + sign * cuts[k];
|
||||
var y1 = arc.Center.Y + arc.Radius * System.Math.Sin(a);
|
||||
var y2 = arc.Center.Y + arc.Radius * System.Math.Sin(b);
|
||||
if ((y1 > point.Y) == (y2 > point.Y))
|
||||
continue;
|
||||
var dy = point.Y - arc.Center.Y;
|
||||
var dx = System.Math.Sqrt(System.Math.Max(0, arc.Radius * arc.Radius - dy * dy));
|
||||
var x = arc.Center.X + (System.Math.Cos((a + b) / 2) >= 0 ? dx : -dx);
|
||||
if (point.X < x)
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact classifier for one moving/stationary pair of boundaries. Geometry is prepared
|
||||
/// on first use, so a slide whose nearest contact is never classified pays nothing; call
|
||||
/// <see cref="Prepare"/> before sharing one instance across threads. Each boundary is
|
||||
/// given in its own frame; the origins place those frames in the world coordinates used
|
||||
/// by slide events.
|
||||
/// </summary>
|
||||
public sealed class SlideContactClassifier
|
||||
{
|
||||
private readonly System.Func<List<Entity>> movingSource;
|
||||
private readonly System.Func<List<Entity>> stationarySource;
|
||||
private SlideContactGeometry moving;
|
||||
private SlideContactGeometry stationary;
|
||||
|
||||
public SlideContactClassifier(List<Entity> movingEntities, List<Entity> stationaryEntities)
|
||||
: this(movingEntities, Vector.Zero, stationaryEntities, Vector.Zero) { }
|
||||
|
||||
public SlideContactClassifier(
|
||||
List<Entity> movingEntities,
|
||||
Vector movingOrigin,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
: this(() => movingEntities, movingOrigin, () => stationaryEntities, stationaryOrigin)
|
||||
{ }
|
||||
|
||||
public SlideContactClassifier(
|
||||
SlideContactGeometry moving,
|
||||
Vector movingOrigin,
|
||||
SlideContactGeometry stationary,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.stationary = stationary;
|
||||
MovingOrigin = movingOrigin;
|
||||
StationaryOrigin = stationaryOrigin;
|
||||
}
|
||||
|
||||
private SlideContactClassifier(
|
||||
System.Func<List<Entity>> movingSource,
|
||||
Vector movingOrigin,
|
||||
System.Func<List<Entity>> stationarySource,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
this.movingSource = movingSource;
|
||||
this.stationarySource = stationarySource;
|
||||
MovingOrigin = movingOrigin;
|
||||
StationaryOrigin = stationaryOrigin;
|
||||
}
|
||||
|
||||
public Vector MovingOrigin { get; }
|
||||
|
||||
public Vector StationaryOrigin { get; }
|
||||
|
||||
public static SlideContactClassifier FromLines(
|
||||
List<Line> movingLines,
|
||||
Vector movingOrigin,
|
||||
List<Line> stationaryLines,
|
||||
Vector stationaryOrigin
|
||||
) =>
|
||||
new SlideContactClassifier(
|
||||
() => new List<Entity>(movingLines),
|
||||
movingOrigin,
|
||||
() => new List<Entity>(stationaryLines),
|
||||
stationaryOrigin
|
||||
);
|
||||
|
||||
public static SlideContactClassifier FromEdges(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOrigin,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOrigin
|
||||
)
|
||||
{
|
||||
// The kernel sorts edge arrays in place, so snapshot the chain order now.
|
||||
var moving = ((Vector start, Vector end)[])movingEdges.Clone();
|
||||
var stationary = ((Vector start, Vector end)[])stationaryEdges.Clone();
|
||||
return new SlideContactClassifier(
|
||||
() => ToLines(moving),
|
||||
movingOrigin,
|
||||
() => ToLines(stationary),
|
||||
stationaryOrigin
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Entity> ToLines((Vector start, Vector end)[] edges)
|
||||
{
|
||||
var lines = new List<Entity>(edges.Length);
|
||||
foreach (var (start, end) in edges)
|
||||
lines.Add(new Line(start, end));
|
||||
// Public edge arrays are sorted in place by previous queries. Recover their
|
||||
// chains on private line objects; never reverse or reorder caller geometry.
|
||||
var ordered = new List<Entity>(lines.Count);
|
||||
foreach (var shape in ShapeBuilder.GetShapes(lines))
|
||||
ordered.AddRange(shape.Entities);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
public SlideContactClassifier Prepare()
|
||||
{
|
||||
moving ??= SlideContactGeometry.Prepare(movingSource?.Invoke() ?? new List<Entity>());
|
||||
stationary ??= SlideContactGeometry.Prepare(
|
||||
stationarySource?.Invoke() ?? new List<Entity>()
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>The same prepared boundaries placed at other origins.</summary>
|
||||
public SlideContactClassifier At(Vector movingOrigin, Vector stationaryOrigin)
|
||||
{
|
||||
Prepare();
|
||||
return new SlideContactClassifier(moving, movingOrigin, stationary, stationaryOrigin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when moving along (dirX, dirY) from this world-space contact would push
|
||||
/// material into material, or the contact cannot be classified.
|
||||
/// </summary>
|
||||
public bool Blocks(Vector movingPoint, Vector stationaryPoint, double dirX, double dirY)
|
||||
{
|
||||
Prepare();
|
||||
return SlideContact.Blocks(
|
||||
moving,
|
||||
movingPoint - MovingOrigin,
|
||||
stationary,
|
||||
stationaryPoint - StationaryOrigin,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Receives candidate contact events from a directional slide query.</summary>
|
||||
public interface ISlideEventSink
|
||||
{
|
||||
/// <summary>True once further events cannot change this sink's result.</summary>
|
||||
bool IsDone { get; }
|
||||
|
||||
/// <param name="distance">Travel to the contact, snapped to zero within Tolerance.Epsilon.</param>
|
||||
/// <param name="movingPoint">Contact on the moving boundary, at its start position.</param>
|
||||
/// <param name="stationaryPoint">Contact on the stationary boundary.</param>
|
||||
void Add(double distance, Vector movingPoint, Vector stationaryPoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates every candidate contact of one slide. Must yield the same events each
|
||||
/// time it is enumerated.
|
||||
/// </summary>
|
||||
public interface ISlideEventSource
|
||||
{
|
||||
void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink;
|
||||
}
|
||||
|
||||
/// <summary>Keeps the nearest event; stops at a contact that is already touching.</summary>
|
||||
public struct NearestSlideEvent : ISlideEventSink
|
||||
{
|
||||
public bool Found;
|
||||
public double Distance;
|
||||
public Vector MovingPoint;
|
||||
public Vector StationaryPoint;
|
||||
|
||||
public bool IsDone => Found && Distance <= 0;
|
||||
|
||||
public void Add(double distance, Vector movingPoint, Vector stationaryPoint)
|
||||
{
|
||||
if (Found && distance >= Distance)
|
||||
return;
|
||||
|
||||
Found = true;
|
||||
Distance = distance;
|
||||
MovingPoint = movingPoint;
|
||||
StationaryPoint = stationaryPoint;
|
||||
}
|
||||
}
|
||||
|
||||
internal struct SlideEventList : ISlideEventSink
|
||||
{
|
||||
public List<(double Distance, Vector MovingPoint, Vector StationaryPoint)> Events;
|
||||
|
||||
public bool IsDone => false;
|
||||
|
||||
public void Add(double distance, Vector movingPoint, Vector stationaryPoint) =>
|
||||
Events.Add((distance, movingPoint, stationaryPoint));
|
||||
}
|
||||
|
||||
public static class SlideResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Travel to the first contact that blocks the slide, or double.MaxValue. When the
|
||||
/// nearest contact blocks (every contact, for unresolved boundaries), the result is
|
||||
/// exactly the nearest event distance and the events are enumerated once.
|
||||
/// </summary>
|
||||
public static double FirstBlocking<TSource>(
|
||||
ref TSource source,
|
||||
SlideContactClassifier contacts,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
where TSource : struct, ISlideEventSource
|
||||
{
|
||||
var nearest = new NearestSlideEvent();
|
||||
source.Enumerate(ref nearest);
|
||||
|
||||
if (!nearest.Found)
|
||||
return double.MaxValue;
|
||||
|
||||
if (contacts.Blocks(nearest.MovingPoint, nearest.StationaryPoint, dirX, dirY))
|
||||
return nearest.Distance;
|
||||
|
||||
var all = new SlideEventList
|
||||
{
|
||||
Events = new List<(double, Vector, Vector)>(),
|
||||
};
|
||||
source.Enumerate(ref all);
|
||||
all.Events.Sort((a, b) => a.Distance.CompareTo(b.Distance));
|
||||
|
||||
foreach (var (distance, movingPoint, stationaryPoint) in all.Events)
|
||||
{
|
||||
if (contacts.Blocks(movingPoint, stationaryPoint, dirX, dirY))
|
||||
return distance;
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a first-contact event found by a directional slide stops the slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parts that already touch may slide along each other or apart. Only a direction that
|
||||
/// would create positive-area overlap blocks: with S the stationary material sector and
|
||||
/// M the moving one at the contact point, that is the open Minkowski cone S ⊕ −M.
|
||||
/// A direction on that cone's boundary is a tangential slide; it blocks only when an
|
||||
/// incident curve is concave, because the second-order bend then closes the gap.
|
||||
/// Unresolved or ambiguous topology blocks, which is the previous behavior for every
|
||||
/// contact.
|
||||
/// </remarks>
|
||||
public static class SlideContact
|
||||
{
|
||||
internal const double AngleTolerance = 1e-7;
|
||||
|
||||
// Reflex sectors are split into two overlapping convex halves; the overlap keeps
|
||||
// the split ray in the interior of the union.
|
||||
internal const double SplitOverlap = 1e-3;
|
||||
|
||||
/// <summary>
|
||||
/// True when moving along (dirX, dirY) from this contact would push material into
|
||||
/// material, or when the contact cannot be classified. False for a near-miss whose
|
||||
/// point is not on both boundaries.
|
||||
/// </summary>
|
||||
/// <param name="movingPoint">Contact point in the moving entities' own frame.</param>
|
||||
/// <param name="stationaryPoint">The same contact in the stationary frame.</param>
|
||||
public static bool Blocks(
|
||||
SlideContactGeometry moving,
|
||||
Vector movingPoint,
|
||||
SlideContactGeometry stationary,
|
||||
Vector stationaryPoint,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (moving == null || stationary == null)
|
||||
return true;
|
||||
|
||||
var stationarySide = stationary.GetMaterialSector(
|
||||
stationaryPoint,
|
||||
out var stationaryStart,
|
||||
out var stationaryWidth,
|
||||
out var stationaryStartConcave,
|
||||
out var stationaryEndConcave
|
||||
);
|
||||
var movingSide = moving.GetMaterialSector(
|
||||
movingPoint,
|
||||
out var movingStart,
|
||||
out var movingWidth,
|
||||
out var movingStartConcave,
|
||||
out var movingEndConcave
|
||||
);
|
||||
|
||||
if (stationarySide == ContactSide.Unresolved || movingSide == ContactSide.Unresolved)
|
||||
return true;
|
||||
|
||||
// Ray tolerances report hits slightly beyond an entity's end; such a point is
|
||||
// not on the other boundary, so the parts pass without touching there.
|
||||
if (stationarySide == ContactSide.Off || movingSide == ContactSide.Off)
|
||||
return false;
|
||||
|
||||
if (stationarySide == ContactSide.Ambiguous || movingSide == ContactSide.Ambiguous)
|
||||
return true;
|
||||
|
||||
var direction = System.Math.Atan2(dirY, dirX);
|
||||
var stationaryPieces = Split(stationaryStart, stationaryWidth);
|
||||
var movingPieces = Split(movingStart + System.Math.PI, movingWidth);
|
||||
var onBoundary = false;
|
||||
|
||||
foreach (var s in stationaryPieces)
|
||||
{
|
||||
foreach (var m in movingPieces)
|
||||
{
|
||||
if (!TryHull(s, m, out var hullStart, out var hullWidth))
|
||||
return true;
|
||||
|
||||
var offset = Angle.NormalizeRad(direction - hullStart);
|
||||
if (offset > AngleTolerance && offset < hullWidth - AngleTolerance)
|
||||
return true;
|
||||
|
||||
if (
|
||||
offset <= AngleTolerance
|
||||
|| offset >= Angle.TwoPI - AngleTolerance
|
||||
|| System.Math.Abs(offset - hullWidth) <= AngleTolerance
|
||||
)
|
||||
onBoundary = true;
|
||||
}
|
||||
}
|
||||
|
||||
return onBoundary && (
|
||||
stationaryStartConcave && SameRay(direction, stationaryStart)
|
||||
|| stationaryEndConcave && SameRay(direction, stationaryStart + stationaryWidth)
|
||||
|| movingStartConcave && SameRay(direction, movingStart + System.Math.PI)
|
||||
|| movingEndConcave && SameRay(direction, movingStart + movingWidth + System.Math.PI));
|
||||
}
|
||||
|
||||
private static bool SameRay(double a, double b)
|
||||
{
|
||||
var offset = Angle.NormalizeRad(a - b);
|
||||
return offset <= AngleTolerance || offset >= Angle.TwoPI - AngleTolerance;
|
||||
}
|
||||
|
||||
private static (double Start, double Width)[] Split(double start, double width)
|
||||
{
|
||||
if (width <= System.Math.PI + AngleTolerance)
|
||||
return new[] { (start, width) };
|
||||
|
||||
var half = width / 2;
|
||||
return new[]
|
||||
{
|
||||
(start, half + SplitOverlap),
|
||||
(start + half - SplitOverlap, half + SplitOverlap),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convex cone generated by two convex sectors. False when it is the whole plane.
|
||||
/// </summary>
|
||||
private static bool TryHull(
|
||||
(double Start, double Width) a,
|
||||
(double Start, double Width) b,
|
||||
out double start,
|
||||
out double width
|
||||
)
|
||||
{
|
||||
var fromA = System.Math.Max(a.Width, Angle.NormalizeRad(b.Start - a.Start) + b.Width);
|
||||
var fromB = System.Math.Max(b.Width, Angle.NormalizeRad(a.Start - b.Start) + a.Width);
|
||||
|
||||
if (fromA <= fromB)
|
||||
{
|
||||
start = a.Start;
|
||||
width = fromA;
|
||||
}
|
||||
else
|
||||
{
|
||||
start = b.Start;
|
||||
width = fromB;
|
||||
}
|
||||
|
||||
return width <= System.Math.PI + AngleTolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Candidate contact events of directional slides. Each emitter reports every forward
|
||||
/// hit its distance kernel considers, with the distance snapped exactly as that kernel
|
||||
/// snaps it, so the nearest event equals the kernel's historical minimum.
|
||||
/// </summary>
|
||||
internal static class SlideEvents
|
||||
{
|
||||
private const double Eps = Tolerance.Epsilon;
|
||||
|
||||
private static double Snap(double t) => t > Eps ? t : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Ray from a vertex against one entity. When <paramref name="vertexMoves"/> is true
|
||||
/// the vertex belongs to the moving boundary and the ray follows the push direction;
|
||||
/// otherwise it is a stationary vertex and the ray runs opposite to the push.
|
||||
/// </summary>
|
||||
public static void Ray<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double entityDx,
|
||||
double entityDy,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
line.pt1.X + entityDx,
|
||||
line.pt1.Y + entityDy,
|
||||
line.pt2.X + entityDx,
|
||||
line.pt2.Y + entityDy,
|
||||
rayX,
|
||||
rayY,
|
||||
vertexMoves
|
||||
);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
{
|
||||
var cx = arc.Center.X + entityDx;
|
||||
var cy = arc.Center.Y + entityDy;
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, arc.Radius, rayX, rayY, out var t1, out var t2))
|
||||
return;
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var t = k == 0 ? t1 : t2;
|
||||
if (t <= -Eps)
|
||||
continue;
|
||||
|
||||
var hitAngle = Angle.NormalizeRad(
|
||||
System.Math.Atan2(vy + t * rayY - cy, vx + t * rayX - cx)
|
||||
);
|
||||
if (!Angle.IsBetweenRad(hitAngle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
continue;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Circle circle:
|
||||
{
|
||||
if (
|
||||
!SolveRayCircle(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X + entityDx,
|
||||
circle.Center.Y + entityDy,
|
||||
circle.Radius,
|
||||
rayX,
|
||||
rayY,
|
||||
out var t1,
|
||||
out var t2
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var t = k == 0 ? t1 : t2;
|
||||
if (t < -Eps)
|
||||
continue;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same hit rule as <see cref="SpatialQuery.RayEdgeDistance(double, double, double, double, double, double, double, double)"/>.</summary>
|
||||
public static void RayLine<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
|
||||
var det = ex * rayY - ey * rayX;
|
||||
if (System.Math.Abs(det) < Eps)
|
||||
return;
|
||||
|
||||
var dvx = p1x - vx;
|
||||
var dvy = p1y - vy;
|
||||
|
||||
var t = (ex * dvy - ey * dvx) / det;
|
||||
if (t < -Eps)
|
||||
return;
|
||||
|
||||
var s = (rayX * dvy - rayY * dvx) / det;
|
||||
if (s < -Eps || s > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
Emit(ref sink, vx, vy, t, rayX, rayY, vertexMoves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned ray against a segment, with the same hit rule as the
|
||||
/// <see cref="PushDirection"/> kernel.
|
||||
/// </summary>
|
||||
public static void AxisRayLine<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double p1x,
|
||||
double p1y,
|
||||
double p2x,
|
||||
double p2y,
|
||||
PushDirection rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
double dist,
|
||||
hx,
|
||||
hy;
|
||||
|
||||
switch (rayDirection)
|
||||
{
|
||||
case PushDirection.Left:
|
||||
case PushDirection.Right:
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (System.Math.Abs(dy) < Eps)
|
||||
return;
|
||||
|
||||
var t = (vy - p1y) / dy;
|
||||
if (t < -Eps || t > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
hx = p1x + t * (p2x - p1x);
|
||||
hy = vy;
|
||||
dist = rayDirection == PushDirection.Left ? vx - hx : hx - vx;
|
||||
break;
|
||||
}
|
||||
|
||||
case PushDirection.Down:
|
||||
case PushDirection.Up:
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (System.Math.Abs(dx) < Eps)
|
||||
return;
|
||||
|
||||
var t = (vx - p1x) / dx;
|
||||
if (t < -Eps || t > 1.0 + Eps)
|
||||
return;
|
||||
|
||||
hx = vx;
|
||||
hy = p1y + t * (p2y - p1y);
|
||||
dist = rayDirection == PushDirection.Down ? vy - hy : hy - vy;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (dist < -Eps)
|
||||
return;
|
||||
|
||||
var vertex = new Vector(vx, vy);
|
||||
var hit = new Vector(hx, hy);
|
||||
if (vertexMoves)
|
||||
sink.Add(Snap(dist), vertex, hit);
|
||||
else
|
||||
sink.Add(Snap(dist), hit, vertex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closest-approach points of arcs against lines, which vertex sampling can miss.
|
||||
/// </summary>
|
||||
public static void ArcToLine<TSink>(
|
||||
ref TSink sink,
|
||||
List<Entity> arcEntities,
|
||||
double arcDx,
|
||||
double arcDy,
|
||||
List<Entity> lineEntities,
|
||||
double lineDx,
|
||||
double lineDy,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool arcMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (!TryGetCurve(arcEntities[i], out var localCx, out var localCy, out var r))
|
||||
continue;
|
||||
|
||||
var arc = arcEntities[i] as Arc;
|
||||
var cx = localCx + arcDx;
|
||||
var cy = localCy + arcDy;
|
||||
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is not Line line)
|
||||
continue;
|
||||
|
||||
var p1x = line.pt1.X + lineDx;
|
||||
var p1y = line.pt1.Y + lineDy;
|
||||
var p2x = line.pt2.X + lineDx;
|
||||
var p2y = line.pt2.Y + lineDy;
|
||||
var ex = p2x - p1x;
|
||||
var ey = p2y - p1y;
|
||||
|
||||
var det = ex * rayY - ey * rayX;
|
||||
if (System.Math.Abs(det) < Eps)
|
||||
continue;
|
||||
|
||||
// The directional distance from an arc point at angle θ to the
|
||||
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (arc != null && !Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
continue;
|
||||
|
||||
var qx = cx + r * System.Math.Cos(theta);
|
||||
var qy = cy + r * System.Math.Sin(theta);
|
||||
|
||||
RayLine(ref sink, qx, qy, p1x, p1y, p2x, p2y, rayX, rayY, arcMoves);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// External and internal tangencies of two curves along a unit direction. Radii must
|
||||
/// be nonnegative; a null arc is a full circle.
|
||||
/// </summary>
|
||||
public static void CurveTangency<TSink>(
|
||||
ref TSink sink,
|
||||
double movingCx,
|
||||
double movingCy,
|
||||
double movingRadius,
|
||||
Arc movingArc,
|
||||
double stationaryCx,
|
||||
double stationaryCy,
|
||||
double stationaryRadius,
|
||||
Arc stationaryArc,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var kind = 0; kind < 2; kind++)
|
||||
{
|
||||
var internalContact = kind == 1;
|
||||
var radius = internalContact
|
||||
? System.Math.Abs(movingRadius - stationaryRadius)
|
||||
: movingRadius + stationaryRadius;
|
||||
|
||||
// Equal-radius internal contact has coincident centers, not a unique
|
||||
// tangent point. Endpoints detect any overlap of those angular spans.
|
||||
if (radius == 0)
|
||||
continue;
|
||||
|
||||
if (
|
||||
!SolveRayCircle(
|
||||
movingCx,
|
||||
movingCy,
|
||||
stationaryCx,
|
||||
stationaryCy,
|
||||
radius,
|
||||
dirX,
|
||||
dirY,
|
||||
out var t1,
|
||||
out var t2
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
// The nearer center-circle root can be outside an arc while the farther
|
||||
// root is its first contact. Check the actual tangent point at BOTH roots.
|
||||
for (var root = 0; root < 2; root++)
|
||||
{
|
||||
var t = root == 0 ? t1 : t2;
|
||||
if (t < -Eps)
|
||||
continue;
|
||||
|
||||
var toX = stationaryCx - (movingCx + t * dirX);
|
||||
var toY = stationaryCy - (movingCy + t * dirY);
|
||||
var movingSign = internalContact && movingRadius < stationaryRadius ? -1 : 1;
|
||||
var stationarySign = internalContact ? movingSign : -1;
|
||||
if (
|
||||
!ContainsContactAngle(
|
||||
movingArc,
|
||||
movingRadius,
|
||||
movingSign * toX,
|
||||
movingSign * toY
|
||||
)
|
||||
|| !ContainsContactAngle(
|
||||
stationaryArc,
|
||||
stationaryRadius,
|
||||
stationarySign * toX,
|
||||
stationarySign * toY
|
||||
)
|
||||
)
|
||||
continue;
|
||||
|
||||
var length = System.Math.Sqrt(toX * toX + toY * toY);
|
||||
var ux = length > 0 ? toX / length : 0;
|
||||
var uy = length > 0 ? toY / length : 0;
|
||||
var movingPoint = new Vector(
|
||||
movingCx + movingSign * movingRadius * ux,
|
||||
movingCy + movingSign * movingRadius * uy
|
||||
);
|
||||
var stationaryPoint = new Vector(
|
||||
stationaryCx + stationarySign * stationaryRadius * ux,
|
||||
stationaryCy + stationarySign * stationaryRadius * uy
|
||||
);
|
||||
|
||||
sink.Add(Snap(t), movingPoint, stationaryPoint);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetCurve(Entity entity, out double cx, out double cy, out double r)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Circle circle:
|
||||
cx = circle.Center.X;
|
||||
cy = circle.Center.Y;
|
||||
r = circle.Radius;
|
||||
return true;
|
||||
case Arc arc:
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
default:
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Emit<TSink>(
|
||||
ref TSink sink,
|
||||
double vx,
|
||||
double vy,
|
||||
double t,
|
||||
double rayX,
|
||||
double rayY,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var vertex = new Vector(vx, vy);
|
||||
var hit = new Vector(vx + t * rayX, vy + t * rayY);
|
||||
if (vertexMoves)
|
||||
sink.Add(Snap(t), vertex, hit);
|
||||
else
|
||||
sink.Add(Snap(t), hit, vertex);
|
||||
}
|
||||
|
||||
private static bool ContainsContactAngle(Arc arc, double radius, double x, double y)
|
||||
{
|
||||
// A zero-radius curve is a point: its angular range has no geometric meaning.
|
||||
if (arc == null || radius == 0)
|
||||
return true;
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
return Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
}
|
||||
|
||||
internal static bool SolveRayCircle(
|
||||
double vx,
|
||||
double vy,
|
||||
double cx,
|
||||
double cy,
|
||||
double r,
|
||||
double dirX,
|
||||
double dirY,
|
||||
out double t1,
|
||||
out double t2
|
||||
)
|
||||
{
|
||||
var ox = vx - cx;
|
||||
var oy = vy - cy;
|
||||
|
||||
var a = dirX * dirX + dirY * dirY;
|
||||
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||
var c = ox * ox + oy * oy - r * r;
|
||||
|
||||
var discriminant = b * b - 4.0 * a * c;
|
||||
if (discriminant < 0)
|
||||
{
|
||||
t1 = t2 = double.MaxValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqrtD = System.Math.Sqrt(discriminant);
|
||||
var inv2a = 1.0 / (2.0 * a);
|
||||
t1 = (-b - sqrtD) * inv2a;
|
||||
t2 = (-b + sqrtD) * inv2a;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide events between native Line/Arc/Circle boundaries. The moving entities and
|
||||
/// vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
|
||||
/// </summary>
|
||||
public struct EntitySlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly List<Entity> moving;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly double movingDx;
|
||||
private readonly double movingDy;
|
||||
private readonly List<Entity> stationary;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly double dirX;
|
||||
private readonly double dirY;
|
||||
private readonly bool arcToLine;
|
||||
|
||||
public EntitySlideEvents(
|
||||
List<Entity> moving,
|
||||
Vector[] movingVertices,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Entity> stationary,
|
||||
Vector[] stationaryVertices,
|
||||
double dirX,
|
||||
double dirY,
|
||||
bool arcToLine
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.movingVertices = movingVertices;
|
||||
this.movingDx = movingDx;
|
||||
this.movingDy = movingDy;
|
||||
this.stationary = stationary;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.dirX = dirX;
|
||||
this.dirY = dirY;
|
||||
this.arcToLine = arcToLine;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
// Phase 1: moving vertices along the push against stationary entities.
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X + movingDx;
|
||||
var vy = movingVertices[v].Y + movingDy;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: stationary vertices against the push onto moving entities.
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < moving.Count; j++)
|
||||
{
|
||||
SlideEvents.Ray(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
moving[j],
|
||||
movingDx,
|
||||
movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: arc-to-line closest points, which vertex sampling can miss.
|
||||
if (arcToLine)
|
||||
{
|
||||
SlideEvents.ArcToLine(
|
||||
ref sink,
|
||||
moving,
|
||||
movingDx,
|
||||
movingDy,
|
||||
stationary,
|
||||
0,
|
||||
0,
|
||||
dirX,
|
||||
dirY,
|
||||
true
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
SlideEvents.ArcToLine(
|
||||
ref sink,
|
||||
stationary,
|
||||
0,
|
||||
0,
|
||||
moving,
|
||||
movingDx,
|
||||
movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: native curve tangency, including a convex corner inside a concave arc.
|
||||
for (var i = 0; i < moving.Count; i++)
|
||||
{
|
||||
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
SlideEvents.CurveTangency(
|
||||
ref sink,
|
||||
mcx + movingDx,
|
||||
mcy + movingDy,
|
||||
mr,
|
||||
moving[i] as Arc,
|
||||
scx,
|
||||
scy,
|
||||
sr,
|
||||
stationary[j] as Arc,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide events between line boundaries along an arbitrary unit direction. The moving
|
||||
/// lines and vertices are translated by (movingDx, movingDy); vertex arrays may be subsets.
|
||||
/// </summary>
|
||||
public struct LineSlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly List<Line> moving;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly double movingDx;
|
||||
private readonly double movingDy;
|
||||
private readonly List<Line> stationary;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly double dirX;
|
||||
private readonly double dirY;
|
||||
|
||||
public LineSlideEvents(
|
||||
List<Line> moving,
|
||||
Vector[] movingVertices,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationary,
|
||||
Vector[] stationaryVertices,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
this.moving = moving;
|
||||
this.movingVertices = movingVertices;
|
||||
this.movingDx = movingDx;
|
||||
this.movingDy = movingDy;
|
||||
this.stationary = stationary;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.dirX = dirX;
|
||||
this.dirY = dirY;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X + movingDx;
|
||||
var vy = movingVertices[v].Y + movingDy;
|
||||
|
||||
for (var j = 0; j < stationary.Count; j++)
|
||||
{
|
||||
var e = stationary[j];
|
||||
SlideEvents.RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY,
|
||||
true
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < moving.Count; j++)
|
||||
{
|
||||
var e = moving[j];
|
||||
SlideEvents.RayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e.pt1.X + movingDx,
|
||||
e.pt1.Y + movingDy,
|
||||
e.pt2.X + movingDx,
|
||||
e.pt2.Y + movingDy,
|
||||
-dirX,
|
||||
-dirY,
|
||||
false
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Axis-aligned slide events between edge arrays sorted for pruning, as used by the
|
||||
/// <see cref="PushDirection"/> kernel. Offsets translate each side into world space.
|
||||
/// </summary>
|
||||
public struct AxisSlideEvents : ISlideEventSource
|
||||
{
|
||||
private readonly (Vector start, Vector end)[] movingEdges;
|
||||
private readonly Vector movingOffset;
|
||||
private readonly Vector[] movingVertices;
|
||||
private readonly (Vector start, Vector end)[] stationaryEdges;
|
||||
private readonly Vector stationaryOffset;
|
||||
private readonly Vector[] stationaryVertices;
|
||||
private readonly PushDirection direction;
|
||||
|
||||
/// <param name="movingVertices">World-space moving vertices.</param>
|
||||
/// <param name="stationaryVertices">World-space stationary vertices.</param>
|
||||
public AxisSlideEvents(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
Vector[] movingVertices,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
Vector[] stationaryVertices,
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
this.movingEdges = movingEdges;
|
||||
this.movingOffset = movingOffset;
|
||||
this.movingVertices = movingVertices;
|
||||
this.stationaryEdges = stationaryEdges;
|
||||
this.stationaryOffset = stationaryOffset;
|
||||
this.stationaryVertices = stationaryVertices;
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
public void Enumerate<TSink>(ref TSink sink)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
OneWay(ref sink, movingVertices[v], stationaryEdges, stationaryOffset, direction, true);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
OneWay(ref sink, stationaryVertices[v], movingEdges, movingOffset, opposite, false);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OneWay<TSink>(
|
||||
ref TSink sink,
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
Vector edgeOffset,
|
||||
PushDirection rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
where TSink : struct, ISlideEventSink
|
||||
{
|
||||
var vx = vertex.X;
|
||||
var vy = vertex.Y;
|
||||
var horizontal = SpatialQuery.IsHorizontalDirection(rayDirection);
|
||||
|
||||
// Edges are sorted by their perpendicular min-coordinate.
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
double perpValue,
|
||||
edgeMin,
|
||||
edgeMax;
|
||||
if (horizontal)
|
||||
{
|
||||
perpValue = vy;
|
||||
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
perpValue = vx;
|
||||
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||
}
|
||||
|
||||
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
SlideEvents.AxisRayLine(
|
||||
ref sink,
|
||||
vx,
|
||||
vy,
|
||||
e1.X,
|
||||
e1.Y,
|
||||
e2.X,
|
||||
e2.Y,
|
||||
rayDirection,
|
||||
vertexMoves
|
||||
);
|
||||
if (sink.IsDone)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,8 +320,9 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction before
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Computes the translation distance along a push direction before any edge of
|
||||
/// movingLines first blocks against an edge of stationaryLines. A contact that
|
||||
/// the push slides along or leaves does not block (see <see cref="SlideContact"/>).
|
||||
/// Returns double.MaxValue if no collision path exists.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
@@ -334,7 +335,7 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance with the moving lines translated
|
||||
/// Computes the directional distance with the moving lines translated
|
||||
/// by (movingDx, movingDy) without creating new Line objects.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
@@ -345,37 +346,57 @@ namespace OpenNest.Geometry
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
return DirectionalDistance(
|
||||
movingLines,
|
||||
movingDx,
|
||||
movingDy,
|
||||
stationaryLines,
|
||||
direction,
|
||||
SlideContactClassifier.FromLines(
|
||||
movingLines,
|
||||
new Vector(movingDx, movingDy),
|
||||
stationaryLines,
|
||||
Vector.Zero
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Line}, double, double, List{Line}, PushDirection)"/>
|
||||
/// with caller-supplied contact topology, for inputs that are not complete closed
|
||||
/// boundaries (for example direction-filtered edges).
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
double movingDx,
|
||||
double movingDy,
|
||||
List<Line> stationaryLines,
|
||||
PushDirection direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset).ToArray();
|
||||
|
||||
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero).ToArray();
|
||||
|
||||
var movingEdges = ToEdgeArray(movingLines);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
var source = new AxisSlideEvents(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
movingVertices,
|
||||
stationaryEdges,
|
||||
Vector.Zero,
|
||||
stationaryVertices,
|
||||
direction
|
||||
);
|
||||
var unit = DirectionToOffset(direction, 1.0);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -396,8 +417,8 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance using raw edge arrays and location offsets
|
||||
/// to avoid all intermediate object allocations.
|
||||
/// Computes the blocking directional distance using raw edge arrays and location
|
||||
/// offsets. Sorts both edge arrays in place for pruning.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
@@ -407,36 +428,58 @@ namespace OpenNest.Geometry
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return DirectionalDistance(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
stationaryEdges,
|
||||
stationaryOffset,
|
||||
direction,
|
||||
SlideContactClassifier.FromEdges(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
stationaryEdges,
|
||||
stationaryOffset
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge-array overload with caller-supplied contact topology. The classifier's
|
||||
/// origins must match <paramref name="movingOffset"/> and
|
||||
/// <paramref name="stationaryOffset"/> in the frame of its boundaries.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges,
|
||||
Vector movingOffset,
|
||||
(Vector start, Vector end)[] stationaryEdges,
|
||||
Vector stationaryOffset,
|
||||
PushDirection direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset).ToArray();
|
||||
|
||||
SortEdgesForPruning(movingEdges, OppositeDirection(direction));
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset).ToArray();
|
||||
|
||||
var source = new AxisSlideEvents(
|
||||
movingEdges,
|
||||
movingOffset,
|
||||
movingVertices,
|
||||
stationaryEdges,
|
||||
stationaryOffset,
|
||||
stationaryVertices,
|
||||
direction
|
||||
);
|
||||
var unit = DirectionToOffset(direction, 1.0);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, unit.X, unit.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nearest raw hit from one vertex along a push direction against edges sorted for
|
||||
/// pruning. This is a first-touch primitive; it does not classify sliding contacts.
|
||||
/// </summary>
|
||||
public static double OneWayDistance(
|
||||
Vector vertex,
|
||||
(Vector start, Vector end)[] edges,
|
||||
@@ -628,8 +671,8 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any edge of movingLines contacts any edge of stationaryLines.
|
||||
/// Computes the translation distance along an arbitrary unit direction before any
|
||||
/// edge of movingLines first blocks against an edge of stationaryLines.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
@@ -637,58 +680,41 @@ namespace OpenNest.Geometry
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
return DirectionalDistance(
|
||||
movingLines,
|
||||
stationaryLines,
|
||||
direction,
|
||||
SlideContactClassifier.FromLines(
|
||||
movingLines,
|
||||
Vector.Zero,
|
||||
stationaryLines,
|
||||
Vector.Zero
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
for (var i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
var e = stationaryLines[i];
|
||||
var d = RayEdgeDistance(
|
||||
mv.X,
|
||||
mv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
for (var i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
var e = movingLines[i];
|
||||
var d = RayEdgeDistance(
|
||||
sv.X,
|
||||
sv.Y,
|
||||
e.pt1.X,
|
||||
e.pt1.Y,
|
||||
e.pt2.X,
|
||||
e.pt2.Y,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Line}, List{Line}, Vector)"/> with
|
||||
/// caller-supplied contact topology.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Line> movingLines,
|
||||
List<Line> stationaryLines,
|
||||
Vector direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
var source = new LineSlideEvents(
|
||||
movingLines,
|
||||
CollectVertices(movingLines, Vector.Zero).ToArray(),
|
||||
0,
|
||||
0,
|
||||
stationaryLines,
|
||||
CollectVertices(stationaryLines, Vector.Zero).ToArray(),
|
||||
direction.X,
|
||||
direction.Y
|
||||
);
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -710,10 +736,10 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Works with native Line, Arc, and Circle entities
|
||||
/// without tessellation.
|
||||
/// Computes the translation distance along an arbitrary unit direction before any
|
||||
/// vertex/edge of movingEntities first blocks against stationaryEntities. Works with
|
||||
/// native Line, Arc, and Circle entities without tessellation. A contact that the
|
||||
/// push slides along or leaves does not block (see <see cref="SlideContact"/>).
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities,
|
||||
@@ -721,228 +747,42 @@ namespace OpenNest.Geometry
|
||||
Vector direction
|
||||
)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = ExtractEntityVertices(movingEntities);
|
||||
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X;
|
||||
var vy = movingVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Arc-to-line closest-point check.
|
||||
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
|
||||
// closest point on a small corner arc to a straight edge may lie between
|
||||
// those samples. Use ClosestPointTo to find it and fire a ray from there.
|
||||
minDist = ArcToLineClosestDistance(
|
||||
return DirectionalDistance(
|
||||
movingEntities,
|
||||
stationaryEntities,
|
||||
dirX,
|
||||
dirY,
|
||||
minDist
|
||||
direction,
|
||||
new SlideContactClassifier(movingEntities, stationaryEntities)
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
minDist = ArcToLineClosestDistance(
|
||||
stationaryEntities,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DirectionalDistance(List{Entity}, List{Entity}, Vector)"/> with
|
||||
/// caller-supplied contact topology.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities,
|
||||
List<Entity> stationaryEntities,
|
||||
Vector direction,
|
||||
SlideContactClassifier contacts
|
||||
)
|
||||
{
|
||||
// Phases: vertex rays both ways, arc-to-line closest points (vertex sampling
|
||||
// misses interior arc contact), then native curve tangency.
|
||||
var source = new EntitySlideEvents(
|
||||
movingEntities,
|
||||
oppX,
|
||||
oppY,
|
||||
minDist
|
||||
ExtractEntityVertices(movingEntities),
|
||||
0,
|
||||
0,
|
||||
stationaryEntities,
|
||||
ExtractEntityVertices(stationaryEntities),
|
||||
direction.X,
|
||||
direction.Y,
|
||||
arcToLine: true
|
||||
);
|
||||
if (minDist <= 0)
|
||||
return 0;
|
||||
|
||||
// Phase 4: Native curve tangency, including a convex corner inside a concave arc.
|
||||
for (var i = 0; i < movingEntities.Count; i++)
|
||||
{
|
||||
var me = movingEntities[i];
|
||||
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var se = stationaryEntities[j];
|
||||
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
var d = CurveTangencyDistance(
|
||||
mcx, mcy, mr, me as Arc,
|
||||
scx, scy, sr, se as Arc, dirX, dirY);
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return SlideResolver.FirstBlocking(ref source, contacts, direction.X, direction.Y);
|
||||
}
|
||||
|
||||
private static double ArcToLineClosestDistance(
|
||||
List<Entity> arcEntities,
|
||||
List<Entity> lineEntities,
|
||||
double dirX,
|
||||
double dirY,
|
||||
double minDist
|
||||
)
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (arcEntities[i] is not Arc arc)
|
||||
continue;
|
||||
|
||||
var cx = arc.Center.X;
|
||||
var cy = arc.Center.Y;
|
||||
var r = arc.Radius;
|
||||
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is not Line line)
|
||||
continue;
|
||||
|
||||
var p1x = line.pt1.X;
|
||||
var p1y = line.pt1.Y;
|
||||
var ex = line.pt2.X - p1x;
|
||||
var ey = line.pt2.Y - p1y;
|
||||
|
||||
var det = ex * dirY - ey * dirX;
|
||||
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
// The directional distance from an arc point at angle θ to the
|
||||
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||
|
||||
for (var k = 0; k < 2; k++)
|
||||
{
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (
|
||||
!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)
|
||||
)
|
||||
continue;
|
||||
|
||||
var qx = cx + r * System.Math.Cos(theta);
|
||||
var qy = cy + r * System.Math.Sin(theta);
|
||||
|
||||
var d = RayEdgeDistance(
|
||||
qx,
|
||||
qy,
|
||||
p1x,
|
||||
p1y,
|
||||
line.pt2.X,
|
||||
line.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
line.pt1.X,
|
||||
line.pt1.Y,
|
||||
line.pt2.X,
|
||||
line.pt2.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return RayArcDistance(
|
||||
vx,
|
||||
vy,
|
||||
arc.Center.X,
|
||||
arc.Center.Y,
|
||||
arc.Radius,
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return RayCircleDistance(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X,
|
||||
circle.Center.Y,
|
||||
circle.Radius,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
public static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
@@ -1041,31 +881,6 @@ namespace OpenNest.Geometry
|
||||
);
|
||||
}
|
||||
|
||||
private static bool TryGetCurveParams(
|
||||
Entity entity,
|
||||
out double cx,
|
||||
out double cy,
|
||||
out double r
|
||||
)
|
||||
{
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
cx = circle.Center.X;
|
||||
cy = circle.Center.Y;
|
||||
r = circle.Radius;
|
||||
return true;
|
||||
}
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
cx = arc.Center.X;
|
||||
cy = arc.Center.Y;
|
||||
r = arc.Radius;
|
||||
return true;
|
||||
}
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||
{
|
||||
var x = dx >= 0 ? box.Left : box.Right;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>Outcome of <see cref="NestDefaults.Load(string, out NestDefaultsStatus)"/>.</summary>
|
||||
public enum NestDefaultsStatus
|
||||
{
|
||||
/// <summary>Defaults were read from the file (invalid fields still fall back individually).</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>No file exists at the path; built-in fallback values were used.</summary>
|
||||
Missing,
|
||||
|
||||
/// <summary>The file exists but could not be read or parsed; fallback values were used.</summary>
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plate/nest defaults persisted to a single JSON file
|
||||
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
|
||||
/// .nstdot nest-template mechanism. Loading never throws: a missing,
|
||||
/// corrupt, or partially valid file degrades field-by-field to
|
||||
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
|
||||
/// </summary>
|
||||
public sealed class NestDefaults
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public Size Size { get; set; } = new(100, 100);
|
||||
|
||||
public int Quadrant { get; set; } = 1;
|
||||
|
||||
public double PartSpacing { get; set; } = 1;
|
||||
|
||||
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// The built-in defaults used when no file exists and for every field
|
||||
/// that is missing or invalid. Matches the historical
|
||||
/// MainForm.CreateDefaultNest values (units default to Inches; callers
|
||||
/// may override from their own settings).
|
||||
/// </summary>
|
||||
public static NestDefaults Fallback => new();
|
||||
|
||||
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OpenNest",
|
||||
"defaults.json"
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults from <paramref name="path"/>, falling back field by
|
||||
/// field for a missing file, invalid JSON, or invalid values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path) => Load(path, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Loads defaults and reports whether the file was missing, loaded, or
|
||||
/// present but unreadable/invalid, so callers can warn about a corrupt
|
||||
/// file while still returning usable values.
|
||||
/// </summary>
|
||||
public static NestDefaults Load(string path, out NestDefaultsStatus status)
|
||||
{
|
||||
var defaults = Fallback;
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
status = NestDefaultsStatus.Missing;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
NestDefaultsDto? dto;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
|
||||
status = NestDefaultsStatus.Ok;
|
||||
|
||||
if (
|
||||
dto.Units is not null
|
||||
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
|
||||
)
|
||||
defaults.Units = units;
|
||||
|
||||
if (
|
||||
dto.Size?.Width is { } width
|
||||
&& dto.Size.Length is { } length
|
||||
&& IsValidSize(width, length)
|
||||
)
|
||||
defaults.Size = new Size(width, length);
|
||||
|
||||
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
|
||||
defaults.Quadrant = quadrant;
|
||||
|
||||
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
|
||||
defaults.PartSpacing = partSpacing;
|
||||
|
||||
if (
|
||||
dto.EdgeSpacing?.Left is { } left
|
||||
&& dto.EdgeSpacing.Bottom is { } bottom
|
||||
&& dto.EdgeSpacing.Right is { } right
|
||||
&& dto.EdgeSpacing.Top is { } top
|
||||
&& IsValidSpacing(left)
|
||||
&& IsValidSpacing(bottom)
|
||||
&& IsValidSpacing(right)
|
||||
&& IsValidSpacing(top)
|
||||
)
|
||||
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current units and plate defaults from a nest.
|
||||
/// </summary>
|
||||
public static NestDefaults FromNest(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
var plate = nest.PlateDefaults;
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = nest.Units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures defaults from an existing plate (a copy of its size,
|
||||
/// quadrant, and spacing), e.g. the active plate in the desktop app.
|
||||
/// </summary>
|
||||
public static NestDefaults FromPlate(Units units, Plate plate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = units,
|
||||
Size = plate.Size,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = plate.PartSpacing,
|
||||
EdgeSpacing = plate.EdgeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(Nest nest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nest);
|
||||
nest.Units = Units;
|
||||
var plate = nest.PlateDefaults;
|
||||
plate.Size = Size;
|
||||
plate.Quadrant = Quadrant;
|
||||
plate.PartSpacing = PartSpacing;
|
||||
plate.EdgeSpacing = EdgeSpacing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the file (creating the parent directory), retrying briefly on
|
||||
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
|
||||
/// </summary>
|
||||
public void Save(string path, int maxRetries = 3)
|
||||
{
|
||||
var dto = new NestDefaultsDto
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Units = Units.ToString().ToLowerInvariant(),
|
||||
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
||||
Quadrant = Quadrant,
|
||||
PartSpacing = PartSpacing,
|
||||
EdgeSpacing = new SpacingDto
|
||||
{
|
||||
Left = EdgeSpacing.Left,
|
||||
Bottom = EdgeSpacing.Bottom,
|
||||
Right = EdgeSpacing.Right,
|
||||
Top = EdgeSpacing.Top,
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSize(double width, double length) =>
|
||||
!double.IsNaN(width)
|
||||
&& !double.IsNaN(length)
|
||||
&& !double.IsInfinity(width)
|
||||
&& !double.IsInfinity(length)
|
||||
&& width > 0
|
||||
&& length > 0;
|
||||
|
||||
private static bool IsValidSpacing(double value) =>
|
||||
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Wire format. Every field is nullable so a partial file merges over
|
||||
/// the fallback field by field; unknown fields (including a future
|
||||
/// higher <c>version</c>) are ignored rather than rejected.
|
||||
/// </summary>
|
||||
private sealed record NestDefaultsDto
|
||||
{
|
||||
public int? Version { get; init; } = CurrentVersion;
|
||||
public string? Units { get; init; }
|
||||
public SizeDto? Size { get; init; }
|
||||
public int? Quadrant { get; init; }
|
||||
public double? PartSpacing { get; init; }
|
||||
public SpacingDto? EdgeSpacing { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SizeDto
|
||||
{
|
||||
public double? Width { get; init; }
|
||||
public double? Length { get; init; }
|
||||
}
|
||||
|
||||
private sealed record SpacingDto
|
||||
{
|
||||
public double? Left { get; init; }
|
||||
public double? Bottom { get; init; }
|
||||
public double? Right { get; init; }
|
||||
public double? Top { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Fill;
|
||||
|
||||
/// <summary>
|
||||
/// PlateView spacing expander: grows part-to-part spacing with the work area
|
||||
/// and non-selected parts as hard boundaries.
|
||||
/// </summary>
|
||||
public class ExpanderTests
|
||||
{
|
||||
private static Program Rectangle(double width = 4, double length = 4)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(width, length);
|
||||
program.LineTo(0, length);
|
||||
program.LineTo(0, 0);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Part AddSquare(Plate plate, double x, double y, double size = 4)
|
||||
{
|
||||
var part = new Part(new Drawing($"sq{plate.Parts.Count}", Rectangle(size, size)), new Vector(x, y));
|
||||
plate.Parts.Add(part);
|
||||
return part;
|
||||
}
|
||||
|
||||
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
|
||||
{
|
||||
var plate = new Plate(new Size(widthY, lengthX));
|
||||
plate.EdgeSpacing = new Spacing(edge, edge);
|
||||
return plate;
|
||||
}
|
||||
|
||||
/// <summary>Independent clearance oracle: naive vertex/segment min distance over raw part lines.</summary>
|
||||
private static double BruteClearance(Part a, Part b)
|
||||
{
|
||||
var linesA = PartGeometry.GetPartLines(a);
|
||||
var linesB = PartGeometry.GetPartLines(b);
|
||||
|
||||
double min = double.MaxValue;
|
||||
foreach (var la in linesA)
|
||||
foreach (var lb in linesB)
|
||||
{
|
||||
min = System.Math.Min(min, PointSegment(a, la.StartPoint, lb));
|
||||
min = System.Math.Min(min, PointSegment(a, la.EndPoint, lb));
|
||||
min = System.Math.Min(min, PointSegment(b, lb.StartPoint, la));
|
||||
min = System.Math.Min(min, PointSegment(b, lb.EndPoint, la));
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
private static double PointSegment(Part owner, Vector pt, Line seg)
|
||||
{
|
||||
var d = seg.EndPoint - seg.StartPoint;
|
||||
var len2 = d.DotProduct(d);
|
||||
var t = len2 <= 1e-12 ? 0 : System.Math.Clamp((pt - seg.StartPoint).DotProduct(d) / len2, 0, 1);
|
||||
return pt.DistanceTo(seg.StartPoint + d * t);
|
||||
}
|
||||
|
||||
private static void AssertNoOverlaps(Plate plate)
|
||||
{
|
||||
for (var i = 0; i < plate.Parts.Count; i++)
|
||||
for (var j = i + 1; j < plate.Parts.Count; j++)
|
||||
Assert.False(
|
||||
plate.Parts[i].Intersects(plate.Parts[j], out _),
|
||||
$"{plate.Parts[i].BaseDrawing.Name} overlaps {plate.Parts[j].BaseDrawing.Name}"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_TwoSquares_GrowUntilEdgeFloor_AndAnchorStaysPut()
|
||||
{
|
||||
var plate = MakePlate(24, 24);
|
||||
var a = AddSquare(plate, 6, 10);
|
||||
var b = AddSquare(plate, 14, 10);
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
// Max gap: B flush against the right edge floor (23.5): 23.5 - 14 - 4 + gap base...
|
||||
// A stays (anchor); B slides to x=19.5 -> gap 9.5.
|
||||
Assert.Equal(6, a.Location.X, 6);
|
||||
Assert.Equal(10, a.Location.Y, 6);
|
||||
Assert.Equal(9.5, b.Location.X - (a.Location.X + 4), 1);
|
||||
Assert.True(result.AchievedSpacing >= 9.4, $"achieved {result.AchievedSpacing}");
|
||||
Assert.True(result.AchievedSpacing <= 9.6, $"achieved {result.AchievedSpacing}");
|
||||
AssertNoOverlaps(plate);
|
||||
Assert.True(b.BoundingBox.Right <= 23.5 + 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_SandwichedBetweenWalls_ConvergesOnlyToInitialGaps_AndKeepsWalls()
|
||||
{
|
||||
var plate = MakePlate(26, 10, edge: 0.0);
|
||||
var wallL = AddSquare(plate, 0, 3);
|
||||
var wallR = AddSquare(plate, 18, 3);
|
||||
var a = AddSquare(plate, 6, 3);
|
||||
var b = AddSquare(plate, 12, 3);
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
// Every gap starts at exactly 2.0; straight separation moves cannot open
|
||||
// the row (opening one gap costs another), so the run stays at ~2.0.
|
||||
Assert.Equal(0, wallL.Location.X, 6);
|
||||
Assert.Equal(18, wallR.Location.X, 6);
|
||||
Assert.True(
|
||||
result.AchievedSpacing >= 1.9 && result.AchievedSpacing <= 2.05,
|
||||
$"achieved {result.AchievedSpacing}"
|
||||
);
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_OverlappingPair_SeparatesAndClearsOverlap()
|
||||
{
|
||||
var plate = MakePlate(30, 12);
|
||||
var a = AddSquare(plate, 5, 4);
|
||||
var b = AddSquare(plate, 7, 4); // 2.0 overlap in X
|
||||
|
||||
var result = Expander.Expand(new List<Part> { a, b }, plate, new Expander.Options
|
||||
{
|
||||
InitialStep = 0.5,
|
||||
MaxSpacing = 3,
|
||||
});
|
||||
|
||||
Assert.False(a.Intersects(b, out _));
|
||||
var gap = b.Location.X - (a.Location.X + 4);
|
||||
Assert.True(gap >= 2.99, $"gap {gap}");
|
||||
Assert.True(result.AchievedSpacing >= 2.9);
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Separate_PinnedPart_ReportsViolationsWithoutOverlap()
|
||||
{
|
||||
var plate = MakePlate(20, 20, edge: 0.0);
|
||||
var pinned = AddSquare(plate, 8, 8);
|
||||
// Walls 0.2 clear on all four sides.
|
||||
var left = AddSquare(plate, 3.8, 8);
|
||||
var right = AddSquare(plate, 12.2, 8);
|
||||
var bottom = AddSquare(plate, 8, 3.8);
|
||||
var top = AddSquare(plate, 8, 12.2);
|
||||
|
||||
var (converged, positions, violations) = Expander.Separate(
|
||||
new List<Part> { pinned },
|
||||
plate,
|
||||
spacing: 1.0
|
||||
);
|
||||
|
||||
Assert.False(converged);
|
||||
Assert.NotEmpty(violations);
|
||||
// The pinned part may slide into the walls but never through them.
|
||||
AssertNoOverlaps(plate);
|
||||
Assert.Equal(3.8, left.Location.X, 6);
|
||||
Assert.Equal(12.2, right.Location.X, 6);
|
||||
Assert.Equal(3.8, bottom.Location.Y, 6);
|
||||
Assert.Equal(12.2, top.Location.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_CancelledBeforeRun_LeavesEverythingInPlace()
|
||||
{
|
||||
var plate = MakePlate(24, 24);
|
||||
var a = AddSquare(plate, 6, 10);
|
||||
var b = AddSquare(plate, 14, 10);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { a, b },
|
||||
plate,
|
||||
token: cts.Token
|
||||
);
|
||||
|
||||
Assert.True(result.Cancelled);
|
||||
Assert.Equal(6, a.Location.X, 6);
|
||||
Assert.Equal(14, b.Location.X, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_ThreeInRow_FirstSelectedNeverMoves_AndOracleConfirmsSpacing()
|
||||
{
|
||||
var plate = MakePlate(60, 14);
|
||||
var a = AddSquare(plate, 5, 5);
|
||||
var b = AddSquare(plate, 10, 5);
|
||||
var c = AddSquare(plate, 15, 5);
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { a, b, c },
|
||||
plate,
|
||||
new Expander.Options { MaxSpacing = 8 }
|
||||
);
|
||||
|
||||
Assert.Equal(5, a.Location.X, 6); // anchor: never the later index of any pair
|
||||
Assert.True(result.AchievedSpacing >= 7.9);
|
||||
|
||||
// Independent oracle: every pair clears the reported spacing.
|
||||
var parts = new List<Part> { a, b, c };
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var clearance = BruteClearance(parts[i], parts[j]);
|
||||
Assert.True(
|
||||
clearance >= result.AchievedSpacing - 0.01,
|
||||
$"{parts[i].BaseDrawing.Name}/{parts[j].BaseDrawing.Name}: oracle {clearance} < reported {result.AchievedSpacing}"
|
||||
);
|
||||
}
|
||||
AssertNoOverlaps(plate);
|
||||
}
|
||||
|
||||
private static Program RectangleWithHole(
|
||||
double width,
|
||||
double length,
|
||||
double hx,
|
||||
double hy,
|
||||
double hw,
|
||||
double hh
|
||||
)
|
||||
{
|
||||
var program = Rectangle(width, length);
|
||||
program.MoveTo(hx, hy);
|
||||
program.LineTo(hx + hw, hy);
|
||||
program.LineTo(hx + hw, hy + hh);
|
||||
program.LineTo(hx, hy + hh);
|
||||
program.LineTo(hx, hy);
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hole-subtracting overlap check matching NestValidator semantics (a part in
|
||||
/// a cutout is legal). Part.Intersects is perimeter-only, so it cannot
|
||||
/// certify part-in-cutout layouts.
|
||||
/// </summary>
|
||||
private static bool MateriallyOverlaps(Part a, Part b)
|
||||
{
|
||||
var (outerA, holesA) = Rings(a);
|
||||
var (outerB, holesB) = Rings(b);
|
||||
return Collision.HasOverlap(outerA, outerB, holesA, holesB);
|
||||
}
|
||||
|
||||
private static (Polygon Outer, List<Polygon> Holes) Rings(Part part)
|
||||
{
|
||||
var entities = OpenNest.Converters.ConvertProgram
|
||||
.ToGeometry(part.Program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
var outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
|
||||
outer.Offset(part.Location);
|
||||
|
||||
var holes = new List<Polygon>();
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var hole = cutout.ToPolygonWithTolerance(0.001);
|
||||
hole.Offset(part.Location);
|
||||
holes.Add(hole);
|
||||
}
|
||||
|
||||
return (outer, holes.Count == 0 ? null : holes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_PartInsideCutout_KeepsLegalAndClearsHoleWalls()
|
||||
{
|
||||
var plate = MakePlate(40, 24);
|
||||
|
||||
// Wall part with a 10x10 cutout; a small selected part sits inside it.
|
||||
var wall = new Part(
|
||||
new Drawing(
|
||||
"wall",
|
||||
RectangleWithHole(20, 20, 5, 5, 10, 10)
|
||||
),
|
||||
new Vector(0, 0)
|
||||
);
|
||||
plate.Parts.Add(wall);
|
||||
|
||||
var inside = new Part(new Drawing("inside", Rectangle(2, 2)), new Vector(9, 9));
|
||||
var other = new Part(new Drawing("other", Rectangle(2, 2)), new Vector(30, 9));
|
||||
plate.Parts.Add(inside);
|
||||
plate.Parts.Add(other);
|
||||
|
||||
var result = Expander.Expand(
|
||||
new List<Part> { inside, other },
|
||||
plate,
|
||||
new Expander.Options { MaxSpacing = 2 }
|
||||
);
|
||||
|
||||
// Part-in-cutout is legal, never a material overlap.
|
||||
Assert.False(MateriallyOverlaps(inside, wall));
|
||||
Assert.False(MateriallyOverlaps(other, wall));
|
||||
Assert.False(MateriallyOverlaps(inside, other));
|
||||
Assert.True(result.AchievedSpacing >= 1.9);
|
||||
|
||||
// The part that started in the cutout must clear the hole walls too.
|
||||
var holeLeft = 5;
|
||||
var holeRight = 15;
|
||||
var gapLeft = inside.Location.X - holeLeft;
|
||||
var gapRight = holeRight - (inside.Location.X + 2);
|
||||
var gapBottom = inside.Location.Y - holeLeft;
|
||||
var gapTop = holeRight - (inside.Location.Y + 2);
|
||||
var minGap = System.Math.Min(
|
||||
System.Math.Min(gapLeft, gapRight),
|
||||
System.Math.Min(gapBottom, gapTop)
|
||||
);
|
||||
Assert.True(minGap >= 1.9, $"closest hole-wall gap {minGap}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_DegenerateInputs_Throw()
|
||||
{
|
||||
var plate = MakePlate(10, 10);
|
||||
var a = AddSquare(plate, 1, 1);
|
||||
var stranger = new Part(new Drawing("stranger", Rectangle()), new Vector(50, 50));
|
||||
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part>(), plate));
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a }, plate));
|
||||
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a, stranger }, plate));
|
||||
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
|
||||
{
|
||||
// Selection must not be pushed to open gaps between parts it excludes.
|
||||
var plate = MakePlate(40, 12);
|
||||
var w1 = AddSquare(plate, 2, 4);
|
||||
var w2 = AddSquare(plate, 6.5, 4); // 0.5 apart from w1, both unselected
|
||||
var a = AddSquare(plate, 14, 4);
|
||||
var b = AddSquare(plate, 20, 4);
|
||||
|
||||
Expander.Expand(new List<Part> { a, b }, plate);
|
||||
|
||||
Assert.Equal(2, w1.Location.X, 6);
|
||||
Assert.Equal(6.5, w2.Location.X, 6);
|
||||
Assert.True(a.Intersects(w1, out _) == false);
|
||||
Assert.True(b.Intersects(w2, out _) == false);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
@@ -13,117 +12,21 @@ namespace OpenNest.Engine.BestFit
|
||||
SlideOffset[] offsets
|
||||
)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
var results = new double[offsets.Length];
|
||||
var movingVertices = Vertices(movingTemplateLines);
|
||||
var stationaryVertices = Vertices(stationaryLines);
|
||||
var contacts = SlideContactClassifier.FromLines(
|
||||
movingTemplateLines, Vector.Zero, stationaryLines, Vector.Zero).Prepare();
|
||||
|
||||
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
||||
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
||||
|
||||
var vertexCache =
|
||||
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(
|
||||
allMovingVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: true
|
||||
);
|
||||
var facing = FilterVerticesByProjection(
|
||||
allStationaryVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: false
|
||||
);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(
|
||||
0,
|
||||
count,
|
||||
i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryLines.Count; j++)
|
||||
{
|
||||
var e = stationaryLines[j];
|
||||
var d = SpatialQuery.RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
e.StartPoint.X,
|
||||
e.StartPoint.Y,
|
||||
e.EndPoint.X,
|
||||
e.EndPoint.Y,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingTemplateLines.Count; j++)
|
||||
{
|
||||
var e = movingTemplateLines[j];
|
||||
var d = SpatialQuery.RayEdgeDistance(
|
||||
svx,
|
||||
svy,
|
||||
e.StartPoint.X + offset.Dx,
|
||||
e.StartPoint.Y + offset.Dy,
|
||||
e.EndPoint.X + offset.Dx,
|
||||
e.EndPoint.Y + offset.Dy,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
}
|
||||
);
|
||||
|
||||
var offset = offsets[i];
|
||||
var source = new LineSlideEvents(
|
||||
movingTemplateLines, movingVertices, offset.Dx, offset.Dy,
|
||||
stationaryLines, stationaryVertices, offset.DirX, offset.DirY);
|
||||
results[i] = SlideResolver.FirstBlocking(ref source,
|
||||
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -133,347 +36,26 @@ namespace OpenNest.Engine.BestFit
|
||||
SlideOffset[] offsets
|
||||
)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
var results = new double[offsets.Length];
|
||||
var movingVertices = SpatialQuery.ExtractEntityVertices(movingEntities);
|
||||
var stationaryVertices = SpatialQuery.ExtractEntityVertices(stationaryEntities);
|
||||
var contacts = new SlideContactClassifier(movingEntities, stationaryEntities).Prepare();
|
||||
|
||||
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
|
||||
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
|
||||
|
||||
var movingCurves = ExtractCurveParams(movingEntities);
|
||||
var stationaryCurves = ExtractCurveParams(stationaryEntities);
|
||||
|
||||
var vertexCache =
|
||||
new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
// All vertices participate: a leading-half filter can miss the next contact
|
||||
// after sliding past an initial touch on a concave boundary.
|
||||
System.Threading.Tasks.Parallel.For(0, offsets.Length, i =>
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(
|
||||
allMovingVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: true
|
||||
);
|
||||
var facing = FilterVerticesByProjection(
|
||||
allStationaryVerts,
|
||||
offset.DirX,
|
||||
offset.DirY,
|
||||
keepHigh: false
|
||||
);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(
|
||||
0,
|
||||
count,
|
||||
i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary entities
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(
|
||||
vx,
|
||||
vy,
|
||||
stationaryEntities[j],
|
||||
0,
|
||||
0,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving entities (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(
|
||||
svx,
|
||||
svy,
|
||||
movingEntities[j],
|
||||
offset.Dx,
|
||||
offset.Dy,
|
||||
oppX,
|
||||
oppY
|
||||
);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Curve-to-curve direct distance.
|
||||
// Vertex sampling misses the true contact between two curved entities
|
||||
// when the approach angle doesn't align with a sampled vertex.
|
||||
for (var m = 0; m < movingCurves.Length; m++)
|
||||
{
|
||||
var mc = movingCurves[m];
|
||||
var mcx = mc.Cx + offset.Dx;
|
||||
var mcy = mc.Cy + offset.Dy;
|
||||
|
||||
for (var s = 0; s < stationaryCurves.Length; s++)
|
||||
{
|
||||
var sc = stationaryCurves[s];
|
||||
var d = SpatialQuery.CurveTangencyDistance(
|
||||
mcx, mcy, mc.Radius, mc.Entity as Arc,
|
||||
sc.Cx, sc.Cy, sc.Radius, sc.Entity as Arc, dirX, dirY);
|
||||
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0)
|
||||
{
|
||||
results[i] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
}
|
||||
);
|
||||
|
||||
var offset = offsets[i];
|
||||
var source = new EntitySlideEvents(
|
||||
movingEntities, movingVertices, offset.Dx, offset.Dy,
|
||||
stationaryEntities, stationaryVertices, offset.DirX, offset.DirY, arcToLine: true);
|
||||
results[i] = SlideResolver.FirstBlocking(ref source,
|
||||
contacts.At(new Vector(offset.Dx, offset.Dy), Vector.Zero), offset.DirX, offset.DirY);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly struct CurveParams
|
||||
{
|
||||
public readonly Entity Entity;
|
||||
public readonly double Cx,
|
||||
Cy,
|
||||
Radius;
|
||||
|
||||
public CurveParams(Entity entity, double cx, double cy, double radius)
|
||||
{
|
||||
Entity = entity;
|
||||
Cx = cx;
|
||||
Cy = cy;
|
||||
Radius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
|
||||
{
|
||||
var curves = new List<CurveParams>();
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
curves.Add(
|
||||
new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius)
|
||||
);
|
||||
else if (entities[i] is Arc arc)
|
||||
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
|
||||
}
|
||||
return curves.ToArray();
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx,
|
||||
double vy,
|
||||
Entity entity,
|
||||
double entityOffsetX,
|
||||
double entityOffsetY,
|
||||
double dirX,
|
||||
double dirY
|
||||
)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return SpatialQuery.RayEdgeDistance(
|
||||
vx,
|
||||
vy,
|
||||
line.StartPoint.X + entityOffsetX,
|
||||
line.StartPoint.Y + entityOffsetY,
|
||||
line.EndPoint.X + entityOffsetX,
|
||||
line.EndPoint.Y + entityOffsetY,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return SpatialQuery.RayArcDistance(
|
||||
vx,
|
||||
vy,
|
||||
arc.Center.X + entityOffsetX,
|
||||
arc.Center.Y + entityOffsetY,
|
||||
arc.Radius,
|
||||
arc.StartAngle,
|
||||
arc.EndAngle,
|
||||
arc.IsReversed,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return SpatialQuery.RayCircleDistance(
|
||||
vx,
|
||||
vy,
|
||||
circle.Center.X + entityOffsetX,
|
||||
circle.Center.Y + entityOffsetY,
|
||||
circle.Radius,
|
||||
dirX,
|
||||
dirY
|
||||
);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
vertices.Add(line.StartPoint);
|
||||
vertices.Add(line.EndPoint);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
vertices.Add(arc.StartPoint());
|
||||
vertices.Add(arc.EndPoint());
|
||||
AddArcExtremes(vertices, arc);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Four cardinal points
|
||||
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
var reversed = arc.IsReversed;
|
||||
|
||||
if (reversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
// Right (0°)
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
|
||||
// Top (90°)
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
|
||||
// Left (180°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
|
||||
// Bottom (270°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
vertices.Add(lines[i].StartPoint);
|
||||
vertices.Add(lines[i].EndPoint);
|
||||
}
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static Vector[] FilterVerticesByProjection(
|
||||
Vector[] vertices,
|
||||
double dirX,
|
||||
double dirY,
|
||||
bool keepHigh
|
||||
)
|
||||
{
|
||||
if (vertices.Length == 0)
|
||||
return vertices;
|
||||
|
||||
var projections = new double[vertices.Length];
|
||||
var min = double.MaxValue;
|
||||
var max = double.MinValue;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
projections[i] = vertices[i].X * dirX + vertices[i].Y * dirY;
|
||||
if (projections[i] < min)
|
||||
min = projections[i];
|
||||
if (projections[i] > max)
|
||||
max = projections[i];
|
||||
}
|
||||
|
||||
var midpoint = (min + max) / 2;
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
|
||||
count++;
|
||||
}
|
||||
|
||||
var result = new Vector[count];
|
||||
var idx = 0;
|
||||
|
||||
for (var i = 0; i < vertices.Length; i++)
|
||||
{
|
||||
if (keepHigh ? projections[i] >= midpoint : projections[i] <= midpoint)
|
||||
result[idx++] = vertices[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
private static Vector[] Vertices(List<Line> lines) =>
|
||||
lines.SelectMany(line => new[] { line.StartPoint, line.EndPoint }).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ namespace OpenNest.Engine.BestFit
|
||||
SlideOffset[] offsets
|
||||
)
|
||||
{
|
||||
// ISlideComputer is axis-only; do not quantize an arbitrary direction into
|
||||
// an unrelated cardinal push. Native curves already use this same fallback.
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
if (!((offset.DirX == 0 && System.Math.Abs(offset.DirY) == 1)
|
||||
|| (offset.DirY == 0 && System.Math.Abs(offset.DirX) == 1)))
|
||||
return new CpuDistanceComputer().ComputeDistances(stationaryLines, movingTemplateLines, offsets);
|
||||
}
|
||||
|
||||
var stationarySegments = SpatialQuery.FlattenLines(stationaryLines);
|
||||
var movingSegments = SpatialQuery.FlattenLines(movingTemplateLines);
|
||||
var count = offsets.Length;
|
||||
@@ -55,7 +64,7 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
/// <summary>
|
||||
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
||||
/// Left=0, Down=1, Right=2, Up=3.
|
||||
/// Up=0, Down=1, Left=2, Right=3.
|
||||
/// </summary>
|
||||
private static int DirectionVectorToInt(double dirX, double dirY)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace OpenNest.Engine.BestFit
|
||||
public interface ISlideComputer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance for each offset position.
|
||||
/// Computes the first blocking contact distance for each offset position.
|
||||
/// Separating/tangential contacts on closed boundaries do not block.
|
||||
/// </summary>
|
||||
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
|
||||
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
|
||||
@@ -30,7 +31,7 @@ namespace OpenNest.Engine.BestFit
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Computes minimum directional distance for offsets with per-offset directions.
|
||||
/// Computes first blocking contact distances with per-offset directions.
|
||||
/// Uploads segment data once for all offsets, reducing GPU round-trips.
|
||||
/// </summary>
|
||||
double[] ComputeBatchMultiDir(
|
||||
|
||||
@@ -127,24 +127,20 @@ namespace OpenNest.Engine.Fill
|
||||
: PartGeometry.GetPerimeterEntities(moving)
|
||||
);
|
||||
|
||||
// A moving part can be inside an obstacle's cutout. Omitting that
|
||||
// loop would let it cross the inner wall before seeing the perimeter.
|
||||
obstacleEntities[i] ??=
|
||||
halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
|
||||
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
||||
? PartGeometry.GetOffsetPartEntities(obstacleParts[i], halfSpacing)
|
||||
: PartGeometry.GetPartEntities(obstacleParts[i]);
|
||||
|
||||
// Contacts left by a previous push only block directions that would
|
||||
// push material into material; the kernel classifies them.
|
||||
var d = SpatialQuery.DirectionalDistance(
|
||||
movingEntities,
|
||||
obstacleEntities[i],
|
||||
direction
|
||||
);
|
||||
if (
|
||||
d <= Tolerance.Epsilon
|
||||
&& partSpacing <= Tolerance.Epsilon
|
||||
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction)
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
@@ -176,27 +172,25 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (candidate.Intersects(parts[i], out _))
|
||||
if (!candidate.Intersects(parts[i], out _))
|
||||
continue;
|
||||
|
||||
// Part.Intersects compares outer perimeters only. A valid insert in a
|
||||
// cutout must remain an obstacle, not be discarded as already overlapping.
|
||||
var a = new ShapeProfile(PartGeometry.GetPartEntities(candidate));
|
||||
var b = new ShapeProfile(PartGeometry.GetPartEntities(parts[i]));
|
||||
if (a.Cutouts.Count == 0 && b.Cutouts.Count == 0)
|
||||
return true;
|
||||
if (Collision.HasOverlap(
|
||||
a.Perimeter.ToPolygonWithTolerance(0.001),
|
||||
b.Perimeter.ToPolygonWithTolerance(0.001),
|
||||
a.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList(),
|
||||
b.Cutouts.Select(hole => hole.ToPolygonWithTolerance(0.001)).ToList()))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
|
||||
{
|
||||
var nudge = direction * (Tolerance.Epsilon * 10);
|
||||
|
||||
moving.Offset(nudge);
|
||||
try
|
||||
{
|
||||
return !moving.Intersects(obstacle, out _);
|
||||
}
|
||||
finally
|
||||
{
|
||||
moving.Offset(-nudge);
|
||||
}
|
||||
}
|
||||
|
||||
public static double Push(
|
||||
List<Part> movingParts,
|
||||
List<Part> obstacleParts,
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes a group of selected parts apart until every constrained pair
|
||||
/// (selected↔selected and selected↔obstacle) reaches a target part-to-part
|
||||
/// spacing, with the plate work area and all non-selected parts as hard
|
||||
/// boundaries. The inverse of <see cref="Compactor"/>: it grows gaps instead
|
||||
/// of closing them.
|
||||
/// <para>
|
||||
/// Input with overlaps is accepted: an overlapping pair is a pair with
|
||||
/// negative clearance and is separated along the minimum-translation
|
||||
/// direction. Moves are straight-line only — a pair that could separate only
|
||||
/// by routing around a blocker is reported as a violation instead. Final
|
||||
/// positions are always overlap-free; nothing moves off the work area.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Anchor policy: within a violated pair the later-indexed selected part
|
||||
/// moves; the counterpart moves only as a fallback when the anchor mover is
|
||||
/// fully blocked by a work-area edge. Walls (non-selected parts) never move.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Expander
|
||||
{
|
||||
public sealed class Options
|
||||
{
|
||||
/// <summary>First spacing probed by the search; also the floor for the doubling step.</summary>
|
||||
public double InitialStep = 1.0;
|
||||
|
||||
/// <summary>Bisection stops once the achievable spacing is known within this tolerance.</summary>
|
||||
public double Tolerance = 0.01;
|
||||
|
||||
/// <summary>Relaxation iteration cap per separation run.</summary>
|
||||
public int MaxIterations = 100;
|
||||
|
||||
/// <summary>Upper bound for the spacing search. 0 = auto (work-area diagonal).</summary>
|
||||
public double MaxSpacing = 0;
|
||||
}
|
||||
|
||||
public sealed class Violation
|
||||
{
|
||||
public Part A;
|
||||
public Part B;
|
||||
|
||||
/// <summary>Clearance actually reached (may be negative for unresolvable overlaps).</summary>
|
||||
public double Achieved;
|
||||
|
||||
/// <summary>True when a work-area edge, not a part, blocked the last needed move.</summary>
|
||||
public bool BlockedByEdge;
|
||||
}
|
||||
|
||||
public sealed class Result
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum part-to-part clearance the returned layout satisfies,
|
||||
/// never below zero. 0 with violations means the layout was only
|
||||
/// cleaned up as far as possible, not opened up.
|
||||
/// </summary>
|
||||
public double AchievedSpacing;
|
||||
|
||||
/// <summary>True when the run was cancelled; no positions were changed.</summary>
|
||||
public bool Cancelled;
|
||||
|
||||
/// <summary>Pairs that could not reach <see cref="AchievedSpacing"/>.</summary>
|
||||
public List<Violation> Violations = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the part-to-part spacing of the selection as far as the plate
|
||||
/// and its other parts allow, applying the best spacing found. Plate
|
||||
/// PartSpacing/EdgeSpacing are not modified; edges keep their own
|
||||
/// EdgeSpacing floor while only part-to-part clearance chases the target.
|
||||
/// Mutates <paramref name="selected"/> locations; probing never touches
|
||||
/// them, so a failed or cancelled run leaves the layout unchanged.
|
||||
/// </summary>
|
||||
public static Result Expand(
|
||||
List<Part> selected,
|
||||
Plate plate,
|
||||
Options options = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
if (plate == null)
|
||||
throw new ArgumentNullException(nameof(plate));
|
||||
if (selected == null || selected.Count < 2)
|
||||
throw new ArgumentException(
|
||||
"Expand requires at least two selected parts.",
|
||||
nameof(selected)
|
||||
);
|
||||
if (selected.Any(p => !plate.Parts.Contains(p)))
|
||||
throw new ArgumentException(
|
||||
"All selected parts must belong to the plate.",
|
||||
nameof(selected)
|
||||
);
|
||||
|
||||
var opt = options ?? new Options();
|
||||
var context = SeparationContext.Prepare(selected, plate);
|
||||
var entry = context.Positions(); // all parts; movers are [0, count)
|
||||
var result = new Result();
|
||||
|
||||
var sMin = MinimumPairClearance(context, entry);
|
||||
result.AchievedSpacing = System.Math.Max(0, sMin);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var sGood = sMin;
|
||||
var cap = opt.MaxSpacing > 0 ? opt.MaxSpacing : context.SpacingCap;
|
||||
double hi; // first spacing that failed; double.NaN = none yet
|
||||
|
||||
(bool Converged, List<Vector> Positions) Probe(double spacing)
|
||||
{
|
||||
var attempt = Separate(context, entry, spacing, opt.MaxIterations, token);
|
||||
return (attempt.Converged, attempt.Positions);
|
||||
}
|
||||
|
||||
// Doubling phase: commit every spacing that converges. The cap gets
|
||||
// its own probe even when the step jumps past it, and no spacing is
|
||||
// probed twice.
|
||||
var s = System.Math.Max(opt.InitialStep, 2 * System.Math.Max(0, sGood));
|
||||
hi = double.NaN;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (s > cap)
|
||||
{
|
||||
if (cap > sGood + opt.Tolerance)
|
||||
s = cap;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
if (s <= sGood + opt.Tolerance)
|
||||
break;
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var probe = Probe(s);
|
||||
|
||||
if (probe.Converged)
|
||||
{
|
||||
sGood = s;
|
||||
if (sGood >= cap)
|
||||
break;
|
||||
s = System.Math.Max(s * 2, sGood + opt.Tolerance);
|
||||
}
|
||||
else
|
||||
{
|
||||
hi = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Bisection between the last spacing that converged and the first that failed.
|
||||
if (!double.IsNaN(hi))
|
||||
{
|
||||
var lo = System.Math.Max(0, sGood);
|
||||
while (hi - lo > opt.Tolerance)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var mid = (lo + hi) / 2;
|
||||
if (Probe(mid).Converged)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
sGood = lo;
|
||||
}
|
||||
|
||||
// Finalize: re-separate at the applied spacing from the entry state.
|
||||
// Deterministic, so this reproduces any committed probe exactly; when
|
||||
// sGood was never probed (overlapping entry) it still performs the
|
||||
// best-effort cleanup and yields the violation report.
|
||||
var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
result.Cancelled = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var positions = final.Positions;
|
||||
|
||||
Apply(selected, entry, positions);
|
||||
|
||||
var measured = MinimumPairClearance(context, positions);
|
||||
result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured));
|
||||
result.Violations = final.Violations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relaxes the given parts apart to a fixed target spacing against the
|
||||
/// plate. Works on scratch positions; the caller applies them. Exposed
|
||||
/// for testing and for callers that manage their own spacing search.
|
||||
/// </summary>
|
||||
public static (bool Converged, List<Vector> Positions, List<Violation> Violations) Separate(
|
||||
List<Part> selected,
|
||||
Plate plate,
|
||||
double spacing,
|
||||
int maxIterations = 100,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
var context = SeparationContext.Prepare(selected, plate);
|
||||
var positions = context.Positions();
|
||||
return Separate(context, positions, spacing, maxIterations, token);
|
||||
}
|
||||
|
||||
private static void Apply(List<Part> selected, List<Vector> from, List<Vector> to)
|
||||
{
|
||||
// Only movers occupy [0, selected.Count); walls never move.
|
||||
for (var i = 0; i < selected.Count; i++)
|
||||
{
|
||||
var delta = to[i] - from[i];
|
||||
if (delta.X != 0 || delta.Y != 0)
|
||||
selected[i].Offset(delta);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MinimumPairClearance(SeparationContext context, List<Vector> positions)
|
||||
{
|
||||
var min = double.MaxValue;
|
||||
|
||||
foreach (var pair in context.Pairs)
|
||||
{
|
||||
var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions);
|
||||
if (distance < min)
|
||||
min = distance;
|
||||
}
|
||||
|
||||
return min == double.MaxValue ? 0 : min;
|
||||
}
|
||||
|
||||
private static (bool Converged, List<Vector> Positions, List<Violation> Violations) Separate(
|
||||
SeparationContext context,
|
||||
List<Vector> start,
|
||||
double spacing,
|
||||
int maxIterations,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var positions = new List<Vector>(start);
|
||||
var stuck = new HashSet<int>();
|
||||
var violations = new List<Violation>();
|
||||
var epsMove = 1e-4;
|
||||
|
||||
// Internal margin absorbs the clearance kernel's tessellation error so
|
||||
// the applied spacing holds against the production validators.
|
||||
var target = spacing + 0.002;
|
||||
|
||||
var iterationLimit = maxIterations < 1 ? 1 : maxIterations;
|
||||
|
||||
for (var iteration = 0; iteration < iterationLimit; iteration++)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return (false, start, violations);
|
||||
|
||||
var moved = 0.0;
|
||||
var stuckChanged = false;
|
||||
|
||||
foreach (var pair in context.Pairs)
|
||||
{
|
||||
if (stuck.Contains(pair.Id))
|
||||
continue;
|
||||
|
||||
var (distance, directionA) = context.PairClearance(
|
||||
pair.IndexA,
|
||||
pair.IndexB,
|
||||
positions
|
||||
);
|
||||
|
||||
// Trigger at the user spacing, not the internal margin: a
|
||||
// pair already at the requested spacing must not be nudged,
|
||||
// or feasible layouts at the ceiling (every pair exactly at
|
||||
// spacing) would oscillate forever. The margin only sets how
|
||||
// far past the trigger a push carries, absorbing tessellation
|
||||
// error in the measurement.
|
||||
if (distance >= spacing)
|
||||
continue;
|
||||
|
||||
var need = target - distance;
|
||||
|
||||
// Anchor policy (decided): only the later-indexed selected
|
||||
// part of a violated pair moves. Counterparts and walls
|
||||
// never do — a pair whose anchor mover cannot reach the
|
||||
// target is a violation, not an invitation to drift the
|
||||
// anchor.
|
||||
var moverIndex = pair.Mover;
|
||||
|
||||
// Clearance direction translates A away from B; a mover on
|
||||
// the B side travels the opposite way.
|
||||
var direction = moverIndex == pair.IndexA ? directionA : -directionA;
|
||||
|
||||
var room = context.ClipToWorkArea(
|
||||
moverIndex,
|
||||
positions[moverIndex],
|
||||
direction,
|
||||
need
|
||||
);
|
||||
|
||||
// Take the largest valid step up to `room`: partial moves let
|
||||
// a blocked mover advance again once its own blockers move
|
||||
// away in later iterations (a wave separates a chain).
|
||||
var applied = 0.0;
|
||||
var blockedByEdge = false;
|
||||
|
||||
if (room > 0)
|
||||
{
|
||||
var trial = positions[moverIndex] + direction * room;
|
||||
|
||||
if (context.MaintainsValidity(moverIndex, trial, positions))
|
||||
{
|
||||
positions[moverIndex] = trial;
|
||||
applied = room;
|
||||
}
|
||||
else
|
||||
{
|
||||
var lo = 0.0;
|
||||
var hi2 = room;
|
||||
for (var bisect = 0; bisect < 24 && hi2 - lo > 1e-6; bisect++)
|
||||
{
|
||||
var mid = (lo + hi2) / 2;
|
||||
if (
|
||||
context.MaintainsValidity(
|
||||
moverIndex,
|
||||
positions[moverIndex] + direction * mid,
|
||||
positions
|
||||
)
|
||||
)
|
||||
lo = mid;
|
||||
else
|
||||
hi2 = mid;
|
||||
}
|
||||
|
||||
if (lo > epsMove)
|
||||
{
|
||||
positions[moverIndex] = positions[moverIndex] + direction * lo;
|
||||
applied = lo;
|
||||
}
|
||||
}
|
||||
|
||||
blockedByEdge = applied < need - epsMove;
|
||||
moved += applied;
|
||||
}
|
||||
else
|
||||
{
|
||||
blockedByEdge = true;
|
||||
}
|
||||
|
||||
// A pair fully separated to the user target (the internal margin
|
||||
// absorbs tessellation slack) is satisfied even if not to target.
|
||||
if (applied > 0)
|
||||
{
|
||||
var (finalDistance, _) = context.PairClearance(
|
||||
pair.IndexA,
|
||||
pair.IndexB,
|
||||
positions
|
||||
);
|
||||
if (finalDistance >= spacing)
|
||||
continue;
|
||||
// Partial progress: keep the pair live — its blockers may
|
||||
// move away in later iterations and unblock the rest.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Zero progress twice in a row parks the pair; the final sweep
|
||||
// re-measures everything, so mid-loop bookkeeping never lies.
|
||||
if (stuck.Contains(pair.Id))
|
||||
continue;
|
||||
|
||||
stuck.Add(pair.Id);
|
||||
stuckChanged = true;
|
||||
}
|
||||
|
||||
if (moved < epsMove && !stuckChanged)
|
||||
break;
|
||||
}
|
||||
|
||||
// Honest verdict: stuck bookkeeping and the internal margin can both
|
||||
// let a pair read as satisfied mid-loop while a later pair move
|
||||
// un-does it (oscillation). Re-measure every constrained pair at the
|
||||
// final positions once; the violations this sweep finds are the
|
||||
// report, and any violation makes the run non-converged.
|
||||
violations.Clear();
|
||||
foreach (var pair in context.Pairs)
|
||||
{
|
||||
var (distance, _) = context.PairClearance(
|
||||
pair.IndexA,
|
||||
pair.IndexB,
|
||||
positions
|
||||
);
|
||||
|
||||
if (distance >= spacing)
|
||||
continue;
|
||||
|
||||
violations.Add(
|
||||
new Violation
|
||||
{
|
||||
A = context.PartOf(pair.IndexA),
|
||||
B = context.PartOf(pair.IndexB),
|
||||
Achieved = distance,
|
||||
BlockedByEdge = stuck.Contains(pair.Id),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (violations.Count == 0, positions, violations);
|
||||
}
|
||||
|
||||
/// <summary>One constrained part↔part pair with its anchor mover.</summary>
|
||||
private sealed class Pair
|
||||
{
|
||||
public int Id;
|
||||
public int IndexA;
|
||||
public int IndexB;
|
||||
public int Mover;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-run prepared geometry. Rings are local-frame polygons (world = local
|
||||
/// + scratch position), prepared once per distinct Program by reference,
|
||||
/// mirroring <see cref="PartOverlapChecker"/>'s caching but translatable.
|
||||
/// </summary>
|
||||
private sealed class SeparationContext
|
||||
{
|
||||
private readonly List<Part> parts; // movers [0, moverCount) then walls
|
||||
private readonly List<RingSet> shapes; // per part
|
||||
private readonly Box[] localBoxes; // per part, local frame
|
||||
private readonly int moverCount;
|
||||
private readonly Box workArea;
|
||||
|
||||
public readonly List<Pair> Pairs = new();
|
||||
public readonly double SpacingCap;
|
||||
|
||||
private sealed class RingSet
|
||||
{
|
||||
public Polygon Outer;
|
||||
public List<Polygon> Rings = new(); // outer + cutout rings, local frame
|
||||
public List<Polygon> Holes = new();
|
||||
}
|
||||
|
||||
private SeparationContext(
|
||||
List<Part> parts,
|
||||
List<RingSet> shapes,
|
||||
Box[] localBoxes,
|
||||
int moverCount,
|
||||
Box workArea,
|
||||
double spacingCap
|
||||
)
|
||||
{
|
||||
this.parts = parts;
|
||||
this.shapes = shapes;
|
||||
this.localBoxes = localBoxes;
|
||||
this.moverCount = moverCount;
|
||||
this.workArea = workArea;
|
||||
SpacingCap = spacingCap;
|
||||
}
|
||||
|
||||
public static SeparationContext Prepare(List<Part> selected, Plate plate)
|
||||
{
|
||||
var movers = new List<Part>(selected);
|
||||
var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList();
|
||||
|
||||
var parts = new List<Part>(movers.Count + walls.Count);
|
||||
parts.AddRange(movers);
|
||||
parts.AddRange(walls);
|
||||
|
||||
var programs = new Dictionary<CNC.Program, RingSet>(
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var shapes = new List<RingSet>(parts.Count);
|
||||
var localBoxes = new Box[parts.Count];
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
shapes.Add(PrepareProgram(programs, parts[i].Program));
|
||||
localBoxes[i] = LocalBox(parts[i]);
|
||||
}
|
||||
|
||||
var workArea = plate.WorkArea();
|
||||
var spacingCap = System.Math.Sqrt(
|
||||
workArea.Length * workArea.Length + workArea.Width * workArea.Width
|
||||
);
|
||||
|
||||
var context = new SeparationContext(
|
||||
parts,
|
||||
shapes,
|
||||
localBoxes,
|
||||
movers.Count,
|
||||
workArea,
|
||||
spacingCap
|
||||
);
|
||||
context.BuildPairs();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static RingSet PrepareProgram(
|
||||
Dictionary<CNC.Program, RingSet> programs,
|
||||
CNC.Program program
|
||||
)
|
||||
{
|
||||
if (programs.TryGetValue(program, out var existing))
|
||||
return existing;
|
||||
|
||||
var prepared = new RingSet();
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(program)
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
if (profile.Perimeter != null)
|
||||
{
|
||||
prepared.Outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
|
||||
prepared.Rings.Add(prepared.Outer);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var hole = cutout.ToPolygonWithTolerance(0.001);
|
||||
prepared.Rings.Add(hole);
|
||||
prepared.Holes.Add(hole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
programs.Add(program, prepared);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private static Box LocalBox(Part part)
|
||||
{
|
||||
var box = part.BoundingBox;
|
||||
return new Box(
|
||||
box.Left - part.Location.X,
|
||||
box.Bottom - part.Location.Y,
|
||||
box.Length,
|
||||
box.Width
|
||||
);
|
||||
}
|
||||
|
||||
private void BuildPairs()
|
||||
{
|
||||
var id = 0;
|
||||
|
||||
for (var a = 0; a < parts.Count; a++)
|
||||
{
|
||||
for (var b = a + 1; b < parts.Count; b++)
|
||||
{
|
||||
var aMover = a < moverCount;
|
||||
var bMover = b < moverCount;
|
||||
|
||||
if (!aMover && !bMover)
|
||||
continue;
|
||||
|
||||
Pairs.Add(
|
||||
new Pair
|
||||
{
|
||||
Id = id++,
|
||||
IndexA = a,
|
||||
IndexB = b,
|
||||
// Anchor policy: the later-index mover moves.
|
||||
Mover = bMover ? b : a,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMover(int index) => index < moverCount;
|
||||
|
||||
public Part PartOf(int index) => parts[index];
|
||||
|
||||
/// <summary>Current world positions of every part in pair-index order.</summary>
|
||||
public List<Vector> Positions() => parts.Select(p => p.Location).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Signed material clearance between two parts at the given scratch
|
||||
/// positions. Material overlap (Collision oracle with hole subtraction)
|
||||
/// reports negative penetration through the outer rings; otherwise the
|
||||
/// clearance is the minimum boundary distance over all ring pairs, so a
|
||||
/// part inside another's cutout measures its true gap to the hole ring
|
||||
/// instead of a bogus outer-ring penetration.
|
||||
/// </summary>
|
||||
public (double Distance, Vector Direction) PairClearance(
|
||||
int indexA,
|
||||
int indexB,
|
||||
List<Vector> positions
|
||||
)
|
||||
{
|
||||
var setA = shapes[indexA];
|
||||
var setB = shapes[indexB];
|
||||
|
||||
if (setA.Outer == null || setB.Outer == null)
|
||||
return (0, new Vector(1, 0));
|
||||
|
||||
var offsetA = positions[indexA];
|
||||
var offsetB = positions[indexB];
|
||||
|
||||
var outerA = CloneAt(setA.Outer, offsetA);
|
||||
var outerB = CloneAt(setB.Outer, offsetB);
|
||||
|
||||
var holesA = setA.Holes.Count == 0 ? null : CloneAll(setA.Holes, offsetA);
|
||||
var holesB = setB.Holes.Count == 0 ? null : CloneAll(setB.Holes, offsetB);
|
||||
|
||||
if (Collision.HasOverlap(outerA, outerB, holesA, holesB))
|
||||
{
|
||||
var penetration = Clearance.Between(outerA, outerB);
|
||||
if (penetration.Distance < 0)
|
||||
return (penetration.Distance, penetration.Direction);
|
||||
// Hole subtraction resolved what the outers overlap: touching.
|
||||
return (0, penetration.Direction);
|
||||
}
|
||||
|
||||
double best = double.MaxValue;
|
||||
var bestDir = new Vector(1, 0);
|
||||
|
||||
foreach (var ringA in setA.Rings)
|
||||
{
|
||||
var worldA = CloneAt(ringA, offsetA);
|
||||
|
||||
foreach (var ringB in setB.Rings)
|
||||
{
|
||||
var worldB = CloneAt(ringB, offsetB);
|
||||
var clearance = Clearance.BoundaryDistance(worldA, worldB);
|
||||
|
||||
if (clearance.Distance < best)
|
||||
{
|
||||
best = clearance.Distance;
|
||||
bestDir = clearance.Direction;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (best, bestDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Largest α ≤ need such that translating the part by direction·α keeps
|
||||
/// its AABB inside the work area.
|
||||
/// </summary>
|
||||
public double ClipToWorkArea(int index, Vector position, Vector direction, double need)
|
||||
{
|
||||
var box = localBoxes[index];
|
||||
var left = position.X + box.Left - workArea.Left;
|
||||
var right = workArea.Right - (position.X + box.Right);
|
||||
var bottom = position.Y + box.Bottom - workArea.Bottom;
|
||||
var top = workArea.Top - (position.Y + box.Top);
|
||||
|
||||
var max = need;
|
||||
if (direction.X > 0)
|
||||
max = System.Math.Min(max, right / direction.X);
|
||||
else if (direction.X < 0)
|
||||
max = System.Math.Min(max, left / -direction.X);
|
||||
|
||||
if (direction.Y > 0)
|
||||
max = System.Math.Min(max, top / direction.Y);
|
||||
else if (direction.Y < 0)
|
||||
max = System.Math.Min(max, bottom / -direction.Y);
|
||||
|
||||
return max < 0 ? 0 : max;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the part at <paramref name="trial"/> stays inside the work
|
||||
/// area and keeps no material overlap with any other part (Collision
|
||||
/// oracle with hole subtraction, so part-in-cutout stays legal).
|
||||
/// </summary>
|
||||
public bool MaintainsValidity(int index, Vector trial, List<Vector> positions)
|
||||
{
|
||||
var box = localBoxes[index];
|
||||
var movedBox = box.Translate(trial);
|
||||
|
||||
if (
|
||||
movedBox.Left < workArea.Left - 1e-9
|
||||
|| movedBox.Right > workArea.Right + 1e-9
|
||||
|| movedBox.Bottom < workArea.Bottom - 1e-9
|
||||
|| movedBox.Top > workArea.Top + 1e-9
|
||||
)
|
||||
return false;
|
||||
|
||||
var outer = shapes[index].Outer;
|
||||
if (outer == null)
|
||||
return true;
|
||||
|
||||
var worldOuter = CloneAt(outer, trial);
|
||||
var worldHoles = shapes[index].Holes.Count == 0
|
||||
? null
|
||||
: CloneAll(shapes[index].Holes, trial);
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (i == index)
|
||||
continue;
|
||||
|
||||
var otherOuter = shapes[i].Outer;
|
||||
if (otherOuter == null)
|
||||
continue;
|
||||
|
||||
if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002))
|
||||
continue;
|
||||
|
||||
var worldOther = CloneAt(otherOuter, positions[i]);
|
||||
var worldOtherHoles = shapes[i].Holes.Count == 0
|
||||
? null
|
||||
: CloneAll(shapes[i].Holes, positions[i]);
|
||||
|
||||
if (
|
||||
Collision.HasOverlap(
|
||||
worldOuter,
|
||||
worldOther,
|
||||
worldHoles,
|
||||
worldOtherHoles
|
||||
)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<Polygon> CloneAll(List<Polygon> polygons, Vector offset)
|
||||
{
|
||||
var list = new List<Polygon>(polygons.Count);
|
||||
foreach (var polygon in polygons)
|
||||
list.Add(CloneAt(polygon, offset));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Clone with world bounds applied — prepared rings are never mutated.</summary>
|
||||
private static Polygon CloneAt(Polygon polygon, Vector offset)
|
||||
{
|
||||
var clone = (Polygon)polygon.Clone();
|
||||
clone.UpdateBounds();
|
||||
clone.Offset(offset);
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static bool BoxOverlap(Box a, Box b, double slack)
|
||||
{
|
||||
return !(
|
||||
a.Right + slack < b.Left
|
||||
|| b.Right + slack < a.Left
|
||||
|| a.Top + slack < b.Bottom
|
||||
|| b.Top + slack < a.Bottom
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,9 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
// Slide uses locations, not cached bounds; Offset already translates the box.
|
||||
// Slide part2 left toward part1.
|
||||
var movingLines = boundary2.GetLines(part2.Location, PushDirection.Left);
|
||||
var stationaryLines = boundary1.GetLines(part1.Location, PushDirection.Right);
|
||||
// Keep complete loops so the shared kernel can classify tangential contacts.
|
||||
var movingLines = boundary2.GetLines(part2.Location);
|
||||
var stationaryLines = boundary1.GetLines(part1.Location);
|
||||
var dist = SpatialQuery.DirectionalDistance(
|
||||
movingLines,
|
||||
stationaryLines,
|
||||
@@ -234,15 +235,9 @@ namespace OpenNest.Engine.Fill
|
||||
PushDirection direction
|
||||
)
|
||||
{
|
||||
var opposite = SpatialQuery.OppositeDirection(direction);
|
||||
var movingEdges = movingBoundary.GetEdges(direction);
|
||||
var stationaryEdges = stationaryBoundary.GetEdges(opposite);
|
||||
|
||||
return SpatialQuery.DirectionalDistance(
|
||||
movingEdges,
|
||||
movingLocation,
|
||||
stationaryEdges,
|
||||
stationaryLocation,
|
||||
movingBoundary.GetLines(movingLocation),
|
||||
stationaryBoundary.GetLines(stationaryLocation),
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ILGPU;
|
||||
using ILGPU.Algorithms;
|
||||
using ILGPU.Runtime;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Gpu
|
||||
{
|
||||
@@ -19,7 +24,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // movingPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // offsets
|
||||
ArrayView1D<double, Stride1D.Dense>, // results
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>, // results
|
||||
int,
|
||||
int,
|
||||
int
|
||||
@@ -30,7 +35,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // movingPrep
|
||||
ArrayView1D<double, Stride1D.Dense>, // offsets
|
||||
ArrayView1D<double, Stride1D.Dense>, // results
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>, // results
|
||||
ArrayView1D<int, Stride1D.Dense>, // directions
|
||||
int,
|
||||
int
|
||||
@@ -47,22 +52,24 @@ namespace OpenNest.Gpu
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep;
|
||||
private double[]? _lastStationaryData; // Keep CPU copy/ref for content check
|
||||
private double[]? _lastStationaryData; // Active segment snapshot used for upload and contact topology
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingRaw;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep;
|
||||
private double[]? _lastMovingData; // Keep CPU copy/ref for content check
|
||||
private double[]? _lastMovingData; // Active segment snapshot used for upload and contact topology
|
||||
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuOffsets;
|
||||
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults;
|
||||
private MemoryBuffer1D<ContactWitness, Stride1D.Dense>? _gpuResults;
|
||||
private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs;
|
||||
private int _offsetCapacity;
|
||||
|
||||
public GpuSlideComputer()
|
||||
public GpuSlideComputer() : this(preferCPU: false) { }
|
||||
|
||||
public GpuSlideComputer(bool preferCPU)
|
||||
{
|
||||
_context = Context.CreateDefault();
|
||||
_accelerator = _context
|
||||
.GetPreferredDevice(preferCPU: false)
|
||||
.GetPreferredDevice(preferCPU)
|
||||
.CreateAccelerator(_context);
|
||||
|
||||
_kernel = _accelerator.LoadAutoGroupedStreamKernel<
|
||||
@@ -70,7 +77,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>,
|
||||
int,
|
||||
int,
|
||||
int
|
||||
@@ -81,7 +88,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<double, Stride1D.Dense>,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense>,
|
||||
ArrayView1D<int, Stride1D.Dense>,
|
||||
int,
|
||||
int
|
||||
@@ -118,21 +125,24 @@ namespace OpenNest.Gpu
|
||||
EnsureMoving(movingTemplateSegments, movingCount);
|
||||
EnsureOffsetBuffers(offsetCount);
|
||||
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
|
||||
_accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
|
||||
|
||||
_kernel(
|
||||
offsetCount,
|
||||
_gpuStationaryPrep!.View,
|
||||
_gpuMovingPrep!.View,
|
||||
_gpuOffsets.View,
|
||||
_gpuResults!.View,
|
||||
_gpuOffsets.View.SubView(0, offsetCount * 2),
|
||||
_gpuResults!.View.SubView(0, offsetCount),
|
||||
stationaryCount,
|
||||
movingCount,
|
||||
(int)direction
|
||||
);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
|
||||
var witnesses = new ContactWitness[offsetCount];
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses);
|
||||
ResolveContacts(witnesses, offsets, results, direction, null);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -161,93 +171,138 @@ namespace OpenNest.Gpu
|
||||
EnsureMoving(movingTemplateSegments, movingCount);
|
||||
EnsureOffsetBuffers(offsetCount);
|
||||
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
|
||||
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions);
|
||||
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(
|
||||
_accelerator.DefaultStream, (ReadOnlySpan<double>)offsets.AsSpan(0, offsetCount * 2));
|
||||
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(
|
||||
_accelerator.DefaultStream, (ReadOnlySpan<int>)directions.AsSpan(0, offsetCount));
|
||||
|
||||
_kernelMultiDir(
|
||||
offsetCount,
|
||||
_gpuStationaryPrep!.View,
|
||||
_gpuMovingPrep!.View,
|
||||
_gpuOffsets.View,
|
||||
_gpuResults!.View,
|
||||
_gpuDirs.View,
|
||||
_gpuOffsets.View.SubView(0, offsetCount * 2),
|
||||
_gpuResults!.View.SubView(0, offsetCount),
|
||||
_gpuDirs.View.SubView(0, offsetCount),
|
||||
stationaryCount,
|
||||
movingCount
|
||||
);
|
||||
|
||||
_accelerator.Synchronize();
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
|
||||
var witnesses = new ContactWitness[offsetCount];
|
||||
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(witnesses);
|
||||
ResolveContacts(witnesses, offsets, results, default, directions);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public void InvalidateStationary() => _lastStationaryData = null;
|
||||
public void InvalidateStationary()
|
||||
{
|
||||
lock (_lock)
|
||||
_lastStationaryData = null;
|
||||
}
|
||||
|
||||
public void InvalidateMoving() => _lastMovingData = null;
|
||||
public void InvalidateMoving()
|
||||
{
|
||||
lock (_lock)
|
||||
_lastMovingData = null;
|
||||
}
|
||||
|
||||
private void ResolveContacts(
|
||||
ContactWitness[] witnesses,
|
||||
double[] offsets,
|
||||
double[] results,
|
||||
PushDirection direction,
|
||||
int[]? directions
|
||||
)
|
||||
{
|
||||
var moving = default(List<Line>);
|
||||
var stationary = default(List<Line>);
|
||||
var contacts = default(SlideContactClassifier);
|
||||
for (var i = 0; i < witnesses.Length; i++)
|
||||
{
|
||||
var witness = witnesses[i];
|
||||
results[i] = witness.Distance;
|
||||
if (witness.Distance == double.MaxValue)
|
||||
continue;
|
||||
|
||||
// GPU finds the nearest event. Prepare the complete boundaries only
|
||||
// once per batch, and share their material-side topology at each offset.
|
||||
if (contacts == null)
|
||||
{
|
||||
moving = ToLines(_lastMovingData!);
|
||||
stationary = ToLines(_lastStationaryData!);
|
||||
contacts = SlideContactClassifier.FromLines(
|
||||
moving, Vector.Zero, stationary, Vector.Zero).Prepare();
|
||||
}
|
||||
|
||||
var offset = new Vector(offsets[i * 2], offsets[i * 2 + 1]);
|
||||
var push = directions == null ? direction : (PushDirection)directions[i];
|
||||
var unit = SpatialQuery.DirectionToOffset(push, 1);
|
||||
var placed = contacts.At(offset, Vector.Zero);
|
||||
if (placed.Blocks(
|
||||
new Vector(witness.MovingX, witness.MovingY),
|
||||
new Vector(witness.StationaryX, witness.StationaryY), unit.X, unit.Y))
|
||||
continue;
|
||||
|
||||
// A departing or grazing event does not discard the obstacle: replay
|
||||
// all events through the shared resolver to find the next blocking one,
|
||||
// including another contact tied at the same distance.
|
||||
results[i] = SpatialQuery.DirectionalDistance(
|
||||
moving!, offset.X, offset.Y, stationary!, push, placed);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Line> ToLines(double[] segments)
|
||||
{
|
||||
var lines = new List<Line>(segments.Length / 4);
|
||||
for (var i = 0; i < segments.Length; i += 4)
|
||||
lines.Add(new Line(segments[i], segments[i + 1], segments[i + 2], segments[i + 3]));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private void EnsureStationary(double[] data, int count)
|
||||
{
|
||||
// Fast check: if same object or content is identical, skip upload
|
||||
if (
|
||||
_gpuStationaryPrep != null
|
||||
&& _lastStationaryData != null
|
||||
&& _lastStationaryData.Length == data.Length
|
||||
)
|
||||
{
|
||||
// Reference equality or content equality
|
||||
if (
|
||||
_lastStationaryData == data
|
||||
|| new ReadOnlySpan<double>(_lastStationaryData).SequenceEqual(
|
||||
new ReadOnlySpan<double>(data)
|
||||
)
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Cache the active prefix by value: callers may reuse an array with a
|
||||
// different segment count or mutate its coordinates between batches.
|
||||
var active = data.AsSpan(0, count * 4);
|
||||
if (_gpuStationaryPrep != null && _lastStationaryData != null
|
||||
&& active.SequenceEqual(_lastStationaryData))
|
||||
return;
|
||||
|
||||
_gpuStationaryRaw?.Dispose();
|
||||
_gpuStationaryPrep?.Dispose();
|
||||
|
||||
_gpuStationaryRaw = _accelerator.Allocate1D(data);
|
||||
var snapshot = active.ToArray();
|
||||
_gpuStationaryRaw = _accelerator.Allocate1D(snapshot);
|
||||
_gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10);
|
||||
|
||||
_prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count);
|
||||
_accelerator.Synchronize();
|
||||
|
||||
_lastStationaryData = data; // store reference for next comparison
|
||||
_lastStationaryData = snapshot;
|
||||
}
|
||||
|
||||
private void EnsureMoving(double[] data, int count)
|
||||
{
|
||||
if (
|
||||
_gpuMovingPrep != null
|
||||
&& _lastMovingData != null
|
||||
&& _lastMovingData.Length == data.Length
|
||||
)
|
||||
{
|
||||
if (
|
||||
_lastMovingData == data
|
||||
|| new ReadOnlySpan<double>(_lastMovingData).SequenceEqual(
|
||||
new ReadOnlySpan<double>(data)
|
||||
)
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Cache the active prefix by value: callers may reuse an array with a
|
||||
// different segment count or mutate its coordinates between batches.
|
||||
var active = data.AsSpan(0, count * 4);
|
||||
if (_gpuMovingPrep != null && _lastMovingData != null
|
||||
&& active.SequenceEqual(_lastMovingData))
|
||||
return;
|
||||
|
||||
_gpuMovingRaw?.Dispose();
|
||||
_gpuMovingPrep?.Dispose();
|
||||
|
||||
_gpuMovingRaw = _accelerator.Allocate1D(data);
|
||||
var snapshot = active.ToArray();
|
||||
_gpuMovingRaw = _accelerator.Allocate1D(snapshot);
|
||||
_gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10);
|
||||
|
||||
_prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count);
|
||||
_accelerator.Synchronize();
|
||||
|
||||
_lastMovingData = data;
|
||||
_lastMovingData = snapshot;
|
||||
}
|
||||
|
||||
private void EnsureOffsetBuffers(int offsetCount)
|
||||
@@ -262,7 +317,7 @@ namespace OpenNest.Gpu
|
||||
_gpuDirs?.Dispose();
|
||||
|
||||
_gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2);
|
||||
_gpuResults = _accelerator.Allocate1D<double>(newCapacity);
|
||||
_gpuResults = _accelerator.Allocate1D<ContactWitness>(newCapacity);
|
||||
_gpuDirs = _accelerator.Allocate1D<int>(newCapacity);
|
||||
|
||||
_offsetCapacity = newCapacity;
|
||||
@@ -293,8 +348,8 @@ namespace OpenNest.Gpu
|
||||
var dy = y2 - y1;
|
||||
|
||||
// invD is used for parameter 't'. We use a small epsilon for stability.
|
||||
prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx;
|
||||
prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy;
|
||||
prepared[index * 10 + 4] = (XMath.Abs(dx) < Tolerance.Epsilon) ? 0 : 1.0 / dx;
|
||||
prepared[index * 10 + 5] = (XMath.Abs(dy) < Tolerance.Epsilon) ? 0 : 1.0 / dy;
|
||||
|
||||
prepared[index * 10 + 6] = XMath.Min(x1, x2);
|
||||
prepared[index * 10 + 7] = XMath.Max(x1, x2);
|
||||
@@ -304,12 +359,48 @@ namespace OpenNest.Gpu
|
||||
|
||||
// ── Main Slide Kernels ───────────────────────────────────────
|
||||
|
||||
// Public because ILGPU's CPU backend emits kernel argument types in a separate assembly.
|
||||
public struct ContactWitness
|
||||
{
|
||||
public double Distance;
|
||||
public double MovingX;
|
||||
public double MovingY;
|
||||
public double StationaryX;
|
||||
public double StationaryY;
|
||||
}
|
||||
|
||||
private static void Consider(
|
||||
ref ContactWitness nearest,
|
||||
double distance,
|
||||
double vx,
|
||||
double vy,
|
||||
int rayDirection,
|
||||
bool vertexMoves
|
||||
)
|
||||
{
|
||||
var snapped = distance > Tolerance.Epsilon ? distance : 0;
|
||||
if (snapped >= nearest.Distance)
|
||||
return;
|
||||
|
||||
// Use the unsnapped hit for incidence; snapping a tiny gap to zero must
|
||||
// not move the witness off the other boundary.
|
||||
var dirX = rayDirection == 2 ? -1 : rayDirection == 3 ? 1 : 0;
|
||||
var dirY = rayDirection == 0 ? 1 : rayDirection == 1 ? -1 : 0;
|
||||
var hx = vx + distance * dirX;
|
||||
var hy = vy + distance * dirY;
|
||||
nearest.Distance = snapped;
|
||||
nearest.MovingX = vertexMoves ? vx : hx;
|
||||
nearest.MovingY = vertexMoves ? vy : hy;
|
||||
nearest.StationaryX = vertexMoves ? hx : vx;
|
||||
nearest.StationaryY = vertexMoves ? hy : vy;
|
||||
}
|
||||
|
||||
private static void SlideKernel(
|
||||
Index1D index,
|
||||
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> movingPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> offsets,
|
||||
ArrayView1D<double, Stride1D.Dense> results,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense> results,
|
||||
int sCount,
|
||||
int mCount,
|
||||
int direction
|
||||
@@ -337,7 +428,7 @@ namespace OpenNest.Gpu
|
||||
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> movingPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> offsets,
|
||||
ArrayView1D<double, Stride1D.Dense> results,
|
||||
ArrayView1D<ContactWitness, Stride1D.Dense> results,
|
||||
ArrayView1D<int, Stride1D.Dense> directions,
|
||||
int sCount,
|
||||
int mCount
|
||||
@@ -361,7 +452,7 @@ namespace OpenNest.Gpu
|
||||
);
|
||||
}
|
||||
|
||||
private static double ComputeSlideLean(
|
||||
private static ContactWitness ComputeSlideLean(
|
||||
ArrayView1D<double, Stride1D.Dense> sPrep,
|
||||
ArrayView1D<double, Stride1D.Dense> mPrep,
|
||||
double dx,
|
||||
@@ -371,20 +462,20 @@ namespace OpenNest.Gpu
|
||||
int direction
|
||||
)
|
||||
{
|
||||
const double eps = 0.00001;
|
||||
var minDist = double.MaxValue;
|
||||
const double eps = Tolerance.Epsilon;
|
||||
var nearest = new ContactWitness { Distance = double.MaxValue };
|
||||
var horizontal = direction >= 2;
|
||||
var oppDir = direction ^ 1;
|
||||
|
||||
// ── Forward Pass: moving vertices vs stationary edges ─────
|
||||
for (int i = 0; i < mCount; i++)
|
||||
for (var i = 0; i < mCount; i++)
|
||||
{
|
||||
var m1x = mPrep[i * 10 + 0] + dx;
|
||||
var m1y = mPrep[i * 10 + 1] + dy;
|
||||
var m2x = mPrep[i * 10 + 2] + dx;
|
||||
var m2y = mPrep[i * 10 + 3] + dy;
|
||||
|
||||
for (int j = 0; j < sCount; j++)
|
||||
for (var j = 0; j < sCount; j++)
|
||||
{
|
||||
var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6];
|
||||
var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7];
|
||||
@@ -394,8 +485,8 @@ namespace OpenNest.Gpu
|
||||
if (mv1 >= sMin - eps && mv1 <= sMax + eps)
|
||||
{
|
||||
var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, m1x, m1y,
|
||||
direction, vertexMoves: true);
|
||||
}
|
||||
|
||||
// Test moving vertex 2 against stationary edge j
|
||||
@@ -403,21 +494,21 @@ namespace OpenNest.Gpu
|
||||
if (mv2 >= sMin - eps && mv2 <= sMax + eps)
|
||||
{
|
||||
var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, m2x, m2y,
|
||||
direction, vertexMoves: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reverse Pass: stationary vertices vs moving edges ─────
|
||||
for (int i = 0; i < sCount; i++)
|
||||
for (var i = 0; i < sCount; i++)
|
||||
{
|
||||
var s1x = sPrep[i * 10 + 0];
|
||||
var s1y = sPrep[i * 10 + 1];
|
||||
var s2x = sPrep[i * 10 + 2];
|
||||
var s2y = sPrep[i * 10 + 3];
|
||||
|
||||
for (int j = 0; j < mCount; j++)
|
||||
for (var j = 0; j < mCount; j++)
|
||||
{
|
||||
var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx);
|
||||
var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx);
|
||||
@@ -427,8 +518,8 @@ namespace OpenNest.Gpu
|
||||
if (sv1 >= mMin - eps && sv1 <= mMax + eps)
|
||||
{
|
||||
var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, s1x, s1y,
|
||||
oppDir, vertexMoves: false);
|
||||
}
|
||||
|
||||
// Test stationary vertex 2 against moving edge j
|
||||
@@ -436,13 +527,13 @@ namespace OpenNest.Gpu
|
||||
if (sv2 >= mMin - eps && sv2 <= mMax + eps)
|
||||
{
|
||||
var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps);
|
||||
if (d < minDist)
|
||||
minDist = d;
|
||||
Consider(ref nearest, d, s2x, s2y,
|
||||
oppDir, vertexMoves: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return nearest;
|
||||
}
|
||||
|
||||
private static double RayEdgeLean(
|
||||
@@ -472,9 +563,7 @@ namespace OpenNest.Gpu
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
else // Vertical (Up=0, Down=1)
|
||||
{
|
||||
@@ -489,9 +578,7 @@ namespace OpenNest.Gpu
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,9 +611,7 @@ namespace OpenNest.Gpu
|
||||
var ix = p1x + t * (p2x - p1x);
|
||||
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
else // Vertical
|
||||
{
|
||||
@@ -541,9 +626,7 @@ namespace OpenNest.Gpu
|
||||
var iy = p1y + t * (p2y - p1y);
|
||||
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
|
||||
|
||||
if (dist > eps)
|
||||
return dist;
|
||||
return (dist >= -eps) ? 0.0 : double.MaxValue;
|
||||
return dist >= -eps ? dist : double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Data;
|
||||
|
||||
public class NestDefaultsTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
private readonly string _path;
|
||||
|
||||
public NestDefaultsTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
_path = Path.Combine(_testDir, "defaults.json");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
Directory.Delete(_testDir, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_ThenLoad_RoundTrips()
|
||||
{
|
||||
var original = new NestDefaults
|
||||
{
|
||||
Units = Units.Millimeters,
|
||||
Size = new Size(1220, 2440),
|
||||
Quadrant = 3,
|
||||
PartSpacing = 2.5,
|
||||
EdgeSpacing = new Spacing(1.5, 3, 1.5, 3),
|
||||
};
|
||||
|
||||
original.Save(_path);
|
||||
var loaded = NestDefaults.Load(_path, out var status);
|
||||
|
||||
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||
Assert.Equal(Units.Millimeters, loaded.Units);
|
||||
Assert.Equal(original.Size, loaded.Size);
|
||||
Assert.Equal(3, loaded.Quadrant);
|
||||
Assert.Equal(2.5, loaded.PartSpacing);
|
||||
Assert.Equal(original.EdgeSpacing.Left, loaded.EdgeSpacing.Left);
|
||||
Assert.Equal(original.EdgeSpacing.Bottom, loaded.EdgeSpacing.Bottom);
|
||||
Assert.Equal(original.EdgeSpacing.Right, loaded.EdgeSpacing.Right);
|
||||
Assert.Equal(original.EdgeSpacing.Top, loaded.EdgeSpacing.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_CreatesMissingDirectory()
|
||||
{
|
||||
var nested = Path.Combine(_testDir, "nested", "defaults.json");
|
||||
new NestDefaults().Save(nested);
|
||||
Assert.True(File.Exists(nested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFile_ReturnsFallback()
|
||||
{
|
||||
var loaded = NestDefaults.Load(_path, out var status);
|
||||
|
||||
Assert.Equal(NestDefaultsStatus.Missing, status);
|
||||
AssertFallback(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CorruptJson_ReturnsFallbackButReportsInvalid()
|
||||
{
|
||||
File.WriteAllText(_path, "{ this is not json");
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var status);
|
||||
|
||||
Assert.Equal(NestDefaultsStatus.Invalid, status);
|
||||
AssertFallback(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_PartialFile_MergesPerField()
|
||||
{
|
||||
File.WriteAllText(_path, """{ "units": "millimeters", "partSpacing": 4 }""");
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var status);
|
||||
|
||||
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||
Assert.Equal(Units.Millimeters, loaded.Units);
|
||||
Assert.Equal(4, loaded.PartSpacing);
|
||||
// Untouched fields keep fallback values.
|
||||
Assert.Equal(new Size(100, 100), loaded.Size);
|
||||
Assert.Equal(1, loaded.Quadrant);
|
||||
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_OutOfRangeValues_FallBackPerField()
|
||||
{
|
||||
File.WriteAllText(
|
||||
_path,
|
||||
"""
|
||||
{
|
||||
"units": "furlongs",
|
||||
"size": { "width": -50, "length": 100 },
|
||||
"quadrant": 9,
|
||||
"partSpacing": -1,
|
||||
"edgeSpacing": { "left": 1, "bottom": -2, "right": 1, "top": 1 }
|
||||
}
|
||||
"""
|
||||
);
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var status);
|
||||
|
||||
// The file parses; only the invalid values fall back, so no warning.
|
||||
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||
Assert.Equal(Units.Inches, loaded.Units);
|
||||
Assert.Equal(new Size(100, 100), loaded.Size);
|
||||
Assert.Equal(1, loaded.Quadrant);
|
||||
Assert.Equal(1, loaded.PartSpacing);
|
||||
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_NonFiniteValues_FallBack()
|
||||
{
|
||||
File.WriteAllText(_path, """{ "partSpacing": 1e400 }""");
|
||||
|
||||
var loaded = NestDefaults.Load(_path);
|
||||
|
||||
// 1e400 deserializes to Infinity, which is rejected.
|
||||
Assert.Equal(1, loaded.PartSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_UnknownFieldsAndFutureVersion_Ignored()
|
||||
{
|
||||
File.WriteAllText(
|
||||
_path,
|
||||
"""
|
||||
{
|
||||
"version": 2,
|
||||
"units": "inches",
|
||||
"partSpacing": 2,
|
||||
"futureFeature": { "enabled": true },
|
||||
"material": { "name": "steel" }
|
||||
}
|
||||
"""
|
||||
);
|
||||
|
||||
var loaded = NestDefaults.Load(_path, out var status);
|
||||
|
||||
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||
Assert.Equal(2, loaded.PartSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromNest_CapturesUnitsAndPlateDefaults()
|
||||
{
|
||||
var nest = new Nest
|
||||
{
|
||||
Units = Units.Millimeters,
|
||||
};
|
||||
nest.PlateDefaults.Size = new Size(60, 120);
|
||||
nest.PlateDefaults.Quadrant = 2;
|
||||
nest.PlateDefaults.PartSpacing = 0.5;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(2, 2, 2, 2);
|
||||
|
||||
var captured = NestDefaults.FromNest(nest);
|
||||
|
||||
Assert.Equal(Units.Millimeters, captured.Units);
|
||||
Assert.Equal(new Size(60, 120), captured.Size);
|
||||
Assert.Equal(2, captured.Quadrant);
|
||||
Assert.Equal(0.5, captured.PartSpacing);
|
||||
Assert.Equal(new Spacing(2, 2, 2, 2), captured.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyTo_SetsUnitsAndPlateDefaults_AndDoesNotAliasSourceNest()
|
||||
{
|
||||
var source = new Nest();
|
||||
source.PlateDefaults.Size = new Size(48, 96);
|
||||
source.PlateDefaults.EdgeSpacing = new Spacing(1.25, 1.25, 1.25, 1.25);
|
||||
var defaults = NestDefaults.FromNest(source);
|
||||
|
||||
// Mutating the source nest afterwards must not change the capture.
|
||||
source.PlateDefaults.Size = new Size(1, 1);
|
||||
source.PlateDefaults.EdgeSpacing = new Spacing(9, 9, 9, 9);
|
||||
|
||||
var target = new Nest();
|
||||
defaults.ApplyTo(target);
|
||||
|
||||
Assert.Equal(new Size(48, 96), target.PlateDefaults.Size);
|
||||
Assert.Equal(
|
||||
new Spacing(1.25, 1.25, 1.25, 1.25),
|
||||
target.PlateDefaults.EdgeSpacing
|
||||
);
|
||||
|
||||
// And the applied target owns its own values too.
|
||||
target.PlateDefaults.Size = new Size(2, 2);
|
||||
Assert.Equal(new Size(48, 96), defaults.Size);
|
||||
}
|
||||
|
||||
private static void AssertFallback(NestDefaults loaded)
|
||||
{
|
||||
Assert.Equal(Units.Inches, loaded.Units);
|
||||
Assert.Equal(new Size(100, 100), loaded.Size);
|
||||
Assert.Equal(1, loaded.Quadrant);
|
||||
Assert.Equal(1, loaded.PartSpacing);
|
||||
Assert.Equal(new Spacing(1, 1, 1, 1), loaded.EdgeSpacing);
|
||||
}
|
||||
}
|
||||
@@ -298,22 +298,111 @@ namespace OpenNest.Tests.Fill
|
||||
Assert.Equal(32, moving.BoundingBox.Left, 7);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PushDirection.Right)]
|
||||
[InlineData(PushDirection.Up)]
|
||||
[InlineData(PushDirection.Down)]
|
||||
public void Push_WithSpacing_ContactFromPreviousPushDoesNotBlockOtherDirections(
|
||||
PushDirection next
|
||||
)
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var obstacle = MakeRectPart(20, 40, 10, 10);
|
||||
var moving = MakeRectPart(60, 40, 10, 10);
|
||||
var parts = new List<Part> { moving };
|
||||
var obstacles = new List<Part> { obstacle };
|
||||
|
||||
Assert.True(Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Left) > 0);
|
||||
Assert.Equal(32, moving.BoundingBox.Left, 7);
|
||||
|
||||
var before = moving.Location;
|
||||
var distance = Compactor.Push(parts, obstacles, workArea, 2, next);
|
||||
|
||||
Assert.True(distance > 1, $"Push {next} after contact moved only {distance:R}");
|
||||
Assert.NotEqual(before, moving.Location);
|
||||
AssertClearance(moving, obstacle, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_WithSpacing_ContactStillBlocksTheSameDirection()
|
||||
{
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var obstacle = MakeRectPart(20, 40, 10, 10);
|
||||
var moving = MakeRectPart(60, 40, 10, 10);
|
||||
var parts = new List<Part> { moving };
|
||||
var obstacles = new List<Part> { obstacle };
|
||||
|
||||
Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Left);
|
||||
var distance = Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Left);
|
||||
|
||||
Assert.Equal(0, distance);
|
||||
Assert.Equal(32, moving.BoundingBox.Left, 7);
|
||||
AssertClearance(moving, obstacle, 2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2)]
|
||||
public void Push_SlidingAlongWall_StopsAtItsLaterHook(double spacing)
|
||||
{
|
||||
var hook = MakeTrianglePart(new Vector(10, 10), new Vector(20, 10),
|
||||
new Vector(20, 50), new Vector(50, 50), new Vector(50, 60), new Vector(10, 60));
|
||||
var moving = MakeRectPart(20 + spacing, 20, 5, 5);
|
||||
var distance = Compactor.Push(new List<Part> { moving }, new List<Part> { hook },
|
||||
new Box(0, 0, 100, 100), spacing, PushDirection.Up);
|
||||
Assert.Equal(25 - spacing, distance, 7);
|
||||
Assert.False(moving.Intersects(hook, out _));
|
||||
if (spacing > 0)
|
||||
AssertClearance(moving, hook, spacing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(2, true)]
|
||||
public void Push_InsideStationaryHole_CannotPassThroughItsWall(double spacing, bool plateEntry)
|
||||
{
|
||||
var program = MakeRectDrawing(60, 60).Program;
|
||||
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(10, 10)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(50, 10)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(50, 50)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 50)));
|
||||
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 10)));
|
||||
var frame = new Part(new Drawing("frame", program));
|
||||
var moving = MakeRectPart(10 + spacing, 20, 5, 5);
|
||||
var plate = new Plate(100, 100) { PartSpacing = spacing };
|
||||
plate.Parts.Add(frame);
|
||||
plate.Parts.Add(moving);
|
||||
var distance = plateEntry
|
||||
? Compactor.Push(new List<Part> { moving }, plate, PushDirection.Right)
|
||||
: Compactor.Push(new List<Part> { moving }, new List<Part> { frame },
|
||||
new Box(0, 0, 100, 100), spacing, PushDirection.Right);
|
||||
Assert.Equal(35 - 2 * spacing, distance, 7);
|
||||
// Independent rectangular-hole oracle; Part.Intersects ignores cutouts.
|
||||
Assert.Equal(50 - spacing, moving.BoundingBox.Right, 7);
|
||||
Assert.InRange(moving.BoundingBox.Bottom, 10 + spacing, 50 - spacing);
|
||||
Assert.InRange(moving.BoundingBox.Top, 10 + spacing, 50 - spacing);
|
||||
if (spacing > 0)
|
||||
AssertClearance(moving, frame, spacing);
|
||||
}
|
||||
|
||||
private static void AssertClearance(Part moving, Part obstacle, double spacing)
|
||||
{
|
||||
var clearance = double.MaxValue;
|
||||
foreach (var a in PartGeometry.GetPartLines(moving))
|
||||
foreach (var b in PartGeometry.GetPartLines(obstacle))
|
||||
{
|
||||
Assert.False(Intersect.Intersects(a, b, out _));
|
||||
clearance = System.Math.Min(
|
||||
clearance,
|
||||
a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint))
|
||||
);
|
||||
clearance = System.Math.Min(
|
||||
clearance,
|
||||
b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint))
|
||||
);
|
||||
}
|
||||
foreach (var b in PartGeometry.GetPartLines(obstacle))
|
||||
{
|
||||
Assert.False(Intersect.Intersects(a, b, out _));
|
||||
clearance = System.Math.Min(
|
||||
clearance,
|
||||
a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint))
|
||||
);
|
||||
clearance = System.Math.Min(
|
||||
clearance,
|
||||
b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint))
|
||||
);
|
||||
}
|
||||
Assert.True(
|
||||
clearance >= spacing - 1e-7,
|
||||
$"Clearance {clearance:R} is less than spacing {spacing:R}"
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class ClearanceTests
|
||||
{
|
||||
private const double Tol = 1e-9;
|
||||
|
||||
private static Polygon Square(double x, double y, double w, double h)
|
||||
{
|
||||
var p = new Polygon
|
||||
{
|
||||
Vertices = new List<Vector>
|
||||
{
|
||||
new Vector(x, y),
|
||||
new Vector(x + w, y),
|
||||
new Vector(x + w, y + h),
|
||||
new Vector(x, y + h),
|
||||
}
|
||||
};
|
||||
p.Close();
|
||||
p.UpdateBounds();
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Polygon Triangle(params double[] xy)
|
||||
{
|
||||
var p = new Polygon();
|
||||
for (var i = 0; i + 1 < xy.Length; i += 2)
|
||||
p.Vertices.Add(new Vector(xy[i], xy[i + 1]));
|
||||
p.Close();
|
||||
p.UpdateBounds();
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- Separation ----
|
||||
|
||||
[Fact]
|
||||
public void Between_SeparatedHorizontally_DistanceAndDirection()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(3, 0, 1, 1);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(2.0, r.Distance, 6);
|
||||
// Pushing a away from b means moving left.
|
||||
Assert.Equal(-1.0, r.Direction.X, 6);
|
||||
Assert.Equal(0.0, r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_SeparatedDiagonally_CornerDistance()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(2, 2, 1, 1);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(System.Math.Sqrt(2.0), r.Distance, 6);
|
||||
Assert.Equal(-1 / System.Math.Sqrt(2), r.Direction.X, 6);
|
||||
Assert.Equal(-1 / System.Math.Sqrt(2), r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_Touching_ZeroDistance()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(1, 0, 2, 1);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.True(System.Math.Abs(r.Distance) < 1e-6, $"expected ~0, got {r.Distance}");
|
||||
var mag = System.Math.Sqrt(
|
||||
r.Direction.X * r.Direction.X + r.Direction.Y * r.Direction.Y
|
||||
);
|
||||
Assert.Equal(1.0, mag, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_VertexToEdge_DistanceIsPerpendicular()
|
||||
{
|
||||
// Triangle above a wide square; the base sits 3 above the square's top edge.
|
||||
var a = Triangle(1, 3, 3, 3, 2, 4);
|
||||
var b = Square(0, -4, 10, 4); // top edge at y = 0
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(3.0, r.Distance, 6); // base y=3 to y=0
|
||||
Assert.Equal(0.0, r.Direction.X, 6);
|
||||
Assert.Equal(1.0, r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_ParallelStaggeredEdges_MinimumAcrossAllPairs()
|
||||
{
|
||||
// Two L-ish shapes (as simple polys) offset so the true minimum is
|
||||
// between mid-edges, not vertices.
|
||||
var a = Square(0, 0, 4, 1);
|
||||
var b = Square(1, 2, 1, 3);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(1.0, r.Distance, 6);
|
||||
Assert.Equal(-1.0, r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
// ---- Penetration ----
|
||||
|
||||
[Fact]
|
||||
public void Between_OverlappingSquares_MinimumTranslationAxis()
|
||||
{
|
||||
// Overlap 0.5 in X, 1.0 in Y -> cheapest exit is X.
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(0.5, 0, 1.5, 1);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(-0.5, r.Distance, 6);
|
||||
Assert.Equal(-1.0, r.Direction.X, 6); // push a left, out of b
|
||||
Assert.Equal(0.0, r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_OverlappingVerticallyCheaper_ExitsInY()
|
||||
{
|
||||
// Overlap 0.8 in X, 0.2 in Y -> cheapest exit is Y.
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(0.2, 0.8, 1.2, 1.8);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(-0.2, r.Distance, 6);
|
||||
Assert.Equal(0.0, r.Direction.X, 6);
|
||||
Assert.Equal(-1.0, r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_ContainedSquare_ExitsThroughNearestWall()
|
||||
{
|
||||
// Inner square near the left wall: the translation that ENDS the overlap
|
||||
// carries its right edge (x=1.2) past the outer's left edge (x=0).
|
||||
var outer = Square(0, 0, 10, 10);
|
||||
var inner = Square(0.2, 4, 1, 1);
|
||||
|
||||
var r = Clearance.Between(inner, outer);
|
||||
|
||||
Assert.Equal(-1.2, r.Distance, 6);
|
||||
Assert.Equal(-1.0, r.Direction.X, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_ConcentricSquares_DepthIsExitTranslation()
|
||||
{
|
||||
var outer = Square(0, 0, 10, 10);
|
||||
var inner = Square(2, 2, 4, 4); // spans [2,6]; leftmost exit carries 6 to 0
|
||||
|
||||
var r = Clearance.Between(inner, outer);
|
||||
|
||||
Assert.Equal(-6.0, r.Distance, 6);
|
||||
Assert.Equal(0.0, r.Direction.X, 6);
|
||||
Assert.Equal(-1.0, r.Direction.Y, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_TrianglesPenetrating_ReportsNegativeDepth()
|
||||
{
|
||||
var a = Triangle(0, 0, 4, 0, 2, 3);
|
||||
var b = Triangle(1, 0, 5, 0, 3, 3);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
Assert.True(r.Distance < 0, $"expected penetration, got {r.Distance}");
|
||||
}
|
||||
|
||||
// ---- Direction is actionable: moving a by -Distance * dir clears contact ----
|
||||
|
||||
[Fact]
|
||||
public void Between_PenetrationApplyingDirection_EndsContact()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(0.3, 0, 1.6, 2);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
|
||||
var moved = (Polygon)a.Clone();
|
||||
moved.Offset(r.Direction * (-r.Distance + 0.001));
|
||||
moved.UpdateBounds();
|
||||
|
||||
Assert.False(Collision.HasOverlap(moved, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_SeparationApplyingDirection_NeverReducesDistance()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(4, 1, 2, 2);
|
||||
|
||||
var r = Clearance.Between(a, b);
|
||||
Assert.True(r.Distance > 0);
|
||||
|
||||
// A tiny step along the reported direction must not move closer.
|
||||
var moved = (Polygon)a.Clone();
|
||||
moved.Offset(r.Direction * (r.Distance / 2));
|
||||
moved.UpdateBounds();
|
||||
|
||||
var r2 = Clearance.Between(moved, b);
|
||||
Assert.True(
|
||||
r2.Distance >= r.Distance - Tol,
|
||||
$"moving along dir reduced clearance {r.Distance} -> {r2.Distance}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Determinism ----
|
||||
|
||||
[Fact]
|
||||
public void Between_RepeatedCalls_IdenticalResult()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(0.5, 0.25, 2, 1.5);
|
||||
|
||||
var r1 = Clearance.Between(a, b);
|
||||
var r2 = Clearance.Between(a, b);
|
||||
|
||||
Assert.Equal(r1.Distance, r2.Distance);
|
||||
Assert.Equal(r1.Direction.X, r2.Direction.X);
|
||||
Assert.Equal(r1.Direction.Y, r2.Direction.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Between_SymmetricSwap_MirrorsDirection()
|
||||
{
|
||||
var a = Square(0, 0, 1, 1);
|
||||
var b = Square(0.5, 0, 1.5, 1);
|
||||
|
||||
var ab = Clearance.Between(a, b);
|
||||
var ba = Clearance.Between(b, a);
|
||||
|
||||
Assert.Equal(ab.Distance, ba.Distance, 6);
|
||||
Assert.Equal(-ab.Direction.X, ba.Direction.X, 6);
|
||||
Assert.Equal(-ab.Direction.Y, ba.Direction.Y, 6);
|
||||
}
|
||||
|
||||
// ---- Agreement with the Collision oracle ----
|
||||
|
||||
[Fact]
|
||||
public void Between_SignMatchesCollisionVerdict()
|
||||
{
|
||||
var polygons = new List<Polygon>
|
||||
{
|
||||
Square(0, 0, 1, 1),
|
||||
Square(1, 0, 2, 1),
|
||||
Square(0.5, 0, 1.5, 1),
|
||||
Square(0.25, 0.25, 0.75, 0.75),
|
||||
Square(5, 5, 6, 6),
|
||||
Triangle(0, 0, 2, 0, 1, 2),
|
||||
Triangle(0.5, -1, 2.5, -1, 1.5, 1),
|
||||
};
|
||||
|
||||
for (var i = 0; i < polygons.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < polygons.Count; j++)
|
||||
{
|
||||
var overlaps = Collision.HasOverlap(polygons[i], polygons[j]);
|
||||
var r = Clearance.Between(polygons[i], polygons[j]);
|
||||
|
||||
if (overlaps)
|
||||
{
|
||||
Assert.True(
|
||||
r.Distance <= Tol,
|
||||
$"pair {i},{j}: Collision overlaps but clearance {r.Distance}"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(
|
||||
r.Distance >= -Tol,
|
||||
$"pair {i},{j}: Collision clear but clearance {r.Distance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class SlideContactTests
|
||||
{
|
||||
public static IEnumerable<object[]> LinePaths()
|
||||
{
|
||||
foreach (var path in new[] { "axis", "offset", "edges", "vector", "entities", "cpu-lines", "cpu-entities" })
|
||||
foreach (var reverse in new[] { false, true })
|
||||
yield return new object[] { path, reverse };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LinePaths))]
|
||||
public void TouchingRectangles_LeaveOrSlideButCannotEnter(string path, bool reverse)
|
||||
{
|
||||
var stationary = Rect(0, 0, 2, 2);
|
||||
var moving = Rect(2, 0, 2, 2);
|
||||
if (reverse)
|
||||
{
|
||||
Reverse(stationary);
|
||||
Reverse(moving);
|
||||
}
|
||||
Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left));
|
||||
Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Right));
|
||||
Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Up));
|
||||
Assert.Equal(double.MaxValue, Distance(path, moving, stationary, PushDirection.Down));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LinePaths))]
|
||||
public void SlidingContact_StillStopsAtLaterHookOnSameObstacle(string path, bool reverse)
|
||||
{
|
||||
var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6));
|
||||
var moving = Rect(2, 0, 1, 1);
|
||||
if (reverse)
|
||||
{
|
||||
Reverse(stationary);
|
||||
Reverse(moving);
|
||||
}
|
||||
Assert.Equal(3, Distance(path, moving, stationary, PushDirection.Up), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LinePaths))]
|
||||
public void HoleContact_LeavingWallStillStopsAtOppositeWall(string path, bool reverse)
|
||||
{
|
||||
var stationary = Rect(0, 0, 10, 10);
|
||||
stationary.AddRange(Rect(2, 2, 6, 6)); // depth, not winding, defines the hole
|
||||
var moving = Rect(2, 3, 1, 1);
|
||||
if (reverse)
|
||||
{
|
||||
Reverse(stationary);
|
||||
Reverse(moving);
|
||||
}
|
||||
Assert.Equal(0, Distance(path, moving, stationary, PushDirection.Left));
|
||||
Assert.Equal(5, Distance(path, moving, stationary, PushDirection.Right), 9);
|
||||
Assert.Equal(4, Distance(path, moving, stationary, PushDirection.Up), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 0)]
|
||||
[InlineData(false, 0.37)]
|
||||
[InlineData(true, 0.37)]
|
||||
public void RotatedHook_StopsAtFirstBlockingContact(bool cpu, double angle)
|
||||
{
|
||||
var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6)).Cast<Entity>().ToList();
|
||||
var moving = Rect(2, 0, 1, 1).Cast<Entity>().ToList();
|
||||
foreach (var entity in stationary.Concat(moving))
|
||||
{
|
||||
entity.Rotate(angle);
|
||||
entity.Offset(17, -23);
|
||||
}
|
||||
Assert.Equal(3, EntityDistance(cpu, moving, stationary, new Vector(0, 1).Rotate(angle)), 8);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Circles_TangentEscapeAndEnteringContact(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Circle(2, 0, 1) };
|
||||
var stationary = new List<Entity> { new Circle(0, 0, 1) };
|
||||
Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)));
|
||||
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0)));
|
||||
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(0, 1)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CircleInsideHole_TangentBlocksButDepartureFindsFarSide(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Circle(3, 0, 1) };
|
||||
var stationary = new List<Entity> { new Circle(0, 0, 6), new Circle(0, 0, 4) };
|
||||
Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(1, 0)));
|
||||
Assert.Equal(0, EntityDistance(cpu, moving, stationary, new Vector(0, 1)));
|
||||
Assert.Equal(6, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void PositiveGrazingContact_DoesNotHideLaterCircle(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Circle(0, 0, 1) };
|
||||
var stationary = new List<Entity> { new Circle(4, 2, 1), new Circle(10, 0, 1) };
|
||||
Assert.Equal(8, EntityDistance(cpu, moving, stationary, new Vector(1, 0)), 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReusedEdgeArrays_KeepTopologyAfterPreviousQuerySortedThem()
|
||||
{
|
||||
var moving = Rect(2, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray();
|
||||
var stationary = Rect(0, 0, 2, 2).Select(l => (l.StartPoint, l.EndPoint)).ToArray();
|
||||
Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Left));
|
||||
Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Right));
|
||||
Assert.Equal(double.MaxValue, SpatialQuery.DirectionalDistance(moving, Vector.Zero, stationary, Vector.Zero, PushDirection.Up));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void FullCircleArcSeam_AllowsSeparation(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Arc(0, 0, 1, 0, 2 * System.Math.PI) };
|
||||
var stationary = new List<Entity> { new Circle(2, 0, 1) };
|
||||
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ConcaveCorner_DoesNotBlockSlidingAlongItsStraightSide(bool cpu)
|
||||
{
|
||||
var moving = Rect(3, -1, 2, 1).Cast<Entity>().ToList();
|
||||
var stationary = new List<Entity>
|
||||
{
|
||||
new Arc(0, 0, 5, System.Math.PI / 2, 0, true),
|
||||
new Line(5, 0, 10, 0),
|
||||
new Arc(0, 0, 10, 0, System.Math.PI / 2),
|
||||
new Line(0, 10, 0, 5),
|
||||
};
|
||||
Assert.Equal(double.MaxValue, EntityDistance(cpu, moving, stationary, new Vector(1, 0)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ThinRing_ArcSeamDoesNotChangeHoleClassification(bool cpu)
|
||||
{
|
||||
var a = System.Math.PI / 72;
|
||||
var moving = new List<Entity> { new Circle(8.995, 0, 1) };
|
||||
var stationary = new List<Entity>
|
||||
{
|
||||
new Circle(0, 0, 10),
|
||||
new Arc(0, 0, 9.995, a, a + System.Math.PI),
|
||||
new Arc(0, 0, 9.995, a + System.Math.PI, a + 2 * System.Math.PI),
|
||||
};
|
||||
Assert.Equal(17.99, EntityDistance(cpu, moving, stationary, new Vector(-1, 0)), 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CircleAgainstInclinedWall_UsesInteriorCurveContact(bool cpu)
|
||||
{
|
||||
var moving = new List<Entity> { new Circle(0, 0, 1) };
|
||||
var stationary = Loop((-10, 0), (10, 10), (10, 12), (-10, 2)).Cast<Entity>().ToList();
|
||||
Assert.Equal(5 - System.Math.Sqrt(1.25), EntityDistance(cpu, moving, stationary, new Vector(0, 1)), 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GpuAdapter_ArbitraryDirectionUsesExactCpuFallback()
|
||||
{
|
||||
using var axesOnly = new RejectSlideComputer();
|
||||
var distance = new GpuDistanceComputer(axesOnly).ComputeDistances(Rect(10, 10, 2, 2), Rect(0, 0, 2, 2),
|
||||
new[] { new SlideOffset(0, 0, 0.6, 0.8) })[0];
|
||||
Assert.Equal(8 / 0.6, distance, 9);
|
||||
}
|
||||
|
||||
private sealed class RejectSlideComputer : ISlideComputer
|
||||
{
|
||||
public double[] ComputeBatch(double[] s, int sc, double[] m, int mc, double[] o, int oc, PushDirection d) =>
|
||||
throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU");
|
||||
public double[] ComputeBatchMultiDir(double[] s, int sc, double[] m, int mc, double[] o, int oc, int[] d) =>
|
||||
throw new InvalidOperationException("Non-cardinal direction reached axis-only GPU");
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenBoundaries_RemainConservative()
|
||||
{
|
||||
var moving = new List<Line> { new Line(2, 0, 2, 2) };
|
||||
var stationary = new List<Line> { new Line(2, 0, 2, 2) };
|
||||
Assert.Equal(0, SpatialQuery.DirectionalDistance(moving, stationary, PushDirection.Right));
|
||||
}
|
||||
|
||||
private static double EntityDistance(bool cpu, List<Entity> moving, List<Entity> stationary, Vector direction) =>
|
||||
cpu ? new CpuDistanceComputer().ComputeDistances(stationary, moving,
|
||||
new[] { new SlideOffset(0, 0, direction.X, direction.Y) })[0]
|
||||
: SpatialQuery.DirectionalDistance(moving, stationary, direction);
|
||||
|
||||
private static double Distance(string path, List<Line> moving, List<Line> stationary, PushDirection direction)
|
||||
{
|
||||
var unit = SpatialQuery.DirectionToOffset(direction, 1);
|
||||
// A nonzero template origin catches mixed local/world contact coordinates.
|
||||
var origin = new Vector(13, -7);
|
||||
var local = moving.Select(l => new Line(l.StartPoint - origin, l.EndPoint - origin)).ToList();
|
||||
return path switch
|
||||
{
|
||||
"axis" => SpatialQuery.DirectionalDistance(moving, stationary, direction),
|
||||
"offset" => SpatialQuery.DirectionalDistance(local, origin.X, origin.Y, stationary, direction),
|
||||
"edges" => SpatialQuery.DirectionalDistance(local.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), origin,
|
||||
stationary.Select(l => (l.StartPoint, l.EndPoint)).ToArray(), Vector.Zero, direction),
|
||||
"vector" => SpatialQuery.DirectionalDistance(moving, stationary, unit),
|
||||
"entities" => SpatialQuery.DirectionalDistance(moving.Cast<Entity>().ToList(), stationary.Cast<Entity>().ToList(), unit),
|
||||
"cpu-lines" => new CpuDistanceComputer().ComputeDistances(stationary, local,
|
||||
new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0],
|
||||
"cpu-entities" => new CpuDistanceComputer().ComputeDistances(stationary.Cast<Entity>().ToList(), local.Cast<Entity>().ToList(),
|
||||
new[] { new SlideOffset(origin.X, origin.Y, unit.X, unit.Y) })[0],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(path)),
|
||||
};
|
||||
}
|
||||
|
||||
private static void Reverse(List<Line> lines)
|
||||
{
|
||||
lines.Reverse();
|
||||
foreach (var line in lines)
|
||||
line.Reverse();
|
||||
}
|
||||
|
||||
private static List<Line> Rect(double x, double y, double w, double h) =>
|
||||
Loop((x, y), (x + w, y), (x + w, y + h), (x, y + h));
|
||||
|
||||
private static List<Line> Loop(params (double X, double Y)[] points) =>
|
||||
points.Select((p, i) => new Line(p.X, p.Y, points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList();
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
using System.Reflection;
|
||||
using ILGPU.Runtime;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Gpu;
|
||||
using OpenNest.Math;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.WinForms.Tests;
|
||||
|
||||
// The real ILGPU kernels run on its deterministic CPU accelerator, not a mock or
|
||||
// CpuDistanceComputer. This also runs without GPU hardware on Windows CI.
|
||||
public sealed class GpuSlideContactFixture : IDisposable
|
||||
{
|
||||
public GpuSlideComputer Computer { get; } = new GpuSlideComputer(preferCPU: true);
|
||||
|
||||
public void Dispose() => Computer.Dispose();
|
||||
}
|
||||
|
||||
public class GpuSlideContactTests : IClassFixture<GpuSlideContactFixture>
|
||||
{
|
||||
private readonly GpuSlideComputer computer;
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public GpuSlideContactTests(GpuSlideContactFixture fixture, ITestOutputHelper output)
|
||||
{
|
||||
computer = fixture.Computer;
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> Paths()
|
||||
{
|
||||
foreach (var multiDir in new[] { false, true })
|
||||
foreach (var reverse in new[] { false, true })
|
||||
for (var turns = 0; turns < 4; turns++)
|
||||
yield return new object[] { multiDir, reverse, turns };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kernels_ExecuteOnCpuAccelerator()
|
||||
{
|
||||
var accelerator = Assert.IsAssignableFrom<Accelerator>(typeof(GpuSlideComputer)
|
||||
.GetField("_accelerator", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(computer));
|
||||
output.WriteLine($"ILGPU backend: {accelerator.AcceleratorType}; device: {accelerator.Name}");
|
||||
Assert.Equal(AcceleratorType.CPU, accelerator.AcceleratorType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void TouchingRectangles_EnterBlocksButDepartureAndTangentsDoNot(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
var stationary = Rect(0, 0, 2, 2);
|
||||
var moving = Rect(2, 0, 2, 2);
|
||||
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Left, 0);
|
||||
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Right, double.MaxValue);
|
||||
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Up, double.MaxValue);
|
||||
AssertSlide(multiDir, reverse, turns, stationary, moving, PushDirection.Down, double.MaxValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void SlidingContact_StopsAtLaterHookOnSameObstacle(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
var stationary = Loop((0, 0), (2, 0), (2, 4), (5, 4), (5, 6), (0, 6));
|
||||
AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 0, 1, 1), PushDirection.Up, 3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void PositiveGrazingContact_StopsAtLaterFeatureOnSameObstacle(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
var stationary = Loop((4, 1), (10, 1), (10, -2), (12, -2), (12, 3), (4, 3));
|
||||
AssertSlide(multiDir, reverse, turns, stationary, Rect(0, 0, 1, 1), PushDirection.Right, 9);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void PositiveGrazingContact_WithoutLaterBlockerIsUnbounded(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
AssertSlide(multiDir, reverse, turns, Rect(4, 1, 2, 2), Rect(0, 0, 1, 1),
|
||||
PushDirection.Right, double.MaxValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void HoleContact_LeavingWallStillStopsAtOppositeWall(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
var stationary = Rect(0, 0, 10, 10);
|
||||
stationary.AddRange(Rect(2, 2, 6, 6)); // Hole depth must not depend on winding.
|
||||
AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 3, 1, 1), PushDirection.Right, 5);
|
||||
AssertSlide(multiDir, reverse, turns, stationary, Rect(2, 3, 1, 1), PushDirection.Up, 4);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void ReverseRayWitness_StationaryVertexHitsMiddleOfMovingEdge(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
// No moving vertex can hit the shorter stationary rectangle.
|
||||
AssertSlide(multiDir, reverse, turns, Rect(5, 2, 1, 1), Rect(0, 0, 1, 6),
|
||||
PushDirection.Right, 4);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void SnappedZeroDistance_KeepsUnsnappedWitnessOnBothBoundaries(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
var gap = Tolerance.Epsilon / 2;
|
||||
AssertSlide(multiDir, reverse, turns, Rect(1 + gap, 0, 1, 1), Rect(0, 0, 1, 1),
|
||||
PushDirection.Right, 0);
|
||||
AssertSlide(multiDir, reverse, turns, Rect(1 + gap, 2, 1, 1), Rect(0, 0, 1, 6),
|
||||
PushDirection.Right, 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Paths))]
|
||||
public void ToleranceNearMiss_DoesNotBecomeABlockingWitness(
|
||||
bool multiDir, bool reverse, int turns)
|
||||
{
|
||||
AssertSlide(multiDir, reverse, turns, Rect(4, 0, 1, 1),
|
||||
Rect(0, 1 + Tolerance.Epsilon / 2, 1, 1), PushDirection.Right, double.MaxValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void NearParallelEdges_UseTheSharedAxisRayTolerance(bool multiDir)
|
||||
{
|
||||
var dy = Tolerance.Epsilon / 2;
|
||||
AssertSlide(multiDir, false, 0,
|
||||
new List<Line> { new Line(2, 0, 3, dy) },
|
||||
new List<Line> { new Line(0, 0, 1, dy) }, PushDirection.Right, double.MaxValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void TiedGrazingAndBlockingContacts_DoNotDropBlockingContact(bool multiDir)
|
||||
{
|
||||
var stationary = Rect(4, 1, 1, 1);
|
||||
stationary.AddRange(Rect(4, -2, 1, 2.5));
|
||||
var moving = Loop((1, 1), (0, 1), (0, 0), (1, 0));
|
||||
AssertSlide(multiDir, false, 0, stationary, moving, PushDirection.Right, 3);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void OpenBoundaries_RemainConservative(bool multiDir)
|
||||
{
|
||||
AssertSlide(multiDir, false, 0,
|
||||
new List<Line> { new Line(2, 0, 2, 2) },
|
||||
new List<Line> { new Line(2, 0, 2, 2) }, PushDirection.Right, 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void EmptyGeometryAndOffsets_ReturnNoHitWithoutStaleResults(bool multiDir)
|
||||
{
|
||||
var rectangle = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1));
|
||||
var offsets = new[] { 0.0, 0.0, 2.0, 0.0 };
|
||||
var directions = new[] { (int)PushDirection.Left, (int)PushDirection.Left };
|
||||
Assert.Equal(new[] { 0.0, 1.0 },
|
||||
Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 2, directions, PushDirection.Left));
|
||||
Assert.Empty(Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 0, directions));
|
||||
Assert.All(Compute(multiDir, Array.Empty<double>(), 0, rectangle, 4, offsets, 2, directions),
|
||||
distance => Assert.Equal(double.MaxValue, distance));
|
||||
Assert.All(Compute(multiDir, rectangle, 4, Array.Empty<double>(), 0, offsets, 2, directions),
|
||||
distance => Assert.Equal(double.MaxValue, distance));
|
||||
Assert.Equal(new[] { 0.0, 1.0 },
|
||||
Compute(multiDir, rectangle, 4, rectangle, 4, offsets, 2, directions, PushDirection.Left));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ReusedOffsetBuffers_GrowShrinkAndRespectActivePrefixes(bool multiDir)
|
||||
{
|
||||
var stationary = Rect(0, 0, 2, 2);
|
||||
var moving = Rect(0, 0, 1, 1);
|
||||
var stationaryData = SpatialQuery.FlattenLines(stationary);
|
||||
var movingData = SpatialQuery.FlattenLines(moving);
|
||||
// Odd lengths exercise rounded-up thread groups and retained excess capacity.
|
||||
foreach (var count in new[] { 1, 37, 3, 65, 2, 97, 0, 5, 129, 1 })
|
||||
{
|
||||
var offsets = new double[(count + 7) * 2];
|
||||
var directions = new int[count + 7];
|
||||
var expected = new double[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var dx = 2 + i % 4;
|
||||
var dy = i % 3;
|
||||
var direction = multiDir ? (PushDirection)(i % 4) : PushDirection.Left;
|
||||
offsets[i * 2] = dx;
|
||||
offsets[i * 2 + 1] = dy;
|
||||
directions[i] = (int)direction;
|
||||
expected[i] = SpatialQuery.DirectionalDistance(moving, dx, dy, stationary, direction);
|
||||
}
|
||||
var actual = Compute(multiDir, stationaryData, 4, movingData, 4, offsets, count,
|
||||
directions, PushDirection.Left);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ReusedSegmentArrays_ChangingCountsAndCoordinatesRefreshesBothCaches(bool multiDir)
|
||||
{
|
||||
var stationary = SpatialQuery.FlattenLines(Rect(4, 0, 1, 1).Concat(Rect(2, 0, 1, 1)).ToList());
|
||||
var moving = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1).Concat(Rect(2, 0, 1, 1)).ToList());
|
||||
var offsets = new[] { 0.0, 0.0 };
|
||||
var directions = new[] { (int)PushDirection.Right };
|
||||
foreach (var counts in new[] { (4, 4, 3.0), (8, 4, 1.0), (4, 8, 1.0), (4, 4, 3.0) })
|
||||
Assert.Equal(counts.Item3,
|
||||
Compute(multiDir, stationary, counts.Item1, moving, counts.Item2, offsets, 1, directions)[0]);
|
||||
|
||||
for (var i = 0; i < stationary.Length; i += 2)
|
||||
stationary[i] += 1;
|
||||
Assert.Equal(4, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]);
|
||||
for (var i = 0; i < moving.Length; i += 2)
|
||||
moving[i] -= 1;
|
||||
Assert.Equal(5, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]);
|
||||
computer.InvalidateStationary();
|
||||
computer.InvalidateMoving();
|
||||
Assert.Equal(5, Compute(multiDir, stationary, 4, moving, 4, offsets, 1, directions)[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiDir_UsesEachOffsetAndDirectionIndependently()
|
||||
{
|
||||
var stationary = SpatialQuery.FlattenLines(Rect(0, 0, 2, 2));
|
||||
var moving = SpatialQuery.FlattenLines(Rect(0, 0, 1, 1));
|
||||
var offsets = new[] { 2.0, 0.0, 2.0, 0.0, 2.0, 0.0, 2.0, 0.0, -3.0, 0.0, 0.0, -4.0 };
|
||||
var directions = new[] { PushDirection.Left, PushDirection.Right, PushDirection.Up,
|
||||
PushDirection.Down, PushDirection.Right, PushDirection.Up }.Select(d => (int)d).ToArray();
|
||||
Assert.Equal(new[] { 0.0, double.MaxValue, double.MaxValue, double.MaxValue, 2.0, 3.0 },
|
||||
computer.ComputeBatchMultiDir(stationary, 4, moving, 4, offsets, 6, directions));
|
||||
}
|
||||
|
||||
private void AssertSlide(bool multiDir, bool reverse, int turns, List<Line> stationary,
|
||||
List<Line> moving, PushDirection direction, double expected)
|
||||
{
|
||||
// Exact quarter turns cover all axis signs without trigonometric rounding.
|
||||
// Offset the world and template independently to expose mixed-frame witnesses.
|
||||
var origin = new Vector(13, -7);
|
||||
stationary = Transform(stationary, turns, new Vector(17, -23), reverse);
|
||||
moving = Transform(moving, turns, new Vector(17, -23) - origin, reverse);
|
||||
for (var turn = 0; turn < turns; turn++)
|
||||
direction = direction switch
|
||||
{
|
||||
PushDirection.Right => PushDirection.Up,
|
||||
PushDirection.Up => PushDirection.Left,
|
||||
PushDirection.Left => PushDirection.Down,
|
||||
_ => PushDirection.Right,
|
||||
};
|
||||
|
||||
Assert.Equal(expected,
|
||||
SpatialQuery.DirectionalDistance(moving, origin.X, origin.Y, stationary, direction), 9);
|
||||
var actual = Compute(multiDir, SpatialQuery.FlattenLines(stationary), stationary.Count,
|
||||
SpatialQuery.FlattenLines(moving), moving.Count, new[] { origin.X, origin.Y }, 1,
|
||||
new[] { (int)direction }, direction);
|
||||
Assert.Single(actual);
|
||||
Assert.Equal(expected, actual[0], 9);
|
||||
}
|
||||
|
||||
private double[] Compute(bool multiDir, double[] stationary, int stationaryCount,
|
||||
double[] moving, int movingCount, double[] offsets, int count, int[] directions,
|
||||
PushDirection direction = PushDirection.Right) =>
|
||||
multiDir
|
||||
? computer.ComputeBatchMultiDir(stationary, stationaryCount, moving, movingCount,
|
||||
offsets, count, directions)
|
||||
: computer.ComputeBatch(stationary, stationaryCount, moving, movingCount,
|
||||
offsets, count, direction);
|
||||
|
||||
private static List<Line> Transform(List<Line> lines, int turns, Vector origin, bool reverse)
|
||||
{
|
||||
Vector Map(Vector point)
|
||||
{
|
||||
for (var i = 0; i < turns; i++)
|
||||
point = new Vector(-point.Y, point.X);
|
||||
return point + origin;
|
||||
}
|
||||
var result = lines.Select(line => new Line(Map(line.StartPoint), Map(line.EndPoint))).ToList();
|
||||
if (reverse)
|
||||
{
|
||||
result.Reverse();
|
||||
foreach (var line in result)
|
||||
line.Reverse();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Line> Rect(double x, double y, double width, double height) =>
|
||||
Loop((x, y), (x + width, y), (x + width, y + height), (x, y + height));
|
||||
|
||||
private static List<Line> Loop(params (double X, double Y)[] points) =>
|
||||
points.Select((point, i) => new Line(point.X, point.Y,
|
||||
points[(i + 1) % points.Length].X, points[(i + 1) % points.Length].Y)).ToList();
|
||||
}
|
||||
@@ -10,13 +10,13 @@ using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Actions;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using Timer = System.Timers.Timer;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
@@ -562,9 +562,6 @@ namespace OpenNest.Controls
|
||||
Plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
public void SetStationaryParts(List<Part> parts) =>
|
||||
previewManager.SetStationaryParts(parts);
|
||||
|
||||
public void SetActiveParts(List<Part> parts) => previewManager.SetActiveParts(parts);
|
||||
|
||||
public void ClearPreviewParts() => previewManager.ClearPreviewParts();
|
||||
@@ -578,23 +575,12 @@ namespace OpenNest.Controls
|
||||
var cts = new CancellationTokenSource();
|
||||
var progressForm = new NestProgressForm(cts, showPlateRow: false);
|
||||
|
||||
var previewPlate = new Plate(Plate.Size)
|
||||
{
|
||||
Quadrant = Plate.Quadrant,
|
||||
PartSpacing = Plate.PartSpacing,
|
||||
};
|
||||
previewPlate.EdgeSpacing = Plate.EdgeSpacing;
|
||||
progressForm.PreviewPlate = previewPlate;
|
||||
|
||||
var progress = new Progress<NestProgress>(p =>
|
||||
{
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
{
|
||||
progressForm.UpdatePreview(p.BestParts);
|
||||
SetActiveParts(p.BestParts);
|
||||
}
|
||||
|
||||
ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
@@ -749,6 +735,8 @@ namespace OpenNest.Controls
|
||||
|
||||
public void PushSelected(PushDirection direction) => selection.PushSelected(direction);
|
||||
|
||||
public bool ExpandSelected() => selection.ExpandSelected();
|
||||
|
||||
public void RotateSelectedParts(double angle) => selection.RotateSelectedParts(angle);
|
||||
|
||||
protected override void UpdateMatrix()
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace OpenNest.Controls
|
||||
internal class PreviewManager
|
||||
{
|
||||
private readonly PlateView view;
|
||||
private readonly List<LayoutPart> stationaryParts = new List<LayoutPart>();
|
||||
private readonly List<LayoutPart> activeParts = new List<LayoutPart>();
|
||||
|
||||
public PreviewManager(PlateView view)
|
||||
@@ -14,32 +13,11 @@ namespace OpenNest.Controls
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
public IReadOnlyList<LayoutPart> PreviewParts =>
|
||||
activeParts.Count > 0 ? activeParts : stationaryParts;
|
||||
public IReadOnlyList<LayoutPart> PreviewParts => activeParts;
|
||||
|
||||
public Brush PreviewBrush =>
|
||||
activeParts.Count > 0
|
||||
? view.ColorScheme.ActivePreviewPartBrush
|
||||
: view.ColorScheme.PreviewPartBrush;
|
||||
public Brush PreviewBrush => view.ColorScheme.ActivePreviewPartBrush;
|
||||
|
||||
public Pen PreviewPen =>
|
||||
activeParts.Count > 0
|
||||
? view.ColorScheme.ActivePreviewPartPen
|
||||
: view.ColorScheme.PreviewPartPen;
|
||||
|
||||
public void SetStationaryParts(List<Part> parts)
|
||||
{
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
|
||||
if (parts != null)
|
||||
{
|
||||
foreach (var part in parts)
|
||||
stationaryParts.Add(LayoutPart.Create(part, view));
|
||||
}
|
||||
|
||||
view.Invalidate();
|
||||
}
|
||||
public Pen PreviewPen => view.ColorScheme.ActivePreviewPartPen;
|
||||
|
||||
public void SetActiveParts(List<Part> parts)
|
||||
{
|
||||
@@ -56,7 +34,6 @@ namespace OpenNest.Controls
|
||||
|
||||
public void ClearPreviewParts()
|
||||
{
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
view.Invalidate();
|
||||
}
|
||||
@@ -69,19 +46,16 @@ namespace OpenNest.Controls
|
||||
view.Plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
}
|
||||
|
||||
public void Update()
|
||||
{
|
||||
stationaryParts.ForEach(p => p.Update(view));
|
||||
activeParts.ForEach(p => p.Update(view));
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
stationaryParts.Clear();
|
||||
activeParts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,36 @@ namespace OpenNest.Controls
|
||||
view.Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grows the part-to-part spacing of the selection as far as the plate
|
||||
/// allows (Expander). Returns false when nothing could be done.
|
||||
/// </summary>
|
||||
public bool ExpandSelected()
|
||||
{
|
||||
var movingParts = selectedParts.Select(p => p.BasePart).ToList();
|
||||
|
||||
if (movingParts.Count < 2)
|
||||
return false;
|
||||
|
||||
var result = Expander.Expand(movingParts, view.Plate);
|
||||
|
||||
if (result.Cancelled)
|
||||
return false;
|
||||
|
||||
selectedParts.ForEach(p => p.IsDirty = true);
|
||||
|
||||
if (view.Plate.CutOffs.Count > 0)
|
||||
view.Plate.RegenerateCutOffs(view.CutOffSettings);
|
||||
|
||||
view.Invalidate();
|
||||
|
||||
view.Status =
|
||||
$"Expanded spacing to {result.AchievedSpacing:0.###}"
|
||||
+ (result.Violations.Count > 0 ? $" - {result.Violations.Count} blocked pairs" : "");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public LayoutPart GetPartAtControlPoint(Point pt)
|
||||
{
|
||||
var pt2 = view.PointControlToGraph(pt);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bom;
|
||||
@@ -16,7 +17,7 @@ namespace OpenNest.Forms
|
||||
private List<BomPartRow> _parts;
|
||||
private Dictionary<string, GroupSettings> _groupSettings;
|
||||
private bool _suppressRegroup;
|
||||
private Nest.PlateSettings _templateDefaults;
|
||||
private NestDefaults _defaults;
|
||||
|
||||
public Form MdiParentForm { get; set; }
|
||||
|
||||
@@ -25,37 +26,14 @@ namespace OpenNest.Forms
|
||||
InitializeComponent();
|
||||
_parts = new List<BomPartRow>();
|
||||
_groupSettings = new Dictionary<string, GroupSettings>();
|
||||
_templateDefaults = LoadTemplateDefaults();
|
||||
ApplyTemplateDefaults();
|
||||
_defaults = NestDefaults.Load(NestDefaults.DefaultPath);
|
||||
ApplyDefaults();
|
||||
}
|
||||
|
||||
private Nest.PlateSettings LoadTemplateDefaults()
|
||||
private void ApplyDefaults()
|
||||
{
|
||||
var templatePath = Properties.Settings.Default.NestTemplatePath;
|
||||
if (File.Exists(templatePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var nest = new NestReader(templatePath).Read();
|
||||
return nest.PlateDefaults;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Fallback defaults matching CreateDefaultNest
|
||||
return new Nest.PlateSettings
|
||||
{
|
||||
Size = new Geometry.Size(100, 100),
|
||||
Quadrant = 1,
|
||||
PartSpacing = 1,
|
||||
EdgeSpacing = new Spacing(1, 1, 1, 1),
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyTemplateDefaults()
|
||||
{
|
||||
txtPlateWidth.Text = _templateDefaults.Size.Width.ToString("0.####");
|
||||
txtPlateLength.Text = _templateDefaults.Size.Length.ToString("0.####");
|
||||
txtPlateWidth.Text = _defaults.Size.Width.ToString("0.####");
|
||||
txtPlateLength.Text = _defaults.Size.Length.ToString("0.####");
|
||||
}
|
||||
|
||||
#region File Browsing
|
||||
@@ -147,9 +125,9 @@ namespace OpenNest.Forms
|
||||
var matchedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in analysis.Groups)
|
||||
foreach (var part in group.Parts)
|
||||
if (part.DxfPath != null)
|
||||
matchedPaths[part.Item.FileName ?? ""] = part.DxfPath;
|
||||
foreach (var part in group.Parts)
|
||||
if (part.DxfPath != null)
|
||||
matchedPaths[part.Item.FileName ?? ""] = part.DxfPath;
|
||||
|
||||
_parts = new List<BomPartRow>();
|
||||
|
||||
@@ -290,10 +268,10 @@ namespace OpenNest.Forms
|
||||
|
||||
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w)
|
||||
? w
|
||||
: _templateDefaults.Size.Width;
|
||||
: _defaults.Size.Width;
|
||||
var defaultLength = double.TryParse(txtPlateLength.Text, out var l)
|
||||
? l
|
||||
: _templateDefaults.Size.Length;
|
||||
: _defaults.Size.Length;
|
||||
|
||||
var groups = _parts
|
||||
.Where(p =>
|
||||
@@ -336,11 +314,11 @@ namespace OpenNest.Forms
|
||||
group.Sum(p => p.Qty ?? 0),
|
||||
existing ? gs.PlateWidth : defaultWidth,
|
||||
existing ? gs.PlateLength : defaultLength,
|
||||
existing ? gs.PartSpacing : _templateDefaults.PartSpacing,
|
||||
existing ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left,
|
||||
existing ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom,
|
||||
existing ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right,
|
||||
existing ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top
|
||||
existing ? gs.PartSpacing : _defaults.PartSpacing,
|
||||
existing ? gs.EdgeLeft : _defaults.EdgeSpacing.Left,
|
||||
existing ? gs.EdgeBottom : _defaults.EdgeSpacing.Bottom,
|
||||
existing ? gs.EdgeRight : _defaults.EdgeSpacing.Right,
|
||||
existing ? gs.EdgeTop : _defaults.EdgeSpacing.Top
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,23 +352,23 @@ namespace OpenNest.Forms
|
||||
{
|
||||
PlateWidth = row["Plate Width"] is double pw
|
||||
? pw
|
||||
: _templateDefaults.Size.Width,
|
||||
: _defaults.Size.Width,
|
||||
PlateLength = row["Plate Length"] is double pl
|
||||
? pl
|
||||
: _templateDefaults.Size.Length,
|
||||
: _defaults.Size.Length,
|
||||
PartSpacing = row["Part Spacing"] is double ps
|
||||
? ps
|
||||
: _templateDefaults.PartSpacing,
|
||||
: _defaults.PartSpacing,
|
||||
EdgeLeft = row["Edge Left"] is double el
|
||||
? el
|
||||
: _templateDefaults.EdgeSpacing.Left,
|
||||
: _defaults.EdgeSpacing.Left,
|
||||
EdgeBottom = row["Edge Bottom"] is double eb
|
||||
? eb
|
||||
: _templateDefaults.EdgeSpacing.Bottom,
|
||||
: _defaults.EdgeSpacing.Bottom,
|
||||
EdgeRight = row["Edge Right"] is double er
|
||||
? er
|
||||
: _templateDefaults.EdgeSpacing.Right,
|
||||
EdgeTop = row["Edge Top"] is double et ? et : _templateDefaults.EdgeSpacing.Top,
|
||||
: _defaults.EdgeSpacing.Right,
|
||||
EdgeTop = row["Edge Top"] is double et ? et : _defaults.EdgeSpacing.Top,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -434,10 +412,10 @@ namespace OpenNest.Forms
|
||||
|
||||
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var dw)
|
||||
? dw
|
||||
: _templateDefaults.Size.Width;
|
||||
: _defaults.Size.Width;
|
||||
var defaultLength = double.TryParse(txtPlateLength.Text, out var dl)
|
||||
? dl
|
||||
: _templateDefaults.Size.Length;
|
||||
: _defaults.Size.Length;
|
||||
|
||||
var groups = _parts
|
||||
.Where(p =>
|
||||
@@ -477,11 +455,11 @@ namespace OpenNest.Forms
|
||||
var hasSettings = _groupSettings.TryGetValue(key, out var gs);
|
||||
var plateWidth = hasSettings ? gs.PlateWidth : defaultWidth;
|
||||
var plateLength = hasSettings ? gs.PlateLength : defaultLength;
|
||||
var partSpacing = hasSettings ? gs.PartSpacing : _templateDefaults.PartSpacing;
|
||||
var edgeLeft = hasSettings ? gs.EdgeLeft : _templateDefaults.EdgeSpacing.Left;
|
||||
var edgeBottom = hasSettings ? gs.EdgeBottom : _templateDefaults.EdgeSpacing.Bottom;
|
||||
var edgeRight = hasSettings ? gs.EdgeRight : _templateDefaults.EdgeSpacing.Right;
|
||||
var edgeTop = hasSettings ? gs.EdgeTop : _templateDefaults.EdgeSpacing.Top;
|
||||
var partSpacing = hasSettings ? gs.PartSpacing : _defaults.PartSpacing;
|
||||
var edgeLeft = hasSettings ? gs.EdgeLeft : _defaults.EdgeSpacing.Left;
|
||||
var edgeBottom = hasSettings ? gs.EdgeBottom : _defaults.EdgeSpacing.Bottom;
|
||||
var edgeRight = hasSettings ? gs.EdgeRight : _defaults.EdgeSpacing.Right;
|
||||
var edgeTop = hasSettings ? gs.EdgeTop : _defaults.EdgeSpacing.Top;
|
||||
|
||||
var nestName = $"{jobName} - {thickness:0.###} {material}";
|
||||
var nest = new Nest(nestName);
|
||||
@@ -490,7 +468,7 @@ namespace OpenNest.Forms
|
||||
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
|
||||
nest.Thickness = thickness;
|
||||
nest.Material = new Material(material);
|
||||
nest.PlateDefaults.Quadrant = _templateDefaults.Quadrant;
|
||||
nest.PlateDefaults.Quadrant = _defaults.Quadrant;
|
||||
nest.PlateDefaults.PartSpacing = partSpacing;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(
|
||||
edgeLeft,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
@@ -301,16 +301,11 @@ namespace OpenNest.Forms
|
||||
public void SaveAs()
|
||||
{
|
||||
var dlg = new SaveFileDialog();
|
||||
dlg.Filter = $"{NestFormat.FileFilter}|Template File|*.nstdot";
|
||||
dlg.Filter = NestFormat.FileFilter;
|
||||
dlg.FileName = Nest.Name;
|
||||
|
||||
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
{
|
||||
if (dlg.FilterIndex == 2)
|
||||
SaveTemplate(dlg.FileName);
|
||||
else
|
||||
SaveAs(dlg.FileName);
|
||||
}
|
||||
SaveAs(dlg.FileName);
|
||||
}
|
||||
|
||||
public void SaveAs(string path)
|
||||
@@ -319,17 +314,6 @@ namespace OpenNest.Forms
|
||||
Text = Document.Name;
|
||||
}
|
||||
|
||||
public void SaveTemplate(string path)
|
||||
{
|
||||
var nst = new Nest();
|
||||
nst.Name = Path.GetFileNameWithoutExtension(path);
|
||||
nst.PlateDefaults = Nest.PlateDefaults;
|
||||
nst.Units = Nest.Units;
|
||||
|
||||
var writer = new NestWriter(nst);
|
||||
writer.Write(path);
|
||||
}
|
||||
|
||||
public void Import()
|
||||
{
|
||||
var dlg = new OpenFileDialog();
|
||||
@@ -943,9 +927,9 @@ namespace OpenNest.Forms
|
||||
|
||||
// Refresh all parts to use the updated programs
|
||||
foreach (var plate in Nest.Plates)
|
||||
foreach (var part in plate.Parts)
|
||||
if (!part.BaseDrawing.IsCutOff)
|
||||
part.Update();
|
||||
foreach (var part in plate.Parts)
|
||||
if (!part.BaseDrawing.IsCutOff)
|
||||
part.Update();
|
||||
|
||||
UpdateDrawingList();
|
||||
PlateView.Invalidate();
|
||||
|
||||
Generated
+28
-1
@@ -65,6 +65,7 @@
|
||||
mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsPatternTile = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsExpandSpacing = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -81,6 +82,8 @@
|
||||
mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem();
|
||||
toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator();
|
||||
mnuToolsMachineConfig = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsNestDefaults = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsSaveCurrentAsDefaults = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuNest = new System.Windows.Forms.ToolStripMenuItem();
|
||||
mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -419,7 +422,7 @@
|
||||
//
|
||||
// mnuTools
|
||||
//
|
||||
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsMachineConfig, mnuToolsOptions });
|
||||
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsExpandSpacing, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsMachineConfig, mnuToolsNestDefaults, mnuToolsSaveCurrentAsDefaults, mnuToolsOptions });
|
||||
mnuTools.Name = "mnuTools";
|
||||
mnuTools.Size = new System.Drawing.Size(47, 20);
|
||||
mnuTools.Text = "&Tools";
|
||||
@@ -445,6 +448,13 @@
|
||||
this.mnuToolsPatternTile.Text = "Pattern Tile";
|
||||
this.mnuToolsPatternTile.Click += PatternTile_Click;
|
||||
//
|
||||
// mnuToolsExpandSpacing
|
||||
//
|
||||
this.mnuToolsExpandSpacing.Name = "mnuToolsExpandSpacing";
|
||||
this.mnuToolsExpandSpacing.Size = new System.Drawing.Size(214, 22);
|
||||
this.mnuToolsExpandSpacing.Text = "Expand Spacing";
|
||||
this.mnuToolsExpandSpacing.Click += ExpandSpacing_Click;
|
||||
//
|
||||
// mnuToolsAlign
|
||||
//
|
||||
mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsAlignLeft, mnuToolsAlignRight, mnuToolsAlignTop, mnuToolsAlignBottom, toolStripMenuItem11, mnuToolsAlignHorizontal, mnuToolsAlignVertically, toolStripMenuItem8, mnuToolsEvenlySpaceHorizontal, mnuToolsEvenlySpaceVertical });
|
||||
@@ -551,6 +561,20 @@
|
||||
mnuToolsMachineConfig.Text = "Machine Configuration...";
|
||||
mnuToolsMachineConfig.Click += MachineConfig_Click;
|
||||
//
|
||||
// mnuToolsNestDefaults
|
||||
//
|
||||
mnuToolsNestDefaults.Name = "mnuToolsNestDefaults";
|
||||
mnuToolsNestDefaults.Size = new System.Drawing.Size(214, 22);
|
||||
mnuToolsNestDefaults.Text = "Nest Defaults...";
|
||||
mnuToolsNestDefaults.Click += NestDefaults_Click;
|
||||
//
|
||||
// mnuToolsSaveCurrentAsDefaults
|
||||
//
|
||||
mnuToolsSaveCurrentAsDefaults.Name = "mnuToolsSaveCurrentAsDefaults";
|
||||
mnuToolsSaveCurrentAsDefaults.Size = new System.Drawing.Size(214, 22);
|
||||
mnuToolsSaveCurrentAsDefaults.Text = "Save Current Plate as Defaults";
|
||||
mnuToolsSaveCurrentAsDefaults.Click += SaveCurrentAsDefaults_Click;
|
||||
//
|
||||
// mnuToolsOptions
|
||||
//
|
||||
mnuToolsOptions.Name = "mnuToolsOptions";
|
||||
@@ -1217,6 +1241,8 @@
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem5;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuTools;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsMachineConfig;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsNestDefaults;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsSaveCurrentAsDefaults;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsOptions;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuNest;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuNestEdit;
|
||||
@@ -1306,6 +1332,7 @@
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsMeasureArea;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsBestFitViewer;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsPatternTile;
|
||||
private System.Windows.Forms.ToolStripMenuItem mnuToolsExpandSpacing;
|
||||
private System.Windows.Forms.ToolStripButton btnSaveAs;
|
||||
private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel;
|
||||
|
||||
+116
-53
@@ -10,16 +10,16 @@ using System.Windows.Forms;
|
||||
using OpenNest.Actions;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Gpu;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Properties;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
@@ -90,14 +90,38 @@ namespace OpenNest.Forms
|
||||
private Nest CreateDefaultNest()
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Units = Properties.Settings.Default.DefaultUnit;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(1, 1, 1, 1);
|
||||
nest.PlateDefaults.PartSpacing = 1;
|
||||
nest.PlateDefaults.Size = new OpenNest.Geometry.Size(100, 100);
|
||||
nest.PlateDefaults.Quadrant = 1;
|
||||
LoadNestDefaults().ApplyTo(nest);
|
||||
return nest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the persisted nest defaults. When no usable file exists,
|
||||
/// units come from the legacy DefaultUnit setting; a corrupt file
|
||||
/// warns once per session.
|
||||
/// </summary>
|
||||
private NestDefaults LoadNestDefaults()
|
||||
{
|
||||
var defaults = NestDefaults.Load(NestDefaults.DefaultPath, out var status);
|
||||
if (status == NestDefaultsStatus.Ok)
|
||||
return defaults;
|
||||
|
||||
defaults.Units = Properties.Settings.Default.DefaultUnit;
|
||||
|
||||
if (status == NestDefaultsStatus.Invalid && !defaultsWarned)
|
||||
{
|
||||
defaultsWarned = true;
|
||||
MessageBox.Show(
|
||||
$"The nest defaults file could not be read:\n{NestDefaults.DefaultPath}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.",
|
||||
"Nest Defaults",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning
|
||||
);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
private bool defaultsWarned;
|
||||
|
||||
private string GetNestName(DateTime date, int id)
|
||||
{
|
||||
var year = (date.Year % 100).ToString("D2");
|
||||
@@ -184,6 +208,8 @@ namespace OpenNest.Forms
|
||||
mnuWindow.Visible = hasValue;
|
||||
mnuToolsAlign.Visible = hasValue;
|
||||
mnuToolsMeasureArea.Visible = hasValue;
|
||||
mnuToolsExpandSpacing.Visible = hasValue;
|
||||
mnuToolsSaveCurrentAsDefaults.Visible = hasValue;
|
||||
|
||||
toolStripMenuItem14.Visible = hasValue;
|
||||
mnuSetOffsetIncrement.Visible = hasValue;
|
||||
@@ -453,10 +479,52 @@ namespace OpenNest.Forms
|
||||
{
|
||||
base.OnLoad(e);
|
||||
|
||||
MigrateNestTemplate();
|
||||
|
||||
if (Settings.Default.CreateNewNestOnOpen)
|
||||
New_Click(this, new EventArgs());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-time upgrade: converts a legacy .nstdot nest template
|
||||
/// (NestTemplatePath setting) into defaults.json, then clears the
|
||||
/// setting so the template mechanism is never consulted again.
|
||||
/// </summary>
|
||||
private void MigrateNestTemplate()
|
||||
{
|
||||
var templatePath = Settings.Default.NestTemplatePath;
|
||||
if (string.IsNullOrWhiteSpace(templatePath))
|
||||
return;
|
||||
|
||||
// A failed conversion keeps the setting populated so the user
|
||||
// can still find their template file.
|
||||
var converted = true;
|
||||
if (File.Exists(templatePath) && !File.Exists(NestDefaults.DefaultPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var nest = new NestReader(templatePath).Read();
|
||||
NestDefaults.FromNest(nest).Save(NestDefaults.DefaultPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
converted = false;
|
||||
MessageBox.Show(
|
||||
$"The nest template could not be converted to the new defaults file:\n{templatePath}\n\n{ex.Message}\n\nIt will no longer be loaded automatically. Set defaults under Tools > Nest Defaults.",
|
||||
"Nest Template",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!converted)
|
||||
return;
|
||||
|
||||
Settings.Default.NestTemplatePath = "";
|
||||
Settings.Default.Save();
|
||||
}
|
||||
|
||||
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
base.OnClosing(e);
|
||||
@@ -472,30 +540,7 @@ namespace OpenNest.Forms
|
||||
var windowState =
|
||||
ActiveMdiChild != null ? ActiveMdiChild.WindowState : FormWindowState.Maximized;
|
||||
|
||||
Nest nest;
|
||||
|
||||
if (File.Exists(Properties.Settings.Default.NestTemplatePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new NestReader(Properties.Settings.Default.NestTemplatePath);
|
||||
nest = reader.Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
$"Failed to load nest template:\n{ex.Message}\n\nA default nest will be created instead.",
|
||||
"Template Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning
|
||||
);
|
||||
nest = CreateDefaultNest();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
nest = CreateDefaultNest();
|
||||
}
|
||||
var nest = CreateDefaultNest();
|
||||
|
||||
nest.DateCreated = DateTime.Now;
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
@@ -741,6 +786,20 @@ namespace OpenNest.Forms
|
||||
}
|
||||
}
|
||||
|
||||
private void ExpandSpacing_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
return;
|
||||
|
||||
if (!activeForm.PlateView.ExpandSelected())
|
||||
MessageBox.Show(
|
||||
"Select at least two parts on the plate to expand.",
|
||||
"Expand Spacing",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information
|
||||
);
|
||||
}
|
||||
|
||||
private void PatternTile_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
@@ -819,6 +878,25 @@ namespace OpenNest.Forms
|
||||
form.ShowDialog();
|
||||
}
|
||||
|
||||
private void NestDefaults_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var form = new NestDefaultsForm(NestDefaults.Load(NestDefaults.DefaultPath)))
|
||||
{
|
||||
if (form.ShowDialog(this) == DialogResult.OK)
|
||||
form.GetDefaults().Save(NestDefaults.DefaultPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveCurrentAsDefaults_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
return;
|
||||
|
||||
NestDefaults
|
||||
.FromPlate(activeForm.Nest.Units, activeForm.PlateView.Plate)
|
||||
.Save(NestDefaults.DefaultPath);
|
||||
}
|
||||
|
||||
private void MachineConfig_Click(object sender, EventArgs e)
|
||||
{
|
||||
var appDataPath = Path.Combine(
|
||||
@@ -1040,7 +1118,6 @@ namespace OpenNest.Forms
|
||||
|
||||
nestingCts = new CancellationTokenSource();
|
||||
var progressForm = new NestProgressForm(nestingCts, showPlateRow: true);
|
||||
progressForm.PreviewPlate = CreatePreviewPlate(activeForm.PlateView.Plate);
|
||||
|
||||
var jobEngineName = EngineSelection.IsFillStrategy(EngineSelection.EngineName)
|
||||
? null
|
||||
@@ -1052,9 +1129,7 @@ namespace OpenNest.Forms
|
||||
progressForm.UpdateProgress(p);
|
||||
|
||||
if (p.IsOverallBest)
|
||||
progressForm.UpdatePreview(p.BestParts);
|
||||
|
||||
activeForm.PlateView.SetActiveParts(p.BestParts);
|
||||
activeForm.PlateView.SetActiveParts(p.BestParts);
|
||||
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea;
|
||||
});
|
||||
|
||||
@@ -1152,7 +1227,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
if (pr.IsNew)
|
||||
{
|
||||
var plate = GetOrCreatePlate(progressForm);
|
||||
var plate = GetOrCreatePlate();
|
||||
plate.Size = pr.Plate.Size;
|
||||
plate.Parts.AddRange(pr.Parts);
|
||||
}
|
||||
@@ -1172,7 +1247,7 @@ namespace OpenNest.Forms
|
||||
if (remaining.Count == 0 || token.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
var plate = GetOrCreatePlate(progressForm);
|
||||
var plate = GetOrCreatePlate();
|
||||
|
||||
var placed = await NestSinglePlateAsync(
|
||||
plate,
|
||||
@@ -1242,7 +1317,7 @@ namespace OpenNest.Forms
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var plate = GetOrCreatePlate(progressForm);
|
||||
var plate = GetOrCreatePlate();
|
||||
plate.Size = sheet.Stock.Size;
|
||||
plate.Parts.AddRange(parts);
|
||||
}
|
||||
@@ -1260,11 +1335,10 @@ namespace OpenNest.Forms
|
||||
);
|
||||
}
|
||||
|
||||
private Plate GetOrCreatePlate(NestProgressForm progressForm)
|
||||
private Plate GetOrCreatePlate()
|
||||
{
|
||||
var plate = activeForm.PlateManager.GetOrCreateEmpty();
|
||||
activeForm.PlateManager.LoadLast();
|
||||
progressForm.PreviewPlate = CreatePreviewPlate(plate);
|
||||
return plate;
|
||||
}
|
||||
|
||||
@@ -1340,17 +1414,6 @@ namespace OpenNest.Forms
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Plate CreatePreviewPlate(Plate source)
|
||||
{
|
||||
var plate = new Plate(source.Size)
|
||||
{
|
||||
Quadrant = source.Quadrant,
|
||||
PartSpacing = source.PartSpacing,
|
||||
};
|
||||
plate.EdgeSpacing = source.EdgeSpacing;
|
||||
return plate;
|
||||
}
|
||||
|
||||
private void SequenceAllPlates_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (activeForm == null)
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
partial class NestDefaultsForm
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
|
||||
this.unitsLabel = new System.Windows.Forms.Label();
|
||||
this.unitsCombo = new System.Windows.Forms.ComboBox();
|
||||
this.widthLabel = new System.Windows.Forms.Label();
|
||||
this.widthBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.lengthLabel = new System.Windows.Forms.Label();
|
||||
this.lengthBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.partSpacingLabel = new System.Windows.Forms.Label();
|
||||
this.partSpacingBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.edgeLeftLabel = new System.Windows.Forms.Label();
|
||||
this.edgeLeftBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.edgeBottomLabel = new System.Windows.Forms.Label();
|
||||
this.edgeBottomBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.edgeRightLabel = new System.Windows.Forms.Label();
|
||||
this.edgeRightBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.edgeTopLabel = new System.Windows.Forms.Label();
|
||||
this.edgeTopBox = new OpenNest.Controls.NumericUpDown();
|
||||
this.quadrantLabel = new System.Windows.Forms.Label();
|
||||
this.quadrantSelect1 = new OpenNest.Controls.QuadrantSelect();
|
||||
this.saveButton = new System.Windows.Forms.Button();
|
||||
this.cancelButton = new System.Windows.Forms.Button();
|
||||
((System.ComponentModel.ISupportInitialize)(this.widthBox)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.lengthBox)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.partSpacingBox)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeLeftBox)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeBottomBox)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeRightBox)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeTopBox)).BeginInit();
|
||||
this.tableLayoutPanel1.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// tableLayoutPanel1
|
||||
//
|
||||
this.tableLayoutPanel1.ColumnCount = 2;
|
||||
this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
|
||||
this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
||||
this.tableLayoutPanel1.Controls.Add(this.unitsLabel, 0, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.unitsCombo, 1, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.widthLabel, 0, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.widthBox, 1, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.lengthLabel, 0, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.lengthBox, 1, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.partSpacingLabel, 0, 3);
|
||||
this.tableLayoutPanel1.Controls.Add(this.partSpacingBox, 1, 3);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeLeftLabel, 0, 4);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeLeftBox, 1, 4);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeBottomLabel, 0, 5);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeBottomBox, 1, 5);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeRightLabel, 0, 6);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeRightBox, 1, 6);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeTopLabel, 0, 7);
|
||||
this.tableLayoutPanel1.Controls.Add(this.edgeTopBox, 1, 7);
|
||||
this.tableLayoutPanel1.Controls.Add(this.quadrantLabel, 0, 8);
|
||||
this.tableLayoutPanel1.Controls.Add(this.quadrantSelect1, 1, 8);
|
||||
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
|
||||
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
|
||||
this.tableLayoutPanel1.RowCount = 9;
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 11.11111F));
|
||||
this.tableLayoutPanel1.Size = new System.Drawing.Size(410, 260);
|
||||
this.tableLayoutPanel1.TabIndex = 0;
|
||||
//
|
||||
// unitsLabel
|
||||
//
|
||||
this.unitsLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.unitsLabel.AutoSize = true;
|
||||
this.unitsLabel.Location = new System.Drawing.Point(3, 9);
|
||||
this.unitsLabel.Name = "unitsLabel";
|
||||
this.unitsLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.unitsLabel.TabIndex = 0;
|
||||
this.unitsLabel.Text = "Units:";
|
||||
//
|
||||
// unitsCombo
|
||||
//
|
||||
this.unitsCombo.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.unitsCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.unitsCombo.Items.AddRange(new object[] { "Inches", "Millimeters" });
|
||||
this.unitsCombo.Location = new System.Drawing.Point(109, 5);
|
||||
this.unitsCombo.Name = "unitsCombo";
|
||||
this.unitsCombo.Size = new System.Drawing.Size(298, 24);
|
||||
this.unitsCombo.TabIndex = 1;
|
||||
//
|
||||
// widthLabel
|
||||
//
|
||||
this.widthLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.widthLabel.AutoSize = true;
|
||||
this.widthLabel.Location = new System.Drawing.Point(3, 37);
|
||||
this.widthLabel.Name = "widthLabel";
|
||||
this.widthLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.widthLabel.TabIndex = 2;
|
||||
this.widthLabel.Text = "Plate width:";
|
||||
//
|
||||
// widthBox
|
||||
//
|
||||
this.widthBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.widthBox.DecimalPlaces = 4;
|
||||
this.widthBox.Location = new System.Drawing.Point(109, 33);
|
||||
this.widthBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.widthBox.Minimum = new decimal(new int[] { 1, 0, 0, 196608 });
|
||||
this.widthBox.Name = "widthBox";
|
||||
this.widthBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.widthBox.Suffix = "";
|
||||
this.widthBox.TabIndex = 3;
|
||||
//
|
||||
// lengthLabel
|
||||
//
|
||||
this.lengthLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.lengthLabel.AutoSize = true;
|
||||
this.lengthLabel.Location = new System.Drawing.Point(3, 65);
|
||||
this.lengthLabel.Name = "lengthLabel";
|
||||
this.lengthLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.lengthLabel.TabIndex = 4;
|
||||
this.lengthLabel.Text = "Plate length:";
|
||||
//
|
||||
// lengthBox
|
||||
//
|
||||
this.lengthBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.lengthBox.DecimalPlaces = 4;
|
||||
this.lengthBox.Location = new System.Drawing.Point(109, 61);
|
||||
this.lengthBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.lengthBox.Minimum = new decimal(new int[] { 1, 0, 0, 196608 });
|
||||
this.lengthBox.Name = "lengthBox";
|
||||
this.lengthBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.lengthBox.Suffix = "";
|
||||
this.lengthBox.TabIndex = 5;
|
||||
//
|
||||
// partSpacingLabel
|
||||
//
|
||||
this.partSpacingLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.partSpacingLabel.AutoSize = true;
|
||||
this.partSpacingLabel.Location = new System.Drawing.Point(3, 93);
|
||||
this.partSpacingLabel.Name = "partSpacingLabel";
|
||||
this.partSpacingLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.partSpacingLabel.TabIndex = 6;
|
||||
this.partSpacingLabel.Text = "Part spacing:";
|
||||
//
|
||||
// partSpacingBox
|
||||
//
|
||||
this.partSpacingBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.partSpacingBox.DecimalPlaces = 4;
|
||||
this.partSpacingBox.Location = new System.Drawing.Point(109, 89);
|
||||
this.partSpacingBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.partSpacingBox.Name = "partSpacingBox";
|
||||
this.partSpacingBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.partSpacingBox.Suffix = "";
|
||||
this.partSpacingBox.TabIndex = 7;
|
||||
//
|
||||
// edgeLeftLabel
|
||||
//
|
||||
this.edgeLeftLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeLeftLabel.AutoSize = true;
|
||||
this.edgeLeftLabel.Location = new System.Drawing.Point(3, 121);
|
||||
this.edgeLeftLabel.Name = "edgeLeftLabel";
|
||||
this.edgeLeftLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.edgeLeftLabel.TabIndex = 8;
|
||||
this.edgeLeftLabel.Text = "Edge left:";
|
||||
//
|
||||
// edgeLeftBox
|
||||
//
|
||||
this.edgeLeftBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeLeftBox.DecimalPlaces = 4;
|
||||
this.edgeLeftBox.Location = new System.Drawing.Point(109, 117);
|
||||
this.edgeLeftBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.edgeLeftBox.Name = "edgeLeftBox";
|
||||
this.edgeLeftBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.edgeLeftBox.Suffix = "";
|
||||
this.edgeLeftBox.TabIndex = 9;
|
||||
//
|
||||
// edgeBottomLabel
|
||||
//
|
||||
this.edgeBottomLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeBottomLabel.AutoSize = true;
|
||||
this.edgeBottomLabel.Location = new System.Drawing.Point(3, 149);
|
||||
this.edgeBottomLabel.Name = "edgeBottomLabel";
|
||||
this.edgeBottomLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.edgeBottomLabel.TabIndex = 10;
|
||||
this.edgeBottomLabel.Text = "Edge bottom:";
|
||||
//
|
||||
// edgeBottomBox
|
||||
//
|
||||
this.edgeBottomBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeBottomBox.DecimalPlaces = 4;
|
||||
this.edgeBottomBox.Location = new System.Drawing.Point(109, 145);
|
||||
this.edgeBottomBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.edgeBottomBox.Name = "edgeBottomBox";
|
||||
this.edgeBottomBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.edgeBottomBox.Suffix = "";
|
||||
this.edgeBottomBox.TabIndex = 11;
|
||||
//
|
||||
// edgeRightLabel
|
||||
//
|
||||
this.edgeRightLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeRightLabel.AutoSize = true;
|
||||
this.edgeRightLabel.Location = new System.Drawing.Point(3, 177);
|
||||
this.edgeRightLabel.Name = "edgeRightLabel";
|
||||
this.edgeRightLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.edgeRightLabel.TabIndex = 12;
|
||||
this.edgeRightLabel.Text = "Edge right:";
|
||||
//
|
||||
// edgeRightBox
|
||||
//
|
||||
this.edgeRightBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeRightBox.DecimalPlaces = 4;
|
||||
this.edgeRightBox.Location = new System.Drawing.Point(109, 173);
|
||||
this.edgeRightBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.edgeRightBox.Name = "edgeRightBox";
|
||||
this.edgeRightBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.edgeRightBox.Suffix = "";
|
||||
this.edgeRightBox.TabIndex = 13;
|
||||
//
|
||||
// edgeTopLabel
|
||||
//
|
||||
this.edgeTopLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeTopLabel.AutoSize = true;
|
||||
this.edgeTopLabel.Location = new System.Drawing.Point(3, 205);
|
||||
this.edgeTopLabel.Name = "edgeTopLabel";
|
||||
this.edgeTopLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.edgeTopLabel.TabIndex = 14;
|
||||
this.edgeTopLabel.Text = "Edge top:";
|
||||
//
|
||||
// edgeTopBox
|
||||
//
|
||||
this.edgeTopBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.edgeTopBox.DecimalPlaces = 4;
|
||||
this.edgeTopBox.Location = new System.Drawing.Point(109, 201);
|
||||
this.edgeTopBox.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 });
|
||||
this.edgeTopBox.Name = "edgeTopBox";
|
||||
this.edgeTopBox.Size = new System.Drawing.Size(298, 22);
|
||||
this.edgeTopBox.Suffix = "";
|
||||
this.edgeTopBox.TabIndex = 15;
|
||||
//
|
||||
// quadrantLabel
|
||||
//
|
||||
this.quadrantLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.quadrantLabel.AutoSize = true;
|
||||
this.quadrantLabel.Location = new System.Drawing.Point(3, 233);
|
||||
this.quadrantLabel.Name = "quadrantLabel";
|
||||
this.quadrantLabel.Size = new System.Drawing.Size(100, 16);
|
||||
this.quadrantLabel.TabIndex = 16;
|
||||
this.quadrantLabel.Text = "Quadrant:";
|
||||
//
|
||||
// quadrantSelect1
|
||||
//
|
||||
this.quadrantSelect1.Anchor = System.Windows.Forms.AnchorStyles.None;
|
||||
this.quadrantSelect1.BackColor = System.Drawing.Color.White;
|
||||
this.quadrantSelect1.Location = new System.Drawing.Point(198, 225);
|
||||
this.quadrantSelect1.Name = "quadrantSelect1";
|
||||
this.quadrantSelect1.Quadrant = 1;
|
||||
this.quadrantSelect1.Size = new System.Drawing.Size(120, 28);
|
||||
this.quadrantSelect1.TabIndex = 17;
|
||||
//
|
||||
// saveButton
|
||||
//
|
||||
this.saveButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveButton.DialogResult = System.Windows.Forms.DialogResult.OK;
|
||||
this.saveButton.Location = new System.Drawing.Point(243, 285);
|
||||
this.saveButton.Name = "saveButton";
|
||||
this.saveButton.Size = new System.Drawing.Size(90, 28);
|
||||
this.saveButton.TabIndex = 1;
|
||||
this.saveButton.Text = "Save";
|
||||
this.saveButton.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// cancelButton
|
||||
//
|
||||
this.cancelButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
this.cancelButton.Location = new System.Drawing.Point(339, 285);
|
||||
this.cancelButton.Name = "cancelButton";
|
||||
this.cancelButton.Size = new System.Drawing.Size(90, 28);
|
||||
this.cancelButton.TabIndex = 2;
|
||||
this.cancelButton.Text = "Cancel";
|
||||
this.cancelButton.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// NestDefaultsForm
|
||||
//
|
||||
this.AcceptButton = this.saveButton;
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||
this.CancelButton = this.cancelButton;
|
||||
this.ClientSize = new System.Drawing.Size(434, 322);
|
||||
this.Controls.Add(this.tableLayoutPanel1);
|
||||
this.Controls.Add(this.saveButton);
|
||||
this.Controls.Add(this.cancelButton);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = false;
|
||||
this.Name = "NestDefaultsForm";
|
||||
this.ShowIcon = false;
|
||||
this.ShowInTaskbar = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Nest Defaults";
|
||||
((System.ComponentModel.ISupportInitialize)(this.widthBox)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.lengthBox)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.partSpacingBox)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeLeftBox)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeBottomBox)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeRightBox)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.edgeTopBox)).EndInit();
|
||||
this.tableLayoutPanel1.ResumeLayout(false);
|
||||
this.tableLayoutPanel1.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
|
||||
private System.Windows.Forms.Label unitsLabel;
|
||||
private System.Windows.Forms.ComboBox unitsCombo;
|
||||
private System.Windows.Forms.Label widthLabel;
|
||||
private OpenNest.Controls.NumericUpDown widthBox;
|
||||
private System.Windows.Forms.Label lengthLabel;
|
||||
private OpenNest.Controls.NumericUpDown lengthBox;
|
||||
private System.Windows.Forms.Label partSpacingLabel;
|
||||
private OpenNest.Controls.NumericUpDown partSpacingBox;
|
||||
private System.Windows.Forms.Label edgeLeftLabel;
|
||||
private OpenNest.Controls.NumericUpDown edgeLeftBox;
|
||||
private System.Windows.Forms.Label edgeBottomLabel;
|
||||
private OpenNest.Controls.NumericUpDown edgeBottomBox;
|
||||
private System.Windows.Forms.Label edgeRightLabel;
|
||||
private OpenNest.Controls.NumericUpDown edgeRightBox;
|
||||
private System.Windows.Forms.Label edgeTopLabel;
|
||||
private OpenNest.Controls.NumericUpDown edgeTopBox;
|
||||
private System.Windows.Forms.Label quadrantLabel;
|
||||
private OpenNest.Controls.QuadrantSelect quadrantSelect1;
|
||||
private System.Windows.Forms.Button saveButton;
|
||||
private System.Windows.Forms.Button cancelButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// Edits the persisted nest defaults (Tools > Nest Defaults), written
|
||||
/// to the JSON file used when creating a new nest.
|
||||
/// </summary>
|
||||
public partial class NestDefaultsForm : Form
|
||||
{
|
||||
public NestDefaultsForm(NestDefaults defaults)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
unitsCombo.SelectedIndex = defaults.Units == Units.Millimeters ? 1 : 0;
|
||||
widthBox.Value = Clamp(widthBox, defaults.Size.Width);
|
||||
lengthBox.Value = Clamp(lengthBox, defaults.Size.Length);
|
||||
partSpacingBox.Value = Clamp(partSpacingBox, defaults.PartSpacing);
|
||||
edgeLeftBox.Value = Clamp(edgeLeftBox, defaults.EdgeSpacing.Left);
|
||||
edgeBottomBox.Value = Clamp(edgeBottomBox, defaults.EdgeSpacing.Bottom);
|
||||
edgeRightBox.Value = Clamp(edgeRightBox, defaults.EdgeSpacing.Right);
|
||||
edgeTopBox.Value = Clamp(edgeTopBox, defaults.EdgeSpacing.Top);
|
||||
quadrantSelect1.Quadrant = defaults.Quadrant;
|
||||
}
|
||||
|
||||
public Units Units => unitsCombo.SelectedIndex == 1 ? Units.Millimeters : Units.Inches;
|
||||
|
||||
public NestDefaults GetDefaults()
|
||||
{
|
||||
return new NestDefaults
|
||||
{
|
||||
Units = Units,
|
||||
Size = new Size((double)widthBox.Value, (double)lengthBox.Value),
|
||||
Quadrant = quadrantSelect1.Quadrant,
|
||||
PartSpacing = (double)partSpacingBox.Value,
|
||||
EdgeSpacing = new Spacing(
|
||||
(double)edgeLeftBox.Value,
|
||||
(double)edgeBottomBox.Value,
|
||||
(double)edgeRightBox.Value,
|
||||
(double)edgeTopBox.Value
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal Clamp(NumericUpDown box, double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
return box.Minimum;
|
||||
|
||||
if (value < (double)box.Minimum)
|
||||
return box.Minimum;
|
||||
|
||||
if (value > (double)box.Maximum)
|
||||
return box.Maximum;
|
||||
|
||||
return (decimal)value;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-88
@@ -17,11 +17,6 @@ namespace OpenNest.Forms
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
ColorScheme colorScheme1 = new ColorScheme();
|
||||
CutOffSettings cutOffSettings1 = new CutOffSettings();
|
||||
Plate plate1 = new Plate();
|
||||
Collections.ObservableList<CutOff> observableList_11 = new Collections.ObservableList<CutOff>();
|
||||
Collections.ObservableList<Part> observableList_12 = new Collections.ObservableList<Part>();
|
||||
phaseStepper = new OpenNest.Controls.PhaseStepperControl();
|
||||
resultsPanel = new System.Windows.Forms.Panel();
|
||||
resultsTable = new System.Windows.Forms.TableLayoutPanel();
|
||||
@@ -46,8 +41,6 @@ namespace OpenNest.Forms
|
||||
buttonPanel = new System.Windows.Forms.FlowLayoutPanel();
|
||||
stopButton = new System.Windows.Forms.Button();
|
||||
acceptButton = new System.Windows.Forms.Button();
|
||||
splitContainer = new System.Windows.Forms.SplitContainer();
|
||||
previewPlateView = new OpenNest.Controls.PlateView();
|
||||
statsPanel = new System.Windows.Forms.Panel();
|
||||
resultsPanel.SuspendLayout();
|
||||
resultsTable.SuspendLayout();
|
||||
@@ -55,10 +48,6 @@ namespace OpenNest.Forms
|
||||
statusPanel.SuspendLayout();
|
||||
statusTable.SuspendLayout();
|
||||
buttonPanel.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
|
||||
splitContainer.Panel1.SuspendLayout();
|
||||
splitContainer.Panel2.SuspendLayout();
|
||||
splitContainer.SuspendLayout();
|
||||
statsPanel.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
@@ -365,74 +354,6 @@ namespace OpenNest.Forms
|
||||
acceptButton.UseVisualStyleBackColor = true;
|
||||
acceptButton.Click += AcceptButton_Click;
|
||||
//
|
||||
// splitContainer
|
||||
//
|
||||
splitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
splitContainer.FixedPanel = System.Windows.Forms.FixedPanel.Panel2;
|
||||
splitContainer.Location = new System.Drawing.Point(0, 0);
|
||||
splitContainer.Name = "splitContainer";
|
||||
//
|
||||
// splitContainer.Panel1
|
||||
//
|
||||
splitContainer.Panel1.Controls.Add(previewPlateView);
|
||||
//
|
||||
// splitContainer.Panel2
|
||||
//
|
||||
splitContainer.Panel2.Controls.Add(statsPanel);
|
||||
splitContainer.Size = new System.Drawing.Size(626, 341);
|
||||
splitContainer.SplitterDistance = 356;
|
||||
splitContainer.TabIndex = 0;
|
||||
//
|
||||
// previewPlateView
|
||||
//
|
||||
previewPlateView.ActiveWorkArea = null;
|
||||
previewPlateView.AllowPan = true;
|
||||
previewPlateView.AllowSelect = true;
|
||||
previewPlateView.AllowZoom = true;
|
||||
previewPlateView.BackColor = System.Drawing.Color.DarkGray;
|
||||
colorScheme1.BackgroundColor = System.Drawing.Color.DarkGray;
|
||||
colorScheme1.BoundingBoxColor = System.Drawing.Color.FromArgb(128, 128, 255);
|
||||
colorScheme1.EdgeSpacingColor = System.Drawing.Color.FromArgb(180, 180, 180);
|
||||
colorScheme1.LayoutFillColor = System.Drawing.Color.WhiteSmoke;
|
||||
colorScheme1.LayoutOutlineColor = System.Drawing.Color.Gray;
|
||||
colorScheme1.OriginColor = System.Drawing.Color.Gray;
|
||||
colorScheme1.PreviewPartColor = System.Drawing.Color.FromArgb(255, 140, 0);
|
||||
colorScheme1.RapidColor = System.Drawing.Color.DodgerBlue;
|
||||
previewPlateView.ColorScheme = colorScheme1;
|
||||
cutOffSettings1.CutDirection = CutDirection.AwayFromOrigin;
|
||||
cutOffSettings1.MinSegmentLength = 0.05D;
|
||||
cutOffSettings1.Overtravel = 0D;
|
||||
cutOffSettings1.PartClearance = 0.02D;
|
||||
previewPlateView.CutOffSettings = cutOffSettings1;
|
||||
previewPlateView.DebugRemnantPriorities = null;
|
||||
previewPlateView.DebugRemnants = null;
|
||||
previewPlateView.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
previewPlateView.DrawBounds = true;
|
||||
previewPlateView.DrawCutDirection = false;
|
||||
previewPlateView.DrawOffset = false;
|
||||
previewPlateView.DrawOrigin = true;
|
||||
previewPlateView.DrawPiercePoints = false;
|
||||
previewPlateView.DrawRapid = false;
|
||||
previewPlateView.FillParts = true;
|
||||
previewPlateView.Location = new System.Drawing.Point(0, 0);
|
||||
previewPlateView.Name = "previewPlateView";
|
||||
previewPlateView.OffsetIncrementDistance = 10D;
|
||||
previewPlateView.OffsetTolerance = 0.001D;
|
||||
plate1.CutOffs = observableList_11;
|
||||
plate1.CuttingParameters = null;
|
||||
plate1.GrainAngle = 0D;
|
||||
plate1.Parts = observableList_12;
|
||||
plate1.PartSpacing = 0D;
|
||||
plate1.Quadrant = 1;
|
||||
plate1.Quantity = 0;
|
||||
previewPlateView.Plate = plate1;
|
||||
previewPlateView.RotateIncrementAngle = 10D;
|
||||
|
||||
previewPlateView.ShowBendLines = false;
|
||||
previewPlateView.Size = new System.Drawing.Size(356, 341);
|
||||
previewPlateView.Status = "Select";
|
||||
previewPlateView.TabIndex = 0;
|
||||
//
|
||||
// statsPanel
|
||||
//
|
||||
statsPanel.AutoScroll = true;
|
||||
@@ -450,12 +371,12 @@ namespace OpenNest.Forms
|
||||
//
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
ClientSize = new System.Drawing.Size(626, 341);
|
||||
Controls.Add(splitContainer);
|
||||
ClientSize = new System.Drawing.Size(266, 341);
|
||||
Controls.Add(statsPanel);
|
||||
FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
MinimumSize = new System.Drawing.Size(550, 380);
|
||||
MinimumSize = new System.Drawing.Size(320, 380);
|
||||
Name = "NestProgressForm";
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
@@ -471,10 +392,6 @@ namespace OpenNest.Forms
|
||||
statusTable.ResumeLayout(false);
|
||||
statusTable.PerformLayout();
|
||||
buttonPanel.ResumeLayout(false);
|
||||
splitContainer.Panel1.ResumeLayout(false);
|
||||
splitContainer.Panel2.ResumeLayout(false);
|
||||
((System.ComponentModel.ISupportInitialize)splitContainer).EndInit();
|
||||
splitContainer.ResumeLayout(false);
|
||||
statsPanel.ResumeLayout(false);
|
||||
statsPanel.PerformLayout();
|
||||
ResumeLayout(false);
|
||||
@@ -506,8 +423,6 @@ namespace OpenNest.Forms
|
||||
private System.Windows.Forms.FlowLayoutPanel buttonPanel;
|
||||
private System.Windows.Forms.Button acceptButton;
|
||||
private System.Windows.Forms.Button stopButton;
|
||||
private System.Windows.Forms.SplitContainer splitContainer;
|
||||
private System.Windows.Forms.Panel statsPanel;
|
||||
private Controls.PlateView previewPlateView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,23 +38,11 @@ namespace OpenNest.Forms
|
||||
set => acceptButton.Visible = value;
|
||||
}
|
||||
|
||||
public Plate PreviewPlate
|
||||
{
|
||||
get => previewPlateView.Plate;
|
||||
set
|
||||
{
|
||||
previewPlateView.Plate = value;
|
||||
previewPlateView.ZoomToFit();
|
||||
}
|
||||
}
|
||||
|
||||
public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)
|
||||
{
|
||||
this.cts = cts;
|
||||
InitializeComponent();
|
||||
|
||||
previewPlateView.AllowSelect = false;
|
||||
|
||||
if (!showPlateRow)
|
||||
{
|
||||
plateLabel.Visible = false;
|
||||
@@ -104,20 +92,6 @@ namespace OpenNest.Forms
|
||||
: progress.Phase.DisplayName();
|
||||
}
|
||||
|
||||
public void UpdatePreview(List<Part> bestParts)
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated)
|
||||
return;
|
||||
|
||||
var plate = previewPlateView.Plate;
|
||||
plate.Parts.Clear();
|
||||
|
||||
foreach (var part in bestParts)
|
||||
plate.Parts.Add((Part)part.Clone());
|
||||
|
||||
previewPlateView.ZoomToFit();
|
||||
}
|
||||
|
||||
public void ShowCompleted()
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated)
|
||||
@@ -178,8 +152,6 @@ namespace OpenNest.Forms
|
||||
if (!cts.IsCancellationRequested)
|
||||
cts.Cancel();
|
||||
|
||||
previewPlateView.Dispose();
|
||||
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
|
||||
|
||||
Generated
+9
-50
@@ -34,9 +34,6 @@
|
||||
this.toolTip1 = new System.Windows.Forms.ToolTip(this.components);
|
||||
this.numericUpDown1 = new OpenNest.Controls.NumericUpDown();
|
||||
this.tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
|
||||
this.textBox1 = new System.Windows.Forms.TextBox();
|
||||
this.label3 = new System.Windows.Forms.Label();
|
||||
this.button1 = new System.Windows.Forms.Button();
|
||||
this.saveButton = new System.Windows.Forms.Button();
|
||||
this.cancelButton = new System.Windows.Forms.Button();
|
||||
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||
@@ -94,55 +91,20 @@
|
||||
this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
||||
this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 297F));
|
||||
this.tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 100F));
|
||||
this.tableLayoutPanel1.Controls.Add(this.label1, 0, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.textBox1, 1, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.label3, 0, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 3);
|
||||
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.button1, 3, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.label1, 0, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 0);
|
||||
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
|
||||
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
|
||||
this.tableLayoutPanel1.RowCount = 4;
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowCount = 3;
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33333F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33333F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33333F));
|
||||
this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160);
|
||||
this.tableLayoutPanel1.TabIndex = 0;
|
||||
//
|
||||
// textBox1
|
||||
//
|
||||
this.textBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.tableLayoutPanel1.SetColumnSpan(this.textBox1, 2);
|
||||
this.textBox1.Location = new System.Drawing.Point(154, 9);
|
||||
this.textBox1.Name = "textBox1";
|
||||
this.textBox1.Size = new System.Drawing.Size(427, 22);
|
||||
this.textBox1.TabIndex = 1;
|
||||
//
|
||||
// label3
|
||||
//
|
||||
this.label3.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.label3.AutoSize = true;
|
||||
this.label3.Location = new System.Drawing.Point(3, 12);
|
||||
this.label3.Name = "label3";
|
||||
this.label3.Size = new System.Drawing.Size(145, 16);
|
||||
this.label3.TabIndex = 0;
|
||||
this.label3.Text = "Nest Template Path:";
|
||||
//
|
||||
// button1
|
||||
//
|
||||
this.button1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.button1.Location = new System.Drawing.Point(588, 6);
|
||||
this.button1.Margin = new System.Windows.Forms.Padding(4);
|
||||
this.button1.Name = "button1";
|
||||
this.button1.Size = new System.Drawing.Size(92, 28);
|
||||
this.button1.TabIndex = 2;
|
||||
this.button1.Text = "Browse...";
|
||||
this.button1.UseVisualStyleBackColor = true;
|
||||
this.button1.Click += new System.EventHandler(this.BrowseNestTemplatePath_Click);
|
||||
//
|
||||
// saveButton
|
||||
//
|
||||
this.saveButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
@@ -258,9 +220,6 @@
|
||||
private System.Windows.Forms.ToolTip toolTip1;
|
||||
private Controls.BottomPanel bottomPanel1;
|
||||
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
|
||||
private System.Windows.Forms.TextBox textBox1;
|
||||
private System.Windows.Forms.Label label3;
|
||||
private System.Windows.Forms.Button button1;
|
||||
private System.Windows.Forms.DataGridView strategyGrid;
|
||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||
private System.Windows.Forms.Label colorSchemeLabel;
|
||||
|
||||
@@ -72,7 +72,6 @@ namespace OpenNest.Forms
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
textBox1.Text = Settings.Default.NestTemplatePath;
|
||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||
|
||||
@@ -92,7 +91,6 @@ namespace OpenNest.Forms
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
Settings.Default.NestTemplatePath = textBox1.Text;
|
||||
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
|
||||
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
|
||||
Settings.Default.ActiveColorScheme =
|
||||
@@ -144,14 +142,5 @@ namespace OpenNest.Forms
|
||||
{
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
private void BrowseNestTemplatePath_Click(object sender, EventArgs e)
|
||||
{
|
||||
var dlg = new OpenFileDialog();
|
||||
dlg.Filter = "Template File|*.nstdot";
|
||||
|
||||
if (dlg.ShowDialog() == DialogResult.OK)
|
||||
textBox1.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
@@ -158,6 +158,8 @@ namespace OpenNest.Properties {
|
||||
[global::System.Configuration.UserScopedSettingAttribute()]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Configuration.DefaultSettingValueAttribute("")]
|
||||
// Legacy .nstdot nest-template path. No longer read by the app
|
||||
// except by the one-time migration to defaults.json in MainForm.
|
||||
public string NestTemplatePath {
|
||||
get {
|
||||
return ((string)(this["NestTemplatePath"]));
|
||||
|
||||
@@ -11,7 +11,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
|
||||
|
||||
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
|
||||
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
|
||||
- **Plate operations** — sheet cut-offs, oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing.
|
||||
- **Plate operations** — sheet cut-offs, oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
|
||||
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included).
|
||||
|
||||
## Requirements
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,69 @@
|
||||
# Cincinnati CI Fiber Post Output Reference
|
||||
|
||||
Project-written summary of `OpenNest.Posts.CincinnatiCIFiber`, not a vendor
|
||||
manual or a machine-operation guide. For the separate CL-series post, see
|
||||
[Cincinnati output](cincinnati-post-output.md).
|
||||
|
||||
Controller reference used during development: Beckhoff *TF5200 | TwinCAT 3 CNC
|
||||
Programming manual*, version 1.33, May 19, 2026
|
||||
(`TF5200_programming_manual_en.pdf`). Obtain the applicable documentation from
|
||||
Beckhoff and Cincinnati. Vendor PDFs and full-text extracts stay outside source
|
||||
control; redistribution permission has not been established.
|
||||
|
||||
## Output contract
|
||||
|
||||
The current [writer](../Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs)
|
||||
uses the Cincinnati machine-sample convention, not a generic TF5200 laser API:
|
||||
|
||||
- Header: nest/configuration/material comments; `V.E.MATERIAL`, `V.E.THICKNESS`,
|
||||
`V.E.X_SIZE`, `V.E.Y_SIZE`, and `V.E.UNIT`. Sheet weight is omitted by default.
|
||||
- Startup: `G90`, `L PROGRAMSTART.NC`, then `P3=V.E.R3`, `$GOTO NP3:`, and `N0:`.
|
||||
- Parts follow plate order. `V.E.R4` identifies the part within its sheet;
|
||||
numbered contour labels and `V.E.R3` continue across sheets for restart lookup.
|
||||
- Each contour: `/L "L0"`, restart number, rapid to pierce, then interior
|
||||
`/L "L2"` with `G41` or exterior `/L "L4"` with `G42`; linear lead-in,
|
||||
`/L "L6"`, cutting moves, and `/L "ZHSOFF"`.
|
||||
- Tail: `/L "L0"`, `L PROGRAMEND.NC`, `M50`, `M30`, and `%`.
|
||||
- Motion endpoints are sheet-absolute XY. Arc `I`/`J` are offsets from the arc
|
||||
start, matching the G162 convention; the post does not explicitly emit G162.
|
||||
- Hole subprogram geometry is inlined. Suppressed moves and, by default,
|
||||
wholly scribe contours are omitted.
|
||||
- Files use UTF-8 without a BOM and CRLF lines. Default accuracy is three decimal
|
||||
places: coordinates trim trailing zeros; dimensional header values retain them.
|
||||
|
||||
## Configuration and boundaries
|
||||
|
||||
See [CIFiberPostConfig](../Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs)
|
||||
for macro names, material mappings, unit codes, precision, and table limits.
|
||||
The post is named for the machine family; table size belongs in configuration.
|
||||
|
||||
- Assign linear lead-ins before posting, including circular holes. The writer
|
||||
rejects missing or arc-first lead-ins. Its conservative rule cites the G238
|
||||
compensation-selection restriction in TF5200 §13.2.4.1; do not generalize it
|
||||
to every controller compensation mode.
|
||||
- `/L` calls are skippable; `L PROGRAMSTART.NC` and `L PROGRAMEND.NC` are not.
|
||||
Macro bodies, process settings, restart handling, and compensation cancellation
|
||||
belong to the machine configuration. The post does not emit an explicit G40.
|
||||
- `InchUnitCode` defaults to `1`; `MetricUnitCode` defaults to `0` but remains
|
||||
unconfirmed. Table limits compare directly with nest dimensions, without unit
|
||||
conversion. Do not assume changing the unit code establishes metric support.
|
||||
- Multiple nonempty plates are written sequentially, but dimensional header
|
||||
values come only from the first plate and the pallet-change tail occurs once.
|
||||
This is not proof of a qualified multi-sheet machine cycle.
|
||||
- Validation can throw after output has started. Discard any output from a failed
|
||||
post; it may be incomplete. Successful generation and tests do not establish
|
||||
that a program is safe to run on a particular machine.
|
||||
|
||||
## Verification
|
||||
|
||||
[Output-contract tests](../OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs)
|
||||
cover a square with a hole, coordinate transforms/formatting, lead-in rejection,
|
||||
table bounds, and suppression/scribe handling. Run:
|
||||
|
||||
```sh
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~CincinnatiCIFiber
|
||||
```
|
||||
|
||||
Keep this summary aligned with the implementation and tests. Cite the relevant
|
||||
manual section for controller rules and distinguish those rules from
|
||||
Cincinnati-specific macros and behavior observed in a machine sample.
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
Reference for the G-code structure emitted by `OpenNest.Posts.Cincinnati`.
|
||||
Every code listed here maps to a section in the Cincinnati Laser Programming
|
||||
Manual (`docs/CINCINNATI LASER PROGRAMMING MANUAL.pdf`, EM-423 R-02/11).
|
||||
Section numbers in parentheses (e.g. `§1.52`) refer to the manual.
|
||||
Manual (EM-423 R-02/11). Section numbers in parentheses (e.g. `§1.52`)
|
||||
refer to that edition. Obtain the applicable manual from Cincinnati; vendor
|
||||
manuals are not bundled because redistribution permission has not been established.
|
||||
|
||||
This is a project-written implementation reference, not a replacement for the
|
||||
machine's programming, operating, or safety documentation. Keep vendor PDFs and
|
||||
full-text extracts outside source control; do not re-add them without permission.
|
||||
For the separate CI Fiber post, see [CI Fiber output](cincinnati-ci-fiber-post-output.md).
|
||||
|
||||
If you add a new emission in the post, either cite the manual section it maps
|
||||
to, or flag it here as a known custom extension. "Custom code" in this project
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Directional slides and repeated pushes
|
||||
|
||||
## Behavior contract
|
||||
|
||||
For initially non-overlapping closed material boundaries, a directional slide stops at the first contact that blocks forward motion. Leaving an existing contact or sliding along a straight shared edge is legal. Skipping a contact must not skip the rest of that obstacle: a later hook or the opposite wall of a hole still stops the slide. Distances retain the existing `Tolerance.Epsilon` snapping and `double.MaxValue` no-hit convention; supplied vector directions are unit vectors.
|
||||
|
||||
`SpatialQuery.DirectionalDistance` overloads and CPU best-fit batches use the same event sources and contact resolver. Events carry both contact points in their initial world frames, rather than only a snapped distance. The classifier uses closed-loop material sectors, native-curve containment for hole depth, and the curvature of the supporting boundary at a tangential contact. A full-circle arc has no physical corner at its seam. Raw ray helpers remain first-touch primitives, not material-aware slide queries.
|
||||
|
||||
Open/incomplete chains and ambiguous contacts conservatively block. This is not an overlap-repair operation or a general replacement for layout validation. Caller-provided contact topology must represent the same boundaries and offsets as the query. Prepared geometry must not be mutated; prepare a classifier before sharing it between parallel queries. Edge-array queries still sort their arrays, and recover loop order from private copies before classifying contacts.
|
||||
|
||||
## Callers
|
||||
|
||||
- PlateView uses `SelectionManager.PushSelected` → `Compactor.Push`. The zero-spacing nudge-and-discard workaround is removed. Cutout contours on stationary obstacles are retained, and the plate entry's existing-overlap filter accounts for holes rather than relying solely on `Part.Intersects` (which compares outer perimeters).
|
||||
- Linear fill inherits the shared native-entity query unchanged. Extents fill passes complete boundary loops instead of direction-filtered fragments.
|
||||
- CPU best-fit batches prepare contact topology once and use all vertices plus curve/line interior and curve/curve tangency events. The old leading-half vertex filter cannot establish the next blocker after a skipped touch.
|
||||
- GPU kernels retain nearest-hit reduction and return unsnapped contact witnesses. The shared CPU classifier accepts a blocking witness or replays the full query after a nonblocking witness, preserving tied/later blockers. Both batch APIs honor active buffer lengths and refresh mutated/reused segment arrays. The GPU distance adapter sends only exact cardinal directions to the axis-only slide interface; arbitrary directions and native curves use the shared CPU path.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`SlideContactTests` exercises cardinal line, translated line, reused edge-array, arbitrary-vector, native-entity, and both CPU batch paths. Cases include winding reversal, nonzero origins, rotated hooks, holes, separating circles, positive-distance grazing followed by a blocker, full-circle arc seams, concave/straight junctions, thin rings, and circle/line interior contact.
|
||||
|
||||
`CompactorTests` covers the reported sequence (push left with spacing, then right/up/down), genuine zero-distance blocking, zero/nonzero-spacing later hooks, and inside-hole pushes through both direct and plate entry points. Physical spacing is measured from raw outlines rather than the inflated contours used by the solver.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```sh
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~SlideContactTests|FullyQualifiedName~CurveContactDistanceTests|FullyQualifiedName~CompactorTests'
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
||||
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||
```
|
||||
|
||||
The isolated repair tree (excluding other sessions' fill-performance and spacing-expander work) passed 130 targeted cases and the full Debug suites: main 1758 passed / 21 skipped, engine 300 passed, IO 41 passed. The main Release suite passed 1728 / 21 skipped. Skips are not counted as passes. The 142 `GpuSlideContactTests` also pass in a net8 harness linking the production GPU class and test source, using ILGPU 1.5.1's CPU accelerator (not a mocked distance solver). The Windows desktop/test project cross-build passes in Release. Neither physical GPU execution nor Windows UI interaction was runtime-verified on Linux.
|
||||
|
||||
## Remaining hardening
|
||||
|
||||
This repair does not change saved best-fit cache versioning, add a clearance acceptance gate to every fill entry, repair existing overlapping layouts, or claim the earlier real-DXF/grid-validator discrepancies in [pair-spacing checks](pair-spacing.md) are resolved. That document's measured candidate counts describe its earlier tree; removing CPU projection filtering and adding interior curve/line contacts does not substitute for rerunning its corpus. General `Part.Intersects` hole semantics remain unchanged outside Compactor. Profile the new classification path before attempting optimizations; retain the first-blocking-contact regressions.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
CPU best-fit slides and shared directional-distance queries now check both external and internal curve tangency. A convex offset corner inside a concave slot contacts at the difference of the radii, not their sum. Both forward ray/circle roots must be checked: the nearer root can be outside an arc's angular span while the farther root is the first actual contact. Tangent-point directions differ for internal contact, including when the moving curve is the larger one.
|
||||
|
||||
`SpatialQuery.CurveTangencyDistance` shares this calculation between the two callers. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed.
|
||||
The raw `SpatialQuery.CurveTangencyDistance` helper and shared slide events implement this calculation. The subsequent [directional-slide repair](directional-slides.md) routes both callers through material-aware contact events; the measurements below describe the earlier native-tangency repair. It assumes a unit direction, nonnegative radii and world-frame centers. An optional arc supplies only angular limits; null represents a full circle. This helper supplements the existing vertex/line phases rather than replacing them. Equal-radius coincident curves have no isolated internal tangent and remain the vertex phases' responsibility; zero-radius curves are points. No spacing tolerances or acceptance policies were changed.
|
||||
|
||||
### Reproduced U-shaped part
|
||||
|
||||
|
||||
Reference in New Issue
Block a user